89 lines
7.1 KiB
TypeScript
89 lines
7.1 KiB
TypeScript
/**
|
|
* 把已选中的未冻结逻辑段投影成更小的 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 = <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。
|
|
* 原始条件链非法会抛错,投影效果不等价也会抛错,并非所有失败都回退。
|
|
* 此处只比对 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<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>();
|
|
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);
|
|
}
|