Signage moves from the legacy module contract to Ekklesia 0.2.0's, with no behaviour or schema
change:
- manifest version 8, `database: { abi: 1 }`, compatibility ^0.2.0 for platform and module SDK;
the runtime declares the same ABI, as Core requires;
- every store reaches PostgreSQL through the platform's Module Database ABI v1 executor, with
Signage's own Drizzle tables and the module-side adapter (`moduleDrizzle`) from
@ekklesia/module-database. createSignageStore takes the executor, so no call site changes;
- the schema entrypoint exports only plain ABI v1 metadata (table names and foreign-key targets),
derived from tables.ts with moduleDrizzleSchemaMetadata; no Drizzle object reaches Core;
- duplicate-image detection reads SQLSTATE 23505 from the stable Ekklesia database error, found
with findEkklesiaDatabaseError through Drizzle's DrizzleQueryError wrapping. The old
`error.code === "23505"` check never matched a wrapped error, so a duplicate-upload race
surfaced as a 500 instead of the content-hash conflict.
tables.ts declarations, the migration, and its pinned checksum are unchanged.
Tests: an ABI v1 executor double that enforces Core's scope, parameter, and abort rules drives all
13 store transactions, their rollbacks, raw int8/timestamptz/jsonb decoding, and the 23505
regression, including generic and driver-shaped errors that must not count as duplicates.
CI, temporarily: the platform pin moves to v0.2.0 (1788d17) with a regenerated ci/pnpm-lock.yaml.
The integration job is disabled: from 0.2.0 the platform image is generic (ADR 0048) and installs
modules only as Module Packages, which Signage cannot be until its sharp dependency goes through
the native package flow. The job and ci/integration.sh stay, and the job returns in that change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
487 lines
23 KiB
TypeScript
487 lines
23 KiB
TypeScript
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 any Ekklesia package; `./index.ts` derives the plain schema metadata Core
|
|
* validates from these same objects, and only Signage's own store code ever uses them as Drizzle
|
|
* tables. 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),
|
|
],
|
|
);
|