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), ], );