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
+635
View File
@@ -0,0 +1,635 @@
import { isModuleDatabaseError, ModuleDatabaseError } from "@ekklesia/module-database";
import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
import { describe, expect, it } from "vitest";
import { imageAssets } from "../src/schema/tables.js";
import { createSignageStore } from "../src/server/store/drizzle.js";
import {
type NewStoredImage,
type NewStoredSelection,
type NewStoredSlot,
type SignageStore,
SignageStoreConflict,
} from "../src/server/store/types.js";
import { type AbiResponder, createFakeAbiExecutor } from "./support/abi-executor.js";
/**
* Signage's production store over the platform's Module Database ABI v1 (platform ADR 0050).
*
* The executor double holds the store to the ABI's own rules (`./support/abi-executor.ts`), so what
* these prove is what Signage hands Core and how it reads Core's answers: SQL text, ABI parameters,
* positional rows of raw text forms, and callback-scoped transactions. The decisions above the store
* are proven against the in-memory store elsewhere.
*/
const GROUP = "00000000-0000-4000-8000-0000000000a1";
const CHANNEL = "00000000-0000-4000-8000-0000000000b1";
const FALLBACK_CHANNEL = "00000000-0000-4000-8000-0000000000b2";
const SELECTION = "00000000-0000-4000-8000-0000000000c1";
const TIMELINE = "00000000-0000-4000-8000-0000000000d1";
const SOURCE = "00000000-0000-4000-8000-0000000000e1";
const SLOT_1 = "00000000-0000-4000-8000-0000000000f1";
const SLOT_2 = "00000000-0000-4000-8000-0000000000f2";
const IMAGE = "00000000-0000-4000-8000-000000000101";
const INSTANT = new Date("2026-10-08T12:34:56.789Z");
/** How Core sends that instant: PostgreSQL's ISO output in UTC (ADR 0050's decoding table). */
const INSTANT_TEXT = "2026-10-08 12:34:56.789+00";
function image(): NewStoredImage {
return {
contentSha256: "a".repeat(64),
displayName: "Welcome",
encodedHeight: 1080,
encodedWidth: 1920,
expiresAt: INSTANT,
hasAlpha: false,
height: 1080,
id: IMAGE,
orientation: null,
originalByteSize: 5_000_000_000,
originalContentType: "image/png",
originalFilename: "welcome.png",
originalObjectKey: `originals/${IMAGE}`,
renditions: [
{
byteSize: 1234,
contentSha256: "b".repeat(64),
contentType: "image/webp",
height: 1080,
kind: "display",
objectKey: `renditions/${IMAGE}/display`,
width: 1920,
},
],
status: "ready",
width: 1920,
};
}
function selection(): NewStoredSelection {
return {
availableUntil: null,
deadlineAt: new Date(INSTANT.getTime() + 7_000),
durationMs: 7_000,
groupId: GROUP,
id: SELECTION,
itemId: IMAGE,
itemRevision: "1",
presentation: { tone: "warm" },
providerId: "signage.native-images",
rendererContributionId: null,
rendererContributionVersion: null,
selectionScope: null,
sequence: 9_007_199_254_740_000,
slotId: SLOT_1,
stale: false,
startedAt: INSTANT,
timelineId: TIMELINE,
variants: [
{ audienceKey: "default", isDefault: true, payload: { title: "Hello" } },
{ audienceKey: "online", isDefault: false, payload: "just text" },
],
withdrawn: false,
};
}
function slot(id: string): NewStoredSlot {
return {
durationOverrideMs: null,
eligibleFrom: null,
eligibleUntil: INSTANT,
enabled: true,
id,
itemId: IMAGE,
kind: "pinned-item",
presentation: {
backgroundColor: null,
backgroundDarkened: null,
backgroundMode: null,
blurPixels: null,
foregroundShadow: null,
},
providerId: "signage.native-images",
sourceId: null,
timelineId: TIMELINE,
};
}
/** Answers a select from a table with the given rows, and every other statement with none. */
function rowsFrom(tables: Readonly<Record<string, AbiResponder>>): AbiResponder {
return (statement) => tables[statement.summary]?.(statement);
}
function times(count: number, summary: string): string[] {
return Array.from({ length: count }, () => summary);
}
describe("Signage's production store over the Module Database ABI v1", () => {
it("reaches PostgreSQL only as SQL text, ABI parameters, and positional rows", async () => {
const fake = createFakeAbiExecutor();
await createSignageStore(fake.executor).listTimelines();
await createSignageStore(fake.executor).findViewerByKey("kiosk-1");
expect(fake.events).toEqual(["select signage_timelines", "select signage_viewers"]);
for (const statement of fake.statements) {
expect(statement.depth).toBe(0);
expect(statement.rowMode).toBe("array");
expect(statement.text).not.toContain("kiosk-1");
}
expect(fake.statements[1]?.values).toEqual(["kiosk-1"]);
});
it("encodes instants as ISO text and JSON as text, never as objects Core would refuse", async () => {
const fake = createFakeAbiExecutor();
await createSignageStore(fake.executor).insertSelection(selection());
const [selectionInsert, variantsInsert] = fake.statements;
expect(selectionInsert?.values).toContain("2026-10-08T12:34:56.789Z");
expect(selectionInsert?.values).toContain("2026-10-08T12:35:03.789Z");
expect(selectionInsert?.values).toContain('{"tone":"warm"}');
expect(selectionInsert?.values).toContain(9_007_199_254_740_000);
expect(variantsInsert?.values).toContain('{"title":"Hello"}');
expect(variantsInsert?.values).toContain('"just text"');
});
it("decodes the ABI's raw int8, timestamptz, and jsonb text into the values Signage reads", async () => {
const fake = createFakeAbiExecutor(
rowsFrom({
"select signage_channel_groups": () => [
{
created_at: INSTANT_TEXT,
id: GROUP,
name: "Sanctuary",
revision: 3,
updated_at: INSTANT_TEXT,
},
],
"select signage_channels": () => [
{ group_id: GROUP, id: CHANNEL, is_default: true, key: "default", name: "Default" },
],
"select signage_image_assets": () => [
{
content_sha256: "a".repeat(64),
created_at: "2026-10-08 12:00:00.123456+00",
display_name: "Welcome",
encoded_height: 1080,
encoded_width: 1920,
expires_at: null,
has_alpha: false,
height: 1080,
id: IMAGE,
orientation: 6,
original_byte_size: "5000000000",
original_content_type: "image/png",
original_filename: "welcome.png",
original_object_key: `originals/${IMAGE}`,
status: "ready",
width: 1920,
},
],
"select signage_image_renditions": () => [
{
asset_id: IMAGE,
byte_size: "1234",
content_sha256: "b".repeat(64),
content_type: "image/webp",
height: 1080,
kind: "display",
object_key: `renditions/${IMAGE}/display`,
width: 1920,
},
],
"select signage_playback_states": () => [
{
active_timeline_id: TIMELINE,
blackout: false,
current_selection_id: SELECTION,
current_slot_id: SLOT_1,
group_id: GROUP,
next_sequence: "42",
paused: false,
paused_remaining_ms: null,
pending_requested_at: null,
pending_timeline_id: null,
revision: 17,
scheduled_activation_at: "2026-10-09 07:30:00-05",
scheduled_timeline_id: TIMELINE,
slot_deadline_at: "2026-10-08 12:35:03.789+00",
slot_started_at: INSTANT_TEXT,
static_asset_id: null,
updated_at: INSTANT_TEXT,
},
],
"select signage_selection_variants": () => [
{
audience_key: "default",
id: "00000000-0000-4000-8000-000000000201",
is_default: true,
payload: '{"title":"Hello","lines":[1,2]}',
selection_id: SELECTION,
},
{
audience_key: "online",
id: "00000000-0000-4000-8000-000000000202",
is_default: false,
payload: '"123"',
selection_id: SELECTION,
},
],
"select signage_selections": () => [
{
available_until: null,
deadline_at: null,
duration_ms: 7000,
group_id: GROUP,
id: SELECTION,
item_id: IMAGE,
item_revision: "1",
presentation: '{"tone":"warm"}',
provider_id: "signage.native-images",
renderer_contribution_id: null,
renderer_contribution_version: null,
resolved_at: INSTANT_TEXT,
revision: 2,
selection_scope: null,
sequence: "9007199254740000",
slot_id: SLOT_1,
stale: false,
started_at: INSTANT_TEXT,
timeline_id: TIMELINE,
withdrawn: false,
},
],
"update signage_playback_states": () => [{ next_sequence: "43" }],
}),
);
const store = createSignageStore(fake.executor);
const group = await store.findChannelGroup(GROUP);
expect(group?.playback).toMatchObject({
nextSequence: 42,
revision: 17,
scheduledActivationAt: new Date("2026-10-09T12:30:00.000Z"),
slotDeadlineAt: new Date("2026-10-08T12:35:03.789Z"),
slotStartedAt: INSTANT,
updatedAt: INSTANT,
});
expect(group?.playback.slotStartedAt).toBeInstanceOf(Date);
expect(group?.channels).toEqual([
{ groupId: GROUP, id: CHANNEL, isDefault: true, key: "default", name: "Default" },
]);
const found = await store.findSelection(SELECTION);
expect(found).toMatchObject({
presentation: { tone: "warm" },
resolvedAt: INSTANT,
sequence: 9_007_199_254_740_000,
startedAt: INSTANT,
});
expect(found?.variants).toEqual([
{ audienceKey: "default", isDefault: true, payload: { lines: [1, 2], title: "Hello" } },
// A JSON string stays a string, even one that looks like a number.
{ audienceKey: "online", isDefault: false, payload: "123" },
]);
// `returning` rows decode the same way: the sequence this call owns is one below the new value.
await expect(store.takeSelectionSequence(GROUP)).resolves.toBe(42);
const [listed] = await store.listImages();
expect(listed).toMatchObject({
createdAt: new Date("2026-10-08T12:00:00.123Z"),
expiresAt: null,
orientation: 6,
originalByteSize: 5_000_000_000,
});
expect(listed?.renditions).toEqual([expect.objectContaining({ byteSize: 1234 })]);
});
});
describe("Signage's transactions over the ABI", () => {
const existingSlots = () => [
{ id: SLOT_1, position: 0 },
{ id: SLOT_2, position: 1 },
];
const flows: readonly {
readonly name: string;
readonly respond?: AbiResponder;
readonly run: (store: SignageStore) => Promise<unknown>;
readonly expected: readonly string[];
}[] = [
{
expected: ["insert signage_image_assets", "insert signage_image_renditions"],
name: "insertImage",
run: (store) => store.insertImage(image()),
},
{
expected: ["insert signage_channels", "update signage_channel_groups"],
name: "addChannel",
run: (store) =>
store.addChannel({
groupId: GROUP,
id: CHANNEL,
isDefault: false,
key: "online",
name: "Online",
}),
},
{
expected: [
"insert signage_channel_groups",
"insert signage_channels",
"insert signage_playback_states",
],
name: "createChannelGroup",
run: (store) =>
store.createChannelGroup({
channelId: CHANNEL,
channelName: "Default",
groupId: GROUP,
name: "Sanctuary",
}),
},
{
expected: [
"select signage_channels",
"update signage_viewers",
"delete signage_channel_groups",
],
name: "deleteChannelGroup",
respond: rowsFrom({ "select signage_channels": () => [{ id: CHANNEL }] }),
run: (store) => store.deleteChannelGroup(GROUP, FALLBACK_CHANNEL),
},
{
expected: ["insert signage_selections", "insert signage_selection_variants"],
name: "insertSelection",
run: (store) => store.insertSelection(selection()),
},
{
expected: [
"select signage_channels",
"delete signage_channels",
"update signage_channel_groups",
],
name: "removeChannel",
respond: rowsFrom({
"select signage_channels": () => [
{ group_id: GROUP, id: CHANNEL, is_default: false, key: "online", name: "Online" },
],
}),
run: (store) => store.removeChannel(CHANNEL),
},
{
expected: [
"update signage_selections",
"delete signage_selection_variants",
"insert signage_selection_variants",
],
name: "updateSelection",
run: (store) =>
store.updateSelection(SELECTION, {
bumpRevision: true,
stale: true,
variants: [{ audienceKey: "default", isDefault: true, payload: { title: "Again" } }],
}),
},
{
expected: [
"select signage_timeline_slots",
"insert signage_timeline_slots",
...times(6, "update signage_timeline_slots"),
"update signage_timelines",
],
name: "addSlot",
respond: rowsFrom({ "select signage_timeline_slots": existingSlots }),
run: (store) => store.addSlot({ ...slot("00000000-0000-4000-8000-0000000000f3") }, 1),
},
{
expected: [
"insert signage_timelines",
"insert signage_provider_sources",
"insert signage_timeline_slots",
],
name: "createTimeline",
run: (store) =>
store.createTimeline({
slots: [slot(SLOT_1)],
sources: [
{
id: SOURCE,
label: "Announcements",
providerId: "announcements",
selectionScope: "scope-1",
sourceId: "all",
timelineId: TIMELINE,
},
],
timeline: {
description: null,
eligibleFrom: INSTANT,
eligibleUntil: null,
id: TIMELINE,
name: "Sunday",
},
}),
},
{
expected: [
"select signage_timeline_slots",
"delete signage_timeline_slots",
"select signage_timeline_slots",
...times(2, "update signage_timeline_slots"),
"update signage_timelines",
],
name: "removeSlot",
respond: rowsFrom({
"select signage_timeline_slots": (statement) =>
statement.text.includes('"position"')
? [{ id: SLOT_2, position: 1 }]
: [{ timeline_id: TIMELINE }],
}),
run: (store) => store.removeSlot(SLOT_1),
},
{
expected: [
"select signage_provider_sources",
"delete signage_provider_sources",
"select signage_timeline_slots",
...times(4, "update signage_timeline_slots"),
"update signage_timelines",
],
name: "removeSource",
respond: rowsFrom({
"select signage_provider_sources": () => [{ timeline_id: TIMELINE }],
"select signage_timeline_slots": existingSlots,
}),
run: (store) => store.removeSource(SOURCE),
},
{
expected: [
"select signage_timeline_slots",
...times(4, "update signage_timeline_slots"),
"update signage_timelines",
],
name: "reorderSlots",
respond: rowsFrom({ "select signage_timeline_slots": existingSlots }),
run: (store) => store.reorderSlots(TIMELINE, [SLOT_2, SLOT_1]),
},
{
expected: [
"select signage_timeline_slots",
"update signage_timeline_slots",
"update signage_timelines",
],
name: "updateSlot",
respond: rowsFrom({ "select signage_timeline_slots": () => [{ timeline_id: TIMELINE }] }),
run: (store) => store.updateSlot(SLOT_1, { enabled: false, eligibleUntil: INSTANT }),
},
];
it("covers every transaction the store opens", () => {
// Thirteen `database.transaction` calls in src/server/store, none of them nested.
expect(flows.map((flow) => flow.name)).toHaveLength(13);
});
for (const flow of flows) {
it(`${flow.name} runs as one ABI transaction and commits`, async () => {
const fake = createFakeAbiExecutor(flow.respond);
await flow.run(createSignageStore(fake.executor));
expect(fake.events).toEqual(["BEGIN", ...flow.expected, "COMMIT"]);
// Every statement ran inside the transaction's own scope, and none opened a savepoint.
expect(fake.statements.every((statement) => statement.depth === 1)).toBe(true);
});
}
it("rolls back a refused default-channel removal and reports the conflict", async () => {
const fake = createFakeAbiExecutor(
rowsFrom({
"select signage_channels": () => [
{ group_id: GROUP, id: CHANNEL, is_default: true, key: "default", name: "Default" },
],
}),
);
await expect(createSignageStore(fake.executor).removeChannel(CHANNEL)).rejects.toMatchObject({
guarantee: "channel.default",
name: "SignageStoreConflict",
});
expect(fake.events).toEqual(["BEGIN", "select signage_channels", "ROLLBACK"]);
});
it("rolls back a slot order that is not exactly the timeline's slots", async () => {
const fake = createFakeAbiExecutor(
rowsFrom({ "select signage_timeline_slots": existingSlots }),
);
await expect(
createSignageStore(fake.executor).reorderSlots(TIMELINE, [SLOT_2]),
).rejects.toMatchObject({ guarantee: "timeline.slotOrder" });
expect(fake.events).toEqual(["BEGIN", "select signage_timeline_slots", "ROLLBACK"]);
});
it("rolls back the whole transaction when a later statement fails, and keeps that error", async () => {
const fake = createFakeAbiExecutor((statement) => {
if (statement.summary === "insert signage_selection_variants") {
throw new ModuleDatabaseError("MODULE_DATABASE_QUERY_FAILED", { sqlState: "23503" });
}
return [];
});
const failure = await createSignageStore(fake.executor)
.insertSelection(selection())
.then(
() => undefined,
(error: unknown) => error,
);
expect(fake.events).toEqual([
"BEGIN",
"insert signage_selections",
"insert signage_selection_variants",
"ROLLBACK",
]);
expect(failure).not.toBeInstanceOf(SignageStoreConflict);
expect((failure as Error).cause).toSatisfy(isModuleDatabaseError);
});
});
describe("a duplicate native image (SQLSTATE 23505)", () => {
function failingAssetInsert(error: Error) {
return createFakeAbiExecutor((statement) => {
if (statement.summary === "insert signage_image_assets") {
throw error;
}
return [];
});
}
it("reaches Signage wrapped by Drizzle, with the Ekklesia error as its cause", async () => {
// Why the old `error.code === "23505"` check never matched: Drizzle wraps whatever the driver,
// here the ABI executor, throws.
const fake = failingAssetInsert(
new ModuleDatabaseError("MODULE_DATABASE_QUERY_FAILED", { sqlState: "23505" }),
);
const failure = await moduleDrizzle(fake.executor)
.insert(imageAssets)
.values({ ...image(), renditions: undefined } as never)
.then(
() => undefined,
(error: unknown) => error as Error & { code?: unknown },
);
expect(failure?.name).not.toBe("ModuleDatabaseError");
expect(failure?.code).toBeUndefined();
expect(failure?.cause).toMatchObject({
code: "MODULE_DATABASE_QUERY_FAILED",
name: "ModuleDatabaseError",
sqlState: "23505",
});
});
it("is recognized through that wrapping and reported as Signage's content-hash conflict", async () => {
const fake = failingAssetInsert(
new ModuleDatabaseError("MODULE_DATABASE_QUERY_FAILED", { sqlState: "23505" }),
);
const failure = await createSignageStore(fake.executor)
.insertImage(image())
.then(
() => undefined,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(SignageStoreConflict);
expect((failure as SignageStoreConflict).guarantee).toBe("image.contentSha256");
expect(fake.events).toEqual(["BEGIN", "insert signage_image_assets", "ROLLBACK"]);
});
it.each([
[
"a foreign-key violation",
new ModuleDatabaseError("MODULE_DATABASE_QUERY_FAILED", { sqlState: "23503" }),
],
["a failure with no SQLSTATE", new ModuleDatabaseError("MODULE_DATABASE_QUERY_FAILED")],
["a refused parameter", new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID")],
// A driver-shaped error is never what the ABI throws, and Signage no longer reads one.
["a raw pg-shaped error", Object.assign(new Error("duplicate key"), { code: "23505" })],
])("does not mistake %s for a duplicate", async (_name, error) => {
const fake = failingAssetInsert(error);
const failure = await createSignageStore(fake.executor)
.insertImage(image())
.then(
() => undefined,
(caught: unknown) => caught,
);
expect(failure).toBeInstanceOf(Error);
expect(failure).not.toBeInstanceOf(SignageStoreConflict);
expect((failure as Error).cause).toBe(error);
expect(fake.events.at(-1)).toBe("ROLLBACK");
});
});