diff --git a/headless/README.md b/headless/README.md index c8e9380..36e2c82 100644 --- a/headless/README.md +++ b/headless/README.md @@ -808,3 +808,13 @@ The transaction materializes an owned document once before publication; a non-create command can materialize earlier through the normal change path. These views are unpublished preparation, not a second document store or durable queue. Rollback drops them without publishing history, ledger or runtime state. + +Resource retention now shares one synchronous history/ledger analysis between +working and acknowledged document scans. The temporary scanner holds interpreted +transition ranges, the earliest pending sequence, the resource-field union and +schema candidates only for IDs used by historical/pending data-field changes. +Create-only histories need no per-element schema table. Both documents still +produce their own pins and completeness result; all supported transitions, +including resolved evidence, are validated. Cross-schema fallback pins and owner +order are preserved. The scanner is internal to one retention pass and is not +stored across Roots, projects or registry changes. diff --git a/src/lib/canvas-runtime/resource-retention.ts b/src/lib/canvas-runtime/resource-retention.ts index 7af86d7..7af2ef2 100644 --- a/src/lib/canvas-runtime/resource-retention.ts +++ b/src/lib/canvas-runtime/resource-retention.ts @@ -2,13 +2,14 @@ import {importNeedsSeal} from './imports.ts'; import type {Id,Registry} from '../canvas-core/types.ts'; import {fault,readMap,readSet,freeze} from '../canvas-core/values.ts'; import type {EngineSnapshot,RuntimeState} from './contracts.ts'; -import {resourcePins} from './resources.ts'; +import {createResourcePinScan} from './resources.ts'; /** Complete local-byte protection for one project scope; no DOM/viewport filtering. */ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap=new Map(),temporary:ReadonlyMap=new Map()) { const {runtime}=snapshot; - const current=resourcePins(snapshot,runtime.ledger,registry,runtime.resourcePins); - const base=resourcePins({...snapshot,document:runtime.acknowledged},runtime.ledger,registry,new Map()); + const scan=createResourcePinScan(snapshot.history,runtime.ledger,registry); + const current=scan(snapshot.document,runtime.resourcePins); + const base=scan(runtime.acknowledged); const pins=new Map>(); const add=(id:Id,owner:string)=>{let owners=pins.get(id);if(!owners){owners=new Set();pins.set(id,owners);}owners.add(owner);}; for(const [id,owners]of current.pins)for(const owner of owners)add(id,owner); diff --git a/src/lib/canvas-runtime/resources.ts b/src/lib/canvas-runtime/resources.ts index a6f65da..2085207 100644 --- a/src/lib/canvas-runtime/resources.ts +++ b/src/lib/canvas-runtime/resources.ts @@ -1,9 +1,9 @@ import type {ImageDecodeState} from './image-decoder.ts'; import type { DefaultImageSizing } from './default-image-size.ts'; -import type { Change, CoreSnapshot, Document, ElementRecord, Id, OperationIntent, Registry } from '../canvas-core/types.ts'; +import type { Change, CoreSnapshot, Document, ElementRecord, Id, HistoryState, OperationIntent, Registry } from '../canvas-core/types.ts'; import { historyTransitionChanges } from '../canvas-core/history-transition.ts'; import { ancestors } from '../canvas-core/document.ts'; -import type { FrozenRequest, LedgerSnapshot } from './ledger.ts'; +import type { FrozenRequest, LedgerSnapshot, OperationRecord } from './ledger.ts'; import type { OperationStatus } from './scheduler.ts'; import { readMap, readSet } from '../canvas-core/values.ts'; @@ -72,47 +72,61 @@ export function conflictsWith(prior: ReturnType, next return [...prior.writes].some(id => next.writes.has(id) || next.reads.has(id)) || [...prior.reads].some(id => next.writes.has(id)); } -/** Pins are derived from their owners. There is no second mutable reference count. */ -export function resourcePins(snapshot: CoreSnapshot, ledger: LedgerSnapshot, registry: Registry, explicit: ReadonlyMap) { - const pins = new Map>(); - let complete = true; - const pin = (id: Id, owner: string) => { const set = pins.get(id) ?? new Set(); set.add(owner); pins.set(id, set); }; - const element = (record: ElementRecord | null, owner: string) => { - if (!record) return; - const component = registry.components.get(record.componentId); - if (registry.resolve(record) !== 'known' || component?.resourceFields === undefined) { complete = false; return; } - for (const field of component.resourceFields) { const ref = record.data[field]; if (typeof ref === 'string') pin(ref, owner);else if(ref!==undefined&&ref!==null)complete=false; } +/** One synchronous retention pass shares history interpretation across working/ACK. + * No snapshot or mutable reference-count state is retained after the caller returns. */ +export function createResourcePinScan(history:HistoryState,ledger:LedgerSnapshot,registry:Registry){ + const transitions=new Map(),fieldTargets=new Set(); + const rememberTargets=(values:readonly Change[])=>{for(const change of values)if(change.kind==='field'&&change.field.startsWith('data.'))fieldTargets.add(change.target.id);}; + const pending=[...ledger.operations.values()].filter(record=>record.phase!=='resolved'),earliest=pending.reduce((value,record)=>Math.min(value,record.intent.sequence),Infinity); + for(const entry of history.entries.values())rememberTargets(entry.changes); + let interpreted=true; + for(const record of ledger.operations.values()){ + // Validate every understood transition, including resolved evidence, as before. + if(record.intent.historyTransition?.version===1)transitions.set(record,historyTransitionChanges(record.intent.historyTransition)); + if(record.phase!=='resolved')rememberTargets(record.intent.changes); + if(record.intent.historyEffect&&record.intent.historyEffect.version!==1)interpreted=false; + if(record.intent.sequence>=earliest&&record.intent.historyTransition){if(record.intent.historyTransition.version!==1)interpreted=false;else rememberTargets(transitions.get(record)!);} + } + const schemas=new Map(); + const remember=(record:ElementRecord|null)=>{if(record&&fieldTargets.has(record.id)){const list=schemas.get(record.id)??[];if(!list.some(item=>item.componentId===record.componentId&&item.dataVersion===record.dataVersion))list.push(record);schemas.set(record.id,list);}}; + const rememberChanges=(values:readonly Change[])=>{for(const change of values)if(change.kind==='element'){remember(change.before);remember(change.after);}}; + if(fieldTargets.size){ + for(const entry of history.entries.values())rememberChanges(entry.changes); + for(const record of ledger.operations.values()){rememberChanges(record.intent.changes);const changes=transitions.get(record);if(changes)rememberChanges(changes);} + } + const fields=new Set([...registry.components.values()].flatMap(component=>component.resourceFields??[])); + return(document:Document,explicit:ReadonlyMap=new Map())=>{ + const pins=new Map>();let complete=interpreted; + const pin=(id:Id,owner:string)=>{const set=pins.get(id)??new Set();set.add(owner);pins.set(id,set);}; + const element=(record:ElementRecord|null,owner:string)=>{ + if(!record)return;const component=registry.components.get(record.componentId); + if(registry.resolve(record)!=='known'||component?.resourceFields===undefined){complete=false;return;} + for(const field of component.resourceFields){const ref=record.data[field];if(typeof ref==='string')pin(ref,owner);else if(ref!==undefined&&ref!==null)complete=false;} }; - const schemas=new Map(); - const remember=(record:ElementRecord|null)=>{if(record){const list=schemas.get(record.id)??[];if(!list.some(item=>item.componentId===record.componentId&&item.dataVersion===record.dataVersion))list.push(record);schemas.set(record.id,list);}}; - for(const record of snapshot.document.elements.values())remember(record); - const rememberChanges=(changes:readonly Change[])=>{for(const change of changes)if(change.kind==='element'){remember(change.before);remember(change.after);}}; - for(const entry of snapshot.history.entries.values())rememberChanges(entry.changes); - for(const record of ledger.operations.values()){rememberChanges(record.intent.changes);if(record.intent.historyTransition?.version===1)rememberChanges(historyTransitionChanges(record.intent.historyTransition));} - const changes = (values: readonly Change[], owner: string) => { - for(const change of values){ - if(change.kind==='element'){element(change.before,owner);element(change.after,owner);} - else if(change.kind==='field'&&change.field.startsWith('data.')){ - const candidates=schemas.get(change.target.id);if(!candidates?.length)complete=false; - for(const record of candidates??[]){const component=registry.components.get(record.componentId);if(registry.resolve(record)!=='known'||component?.resourceFields===undefined){complete=false;continue;} - if(component.resourceFields.includes(change.field.slice(5)))for(const value of [change.before,change.after])if(typeof value==='string')pin(value,owner);else if(value!==undefined&&value!==null)complete=false; - } - } + const changes=(values:readonly Change[],owner:string)=>{ + for(const change of values){ + if(change.kind==='element'){element(change.before,owner);element(change.after,owner);} + else if(change.kind==='field'&&change.field.startsWith('data.')){ + const candidates:ElementRecord[]=[],current=document.elements.get(change.target.id);if(current)candidates.push(current); + for(const record of schemas.get(change.target.id)??[])if(!candidates.some(item=>item.componentId===record.componentId&&item.dataVersion===record.dataVersion))candidates.push(record); + if(!candidates.length)complete=false; + for(const record of candidates){const component=registry.components.get(record.componentId);if(registry.resolve(record)!=='known'||component?.resourceFields===undefined){complete=false;continue;} + if(component.resourceFields.includes(change.field.slice(5)))for(const value of [change.before,change.after])if(typeof value==='string')pin(value,owner);else if(value!==undefined&&value!==null)complete=false; + } } - for (const id of referencedResources(values, registry)) pin(id, owner); + } + // Keep the conservative cross-schema fallback and its original insertion order. + const refs=new Set(),collect=(record:ElementRecord|null)=>{if(record)for(const field of registry.components.get(record.componentId)?.resourceFields??[])if(typeof record.data[field]==='string')refs.add(record.data[field] as string);}; + for(const change of values)if(change.kind==='element'){collect(change.before);collect(change.after);}else if(change.kind==='field'&&change.field.startsWith('data.')&&fields.has(change.field.slice(5))){if(typeof change.before==='string')refs.add(change.before);if(typeof change.after==='string')refs.add(change.after);} + for(const id of refs)pin(id,owner); }; - for (const record of snapshot.document.elements.values()) element(record, `element:${record.id}:${record.incarnation}`); - for (const entry of snapshot.history.entries.values()) changes(entry.changes, `history:${entry.id}`); - for (const record of ledger.operations.values()) if (record.phase !== 'resolved') { - if (record.intent.operationType === 'asset.upload') pin(record.intent.resourceId, `operation:${record.intent.id}`); - else changes(record.intent.changes, `operation:${record.intent.id}`); - } - const pending=[...ledger.operations.values()].filter(record=>record.phase!=='resolved'),earliest=pending.reduce((value,record)=>Math.min(value,record.intent.sequence),Infinity); - for (const record of ledger.operations.values()) if (record.intent.historyEffect && record.intent.historyEffect.version !== 1) complete = false; - for(const record of ledger.operations.values())if(record.intent.sequence>=earliest&&record.intent.historyTransition){ - if(record.intent.historyTransition.version!==1){complete=false;continue;} - changes(historyTransitionChanges(record.intent.historyTransition),`history-transition:${record.intent.id}`); - } - for (const [owner, ids] of explicit) for (const id of ids) pin(id, owner); - return Object.freeze({ complete, pins: readMap(new Map([...pins].map(([id, owners]) => [id, readSet(owners)]))) }); + for(const record of document.elements.values())element(record,`element:${record.id}:${record.incarnation}`); + for(const entry of history.entries.values())changes(entry.changes,`history:${entry.id}`); + for(const record of ledger.operations.values())if(record.phase!=='resolved'){if(record.intent.operationType==='asset.upload')pin(record.intent.resourceId,`operation:${record.intent.id}`);else changes(record.intent.changes,`operation:${record.intent.id}`);} + for(const record of ledger.operations.values())if(record.intent.sequence>=earliest&&record.intent.historyTransition?.version===1)changes(transitions.get(record)!,`history-transition:${record.intent.id}`); + for(const[owner,ids]of explicit)for(const id of ids)pin(id,owner); + return Object.freeze({complete,pins:readMap(new Map([...pins].map(([id,owners])=>[id,readSet(owners)])))}); + }; } +/** Pins are derived from owners; callers receive no second mutable reference count. */ +export function resourcePins(snapshot:CoreSnapshot,ledger:LedgerSnapshot,registry:Registry,explicit:ReadonlyMap){return createResourcePinScan(snapshot.history,ledger,registry)(snapshot.document,explicit);} diff --git a/tests/headless/resource-pin-scan.test.mjs b/tests/headless/resource-pin-scan.test.mjs new file mode 100644 index 0000000..098819d --- /dev/null +++ b/tests/headless/resource-pin-scan.test.mjs @@ -0,0 +1,16 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createDocument,createRegistry,builtinComponents,describeHistoryTransition} from '../../src/lib/canvas-core/index.ts'; +import {createResourcePinScan,resourcePins} from '../../src/lib/canvas-runtime/resources.ts'; +import {resourceRetentionPins} from '../../src/lib/canvas-runtime/resource-retention.ts'; +const registry=createRegistry(builtinComponents()),empty={undo:[],redo:[],entries:new Map()},node=(id,asset)=>({id,incarnation:1,ownerCanvasId:'root',componentId:asset?'image':'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:'',...(asset?{assetId:asset}:{})}}),doc=(records=[])=>createDocument('p',[{id:'root',parent:null}],records); +test('one retention pass interprets historical evidence once for working and ACK while preserving their separate owners',()=>{ + const entry={id:'h',label:'h',changes:[{kind:'element',id:'past',before:null,after:node('past','historical')}],operationIds:[]},raw=describeHistoryTransition(empty,{undo:['h'],redo:[],entries:new Map([['h',entry]])},1);let reads=0;const transition=new Proxy(raw,{get(target,key,receiver){if(key==='entries')reads++;return Reflect.get(target,key,receiver);}}),ledger={operations:new Map([['o',{phase:'queued',intent:{id:'o',sequence:1,operationType:'document.change',changes:[],historyTransition:transition}}]])},scan=createResourcePinScan(empty,ledger,registry),preparationReads=reads;assert.ok(preparationReads>0);scan(doc([node('text')]));scan(doc([node('remote','ack')]));assert.equal(reads,preparationReads); + const snapshot={document:doc([node('text')]),history:empty,runtime:{ledger,acknowledged:doc([node('remote','ack')]),resourcePins:new Map(),resources:new Map(),runs:new Map()}};reads=0;const result=resourceRetentionPins(snapshot,registry);assert.equal(reads,preparationReads);assert.equal(result.complete,true);assert.deepEqual([...result.pins.get('historical')],['history-transition:o']);assert.deepEqual([...result.pins.get('ack')],['acknowledged:element:remote:1']); +}); +test('historical data fields retain schemas from resolved creations, conservative fallback pins and explicit owner order',()=>{ + const field={kind:'field',target:{id:'deleted',incarnation:1},field:'data.assetId',before:'old',after:'new'},history={undo:['h'],redo:[],entries:new Map([['h',{id:'h',label:'h',changes:[field],operationIds:[]}]])},ledger={operations:new Map([['resolved',{phase:'resolved',intent:{id:'resolved',sequence:1,operationType:'document.change',changes:[{kind:'element',id:'deleted',before:null,after:node('deleted','unreferenced')}]} }]])},snapshot={document:doc(),history},result=resourcePins(snapshot,ledger,registry,new Map([['explicit',['old','extra']]]));assert.equal(result.complete,true);assert.deepEqual([...result.pins].map(([id,owners])=>[id,[...owners]]),[['old',['history:h','explicit']],['new',['history:h']],['extra',['explicit']]]);ledger.operations.clear();assert.equal(resourcePins(snapshot,ledger,registry,new Map()).complete,false); +}); +test('new passes reread mutable input membership and still reject malformed resolved evidence with no resource fields',()=>{ + const history={undo:[],redo:[],entries:new Map()},ledger={operations:new Map()},snapshot={document:doc(),history};assert.equal(resourcePins(snapshot,ledger,registry,new Map()).pins.size,0);history.entries.set('h',{id:'h',label:'h',changes:[{kind:'element',id:'image',before:null,after:node('image','late')}],operationIds:[]});assert.ok(resourcePins(snapshot,ledger,registry,new Map()).pins.has('late'));ledger.operations.set('bad',{phase:'resolved',intent:{id:'bad',sequence:1,changes:[],historyTransition:{version:1,rootRevision:-1,before:{undo:[],redo:[]},after:{undo:[],redo:[]},entries:[]}}});assert.throws(()=>resourcePins(snapshot,ledger,registry,new Map()),/invalid_history_transition/); +});