4 Commits
Author SHA1 Message Date
The_River e7f7404d35 Merge pull request 'feat: build Signage on its own from the published Ekklesia 0.2.0 packages' (#11) from feat/standalone-build into main
CI / lint (push) Successful in 10s
CI / typecheck (push) Successful in 10s
CI / test (push) Successful in 15s
CI / build (push) Successful in 12s
CI / integration (push) Skipped
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/11
Reviewed-by: The_River <[email protected]>
2026-10-09 02:18:39 +00:00
ekklesiaandClaude Opus 5.5 33bb146349 feat: build Signage on its own from the published Ekklesia 0.2.0 packages
CI / lint (pull_request) Successful in 11s
CI / typecheck (pull_request) Successful in 11s
CI / test (pull_request) Successful in 17s
CI / build (pull_request) Successful in 12s
CI / integration (pull_request) Skipped
Signage no longer needs an Ekklesia checkout. A clean clone installs with
`pnpm install --frozen-lockfile` and runs lint, typecheck, test, build, and validate by itself, taking
Ekklesia's module-development packages from Ekklesia's public registry (ADRs 0053 and 0054).

- Dependencies: every `workspace:*` becomes `^0.2.0`. @ekklesia/module-pack is added as a
  devDependency pinned exactly to 0.2.0, as ADR 0053 requires; it is not invoked yet. No
  @ekklesia/contracts dependency. drizzle-orm stays a direct dependency, satisfying the adapter's
  optional peer.
- .npmrc points the @ekklesia scope at
  https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ with no credential, because
  reading needs none.
- pnpm-workspace.yaml holds this repository's pnpm settings only:
  - `allowBuilds: esbuild: true`, as the platform's standalone fixture does;
  - exact-version minimumReleaseAgeExclude entries for the 0.2.0 train, which pnpm 11 otherwise holds
    back on its release day;
  - the platform's narrowly scoped override for GHSA-67mh-4wv8-2f99 (esbuild via drizzle-kit), which
    Signage inherited while it built inside Ekklesia's workspace.
- engines.node `>=24.11.0 <25` and packageManager `[email protected].0`, both from Ekklesia 0.2.0. Node
  24.18.0 goes in .node-version for CI, as in Ekklesia.
- biome.json carries the platform's Biome configuration, which `biome lint` used to pick up from the
  workspace root, so lint rules are unchanged.
- pnpm-lock.yaml is Signage's own: registry tarballs with integrity hashes, and no workspace or file
  links.
- `prepack` runs this package's own build instead of the workspace root's turbo.
- `pnpm validate` (scripts/validate-module.mjs) checks the built package and its migrations with the
  published SDK parsers only:
  - the manifest, its identity, and its entrypoints;
  - that the built runtime's id, version, and database ABI match the manifest;
  - that the built schema metadata is plain, namespaced, and has module-internal foreign keys;
  - the SDK compatibility range;
  - that the migration journal and policy agree.

  Core's own `modules:validate` and `migrations:validate` are not published. What only an Ekklesia
  installation can check is named in the script.

Removed: ci/assemble.sh, ci/platform.env, ci/pnpm-lock.yaml, ci/update-lock.sh. The lint, typecheck,
test, and build jobs check out only this repository; build also runs `pnpm validate`. The integration
job stays disabled. Its first step is now an explicit placeholder rather than the deleted assembly,
and its explanation says it returns when Signage is a canonical linux-x64-glibc Module Package
installed into Ekklesia's generic image.

No source, behaviour, schema, or version change: Signage stays 0.1.2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 20:49:17 -05:00
The_River fefe34be03 Merge pull request 'feat: move Signage to the Ekklesia 0.2 module contract (manifest v8, database ABI v1)' (#10) from feat/platform-0.2-compat into main
CI / lint (push) Successful in 14s
CI / typecheck (push) Successful in 15s
CI / test (push) Successful in 20s
CI / build (push) Successful in 19s
CI / integration (push) Skipped
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/10
Reviewed-by: The_River <[email protected]>
2026-10-09 01:41:35 +00:00
ekklesiaandClaude Opus 5.5 a6d553efbb feat: move Signage to the Ekklesia 0.2 module contract (manifest v8, database ABI v1)
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 16s
CI / test (pull_request) Successful in 21s
CI / build (pull_request) Successful in 20s
CI / integration (pull_request) Skipped
Signage moves from the legacy module contract to Ekklesia 0.2.0's, with no behaviour or schema
change:

- manifest version 8, `database: { abi: 1 }`, compatibility ^0.2.0 for platform and module SDK;
  the runtime declares the same ABI, as Core requires;
- every store reaches PostgreSQL through the platform's Module Database ABI v1 executor, with
  Signage's own Drizzle tables and the module-side adapter (`moduleDrizzle`) from
  @ekklesia/module-database. createSignageStore takes the executor, so no call site changes;
- the schema entrypoint exports only plain ABI v1 metadata (table names and foreign-key targets),
  derived from tables.ts with moduleDrizzleSchemaMetadata; no Drizzle object reaches Core;
- duplicate-image detection reads SQLSTATE 23505 from the stable Ekklesia database error, found
  with findEkklesiaDatabaseError through Drizzle's DrizzleQueryError wrapping. The old
  `error.code === "23505"` check never matched a wrapped error, so a duplicate-upload race
  surfaced as a 500 instead of the content-hash conflict.

tables.ts declarations, the migration, and its pinned checksum are unchanged.

Tests: an ABI v1 executor double that enforces Core's scope, parameter, and abort rules drives all
13 store transactions, their rollbacks, raw int8/timestamptz/jsonb decoding, and the 23505
regression, including generic and driver-shaped errors that must not count as duplicates.

CI, temporarily: the platform pin moves to v0.2.0 (1788d17) with a regenerated ci/pnpm-lock.yaml.
The integration job is disabled: from 0.2.0 the platform image is generic (ADR 0048) and installs
modules only as Module Packages, which Signage cannot be until its sharp dependency goes through
the native package flow. The job and ci/integration.sh stay, and the job returns in that change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 20:36:11 -05:00
31 changed files with 3882 additions and 4682 deletions
+55 -64
View File
@@ -1,8 +1,9 @@
name: CI name: CI
# Signage builds, tests, and runs only inside an Ekklesia workspace, so every job first assembles it # Signage is a standalone repository: every job checks out only this repository, installs it with
# into the platform commit pinned in ci/platform.env (ci/assemble.sh) and installs that workspace # --frozen-lockfile from its own pnpm-lock.yaml, and takes Ekklesia's module-development packages
# with --frozen-lockfile from ci/pnpm-lock.yaml. # (@ekklesia/*) from Ekklesia's public package registry (.npmrc; platform ADRs 0053 and 0054). No job
# clones Ekklesia.
# #
# The same trust rules as the platform's own CI (docs/development/security.md there): # The same trust rules as the platform's own CI (docs/development/security.md there):
# * every action is pinned by a full commit SHA; # * every action is pinned by a full commit SHA;
@@ -11,10 +12,8 @@ name: CI
# * there is no `pull_request_target`; # * there is no `pull_request_target`;
# * the self-hosted runner executes trusted, reviewed code only. # * the self-hosted runner executes trusted, reviewed code only.
# #
# The server is Gitea 1.24, which ignores `permissions`, `concurrency`, and `timeout-minutes` # The server is Gitea 1.27, which honours `concurrency` cancellation; `permissions` and
# (https://docs.gitea.com/1.24/usage/actions/comparison). They are declared so the workflow is already # `timeout-minutes` are declared so the workflow is correct wherever they are enforced.
# correct on a release that honours them; on 1.24 they bound nothing, and `permissions` does not
# restrict the job token.
on: on:
pull_request: pull_request:
@@ -42,24 +41,21 @@ jobs:
# in .git/config for a later step to read. # in .git/config for a later step to read.
persist-credentials: false persist-credentials: false
- name: Assemble Signage into the pinned platform - name: Set up Node.js at the repository's version
run: bash ci/assemble.sh
- name: Set up Node.js at the platform's version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: build/workspace/.node-version node-version-file: .node-version
- name: Install the assembled workspace (frozen lockfile) # pnpm at exactly the `packageManager` version, and @ekklesia/* from the public registry named in
working-directory: build/workspace # .npmrc: no token, and no Ekklesia checkout.
- name: Install (frozen lockfile)
run: | run: |
corepack enable corepack enable
corepack install corepack install
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
- name: Lint - name: Lint
working-directory: build/workspace run: pnpm lint
run: pnpm --filter @ekklesia/signage lint
- name: Check that CI cleans up every stack it starts - name: Check that CI cleans up every stack it starts
run: bash ci/check-cleanup.sh run: bash ci/check-cleanup.sh
@@ -76,28 +72,21 @@ jobs:
# in .git/config for a later step to read. # in .git/config for a later step to read.
persist-credentials: false persist-credentials: false
- name: Assemble Signage into the pinned platform - name: Set up Node.js at the repository's version
run: bash ci/assemble.sh
- name: Set up Node.js at the platform's version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: build/workspace/.node-version node-version-file: .node-version
- name: Install the assembled workspace (frozen lockfile) # pnpm at exactly the `packageManager` version, and @ekklesia/* from the public registry named in
working-directory: build/workspace # .npmrc: no token, and no Ekklesia checkout.
- name: Install (frozen lockfile)
run: | run: |
corepack enable corepack enable
corepack install corepack install
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
- name: Build the platform packages Signage depends on
working-directory: build/workspace
run: pnpm exec turbo run build --filter='@ekklesia/signage^...'
- name: Typecheck the server, browser, and browser-test projects - name: Typecheck the server, browser, and browser-test projects
working-directory: build/workspace run: pnpm typecheck
run: pnpm --filter @ekklesia/signage typecheck
test: test:
name: test name: test
@@ -111,28 +100,21 @@ jobs:
# in .git/config for a later step to read. # in .git/config for a later step to read.
persist-credentials: false persist-credentials: false
- name: Assemble Signage into the pinned platform - name: Set up Node.js at the repository's version
run: bash ci/assemble.sh
- name: Set up Node.js at the platform's version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: build/workspace/.node-version node-version-file: .node-version
- name: Install the assembled workspace (frozen lockfile) # pnpm at exactly the `packageManager` version, and @ekklesia/* from the public registry named in
working-directory: build/workspace # .npmrc: no token, and no Ekklesia checkout.
- name: Install (frozen lockfile)
run: | run: |
corepack enable corepack enable
corepack install corepack install
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
- name: Build the platform packages Signage depends on
working-directory: build/workspace
run: pnpm exec turbo run build --filter='@ekklesia/signage^...'
- name: Unit, browser, and display contract tests - name: Unit, browser, and display contract tests
working-directory: build/workspace run: pnpm test
run: pnpm --filter @ekklesia/signage test
build: build:
name: build name: build
@@ -146,40 +128,47 @@ jobs:
# in .git/config for a later step to read. # in .git/config for a later step to read.
persist-credentials: false persist-credentials: false
- name: Assemble Signage into the pinned platform - name: Set up Node.js at the repository's version
run: bash ci/assemble.sh
- name: Set up Node.js at the platform's version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: build/workspace/.node-version node-version-file: .node-version
- name: Install the assembled workspace (frozen lockfile) # pnpm at exactly the `packageManager` version, and @ekklesia/* from the public registry named in
working-directory: build/workspace # .npmrc: no token, and no Ekklesia checkout.
- name: Install (frozen lockfile)
run: | run: |
corepack enable corepack enable
corepack install corepack install
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
# The same gates the platform's image build runs against exactly this module set: its boundary - name: Build the server and both browser artifacts
# rules (which apply to modules), the manifest, and the migrations. run: pnpm build
- name: Platform gates (boundaries, module manifest, migrations)
working-directory: build/workspace
run: |
pnpm boundaries
pnpm modules:validate
pnpm migrations:validate
- name: Build Signage and the platform it depends on # The built package and its migrations, read the way the platform reads them, with only the
working-directory: build/workspace # published SDK parsers (scripts/validate-module.mjs says what only Ekklesia itself can check).
run: pnpm exec turbo run build --filter='@ekklesia/signage...' - name: Validate the built module and its migrations
run: pnpm validate
# Signage inside Ekklesia, end to end, in a real browser: the platform's own Dockerfile builds the # Signage inside Ekklesia, end to end, in a real browser: the platform's own Dockerfile builds the
# 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. From Ekklesia 0.2.0 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. Run as it
# was, 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.
#
# It returns when Signage is built as a canonical linux-x64-glibc Module Package and installed into
# Ekklesia's generic image; that change removes `if: false` and rewrites these steps. They still
# expect the platform tree ci/assemble.sh used to build at build/workspace, and that assembly is
# gone now that Signage builds on its own. The platform's deployment smoke and stack sweep will
# still come from Ekklesia, at the release Signage targets, because they are the platform's to run.
integration: integration:
name: integration name: integration
if: false
needs: needs:
- test - test
- build - build
@@ -196,8 +185,10 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Assemble Signage into the pinned platform - name: Fetch Ekklesia's deployment smoke and build Signage's Module Package
run: bash ci/assemble.sh run: |
echo "integration: rewritten when Signage is a canonical linux-x64-glibc Module Package." >&2
exit 1
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its # A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first. # cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
@@ -207,8 +198,8 @@ jobs:
- name: Build the image and run the display scenarios - name: Build the image and run the display scenarios
run: bash ci/integration.sh run: bash ci/integration.sh
# Whatever happened above, this job's own stacks do not outlive it. If assembly failed, nothing # Whatever happened above, this job's own stacks do not outlive it. If the first step failed,
# was started. # nothing was started.
- name: Remove this job's CI stacks - name: Remove this job's CI stacks
if: always() if: always()
run: | run: |
+1 -1
View File
@@ -1,7 +1,7 @@
node_modules/ node_modules/
.pnpm-store/
dist/ dist/
.turbo/ .turbo/
coverage/ coverage/
*.log *.log
.DS_Store .DS_Store
build/
+1
View File
@@ -0,0 +1 @@
24.18.0
+3
View File
@@ -0,0 +1,3 @@
# Ekklesia's module-development packages (@ekklesia/*) come from Ekklesia's public package registry
# (platform ADRs 0053 and 0054). Reading needs no account and no token; never put a credential here.
@ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
+44 -39
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
@@ -51,66 +51,71 @@ Mounting requires an authenticated module descriptor, and Core does not yet cach
cold start depends on a Core-provided authorized offline display descriptor or lease; until that cold start depends on a Core-provided authorized offline display descriptor or lease; until that
exists, this module does not claim it. See ADR 0006. exists, this module does not claim it. See ADR 0006.
## Developing against Ekklesia ## Developing Signage
Signage is developed **inside the authoritative Ekklesia workspace**, checked out at the path it will Signage builds and tests on its own. You need no Ekklesia checkout and no Ekklesia account:
eventually be installed at:
- **Node.js** in the range `engines.node` declares (`>=24.11.0 <25`); `.node-version` names the
version CI uses.
- **pnpm** at the version `packageManager` pins, through Corepack (`corepack enable`).
```bash ```bash
git clone <ekklesia> ekklesia git clone <this repository> signage
git clone <this repository> ekklesia/modules/signage cd signage
cd ekklesia corepack enable
pnpm install pnpm install --frozen-lockfile
``` ```
`modules/*` is an Ekklesia workspace glob, so the checkout is picked up with no registry edit, Ekklesia's module-development packages (`@ekklesia/module-sdk`, `@ekklesia/module-database`,
no second copy of Ekklesia, and no vendored SDK. `@ekklesia/module-sdk`, `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-sdk`, `@ekklesia/ui`, `@ekklesia/design-tokens`, `@ekklesia/module-ui-build`,
`@ekklesia/design-tokens`, `@ekklesia/ui`, and `@ekklesia/module-ui-build` all resolve through the `@ekklesia/module-pack`, `@ekklesia/typescript-config`) come from Ekklesia's public package registry.
workspace as `workspace:*` dependencies. The committed `.npmrc` points the `@ekklesia` scope at it, and reading it needs **no token** and no
login (platform ADRs 0053 and 0054); never add a credential to that file. Everything else comes from
the public npm registry. `package.json` takes the Ekklesia packages as `^0.2.0` ranges, except
`@ekklesia/module-pack`, which is pinned exactly; `pnpm-lock.yaml` pins every package's exact version
and integrity, so always install with `--frozen-lockfile` unless you mean to change a dependency.
Two consequences worth knowing: `pnpm-workspace.yaml` holds this repository's own pnpm settings: it allows esbuild's install script,
exempts exactly the Ekklesia package train in the lockfile from pnpm's minimum release age, and keeps
one narrowly scoped advisory override. It does not make Signage part of any other workspace.
- Ekklesia uses a shared workspace lockfile, so installing here writes an importer entry for Signage requires Ekklesia 0.2: manifest version 8 and the Module Database ABI v1 (platform ADR 0050).
`modules/signage` into Ekklesia's `pnpm-lock.yaml`. That entry belongs to whoever maintains the It reaches PostgreSQL only through the ABI v1 executor the platform hands it, with its own Drizzle
Ekklesia checkout; it is not committed from this repository. tables and the module-side adapter from `@ekklesia/module-database/drizzle`. Core receives SQL text,
- Signage requires the Ekklesia platform prerequisites for shared design tokens, module stylesheets, bound values, and plain schema metadata, never a Drizzle object.
browser contributions, bounded streaming request bodies, and display offline code
(ADRs 0039–0043). An Ekklesia checkout without them cannot build or validate this module.
### Commands ### Commands
Run from the **Ekklesia repository root**:
```bash ```bash
pnpm --filter @ekklesia/signage build # tsc + the canonical module UI build pnpm lint # Biome, with this repository's biome.json
pnpm --filter @ekklesia/signage test # unit and browser-artifact tests pnpm typecheck # node, browser, and browser-test projects
pnpm --filter @ekklesia/signage typecheck # node, browser, and browser-test projects pnpm test # unit, browser-artifact, and display contract tests (vitest.config.ts)
pnpm --filter @ekklesia/signage lint pnpm build # tsc, then the canonical module UI build of the primary and display artifacts
pnpm validate # after build: the built package and its migrations, as the platform reads them
pnpm boundaries # four-layer boundary enforcement
pnpm modules:validate # static manifest, entrypoint, and schema validation
pnpm migrations:validate # migration journal, policy, ordering, and checksums
pnpm verify # the full Docker-free gate
``` ```
Signage's tests are its own: `pnpm test` here runs `vitest` against this package's `vitest.config.ts`, CI runs exactly these, from a clean checkout.
which includes `.test.tsx` as well as `.test.ts`. The workspace-root config includes only `.test.ts`,
so a browser test added here without that config would be silently skipped.
Never run `pnpm migrations:apply` against a persistent database while developing this module. Signage `pnpm validate` uses only the published SDK parsers. It cannot check what only an Ekklesia
reads no environment variable, constructs no database client, configures no object storage, hardcodes installation knows: compatibility with that installation, other installed modules' capabilities and
no Ekklesia URL, opens no socket of its own, and applies no migration: every one of those reaches it dependencies, or a migration's checksum against a database's applied history. Ekklesia checks those
when Signage is installed.
Never run a migration against a persistent database while developing this module. Signage reads no
environment variable, constructs no database client, configures no object storage, hardcodes no
Ekklesia URL, opens no socket of its own, and applies no migration: every one of those reaches it
through the host. through the host.
### Changing the schema ### Changing the schema
```bash ```bash
pnpm --filter @ekklesia/signage migration:generate # drizzle-kit, into drizzle/ pnpm migration:generate # drizzle-kit, into drizzle/
``` ```
`tests/schema.test.ts` pins the checksum of the generated SQL, so a regenerated migration fails the `tests/schema.test.ts` pins the checksum of the generated SQL, so a regenerated migration fails the
suite until the pin is updated deliberately. `drizzle/meta/_ekklesia.json` declares the migration suite until the pin is updated deliberately. `drizzle/meta/_ekklesia.json` declares the migration
non-destructive; `pnpm migrations:validate` enforces that. non-destructive: `tests/schema.test.ts` asserts it, and Ekklesia's own migration validation enforces
it when Signage is packaged and installed.
### Verifying what only a browser can show ### Verifying what only a browser can show
+33
View File
@@ -0,0 +1,33 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
"files": {
"includes": [
"**",
"!**/.pnpm-store",
"!**/.turbo",
"!**/coverage",
"!**/dist",
"!**/node_modules",
"!pnpm-lock.yaml"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all"
}
}
}
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env bash
# Assembles Signage into the Ekklesia platform it is tested against: the platform at the commit pinned
# in ci/platform.env, this checkout at modules/signage, and ci/pnpm-lock.yaml as the workspace
# lockfile. Signage cannot build or run outside an Ekklesia workspace, so every CI job starts here.
#
# bash ci/assemble.sh [--allow-missing-lock] [output directory]
#
# The output directory (default build/workspace) is deleted and recreated. The platform is fetched
# by its `main` branch and the pinned commit must be on it, so CI only ever runs reviewed platform
# code; the checkout is then by commit, so nothing after it depends on a ref the network can move.
# Every .git directory is removed afterwards.
#
# --allow-missing-lock keeps the platform's own lockfile. Only ci/update-lock.sh uses it.
set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
allow_missing_lock=0
if [[ "${1:-}" == "--allow-missing-lock" ]]; then
allow_missing_lock=1
shift
fi
out="${1:-$root/build/workspace}"
fail() {
echo "assemble: $*" >&2
exit 1
}
for command in git tar; do
command -v "$command" >/dev/null || fail "$command is required."
done
# Read the two pins without sourcing the file: it is data, not a script.
pin() {
local value
value="$(sed -n "s/^$1=//p" "$root/ci/platform.env")"
[[ -n "$value" ]] || fail "ci/platform.env has no $1."
printf '%s' "$value"
}
repository="$(pin EKKLESIA_PLATFORM_REPOSITORY)"
commit="$(pin EKKLESIA_PLATFORM_COMMIT)"
[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "EKKLESIA_PLATFORM_COMMIT must be a full commit SHA."
rm -rf "$out"
mkdir -p "$(dirname -- "$out")"
git init --quiet "$out"
git -C "$out" remote add origin "$repository"
git -C "$out" fetch --quiet --no-tags origin "+refs/heads/main:refs/remotes/origin/main" ||
fail "cannot fetch the platform's main from ${repository}."
git -C "$out" cat-file -e "${commit}^{commit}" 2>/dev/null ||
fail "the pinned platform commit ${commit} is not on the platform's main."
git -C "$out" merge-base --is-ancestor "$commit" refs/remotes/origin/main ||
fail "the pinned platform commit ${commit} is not on the platform's main."
git -C "$out" -c advice.detachedHead=false checkout --quiet --detach "$commit"
[[ "$(git -C "$out" rev-parse HEAD)" == "$commit" ]] || fail "the platform did not check out at ${commit}."
rm -rf "$out/.git"
echo "assemble: platform ${commit}"
[[ ! -e "$out/modules/signage" ]] || fail "the platform checkout already has modules/signage."
mkdir -p "$out/modules/signage"
# Exactly the files this checkout tracks, as committed or as changed in the working tree. Nothing
# installed, built, or ignored, and not the CI scaffolding, which is not part of the module.
git -C "$root" ls-files -z --cached --others --exclude-standard -- . ':!ci' ':!.gitea' |
tar -C "$root" --null --files-from=- --create --file=- |
tar -C "$out/modules/signage" --extract --file=-
echo "assemble: signage $(git -C "$root" rev-parse HEAD 2>/dev/null || echo "(working tree)")"
if [[ -f "$root/ci/pnpm-lock.yaml" ]]; then
cp "$root/ci/pnpm-lock.yaml" "$out/pnpm-lock.yaml"
elif ((allow_missing_lock == 0)); then
fail "ci/pnpm-lock.yaml is missing; run bash ci/update-lock.sh."
fi
+6 -2
View File
@@ -5,13 +5,17 @@
# store, generated secrets, and an explicit migration apply against that fresh database only; nothing # store, generated secrets, and an explicit migration apply against that fresh database only; nothing
# is pushed, published, or logged in to. The image is removed afterwards. # is pushed, published, or logged in to. The image is removed afterwards.
# #
# bash ci/integration.sh (after bash ci/assemble.sh) # bash ci/integration.sh
#
# NOT RUN: the integration job is disabled (.gitea/workflows/ci.yml says why). This script still
# expects Ekklesia's deployment tree at build/workspace, which the removed ci/assemble.sh used to
# build; it is rewritten when Signage is installed into the generic image as a Module Package.
set -Eeuo pipefail set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
workspace="$root/build/workspace" workspace="$root/build/workspace"
[[ -f "$workspace/deploy/Dockerfile" ]] || { [[ -f "$workspace/deploy/Dockerfile" ]] || {
echo "integration: run bash ci/assemble.sh first." >&2 echo "integration: Ekklesia's deployment tree is not at build/workspace." >&2
exit 1 exit 1
} }
-6
View File
@@ -1,6 +0,0 @@
# The Ekklesia platform Signage's CI builds and tests against: one reviewed commit on the platform's
# `main`, never a moving ref. ci/assemble.sh refuses a commit that is not on `main`.
#
# 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
-4403
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
# Regenerates ci/pnpm-lock.yaml: the lockfile of Signage assembled into the pinned platform. Run it
# after changing Signage's dependencies or ci/platform.env, and commit the result. CI installs with
# --frozen-lockfile, so a stale lockfile fails there rather than resolving anything new.
#
# bash ci/update-lock.sh
#
# Uses the platform's own pinned pnpm (its packageManager field) through Corepack.
set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
bash "$root/ci/assemble.sh" --allow-missing-lock "$work/workspace"
cd "$work/workspace"
corepack enable >/dev/null 2>&1 || true
corepack install >/dev/null
pnpm install --lockfile-only
cp pnpm-lock.yaml "$root/ci/pnpm-lock.yaml"
echo "update-lock: wrote ci/pnpm-lock.yaml"
+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": [
{ {
+15 -8
View File
@@ -3,6 +3,10 @@
"version": "0.1.2", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "[email protected]",
"engines": {
"node": ">=24.11.0 <25"
},
"description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.", "description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.",
"files": [ "files": [
"dist", "dist",
@@ -23,15 +27,17 @@
"build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui", "build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui",
"lint": "biome lint .", "lint": "biome lint .",
"migration:generate": "drizzle-kit generate --config=drizzle.config.ts", "migration:generate": "drizzle-kit generate --config=drizzle.config.ts",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/signage", "prepack": "pnpm run build",
"test": "vitest run", "test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json && tsc --noEmit --project tsconfig.ui-test.json" "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json && tsc --noEmit --project tsconfig.ui-test.json",
"validate": "node scripts/validate-module.mjs"
}, },
"dependencies": { "dependencies": {
"@ekklesia/design-tokens": "workspace:*", "@ekklesia/design-tokens": "^0.2.0",
"@ekklesia/module-sdk": "workspace:*", "@ekklesia/module-database": "^0.2.0",
"@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/module-sdk": "^0.2.0",
"@ekklesia/ui": "workspace:*", "@ekklesia/module-ui-sdk": "^0.2.0",
"@ekklesia/ui": "^0.2.0",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
@@ -39,8 +45,9 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-build": "workspace:*", "@ekklesia/module-pack": "0.2.0",
"@ekklesia/typescript-config": "workspace:*", "@ekklesia/module-ui-build": "^0.2.0",
"@ekklesia/typescript-config": "^0.2.0",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/react": "19.2.18", "@types/react": "19.2.18",
"@types/react-dom": "19.2.4", "@types/react-dom": "19.2.4",
+2556
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
# This repository's own pnpm settings. Signage is a single standalone package, not part of any other
# workspace; pnpm reads its settings from this file.
# esbuild, which the module UI build and the module packager run, has an install script. pnpm runs
# none it was not told to.
allowBuilds:
esbuild: true
# pnpm holds back any package younger than its minimum release age, and refuses a lockfile that names
# one. A new Ekklesia package train is that young on its release day. These exact versions, and only
# these, are the train this lockfile pins (published by Ekklesia's release workflow, ADR 0053), so
# they are exempt by version; a train bump replaces them, and everything else keeps the policy.
minimumReleaseAgeExclude:
- '@ekklesia/[email protected]'
- '@ekklesia/[email protected]'
- '@ekklesia/[email protected]'
- '@ekklesia/[email protected]'
- '@ekklesia/[email protected]'
- '@ekklesia/[email protected]'
- '@ekklesia/[email protected]'
- '@ekklesia/[email protected]'
- '@ekklesia/[email protected]'
# The narrowly scoped advisory remediation Signage had while it was built inside Ekklesia's workspace,
# kept now that it is not. GHSA-67mh-4wv8-2f99 (moderate): the vulnerable esbuild 0.18.20 is reached
# only through the deprecated @esbuild-kit/core-utils (drizzle-kit -> @esbuild-kit/esm-loader), whose
# pinned range predates the fix. Scoped to that one edge, exactly as Ekklesia's own workspace does,
# so drizzle-kit's direct esbuild and Vite's are not dragged to another version.
overrides:
"@esbuild-kit/core-utils>esbuild": "0.25.12"
+145
View File
@@ -0,0 +1,145 @@
// Validates the built Signage package and its migrations the way the platform reads them, with only
// Ekklesia's published module-development packages: no Ekklesia checkout, no database.
//
// pnpm build && pnpm validate
//
// What it checks:
// - the manifest parses, and its id and version match package.json and the built runtime;
// - every declared entrypoint exists in the built package;
// - the built runtime declares the same Module Database ABI as the manifest (Core refuses a
// mismatch);
// - the built schema entrypoint's only export is plain ABI v1 metadata for this module's own,
// namespaced tables, and every foreign key targets one of them;
// - the installed module SDK satisfies the manifest's `compatibility.moduleSdk`;
// - the migration journal and policy parse, name the same migrations in the same order, and every
// migration's SQL is present.
//
// What it cannot check, because only Ekklesia itself can: compatibility with a particular platform
// installation, capability and module dependencies between installed modules, and a migration's
// checksum against a database's applied history. The module packager and the platform's package
// verifier re-check all of the above when Signage is built as a Module Package.
import { readFile, stat } from "node:fs/promises";
import { fileURLToPath, pathToFileURL } from "node:url";
import { defineModuleSchemaMetadata, moduleTablePrefix } from "@ekklesia/module-database";
import {
parseDrizzleMigrationJournal,
parseModuleManifest,
parseModuleMigrationPolicy,
} from "@ekklesia/module-sdk";
const root = fileURLToPath(new URL("..", import.meta.url));
const failures = [];
const passes = [];
function check(condition, description) {
(condition ? passes : failures).push(description);
}
async function json(relative) {
return JSON.parse(await readFile(new URL(relative, pathToFileURL(root)), "utf8"));
}
async function exists(relative) {
try {
return (await stat(new URL(relative, pathToFileURL(root)))).isFile();
} catch {
return false;
}
}
/** A caret range, `^X.Y.Z`, as the platform's manifests write them. Anything else is refused here
* rather than half understood. */
function satisfiesCaret(version, range) {
const wanted = /^\^(\d+)\.(\d+)\.(\d+)$/u.exec(range);
const actual = /^(\d+)\.(\d+)\.(\d+)$/u.exec(version);
if (!wanted || !actual) {
return false;
}
const [w, a] = [wanted.slice(1).map(Number), actual.slice(1).map(Number)];
if (w[0] !== a[0] || (w[0] === 0 && w[1] !== a[1])) {
return false;
}
for (let index = 0; index < 3; index += 1) {
if (a[index] !== w[index]) {
return a[index] > w[index];
}
}
return true;
}
const packageJson = await json("package.json");
const manifest = parseModuleManifest(await json("ekklesia.module.json"));
check(true, "ekklesia.module.json is a valid manifest");
check(packageJson.version === manifest.version, "package.json and the manifest name one version");
check(packageJson.type === "module", "the package is ESM");
check(packageJson.ekklesia?.layer === "module", "the package carries the module layer marker");
const { server, schema, migrations, ui, display } = manifest.entrypoints;
for (const entrypoint of [server, schema, ui, display?.artifact].filter(Boolean)) {
check(await exists(entrypoint), `the built package has ${entrypoint}`);
}
const runtime = (await import(new URL(server, pathToFileURL(root)).href)).default;
check(runtime?.id === manifest.id, "the built runtime's id is the manifest's");
check(runtime?.version === manifest.version, "the built runtime's version is the manifest's");
check(
JSON.stringify(runtime?.database ?? null) === JSON.stringify(manifest.database ?? null),
"the built runtime declares the manifest's database ABI",
);
if (schema) {
const namespace = await import(new URL(schema, pathToFileURL(root)).href);
check(
Object.keys(namespace).join() === "default",
"the schema entrypoint exports nothing but its metadata",
);
const metadata = defineModuleSchemaMetadata(namespace.default);
check(
JSON.stringify(JSON.parse(JSON.stringify(metadata))) === JSON.stringify(metadata),
"the schema metadata is plain data",
);
check(metadata.moduleId === manifest.id, "the schema metadata is this module's");
const prefix = moduleTablePrefix(manifest.id);
const owned = new Set(metadata.tables.map((table) => table.name));
check(
metadata.tables.every((table) => table.name.startsWith(prefix)),
`every table is in the ${prefix} namespace`,
);
check(
metadata.tables.every((table) => table.foreignKeys.every((key) => owned.has(key.table))),
"every foreign key targets one of this module's own tables",
);
}
const sdkVersion = (await json("node_modules/@ekklesia/module-sdk/package.json")).version;
check(
satisfiesCaret(sdkVersion, manifest.compatibility.moduleSdk),
`the installed module SDK ${sdkVersion} satisfies compatibility.moduleSdk ${manifest.compatibility.moduleSdk}`,
);
if (migrations) {
const directory = migrations.endsWith("/") ? migrations : `${migrations}/`;
const journal = parseDrizzleMigrationJournal(await json(`${directory}meta/_journal.json`));
const policy = parseModuleMigrationPolicy(await json(`${directory}meta/_ekklesia.json`));
check(policy.moduleId === manifest.id, "the migration policy is this module's");
check(
journal.entries.map((entry) => entry.tag).join() ===
policy.entries.map((entry) => entry.tag).join(),
"the migration journal and policy name the same migrations in the same order",
);
for (const entry of journal.entries) {
check(await exists(`${directory}${entry.tag}.sql`), `${entry.tag}.sql is present`);
}
}
for (const description of passes) {
console.log(`ok ${description}`);
}
for (const description of failures) {
console.log(`FAIL ${description}`);
}
if (failures.length > 0) {
console.error(`validate: ${failures.length} check(s) failed.`);
process.exit(1);
}
console.log(`validate: ${manifest.id}@${manifest.version} passed ${passes.length} checks.`);
+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 };
}
+3 -3
View File
@@ -3,9 +3,9 @@ import { defineConfig } from "vitest/config";
/** /**
* Signage's own test configuration. * Signage's own test configuration.
* *
* It exists because this module's browser tests are `.tsx` — they mount real React — and the * It names both `.test.ts` and `.test.tsx` explicitly, because this module's browser tests are `.tsx`
* repository root's configuration includes only `.test.ts`. Without this file those tests are * — they mount real React — and a test that is silently not run is the worst possible failure mode
* silently not run, which is the worst possible failure mode for a test suite. * for a test suite. An empty run fails rather than passes.
*/ */
export default defineConfig({ export default defineConfig({
test: { test: {