Store recovery journal deltas between verified checkpoints
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import type { Json } from '../canvas-core/types.ts';
|
||||
import { fault, validJson } from '../canvas-core/values.ts';
|
||||
|
||||
type Path = readonly (string | number)[];
|
||||
export type JournalPatch = {op:'set';path:Path;value:Json}|{op:'splice';path:Path;start:number;deleteCount:number;items:Json[]};
|
||||
/** Exact JSON property order is retained because recovery checksums cover serialized bytes. */
|
||||
export function diffJournal(before:Json,after:Json):JournalPatch[]|null {
|
||||
const patches:JournalPatch[]=[];
|
||||
function visit(a:Json,b:Json,path:(string|number)[]){
|
||||
if(a===b||patches.length>10000)return;
|
||||
if(Array.isArray(a)&&Array.isArray(b)){
|
||||
const common=Math.min(a.length,b.length);for(let i=0;i<common;i++)visit(a[i],b[i],[...path,i]);
|
||||
if(a.length!==b.length)patches.push({op:'splice',path,start:common,deleteCount:a.length-common,items:b.slice(common)});
|
||||
}else if(a&&b&&typeof a==='object'&&typeof b==='object'&&!Array.isArray(a)&&!Array.isArray(b)){
|
||||
const left=Object.keys(a),right=Object.keys(b);
|
||||
if(left.length===right.length&&left.every((key,i)=>key===right[i]))for(const key of right)visit(a[key],b[key],[...path,key]);
|
||||
else patches.push({op:'set',path,value:b});
|
||||
}else patches.push({op:'set',path,value:b});
|
||||
}
|
||||
visit(before,after,[]);return patches.length>10000?null:patches;
|
||||
}
|
||||
/** Copy only changed ancestors; never mutate the preceding checkpoint or another patch's input. */
|
||||
export function applyJournal(before:Json,patches:readonly JournalPatch[]):Json {
|
||||
if(!Array.isArray(patches)||patches.length>10000)throw fault('corrupt_recovery_delta');
|
||||
let result=before;
|
||||
for(const patch of patches){
|
||||
if(!patch||!['set','splice'].includes(patch.op)||!Array.isArray(patch.path)||patch.path.length>64||patch.path.some((key:unknown)=>typeof key!=='string'&&(typeof key!=='number'||!Number.isSafeInteger(key)||key<0)))throw fault('corrupt_recovery_delta');
|
||||
if(patch.op==='set'?!validJson(patch.value):!Array.isArray(patch.items)||!validJson(patch.items))throw fault('corrupt_recovery_delta');
|
||||
function replace(value:Json,depth:number):Json {
|
||||
if(depth===patch.path.length){
|
||||
if(patch.op==='set')return patch.value;
|
||||
if(!Array.isArray(value)||!Number.isSafeInteger(patch.start)||!Number.isSafeInteger(patch.deleteCount)||patch.start<0||patch.deleteCount<0||patch.start>value.length||patch.deleteCount>value.length-patch.start)throw fault('corrupt_recovery_delta');
|
||||
return [...value.slice(0,patch.start),...patch.items,...value.slice(patch.start+patch.deleteCount)];
|
||||
}
|
||||
const key=patch.path[depth];
|
||||
if(!value||typeof value!=='object'||!Object.hasOwn(value,key)||(Array.isArray(value)?typeof key!=='number'||key>=value.length:typeof key!=='string'))throw fault('corrupt_recovery_delta');
|
||||
const copy=Array.isArray(value)?[...value]:{...value};
|
||||
Object.defineProperty(copy,key,{value:replace((value as Record<string|number,Json>)[key],depth+1),enumerable:true,writable:true,configurable:true});return copy;
|
||||
}
|
||||
result=replace(result,0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { applyJournal, diffJournal, type JournalPatch } from './journal-delta.ts';
|
||||
import { isSealedRecoveryImage } from './recovery-encoding.ts';
|
||||
import type { Json } from '../canvas-core/types.ts';
|
||||
import { clone, equal, fault, validJson } from '../canvas-core/values.ts';
|
||||
import { clone, equal, fault, freeze, validJson } from '../canvas-core/values.ts';
|
||||
|
||||
export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string };
|
||||
export type RecoveryImage = { readonly formatVersion: 1 | 2; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
|
||||
@@ -15,9 +16,17 @@ export type StoragePort = {
|
||||
};
|
||||
type Metadata = { fence: number; throughRevision?: number; checkpointRevision?: number };
|
||||
type StoredImage = { readonly image: RecoveryImage; readonly checksum: string };
|
||||
async function checksum(image: RecoveryImage) {
|
||||
type StoredDelta = {journalVersion:1;baseRevision:number;baseChecksum:string;header:Omit<RecoveryImage,'payload'>;patches:JournalPatch[];checksum:string};
|
||||
type StoredCut = StoredImage | StoredDelta;
|
||||
function cutHeader(cut:StoredCut):Omit<RecoveryImage,'payload'>{
|
||||
if(!cut||typeof cut!=='object')throw fault('corrupt_recovery_image');
|
||||
if('image' in cut){validateImage(cut.image);return cut.image;}
|
||||
if(cut.journalVersion!==1)throw fault('recovery_delta_interpreter_unavailable');
|
||||
if(!cut.header)throw fault('corrupt_recovery_delta');validateImage({...cut.header,payload:null});return cut.header;
|
||||
}
|
||||
async function imageDigest(image: RecoveryImage) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(image));
|
||||
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(value => value.toString(16).padStart(2, '0')).join('');
|
||||
return {checksum:[...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(value => value.toString(16).padStart(2, '0')).join(''),byteLength:bytes.byteLength};
|
||||
}
|
||||
const keyFor = (scope: StorageScope) => {
|
||||
if (![scope.accountId, scope.workspaceId, scope.projectId].every(value => typeof value === 'string' && value.length > 0)) throw fault('invalid_storage_scope');
|
||||
@@ -37,9 +46,9 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?:
|
||||
const checkpointEvery = options.checkpointEvery ?? 32;
|
||||
if (!Number.isSafeInteger(checkpointEvery) || checkpointEvery < 1) throw fault('invalid_checkpoint_interval');
|
||||
let closed = false;
|
||||
const handles = new Set<StorageLeader>();
|
||||
const handles = new Set<StorageLeader>(), lastCuts=new WeakMap<StorageLeader,StoredImage>(), writeTails=new WeakMap<StorageLeader,Promise<unknown>>();
|
||||
const opening = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = factory.open(options.name ?? 'endoleap-headless', 1);
|
||||
const request = factory.open(options.name ?? 'endoleap-headless', 2);
|
||||
request.onupgradeneeded = () => {
|
||||
for (const name of ['metadata', 'checkpoints', 'journal', 'files']) if (!request.result.objectStoreNames.contains(name)) request.result.createObjectStore(name);
|
||||
};
|
||||
@@ -71,6 +80,27 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?:
|
||||
return result;
|
||||
} catch (error) { try { transaction.abort(); } catch { /* Already terminal. */ } await done.catch(() => {}); throw error; }
|
||||
}
|
||||
async function recoverCut(leader:StorageLeader):Promise<StoredImage|null>{
|
||||
const cuts=await execute(leader,['checkpoints','journal'],'readonly',async(transaction,metadata,key)=>{
|
||||
if(metadata.throughRevision===undefined)return [];
|
||||
if(metadata.checkpointRevision===undefined)throw fault('recovery_checkpoint_missing');
|
||||
const checkpoint=await requestValue<StoredImage|undefined>(transaction.objectStore('checkpoints').get([key,metadata.checkpointRevision]));
|
||||
if(!checkpoint?.image)throw fault('recovery_checkpoint_missing');
|
||||
let header=cutHeader(checkpoint);if(header.rootRevision!==metadata.checkpointRevision)throw fault('corrupt_recovery_image');
|
||||
const journal=metadata.checkpointRevision===metadata.throughRevision?[]:await requestValue<StoredCut[]>(transaction.objectStore('journal').getAll(IDBKeyRange.bound([key,metadata.checkpointRevision+1],[key,metadata.throughRevision])));
|
||||
for(const row of journal){const next=cutHeader(row);if(next.rootRevision!==header.rootRevision+1||next.localRevision<header.localRevision)throw fault('recovery_journal_gap');header=next;}
|
||||
if(header.rootRevision!==metadata.throughRevision)throw fault('recovery_journal_gap');return [checkpoint,...journal];
|
||||
});
|
||||
let previous:StoredImage|null=null;
|
||||
// Hash and apply after the readonly transaction; awaiting crypto inside IDB would close it.
|
||||
for(const cut of cuts){
|
||||
let image:RecoveryImage;
|
||||
if('image' in cut)image=cut.image;
|
||||
else{if(!previous||cut.baseRevision!==previous.image.rootRevision||cut.baseChecksum!==previous.checksum)throw fault('recovery_delta_base_mismatch');image={...cut.header,payload:applyJournal(previous.image.payload,cut.patches)};validateImage(image);}
|
||||
if(cut.checksum!==(await imageDigest(image)).checksum)throw fault('recovery_checksum_mismatch');previous=freeze({image,checksum:cut.checksum});
|
||||
}
|
||||
check(leader);if(previous&&(!lastCuts.has(leader)||lastCuts.get(leader)!.image.rootRevision<=previous.image.rootRevision))lastCuts.set(leader,previous);return previous;
|
||||
}
|
||||
return Object.freeze({
|
||||
async claim(scope: StorageScope) {
|
||||
const key = keyFor(scope);
|
||||
@@ -97,53 +127,36 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?:
|
||||
void held.catch(failed);
|
||||
return result;
|
||||
},
|
||||
async recover(leader: StorageLeader) {
|
||||
const images = await execute(leader, ['checkpoints', 'journal'], 'readonly', async (transaction, metadata, key) => {
|
||||
if (metadata.throughRevision === undefined) return [];
|
||||
if (metadata.checkpointRevision === undefined) throw fault('recovery_checkpoint_missing');
|
||||
const checkpoint = await requestValue<StoredImage | undefined>(transaction.objectStore('checkpoints').get([key, metadata.checkpointRevision]));
|
||||
if (!checkpoint?.image) throw fault('recovery_checkpoint_missing');
|
||||
let image = checkpoint.image;
|
||||
validateImage(image);
|
||||
if (image.rootRevision !== metadata.checkpointRevision) throw fault('corrupt_recovery_image');
|
||||
const journal = metadata.checkpointRevision === metadata.throughRevision ? [] : await requestValue<StoredImage[]>(transaction.objectStore('journal').getAll(IDBKeyRange.bound([key, metadata.checkpointRevision + 1], [key, metadata.throughRevision])));
|
||||
for (const next of journal) { validateImage(next.image); if (next.image.rootRevision !== image.rootRevision + 1 || next.image.localRevision < image.localRevision) throw fault('recovery_journal_gap'); image = next.image; }
|
||||
if (image.rootRevision !== metadata.throughRevision) throw fault('recovery_journal_gap');
|
||||
return [checkpoint, ...journal];
|
||||
});
|
||||
// Hashing must run after the IDB transaction, which would otherwise auto-close while awaiting it.
|
||||
for (const stored of images) if (stored.checksum !== await checksum(stored.image)) throw fault('recovery_checksum_mismatch');
|
||||
check(leader);
|
||||
return images.at(-1)?.image ?? null;
|
||||
},
|
||||
async recover(leader: StorageLeader) {const cut=await recoverCut(leader);return cut?clone(cut.image):null;},
|
||||
async commit(leader: StorageLeader, batch: DurableBatch) {
|
||||
// Copy before awaiting: callers cannot change the durable cut while a transaction queues.
|
||||
const image = isSealedRecoveryImage(batch.image) ? batch.image : clone(batch.image), files = (batch.files ?? []).map(file => ({id:file.id,bytes:file.bytes})); if(!isSealedRecoveryImage(image))validateImage(image);
|
||||
const stored: StoredImage = { image, checksum: await checksum(image) };
|
||||
return execute(leader, ['checkpoints', 'journal', 'files'], 'readwrite', async (transaction, metadata, key) => {
|
||||
const previous = metadata.throughRevision;
|
||||
if (previous !== undefined && image.rootRevision !== previous + 1) {
|
||||
if (image.rootRevision !== previous) throw fault('storage_revision_gap');
|
||||
const saved = await requestValue<StoredImage>(transaction.objectStore(metadata.checkpointRevision === previous ? 'checkpoints' : 'journal').get([key, previous]));
|
||||
if (!equal(saved, stored) || files.length) throw fault('storage_revision_conflict');
|
||||
return { durableRootRevision: previous };
|
||||
// Detach mutable callers and descriptors before the first await, including queued commits.
|
||||
const image=isSealedRecoveryImage(batch.image)?batch.image:freeze(clone(batch.image)),files=(batch.files??[]).map(file=>({id:file.id,bytes:file.bytes}));if(!isSealedRecoveryImage(image))validateImage(image);check(leader);
|
||||
const task=(writeTails.get(leader)??Promise.resolve()).catch(()=>{}).then(async()=>{
|
||||
let base=lastCuts.get(leader)??await recoverCut(leader);
|
||||
if(base?.image.rootRevision===image.rootRevision)base=await recoverCut(leader);
|
||||
const digest=await imageDigest(image),stored:StoredImage={image,checksum:digest.checksum};let candidate:StoredCut=stored;
|
||||
if(base&&image.rootRevision===base.image.rootRevision+1){
|
||||
const patches=diffJournal(base.image.payload,image.payload);
|
||||
if(patches){const delta:StoredDelta={journalVersion:1,baseRevision:base.image.rootRevision,baseChecksum:base.checksum,header:{formatVersion:image.formatVersion,rootRevision:image.rootRevision,localRevision:image.localRevision},patches,checksum:digest.checksum};if(new TextEncoder().encode(JSON.stringify(delta)).byteLength<digest.byteLength)candidate=delta;}
|
||||
}
|
||||
if (previous !== undefined && image.localRevision < (await requestValue<StoredImage>(transaction.objectStore(metadata.checkpointRevision === previous ? 'checkpoints' : 'journal').get([key, previous]))).image.localRevision) throw fault('storage_revision_conflict');
|
||||
for (const file of files) {
|
||||
if (!file.id || !(file.bytes instanceof Blob)) throw fault('invalid_staged_file');
|
||||
// Immutable resource identity: replacing bytes requires a new resource ID.
|
||||
if (await requestValue(transaction.objectStore('files').getKey([key, file.id]))) throw fault('resource_identity_conflict', [file.id]);
|
||||
transaction.objectStore('files').put(file.bytes, [key, file.id]);
|
||||
}
|
||||
if (previous === undefined || image.rootRevision - (metadata.checkpointRevision ?? 0) >= checkpointEvery) {
|
||||
transaction.objectStore('checkpoints').put(stored, [key, image.rootRevision]);
|
||||
if (metadata.checkpointRevision !== undefined) transaction.objectStore('checkpoints').delete([key, metadata.checkpointRevision]);
|
||||
transaction.objectStore('journal').delete(IDBKeyRange.bound([key, 0], [key, image.rootRevision]));
|
||||
metadata.checkpointRevision = image.rootRevision;
|
||||
} else transaction.objectStore('journal').put(stored, [key, image.rootRevision]);
|
||||
transaction.objectStore('metadata').put({ ...metadata, throughRevision: image.rootRevision }, key);
|
||||
return { durableRootRevision: image.rootRevision };
|
||||
const receipt=await execute(leader,['checkpoints','journal','files'],'readwrite',async(transaction,metadata,key)=>{
|
||||
const previous=metadata.throughRevision;
|
||||
const saved=previous===undefined?undefined:await requestValue<StoredCut>(transaction.objectStore(metadata.checkpointRevision===previous?'checkpoints':'journal').get([key,previous]));
|
||||
if(previous!==undefined&&image.rootRevision!==previous+1){
|
||||
if(image.rootRevision!==previous)throw fault('storage_revision_gap');
|
||||
if(!saved||saved.checksum!==stored.checksum||!base||!equal(base.image,image)||files.length)throw fault('storage_revision_conflict');return{durableRootRevision:previous};
|
||||
}
|
||||
if(previous!==undefined&&(!saved||image.localRevision<cutHeader(saved).localRevision))throw fault('storage_revision_conflict');
|
||||
if(!('image' in candidate)&&(candidate.baseRevision!==previous||candidate.baseChecksum!==saved?.checksum))throw fault('recovery_delta_base_mismatch');
|
||||
for(const file of files){if(!file.id||!(file.bytes instanceof Blob))throw fault('invalid_staged_file');if(await requestValue(transaction.objectStore('files').getKey([key,file.id])))throw fault('resource_identity_conflict',[file.id]);transaction.objectStore('files').put(file.bytes,[key,file.id]);}
|
||||
if(previous===undefined||image.rootRevision-(metadata.checkpointRevision??0)>=checkpointEvery){
|
||||
transaction.objectStore('checkpoints').put(stored,[key,image.rootRevision]);if(metadata.checkpointRevision!==undefined)transaction.objectStore('checkpoints').delete([key,metadata.checkpointRevision]);transaction.objectStore('journal').delete(IDBKeyRange.bound([key,0],[key,image.rootRevision]));metadata.checkpointRevision=image.rootRevision;
|
||||
}else transaction.objectStore('journal').put(candidate,[key,image.rootRevision]);
|
||||
transaction.objectStore('metadata').put({...metadata,throughRevision:image.rootRevision},key);return{durableRootRevision:image.rootRevision};
|
||||
});
|
||||
lastCuts.set(leader,freeze(stored));return receipt;
|
||||
});
|
||||
writeTails.set(leader,task);return task;
|
||||
},
|
||||
readFile(leader: StorageLeader, id: string) { return execute(leader, ['files'], 'readonly', (transaction, _metadata, key) => requestValue<Blob | undefined>(transaction.objectStore('files').get([key, id]))); },
|
||||
close() { closed = true; for (const handle of handles) void handle.release(); void opening.then(database => database.close()).catch(() => {}); },
|
||||
|
||||
Reference in New Issue
Block a user