feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
import { defineModuleSchema } from "@ekklesia/module-sdk";
import {
channelGroups,
channels,
imageAssets,
imageRenditions,
playbackStates,
providerSources,
selections,
selectionVariants,
timelineSlots,
timelines,
viewers,
} from "./tables.js";
export {
channelGroups,
channels,
imageAssets,
imageRenditions,
playbackStates,
providerSources,
selections,
selectionVariants,
timelineSlots,
timelines,
viewers,
} from "./tables.js";
/**
* Signage's compiled schema entrypoint: the same table objects `tables.ts` declares, wrapped with the
* ownership marker Core validates during discovery. Side-effect free by contract — importing this
* connects to nothing and reads nothing.
*/
export default defineModuleSchema({
moduleId: "signage",
tables: {
channelGroups,
channels,
imageAssets,
imageRenditions,
playbackStates,
providerSources,
selections,
selectionVariants,
timelines,
timelineSlots,
viewers,
},
});
+485
View File
@@ -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),
],
);