diff --git a/src/lib/canvas-runtime/conflicts.ts b/src/lib/canvas-runtime/conflicts.ts index 5150984..9cc4edd 100644 --- a/src/lib/canvas-runtime/conflicts.ts +++ b/src/lib/canvas-runtime/conflicts.ts @@ -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]); diff --git a/src/lib/canvas-runtime/elision.ts b/src/lib/canvas-runtime/elision.ts new file mode 100644 index 0000000..91c1448 --- /dev/null +++ b/src/lib/canvas-runtime/elision.ts @@ -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>; readonly tombstones: Readonly> }; + +/** Reconstruct a closed lifetime from an empty fragment. Existing objects cannot enter this proof. */ +export function proveElision(intents: readonly OperationIntent[], baseTombstones: Readonly>): 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(), canvases = new Set(); + 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 = new Set()): ElisionProof | undefined { + const selected: OperationIntent[] = [], seen = new Set(), canvases = new Set(), bases: Record = 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 = {}; + 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(); + 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): void { + const bases: Record = 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): 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(); + 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(), 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): 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) }); +} diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index 927abc6..a67fc57 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -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' }; diff --git a/src/lib/canvas-runtime/history-journal-recovery.ts b/src/lib/canvas-runtime/history-journal-recovery.ts index 4239003..b236aeb 100644 --- a/src/lib/canvas-runtime/history-journal-recovery.ts +++ b/src/lib/canvas-runtime/history-journal-recovery.ts @@ -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++; diff --git a/src/lib/canvas-runtime/ledger.ts b/src/lib/canvas-runtime/ledger.ts index a3bad04..3640f0a 100644 --- a/src/lib/canvas-runtime/ledger.ts +++ b/src/lib/canvas-runtime/ledger.ts @@ -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> }; +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> }; export type LedgerSnapshot = { readonly generation: number; readonly durableGeneration: number; readonly operations: ReadonlyMap; readonly requests: ReadonlyMap }; /** 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(), 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(); + 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'); diff --git a/src/lib/canvas-runtime/reconcile.ts b/src/lib/canvas-runtime/reconcile.ts index 8a7dbf7..c0ecc27 100644 --- a/src/lib/canvas-runtime/reconcile.ts +++ b/src/lib/canvas-runtime/reconcile.ts @@ -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) { diff --git a/src/lib/canvas-runtime/recovery.ts b/src/lib/canvas-runtime/recovery.ts index f501a45..b232e92 100644 --- a/src/lib/canvas-runtime/recovery.ts +++ b/src/lib/canvas-runtime/recovery.ts @@ -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'); diff --git a/src/lib/canvas-runtime/scheduler.ts b/src/lib/canvas-runtime/scheduler.ts index c0be443..9532fda 100644 --- a/src/lib/canvas-runtime/scheduler.ts +++ b/src/lib/canvas-runtime/scheduler.ts @@ -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; query(request: FrozenRequest, signal: AbortSignal): Promise; @@ -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): 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(); 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) })); diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 2368d3f..e3f0b57 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -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); diff --git a/tests/headless/conflicts.test.mjs b/tests/headless/conflicts.test.mjs index e649bf4..0a075fe 100644 --- a/tests/headless/conflicts.test.mjs +++ b/tests/headless/conflicts.test.mjs @@ -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();} + } +}); diff --git a/tests/headless/elision.test.mjs b/tests/headless/elision.test.mjs new file mode 100644 index 0000000..a308ecd --- /dev/null +++ b/tests/headless/elision.test.mjs @@ -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); +}); diff --git a/tests/headless/engine.test.mjs b/tests/headless/engine.test.mjs index 79ffe6e..bc54a6e 100644 --- a/tests/headless/engine.test.mjs +++ b/tests/headless/engine.test.mjs @@ -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();} +});