docs(canvas): explain ledger scheduling and recovery invariants
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user