feat: move Signage to the Ekklesia 0.2 module contract (manifest v8, database ABI v1)
Signage moves from the legacy module contract to Ekklesia 0.2.0's, with no behaviour or schema
change:
- manifest version 8, `database: { abi: 1 }`, compatibility ^0.2.0 for platform and module SDK;
the runtime declares the same ABI, as Core requires;
- every store reaches PostgreSQL through the platform's Module Database ABI v1 executor, with
Signage's own Drizzle tables and the module-side adapter (`moduleDrizzle`) from
@ekklesia/module-database. createSignageStore takes the executor, so no call site changes;
- the schema entrypoint exports only plain ABI v1 metadata (table names and foreign-key targets),
derived from tables.ts with moduleDrizzleSchemaMetadata; no Drizzle object reaches Core;
- duplicate-image detection reads SQLSTATE 23505 from the stable Ekklesia database error, found
with findEkklesiaDatabaseError through Drizzle's DrizzleQueryError wrapping. The old
`error.code === "23505"` check never matched a wrapped error, so a duplicate-upload race
surfaced as a 500 instead of the content-hash conflict.
tables.ts declarations, the migration, and its pinned checksum are unchanged.
Tests: an ABI v1 executor double that enforces Core's scope, parameter, and abort rules drives all
13 store transactions, their rollbacks, raw int8/timestamptz/jsonb decoding, and the 23505
regression, including generic and driver-shaped errors that must not count as duplicates.
CI, temporarily: the platform pin moves to v0.2.0 (1788d17) with a regenerated ci/pnpm-lock.yaml.
The integration job is disabled: from 0.2.0 the platform image is generic (ADR 0048) and installs
modules only as Module Packages, which Signage cannot be until its sharp dependency goes through
the native package flow. The job and ci/integration.sh stay, and the job returns in that change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
86ae907f59
commit
a6d553efbb
@@ -178,8 +178,19 @@ jobs:
|
||||
# deployment image with Signage in it, and the platform's split-topology deployment smoke runs
|
||||
# against it, pairing a kiosk display and running the scenarios in ci/display-scenarios. A
|
||||
# disposable database and object store with generated secrets; nothing is pushed or logged in to.
|
||||
#
|
||||
# DISABLED while Signage is pinned to Ekklesia 0.2.0. From that release on, the platform's image is
|
||||
# the one generic image (platform ADR 0048): deploy/Dockerfile keeps `modules/` out of its build
|
||||
# context and refuses one that has it, and the deployment smoke installs modules only as canonical
|
||||
# Module Packages (ADR 0049). Signage is not one yet: its static sharp dependency needs the native
|
||||
# linux-x64-glibc package flow, which is deliberately not part of the 0.2.0 compatibility change.
|
||||
# Run against 0.2.0, this job would build an image without Signage and drive the module template's
|
||||
# display instead, so it is switched off rather than left reporting on something else. The job and
|
||||
# ci/integration.sh stay as they are; the job returns once Signage is built as a Module Package and
|
||||
# installed into the generic image, and `if: false` is removed in that change.
|
||||
integration:
|
||||
name: integration
|
||||
if: false
|
||||
needs:
|
||||
- test
|
||||
- build
|
||||
|
||||
@@ -12,14 +12,14 @@ Ekklesia and vendors none of its SDKs.
|
||||
|
||||
- Module id: `signage`
|
||||
- Package: `@ekklesia/signage`
|
||||
- Manifest version: 7
|
||||
- Manifest version: 8, with the Module Database ABI v1 (`database: { abi: 1 }`)
|
||||
|
||||
## Status
|
||||
|
||||
Version 0.1.2 is feature-complete for its declared scope, and Signage has run in production since
|
||||
0.1.1. What exists today:
|
||||
|
||||
- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
|
||||
- The module package, manifest (v8), four capabilities, six permissions, ten operator settings, and
|
||||
one logical storage bucket.
|
||||
- Eleven tables and a single initial migration, declared non-destructive.
|
||||
- Native image ingest: one image per streamed request, 25 MiB, content-addressed, with display and
|
||||
@@ -64,18 +64,22 @@ pnpm install
|
||||
```
|
||||
|
||||
`modules/*` is an Ekklesia workspace glob, so the checkout is picked up with no registry edit,
|
||||
no second copy of Ekklesia, and no vendored SDK. `@ekklesia/module-sdk`, `@ekklesia/module-ui-sdk`,
|
||||
`@ekklesia/design-tokens`, `@ekklesia/ui`, and `@ekklesia/module-ui-build` all resolve through the
|
||||
workspace as `workspace:*` dependencies.
|
||||
no second copy of Ekklesia, and no vendored SDK. `@ekklesia/module-sdk`, `@ekklesia/module-database`,
|
||||
`@ekklesia/module-ui-sdk`, `@ekklesia/design-tokens`, `@ekklesia/ui`, and `@ekklesia/module-ui-build`
|
||||
all resolve through the workspace as `workspace:*` dependencies.
|
||||
|
||||
Two consequences worth knowing:
|
||||
|
||||
- Ekklesia uses a shared workspace lockfile, so installing here writes an importer entry for
|
||||
`modules/signage` into Ekklesia's `pnpm-lock.yaml`. That entry belongs to whoever maintains the
|
||||
Ekklesia checkout; it is not committed from this repository.
|
||||
- Signage requires the Ekklesia platform prerequisites for shared design tokens, module stylesheets,
|
||||
browser contributions, bounded streaming request bodies, and display offline code
|
||||
(ADRs 0039–0043). An Ekklesia checkout without them cannot build or validate this module.
|
||||
- Signage requires Ekklesia 0.2: manifest version 8 and the Module Database ABI v1 (platform ADR
|
||||
0050), on top of the prerequisites for shared design tokens, module stylesheets, browser
|
||||
contributions, bounded streaming request bodies, and display offline code (ADRs 0039–0043). An
|
||||
older Ekklesia checkout cannot build or validate this module.
|
||||
- Signage reaches PostgreSQL only through the ABI v1 executor the platform hands it, with its own
|
||||
Drizzle tables and the module-side adapter from `@ekklesia/module-database/drizzle`. Core receives
|
||||
SQL text, bound values, and plain schema metadata, never a Drizzle object.
|
||||
|
||||
### Commands
|
||||
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
#
|
||||
# Move it deliberately: change the commit, run `bash ci/update-lock.sh`, and commit both.
|
||||
EKKLESIA_PLATFORM_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git
|
||||
EKKLESIA_PLATFORM_COMMIT=874deabea3468eb5c3e907f0821718b077a682c7
|
||||
EKKLESIA_PLATFORM_COMMIT=1788d17f0ab618ebc41550728da865d14cdd7a75
|
||||
|
||||
Generated
+109
-9
@@ -152,6 +152,9 @@ importers:
|
||||
'@ekklesia/design-tokens':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/design-tokens
|
||||
'@ekklesia/module-database':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/module-database
|
||||
'@ekklesia/module-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/module-sdk
|
||||
@@ -207,12 +210,12 @@ importers:
|
||||
|
||||
packages/contracts:
|
||||
dependencies:
|
||||
drizzle-orm:
|
||||
specifier: 0.45.2
|
||||
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
||||
semver:
|
||||
specifier: 7.8.5
|
||||
version: 7.8.5
|
||||
spdx-expression-parse:
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0
|
||||
zod:
|
||||
specifier: 4.4.3
|
||||
version: 4.4.3
|
||||
@@ -226,6 +229,9 @@ importers:
|
||||
'@types/semver':
|
||||
specifier: 7.7.1
|
||||
version: 7.7.1
|
||||
'@types/spdx-expression-parse':
|
||||
specifier: 4.0.0
|
||||
version: 4.0.0
|
||||
typescript:
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
@@ -254,14 +260,82 @@ importers:
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
|
||||
packages/module-database:
|
||||
dependencies:
|
||||
'@ekklesia/contracts':
|
||||
specifier: workspace:*
|
||||
version: link:../contracts
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.3
|
||||
version: 2.5.3
|
||||
'@types/node':
|
||||
specifier: 24.13.3
|
||||
version: 24.13.3
|
||||
drizzle-orm:
|
||||
specifier: 0.45.2
|
||||
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
||||
typescript:
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
vitest:
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
|
||||
packages/module-pack:
|
||||
dependencies:
|
||||
'@ekklesia/contracts':
|
||||
specifier: workspace:*
|
||||
version: link:../contracts
|
||||
'@ekklesia/module-ui-build':
|
||||
specifier: workspace:*
|
||||
version: link:../module-ui-build
|
||||
esbuild:
|
||||
specifier: 0.28.1
|
||||
version: 0.28.1
|
||||
semver:
|
||||
specifier: 7.8.5
|
||||
version: 7.8.5
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.3
|
||||
version: 2.5.3
|
||||
'@ekklesia/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../core
|
||||
'@ekklesia/module-database':
|
||||
specifier: workspace:*
|
||||
version: link:../module-database
|
||||
'@ekklesia/module-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../module-sdk
|
||||
'@ekklesia/module-template':
|
||||
specifier: workspace:*
|
||||
version: link:../../templates/module
|
||||
'@types/node':
|
||||
specifier: 24.13.3
|
||||
version: 24.13.3
|
||||
'@types/semver':
|
||||
specifier: 7.7.1
|
||||
version: 7.7.1
|
||||
drizzle-orm:
|
||||
specifier: 0.45.2
|
||||
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
||||
sharp:
|
||||
specifier: 0.35.4
|
||||
version: 0.35.4(@types/[email protected])
|
||||
typescript:
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
vitest:
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
|
||||
packages/module-sdk:
|
||||
dependencies:
|
||||
'@ekklesia/contracts':
|
||||
specifier: workspace:*
|
||||
version: link:../contracts
|
||||
drizzle-orm:
|
||||
specifier: 0.45.2
|
||||
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.3
|
||||
@@ -345,9 +419,6 @@ importers:
|
||||
'@ekklesia/design-tokens':
|
||||
specifier: workspace:*
|
||||
version: link:../design-tokens
|
||||
react:
|
||||
specifier: 19.2.8
|
||||
version: 19.2.8
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.3
|
||||
@@ -367,6 +438,9 @@ importers:
|
||||
happy-dom:
|
||||
specifier: 20.0.11
|
||||
version: 20.0.11
|
||||
react:
|
||||
specifier: 19.2.8
|
||||
version: 19.2.8
|
||||
react-dom:
|
||||
specifier: 19.2.8
|
||||
version: 19.2.8([email protected])
|
||||
@@ -431,6 +505,9 @@ importers:
|
||||
'@ekklesia/design-tokens':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/design-tokens
|
||||
'@ekklesia/module-database':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/module-database
|
||||
'@ekklesia/module-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/module-sdk
|
||||
@@ -1609,6 +1686,9 @@ packages:
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||
|
||||
@@ -2417,6 +2497,15 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
@@ -3597,6 +3686,8 @@ snapshots:
|
||||
'@types/node': 24.13.3
|
||||
'@types/nodemailer': 8.0.1
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@typescript/[email protected]':
|
||||
@@ -4267,6 +4358,15 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
spdx-exceptions: 2.5.0
|
||||
spdx-license-ids: 3.0.24
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"manifestVersion": 7,
|
||||
"manifestVersion": 8,
|
||||
"id": "signage",
|
||||
"name": "Signage",
|
||||
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
|
||||
"version": "0.1.2",
|
||||
"compatibility": {
|
||||
"platform": "^0.1.0",
|
||||
"moduleSdk": "^0.1.0"
|
||||
"platform": "^0.2.0",
|
||||
"moduleSdk": "^0.2.0"
|
||||
},
|
||||
"database": {
|
||||
"abi": 1
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ekklesia/design-tokens": "workspace:*",
|
||||
"@ekklesia/module-database": "workspace:*",
|
||||
"@ekklesia/module-sdk": "workspace:*",
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"@ekklesia/ui": "workspace:*",
|
||||
|
||||
+12
-25
@@ -1,4 +1,4 @@
|
||||
import { defineModuleSchema } from "@ekklesia/module-sdk";
|
||||
import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";
|
||||
import {
|
||||
channelGroups,
|
||||
channels,
|
||||
@@ -13,7 +13,16 @@ import {
|
||||
viewers,
|
||||
} from "./tables.js";
|
||||
|
||||
export {
|
||||
/**
|
||||
* Signage's compiled schema entrypoint: the plain Module Database ABI v1 metadata Core validates
|
||||
* table ownership from (platform ADR 0050) — each table's physical name and the tables its foreign
|
||||
* keys target, as strings and arrays.
|
||||
*
|
||||
* Derived on this side from the same Drizzle tables `tables.ts` declares, so it cannot drift from
|
||||
* them, and deliberately the **only** export: Core never receives a Drizzle object from Signage.
|
||||
* Side-effect free by contract — importing this connects to nothing and reads nothing.
|
||||
*/
|
||||
export default moduleDrizzleSchemaMetadata("signage", {
|
||||
channelGroups,
|
||||
channels,
|
||||
imageAssets,
|
||||
@@ -22,29 +31,7 @@ export {
|
||||
providerSources,
|
||||
selections,
|
||||
selectionVariants,
|
||||
timelineSlots,
|
||||
timelines,
|
||||
timelineSlots,
|
||||
viewers,
|
||||
} from "./tables.js";
|
||||
|
||||
/**
|
||||
* Signage's compiled schema entrypoint: the same table objects `tables.ts` declares, wrapped with the
|
||||
* ownership marker Core validates during discovery. Side-effect free by contract — importing this
|
||||
* connects to nothing and reads nothing.
|
||||
*/
|
||||
export default defineModuleSchema({
|
||||
moduleId: "signage",
|
||||
tables: {
|
||||
channelGroups,
|
||||
channels,
|
||||
imageAssets,
|
||||
imageRenditions,
|
||||
playbackStates,
|
||||
providerSources,
|
||||
selections,
|
||||
selectionVariants,
|
||||
timelines,
|
||||
timelineSlots,
|
||||
viewers,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
* Signage's own PostgreSQL tables.
|
||||
*
|
||||
* Kept to **pure Drizzle declarations** so the stable Drizzle Kit generator can load this file
|
||||
* without executing the ESM Module SDK wrapper; `./index.ts` wraps these same objects with the
|
||||
* ownership marker Core validates. The required `signage_` namespace is applied once here, and Core
|
||||
* validates it independently of this helper.
|
||||
* without executing any Ekklesia package; `./index.ts` derives the plain schema metadata Core
|
||||
* validates from these same objects, and only Signage's own store code ever uses them as Drizzle
|
||||
* tables. The required `signage_` namespace is applied once here, and Core validates it
|
||||
* independently of this helper.
|
||||
*
|
||||
* Two rules hold everywhere below, and both are architecture rather than style:
|
||||
*
|
||||
|
||||
@@ -89,7 +89,7 @@ export function createSignageModuleRuntime(
|
||||
|
||||
function requireStore(): ReturnType<typeof createSignageStore> {
|
||||
if (!store) {
|
||||
throw new Error("Signage's database facade is only available after start.");
|
||||
throw new Error("Signage's store is only available after start.");
|
||||
}
|
||||
return store;
|
||||
}
|
||||
@@ -126,6 +126,9 @@ export function createSignageModuleRuntime(
|
||||
}
|
||||
|
||||
const definition = defineModuleRuntime({
|
||||
// Matches `database.abi` in the manifest: `context.services.database` is this module's Module
|
||||
// Database ABI v1 executor, which `createSignageStore` binds Signage's own Drizzle adapter to.
|
||||
database: { abi: 1 },
|
||||
id: "signage",
|
||||
version: "0.1.2",
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ModuleDatabase } from "@ekklesia/module-sdk";
|
||||
import type { ModuleDrizzleDatabase } from "@ekklesia/module-database/drizzle";
|
||||
import { and, asc, desc, eq, inArray, lt, sql } from "drizzle-orm";
|
||||
import {
|
||||
channelGroups,
|
||||
@@ -94,7 +94,7 @@ function toSelection(
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackStore {
|
||||
export function createPlaybackStore(database: ModuleDrizzleDatabase): SignagePlaybackStore {
|
||||
async function variantsFor(
|
||||
selectionIds: readonly string[],
|
||||
): Promise<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 { playbackStates, providerSources, timelineSlots, timelines } from "../../schema/tables.js";
|
||||
import {
|
||||
@@ -108,7 +111,7 @@ function slotValues(slot: NewStoredSlot, position: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export function createTimelineStore(database: ModuleDatabase): SignageTimelineStore {
|
||||
export function createTimelineStore(database: ModuleDrizzleDatabase): SignageTimelineStore {
|
||||
/**
|
||||
* Rewrites `position` for one timeline from an explicit order.
|
||||
*
|
||||
@@ -118,7 +121,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
|
||||
* remaining inside the database's non-negative constraint.
|
||||
*/
|
||||
async function renumber(
|
||||
transaction: ModuleDatabase,
|
||||
transaction: ModuleDrizzleTransaction,
|
||||
timelineId: string,
|
||||
orderedSlotIds: readonly string[],
|
||||
): Promise<void> {
|
||||
@@ -138,7 +141,10 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
|
||||
await bumpRevision(transaction, timelineId);
|
||||
}
|
||||
|
||||
async function bumpRevision(transaction: ModuleDatabase, timelineId: string): Promise<void> {
|
||||
async function bumpRevision(
|
||||
transaction: ModuleDrizzleTransaction,
|
||||
timelineId: string,
|
||||
): Promise<void> {
|
||||
await transaction
|
||||
.update(timelines)
|
||||
.set({ revision: sql`${timelines.revision} + 1`, updatedAt: new Date() })
|
||||
@@ -146,7 +152,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
|
||||
}
|
||||
|
||||
async function orderedSlotIdsFor(
|
||||
transaction: ModuleDatabase,
|
||||
transaction: ModuleDrizzleTransaction,
|
||||
timelineId: string,
|
||||
): Promise<string[]> {
|
||||
const rows = await transaction
|
||||
@@ -158,7 +164,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
|
||||
}
|
||||
|
||||
async function timelineIdForSlot(
|
||||
transaction: ModuleDatabase,
|
||||
transaction: ModuleDrizzleTransaction,
|
||||
slotId: string,
|
||||
): Promise<string | undefined> {
|
||||
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 { viewers } from "../../schema/tables.js";
|
||||
import type { NewStoredViewer, SignageViewerStore, StoredViewer, ViewerChanges } from "./types.js";
|
||||
@@ -29,7 +29,7 @@ function toViewer(row: typeof viewers.$inferSelect): StoredViewer {
|
||||
};
|
||||
}
|
||||
|
||||
export function createViewerStore(database: ModuleDatabase): SignageViewerStore {
|
||||
export function createViewerStore(database: ModuleDrizzleDatabase): SignageViewerStore {
|
||||
return {
|
||||
async deleteViewer(viewerId: string): Promise<void> {
|
||||
await database.delete(viewers).where(eq(viewers.id, viewerId));
|
||||
|
||||
+17
-11
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ModuleDatabase } from "@ekklesia/module-sdk";
|
||||
import { findEkklesiaDatabaseError, type ModuleDatabaseExecutor } from "@ekklesia/module-database";
|
||||
import { type ModuleDrizzleDatabase, moduleDrizzle } from "@ekklesia/module-database/drizzle";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
imageAssets,
|
||||
@@ -22,14 +23,16 @@ import {
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* The production implementation of Signage's data port, over the platform's `ModuleDatabase`.
|
||||
* The production implementation of Signage's data port, over the platform's Module Database ABI v1
|
||||
* executor (platform ADR 0050) through the module-side Drizzle adapter.
|
||||
*
|
||||
* This file is deliberately dull: it maps rows and nothing else. Every decision — what to write,
|
||||
* when a revision changes, what a no-op is — lives in the services above it, so that behaviour can
|
||||
* be proven without PostgreSQL and this mapping can be proven against a live one.
|
||||
*
|
||||
* The facade is the only way Signage reaches PostgreSQL. Nothing here constructs a client, reads a
|
||||
* connection string, or touches another module's table.
|
||||
* The executor is the only way Signage reaches PostgreSQL. Drizzle compiles every query here, on
|
||||
* Signage's side; Core receives SQL text and bound values and nothing else. Nothing here constructs a
|
||||
* client, reads a connection string, or touches another module's table.
|
||||
*/
|
||||
|
||||
const assetColumns = {
|
||||
@@ -66,12 +69,11 @@ const renditionColumns = {
|
||||
width: imageRenditions.width,
|
||||
};
|
||||
|
||||
/** PostgreSQL reports a uniqueness violation as SQLSTATE `23505`. The code is all that is read; the
|
||||
* driver's message, detail, and constraint text are never surfaced. */
|
||||
/** PostgreSQL reports a uniqueness violation as SQLSTATE `23505`. Drizzle wraps whatever the
|
||||
* executor throws, so the stable Ekklesia database error is found through that wrapping; its SQLSTATE
|
||||
* is all that is read, and no driver error shape is ever relied on. */
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505"
|
||||
);
|
||||
return findEkklesiaDatabaseError(error)?.sqlState === "23505";
|
||||
}
|
||||
|
||||
function toRendition(row: {
|
||||
@@ -116,7 +118,7 @@ function toImage(row: AssetRow, renditions: readonly StoredImageRendition[]): St
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageStore(database: ModuleDatabase): SignageImageStore {
|
||||
export function createImageStore(database: ModuleDrizzleDatabase): SignageImageStore {
|
||||
async function renditionsFor(
|
||||
assetIds: readonly string[],
|
||||
): Promise<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
|
||||
* handle, and keeping them separate is what lets each be read on its own.
|
||||
*
|
||||
* Takes the ABI executor the platform hands a route handler or lifecycle hook, and binds Signage's
|
||||
* own Drizzle adapter to it here, once, so every caller reaches its tables the same way.
|
||||
*/
|
||||
export function createSignageStore(database: ModuleDatabase): SignageStore {
|
||||
export function createSignageStore(executor: ModuleDatabaseExecutor): SignageStore {
|
||||
const database = moduleDrizzle(executor);
|
||||
return {
|
||||
...createImageStore(database),
|
||||
...createTimelineStore(database),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Every piece of Signage's behaviour — ingest, timeline editing, provider resolution, authoritative
|
||||
* playback, viewer health — is written against this interface rather than against Drizzle directly.
|
||||
* There are two implementations: the production one over the platform's `ModuleDatabase`
|
||||
* There are two implementations: the production one over the platform's database ABI executor
|
||||
* (`./drizzle.ts`), and an in-memory one used by tests.
|
||||
*
|
||||
* This is the same discipline Core's own migration runner follows, and it exists for the same
|
||||
|
||||
@@ -263,7 +263,9 @@ describe("what this release deliberately does not contain", () => {
|
||||
]) {
|
||||
expect(names).not.toContain(forbidden);
|
||||
}
|
||||
// The database is reached only through the platform's facade; there is no client of Signage's own.
|
||||
// The database is reached only through the platform's ABI executor, with Signage's own Drizzle
|
||||
// adapter compiling the queries; there is no client of Signage's own.
|
||||
expect(names).toContain("drizzle-orm");
|
||||
expect(names).toContain("@ekklesia/module-database");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { createTimelineStore } from "../src/server/store/drizzle-timelines.js";
|
||||
import type { NewStoredSlot } from "../src/server/store/types.js";
|
||||
@@ -36,7 +36,7 @@ function slot(id: string): NewStoredSlot {
|
||||
* actual database rejected a native-image slot before playback ever had content to resolve.
|
||||
*/
|
||||
function constraintDatabase(existing: readonly { id: string; position: number }[]): {
|
||||
readonly database: ModuleDatabase;
|
||||
readonly database: ModuleDrizzleDatabase;
|
||||
readonly positions: number[];
|
||||
} {
|
||||
const positions: number[] = [];
|
||||
@@ -66,7 +66,7 @@ function constraintDatabase(existing: readonly { id: string; position: number }[
|
||||
return {
|
||||
database: {
|
||||
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction),
|
||||
} as unknown as ModuleDatabase,
|
||||
} as unknown as ModuleDrizzleDatabase,
|
||||
positions,
|
||||
};
|
||||
}
|
||||
|
||||
+12
-3
@@ -63,11 +63,19 @@ describe("Signage module manifest", () => {
|
||||
it("declares the module's product identity at the current manifest version", async () => {
|
||||
const manifest = await readManifest();
|
||||
|
||||
expect(manifest.manifestVersion).toBe(7);
|
||||
expect(manifest.manifestVersion).toBe(8);
|
||||
expect(manifest.id).toBe("signage");
|
||||
expect(manifest.name).toBe("Signage");
|
||||
expect(manifest.version).toBe("0.1.2");
|
||||
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
|
||||
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" });
|
||||
});
|
||||
|
||||
it("declares the Module Database ABI v1, so the platform hands it an executor and no ORM object", async () => {
|
||||
const manifest = await readManifest();
|
||||
|
||||
// Platform ADR 0050: with `database.abi: 1`, `services.database` is the ABI v1 executor. Without
|
||||
// it, a version 8 manifest would get the legacy Drizzle facade Signage no longer compiles against.
|
||||
expect(manifest.database).toEqual({ abi: 1 });
|
||||
});
|
||||
|
||||
it("declares every Signage permission, and none of them publicly", async () => {
|
||||
@@ -142,7 +150,8 @@ describe("Signage module manifest", () => {
|
||||
|
||||
describe("Signage server runtime", () => {
|
||||
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
|
||||
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.2" });
|
||||
// The runtime's database declaration must match the manifest's, or Core refuses to start it.
|
||||
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.2" });
|
||||
});
|
||||
|
||||
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
|
||||
|
||||
+49
-2
@@ -7,9 +7,12 @@ import {
|
||||
} from "@ekklesia/module-sdk";
|
||||
import { getTableConfig, type PgTable } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import moduleSchema from "../src/schema/index.js";
|
||||
import * as schemaEntrypoint from "../src/schema/index.js";
|
||||
import * as schemaTables from "../src/schema/tables.js";
|
||||
|
||||
const tables = moduleSchema.tables as Readonly<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> {
|
||||
return readFile(new URL("../drizzle/0000_initial.sql", import.meta.url), "utf8");
|
||||
@@ -43,6 +46,50 @@ describe("Signage schema", () => {
|
||||
expect(moduleSchema.moduleId).toBe("signage");
|
||||
});
|
||||
|
||||
it("hands Core plain ABI v1 schema metadata: names and foreign-key targets, and no Drizzle object", () => {
|
||||
// Platform ADR 0050: Core validates table ownership from plain data and never inspects an ORM
|
||||
// object. The entrypoint's only export is that data.
|
||||
expect(Object.keys(schemaEntrypoint)).toEqual(["default"]);
|
||||
expect(JSON.parse(JSON.stringify(moduleSchema))).toEqual(moduleSchema);
|
||||
const byName = [...moduleSchema.tables].sort((left, right) =>
|
||||
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
|
||||
);
|
||||
expect(byName).toEqual([
|
||||
{ name: "signage_channel_groups", foreignKeys: [] },
|
||||
{ name: "signage_channels", foreignKeys: [{ table: "signage_channel_groups" }] },
|
||||
{ name: "signage_image_assets", foreignKeys: [] },
|
||||
{ name: "signage_image_renditions", foreignKeys: [{ table: "signage_image_assets" }] },
|
||||
{
|
||||
name: "signage_playback_states",
|
||||
foreignKeys: [
|
||||
{ table: "signage_channel_groups" },
|
||||
{ table: "signage_timelines" },
|
||||
{ table: "signage_timelines" },
|
||||
{ table: "signage_image_assets" },
|
||||
{ table: "signage_timelines" },
|
||||
],
|
||||
},
|
||||
{ name: "signage_provider_sources", foreignKeys: [{ table: "signage_timelines" }] },
|
||||
{ name: "signage_selection_variants", foreignKeys: [{ table: "signage_selections" }] },
|
||||
{
|
||||
name: "signage_selections",
|
||||
foreignKeys: [{ table: "signage_channel_groups" }, { table: "signage_timeline_slots" }],
|
||||
},
|
||||
{
|
||||
name: "signage_timeline_slots",
|
||||
foreignKeys: [{ table: "signage_timelines" }, { table: "signage_provider_sources" }],
|
||||
},
|
||||
{ name: "signage_timelines", foreignKeys: [] },
|
||||
{ name: "signage_viewers", foreignKeys: [{ table: "signage_channels" }] },
|
||||
]);
|
||||
// The same tables the store queries, derived rather than restated, so the two cannot drift.
|
||||
expect(byName.map((table) => table.name)).toEqual(
|
||||
Object.values(tables)
|
||||
.map((table) => getTableConfig(table).name)
|
||||
.sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("never points a foreign key outside this module", () => {
|
||||
const owned = new Set(Object.values(tables).map((table) => getTableConfig(table).name));
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user