/** * 未发出的创建→编辑→删除闭合生命周期可以不发 wire 请求,但不能抹掉本地身份事实。 * 抵消要求 absent 基线、连续未冻结意图、闭合依赖和完整零最终记录效果;墓碑保留在 working。 * 已发/可能已应用或存在基线上的修改不进入该路径,原 history/意图继续保留以支持撤销与恢复。 */ 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'; /** 抵消证据:连续逻辑 ID、起始墓碑与最终生命周期水位;version 管理本地证明解释器。 */ 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. */ /** 在空记录片段上重建至多 64 意图/10000 changes,验证每个对象首触及都是创建且最终全删。 * 本函数不读 ledger 阶段、basePresence、请求或依赖,不独自证明这些操作从未发送/实际基线 absent。 * 只做条件变化与生命周期闭合检查,不调用 registry/整图校验;临时片段可无根,最终只保留本地墓碑。 */ 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(); // 用起始+本段删除水位阻止创建/恢复复用已死亡 incarnation。 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)); } } } // 起始墓碑只能描述本段确实创建过的元素,不能趁抵消夹带无关 ID 的生命周期水位。 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)) }; // 条件执行仍使用真实 applyChanges;before 不匹配不能因最终想删除就被忽略。 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. */ /** 在传入候选顺序内寻找最长闭合前缀,检查 pending/未绑请求、基线和依赖;不扫描全账本证明中间无漏项。 * 全账本连续性还由 ledger.elide/validateElisions 复核;preceding 只给批次边界规划暂时满足前段依赖。 * undefined 表示没找到;条件回放失败或闭合证明非法仍会抛错,不统一吞成 undefined。 */ 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); // 候选超过 64 直接不寻找,不自动截取前 64;真正闭合后的 changes 上限由 proveElision 检查。 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; // resolved 只表示依赖已被本地解除,不在此区分 applied/elided/retired 的业务理由;preceding 不是远端执行证明。 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); // 新发现对象的 ACK 墓碑合入私有片段后再回放,验证本次生命周期严格高于已知水位。 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. */ /** 若后面存在可抵消候选段,缩短前面的真实请求前缀,避免本次 freeze 锁住后段。 * 这里只返回前缀长度,不标 ledger.elided、不证明前段已成功;原前缀可直接抵消时交其它钩子处理。 */ 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. */ /** 恢复时按逻辑顺序复验持久抵消组:成员连续、依赖闭合、anchor 唯一、证明与水位一致。 * 不依赖当前可能更新过的 ACK 基线来重新猜历史 absent 事实,而是复核保存的 basePresence/proof。 * 它不向服务器查询当年是否接收过请求;此前的持久记录捕获与身份校验仍由恢复边界负责。 */ 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; } // 只有组首成员保存整份 proof,其余成员通过 elisionId 引用它,避免重复矛盾证明。 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)!; // 每个成员仍必须 resolved/elided、无 request/blocker,不能伪造为已发送后又本地抵消。 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. */ /** 只为候选复制墓碑表并补本地最大值,其余文档表沿用;不改传入文档。 * 调用方只能将结果当 working,不应覆写 acknowledged;这里不自动区分两种变量的用途。 * 直接读取保存的 elision 字段,不在此复验证据;ledger 应先经可信生成或 validateElisions。 */ 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) }); }