89 lines
3.8 KiB
TypeScript
89 lines
3.8 KiB
TypeScript
/**
|
|
* The durable part of a streamed model response. Deliberately no text or
|
|
* provider payload is accepted here: a checkpoint is a resume cursor, not a
|
|
* second copy of the response.
|
|
*/
|
|
export type CheckpointState = "STREAMING" | "COMPLETED" | "FAILED";
|
|
|
|
export interface AtomicCheckpoint {
|
|
state: CheckpointState;
|
|
byteCount: number;
|
|
chunkCount: number;
|
|
sha256: string;
|
|
}
|
|
|
|
export interface AtomicCheckpointStore {
|
|
writeTemporary(path: string, checkpoint: AtomicCheckpoint): Promise<void> | void;
|
|
renameTemporary(temporaryPath: string, targetPath: string): Promise<void> | void;
|
|
}
|
|
|
|
const CHECKPOINT_KEYS = ["state", "byteCount", "chunkCount", "sha256"] as const;
|
|
const HASH_PATTERN = /^[a-f0-9]{64}$/u;
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
/** Validate and clone so callers cannot mutate a value after the atomic write. */
|
|
export function createAtomicCheckpoint(value: AtomicCheckpoint): AtomicCheckpoint {
|
|
if (!isRecord(value)) throw new TypeError("CHECKPOINT_INVALID: checkpoint must be an object");
|
|
const keys = Object.keys(value).sort();
|
|
const expected = [...CHECKPOINT_KEYS].sort();
|
|
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
|
throw new TypeError("CHECKPOINT_INVALID: checkpoint may contain only state, byteCount, chunkCount and sha256");
|
|
}
|
|
if (!["STREAMING", "COMPLETED", "FAILED"].includes(value.state as string)) {
|
|
throw new TypeError("CHECKPOINT_INVALID: state is invalid");
|
|
}
|
|
if (!Number.isSafeInteger(value.byteCount) || value.byteCount < 0 || !Number.isSafeInteger(value.chunkCount) || value.chunkCount < 0) {
|
|
throw new TypeError("CHECKPOINT_INVALID: counts must be non-negative safe integers");
|
|
}
|
|
if (typeof value.sha256 !== "string" || !HASH_PATTERN.test(value.sha256)) {
|
|
throw new TypeError("CHECKPOINT_INVALID: sha256 must be a lowercase SHA-256 digest");
|
|
}
|
|
return Object.freeze({
|
|
state: value.state as CheckpointState,
|
|
byteCount: value.byteCount,
|
|
chunkCount: value.chunkCount,
|
|
sha256: value.sha256,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Write a checkpoint to a temporary key and publish it with one rename. A
|
|
* failed write never replaces the previously committed checkpoint.
|
|
*/
|
|
export async function commitAtomicCheckpoint(store: AtomicCheckpointStore, targetPath: string, value: AtomicCheckpoint): Promise<AtomicCheckpoint> {
|
|
if (!store || typeof store.writeTemporary !== "function" || typeof store.renameTemporary !== "function") {
|
|
throw new TypeError("CHECKPOINT_STORE_INVALID: atomic write and rename are required");
|
|
}
|
|
if (typeof targetPath !== "string" || targetPath.length === 0) throw new TypeError("CHECKPOINT_TARGET_INVALID: target path is required");
|
|
const checkpoint = createAtomicCheckpoint(value);
|
|
const temporaryPath = `${targetPath}.tmp`;
|
|
await store.writeTemporary(temporaryPath, checkpoint);
|
|
await store.renameTemporary(temporaryPath, targetPath);
|
|
return checkpoint;
|
|
}
|
|
|
|
/** In-memory store useful for recovery tests and callers without OPFS. */
|
|
export function createMemoryCheckpointStore(): AtomicCheckpointStore & { get(path: string): AtomicCheckpoint | undefined } {
|
|
const files = new Map<string, AtomicCheckpoint>();
|
|
return {
|
|
writeTemporary(path, checkpoint) {
|
|
files.set(path, createAtomicCheckpoint(checkpoint));
|
|
},
|
|
renameTemporary(temporaryPath, targetPath) {
|
|
const checkpoint = files.get(temporaryPath);
|
|
if (!checkpoint) throw new Error("CHECKPOINT_RENAME_FAILED: temporary checkpoint is missing");
|
|
files.set(targetPath, checkpoint);
|
|
files.delete(temporaryPath);
|
|
},
|
|
get(path) {
|
|
return files.get(path);
|
|
},
|
|
};
|
|
}
|
|
|
|
export const createCheckpoint = createAtomicCheckpoint;
|
|
export const commitCheckpoint = commitAtomicCheckpoint;
|