Merge pull request 'feat: move Signage to the Ekklesia 0.2 module contract (manifest v8, database ABI v1)' (#10) from feat/platform-0.2-compat into main
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/10 Reviewed-by: The_River <[email protected]>
This commit was merged in pull request #10.
This commit is contained in:
@@ -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