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
+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();
},
};
}