Files
Endoleap/src/lib/canvas-runtime/elision.ts
T

146 lines
13 KiB
TypeScript

/**
* 未发出的创建→编辑→删除闭合生命周期可以不发 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<Record<Id, number>>; readonly tombstones: Readonly<Record<Id, number>> };
/** 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<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>();
// 用起始+本段删除水位阻止创建/恢复复用已死亡 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<Id> = new Set()): ElisionProof | undefined {
const selected: OperationIntent[] = [], seen = new Set<Id>(), canvases = new Set<Id>(), bases: Record<Id, number> = 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<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;
// 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<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. */
/** 恢复时按逻辑顺序复验持久抵消组:成员连续、依赖闭合、anchor 唯一、证明与水位一致。
* 不依赖当前可能更新过的 ACK 基线来重新猜历史 absent 事实,而是复核保存的 basePresence/proof。
* 它不向服务器查询当年是否接收过请求;此前的持久记录捕获与身份校验仍由恢复边界负责。 */
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; }
// 只有组首成员保存整份 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<Id>(), 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<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) });
}