Checkpoint web parity through Chromium input tasks
This commit is contained in:
222
web/protocol/library-linked-reload.ts
Normal file
222
web/protocol/library-linked-reload.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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 })),
|
||||
})) },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user