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> = schemaTables; async function readSql(): Promise { 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", ); }); });