163 lines
6.7 KiB
TypeScript
163 lines
6.7 KiB
TypeScript
import type { ErrorCode } from "./error";
|
|
|
|
export const SEQUENCER_MEDIA_REVISION_SCHEMA = 1 as const;
|
|
export type SequencerMediaOperation = "SEEK" | "SCRUB" | "DECODE";
|
|
|
|
export interface SequencerMediaRevisionStateIR {
|
|
schemaVersion: typeof SEQUENCER_MEDIA_REVISION_SCHEMA;
|
|
timelineId: string;
|
|
timelineRevision: number;
|
|
latestRequestRevision: number;
|
|
}
|
|
|
|
export interface SequencerMediaRevisionRequestIR {
|
|
schemaVersion: typeof SEQUENCER_MEDIA_REVISION_SCHEMA;
|
|
requestId: string;
|
|
timelineId: string;
|
|
timelineRevision: number;
|
|
requestRevision: number;
|
|
operation: SequencerMediaOperation;
|
|
frame: number;
|
|
}
|
|
|
|
export interface SequencerMediaRevisionResultIR extends SequencerMediaRevisionRequestIR {
|
|
status: "COMPLETED";
|
|
sourceFrame: number;
|
|
payloadSha256: string;
|
|
}
|
|
|
|
export interface SequencerMediaRevisionDecisionIR {
|
|
status: "PUBLISH" | "STALE";
|
|
code: ErrorCode | null;
|
|
operation: SequencerMediaOperation;
|
|
requestRevision: number;
|
|
}
|
|
|
|
export class SequencerMediaRevisionValidationError extends Error {
|
|
readonly code: ErrorCode;
|
|
|
|
constructor(code: ErrorCode, message: string) {
|
|
super(`${code}: ${message}`);
|
|
this.name = "SequencerMediaRevisionValidationError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
const ID = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,127}$/;
|
|
const OPERATIONS = new Set<SequencerMediaOperation>(["SEEK", "SCRUB", "DECODE"]);
|
|
const STATE_KEYS = new Set(["schemaVersion", "timelineId", "timelineRevision", "latestRequestRevision"]);
|
|
const REQUEST_KEYS = new Set([
|
|
"schemaVersion", "requestId", "timelineId", "timelineRevision", "requestRevision", "operation", "frame",
|
|
]);
|
|
const RESULT_KEYS = new Set([...REQUEST_KEYS, "status", "sourceFrame", "payloadSha256"]);
|
|
|
|
function record(value: unknown, label: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", `${label} must be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exactKeys(value: Record<string, unknown>, keys: ReadonlySet<string>, label: string): void {
|
|
const actual = Object.keys(value);
|
|
if (actual.length !== keys.size || actual.some((key) => !keys.has(key))) {
|
|
throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", `${label} fields are invalid`);
|
|
}
|
|
}
|
|
|
|
function identity(value: unknown, label: string): string {
|
|
if (typeof value !== "string" || !ID.test(value)) {
|
|
throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", `${label} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function integer(value: unknown, label: string, maximum = Number.MAX_SAFE_INTEGER): number {
|
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
|
throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", `${label} is outside the bounded range`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function parseSequencerMediaRevisionState(value: unknown): SequencerMediaRevisionStateIR {
|
|
const state = record(value, "Sequencer media revision state");
|
|
exactKeys(state, STATE_KEYS, "Sequencer media revision state");
|
|
if (state.schemaVersion !== SEQUENCER_MEDIA_REVISION_SCHEMA) {
|
|
throw new SequencerMediaRevisionValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer media revision schema");
|
|
}
|
|
return {
|
|
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
|
|
timelineId: identity(state.timelineId, "timelineId"),
|
|
timelineRevision: integer(state.timelineRevision, "timelineRevision"),
|
|
latestRequestRevision: integer(state.latestRequestRevision, "latestRequestRevision"),
|
|
};
|
|
}
|
|
|
|
export function parseSequencerMediaRevisionRequest(value: unknown): SequencerMediaRevisionRequestIR {
|
|
const request = record(value, "Sequencer media revision request");
|
|
exactKeys(request, REQUEST_KEYS, "Sequencer media revision request");
|
|
if (request.schemaVersion !== SEQUENCER_MEDIA_REVISION_SCHEMA) {
|
|
throw new SequencerMediaRevisionValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer media revision schema");
|
|
}
|
|
if (!OPERATIONS.has(request.operation as SequencerMediaOperation)) {
|
|
throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", "Sequencer media operation is invalid");
|
|
}
|
|
return {
|
|
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
|
|
requestId: identity(request.requestId, "requestId"),
|
|
timelineId: identity(request.timelineId, "timelineId"),
|
|
timelineRevision: integer(request.timelineRevision, "timelineRevision"),
|
|
requestRevision: integer(request.requestRevision, "requestRevision"),
|
|
operation: request.operation as SequencerMediaOperation,
|
|
frame: integer(request.frame, "frame", 1_000_000),
|
|
};
|
|
}
|
|
|
|
export function parseSequencerMediaRevisionResult(value: unknown): SequencerMediaRevisionResultIR {
|
|
const result = record(value, "Sequencer media revision result");
|
|
exactKeys(result, RESULT_KEYS, "Sequencer media revision result");
|
|
const request = parseSequencerMediaRevisionRequest({
|
|
schemaVersion: result.schemaVersion,
|
|
requestId: result.requestId,
|
|
timelineId: result.timelineId,
|
|
timelineRevision: result.timelineRevision,
|
|
requestRevision: result.requestRevision,
|
|
operation: result.operation,
|
|
frame: result.frame,
|
|
});
|
|
if (result.status !== "COMPLETED" || typeof result.payloadSha256 !== "string" || !SHA256.test(result.payloadSha256)) {
|
|
throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", "Sequencer media completed result is invalid");
|
|
}
|
|
return {
|
|
...request,
|
|
status: "COMPLETED",
|
|
sourceFrame: integer(result.sourceFrame, "sourceFrame", 1_000_000),
|
|
payloadSha256: result.payloadSha256,
|
|
};
|
|
}
|
|
|
|
function sameRequest(request: SequencerMediaRevisionRequestIR, result: SequencerMediaRevisionResultIR): boolean {
|
|
return request.requestId === result.requestId && request.timelineId === result.timelineId &&
|
|
request.timelineRevision === result.timelineRevision && request.requestRevision === result.requestRevision &&
|
|
request.operation === result.operation && request.frame === result.frame;
|
|
}
|
|
|
|
export function gateSequencerMediaRevision(
|
|
requestValue: unknown,
|
|
stateValue: unknown,
|
|
resultValue: unknown,
|
|
): SequencerMediaRevisionDecisionIR {
|
|
const request = parseSequencerMediaRevisionRequest(requestValue);
|
|
const state = parseSequencerMediaRevisionState(stateValue);
|
|
const result = parseSequencerMediaRevisionResult(resultValue);
|
|
const stale = !sameRequest(request, result) || request.timelineId !== state.timelineId ||
|
|
request.timelineRevision !== state.timelineRevision || request.requestRevision !== state.latestRequestRevision;
|
|
return {
|
|
status: stale ? "STALE" : "PUBLISH",
|
|
code: stale ? "REVISION_CONFLICT" : null,
|
|
operation: request.operation,
|
|
requestRevision: request.requestRevision,
|
|
};
|
|
}
|