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
+3 -1
View File
@@ -263,7 +263,9 @@ describe("what this release deliberately does not contain", () => {
]) {
expect(names).not.toContain(forbidden);
}
// The database is reached only through the platform's facade; there is no client of Signage's own.
// The database is reached only through the platform's ABI executor, with Signage's own Drizzle
// adapter compiling the queries; there is no client of Signage's own.
expect(names).toContain("drizzle-orm");
expect(names).toContain("@ekklesia/module-database");
});
});
+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");
});
});
+3 -3
View File
@@ -1,4 +1,4 @@
import type { ModuleDatabase } from "@ekklesia/module-sdk";
import type { ModuleDrizzleDatabase } from "@ekklesia/module-database/drizzle";
import { describe, expect, it } from "vitest";
import { createTimelineStore } from "../src/server/store/drizzle-timelines.js";
import type { NewStoredSlot } from "../src/server/store/types.js";
@@ -36,7 +36,7 @@ function slot(id: string): NewStoredSlot {
* actual database rejected a native-image slot before playback ever had content to resolve.
*/
function constraintDatabase(existing: readonly { id: string; position: number }[]): {
readonly database: ModuleDatabase;
readonly database: ModuleDrizzleDatabase;
readonly positions: number[];
} {
const positions: number[] = [];
@@ -66,7 +66,7 @@ function constraintDatabase(existing: readonly { id: string; position: number }[
return {
database: {
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction),
} as unknown as ModuleDatabase,
} as unknown as ModuleDrizzleDatabase,
positions,
};
}
+12 -3
View File
@@ -63,11 +63,19 @@ describe("Signage module manifest", () => {
it("declares the module's product identity at the current manifest version", async () => {
const manifest = await readManifest();
expect(manifest.manifestVersion).toBe(7);
expect(manifest.manifestVersion).toBe(8);
expect(manifest.id).toBe("signage");
expect(manifest.name).toBe("Signage");
expect(manifest.version).toBe("0.1.2");
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" });
});
it("declares the Module Database ABI v1, so the platform hands it an executor and no ORM object", async () => {
const manifest = await readManifest();
// Platform ADR 0050: with `database.abi: 1`, `services.database` is the ABI v1 executor. Without
// it, a version 8 manifest would get the legacy Drizzle facade Signage no longer compiles against.
expect(manifest.database).toEqual({ abi: 1 });
});
it("declares every Signage permission, and none of them publicly", async () => {
@@ -142,7 +150,8 @@ describe("Signage module manifest", () => {
describe("Signage server runtime", () => {
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.2" });
// The runtime's database declaration must match the manifest's, or Core refuses to start it.
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.2" });
});
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
+49 -2
View File
@@ -7,9 +7,12 @@ import {
} from "@ekklesia/module-sdk";
import { getTableConfig, type PgTable } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import moduleSchema from "../src/schema/index.js";
import * as schemaEntrypoint from "../src/schema/index.js";
import * as schemaTables from "../src/schema/tables.js";
const tables = moduleSchema.tables as Readonly<Record<string, PgTable>>;
const moduleSchema = schemaEntrypoint.default;
/** Signage's own Drizzle tables, which only its store code uses. Core sees the metadata above. */
const tables: Readonly<Record<string, PgTable>> = schemaTables;
async function readSql(): Promise<string> {
return readFile(new URL("../drizzle/0000_initial.sql", import.meta.url), "utf8");
@@ -43,6 +46,50 @@ describe("Signage schema", () => {
expect(moduleSchema.moduleId).toBe("signage");
});
it("hands Core plain ABI v1 schema metadata: names and foreign-key targets, and no Drizzle object", () => {
// Platform ADR 0050: Core validates table ownership from plain data and never inspects an ORM
// object. The entrypoint's only export is that data.
expect(Object.keys(schemaEntrypoint)).toEqual(["default"]);
expect(JSON.parse(JSON.stringify(moduleSchema))).toEqual(moduleSchema);
const byName = [...moduleSchema.tables].sort((left, right) =>
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
);
expect(byName).toEqual([
{ name: "signage_channel_groups", foreignKeys: [] },
{ name: "signage_channels", foreignKeys: [{ table: "signage_channel_groups" }] },
{ name: "signage_image_assets", foreignKeys: [] },
{ name: "signage_image_renditions", foreignKeys: [{ table: "signage_image_assets" }] },
{
name: "signage_playback_states",
foreignKeys: [
{ table: "signage_channel_groups" },
{ table: "signage_timelines" },
{ table: "signage_timelines" },
{ table: "signage_image_assets" },
{ table: "signage_timelines" },
],
},
{ name: "signage_provider_sources", foreignKeys: [{ table: "signage_timelines" }] },
{ name: "signage_selection_variants", foreignKeys: [{ table: "signage_selections" }] },
{
name: "signage_selections",
foreignKeys: [{ table: "signage_channel_groups" }, { table: "signage_timeline_slots" }],
},
{
name: "signage_timeline_slots",
foreignKeys: [{ table: "signage_timelines" }, { table: "signage_provider_sources" }],
},
{ name: "signage_timelines", foreignKeys: [] },
{ name: "signage_viewers", foreignKeys: [{ table: "signage_channels" }] },
]);
// The same tables the store queries, derived rather than restated, so the two cannot drift.
expect(byName.map((table) => table.name)).toEqual(
Object.values(tables)
.map((table) => getTableConfig(table).name)
.sort(),
);
});
it("never points a foreign key outside this module", () => {
const owned = new Set(Object.values(tables).map((table) => getTableConfig(table).name));
+228
View File
@@ -0,0 +1,228 @@
import {
ModuleDatabaseError,
type ModuleDatabaseExecutor,
type ModuleDatabaseParameter,
type ModuleDatabaseQuery,
type ModuleDatabaseTransaction,
type ModuleDatabaseValue,
} from "@ekklesia/module-database";
/**
* A Module Database ABI v1 executor double (platform ADR 0050), for proving Signage's production
* store without PostgreSQL.
*
* It holds Signage to the rules the platform's real executor enforces, so a store change that would
* break against Core breaks here first:
*
* - parameters must be ABI values — a `Date` or a plain object is refused, exactly as Core refuses
* one, so an instant or a JSON payload the adapter failed to encode cannot slip through;
* - transactions and savepoints are callback scopes: committed only when the callback resolves with
* no work outstanding, rolled back when it rejects, and revoked once it settles;
* - only the innermost open scope may run statements;
* - after a failed statement the transaction is aborted, as in PostgreSQL, and cannot commit.
*
* Results are what Core sends: positional rows of the ABI's raw forms (`int8`, timestamps, and JSON
* as text). A responder answers each statement with rows keyed by column name; the double orders
* them by the statement's own select or returning list.
*/
export interface RecordedStatement {
readonly text: string;
readonly values: readonly ModuleDatabaseParameter[];
readonly rowMode: string;
/** 0 for an autocommit statement, 1 inside a transaction, 2 or more inside savepoints. */
readonly depth: number;
/** `select signage_timelines`, `insert signage_channels`, and so on. */
readonly summary: string;
}
export type AbiRow = Readonly<Record<string, ModuleDatabaseValue>>;
export type AbiResponder = (statement: RecordedStatement) => readonly AbiRow[] | undefined;
export interface FakeAbiExecutor {
readonly executor: ModuleDatabaseExecutor;
/** Every statement, in order, plus `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE`, and
* `ROLLBACK TO SAVEPOINT` markers. */
readonly events: string[];
readonly statements: RecordedStatement[];
}
function isAbiParameter(value: unknown): value is ModuleDatabaseParameter {
if (value === null) {
return true;
}
if (Array.isArray(value)) {
return value.every(isAbiParameter);
}
return (
typeof value === "boolean" ||
typeof value === "number" ||
typeof value === "bigint" ||
typeof value === "string" ||
value instanceof Uint8Array
);
}
function summarize(text: string): string {
const verb = text.trimStart().split(/\s/u, 1)[0]?.toLowerCase() ?? "";
const target =
verb === "select"
? /\bfrom\s+"([^"]+)"/iu.exec(text)
: verb === "insert"
? /^\s*insert\s+into\s+"([^"]+)"/iu.exec(text)
: verb === "update"
? /^\s*update\s+"([^"]+)"/iu.exec(text)
: /^\s*delete\s+from\s+"([^"]+)"/iu.exec(text);
return `${verb} ${target?.[1] ?? "?"}`;
}
/** The column names a statement returns, in order: its select list, or its `returning` list. */
function resultColumns(text: string): string[] {
const list =
/^\s*select\s+(.*?)\s+from\s/isu.exec(text)?.[1] ?? /\sreturning\s+(.*)$/isu.exec(text)?.[1];
if (list === undefined) {
return [];
}
return list.split(",").map((column) => {
const names = [...column.matchAll(/"([^"]+)"/gu)].map((match) => match[1] ?? "");
return names.at(-1) ?? column.trim();
});
}
interface Scope {
closed: boolean;
active: boolean;
pending: number;
aborted: boolean;
}
export function createFakeAbiExecutor(respond: AbiResponder = () => []): FakeAbiExecutor {
const events: string[] = [];
const statements: RecordedStatement[] = [];
async function run(
scope: Scope,
transaction: Scope | undefined,
depth: number,
request: ModuleDatabaseQuery,
) {
if (scope.closed) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
if (!scope.active) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_INACTIVE");
}
const values = request.values ?? [];
if (!values.every(isAbiParameter)) {
throw new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID");
}
if (transaction?.aborted) {
// PostgreSQL's in_failed_sql_transaction.
throw new ModuleDatabaseError("MODULE_DATABASE_QUERY_FAILED", { sqlState: "25P02" });
}
const statement: RecordedStatement = {
depth,
rowMode: request.rowMode ?? "object",
summary: summarize(request.text),
text: request.text,
values,
};
statements.push(statement);
events.push(statement.summary);
scope.pending += 1;
try {
// Settle on a later event-loop turn, as a real round trip does, so a statement a callback
// forgot to await is still outstanding when that callback returns.
await new Promise<void>((resolve) => setImmediate(resolve));
let rows: readonly AbiRow[];
try {
rows = respond(statement) ?? [];
} catch (error) {
if (transaction) {
transaction.aborted = true;
}
throw error;
}
const columns = resultColumns(request.text);
return {
command: statement.summary.split(" ")[0]?.toUpperCase() ?? "",
fields: columns.map((name) => ({ name, typeOid: 25 })),
rowCount: rows.length,
rows:
statement.rowMode === "array"
? rows.map((row) => columns.map((column) => row[column] ?? null))
: rows,
};
} finally {
scope.pending -= 1;
}
}
async function runScope<T>(
parent: Scope,
transaction: Scope | undefined,
depth: number,
callback: (scope: ModuleDatabaseTransaction) => Promise<T> | T,
): Promise<T> {
const isTransaction = transaction === undefined;
const scope: Scope = { aborted: false, active: true, closed: false, pending: 0 };
const owner = transaction ?? scope;
const executor = Object.freeze({
abiVersion: 1 as const,
query: (request: ModuleDatabaseQuery) => run(scope, owner, depth, request),
savepoint: <S>(inner: (savepoint: ModuleDatabaseTransaction) => Promise<S> | S) => {
if (scope.closed) {
return Promise.reject(new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED"));
}
if (!scope.active) {
return Promise.reject(new ModuleDatabaseError("MODULE_DATABASE_SCOPE_INACTIVE"));
}
return runScope(scope, owner, depth + 1, inner);
},
}) as unknown as ModuleDatabaseTransaction;
parent.active = false;
events.push(isTransaction ? "BEGIN" : "SAVEPOINT");
const abort = isTransaction ? "ROLLBACK" : "ROLLBACK TO SAVEPOINT";
try {
let value: T;
try {
value = await callback(executor);
} catch (error) {
events.push(abort);
throw error;
}
if (scope.pending > 0) {
events.push(abort);
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
if (isTransaction && owner.aborted) {
events.push(abort);
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED", { sqlState: "25P02" });
}
events.push(isTransaction ? "COMMIT" : "RELEASE");
return value;
} finally {
if (!isTransaction && events.at(-1) === abort) {
// A savepoint that rolled back leaves its transaction usable, as in PostgreSQL.
owner.aborted = false;
}
scope.closed = true;
parent.active = true;
}
}
const root: Scope = { aborted: false, active: true, closed: false, pending: 0 };
const executor = Object.freeze({
abiVersion: 1 as const,
query: (request: ModuleDatabaseQuery) => run(root, undefined, 0, request),
transaction: <T>(callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T) => {
// Top-level statements are autocommit and may run on other pooled connections, so the root
// executor stays usable while a transaction is open.
const detached: Scope = { aborted: false, active: true, closed: false, pending: 0 };
return runScope(detached, undefined, 1, callback);
},
}) as unknown as ModuleDatabaseExecutor;
return { events, executor, statements };
}