diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d8b50f0..1182f07 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -178,8 +178,19 @@ jobs: # deployment image with Signage in it, and the platform's split-topology deployment smoke runs # against it, pairing a kiosk display and running the scenarios in ci/display-scenarios. A # disposable database and object store with generated secrets; nothing is pushed or logged in to. + # + # DISABLED while Signage is pinned to Ekklesia 0.2.0. From that release on, the platform's image is + # the one generic image (platform ADR 0048): deploy/Dockerfile keeps `modules/` out of its build + # context and refuses one that has it, and the deployment smoke installs modules only as canonical + # Module Packages (ADR 0049). Signage is not one yet: its static sharp dependency needs the native + # linux-x64-glibc package flow, which is deliberately not part of the 0.2.0 compatibility change. + # Run against 0.2.0, this job would build an image without Signage and drive the module template's + # display instead, so it is switched off rather than left reporting on something else. The job and + # ci/integration.sh stay as they are; the job returns once Signage is built as a Module Package and + # installed into the generic image, and `if: false` is removed in that change. integration: name: integration + if: false needs: - test - build diff --git a/README.md b/README.md index 0010899..8394354 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,14 @@ Ekklesia and vendors none of its SDKs. - Module id: `signage` - Package: `@ekklesia/signage` -- Manifest version: 7 +- Manifest version: 8, with the Module Database ABI v1 (`database: { abi: 1 }`) ## Status Version 0.1.2 is feature-complete for its declared scope, and Signage has run in production since 0.1.1. What exists today: -- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and +- The module package, manifest (v8), four capabilities, six permissions, ten operator settings, and one logical storage bucket. - Eleven tables and a single initial migration, declared non-destructive. - Native image ingest: one image per streamed request, 25 MiB, content-addressed, with display and @@ -64,18 +64,22 @@ pnpm install ``` `modules/*` is an Ekklesia workspace glob, so the checkout is picked up with no registry edit, -no second copy of Ekklesia, and no vendored SDK. `@ekklesia/module-sdk`, `@ekklesia/module-ui-sdk`, -`@ekklesia/design-tokens`, `@ekklesia/ui`, and `@ekklesia/module-ui-build` all resolve through the -workspace as `workspace:*` dependencies. +no second copy of Ekklesia, and no vendored SDK. `@ekklesia/module-sdk`, `@ekklesia/module-database`, +`@ekklesia/module-ui-sdk`, `@ekklesia/design-tokens`, `@ekklesia/ui`, and `@ekklesia/module-ui-build` +all resolve through the workspace as `workspace:*` dependencies. Two consequences worth knowing: - Ekklesia uses a shared workspace lockfile, so installing here writes an importer entry for `modules/signage` into Ekklesia's `pnpm-lock.yaml`. That entry belongs to whoever maintains the Ekklesia checkout; it is not committed from this repository. -- Signage requires the Ekklesia platform prerequisites for shared design tokens, module stylesheets, - browser contributions, bounded streaming request bodies, and display offline code - (ADRs 0039–0043). An Ekklesia checkout without them cannot build or validate this module. +- Signage requires Ekklesia 0.2: manifest version 8 and the Module Database ABI v1 (platform ADR + 0050), on top of the prerequisites for shared design tokens, module stylesheets, browser + contributions, bounded streaming request bodies, and display offline code (ADRs 0039–0043). An + older Ekklesia checkout cannot build or validate this module. +- Signage reaches PostgreSQL only through the ABI v1 executor the platform hands it, with its own + Drizzle tables and the module-side adapter from `@ekklesia/module-database/drizzle`. Core receives + SQL text, bound values, and plain schema metadata, never a Drizzle object. ### Commands diff --git a/ci/platform.env b/ci/platform.env index eb7fadd..c67517a 100644 --- a/ci/platform.env +++ b/ci/platform.env @@ -3,4 +3,4 @@ # # Move it deliberately: change the commit, run `bash ci/update-lock.sh`, and commit both. EKKLESIA_PLATFORM_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git -EKKLESIA_PLATFORM_COMMIT=874deabea3468eb5c3e907f0821718b077a682c7 +EKKLESIA_PLATFORM_COMMIT=1788d17f0ab618ebc41550728da865d14cdd7a75 diff --git a/ci/pnpm-lock.yaml b/ci/pnpm-lock.yaml index 48611b0..94d9cc3 100644 --- a/ci/pnpm-lock.yaml +++ b/ci/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: '@ekklesia/design-tokens': specifier: workspace:* version: link:../../packages/design-tokens + '@ekklesia/module-database': + specifier: workspace:* + version: link:../../packages/module-database '@ekklesia/module-sdk': specifier: workspace:* version: link:../../packages/module-sdk @@ -207,12 +210,12 @@ importers: packages/contracts: dependencies: - drizzle-orm: - specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) semver: specifier: 7.8.5 version: 7.8.5 + spdx-expression-parse: + specifier: 5.0.0 + version: 5.0.0 zod: specifier: 4.4.3 version: 4.4.3 @@ -226,6 +229,9 @@ importers: '@types/semver': specifier: 7.7.1 version: 7.7.1 + '@types/spdx-expression-parse': + specifier: 4.0.0 + version: 4.0.0 typescript: specifier: 7.0.2 version: 7.0.2 @@ -254,14 +260,82 @@ importers: specifier: 4.1.10 version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/module-database: + dependencies: + '@ekklesia/contracts': + specifier: workspace:* + version: link:../contracts + devDependencies: + '@biomejs/biome': + specifier: 2.5.3 + version: 2.5.3 + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + drizzle-orm: + specifier: 0.45.2 + version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + + packages/module-pack: + dependencies: + '@ekklesia/contracts': + specifier: workspace:* + version: link:../contracts + '@ekklesia/module-ui-build': + specifier: workspace:* + version: link:../module-ui-build + esbuild: + specifier: 0.28.1 + version: 0.28.1 + semver: + specifier: 7.8.5 + version: 7.8.5 + devDependencies: + '@biomejs/biome': + specifier: 2.5.3 + version: 2.5.3 + '@ekklesia/core': + specifier: workspace:* + version: link:../../core + '@ekklesia/module-database': + specifier: workspace:* + version: link:../module-database + '@ekklesia/module-sdk': + specifier: workspace:* + version: link:../module-sdk + '@ekklesia/module-template': + specifier: workspace:* + version: link:../../templates/module + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + '@types/semver': + specifier: 7.7.1 + version: 7.7.1 + drizzle-orm: + specifier: 0.45.2 + version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) + sharp: + specifier: 0.35.4 + version: 0.35.4(@types/node@24.13.3) + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/module-sdk: dependencies: '@ekklesia/contracts': specifier: workspace:* version: link:../contracts - drizzle-orm: - specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) devDependencies: '@biomejs/biome': specifier: 2.5.3 @@ -345,9 +419,6 @@ importers: '@ekklesia/design-tokens': specifier: workspace:* version: link:../design-tokens - react: - specifier: 19.2.8 - version: 19.2.8 devDependencies: '@biomejs/biome': specifier: 2.5.3 @@ -367,6 +438,9 @@ importers: happy-dom: specifier: 20.0.11 version: 20.0.11 + react: + specifier: 19.2.8 + version: 19.2.8 react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) @@ -431,6 +505,9 @@ importers: '@ekklesia/design-tokens': specifier: workspace:* version: link:../../packages/design-tokens + '@ekklesia/module-database': + specifier: workspace:* + version: link:../../packages/module-database '@ekklesia/module-sdk': specifier: workspace:* version: link:../../packages/module-sdk @@ -1609,6 +1686,9 @@ packages: '@types/smtp-server@3.5.13': resolution: {integrity: sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==} + '@types/spdx-expression-parse@4.0.0': + resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==} + '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -2417,6 +2497,15 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@5.0.0: + resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} + + spdx-license-ids@3.0.24: + resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -3597,6 +3686,8 @@ snapshots: '@types/node': 24.13.3 '@types/nodemailer': 8.0.1 + '@types/spdx-expression-parse@4.0.0': {} + '@types/whatwg-mimetype@3.0.2': {} '@typescript/typescript-aix-ppc64@7.0.2': @@ -4267,6 +4358,15 @@ snapshots: source-map@0.6.1: {} + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@5.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.24 + + spdx-license-ids@3.0.24: {} + split2@4.2.0: {} stackback@0.0.2: {} diff --git a/ekklesia.module.json b/ekklesia.module.json index be6aa8f..4ac9502 100644 --- a/ekklesia.module.json +++ b/ekklesia.module.json @@ -1,12 +1,15 @@ { - "manifestVersion": 7, + "manifestVersion": 8, "id": "signage", "name": "Signage", "description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.", "version": "0.1.2", "compatibility": { - "platform": "^0.1.0", - "moduleSdk": "^0.1.0" + "platform": "^0.2.0", + "moduleSdk": "^0.2.0" + }, + "database": { + "abi": 1 }, "capabilities": [ { diff --git a/package.json b/package.json index 0f21853..d8e9a8d 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@ekklesia/design-tokens": "workspace:*", + "@ekklesia/module-database": "workspace:*", "@ekklesia/module-sdk": "workspace:*", "@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/ui": "workspace:*", diff --git a/src/schema/index.ts b/src/schema/index.ts index beb9fd2..1fc9a05 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -1,4 +1,4 @@ -import { defineModuleSchema } from "@ekklesia/module-sdk"; +import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle"; import { channelGroups, channels, @@ -13,7 +13,16 @@ import { viewers, } from "./tables.js"; -export { +/** + * Signage's compiled schema entrypoint: the plain Module Database ABI v1 metadata Core validates + * table ownership from (platform ADR 0050) — each table's physical name and the tables its foreign + * keys target, as strings and arrays. + * + * Derived on this side from the same Drizzle tables `tables.ts` declares, so it cannot drift from + * them, and deliberately the **only** export: Core never receives a Drizzle object from Signage. + * Side-effect free by contract — importing this connects to nothing and reads nothing. + */ +export default moduleDrizzleSchemaMetadata("signage", { channelGroups, channels, imageAssets, @@ -22,29 +31,7 @@ export { providerSources, selections, selectionVariants, - timelineSlots, timelines, + timelineSlots, viewers, -} from "./tables.js"; - -/** - * Signage's compiled schema entrypoint: the same table objects `tables.ts` declares, wrapped with the - * ownership marker Core validates during discovery. Side-effect free by contract — importing this - * connects to nothing and reads nothing. - */ -export default defineModuleSchema({ - moduleId: "signage", - tables: { - channelGroups, - channels, - imageAssets, - imageRenditions, - playbackStates, - providerSources, - selections, - selectionVariants, - timelines, - timelineSlots, - viewers, - }, }); diff --git a/src/schema/tables.ts b/src/schema/tables.ts index 37ab33d..030e3a3 100644 --- a/src/schema/tables.ts +++ b/src/schema/tables.ts @@ -17,9 +17,10 @@ import { * Signage's own PostgreSQL tables. * * Kept to **pure Drizzle declarations** so the stable Drizzle Kit generator can load this file - * without executing the ESM Module SDK wrapper; `./index.ts` wraps these same objects with the - * ownership marker Core validates. The required `signage_` namespace is applied once here, and Core - * validates it independently of this helper. + * without executing any Ekklesia package; `./index.ts` derives the plain schema metadata Core + * validates from these same objects, and only Signage's own store code ever uses them as Drizzle + * tables. The required `signage_` namespace is applied once here, and Core validates it + * independently of this helper. * * Two rules hold everywhere below, and both are architecture rather than style: * diff --git a/src/server/runtime.ts b/src/server/runtime.ts index 4052853..a6eb798 100644 --- a/src/server/runtime.ts +++ b/src/server/runtime.ts @@ -89,7 +89,7 @@ export function createSignageModuleRuntime( function requireStore(): ReturnType { if (!store) { - throw new Error("Signage's database facade is only available after start."); + throw new Error("Signage's store is only available after start."); } return store; } @@ -126,6 +126,9 @@ export function createSignageModuleRuntime( } const definition = defineModuleRuntime({ + // Matches `database.abi` in the manifest: `context.services.database` is this module's Module + // Database ABI v1 executor, which `createSignageStore` binds Signage's own Drizzle adapter to. + database: { abi: 1 }, id: "signage", version: "0.1.2", diff --git a/src/server/store/drizzle-playback.ts b/src/server/store/drizzle-playback.ts index d00ccc1..695f6c9 100644 --- a/src/server/store/drizzle-playback.ts +++ b/src/server/store/drizzle-playback.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { ModuleDatabase } from "@ekklesia/module-sdk"; +import type { ModuleDrizzleDatabase } from "@ekklesia/module-database/drizzle"; import { and, asc, desc, eq, inArray, lt, sql } from "drizzle-orm"; import { channelGroups, @@ -94,7 +94,7 @@ function toSelection( }; } -export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackStore { +export function createPlaybackStore(database: ModuleDrizzleDatabase): SignagePlaybackStore { async function variantsFor( selectionIds: readonly string[], ): Promise> { diff --git a/src/server/store/drizzle-timelines.ts b/src/server/store/drizzle-timelines.ts index ed08cbc..a22cdda 100644 --- a/src/server/store/drizzle-timelines.ts +++ b/src/server/store/drizzle-timelines.ts @@ -1,4 +1,7 @@ -import type { ModuleDatabase } from "@ekklesia/module-sdk"; +import type { + ModuleDrizzleDatabase, + ModuleDrizzleTransaction, +} from "@ekklesia/module-database/drizzle"; import { asc, eq, or, sql } from "drizzle-orm"; import { playbackStates, providerSources, timelineSlots, timelines } from "../../schema/tables.js"; import { @@ -108,7 +111,7 @@ function slotValues(slot: NewStoredSlot, position: number) { }; } -export function createTimelineStore(database: ModuleDatabase): SignageTimelineStore { +export function createTimelineStore(database: ModuleDrizzleDatabase): SignageTimelineStore { /** * Rewrites `position` for one timeline from an explicit order. * @@ -118,7 +121,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt * remaining inside the database's non-negative constraint. */ async function renumber( - transaction: ModuleDatabase, + transaction: ModuleDrizzleTransaction, timelineId: string, orderedSlotIds: readonly string[], ): Promise { @@ -138,7 +141,10 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt await bumpRevision(transaction, timelineId); } - async function bumpRevision(transaction: ModuleDatabase, timelineId: string): Promise { + async function bumpRevision( + transaction: ModuleDrizzleTransaction, + timelineId: string, + ): Promise { await transaction .update(timelines) .set({ revision: sql`${timelines.revision} + 1`, updatedAt: new Date() }) @@ -146,7 +152,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt } async function orderedSlotIdsFor( - transaction: ModuleDatabase, + transaction: ModuleDrizzleTransaction, timelineId: string, ): Promise { const rows = await transaction @@ -158,7 +164,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt } async function timelineIdForSlot( - transaction: ModuleDatabase, + transaction: ModuleDrizzleTransaction, slotId: string, ): Promise { const rows = await transaction diff --git a/src/server/store/drizzle-viewers.ts b/src/server/store/drizzle-viewers.ts index fc3c172..86d9dc6 100644 --- a/src/server/store/drizzle-viewers.ts +++ b/src/server/store/drizzle-viewers.ts @@ -1,4 +1,4 @@ -import type { ModuleDatabase } from "@ekklesia/module-sdk"; +import type { ModuleDrizzleDatabase } from "@ekklesia/module-database/drizzle"; import { asc, eq } from "drizzle-orm"; import { viewers } from "../../schema/tables.js"; import type { NewStoredViewer, SignageViewerStore, StoredViewer, ViewerChanges } from "./types.js"; @@ -29,7 +29,7 @@ function toViewer(row: typeof viewers.$inferSelect): StoredViewer { }; } -export function createViewerStore(database: ModuleDatabase): SignageViewerStore { +export function createViewerStore(database: ModuleDrizzleDatabase): SignageViewerStore { return { async deleteViewer(viewerId: string): Promise { await database.delete(viewers).where(eq(viewers.id, viewerId)); diff --git a/src/server/store/drizzle.ts b/src/server/store/drizzle.ts index 7a0bde7..9c8d2b2 100644 --- a/src/server/store/drizzle.ts +++ b/src/server/store/drizzle.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import type { ModuleDatabase } from "@ekklesia/module-sdk"; +import { findEkklesiaDatabaseError, type ModuleDatabaseExecutor } from "@ekklesia/module-database"; +import { type ModuleDrizzleDatabase, moduleDrizzle } from "@ekklesia/module-database/drizzle"; import { and, desc, eq, inArray } from "drizzle-orm"; import { imageAssets, @@ -22,14 +23,16 @@ import { } from "./types.js"; /** - * The production implementation of Signage's data port, over the platform's `ModuleDatabase`. + * The production implementation of Signage's data port, over the platform's Module Database ABI v1 + * executor (platform ADR 0050) through the module-side Drizzle adapter. * * This file is deliberately dull: it maps rows and nothing else. Every decision — what to write, * when a revision changes, what a no-op is — lives in the services above it, so that behaviour can * be proven without PostgreSQL and this mapping can be proven against a live one. * - * The facade is the only way Signage reaches PostgreSQL. Nothing here constructs a client, reads a - * connection string, or touches another module's table. + * The executor is the only way Signage reaches PostgreSQL. Drizzle compiles every query here, on + * Signage's side; Core receives SQL text and bound values and nothing else. Nothing here constructs a + * client, reads a connection string, or touches another module's table. */ const assetColumns = { @@ -66,12 +69,11 @@ const renditionColumns = { width: imageRenditions.width, }; -/** PostgreSQL reports a uniqueness violation as SQLSTATE `23505`. The code is all that is read; the - * driver's message, detail, and constraint text are never surfaced. */ +/** PostgreSQL reports a uniqueness violation as SQLSTATE `23505`. Drizzle wraps whatever the + * executor throws, so the stable Ekklesia database error is found through that wrapping; its SQLSTATE + * is all that is read, and no driver error shape is ever relied on. */ function isUniqueViolation(error: unknown): boolean { - return ( - typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505" - ); + return findEkklesiaDatabaseError(error)?.sqlState === "23505"; } function toRendition(row: { @@ -116,7 +118,7 @@ function toImage(row: AssetRow, renditions: readonly StoredImageRendition[]): St }; } -export function createImageStore(database: ModuleDatabase): SignageImageStore { +export function createImageStore(database: ModuleDrizzleDatabase): SignageImageStore { async function renditionsFor( assetIds: readonly string[], ): Promise> { @@ -258,8 +260,12 @@ export function createImageStore(database: ModuleDatabase): SignageImageStore { * * Composed rather than written as one object because the two halves share nothing but the database * handle, and keeping them separate is what lets each be read on its own. + * + * Takes the ABI executor the platform hands a route handler or lifecycle hook, and binds Signage's + * own Drizzle adapter to it here, once, so every caller reaches its tables the same way. */ -export function createSignageStore(database: ModuleDatabase): SignageStore { +export function createSignageStore(executor: ModuleDatabaseExecutor): SignageStore { + const database = moduleDrizzle(executor); return { ...createImageStore(database), ...createTimelineStore(database), diff --git a/src/server/store/types.ts b/src/server/store/types.ts index c80079b..1299243 100644 --- a/src/server/store/types.ts +++ b/src/server/store/types.ts @@ -3,7 +3,7 @@ * * Every piece of Signage's behaviour — ingest, timeline editing, provider resolution, authoritative * playback, viewer health — is written against this interface rather than against Drizzle directly. - * There are two implementations: the production one over the platform's `ModuleDatabase` + * There are two implementations: the production one over the platform's database ABI executor * (`./drizzle.ts`), and an in-memory one used by tests. * * This is the same discipline Core's own migration runner follows, and it exists for the same diff --git a/tests/acceptance.test.ts b/tests/acceptance.test.ts index 4f82ae6..dd14d94 100644 --- a/tests/acceptance.test.ts +++ b/tests/acceptance.test.ts @@ -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"); }); }); diff --git a/tests/drizzle-store.test.ts b/tests/drizzle-store.test.ts new file mode 100644 index 0000000..cc99699 --- /dev/null +++ b/tests/drizzle-store.test.ts @@ -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>): 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; + 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"); + }); +}); diff --git a/tests/drizzle-timelines.test.ts b/tests/drizzle-timelines.test.ts index 07add12..3e40448 100644 --- a/tests/drizzle-timelines.test.ts +++ b/tests/drizzle-timelines.test.ts @@ -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) => operation(transaction), - } as unknown as ModuleDatabase, + } as unknown as ModuleDrizzleDatabase, positions, }; } diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 301f3f0..eee2d85 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -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 () => { diff --git a/tests/schema.test.ts b/tests/schema.test.ts index d04c81e..64501e2 100644 --- a/tests/schema.test.ts +++ b/tests/schema.test.ts @@ -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>; +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"); @@ -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)); diff --git a/tests/support/abi-executor.ts b/tests/support/abi-executor.ts new file mode 100644 index 0000000..ac77f7d --- /dev/null +++ b/tests/support/abi-executor.ts @@ -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>; +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((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( + parent: Scope, + transaction: Scope | undefined, + depth: number, + callback: (scope: ModuleDatabaseTransaction) => Promise | T, + ): Promise { + 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: (inner: (savepoint: ModuleDatabaseTransaction) => Promise | 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: (callback: (transaction: ModuleDatabaseTransaction) => Promise | 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 }; +}