feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
import {
type ModuleStorage,
type ModuleStorageBucket,
ModuleStorageError,
type ModuleStorageListing,
type ModuleStorageObject,
type ModuleStorageObjectMetadata,
type ModuleStorageReadUrl,
} from "@ekklesia/module-sdk";
/**
* An in-memory `ModuleStorage`, scoped exactly as the real one is.
*
* `ModuleStorage` is a `@ekklesia/contracts` interface, so a module-layer test may legitimately
* construct one — and doing so demonstrates the portability claim directly: the host supplying this
* object could be Ekklesia over S3-compatible storage, or a future standalone host over something
* else entirely, and Signage cannot tell.
*
* It refuses an undeclared bucket exactly as the real facade does, so a test cannot accidentally
* pass by reaching a bucket the manifest never declared.
*/
export interface FakeStorage extends ModuleStorage {
readonly objects: Map<string, { bytes: Uint8Array; contentType?: string }>;
/** Test-only: make the next `put` for this key fail, so cleanup can be exercised. */
failPut(key: string): void;
readonly readUrlsIssued: string[];
}
export function createFakeStorage(
options: { readonly declared?: readonly string[]; readonly now?: () => Date } = {},
): FakeStorage {
const declared = new Set(options.declared ?? ["native-images"]);
const now = options.now ?? (() => new Date());
const objects = new Map<string, { bytes: Uint8Array; contentType?: string }>();
const failingPuts = new Set<string>();
const readUrlsIssued: string[] = [];
function makeBucket(id: string): ModuleStorageBucket {
return {
createReadUrl(key: string): Promise<ModuleStorageReadUrl> {
readUrlsIssued.push(key);
// Opaque by contract: shape, host, and query are the host's business. A module that parsed
// one would be welded to a deployment.
return Promise.resolve({
expiresAt: new Date(now().getTime() + 15 * 60 * 1000),
url: `https://storage.invalid/${encodeURIComponent(key)}?token=opaque`,
});
},
delete(key: string): Promise<void> {
objects.delete(`${id}/${key}`);
return Promise.resolve();
},
get(key: string): Promise<ModuleStorageObject> {
const stored = objects.get(`${id}/${key}`);
if (!stored) {
return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key));
}
return Promise.resolve({
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(stored.bytes);
controller.close();
},
}),
contentLength: stored.bytes.byteLength,
key,
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
});
},
head(key: string): Promise<ModuleStorageObjectMetadata> {
const stored = objects.get(`${id}/${key}`);
if (!stored) {
return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key));
}
return Promise.resolve({ contentLength: stored.bytes.byteLength, key });
},
id,
list(options_?: { readonly prefix?: string }): Promise<ModuleStorageListing> {
const prefix = `${id}/${options_?.prefix ?? ""}`;
return Promise.resolve({
objects: [...objects.entries()]
.filter(([key]) => key.startsWith(prefix))
.map(([key, value]) => ({
contentLength: value.bytes.byteLength,
key: key.slice(id.length + 1),
})),
});
},
put(
key: string,
body: unknown,
putOptions?: { readonly contentType?: string },
): Promise<ModuleStorageObjectMetadata> {
if (failingPuts.has(key)) {
failingPuts.delete(key);
return Promise.reject(new ModuleStorageError("STORAGE_PROVIDER_UNAVAILABLE", key));
}
if (!(body instanceof Uint8Array)) {
return Promise.reject(new ModuleStorageError("STORAGE_OPERATION_FAILED", key));
}
objects.set(`${id}/${key}`, {
bytes: body,
...(putOptions?.contentType === undefined ? {} : { contentType: putOptions.contentType }),
});
return Promise.resolve({ contentLength: body.byteLength, key });
},
};
}
return {
bucket(logicalBucketId: string): ModuleStorageBucket {
if (!declared.has(logicalBucketId)) {
throw new ModuleStorageError("STORAGE_BUCKET_UNDECLARED", logicalBucketId);
}
return makeBucket(logicalBucketId);
},
buckets: () => [...declared].sort(),
failPut(key: string): void {
failingPuts.add(key);
},
objects,
readUrlsIssued,
};
}
+294
View File
@@ -0,0 +1,294 @@
import type {
SignageJsonValue,
SignageProviderDiscoveryRequest,
SignageProviderItemDescriptor,
SignageProviderRefreshRequest,
SignageProviderRegistration,
SignageProviderResolution,
SignageProviderResolveRequest,
SignageProviderSourceDescriptor,
} from "../../src/server/providers/contract.js";
/**
* A **test-only** content provider, standing in for the real modules that do not exist yet.
*
* It is not an installed module, it has no manifest, and nothing about it is reachable from a
* running Signage: it lives under `tests/`, and the only way it enters the system is a test calling
* the same `register` a real provider module would call from its own `start` hook.
*
* The point is to prove the seam with something that behaves like the three real providers will:
* an announcements-shaped provider that cycles continuously, and trivia- and scripture-shaped
* providers that refuse to repeat until their pool is exhausted. **Signage implements none of those
* policies** — that is the whole demonstration.
*/
export interface FixtureItem {
readonly id: string;
readonly label: string;
/** Payload for the default audience. */
readonly payload: Record<string, SignageJsonValue>;
/** Payload for one specific audience, when this item has one. */
readonly audiencePayloads?: Readonly<Record<string, Record<string, SignageJsonValue>>>;
readonly availableUntil?: string | null;
readonly durationMs?: number;
}
export type FixtureRepeatPolicy =
/** Announcements: cycle the active set forever, in order, wrapping when it runs out. */
| "cycle"
/** Trivia and Scripture: never repeat until every currently available item has appeared. */
| "no-repeat-until-exhausted";
export interface FixtureProviderOptions {
readonly providerId: string;
readonly sourceId: string;
readonly sourceLabel: string;
readonly items: readonly FixtureItem[];
readonly policy: FixtureRepeatPolicy;
readonly defaultDurationMs?: number;
readonly contractVersion?: string;
readonly rendererVersion?: string;
}
export interface FixtureProvider {
readonly registration: SignageProviderRegistration;
/** Every item id this provider has handed out, in order, across every scope. */
readonly served: string[];
/** Every `resolveNext` request it received, so a test can prove what Signage supplied. */
readonly resolveRequests: SignageProviderResolveRequest[];
readonly refreshRequests: SignageProviderRefreshRequest[];
/** Replace the available set at run time — an announcement expiring, a question pool refilling. */
setItems(items: readonly FixtureItem[]): void;
/** Make the next `n` calls fail the way a briefly unavailable provider does. */
failNext(count: number): void;
/** Make every call hang, so the deadline is what ends it. */
hang(hanging: boolean): void;
/** Declare one item explicitly gone. */
withdraw(itemId: string): void;
/** Change an item's payload without changing which item it is — a cancellation, a countdown. */
reviseItem(itemId: string, payload: Record<string, SignageJsonValue>): void;
}
export function createFixtureProvider(options: FixtureProviderOptions): FixtureProvider {
let items = [...options.items];
const revisions = new Map<string, number>();
const withdrawn = new Set<string>();
/** Per selection scope: which items have already been served in the current pass. This is the
* provider's own state, exactly where the contract says it belongs — Signage stores none of it. */
const seen = new Map<string, Set<string>>();
const cursors = new Map<string, number>();
const served: string[] = [];
const resolveRequests: SignageProviderResolveRequest[] = [];
const refreshRequests: SignageProviderRefreshRequest[] = [];
let failures = 0;
let hanging = false;
function guard(): void {
if (failures > 0) {
failures -= 1;
throw new Error("fixture provider is briefly unavailable");
}
}
function available(at: string): readonly FixtureItem[] {
const instant = Date.parse(at);
return items.filter((item) => {
if (withdrawn.has(item.id)) {
return false;
}
if (typeof item.availableUntil !== "string") {
return true;
}
return instant < Date.parse(item.availableUntil);
});
}
function revisionOf(item: FixtureItem): string {
return String(revisions.get(item.id) ?? 1);
}
function resolutionFor(item: FixtureItem): SignageProviderResolution {
const variants = [
{ audienceKey: null, payload: item.payload },
...Object.entries(item.audiencePayloads ?? {}).map(([audienceKey, payload]) => ({
audienceKey,
payload,
})),
];
return {
availableUntil: item.availableUntil ?? null,
itemId: item.id,
itemRevision: revisionOf(item),
variants,
...(item.durationMs === undefined ? {} : { durationMs: item.durationMs }),
};
}
function pickNext(scope: string, at: string): FixtureItem | null {
const pool = available(at);
if (pool.length === 0) {
return null;
}
if (options.policy === "cycle") {
const index = cursors.get(scope) ?? 0;
const item = pool[index % pool.length] as FixtureItem;
cursors.set(scope, index + 1);
return item;
}
// No repeat until exhausted: only when every currently available item has appeared does the
// pass reset. An item added mid-pass joins the current pass rather than waiting for the next.
let usedInPass = seen.get(scope);
if (!usedInPass) {
usedInPass = new Set<string>();
seen.set(scope, usedInPass);
}
let remaining = pool.filter((item) => !usedInPass.has(item.id));
if (remaining.length === 0) {
usedInPass.clear();
remaining = [...pool];
}
const item = remaining[0] as FixtureItem;
usedInPass.add(item.id);
return item;
}
const registration: SignageProviderRegistration = {
contractVersion: options.contractVersion ?? "1.0.0",
defaultDurationMs: options.defaultDurationMs ?? 5000,
listItems(request: SignageProviderDiscoveryRequest): readonly SignageProviderItemDescriptor[] {
guard();
return available(request.at).map((item) => ({
id: item.id,
label: item.label,
...(item.availableUntil === undefined ? {} : { availableUntil: item.availableUntil }),
}));
},
listSources(): readonly SignageProviderSourceDescriptor[] {
guard();
return [{ id: options.sourceId, label: options.sourceLabel }];
},
providerId: options.providerId,
providerVersion: "1.0.0",
async refresh(
request: SignageProviderRefreshRequest,
): Promise<SignageProviderResolution | null> {
refreshRequests.push(request);
if (hanging) {
await new Promise(() => undefined);
}
guard();
if (withdrawn.has(request.itemId)) {
// Explicitly gone — distinct from "nothing right now", and Signage removes it at the next
// safe boundary rather than mid-slide.
return {
itemId: request.itemId,
itemRevision: revisionOf({ id: request.itemId } as FixtureItem),
variants: [{ audienceKey: null, payload: { withdrawn: true } }],
withdrawn: true,
};
}
const item = available(request.at).find((candidate) => candidate.id === request.itemId);
return item ? resolutionFor(item) : null;
},
renderer: {
contributionId: `${options.providerId}.slide`,
contributionVersion: options.rendererVersion ?? "1.0.0",
},
async resolveNext(
request: SignageProviderResolveRequest,
): Promise<SignageProviderResolution | null> {
resolveRequests.push(request);
if (hanging) {
await new Promise(() => undefined);
}
guard();
if (request.sourceId !== options.sourceId) {
return null;
}
const item = pickNext(request.selectionScopeId, request.at);
if (!item) {
return null;
}
served.push(item.id);
return resolutionFor(item);
},
};
return {
failNext(count: number): void {
failures = count;
},
hang(value: boolean): void {
hanging = value;
},
refreshRequests,
registration,
resolveRequests,
reviseItem(itemId: string, payload: Record<string, SignageJsonValue>): void {
items = items.map((item) => (item.id === itemId ? { ...item, payload } : item));
revisions.set(itemId, (revisions.get(itemId) ?? 1) + 1);
},
served,
setItems(next: readonly FixtureItem[]): void {
items = [...next];
},
withdraw(itemId: string): void {
withdrawn.add(itemId);
},
};
}
/** The three provider shapes the real modules will have, so a test can name them the way an operator
* would. Nothing about their policies lives in Signage. */
export function announcementsFixture(): FixtureProvider {
return createFixtureProvider({
items: [
{ id: "welcome", label: "Welcome", payload: { headline: "Welcome" } },
{
audiencePayloads: { online: { headline: "Watching from home" } },
id: "greeting",
label: "Greeting",
payload: { headline: "Good morning" },
},
{ id: "giving", label: "Giving", payload: { headline: "Giving" } },
{ id: "kids", label: "Kids check-in", payload: { headline: "Kids check-in" } },
],
policy: "cycle",
providerId: "fixture.announcements",
sourceId: "active-slides",
sourceLabel: "Active announcements",
});
}
export function triviaFixture(): FixtureProvider {
return createFixtureProvider({
items: [
{ id: "q1", label: "Question 1", payload: { question: "one" } },
{ id: "q2", label: "Question 2", payload: { question: "two" } },
{ id: "q3", label: "Question 3", payload: { question: "three" } },
],
policy: "no-repeat-until-exhausted",
providerId: "fixture.trivia",
sourceId: "open-questions",
sourceLabel: "Open questions",
});
}
export function scriptureFixture(): FixtureProvider {
return createFixtureProvider({
items: [
{ id: "psalm-23", label: "Psalm 23", payload: { reference: "Psalm 23" } },
{ id: "john-3", label: "John 3", payload: { reference: "John 3" } },
],
policy: "no-repeat-until-exhausted",
providerId: "fixture.scripture",
sourceId: "reading-plan",
sourceLabel: "Reading plan",
});
}
+80
View File
@@ -0,0 +1,80 @@
import sharp from "sharp";
/** Real encoded images, generated rather than checked in, so a fixture can never drift from what the
* pipeline actually accepts. */
export async function makeImage(options: {
readonly width: number;
readonly height: number;
readonly format: "png" | "jpeg" | "webp";
readonly alpha?: boolean;
readonly colour?: { r: number; g: number; b: number };
}): Promise<Uint8Array> {
const colour = options.colour ?? { b: 40, g: 120, r: 200 };
const pipeline = sharp({
create: {
background: { ...colour, alpha: options.alpha === true ? 0.5 : 1 },
channels: options.alpha === true ? 4 : 3,
height: options.height,
width: options.width,
},
});
const encoded =
options.format === "png"
? await pipeline.png().toBuffer()
: options.format === "jpeg"
? await pipeline.jpeg().toBuffer()
: await pipeline.webp().toBuffer();
return new Uint8Array(encoded);
}
/** A genuine two-frame animated WebP. Signage refuses animation: a signage slide is a still frame,
* and an animated one would loop out of step with the authoritative slot deadline. */
export async function makeAnimatedWebp(): Promise<Uint8Array> {
const frame = (red: number) =>
sharp({
create: { background: { alpha: 1, b: 0, g: 0, r: red }, channels: 4, height: 8, width: 8 },
})
.raw()
.toBuffer();
const frames = Buffer.concat([await frame(255), await frame(0)]);
const encoded = await sharp(frames, {
raw: { channels: 4, height: 16, pageHeight: 8, width: 8 },
})
.webp({ delay: [100, 100], loop: 0 })
.toBuffer();
return new Uint8Array(encoded);
}
/** A genuine two-frame animated GIF. GIF is refused outright, but animation is detected first, so
* this proves the *animation* rule rather than the format rule. */
export async function makeAnimatedGif(): Promise<Uint8Array> {
const frame = (red: number) =>
sharp({
create: { background: { alpha: 1, b: 0, g: 0, r: red }, channels: 4, height: 8, width: 8 },
})
.raw()
.toBuffer();
const frames = Buffer.concat([await frame(255), await frame(0)]);
const encoded = await sharp(frames, {
raw: { channels: 4, height: 16, pageHeight: 8, width: 8 },
})
.gif({ delay: [100, 100], loop: 0 })
.toBuffer();
return new Uint8Array(encoded);
}
export function bytesOf(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
/** A minimal, genuinely well-formed SVG. Signage refuses it: an SVG is a document with script and
* external-reference capability, not a raster image. */
export const SVG_DOCUMENT =
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>';
/** The first bytes of a PDF. Refused for the same reason as everything else that is not a still
* raster image. */
export const PDF_DOCUMENT = "%PDF-1.7\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>";
/** A GIF89a header with one tiny frame. */
export const GIF_DOCUMENT_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
+249
View File
@@ -0,0 +1,249 @@
import {
type NewStoredSelection,
type PlaybackChanges,
type SelectionChanges,
type SignagePlaybackStore,
SignageStoreConflict,
type StoredChannel,
type StoredChannelGroup,
type StoredChannelGroupDetail,
type StoredPlaybackState,
type StoredSelection,
} from "../../src/server/store/types.js";
/**
* The playback half of the in-memory store.
*
* It enforces the guarantees the database enforces — one default channel that cannot be removed, a
* sequence that is never handed out twice, variants replaced wholesale — so a decision proven here
* holds against PostgreSQL too.
*/
export interface MemoryPlaybackStore extends SignagePlaybackStore {
readonly groups: Map<string, StoredChannelGroup>;
readonly channels: Map<string, StoredChannel>;
readonly playback: Map<string, StoredPlaybackState>;
readonly selections: Map<string, StoredSelection>;
}
export function createMemoryPlaybackStore(
options: { readonly now?: () => Date } = {},
): MemoryPlaybackStore {
const now = options.now ?? (() => new Date());
const groups = new Map<string, StoredChannelGroup>();
const channels = new Map<string, StoredChannel>();
const playback = new Map<string, StoredPlaybackState>();
const selections = new Map<string, StoredSelection>();
function detail(groupId: string): StoredChannelGroupDetail | undefined {
const group = groups.get(groupId);
const state = playback.get(groupId);
if (!group || !state) {
return undefined;
}
return {
channels: [...channels.values()]
.filter((channel) => channel.groupId === groupId)
.sort((left, right) =>
left.isDefault === right.isDefault
? left.key.localeCompare(right.key)
: left.isDefault
? -1
: 1,
),
group,
playback: state,
};
}
return {
addChannel(channel: StoredChannel): Promise<void> {
channels.set(channel.id, channel);
const group = groups.get(channel.groupId);
if (group) {
groups.set(group.id, { ...group, revision: group.revision + 1 });
}
return Promise.resolve();
},
channels,
createChannelGroup(input: {
readonly groupId: string;
readonly name: string;
readonly channelId: string;
readonly channelName: string;
}): Promise<void> {
groups.set(input.groupId, { id: input.groupId, name: input.name, revision: 1 });
channels.set(input.channelId, {
groupId: input.groupId,
id: input.channelId,
isDefault: true,
key: "default",
name: input.channelName,
});
playback.set(input.groupId, {
activeTimelineId: null,
blackout: false,
currentSelectionId: null,
currentSlotId: null,
groupId: input.groupId,
nextSequence: 1,
paused: false,
pausedRemainingMs: null,
pendingRequestedAt: null,
pendingTimelineId: null,
revision: 1,
scheduledActivationAt: null,
scheduledTimelineId: null,
slotDeadlineAt: null,
slotStartedAt: null,
staticAssetId: null,
updatedAt: now(),
});
return Promise.resolve();
},
deleteChannelGroup(groupId: string): Promise<void> {
groups.delete(groupId);
playback.delete(groupId);
for (const [id, channel] of channels) {
if (channel.groupId === groupId) {
channels.delete(id);
}
}
for (const [id, selection] of selections) {
if (selection.groupId === groupId) {
selections.delete(id);
}
}
return Promise.resolve();
},
findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
return Promise.resolve(detail(groupId));
},
findLatestSelectionForScope(
groupId: string,
selectionScope: string,
): Promise<StoredSelection | undefined> {
const matching = [...selections.values()]
.filter(
(selection) =>
selection.groupId === groupId && selection.selectionScope === selectionScope,
)
.sort((left, right) => right.sequence - left.sequence);
return Promise.resolve(matching[0]);
},
findSelection(selectionId: string): Promise<StoredSelection | undefined> {
return Promise.resolve(selections.get(selectionId));
},
groups,
insertSelection(selection: NewStoredSelection): Promise<void> {
selections.set(selection.id, { ...selection, resolvedAt: now(), revision: 1 });
return Promise.resolve();
},
listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]> {
return Promise.resolve(
[...groups.values()]
.sort((left, right) => left.name.localeCompare(right.name))
.map((group) => detail(group.id))
.filter((value): value is StoredChannelGroupDetail => value !== undefined),
);
},
listSelections(groupId: string, limit: number): Promise<readonly StoredSelection[]> {
return Promise.resolve(
[...selections.values()]
.filter((selection) => selection.groupId === groupId)
.sort((left, right) => right.sequence - left.sequence)
.slice(0, limit),
);
},
playback,
pruneSelections(groupId: string, limit: number): Promise<void> {
const ordered = [...selections.values()]
.filter((selection) => selection.groupId === groupId)
.sort((left, right) => right.sequence - left.sequence);
for (const selection of ordered.slice(limit)) {
selections.delete(selection.id);
}
return Promise.resolve();
},
removeChannel(channelId: string): Promise<void> {
const channel = channels.get(channelId);
if (!channel) {
return Promise.resolve();
}
if (channel.isDefault) {
return Promise.reject(new SignageStoreConflict("channel.default"));
}
channels.delete(channelId);
const group = groups.get(channel.groupId);
if (group) {
groups.set(group.id, { ...group, revision: group.revision + 1 });
}
return Promise.resolve();
},
selections,
takeSelectionSequence(groupId: string): Promise<number> {
const state = playback.get(groupId);
if (!state) {
return Promise.reject(new SignageStoreConflict("playback.sequence"));
}
playback.set(groupId, { ...state, nextSequence: state.nextSequence + 1 });
return Promise.resolve(state.nextSequence);
},
updatePlayback(
groupId: string,
changes: PlaybackChanges,
updateOptions?: { readonly bumpRevision?: boolean },
): Promise<void> {
const state = playback.get(groupId);
if (!state) {
return Promise.resolve();
}
const next: Record<string, unknown> = { ...state };
for (const key of Object.keys(changes)) {
next[key] = (changes as Record<string, unknown>)[key] ?? null;
}
playback.set(groupId, {
...(next as unknown as StoredPlaybackState),
revision: updateOptions?.bumpRevision === false ? state.revision : state.revision + 1,
updatedAt: now(),
});
return Promise.resolve();
},
updateSelection(selectionId: string, changes: SelectionChanges): Promise<void> {
const selection = selections.get(selectionId);
if (!selection) {
return Promise.resolve();
}
selections.set(selectionId, {
...selection,
...(changes.availableUntil === undefined ? {} : { availableUntil: changes.availableUntil }),
...(changes.deadlineAt === undefined ? {} : { deadlineAt: changes.deadlineAt }),
...(changes.durationMs === undefined ? {} : { durationMs: changes.durationMs }),
...(changes.itemRevision === undefined ? {} : { itemRevision: changes.itemRevision }),
...(changes.presentation === undefined ? {} : { presentation: changes.presentation }),
...(changes.stale === undefined ? {} : { stale: changes.stale }),
...(changes.startedAt === undefined ? {} : { startedAt: changes.startedAt }),
...(changes.withdrawn === undefined ? {} : { withdrawn: changes.withdrawn }),
...(changes.variants === undefined ? {} : { variants: changes.variants }),
revision: changes.bumpRevision === true ? selection.revision + 1 : selection.revision,
});
return Promise.resolve();
},
};
}
+120
View File
@@ -0,0 +1,120 @@
import {
type NewStoredImage,
type SignageStore,
SignageStoreConflict,
type StoredImage,
type StoredImageReferences,
} from "../../src/server/store/types.js";
import { createMemoryPlaybackStore, type MemoryPlaybackStore } from "./memory-playback.js";
import { createMemoryTimelineStore, type MemoryTimelineStore } from "./memory-timelines.js";
import { createMemoryViewerStore, type MemoryViewerStore } from "./memory-viewers.js";
/**
* An in-memory implementation of Signage's data port.
*
* This is what makes Signage's *decisions* testable without PostgreSQL: deduplication, no-op
* detection, reference-aware deletion, provider resolution, and playback reconciliation are all
* behaviour, and behaviour proven against a real database for every case would be slow, flaky, and
* therefore rare. The Drizzle implementation is proven separately, against a live disposable
* database, where what is being proven is the row mapping rather than the decision.
*
* It enforces the same uniqueness guarantees the database does, so a test cannot pass here and fail
* there.
*/
export interface MemorySignageStore
extends SignageStore,
MemoryTimelineStore,
MemoryPlaybackStore,
MemoryViewerStore {
/** Test-only: seed a reference so reference-aware deletion can be exercised. */
addImageReference(imageId: string, kind: "slot" | "static-hold"): void;
readonly images: Map<string, StoredImage>;
}
export function createMemorySignageStore(
options: { readonly now?: () => Date } = {},
): MemorySignageStore {
const now = options.now ?? (() => new Date());
const images = new Map<string, StoredImage>();
const references = new Map<string, StoredImageReferences>();
const timelineStore = createMemoryTimelineStore(options);
const playbackStore = createMemoryPlaybackStore(options);
const viewerStore = createMemoryViewerStore(options);
return {
...timelineStore,
...playbackStore,
...viewerStore,
addImageReference(imageId: string, kind: "slot" | "static-hold"): void {
const current = references.get(imageId) ?? { slots: 0, staticHolds: 0 };
references.set(
imageId,
kind === "slot"
? { slots: current.slots + 1, staticHolds: current.staticHolds }
: { slots: current.slots, staticHolds: current.staticHolds + 1 },
);
},
countImageReferences(imageId: string): Promise<StoredImageReferences> {
return Promise.resolve(references.get(imageId) ?? { slots: 0, staticHolds: 0 });
},
deleteImage(imageId: string): Promise<void> {
images.delete(imageId);
references.delete(imageId);
return Promise.resolve();
},
findImage(imageId: string): Promise<StoredImage | undefined> {
return Promise.resolve(images.get(imageId));
},
findReadyImageByContentHash(contentSha256: string): Promise<StoredImage | undefined> {
for (const image of images.values()) {
if (image.contentSha256 === contentSha256 && image.status === "ready") {
return Promise.resolve(image);
}
}
return Promise.resolve(undefined);
},
images,
insertImage(image: NewStoredImage): Promise<void> {
for (const existing of images.values()) {
if (existing.contentSha256 === image.contentSha256) {
// Exactly what the unique index does, so a race is exercised here and not only in
// production.
return Promise.reject(new SignageStoreConflict("image.contentSha256"));
}
}
images.set(image.id, { ...image, createdAt: now() });
return Promise.resolve();
},
listImages(): Promise<readonly StoredImage[]> {
return Promise.resolve(
[...images.values()].sort(
(left, right) => right.createdAt.getTime() - left.createdAt.getTime(),
),
);
},
updateImage(
imageId: string,
changes: { readonly displayName?: string; readonly expiresAt?: Date | null },
): Promise<void> {
const existing = images.get(imageId);
if (!existing) {
return Promise.resolve();
}
images.set(imageId, {
...existing,
...(changes.displayName === undefined ? {} : { displayName: changes.displayName }),
...(changes.expiresAt === undefined ? {} : { expiresAt: changes.expiresAt }),
});
return Promise.resolve();
},
};
}
+238
View File
@@ -0,0 +1,238 @@
import {
type NewStoredProviderSource,
type NewStoredSlot,
type NewStoredTimeline,
SignageStoreConflict,
type SignageTimelineStore,
type SlotChanges,
type StoredProviderSource,
type StoredSlot,
type StoredTimeline,
type StoredTimelineDetail,
type StoredTimelineUsage,
type TimelineChanges,
} from "../../src/server/store/types.js";
/**
* The timeline half of the in-memory store.
*
* It enforces the same invariants the database does — contiguous positions, an exhaustive reorder,
* a revision that increments once per semantic change — so a test cannot pass here and fail against
* PostgreSQL. Where the production store uses a transaction, this uses the fact that JavaScript does
* not interleave synchronous work; the property being proven is the same.
*/
export interface MemoryTimelineStore extends SignageTimelineStore {
/** Test-only: pretend a channel group has this timeline active, pending, or scheduled. */
setTimelineUsage(timelineId: string, usage: StoredTimelineUsage): void;
readonly timelines: Map<string, StoredTimeline>;
readonly slots: Map<string, StoredSlot>;
readonly sources: Map<string, StoredProviderSource>;
}
export function createMemoryTimelineStore(
options: { readonly now?: () => Date } = {},
): MemoryTimelineStore {
const now = options.now ?? (() => new Date());
const timelines = new Map<string, StoredTimeline>();
const slots = new Map<string, StoredSlot>();
const sources = new Map<string, StoredProviderSource>();
const usage = new Map<string, StoredTimelineUsage>();
function slotsOf(timelineId: string): StoredSlot[] {
return [...slots.values()]
.filter((slot) => slot.timelineId === timelineId)
.sort((left, right) => left.position - right.position);
}
function bump(timelineId: string): void {
const timeline = timelines.get(timelineId);
if (timeline) {
timelines.set(timelineId, {
...timeline,
revision: timeline.revision + 1,
updatedAt: now(),
});
}
}
function renumber(timelineId: string, orderedSlotIds: readonly string[]): void {
for (const [index, slotId] of orderedSlotIds.entries()) {
const slot = slots.get(slotId);
if (slot) {
slots.set(slotId, { ...slot, position: index });
}
}
bump(timelineId);
}
return {
addSlot(slot: NewStoredSlot, position?: number): Promise<void> {
const existing = slotsOf(slot.timelineId).map((entry) => entry.id);
const index =
position === undefined ? existing.length : Math.max(0, Math.min(position, existing.length));
slots.set(slot.id, { ...slot, position: -1 });
renumber(slot.timelineId, [...existing.slice(0, index), slot.id, ...existing.slice(index)]);
return Promise.resolve();
},
addSource(source: NewStoredProviderSource): Promise<void> {
sources.set(source.id, { ...source, createdAt: now() });
return Promise.resolve();
},
createTimeline(input: {
readonly timeline: NewStoredTimeline;
readonly sources: readonly NewStoredProviderSource[];
readonly slots: readonly NewStoredSlot[];
}): Promise<void> {
const createdAt = now();
timelines.set(input.timeline.id, {
...input.timeline,
createdAt,
revision: 1,
updatedAt: createdAt,
});
for (const source of input.sources) {
sources.set(source.id, { ...source, createdAt });
}
for (const [index, slot] of input.slots.entries()) {
slots.set(slot.id, { ...slot, position: index });
}
return Promise.resolve();
},
deleteTimeline(timelineId: string): Promise<void> {
timelines.delete(timelineId);
for (const [id, slot] of slots) {
if (slot.timelineId === timelineId) {
slots.delete(id);
}
}
for (const [id, source] of sources) {
if (source.timelineId === timelineId) {
sources.delete(id);
}
}
return Promise.resolve();
},
findTimeline(timelineId: string): Promise<StoredTimelineDetail | undefined> {
const timeline = timelines.get(timelineId);
if (!timeline) {
return Promise.resolve(undefined);
}
return Promise.resolve({
slots: slotsOf(timelineId),
sources: [...sources.values()]
.filter((source) => source.timelineId === timelineId)
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()),
timeline,
});
},
listTimelines(): Promise<readonly StoredTimeline[]> {
return Promise.resolve(
[...timelines.values()].sort((left, right) => left.name.localeCompare(right.name)),
);
},
removeSlot(slotId: string): Promise<void> {
const slot = slots.get(slotId);
if (!slot) {
return Promise.resolve();
}
slots.delete(slotId);
renumber(
slot.timelineId,
slotsOf(slot.timelineId).map((entry) => entry.id),
);
return Promise.resolve();
},
removeSource(sourceId: string): Promise<void> {
const source = sources.get(sourceId);
if (!source) {
return Promise.resolve();
}
sources.delete(sourceId);
for (const [id, slot] of slots) {
if (slot.sourceId === sourceId) {
slots.delete(id);
}
}
renumber(
source.timelineId,
slotsOf(source.timelineId).map((entry) => entry.id),
);
return Promise.resolve();
},
reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise<void> {
const current = slotsOf(timelineId).map((slot) => slot.id);
const given = new Set(orderedSlotIds);
if (given.size !== orderedSlotIds.length || given.size !== current.length) {
return Promise.reject(new SignageStoreConflict("timeline.slotOrder"));
}
for (const slotId of current) {
if (!given.has(slotId)) {
return Promise.reject(new SignageStoreConflict("timeline.slotOrder"));
}
}
renumber(timelineId, orderedSlotIds);
return Promise.resolve();
},
setTimelineUsage(timelineId: string, value: StoredTimelineUsage): void {
usage.set(timelineId, value);
},
slots,
sources,
timelineUsage(timelineId: string): Promise<StoredTimelineUsage> {
return Promise.resolve(
usage.get(timelineId) ?? { activeGroups: 0, pendingGroups: 0, scheduledGroups: 0 },
);
},
timelines,
updateSlot(slotId: string, changes: SlotChanges): Promise<void> {
const slot = slots.get(slotId);
if (!slot) {
return Promise.resolve();
}
slots.set(slotId, {
...slot,
...(changes.enabled === undefined ? {} : { enabled: changes.enabled }),
...(changes.durationOverrideMs === undefined
? {}
: { durationOverrideMs: changes.durationOverrideMs }),
...(changes.eligibleFrom === undefined ? {} : { eligibleFrom: changes.eligibleFrom }),
...(changes.eligibleUntil === undefined ? {} : { eligibleUntil: changes.eligibleUntil }),
...(changes.itemId === undefined ? {} : { itemId: changes.itemId }),
presentation: { ...slot.presentation, ...(changes.presentation ?? {}) },
});
bump(slot.timelineId);
return Promise.resolve();
},
updateTimeline(timelineId: string, changes: TimelineChanges): Promise<void> {
const timeline = timelines.get(timelineId);
if (!timeline) {
return Promise.resolve();
}
timelines.set(timelineId, {
...timeline,
...(changes.name === undefined ? {} : { name: changes.name }),
...(changes.description === undefined ? {} : { description: changes.description }),
...(changes.eligibleFrom === undefined ? {} : { eligibleFrom: changes.eligibleFrom }),
...(changes.eligibleUntil === undefined ? {} : { eligibleUntil: changes.eligibleUntil }),
revision: timeline.revision + 1,
updatedAt: now(),
});
return Promise.resolve();
},
};
}
+72
View File
@@ -0,0 +1,72 @@
import type {
NewStoredViewer,
SignageViewerStore,
StoredViewer,
ViewerChanges,
} from "../../src/server/store/types.js";
export interface MemoryViewerStore extends SignageViewerStore {
readonly viewers: Map<string, StoredViewer>;
/** Test-only: how many times a viewer row was actually written, which is what heartbeat
* coalescing exists to keep small. */
readonly viewerWrites: { count: number };
}
export function createMemoryViewerStore(
options: { readonly now?: () => Date } = {},
): MemoryViewerStore {
const now = options.now ?? (() => new Date());
const viewers = new Map<string, StoredViewer>();
const viewerWrites = { count: 0 };
return {
deleteViewer(viewerId: string): Promise<void> {
viewers.delete(viewerId);
return Promise.resolve();
},
findViewer(viewerId: string): Promise<StoredViewer | undefined> {
return Promise.resolve(viewers.get(viewerId));
},
findViewerByKey(viewerKey: string): Promise<StoredViewer | undefined> {
for (const viewer of viewers.values()) {
if (viewer.viewerKey === viewerKey) {
return Promise.resolve(viewer);
}
}
return Promise.resolve(undefined);
},
insertViewer(viewer: NewStoredViewer): Promise<void> {
viewerWrites.count += 1;
viewers.set(viewer.id, { ...viewer, createdAt: now(), lastError: null, lastSeenAt: null });
return Promise.resolve();
},
listViewers(): Promise<readonly StoredViewer[]> {
return Promise.resolve(
[...viewers.values()].sort((left, right) => left.name.localeCompare(right.name)),
);
},
updateViewer(viewerId: string, changes: ViewerChanges): Promise<void> {
const viewer = viewers.get(viewerId);
if (!viewer) {
return Promise.resolve();
}
viewerWrites.count += 1;
const next: Record<string, unknown> = { ...viewer };
for (const [key, value] of Object.entries(changes)) {
if (value !== undefined) {
next[key] = value;
}
}
viewers.set(viewerId, next as unknown as StoredViewer);
return Promise.resolve();
},
viewers,
viewerWrites,
};
}
+106
View File
@@ -0,0 +1,106 @@
import {
ANONYMOUS_PRINCIPAL,
type CapabilityId,
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleConfiguration,
type ModuleRouteDefinitionInput,
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
type RegisteredCapability,
} from "@ekklesia/module-sdk";
import moduleRuntime from "../../src/server/index.js";
/**
* The module-layer test harness: everything a Signage unit test needs to exercise the real runtime
* definition without a platform.
*
* Every object here is a `@ekklesia/contracts` interface constructed by hand, which is exactly what
* a module-layer test may legitimately do — a module may never import Core. It also demonstrates the
* portability claim directly: the host producing these objects could be Ekklesia, or a future
* standalone host reading YAML from disk, and Signage cannot tell the difference.
*/
export interface CapturedRegistration {
readonly channels: { channel: string; permission: string }[];
readonly provided: Map<CapabilityId, unknown>;
readonly routes: ModuleRouteDefinitionInput[];
}
export async function registerModule(
config: ModuleConfiguration = EMPTY_MODULE_CONFIGURATION,
): Promise<CapturedRegistration> {
const channels: { channel: string; permission: string }[] = [];
const provided = new Map<CapabilityId, unknown>();
const routes: ModuleRouteDefinitionInput[] = [];
await moduleRuntime.register?.({
capabilities: {
provide(capabilityId, implementation) {
provided.set(capabilityId, implementation);
},
resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined {
return undefined;
},
},
config,
http: {
route(definition) {
routes.push(definition);
},
},
moduleId: "signage",
realtime: {
channel(definition) {
channels.push({ channel: definition.channel, permission: definition.permission });
},
},
});
return { channels, provided, routes };
}
export function findRoute(
registration: CapturedRegistration,
method: string,
path: string,
): ModuleRouteDefinitionInput {
const route = registration.routes.find(
(candidate) => candidate.method === method && candidate.path === path,
);
if (!route) {
throw new Error(`Signage did not register ${method} ${path}.`);
}
return route;
}
export function fakeConfiguration(values: Readonly<Record<string, unknown>>): ModuleConfiguration {
const read = <Value>(key: string, kind: string): Value | undefined => {
const value = values[key];
return typeof value === kind ? (value as Value) : undefined;
};
return Object.freeze({
get: (key: string) => values[key] as never,
getBoolean: (key: string) => read<boolean>(key, "boolean"),
getEnum: (key: string) => read<string>(key, "string"),
getInteger: (key: string) => read<number>(key, "number"),
getSecret: (key: string) => read<string>(key, "string"),
getString: (key: string) => read<string>(key, "string"),
getUrl: (key: string) => read<string>(key, "string"),
has: (key: string) => Object.hasOwn(values, key),
keys: () => Object.freeze(Object.keys(values)),
});
}
export function fakeHandlerContext(
overrides: Partial<ModuleRouteHandlerContext> = {},
): ModuleRouteHandlerContext {
const services = { database: undefined } as unknown as ModuleRuntimeServices;
return Object.freeze({
config: EMPTY_MODULE_CONFIGURATION,
moduleId: "signage",
params: Object.freeze({}),
principal: ANONYMOUS_PRINCIPAL,
realtime: { publish: () => undefined },
services,
storage: EMPTY_MODULE_STORAGE,
...overrides,
});
}