223 lines
9.5 KiB
TypeScript
223 lines
9.5 KiB
TypeScript
import type { ErrorCode } from "./error";
|
|
|
|
export const LIBRARY_LINKED_RELOAD_SCHEMA = 1 as const;
|
|
export const LINKED_RELOAD_OPERATION = "RELOAD" as const;
|
|
|
|
export interface LinkedSnapshotDataBlockIR {
|
|
dataBlockId: string;
|
|
owner: "SOURCE_LIBRARY";
|
|
readOnly: true;
|
|
}
|
|
|
|
export interface LinkedSnapshotIR {
|
|
sourceLibraryId: string;
|
|
sourceGeneration: number;
|
|
sourceRevision: number;
|
|
dependencyClosureSha256: string;
|
|
graphSha256: string;
|
|
dataBlocks: LinkedSnapshotDataBlockIR[];
|
|
}
|
|
|
|
export interface LinkedReloadStateIR {
|
|
schemaVersion: typeof LIBRARY_LINKED_RELOAD_SCHEMA;
|
|
snapshots: LinkedSnapshotIR[];
|
|
}
|
|
|
|
export interface LinkedReloadRequestIR {
|
|
schemaVersion: typeof LIBRARY_LINKED_RELOAD_SCHEMA;
|
|
operation: typeof LINKED_RELOAD_OPERATION;
|
|
sourceLibraryId: string;
|
|
expectedGeneration: number;
|
|
expectedRevision: number;
|
|
replacement: LinkedSnapshotIR;
|
|
}
|
|
|
|
export interface LinkedReloadDecisionIR {
|
|
status: "REPLACED" | "STALE";
|
|
code: ErrorCode | null;
|
|
sourceLibraryId: string;
|
|
replacedGeneration: number;
|
|
replacementGeneration: number;
|
|
state: LinkedReloadStateIR;
|
|
}
|
|
|
|
export class LinkedReloadValidationError extends Error {
|
|
readonly code: ErrorCode;
|
|
readonly path?: string;
|
|
|
|
constructor(code: ErrorCode, message: string, path?: string) {
|
|
super(`${code}: ${message}`);
|
|
this.name = "LinkedReloadValidationError";
|
|
this.code = code;
|
|
this.path = path;
|
|
}
|
|
}
|
|
|
|
const LIBRARY_ID = /^library:[a-f0-9]{64}$/;
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
const DATA_BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9:._/ -]{0,255}$/;
|
|
const MAX_SNAPSHOTS = 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 LinkedReloadValidationError("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 keys = Object.keys(value).sort();
|
|
const allowed = [...expected].sort();
|
|
if (keys.length !== allowed.length || keys.some((key, index) => key !== allowed[index])) {
|
|
throw new LinkedReloadValidationError("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 LinkedReloadValidationError("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 LinkedReloadValidationError("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 LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path} must be a lowercase SHA-256 digest`, path);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function dataBlock(value: unknown, path: string): LinkedSnapshotDataBlockIR {
|
|
const item = record(value, path);
|
|
exactKeys(item, ["dataBlockId", "owner", "readOnly"], path);
|
|
if (typeof item.dataBlockId !== "string" || !DATA_BLOCK_ID.test(item.dataBlockId)) {
|
|
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path}.dataBlockId is invalid`, `${path}.dataBlockId`);
|
|
}
|
|
if (item.owner !== "SOURCE_LIBRARY" || item.readOnly !== true) {
|
|
throw new LinkedReloadValidationError("LINKED_DATA_MUTATION_BLOCKED", `${path} must remain source-library/read-only`, path);
|
|
}
|
|
return { dataBlockId: item.dataBlockId, owner: "SOURCE_LIBRARY", readOnly: true };
|
|
}
|
|
|
|
export function parseLinkedSnapshot(value: unknown, path = "snapshot"): LinkedSnapshotIR {
|
|
const snapshot = record(value, path);
|
|
exactKeys(snapshot, ["sourceLibraryId", "sourceGeneration", "sourceRevision", "dependencyClosureSha256", "graphSha256", "dataBlocks"], path);
|
|
if (!Array.isArray(snapshot.dataBlocks) || snapshot.dataBlocks.length === 0 || snapshot.dataBlocks.length > MAX_DATA_BLOCKS) {
|
|
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path}.dataBlocks is outside its bounded range`, `${path}.dataBlocks`);
|
|
}
|
|
const dataBlocks = snapshot.dataBlocks.map((item, index) => dataBlock(item, `${path}.dataBlocks[${index}]`));
|
|
if (new Set(dataBlocks.map((item) => item.dataBlockId)).size !== dataBlocks.length) {
|
|
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", `${path}.dataBlocks contains duplicate IDs`, `${path}.dataBlocks`);
|
|
}
|
|
return {
|
|
sourceLibraryId: libraryId(snapshot.sourceLibraryId, `${path}.sourceLibraryId`),
|
|
sourceGeneration: integer(snapshot.sourceGeneration, `${path}.sourceGeneration`),
|
|
sourceRevision: integer(snapshot.sourceRevision, `${path}.sourceRevision`),
|
|
dependencyClosureSha256: digest(snapshot.dependencyClosureSha256, `${path}.dependencyClosureSha256`),
|
|
graphSha256: digest(snapshot.graphSha256, `${path}.graphSha256`),
|
|
dataBlocks,
|
|
};
|
|
}
|
|
|
|
export function parseLinkedReloadState(value: unknown): LinkedReloadStateIR {
|
|
const state = record(value, "state");
|
|
exactKeys(state, ["schemaVersion", "snapshots"], "state");
|
|
if (state.schemaVersion !== LIBRARY_LINKED_RELOAD_SCHEMA) {
|
|
throw new LinkedReloadValidationError("PROTOCOL_MISMATCH", "Unsupported linked reload state schema", "schemaVersion");
|
|
}
|
|
if (!Array.isArray(state.snapshots) || state.snapshots.length > MAX_SNAPSHOTS) {
|
|
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", "state.snapshots exceeds its bounded range", "snapshots");
|
|
}
|
|
const snapshots = state.snapshots.map((item, index) => parseLinkedSnapshot(item, `state.snapshots[${index}]`));
|
|
const identities = snapshots.map((item) => `${item.sourceLibraryId}:${item.sourceGeneration}`);
|
|
if (new Set(identities).size !== identities.length) {
|
|
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", "state contains duplicate library generations", "snapshots");
|
|
}
|
|
return { schemaVersion: LIBRARY_LINKED_RELOAD_SCHEMA, snapshots };
|
|
}
|
|
|
|
export function parseLinkedReloadRequest(value: unknown): LinkedReloadRequestIR {
|
|
const request = record(value, "request");
|
|
exactKeys(request, ["schemaVersion", "operation", "sourceLibraryId", "expectedGeneration", "expectedRevision", "replacement"], "request");
|
|
if (request.schemaVersion !== LIBRARY_LINKED_RELOAD_SCHEMA) {
|
|
throw new LinkedReloadValidationError("PROTOCOL_MISMATCH", "Unsupported linked reload request schema", "schemaVersion");
|
|
}
|
|
if (request.operation !== LINKED_RELOAD_OPERATION) {
|
|
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", "linked reload operation is invalid", "operation");
|
|
}
|
|
const replacement = parseLinkedSnapshot(request.replacement, "request.replacement");
|
|
const sourceLibraryId = libraryId(request.sourceLibraryId, "request.sourceLibraryId");
|
|
if (replacement.sourceLibraryId !== sourceLibraryId) {
|
|
throw new LinkedReloadValidationError("TASK_VALIDATION_FAILED", "replacement must retain the requested source library", "replacement.sourceLibraryId");
|
|
}
|
|
return {
|
|
schemaVersion: LIBRARY_LINKED_RELOAD_SCHEMA,
|
|
operation: LINKED_RELOAD_OPERATION,
|
|
sourceLibraryId,
|
|
expectedGeneration: integer(request.expectedGeneration, "request.expectedGeneration"),
|
|
expectedRevision: integer(request.expectedRevision, "request.expectedRevision"),
|
|
replacement,
|
|
};
|
|
}
|
|
|
|
function cloneState(state: LinkedReloadStateIR): LinkedReloadStateIR {
|
|
return {
|
|
schemaVersion: LIBRARY_LINKED_RELOAD_SCHEMA,
|
|
snapshots: state.snapshots.map((snapshot) => ({
|
|
...snapshot,
|
|
dataBlocks: snapshot.dataBlocks.map((dataBlock) => ({ ...dataBlock })),
|
|
})),
|
|
};
|
|
}
|
|
|
|
export function reloadMatchingLinkedSnapshot(stateValue: unknown, requestValue: unknown): LinkedReloadDecisionIR {
|
|
const state = parseLinkedReloadState(stateValue);
|
|
const request = parseLinkedReloadRequest(requestValue);
|
|
const matchingIndex = state.snapshots.findIndex((snapshot) =>
|
|
snapshot.sourceLibraryId === request.sourceLibraryId && snapshot.sourceGeneration === request.expectedGeneration,
|
|
);
|
|
if (matchingIndex === -1 || state.snapshots[matchingIndex].sourceRevision !== request.expectedRevision) {
|
|
return {
|
|
status: "STALE",
|
|
code: "REVISION_CONFLICT",
|
|
sourceLibraryId: request.sourceLibraryId,
|
|
replacedGeneration: request.expectedGeneration,
|
|
replacementGeneration: request.replacement.sourceGeneration,
|
|
state: cloneState(state),
|
|
};
|
|
}
|
|
const current = state.snapshots[matchingIndex];
|
|
if (request.replacement.sourceLibraryId !== request.sourceLibraryId ||
|
|
request.replacement.sourceGeneration !== request.expectedGeneration + 1 ||
|
|
request.replacement.sourceRevision <= current.sourceRevision) {
|
|
return {
|
|
status: "STALE",
|
|
code: "REVISION_CONFLICT",
|
|
sourceLibraryId: request.sourceLibraryId,
|
|
replacedGeneration: request.expectedGeneration,
|
|
replacementGeneration: request.replacement.sourceGeneration,
|
|
state: cloneState(state),
|
|
};
|
|
}
|
|
const snapshots = state.snapshots.map((snapshot, index) => index === matchingIndex ? request.replacement : snapshot);
|
|
return {
|
|
status: "REPLACED",
|
|
code: null,
|
|
sourceLibraryId: request.sourceLibraryId,
|
|
replacedGeneration: current.sourceGeneration,
|
|
replacementGeneration: request.replacement.sourceGeneration,
|
|
state: { schemaVersion: LIBRARY_LINKED_RELOAD_SCHEMA, snapshots: snapshots.map((snapshot) => ({
|
|
...snapshot,
|
|
dataBlocks: snapshot.dataBlocks.map((dataBlock) => ({ ...dataBlock })),
|
|
})) },
|
|
};
|
|
}
|