feat: initial Signage 0.1.0
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
Reference in New Issue
Block a user