feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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 moduleSchema from "../src/schema/index.js";
|
||||
|
||||
const tables = moduleSchema.tables as Readonly<Record<string, PgTable>>;
|
||||
|
||||
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("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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user