diff --git a/headless/README.md b/headless/README.md index 0b052e6..7201459 100644 --- a/headless/README.md +++ b/headless/README.md @@ -847,3 +847,11 @@ hang a second metrics read. Browser cleanup escalates only the fixture's own process after its exit grace period. Optional heap/receipt sampling and a pinned baseline override affect only fixture copies and are diagnostic, not product APIs or production performance claims. + +Recovery serialization memoizes entry arrays only for core-owned read-only Map +views. The core tracks membership ownership in a WeakSet; the codec's WeakMap +holds frozen pair/array containers per view. Value objects are not frozen by this +step. The existing recursive codec still checks nested mutability and depth, so +mutable values and external Maps are re-read. This is an encoding cache, not a +second state table; recovery JSON order, formats and journal checks remain the +same. Replacing a table gets a new entry view and leaves prior images unchanged. diff --git a/src/lib/canvas-core/index.ts b/src/lib/canvas-core/index.ts index 6f7db3c..77a371d 100644 --- a/src/lib/canvas-core/index.ts +++ b/src/lib/canvas-core/index.ts @@ -1,5 +1,5 @@ export * from './types.ts'; -export * from './values.ts'; +export {fault,reject,freeze,clone,equal,readMap,readSet,validJson} from './values.ts'; export * from './document.ts'; export * from './registry.ts'; export * from './features.ts'; diff --git a/src/lib/canvas-core/values.ts b/src/lib/canvas-core/values.ts index 5bb99ba..480dc91 100644 --- a/src/lib/canvas-core/values.ts +++ b/src/lib/canvas-core/values.ts @@ -24,6 +24,9 @@ export function equal(a: unknown, b: unknown): boolean { const left = Object.keys(a), right = Object.keys(b); return left.length === right.length && left.every(key => Object.prototype.hasOwnProperty.call(b, key) && equal((a as Record)[key], (b as Record)[key])); } +const ownedMapViews=new WeakSet(); +/** Internal provenance only; table ownership does not imply immutable values. */ +export function isOwnedMapView(value:object){return ownedMapViews.has(value);} /** A capability-safe read view: unlike TS ReadonlyMap, no .set is exposed at runtime. */ export function readMap(input: ReadonlyMap): ReadonlyMap { const map = new Map(input); @@ -33,7 +36,7 @@ export function readMap(input: ReadonlyMap): ReadonlyMap { [Symbol.iterator]: () => map[Symbol.iterator](), forEach: (callback, thisArg) => map.forEach((value, key) => callback.call(thisArg, value, key, view)), }; - return Object.freeze(view); + const result=Object.freeze(view);ownedMapViews.add(result);return result; } export function readSet(input: Iterable): ReadonlyValueSet { const set = new Set(input); diff --git a/src/lib/canvas-runtime/recovery.ts b/src/lib/canvas-runtime/recovery.ts index 5134b16..b169898 100644 --- a/src/lib/canvas-runtime/recovery.ts +++ b/src/lib/canvas-runtime/recovery.ts @@ -14,13 +14,25 @@ import { validateHistoryReplay, replayHistoryEvidence, sameHistory } from './his import type { RecoveryAudit } from './conflicts.ts'; import { applyHistoryTransition, validateHistoryTransition, type HistoryTransition } from '../canvas-core/history-transition.ts'; import { createDocument, validateDocument, isFactoryDocument } from '../canvas-core/document.ts'; -import { fault, freeze, readMap, validJson, equal } from '../canvas-core/values.ts'; +import { fault, freeze, readMap, validJson, equal, isOwnedMapView } from '../canvas-core/values.ts'; import type { EngineSnapshot, RuntimeState } from './contracts.ts'; import type { FrozenRequest, LedgerSnapshot, OperationRecord } from './ledger.ts'; import { validateRunInput, type RunRecord } from './runs.ts'; import type { ResourceRecord } from './resources.ts'; import type { RecoveryImage } from './storage-port.ts'; +// Membership is stable only for core-owned read views. Never infer ownership +// from Object.freeze: a frozen native Map can still change through .set(). +const entryViews=new WeakMap(); +const noEntries=Object.freeze([]); +function mapValue(map?:ReadonlyMap):readonly (readonly [K,V])[]{ + if(!map)return noEntries; + if(!isOwnedMapView(map))return [...map]; + let value=entryViews.get(map); + if(!value){value=Object.freeze([...map].map(pair=>Object.freeze(pair)));entryViews.set(map,value);} + return value as readonly (readonly [K,V])[]; +} + function encodeDocumentValue(document:Document){ // Freeze our own containers only. The codec still independently proves whether // each nested value can be reused, including external mutable records. @@ -35,9 +47,9 @@ function documentValue(document:Document){ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage { const payload = { document: documentValue(snapshot.document), - history: { undo: snapshot.history.undo, redo: snapshot.history.redo, entries: [...snapshot.history.entries] }, - operations: [...snapshot.operations], - runtime: { ...(snapshot.runtime.assetBindings?.size?{assetBindings:[...snapshot.runtime.assetBindings]}:{}), ...(snapshot.runtime.loadingRead?{loadingRead:{document:documentValue(snapshot.runtime.loadingRead.document),versions:[...snapshot.runtime.loadingRead.versions]}}:{}), ...(snapshot.runtime.loading?{loading:snapshot.runtime.loading}:{}), ...(snapshot.runtime.orphanFiles?.size?{orphanFiles:[...snapshot.runtime.orphanFiles]}:{}), imports:[...(snapshot.runtime.imports??[])], recoveries:[...(snapshot.runtime.recoveries??[])], runs: [...snapshot.runtime.runs], resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } }, + history: { undo: snapshot.history.undo, redo: snapshot.history.redo, entries: mapValue(snapshot.history.entries) }, + operations: mapValue(snapshot.operations), + runtime: { ...(snapshot.runtime.assetBindings?.size?{assetBindings:mapValue(snapshot.runtime.assetBindings)}:{}), ...(snapshot.runtime.loadingRead?{loadingRead:{document:documentValue(snapshot.runtime.loadingRead.document),versions:mapValue(snapshot.runtime.loadingRead.versions)}}:{}), ...(snapshot.runtime.loading?{loading:snapshot.runtime.loading}:{}), ...(snapshot.runtime.orphanFiles?.size?{orphanFiles:mapValue(snapshot.runtime.orphanFiles)}:{}), imports:mapValue(snapshot.runtime.imports), recoveries:mapValue(snapshot.runtime.recoveries), runs: mapValue(snapshot.runtime.runs), resources: mapValue(snapshot.runtime.resources), resourcePins: mapValue(snapshot.runtime.resourcePins), acknowledged: documentValue(snapshot.runtime.acknowledged), versions: mapValue(snapshot.runtime.versions), receipts: mapValue(snapshot.runtime.receipts), conflicts: mapValue(snapshot.runtime.conflicts), ledger: { ...snapshot.runtime.ledger, operations: mapValue(snapshot.runtime.ledger.operations), requests: mapValue(snapshot.runtime.ledger.requests) } }, }; return sealRecoveryImage([...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.integrityVersion===2)?17:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document'&&job.parserVersion===5)?16:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document')?15:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-json-array')?14:snapshot.runtime.assetBindings?.size?13:snapshot.runtime.loading?(snapshot.runtime.loading.version===6?12:snapshot.runtime.loading.version===5?11:snapshot.runtime.loading.version===4?10:snapshot.runtime.loading.version===3?9:snapshot.runtime.loading.version===2?8:7):[...snapshot.operations.values()].some(intent=>intent.historyEffect)||[...snapshot.runtime.resources.values()].some(resource=>resource.sizing)?6:snapshot.runtime.orphanFiles?.size?5:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload); } diff --git a/tests/headless/recovery-views.test.mjs b/tests/headless/recovery-views.test.mjs index 5dbd6f2..6e56eac 100644 --- a/tests/headless/recovery-views.test.mjs +++ b/tests/headless/recovery-views.test.mjs @@ -14,3 +14,17 @@ test('unowned document envelopes reread mutable map membership and nested data w test('factory table ownership does not imply deep immutability of values inherited from an external document',()=>{ const base=doc(),mutable=node('external'),source={...base,elements:new Map([['external',mutable]])},document=applyChanges(source,[]),one=encodeRecovery(snapshot(document));mutable.data.text='later';source.elements.set('new',node('new'));const two=encodeRecovery(snapshot(document,1));assert.equal(two.payload.document.elements.length,1);assert.equal(two.payload.document.elements[0].data.text,'later');assert.equal(one.payload.document.elements[0].data.text,'');assert.equal(Object.isFrozen(mutable),false); }); + +import {readMap,freeze} from '../../src/lib/canvas-core/values.ts'; +import * as publicCore from '../../src/lib/canvas-core/index.ts'; +test('metadata roots reuse owned table entry encodings while new tables retain old image membership',()=>{ + assert.equal(Object.hasOwn(publicCore,'isOwnedMapView'),false,'provenance is an internal codec optimization'); + const state=snapshot(doc()),entries=readMap(new Map([['one',freeze({text:'first'})],['two',freeze({text:'second'})]]));state.operations=entries;state.history.entries=entries;state.runtime.receipts=entries;const one=encodeRecovery(state),iterate=Map.prototype[Symbol.iterator];let two; + try{Map.prototype[Symbol.iterator]=function(){if(this.size===2)throw Error('owned table enumerated again');return iterate.call(this);};two=encodeRecovery({...state,rootRevision:1});}finally{Map.prototype[Symbol.iterator]=iterate;} + assert.strictEqual(two.payload.operations,one.payload.operations);assert.strictEqual(two.payload.history.entries,one.payload.history.entries);assert.strictEqual(two.payload.runtime.receipts,one.payload.runtime.receipts);assert.strictEqual(one.payload.operations,one.payload.runtime.receipts); + const changed=readMap(new Map([...entries,['three',freeze({text:'third'})]])),three=encodeRecovery({...state,rootRevision:2,operations:changed});assert.equal(three.payload.operations.length,3);assert.equal(one.payload.operations.length,2);assert.strictEqual(three.payload.operations[0][1],one.payload.operations[0][1]); +}); +test('external maps and immutable-looking maps reread membership; owned tables still reread mutable nested values',()=>{ + const state=snapshot(doc()),external=Object.freeze(new Map([['one',{text:'first'}]]));state.runtime.receipts=external;const one=encodeRecovery(state);external.get('one').text='second';external.set('two',{text:'other'});const two=encodeRecovery({...state,rootRevision:1});assert.deepEqual(two.payload.runtime.receipts,[['one',{text:'second'}],['two',{text:'other'}]]);assert.equal(one.payload.runtime.receipts.length,1);assert.equal(one.payload.runtime.receipts[0][1].text,'first');assert.equal(Object.isFrozen(external.get('one')),false); + const mutable={text:'first'},shallow=Object.freeze({mutable}),owned=readMap(new Map([['one',shallow]]));state.runtime.receipts=owned;const three=encodeRecovery({...state,rootRevision:2});mutable.text='changed';const four=encodeRecovery({...state,rootRevision:3});assert.equal(three.payload.runtime.receipts[0][1].mutable.text,'first');assert.equal(four.payload.runtime.receipts[0][1].mutable.text,'changed');assert.equal(Object.isFrozen(mutable),false); +});