229 lines
11 KiB
TypeScript
229 lines
11 KiB
TypeScript
import type { ErrorCode } from "./error";
|
|
|
|
export const LIBRARY_LINKED_MISSING_SCHEMA = 1 as const;
|
|
export const LINKED_MISSING_OPERATION = "MARK_MISSING" as const;
|
|
|
|
export interface MissingLibraryPlaceholderIR {
|
|
kind: "MISSING_LIBRARY";
|
|
sourceLibraryId: string;
|
|
dataBlockIds: string[];
|
|
}
|
|
|
|
export interface LinkedLibraryReferenceIR {
|
|
sourceLibraryId: string;
|
|
sourceLocator: string;
|
|
sourceSha256: string;
|
|
sourceGeneration: number;
|
|
sourceRevision: number;
|
|
dataBlockIds: string[];
|
|
status: "AVAILABLE" | "MISSING";
|
|
placeholder: MissingLibraryPlaceholderIR | null;
|
|
}
|
|
|
|
export interface LinkedMissingStateIR {
|
|
schemaVersion: typeof LIBRARY_LINKED_MISSING_SCHEMA;
|
|
references: LinkedLibraryReferenceIR[];
|
|
}
|
|
|
|
export interface LinkedMissingRequestIR {
|
|
schemaVersion: typeof LIBRARY_LINKED_MISSING_SCHEMA;
|
|
operation: typeof LINKED_MISSING_OPERATION;
|
|
sourceLibraryId: string;
|
|
sourceLocator: string;
|
|
sourceSha256: string;
|
|
expectedGeneration: number;
|
|
expectedRevision: number;
|
|
}
|
|
|
|
export interface LinkedMissingDecisionIR {
|
|
status: "MARKED" | "STALE";
|
|
code: ErrorCode | null;
|
|
sourceLibraryId: string;
|
|
state: LinkedMissingStateIR;
|
|
}
|
|
|
|
export class LinkedMissingValidationError extends Error {
|
|
readonly code: ErrorCode;
|
|
readonly path?: string;
|
|
|
|
constructor(code: ErrorCode, message: string, path?: string) {
|
|
super(`${code}: ${message}`);
|
|
this.name = "LinkedMissingValidationError";
|
|
this.code = code;
|
|
this.path = path;
|
|
}
|
|
}
|
|
|
|
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
const SOURCE_LOCATOR = /^[^\u0000\r\n]{1,4096}$/;
|
|
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
|
|
const MAX_REFERENCES = 10_000;
|
|
const MAX_DATA_BLOCKS = 256;
|
|
|
|
function record(value: unknown, path: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} must be an object`, path);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
|
|
const actual = Object.keys(value).sort();
|
|
const allowed = [...expected].sort();
|
|
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} contains undeclared fields`, path);
|
|
}
|
|
}
|
|
|
|
function integer(value: unknown, path: string): number {
|
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} must be a safe integer >= 0`, path);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function libraryId(value: unknown, path: string): string {
|
|
if (typeof value !== "string" || !LIBRARY_ID.test(value)) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} must be a library identity`, path);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function digest(value: unknown, path: string): string {
|
|
if (typeof value !== "string" || !SHA256.test(value)) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} must be a lowercase SHA-256 digest`, path);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function locator(value: unknown, path: string): string {
|
|
if (typeof value !== "string" || !SOURCE_LOCATOR.test(value)) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} is outside the source locator budget`, path);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function dataBlockIds(value: unknown, path: string): string[] {
|
|
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_DATA_BLOCKS ||
|
|
value.some((item) => typeof item !== "string" || !DATA_BLOCK_ID.test(item))) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} is outside its bounded ID list`, path);
|
|
}
|
|
const result = [...value] as string[];
|
|
if (new Set(result).size !== result.length) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} contains duplicate IDs`, path);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parsePlaceholder(value: unknown, path: string): MissingLibraryPlaceholderIR | null {
|
|
if (value === null) return null;
|
|
const placeholder = record(value, path);
|
|
exactKeys(placeholder, ["kind", "sourceLibraryId", "dataBlockIds"], path);
|
|
if (placeholder.kind !== "MISSING_LIBRARY") {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path}.kind is invalid`, `${path}.kind`);
|
|
}
|
|
return {
|
|
kind: "MISSING_LIBRARY",
|
|
sourceLibraryId: libraryId(placeholder.sourceLibraryId, `${path}.sourceLibraryId`),
|
|
dataBlockIds: dataBlockIds(placeholder.dataBlockIds, `${path}.dataBlockIds`),
|
|
};
|
|
}
|
|
|
|
export function parseLinkedLibraryReference(value: unknown, path = "reference"): LinkedLibraryReferenceIR {
|
|
const reference = record(value, path);
|
|
exactKeys(reference, ["sourceLibraryId", "sourceLocator", "sourceSha256", "sourceGeneration", "sourceRevision", "dataBlockIds", "status", "placeholder"], path);
|
|
const sourceLibraryId = libraryId(reference.sourceLibraryId, `${path}.sourceLibraryId`);
|
|
const sourceLocator = locator(reference.sourceLocator, `${path}.sourceLocator`);
|
|
const sourceSha256 = digest(reference.sourceSha256, `${path}.sourceSha256`);
|
|
const sourceGeneration = integer(reference.sourceGeneration, `${path}.sourceGeneration`);
|
|
const sourceRevision = integer(reference.sourceRevision, `${path}.sourceRevision`);
|
|
const ids = dataBlockIds(reference.dataBlockIds, `${path}.dataBlockIds`);
|
|
if (reference.status !== "AVAILABLE" && reference.status !== "MISSING") {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path}.status is invalid`, `${path}.status`);
|
|
}
|
|
const placeholder = parsePlaceholder(reference.placeholder, `${path}.placeholder`);
|
|
if (reference.status === "AVAILABLE" && placeholder !== null) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} available reference cannot have a placeholder`, path);
|
|
}
|
|
if (reference.status === "MISSING" && (placeholder === null || placeholder.sourceLibraryId !== sourceLibraryId ||
|
|
placeholder.dataBlockIds.length !== ids.length || placeholder.dataBlockIds.some((id, index) => id !== ids[index]))) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", `${path} missing placeholder must preserve the source IDs`, path);
|
|
}
|
|
return { sourceLibraryId, sourceLocator, sourceSha256, sourceGeneration, sourceRevision, dataBlockIds: ids, status: reference.status, placeholder };
|
|
}
|
|
|
|
export function parseLinkedMissingState(value: unknown): LinkedMissingStateIR {
|
|
const state = record(value, "state");
|
|
exactKeys(state, ["schemaVersion", "references"], "state");
|
|
if (state.schemaVersion !== LIBRARY_LINKED_MISSING_SCHEMA) {
|
|
throw new LinkedMissingValidationError("PROTOCOL_MISMATCH", "Unsupported linked missing-library state schema", "schemaVersion");
|
|
}
|
|
if (!Array.isArray(state.references) || state.references.length > MAX_REFERENCES) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", "state.references exceeds its bounded range", "references");
|
|
}
|
|
const references = state.references.map((item, index) => parseLinkedLibraryReference(item, `state.references[${index}]`));
|
|
const identities = references.map((item) => `${item.sourceLibraryId}:${item.sourceGeneration}`);
|
|
if (new Set(identities).size !== identities.length) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", "state contains duplicate source generations", "references");
|
|
}
|
|
return { schemaVersion: LIBRARY_LINKED_MISSING_SCHEMA, references };
|
|
}
|
|
|
|
export function parseLinkedMissingRequest(value: unknown): LinkedMissingRequestIR {
|
|
const request = record(value, "request");
|
|
exactKeys(request, ["schemaVersion", "operation", "sourceLibraryId", "sourceLocator", "sourceSha256", "expectedGeneration", "expectedRevision"], "request");
|
|
if (request.schemaVersion !== LIBRARY_LINKED_MISSING_SCHEMA) {
|
|
throw new LinkedMissingValidationError("PROTOCOL_MISMATCH", "Unsupported linked missing-library request schema", "schemaVersion");
|
|
}
|
|
if (request.operation !== LINKED_MISSING_OPERATION) {
|
|
throw new LinkedMissingValidationError("TASK_VALIDATION_FAILED", "missing-library operation is invalid", "operation");
|
|
}
|
|
return {
|
|
schemaVersion: LIBRARY_LINKED_MISSING_SCHEMA,
|
|
operation: LINKED_MISSING_OPERATION,
|
|
sourceLibraryId: libraryId(request.sourceLibraryId, "request.sourceLibraryId"),
|
|
sourceLocator: locator(request.sourceLocator, "request.sourceLocator"),
|
|
sourceSha256: digest(request.sourceSha256, "request.sourceSha256"),
|
|
expectedGeneration: integer(request.expectedGeneration, "request.expectedGeneration"),
|
|
expectedRevision: integer(request.expectedRevision, "request.expectedRevision"),
|
|
};
|
|
}
|
|
|
|
function cloneState(state: LinkedMissingStateIR): LinkedMissingStateIR {
|
|
return {
|
|
schemaVersion: LIBRARY_LINKED_MISSING_SCHEMA,
|
|
references: state.references.map((reference) => ({
|
|
...reference,
|
|
dataBlockIds: [...reference.dataBlockIds],
|
|
placeholder: reference.placeholder === null ? null : {
|
|
...reference.placeholder,
|
|
dataBlockIds: [...reference.placeholder.dataBlockIds],
|
|
},
|
|
})),
|
|
};
|
|
}
|
|
|
|
export function markLinkedLibraryMissing(stateValue: unknown, requestValue: unknown): LinkedMissingDecisionIR {
|
|
const state = parseLinkedMissingState(stateValue);
|
|
const request = parseLinkedMissingRequest(requestValue);
|
|
const index = state.references.findIndex((reference) =>
|
|
reference.sourceLibraryId === request.sourceLibraryId && reference.sourceGeneration === request.expectedGeneration,
|
|
);
|
|
if (index === -1 || state.references[index].sourceRevision !== request.expectedRevision) {
|
|
return { status: "STALE", code: "REVISION_CONFLICT", sourceLibraryId: request.sourceLibraryId, state: cloneState(state) };
|
|
}
|
|
const current = state.references[index];
|
|
if (current.sourceLocator !== request.sourceLocator || current.sourceSha256 !== request.sourceSha256) {
|
|
return { status: "STALE", code: "ASSET_SOURCE_HASH_MISMATCH", sourceLibraryId: request.sourceLibraryId, state: cloneState(state) };
|
|
}
|
|
const missing: LinkedLibraryReferenceIR = {
|
|
...current,
|
|
status: "MISSING",
|
|
placeholder: { kind: "MISSING_LIBRARY", sourceLibraryId: current.sourceLibraryId, dataBlockIds: [...current.dataBlockIds] },
|
|
dataBlockIds: [...current.dataBlockIds],
|
|
};
|
|
const references = state.references.map((reference, itemIndex) => itemIndex === index ? missing : reference);
|
|
return { status: "MARKED", code: null, sourceLibraryId: request.sourceLibraryId, state: { schemaVersion: 1, references: references.map((reference) => ({ ...reference, dataBlockIds: [...reference.dataBlockIds], placeholder: reference.placeholder === null ? null : { ...reference.placeholder, dataBlockIds: [...reference.placeholder.dataBlockIds] } })) } };
|
|
}
|