feat: move Signage to the Ekklesia 0.2 module contract (manifest v8, database ABI v1) #10

Merged
Zayin_Admin merged 1 commits from feat/platform-0.2-compat into main 2026-10-09 01:41:36 +00:00
20 changed files with 1124 additions and 81 deletions
Showing only changes of commit a6d553efbb - Show all commits
+11
View File
@@ -178,8 +178,19 @@ jobs:
# deployment image with Signage in it, and the platform's split-topology deployment smoke runs # 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 # 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. # 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: integration:
name: integration name: integration
if: false
needs: needs:
- test - test
- build - build
+12 -8
View File
@@ -12,14 +12,14 @@ Ekklesia and vendors none of its SDKs.
- Module id: `signage` - Module id: `signage`
- Package: `@ekklesia/signage` - Package: `@ekklesia/signage`
- Manifest version: 7 - Manifest version: 8, with the Module Database ABI v1 (`database: { abi: 1 }`)
## Status ## Status
Version 0.1.2 is feature-complete for its declared scope, and Signage has run in production since Version 0.1.2 is feature-complete for its declared scope, and Signage has run in production since
0.1.1. What exists today: 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. one logical storage bucket.
- Eleven tables and a single initial migration, declared non-destructive. - 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 - 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, `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`, no second copy of Ekklesia, and no vendored SDK. `@ekklesia/module-sdk`, `@ekklesia/module-database`,
`@ekklesia/design-tokens`, `@ekklesia/ui`, and `@ekklesia/module-ui-build` all resolve through the `@ekklesia/module-ui-sdk`, `@ekklesia/design-tokens`, `@ekklesia/ui`, and `@ekklesia/module-ui-build`
workspace as `workspace:*` dependencies. all resolve through the workspace as `workspace:*` dependencies.
Two consequences worth knowing: Two consequences worth knowing:
- Ekklesia uses a shared workspace lockfile, so installing here writes an importer entry for - 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 `modules/signage` into Ekklesia's `pnpm-lock.yaml`. That entry belongs to whoever maintains the
Ekklesia checkout; it is not committed from this repository. Ekklesia checkout; it is not committed from this repository.
- Signage requires the Ekklesia platform prerequisites for shared design tokens, module stylesheets, - Signage requires Ekklesia 0.2: manifest version 8 and the Module Database ABI v1 (platform ADR
browser contributions, bounded streaming request bodies, and display offline code 0050), on top of the prerequisites for shared design tokens, module stylesheets, browser
(ADRs 0039–0043). An Ekklesia checkout without them cannot build or validate this module. 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 ### Commands
+1 -1
View File
@@ -3,4 +3,4 @@
# #
# Move it deliberately: change the commit, run `bash ci/update-lock.sh`, and commit both. # 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_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git
EKKLESIA_PLATFORM_COMMIT=874deabea3468eb5c3e907f0821718b077a682c7 EKKLESIA_PLATFORM_COMMIT=1788d17f0ab618ebc41550728da865d14cdd7a75
+109 -9
View File
@@ -152,6 +152,9 @@ importers:
'@ekklesia/design-tokens': '@ekklesia/design-tokens':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/design-tokens version: link:../../packages/design-tokens
'@ekklesia/module-database':
specifier: workspace:*
version: link:../../packages/module-database
'@ekklesia/module-sdk': '@ekklesia/module-sdk':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/module-sdk version: link:../../packages/module-sdk
@@ -207,12 +210,12 @@ importers:
packages/contracts: packages/contracts:
dependencies: dependencies:
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected])
semver: semver:
specifier: 7.8.5 specifier: 7.8.5
version: 7.8.5 version: 7.8.5
spdx-expression-parse:
specifier: 5.0.0
version: 5.0.0
zod: zod:
specifier: 4.4.3 specifier: 4.4.3
version: 4.4.3 version: 4.4.3
@@ -226,6 +229,9 @@ importers:
'@types/semver': '@types/semver':
specifier: 7.7.1 specifier: 7.7.1
version: 7.7.1 version: 7.7.1
'@types/spdx-expression-parse':
specifier: 4.0.0
version: 4.0.0
typescript: typescript:
specifier: 7.0.2 specifier: 7.0.2
version: 7.0.2 version: 7.0.2
@@ -254,14 +260,82 @@ importers:
specifier: 4.1.10 specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])) version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
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/[email protected])([email protected])([email protected])
typescript:
specifier: 7.0.2
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
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/[email protected])([email protected])([email protected])
sharp:
specifier: 0.35.4
version: 0.35.4(@types/[email protected])
typescript:
specifier: 7.0.2
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/module-sdk: packages/module-sdk:
dependencies: dependencies:
'@ekklesia/contracts': '@ekklesia/contracts':
specifier: workspace:* specifier: workspace:*
version: link:../contracts version: link:../contracts
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected])
devDependencies: devDependencies:
'@biomejs/biome': '@biomejs/biome':
specifier: 2.5.3 specifier: 2.5.3
@@ -345,9 +419,6 @@ importers:
'@ekklesia/design-tokens': '@ekklesia/design-tokens':
specifier: workspace:* specifier: workspace:*
version: link:../design-tokens version: link:../design-tokens
react:
specifier: 19.2.8
version: 19.2.8
devDependencies: devDependencies:
'@biomejs/biome': '@biomejs/biome':
specifier: 2.5.3 specifier: 2.5.3
@@ -367,6 +438,9 @@ importers:
happy-dom: happy-dom:
specifier: 20.0.11 specifier: 20.0.11
version: 20.0.11 version: 20.0.11
react:
specifier: 19.2.8
version: 19.2.8
react-dom: react-dom:
specifier: 19.2.8 specifier: 19.2.8
version: 19.2.8([email protected]) version: 19.2.8([email protected])
@@ -431,6 +505,9 @@ importers:
'@ekklesia/design-tokens': '@ekklesia/design-tokens':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/design-tokens version: link:../../packages/design-tokens
'@ekklesia/module-database':
specifier: workspace:*
version: link:../../packages/module-database
'@ekklesia/module-sdk': '@ekklesia/module-sdk':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/module-sdk version: link:../../packages/module-sdk
@@ -1609,6 +1686,9 @@ packages:
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==} resolution: {integrity: sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==}
'@types/[email protected]':
resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==}
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
@@ -2417,6 +2497,15 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
[email protected]:
resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==}
[email protected]:
resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==}
[email protected]: [email protected]:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
@@ -3597,6 +3686,8 @@ snapshots:
'@types/node': 24.13.3 '@types/node': 24.13.3
'@types/nodemailer': 8.0.1 '@types/nodemailer': 8.0.1
'@types/[email protected]': {}
'@types/[email protected]': {} '@types/[email protected]': {}
'@typescript/[email protected]': '@typescript/[email protected]':
@@ -4267,6 +4358,15 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
spdx-exceptions: 2.5.0
spdx-license-ids: 3.0.24
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+6 -3
View File
@@ -1,12 +1,15 @@
{ {
"manifestVersion": 7, "manifestVersion": 8,
"id": "signage", "id": "signage",
"name": "Signage", "name": "Signage",
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.", "description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
"version": "0.1.2", "version": "0.1.2",
"compatibility": { "compatibility": {
"platform": "^0.1.0", "platform": "^0.2.0",
"moduleSdk": "^0.1.0" "moduleSdk": "^0.2.0"
},
"database": {
"abi": 1
}, },
"capabilities": [ "capabilities": [
{ {
+1
View File
@@ -29,6 +29,7 @@
}, },
"dependencies": { "dependencies": {
"@ekklesia/design-tokens": "workspace:*", "@ekklesia/design-tokens": "workspace:*",
"@ekklesia/module-database": "workspace:*",
"@ekklesia/module-sdk": "workspace:*", "@ekklesia/module-sdk": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/ui": "workspace:*", "@ekklesia/ui": "workspace:*",
+12 -25
View File
@@ -1,4 +1,4 @@
import { defineModuleSchema } from "@ekklesia/module-sdk"; import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";
import { import {
channelGroups, channelGroups,
channels, channels,
@@ -13,7 +13,16 @@ import {
viewers, viewers,
} from "./tables.js"; } 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, channelGroups,
channels, channels,
imageAssets, imageAssets,
@@ -22,29 +31,7 @@ export {
providerSources, providerSources,
selections, selections,
selectionVariants, selectionVariants,
timelineSlots,
timelines, timelines,
timelineSlots,
viewers, 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,
},
}); });
+4 -3
View File
@@ -17,9 +17,10 @@ import {
* Signage's own PostgreSQL tables. * Signage's own PostgreSQL tables.
* *
* Kept to **pure Drizzle declarations** so the stable Drizzle Kit generator can load this file * 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 * without executing any Ekklesia package; `./index.ts` derives the plain schema metadata Core
* ownership marker Core validates. The required `signage_` namespace is applied once here, and Core * validates from these same objects, and only Signage's own store code ever uses them as Drizzle
* validates it independently of this helper. * 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: * Two rules hold everywhere below, and both are architecture rather than style:
* *
+4 -1
View File
@@ -89,7 +89,7 @@ export function createSignageModuleRuntime(
function requireStore(): ReturnType<typeof createSignageStore> { function requireStore(): ReturnType<typeof createSignageStore> {
if (!store) { 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; return store;
} }
@@ -126,6 +126,9 @@ export function createSignageModuleRuntime(
} }
const definition = defineModuleRuntime({ 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", id: "signage",
version: "0.1.2", version: "0.1.2",
+2 -2
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; 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 { and, asc, desc, eq, inArray, lt, sql } from "drizzle-orm";
import { import {
channelGroups, channelGroups,
@@ -94,7 +94,7 @@ function toSelection(
}; };
} }
export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackStore { export function createPlaybackStore(database: ModuleDrizzleDatabase): SignagePlaybackStore {
async function variantsFor( async function variantsFor(
selectionIds: readonly string[], selectionIds: readonly string[],
): Promise<Map<string, StoredSelectionVariant[]>> { ): Promise<Map<string, StoredSelectionVariant[]>> {
+12 -6
View File
@@ -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 { asc, eq, or, sql } from "drizzle-orm";
import { playbackStates, providerSources, timelineSlots, timelines } from "../../schema/tables.js"; import { playbackStates, providerSources, timelineSlots, timelines } from "../../schema/tables.js";
import { 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. * 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. * remaining inside the database's non-negative constraint.
*/ */
async function renumber( async function renumber(
transaction: ModuleDatabase, transaction: ModuleDrizzleTransaction,
timelineId: string, timelineId: string,
orderedSlotIds: readonly string[], orderedSlotIds: readonly string[],
): Promise<void> { ): Promise<void> {
@@ -138,7 +141,10 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
await bumpRevision(transaction, timelineId); await bumpRevision(transaction, timelineId);
} }
async function bumpRevision(transaction: ModuleDatabase, timelineId: string): Promise<void> { async function bumpRevision(
transaction: ModuleDrizzleTransaction,
timelineId: string,
): Promise<void> {
await transaction await transaction
.update(timelines) .update(timelines)
.set({ revision: sql`${timelines.revision} + 1`, updatedAt: new Date() }) .set({ revision: sql`${timelines.revision} + 1`, updatedAt: new Date() })
@@ -146,7 +152,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
} }
async function orderedSlotIdsFor( async function orderedSlotIdsFor(
transaction: ModuleDatabase, transaction: ModuleDrizzleTransaction,
timelineId: string, timelineId: string,
): Promise<string[]> { ): Promise<string[]> {
const rows = await transaction const rows = await transaction
@@ -158,7 +164,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
} }
async function timelineIdForSlot( async function timelineIdForSlot(
transaction: ModuleDatabase, transaction: ModuleDrizzleTransaction,
slotId: string, slotId: string,
): Promise<string | undefined> { ): Promise<string | undefined> {
const rows = await transaction const rows = await transaction
+2 -2
View File
@@ -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 { asc, eq } from "drizzle-orm";
import { viewers } from "../../schema/tables.js"; import { viewers } from "../../schema/tables.js";
import type { NewStoredViewer, SignageViewerStore, StoredViewer, ViewerChanges } from "./types.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 { return {
async deleteViewer(viewerId: string): Promise<void> { async deleteViewer(viewerId: string): Promise<void> {
await database.delete(viewers).where(eq(viewers.id, viewerId)); await database.delete(viewers).where(eq(viewers.id, viewerId));
+17 -11
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto"; 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 { and, desc, eq, inArray } from "drizzle-orm";
import { import {
imageAssets, imageAssets,
@@ -22,14 +23,16 @@ import {
} from "./types.js"; } 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, * 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 * 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. * 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 * The executor is the only way Signage reaches PostgreSQL. Drizzle compiles every query here, on
* connection string, or touches another module's table. * 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 = { const assetColumns = {
@@ -66,12 +69,11 @@ const renditionColumns = {
width: imageRenditions.width, width: imageRenditions.width,
}; };
/** PostgreSQL reports a uniqueness violation as SQLSTATE `23505`. The code is all that is read; the /** PostgreSQL reports a uniqueness violation as SQLSTATE `23505`. Drizzle wraps whatever the
* driver's message, detail, and constraint text are never surfaced. */ * 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 { function isUniqueViolation(error: unknown): boolean {
return ( return findEkklesiaDatabaseError(error)?.sqlState === "23505";
typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505"
);
} }
function toRendition(row: { 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( async function renditionsFor(
assetIds: readonly string[], assetIds: readonly string[],
): Promise<Map<string, StoredImageRendition[]>> { ): Promise<Map<string, StoredImageRendition[]>> {
@@ -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 * 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. * 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 { return {
...createImageStore(database), ...createImageStore(database),
...createTimelineStore(database), ...createTimelineStore(database),
+1 -1
View File
@@ -3,7 +3,7 @@
* *
* Every piece of Signage's behaviour — ingest, timeline editing, provider resolution, authoritative * 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. * 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. * (`./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 * This is the same discipline Core's own migration runner follows, and it exists for the same
+3 -1
View File
@@ -263,7 +263,9 @@ describe("what this release deliberately does not contain", () => {
]) { ]) {
expect(names).not.toContain(forbidden); 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("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 { describe, expect, it } from "vitest";
import { createTimelineStore } from "../src/server/store/drizzle-timelines.js"; import { createTimelineStore } from "../src/server/store/drizzle-timelines.js";
import type { NewStoredSlot } from "../src/server/store/types.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. * actual database rejected a native-image slot before playback ever had content to resolve.
*/ */
function constraintDatabase(existing: readonly { id: string; position: number }[]): { function constraintDatabase(existing: readonly { id: string; position: number }[]): {
readonly database: ModuleDatabase; readonly database: ModuleDrizzleDatabase;
readonly positions: number[]; readonly positions: number[];
} { } {
const positions: number[] = []; const positions: number[] = [];
@@ -66,7 +66,7 @@ function constraintDatabase(existing: readonly { id: string; position: number }[
return { return {
database: { database: {
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction), transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction),
} as unknown as ModuleDatabase, } as unknown as ModuleDrizzleDatabase,
positions, 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 () => { it("declares the module's product identity at the current manifest version", async () => {
const manifest = await readManifest(); const manifest = await readManifest();
expect(manifest.manifestVersion).toBe(7); expect(manifest.manifestVersion).toBe(8);
expect(manifest.id).toBe("signage"); expect(manifest.id).toBe("signage");
expect(manifest.name).toBe("Signage"); expect(manifest.name).toBe("Signage");
expect(manifest.version).toBe("0.1.2"); 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 () => { it("declares every Signage permission, and none of them publicly", async () => {
@@ -142,7 +150,8 @@ describe("Signage module manifest", () => {
describe("Signage server runtime", () => { describe("Signage server runtime", () => {
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => { 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 () => { 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"; } from "@ekklesia/module-sdk";
import { getTableConfig, type PgTable } from "drizzle-orm/pg-core"; import { getTableConfig, type PgTable } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest"; 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> { async function readSql(): Promise<string> {
return readFile(new URL("../drizzle/0000_initial.sql", import.meta.url), "utf8"); return readFile(new URL("../drizzle/0000_initial.sql", import.meta.url), "utf8");
@@ -43,6 +46,50 @@ describe("Signage schema", () => {
expect(moduleSchema.moduleId).toBe("signage"); 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", () => { it("never points a foreign key outside this module", () => {
const owned = new Set(Object.values(tables).map((table) => getTableConfig(table).name)); 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 };
}