Budget growing history and import recovery encoding

This commit is contained in:
2026-11-11 04:16:21 +08:00
parent b4c40d3087
commit 210bc14074
11 changed files with 120 additions and 22 deletions
+6 -5
View File
@@ -1,3 +1,4 @@
import {dataArray,dataRecord} from './data-containers.ts';
import {stageCreation,materializeCreations} from './creation-draft.ts';
import type { Applied, Availability, Capability, Change, Command, Commit, CoreSnapshot, Document, ElementRecord, Geometry, HistoryEntry, HistoryState, Id, OperationIntent, Origin, Registry, Result, Snapshot } from './types.ts';
import { applyChanges, createDocument, invert, targetElement, validateDocument } from './document.ts';
@@ -151,7 +152,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (!Number.isSafeInteger(nextSequence) || outbound && nextSequence <= sequence) throw fault('invalid_operation_sequence');
if (historyEntryId) {
const entries = new Map(history.entries), entry = entries.get(historyEntryId)!;
entries.set(entry.id, freeze({ ...entry, operationIds: [...entry.operationIds, ...operationIds] }));
entries.set(entry.id, dataRecord(freeze({ ...entry, operationIds: dataArray(freeze([...entry.operationIds, ...operationIds])) })));
history = Object.freeze({ ...history, entries: readMap(entries) });
}
if (outbound) {
@@ -232,10 +233,10 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
const prior = undo.length ? entries.get(undo[undo.length - 1]) : undefined;
if (settings.groupId && prior?.groupId === settings.groupId) {
historyEntryId = prior.id;
entries.set(prior.id, freeze({ ...prior, changes: [...prior.changes, ...changes] }));
entries.set(prior.id, dataRecord(freeze({ ...prior, changes: dataArray(freeze([...prior.changes, ...changes])) })));
} else {
historyEntryId = uniqueId(new Set());
entries.set(historyEntryId, freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: [...changes], operationIds: [], ...(settings.groupId ? { groupId: settings.groupId } : {}) }));
entries.set(historyEntryId, dataRecord(freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: dataArray(freeze([...changes])), operationIds: dataArray<Id>([]), ...(settings.groupId ? { groupId: settings.groupId } : {}) })));
undo.push(historyEntryId);
}
while (undo.length > limit) entries.delete(undo.shift()!);
@@ -266,8 +267,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
const entries=new Map(state.history.entries);
// Later redo entries belong to the lifecycle restored by this explicit history action.
// Frozen operations retain their original identities and incarnations.
if(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(remap)}));
entries.set(id,freeze({...entry,changes:direction==='undo'?invert(changes):changes}));
if(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,dataRecord(freeze({...other,changes:dataArray(freeze(other.changes.map(remap)))})));
entries.set(id,dataRecord(freeze({...entry,changes:dataArray(freeze(direction==='undo'?invert(changes):changes))})));
const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) });
return publish(document, changes, direction, history, id);
} catch (error) { return reject(error); }
+2 -1
View File
@@ -1,3 +1,4 @@
import {dataRecord} from '../canvas-core/data-containers.ts';
import {createImportReadProof,IMPORT_PROOF_BLOCK_BYTES} from './import-read-proof.ts';
import {isImportByteFailure} from './import-byte-reader.ts';
import {assertImportEvidence,importSeals,importsMaySend} from './import-verification.ts';
@@ -674,7 +675,7 @@ export async function openProjectEngine(options: EngineOptions) {
const context=proofReaders.get(job.id),current=editor.getSnapshot().runtime.imports?.get(job.id)?.source;
if(!context||context.fileId!==job.source.fileId)throw fault('import_source_unavailable');
const {sha256:_,integrityError:__,...source}=job.source;
return {...job,source:{...source,readProof:context.reader.getProof(),...(current?.sha256?{sha256:current.sha256}:{}),...(current?.integrityError?{integrityError:current.integrityError}:{})}};
return dataRecord(freeze({...job,source:{...source,readProof:context.reader.getProof(),...(current?.sha256?{sha256:current.sha256}:{}),...(current?.integrityError?{integrityError:current.integrityError}:{})}}));
}
function failImportVerification(job:ImportJob,error:unknown){
const code=(error as {code?:string}).code??'import_source_verification_failed';
+2 -1
View File
@@ -1,3 +1,4 @@
import {dataRecord} from '../canvas-core/data-containers.ts';
import type {Id} from '../canvas-core/types.ts';
import {fault} from '../canvas-core/values.ts';
type Ids=Readonly<Record<string,Id>>;
@@ -20,7 +21,7 @@ export function createImportIdentities(){
(ids as Record<string,Id>)[key]=id;used.add(id);return id;
},
has:(id:Id)=>used.has(id),
finish:():Ids=>owned?{...ids}:ids,
finish:():Ids=>owned?dataRecord(ids):ids,
});
};
return Object.freeze(Object.assign(capture,{clear(){cached=undefined;}}));
+3 -2
View File
@@ -1,3 +1,4 @@
import {dataArray,dataRecord} from '../canvas-core/data-containers.ts';
import {createImportBatches} from './import-batches.ts';
import {createImportIdentities} from './import-identities.ts';
import {validateImportReadProof,type ImportReadProof} from './import-read-proof.ts';
@@ -146,7 +147,7 @@ export function createImportRunner(ports:ImportRunnerPorts){
else if(!equal(job.source,value.source))throw fault('invalid_import_checkpoint');
return value;
}
function next(job:ImportJob,patch:Partial<ImportJob>):ImportJob{const value={...job,...patch,version:3 as const,generation:job.generation+1};if(value.error===undefined)delete value.error;return freeze(checkpointEvidence(value));}
function next(job:ImportJob,patch:Partial<ImportJob>):ImportJob{const value={...job,...patch,version:3 as const,generation:job.generation+1};if(value.error===undefined)delete value.error;const checked=checkpointEvidence(value);return checked===value?dataRecord(freeze(value)):freeze(checked);}
function publish(job:ImportJob,patch:Partial<ImportJob>){const value=next(job,patch);ports.publish(value,job.generation);if(!equal(current(),value))throw fault('invalid_import_publish');return value;}
function prepare(line:ImportLine,job:ImportJob){
const ids=identities(job.stableIds,allocateIdentity);
@@ -161,7 +162,7 @@ export function createImportRunner(ports:ImportRunnerPorts){
function completed(job:ImportJob,units:readonly ImportUnit[],pending:readonly ImportLocator[]){
const bindings:Record<Id,ImportBinding>=Object.assign(Object.create(null),job.bindings??{});
for(const unit of units)for(const change of unit.changes)if(change.kind==='element'&&change.before===null&&change.after)bindings[change.id]={kind:'element',incarnation:change.after.incarnation};else if(change.kind==='canvas'&&change.before===null&&change.after)bindings[change.id]={kind:'canvas',...(change.after.parent?{entryElementId:change.after.parent.entryElementId}:{})};
return next(job,{pending,committedSourceKeys:[...job.committedSourceKeys,...units.map(unit=>unit.key)],bindings:{...bindings}});
return next(job,{pending,committedSourceKeys:dataArray(freeze([...job.committedSourceKeys,...units.map(unit=>unit.key)])),bindings:dataRecord(freeze({...bindings}))});
}
function commit(units:readonly ImportUnit[],job:ImportJob,value:ImportJob){const base=current();if(base.generation!==job.generation)throw fault('stale_import_job');value=freeze(checkpointEvidence(value));const start=now(),result=ports.commit(units,value,job.generation),elapsed=Math.max(0,now()-start);if(!['applied','rejected'].includes(result?.status)||!equal(current(),result.status==='applied'?value:base))throw fault('invalid_import_commit');if(result.status==='applied')batches.record(units.length,elapsed);return result;}
async function pump(signal:AbortSignal){
+18 -7
View File
@@ -71,21 +71,30 @@ export function sealRecoveryImage(formatVersion:ImageVersion,rootRevision:number
return finishImage(formatVersion,rootRevision,localRevision,encodeRecoveryValue(payload));
}
type Prepared = Encoded | {source:object;depth:number;children:Prepared[];keys?:string[]};
type Scalar = null|string|number|boolean|undefined|symbol;
type Prepared = Scalar | Encoded | {source:object;depth:number;children:Prepared[];keys?:string[]};
// Read unknown objects synchronously, before the first suspension. Shape-owned
// containers can include external values; each such occurrence is captured now.
// Only our fresh container shape and previously proven encoded values defer.
function prepareEncoding(input:unknown) {
const normalize=createEncoder(),visiting=new Set<object>();
function prepare(value:unknown,depth:number,key:string):Prepared {
if(!value||typeof value!=='object')return normalize(value,depth,key);
const kind=typeof value;
if(value===null||kind==='string'||kind==='number'||kind==='boolean'||kind==='undefined'||kind==='symbol'){
if(depth>64&&value!==undefined&&typeof value!=='symbol')throw fault('invalid_recovery_payload');
// Primitives cannot change while suspended. Defer normalization/UTF-8 work
// as well as allocation of their Encoded facts to the budgeted traversal.
return value as Scalar;
}
if(typeof value!=='object')return normalize(value,depth,key);
const cached=immutable.get(value);
if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;}
if(!isDataContainer(value))return normalize(value,depth,key);
// Prototype toJSON changes must retain ordinary JSON semantics, including
// accessors. Inspect descriptors without evaluating them an extra time.
let prototype:object|null=value,hasToJSON=false;
while(prototype){if(Object.getOwnPropertyDescriptor(prototype,'toJSON')){hasToJSON=true;break;}prototype=Object.getPrototypeOf(prototype);}
if(!isDataContainer(value)||hasToJSON)return normalize(value,depth,key);
if(hasToJSON)return normalize(value,depth,key);
if(depth>64||visiting.has(value))throw fault('invalid_recovery_payload');
visiting.add(value);
try {
@@ -95,7 +104,7 @@ function prepareEncoding(input:unknown) {
return {source:value,depth,children,keys};
}finally{visiting.delete(value);}
}
return prepare(input,0,'');
return {plan:prepare(input,0,''),normalize};
}
/** Capture external data immediately; cooperatively encode stable owned tables.
@@ -103,15 +112,17 @@ function prepareEncoding(input:unknown) {
* The returned image has exactly the synchronous codec's wire representation. */
export async function sealRecoveryImageAsync(formatVersion:ImageVersion,rootRevision:number,localRevision:number,payload:unknown,options:ImportScheduling={}):Promise<RecoveryImage> {
validateHeader(formatVersion,rootRevision,localRevision);
const plan=prepareEncoding(payload);
const {plan,normalize}=prepareEncoding(payload);
const scheduler=(globalThis as typeof globalThis & {scheduler?:{yield?:()=>Promise<void>}}).scheduler;
const budget=createImportBudget(new AbortController().signal,{...options,yieldTask:options.yieldTask??(typeof scheduler?.yield==='function'?()=>scheduler.yield!():undefined)});
function* encode(plan:Prepared):Generator<Promise<void>,Encoded,void>{
function* encode(plan:Prepared,depth=0,key=''):Generator<Promise<void>,Encoded,void>{
if(plan===null||typeof plan!=='object')return normalize(plan,depth,key);
if('height' in plan)return plan;
let height=0,bytes=2,reusable=true,properties=0;
const array=plan.keys===undefined,result:Json[]|Record<string,Json>=array?[]:{};
for(let index=0;index<plan.children.length;index++){
const entry=plan.children[index],child='height' in entry?entry:yield* encode(entry);
const entry=plan.children[index],name=plan.keys?.[index]??String(index);
const child=entry===null||typeof entry!=='object'?normalize(entry,plan.depth+1,name):'height' in entry?entry:yield* encode(entry,plan.depth+1,name);
reusable&&=child.reusable;
if(array){
if(child.value===undefined&&plan.depth+1>64)throw fault('invalid_recovery_payload');