Files
Signage/tests/schema.test.ts
ekklesiaandClaude Opus 5.5 a6d553efbb
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
feat: move Signage to the Ekklesia 0.2 module contract (manifest v8, database ABI v1)
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]>
2026-10-08 20:36:11 -05:00

187 lines
8.2 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import {
parseDrizzleMigrationJournal,
parseModuleManifest,
parseModuleMigrationPolicy,
} from "@ekklesia/module-sdk";
import { getTableConfig, type PgTable } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import * as schemaEntrypoint from "../src/schema/index.js";
import * as schemaTables from "../src/schema/tables.js";
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");
}
describe("Signage schema", () => {
it("declares every table Signage's data model needs, and nothing outside its namespace", () => {
const physicalNames = Object.values(tables)
.map((table) => getTableConfig(table).name)
.sort();
expect(physicalNames).toEqual([
"signage_channel_groups",
"signage_channels",
"signage_image_assets",
"signage_image_renditions",
"signage_playback_states",
"signage_provider_sources",
"signage_selection_variants",
"signage_selections",
"signage_timeline_slots",
"signage_timelines",
"signage_viewers",
]);
for (const table of Object.values(tables)) {
const config = getTableConfig(table);
// The platform-managed default schema, never a custom PostgreSQL schema.
expect(config.schema).toBeUndefined();
expect(config.name.length).toBeLessThanOrEqual(63);
}
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));
for (const table of Object.values(tables)) {
for (const foreignKey of getTableConfig(table).foreignKeys) {
const reference = foreignKey.reference();
expect(owned).toContain(getTableConfig(reference.foreignTable).name);
}
}
});
it("stores another module's identities as opaque text rather than as a reference", () => {
// A provider, its sources, and its items belong to another module. Signage stores what it was
// told and can never join to it — which is exactly what keeps the two independently installable.
const opaque = [
["providerSources", "provider_id"],
["providerSources", "source_id"],
["timelineSlots", "provider_id"],
["timelineSlots", "item_id"],
["selections", "provider_id"],
["selections", "item_id"],
["viewers", "device_id"],
] as const;
for (const [tableName, columnName] of opaque) {
const table = tables[tableName];
if (!table) {
throw new Error(`Signage's schema is missing ${tableName}.`);
}
const config = getTableConfig(table);
const column = config.columns.find((candidate) => candidate.name === columnName);
expect(column?.getSQLType()).toBe("text");
const referenced = config.foreignKeys.some((foreignKey) =>
foreignKey.reference().columns.some((candidate) => candidate.name === columnName),
);
expect(referenced).toBe(false);
}
});
it("stores logical object keys and never a signed delivery URL", async () => {
const sql = await readSql();
expect(sql).toContain('"original_object_key" text NOT NULL');
expect(sql).toContain('"object_key" text NOT NULL');
// A read URL is a bearer capability with an expiry in it. Nothing in this schema can hold one.
for (const forbidden of ["url", "signed_url", "presigned", "delivery_url"]) {
expect(sql.toLowerCase()).not.toContain(`"${forbidden}"`);
}
});
it("makes the invariants playback depends on the database's job, not a convention", async () => {
const sql = await readSql();
// Content-addressed identity: the same bytes are the same asset.
expect(sql).toContain("signage_image_assets_content_sha256_key");
// A slot position is unique within its timeline.
expect(sql).toContain("signage_timeline_slots_position_key");
// Exactly one default channel per group.
expect(sql).toContain("signage_channels_group_default_key");
// A slot is a provider source or a pinned item, never both and never neither.
expect(sql).toContain("signage_timeline_slots_reference_check");
// History is ordered and gapless per group.
expect(sql).toContain("signage_selections_group_sequence_key");
// One payload per audience per selection.
expect(sql).toContain("signage_selection_variants_selection_audience_key");
// Deleting a slot must not erase what already happened on screen.
expect(sql).toMatch(/signage_selections_slot_id_.*ON DELETE set null/su);
});
it("packages one reviewed, non-destructive, forward-only migration", async () => {
const [journalInput, policyInput, manifestInput, bytes] = await Promise.all([
readFile(new URL("../drizzle/meta/_journal.json", import.meta.url), "utf8"),
readFile(new URL("../drizzle/meta/_ekklesia.json", import.meta.url), "utf8"),
readFile(new URL("../ekklesia.module.json", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0000_initial.sql", import.meta.url)),
]);
const journal = parseDrizzleMigrationJournal(JSON.parse(journalInput) as unknown);
const policy = parseModuleMigrationPolicy(JSON.parse(policyInput) as unknown);
const manifest = parseModuleManifest(JSON.parse(manifestInput) as unknown);
expect(journal.entries.map((entry) => entry.tag)).toEqual(["0000_initial"]);
expect(policy).toMatchObject({
moduleId: "signage",
entries: [{ tag: "0000_initial", destructive: { mode: "none" } }],
});
expect(manifest.entrypoints.migrations).toBe("./drizzle");
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
// Pinned deliberately. Core compares the exact bytes against its applied history and refuses a
// changed migration, so an accidental edit here must fail in this repository first.
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
"fbbc448f9a951818bf37e89849ccdbfb55f3221260db0f59d1a2b245cc126838",
);
});
});