docs(runtime): explain wire projection and conflict replanning
This commit is contained in:
@@ -10,7 +10,8 @@ import { compressChanges, type LedgerSnapshot } from './ledger.ts';
|
||||
|
||||
/** 使用 UTF-8 实际字节衡量 wire 大小,不能把 JS 字符串长度当 HTTP payload 大小。 */
|
||||
const encoder = new TextEncoder();
|
||||
/** 仅计算候选 JSON 表示的字节成本,不在这里发送或冻结请求。 */
|
||||
/** 仅比较传入 JSON 的 UTF-8 字节;调用处测量 operation 数组,不含 HTTP 头/请求 envelope。
|
||||
* 不在这里发送或冻结请求,也不估算压缩传输后的成本。 */
|
||||
const wireBytes = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength;
|
||||
|
||||
/** wire 中保留的 logical ID 与对应条件 changes;不是新的本地历史单元。 */
|
||||
@@ -19,9 +20,13 @@ export type WireOperation = { readonly id: Id; readonly changes: readonly Change
|
||||
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。 */
|
||||
/** 为调用方已选好的未发送段尝试 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 文档,减少复制/比较完整项目的成本。
|
||||
@@ -52,9 +57,11 @@ export function projectBatch(intents: readonly OperationIntent[], acknowledged:
|
||||
// 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. All IDs remain deduplicated.
|
||||
// 从参与 owner 的少量元素 title/text 或根 canvas 选 no-op guard,不扩大原 claim 范围。
|
||||
// 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)) {
|
||||
@@ -73,7 +80,8 @@ export function projectBatch(intents: readonly OperationIntent[], acknowledged:
|
||||
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 全效果,不只比较可见节点内容。
|
||||
// 在同一 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);
|
||||
|
||||
@@ -13,7 +13,10 @@ import { equal, fault, freeze, readMap } from '../canvas-core/values.ts';
|
||||
/** 原操作相关对象的前后记录及 owner 变换;用于区分用户意图与仅由坐标转换引起的差异。 */
|
||||
export type IntentContext = { readonly before:ReadonlyMap<Id,ElementRecord>; readonly after:ReadonlyMap<Id,ElementRecord>; readonly transforms:ReadonlyMap<Id,Transform>; readonly beforeTransforms:ReadonlyMap<Id,Transform> };
|
||||
/** Reconstruct only immutable intention metadata. This mutable cursor is never published as a document. */
|
||||
/** 从当前 working 反向遍历意图,重建每项的局部前后上下文;可变 cursor 永不作为业务文档发布。 */
|
||||
/** 从已包含这些意图效果的 working 逆序退回,重建局部前后记录及 owner 变换。
|
||||
* 输入顺序就是应用顺序;函数不按 sequence 排序,也不验证当前文档确由这些意图产生。
|
||||
* 这里只还原意图元数据,不按 before/after 条件执行撤销,不回退墓碑、不分配新代次或发布文档。
|
||||
* 只读 Map 捕获表成员,记录值沿用不可变输入;不是任意外部对象的深克隆边界。 */
|
||||
export function intentContexts(document:Document,intents:readonly OperationIntent[]):ReadonlyMap<Id,IntentContext> {
|
||||
const elements=new Map(document.elements),canvases=new Map(document.canvases),contexts=new Map<Id,IntentContext>();
|
||||
const cursor={...document,elements,canvases};
|
||||
@@ -27,7 +30,8 @@ export function intentContexts(document:Document,intents:readonly OperationInten
|
||||
}
|
||||
const after=new Map<Id,ElementRecord>();for(const id of ids){const record=elements.get(id);if(record){after.set(id,record);owners.add(record.ownerCanvasId);}}
|
||||
const transforms=new Map<Id,Transform>();for(const owner of owners)if(canvases.has(owner))transforms.set(owner,canvasTransform(cursor,owner));
|
||||
// 私有 cursor 按原 before 逆向退回,不执行用户可见的 restore,也不因此分配新 incarnation。
|
||||
// 直接把记录/字段写回原 before,不检查当前是否等于 after;field 缺对象/代次不符时跳过。
|
||||
// cursor 中间树可不完整,随后仅在 owner 仍存在时取变换;无法解析的祖先链仍可能抛错。
|
||||
for(const change of [...intent.changes].reverse()){
|
||||
if(change.kind==='element'){if(change.before)elements.set(change.id,change.before);else elements.delete(change.id);}
|
||||
else if(change.kind==='canvas'){if(change.before)canvases.set(change.id,change.before);else canvases.delete(change.id);}
|
||||
@@ -51,7 +55,9 @@ function sameConvertedPath(left:Json,right:Json):boolean {
|
||||
const point=(a:Json|undefined,b:Json|undefined)=>{if(a===undefined||b===undefined)return a===b;if(!a||!b||typeof a!=='object'||typeof b!=='object'||Array.isArray(a)||Array.isArray(b))return false;const{x:ax,y:ay,...restA}=a,{x:bx,y:by,...restB}=b;return typeof ax==='number'&&typeof ay==='number'&&typeof bx==='number'&&typeof by==='number'&&closeNumber(ax,bx)&&closeNumber(ay,by)&&equal(restA,restB);};
|
||||
return equal(ar,br)&&point(as,bs)&&point(ae,be)&&a.length===b.length&&a.every((value,index)=>{const other=b[index];if(!value||!other||typeof value!=='object'||typeof other!=='object'||Array.isArray(value)||Array.isArray(other))return false;const{position:p,in:i,out:o,...rest}=value,{position:q,in:j,out:k,...remaining}=other;return equal(rest,remaining)&&point(p,q)&&point(i,j)&&point(o,k);});
|
||||
}
|
||||
/** 只把原意图实际改过的 presentation 键应用到 current;unitScale 另走视觉单位换算。 */
|
||||
/** 以键的存在性和值比较找 presentation 顶层差异,再覆盖/删除 current 的对应键。
|
||||
* 嵌套键值作为整体处理,不递归合并;unitScale 排除在本函数外,交几何分支做视觉单位换算。
|
||||
* 无变化时沿用 current;有变化后若结果无键则返回 undefined,交调用方处理字段存在性。 */
|
||||
function presentationDelta(current:Json|undefined,before:Json|undefined,after:Json|undefined):Json|undefined {
|
||||
if(equal(before,after))return current;
|
||||
const start=presentationRecord(before),end=presentationRecord(after),keys=new Set([...Object.keys(start),...Object.keys(end)]);keys.delete('unitScale');
|
||||
@@ -63,6 +69,7 @@ function presentationDelta(current:Json|undefined,before:Json|undefined,after:Js
|
||||
* 用户明确改变的中心/尺寸/旋转取其意图值,再转到 fresh destination;不是盲加一份旧 local patch。 */
|
||||
function movedNode(current:Extract<ElementRecord,{structure:'node'}>,originalBefore:Extract<ElementRecord,{structure:'node'}>,originalAfter:Extract<ElementRecord,{structure:'node'}>,beforeBasis:Transform,afterBasis:Transform,source:Transform,destination:Transform,intent:OperationIntent){
|
||||
const world:Transform={x:0,y:0,scale:1,rotation:0},start=convertGeometry(originalBefore.geometry,beforeBasis,world),end=convertGeometry(originalAfter.geometry,afterBasis,world),fresh=convertGeometry(current.geometry,source,world);
|
||||
// 世界中心用于分别判断横/纵移动意图;未变化的维度取 fresh,修改维度取原意图终值,而非叠加 delta。
|
||||
const center=(g:typeof start)=>({x:g.x+g.width/2,y:g.y+g.height/2}),a=center(start),b=center(end),c=center(fresh),width=closeNumber(start.width,end.width)?fresh.width:end.width,height=closeNumber(start.height,end.height)?fresh.height:end.height;
|
||||
const x=closeNumber(a.x,b.x)?c.x:b.x,y=closeNumber(a.y,b.y)?c.y:b.y,rotation=Math.abs(((start.rotation-end.rotation+540)%360)-180)<1e-9?fresh.rotation:end.rotation;
|
||||
const geometry=convertGeometry({x:x-width/2,y:y-height/2,width,height,rotation},world,destination);
|
||||
@@ -75,7 +82,9 @@ function movedNode(current:Extract<ElementRecord,{structure:'node'}>,originalBef
|
||||
}
|
||||
|
||||
/** Restore batches may have inverse storage order (canvas before entry). Only reorder adjacent creates, never move across an edit/delete. */
|
||||
/** 只在连续创建段内按 parent/entry/owner/端点依赖拓扑排序,绝不跨编辑/删除重排。 */
|
||||
/** 只在连续创建段内按 parent/entry/owner/端点依赖拓扑排序,绝不跨编辑/删除重排。
|
||||
* 仅段内依赖参与 Kahn 计数;段外依赖是否存在留给执行/最终校验。元素与 canvas 分命名空间。
|
||||
* ready 按发现顺序排队,不保证每次都选择剩余项中原索引最小者;重 ID 或环直接拒绝。 */
|
||||
function orderedCreates(changes:readonly Change[]):readonly Change[]{
|
||||
const result:Change[]=[];
|
||||
/** before=null 且 after 存在才是创建,整记录替换不能冒充依赖排序候选。 */
|
||||
@@ -101,17 +110,22 @@ function orderedCreates(changes:readonly Change[]):readonly Change[]{
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Re-plan against current data. Whole records are used only for explicit creation/removal. */
|
||||
/** 在 fresh 私有文档上重新生成条件 changes,原 intent/context 保持不变;最终完整校验才返回。 */
|
||||
/** Re-plan against current data; whole element replacement is unsupported. */
|
||||
/** 在 fresh 私有候选上重新生成条件 changes,保留当前未被用户意图修改的数据,最终整图校验才返回。
|
||||
* 本函数不验证恢复选择/账本依赖,不发布 Root、不记录 history、不修改原冻结请求;这些由冲突协调器负责。
|
||||
* 整元素仅支持显式增删,canvas 记录可重建 parent;普通字段的 before 从 fresh 实值重新生成。
|
||||
* 输入应为已捕获的不可变模型/上下文,辅助 apply 的冻结不构成任意外部对象的深隔离保证。 */
|
||||
export function rebaseIntent(document:Document,intent:OperationIntent,context:IntentContext,registry:Registry):{document:Document;changes:readonly Change[]} {
|
||||
// moved 标记需整体世界空间重规划的节点;deferredPaths 等端点/树最终确定后再算曲线。
|
||||
const before=document,changes:Change[]=[],moved=new Set<Id>(),deferredPaths:FieldChange[]=[];
|
||||
for(const change of intent.changes)if(change.kind==='field'&&change.field==='ownerCanvasId'&&context.after.get(change.target.id)?.structure==='node')moved.add(change.target.id);
|
||||
/** 每条新变化立即作用于私有 candidate,后面的条件读取已重规划后的实际 before。 */
|
||||
function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));}
|
||||
/** 同 ID 还必须同 incarnation/结构/组件/版本且 registry 可解释,防止旧意图套到新对象上。 */
|
||||
/** 目标必须存在、代次相同且 registry 已知;上下文含 prior 时再比较结构/组件/版本。
|
||||
* 这是字段分支共用准入,具体能力检查在各分支;上下文缺 prior 不在这里补造类型依据。 */
|
||||
function target(change:FieldChange){const current=document.elements.get(change.target.id),prior=context.before.get(change.target.id)??context.after.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)throw fault('stale_incarnation',[change.target.id]);if(prior&&(current.componentId!==prior.componentId||current.dataVersion!==prior.dataVersion||current.structure!==prior.structure))throw fault('recovery_target_type_changed',[current.id]);if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);return current;}
|
||||
/** 端点必须仍为原上下文中的同代次节点;不能把恢复出来的新节点视为旧外部引用。 */
|
||||
/** 当前端点须为 node,且与 context.after 中对象代次一致;无上下文也拒绝。
|
||||
* 这里不单独比较端点的 componentId/dataVersion,图关系是否合法仍由最终校验判断。 */
|
||||
function endpoint(id:Id){const current=document.elements.get(id),expected=context.after.get(id);if(!current||current.structure!=='node'||!expected||current.incarnation!==expected.incarnation)throw fault('stale_endpoint',[id]);}
|
||||
// 按受限依赖顺序处理创建;删除/字段意图的原语义顺序仍保留。
|
||||
for(const original of orderedCreates(intent.changes)){
|
||||
@@ -132,7 +146,8 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In
|
||||
after={...after,ownerCanvasId:owner,path:convertPath(after.path,basis,canvasTransform(document,owner))};
|
||||
}
|
||||
apply({...original,after});
|
||||
// 已经不存在的删除可满足;仍存在时按 fresh 拥有树规划移除,不能直接覆盖旧整记录。
|
||||
// 删除目标已不存在时直接满足,不在此以墓碑证明它曾是哪一代;仍存在则要求旧代次和已知类型。
|
||||
// 按 fresh 拥有树重新规划删除,可包含远端新增子内容/关联边,而不是照抄旧删除清单。
|
||||
}else if(original.before&&original.after===null){
|
||||
if(!current)continue;
|
||||
if(current.incarnation!==original.before.incarnation)throw fault('stale_incarnation',[original.id]);
|
||||
@@ -210,7 +225,8 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In
|
||||
if(edge.ownerCanvasId!==owner)apply({kind:'field',target:original.target,field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner});
|
||||
if(!equal(edge.path,path))apply({...original,before:edge.path,after:path});
|
||||
}
|
||||
// 所有端点/树变化结束后统一修复边 owner/path,再验证最终文档,避免暴露半份图。
|
||||
// 使用初始 fresh 与最终候选修复受影响边的 owner/path,再做整图校验;空 changes 也要校验。
|
||||
// 抛错不返回半成品,成功仍只是规划结果;是否采纳以及新的 operation/history 身份由调用方决定。
|
||||
for(const change of repairConnectionOwners(before,document))apply(change);
|
||||
validateDocument(document,registry);return{document,changes:freeze(changes)};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { fault } from '../canvas-core/values.ts';
|
||||
import { createDeque } from './structures.ts';
|
||||
|
||||
/** Shared bounded transport capacity for writes, uploads and run reads. */
|
||||
/** 建立正整数容量的 gate,beforeRun 在实际执行 callback 前检查宿主身份等条件。 */
|
||||
/** 建立正整数容量的 gate,beforeRun 在实际执行 callback 前同步检查宿主身份等条件。
|
||||
* 每个实例独立计数,等待 FIFO 无长度上限;这里没有跨实例锁、重试或持久任务记录。
|
||||
* beforeRun 是同步钩子;异步身份校验应由 callback 自己 await,不能依赖 void 钩子等待。 */
|
||||
export function createNetworkGate(limit = 2, beforeRun?:()=>void) {
|
||||
if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_concurrency');
|
||||
let active = 0;
|
||||
@@ -15,6 +17,7 @@ export function createNetworkGate(limit = 2, beforeRun?:()=>void) {
|
||||
/** 有空位直接占用,否则等待 grant;已取消的等待项标记保留,释放时惰性跳过。 */
|
||||
async function enter(signal: AbortSignal) {
|
||||
signal.throwIfAborted();
|
||||
// enter 自己推进 active,或等 release 直接转交名额;等待者获准时不再重复 active++。
|
||||
if (active < limit) { active++; return; }
|
||||
await new Promise<void>((resolve,reject) => {
|
||||
// grant 解除 abort 监听再唤醒;被唤醒后 run 还会再次检查 signal,避免取消竞态执行请求。
|
||||
@@ -29,7 +32,9 @@ export function createNetworkGate(limit = 2, beforeRun?:()=>void) {
|
||||
let item=waiting.pop();while(item?.cancelled)item=waiting.pop();
|
||||
if(item)item.grant();else active--;
|
||||
}
|
||||
/** 统一入口:enter 后即使 signal/beforeRun/callback 抛错,finally 也会归还名额。 */
|
||||
/** enter 成功后即使 signal/beforeRun/callback 抛错,finally 也会归还名额。
|
||||
* 排队取消会拒绝 enter;执行中取消不自动结束 callback 或提早释放,仍等其 Promise 结束。
|
||||
* 身份只在准入时检查一次;任务运行中的失效/响应采纳校验由 callback 和上层协调。 */
|
||||
return Object.freeze({ async run<T>(signal: AbortSignal, callback: () => Promise<T>): Promise<T> {
|
||||
await enter(signal);
|
||||
try { signal.throwIfAborted();beforeRun?.();return await callback(); } finally { release(); }
|
||||
|
||||
Reference in New Issue
Block a user