feat: move Signage to the Ekklesia 0.2 module contract (manifest v8, database ABI v1)
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 16s
CI / test (pull_request) Successful in 21s
CI / build (pull_request) Successful in 20s
CI / integration (pull_request) Skipped

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]>
This commit is contained in:
ekklesia
2026-10-08 20:36:11 -05:00
co-authored by Claude Opus 5.5
parent 86ae907f59
commit a6d553efbb
20 changed files with 1124 additions and 81 deletions
+12 -25
View File
@@ -1,4 +1,4 @@
import { defineModuleSchema } from "@ekklesia/module-sdk";
import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";
import {
channelGroups,
channels,
@@ -13,7 +13,16 @@ import {
viewers,
} from "./tables.js";
export {
/**
* Signage's compiled schema entrypoint: the plain Module Database ABI v1 metadata Core validates
* table ownership from (platform ADR 0050) — each table's physical name and the tables its foreign
* keys target, as strings and arrays.
*
* Derived on this side from the same Drizzle tables `tables.ts` declares, so it cannot drift from
* them, and deliberately the **only** export: Core never receives a Drizzle object from Signage.
* Side-effect free by contract — importing this connects to nothing and reads nothing.
*/
export default moduleDrizzleSchemaMetadata("signage", {
channelGroups,
channels,
imageAssets,
@@ -22,29 +31,7 @@ export {
providerSources,
selections,
selectionVariants,
timelineSlots,
timelines,
timelineSlots,
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,
},
});
+4 -3
View File
@@ -17,9 +17,10 @@ import {
* 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.
* 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:
*
+4 -1
View File
@@ -89,7 +89,7 @@ export function createSignageModuleRuntime(
function requireStore(): ReturnType<typeof createSignageStore> {
if (!store) {
throw new Error("Signage's database facade is only available after start.");
throw new Error("Signage's store is only available after start.");
}
return store;
}
@@ -126,6 +126,9 @@ export function createSignageModuleRuntime(
}
const definition = defineModuleRuntime({
// Matches `database.abi` in the manifest: `context.services.database` is this module's Module
// Database ABI v1 executor, which `createSignageStore` binds Signage's own Drizzle adapter to.
database: { abi: 1 },
id: "signage",
version: "0.1.2",
+2 -2
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import type { ModuleDatabase } from "@ekklesia/module-sdk";
import type { ModuleDrizzleDatabase } from "@ekklesia/module-database/drizzle";
import { and, asc, desc, eq, inArray, lt, sql } from "drizzle-orm";
import {
channelGroups,
@@ -94,7 +94,7 @@ function toSelection(
};
}
export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackStore {
export function createPlaybackStore(database: ModuleDrizzleDatabase): SignagePlaybackStore {
async function variantsFor(
selectionIds: readonly string[],
): Promise<Map<string, StoredSelectionVariant[]>> {
+12 -6
View File
@@ -1,4 +1,7 @@
import type { ModuleDatabase } from "@ekklesia/module-sdk";
import type {
ModuleDrizzleDatabase,
ModuleDrizzleTransaction,
} from "@ekklesia/module-database/drizzle";
import { asc, eq, or, sql } from "drizzle-orm";
import { playbackStates, providerSources, timelineSlots, timelines } from "../../schema/tables.js";
import {
@@ -108,7 +111,7 @@ function slotValues(slot: NewStoredSlot, position: number) {
};
}
export function createTimelineStore(database: ModuleDatabase): SignageTimelineStore {
export function createTimelineStore(database: ModuleDrizzleDatabase): SignageTimelineStore {
/**
* Rewrites `position` for one timeline from an explicit order.
*
@@ -118,7 +121,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
* remaining inside the database's non-negative constraint.
*/
async function renumber(
transaction: ModuleDatabase,
transaction: ModuleDrizzleTransaction,
timelineId: string,
orderedSlotIds: readonly string[],
): Promise<void> {
@@ -138,7 +141,10 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
await bumpRevision(transaction, timelineId);
}
async function bumpRevision(transaction: ModuleDatabase, timelineId: string): Promise<void> {
async function bumpRevision(
transaction: ModuleDrizzleTransaction,
timelineId: string,
): Promise<void> {
await transaction
.update(timelines)
.set({ revision: sql`${timelines.revision} + 1`, updatedAt: new Date() })
@@ -146,7 +152,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
}
async function orderedSlotIdsFor(
transaction: ModuleDatabase,
transaction: ModuleDrizzleTransaction,
timelineId: string,
): Promise<string[]> {
const rows = await transaction
@@ -158,7 +164,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
}
async function timelineIdForSlot(
transaction: ModuleDatabase,
transaction: ModuleDrizzleTransaction,
slotId: string,
): Promise<string | undefined> {
const rows = await transaction
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ModuleDatabase } from "@ekklesia/module-sdk";
import type { ModuleDrizzleDatabase } from "@ekklesia/module-database/drizzle";
import { asc, eq } from "drizzle-orm";
import { viewers } from "../../schema/tables.js";
import type { NewStoredViewer, SignageViewerStore, StoredViewer, ViewerChanges } from "./types.js";
@@ -29,7 +29,7 @@ function toViewer(row: typeof viewers.$inferSelect): StoredViewer {
};
}
export function createViewerStore(database: ModuleDatabase): SignageViewerStore {
export function createViewerStore(database: ModuleDrizzleDatabase): SignageViewerStore {
return {
async deleteViewer(viewerId: string): Promise<void> {
await database.delete(viewers).where(eq(viewers.id, viewerId));
+17 -11
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import type { ModuleDatabase } from "@ekklesia/module-sdk";
import { findEkklesiaDatabaseError, type ModuleDatabaseExecutor } from "@ekklesia/module-database";
import { type ModuleDrizzleDatabase, moduleDrizzle } from "@ekklesia/module-database/drizzle";
import { and, desc, eq, inArray } from "drizzle-orm";
import {
imageAssets,
@@ -22,14 +23,16 @@ import {
} from "./types.js";
/**
* The production implementation of Signage's data port, over the platform's `ModuleDatabase`.
* The production implementation of Signage's data port, over the platform's Module Database ABI v1
* executor (platform ADR 0050) through the module-side Drizzle adapter.
*
* This file is deliberately dull: it maps rows and nothing else. Every decision — what to write,
* when a revision changes, what a no-op is — lives in the services above it, so that behaviour can
* be proven without PostgreSQL and this mapping can be proven against a live one.
*
* The facade is the only way Signage reaches PostgreSQL. Nothing here constructs a client, reads a
* connection string, or touches another module's table.
* The executor is the only way Signage reaches PostgreSQL. Drizzle compiles every query here, on
* Signage's side; Core receives SQL text and bound values and nothing else. Nothing here constructs a
* client, reads a connection string, or touches another module's table.
*/
const assetColumns = {
@@ -66,12 +69,11 @@ const renditionColumns = {
width: imageRenditions.width,
};
/** PostgreSQL reports a uniqueness violation as SQLSTATE `23505`. The code is all that is read; the
* driver's message, detail, and constraint text are never surfaced. */
/** PostgreSQL reports a uniqueness violation as SQLSTATE `23505`. Drizzle wraps whatever the
* executor throws, so the stable Ekklesia database error is found through that wrapping; its SQLSTATE
* is all that is read, and no driver error shape is ever relied on. */
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505"
);
return findEkklesiaDatabaseError(error)?.sqlState === "23505";
}
function toRendition(row: {
@@ -116,7 +118,7 @@ function toImage(row: AssetRow, renditions: readonly StoredImageRendition[]): St
};
}
export function createImageStore(database: ModuleDatabase): SignageImageStore {
export function createImageStore(database: ModuleDrizzleDatabase): SignageImageStore {
async function renditionsFor(
assetIds: readonly string[],
): Promise<Map<string, StoredImageRendition[]>> {
@@ -258,8 +260,12 @@ export function createImageStore(database: ModuleDatabase): SignageImageStore {
*
* Composed rather than written as one object because the two halves share nothing but the database
* handle, and keeping them separate is what lets each be read on its own.
*
* Takes the ABI executor the platform hands a route handler or lifecycle hook, and binds Signage's
* own Drizzle adapter to it here, once, so every caller reaches its tables the same way.
*/
export function createSignageStore(database: ModuleDatabase): SignageStore {
export function createSignageStore(executor: ModuleDatabaseExecutor): SignageStore {
const database = moduleDrizzle(executor);
return {
...createImageStore(database),
...createTimelineStore(database),
+1 -1
View File
@@ -3,7 +3,7 @@
*
* Every piece of Signage's behaviour — ingest, timeline editing, provider resolution, authoritative
* playback, viewer health — is written against this interface rather than against Drizzle directly.
* There are two implementations: the production one over the platform's `ModuleDatabase`
* There are two implementations: the production one over the platform's database ABI executor
* (`./drizzle.ts`), and an in-memory one used by tests.
*
* This is the same discipline Core's own migration runner follows, and it exists for the same