diff --git a/headless/README.md b/headless/README.md index 26e7710..56ccbcf 100644 --- a/headless/README.md +++ b/headless/README.md @@ -720,3 +720,34 @@ probe still exceeded the 240-second completion window. During the copy it accept read-only diagnostics and displayed incremental content; commit/persistence costs remain unresolved at that scale. Do not interpret the initial-scene improvement or the batching checks as a passed 10000-node copy budget. + + +Browser storage writes `journalVersion: 2` deltas whose SHA-256 covers the ordered +JSON object `{journalVersion, baseRevision, baseChecksum, header, patches}`. The +prior checksum may belong to a full checkpoint, a v1 delta's reconstructed image, +or another v2 delta. Checkpoints and full-record fallbacks still hash the entire +image. Readers retain v1's full-image checksum rule and select the interpreter +explicitly; unknown journal versions fail. DB schema remains 4 and RecoveryImage +remains 1–17. This changes the internal journal interpreter, not wire protocol 2. + +The recovery codec records exact UTF-8 byte lengths while producing immutable JSON, +including omission/null normalization and escaped Unicode. Only factory-owned +images expose the cached size, so choosing a smaller delta no longer first +serializes the complete owned image. Mutable callers retain defensive copying +and full size calculation. Noncanonical image header order/fields use a full +record to preserve exact serialization. + +Each leader retains its last verified image/checksum and checkpoint revision in +WeakMaps. Both advance only after transaction completion. Every next cut still +checks stored metadata, prior checksum, continuous revision and local revision; +all file/depot writes remain atomic with the cut. Same-cut retries reread and +validate the chain, compare complete serialized images (including property order), +and keep the existing restrictions on new files. No Root cuts are skipped. +Recovery hashes/applies outside the read transaction, validates every reconstructed +image, and retains original frozen request bytes. A v1-only reader must reject a +v2 row rather than ignore its checksum semantics. + +The 10000-child copy probe still exceeded its 240-second completion window after +this change. The exact hashed-input browser test proves removal of whole-image +hashing for ordinary adjacent delta writes; this is not a passed end-to-end copy +budget or a claim that encoding, rendering and dispatch costs are all resolved. diff --git a/src/lib/canvas-runtime/recovery-encoding.ts b/src/lib/canvas-runtime/recovery-encoding.ts index 61d8001..7027c1f 100644 --- a/src/lib/canvas-runtime/recovery-encoding.ts +++ b/src/lib/canvas-runtime/recovery-encoding.ts @@ -2,22 +2,24 @@ import type { Json } from '../canvas-core/types.ts'; import { fault, freeze, validJson } from '../canvas-core/values.ts'; import type { RecoveryImage } from './storage-port.ts'; -type Encoded = {value:Json|undefined;height:number;reusable:boolean}; -const immutable=new WeakMap(),sealed=new WeakSet(); +type Encoded = {value:Json|undefined;height:number;reusable:boolean;bytes:number}; +const immutable=new WeakMap(),sealed=new WeakSet(),encodedSizes=new WeakMap(),imageSizes=new WeakMap(); +function utf8Length(text:string){let bytes=0;for(let i=0;i=0xd800&&c<=0xdbff&&i+1=0xdc00&&text.charCodeAt(i+1)<=0xdfff){bytes+=4;i++;}else bytes+=3;}return bytes;} +const scalarBytes=(value:unknown)=>utf8Length(JSON.stringify(value)); /** JSON value semantics, with reuse only for transitively frozen plain data properties. */ export function encodeRecoveryValue(input:unknown):Json { const visiting=new Set(); function visit(value:unknown,depth:number,key:string):Encoded { - if(value===null||typeof value==='string'||typeof value==='boolean'){if(depth>64)throw fault('invalid_recovery_payload');return{value,height:0,reusable:true};} - if(typeof value==='number'){if(depth>64)throw fault('invalid_recovery_payload');return{value:Number.isFinite(value)?(Object.is(value,-0)?0:value):null,height:0,reusable:true};} - if(value===undefined||typeof value==='symbol')return{value:undefined,height:0,reusable:true}; + if(value===null||typeof value==='string'||typeof value==='boolean'){if(depth>64)throw fault('invalid_recovery_payload');return{value,height:0,reusable:true,bytes:scalarBytes(value)};} + if(typeof value==='number'){if(depth>64)throw fault('invalid_recovery_payload');const normalized=Number.isFinite(value)?(Object.is(value,-0)?0:value):null;return{value:normalized,height:0,reusable:true,bytes:scalarBytes(normalized)};} + if(value===undefined||typeof value==='symbol')return{value:undefined,height:0,reusable:true,bytes:0}; const fallback=()=>{ // Preserve the previous JSON codec for toJSON, boxed values and custom prototypes. // These can depend on hidden mutable state and are deliberately never cached. const text=JSON.stringify({[key]:value}),encoded=JSON.parse(text)[key] as Json|undefined; if(encoded!==undefined&&!validJson(encoded,depth))throw fault('invalid_recovery_payload'); - return{value:freeze(encoded),height:0,reusable:false}; + return{value:freeze(encoded),height:0,reusable:false,bytes:encoded===undefined?0:scalarBytes(encoded)}; }; if(typeof value==='bigint'||typeof value==='function')return fallback(); if(typeof value!=='object')throw fault('invalid_recovery_payload'); @@ -29,23 +31,23 @@ export function encodeRecoveryValue(input:unknown):Json { if(depth>64)throw fault('invalid_recovery_payload'); if(visiting.has(object))throw fault('invalid_recovery_payload');visiting.add(object); const toJSON=Object.getOwnPropertyDescriptor(object,'toJSON'); - let reusable=Object.isFrozen(object)&&(!toJSON||Object.hasOwn(toJSON,'value')),height=0; + let reusable=Object.isFrozen(object)&&(!toJSON||Object.hasOwn(toJSON,'value')),height=0,bytes=2; try { if(Array.isArray(object)){ const result:Json[]=[]; for(let index=0;index64)throw fault('invalid_recovery_payload');result.push(child.value??null);height=Math.max(height,1+child.height);reusable&&=child.reusable; + const child=visit(object[index],depth+1,String(index));if(child.value===undefined&&depth+1>64)throw fault('invalid_recovery_payload');result.push(child.value??null);bytes+=(index?1:0)+(child.value===undefined?4:child.bytes);height=Math.max(height,1+child.height);reusable&&=child.reusable; } - const encoded={value:Object.freeze(result) as unknown as Json,height,reusable};if(reusable)immutable.set(object,encoded);return encoded; + const encoded={value:Object.freeze(result) as unknown as Json,height,reusable,bytes};encodedSizes.set(result,bytes);if(reusable)immutable.set(object,encoded);return encoded; } - const result:Record={}; + const result:Record={};let properties=0; for(const name of Object.keys(object)){ const descriptor=Object.getOwnPropertyDescriptor(object,name);if(!descriptor||!Object.hasOwn(descriptor,'value'))reusable=false; const child=visit((object as Record)[name],depth+1,name);reusable&&=child.reusable; - if(child.value!==undefined){Object.defineProperty(result,name,{value:child.value,enumerable:true});height=Math.max(height,1+child.height);} + if(child.value!==undefined){bytes+=(properties++?1:0)+scalarBytes(name)+1+child.bytes;Object.defineProperty(result,name,{value:child.value,enumerable:true});height=Math.max(height,1+child.height);} } - const encoded={value:Object.freeze(result) as Json,height,reusable};if(reusable)immutable.set(object,encoded);return encoded; + const encoded={value:Object.freeze(result) as Json,height,reusable,bytes};encodedSizes.set(result,bytes);if(reusable)immutable.set(object,encoded);return encoded; }finally{visiting.delete(object);} } const result=visit(input,0,'');if(result.value===undefined)throw fault('invalid_recovery_payload');return result.value; @@ -54,6 +56,9 @@ export function encodeRecoveryValue(input:unknown):Json { /** Only codec-owned, validated immutable images qualify for the storage copy fast path. */ export function sealRecoveryImage(formatVersion:1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage { if(![1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image'); - const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encodeRecoveryValue(payload)});sealed.add(image);return image; + const encoded=encodeRecoveryValue(payload),image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encoded});sealed.add(image);imageSizes.set(image,JSON.stringify({...image,payload:null}).length-4+(encoded&&typeof encoded==='object'?encodedSizes.get(encoded)??scalarBytes(encoded):scalarBytes(encoded)));return image; } export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);} + +/** Exact UTF-8 JSON size, available only for codec-owned immutable images. */ +export function sealedRecoveryByteLength(image:RecoveryImage){return imageSizes.get(image);} diff --git a/src/lib/canvas-runtime/storage.ts b/src/lib/canvas-runtime/storage.ts index 5308929..2a598c8 100644 --- a/src/lib/canvas-runtime/storage.ts +++ b/src/lib/canvas-runtime/storage.ts @@ -1,25 +1,26 @@ import {depotStores,createDepotStorage,detachDepotCut,verifyDepotBytes,writeDepotCut,promoteDepotFiles,resolveDepotFile} from './asset-depot-storage.ts'; import { applyJournal, diffJournal, type JournalPatch } from './journal-delta.ts'; -import { isSealedRecoveryImage } from './recovery-encoding.ts'; +import { isSealedRecoveryImage, sealedRecoveryByteLength } from './recovery-encoding.ts'; import type { Json } from '../canvas-core/types.ts'; -import { clone, equal, fault, freeze, validJson } from '../canvas-core/values.ts'; +import { clone, fault, freeze, validJson } from '../canvas-core/values.ts'; import type {StorageScope,RecoveryImage,DurableBatch,StorageLeader,StoredFileInfo,FileInventoryPage,StoragePort} from './storage-port.ts'; export type {StorageScope,RecoveryImage,DurableBatch,StorageLeader,StoredFileInfo,FileInventoryPage,StoragePort} from './storage-port.ts'; type Metadata = { fence: number; throughRevision?: number; checkpointRevision?: number }; type StoredImage = { readonly image: RecoveryImage; readonly checksum: string }; -type StoredDelta = {journalVersion:1;baseRevision:number;baseChecksum:string;header:Omit;patches:JournalPatch[];checksum:string}; +type StoredDelta = {journalVersion:1|2;baseRevision:number;baseChecksum:string;header:Omit;patches:JournalPatch[];checksum:string}; type StoredCut = StoredImage | StoredDelta; function cutHeader(cut:StoredCut):Omit{ 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(![1,2].includes(cut.journalVersion))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) { +async function imageDigest(image: unknown) { const bytes = new TextEncoder().encode(JSON.stringify(image)); return {checksum:[...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(value => value.toString(16).padStart(2, '0')).join(''),byteLength:bytes.byteLength}; } +function deltaBody(cut:StoredDelta){const {journalVersion,baseRevision,baseChecksum,header,patches}=cut;return{journalVersion,baseRevision,baseChecksum,header,patches};} const keyFor = (scope: StorageScope) => { if (![scope.accountId, scope.workspaceId, scope.projectId].every(value => typeof value === 'string' && value.length > 0)) throw fault('invalid_storage_scope'); return JSON.stringify([scope.accountId, scope.workspaceId, scope.projectId]); @@ -44,7 +45,7 @@ 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(), lastCuts=new WeakMap(), writeTails=new WeakMap>(); + const handles = new Set(), lastCuts=new WeakMap(), writeTails=new WeakMap>(),lastCheckpoints=new WeakMap(); const opening = new Promise((resolve, reject) => { const request = factory.open(options.name ?? 'endoleap-headless', 4); request.onupgradeneeded = () => { @@ -79,25 +80,25 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: } catch (error) { try { transaction.abort(); } catch { /* Already terminal. */ } await done.catch(() => {}); throw error; } } async function recoverCut(leader:StorageLeader):Promise{ - const cuts=await execute(leader,['checkpoints','journal'],'readonly',async(transaction,metadata,key)=>{ - if(metadata.throughRevision===undefined)return []; + const captured=await execute(leader,['checkpoints','journal'],'readonly',async(transaction,metadata,key)=>{ + if(metadata.throughRevision===undefined)return{cuts:[] as StoredCut[],checkpointRevision:undefined}; if(metadata.checkpointRevision===undefined)throw fault('recovery_checkpoint_missing'); const checkpoint=await requestValue(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(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.localRevisionretired.has(file.id)))throw fault('resource_identity_conflict');} - const digest=await imageDigest(image),stored:StoredImage={image,checksum:digest.checksum};let candidate:StoredCut=stored; - if(base&&image.rootRevision===base.image.rootRevision+1){ + const baseCheckpoint=lastCheckpoints.get(leader),checkpoint=!base||((base.image.formatVersion>=7)&&base.image.formatVersion!==image.formatVersion)||image.rootRevision-(baseCheckpoint??0)>=checkpointEvery; + let stored:StoredImage|undefined,candidate:StoredCut|undefined; + if(base?.image.rootRevision===image.rootRevision){stored={image,checksum:base.checksum};candidate=stored;} + else if(base&&!checkpoint&&image.rootRevision===base.image.rootRevision+1&&JSON.stringify(Object.keys(image))===JSON.stringify(['formatVersion','rootRevision','localRevision','payload'])){ 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{ const previous=metadata.throughRevision; + if(previous===undefined&&base)throw fault('recovery_delta_base_mismatch'); const saved=previous===undefined?undefined:await requestValue(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');for(const id of deleteFiles)if(await requestValue(transaction.objectStore('files').getKey([key,id])))throw fault('storage_revision_conflict');if(depot){await writeDepotCut(transaction,leader.scope,image.rootRevision,depot);await promoteDepotFiles(transaction,leader.scope,key,image,depot);}return{durableRootRevision:previous}; + if(!saved||saved.checksum!==accepted.checksum||!base||JSON.stringify(base.image)!==JSON.stringify(image)||files.length)throw fault('storage_revision_conflict');for(const id of deleteFiles)if(await requestValue(transaction.objectStore('files').getKey([key,id])))throw fault('storage_revision_conflict');if(depot){await writeDepotCut(transaction,leader.scope,image.rootRevision,depot);await promoteDepotFiles(transaction,leader.scope,key,image,depot);}return{durableRootRevision:previous,checkpointRevision:metadata.checkpointRevision!}; } if(previous!==undefined&&(!saved||image.localRevision=7&&base?.image.formatVersion!==image.formatVersion)||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}; + if(checkpoint){ + transaction.objectStore('checkpoints').put(accepted,[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(cut,[key,image.rootRevision]); + transaction.objectStore('metadata').put({...metadata,throughRevision:image.rootRevision},key);return{durableRootRevision:image.rootRevision,checkpointRevision:metadata.checkpointRevision!}; }); - lastCuts.set(leader,freeze(stored));return receipt; + lastCuts.set(leader,freeze(accepted));lastCheckpoints.set(leader,receipt.checkpointRevision);return{durableRootRevision:receipt.durableRootRevision}; }); writeTails.set(leader,task);return task; }, diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 1d74817..d3d2706 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -485,15 +485,23 @@ async function deltaJournalCase(name,assert){ const initial=image(0),checksum=[...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(initial))))].map(byte=>byte.toString(16).padStart(2,'0')).join('');let tx=raw.transaction(['metadata','checkpoints'],'readwrite'),terminal=done(tx);tx.objectStore('metadata').put({fence:0,throughRevision:0,checkpointRevision:0},key);tx.objectStore('checkpoints').put({image:initial,checksum},[key,0]);await terminal;raw.close(); let storage=createBrowserStorage({name:databaseName,checkpointEvery:3}),leader; try{ - leader=await storage.claim(scope);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(initial),'legacy checkpoint did not migrate');await storage.commit(leader,{image:image(1),files:[{id:'asset',bytes:new Blob(['immutable'])}]}); + leader=await storage.claim(scope);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(initial),'legacy checkpoint did not migrate'); + const digest=crypto.subtle.digest,hashed=[];crypto.subtle.digest=function(algorithm,bytes){hashed.push(bytes.byteLength);return digest.call(this,algorithm,bytes);};try{const first=image(1);await storage.commit(leader,{image:sealRecoveryImage(first.formatVersion,first.rootRevision,first.localRevision,first.payload),files:[{id:'asset',bytes:new Blob(['immutable'])}]});}finally{crypto.subtle.digest=digest;} + assert(hashed.length===1&&hashed[0]{const request=indexedDB.open(databaseName);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);}); - const row=await read(raw.transaction('journal').objectStore('journal').get([key,1]));assert(row.journalVersion===1&&!row.image&&JSON.stringify(row).length[...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(value))))].map(byte=>byte.toString(16).padStart(2,'0')).join(''),legacy={...row,journalVersion:1,checksum:await hash(image(1))},mixed={...intact,baseChecksum:await hash(image(1))};mixed.checksum=await hash({journalVersion:mixed.journalVersion,baseRevision:mixed.baseRevision,baseChecksum:mixed.baseChecksum,header:mixed.header,patches:mixed.patches}); + let mixedTx=raw.transaction('journal','readwrite'),mixedDone=done(mixedTx);mixedTx.objectStore('journal').put(legacy,[key,1]);mixedTx.objectStore('journal').put(mixed,[key,2]);await mixedDone;assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(image(2)),'mixed legacy-v1 and chained-v2 deltas did not recover');mixedTx=raw.transaction('journal','readwrite');mixedDone=done(mixedTx);mixedTx.objectStore('journal').put(row,[key,1]);mixedTx.objectStore('journal').put(intact,[key,2]);await mixedDone;await storage.recover(leader); + const write=async value=>{const tx=raw.transaction('journal','readwrite'),wait=done(tx);tx.objectStore('journal').put(value,[key,2]);await wait;}; - for(const[kind,expected]of [['payload','recovery_checksum_mismatch'],['base','recovery_delta_base_mismatch'],['future','recovery_delta_interpreter_unavailable']]){const damaged=structuredClone(intact);if(kind==='payload')damaged.patches[0].value='tampered';else if(kind==='base')damaged.baseChecksum='0'.repeat(64);else damaged.journalVersion=99;await write(damaged);code=undefined;try{await storage.recover(leader);}catch(error){code=error.code;}assert(code===expected,`delta ${kind} corruption was not detected: ${code}`);await write(intact);} - await storage.commit(leader,{image:image(3)});assert((await storage.recover(leader)).rootRevision===3,'checkpoint after deltas failed');assert(await read(raw.transaction('journal').objectStore('journal').count())===0,'checkpoint did not compact prior deltas'); + for(const[kind,expected]of [['payload','recovery_checksum_mismatch'],['header','recovery_checksum_mismatch'],['base','recovery_delta_base_mismatch'],['future','recovery_delta_interpreter_unavailable']]){const damaged=structuredClone(intact);if(kind==='payload')damaged.patches[0].value='tampered';else if(kind==='header')damaged.header.formatVersion=2;else if(kind==='base')damaged.baseChecksum='0'.repeat(64);else damaged.journalVersion=99;await write(damaged);code=undefined;try{await storage.recover(leader);}catch(error){code=error.code;}assert(code===expected,`delta ${kind} corruption was not detected: ${code}`);await write(intact);} + const checkpointHashes=[];crypto.subtle.digest=function(algorithm,bytes){checkpointHashes.push(bytes.byteLength);return digest.call(this,algorithm,bytes);};try{await storage.commit(leader,{image:image(3)});}finally{crypto.subtle.digest=digest;}assert(checkpointHashes.length===1&&checkpointHashes[0]===new TextEncoder().encode(JSON.stringify(image(3))).byteLength,'checkpoint did not hash the full image');assert((await storage.recover(leader)).rootRevision===3,'checkpoint after deltas failed');assert(await read(raw.transaction('journal').objectStore('journal').count())===0,'checkpoint did not compact prior deltas'); const detached=await storage.recover(leader);detached.payload.rows[0].text='caller mutation';const fourth=image(4),fifth=image(5),one=storage.commit(leader,{image:fourth}),two=storage.commit(leader,{image:fifth});fifth.payload.rows[0].text='late mutation';await Promise.all([one,two]);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(image(5)),'queued commits or recovered callers mutated the cached base');await storage.commit(leader,{image:image(5)}); + const reordered=image(5);reordered.payload={ledger:reordered.payload.ledger,rows:reordered.payload.rows};code=undefined;try{await storage.commit(leader,{image:reordered});}catch(error){code=error.code;}assert(code==='storage_revision_conflict','same-cut retry accepted different serialized property order'); + await storage.commit(leader,{image:image(6)});const extended={marker:'retained header',...image(7)};await storage.commit(leader,{image:extended});assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(extended),'noncanonical header lost fields or property order');assert((await read(raw.transaction('journal').objectStore('journal').get([key,7]))).image,'noncanonical header was encoded as a lossy delta'); + const metadata=await read(raw.transaction('metadata').objectStore('metadata').get(key));let metadataTx=raw.transaction('metadata','readwrite'),metadataDone=done(metadataTx);const missingHead={...metadata};delete missingHead.throughRevision;metadataTx.objectStore('metadata').put(missingHead,key);await metadataDone;code=undefined;try{await storage.commit(leader,{image:image(8)});}catch(error){code=error.code;}assert(code==='recovery_delta_base_mismatch','cached baseline overwrote missing durable head');metadataTx=raw.transaction('metadata','readwrite');metadataDone=done(metadataTx);metadataTx.objectStore('metadata').put(metadata,key);await metadataDone;assert((await storage.recover(leader)).rootRevision===7,'head validation failure advanced cached state'); const oldError=await new Promise(resolve=>{const request=indexedDB.open(databaseName,1);request.onerror=event=>{event.preventDefault();resolve(request.error.name);};request.onsuccess=()=>{request.result.close();resolve('accepted');};});assert(oldError==='VersionError','old database reader accepted the delta format'); }finally{raw?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(databaseName);} } diff --git a/tests/headless/recovery-encoding.test.mjs b/tests/headless/recovery-encoding.test.mjs index bb07128..df3453d 100644 --- a/tests/headless/recovery-encoding.test.mjs +++ b/tests/headless/recovery-encoding.test.mjs @@ -26,3 +26,8 @@ test('cached subtree height cannot bypass the depth bound and omitted object fie test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{ const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(7,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(8,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(9,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(10,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(11,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(12,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(13,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(14,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(15,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(16,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(17,1,1,{})),true);assert.throws(()=>sealRecoveryImage(18,1,1,{}),/corrupt_recovery_image/); }); + +test('sealed image byte sizes match UTF-8 JSON for reused data and all codec normalization paths',async()=>{ + const {sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'),shared=freeze({text:'δΈ­ζ–‡πŸ˜€\ud800\n"\\',items:[null,1,true]}),payloads=[null,'πŸ˜€',-0,{shared,omitted:undefined,array:[,undefined,NaN]},new Date('2026-01-01Z'),{['__proto__']:'保留'},Object.freeze({get text(){return 'εŠ¨ζ€πŸ˜€';}})]; + for(const payload of payloads)for(let revision=0;revision<3;revision++){const image=sealRecoveryImage(17,revision,revision,payload);assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(JSON.stringify(image)).byteLength);assert.equal(sealedRecoveryByteLength(structuredClone(image)),undefined);} +}); diff --git a/tests/headless/renderer-performance.mjs b/tests/headless/renderer-performance.mjs index 66649a9..5d399bc 100644 --- a/tests/headless/renderer-performance.mjs +++ b/tests/headless/renderer-performance.mjs @@ -41,6 +41,6 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co const viewportCheck={visibleEdgesBeforePan:visibleEdges,copiedId:copiedLast.id,mountedAfterPan:await evaluate('document.querySelectorAll(".react-flow__node").length')}; report={scenario:'actual GraphPage deep copy of expanded partition',mode:'Next development webpack; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks',count,initialReadyMs,internals:{batches:internalsBatches.length,maxBatch:Math.max(0,...internalsBatches),totalRequests:internalsBatches.reduce((sum,size)=>sum+size,0)},measurements,viewportCheck,browser:await call('Browser.getVersion'),serverElements:document.elements.size,serverCanvases:document.canvases.size,wallMs:Date.now()-started}; const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile(reportPath.endsWith('.json')?reportPath.slice(0,-5)+'.png':reportPath+'.png',Buffer.from(screenshot.data,'base64')); - }catch(error){let measurements;try{measurements=await evaluate('window.__canvasPerformance?.stop()');}catch{}report={scenario:'actual GraphPage deep copy of expanded partition',count,error:error.message,measurements,wallMs:Date.now()-started};await writeFile(reportPath,JSON.stringify(report,null,2));throw error;} + }catch(error){let measurements;try{measurements=await evaluate('window.__canvasPerformance?.stop()');}catch{}report={scenario:'actual GraphPage deep copy of expanded partition',count,error:error.message,measurements,serverElements:documents.get(root)?.elements.size,serverCanvases:documents.get(root)?.canvases.size,wallMs:Date.now()-started};await writeFile(reportPath,JSON.stringify(report,null,2));throw error;} await writeFile(reportPath,JSON.stringify(report,null,2));console.log('MEASURE '+JSON.stringify({...report,measurements:{...report.measurements,longTasks:undefined}}));console.log('REPORT '+reportPath); }