docs(canvas): explain ledger scheduling and recovery invariants

This commit is contained in:
2026-11-12 00:00:36 +08:00
parent 391cd48be8
commit 4c1b35e0cf
7 changed files with 207 additions and 0 deletions
@@ -1,19 +1,30 @@
/**
* 把已选中的未冻结逻辑段投影成更小的 wire operations;不修改 ledger 意图或用户 history。
* 协议 2 要求每个 logical ID 有非空 changes,所以前缀 ID 用显式条件 guard,末 ID 携带组合效果。
* 只有基线可证明、完整条件链合法且字节确实减少才采用,否则保留原始逐项请求表示。
*/
import type { Change, Document, ElementRecord, Id, OperationIntent } from '../canvas-core/types.ts';
import { applyChanges } from '../canvas-core/document.ts';
import { equal, fault, freeze } from '../canvas-core/values.ts';
import { compressChanges, type LedgerSnapshot } from './ledger.ts';
/** 使用 UTF-8 实际字节衡量 wire 大小,不能把 JS 字符串长度当 HTTP payload 大小。 */
const encoder = new TextEncoder();
/** 仅计算候选 JSON 表示的字节成本,不在这里发送或冻结请求。 */
const wireBytes = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength;
/** wire 中保留的 logical ID 与对应条件 changes;不是新的本地历史单元。 */
export type WireOperation = { readonly id: Id; readonly changes: readonly Change[] };
/** 对比规范化表的键值效果,忽略表迭代顺序但包含实际 value;墓碑也用同规则检查。 */
const sameMap = <T>(a: ReadonlyMap<Id, T>, b: ReadonlyMap<Id, T>) => a.size === b.size && [...a].every(([id, value]) => b.has(id) && equal(value, b.get(id)));
/** Pure projection for an already-selected mutable segment. Original ledger/history records never change. */
/** 尝试无损压缩多意图的 wire 投影;不足两项或任一不安全前提直接返回 ordered。 */
export function projectBatch(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, participants: readonly Id[]): readonly WireOperation[] {
const scope = new Set(participants);
const ordered = intents.map(({ id, changes }) => ({ id, changes }));
if (intents.length < 2) return ordered;
// 只收集这段触及的基线记录组成 sparse 文档,减少复制/比较完整项目的成本。
const elements = new Map<Id, ElementRecord>();
const canvases = new Map(acknowledged.canvases.has(acknowledged.projectId) ? [[acknowledged.projectId, acknowledged.canvases.get(acknowledged.projectId)!]] : []);
const tombstones = new Map<Id, number>(), seen = new Set<Id>();
@@ -26,6 +37,7 @@ export function projectBatch(intents: readonly OperationIntent[], acknowledged:
if (change.kind === 'canvas') { const canvas = acknowledged.canvases.get(id); if (canvas) canvases.set(id, canvas); continue; }
if (seen.has(id)) continue;
seen.add(id);
// 首次触及必须有明确 absent/exists 且与 acknowledged 一致;unknown 不能推测为不存在。
const element = acknowledged.elements.get(id), presence = record.basePresence[id];
if (!Object.hasOwn(record.basePresence, id) || (presence !== 'absent' && presence !== 'exists') || (presence === 'exists') !== !!element) return ordered;
if (element) elements.set(id, element);
@@ -35,11 +47,14 @@ export function projectBatch(intents: readonly OperationIntent[], acknowledged:
const sparse: Document = { projectId: acknowledged.projectId, elements, canvases, tombstones };
const raw = intents.flatMap(intent => intent.changes);
// Check the original conditional chain before any transformation can remove a check.
// 先执行原始条件链,防止压缩把原本应失败的 before 检查吞掉。
const original = applyChanges(sparse, raw);
// wire 投影保留生命周期和 no-op 检查,完整零效果段由独立 elision 证明处理。
const compacted = compressChanges(raw, { preserveLifecycle: true, preserveChecks: true });
if (!compacted.length) return ordered;
// Protocol 2 requires a nonempty change list per logical ID. Prefix IDs get an explicit
// conditional no-op; the final ID carries the composed effect. All IDs remain deduplicated.
// 从参与 owner 的少量元素 title/text 或根 canvas 选 no-op guard,不扩大原 claim 范围。
const guards: Change[] = [];
let candidates = 0;
for (const element of elements.values()) if (scope.has(element.ownerCanvasId)) {
@@ -52,11 +67,13 @@ export function projectBatch(intents: readonly OperationIntent[], acknowledged:
const root = canvases.get(acknowledged.projectId);
// A child canvas no-op would touch its parent. Never silently widen the selected claims.
if (root && root.parent === null && scope.has(root.id)) guards.push({ kind: 'canvas', id: root.id, before: root, after: root });
// 选择最小字节 guard;没有安全 guard、超 changes 限制或不省字节就不压缩。
let guard: Change | undefined, guardBytes = Infinity;
for (const candidate of guards) { const bytes = wireBytes(candidate); if (bytes < guardBytes) { guard = candidate; guardBytes = bytes; } }
if (!guard) return ordered;
const projected = intents.map((intent, index) => ({ id: intent.id, changes: index === intents.length - 1 ? compacted : [guard!] }));
if (projected.reduce((count, operation) => count + operation.changes.length, 0) > 10_000 || wireBytes(projected) >= wireBytes(ordered)) return ordered;
// 最后执行投影并对比 elements/canvases/tombstones 全效果,不只比较可见节点内容。
const result = applyChanges(sparse, projected.flatMap(operation => operation.changes));
if (!sameMap(original.elements, result.elements) || !sameMap(original.canvases, result.canvases) || !sameMap(original.tombstones, result.tombstones)) throw fault('batch_projection_mismatch');
return freeze(projected);