/** * 把已选中的未冻结逻辑段投影成更小的 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 的 UTF-8 字节;调用处测量 operation 数组,不含 HTTP 头/请求 envelope。 * 不在这里发送或冻结请求,也不估算压缩传输后的成本。 */ 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 = (a: ReadonlyMap, b: ReadonlyMap) => 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。 * 原始条件链非法会抛错,投影效果不等价也会抛错,并非所有失败都回退。 * 此处只比对 ledger 的 changes/phase 等局部条件,不验证完整意图身份、依赖、类型/版本或段连续性。 * 参与范围与选择合法性由调用方保证;这里不调用 registry/整图校验来证明所有权或权限。 */ export function projectBatch(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, participants: readonly Id[]): readonly WireOperation[] { const scope = new Set(participants); // 回退表示创建新的外层数组/条目,但 changes 沿用输入引用;仅成功压缩的结果走末尾 freeze。 const ordered = intents.map(({ id, changes }) => ({ id, changes })); if (intents.length < 2) return ordered; // 只收集这段触及的基线记录组成 sparse 文档,减少复制/比较完整项目的成本。 const elements = new Map(); const canvases = new Map(acknowledged.canvases.has(acknowledged.projectId) ? [[acknowledged.projectId, acknowledged.canvases.get(acknowledged.projectId)!]] : []); const tombstones = new Map(), seen = new Set(); for (const intent of intents) { const record = ledger.operations.get(intent.id); // A missing/unknown baseline, frozen identity, or blocked intent cannot be guessed away. if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || !equal(record.intent.changes, intent.changes)) return ordered; for (const change of intent.changes) { const id = change.kind === 'field' ? change.target.id : change.id; 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); if (acknowledged.tombstones.has(id)) tombstones.set(id, acknowledged.tombstones.get(id)!); } } 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; // 原逻辑 ID 顺序逐项保留;函数本身不验证输入 ID 唯一性,去重身份由 ledger/请求编译边界保证。 // 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. Input ID order is retained. // 从参与 owner 的基线元素选 title/text 条件 no-op,最多查看 8 个候选元素,每个产生两个 guard。 // guard 表达的是基线值/存在性,不是某个前缀逻辑操作的独立最终效果;最后一项统一携带组合变化。 const guards: Change[] = []; let candidates = 0; for (const element of elements.values()) if (scope.has(element.ownerCanvasId)) { if (++candidates > 8) break; for (const key of ['title', 'text']) { const present = Object.hasOwn(element.data, key), value = element.data[key] ?? null; guards.push({ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: `data.${key}`, before: value, after: value, beforePresent: present, afterPresent: present }); } } 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; // 在同一 sparse 片段执行投影,比较它的三张表而非完整项目;未触及记录从未纳入片段。 // 这证明该基线上的组合效果一致,不替代请求执行时的版本检查或最终文档校验。 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); }