feat(canvas): elide closed unsent lifetimes with durable local proofs
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { withLocalLifecycles } from './elision.ts';
|
||||
import type { Change, Document, FieldChange, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import { describeHistoryTransition, historyTransitionChanges, type HistoryTransition } from '../canvas-core/history-transition.ts';
|
||||
import { invert } from '../canvas-core/document.ts';
|
||||
@@ -41,7 +42,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
const adopted=new Set([...snapshot.runtime.recoveries.values()].filter(audit=>audit.historyReplay!==undefined).flatMap(audit=>audit.decisions.flatMap(decision=>decision.replacementId?[decision.replacementId]:[])));
|
||||
const content=records.filter(record=>record.intent.changes.length),hasEvidence=content.length>0&&content.every(record=>record.intent.historyTransition)||records.some(record=>['undo','redo'].includes(record.intent.origin)&&record.intent.historyTransition)||content.some(record=>adopted.has(record.intent.id));
|
||||
const journal=hasEvidence?createHistoryJournalRecovery(snapshot,records.map(record=>record.intent),registry,historyLimit):undefined;
|
||||
let document=canonical.document,sequence=0;for(const record of snapshot.runtime.ledger.operations.values())sequence=Math.max(sequence,record.intent.sequence);
|
||||
let document=withLocalLifecycles(canonical.document,snapshot.runtime.ledger),sequence=0;for(const record of snapshot.runtime.ledger.operations.values())sequence=Math.max(sequence,record.intent.sequence);
|
||||
for(const record of records){
|
||||
const intent=record.intent,choice=choices.get(intent.id)!;
|
||||
if(intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[intent.id]);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Document, Id, OperationIntent } from '../canvas-core/types.ts';
|
||||
import { applyChanges } from '../canvas-core/document.ts';
|
||||
import { equal, fault, freeze, readMap } from '../canvas-core/values.ts';
|
||||
import type { LedgerSnapshot } from './ledger.ts';
|
||||
|
||||
export type ElisionProof = { readonly version: 1; readonly operationIds: readonly Id[]; readonly baseTombstones: Readonly<Record<Id, number>>; readonly tombstones: Readonly<Record<Id, number>> };
|
||||
|
||||
/** Reconstruct a closed lifetime from an empty fragment. Existing objects cannot enter this proof. */
|
||||
export function proveElision(intents: readonly OperationIntent[], baseTombstones: Readonly<Record<Id, number>>): ElisionProof {
|
||||
if (!intents.length || intents.length > 64 || new Set(intents.map(intent => intent.id)).size !== intents.length) throw fault('invalid_elision');
|
||||
const created = new Set<Id>(), canvases = new Set<Id>();
|
||||
const highWater = new Map(Object.entries(baseTombstones));
|
||||
let count = 0;
|
||||
for (const [id, incarnation] of Object.entries(baseTombstones)) if (!id || !Number.isSafeInteger(incarnation) || incarnation < 1) throw fault('invalid_elision');
|
||||
for (const [index, intent] of intents.entries()) {
|
||||
if (intent.operationType !== 'document.change' || intent.operationSchemaVersion !== 1 || !intent.changes.length || index && intent.sequence <= intents[index - 1].sequence) throw fault('invalid_elision');
|
||||
for (const change of intent.changes) {
|
||||
if (++count > 10_000) throw fault('invalid_elision');
|
||||
const id = change.kind === 'field' ? change.target.id : change.id, seen = change.kind === 'canvas' ? canvases : created;
|
||||
if (!seen.has(id)) {
|
||||
if (change.kind === 'field' || change.before !== null || !change.after) throw fault('elision_existing_target', [id]);
|
||||
seen.add(id);
|
||||
}
|
||||
if (change.kind === 'element') {
|
||||
if (change.after && (!Number.isSafeInteger(change.after.incarnation) || change.after.incarnation < 1 || !change.before && change.after.incarnation <= (highWater.get(id) ?? 0) || change.before && change.after.incarnation !== change.before.incarnation)) throw fault('stale_incarnation', [id]);
|
||||
if (change.before && !change.after) highWater.set(id, Math.max(highWater.get(id) ?? 0, change.before.incarnation));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(baseTombstones).some(id => !created.has(id))) throw fault('invalid_elision');
|
||||
const fragment: Document = { projectId: '', elements: new Map(), canvases: new Map(), tombstones: new Map(Object.entries(baseTombstones)) };
|
||||
const final = applyChanges(fragment, intents.flatMap(intent => intent.changes));
|
||||
if (final.elements.size || final.canvases.size) throw fault('elision_has_effect');
|
||||
return freeze({ version: 1, operationIds: intents.map(intent => intent.id), baseTombstones: { ...baseTombstones }, tombstones: Object.fromEntries(final.tombstones) });
|
||||
}
|
||||
|
||||
/** Longest closed prefix before an existing-object write or another unsafe precondition. */
|
||||
export function findElision(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, preceding: ReadonlySet<Id> = new Set()): ElisionProof | undefined {
|
||||
const selected: OperationIntent[] = [], seen = new Set<Id>(), canvases = new Set<Id>(), bases: Record<Id, number> = Object.create(null);
|
||||
if (intents.length > 64 || !intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null))) return undefined;
|
||||
let bestLength = 0, bestBases: Record<Id, number> = {};
|
||||
let fragment: Document = { projectId: acknowledged.projectId, elements: new Map(), canvases: new Map(), tombstones: new Map() };
|
||||
for (const intent of intents) {
|
||||
const record = ledger.operations.get(intent.id);
|
||||
if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || !equal(record.intent, intent) || intent.operationType !== 'document.change' || intent.operationSchemaVersion !== 1) break;
|
||||
if (intent.dependencies.some(id => !selected.some(intent => intent.id === id) && !preceding.has(id) && ledger.operations.get(id)?.phase !== 'resolved')) break;
|
||||
let safe = true;
|
||||
for (const change of intent.changes) {
|
||||
const id = change.kind === 'field' ? change.target.id : change.id, known = change.kind === 'canvas' ? canvases : seen;
|
||||
if (known.has(id)) continue;
|
||||
if (change.kind === 'field' || change.before !== null || !change.after || (change.kind === 'canvas' ? acknowledged.canvases.has(id) : acknowledged.elements.has(id) || !Object.hasOwn(record.basePresence, id) || record.basePresence[id] !== 'absent')) { safe = false; break; }
|
||||
known.add(id);
|
||||
if (change.kind === 'element' && acknowledged.tombstones.has(id)) bases[id] = acknowledged.tombstones.get(id)!;
|
||||
}
|
||||
if (!safe) break;
|
||||
selected.push(intent);
|
||||
const tombstones = new Map(fragment.tombstones);
|
||||
for (const [id, incarnation] of Object.entries(bases)) tombstones.set(id, Math.max(tombstones.get(id) ?? 0, incarnation));
|
||||
fragment = applyChanges({ ...fragment, tombstones }, intent.changes);
|
||||
if (!fragment.elements.size && !fragment.canvases.size) { bestLength = selected.length; bestBases = { ...bases }; }
|
||||
}
|
||||
return bestLength ? proveElision(selected.slice(0, bestLength), bestBases) : undefined;
|
||||
}
|
||||
|
||||
/** Keep a later closed segment mutable while earlier real edits obtain their own receipt. */
|
||||
export function elisionBatchLimit(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot): number {
|
||||
if (!intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null)) || findElision(intents, acknowledged, ledger)) return intents.length;
|
||||
const preceding = new Set<Id>();
|
||||
for (let offset = 1; offset < intents.length; offset++) {
|
||||
preceding.add(intents[offset - 1].id);
|
||||
if (findElision(intents.slice(offset), acknowledged, ledger, preceding)) return offset;
|
||||
}
|
||||
return intents.length;
|
||||
}
|
||||
|
||||
/** A later locally canceled lifetime cannot reuse an incarnation retired by an earlier one. */
|
||||
export function assertElisionLineage(proof: ElisionProof, intents: readonly OperationIntent[], previous: ReadonlyMap<Id, number>): void {
|
||||
const bases: Record<Id, number> = Object.assign(Object.create(null), proof.baseTombstones);
|
||||
for (const id of Object.keys(proof.tombstones)) if ((previous.get(id) ?? 0) > (bases[id] ?? 0)) bases[id] = previous.get(id)!;
|
||||
if (!equal(proveElision(intents, bases).tombstones, proof.tombstones)) throw fault('invalid_elision');
|
||||
}
|
||||
|
||||
/** Validate persisted local facts independently of the current (possibly newer) server baseline. */
|
||||
export function validateElisions(ledger: Pick<LedgerSnapshot, 'operations'>): void {
|
||||
const ordered = [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence), positions = new Map(ordered.map((record, index) => [record.intent.id, index]));
|
||||
const previous = new Map<Id, number>();
|
||||
for (const record of ordered) {
|
||||
if (record.resolution !== 'elided') { if (record.elision !== undefined || record.elisionId !== undefined) throw fault('invalid_elision'); continue; }
|
||||
const anchor = record.elisionId ? ledger.operations.get(record.elisionId) : undefined, proof = anchor?.elision;
|
||||
if (proof && Number.isSafeInteger(proof.version) && proof.version > 1) throw fault('elision_interpreter_unavailable');
|
||||
if (!proof || proof.version !== 1 || proof.operationIds[0] !== record.elisionId || !proof.operationIds.includes(record.intent.id) || record.phase !== 'resolved' || record.requestId || record.blockers.length) throw fault('invalid_elision');
|
||||
if (record !== anchor && record.elision !== undefined) throw fault('invalid_elision');
|
||||
if (record === anchor) {
|
||||
const seen = new Set<Id>(), members = new Set(proof.operationIds), offset = positions.get(record.intent.id)!;
|
||||
const intents = proof.operationIds.map((id, index) => {
|
||||
const member = ledger.operations.get(id);
|
||||
if (!member || member.phase !== 'resolved' || member.resolution !== 'elided' || member.elisionId !== record.intent.id || member.requestId || member.blockers.length) throw fault('invalid_elision');
|
||||
if (ordered[offset + index]?.intent.id !== id || member.intent.dependencies.some(dependency => members.has(dependency) ? (ledger.operations.get(dependency)?.intent.sequence ?? Infinity) >= member.intent.sequence : ledger.operations.get(dependency)?.phase !== 'resolved')) throw fault('invalid_elision');
|
||||
for (const change of member.intent.changes) if (change.kind !== 'canvas') { const target = change.kind === 'field' ? change.target.id : change.id; if (!seen.has(target) && (!Object.hasOwn(member.basePresence, target) || member.basePresence[target] !== 'absent')) throw fault('invalid_elision'); seen.add(target); }
|
||||
return member.intent;
|
||||
});
|
||||
if (!equal(proof, proveElision(intents, proof.baseTombstones))) throw fault('invalid_elision');
|
||||
assertElisionLineage(proof, intents, previous);
|
||||
for (const [id, incarnation] of Object.entries(proof.tombstones)) previous.set(id, Math.max(previous.get(id) ?? 0, incarnation));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Local high-water marks belong to working state, never to the server ACK document. */
|
||||
export function withLocalLifecycles(document: Document, ledger: Pick<LedgerSnapshot, 'operations'>): Document {
|
||||
const tombstones = new Map(document.tombstones);
|
||||
for (const record of ledger.operations.values()) if (record.elision) for (const [id, incarnation] of Object.entries(record.elision.tombstones)) tombstones.set(id, Math.max(tombstones.get(id) ?? 0, incarnation));
|
||||
return Object.freeze({ ...document, tombstones: readMap(tombstones) });
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { elisionBatchLimit, findElision } from './elision.ts';
|
||||
import type { Document, Id, Registry, Result, RunInput, RunApplyInput, RunCancelInput, Json, OperationIntent, Data } from '../canvas-core/types.ts';
|
||||
import { createEditor, type EditorOptions, type Transaction } from '../canvas-core/editor.ts';
|
||||
import { createDocument } from '../canvas-core/document.ts';
|
||||
@@ -199,6 +200,12 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
if (!options.assets || !resource?.digest) throw fault('asset_not_ready');
|
||||
return { kind: 'asset.upload', protocolVersion: 2, canvasIds: [], expectedVersions: {}, payload: { protocol_version: 2, request_id: id, resource_id: resource.id, operation_id: resource.operationId, sha256: resource.digest, byte_length: resource.byteLength, name: resource.name, mime_type: resource.mimeType } };
|
||||
},
|
||||
batchLimit(intents) { const snapshot = editor.getSnapshot(); return elisionBatchLimit(intents, snapshot.runtime.acknowledged, snapshot.runtime.ledger); },
|
||||
elide(intents) {
|
||||
const snapshot = editor.getSnapshot(), proof = findElision(intents, snapshot.runtime.acknowledged, snapshot.runtime.ledger);
|
||||
if (!proof) return [];
|
||||
mutate(ledger => ledger.elide(proof)); return proof.operationIds;
|
||||
},
|
||||
compileBatch: options.persistence.compileBatch ? (intents, id) => options.persistence.compileBatch!(intents, id, editor.getSnapshot()) : undefined,
|
||||
async submit(request, signal) {
|
||||
if (['run.start','run.cancel','run.apply'].includes(request.kind??'')) return options.runs ? network.run(signal, () => options.runs!.submit(request,signal)) : { outcome: 'unresolved', blocked: true, error: 'run_interpreter_unavailable' };
|
||||
|
||||
@@ -60,7 +60,7 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
|
||||
const event=events[next],record=event.record,before=history;
|
||||
if(!record)applyAdjustment(event);
|
||||
else{
|
||||
if(pendingIds.has(record.intent.id))throw fault('recovery_history_event_order',[record.intent.id]);if(record.resolution!=='applied')throw fault('recovery_history_event_unresolved',[record.intent.id]);
|
||||
if(pendingIds.has(record.intent.id))throw fault('recovery_history_event_order',[record.intent.id]);if(!['applied','elided'].includes(record.resolution??''))throw fault('recovery_history_event_unresolved',[record.intent.id]);
|
||||
if(record.intent.origin==='user')addUser(record.intent,record.intent.changes,record.intent.id,false);else if(['undo','redo'].includes(record.intent.origin))addConfirmedTraversal(record.intent);else throw fault('recovery_history_event_origin_unsupported',[record.intent.id]);
|
||||
}
|
||||
recordStep(before,event.transition.entryId,record?.intent.id);next++;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertElisionLineage, proveElision, type ElisionProof } from './elision.ts';
|
||||
import { applyChanges } from '../canvas-core/document.ts';
|
||||
import type { Change, Id, Json, OperationIntent } from '../canvas-core/types.ts';
|
||||
import { clone, equal, fault, freeze, readMap, validJson } from '../canvas-core/values.ts';
|
||||
@@ -13,7 +14,7 @@ export type FrozenRequest = {
|
||||
readonly phase: RequestPhase; readonly outcome: Outcome; readonly error?: string; readonly attempt: number;
|
||||
readonly retryAt?: number; readonly retryAction?: 'submit' | 'query';
|
||||
};
|
||||
export type OperationRecord = { readonly intent: OperationIntent; readonly phase: 'pending' | 'frozen' | 'resolved' | 'blocked'; readonly requestId?: Id; readonly blockers: readonly string[]; readonly resolution?: 'applied' | 'not-applied' | 'superseded' | 'discarded' | 'satisfied'; readonly recoveryId?: Id; readonly replacementId?: Id; readonly basePresence: Readonly<Record<Id, Presence>> };
|
||||
export type OperationRecord = { readonly intent: OperationIntent; readonly phase: 'pending' | 'frozen' | 'resolved' | 'blocked'; readonly requestId?: Id; readonly blockers: readonly string[]; readonly resolution?: 'applied' | 'not-applied' | 'superseded' | 'discarded' | 'satisfied' | 'elided'; readonly elisionId?: Id; readonly elision?: ElisionProof; readonly recoveryId?: Id; readonly replacementId?: Id; readonly basePresence: Readonly<Record<Id, Presence>> };
|
||||
export type LedgerSnapshot = { readonly generation: number; readonly durableGeneration: number; readonly operations: ReadonlyMap<Id, OperationRecord>; readonly requests: ReadonlyMap<Id, FrozenRequest> };
|
||||
|
||||
/** A field-safe compressor for a wholly unsent segment. Frozen records never enter here. */
|
||||
@@ -99,6 +100,22 @@ export function createLedger(initial?: LedgerSnapshot) {
|
||||
if (outcome === 'not-applied') for (const operationId of request.operationIds) operations.set(operationId, freeze({ ...operations.get(operationId)!, phase: 'blocked', blockers: [error || 'not_applied'] }));
|
||||
changed();
|
||||
},
|
||||
elide(proof: ElisionProof) {
|
||||
const ids = new Set(proof.operationIds), seen = new Set<Id>(), intents = proof.operationIds.map(id => {
|
||||
const record = operations.get(id);
|
||||
if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || record.intent.dependencies.some(dependency => ids.has(dependency) ? (operations.get(dependency)?.intent.sequence ?? Infinity) >= record.intent.sequence : operations.get(dependency)?.phase !== 'resolved')) throw fault('elision_not_safe', [id]);
|
||||
for (const change of record.intent.changes) if (change.kind !== 'canvas') { const target = change.kind === 'field' ? change.target.id : change.id; if (!seen.has(target) && (!Object.hasOwn(record.basePresence, target) || record.basePresence[target] !== 'absent')) throw fault('elision_not_safe', [target]); seen.add(target); }
|
||||
return record.intent;
|
||||
});
|
||||
if (!equal(proof, proveElision(intents, proof.baseTombstones))) throw fault('invalid_elision');
|
||||
const previous = new Map<Id, number>();
|
||||
for (const record of operations.values()) if (record.elision) for (const [id, incarnation] of Object.entries(record.elision.tombstones)) previous.set(id, Math.max(previous.get(id) ?? 0, incarnation));
|
||||
assertElisionLineage(proof, intents, previous);
|
||||
const first = intents[0].sequence, last = intents.at(-1)!.sequence;
|
||||
if ([...operations.values()].some(record => record.intent.sequence >= first && record.intent.sequence <= last && !ids.has(record.intent.id))) throw fault('elision_not_contiguous');
|
||||
for (const id of proof.operationIds) operations.set(id, freeze({ ...operations.get(id)!, phase: 'resolved', resolution: 'elided', elisionId: proof.operationIds[0], ...(id === proof.operationIds[0] ? { elision: clone(proof) } : {}) }));
|
||||
changed();
|
||||
},
|
||||
resolveNoEffect(id: Id) {
|
||||
const request=requireRequest(id);
|
||||
if(request.outcome!=='not-applied'||!['run.start','run.cancel'].includes(request.kind??''))throw fault('invalid_no_effect_resolution');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withLocalLifecycles } from './elision.ts';
|
||||
import type { Change, Document, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import { applyChanges, validateDocument } from '../canvas-core/document.ts';
|
||||
import { equal, fault } from '../canvas-core/values.ts';
|
||||
@@ -51,7 +52,7 @@ export function operationCanvases(intent: OperationIntent, document: Document, a
|
||||
|
||||
/** Replay once, by logical sequence. A receipt can already contain a desired field value. */
|
||||
export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document {
|
||||
let document = base;
|
||||
let document = withLocalLifecycles(base, ledger);
|
||||
for (const record of [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) {
|
||||
if (record.phase === 'resolved' || !['document.change','run.apply'].includes(record.intent.operationType)) continue;
|
||||
for (const change of record.intent.changes) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { validateElisions, withLocalLifecycles } from './elision.ts';
|
||||
import type { Change, CoreSnapshot, Document, HistoryEntry, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import { validateHistoryReplay, replayHistoryEvidence, sameHistory } from './history-replay.ts';
|
||||
import type { RecoveryAudit } from './conflicts.ts';
|
||||
@@ -94,7 +95,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
const raw = object(value); intent(raw.intent, id); strings(raw.blockers); object(raw.basePresence);
|
||||
if (((object(raw.intent).operationType === 'document.change'||object(raw.intent).operationType==='run.apply'&&array(object(raw.intent).changes).length>0) && !operations.has(id)) || !['pending', 'frozen', 'resolved', 'blocked'].includes(string(raw.phase))) throw fault('corrupt_recovery_image');
|
||||
if(operations.has(id)&&!equal(operations.get(id)!.historyTransition,(raw.intent as OperationIntent).historyTransition))throw fault('corrupt_recovery_image');
|
||||
if(raw.resolution!==undefined&&!['applied','not-applied','superseded','discarded','satisfied'].includes(string(raw.resolution)))throw fault('corrupt_recovery_image');
|
||||
if(raw.resolution!==undefined&&!['applied','not-applied','superseded','discarded','satisfied','elided'].includes(string(raw.resolution)))throw fault('corrupt_recovery_image');
|
||||
return freeze(value as OperationRecord);
|
||||
});
|
||||
if (operations.size !== [...records.values()].filter(record => (record.intent.operationType === 'document.change'||record.intent.operationType==='run.apply'&&record.intent.changes.length>0)).length) throw fault('corrupt_recovery_image');
|
||||
@@ -136,6 +137,12 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
return freeze(value as FrozenRequest);
|
||||
});
|
||||
for (const record of records.values()) if (record.requestId && !requests.has(record.requestId) || record.phase === 'frozen' && !record.requestId) throw fault('corrupt_recovery_image');
|
||||
try {
|
||||
validateElisions({ operations: records });
|
||||
for (const record of records.values()) if (record.resolution === 'elided') { const original = operations.get(record.intent.id); if (!original || !equal(original.changes, record.intent.changes) || original.sequence !== record.intent.sequence || original.origin !== record.intent.origin) throw fault('invalid_elision'); }
|
||||
const local = withLocalLifecycles(document, { operations: records });
|
||||
if (!equal([...local.tombstones], [...document.tombstones])) throw fault('invalid_elision');
|
||||
} catch (error) { if ((error as {code?:string}).code === 'elision_interpreter_unavailable') throw error; throw fault('corrupt_recovery_image'); }
|
||||
const receipts=table(runtime.receipts,value=>freeze(value as Json));
|
||||
for(const record of records.values())if(record.resolution==='not-applied'){
|
||||
const request=record.requestId?requests.get(record.requestId):undefined;
|
||||
@@ -186,7 +193,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
const original=readHistory(object(backup.history),operations);
|
||||
const composed=replayHistoryEvidence(original,replay,id=>{
|
||||
const record=records.get(id);
|
||||
return!!record?.intent.changes.length&&['user','undo','redo'].includes(record.intent.origin)&&(local.has(id)||record.resolution==='applied');
|
||||
return!!record?.intent.changes.length&&['user','undo','redo'].includes(record.intent.origin)&&(local.has(id)||['applied','elided'].includes(record.resolution??''));
|
||||
});
|
||||
const expected=applyHistoryTransition(original,raw.historyTransition as HistoryTransition,'forward');
|
||||
if(!sameHistory(composed,expected))throw fault('corrupt_recovery_image');
|
||||
|
||||
@@ -15,6 +15,8 @@ export type SchedulerOptions = {
|
||||
createId(): Id;
|
||||
participants(intent: OperationIntent): readonly Id[];
|
||||
compile(intent: OperationIntent, requestId: Id): CompiledRequest;
|
||||
batchLimit?: (intents: readonly OperationIntent[]) => number;
|
||||
elide?: (intents: readonly OperationIntent[]) => readonly Id[];
|
||||
compileBatch?: (intents: readonly OperationIntent[], requestId: Id) => CompiledRequest;
|
||||
submit(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
||||
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
||||
@@ -68,7 +70,7 @@ export function createScheduler(options: SchedulerOptions) {
|
||||
/** Only a contiguous mutable document prefix may share a request. Task barriers stay visible. */
|
||||
function batch(first: OperationIntent, canvases: Set<Id>): readonly OperationIntent[] {
|
||||
const selected = [first], ids = new Set([first.id]);
|
||||
if (!options.compileBatch || first.operationType !== 'document.change' || first.operationSchemaVersion !== 1) return selected;
|
||||
if ((!options.compileBatch && !options.elide) || first.operationType !== 'document.change' || first.operationSchemaVersion !== 1) return selected;
|
||||
let lo = 0, hi = ordered.length, changeCount = first.changes.length;
|
||||
while (lo < hi) { const mid = (lo + hi) >>> 1; if (ordered[mid].sequence <= first.sequence) lo = mid + 1; else hi = mid; }
|
||||
const ledger = options.getLedger();
|
||||
@@ -85,7 +87,7 @@ export function createScheduler(options: SchedulerOptions) {
|
||||
return selected;
|
||||
}
|
||||
function requestPump() {
|
||||
if (!options.compileBatch) { pump(); return; }
|
||||
if (!options.compileBatch && !options.elide) { pump(); return; }
|
||||
if (!scheduledPump) scheduledPump = Promise.resolve().then(() => { scheduledPump = undefined; pump(); });
|
||||
}
|
||||
function own(request: FrozenRequest) {
|
||||
@@ -185,8 +187,18 @@ export function createScheduler(options: SchedulerOptions) {
|
||||
for (const canvas of occupied) { const set = waiters.get(canvas) ?? new Set<Id>(); set.add(id); waiters.set(canvas, set); }
|
||||
continue;
|
||||
}
|
||||
const scope = new Set(canvases), intents = batch(record.intent, scope);
|
||||
const participants = [...scope].sort();
|
||||
const scope = new Set(canvases), selected = batch(record.intent, scope);
|
||||
const limit = options.batchLimit?.(selected) ?? selected.length;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > selected.length) throw fault('invalid_batch_limit');
|
||||
const candidates = selected.slice(0, limit);
|
||||
const elided = options.elide?.(candidates) ?? [];
|
||||
if (elided.length) {
|
||||
if (elided.some((id, index) => id !== candidates[index]?.id || options.getLedger().operations.get(id)?.resolution !== 'elided')) throw fault('invalid_elision');
|
||||
for (const id of elided) for (const dependent of dependents.get(id) ?? []) enqueue(dependent);
|
||||
continue;
|
||||
}
|
||||
const intents = options.compileBatch ? candidates : [record.intent];
|
||||
const participants = [...new Set(intents.flatMap(intent => options.participants(intent)))].sort();
|
||||
const requestId = options.createId(), compiled = intents.length > 1 ? options.compileBatch!(intents, requestId) : options.compile(record.intent, requestId);
|
||||
if (participants.length !== compiled.canvasIds.length || participants.some(canvas => !compiled.canvasIds.includes(canvas))) throw fault('participant_mismatch');
|
||||
options.mutate(ledger => ledger.freeze({ ...compiled, id: requestId, operationIds: intents.map(intent => intent.id) }));
|
||||
|
||||
@@ -294,6 +294,25 @@ async function resourceRecoveryCase(name, assert) {
|
||||
}
|
||||
|
||||
|
||||
async function elisionRecoveryCase(name,assert){
|
||||
for(const structural of [false,true]){
|
||||
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:200,height:100,rotation:0}}),original=createDocument(root,[{id:root,parent:null}],[node('a')]),database=`${name}-elision-${structural}`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1]]),engine;
|
||||
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
|
||||
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'elision restore lost the actual server version');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
|
||||
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
|
||||
const reopen=async()=>{await engine.close();engine=await openProjectEngine(options);};
|
||||
try{
|
||||
engine=await openProjectEngine(options);const created=engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:structural?{...node('temporary'),componentId:'frame'}:node('temporary')});if(structural){tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'temporary'}}});tx.dispatch({type:'element.create',element:node('member',child)});}}),removed=engine.editor.dispatch({type:'element.remove',target:{id:'temporary',incarnation:1}});await engine.settle();
|
||||
const ids=[...created.operationIds,...removed.operationIds];assert(ids.length===2&&calls.length===0,'closed unsent lifetime still sent a request');assert(engine.getSnapshot().runtime.ledger.requests.size===0&&engine.getSnapshot().history.undo.length===2,'local elision forged a request or collapsed history');
|
||||
await reopen();for(const id of ids)assert(engine.getSnapshot().runtime.ledger.operations.get(id).resolution==='elided','local completion proof did not survive IndexedDB');assert(engine.getSnapshot().document.tombstones.get('temporary')===1&&!engine.getSnapshot().runtime.acknowledged.tombstones.has('temporary'),'local lifecycle contaminated the server baseline');
|
||||
const localUndo=engine.editor.history.undo(),localRedo=engine.editor.history.redo();assert(localUndo.status==='applied'&&localRedo.status==='applied','local traversal pair failed');ids.push(...localUndo.operationIds,...localRedo.operationIds);await engine.settle();await reopen();assert(calls.length===0&&engine.getSnapshot().document.tombstones.get('temporary')===2,'repeated local elision lost its next incarnation');
|
||||
engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'unrelated ACK'}});await engine.settle();const session=await engine.prepareRecovery();assert((await session.apply()).status==='applied','canonical refresh after elision failed');await engine.settle();await reopen();assert(engine.getSnapshot().document.tombstones.get('temporary')===2,'ACK or fresh canonical read lost the local high-water mark');
|
||||
assert(engine.editor.history.undo().status==='applied','unrelated undo failed');await engine.settle();assert(engine.editor.history.undo().status==='applied','elided deletion undo failed');await engine.settle();assert(document.elements.get('temporary').incarnation===3,'undo reused the canceled incarnation');if(structural)assert(document.canvases.has(child)&&document.elements.get('member').incarnation===3,'partition undo lost its members or lifecycle');
|
||||
assert(engine.editor.history.redo().status==='applied','elided deletion redo failed');await engine.settle();assert(!document.elements.has('temporary')&&document.tombstones.get('temporary')===3,'redo did not archive the fresh life');await reopen();assert(engine.getSnapshot().document.tombstones.get('temporary')===3,'server/local lifecycle merge regressed after reopening');assert(calls.filter(call=>call.method==='POST').length===4,'elision history was retransmitted');for(const request of engine.getSnapshot().runtime.ledger.requests.values())assert(!request.operationIds.some(id=>ids.includes(id)),'locally elided IDs appeared in a backend request');
|
||||
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
|
||||
}
|
||||
}
|
||||
|
||||
async function batchRecoveryCase(name,assert){
|
||||
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},document=createDocument(root,[{id:root,parent:null}],[node]);
|
||||
const firstTitle='first '.repeat(100),secondTitle='second '.repeat(100);
|
||||
@@ -445,6 +464,8 @@ export async function run() {
|
||||
results.push('project engine reopens real IndexedDB with one history unit and the original request; ACK resolves without duplicate edit');
|
||||
await resourceRecoveryCase(name, assert);
|
||||
results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity');
|
||||
await elisionRecoveryCase(name,assert);
|
||||
results.push('unsent node/partition create-delete lifetimes make zero requests, retain local proofs through IndexedDB/ACK/fresh reads, and undo restores new incarnations');
|
||||
await batchRecoveryCase(name,assert);
|
||||
results.push('ordered mutable batches retain every logical ID and independent undo unit through a lost ACK, real IndexedDB reopening and exact receipt query');
|
||||
await protocolRecoveryCase(name,assert);
|
||||
|
||||
@@ -134,3 +134,21 @@ test('discard-only resolution rechecks current identity and a cancelled read can
|
||||
const other=fixture(),guarded=await openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:other.storage,persistence:other.port,policy:()=>allowed?{enabled:true}:{enabled:false,code:'identity_changed',targets:[]}});
|
||||
title(guarded,'local');await guarded.settle();const review=await guarded.prepareRecovery(),discard=review.plan(choices(guarded,'discard-local'));allowed=false;assert.equal((await review.resolve(discard)).code,'identity_changed');assert.equal(guarded.getSnapshot().runtime.recoveries.size,0);await review.cancel();await guarded.close();
|
||||
});
|
||||
|
||||
|
||||
test('later locally elided history survives keep/discard of an earlier rejected edit and its recovery replay audit',async()=>{
|
||||
for(const keep of [false,true]){
|
||||
const f=fixture();let engine=await f.open();
|
||||
try{
|
||||
title(engine,'pending');await engine.settle();const temporary={...element,id:'temporary'};
|
||||
const created=engine.editor.dispatch({type:'element.create',element:temporary}),removed=engine.editor.dispatch({type:'element.remove',target:{id:temporary.id,incarnation:1}});await engine.settle();
|
||||
const localIds=[...created.operationIds,...removed.operationIds];assert.equal(f.calls.length,1);for(const id of localIds)assert.equal(engine.getSnapshot().runtime.ledger.operations.get(id).resolution,'elided');
|
||||
f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,keep?'keep-local':'discard-local'));assert.equal(plan.document.tombstones.get(temporary.id),1);f.reject=false;await session.resolve(plan);await engine.settle();
|
||||
await engine.close();engine=await f.open();assert.equal(engine.getSnapshot().document.tombstones.get(temporary.id),1);assert.equal(engine.getSnapshot().runtime.acknowledged.tombstones.has(temporary.id),false);assert.equal(engine.getSnapshot().history.undo.length,keep?3:2);
|
||||
for(const request of engine.getSnapshot().runtime.ledger.requests.values())assert.equal(request.operationIds.some(id=>localIds.includes(id)),false);
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(temporary.id).incarnation,2);
|
||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has(temporary.id),false);assert.equal(engine.getSnapshot().document.elements.get('a').data.title,keep?'pending':'remote');
|
||||
decodeRecovery(f.image(),registry);
|
||||
}finally{await engine.close();}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDocument, createRegistry, builtinComponents, applyChanges } from '../../src/lib/canvas-core/index.ts';
|
||||
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
|
||||
import { elisionBatchLimit, findElision, proveElision, validateElisions, withLocalLifecycles } from '../../src/lib/canvas-runtime/elision.ts';
|
||||
import { replayPending } from '../../src/lib/canvas-runtime/reconcile.ts';
|
||||
import { resourcePins } from '../../src/lib/canvas-runtime/resources.ts';
|
||||
const registry=createRegistry(builtinComponents()),base=createDocument('project',[{id:'canvas',parent:null}],[]);
|
||||
const node=(id='new',ownerCanvasId='canvas',incarnation=1)=>({id,ownerCanvasId,incarnation,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||
const create=element=>({kind:'element',id:element.id,before:null,after:element}),remove=element=>({kind:'element',id:element.id,before:element,after:null});
|
||||
function fixture(chunks,document=base){
|
||||
const ledger=createLedger(),intents=chunks.map((changes,index)=>({id:`op-${index}`,operationType:'document.change',operationSchemaVersion:1,sequence:index+1,origin:'user',canvasIds:['canvas'],changes,dependencies:index?[`op-${index-1}`]:[]}));
|
||||
for(const intent of intents){const presence=Object.create(null);for(const change of intent.changes)if(change.kind!=='canvas'){const id=change.kind==='field'?change.target.id:change.id;presence[id]??=change.kind==='element'&&!change.before?'absent':'exists';}ledger.add(intent,presence);}
|
||||
return{ledger,intents,proof:()=>findElision(intents,document,ledger.getSnapshot())};
|
||||
}
|
||||
|
||||
test('closed create/update/delete lifetime retires without a request and preserves only local high-water marks',()=>{
|
||||
const original=node(),changed={...original,data:{...original.data,title:'changed'}},f=fixture([[create(original)],[{kind:'field',target:{id:original.id,incarnation:1},field:'data.title',before:'new',after:'changed'}],[remove(changed)]]),proof=f.proof();
|
||||
assert.deepEqual(proof.operationIds,['op-0','op-1','op-2']);assert.deepEqual(proof.tombstones,{new:1});f.ledger.elide(proof);validateElisions(f.ledger.getSnapshot());
|
||||
assert.equal(f.ledger.getSnapshot().requests.size,0);assert.equal(base.tombstones.size,0);assert.equal(replayPending(base,f.ledger.getSnapshot(),registry).tombstones.get('new'),1);
|
||||
for(const record of f.ledger.getSnapshot().operations.values()){assert.equal(record.resolution,'elided');assert.equal(record.requestId,undefined);}
|
||||
});
|
||||
|
||||
test('the longest balanced prefix can retire before a later existing-element edit',()=>{
|
||||
const a=node('a'),document=createDocument('project',[{id:'canvas',parent:null}],[a]),n=node(),f=fixture([[create(n)],[remove(n)],[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'edited'}]],document);
|
||||
const proof=f.proof();assert.deepEqual(proof.operationIds,['op-0','op-1']);f.ledger.elide(proof);
|
||||
const working=replayPending(document,f.ledger.getSnapshot(),registry);assert.equal(working.elements.get('a').data.title,'edited');assert.equal(working.tombstones.get('new'),1);assert.equal(f.ledger.getSnapshot().operations.get('op-2').phase,'pending');
|
||||
});
|
||||
|
||||
test('closed partitions retain entry/member lifecycles without inventing server canvas versions',()=>{
|
||||
const frame={...node('frame'),componentId:'frame'},member=node('member','child'),canvas={id:'child',parent:{canvasId:'canvas',entryElementId:'frame'}},changes=[create(frame),{kind:'canvas',id:'child',before:null,after:canvas},create(member)],inverse=[remove(member),{kind:'canvas',id:'child',before:canvas,after:null},remove(frame)],f=fixture([changes,inverse]);
|
||||
f.ledger.elide(f.proof());const working=replayPending(base,f.ledger.getSnapshot(),registry);assert.equal(working.canvases.size,1);assert.deepEqual(Object.fromEntries(working.tombstones),{member:1,frame:1});assert.equal(base.canvases.has('child'),false);
|
||||
});
|
||||
|
||||
test('unknown, blocked, frozen and possibly-applied creation cannot be elided',()=>{
|
||||
const n=node();
|
||||
for(const state of ['unknown','blocked','frozen','dispatch']){
|
||||
const f=fixture([[create(n)],[remove(n)]]);
|
||||
if(state==='unknown'){const records=new Map(f.ledger.getSnapshot().operations);records.set('op-0',{...records.get('op-0'),basePresence:{new:'unknown'}});assert.equal(findElision(f.intents,base,{...f.ledger.getSnapshot(),operations:records}),undefined);continue;}
|
||||
if(state==='blocked')f.ledger.block('op-0',['resource']);else{f.ledger.freeze({id:'request',operationIds:['op-0'],canvasIds:['canvas'],expectedVersions:{canvas:1},payload:{}});if(state==='dispatch')f.ledger.markDispatch('request');}
|
||||
assert.equal(f.proof(),undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('a skipped observer, forged tombstone or reused incarnation cannot acquire a local completion proof',()=>{
|
||||
const n=node(),f=fixture([[create(n)],[remove(n)]]),proof=f.proof();assert.throws(()=>f.ledger.elide({...proof,tombstones:{new:2}}),/invalid_elision/);assert.equal(f.ledger.getSnapshot().operations.get('op-0').phase,'pending');
|
||||
const createIntent={...f.intents[0],sequence:1},deleteIntent={...f.intents[1],sequence:3},ledger=createLedger();ledger.add(createIntent,{new:'absent'});ledger.add({...createIntent,id:'observer',sequence:2,changes:[],dependencies:['op-0']},{});ledger.add(deleteIntent,{new:'exists'});
|
||||
assert.throws(()=>ledger.elide(proveElision([createIntent,deleteIntent],{})),/elision_not_contiguous/);
|
||||
assert.throws(()=>proveElision([{...createIntent,changes:[create(n),remove(n),create(n),remove(n)]}],{}),/stale_incarnation/);
|
||||
});
|
||||
|
||||
test('persisted elision groups validate ownership, scope continuity, version and exact lifecycle evidence',()=>{
|
||||
const n=node(),f=fixture([[create(n)],[remove(n)]]);f.ledger.elide(f.proof());const saved=f.ledger.getSnapshot();
|
||||
for(const change of [record=>({...record,elisionId:'missing'}),record=>({...record,elision:{...record.elision,tombstones:{new:2}}}),record=>({...record,basePresence:{new:'unknown'}})]){
|
||||
const operations=new Map(saved.operations);operations.set('op-0',change(operations.get('op-0')));assert.throws(()=>validateElisions({operations}),/invalid_elision/);
|
||||
}
|
||||
const operations=new Map(saved.operations),anchor=operations.get('op-0');operations.set('op-0',{...anchor,elision:{...anchor.elision,version:2}});assert.throws(()=>validateElisions({operations}),/elision_interpreter_unavailable/);
|
||||
});
|
||||
|
||||
test('resource references remain pinned by undo history after the document operations retire',()=>{
|
||||
const picture={...node('picture'),componentId:'image',data:{title:'picture',text:'',src:'asset:asset',assetId:'asset'}},f=fixture([[create(picture)],[remove(picture)]]);f.ledger.elide(f.proof());
|
||||
const entries=new Map([['create',{id:'create',changes:[create(picture)],operationIds:['op-0']}],['delete',{id:'delete',changes:[remove(picture)],operationIds:['op-1']}]]),pins=resourcePins({document:base,history:{undo:['create','delete'],redo:[],entries}},f.ledger.getSnapshot(),registry,new Map());
|
||||
assert.equal(pins.complete,true);assert.ok(pins.pins.get('asset').has('history:create'));assert.ok(pins.pins.get('asset').has('history:delete'));
|
||||
});
|
||||
|
||||
|
||||
test('a subsequent cancellation group cannot reuse an incarnation from a prior local lifetime',()=>{
|
||||
for(const id of ['new','constructor','__proto__']){
|
||||
const n=node(id),f=fixture([[create(n)],[remove(n)]]);f.ledger.elide(f.proof());
|
||||
const intents=f.intents.map((intent,index)=>({...intent,id:`later-${index}`,sequence:index+3,dependencies:index?['later-0']:[]}));
|
||||
f.ledger.add(intents[0],{[id]:'absent'});f.ledger.add(intents[1],{[id]:'exists'});const before=f.ledger.getSnapshot();
|
||||
assert.throws(()=>f.ledger.elide(proveElision(intents,{})),/stale_incarnation/);assert.equal(f.ledger.getSnapshot(),before);
|
||||
}
|
||||
});
|
||||
|
||||
test('a preceding real edit is split from a later closed segment, including explicit preceding dependencies',()=>{
|
||||
const a=node('a'),n=node(),document=createDocument('project',[{id:'canvas',parent:null}],[a]),f=fixture([[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'edited'}],[create(n)],[remove(n)]],document);
|
||||
assert.equal(elisionBatchLimit(f.intents,document,f.ledger.getSnapshot()),1);assert.equal(findElision(f.intents.slice(1),document,f.ledger.getSnapshot()),undefined);
|
||||
const closed=fixture([[create(n)],[remove(n)]]);assert.equal(elisionBatchLimit(closed.intents,base,closed.ledger.getSnapshot()),2);
|
||||
});
|
||||
@@ -217,6 +217,82 @@ test('one transaction captures the first presence baseline even when its created
|
||||
const operation = engine.getSnapshot().runtime.ledger.operations.get(result.operationIds[0]);
|
||||
assert.equal(operation.basePresence.temporary, 'absent');
|
||||
assert.equal(engine.getSnapshot().document.tombstones.get(node.id), 1);
|
||||
await engine.settle(); assert.equal(port.calls.length, 1);
|
||||
await engine.settle(); assert.equal(port.calls.length, 0);
|
||||
assert.equal(engine.getSnapshot().runtime.ledger.operations.get(result.operationIds[0]).resolution, 'elided');
|
||||
assert.equal(engine.getSnapshot().runtime.acknowledged.tombstones.has(node.id), false);
|
||||
} finally { await engine.close(); }
|
||||
});
|
||||
|
||||
|
||||
test('unsent create/delete retires locally, survives unrelated ACK and reopening, and undo restores a fresh incarnation', async () => {
|
||||
const storage = storageFixture(), port = persistenceFixture(); let next=0, engine;
|
||||
const reopen=async()=>{if(engine)await engine.close();engine=(await open(storage,port,{createId:()=>`elision-${++next}`})).engine;};
|
||||
try {
|
||||
await reopen();const node={...original.elements.get('a'),id:'temporary'};
|
||||
const created=engine.editor.dispatch({type:'element.create',element:node});
|
||||
const removed=engine.editor.dispatch({type:'element.remove',target:{id:node.id,incarnation:1}});
|
||||
await engine.settle();assert.equal(port.calls.length,0);assert.equal(engine.getSnapshot().history.undo.length,2);
|
||||
for(const id of [...created.operationIds,...removed.operationIds])assert.equal(engine.getSnapshot().runtime.ledger.operations.get(id).resolution,'elided');
|
||||
engine.editor.dispatch({type:'element.data',target,patch:{title:'unrelated'}});await engine.settle();assert.equal(port.calls.length,1);
|
||||
assert.equal(engine.getSnapshot().document.tombstones.get(node.id),1);assert.equal(engine.getSnapshot().runtime.acknowledged.tombstones.has(node.id),false);
|
||||
await reopen();assert.equal(engine.getSnapshot().document.tombstones.get(node.id),1);
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();
|
||||
assert.equal(engine.getSnapshot().document.elements.get(node.id).incarnation,2);assert.equal(port.calls.length,3);
|
||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has(node.id),false);assert.equal(engine.getSnapshot().document.tombstones.get(node.id),2);
|
||||
await reopen();assert.equal(engine.getSnapshot().document.tombstones.get(node.id),2);
|
||||
} finally { await engine?.close(); }
|
||||
});
|
||||
|
||||
|
||||
test('recovery rejects forged or unsupported local completion proofs and missing working lifecycle marks', async () => {
|
||||
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port);
|
||||
try {
|
||||
const n={...original.elements.get('a'),id:'temporary'};
|
||||
engine.editor.dispatch({type:'element.create',element:n});engine.editor.dispatch({type:'element.remove',target:{id:n.id,incarnation:1}});await engine.settle();
|
||||
const image=storage.image(),anchorIndex=image.payload.runtime.ledger.operations.findIndex(([,record])=>record.elision);
|
||||
for(const corrupt of [
|
||||
image=>{image.payload.runtime.ledger.operations[anchorIndex][1].elision.tombstones.temporary=2;},
|
||||
image=>{image.payload.document.tombstones=[];},
|
||||
image=>{image.payload.operations[0][1].changes[0].after.data.title='different historical content';},
|
||||
]){const bad=structuredClone(image);corrupt(bad);assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/);}
|
||||
const future=structuredClone(image);future.payload.runtime.ledger.operations[anchorIndex][1].elision.version=2;assert.throws(()=>decodeRecovery(future,registry),/elision_interpreter_unavailable/);
|
||||
assert.equal(decodeRecovery(image,registry).document.tombstones.get(n.id),1);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
test('failed durability keeps a local elision in memory without inventing a network success, then persists the same proof', async () => {
|
||||
const storage=storageFixture(),port=persistenceFixture();let engine=(await open(storage,port)).engine;
|
||||
try{
|
||||
await engine.persist();storage.broken=true;const n={...original.elements.get('a'),id:'temporary'};
|
||||
engine.editor.dispatch({type:'element.create',element:n});engine.editor.dispatch({type:'element.remove',target:{id:n.id,incarnation:1}});await tick();
|
||||
await assert.rejects(engine.persist(),/quota/);assert.equal(port.calls.length,0);
|
||||
const anchor=[...engine.getSnapshot().runtime.ledger.operations.values()].find(record=>record.elision),proof=JSON.stringify(anchor.elision);assert.equal(anchor.resolution,'elided');assert.equal(engine.getSnapshot().runtime.receipts.size,0);
|
||||
storage.broken=false;await engine.persist();await engine.close();engine=(await open(storage,port)).engine;await engine.settle();
|
||||
assert.equal(port.calls.length,0);assert.equal(JSON.stringify(engine.getSnapshot().runtime.ledger.operations.get(anchor.intent.id).elision),proof);
|
||||
}finally{storage.broken=false;await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
test('repeated unsent undo/redo pairs remain local and the first published restore skips all canceled incarnations',async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture();let next=0,engine=(await open(storage,port,{createId:()=>`repeat-${++next}`})).engine;
|
||||
try{
|
||||
const n={...original.elements.get('a'),id:'temporary'};engine.editor.dispatch({type:'element.create',element:n});engine.editor.dispatch({type:'element.remove',target:{id:n.id,incarnation:1}});await engine.settle();
|
||||
for(let incarnation=2;incarnation<=3;incarnation++){
|
||||
assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get(n.id).incarnation,incarnation);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(port.calls.length,0);assert.equal(engine.getSnapshot().document.tombstones.get(n.id),incarnation);
|
||||
await engine.close();engine=(await open(storage,port,{createId:()=>`repeat-${++next}`})).engine;
|
||||
}
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(port.calls.length,1);assert.equal(engine.getSnapshot().document.elements.get(n.id).incarnation,4);assert.equal(engine.getSnapshot().runtime.acknowledged.elements.get(n.id).incarnation,4);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
test('batch-capable persistence sends an earlier real edit separately and never freezes the later closed lifetime',async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture({compileBatch(){throw Error('closed lifetime entered the wire batch');}}),{engine,errors}=await open(storage,port);
|
||||
try{
|
||||
engine.editor.dispatch({type:'element.data',target,patch:{title:'prefix'}});const n={...original.elements.get('a'),id:'temporary'},created=engine.editor.dispatch({type:'element.create',element:n}),removed=engine.editor.dispatch({type:'element.remove',target:{id:n.id,incarnation:1}});
|
||||
await engine.settle();assert.equal(errors.length,0);assert.equal(port.calls.length,1);assert.equal(engine.getSnapshot().runtime.acknowledged.elements.get('a').data.title,'prefix');
|
||||
for(const id of [...created.operationIds,...removed.operationIds])assert.equal(engine.getSnapshot().runtime.ledger.operations.get(id).resolution,'elided');assert.equal(engine.getSnapshot().runtime.acknowledged.tombstones.has(n.id),false);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user