135 lines
4.9 KiB
TypeScript
135 lines
4.9 KiB
TypeScript
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,
|
|
|
|
async deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void> {
|
|
const removedChannelIds = new Set(
|
|
[...playbackStore.channels.values()]
|
|
.filter((channel) => channel.groupId === groupId)
|
|
.map((channel) => channel.id),
|
|
);
|
|
for (const viewer of viewerStore.viewers.values()) {
|
|
if (viewer.channelId !== null && removedChannelIds.has(viewer.channelId)) {
|
|
await viewerStore.updateViewer(viewer.id, { channelId: fallbackChannelId });
|
|
}
|
|
}
|
|
await playbackStore.deleteChannelGroup(groupId, fallbackChannelId);
|
|
},
|
|
|
|
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();
|
|
},
|
|
};
|
|
}
|