feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTableCreator,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* Signage's own PostgreSQL tables.
|
||||
*
|
||||
* Kept to **pure Drizzle declarations** so the stable Drizzle Kit generator can load this file
|
||||
* without executing the ESM Module SDK wrapper; `./index.ts` wraps these same objects with the
|
||||
* ownership marker Core validates. The required `signage_` namespace is applied once here, and Core
|
||||
* validates it independently of this helper.
|
||||
*
|
||||
* Two rules hold everywhere below, and both are architecture rather than style:
|
||||
*
|
||||
* - **No foreign key leaves this module.** A provider's id, a source id, an item id, and a Core
|
||||
* device id are all stored as opaque text. Signage cannot join to another module's data and must
|
||||
* never be able to (ADR 0007 of the platform, ADR 0001 here).
|
||||
* - **No signed delivery URL is ever stored.** Objects are addressed by their logical key, and a
|
||||
* read URL is requested fresh when something is about to be delivered — a stored URL is a
|
||||
* credential with an expiry date in it.
|
||||
*/
|
||||
const moduleTable = pgTableCreator((localName) => `signage_${localName}`);
|
||||
|
||||
/** Timestamps are absolute instants. The installation's IANA zone is an operator setting used to
|
||||
* write and read them for a person, never to interpret one at run time. */
|
||||
const instant = (name: string) => timestamp(name, { mode: "date", withTimezone: true });
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Native images.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One native image, identified by the SHA-256 of the bytes an operator uploaded (ADR 0007).
|
||||
*
|
||||
* `content_sha256` is unique, so uploading the same file twice returns the same asset instead of
|
||||
* storing a second copy, and re-running a failed ingest is idempotent.
|
||||
*/
|
||||
export const imageAssets = moduleTable(
|
||||
"image_assets",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
contentSha256: text("content_sha256").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
originalFilename: text("original_filename").notNull(),
|
||||
/** The archived original, byte for byte. Signage is not entitled to rewrite a church's file. */
|
||||
originalObjectKey: text("original_object_key").notNull(),
|
||||
originalContentType: text("original_content_type").notNull(),
|
||||
originalByteSize: bigint("original_byte_size", { mode: "number" }).notNull(),
|
||||
/** How the original should appear once its own orientation is applied. */
|
||||
width: integer("width").notNull(),
|
||||
height: integer("height").notNull(),
|
||||
/** What is actually encoded in the file, kept for diagnostics without implying the archived
|
||||
* bytes were rewritten. */
|
||||
encodedWidth: integer("encoded_width").notNull(),
|
||||
encodedHeight: integer("encoded_height").notNull(),
|
||||
orientation: integer("orientation"),
|
||||
hasAlpha: boolean("has_alpha").notNull().default(false),
|
||||
/** `pending` while ingest is in flight, `ready` once every rendition is committed, `failed` when
|
||||
* ingest gave up. Only `ready` is ever selectable by playback. */
|
||||
status: text("status").notNull().default("pending"),
|
||||
/** A stable sanitized code, never a caught exception, a path, or a provider message. */
|
||||
failureCode: text("failure_code"),
|
||||
/** Null means permanent. Local age never invents an expiration. */
|
||||
expiresAt: instant("expires_at"),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_image_assets_content_sha256_key").on(table.contentSha256),
|
||||
index("signage_image_assets_status_idx").on(table.status),
|
||||
check(
|
||||
"signage_image_assets_status_check",
|
||||
sql`${table.status} in ('pending', 'ready', 'failed')`,
|
||||
),
|
||||
check(
|
||||
"signage_image_assets_dimensions_check",
|
||||
sql`${table.width} > 0 and ${table.height} > 0 and ${table.encodedWidth} > 0 and ${table.encodedHeight} > 0`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A generated rendition of one asset. Immutable: different bytes are a different asset, so a
|
||||
* rendition is never rewritten in place.
|
||||
*/
|
||||
export const imageRenditions = moduleTable(
|
||||
"image_renditions",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
assetId: uuid("asset_id")
|
||||
.notNull()
|
||||
.references(() => imageAssets.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").notNull(),
|
||||
objectKey: text("object_key").notNull(),
|
||||
contentType: text("content_type").notNull(),
|
||||
contentSha256: text("content_sha256").notNull(),
|
||||
byteSize: bigint("byte_size", { mode: "number" }).notNull(),
|
||||
width: integer("width").notNull(),
|
||||
height: integer("height").notNull(),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_image_renditions_asset_kind_key").on(table.assetId, table.kind),
|
||||
check("signage_image_renditions_kind_check", sql`${table.kind} in ('display', 'thumbnail')`),
|
||||
],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Timelines, sources, and slots.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
/** A saved, ordered, repeating pattern. It holds no cursor and no clock: running one is what a
|
||||
* channel group does, and the same timeline may run on two groups without interfering (ADR 0003). */
|
||||
export const timelines = moduleTable(
|
||||
"timelines",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
/** Timeline-level eligibility, half-open `[eligibleFrom, eligibleUntil)`. */
|
||||
eligibleFrom: instant("eligible_from"),
|
||||
eligibleUntil: instant("eligible_until"),
|
||||
/** Increments only on a semantic change, so a client can compare and refetch. */
|
||||
revision: integer("revision").notNull().default(1),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("signage_timelines_name_idx").on(table.name),
|
||||
check(
|
||||
"signage_timelines_eligibility_check",
|
||||
sql`${table.eligibleFrom} is null or ${table.eligibleUntil} is null or ${table.eligibleFrom} < ${table.eligibleUntil}`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* One **selection scope**: a provider's source as used by a timeline (ADR 0003).
|
||||
*
|
||||
* Every slot referencing this row passes the same `selection_scope` to `resolveNext`, so five
|
||||
* Announcement slots walk one rotation rather than five. Two rows for the same provider source are
|
||||
* two deliberately independent rotations.
|
||||
*
|
||||
* `provider_id` and `source_id` are opaque text. There is no foreign key here and there never can be:
|
||||
* the provider is another module.
|
||||
*/
|
||||
export const providerSources = moduleTable(
|
||||
"provider_sources",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
timelineId: uuid("timeline_id")
|
||||
.notNull()
|
||||
.references(() => timelines.id, { onDelete: "cascade" }),
|
||||
providerId: text("provider_id").notNull(),
|
||||
sourceId: text("source_id").notNull(),
|
||||
/** What an operator sees. Captured from provider discovery when the source was added, so the
|
||||
* editor still reads sensibly when that provider is temporarily not running. */
|
||||
label: text("label").notNull(),
|
||||
/** Stable, opaque, and deliberately not derived from the timeline or slot id — either would
|
||||
* silently reset a provider's rotation when an operator renamed or reordered something. */
|
||||
selectionScope: text("selection_scope").notNull(),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_provider_sources_selection_scope_key").on(table.selectionScope),
|
||||
index("signage_provider_sources_timeline_idx").on(table.timelineId),
|
||||
index("signage_provider_sources_provider_idx").on(table.providerId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* One position in a timeline's pattern.
|
||||
*
|
||||
* `position` is unique per timeline and kept contiguous from zero inside the transaction that
|
||||
* changes it — a gap would make "the next slot" ambiguous exactly when playback needs it least. The
|
||||
* slot's `id` is stable across every reorder, which is what lets history and a display's cursor
|
||||
* survive an operator dragging a row.
|
||||
*/
|
||||
export const timelineSlots = moduleTable(
|
||||
"timeline_slots",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
timelineId: uuid("timeline_id")
|
||||
.notNull()
|
||||
.references(() => timelines.id, { onDelete: "cascade" }),
|
||||
position: integer("position").notNull(),
|
||||
/** `provider-source` asks for the next item; `pinned-item` shows exactly one. A native image is
|
||||
* a pinned item of Signage's own built-in image provider (ADR 0001). */
|
||||
kind: text("kind").notNull(),
|
||||
sourceId: uuid("source_id").references(() => providerSources.id, { onDelete: "cascade" }),
|
||||
/** Set for a pinned item only. Opaque: another module's identity for its own content. */
|
||||
providerId: text("provider_id"),
|
||||
itemId: text("item_id"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
durationOverrideMs: integer("duration_override_ms"),
|
||||
/** Per-slot eligibility, half-open `[eligibleFrom, eligibleUntil)`. */
|
||||
eligibleFrom: instant("eligible_from"),
|
||||
eligibleUntil: instant("eligible_until"),
|
||||
/** Native-image presentation overrides. Null means "take the installation default". These apply
|
||||
* to native images only; an external provider owns its rendering completely. */
|
||||
backgroundMode: text("background_mode"),
|
||||
backgroundColor: text("background_color"),
|
||||
backgroundDarkened: boolean("background_darkened"),
|
||||
blurPixels: integer("blur_pixels"),
|
||||
foregroundShadow: boolean("foreground_shadow"),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_timeline_slots_position_key").on(table.timelineId, table.position),
|
||||
index("signage_timeline_slots_source_idx").on(table.sourceId),
|
||||
check("signage_timeline_slots_position_check", sql`${table.position} >= 0`),
|
||||
check(
|
||||
"signage_timeline_slots_kind_check",
|
||||
sql`${table.kind} in ('provider-source', 'pinned-item')`,
|
||||
),
|
||||
// Exactly one of the two shapes is populated, so a slot can never be both or neither.
|
||||
check(
|
||||
"signage_timeline_slots_reference_check",
|
||||
sql`(${table.kind} = 'provider-source' and ${table.sourceId} is not null and ${table.providerId} is null and ${table.itemId} is null)
|
||||
or (${table.kind} = 'pinned-item' and ${table.sourceId} is null and ${table.providerId} is not null and ${table.itemId} is not null)`,
|
||||
),
|
||||
check(
|
||||
"signage_timeline_slots_duration_check",
|
||||
sql`${table.durationOverrideMs} is null or ${table.durationOverrideMs} > 0`,
|
||||
),
|
||||
check(
|
||||
"signage_timeline_slots_eligibility_check",
|
||||
sql`${table.eligibleFrom} is null or ${table.eligibleUntil} is null or ${table.eligibleFrom} < ${table.eligibleUntil}`,
|
||||
),
|
||||
check(
|
||||
"signage_timeline_slots_background_check",
|
||||
sql`${table.backgroundMode} is null or ${table.backgroundMode} in ('blurred', 'solid', 'black')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Channel groups, channels, and authoritative playback.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
/** One synchronized group: one authoritative timeline clock, one active timeline, one cursor
|
||||
* (ADR 0004). A genuinely independent clock is another group. */
|
||||
export const channelGroups = moduleTable(
|
||||
"channel_groups",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
/** Increments when the group's channel membership changes, so a client knows to refetch. */
|
||||
revision: integer("revision").notNull().default(1),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [index("signage_channel_groups_name_idx").on(table.name)],
|
||||
);
|
||||
|
||||
/** An audience within a group. Channels differ in exactly one way: which variant of a resolved
|
||||
* selection they render. Exactly one channel per group is the default. */
|
||||
export const channels = moduleTable(
|
||||
"channels",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
groupId: uuid("group_id")
|
||||
.notNull()
|
||||
.references(() => channelGroups.id, { onDelete: "cascade" }),
|
||||
/** The audience key handed to a provider. `default` is the base channel's. */
|
||||
key: text("key").notNull(),
|
||||
name: text("name").notNull(),
|
||||
isDefault: boolean("is_default").notNull().default(false),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_channels_group_key_key").on(table.groupId, table.key),
|
||||
// Exactly one default per group, enforced by the database rather than by convention.
|
||||
uniqueIndex("signage_channels_group_default_key")
|
||||
.on(table.groupId)
|
||||
.where(sql`${table.isDefault}`),
|
||||
check("signage_channels_key_check", sql`${table.key} ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'`),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* One row per channel group: the authoritative clock (ADR 0005).
|
||||
*
|
||||
* `slot_started_at` and `slot_deadline_at` are **absolute instants**, never a remaining count. That
|
||||
* is what lets two viewers who connected minutes apart agree, and what lets a restart resume the
|
||||
* item it was already showing instead of choosing a new one.
|
||||
*/
|
||||
export const playbackStates = moduleTable(
|
||||
"playback_states",
|
||||
{
|
||||
groupId: uuid("group_id")
|
||||
.primaryKey()
|
||||
.references(() => channelGroups.id, { onDelete: "cascade" }),
|
||||
activeTimelineId: uuid("active_timeline_id").references(() => timelines.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
/** Queued activation, applied at the next safe slot boundary. */
|
||||
pendingTimelineId: uuid("pending_timeline_id").references(() => timelines.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
pendingRequestedAt: instant("pending_requested_at"),
|
||||
/** Which resolved selection is on screen. Nulled rather than cascaded when history is pruned. */
|
||||
currentSelectionId: uuid("current_selection_id"),
|
||||
/** Where in the pattern the cursor is. Kept beside the selection so an empty or skipped slot
|
||||
* still advances deterministically. */
|
||||
currentSlotId: uuid("current_slot_id"),
|
||||
slotStartedAt: instant("slot_started_at"),
|
||||
slotDeadlineAt: instant("slot_deadline_at"),
|
||||
/** Preserved across pause and static hold, so releasing either continues the same slide. */
|
||||
pausedRemainingMs: integer("paused_remaining_ms"),
|
||||
paused: boolean("paused").notNull().default(false),
|
||||
/** Covers the output with black while the hidden timeline keeps running. */
|
||||
blackout: boolean("blackout").notNull().default(false),
|
||||
/** A native image held on screen while the normal cursor and remainder stay preserved. */
|
||||
staticAssetId: uuid("static_asset_id").references(() => imageAssets.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
/** An activation an operator asked for in advance. One per group: "activate this timeline at
|
||||
* this instant" is a single intention, and a queue of them would be a scheduling product
|
||||
* Signage does not have. */
|
||||
scheduledTimelineId: uuid("scheduled_timeline_id").references(() => timelines.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
scheduledActivationAt: instant("scheduled_activation_at"),
|
||||
/** Monotonic. Increments only on a semantic change; an exact no-op commits nothing. */
|
||||
revision: integer("revision").notNull().default(1),
|
||||
/** The next sequence number a resolved selection for this group will take. */
|
||||
nextSequence: bigint("next_sequence", { mode: "number" }).notNull().default(1),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
check(
|
||||
"signage_playback_states_remaining_check",
|
||||
sql`${table.pausedRemainingMs} is null or ${table.pausedRemainingMs} >= 0`,
|
||||
),
|
||||
check(
|
||||
"signage_playback_states_window_check",
|
||||
sql`${table.slotStartedAt} is null or ${table.slotDeadlineAt} is null or ${table.slotStartedAt} <= ${table.slotDeadlineAt}`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Resolved selections and bounded history.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One logical selection, resolved once for the whole channel group (ADR 0004).
|
||||
*
|
||||
* Persisting this is what makes two viewers that joined at different times render the same slot,
|
||||
* what lets `previous` walk actual playback rather than the slot order, what makes restart recovery
|
||||
* show the same item, and what gives offline continuation something real to continue from.
|
||||
*
|
||||
* `slot_id` is nulled rather than cascaded when a slot is deleted: history is a record of what
|
||||
* happened, and an operator editing a timeline must not erase it.
|
||||
*/
|
||||
export const selections = moduleTable(
|
||||
"selections",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
groupId: uuid("group_id")
|
||||
.notNull()
|
||||
.references(() => channelGroups.id, { onDelete: "cascade" }),
|
||||
/** Monotonic per group. The ordering `previous` walks. */
|
||||
sequence: bigint("sequence", { mode: "number" }).notNull(),
|
||||
timelineId: uuid("timeline_id"),
|
||||
slotId: uuid("slot_id").references(() => timelineSlots.id, { onDelete: "set null" }),
|
||||
/** The selection scope this item came from, copied at resolution time. Kept even after the
|
||||
* source row is gone, so history stays readable and a provider's rotation can be re-seeded from
|
||||
* what actually played rather than from the beginning. Null for a pinned item. */
|
||||
selectionScope: text("selection_scope"),
|
||||
/** Opaque provider identity, and the provider's own identity for its content. */
|
||||
providerId: text("provider_id").notNull(),
|
||||
itemId: text("item_id").notNull(),
|
||||
/** Changes whenever the payload changes and the item stays the same thing. */
|
||||
itemRevision: text("item_revision").notNull(),
|
||||
/** Which renderer draws this. Signage never imports it; the display asks the host to mount it. */
|
||||
rendererContributionId: text("renderer_contribution_id"),
|
||||
rendererContributionVersion: text("renderer_contribution_version"),
|
||||
/** Opaque presentation values the provider itself supplied to its own renderer. */
|
||||
presentation: jsonb("presentation"),
|
||||
durationMs: integer("duration_ms").notNull(),
|
||||
resolvedAt: instant("resolved_at").notNull().defaultNow(),
|
||||
startedAt: instant("started_at"),
|
||||
deadlineAt: instant("deadline_at"),
|
||||
/** When this selection stops being valid, as supplied by the provider. */
|
||||
availableUntil: instant("available_until"),
|
||||
/** The provider said this item is explicitly gone. Removed at the next safe boundary. */
|
||||
withdrawn: boolean("withdrawn").notNull().default(false),
|
||||
/** A provider call failed temporarily and this is the last known good answer. */
|
||||
stale: boolean("stale").notNull().default(false),
|
||||
/** Increments when a refresh changes payloads without changing which item this is — which is
|
||||
* what updates a mounted renderer in place without restarting its slot. */
|
||||
revision: integer("revision").notNull().default(1),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_selections_group_sequence_key").on(table.groupId, table.sequence),
|
||||
index("signage_selections_group_resolved_idx").on(table.groupId, table.resolvedAt),
|
||||
index("signage_selections_provider_item_idx").on(table.providerId, table.itemId),
|
||||
check("signage_selections_duration_check", sql`${table.durationMs} > 0`),
|
||||
],
|
||||
);
|
||||
|
||||
/** One audience's payload for one selection. The default variant is always present; an
|
||||
* audience-specific one exists only when the provider offered it. */
|
||||
export const selectionVariants = moduleTable(
|
||||
"selection_variants",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
selectionId: uuid("selection_id")
|
||||
.notNull()
|
||||
.references(() => selections.id, { onDelete: "cascade" }),
|
||||
/** The audience key this payload is for. The default variant uses the literal `default` key. */
|
||||
audienceKey: text("audience_key").notNull(),
|
||||
isDefault: boolean("is_default").notNull().default(false),
|
||||
/** Opaque JSON. Signage validates that it *is* finite JSON and never what it means. */
|
||||
payload: jsonb("payload").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_selection_variants_selection_audience_key").on(
|
||||
table.selectionId,
|
||||
table.audienceKey,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Viewers.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One display Signage has seen.
|
||||
*
|
||||
* `viewer_key` is the persistent identity a display keeps in its own IndexedDB, so a reconnect is
|
||||
* recognized as the same screen rather than a new one. `device_id` is the **safe** Core device
|
||||
* identifier a paired display's principal carries — an opaque correlation value. Signage never sees,
|
||||
* stores, or handles a device credential, and pairing remains entirely Core's.
|
||||
*/
|
||||
export const viewers = moduleTable(
|
||||
"viewers",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
viewerKey: text("viewer_key").notNull(),
|
||||
name: text("name").notNull(),
|
||||
deviceId: text("device_id"),
|
||||
channelId: uuid("channel_id").references(() => channels.id, { onDelete: "set null" }),
|
||||
lastSeenAt: instant("last_seen_at"),
|
||||
/** What the viewer last reported it was actually showing. Telemetry: a display never mutates
|
||||
* authoritative playback, and a local cursor is never accepted as authority. */
|
||||
reportedTimelineId: uuid("reported_timeline_id"),
|
||||
reportedSelectionId: uuid("reported_selection_id"),
|
||||
reportedProviderId: text("reported_provider_id"),
|
||||
reportedContributionId: text("reported_contribution_id"),
|
||||
reportedPlaybackRevision: integer("reported_playback_revision"),
|
||||
/** Whether the viewer says it has enough cached to keep rendering through an outage. */
|
||||
offlineReady: boolean("offline_ready").notNull().default(false),
|
||||
cachedAssetCount: integer("cached_asset_count").notNull().default(0),
|
||||
/** A stable sanitized code and a bounded operator-facing detail. Never an exception, a URL, a
|
||||
* payload, or a stack. */
|
||||
lastErrorCode: text("last_error_code"),
|
||||
lastErrorDetail: text("last_error_detail"),
|
||||
lastErrorAt: instant("last_error_at"),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_viewers_viewer_key_key").on(table.viewerKey),
|
||||
index("signage_viewers_channel_idx").on(table.channelId),
|
||||
index("signage_viewers_last_seen_idx").on(table.lastSeenAt),
|
||||
],
|
||||
);
|
||||
Reference in New Issue
Block a user