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
+278
View File
@@ -0,0 +1,278 @@
import { randomUUID } from "node:crypto";
import {
type ModuleStorage,
type ModuleStorageBucket,
ModuleStorageError,
} from "@ekklesia/module-sdk";
import { SignageError } from "../errors.js";
import { type SignageStore, SignageStoreConflict, type StoredImage } from "../store/types.js";
import { SIGNAGE_IMAGE_MAX_BYTES } from "./limits.js";
import { processSignageImage, type SignageProcessedImage } from "./processing.js";
/** The one logical bucket Signage declares. It names no service, endpoint, region, or credential —
* which is what lets the same code run under Ekklesia and under a future standalone host. */
export const SIGNAGE_IMAGE_BUCKET = "native-images";
export function nativeImageBucket(storage: ModuleStorage): ModuleStorageBucket {
return storage.bucket(SIGNAGE_IMAGE_BUCKET);
}
/**
* Object keys are scoped to the asset they belong to.
*
* Until the asset row commits these are **temporary**: nothing references them, nothing can select
* them, and a failed ingest deletes every one it wrote. Scoping them to the asset rather than to a
* throwaway upload id is what makes a successful upload cost exactly one write of each object
* instead of two — the commit is the database row, not a second copy of 25 MiB.
*/
export function originalObjectKey(assetId: string, extension: string): string {
return `assets/${assetId}/original.${extension}`;
}
export function renditionObjectKey(assetId: string, kind: string): string {
return `assets/${assetId}/${kind}.webp`;
}
/**
* Every storage failure becomes one of Signage's own codes.
*
* `ModuleStorageError` already carries no endpoint, credential, or provider body; collapsing it
* further keeps even *which* storage operation failed out of Signage's responses, because that is
* not a caller's business.
*/
function storageFailure(error: unknown): SignageError {
if (error instanceof ModuleStorageError && error.code === "STORAGE_OBJECT_NOT_FOUND") {
return new SignageError("SIGNAGE_NOT_FOUND");
}
return new SignageError("SIGNAGE_STORAGE_UNAVAILABLE");
}
export interface IngestNativeImageInput {
readonly store: SignageStore;
readonly storage: ModuleStorage;
readonly bytes: Uint8Array;
readonly filename: string;
readonly displayName: string;
readonly expiresAt: Date | null;
/** Supplied so a test can pin identity. Production passes nothing and gets a fresh UUID. */
readonly newId?: () => string;
}
export interface IngestNativeImageResult {
readonly image: StoredImage;
/** True when identical bytes were already stored, so nothing new was written anywhere. */
readonly deduplicated: boolean;
}
/**
* Ingests one native image.
*
* The order **is** the contract (ADR 0007):
*
* 1. The caller has already read the whole request body under Signage's own bound.
* 2. Decode and validate. An invalid image has cost zero storage writes and zero rows.
* 3. Deduplicate by content hash. Identical bytes return the existing asset — again, zero writes.
* 4. Write the original and both renditions to asset-scoped keys. Temporary: nothing refers to them.
* 5. Commit the asset and its renditions in one transaction. That row is what makes the asset exist,
* so a partial write can never be promoted into something playback could select.
* 6. On any failure after step 4, delete every object this attempt wrote.
*/
export async function ingestNativeImage(
input: IngestNativeImageInput,
): Promise<IngestNativeImageResult> {
if (input.bytes.byteLength > SIGNAGE_IMAGE_MAX_BYTES) {
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes: SIGNAGE_IMAGE_MAX_BYTES });
}
const processed: SignageProcessedImage = await processSignageImage(input.bytes);
const existing = await input.store.findReadyImageByContentHash(processed.contentSha256);
if (existing) {
return { deduplicated: true, image: existing };
}
const bucket = nativeImageBucket(input.storage);
const assetId = (input.newId ?? randomUUID)();
const original = originalObjectKey(assetId, processed.fileExtension);
const written: string[] = [];
try {
await bucket.put(original, input.bytes, { contentType: processed.contentType });
written.push(original);
const renditions = [];
for (const rendition of processed.renditions) {
const key = renditionObjectKey(assetId, rendition.kind);
await bucket.put(key, rendition.bytes, { contentType: rendition.contentType });
written.push(key);
renditions.push({
byteSize: rendition.byteSize,
contentSha256: rendition.contentSha256,
contentType: rendition.contentType,
height: rendition.height,
kind: rendition.kind,
objectKey: key,
width: rendition.width,
});
}
await input.store.insertImage({
contentSha256: processed.contentSha256,
displayName: input.displayName,
encodedHeight: processed.encodedHeight,
encodedWidth: processed.encodedWidth,
expiresAt: input.expiresAt,
hasAlpha: processed.hasAlpha,
height: processed.height,
id: assetId,
orientation: processed.orientation,
originalByteSize: processed.byteSize,
originalContentType: processed.contentType,
originalFilename: input.filename,
originalObjectKey: original,
renditions,
status: "ready",
width: processed.width,
});
} catch (error) {
// Nothing this attempt wrote is referenced by anything, so removing all of it is always safe.
await Promise.all(written.map((key) => bucket.delete(key).catch(() => undefined)));
if (error instanceof SignageStoreConflict) {
// Another request committed the identical bytes while this one was writing. For the caller
// that is a success, not a failure: the image they uploaded is stored.
const raced = await input.store.findReadyImageByContentHash(processed.contentSha256);
if (raced) {
return { deduplicated: true, image: raced };
}
throw new SignageError("SIGNAGE_CONFLICT");
}
if (error instanceof SignageError) {
throw error;
}
if (error instanceof ModuleStorageError) {
throw storageFailure(error);
}
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
const image = await input.store.findImage(assetId);
if (!image) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return { deduplicated: false, image };
}
export interface SignageImageDelivery {
readonly kind: string;
readonly url: string;
readonly expiresAt: string;
readonly width: number;
readonly height: number;
}
/**
* Fresh, short-lived delivery URLs for one image's renditions.
*
* Requested when something is about to be delivered, and then forgotten. Signage persists the
* logical key and never the URL: a URL is a bearer capability with an expiry in it, and storing one
* in a table would be storing a credential that stops working.
*/
export async function createImageDeliveryUrls(
storage: ModuleStorage,
renditions: readonly {
readonly kind: string;
readonly objectKey: string;
readonly width: number;
readonly height: number;
}[],
): Promise<readonly SignageImageDelivery[]> {
const bucket = nativeImageBucket(storage);
try {
return await Promise.all(
renditions.map(async (rendition) => {
const url = await bucket.createReadUrl(rendition.objectKey);
return {
expiresAt: url.expiresAt.toISOString(),
height: rendition.height,
kind: rendition.kind,
url: url.url,
width: rendition.width,
};
}),
);
} catch (error) {
throw storageFailure(error);
}
}
export async function updateNativeImage(input: {
readonly store: SignageStore;
readonly assetId: string;
readonly displayName?: string | undefined;
readonly expiresAt?: Date | null | undefined;
}): Promise<StoredImage> {
const existing = await input.store.findImage(input.assetId);
if (!existing) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
const changes: { displayName?: string; expiresAt?: Date | null } = {};
if (input.displayName !== undefined && input.displayName !== existing.displayName) {
changes.displayName = input.displayName;
}
if (input.expiresAt !== undefined) {
const current = existing.expiresAt?.getTime() ?? null;
const next = input.expiresAt?.getTime() ?? null;
if (current !== next) {
changes.expiresAt = input.expiresAt;
}
}
// An exact no-op writes nothing at all.
if (Object.keys(changes).length === 0) {
return existing;
}
await input.store.updateImage(input.assetId, changes);
const updated = await input.store.findImage(input.assetId);
if (!updated) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return updated;
}
/**
* Deletes an image, and refuses while something still refers to it.
*
* A slot pinned to a deleted image would resolve to nothing at the moment its turn came, mid-service.
* Refusing with a count of what refers to it is something an operator can act on; a silent cascade
* is not.
*/
export async function deleteNativeImage(input: {
readonly store: SignageStore;
readonly storage: ModuleStorage;
readonly assetId: string;
}): Promise<void> {
const existing = await input.store.findImage(input.assetId);
if (!existing) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
const references = await input.store.countImageReferences(input.assetId);
if (references.slots > 0 || references.staticHolds > 0) {
throw new SignageError("SIGNAGE_ASSET_IN_USE", {
heldByChannelGroups: references.staticHolds,
referencedBySlots: references.slots,
});
}
// The row goes first: an asset nothing can select is the state that matters, and an object left
// behind by a storage outage is inert rather than dangerous.
await input.store.deleteImage(input.assetId);
const bucket = nativeImageBucket(input.storage);
const keys = [
existing.originalObjectKey,
...existing.renditions.map((rendition) => rendition.objectKey),
];
await Promise.all(keys.map((key) => bucket.delete(key).catch(() => undefined)));
}