Compare commits
4
Commits
v0.1.2
..
e7f7404d35
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7f7404d35 | ||
|
|
33bb146349 | ||
|
|
fefe34be03 | ||
|
|
a6d553efbb |
+55
-64
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
dist/
|
dist/
|
||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
build/
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
24.18.0
|
||||||
@@ -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/
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
Generated
-4403
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+2556
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
@@ -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.`);
|
||||||
+9
-22
@@ -1,4 +1,4 @@
|
|||||||
import { defineModuleSchema } from "@ekklesia/module-sdk";
|
import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";
|
||||||
import {
|
import {
|
||||||
channelGroups,
|
channelGroups,
|
||||||
channels,
|
channels,
|
||||||
@@ -13,28 +13,16 @@ import {
|
|||||||
viewers,
|
viewers,
|
||||||
} from "./tables.js";
|
} from "./tables.js";
|
||||||
|
|
||||||
export {
|
|
||||||
channelGroups,
|
|
||||||
channels,
|
|
||||||
imageAssets,
|
|
||||||
imageRenditions,
|
|
||||||
playbackStates,
|
|
||||||
providerSources,
|
|
||||||
selections,
|
|
||||||
selectionVariants,
|
|
||||||
timelineSlots,
|
|
||||||
timelines,
|
|
||||||
viewers,
|
|
||||||
} from "./tables.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signage's compiled schema entrypoint: the same table objects `tables.ts` declares, wrapped with the
|
* Signage's compiled schema entrypoint: the plain Module Database ABI v1 metadata Core validates
|
||||||
* ownership marker Core validates during discovery. Side-effect free by contract — importing this
|
* table ownership from (platform ADR 0050) — each table's physical name and the tables its foreign
|
||||||
* connects to nothing and reads nothing.
|
* 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 defineModuleSchema({
|
export default moduleDrizzleSchemaMetadata("signage", {
|
||||||
moduleId: "signage",
|
|
||||||
tables: {
|
|
||||||
channelGroups,
|
channelGroups,
|
||||||
channels,
|
channels,
|
||||||
imageAssets,
|
imageAssets,
|
||||||
@@ -46,5 +34,4 @@ export default defineModuleSchema({
|
|||||||
timelines,
|
timelines,
|
||||||
timelineSlots,
|
timelineSlots,
|
||||||
viewers,
|
viewers,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|
||||||
|
|||||||
@@ -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[]>> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user