Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c2ecb9fe2 | |||
| c83641417b | |||
| a3d92f9cd6 | |||
| 196866aaf3 | |||
| 59b59c2f03 | |||
| 437a46ad36 | |||
| 2a9667a287 | |||
| 3e4833470a | |||
| 896e6a0656 | |||
| 17c14c85e8 | |||
| 020fb55084 | |||
| 238e4dd4c5 | |||
| 3c8953e842 | |||
| d02cd2b01d | |||
| 2b0afddd07 | |||
| f6acdac8d8 | |||
| 9ea8463867 | |||
| c491cd023c | |||
| 20bdc7d44a | |||
| e78c18b697 |
@@ -27,32 +27,43 @@ export type AssetDepotPort={
|
||||
read(leader:StorageLeader,authority:string,id:Id):Promise<DepotEntry|undefined>;
|
||||
collect(leader:StorageLeader,input:{readonly authority:string;readonly now:number;readonly retentionMs:number;readonly limit:number;readonly after?:Id}):Promise<DepotCollection>;
|
||||
};
|
||||
/** 严格验证授权域、64位小写十六进制摘要、图片 MIME、正尺寸/字节及像素/20MiB上限。 */
|
||||
/** 校验版本、非空授权域/URL、64 字符小写摘要、图片 MIME、正整数尺寸/字节及像素/20MiB上限。
|
||||
* 只检查元数据:URL 未限制协议/域,摘要未重算,尺寸未通过实际解码核实;授权真实性由服务端口保证。 */
|
||||
export function validateSharedAsset(value:SharedAsset):void{
|
||||
if(!value||value.version!==1||typeof value.authority!=='string'||!value.authority||value.authority.length>8192||typeof value.digest!=='string'||!/^([a-f0-9]{64})$/.test(value.digest))throw fault('invalid_shared_asset');
|
||||
const asset=value.asset;if(!asset||typeof asset.id!=='string'||!asset.id||typeof asset.url!=='string'||!asset.url||asset.url.length>8192||![asset.width,asset.height,asset.bytes].every(n=>Number.isSafeInteger(n)&&n>0)||asset.width*asset.height>40_000_000||asset.bytes>20*1024*1024||!['image/png','image/jpeg','image/gif','image/webp'].includes(asset.mime_type))throw fault('invalid_shared_asset');
|
||||
}
|
||||
/** 验证后捕获并冻结资源元信息,隔离调用者可变的 asset 对象。 */
|
||||
/** 验证后重建标准外层字段并浅复制 asset,再递归冻结,隔离标准标量元信息。
|
||||
* asset 的扩展属性仍由 spread 保留,嵌套扩展值不深克隆,不能当作任意外部对象的完整隔离入口。 */
|
||||
export function sharedAssetRef(value:SharedAsset):SharedAsset{validateSharedAsset(value);return freeze({version:1 as const,authority:value.authority,digest:value.digest,asset:{...value.asset}});}
|
||||
/** 比较完整不可变引用,不能只因 ID 或 URL 一样就容忍摘要/尺寸变化。 */
|
||||
const same=(a:SharedAsset,b:SharedAsset)=>equal(sharedAssetRef(a),sharedAssetRef(b));
|
||||
/** Immutable metadata cache only. Every new adoption resolves current permission first. */
|
||||
/** 建立工作区门面,绑定作用域与当前身份,不把跨账号缓存混入同一个 records 表。 */
|
||||
/** 捕获 account/workspace 标量,建立仅存元信息的 Map;字节仍由 port 持有,不建立内存 Blob 缓存。
|
||||
* 宿主 current/leader 校验约束使用生命周期,实际存储 fence/权限检查仍由端口实现。
|
||||
* 每次 getResources 返回成员快照,旧快照不随 remember/close 改变;它不另做授权或关闭检查。 */
|
||||
export function createAssetDepot(options:{scope:Pick<StorageScope,'accountId'|'workspaceId'>;port:AssetDepotPort;current():boolean;digest(bytes:Blob,signal?:AbortSignal):Promise<string>}){
|
||||
const scope=freeze({accountId:options.scope.accountId,workspaceId:options.scope.workspaceId});let closed=false;const records=new Map<string,SharedAsset>(),controllers=new Set<AbortController>();
|
||||
/** 每个 await 前后使用:宿主仍有效且 leader 属于同账号/工作区;允许不同项目共享仓库。 */
|
||||
/** 入口及异步结果采纳前检查宿主/leader,允许同账号工作区的不同项目共享。
|
||||
* 不比较 projectId,不读取远端 fence;在途 I/O 是否立即停止由端口/信号决定。 */
|
||||
const check=(leader:StorageLeader)=>{if(closed||!options.current())throw fault('identity_changed');if(!leader.active||leader.scope.accountId!==scope.accountId||leader.scope.workspaceId!==scope.workspaceId)throw fault('asset_depot_scope_mismatch');};
|
||||
/** 缓存同 authority/assetId 的不可变引用;相同身份元数据改变视为冲突,不静默覆盖。 */
|
||||
function remember(ref:SharedAsset){validateSharedAsset(ref);const key=JSON.stringify([ref.authority,ref.asset.id]),previous=records.get(key);if(previous&&!same(previous,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);records.set(key,sharedAssetRef(ref));}
|
||||
return Object.freeze({scope,getResources:()=>readMap(records),
|
||||
/** 新采纳先远端 resolve,再比本地身份/读取原字节并验长度+SHA;每个 await 后重新检查取消/身份。
|
||||
* 成功只返回 ref/bytes 和记元信息,目标 binding 与字节持久化由 engine 的单 Root 完成。 */
|
||||
/** 新采纳先 resolve 当前权限,再核对缓存身份;有缓存字节仍验长度/摘要,否则向 authority 读取。
|
||||
* combined 信号交端口,异步返回后再检查;入口不先 throwIfAborted,已取消信号仍可能传给 resolve。
|
||||
* remember 捕获元信息,但返回 ref 沿用 resolve 结果(只冻结返回 envelope),端口须提供不可变身份。
|
||||
* 成功不写共享字节/项目 binding;这些由 engine 同 Root 持久化,不是新上传任务。 */
|
||||
async authorize(leader:StorageLeader,authority:AssetAuthority,id:Id,signal:AbortSignal){check(leader);const controller=new AbortController(),combined=AbortSignal.any([signal,controller.signal]);controllers.add(controller);try{const ref=await authority.resolve(id,combined);check(leader);combined.throwIfAborted();validateSharedAsset(ref);if(ref.authority!==authority.scope||ref.asset.id!==id)throw fault('asset_authorization_mismatch');const cached=await options.port.read(leader,authority.scope,id);check(leader);combined.throwIfAborted();if(cached&&!same(cached.ref,ref))throw fault('shared_asset_identity_conflict',[id]);let bytes=cached?.bytes;if(!bytes)bytes=await authority.read(id,combined);check(leader);combined.throwIfAborted();if(bytes.size!==ref.asset.bytes||await options.digest(bytes,combined)!==ref.digest)throw fault('shared_asset_bytes_changed',[id]);check(leader);combined.throwIfAborted();remember(ref);return Object.freeze({ref,bytes});}finally{controllers.delete(controller);}},
|
||||
/** 读取已绑定本地共享字节并验身份/摘要;不是新 adoption 的授权捷径,缺字节返回 undefined。 */
|
||||
/** 按已绑定 ref 读取本地共享字节并验身份/摘要,不调用 authority.resolve 或回源下载。
|
||||
* 无记录直接 undefined;有记录无字节也记下元信息再返回 undefined,不能据此判定服务器资产消失。
|
||||
* 输入 ref 沿用调用方身份;本方法不单独证明调用者有权新采纳该资源。 */
|
||||
async read(leader:StorageLeader,ref:SharedAsset,signal?:AbortSignal){check(leader);signal?.throwIfAborted();const row=await options.port.read(leader,ref.authority,ref.asset.id);check(leader);signal?.throwIfAborted();if(!row)return;if(!same(row.ref,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);if(row.bytes&&(row.bytes.size!==ref.asset.bytes||await options.digest(row.bytes,signal)!==ref.digest))throw fault('shared_asset_bytes_changed',[ref.asset.id]);check(leader);signal?.throwIfAborted();remember(ref);return row.bytes;},
|
||||
/** 作用域检查后委托存储扫描/保留期回收,结束再检查身份,不自行删除服务器资产。 */
|
||||
/** 作用域检查后直接委托 port.collect,结果由端口负责结构与回收证明,不在此重验。
|
||||
* 返回后再核对身份;检查失败不代表端口之前的本地存储事务未执行,也不删除服务器资产。 */
|
||||
async collect(leader:StorageLeader,input:Parameters<AssetDepotPort['collect']>[1]){check(leader);const result=await options.port.collect(leader,input);check(leader);return result;},
|
||||
/** 关闭门面并 abort 授权任务、清元缓存;不抹掉共享存储中的持久身份/字节。 */
|
||||
/** 关闭后拒绝新操作,abort 本门面追踪的 authorize 并清元缓存;不删除持久身份/字节。
|
||||
* read/collect 没有被加入 controllers,在途结果由后续 check 拒绝采纳;close 不等待底层 I/O 退出。 */
|
||||
close(){closed=true;for(const controller of controllers)controller.abort();controllers.clear();records.clear();},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
import {createDigestPool} from './digest-pool.ts';
|
||||
/** Browser capability stays at the adapter; WorkspaceRuntime owns the pool's lifetime. */
|
||||
/** 构建 workspace 可共享的 DigestPort;任务 ID/长度/版本共同验证响应归属。 */
|
||||
/** 在构造时选择能力:Worker 不存在就使用池的默认分块执行器,否则每个 execute 新建专用 Worker。
|
||||
* 此选择不随后续 Worker 全局变化自动重算;创建/运行失败也不降级重试。
|
||||
* 使用默认池预算,响应按实例内递增 ID/输入长度/版本和摘要格式核对,不独立重算摘要。
|
||||
* 没有超时,线程无响应需外部取消/close;Worker 内原生哈希阈值由 digestWorkerSource 控制。 */
|
||||
export function createBrowserDigests(){
|
||||
let next=0;
|
||||
return createDigestPool({execute:typeof Worker==='undefined'?undefined:(bytes,signal)=>new Promise<string>((resolve,reject)=>{
|
||||
@@ -15,6 +18,8 @@ export function createBrowserDigests(){
|
||||
const id=++next;let done=false;
|
||||
/** 幂等完成:解绑所有消息/错误/abort 监听并终止 Worker,防止迟到响应泄漏或重复 resolve。 */
|
||||
const finish=(error?:unknown,sha256?:string)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(sha256!);},abort=()=>finish(fault('import_aborted'));
|
||||
// 稳定 digest_worker_failed 只有身份匹配才采纳;其它响应走摘要字段校验,不把任意 error 字段当服务错误。
|
||||
// 格式正确只表明消息可解释,真实性来自受信的专用 Worker 算法;不在 UI 线程重复计算。
|
||||
// 消息须精确匹配版本、任务 ID 和输入长度,再验64位十六进制摘要;已知错误也必须匹配身份。
|
||||
signal.addEventListener('abort',abort,{once:true});worker.onmessage=event=>{const value=event.data;if(value?.version===1&&value.id===id&&value.byteLength===bytes.size&&value.error==='digest_worker_failed'){finish(fault('digest_worker_failed'));return;}if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size||typeof value.sha256!=='string'||!(/^[a-f0-9]{64}$/.test(value.sha256)))finish(fault('invalid_digest_response'));else finish(undefined,value.sha256);};worker.onerror=event=>{event.preventDefault();finish(fault('digest_worker_failed'));};worker.onmessageerror=()=>finish(fault('invalid_digest_response'));
|
||||
// Blob 通过结构化克隆传递,不在 UI 主线程先读取整份 arrayBuffer。
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
*/
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
import {createImageDecodePool,type ImageDimensions} from './image-decoder.ts';
|
||||
/** 返回 ImageDecoderPort;版本/id/字节数验证响应归属,尺寸合法性由 pool 再检查。 */
|
||||
/** 将每次 execute 转成专用 module Worker,请求 ID 在此 decoder 实例内递增。
|
||||
* 外围池使用默认并发/排队预算;此适配按响应版本/id/输入长度核对归属,尺寸合法性由池检查。
|
||||
* 没有超时;Worker 无响应时要由外部取消/close 才能终止,构造失败也不回退主线程解码。 */
|
||||
export function createBrowserImageDecoder(){let next=0;return createImageDecodePool({execute:(bytes,signal)=>new Promise<ImageDimensions>((resolve,reject)=>{
|
||||
if(typeof Worker==='undefined'){reject(fault('image_decoder_unavailable'));return;}
|
||||
// 每任务一个 Worker,取消只 terminate 自己,不会打断其它图片的解码。
|
||||
@@ -12,6 +14,7 @@ export function createBrowserImageDecoder(){let next=0;return createImageDecodeP
|
||||
/** finish 幂等解除消息/错误/abort 监听并 terminate;abort 在监听安装后再检查一次覆盖竞态。 */
|
||||
const id=++next;let done=false;const finish=(error?:unknown,size?:ImageDimensions)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(size!);},abort=()=>finish(fault('image_decode_aborted'));
|
||||
signal.addEventListener('abort',abort,{once:true});if(signal.aborted){abort();return;}
|
||||
// Worker error 会 preventDefault 并转稳定错误,消息无法反序列化则拒绝;所有路径共用 finish 终止线程。
|
||||
// 只采纳精确匹配请求的成功尺寸或已知错误;未知错误码/错误协议作为 invalid response 拒绝。
|
||||
worker.onmessage=event=>{const value=event.data;if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size){finish(fault('invalid_image_response'));return;}if(value.error){finish(fault(value.error==='image_decode_failed'?'image_decode_failed':'invalid_image_response'));return;}finish(undefined,{width:value.width,height:value.height});};worker.onerror=event=>{event.preventDefault();finish(fault('image_decode_failed'));};worker.onmessageerror=()=>finish(fault('invalid_image_response'));
|
||||
// 直接传不可变 Blob,不先在 UI 线程转成像素或整份 ArrayBuffer。
|
||||
|
||||
@@ -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)};
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ export type RecoveryChoice = 'keep-local' | 'discard-local';
|
||||
export type RecoveryDecision = { readonly operationId: Id; readonly choice: RecoveryChoice; readonly replacementId?: Id; readonly resolution: 'superseded' | 'discarded' | 'satisfied' };
|
||||
/** 恢复审计记录:绑定规划时的根 revision,保留备份及可用的历史重放证据。 */
|
||||
export type RecoveryAudit = { readonly id: Id; readonly formatVersion: 1; readonly baseRootRevision: number; readonly decisions: readonly RecoveryDecision[]; readonly backup: Json; readonly historyTransition?:HistoryTransition; readonly historyReplay?:HistoryReplay };
|
||||
/** 待一次性发布的候选根及引用资源;baseRootRevision 供提交方拒绝过时方案。 */
|
||||
/** 待采纳的文档/历史/操作/账本候选及审计,非完整 EngineSnapshot;引擎还需结合 canonical 版本等事实发布。
|
||||
* baseRootRevision 用于拒绝过时方案,resourceIds 是本次已知保留集合,operationIds 只列新替代操作。 */
|
||||
export type ConflictPlan = { readonly id: Id; readonly baseRootRevision: number; readonly document: Document; readonly history: HistoryState; readonly operations: EngineSnapshot['operations']; readonly ledger: EngineSnapshot['runtime']['ledger']; readonly audit: RecoveryAudit; readonly resourceIds: readonly Id[]; readonly operationIds: readonly Id[] };
|
||||
/** 字段身份包括元素代次,避免把删除后重建的同 ID 元素算成同一字段历史。 */
|
||||
const fieldKey = (change: FieldChange) => JSON.stringify([change.target.id,change.target.incarnation,change.field]);
|
||||
@@ -41,8 +42,11 @@ function value(document: Document, change: FieldChange, allowMissing=false): {be
|
||||
/** 把文档 Map 转成备份用的 JSON 形状;序列化的是数据,不是运行时方法或索引。 */
|
||||
const documentValue = (document: Document) => ({projectId:document.projectId,canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:[...document.tombstones]});
|
||||
|
||||
/** 纯规划入口:先校验权威读取与回执已全部落定,再按操作顺序投影用户决策。
|
||||
* 不复用旧请求键/请求体;未知结果尚未确认时拒绝规划,防止重复执行已经生效的修改。 */
|
||||
/** 在不可变 snapshot 上规划恢复:先验 canonical 单调性/完整性,并要求 ledger 中请求 phase 全为 resolved。
|
||||
* 不自己查询回执、认证来源或等待磁盘;canonical/账本已捕获且可信由 engine/端口负责。
|
||||
* 取全部非 resolved 且非 asset.upload 意图,要求 choices 精确覆盖每项;依次重规划、重建 history、退休旧项并加入替代项。
|
||||
* 不发布/持久化/提交请求;createId 可消耗外部编号,失败不会回滚该回调副作用。
|
||||
* 小输入/模型按既有不可变合同使用,备份 JSON 编码与全部恢复规划同步完成,没有时间片/容量预算。 */
|
||||
export function planConflictRecovery(snapshot: EngineSnapshot, canonical: CanonicalProposal, choices: ReadonlyMap<Id,RecoveryChoice>, registry: Registry, createId:()=>Id,historyLimit=100): ConflictPlan {
|
||||
if(snapshot.runtime.loading)validateLoadingCompletion(snapshot.runtime.acknowledged,snapshot.runtime.versions,snapshot.runtime.loading,canonical.document,canonical.versions,registry,true);else validateCanonicalRead(snapshot.runtime.acknowledged,snapshot.runtime.versions,canonical.document,canonical.versions,registry);
|
||||
if([...snapshot.runtime.ledger.requests.values()].some(request=>request.phase!=='resolved'))throw fault('recovery_waiting_for_receipts');
|
||||
@@ -59,7 +63,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
// 边界记录历史单元恢复前后的文档;starts 保存首次触及字段时的基线,供混合 ACK 历史核对。
|
||||
const boundaries=new Map<Id,{before:Document;after:Document}>(),starts=new Map<string,ReturnType<typeof value>>(),historyOwner=new Map<Id,Id>(),contexts=intentContexts(snapshot.document,records.map(record=>record.intent));
|
||||
for(const entry of snapshot.history.entries.values())for(const operationId of entry.operationIds){if(historyOwner.has(operationId))throw fault('recovery_history_ambiguous');historyOwner.set(operationId,entry.id);}
|
||||
// 有历史转移证据或之前已采用重放的操作时,优先用日志恢复保持 undo/redo 的语义。
|
||||
// 全部 content 有原生 transition、某个遍历有 transition,或命中已有 adoption 时,进入严格日志恢复。
|
||||
// 这只是分支选择,不证明证据齐全/可回放;journal 构造和后续 project/accept 仍可拒绝不完整/矛盾记录。
|
||||
const adopted=new Set([...snapshot.runtime.recoveries.values()].filter(audit=>audit.historyReplay!==undefined).flatMap(audit=>audit.decisions.flatMap(decision=>decision.replacementId?[decision.replacementId]:[])));
|
||||
const content=records.filter(record=>record.intent.changes.length),hasEvidence=content.length>0&&content.every(record=>record.intent.historyTransition)||records.some(record=>['undo','redo'].includes(record.intent.origin)&&record.intent.historyTransition)||content.some(record=>adopted.has(record.intent.id));
|
||||
const journal=hasEvidence?createHistoryJournalRecovery(snapshot,records.map(record=>record.intent),registry,historyLimit):undefined;
|
||||
@@ -78,7 +83,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
}
|
||||
// 用户新建 canvas 的身份若已被服务端占用,不能悄悄视为同一对象。
|
||||
if(choice==='keep-local'&&intent.origin==='user')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]);
|
||||
// keep-local 在新基线上重新计算改动;discard-local 不施加该意图,日志仍记录该决定。
|
||||
// 每项边界 before→after 反映此前全部审阅结果;keep 在该新候选上规划,discard 不施加原文档变化。
|
||||
// journal.project 还可能先推进已有结算历史;没有 journal 时直接走 rebaseIntent,不能并行按同一旧基线处理。
|
||||
const rebased=journal?journal.project(document,intent,contexts.get(intent.id)!,choice==='keep-local'):choice==='keep-local'?rebaseIntent(document,intent,contexts.get(intent.id)!,registry):{document,changes:[]};
|
||||
const changes=rebased.changes;document=rebased.document;
|
||||
if(entryId)boundaries.get(entryId)!.after=document;
|
||||
@@ -87,7 +93,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
// 生成启动、取消等动作需重新发起命令;冲突审阅不能顺手重新执行可能付费的任务。
|
||||
if(intent.operationType!=='document.change'&&intent.operationType!=='run.apply')throw fault('recovery_action_requires_new_command',[intent.id]);
|
||||
if(changes.length){
|
||||
// 依赖替换为对应的新操作,丢弃或已满足的依赖不再排队;再串联此前所有替代操作。
|
||||
// 原依赖若已审阅:改指对应 replacement,或在丢弃/已满足时移除;未审阅的既有依赖保留原 ID。
|
||||
// 再依赖所有此前新 replacement,保证后项看到前项的串行效果;新 sequence 从账本最大值后递增。
|
||||
const dependencyIds=intent.dependencies.flatMap(dependency=>replacements.has(dependency)?replacements.get(dependency)?[replacements.get(dependency)!.id]:[]:[dependency]);
|
||||
// New dependencies reference new logical facts; a discarded parent never silently starts a paid run.
|
||||
const dependencySet=new Set(dependencyIds);
|
||||
@@ -102,6 +109,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
}
|
||||
journal?.accept(intent,replacement,choice==='keep-local',before,document);
|
||||
replacements.set(intent.id,replacement);
|
||||
// satisfied 只表示这次规划无需新 changes;不是在此收到另一份服务器 applied 回执。
|
||||
decisions.push(freeze({operationId:intent.id,choice,resolution:choice==='discard-local'?'discarded':replacement?'superseded':'satisfied',...(replacement?{replacementId:replacement.id}:{})}));
|
||||
}
|
||||
// 根据现有证据选日志重放、撤销/重做重规划或兼容历史重写,拒绝无法证明安全的路径。
|
||||
@@ -113,20 +121,24 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
ledger.add(replacement,presence);
|
||||
}
|
||||
const historyReplay=journal?.evidence();
|
||||
// Conservative pins include the archived document/history and unresolved opaque local resources.
|
||||
// 已知资源保留集合:先纳入本地 resources 全部 ID,再补原/替代 changes 与历史 replay 中可解释引用。
|
||||
// 不在此推断 opaque payload 的所有私有引用,也不把资源 ID 清单当作已保存 Blob 字节的证明。
|
||||
const resourceIds=new Set(snapshot.runtime.resources.keys());
|
||||
for(const record of records)for(const resource of referencedResources(record.intent.changes,registry))resourceIds.add(resource);
|
||||
for(const replacement of replacements.values())if(replacement)for(const resource of referencedResources(replacement.changes,registry))resourceIds.add(resource);
|
||||
if(historyReplay)for(const transition of [historyReplay.anchor,...historyReplay.events.map(event=>event.transition)])for(const resource of referencedResources(historyTransitionChanges(transition),registry))resourceIds.add(resource);
|
||||
// 同时备份旧 working、ACK 基线和历史;资源 pin 保护恢复证据,防止审阅后被清理。
|
||||
// backup 捕获旧 working/ACK/版本/history 和资源 ID;不嵌入 Blob、服务回执或完整 workspace 快照。
|
||||
// 顶层 audit transition 描述旧→新历史,journal evidence 另解释内部事件;两者都须与恢复 Root 同次保存。
|
||||
const backup=JSON.parse(JSON.stringify({document:documentValue(snapshot.document),acknowledged:documentValue(snapshot.runtime.acknowledged),versions:[...snapshot.runtime.versions],resourceIds:[...resourceIds],history:{undo:snapshot.history.undo,redo:snapshot.history.redo,entries:[...snapshot.history.entries]}})) as Json;
|
||||
const historyTransition=describeHistoryTransition(snapshot.history,history,snapshot.rootRevision+1,undefined,!!historyReplay);
|
||||
const audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup,...(historyTransition?{historyTransition}:{}),...(historyReplay?{historyReplay}:{})});
|
||||
return Object.freeze({id,baseRootRevision:snapshot.rootRevision,document,history,operations:readMap(operations),ledger:ledger.getSnapshot(),audit,resourceIds:Object.freeze([...resourceIds]),operationIds:Object.freeze([...replacements.values()].flatMap(intent=>intent?[intent.id]:[]))});
|
||||
}
|
||||
|
||||
/** 无完整日志证据时的兼容历史重写。仅支持能够证明前缀/字段基线的撤销栈项,
|
||||
* 不推测交错 ACK、结构变更或不支持的历史遍历;无法验证时抛错让整个规划保持未发布。 */
|
||||
/** 无 journal 的兼容分支,仅重写受本批 pending 影响的 undo 项,且其关联操作都必须是已知 user origin。
|
||||
* 全项未决时直接用 replacements 拼回;混合已确认贡献时,结构项要求确认贡献为完整前缀,纯字段按身份键分别审阅。
|
||||
* 不生成完整事件 replay,不重规划 redo 或任意交错遍历;不满足证据条件即拒绝本次方案,交外层保持未发布。
|
||||
* 返回私有 entry 表/undo 候选,未影响 entry 与 redo 数组沿用不可变快照引用。 */
|
||||
function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap<Id,OperationIntent|undefined>,starts:ReadonlyMap<string,ReturnType<typeof value>>,boundaries:ReadonlyMap<Id,{before:Document;after:Document}>,registry:Registry):HistoryState {
|
||||
const entries=new Map(snapshot.history.entries),affected=new Set(pending.map(intent=>intent.id));
|
||||
for(const entry of snapshot.history.entries.values()){
|
||||
@@ -155,7 +167,8 @@ function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent
|
||||
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
|
||||
continue;
|
||||
}
|
||||
// 纯字段混合历史按“目标代次 + 字段”分组;确认部分是否保留取决于恢复起点的值和存在性。
|
||||
// 纯字段按 ID/代次/field 分组,先验证该字段历史尾部确为 oldPending,再分离已确认 prefix。
|
||||
// starts 记录本次审阅首次触及该字段的 fresh 值/存在性;只在它仍等于旧 pending 的 before 时保留确认前缀。
|
||||
const keys=new Map<string,FieldChange>();for(const intent of intents)for(const change of intent.changes as readonly FieldChange[])keys.set(fieldKey(change),change);
|
||||
const fields:FieldChange[]=[];
|
||||
for(const [key,example]of keys){
|
||||
@@ -169,6 +182,7 @@ function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent
|
||||
if(!start.missing&&equal(start.before,first.before)&&(start.beforePresent??true)===(first.beforePresent??true))fields.push(...prefix);
|
||||
fields.push(...intents.flatMap(intent=>replacements.get(intent.id)?.changes as readonly FieldChange[]??[]).filter(change=>fieldKey(change)===key));
|
||||
}
|
||||
// 未触及字段保留;重建字段链追加到后面。此分支已排除 owner/端点等结构变化,不用该方式重排结构依赖。
|
||||
const changes:Change[]=[...entry.changes.filter(change=>change.kind!=='field'||!keys.has(fieldKey(change))),...fields];
|
||||
const operationIds=entry.operationIds.flatMap(operationId=>affected.has(operationId)?replacements.get(operationId)?[replacements.get(operationId)!.id]:[]:[operationId]);
|
||||
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
import {createImportBudget,type ImportScheduling} from './import-budget.ts';
|
||||
/** Share import scheduling while retaining the copy cancellation/error contract. */
|
||||
/** 验证copy预算后包装ImportBudget;调用者只有真正await返回Promise才会交出执行权。 */
|
||||
/** 复制任务的 ImportBudget 包装:构造与每次 step 都先检查取消,错误码使用 copy_aborted。
|
||||
* 只预校验 frameBudgetMs;units/时钟/调度器沿用底层合同,单位可以是记录数或由调用方折算的字节工作量。
|
||||
* 与同一个包装交互应顺序 await;没有独立计数器、持久进度或强制抢占能力。 */
|
||||
export function createCopyBudget(signal:AbortSignal,options:ImportScheduling={}){
|
||||
if(signal.aborted)throw fault('copy_aborted');
|
||||
if(options.frameBudgetMs!==undefined&&(!Number.isFinite(options.frameBudgetMs)||options.frameBudgetMs<=0))throw fault('invalid_copy_budget');
|
||||
const budget=createImportBudget(signal,options);
|
||||
/** signal已取消时将同步/异步预算失败统一为copy_aborted,其它错误原样抛给宿主。 */
|
||||
/** 只在处理错误时 signal 已取消才改成 copy_aborted;其它错误保留原对象。
|
||||
* 因此取消和宿主调度失败并发出现时,取消优先,不一定暴露原始调度错误。 */
|
||||
const fail=(error:unknown):never=>{if(signal.aborted)throw fault('copy_aborted');throw error;};
|
||||
/** 每次step先查copy取消,保持底层同步返回undefined或异步让出的原调度语义。 */
|
||||
return Object.freeze({step(units=1):Promise<void>|undefined{
|
||||
|
||||
@@ -12,11 +12,15 @@ import type {StorageLeader,StoragePort} from './storage-port.ts';
|
||||
import {resourceRetentionPins} from './resource-retention.ts';
|
||||
|
||||
/** Derive one project's shared-byte manifest from the exact Root being persisted. */
|
||||
/** 生成已确认上传/跨项目 binding 的共享身份、所需字节与保留清单;无 depot 能力明确拒绝。 */
|
||||
/** 从指定不可变 snapshot 派生共享身份/字节写入项与 pins,缺少 assetDepot 端口则拒绝。
|
||||
* 会异步读取 depot/本地文件;不提交存储事务、不重算摘要,也不在 await 后自行校验 leader/宿主身份。
|
||||
* 返回可变描述数组而非冻结 Root;调用方须把清单与该 snapshot 同次提交,rootRevision 不另存于返回值。
|
||||
* snapshot 和各输入缓存需遵循宿主捕获约定,尤其 adoptedFiles 用 revision 防止混入较晚的采纳。 */
|
||||
export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Registry;storage:StoragePort;leader:StorageLeader;authority:string;files:ReadonlyMap<string,Blob>;displays:ReadonlyMap<string,number>;temporary:ReadonlyMap<string,readonly string[]>;adoptedFiles?:ReadonlyMap<string,{readonly bytes:Blob;readonly rootRevision:number}>}):Promise<DepotCut>{
|
||||
const {snapshot,storage,leader,authority}=input,pins=resourceRetentionPins(snapshot,input.registry,input.displays,input.temporary),writes:DepotWrite[]=[];
|
||||
if(!storage.assetDepot)throw fault('asset_depot_unavailable');
|
||||
// 仅 stored+digest 的原图可进入共享身份;未上传文件仍留在项目本地文件仓库。
|
||||
// 每个 stored+digest 资源都登记身份(包括已 purge 的项目记录),但只有仍保留的才尝试补字节。
|
||||
// sharedAssetRef 校验并捕获元数据,随后要求资产 ID/长度对应本地记录;未上传资源不进入共享写入清单。
|
||||
for(const resource of snapshot.runtime.resources.values())if(resource.stored&&resource.digest){
|
||||
const ref=sharedAssetRef({version:1,authority,digest:resource.digest,asset:resource.stored});
|
||||
if(ref.asset.id!==resource.id||ref.asset.bytes!==resource.byteLength)throw fault('shared_asset_identity_conflict',[resource.id]);
|
||||
@@ -25,6 +29,8 @@ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Re
|
||||
if(resource.retirement?.purgedAt===undefined&&pins.pins.has(resource.id)){
|
||||
const cached=await storage.assetDepot.read(leader,authority,resource.id);
|
||||
// The store enforces immutable identity even when bytes are already present.
|
||||
// 缓存已有字节则只提交身份;否则优先项目内存文件,再读项目存储。
|
||||
// 两处均无字节时仍可形成仅身份写入项;是否满足事务要求、摘要与不可变身份由 adapter 检查。
|
||||
if(!cached?.bytes)bytes=input.files.get(resource.id)??await storage.readFile(leader,resource.id);
|
||||
}
|
||||
writes.push({ref,...(bytes?{bytes}:{})});
|
||||
@@ -34,9 +40,11 @@ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Re
|
||||
if(binding.authority!==authority||binding.asset.id!==id||snapshot.runtime.resources.has(id))throw fault('shared_asset_identity_conflict',[id]);
|
||||
const pending=input.adoptedFiles?.get(id),bytes=pending&&pending.rootRevision<=snapshot.rootRevision?pending.bytes:undefined;writes.push({ref:sharedAssetRef(binding),...(bytes?{bytes}:{})});
|
||||
}
|
||||
// binding 不以 pins 决定是否写入,只有其暂存 bytes 的采纳 revision 不晚于本 Root 才携带字节。
|
||||
// A source/run envelope may refer to resources outside this project's upload table.
|
||||
// Its complete resource interpreter must be installed before global deletion is allowed.
|
||||
// 引用、加载与任务解释都完整才允许全局回收;opaque run/未完导入不能假定无共享资源引用。
|
||||
const complete=pins.complete&&!snapshot.runtime.loading&&![...(snapshot.runtime.imports?.values()??[])].some(job=>!['completed','partial','cancelled'].includes(job.status))&&snapshot.runtime.runs.size===0;
|
||||
// complete 是共享仓库全局删除所需的覆盖声明,不是字节写入完成回执;真正删除由仓库协议决定。
|
||||
return{version:1,authority,complete,pins:[...pins.pins].map(([id,owners])=>[id,[...owners]] as const),writes};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import {importSourceDigest} from './import-integrity.ts';
|
||||
/** 可替换摘要端口,输出完整 Blob 的小写 SHA256;close 终止本池,不清业务数据或外部回执。 */
|
||||
export type DigestPort={digest(bytes:Blob,signal?:AbortSignal):Promise<string>;close():void};
|
||||
/** Read-only compute scheduling: bounded active slots + FIFO Map, never durable operation facts. */
|
||||
/** 默认2并发/64等待,上限8并发;可注入 Worker/native/测试执行器,默认使用分块摘要。 */
|
||||
/** 默认 2 并发/64 等待,上限 8 并发;可注入 execute,默认 importSourceDigest 分块计算。
|
||||
* maxQueued 只计算 waiting,不含 active;不限制 Blob 大小/MIME,不做相同文件去重或结果缓存。
|
||||
* execute 负责计算真实完整摘要,池仅用正则检查输出格式,不能证明输出确实对应输入字节。 */
|
||||
export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSignal)=>Promise<string>;concurrency?:number;maxQueued?:number}={}):DigestPort{
|
||||
const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64,execute=options.execute??importSourceDigest;
|
||||
if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_digest_budget');
|
||||
@@ -16,9 +18,13 @@ export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSigna
|
||||
type Task={id:number;bytes:Blob;controller:AbortController;resolve:(value:string)=>void;reject:(error:unknown)=>void;cleanup:()=>void;done:boolean};
|
||||
// Map 既保留入队次序,又可在等待期取消时 O(1) 删除;active 保留真实未结束执行的容量占用。
|
||||
const waiting=new Map<number,Task>(),active=new Map<number,Task>();let next=0,closed=false;
|
||||
/** 只 settle 一次并移除等待项/监听;故意不在这里 active.delete,迟到结果不能二次采纳。 */
|
||||
/** 只 settle 一次并移除 waiting/外部监听,不在这里 active.delete,迟到结果不能二次采纳。
|
||||
* error 用真值分支,execute 应以 Error 等真值拒绝;falsy rejection 不保证被当成失败处理。 */
|
||||
function finish(task:Task,error?:unknown,value?:string){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(value!);}
|
||||
/** 有空槽就取最早等待项,先登记 active 再微任务执行;结果校验/取消后 finally 归还真实槽。 */
|
||||
/** 从 Map 首项 FIFO 取任务,先占 active 再微任务调用 execute;启动前、结果返回后均复查 abort。
|
||||
* execute 的拒绝由第二个 then 回调处理,结果校验抛错再由 catch 处理,最终统一归还槽位并泵下一项。
|
||||
* 正则会按 JS 规则强制转字符串,未另做 typeof 检查;注入执行器应遵守 Promise<string> 合同。
|
||||
* 无视 abort 且不 settle 的执行器会一直占槽;池没有超时或强制终止通用执行器的能力。 */
|
||||
function pump(){
|
||||
if(closed)return;
|
||||
while(active.size<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task);
|
||||
@@ -31,7 +37,8 @@ export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSigna
|
||||
if(closed)return Promise.reject(fault('digest_pool_closed'));if(signal?.aborted)return Promise.reject(fault('import_aborted'));if(!(bytes instanceof Blob))return Promise.reject(fault('invalid_digest_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('digest_capacity'));
|
||||
return new Promise((resolve,reject)=>{const task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,cleanup:()=>signal?.removeEventListener('abort',abort),done:false},abort=()=>{task.controller.abort();finish(task,fault('import_aborted'));pump();};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();});
|
||||
},
|
||||
/** 幂等关闭,取消等待/活跃任务的调用结果;无视 signal 的执行仍由其 Promise 收尾后释放 active。 */
|
||||
/** 幂等关闭并取消 waiting/active 的调用结果,停止接受新任务;不返回 drain Promise。
|
||||
* 无视 signal 的执行仍由自身 Promise 收尾后释放 active,close 返回不证明底层计算已停止。 */
|
||||
close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('import_aborted'));}},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ export const nativeDigestByteLimit=64*1024*1024;
|
||||
|
||||
/** Dedicated Worker only: whole-buffer native hashing never moves onto the UI thread.
|
||||
* The host still owns cancellation by terminating the request's Worker. */
|
||||
/** 验证 Blob,按能力/大小选 native 或增量实现,均返回相同的完整原字节摘要。 */
|
||||
/** 验证 Blob,SubtleCrypto 存在且大小 ≤64MiB 时读取整个 ArrayBuffer 做 SHA-256,否则流式计算。
|
||||
* 原生读/算失败直接传播,不自动切到流式重试;注入 subtle 应遵守原生 digest 的 ArrayBuffer 合同。
|
||||
* 本函数无 signal,取消靠宿主终止 Worker;代码本身不检测当前线程,Worker 限制由装配调用方遵守。
|
||||
* 阈值只限制此输入的整缓冲分配,未计 Blob/原生算法内部内存及其它并发 Worker。 */
|
||||
export async function digestWorkerSource(bytes:Blob,subtle:Pick<SubtleCrypto,'digest'>|null|undefined=globalThis.crypto?.subtle):Promise<string>{
|
||||
if(!(bytes instanceof Blob))throw fault('invalid_digest_input');
|
||||
// native digest 必须返回32字节;输出固定64字符十六进制,不接受变长响应。
|
||||
@@ -20,6 +23,7 @@ export async function digestWorkerSource(bytes:Blob,subtle:Pick<SubtleCrypto,'di
|
||||
if(digest.byteLength!==32)throw fault('invalid_digest_response');
|
||||
return [...new Uint8Array(digest)].map(value=>value.toString(16).padStart(2,'0')).join('');
|
||||
}
|
||||
// Worker fallback 每块1MiB,只做微任务让出;UI 让出策略由 UI fallback 的端口另行决定。
|
||||
// 每块 1MiB,块间 yieldTask 仅完成一个 Promise 微任务,不保证 Worker 消息队列获得宏任务机会。
|
||||
// UI fallback 用 importSourceDigest 默认定时器让出;不要把这里的微任务策略移作 UI 响应性承诺。
|
||||
return importSourceDigest(bytes,undefined,{chunkBytes:1024*1024,yieldTask:()=>Promise.resolve()});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import {digestWorkerSource} from './digest-worker-source.ts';
|
||||
const scope=globalThis as unknown as {onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void};
|
||||
/** 首条有效请求被接受后保持 true,直到整个 Worker 被宿主终止。 */
|
||||
let accepted=false;
|
||||
/** 校验协议和请求后启动只读摘要;成功/失败都回显输入 ID 与字节数,供宿主核对。 */
|
||||
/** 首条有效 version=1/正安全整数 ID/Blob 设置 accepted,之后包括失败后的重复消息都忽略。
|
||||
* 无效请求直接忽略,不返回错误;这里不检查 MIME/大小或资源身份,不拥有持久任务。
|
||||
* 算法拒绝会尝试回稳定错误;成功回信自身抛错不由同一个 then 的失败回调捕获,宿主仍需错误/取消收尾。 */
|
||||
scope.onmessage=event=>{
|
||||
const request=event.data;if(accepted||!request||request.version!==1||!Number.isSafeInteger(request.id)||request.id<1||!(request.bytes instanceof Blob))return;accepted=true;
|
||||
void digestWorkerSource(request.bytes).then(sha256=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,sha256}),()=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,error:'digest_worker_failed'}));
|
||||
|
||||
@@ -16,12 +16,15 @@ type Entry = { readonly key: string; readonly owner: object; readonly controller
|
||||
* durable bytes before acquiring. Scope objects are never serialized or reused
|
||||
* across project engines/auth epochs. There is no idle URL retention.
|
||||
*/
|
||||
/** 创建纯显示生命周期容器;owner 应绑定 engine/auth epoch,不能序列化或跨账号复用。 */
|
||||
/** 创建纯显示生命周期容器,owners 两层 Map 用对象身份+字符串 key 索引共享条目。
|
||||
* owner 应绑定 engine/auth epoch;本模块不解释账号、资源 ID、URL 权限或实际文件 pin。
|
||||
* 没有容量上限/空闲保留,最后 lease 释放即清理;永久撤销 owner 则另记到 WeakSet。 */
|
||||
export function createDisplayCache(options: { onError?: (error: unknown) => void } = {}) {
|
||||
// owners 仅保留活条目;retired WeakSet 禁止退役 owner 再 acquire,但不强持有其内存。
|
||||
const owners = new Map<object, Map<string, Entry>>(), retired = new WeakSet<object>();
|
||||
let closed = false;
|
||||
/** 统一调用来源 release 并隔离错误,清理/日志失败不阻止释放其它 URL。 */
|
||||
/** 同步调用来源 release,隔离其抛错和 onError 的抛错;onError 只报告清理失败。
|
||||
* load 的失败通过各 lease.ready 传出,不在这里自动记录或重试;release 合同也不是异步清理任务。 */
|
||||
function dispose(source: DisplaySource) { try { source.release?.(); } catch (error) { try { options.onError?.(error); } catch { /* Reporting cannot interrupt cleanup. */ } } }
|
||||
/** 幂等退休共享条目,仅删除仍指向自己的表项,避免旧任务删掉同 key 的新 entry。 */
|
||||
function retire(entry: Entry) {
|
||||
@@ -41,7 +44,9 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
/** 借用显示来源:同 owner/key 合并加载,但每个调用者可独立取消自己的等待。 */
|
||||
/** 同 owner/key 复用进行中或已加载来源,命中时忽略本次传入的 load,不逐次重验权限。
|
||||
* 每个调用者独立 ready/release;释放已兑现的 ready 不会改变 Promise 的成功结果,但 URL 可能随最后释放失效。
|
||||
* 不自动接收 AbortSignal,取消通过 lease.release;单个释放不取消仍有其它消费者的共享加载。 */
|
||||
acquire(owner: object, key: string, load: (signal: AbortSignal) => Promise<DisplaySource>): DisplayLease {
|
||||
if (closed || retired.has(owner)) throw fault('display_scope_closed');
|
||||
if (!owner || typeof owner !== 'object' || typeof key !== 'string' || !key) throw fault('invalid_display_key');
|
||||
@@ -55,6 +60,7 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
|
||||
if (created.retired) throw fault('display_released');
|
||||
return load(created.controller.signal);
|
||||
}).then(source => {
|
||||
// 这里只检查 URL 为非空字符串及可选 release 为函数,不验证 URL 协议/可访问性或图片内容。
|
||||
if (!source || typeof source.url !== 'string' || !source.url || source.release !== undefined && typeof source.release !== 'function') { if (typeof source?.release === 'function') dispose(source); throw fault('invalid_display_source'); }
|
||||
// 即使 load 不响应 abort,迟到返回也只能 dispose,不允许挂回已无消费者的缓存。
|
||||
if (created.retired || !created.leases.size) { dispose(source); throw fault('display_released'); }
|
||||
@@ -70,7 +76,8 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
|
||||
if (!shared.leases.size) retire(shared);
|
||||
};
|
||||
shared.leases.add(release);
|
||||
// 将共享 promise 映射成每租约的独立 ready;来源失败时本租约也自动归还,不泄漏计数。
|
||||
// 映射共享 promise 到各 lease.ready,失败逐个归还;校验 source 时抛错也沿这条失败路径清理。
|
||||
// 缓存不吞 ready 的拒绝,消费者需接住取消/加载错误;释放后迟到成功不能再次 resolve 本租约。
|
||||
const ready = new Promise<string>((resolve, reject) => {
|
||||
rejectReady = reject;
|
||||
shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } });
|
||||
|
||||
@@ -13,7 +13,10 @@ import { duplicateImportLine } from './import-duplicate.ts';
|
||||
import type { ImportSource } from './imports.ts';
|
||||
|
||||
/** A seekable record cursor over the original SHA-bound snapshot, not a second mapped Blob. */
|
||||
/** 要求未输出的emit起点、当前registry签名及有效绑定,ID工厂在恢复重校验中禁止意外再分配。 */
|
||||
/** 要求 emit 起点/零输出计数与当前 registry 签名,调用 planner 复核 checkpoint 结构绑定且禁止再分配 ID。
|
||||
* 映射复制为私有 Map,但 document/registry/state 及 readElement 仍沿用调用方引用,须保持不可变/稳定。
|
||||
* sourceToken/fingerprint 的真实字节绑定由上层验证;本函数不读取文件或重新计算 SHA。
|
||||
* 构造后可按 ordinal 任意重读,read 不推进 state.emitted;持久进度由 runner 的 job.cursor 保存。 */
|
||||
export function createDuplicatePlanSource(document:Document,registry:Registry,state:DuplicateCheckpoint,fingerprint:string,readElement?:(id:Id,signal:AbortSignal)=>Promise<ElementRecord>):ImportSource{
|
||||
if(state.phase!=='emit'||state.emitted!==0||state.emittedEdges!==0||state.schemaToken!==copyRegistrySignature(document,registry))throw fault('copy_interpreter_changed');
|
||||
// Structural/source binding checks; the decoded source has already passed document validation.
|
||||
@@ -22,7 +25,9 @@ export function createDuplicatePlanSource(document:Document,registry:Registry,st
|
||||
const elements=new Map(state.elementIds),canvases=new Map(state.canvasIds),copiedCanvases=new Set(state.canvases),keys=new Map<Id,string>(),reverseElements=new Map(state.elementIds.map(([id,mapped])=>[mapped,id])),reverseCanvases=new Map(state.canvasIds.map(([id,mapped])=>[mapped,id]));
|
||||
for(const[id,mapped]of elements)keys.set(mapped,`element:${id}`);for(const[id,mapped]of canvases)keys.set(mapped,`canvas:${id}`);
|
||||
// Edge geometry needs mapped endpoint/owner records. Compute these on demand; retain a bounded cache.
|
||||
/** 给边计算提供新端点/owner视图,get按需转换并只保留32条缓存,不批量生成所有副本对象。 */
|
||||
/** 给边计算提供原表+映射表的只读投影,get 按需转换映射 ID,每个投影最多缓存 32 项。
|
||||
* 淘汰按插入顺序,缓存命中不刷新顺序,因此是 FIFO 而非 LRU;不缓存 readElement 的完整 payload。
|
||||
* size/迭代将两组成员拼接,依赖映射不与原 ID 碰撞的 planner 合同,不能当可发布的规范文档表。 */
|
||||
function projection<T extends ElementRecord|CanvasRecord>(base:ReadonlyMap<Id,T>,reverse:ReadonlyMap<Id,Id>,kind:'element'|'canvas'):ReadonlyMap<Id,T>{
|
||||
// 未映射ID沿用原表,新ID转换源记录;缓存淘汰只影响计算成本,不改变稳定映射/源数据。
|
||||
const cache=new Map<Id,T>();const get=(id:Id):T|undefined=>{const old=reverse.get(id);if(!old)return base.get(id);const found=cache.get(id);if(found)return found;const original=base.get(old);if(!original)throw fault('invalid_copy_stream_checkpoint');const record=duplicateTreeChange(document,registry,kind,original,elements,canvases,copiedCanvases,state.offset).after as T;cache.set(id,record);if(cache.size>32)cache.delete(cache.keys().next().value!);return record;};
|
||||
@@ -32,7 +37,11 @@ export function createDuplicatePlanSource(document:Document,registry:Registry,st
|
||||
}
|
||||
// projected只服务边端点和公共owner的坐标计算;length是tree order+edges的固定ordinal总数。
|
||||
const projected:Document={...document,elements:projection(document.elements,reverseElements,'element'),canvases:projection(document.canvases,reverseCanvases,'canvas')},length=state.order.length+state.edges.length;
|
||||
/** read按ordinal选择共享tree/edge转换器;单记录错误返回error,源指纹/完整性与调度另由宿主约束。 */
|
||||
/** 前 state.order.length 项用树转换器,其余用边转换器,生成同一预映射 CopyLine。
|
||||
* readElement 可按需异步加载元素;其身份/源一致性由注入端口保证,canvas 直接从已捕获文档取。
|
||||
* 预先取消/非法 ordinal 在 try 外拒绝;try 内失败(含读取后取消)转成 ImportLine.error,runner 必须复查信号。
|
||||
* 成功检查单行 JSON 的 UTF-8 长度 ≤256KiB(不含 NDJSON 换行),不限制完整输出总字节或在单条内让出。
|
||||
* 失败不会推进此源的全局游标;是否保留失败、跳过或停止由 runner 管理。 */
|
||||
return Object.freeze({fingerprint,parserVersion:2,async read(cursor:number,signal:AbortSignal){
|
||||
if(signal.aborted)throw fault('import_aborted');if(!Number.isSafeInteger(cursor)||cursor<0||cursor>length)throw fault('invalid_import_cursor');if(cursor===length)return null;
|
||||
const task=state.order[cursor],key=task?`${task.kind}:${task.id}`:`element:${state.edges[cursor-state.order.length]}`;
|
||||
|
||||
@@ -23,7 +23,10 @@ export type DuplicateScheduling = {
|
||||
* No document publication or network effects. The caller must persist source/mapping and
|
||||
* revalidate external identities before applying a plan; this is not the import executor.
|
||||
*/
|
||||
/** 分片消费完整plan迭代器,每片默认6ms/512步;返回前全部记录已生成并校验,但不执行导入。 */
|
||||
/** 分片消费完整 plan 生成器,默认每片 6ms/512 个语义步,完成后返回全量计划而非逐条发布。
|
||||
* checkpoint 仅接收累计步数,不含映射/生成器状态,不能用该数字恢复规划;最终返回前不保证再调用一次。
|
||||
* 每次 next 本身可包含同步固定成本,软预算不能抢占它;now/yieldTask 决定实际调度,默认 timer 让出。
|
||||
* 异常/取消关闭生成器,不回滚已调用 createId 或 checkpoint 的外部副作用;调用方负责可靠源/映射保存。 */
|
||||
export async function planDuplicateAsync(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions, scheduling: DuplicateScheduling = {}): Promise<DuplicatePlan> {
|
||||
const budget = scheduling.frameBudgetMs ?? 6, limit = scheduling.maxStepsPerSlice ?? 512;
|
||||
if (!Number.isFinite(budget) || budget <= 0 || !Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_copy_budget');
|
||||
@@ -58,7 +61,11 @@ export async function planDuplicateAsync(document: Document, registry: Registry,
|
||||
}
|
||||
|
||||
/** Resume semantic planning cursors. Source/output validation and derived output caches are rebuilt. */
|
||||
/** 以完整checkpoint恢复语义游标,仍重做源验证/派生输出缓存,不声称从任意字节零重算。 */
|
||||
/** 从完整 DuplicateCheckpoint 恢复语义游标/已分配 ID,源验证和派生输出缓存仍由 planner 重建。
|
||||
* 每个未完成时间片可将不可变 checkpoint 交宿主 await;回调成功只代表宿主 Promise 成功,不独自证明落盘。
|
||||
* planner.step 完成后直接 result,不额外回调最终状态;需要最终可靠切点时由调用方保存返回计划/状态。
|
||||
* selection/偏移/映射在入口捕获,document/registry 沿不可变快照合同使用;调度选项应保持稳定。
|
||||
* 与完整生成器入口一样,不发布业务文档、不补偿已分配 ID 或宿主 checkpoint 副作用。 */
|
||||
export async function planDuplicateResumable(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>}={}):Promise<DuplicatePlan>{
|
||||
const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
|
||||
if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget');
|
||||
@@ -73,7 +80,12 @@ export async function planDuplicateResumable(document:Document,registry:Registry
|
||||
|
||||
|
||||
/** Stop before record conversion: the stable order/mappings are sufficient for seekable lazy output. */
|
||||
/** 只推进到emit起点:范围/顺序/全部新ID已固定,但尚未转换全部payload,可交lazy source逐条输出。 */
|
||||
/** 仅推进至 ready/emit 起点,返回顺序/映射已固定的 checkpoint;尚未完成所有 payload 转换与输出校验。
|
||||
* planner 捕获小输入与私有语义 state;此层只调度 step/checkpoint,不用另一个 live 文档替换源。
|
||||
* checkpointIntervalMs=0 默认每个未完成切片保存;>0 时按阶段/首次分配/时间/步数门槛节奏化保存。
|
||||
* 阈值只在切片末检查,maxStepsPerCheckpoint 可能多跨一片;不是逐语义步的硬持久间隔。
|
||||
* ready 时只有 interval>0 且有回调才补一次保存;默认路径可直接返回,宿主仍须持久化返回状态。
|
||||
* topologyOnly 仅选择源拓扑检查,不证明全部组件 payload 可复制;懒源逐条转换时仍可报记录失败。 */
|
||||
export async function prepareDuplicateStream(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>;checkpointIntervalMs?:number;maxStepsPerCheckpoint?:number}={}):Promise<DuplicateCheckpoint>{
|
||||
const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
|
||||
if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget');
|
||||
@@ -82,7 +94,8 @@ export async function prepareDuplicateStream(document:Document,registry:Registry
|
||||
const interval=scheduling.checkpointIntervalMs??0,maxCheckpointSteps=scheduling.maxStepsPerCheckpoint??8192;
|
||||
if(!Number.isFinite(interval)||interval<0||!Number.isSafeInteger(maxCheckpointSteps)||maxCheckpointSteps<1)throw fault('invalid_copy_budget');
|
||||
const planner=createDuplicatePlanner(document,registry,selection,options,binding);
|
||||
// savedSteps等只记录上次已等待checkpoint回调成功的位置,保存耗时不冒充新增规划工作。
|
||||
// saved* 是调度内存,不是另一份持久 checkpoint;首次 savedPhase 未知会在首片触发回调。
|
||||
// savedAt 只在回调成功后重取,保存耗时不算新增规划工作,也不会单因慢存储强迫下一片立即再存。
|
||||
let savedPhase:DuplicateCheckpoint['phase']|undefined,savedSteps=binding.checkpoint?.steps??0,savedAllocated=(binding.checkpoint?.elementIds.length??0)+(binding.checkpoint?.canvasIds.length??0),savedAt=now();
|
||||
while(true){
|
||||
const start=now();let count=0;
|
||||
|
||||
@@ -13,7 +13,8 @@ export type ElisionProof = { readonly version: 1; readonly operationIds: readonl
|
||||
|
||||
/** Reconstruct a closed lifetime from an empty fragment. Existing objects cannot enter this proof. */
|
||||
/** 在空记录片段上重建至多 64 意图/10000 changes,验证每个对象首触及都是创建且最终全删。
|
||||
* 墓碑变化是允许保留的本地效果,不等于服务器执行过这些创建/删除。 */
|
||||
* 本函数不读 ledger 阶段、basePresence、请求或依赖,不独自证明这些操作从未发送/实际基线 absent。
|
||||
* 只做条件变化与生命周期闭合检查,不调用 registry/整图校验;临时片段可无根,最终只保留本地墓碑。 */
|
||||
export function proveElision(intents: readonly OperationIntent[], baseTombstones: Readonly<Record<Id, number>>): ElisionProof {
|
||||
if (!intents.length || intents.length > 64 || new Set(intents.map(intent => intent.id)).size !== intents.length) throw fault('invalid_elision');
|
||||
const created = new Set<Id>(), canvases = new Set<Id>();
|
||||
@@ -37,6 +38,7 @@ export function proveElision(intents: readonly OperationIntent[], baseTombstones
|
||||
}
|
||||
}
|
||||
}
|
||||
// 起始墓碑只能描述本段确实创建过的元素,不能趁抵消夹带无关 ID 的生命周期水位。
|
||||
if (Object.keys(baseTombstones).some(id => !created.has(id))) throw fault('invalid_elision');
|
||||
const fragment: Document = { projectId: '', elements: new Map(), canvases: new Map(), tombstones: new Map(Object.entries(baseTombstones)) };
|
||||
// 条件执行仍使用真实 applyChanges;before 不匹配不能因最终想删除就被忽略。
|
||||
@@ -46,10 +48,12 @@ export function proveElision(intents: readonly OperationIntent[], baseTombstones
|
||||
}
|
||||
|
||||
/** Longest closed prefix before an existing-object write or another unsafe precondition. */
|
||||
/** 寻找最长安全闭合前缀;遇 frozen/blocker/外部未解依赖/已有对象首写即停止扩展。
|
||||
* preceding 只允许前面已选独立真实段作为依赖屏障,不把它当作已经 applied 的回执。 */
|
||||
/** 在传入候选顺序内寻找最长闭合前缀,检查 pending/未绑请求、基线和依赖;不扫描全账本证明中间无漏项。
|
||||
* 全账本连续性还由 ledger.elide/validateElisions 复核;preceding 只给批次边界规划暂时满足前段依赖。
|
||||
* undefined 表示没找到;条件回放失败或闭合证明非法仍会抛错,不统一吞成 undefined。 */
|
||||
export function findElision(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, preceding: ReadonlySet<Id> = new Set()): ElisionProof | undefined {
|
||||
const selected: OperationIntent[] = [], seen = new Set<Id>(), canvases = new Set<Id>(), bases: Record<Id, number> = Object.create(null);
|
||||
// 候选超过 64 直接不寻找,不自动截取前 64;真正闭合后的 changes 上限由 proveElision 检查。
|
||||
if (intents.length > 64 || !intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null))) return undefined;
|
||||
// 每次片段归空时保存最佳前缀;后续又创建未删对象,不影响之前已找到的闭合边界。
|
||||
let bestLength = 0, bestBases: Record<Id, number> = {};
|
||||
@@ -57,6 +61,7 @@ export function findElision(intents: readonly OperationIntent[], acknowledged: D
|
||||
for (const intent of intents) {
|
||||
const record = ledger.operations.get(intent.id);
|
||||
if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || !equal(record.intent, intent) || intent.operationType !== 'document.change' || intent.operationSchemaVersion !== 1) break;
|
||||
// resolved 只表示依赖已被本地解除,不在此区分 applied/elided/retired 的业务理由;preceding 不是远端执行证明。
|
||||
if (intent.dependencies.some(id => !selected.some(intent => intent.id === id) && !preceding.has(id) && ledger.operations.get(id)?.phase !== 'resolved')) break;
|
||||
let safe = true;
|
||||
for (const change of intent.changes) {
|
||||
@@ -78,7 +83,8 @@ export function findElision(intents: readonly OperationIntent[], acknowledged: D
|
||||
}
|
||||
|
||||
/** Keep a later closed segment mutable while earlier real edits obtain their own receipt. */
|
||||
/** 若后面存在可抵消段,限制前面的真实请求前缀,避免一次冻结把可抵消段也锁死。 */
|
||||
/** 若后面存在可抵消候选段,缩短前面的真实请求前缀,避免本次 freeze 锁住后段。
|
||||
* 这里只返回前缀长度,不标 ledger.elided、不证明前段已成功;原前缀可直接抵消时交其它钩子处理。 */
|
||||
export function elisionBatchLimit(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot): number {
|
||||
if (!intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null)) || findElision(intents, acknowledged, ledger)) return intents.length;
|
||||
const preceding = new Set<Id>();
|
||||
@@ -99,7 +105,8 @@ export function assertElisionLineage(proof: ElisionProof, intents: readonly Oper
|
||||
|
||||
/** Validate persisted local facts independently of the current (possibly newer) server baseline. */
|
||||
/** 恢复时按逻辑顺序复验持久抵消组:成员连续、依赖闭合、anchor 唯一、证明与水位一致。
|
||||
* 不依赖当前可能更新过的 ACK 基线来重新猜历史 absent 事实。 */
|
||||
* 不依赖当前可能更新过的 ACK 基线来重新猜历史 absent 事实,而是复核保存的 basePresence/proof。
|
||||
* 它不向服务器查询当年是否接收过请求;此前的持久记录捕获与身份校验仍由恢复边界负责。 */
|
||||
export function validateElisions(ledger: Pick<LedgerSnapshot, 'operations'>): void {
|
||||
const ordered = [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence), positions = new Map(ordered.map((record, index) => [record.intent.id, index]));
|
||||
const previous = new Map<Id, number>();
|
||||
@@ -128,7 +135,9 @@ export function validateElisions(ledger: Pick<LedgerSnapshot, 'operations'>): vo
|
||||
}
|
||||
|
||||
/** Local high-water marks belong to working state, never to the server ACK document. */
|
||||
/** 只给 working 候选补本地墓碑最大值,不修改源文档,也不向服务器 ACK 事实表灌入本地证明。 */
|
||||
/** 只为候选复制墓碑表并补本地最大值,其余文档表沿用;不改传入文档。
|
||||
* 调用方只能将结果当 working,不应覆写 acknowledged;这里不自动区分两种变量的用途。
|
||||
* 直接读取保存的 elision 字段,不在此复验证据;ledger 应先经可信生成或 validateElisions。 */
|
||||
export function withLocalLifecycles(document: Document, ledger: Pick<LedgerSnapshot, 'operations'>): Document {
|
||||
const tombstones = new Map(document.tombstones);
|
||||
for (const record of ledger.operations.values()) if (record.elision) for (const [id, incarnation] of Object.entries(record.elision.tombstones)) tombstones.set(id, Math.max(tombstones.get(id) ?? 0, incarnation));
|
||||
|
||||
@@ -15,17 +15,24 @@ export type HistorySourceEvent={readonly record:OperationRecord|undefined;readon
|
||||
/** 事件序列与可选 owning adoption 证据;count 标识虚拟事件前缀长度。 */
|
||||
export type HistoryEventSource={readonly events:readonly HistorySourceEvent[];readonly adoption?:{readonly count:number;readonly replay:HistoryReplay;readonly transition:HistoryTransition}};
|
||||
/** Resolve either a native interval or the owning adoption's proved virtual events, followed by later native events. */
|
||||
/** 找最早有文档效果的 pending 所属事件源;缺失/歧义/陈旧审计不能猜,直接拒绝。 */
|
||||
/** 从 pending 中有 changes 的最早 sequence 选择事件源;全为空则返回空列表,不读取其它证据。
|
||||
* 无 owning adoption 时取原生区间;有时先取其虚拟 replay.events,再接 revision 之后的原生事件。
|
||||
* 这里只解析形状/解释器/先后关系,不反向执行历史或核验事件文档效果;resolution 由消费规划器继续检查。
|
||||
* pending 应来自同一已验证账本;ID 顺序检查不等于逐字段比对传入 intent 与 ledger 对象。
|
||||
* 返回数组/envelope 未冻结,record/transition 沿用快照引用,调用方按不可变证据使用。 */
|
||||
export function historyEventSource(snapshot:EngineSnapshot,pending:readonly OperationIntent[]):HistoryEventSource{
|
||||
const content=pending.filter(intent=>intent.changes.length).sort((a,b)=>a.sequence-b.sequence),first=content[0];if(!first)return{events:[]};
|
||||
const records=snapshot.runtime.ledger.operations,audits=[...snapshot.runtime.recoveries.values()],owners=audits.filter(audit=>audit.decisions.some(decision=>decision.replacementId===first.id));
|
||||
if(owners.length>1)throw fault('recovery_history_adoption_ambiguous',[first.id]);
|
||||
/** 确认操作/尺寸效果解释器存在,未来数据保留不等于允许当前代码解释执行。 */
|
||||
/** 要求 operationSchemaVersion=1,存在尺寸效果时验其解释器形状;不在此执行尺寸计划或校验操作类别。
|
||||
* 未来数据可被上层保留,不表示此路径能据它恢复历史。 */
|
||||
const interpreter=(record:OperationRecord)=>{if(record.intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[record.intent.id]);if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect);};
|
||||
const pendingIds=new Set(content.map(intent=>intent.id));
|
||||
/** 校验事件不重复,且 pending 子序列与逻辑 sequence 精确一致,不能遗漏或交换。 */
|
||||
/** 带 record 的所有事件 ID 不重复,抽出的 pending 子序列须与按 sequence 排序的 content 完全对应。
|
||||
* adjustment 无 operation ID 不参与此计数;不要求所有账本 sequence 数值连续,也不检查服务执行顺序。 */
|
||||
const order=(events:readonly HistorySourceEvent[])=>{const seen=new Set<string>(),active:string[]=[];for(const event of events)if(event.record){const id=event.record.intent.id;if(seen.has(id))throw fault('recovery_history_event_order',[id]);seen.add(id);if(pendingIds.has(id))active.push(id);}if(active.length!==content.length||active.some((id,index)=>id!==content[index].id))throw fault('recovery_history_event_order');};
|
||||
/** 从操作提取已验证的原生历史 transition,缺证据不构造推测事件。 */
|
||||
/** 提取原生 transition 并验结构;未传领域 checkChanges,也不在这里重放其 before/after 元数据。
|
||||
* 缺失证据直接拒绝,完整可回放性由 createHistoryJournalRecovery 的反向条件执行验证。 */
|
||||
const native=(record:OperationRecord):HistorySourceEvent=>{interpreter(record);const transition=record.intent.historyTransition;if(!transition)throw fault('recovery_history_transition_missing',[record.intent.id]);validateHistoryTransition(transition);return{record,transition};};
|
||||
// 无 owning adoption 时用原生区间;区间内还有 adoption 就不能越过其边界盲目回放。
|
||||
if(!owners.length){
|
||||
@@ -33,7 +40,8 @@ export function historyEventSource(snapshot:EngineSnapshot,pending:readonly Oper
|
||||
const start=events[0]?.transition.rootRevision??Infinity;if(audits.some(audit=>(audit.historyTransition?.rootRevision??audit.baseRootRevision+1)>=start))throw fault('recovery_history_adoption_replay_required');
|
||||
for(let i=1;i<events.length;i++)if(events[i].transition.rootRevision<=events[i-1].transition.rootRevision)throw fault('recovery_history_event_order');order(events);return{events};
|
||||
}
|
||||
// 有 adoption 时要求其 replay/transition 齐全且没有更晚审计覆盖;先重建虚拟历史事实。
|
||||
// owning audit 必须有 replay/transition,replay 绑定 baseRootRevision+1,且不允许其它更晚审计遮盖。
|
||||
// 此处只组织虚拟事件,anchor 与整体 historyTransition 是否真的一致,留给日志规划器反向验证。
|
||||
const audit=owners[0],revision=audit.baseRootRevision+1,replay=audit.historyReplay;
|
||||
if(!replay||!audit.historyTransition)throw fault('recovery_history_adoption_evidence_missing',[audit.id]);validateHistoryReplay(replay,revision);validateHistoryTransition(audit.historyTransition);
|
||||
if(audits.some(other=>other.baseRootRevision+1>revision))throw fault('recovery_history_adoption_stale',[audit.id]);
|
||||
@@ -42,7 +50,8 @@ export function historyEventSource(snapshot:EngineSnapshot,pending:readonly Oper
|
||||
if(event.kind==='adjustment')return{record:undefined,transition:event.transition};const record=records.get(event.operationId);if(!record?.intent.changes.length)throw fault('history_replay_operation_missing',[event.operationId]);interpreter(record);witnessed.add(event.operationId);return{record,transition:event.transition};
|
||||
});
|
||||
for(const intent of content)if(!witnessed.has(intent.id)&&!(intent.historyTransition&&intent.historyTransition.rootRevision>revision))throw fault('recovery_history_transition_missing',[intent.id]);
|
||||
// 只接 adoption revision 之后的原生事件,再核对 revision 单调与 pending 完整顺序。
|
||||
// 后续原生事件按逻辑 sequence 排序,再要求 rootRevision 严格递增,不以网络回执顺序拼接。
|
||||
// 本层不按 phase/resolution 过滤;消费端仍须拒绝非 pending 但结果未明确的历史贡献。
|
||||
const later=[...records.values()].filter(record=>record.intent.changes.length&&record.intent.historyTransition&&record.intent.historyTransition.rootRevision>revision).sort((a,b)=>a.intent.sequence-b.intent.sequence).map(native);
|
||||
let previous=revision;for(const event of later){if(event.transition.rootRevision<=previous)throw fault('recovery_history_event_order');previous=event.transition.rootRevision;}
|
||||
order([...events,...later]);return{events:[...events,...later],adoption:{count:events.length,replay,transition:audit.historyTransition}};
|
||||
|
||||
@@ -15,7 +15,11 @@ import type { EngineSnapshot } from './contracts.ts';
|
||||
import { planCreationSize, validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
|
||||
|
||||
/** Native history events are reconstructed from evidence; all cursors remain private until the recovery root is accepted. */
|
||||
/** 创建一次恢复会话,要求有效 history 容量和完整事件证据;返回 project→accept→finish/evidence 协作接口。 */
|
||||
/** 一次同步恢复规划会话:从已验证 snapshot/pending 取事件,逆向条件回放历史元数据得到 baseline。
|
||||
* 不逆向执行文档 changes 或访问服务;document 的 fresh 重规划由 project/rebaseIntent 处理。
|
||||
* 调用方按事件顺序为每项 project→分配替代操作→accept,最后 finish→evidence,并同一个恢复 Root 采纳。
|
||||
* snapshot/registry 沿用不可变输入;方法会改变私有游标/history,失败不自动回滚或封闭会话,应丢弃本次规划对象。
|
||||
* limit 约束重建 user 贡献时的 undo 容量,不代表所有外部事件证据都可截断;无异步预算/网络/持久化。 */
|
||||
export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:readonly OperationIntent[],registry:Registry,limit:number){
|
||||
if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_history_limit');
|
||||
// historyEventSource 可能提供 adoption 虚拟事件;给每项补私有 before/after 以便核对后续取舍。
|
||||
@@ -31,15 +35,19 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
|
||||
const baseline=history,replayEvents:HistoryReplayEvent[]=[],adoptionRevision=snapshot.rootRevision+1;
|
||||
/** 记录私有 history 的一次真实变化;关联操作即使 no-op 也留证据,调整事件不造 operation。 */
|
||||
function recordStep(before:HistoryState,entryId:Id|undefined,operationId?:Id){const transition=describeHistoryTransition(before,history,adoptionRevision,entryId,!!operationId);if(transition)replayEvents.push(operationId?freeze({kind:'operation' as const,operationId,transition}):freeze({kind:'adjustment' as const,transition}));}
|
||||
// next 是严格事件游标;touched 区分本轮已审阅项,retired 解释被清redo/容量淘汰或无效果移除的项。
|
||||
// events 保存证据中的原始 before/after,history 是随取舍变化的新候选,不能混成同一份状态。
|
||||
// next 指下个事件;touched 标本轮审阅项,retired 解释清 redo/容量淘汰/无效果删除;remap/sizeHistory 仅服务当前 project→accept。
|
||||
let next=0;const touched=new Set<Id>(),retired=new Set<Id>();let remap:((change:Change)=>Change)|undefined;
|
||||
let sizeHistory: HistoryState | undefined;
|
||||
/** 捕获双栈数组和 entry 表成员为新只读 history,不暴露私有可变容器。 */
|
||||
const state=(undo:readonly Id[],redo:readonly Id[],entries:ReadonlyMap<Id,HistoryEntry>)=>Object.freeze({undo:freeze([...undo]),redo:freeze([...redo]),entries:readMap(entries)});
|
||||
/** 当前处理的 intent 必须恰好位于 next;不能按网络返回先后跳过或交换历史。 */
|
||||
/** 只按下个 record 的 intent.id 检查位置,不逐字段比对外来 intent;协调器须传同快照中受信意图。
|
||||
* 不能按网络返回顺序跳过/交换,adjustment 和已结算事件由 advance 先消费。 */
|
||||
const eventFor=(intent:OperationIntent)=>{const event=events[next];if(!event||event.record?.intent.id!==intent.id)throw fault('recovery_history_event_order',[intent.id]);return event;};
|
||||
/** 处理一个已有分组项的确认前缀:纯字段仅保留 fresh 仍含其最终效果的字段链。
|
||||
* 几何/path 要求 owner 未变;结构性前缀必须可完整逆向执行,否则拒绝猜测部分历史。 */
|
||||
/** 审阅已有分组项:纯字段按 target ID/代次/field 的最后 after 判断 fresh 是否还保留该贡献。
|
||||
* 最终值/存在性匹配才保留该字段的原整条链,不将每条都压成一次最后值;不匹配就移除此字段贡献。
|
||||
* geometry/path 的 owner 还须与原 ACK 一致;含 owner/端点或整记录的前缀必须可完整逆向规划。
|
||||
* 只返回 changes,不移动文档;不在这里恢复被远端覆盖的旧字段值。 */
|
||||
function reviewPrefix(entry:HistoryEntry,document:Document):readonly Change[]{
|
||||
const prefix=entry.changes;
|
||||
if(prefix.every(change=>change.kind==='field'&&!['ownerCanvasId','source','target'].includes(change.field))){
|
||||
@@ -50,14 +58,17 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
|
||||
}
|
||||
try{planHistoryChanges(document,invert(prefix),registry);}catch{throw fault('recovery_history_prefix_conflict',[entry.id]);}return prefix;
|
||||
}
|
||||
/** 丢弃一个未保留的 user 贡献时,仍审查同 entry 中已确认的旧前缀,不能连旧贡献一起删除。 */
|
||||
/** 无 replacement 的 user 贡献不追加新 changes;首次触及且还在 undo 的旧项才审阅原前缀。
|
||||
* 空前缀退休并从两栈移除,非空则保留;无 entryId(history=false)、已 touched 或不在 undo 时不重复处理。 */
|
||||
function skipUser(intent:OperationIntent,document:Document){
|
||||
const event=eventFor(intent),id=event.transition.entryId;if(!id)return;const entry=history.entries.get(id);
|
||||
if(!entry){retired.add(id);return;}
|
||||
if(touched.has(id)||!history.undo.includes(id))return;
|
||||
const changes=reviewPrefix(entry,document),entries=new Map(history.entries);if(changes.length)entries.set(id,freeze({...entry,changes}));else{entries.delete(id);retired.add(id);}history=state(history.undo.filter(key=>entries.has(key)),history.redo.filter(key=>entries.has(key)),entries);
|
||||
}
|
||||
/** 加入保留/已确认的 user 贡献,沿用原 entry 元数据并维护分组顺序、清redo和容量淘汰。 */
|
||||
/** 以原事件 after 的 label/group 等元数据加入贡献,append 必须与原有分组/栈顶关系相容。
|
||||
* 先清 redo 并标 retired;pending 首次触及旧项才审阅 fresh 前缀,随后追加本轮 changes/operationId。
|
||||
* 新项压 undo,超过 limit 从栈底淘汰;已退休原组可按证据重新建立,不借旧 ID 静默改写任意已有项。 */
|
||||
function addUser(intent:OperationIntent,changes:readonly Change[],operationId:Id,pending:boolean,document?:Document){
|
||||
const event=eventFor(intent),id=event.transition.entryId;if(!id)return;
|
||||
const original=event.after!.entries.get(id);if(!original)throw fault('recovery_history_transition_conflict',[id]);
|
||||
@@ -77,7 +88,9 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
|
||||
for(const patch of event.transition.entries){const current=entries.get(patch.id),original=before.entries.get(patch.id),desired=after.entries.get(patch.id);if(equal(current,desired))continue;if(!original||!equal(current,original))throw fault('recovery_history_adjustment_conflict',[patch.id]);if(desired)entries.set(patch.id,desired);else{entries.delete(patch.id);retired.add(patch.id);}}
|
||||
history=state(history.undo.filter(id=>entries.has(id)),history.redo.filter(id=>entries.has(id)),entries);
|
||||
}
|
||||
/** 先消费下一个待审阅意图之前的已确认/调整事件;碰到另一 pending 不能越过它自动处理。 */
|
||||
/** 消费目标 pending 之前(或 finish 时剩余)的 adjustment 与已有结果事件,逐个 recordStep/推进 next。
|
||||
* 带 record 者不得是另一个 pending,resolution 只接受 applied/elided;不在此独立校验 phase/服务回执真实性。
|
||||
* 默认尺寸、user、undo/redo 分别走专用逻辑,其它 origin 明确拒绝;不会重发这些已结算操作。 */
|
||||
function advance(intent?:OperationIntent){
|
||||
while(next<events.length&&(!intent||events[next].record?.intent.id!==intent.id)){
|
||||
const event=events[next],record=event.record,before=history;
|
||||
@@ -90,7 +103,9 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
|
||||
recordStep(before,event.transition.entryId,record?.intent.id);next++;
|
||||
}
|
||||
}
|
||||
/** 对应已验证前后 entry 的变化位置提取代次映射,供其它历史目标跟随显式 restore。 */
|
||||
/** 按原/新 entry changes 的对应索引提取同 ID 的 old incarnation→new incarnation Map。
|
||||
* 不是任意两组 changes 的通用差异算法,依赖证据保证对应位置语义一致;不按资源/正文字符串猜身份。
|
||||
* 返回纯 change 改写函数供其它历史目标跟随 restore,未映射代次保留原值。 */
|
||||
function incarnationRemap(before:HistoryEntry,after:HistoryEntry){
|
||||
const maps=new Map<Id,Map<number,number>>();
|
||||
/** 仅同 ID 且代次确实改变时登记,不能把两个不同元素意外映为同一生命周期。 */
|
||||
@@ -99,7 +114,8 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
|
||||
/** 仅重写 field target 与整元素 before/after 代次,不修改 canvas 记录或冻结服务请求。 */
|
||||
return(change:Change):Change=>{const record=<T extends {id:Id;incarnation:number}>(value:T|null)=>value?{...value,incarnation:maps.get(value.id)?.get(value.incarnation)??value.incarnation}:null;if(change.kind==='field')return freeze({...change,target:record(change.target)!});if(change.kind==='element')return freeze({...change,before:record(change.before),after:record(change.after)});return change;};
|
||||
}
|
||||
/** 已 applied 的 undo/redo 是事实:只有当前审阅前缀仍表示完全相同效果才允许移动栈。 */
|
||||
/** 已有 applied/elided 结果的遍历必须命中当前栈顶,且原事件前后变化与审阅后的正向效果精确对应。
|
||||
* 只重建历史移动/关联并为其它项重映代次,不执行已结算文档变化,也不改旧 operation/request。 */
|
||||
function addConfirmedTraversal(intent:OperationIntent){
|
||||
const event=eventFor(intent),id=event.transition.entryId,undo=[...history.undo],redo=[...history.redo],source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo;
|
||||
if(!id||source.at(-1)!==id)throw fault('recovery_history_traversal_order',id?[id]:[]);
|
||||
@@ -110,7 +126,8 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
|
||||
const entries=new Map(history.entries),map=incarnationRemap(before,after);for(const[otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(map)}));
|
||||
entries.set(id,freeze({...entry,changes,operationIds:[...entry.operationIds,intent.id]}));source.pop();destination.push(id);history=state(undo,redo,entries);
|
||||
}
|
||||
/** 已确认默认尺寸效果只修原 entry/后续条件,不移动双栈;当前历史必须仍匹配原前置证据。 */
|
||||
/** 已结算默认尺寸使用原 transition 的 entry changes 修正,要求该事件两栈不动、当前 changes/label/group 仍匹配。
|
||||
* 不再次运行尺寸文档命令;只给所属创建项追加逻辑操作关联,其它被修正项保留各自关联。 */
|
||||
function addConfirmedSize(intent: OperationIntent) {
|
||||
validateCreationSizeEffect(intent.historyEffect);
|
||||
const event = eventFor(intent), entries = new Map(history.entries);
|
||||
@@ -123,7 +140,10 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
|
||||
history = state(history.undo, history.redo, entries);
|
||||
}
|
||||
return{
|
||||
/** 为当前 intent 计算文档候选,不立即移动对应新历史;accept 在替代操作身份确定后接续。 */
|
||||
/** 先 advance 结算前置事件,再为当前 pending 返回 fresh 文档候选;自身对应的历史贡献等 accept。
|
||||
* 尺寸专用 planner 暂存 sizeHistory;touched 遍历按审阅后的 entry 重新规划,其它 keep 意图走 rebaseIntent。
|
||||
* 丢弃返回零文档变化但仍可能校验原遍历可行性;空 changes 直接返回,不消费事件游标。
|
||||
* 此调用可能已经改变前置私有 history,不能当无状态 query,也不能并发交错不同项的 project/accept。 */
|
||||
project(document:Document,intent:OperationIntent,context:IntentContext,keep:boolean){
|
||||
if(!intent.changes.length)return{document,changes:[] as readonly Change[]};advance(intent);const event=eventFor(intent),id=event.transition.entryId;remap=undefined;sizeHistory=undefined;
|
||||
// 尺寸效果通过专用 planner 重算 history;keep=false 不发布旧自动尺寸。
|
||||
@@ -150,7 +170,10 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
|
||||
// 普通保留意图经显式审阅后重新规划;丢弃返回零文档变化,不自行伪造成功回执。
|
||||
return keep?rebaseIntent(document,intent,context,registry):{document,changes:[] as readonly Change[]};
|
||||
},
|
||||
/** 接受 project 的取舍/替代操作,更新私有双栈并记 replay;每项完成后 next 单调前进。 */
|
||||
/** 在协调器确定 replacement 后,按当前 project 结果更新私有历史、记 replay 并将 next 加一。
|
||||
* replacement/keep/before/after 须与此前 project 配对;接口不独立比对该语义或自动生成新 ID/账本记录。
|
||||
* user 按贡献维护组,尺寸采用暂存计划,keep 遍历有剩余效果才移到另一栈,否则退休该项。
|
||||
* 不发布 Root、不持久化审计;调用失败后不要继续使用可能已部分推进的同一规划会话。 */
|
||||
accept(intent:OperationIntent,replacement:OperationIntent|undefined,keep:boolean,before:Document,after:Document){
|
||||
if(!intent.changes.length)return;const previous=history,event=eventFor(intent),id=event.transition.entryId;
|
||||
if (intent.historyEffect) {
|
||||
@@ -174,9 +197,12 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
|
||||
}
|
||||
recordStep(previous,id,replacement?.id);next++;
|
||||
},
|
||||
/** 消费剩余已确认事件后返回最终候选 history;未审阅 pending 仍会被 advance 拒绝。 */
|
||||
/** 消费剩余已结算/调整事件并返回候选 history;未处理 pending 会拒绝,不静默跳过。
|
||||
* 不设置 finished 锁或自动保存,协调器仍需整体校验并原子采纳文档/历史/账本。 */
|
||||
finish(){advance();return history;},
|
||||
/** 输出原 history→baseline 的 anchor 及有序增量,和本次 adoption 一起持久化以支持再次重开。 */
|
||||
/** 完成 finish 后作为最后步骤输出证据:原 history→baseline 的 anchor,加本次有序元数据增量。
|
||||
* 所有 transition 绑定 snapshot.rootRevision+1,不表示多次服务写入或已持久化成功。
|
||||
* freeze 直接包含 replayEvents 数组,会冻结该会话的事件缓冲;提前调用后不能再向它追加规划事件。 */
|
||||
evidence():HistoryReplay{return freeze({version:1 as const,anchor:describeHistoryTransition(snapshot.history,baseline,adoptionRevision,undefined,true)!,events:replayEvents});},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,13 +11,16 @@ export type HistoryReplayEvent={readonly kind:'operation';readonly operationId:I
|
||||
/** version=1 的 anchor+有序事件;所有 transition 绑定同一次 adoption Root revision。 */
|
||||
export type HistoryReplay={readonly version:1;readonly anchor:HistoryTransition;readonly events:readonly HistoryReplayEvent[]};
|
||||
/** Local evidence only. The anchor and event deltas explain one atomic adoption, not new service writes. */
|
||||
/** 检查证据版本/JSON、anchor无entry、事件 revision 一致和 operation ID 唯一;未知版本拒绝解释。 */
|
||||
/** 检查证据版本/JSON、anchor 无 entry、事件 revision 一致及 operation ID 唯一;未知版本拒绝解释。
|
||||
* revision 由调用方提供;不查询真实发布日志,checkChanges 缺省时不完整解释各条领域 Change。 */
|
||||
export function validateHistoryReplay(value:unknown,revision:number,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryReplay{
|
||||
if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const raw=value as Record<string,unknown>;
|
||||
if(raw.version!==1)throw fault('history_replay_interpreter_unavailable');if(!validJson(value))throw fault('invalid_history_replay');validateHistoryTransition(raw.anchor,checkChanges);if(raw.anchor.rootRevision!==revision||raw.anchor.entryId!==undefined||!Array.isArray(raw.events))throw fault('invalid_history_replay');const ids=new Set<Id>();
|
||||
for(const value of raw.events){if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const event=value as Record<string,unknown>;validateHistoryTransition(event.transition,checkChanges);if(event.transition.rootRevision!==revision)throw fault('invalid_history_replay');if(event.kind==='operation'){if(typeof event.operationId!=='string'||!event.operationId||ids.has(event.operationId))throw fault('invalid_history_replay');ids.add(event.operationId);}else if(event.kind!=='adjustment'||event.operationId!==undefined)throw fault('invalid_history_replay');}
|
||||
}
|
||||
/** 纯条件回放 anchor/events;可选校验逻辑 ID 存在,带 entry 的事件必须在该项关联同 operation。 */
|
||||
/** 条件回放历史元数据:先正向 anchor,再按 events 顺序更新两栈/entries,不执行文档 changes 或网络。
|
||||
* 用 anchor 自身 revision 调结构校验,不独自核对外部 adoption 的 Root;调用方另需绑定那次发布。
|
||||
* hasOperation 可选检查 ID 存在,带 entry 的操作事件需关联同 ID;不在此比较逻辑意图内容与 transition 是否语义等价。 */
|
||||
export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,hasOperation?:(id:Id)=>boolean):HistoryState{
|
||||
validateHistoryReplay(replay,replay.anchor.rootRevision);let history=applyHistoryTransition(before,replay.anchor,'forward');
|
||||
for(const event of replay.events){if(event.kind==='operation'&&hasOperation&&!hasOperation(event.operationId))throw fault('history_replay_operation_missing',[event.operationId]);history=applyHistoryTransition(history,event.transition,'forward');if(event.kind==='operation'&&event.transition.entryId&&!history.entries.get(event.transition.entryId)?.operationIds.includes(event.operationId))throw fault('history_replay_operation_mismatch',[event.operationId]);}
|
||||
@@ -25,5 +28,6 @@ export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,h
|
||||
}
|
||||
/** 比较两栈和全部 entry;entry 表按 ID 排序,避免 Map 插入顺序差异被误认作历史不一致。 */
|
||||
export function sameHistory(left:HistoryState,right:HistoryState):boolean{
|
||||
// 仅规范化 entries 表的顺序;undo/redo 数组顺序仍按栈语义精确比较,不能排序后当集合。
|
||||
const value=(history:HistoryState)=>({undo:history.undo,redo:history.redo,entries:[...history.entries].sort(([a],[b])=>a.localeCompare(b))});return equal(value(left),value(right));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import { createScopedLoader, type LoadPort, type ScopedLoader } from './loader.t
|
||||
import type { CanonicalReadUpdate } from './contracts.ts';
|
||||
|
||||
/** Host lifetime matches the signed-in application, independently of navigation. */
|
||||
/** 宿主注入 workspace/loader 工厂;current 闭包绑定精确账号与 epoch,作为异步结果准入围栏。 */
|
||||
/** 一个应用级宿主:workspaces/readers 按当前账号下的 workspaceId 复用,retired 单独保留待关闭实例。
|
||||
* registry 由注入方按不可变定义合同共享,本函数不冻结或复制它;也不保存 token/密码等认证凭证。
|
||||
* current 闭包绑定账号+authEpoch,拒绝旧身份结果;业务/存储实际副作用仍需 adapters 自行遵守该围栏。
|
||||
* 后台退休关闭可与新身份运行并行;Host 不为 closeRetired 建单飞锁,workspace.close 应支持重复/并发调用。 */
|
||||
export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; modelLoader?(accountId:Id,workspaceId:Id,authEpoch:number,current:()=>boolean):LoadPort<CanonicalReadUpdate>; onError?: (error: unknown) => void; onObserverError?: (error: unknown) => void }) {
|
||||
let accountId: Id | null = null, authEpoch = 0, disposed = false;
|
||||
// active 表只含当前身份,retired 持有尚未成功关闭的旧 runtime,不能因换账号就丢掉待保存内容。
|
||||
@@ -25,7 +28,8 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
|
||||
};
|
||||
/** 同步撤销当前身份:先换 epoch,再关闭读取句柄/移走 workspace;实际存储收尾另行等待。 */
|
||||
function retire() { authEpoch++; accountId = null; for (const reader of readers.values()) reader.close();readers.clear();for (const runtime of workspaces.values()) retired.add(runtime); workspaces.clear(); }
|
||||
/** 账号变化才退休旧作用域;后台关闭错误走 onError,报告失败不能改变新账号的身份事实。 */
|
||||
/** 相同账号复用 epoch,换账号先 retire 再同步启用新账号,不等待旧 workspace 关闭。
|
||||
* 旧关闭失败通过 onError 报告且保留 retired,后续身份退休/close 可重试;异常报告也被隔离。 */
|
||||
function activate(account: Id) {
|
||||
if (disposed) throw fault('host_disposed');
|
||||
if (accountId !== account) {
|
||||
@@ -42,7 +46,10 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
|
||||
identity: () => Object.freeze({ accountId, authEpoch }),
|
||||
/** 返回当前 workspace 表的安全只读快照,外部不能手动增删运行实例。 */
|
||||
getWorkspaces: () => readMap(workspaces),
|
||||
/** 通过共享 scoped loader 读 canonical 源;增量可产生只读预览,terminal 完整后才返回最终模型。 */
|
||||
/** 只读加载不创建 workspace engine/storage;每 workspace 复用 scoped loader、每订阅维护独立 preview/seen。
|
||||
* 导航代次属于订阅者,授权 epoch 才参与共享身份;已完成读取不作长期缓存(maxCacheEntries=0)。
|
||||
* ready 后核对身份与终结完整标志,再返回 ready 模型或其它终态;不是在 Host 再做全量 Document/schema 校验。
|
||||
* preview/observer 出错只停止此订阅预览并上报 onObserverError,不取消其它订阅或替换共享读取事实。 */
|
||||
async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:LiveModelPreview)=>void}={}) {
|
||||
activate(account);
|
||||
const readEpoch=authEpoch;
|
||||
@@ -60,7 +67,8 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
|
||||
};
|
||||
// key 纳入账号、workspace、格式、查询与 authEpoch;view 导航代次不替代授权代次。
|
||||
const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal,onUpdate});
|
||||
// ready 后再查身份和 terminal/complete;finally 始终释放本订阅,不能泄漏 reader 引用。
|
||||
// handle 成功取得后,ready 成功/拒绝都在 finally 释放订阅并 close preview;不关闭共享 reader/其它订阅。
|
||||
// 端口返回模型的领域合法性仍由 canonical adapter/预览解释器和后续 engine 准入负责。
|
||||
try{const snapshot=await handle.ready;if(disposed||accountId!==account||authEpoch!==readEpoch)throw fault('identity_changed');const last=snapshot.chunks.at(-1);if(!last||!last.complete||last.value.status==='receiving')throw fault('incomplete_load');return last.value.status==='ready'?Object.freeze({...last.value,sourceToken:last.snapshotToken}):last.value;}finally{handle.release();preview?.close();}
|
||||
},
|
||||
/** 按当前账号激活并懒创建 workspace;导航到同工作区不会重新创建资源池或项目。 */
|
||||
@@ -74,9 +82,11 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
|
||||
}
|
||||
return runtime;
|
||||
},
|
||||
/** 退出身份但保留 host 可再次绑定;返回旧 runtime 收尾 Promise,让宿主处理未保存错误。 */
|
||||
/** 同步清账号并递增 epoch,关闭读句柄,再等待退休 runtime;不永久 disposed,之后可重新绑定。
|
||||
* 不等待 close 成功也已使旧 current 失效;失败不会恢复旧身份或删除退休实例。 */
|
||||
retireIdentity() { retire(); return closeRetired(); },
|
||||
/** 永久关闭 host 并退休全部作用域;失败仍可再次调用 close 重试退休集合。 */
|
||||
/** 永久 disposed 后再退休/等待关闭,后续 workspace/bind/load 准入拒绝。
|
||||
* 失败可再次 close 重试;每次都会再次 retire/推进 epoch,返回值不是共享的单飞关闭 Promise。 */
|
||||
close() { disposed = true; retire(); return closeRetired(); },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,17 +15,25 @@ export type ImageDecoderPort={decode(bytes:Blob,signal?:AbortSignal):Promise<Ima
|
||||
/** 这里只验证正安全整数和乘积可安全表示,不等同于已应用产品的像素预算。 */
|
||||
export function validImageDimensions(value:unknown):value is ImageDimensions{const size=value as ImageDimensions;return !!size&&Number.isSafeInteger(size.width)&&size.width>0&&Number.isSafeInteger(size.height)&&size.height>0&&Number.isSafeInteger(size.width*size.height);}
|
||||
/** Bounded read-only compute. Cancellation rejects the caller; a slot remains occupied until execution settles. */
|
||||
/** 默认2并发/64等待,最多8并发;取消结果与执行容量分开,防止无视 abort 的任务挤爆池。 */
|
||||
/** 默认 2 并发/64 等待,最多 8 并发;waiting/active 各用按插入顺序的 Map,不是持久任务队列。
|
||||
* maxQueued 只限制尚未进入 active 的任务;active 包括已取消但执行器仍未退出的任务。
|
||||
* 池只检查 Blob 的 image/ MIME 前缀和尺寸数值,不限制文件字节/实际像素预算,也不缓存解码结果。 */
|
||||
export function createImageDecodePool(options:{execute:(bytes:Blob,signal:AbortSignal)=>Promise<ImageDimensions>;concurrency?:number;maxQueued?:number}):ImageDecoderPort{
|
||||
const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64;
|
||||
if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_image_decode_budget');
|
||||
/** 每个解码请求独立控制器/完成标记;cleanup 解除外部取消监听,迟到执行结果不再采纳。 */
|
||||
type Task={id:number;bytes:Blob;controller:AbortController;resolve:(size:ImageDimensions)=>void;reject:(error:unknown)=>void;done:boolean;cleanup():void};
|
||||
const waiting=new Map<number,Task>(),active=new Map<number,Task>();let next=0,closed=false;
|
||||
/** 幂等 settle 调用者,成功捕获冻结尺寸;不提前归还仍在执行的 active 槽。 */
|
||||
/** 幂等 settle 调用者并清理等待项;成功浅复制/冻结执行器结果,扩展属性不会被过滤。
|
||||
* error 用真值区分成败,执行器应以 Error 等真值拒绝;falsy rejection 不保证按失败解释。
|
||||
* 不删除 active,取消后的名额仍要等 execute 链的 finally 才归还。 */
|
||||
function finish(task:Task,error?:unknown,size?:ImageDimensions){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(freeze({...size!}));}
|
||||
/** FIFO 取任务并在微任务执行,校验返回尺寸,最后 finally 删除 active 再继续泵;异常不毒化后续队列。 */
|
||||
/** 取 waiting 首项立即占 active,再在微任务调用 execute;启动前复查内部 abort。
|
||||
* 成功尺寸先校验再 finish;正常错误交 finish,finally 负责释放 active 并继续 FIFO。
|
||||
* execute 若不响应取消且永不 settle,名额会持续占用;池没有超时或强制终止通用执行器的能力。 */
|
||||
function pump(){while(!closed&&active.size<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task);void Promise.resolve().then(()=>{if(task.controller.signal.aborted)throw fault('image_decode_aborted');return options.execute(task.bytes,task.controller.signal);}).then(size=>{if(!validImageDimensions(size))throw fault('invalid_image_dimensions');finish(task,undefined,size);}).catch(error=>finish(task,error)).finally(()=>{active.delete(task.id);pump();});}}
|
||||
/** decode 校验图片 MIME/容量并建立任务;close 取消所有调用结果,active 实际结束仍由 finally 收尾。 */
|
||||
/** decode 拒绝关闭/预先取消/非图片 Blob/等待容量已满,随后创建任务、绑定外部 abort 并入 waiting。
|
||||
* abort 转发给内部 controller,立即拒绝调用者并移除 waiting;运行中的 active 留待 finally。
|
||||
* close 幂等取消全部调用结果并禁止继续泵,不返回 drain Promise,也不证明所有 execute 已停止。 */
|
||||
return Object.freeze({decode(bytes:Blob,signal?:AbortSignal){if(closed)return Promise.reject(fault('image_decoder_closed'));if(signal?.aborted)return Promise.reject(fault('image_decode_aborted'));if(!(bytes instanceof Blob)||!bytes.type.startsWith('image/'))return Promise.reject(fault('invalid_image_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('image_decode_capacity'));return new Promise<ImageDimensions>((resolve,reject)=>{const abort=()=>{task.controller.abort();finish(task,fault('image_decode_aborted'));pump();},task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,done:false,cleanup:()=>signal?.removeEventListener('abort',abort)};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();});},close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('image_decode_aborted'));}}});
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
*/
|
||||
/** 仅声明本入口需要的 Worker 消息能力,不导入 renderer 或 DOM 状态。 */
|
||||
const worker=globalThis as unknown as{onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void};
|
||||
/** 接受正确版本/id/Blob,按 from-image 选项解码;失败回稳定 image_decode_failed。 */
|
||||
/** 仅接收 version=1、安全整数 id、Blob;非法请求直接忽略,不回错误消息。
|
||||
* 不在此限制 id 为正数、MIME、字节或像素预算;单任务 Worker 的创建/并发/取消由宿主控制。
|
||||
* 异步 handler 本身不排队,重复消息可并行解码;正常宿主每 Worker 只发一个请求。 */
|
||||
worker.onmessage=async event=>{
|
||||
const {version,id,bytes}=event.data??{};if(version!==1||!Number.isSafeInteger(id)||!(bytes instanceof Blob))return;
|
||||
// bitmap 是临时原生资源,尺寸读取即使抛错也由 finally close;成功只发送 width/height 和输入长度。
|
||||
try{const bitmap=await createImageBitmap(bytes,{imageOrientation:'from-image'});let width:number,height:number;try{width=bitmap.width;height=bitmap.height;}finally{bitmap.close();}worker.postMessage({version:1,id,byteLength:bytes.size,width,height});}
|
||||
// 解码/读取尺寸/close/成功回信失败均尝试发送稳定错误;若通信本身不可用,宿主仍需取消/错误事件收尾。
|
||||
catch{worker.postMessage({version:1,id,byteLength:bytes.size,error:'image_decode_failed'});}
|
||||
};
|
||||
// 标记为独立 TypeScript 模块,避免 Worker 全局声明与其它源码脚本命名空间合并。
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
*/
|
||||
/** Runner-owned feedback, not persisted job state. Probe bounded batches to
|
||||
* distinguish fixed publication cost from the marginal cost of another unit. */
|
||||
/** budget/max由runner给出,用实际已提交units和耗时更新下一批软上限。 */
|
||||
/** runner 提供已验证的正 budget/max,并在尝试后反馈 units/elapsed;本函数不自行读取时钟或发布内容。
|
||||
* 闭包只保留 limit 和 observed,重启 runner 会重新从 1 探测,不用持久化这些性能估计。
|
||||
* 参数/样本未在此验证,调用方需传有限且合理的耗时/单位;它不是容量或源完整性校验器。 */
|
||||
export function createImportBatches(budget:number,max:number){
|
||||
// 初始未观测时保守为1;observed只表示收到反馈,不是源校验或导入已完成。
|
||||
let limit=1,observed=false;
|
||||
@@ -13,7 +15,9 @@ export function createImportBatches(budget:number,max:number){
|
||||
get limit(){return limit;},
|
||||
/** 是否已有至少一次发布成本样本,用于runner的调度决策。 */
|
||||
get observed(){return observed;},
|
||||
/** 超时按比例减小并至少降一档;不足当前limit的尾批不能证明更大批也便宜。 */
|
||||
/** 首次反馈即 observed=true;elapsed > budget 按比例缩小,且尽量至少减 1,最低保留 1。
|
||||
* 未超时且 units ≥ limit 才按约 1.5 倍增长并封顶 max;等于 budget 也算此分支。
|
||||
* 不足当前 limit 的尾批不增长,避免将少量记录的固定发布成本误当成更大批仍便宜。 */
|
||||
record(units:number,elapsed:number){
|
||||
observed=true;
|
||||
if(elapsed>budget){limit=Math.max(1,Math.min(limit-1,Math.floor(units*budget/elapsed)));return;}
|
||||
|
||||
@@ -13,7 +13,10 @@ export type ImportScheduling = {
|
||||
|
||||
/** A soft time slice with a work threshold, even when the clock is coarse.
|
||||
* The caller bounds each indivisible parse/decode operation separately. */
|
||||
/** 默认6ms软目标;step 不一定产生 Promise,调用方须在返回等待值时真正 await/yield。 */
|
||||
/** 默认 6ms 软目标,闭包保存本时间片 started/work/unchecked 三个计数,不存任务或文档。
|
||||
* step 可能同步返回 undefined;只有返回 Promise 且调用方 await 时才形成有效背压,应串行使用同一预算。
|
||||
* 自定义 yieldTask 决定让出到微任务还是后续任务;预算不保证浏览器一定在该点完成一帧绘制。
|
||||
* 构造只校验 frameBudgetMs,不预先检查 signal;units 应由调用者提供非负有限工作量。 */
|
||||
export function createImportBudget(signal: AbortSignal, options: ImportScheduling = {}) {
|
||||
const budget = options.frameBudgetMs ?? 6;
|
||||
if (!Number.isFinite(budget) || budget <= 0) throw fault('invalid_import_budget');
|
||||
@@ -22,7 +25,10 @@ export function createImportBudget(signal: AbortSignal, options: ImportSchedulin
|
||||
// 每128工作单位才读一次时钟,减少高频开销;总量16384强制让出,不能因时间测不准而无限执行。
|
||||
let started = now(), work = 0, unchecked = 0;
|
||||
return {
|
||||
/** 记工作量并按阈值检查取消/时间;达到预算或时间异常时让出,回来再验取消并重置时间片。 */
|
||||
/** 累加 units;未满 128 个未检查单位且总量未满 16384 时直接返回,连 signal 也尚未检查。
|
||||
* 阈值到达才读时钟;未超时保留 work 累计,超时/时间倒退或非有限值/工作量封顶则 await yieldTask。
|
||||
* yield 后复查取消,成功才重设 started/work;读取时钟或 yield 抛错直接传播,不隐式重试。
|
||||
* units 无运行时校验,负数/NaN 会破坏计数假设;本函数不测量单次实际 CPU 成本或字节数。 */
|
||||
step(units = 1): Promise<void> | undefined {
|
||||
work += units;
|
||||
unchecked += units;
|
||||
|
||||
@@ -6,13 +6,17 @@ import {fault} from '../canvas-core/values.ts';
|
||||
|
||||
/** 会破坏整个源读取可信性的错误集合,供上层区分单记录失败与源级暂停/失败。 */
|
||||
const failures=new Set(['import_source_changed','invalid_import_byte_range','import_read_capacity','import_source_closed','import_source_read_failed','invalid_import_read_proof','import_read_proof_interpreter_unavailable']);
|
||||
/** 仅按稳定领域错误码分类,不依赖展示 message 或错误文本包含某个词。 */
|
||||
/** 只按 code 是否在固定集合分类,不校验错误对象的来源;其它码(含一般 import_aborted)不在集合内。
|
||||
* 取消是否应优先由读取调用方看 signal;本分类器不是所有源错误/取消的通用判定器。 */
|
||||
export const isImportByteFailure=(error:unknown)=>failures.has((error as {code?:string}|undefined)?.code??'');
|
||||
|
||||
/** Optional immutable-source port. Implementations return exactly the requested bytes. */
|
||||
/** 可选只读端口声明完整长度,必须返回所请求区间的精确字节,不能截断或换源。 */
|
||||
export type ImportByteReader={readonly byteLength:number;read(start:number,end:number,signal:AbortSignal):Promise<Uint8Array>};
|
||||
/** 校验安全整数范围,end 可截至EOF;读取前后查取消,外来 reader 结果额外复制以隔离缓冲复用。 */
|
||||
/** 校验安全整数范围,允许 end 超出 EOF 并截到 bytes.size;start 超出 EOF 则拒绝。
|
||||
* reader 长度须等于 Blob 长度,但本函数不逐字节比较 Blob/reader,也不证明同长度源未被替换。
|
||||
* 外来 Uint8Array 结果复制为自有缓冲,默认 Blob 路径返回此次新读缓冲;不缓存结果或做并发合并。
|
||||
* 空范围仍会调用 reader/Blob 读取,是否优化零 I/O 由端口决定;bytes 依赖调用方已验证的 Blob 合同。 */
|
||||
export async function readImportRange(bytes:Blob,reader:ImportByteReader|undefined,start:number,end:number,signal:AbortSignal):Promise<Uint8Array<ArrayBuffer>>{
|
||||
if(signal.aborted)throw fault('import_aborted');
|
||||
if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||end<start||start>bytes.size||reader&&reader.byteLength!==bytes.size)throw fault('invalid_import_byte_range');
|
||||
|
||||
@@ -22,8 +22,10 @@ const allowed=new Set(['documentFormatVersion','projectId','canvases','elements'
|
||||
/** JSON语法空白字节,不用于处理字符串内容。 */
|
||||
const whitespace=(byte:number)=>byte===9||byte===10||byte===13||byte===32;
|
||||
|
||||
/** Each scan owns its cursor. An aborted scan cannot advance the resumable source. */
|
||||
/** 每次扫描从指定位置建立私有cursor,完成边界后外层才更新可恢复源进度。 */
|
||||
/** Each scan owns a private byte cursor; shared progress records completed boundaries. */
|
||||
/** 从指定字节位置建立私有 cursor;仅外层 scanNext 将完整结构边界写回源级进度。
|
||||
* 取消不发布业务结果,但已定位的完整 ranges 可保留用于重试,不承诺整次 read 的缓存/边界都回滚。
|
||||
* options/缓存沿用内部引用;scanner 负责定位/单值解码,不验证完整文档引用关系。 */
|
||||
function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={start:-1,bytes:new Uint8Array()}){
|
||||
const {bytes}=options,budget=createImportBudget(signal,options);
|
||||
let cursor=start;
|
||||
@@ -41,7 +43,9 @@ function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={sta
|
||||
async function spaces(){while(whitespace(await peek()))await step();}
|
||||
/** 严格匹配一个结构字节,不修补缺逗号/冒号等源损坏。 */
|
||||
async function take(byte:number){await spaces();if(await peek()!==byte)throw fault('invalid_document_import');await step();}
|
||||
/** 引号/转义/栈配对扫描一个完整值range,限制嵌套深度,避免把字符串里的分隔符当边界。 */
|
||||
/** 用 quoted/escaped/显式 stack 定位一个值范围,最多 256 层;不收集 payload 字节。
|
||||
* 此步只找词法/括号边界,primitive 或字符串内容仍可能是非法 JSON,稍后 decode 才完整解析。
|
||||
* 超大值也要扫描完才得到范围,maxRecordBytes 仅约束后续解码分配,不限制扫描总工作量。 */
|
||||
async function value():Promise<Range>{
|
||||
await spaces();const start=cursor,stack:number[]=[];let quoted=false,escaped=false;
|
||||
while(true){
|
||||
@@ -57,7 +61,9 @@ function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={sta
|
||||
}
|
||||
if(cursor===start)throw fault('invalid_document_import');return{start,end:cursor};
|
||||
}
|
||||
/** 对单range校验大小、fatal UTF8、JSON语法和值域,返回原值,不修改业务ID。 */
|
||||
/** 读取单 range,按指定上限控制分配,再 fatal UTF-8/JSON.parse/validJson;不重写业务 ID。
|
||||
* 默认 payload 256KiB,header 调用可传独立上限;合法 JSON 不等同元素 schema 或引用关系合法。
|
||||
* JSON.parse 本身为同步工作,扫描预算不抢占这一步;字节端口异常保持源级错误,不转成坏记录。 */
|
||||
async function decode(range:Range,limit=options.maxRecordBytes??256*1024):Promise<any>{
|
||||
check();if(range.end-range.start>limit)throw fault('record_too_large');
|
||||
const data=await readImportRange(bytes,options.readBytes,range.start,range.end,signal);let text:string;
|
||||
@@ -75,7 +81,11 @@ function tombstones(value:unknown){
|
||||
/** Parser 5: canonical headers open before scanning the elements tail.
|
||||
* Out-of-order headers require the indexed interpreter to establish ownership first.
|
||||
* Ordinals remain stable; ranges are retained only as records are requested. */
|
||||
/** 打开standard header后即可返回源;真正elements/tail结构检查跟随read进行,未扫描尾部不冒称合法。 */
|
||||
/** 浅捕获 options 后打开标准头:源最多 64MiB,单个元数据值上限 4MiB,字段名上限 1KiB。
|
||||
* 先解析整份 canvases 并验证单根/父链,再返回 canvas-first 的 ordinal 接口;此时元素/尾部可尚未读取。
|
||||
* 头顺序不满足拥有树前提时委托索引解释器,仍暴露 parserVersion=5,不能据版本推断一定渐进扫描。
|
||||
* 指纹只是上层传入身份;源字节证明/完整 SHA 与最终元素/入口校验由读取端口和导入准备层负责。
|
||||
* options.signal 用于打开阶段,返回后的每次 read 使用自己的 readSignal,不自动合并原 signal。 */
|
||||
export async function openStreamingDocumentImportSource(input:Options):Promise<ImportSource>{
|
||||
const options:Options=Object.freeze({...input});
|
||||
const {bytes,signal}=options,max=options.maxRecordBytes??256*1024;
|
||||
@@ -120,7 +130,9 @@ export async function openStreamingDocumentImportSource(input:Options):Promise<I
|
||||
for(const id of path){rooted.add(id);await budget.step();}
|
||||
}
|
||||
const children=canvases.filter(canvas=>canvas.id!==rootId),ranges:Range[]=[];
|
||||
// 源级进度只记录完整range边界/phase,serial只串行化延伸;failure最多输出一条终结错误。
|
||||
// ranges 随请求积累元素字节范围,children 是已捕获子 canvas;ordinal 与原文件 byte offset 不同。
|
||||
// 重开源需重建这些内存范围,持久 runner 保存的是稳定 ordinal/源身份,不是这里的 Promise 链。
|
||||
// serial 只串行化范围延伸,failure 最多输出一条终结错误;请求很远的 ordinal 仍会扫描此前全部未定位项。
|
||||
let cursor=header.position(),phase:'elements'|'tail'|'done'='elements',first=true,failure:string|undefined,serial=Promise.resolve();
|
||||
/** 延伸一个element range,或验证剩余envelope与EOF;只在结构边界确认后更新cursor/phase。 */
|
||||
async function scanNext(readSignal:AbortSignal){
|
||||
@@ -146,7 +158,10 @@ export async function openStreamingDocumentImportSource(input:Options):Promise<I
|
||||
await scan.take(125);await scan.spaces();if(scan.position()!==bytes.size)throw fault('invalid_document_import');
|
||||
cursor=scan.position();phase='done';
|
||||
}
|
||||
/** ordinal read:子canvas直接返回,元素按需scan定位;已定位范围逐记录解码,后续恢复可重读。 */
|
||||
/** ordinal read:先 children,后 ranges 中元素;源根只作为 sourceRootId 别名,不作为导入子项输出。
|
||||
* 子 canvas 可直接返回,元素定位后按独立 range 重读/解码;一个 payload 错误不抹掉其它已定位项。
|
||||
* 结构错误记终结 failure;取消/已知字节证明错误只拒绝本次,允许同 ordinal 重试。
|
||||
* EOF 仅在有效末序号返回 null,越过末尾仍拒绝,不能把任意大序号当作已完成。 */
|
||||
async function read(position:number,readSignal:AbortSignal):Promise<ImportLine|null>{
|
||||
if(readSignal.aborted)throw fault('import_aborted');
|
||||
if(!Number.isSafeInteger(position)||position<0)throw fault('invalid_import_cursor');
|
||||
|
||||
@@ -11,7 +11,11 @@ import {fault,validJson} from '../canvas-core/values.ts';
|
||||
type Range={start:number;end:number};
|
||||
/** Index the envelope without decoding element payloads. Original bytes remain the source.
|
||||
* Preparation scans O(bytes) and retains O(records) ranges; this is not constant memory. */
|
||||
/** 在64MiB源上索引envelope/记录范围,验证头与树后返回可按ordinal重读的ImportSource。 */
|
||||
/** 在最多 64MiB 源上完整扫描 envelope/元素 ranges,验证元数据与父链后才返回 parser 4。
|
||||
* 字段名解码上限 1KiB、单元数据值 4MiB,元素默认 256KiB 且逐次 read 解码;坏 envelope 尾部会拒绝打开。
|
||||
* bytes/signal/max 为入口局部值,readBytes 和返回 fingerprint 仍取原 options,调用方须保持选项稳定。
|
||||
* 不生成完整源 SHA/读取证明或业务 ID;注入端口负责源字节一致性,runner 负责业务发布/恢复。
|
||||
* 完整扫描至少访问全部源字节;父链校验另有树遍历成本,不能将全部准备工作都算作 O(bytes)。 */
|
||||
export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:string;readBytes?:ImportByteReader;signal:AbortSignal;maxRecordBytes?:number}&ImportScheduling):Promise<ImportSource>{
|
||||
const {bytes,signal}=options,max=options.maxRecordBytes??256*1024;
|
||||
if(!(bytes instanceof Blob)||bytes.size>64*1024*1024||!options.fingerprint||!Number.isSafeInteger(max)||max<1)throw fault('invalid_import_source');
|
||||
@@ -35,7 +39,8 @@ export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:s
|
||||
}
|
||||
/** 对限定range执行fatal UTF8和JSON值域校验;key/元数据/记录可使用不同字节上限。 */
|
||||
async function decode(range:Range,limit=max,readSignal=signal){if(range.end-range.start>limit)throw fault('record_too_large');const data=await readImportRange(bytes,options.readBytes,range.start,range.end,readSignal);let text:string;try{text=new TextDecoder('utf-8',{fatal:true}).decode(data);}catch{throw fault('invalid_utf8');}try{const result=JSON.parse(text);if(!validJson(result))throw Error();return result;}catch{throw fault('invalid_json');}}
|
||||
// envelope字段名去重;elements仅保留ranges,其它字段按有限metadata大小解码。
|
||||
// fields 用 Map 去重/保存元数据,elements 数组保存 [start,end);扫描 payload 时不缓存其完整 JSON 对象。
|
||||
// 范围定位只检查括号/分隔,元素内部的 JSON/UTF-8 内容错误仍可能直到 read 才发现。
|
||||
const fields=new Map<string,unknown>(),elements:Range[]=[];
|
||||
await take(123);await spaces();if(await peek()===125)throw fault('invalid_document_import');
|
||||
while(true){const key=await decode(await value(),1024);if(typeof key!=='string'||fields.has(key))throw fault('invalid_document_import');await take(58);
|
||||
@@ -48,7 +53,8 @@ export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:s
|
||||
const canvases=fields.get('canvases') as {id:string;parent:null|{canvasId:string;entryElementId:string}}[],ids=new Set<string>();
|
||||
for(const canvas of canvases){if(!canvas||typeof canvas.id!=='string'||!canvas.id||ids.has(canvas.id)||canvas.parent!==null&&(!canvas.parent||typeof canvas.parent.canvasId!=='string'||typeof canvas.parent.entryElementId!=='string'))throw fault('invalid_document_import');ids.add(canvas.id);}
|
||||
const roots=canvases.filter(canvas=>canvas.parent===null);if(roots.length!==1)throw fault('invalid_document_import');const rootId=roots[0].id;
|
||||
// parent链必须存在且无环;入口实际节点/引用闭合仍由后续原生转换与文档校验完成。
|
||||
// 对每个 canvas 从头跟父链,用本路径 seen 检测环;不复用其它路径结果,深树可能反复遍历共同祖先。
|
||||
// 这里只证明 canvas 单根/父链连通,不核对入口节点是否存在、组件引用是否闭合或 projectId 是否等于根 ID。
|
||||
const byId=new Map(canvases.map(canvas=>[canvas.id,canvas]));
|
||||
for(const canvas of canvases){const seen=new Set<string>();let current=canvas;while(current.parent){if(seen.has(current.id))throw fault('invalid_document_import');seen.add(current.id);await budget.step();const parent=byId.get(current.parent.canvasId);if(!parent)throw fault('invalid_document_import');current=parent;}}
|
||||
const tombstones=fields.get('tombstones');if(tombstones!==undefined&&(!tombstones||typeof tombstones!=='object'||Array.isArray(tombstones)||Object.values(tombstones).some(n=>!Number.isSafeInteger(n)||(n as number)<1)))throw fault('invalid_document_import');
|
||||
@@ -57,7 +63,9 @@ export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:s
|
||||
return Object.freeze({fingerprint:options.fingerprint,parserVersion:4,async read(position:number,readSignal:AbortSignal):Promise<ImportLine|null>{
|
||||
if(readSignal.aborted)throw fault('import_aborted');if(!Number.isSafeInteger(position)||position<0||position>count)throw fault('invalid_import_cursor');if(position===count)return null;
|
||||
const base={cursor:position,nextCursor:position+1,key:`record:${position}`};let record:any,kind:string;
|
||||
// 单记录payload失败返回error/next ordinal,保留后续合法记录;源级字节失败和取消仍抛出。
|
||||
// children 返回已解析的元数据记录引用,元素则每次按 range 重读/新解析;消费者不应修改共享 children。
|
||||
// 已完成索引的读取无需再移动扫描 cursor;本次 readSignal 独立于打开阶段 signal。
|
||||
// 单记录 payload 失败返回 error/next ordinal,不影响其它项;源级字节错误/取消仍抛出,供同序号重试。
|
||||
try{if(position<children.length){kind='canvas';record=children[position];}else{kind='element';record=await decode(elements[position-children.length],max,readSignal);}if(readSignal.aborted)throw fault('import_aborted');if(!record||typeof record!=='object'||Array.isArray(record)||typeof record.id!=='string'||!record.id)throw fault('invalid_native_import_record');return{...base,value:{kind,key:record.id,record,sourceRootId:rootId}};}catch(error){if(readSignal.aborted)throw fault('import_aborted');if(isImportByteFailure(error))throw error;return{...base,error:(error as {code?:string}).code??'invalid_document_import'};}
|
||||
}});
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ type ExternalCanvas = {id: Id; parent: CanvasRecord['parent']; entryIncarnation?
|
||||
type CopyLine = {key: string; change: Exclude<Change,{kind:'field'}>; dependencies: string[]; externalElements: ExternalElement[]; externalCanvases: ExternalCanvas[]};
|
||||
|
||||
/** Internal source: already mapped records, with explicit guards for every retained external reference. */
|
||||
/** 兼容完整计划路径:逐change生成有限记录NDJSON,单条≤256KiB、全源≤64MiB;仍返回完整Blob。 */
|
||||
/** 完整计划兼容路径:先同步建立新 ID→源 key Map,再逐 change 编码 NDJSON,保留所有字符串直到生成 Blob。
|
||||
* 单条含换行的 UTF-8 字节须 ≤256KiB,全源 ≤64MiB;这些是编码成本限制,不是进程峰值内存保证。
|
||||
* 每条完成后按约每 KiB 一个工作单位调用 copy budget;单次映射建表/JSON.stringify 不被抢占。
|
||||
* 成功前复查取消,不输出部分 Blob;不验证整个 plan 的 ID 唯一性/图合法性或计算源摘要。 */
|
||||
export async function encodeDuplicateSource(document: Document, registry: Registry, plan: DuplicatePlan, signal: AbortSignal, scheduling:ImportScheduling={}): Promise<Blob> {
|
||||
const keys = new Map<Id,string>(),parts: BlobPart[] = [];
|
||||
for (const [id,mapped] of Object.entries(plan.elementIds)) keys.set(mapped,`element:${id}`);
|
||||
@@ -36,11 +39,15 @@ export async function encodeDuplicateSource(document: Document, registry: Regist
|
||||
if(signal.aborted)throw fault('copy_aborted');return new Blob(parts,{type:'application/x-ndjson'});
|
||||
}
|
||||
|
||||
/** 共享逐记录转换器:将新映射ID关联到source key,收集owner/端点/声明引用的必要外部闭包。 */
|
||||
/** 将一条已映射创建变化封装成 CopyLine:keys 区分副本内部依赖与仍引用原图的外部身份。
|
||||
* 收集 owner/端点/声明数据引用及外部 canvas 父链入口,去掉自身依赖;不重新映射字段或分配 ID。
|
||||
* 返回的 change 沿用输入引用,数组/guard 未整体冻结;输入文档/映射须来自已验证的不可变计划。
|
||||
* 这里只收集已知 copy.references,不独立调用 resolve 验组件版本,也不替代最终整图校验。 */
|
||||
export function duplicateImportLine(document:Document,registry:Registry,change:Exclude<Change,{kind:'field'}>,keys:ReadonlyMap<Id,string>):CopyLine {
|
||||
if(!change.after||change.before!==null)throw fault('invalid_duplicate_plan');
|
||||
const dependencies=new Set<string>(),elements=new Map<Id,ExternalElement>(),canvases=new Map<Id,ExternalCanvas>();
|
||||
/** 内部canvas转成依赖;外部canvas沿父链收集guard和入口节点身份,不猜其数据字段。 */
|
||||
/** keys 命中即登记内部依赖;否则沿原 canvas 父链收集 parent 与存在的入口代次。
|
||||
* canvases Map 同时去重/止循环,不在这里证明父链无环或每个入口都存在,依赖合法源文档合同。 */
|
||||
const canvas=(id:Id)=>{
|
||||
const key=keys.get(id);if(key){dependencies.add(key);return;}
|
||||
let next:Id|undefined=id;
|
||||
@@ -65,7 +72,9 @@ export function duplicateImportLine(document:Document,registry:Registry,change:E
|
||||
return {key,change,dependencies:[...dependencies],externalElements:[...elements.values()],externalCanvases:[...canvases.values()]};
|
||||
}
|
||||
|
||||
/** 校验旧durable预映射行与当前job.stableIds一致,元素新incarnation必须为1,不重新分配ID。 */
|
||||
/** 旧持久源的基础封装校验:允许源种类、JSON 值域、数组形状、创建 ID 与 stableIds/key 前缀对应。
|
||||
* element 还要求 incarnation=1;不逐字段验证 guard/dependency schema,也不证明外部 guard 集合完整。
|
||||
* 返回原行引用,不捕获/修复源,不重新分配 ID;完整源绑定、变化及图校验仍由 runner/发布边界承担。 */
|
||||
function copyLine(line:ImportLine,job:ImportJob):CopyLine {
|
||||
const value=line.value as unknown as CopyLine;
|
||||
if(!['duplicate-ndjson','duplicate-plan'].includes(job.source?.kind??'')||!value||!validJson(value)||typeof value.key!=='string'||!Array.isArray(value.dependencies)||!Array.isArray(value.externalElements)||!Array.isArray(value.externalCanvases)||!value.change||value.change.kind!=='element'&&value.change.kind!=='canvas'||value.change.before!==null||!value.change.after||!Object.hasOwn(job.stableIds,value.key)||job.stableIds[value.key]!==value.change.id||value.change.after.id!==value.change.id||!value.key.startsWith(`${value.change.kind}:`))throw fault('invalid_duplicate_source');
|
||||
@@ -73,7 +82,9 @@ function copyLine(line:ImportLine,job:ImportJob):CopyLine {
|
||||
return value;
|
||||
}
|
||||
/** Re-check the current reference contract when decoding old durable prepared sources. */
|
||||
/** 对当前复制schema重验引用:内部映射须声明依赖,外部引用须explicit preserve且有对应guard。 */
|
||||
/** 对 element 的当前 copy.references 重验:内部映射(自引用除外)须列依赖,外部须 preserve 且列 guard ID。
|
||||
* 每个引用线性扫描 stableIds 找对应命名空间;这里只查声明数据引用,不重建 owner/端点的完整 guard 闭包。
|
||||
* canvas 直接跳过;此步骤不是全部 CopyLine schema 校验或外部对象当前生命周期校验。 */
|
||||
function assertCopyReferences(value:CopyLine,job:ImportJob,registry:Registry){
|
||||
if(value.change.kind!=='element')return;
|
||||
const record=value.change.after!,schema=registry.components.get(record.componentId)?.copy;
|
||||
@@ -84,14 +95,18 @@ function assertCopyReferences(value:CopyLine,job:ImportJob,registry:Registry){
|
||||
else if(ref.external!=='preserve'||!(ref.target==='element'?value.externalElements:value.externalCanvases).some(guard=>guard.id===id))throw fault('copy_external_reference',[record.id,id]);
|
||||
}
|
||||
}
|
||||
/** 形成runner的纯ImportUnit并保留原行作为guard,reserve只核对已有映射,不修改原图。 */
|
||||
/** 先校验预映射行/声明引用,再用 reserve 核对自身 ID,要求依赖键都在 job.stableIds 中。
|
||||
* reserve 由调用方提供,本函数不回滚它的副作用;合法已准备源应命中现有映射而非再分配身份。
|
||||
* dependencies/change 沿用原行引用,guard 用 JSON 克隆捕获;不检查依赖已提交,也不发布/持久化内容。 */
|
||||
export function prepareDuplicateImport(line:ImportLine,job:ImportJob,reserve:(key:string)=>Id,registry:Registry):ImportUnit {
|
||||
const value=copyLine(line,job);assertCopyReferences(value,job,registry);if(reserve(value.key)!==value.change.id)throw fault('invalid_duplicate_source');
|
||||
for(const key of value.dependencies)if(!Object.hasOwn(job.stableIds,key))throw fault('invalid_duplicate_source');
|
||||
return{key:value.key,dependencies:value.dependencies,changes:[value.change],guard:clone(line.value!)};
|
||||
}
|
||||
/** Check at the synchronous commit boundary, never only when the source was first parsed. */
|
||||
/** 提交前按当前文档核对外部节点代次/owner、canvas parent/入口代次;不符即拒绝这一单元/组。 */
|
||||
/** 同步提交前重新解释行与声明引用,再逐个核对提供的外部 element 代次/owner、canvas parent/入口代次。
|
||||
* 内容/样式可变化而通过;组件版本等未在此额外比较,guard 是否完整依赖受信源生成与恢复校验。
|
||||
* 不锁住对象或发布 changes;应与真正提交处于同一同步边界,中间 await 会让该检查过期。 */
|
||||
export function assertDuplicateExternal(line:ImportLine,job:ImportJob,document:Document,registry:Registry){
|
||||
const value=copyLine(line,job);assertCopyReferences(value,job,registry);
|
||||
for(const guard of value.externalElements){const record=document.elements.get(guard.id);if(!record||record.incarnation!==guard.incarnation||record.ownerCanvasId!==guard.ownerCanvasId)throw fault('copy_external_changed',[guard.id]);}
|
||||
|
||||
@@ -15,12 +15,15 @@ export type PreviousImportFile={readonly fileId:Id;readonly byteLength:number;re
|
||||
export type ImportFile={readonly jobId:Id;readonly current:boolean;readonly file:PreviousImportFile};
|
||||
/** completed/partial/cancelled是本任务终态;paused/preparing不代表源已经不再需要。 */
|
||||
export const terminalImport=(status:ImportJob['status'])=>['completed','partial','cancelled'].includes(status);
|
||||
/** 检查退休协议与安全时间值;只是结构合法,不证明引用扫描已完整。 */
|
||||
/** 检查版本及非负安全整数时间,purgedAt 不得早于 unreferencedAt。
|
||||
* 不与当前时钟比较,也不证明保留期已满、引用已清空或删除已提交;这些由收集器/存储边界保证。 */
|
||||
export function validFileRetirement(value:unknown):value is FileRetirement {
|
||||
const marker=value as FileRetirement;
|
||||
return !!marker&&marker.version===1&&Number.isSafeInteger(marker.unreferencedAt)&&marker.unreferencedAt>=0&&(marker.purgedAt===undefined||Number.isSafeInteger(marker.purgedAt)&&marker.purgedAt>=marker.unreferencedAt);
|
||||
}
|
||||
/** 汇总全部当前/旧源,验证job键与ID一致且同fileId不跨job重复,供原子回收检查使用。 */
|
||||
/** 从有 source 的 job 汇总当前/previous 文件,检查 job 键与 ID 及全表 fileId 唯一性。
|
||||
* 重复包括同 job 的 current/previous 重复;无 source 的 job 不贡献文件。
|
||||
* 只派生索引,不完整校验文件元数据;返回实际可变 Map(类型只读),值中的 file 沿用输入引用。 */
|
||||
export function importFiles(jobs:ReadonlyMap<Id,ImportJob>|undefined):ReadonlyMap<Id,ImportFile> {
|
||||
const files=new Map<Id,ImportFile>();
|
||||
for(const [jobId,job]of jobs??[]){
|
||||
@@ -32,18 +35,22 @@ export function importFiles(jobs:ReadonlyMap<Id,ImportJob>|undefined):ReadonlyMa
|
||||
}
|
||||
return files;
|
||||
}
|
||||
/** 当前源仅终态可退休;旧源可独立退休,且同job文件ID互异、元数据和摘要合法。 */
|
||||
/** 当前源带任何退休标记时都要求终态;previous 文件可独立退休,逐项检查 ID/长度/可选摘要/标记。
|
||||
* 最后检查 current+previous ID 互异;当前源自身的 fileId/长度/摘要等仍由导入源完整校验负责。 */
|
||||
export function validateImportFileRetention(source:NativeImportSource,status:ImportJob['status']) {
|
||||
if(source.retirement!==undefined&&(!validFileRetirement(source.retirement)||!terminalImport(status)))throw fault('invalid_import_file_retirement');
|
||||
if(source.previousFiles!==undefined&&(!Array.isArray(source.previousFiles)||source.previousFiles.some(file=>!file||typeof file.fileId!=='string'||!file.fileId||!Number.isSafeInteger(file.byteLength)||file.byteLength<0||file.sha256!==undefined&&!/^[a-f0-9]{64}$/.test(file.sha256)||file.retirement!==undefined&&!validFileRetirement(file.retirement))))throw fault('invalid_import_file_retirement');
|
||||
const ids=[source.fileId,...(source.previousFiles??[]).map(file=>file.fileId)];if(new Set(ids).size!==ids.length)throw fault('import_file_identity_conflict');
|
||||
}
|
||||
/** 只改退休标记并保留文件身份;相同标记复用原对象,清标记不删除文件记录。 */
|
||||
/** 仅替换/移除 retirement;相等时沿用原对象,否则返回冻结记录。
|
||||
* 不验证新标记或任务终态,也不删除文件;调用方仍需经过发布/源结构校验。 */
|
||||
function mark<T extends PreviousImportFile>(file:T,retirement:FileRetirement|undefined):T {
|
||||
if(equal(file.retirement,retirement))return file;
|
||||
const {retirement:_,...rest}=file;return freeze(retirement?{...rest,retirement}:rest) as T;
|
||||
}
|
||||
/** 按fileId批量更新当前/旧源标记,必要时copy-on-write并推进job.generation;不直接删字节。 */
|
||||
/** 按 fileId 更新 current/previous 标记,首次修改才复制 imports,每个变化 job 的 generation 只加一次。
|
||||
* 未命中任何文件的 marker 忽略,重复同值无变化时复用 runtime;这里只改元数据,不校验退休资格或删字节。
|
||||
* 新 generation 溢出则拒绝,不能生成无法可靠恢复的任务版本。 */
|
||||
export function markImportFiles(runtime:RuntimeState,markers:ReadonlyMap<Id,FileRetirement|undefined>):RuntimeState {
|
||||
let imports=runtime.imports;
|
||||
for(const [id,job]of runtime.imports??[]){
|
||||
@@ -56,7 +63,10 @@ export function markImportFiles(runtime:RuntimeState,markers:ReadonlyMap<Id,File
|
||||
return imports===runtime.imports?runtime:Object.freeze({...runtime,imports:readMap(imports!)});
|
||||
}
|
||||
/** A source file cannot disappear from its owning job, become an asset or regain execution after purge. */
|
||||
/** 每次Root发布前校验源身份单调性并取消不安全软退休;已purge仍被pin明确拒绝。 */
|
||||
/** 发布前校验文件归属/长度/已有摘要单调性,并清掉重 pin 或覆盖不完整的软退休。
|
||||
* 已 purge 的 retirement 不得改写且不能再 pin;当前源若带标记回到非终态也会被结构校验拒绝。
|
||||
* reserved 只让此函数绕过快路,不逐 ID 检查占用;统一 reservation 屏障由外围资源保留钩子执行。
|
||||
* referencesChanged 默认 true;传 false 的调用方须确保引用变化已准确反映,不能仅凭 Map 相等猜完整性。 */
|
||||
export function prepareImportFileRetention(previous:RuntimeState,next:RuntimeState,pins:()=>{complete:boolean;pins:ReadonlyMap<Id,{has(owner:string):boolean}>},reserved:ReadonlySet<Id>,referencesChanged=true):RuntimeState {
|
||||
const hasSoft=[...(next.imports?.values()??[])].some(job=>job.source&&[job.source,...(job.source.previousFiles??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined));
|
||||
if(previous.imports===next.imports&&previous.resources===next.resources&&previous.resourcePins===next.resourcePins&&!reserved.size&&!hasSoft&&!referencesChanged)return next;
|
||||
@@ -69,8 +79,10 @@ export function prepareImportFileRetention(previous:RuntimeState,next:RuntimeSta
|
||||
if(!entry||entry.jobId!==old.jobId||entry.file.byteLength!==old.file.byteLength||old.file.sha256!==undefined&&entry.file.sha256!==old.file.sha256||!old.current&&entry.current)throw fault('import_file_identity_conflict',[id]);
|
||||
if(old.file.retirement?.purgedAt!==undefined&&!equal(old.file.retirement,entry.file.retirement))throw fault('import_source_retired',[id]);
|
||||
}
|
||||
// 无软标记、reservation 或 purge 记录时不调用 pins;身份校验已完成,不等于执行了完整引用扫描。
|
||||
if(!hasSoft&&!reserved.size&&![...after.values()].some(entry=>entry.file.retirement?.purgedAt!==undefined))return next;
|
||||
// 覆盖未知或重新引用都清软标记,下一次无引用须重新计保留期,不沿用旧时钟直接删除。
|
||||
// 新摘要可在旧值缺失时补齐,但已有摘要不能替换;清标记通过 markImportFiles 同步推进 job generation。
|
||||
const coverage=pins(),clear=new Map<Id,undefined>();
|
||||
for(const [id,entry]of after){
|
||||
if(entry.file.retirement?.purgedAt!==undefined){if(coverage.pins.has(id))throw fault('import_source_retired',[id]);continue;}
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
import type { ImportLocator } from './imports.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
|
||||
/** Ready SCCs are indivisible. Ordinary records retain source-order FIFO selection. */
|
||||
/** pending按源顺序输入;返回有序原子组,不实际提交,缺依赖的组继续等待而非直接丢失。 */
|
||||
/** Ready SCCs are indivisible; source order guides each readiness scan. */
|
||||
/** pending 由调用方按源顺序提供且 key 唯一;先找 SCC,再按组内/组间最早源索引扫描。
|
||||
* 缺外部依赖的组会跳过,可选择其后的已就绪组;同轮后面补齐依赖也不回头重扫,留给下一次调用。
|
||||
* 不修改 committed/pending,不读取源或实际提交;输出数组可变且 row 沿用输入引用。
|
||||
* limits 与 locator 成本须先校验;本函数不核对真实编码字节/changes,也没有中途取消或异步让出。 */
|
||||
export function selectImportGroups(pending: readonly ImportLocator[], committed: ReadonlySet<string>, limits: {units: number; bytes: number; changes: number}): readonly (readonly ImportLocator[])[] {
|
||||
// 正向边仅含当前pending内依赖,反向表用于第二次DFS;原index保留稳定源顺序。
|
||||
// byKey 用 key→原记录/源索引,edges 是依赖邻接表,reverse 是依赖→引用者表。
|
||||
// 建图只含 pending 内边;外部 key 暂不入图,稍后用 committed 判定是否满足。
|
||||
// 全段建图与排序同步完成,runner 应先限制 pending 大小,不能靠返回组数限制本次计算成本。
|
||||
const byKey = new Map(pending.map((row, index) => [row.key, {row, index}])), edges = new Map<string, string[]>(), reverse = new Map<string, string[]>();
|
||||
for (const row of pending) {
|
||||
const dependencies = row.dependencies.filter(key => byKey.has(key)); edges.set(row.key, dependencies);
|
||||
@@ -39,11 +44,13 @@ export function selectImportGroups(pending: readonly ImportLocator[], committed:
|
||||
for (const group of components) {
|
||||
const internal = new Set(group.map(row => row.key));
|
||||
if (!group.every(row => row.dependencies.every(key => available.has(key) || internal.has(key)))) continue;
|
||||
// SCC不可拆:单组字节/changes超安全上限必须报错,不能分批提交半个循环引用。
|
||||
// 只有依赖就绪的组才检查硬成本;超限直接抛错,即使前面已选了组也不会返回部分结果。
|
||||
// SCC 不可拆,依赖未就绪的超限组仍先等待,不能用拆组绕过循环引用完整性。
|
||||
const size = group.reduce((sum, row) => sum + row.byteLength, 0), count = group.reduce((sum, row) => sum + row.changeCount, 0);
|
||||
if (size > limits.bytes || count > limits.changes) throw fault('import_dependency_group_capacity');
|
||||
// The frame's adaptive unit budget is soft for one SCC, byte/change safety limits are hard.
|
||||
// 第一组可超过units软预算以闭合环;后续组和字节/changes总预算仍保守限制。
|
||||
// 第一组可超过 units 软预算以闭合环,字节/changes 总量始终不得超限。
|
||||
// 后续组放不下就继续找后面的较小可用组,不以 break 阻止其它独立组;available 只加入实际 chosen 的键。
|
||||
if (units && units + group.length > limits.units || bytes + size > limits.bytes || changes + count > limits.changes) continue;
|
||||
chosen.push(group); units += group.length; bytes += size; changes += count; for (const row of group) available.add(row.key);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,14 @@ import {fault} from '../canvas-core/values.ts';
|
||||
/** 源记录/依赖键到目标ID的只读字典,element/canvas前缀由调用者约定。 */
|
||||
type Ids=Readonly<Record<string,Id>>;
|
||||
/** One runner's last immutable ID index. Copies are deferred until allocation. */
|
||||
/** 返回带clear方法的捕获工厂,每次为一个源记录的准备提供reserve/has/finish私有上下文。 */
|
||||
/** 一个 runner 的捕获工厂,缓存最近一张可复用表及其 used Set;每次 capture 创建独立分配上下文。
|
||||
* 初始表的值域/空 ID/已有重复映射未在此完整校验,Set 只用于拦截后续 createId 与已知 ID 碰撞。
|
||||
* 不查项目文档、墓碑、其它 runner 或服务身份空间;稳定映射须由 job 校验与发布层共同保证。 */
|
||||
export function createImportIdentities(){
|
||||
let cached:{ids:Ids;used:Set<Id>}|undefined;
|
||||
/** 相同已冻结纯字符串属性表可复用used索引;其它输入先复制为无原型字典。 */
|
||||
/** 同引用命中缓存时直接复用;否则仅将冻结且描述符为字符串数据值的输入视为可复用。
|
||||
* 这是性能准入,不是完整 schema/普通对象证明;其余输入以 Object.assign 捕获到无原型字典,getter 会在此求值。
|
||||
* 首次分配前共享基表/used;写时再分离,避免每条只查既有键的记录重扫整张映射。 */
|
||||
const capture=(input:Ids,createId:()=>Id)=>{
|
||||
let identity=cached?.ids===input?cached:undefined;
|
||||
if(!identity){
|
||||
@@ -30,10 +34,12 @@ export function createImportIdentities(){
|
||||
},
|
||||
/** 判断目标ID是否已被本映射使用,不声称该ID已加入文档或已同步。 */
|
||||
has:(id:Id)=>used.has(id),
|
||||
/** 返回本次分配后的只读映射;没有新分配时保留输入身份便于下一记录复用索引。 */
|
||||
/** owned 缓冲通过 dataRecord 再捕获不可变值,后续 reserve 不改变已经返回的结果。
|
||||
* 可复用输入且无分配时沿用原表;可变/getter 输入即使未分配也返回捕获表,每次 finish 可重新捕获。
|
||||
* 返回不代表映射已落盘;runner 须与导入进度/文档同次发布和持久化。 */
|
||||
finish:():Ids=>owned?dataRecord(ids):ids,
|
||||
});
|
||||
};
|
||||
/** clear只丢弃临时复用索引,不清除已持久化job的stableIds。 */
|
||||
/** clear 只丢工厂最近索引;现有 capture 上下文仍能使用,不清已发布/持久 job.stableIds,也不回收 ID。 */
|
||||
return Object.freeze(Object.assign(capture,{clear(){cached=undefined;}}));
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
import {createSha256} from './sha256.ts';
|
||||
/** Complete-source SHA-256, with bounded chunk memory and cooperative fallback scheduling. */
|
||||
/** 默认64KiB分块(配置上限4MiB),每块读前/读后检查取消,并在块间合作让出。 */
|
||||
/** 顺序 slice 读取完整 Blob,默认 64KiB 分块(配置上限 4MiB),空文件也返回合法摘要。
|
||||
* 每块读前/读后及 yield 后检查取消;原生 arrayBuffer/yield 本身不被信号强制终止。
|
||||
* 默认块间 setTimeout(0) 让出,只有仍有下一块才 yield;自定义 yieldTask 决定实际调度公平性。
|
||||
* 块内 hash.update 为同步 CPU 工作,不会中途取消;读/让出错误直接传播,不返回部分摘要。 */
|
||||
export async function importSourceDigest(bytes:Blob,signal?:AbortSignal,options:{chunkBytes?:number;yieldTask?:()=>Promise<void>}={}):Promise<string>{
|
||||
const chunk=options.chunkBytes??64*1024,yieldTask=options.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
|
||||
if(!(bytes instanceof Blob)||!Number.isSafeInteger(chunk)||chunk<1||chunk>4*1024*1024)throw fault('invalid_digest_input');
|
||||
/** 读/计算/让出边界统一检查 signal;异步读取即使无法中止,迟到字节也不再参与有效结果。 */
|
||||
const check=()=>{if(signal?.aborted)throw fault('import_aborted');};check();const hash=createSha256();
|
||||
// 更新增量状态后释放对本块的局部引用;空文件直接 hex 得到标准空输入摘要。
|
||||
// 只顺序持有当前块,hash 自身不累计输入;part 在本次迭代/yield 期间仍可存在,实际释放时机由 GC 决定。
|
||||
// 空文件跳过读取/让出,最后 check 后直接 hex;有界分块不是整个应用进程的内存硬上限。
|
||||
for(let cursor=0;cursor<bytes.size;cursor+=chunk){check();const part=await bytes.slice(cursor,cursor+chunk).arrayBuffer();check();hash.update(new Uint8Array(part));if(cursor+chunk<bytes.size){await yieldTask();check();}}
|
||||
check();return hash.hex();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import type {ImportSource,ImportLine} from './imports.ts';
|
||||
import {fault,validJson} from '../canvas-core/values.ts';
|
||||
/** Seekable JSON array reader. Cursors are byte offsets after '[' or a record comma.
|
||||
* Structure errors end the source; previously published records remain explicit. */
|
||||
/** 捕获Blob/指纹与读取预算,缓存一块源字节;本解析器不分配业务ID、不发布history或发送请求。 */
|
||||
/** 捕获 Blob/max/chunk 与返回指纹标量,按字节 offset 每次返回一条记录,缓存一个读取块。
|
||||
* readBytes 与调度选项仍从原 options 读取,调用方应保持它们稳定;这里不建立源摘要/读取证明。
|
||||
* 默认单条 256KiB/块 16KiB,未给 maxRecordBytes 设置总上限,也不限制源文件总长。
|
||||
* 不分配业务 ID、不发布 history 或发送请求;缓存不是持久进度,合法边界由 runner 保存。 */
|
||||
export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:string;readBytes?:ImportByteReader;maxRecordBytes?:number;chunkBytes?:number}&ImportScheduling):ImportSource{
|
||||
const bytes=options.bytes,max=options.maxRecordBytes??256*1024,chunk=options.chunkBytes??16384;
|
||||
if(!(bytes instanceof Blob)||!options.fingerprint||!Number.isSafeInteger(max)||max<1||!Number.isSafeInteger(chunk)||chunk<1||chunk>max)throw fault('invalid_import_source');
|
||||
@@ -18,6 +21,7 @@ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:stri
|
||||
/** read每次返回一个ImportLine及nextCursor;EOF返回null,源级I/O失败仍抛出而不伪装坏记录。 */
|
||||
return Object.freeze({fingerprint:options.fingerprint,parserVersion:3,async read(start:number,signal:AbortSignal):Promise<ImportLine|null>{
|
||||
if(!Number.isSafeInteger(start)||start<0||start>bytes.size)throw fault('invalid_import_cursor');
|
||||
// EOF offset 直接返回 null,不重扫先前数组结构;任意范围内 seek 也不证明是合法记录起点。
|
||||
if(start>0&&start===bytes.size){if(signal.aborted)throw fault('import_aborted');return null;}
|
||||
if(bytes.size===0)throw fault('invalid_json_array');
|
||||
let cursor=start;const budget=createImportBudget(signal,options);
|
||||
@@ -32,7 +36,8 @@ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:stri
|
||||
await spaces();if(start===0){if(await peek()!==91)return failure('invalid_json_array');await advance();await spaces();}
|
||||
if(await peek()===93){await advance();await spaces();if(cursor!==bytes.size)return failure('invalid_json_array');if(start!==0){/* Empty tail is valid only after a record, not a trailing comma. */return failure('invalid_json_array');}return null;}
|
||||
if(await peek()===-1)return failure('incomplete_json_array');
|
||||
// quoted/escaped隔离字符串分隔符,stack配对{}与[];超大小后仍扫描至合法边界但不再保留字节。
|
||||
// parts 是至多 max 个字节数值的普通数组(内存成本不等于 max 字节),stack 至多保留 256 层括号。
|
||||
// quoted/escaped 只定位边界,不完整校验 JSON;超大小后仍扫描但不再收集,坏结构仍可能终结整个源。
|
||||
const recordStart=cursor,parts:number[]=[],stack:number[]=[];let quoted=false,escaped=false,oversized=false;
|
||||
while(true){const byte=await peek();if(byte===-1)return failure('incomplete_json_array');
|
||||
if(!quoted&&stack.length===0&&(byte===44||byte===93))break;
|
||||
@@ -41,13 +46,15 @@ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:stri
|
||||
else if(byte===34)quoted=true;else if(byte===123||byte===91){stack.push(byte);if(stack.length>256)return failure('json_nesting_limit');}else if(byte===125||byte===93){if(stack.pop()!==(byte===125?123:91))return failure('invalid_json_array');}
|
||||
await advance();
|
||||
}
|
||||
// 消费外层逗号或]并检查尾部/尾逗号;内嵌数组的]已由stack处理,不会截断记录。
|
||||
// 先确认记录后的外层分隔与尾部,再返回此记录;若尾逗号/垃圾在此处发现,本记录也返回结构失败。
|
||||
// 数组内嵌 ] 已由 stack 处理;nextCursor 消费分隔和后续空白,最后一项则推进到真实 EOF。
|
||||
const delimiter=await peek();await advance();
|
||||
if(delimiter===93){await spaces();if(cursor!==bytes.size)return failure('invalid_json_array');}
|
||||
else {await spaces();if(await peek()===93||await peek()===-1)return failure('invalid_json_array');}
|
||||
const base={cursor:start,nextCursor:cursor,key:`byte:${start}`};
|
||||
if(oversized)return{...base,error:'record_too_large'};
|
||||
// 只解码这一记录:fatal UTF8防替换字符掩盖损坏,再JSON.parse/validJson校验值域。
|
||||
// fatal UTF-8 与 JSON.parse 在边界已确定后处理,失败可随 nextCursor 继续下一项;不做元素业务 schema 校验。
|
||||
// JSON.parse 同步完成且无中途取消,记录扫描的预算不抢占解码/解析;调用方仍需在采纳结果前校验生命周期。
|
||||
let text:string;try{text=new TextDecoder('utf-8',{fatal:true}).decode(new Uint8Array(parts));}catch{return{...base,error:'invalid_utf8'};}
|
||||
try{const value=JSON.parse(text);return validJson(value)?{...base,value}:{...base,error:'invalid_json_value'};}catch{return{...base,error:'invalid_json'};}
|
||||
}});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 原生导入记录到纯ImportUnit的转换:源ID经reserve映射为新身份,依赖保留为source key等待闭合。
|
||||
* $target或文档源根映射到显式选定的现有canvas;副本incarnation从1开始,不继承旧墓碑/任务执行。
|
||||
* $target或文档源根映射到显式选定的现有canvas;副本incarnation从1开始,不继承旧墓碑。
|
||||
* 任务字段只按组件copy.omit声明去除;转换不创建/启动生成任务,不能据此假定所有未知执行字段都已过滤。
|
||||
* 本模块只规划changes,不写文档、不上传/授权;资源地址和授权事实由宿主/engine另行提供与校验。
|
||||
*/
|
||||
import type { Document, ElementRecord, Id, Json, Registry } from '../canvas-core/types.ts';
|
||||
@@ -9,14 +10,18 @@ import { remapCopyData } from '../canvas-core/duplicate.ts';
|
||||
import { clone, fault } from '../canvas-core/values.ts';
|
||||
import type { ImportJob, ImportLine, ImportUnit } from './imports.ts';
|
||||
|
||||
/** 校验目标canvas仍存在且入口ID/代次或根身份未变,防旧任务把尾批导入后来替换的分区。 */
|
||||
/** 按 source.targetCanvasId 定位当前 canvas;有 targetEntry 时核对入口 ID 与元素代次,否则要求根。
|
||||
* 不比较整个祖先链/父 canvas、入口组件或 job.projectId;同入口移动后的目标仍可通过此局部身份检查。
|
||||
* 返回原 source 引用,不生成新任务或锁住目标,后续提交前还需再检查当前文档。 */
|
||||
export function assertNativeImportTarget(job:ImportJob,document:Document){
|
||||
const source=job.source,canvas=source?document.canvases.get(source.targetCanvasId):undefined;
|
||||
if(!source||!canvas)throw fault('import_target_missing');
|
||||
if(source.targetEntry?(canvas.parent?.entryElementId!==source.targetEntry.id||document.elements.get(source.targetEntry.id)?.incarnation!==source.targetEntry.incarnation):canvas.parent!==null)throw fault('import_target_changed');
|
||||
return source;
|
||||
}
|
||||
/** 已committed依赖必须仍是原绑定生命周期;用户删后重建同ID不能被后续导入误认领。 */
|
||||
/** 仅检查已在 committedSourceKeys 的依赖:stableIds 定位目标,再以 job.bindings 核对生命周期。
|
||||
* element 检查代次,canvas 检查仍存在、入口 ID 及已记录入口代次;不比较正文、几何、组件或全部 owner 链。
|
||||
* 未提交/SCC 内部依赖不在这里猜成存在,留给分组选取与原子创建后的文档校验;本函数不改变绑定。 */
|
||||
export function assertNativeImportDependencies(job:ImportJob,document:Document,dependencies:readonly string[]){
|
||||
for(const key of dependencies)if(job.committedSourceKeys.includes(key)){
|
||||
const id=job.stableIds[key],binding=job.bindings?.[id];
|
||||
@@ -27,7 +32,11 @@ export function assertNativeImportDependencies(job:ImportJob,document:Document,d
|
||||
}
|
||||
|
||||
/** Native NDJSON records use source IDs; $target names the explicitly chosen existing canvas. */
|
||||
/** 校验记录封装/源ID,预留自身与引用ID,生成一条创建变化及去除自引用后的依赖列表。 */
|
||||
/** 校验封装/源 ID 后克隆 record,reserve 自身与引用键,生成一条 before=null 的创建变化和依赖列表。
|
||||
* reserve 可能先于后续类型校验调用;本函数不回滚自定义 reserve 的副作用,runner 应使用私有捕获上下文。
|
||||
* registry.resolve 只确认已知类型/版本与 copy 声明,不等同完整数据/几何/引用校验;最终提交仍需验证文档。
|
||||
* 原几何/其它字段按记录携带,不在此统一转换世界坐标,也不检查所有目标 ID 的文档/墓碑冲突。
|
||||
* 返回的 ImportUnit/changes envelope 未整体冻结,交 runner 捕获、计成本、持久化和发布。 */
|
||||
export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:string)=>Id,document:Document,registry:Registry,resourceURL?:(id:Id)=>string):ImportUnit {
|
||||
const source=assertNativeImportTarget(job,document);
|
||||
const raw=line.value as Record<string,Json>|undefined;
|
||||
@@ -39,7 +48,8 @@ export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:s
|
||||
/** 根/$target直接重定向目标,子canvas则分配稳定新ID并登记canvas依赖。 */
|
||||
const owner=(id:Json):Id=>{if(id==='$target'||source.kind==='native-document'&&id===raw.sourceRootId){canvasIds.set(id as string,source.targetCanvasId);return source.targetCanvasId;}if(typeof id!=='string'||!id)throw fault('invalid_import_reference');const key=`canvas:${id}`;dependencies.add(key);const mapped=reserve(key);canvasIds.set(id,mapped);return mapped;};
|
||||
const key=`${raw.kind}:${raw.key}`,id=reserve(key);
|
||||
// 不把源根作为第二个根插入;这里只处理有parent的子canvas,parent与入口都使用新映射。
|
||||
// 只接受带 parent 的子 canvas;源根由读取器排除,再通过 sourceRootId/$target alias 接到现有目标。
|
||||
// parent/record 其它属性沿用克隆数据,额外 schema/树约束不会在此分支自动补齐。
|
||||
if(raw.kind==='canvas'){
|
||||
const parent=record.parent as Record<string,Json>|null;
|
||||
if(!parent||typeof parent!=='object'||Array.isArray(parent))throw fault('invalid_import_canvas');
|
||||
@@ -49,7 +59,8 @@ export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:s
|
||||
// record是脱离源的副本,original是其类型视图;未知组件/缺复制声明不猜字段含义。
|
||||
const original=record as unknown as ElementRecord,definition=registry.components.get(original.componentId);
|
||||
if(registry.resolve(original)!=='known'||!definition?.copy||!original.data||typeof original.data!=='object'||Array.isArray(original.data))throw fault('import_component_unavailable');
|
||||
// 文档图片需宿主提供规范资源URL;仅重建引用不代表完成授权,授权由运行时后续门禁证明。
|
||||
// native-document 的非空资源字段要求宿主 resourceURL:值须为非空 ID,assetId 还重建 data.src。
|
||||
// callback 只是地址提供者,此处不读取字节/校验权限;其它资源字段不自动改名,NDJSON 也不走此文档专用分支。
|
||||
if(source.kind==='native-document')for(const key of definition.resourceFields??[]){const value=original.data[key];if(value===undefined||value===null)continue;if(!resourceURL)throw fault('import_resource_authorization_required');if(typeof value!=='string'||!value)throw fault('invalid_import_reference');if(key==='assetId')record.data={...original.data,src:resourceURL(value)};}
|
||||
record.data=original.data;
|
||||
// 只遍历schema声明引用,避免重写普通文字里的ID;omit字段由共享remapCopyData处理。
|
||||
@@ -60,5 +71,7 @@ export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:s
|
||||
if(!original.source||!original.target)throw fault('invalid_import_endpoint');
|
||||
Object.assign(after,{source:{...original.source,elementId:element(original.source.elementId)},target:{...original.target,elementId:element(original.target.elementId)}});
|
||||
}else if(original.structure!=='node')throw fault('invalid_import_structure');
|
||||
// Set 保留引用首次遇到的顺序并去重;删除自身依赖避免单节点自引用被误当外部等待。
|
||||
// 尚未出现的定义仍有稳定 reserve ID/依赖键,缺失定义由 runner 后续报告,不直接连回源项目。
|
||||
dependencies.delete(key);return{key,dependencies:[...dependencies],changes:[{kind:'element',id,before:null,after:after as ElementRecord}]};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ const MAX_SOURCE_BYTES=64*1024*1024;
|
||||
/** version=1是块证明DTO版本,与source.integrityVersion=2的业务封存协议不是同一版本域。 */
|
||||
export type ImportReadProof={readonly version:1;readonly fileId:string;readonly byteLength:number;readonly blockBytes:16384;readonly blocks:readonly {readonly index:number;readonly sha256:string}[]};
|
||||
/** This binds only read blocks. It is not a whole-source digest or permission to send mutations. */
|
||||
/** 校验fileId/长度/固定块大小、严格递增的合法块索引及摘要;可表示只读过部分非连续块。 */
|
||||
/** 校验源身份/长度、固定块宽、严格递增的合法块索引、摘要格式及允许字段,再捕获冻结 DTO。
|
||||
* 可包含不连续的部分块,不要求完整前缀;这里不读 Blob 或验证摘要真实性,也不是整源 SHA。
|
||||
* 缺记录/非法 JSON/未知版本报解释器不可用,其它身份/结构错误报 invalid_import_read_proof。 */
|
||||
export function validateImportReadProof(value:unknown,fileId:string,byteLength:number):ImportReadProof{
|
||||
if(typeof fileId!=='string'||!fileId||!Number.isSafeInteger(byteLength)||byteLength<0||byteLength>MAX_SOURCE_BYTES)throw fault('invalid_import_read_proof');
|
||||
const proof=value as ImportReadProof;
|
||||
@@ -29,7 +31,10 @@ export function validateImportReadProof(value:unknown,fileId:string,byteLength:n
|
||||
|
||||
/** Bounded, seekable verified reads for parsers; checkpoints contain hashes, never byte buffers.
|
||||
* Restored evidence must be checked with verify() before a runner resumes business changes. */
|
||||
/** 创建可seek的验证reader;初始证明与源绑定,port.read成功后才把新块追加到本地证明。 */
|
||||
/** 建立 seek reader:hashes 保存期望摘要,resident 是按 Map 顺序淘汰的 LRU,pending 合并同块在途读取。
|
||||
* 初始 proof 仅结构校验即进入 hashes/getProof;没有 verified 标志,调用方须在恢复业务前主动 verify。
|
||||
* 最多 4 个活跃非空 range、4 个不同块的原生读取;驻留默认 4 块(最多 64),单 range 默认/最多 4MiB。
|
||||
* range 输出、在途块和 Blob 本身另占内存,residentLimit 不是整体内存硬上限;本实例不持久化证明。 */
|
||||
export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:ImportReadProof;maxResidentBlocks?:number;maxReadBytes?:number}&ImportScheduling){
|
||||
const options=Object.freeze({...input});
|
||||
const {bytes,fileId}=options,residentLimit=options.maxResidentBlocks??4,readLimit=options.maxReadBytes??4*1024*1024;
|
||||
@@ -40,13 +45,17 @@ export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:Imp
|
||||
type Block={index:number;sha256:string;data:Uint8Array};
|
||||
const resident=new Map<number,Block>(),pending=new Map<number,Promise<Block>>(),lifecycle=new AbortController();
|
||||
let closed=false,activeReads=0,proof:ImportReadProof=initial;
|
||||
/** 每次等待前后检查生命周期/取消;close是源关闭,调用者abort是停止此订阅采纳。 */
|
||||
/** 入口/成功采纳时检查 closed 与 signal,关闭优先报 import_source_closed,其次报 import_aborted。
|
||||
* 单个订阅取消只停止等待,不终止共享原生块读取;已拒绝的底层 Promise 仍可能保留自己的原始错误。 */
|
||||
const check=(signal:AbortSignal)=>{if(closed)throw fault('import_source_closed');if(signal.aborted)throw fault('import_aborted');};
|
||||
/** 单调用可取消等待共享块Promise,不强制取消可能正被其它range复用的只读底层读取。 */
|
||||
function wait<T>(task:Promise<T>,signal:AbortSignal):Promise<T>{
|
||||
check(signal);return new Promise((resolve,reject)=>{const abort=()=>{try{check(signal);}catch(error){reject(error);}};signal.addEventListener('abort',abort,{once:true});task.then(value=>{signal.removeEventListener('abort',abort);try{check(signal);resolve(value);}catch(error){reject(error);}},error=>{signal.removeEventListener('abort',abort);reject(error);});});
|
||||
}
|
||||
/** 先命中LRU或共享pending,再读块并对旧摘要;新原图与已证明块不符即源改变。 */
|
||||
/** 命中 resident 时移动到 Map 尾部作为最近使用,命中 pending 时复用同一 Promise。
|
||||
* 缺块就读完整对齐 16KiB(最后块截 EOF)并计算 SHA,可能覆盖请求 range 之外的相邻字节。
|
||||
* 比对 hashes 中旧期望后放 resident,但不在此新增持久候选 proof;range 失败也可留下可复用缓存。
|
||||
* 缓存命中不重新读 Blob/算摘要,依赖 Blob 不可变合同;淘汰只丢字节,不丢已提交的期望摘要。 */
|
||||
function block(index:number):Promise<Block>{
|
||||
const cached=resident.get(index);if(cached){resident.delete(index);resident.set(index,cached);return Promise.resolve(cached);}
|
||||
const active=pending.get(index);if(active)return active;
|
||||
@@ -61,7 +70,10 @@ export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:Imp
|
||||
})();
|
||||
pending.set(index,task);void task.finally(()=>{if(pending.get(index)===task)pending.delete(index);}).catch(()=>{});return task;
|
||||
}
|
||||
/** 精确range端口:只分配所需输出,逐块拼接并按软预算让出,最后成功才提交used块证明。 */
|
||||
/** 精确 range 端口:要求 end ≤ EOF,不自行截断;空范围在容量检查前返回,不读块/新增证明。
|
||||
* 非空范围分配独立输出并逐块拼接,每块向预算记 128 单位,最后检查取消与并发摘要一致性。
|
||||
* 只有整次范围成功才将 used 中新块加入 hashes/proof;取消/失败不提交本次部分块,也不回退其它成功读取的证明。
|
||||
* activeReads 随等待退出而归还,真实不可取消 I/O 仍占 pending 容量,不能用大量取消绕过底层上限。 */
|
||||
const port:ImportByteReader=Object.freeze({byteLength:bytes.size,async read(start:number,end:number,signal:AbortSignal){
|
||||
const combined=AbortSignal.any([signal,lifecycle.signal]);check(combined);
|
||||
if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||end<start||end>bytes.size||end-start>readLimit)throw fault('invalid_import_byte_range');
|
||||
@@ -81,9 +93,12 @@ export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:Imp
|
||||
}finally{activeReads--;}
|
||||
}});
|
||||
return Object.freeze({port,getProof:()=>proof,
|
||||
/** 重开后验证所有已保存块;不扫描尚未读到的尾部,故不能代替完整SHA或已durable封存。 */
|
||||
/** 对调用开始时 hashes 中的索引逐块核对,不增加新证明块、不产生完整源摘要或自动持久化。
|
||||
* 重开实例缓存为空时实际重读旧块;同实例调用可命中 resident,不强制绕过缓存重新读物理字节。
|
||||
* 不核对尚未证明的尾部,也不锁住并发 range;是否已安全恢复、何时封存由引擎决定。 */
|
||||
async verify(signal:AbortSignal){const combined=AbortSignal.any([signal,lifecycle.signal]),budget=createImportBudget(combined,options);check(combined);for(const index of [...hashes.keys()]){await wait(block(index),combined);await budget.step(128);}check(combined);},
|
||||
/** 幂等关闭,清驻留字节并取消等待者;已开始的只读Blob读取可能迟到,由closed检查拒绝采纳。 */
|
||||
/** 幂等关闭并清 resident/取消等待者,不清 hashes/proof,getProof 仍可读取最后不可变快照。
|
||||
* 不等待原生 Blob 读取退出;pending 由各 Promise finally 清理,迟到读取经 closed 检查拒绝进入缓存。 */
|
||||
close(){if(closed)return;closed=true;resident.clear();lifecycle.abort();},
|
||||
/** 只读诊断:驻留/活range/pending块/证明块分别计数,不当作导入完成百分比。 */
|
||||
stats:()=>Object.freeze({residentBlocks:resident.size,activeReads,pendingBlocks:pending.size,proofBlocks:hashes.size,closed}),
|
||||
|
||||
@@ -8,20 +8,28 @@ import {importNeedsSeal,validateImportJob,type ImportJob} from './imports.ts';
|
||||
import {validateImportReadProof} from './import-read-proof.ts';
|
||||
|
||||
/** A derived durable-cut index, never an in-memory permission boolean. */
|
||||
/** 从给定job表派生fileId→SHA索引;调用者须传已可靠保存的cut,本函数不执行落盘。 */
|
||||
/** 从 integrity-v2、带 SHA 且无 integrityError 的 job 派生 fileId→SHA Map,不扫描字节或读取存储。
|
||||
* 调用方必须传已持久 cut;函数不证明其来源可靠,也不独立调用 validateImportJob 验摘要。
|
||||
* 返回实际可变 Map,若重复 fileId 后项覆盖前项;文件身份唯一性由外围约束保证。 */
|
||||
export function importSeals(jobs:ReadonlyMap<Id,ImportJob>|undefined){
|
||||
const seals=new Map<Id,string>();for(const job of jobs?.values()??[])if(job.source?.integrityVersion===2&&job.source.sha256&&!job.source.integrityError)seals.set(job.source.fileId,job.source.sha256);return seals;
|
||||
}
|
||||
/** integrity-v2任务需自身已完整验证且durable seals匹配;取消且零已提交前缀的任务可不阻塞。 */
|
||||
/** 整个 job 表的发送闸门:每个非豁免 integrity-v2 源都须无需 seal 且与 seals 中 SHA 相等。
|
||||
* 取消且 committedSourceKeys 为空者豁免;不按本次请求挑选 job,也不重验块证明/实际存储事务。
|
||||
* 其它 integrityVersion 在此跳过,是否支持/合法由 job 解释器先校验,不能把 true 当所有源都已验证。 */
|
||||
export function importsMaySend(jobs:ReadonlyMap<Id,ImportJob>|undefined,seals:ReadonlyMap<Id,string>){
|
||||
for(const job of jobs?.values()??[])if(job.source?.integrityVersion===2&&!(job.status==='cancelled'&&!job.committedSourceKeys.length)&&(importNeedsSeal(job)||seals.get(job.source.fileId)!==job.source.sha256))return false;return true;
|
||||
}
|
||||
/** Only engine-owned publication paths may advance read evidence or source verification. */
|
||||
/** 发布前保护引擎拥有的readProof/SHA/执行进度,未持owner任务ID的路径不能伪造或删除它们。 */
|
||||
/** 比较前后 job 表保护 integrity-v2 证据,owner 是引擎传入的当前任务 ID,不是外部权限令牌。
|
||||
* 同一表引用直接返回,依赖不可变状态合同;v2 job 不得删除或降级,即使 owner 匹配也不例外。
|
||||
* 每个 next v2 job 验结构且要求 proof 索引为从 0 开始的连续前缀,比通用 seek reader 更严格。
|
||||
* owner 可推进证据/执行事实,仍不能改 fileId/长度/已有 SHA 或丢改旧块;此处不重算字节或检查持久水位。 */
|
||||
export function assertImportEvidence(previous:ReadonlyMap<Id,ImportJob>|undefined,next:ReadonlyMap<Id,ImportJob>|undefined,owner:Id|undefined){
|
||||
if(previous===next)return;
|
||||
for(const[id,job]of previous??[])if(job.source?.integrityVersion===2&&!next?.has(id))throw fault('import_integrity_owned',[id]);
|
||||
/** 对比执行事实时剔除generation和源retirement生命周期字段;其它进度/身份仍要求合法owner。 */
|
||||
/** 比较执行事实时仅剔除 job.generation 和当前 source.retirement;previousFiles 等内容仍参与比较。
|
||||
* 允许普通保留流程调整这些排除字段,不在这里证明 generation/退休时钟单调,交其它发布校验处理。 */
|
||||
const execution=(job:ImportJob|undefined)=>{if(!job)return job;const{generation:_,source,...body}=job;if(!source)return body;const{retirement:__,...identity}=source;return {...body,source:identity};};
|
||||
for(const[id,job]of next??[]){const source=job.source,old=previous?.get(id)?.source;if(source?.integrityVersion!==2){if(old?.integrityVersion===2)throw fault('import_integrity_owned',[id]);continue;}
|
||||
validateImportJob(job);const proof=validateImportReadProof(source.readProof,source.fileId,source.byteLength);
|
||||
|
||||
@@ -19,7 +19,8 @@ export type LoadChunk<T> = {
|
||||
readonly sequence: number; readonly coverage: readonly string[]; readonly complete: boolean;
|
||||
readonly byteLength: number; readonly value: T;
|
||||
};
|
||||
/** 加载状态及已收到块;initial-ready/partial 只说明有内容,ready Promise 要到 terminal+EOF 才完成。 */
|
||||
/** 加载状态及已收到块;initial-ready 表示收到首个合法块,可能只是空模型头,并不证明画面已有元素。
|
||||
* partial 继续累积前缀;ready Promise 要等 terminal+EOF,失败前缀仍保留在 failed-with-partial 快照中。 */
|
||||
export type LoadSnapshot<T> = {
|
||||
readonly scope: LoadScope; readonly generation: number;
|
||||
readonly cacheStatus: 'bypassed' | 'miss' | 'invalid' | 'hit';
|
||||
@@ -44,7 +45,10 @@ export function loadQueryKey(scope: LoadScope): string {
|
||||
/** Read copies only. This coordinator never publishes a document or a server ACK.
|
||||
* Adapters validate payloads and declare their measured byte cost before yielding.
|
||||
*/
|
||||
/** 装配有界加载池;默认缓存年龄0不保留空闲结果,启用缓存也要求宿主 acceptCached 明确同意。 */
|
||||
/** 建立共享 query 调度器:inflight 去重、queue 按 generation FIFO、active 计真实未退出源,cache 按 Map LRU。
|
||||
* 默认并发 4/等待 128、单响应声明 64MiB/4096 块;上限依赖 adapter 准确测量 byteLength,不测 T 或内存峰值。
|
||||
* 默认缓存年龄 0 不插入终态缓存;启用后仍须订阅 allow-validated 且 acceptCached 同意,不把缓存当授权事实。
|
||||
* 不重试、无读取超时,不深克隆/解释 T;current 应表达共享查询的授权生命周期,导航取消由各订阅管理。 */
|
||||
export function createScopedLoader<T>(options: {
|
||||
load: LoadPort<T>; current(scope: LoadScope): boolean; concurrency?: number; maxQueued?: number;
|
||||
maxResponseBytes?: number; maxChunks?: number; maxCacheEntries?: number; maxCacheBytes?: number;
|
||||
@@ -54,7 +58,8 @@ export function createScopedLoader<T>(options: {
|
||||
}) {
|
||||
/** 订阅自己的 scope/取消/通知状态,不把旧导航的取消写成整个 job 失败。 */
|
||||
type Subscriber = { readonly scope: LoadScope; active: boolean; cancelled?: LoadSnapshot<T>; lastState?: LoadSnapshot<T>; snapshot?: LoadSnapshot<T>;resolve(value: LoadSnapshot<T>): void; reject(error: unknown): void; cleanup(): void; notify?(snapshot: LoadSnapshot<T>): void };
|
||||
/** 共享 query 的执行句柄;generation 防重开混淆,controller 只在最后订阅释放时 abort。 */
|
||||
/** 共享执行句柄,scope 来自首个订阅;view 会将导航信息换成各订阅自己的 scope。
|
||||
* generation 区分重开,controller 在 retire(最后释放或 close)时 abort,不以订阅数为持久任务事实。 */
|
||||
type Job = { readonly key: string; readonly scope: LoadScope; readonly generation: number; readonly controller: AbortController; readonly subscribers: Set<Subscriber>; state: LoadSnapshot<T>; retired: boolean };
|
||||
const concurrency = options.concurrency ?? 4, maxQueued = options.maxQueued ?? 128, maxBytes = options.maxResponseBytes ?? 64 * 1024 * 1024, maxChunks = options.maxChunks ?? 4096;
|
||||
const maxCacheEntries = options.maxCacheEntries ?? 16, maxCacheBytes = options.maxCacheBytes ?? 16 * 1024 * 1024, cacheMaxAge = options.cacheMaxAgeMs ?? 0;
|
||||
@@ -74,7 +79,8 @@ export function createScopedLoader<T>(options: {
|
||||
const notify = (job: Job) => { for (const subscriber of [...job.subscribers]) notifyOne(job, subscriber); };
|
||||
/** 淘汰同时扣除字节计数,避免缓存键删了但容量永远不归还。 */
|
||||
const removeCache = (key: string) => { const entry = cache.get(key); if (entry) { cacheBytes -= entry.state.byteLength; cache.delete(key); } };
|
||||
/** 幂等废弃 job:abort/出等待队列,仅当 inflight 仍指向自己时删除,旧任务不能删新代次。 */
|
||||
/** 幂等 retire:abort、移除排队/仍指向自己的 inflight,不删除缓存、不清前缀或提早扣 active。
|
||||
* 成功/失败后仍保留的句柄可独立 release;本函数不把已兑现的 ready Promise 改成拒绝。 */
|
||||
function retire(job: Job) {
|
||||
if (job.retired) return; job.retired = true; job.controller.abort(); queue.delete(job.generation);
|
||||
if (inflight.get(job.key) === job) inflight.delete(job.key);
|
||||
@@ -88,7 +94,9 @@ export function createScopedLoader<T>(options: {
|
||||
void (async () => {
|
||||
try {
|
||||
current(job.scope); let terminal = false, projectId: Id | undefined, token: string | undefined, versions: Readonly<Record<Id, number>> | undefined;
|
||||
// 每块重新检查身份并核对连续序号、稳定 token/项目/版本向量、权限代次和声明预算。
|
||||
// 端口可收到已取消信号(例如 loading 通知期间最后订阅释放);它仍须在实际 I/O 前检查 signal。
|
||||
// 每块重新检查身份,核对连续序号、稳定 token/项目/版本、权限代次和声明预算;coverage 只是合法字符串集合。
|
||||
// complete/coverage 的领域含义、实际请求范围和 T 的 schema 由 adapter 负责,本层不从它们推断文档闭合。
|
||||
for await (const chunk of options.load(job.scope, job.controller.signal)) {
|
||||
if (job.retired) return; current(job.scope);
|
||||
if (!chunk || terminal || chunk.queryKey !== job.key || chunk.permissionEpoch !== job.scope.authEpoch || typeof chunk.projectId !== 'string' || !chunk.projectId
|
||||
@@ -103,7 +111,8 @@ export function createScopedLoader<T>(options: {
|
||||
if (projectId !== undefined && (chunk.projectId !== projectId || chunk.snapshotToken !== token || !equal(orderedVersions, versions))) throw fault('load_snapshot_changed');
|
||||
if (job.state.chunks.length >= maxChunks || chunk.byteLength > maxBytes - job.state.byteLength) throw fault('load_response_budget');
|
||||
projectId = chunk.projectId; token = chunk.snapshotToken; versions = orderedVersions; terminal = chunk.complete;
|
||||
// Own metadata; the adapter supplies an immutable/owned payload value.
|
||||
// 捕获标准元数据表/coverage,value 与其它嵌套扩展字段仍沿用端口引用,不在此深冻结任意 T。
|
||||
// adapter 应在 yield 前完成 payload 校验并交出不可变值;外层 Object.freeze 不证明内部所有权。
|
||||
const owned = Object.freeze({ ...chunk, serverVersions: freeze(orderedVersions), coverage: freeze([...chunk.coverage]) });
|
||||
// 只追加合法块并通知可显示前缀;即使当前块 complete=true,也要等源 EOF 才能成功 ready。
|
||||
job.state = Object.freeze({ ...job.state, status: job.state.chunks.length ? 'partial' : 'initial-ready', chunks: Object.freeze([...job.state.chunks, owned]),
|
||||
@@ -116,6 +125,8 @@ export function createScopedLoader<T>(options: {
|
||||
removeCache(job.key); cache.set(job.key, { state: job.state, at: now() }); cacheBytes += job.state.byteLength;
|
||||
while (cache.size > maxCacheEntries || cacheBytes > maxCacheBytes) removeCache(cache.keys().next().value!);
|
||||
}
|
||||
// 成功清理外部 abort 监听并兑现 ready,但不自动 release 句柄;后续 signal.abort 不再改变其快照。
|
||||
// subscriber 仍持有共享状态,调用方应在不再使用时显式 release;已发出的快照继续描述原切点。
|
||||
notify(job); for (const subscriber of job.subscribers) if (subscriber.active) { subscriber.cleanup(); subscriber.resolve(view(job, subscriber)); }
|
||||
} catch (error) {
|
||||
if (job.retired) return;
|
||||
@@ -132,7 +143,10 @@ export function createScopedLoader<T>(options: {
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
/** 捕获 scope→检查缓存→共享/新建 job→建立独立订阅;本调用不直接打开/关闭 ProjectEngine。 */
|
||||
/** 捕获声明 scope 字段,校验身份/取消,再检查缓存并共享或新建 job;不打开 ProjectEngine。
|
||||
* fresh 会淘汰该 key 的旧缓存,但仍共享已有 inflight,并不保证为每次调用发新请求。
|
||||
* 缓存验证/时钟/容量等错误可同步抛出,成功才返回 ready/getSnapshot/release 句柄。
|
||||
* cacheStatus 属于共享 job 建立时的读取事实,复用在途 job 不会按新订阅的缓存偏好重写它。 */
|
||||
acquire(scopeInput: LoadScope, settings: { signal?: AbortSignal; onUpdate?: (snapshot: LoadSnapshot<T>) => void; cache?: 'fresh' | 'allow-validated' } = {}): LoadHandle<T> {
|
||||
if(!scopeInput)throw fault('invalid_load_scope');
|
||||
const scope:LoadScope=Object.freeze({accountId:scopeInput.accountId,workspaceId:scopeInput.workspaceId,projectId:scopeInput.projectId,canvasId:scopeInput.canvasId,viewKey:scopeInput.viewKey,format:scopeInput.format,queryFingerprint:scopeInput.queryFingerprint,source:scopeInput.source,authEpoch:scopeInput.authEpoch,navigationGeneration:scopeInput.navigationGeneration});
|
||||
@@ -158,13 +172,19 @@ export function createScopedLoader<T>(options: {
|
||||
}
|
||||
const shared = job; let subscriber: Subscriber;
|
||||
const ready = new Promise<LoadSnapshot<T>>((resolve, reject) => { subscriber = { scope, active: true, resolve, reject, notify: settings.onUpdate, cleanup: () => settings.signal?.removeEventListener('abort', release) }; });
|
||||
/** 冻结本订阅 cancelled 视图、清监听并拒绝等待;只有最后借用者离开才 retire 共享源。 */
|
||||
/** 为本订阅记录 cancelled 快照、移除监听/借用,尝试以 load_aborted 拒绝尚未兑现的 ready。
|
||||
* ready 已成功/失败时 Promise 结果不变;getSnapshot 则改读此 cancelled 切点,保留已有 chunks。
|
||||
* 最后借用者才 retire 共享源;取消一个订阅不会撤回其它订阅的前缀或删除成功缓存。 */
|
||||
const release = () => { if (!subscriber.active) return; subscriber.cancelled=Object.freeze({...view(shared,subscriber),status:'cancelled'});subscriber.active = false; subscriber.cleanup(); shared.subscribers.delete(subscriber); subscriber.reject(fault('load_aborted')); if (!shared.subscribers.size) retire(shared); };
|
||||
shared.subscribers.add(subscriber!); settings.signal?.addEventListener('abort', release, { once: true });
|
||||
// 缓存命中的句柄不登记进 inflight,ready 立即兑现,外部 abort 监听仍保留到 release。
|
||||
// 因此 close 负责清共享缓存,却不追溯取消这种已返回句柄;调用方仍拥有其只读快照。
|
||||
if (cached) { subscriber!.resolve(view(shared, subscriber!)); notifyOne(shared, subscriber!); } else { notifyOne(shared, subscriber!); pump(); }
|
||||
return Object.freeze({ ready, getSnapshot: () => subscriber.cancelled??view(shared,subscriber), release });
|
||||
},
|
||||
/** 永久关闭并拒绝所有活订阅,清等待/缓存;底层迟到结果由 retired/current 围栏拒绝采纳。 */
|
||||
/** 永久关闭:只对当前 inflight 的订阅记录 cancelled 并以 loader_closed 拒绝,随后清 queue/cache。
|
||||
* 不追溯修改已从 inflight 退出的终态句柄或缓存命中句柄;其旧快照仍可读,显式 release 仍可调用。
|
||||
* 不等待底层迭代器退出,active 在各 finally 才归还;无视 abort 的源仍可能占着资源,但迟到块不被采纳。 */
|
||||
close() {
|
||||
if (closed) return; closed = true;
|
||||
for (const job of [...inflight.values()]) { retire(job); for (const subscriber of job.subscribers) if (subscriber.active) { subscriber.cancelled=Object.freeze({...view(job,subscriber),status:'cancelled'});subscriber.active = false; subscriber.cleanup(); subscriber.reject(fault('loader_closed')); } job.subscribers.clear(); }
|
||||
|
||||
@@ -11,7 +11,10 @@ import {referencedResources,type LoadingAssetAdmission} from './resources.ts';
|
||||
/** Proven local upload identities; never infer authorization from an arbitrary URL. */
|
||||
// source身份+各proof身份一致时复用资源URL表;不因浏览器缓存有URL就授予准入。
|
||||
const namespaces=new WeakMap<object,{proofs:Map<Id,LoadingAssetAdmission>;urls:ReadonlyMap<Id,string>}>();
|
||||
/** 从已记录loadingAdmission派生resource→规范来源URL,只在v6和保留源存在时提供。 */
|
||||
/** v6 且 loadingRead 存在时提取 resource/binding 的 admission.sourceURL,不执行认证或完整 proof 校验。
|
||||
* 以源对象与各 proof 引用身份缓存;依赖 runtime/proof 不可变合同,准入校验应由外围先完成。
|
||||
* 返回实际可变 Map(类型只读),命中时沿用同一表,内部调用方不得修改;否则会污染后续复用。
|
||||
* 同 ID 若两表均有 proof 后者覆盖,资源命名空间无冲突由其它运行时校验保证。 */
|
||||
export function loadingAssetURLs(runtime:RuntimeState):ReadonlyMap<Id,string>{
|
||||
const source=runtime.loadingRead;if(!source||runtime.loading?.version!==6)return new Map();
|
||||
const proofs=new Map<Id,LoadingAssetAdmission>();for(const resource of runtime.resources.values())if(resource.loadingAdmission)proofs.set(resource.id,resource.loadingAdmission);
|
||||
@@ -19,10 +22,14 @@ export function loadingAssetURLs(runtime:RuntimeState):ReadonlyMap<Id,string>{
|
||||
const previous=namespaces.get(source);if(previous&&previous.proofs.size===proofs.size&&[...proofs].every(([id,proof])=>previous.proofs.get(id)===proof))return previous.urls;
|
||||
const urls=new Map([...proofs].map(([id,proof])=>[id,proof.sourceURL]));namespaces.set(source,{proofs,urls});return urls;
|
||||
}
|
||||
/** 联合验证上传/采纳与源范围、操作顺序、引用和stored证明;最后核对冻结上传协议字段。 */
|
||||
/** 仅 v6 校验全部本地 resource/binding 的准入:token/范围、逻辑因果与冻结上传 body 一致性。
|
||||
* 要求外围已验证 loadingRead/ledger/资源结构;这里不下载字节、重算摘要、请求授权或核验真实服务回执。
|
||||
* referencedResources 是声明字段的保守引用集(含变化前后),顺序检查不单独证明某条记录当前仍可显示。
|
||||
* sourceURL 对 upload 只查非空/长度,对 adoption 要与 binding.asset.url 相等,规范来源由受信端口提供。 */
|
||||
export function validateLoadingAssetAdmissions(runtime:RuntimeState,registry:Registry):void{
|
||||
const loading=runtime.loading;if(loading?.version!==6)return;
|
||||
// upload先分配资源,后续document.change才能引用;早于/同于upload的引用不具备可靠身份因果。
|
||||
// 每个 upload resource 都要有对应上传意图;stored 存在时 resolution 必须 applied。
|
||||
// 至少有一个后续 document.change 引用且不允许早于/等于上传序号的引用;不按 ledger phase 过滤。
|
||||
for(const resource of runtime.resources.values()){
|
||||
const proof=resource.loadingAdmission,record=runtime.ledger.operations.get(resource.operationId);
|
||||
if(!proof||proof.version!==1||proof.sourceToken!==loading.sourceToken||typeof proof.sourceURL!=='string'||!proof.sourceURL||proof.sourceURL.length>8192||!Array.isArray(proof.canvasIds)||!proof.canvasIds.length||new Set(proof.canvasIds).size!==proof.canvasIds.length||proof.canvasIds.some(id=>typeof id!=='string'||!structuralRequestScope(runtime,runtime.loadingRead!,id))||record?.intent.operationType!=='asset.upload'||record.intent.resourceId!==resource.id||resource.stored&&record.resolution!=='applied')throw fault('invalid_loading_asset',[resource.id]);
|
||||
@@ -35,7 +42,8 @@ export function validateLoadingAssetAdmissions(runtime:RuntimeState,registry:Reg
|
||||
if(!proof||proof.version!==1||proof.operationId!==binding.operationId||proof.sourceToken!==loading.sourceToken||proof.sourceURL!==binding.asset.url||!Array.isArray(proof.canvasIds)||!proof.canvasIds.length||new Set(proof.canvasIds).size!==proof.canvasIds.length||proof.canvasIds.some(owner=>!structuralRequestScope(runtime,runtime.loadingRead!,owner))||record?.intent.operationType!=='document.change'||!referencedResources(record.intent.changes,registry).has(id))throw fault('invalid_loading_asset',[id]);
|
||||
if([...runtime.ledger.operations.values()].some(candidate=>candidate.intent.operationType==='document.change'&&candidate.intent.sequence<record.intent.sequence&&referencedResources(candidate.intent.changes,registry).has(id)))throw fault('invalid_loading_asset',[id]);
|
||||
}
|
||||
// 冻结请求必须是原资源/operation/digest/长度/MIME,且不伪造canvas版本参与者。
|
||||
// 每个 asset.upload 冻结请求必须只对应一个资源意图,协议 2 且 canvas/expectedVersions 为空。
|
||||
// JSON body 与原 request/resource/operation/digest/长度/名称/MIME 精确匹配;不在此重新编译或修改冻结请求。
|
||||
for(const request of runtime.ledger.requests.values())if(request.kind==='asset.upload'){
|
||||
const intent=request.operationIds.length===1?runtime.ledger.operations.get(request.operationIds[0])?.intent:undefined,resource=intent?.operationType==='asset.upload'?runtime.resources.get(intent.resourceId):undefined;
|
||||
if(!resource?.digest||request.protocolVersion!==2||request.canvasIds.length||Object.keys(request.expectedVersions).length)throw fault('invalid_loading_request');
|
||||
@@ -46,7 +54,10 @@ export function validateLoadingAssetAdmissions(runtime:RuntimeState,registry:Reg
|
||||
|
||||
// 每registry+文档身份缓存资源的实际owner集合,完整比较首用范围时避免重复全表扫描。
|
||||
const ownership=new WeakMap<Registry,WeakMap<Document,Map<Id,readonly Id[]>>>();
|
||||
/** 资源首次出现在逻辑回放中时,实际owner集合须精确匹配准入范围;seen只在本次验证中去重。 */
|
||||
/** 外围应先将本 intent 应用到逻辑候选 document,再在首次遇有 admission 的资源时比较实际 owner 集合。
|
||||
* registry+document 身份缓存全表资源→owner 索引,复用依赖不可变文档/定义;统计内容引用,不使用 DOM 可见性。
|
||||
* 无 proof 或已 seen 的资源跳过,后续意图不重复做首次范围比较;一般资源合法性由其它门禁负责。
|
||||
* seen 在比较前更新,失败后应丢弃本轮 Set,不能将失败结果当作已验证缓存继续使用。 */
|
||||
export function validateLoadingAssetUse(intent:OperationIntent,document:Document,runtime:RuntimeState,registry:Registry,seen:Set<Id>):void{
|
||||
if(runtime.loading?.version!==6)return;
|
||||
for(const id of referencedResources(intent.changes,registry)){
|
||||
|
||||
@@ -16,7 +16,10 @@ const relations=new WeakMap<Document,{members:Map<Id,Id[]>;children:Map<Id,Id[]>
|
||||
/** 构造直接成员和entry→子canvas映射,供选区DFS扩张使用,不从节点可见性推断拥有范围。 */
|
||||
function index(document:Document){let value=relations.get(document);if(!value){const members=new Map<Id,Id[]>(),children=new Map<Id,Id[]>();for(const element of document.elements.values()){const ids=members.get(element.ownerCanvasId)??[];ids.push(element.id);members.set(element.ownerCanvasId,ids);}for(const canvas of document.canvases.values())if(canvas.parent){const ids=children.get(canvas.parent.entryElementId)??[];ids.push(canvas.id);children.set(canvas.parent.entryElementId,ids);}value={members,children};relations.set(document,value);}return value;}
|
||||
/** The caller captures this proof with the same document that becomes source bytes. */
|
||||
/** target先闭合;复制还要求全部选中后代/必要祖先/上级入边owner完整,然后冻结准入范围与sequence。 */
|
||||
/** 无 loading 返回 undefined;只有 v5/v6+loadingRead 能生成准入,target 必须 ownerReady。
|
||||
* selection 存在时以数组栈+copied Set 深扩张,补入头部声明却未发布的 child,避免误将未读分区当叶子。
|
||||
* captured 元素还检查必要祖先;上级入边的 owner 也纳入 required,最终冻结去重排序范围与账本最大 sequence。
|
||||
* 只派生证据,不复制字节/分配 ID/验证 policy;没有 selection 表示普通目标导入,不检查复制子树。 */
|
||||
export function loadingImportAdmission(snapshot:EngineSnapshot,target:Id,selection?:readonly Target[]):LoadingImportAdmission|undefined {
|
||||
const state=snapshot.runtime.loading;if(!state)return;
|
||||
if((state.version!==5&&state.version!==6)||!snapshot.runtime.loadingRead)throw fault('model_loading');
|
||||
@@ -42,7 +45,9 @@ export function loadingImportAdmission(snapshot:EngineSnapshot,target:Id,selecti
|
||||
let operationSequence=0;for(const record of snapshot.runtime.ledger.operations.values())operationSequence=Math.max(operationSequence,record.intent.sequence);
|
||||
return freeze({version:1 as const,sourceToken:state.sourceToken,operationSequence,requiredCanvasIds:[...required].sort()});
|
||||
}
|
||||
/** 恢复时job须同项目/同token,证明sequence不超账本,target与全部必要canvas仍是可证明范围。 */
|
||||
/** v5/v6 恢复/发布时逐个 validateImportJob,再核对项目、token、sequence 上界、target 包含与各范围准入。
|
||||
* 只检查保存的 requiredCanvasIds,不在此重新展开 selection 或读取源字节;复制源重建由下一函数负责。
|
||||
* 依赖外围已建立 loadingRead/账本身份,本函数不证明原 checkpoint 已落盘或获得当前服务器权限。 */
|
||||
export function validateLoadingImportAdmissions(runtime:EngineSnapshot['runtime']):void {
|
||||
const state=runtime.loading;if(!state||(state.version!==5&&state.version!==6))return;
|
||||
let maxSequence=0;for(const record of runtime.ledger.operations.values())maxSequence=Math.max(maxSequence,record.intent.sequence);
|
||||
@@ -51,7 +56,10 @@ export function validateLoadingImportAdmissions(runtime:EngineSnapshot['runtime'
|
||||
|
||||
/** Revalidate the frozen copy topology before planning or resuming its source.
|
||||
* Later source chunks may add unrelated owners, so compare only the closed cut. */
|
||||
/** 恢复规划前按准入sequence重建预期源,只比较原闭合cut的topology,不要求后到无关owner保持不增长。 */
|
||||
/** 从原捕获源重放 sequence≤准入时点的全部意图 changes(不按 phase 过滤,conditional=false)重建旧逻辑切点。
|
||||
* 用 preparation/plan 中 selection 重算 required 范围并逐 owner 比较 canvas 与 copyTopology,忽略无关后到范围。
|
||||
* copyTopology 保留身份/owner/端点/几何及表现尺度,省略正文等组件内容和边 path;这不是完整 payload 一致性证明。
|
||||
* 不计算 SHA、不执行完整文档/权限校验,也不发布重建文档;源字节完整性由快照/导入证据链负责。 */
|
||||
export function validateLoadingCopySource(snapshot:EngineSnapshot,job:ImportJob,document:Document):void {
|
||||
const state=snapshot.runtime.loading,proof=job.source?.loadingAdmission;if(!state||(state.version!==5&&state.version!==6)||!proof)return;
|
||||
const source=snapshot.runtime.loadingRead!;let expected=source.document;
|
||||
|
||||
@@ -17,11 +17,16 @@ const counts=new WeakMap<Document,ReadonlyMap<Id,number>>();
|
||||
function membership(document:Document){let result=counts.get(document);if(!result){const next=new Map<Id,number>();for(const element of document.elements.values())next.set(element.ownerCanvasId,(next.get(element.ownerCanvasId)??0)+1);result=next;counts.set(document,result);}return result;}
|
||||
/** 全部声明元素/canvas 均到齐才算源闭合;这里只做数量闭合判断,领域合法性由其它校验共同保证。 */
|
||||
export function sourceClosed(state:LoadingState,source:Capture){return source.document.elements.size===state.totalElements&&source.document.canvases.size===state.declaredCanvasIds.length;}
|
||||
/** working中owner存在,且是明确新canvas或源成员数已达头部证明,才能作为闭合操作范围。 */
|
||||
/** working 中 canvas 必须存在;source.versions 不含它时按本地新 scope 处理,否则需 v2+ 直属源计数齐。
|
||||
* 依赖 source/state 事先验证的完整声明,不以 working 乐观新增数补齐;也不单独证明全部后代/引用闭合。 */
|
||||
export function ownerReady(state:LoadingState,source:Capture,document:Document,id:Id){return document.canvases.has(id)&&(!source.versions.has(id)||state.version!==1&&(membership(source.document).get(id)??0)===state.canvasElementCounts[id]);}
|
||||
/** 只有v4+完整ID命名空间才能证明一个ID不属于原捕获;“当前还没看到”本身不够。 */
|
||||
/** 仅 v4/v5/v6 用完整 elementOwners 的缺席判定不属于捕获,不能只看当前 prefix 未出现。
|
||||
* 这里不查元素是否真的新建、墓碑或 ID 冲突;born 是命名空间分类,具体创建条件在其它分支核对。 */
|
||||
function born(state:LoadingState,id:Id){return (state.version===4||state.version===5||state.version===6)&&!Object.hasOwn(state.elementOwners,id);}
|
||||
/** 禁止凭早期payload引入新任务状态或任意资源URL;新资源须有准入表,已有资源须能在已知数据证明。 */
|
||||
/** 数据门禁要求 copy 声明:ai/taskId/runId 的非空值只能与 prior 相等,允许清空而不允许改成新任务值。
|
||||
* src/assetId/声明资源字段若非空,须命中受控 assets 表,或与 working/source 已知组件的同字段值一致。
|
||||
* 这是可解释来源检查,不发授权请求、不验证 URL 协议,也不代替数据 schema/任务服务校验。
|
||||
* 比对可扫描当前与源的元素表,不能把此函数当作常数成本字段检查。 */
|
||||
function safeData(data:ElementRecord['data'],registry:Registry,component:Id,document:Document,source?:Document,prior?:ElementRecord['data'],assets?:ReadonlyMap<Id,string>){
|
||||
const definition=registry.components.get(component);if(!definition?.copy)return false;
|
||||
for(const key of ['ai','taskId','runId'])if(data[key]!==undefined&&data[key]!==null&&!equal(data[key],prior?.[key]))return false;
|
||||
@@ -38,7 +43,10 @@ function assetPatch(patch:ElementRecord['data'],registry:Registry,component:Id){
|
||||
const definition=registry.components.get(component),fields=['src','assetId',...(definition?.resourceFields??[])],keys=Object.keys(patch);
|
||||
return keys.some(key=>fields.includes(key))&&keys.every(key=>fields.includes(key)||!['presentation','ports','ai','taskId','runId'].includes(key)&&!definition?.copy?.references.some(ref=>ref.path[0]===key||ref.path[0]==='*'));
|
||||
}
|
||||
/** 返回结构命令的预检结果,undefined交普通字段门;准入仍需后续核心policy/schema与实际changes检查。 */
|
||||
/** v4+ 对结构/受控数据命令返回 enabled/deny;未处理命令或不满足专用数据入口时返回 undefined。
|
||||
* undefined 留给普通字段门,不表示允许;enabled 也不证明目标代次、能力、handler 副作用与最终文档都合法。
|
||||
* 原捕获对象的删除/重挂等要求源闭合,本地 born 对象可在 ready owner 操作;解散仍要求整个源到齐。
|
||||
* 函数只预检,不锁范围、不生成 changes;调用方仍需核心 policy/schema 和 assertStructuralChanges。 */
|
||||
export function structuralCommand(command:Command,document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap<Id,string>):Availability|undefined {
|
||||
if(state.version<4)return;
|
||||
const deny:Availability={enabled:false,code:'model_loading',targets:[]},yes:Availability={enabled:true};
|
||||
@@ -52,7 +60,10 @@ export function structuralCommand(command:Command,document:Document,registry:Reg
|
||||
if(command.type==='element.data'&&(born(state,command.target.id)||sourceClosed(state,source)||state.version===6&&assetPatch(command.patch,registry,document.elements.get(command.target.id)?.componentId??'')&&ownerReady(state,source,document,document.elements.get(command.target.id)?.ownerCanvasId??''))){const element=document.elements.get(command.target.id);return element&®istry.resolve(element)==='known'&&safeData({...element.data,...command.patch},registry,element.componentId,document,source.document,element.data,assets)?yes:deny;}
|
||||
}
|
||||
/** Actual changes, not a command name, establish the mutation's required closure. */
|
||||
/** 逐条在私有cursor验证实际变化的必要闭合范围/数据,并检查最终声明引用,防handler副作用绕过。 */
|
||||
/** 将每条实际 change 按顺序作用到私有 cursor,逐项核对 owner/生命周期分类/数据门禁,最终检查声明引用。
|
||||
* geometry 的局部门只要求已知 node,path/端点还要求 ready owner;owner 迁移则检查新旧两侧与 captured 分类。
|
||||
* applyChanges 检查 before 条件,但这里不调用完整 validateDocument,也不执行 capability/policy/服务器授权校验。
|
||||
* 返回是通过加载门禁的候选,非已发布 Root;任何抛错都不返回半份候选,原文档按不可变输入合同复用。 */
|
||||
export function assertStructuralChanges(changes:readonly Change[],document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap<Id,string>):Document {
|
||||
if(state.version<4)throw fault('model_loading');
|
||||
// 按变化顺序推进,同批先创建owner/端点可满足后续依赖,但原源命名空间不会因此被改写。
|
||||
@@ -84,10 +95,13 @@ export function assertStructuralChanges(changes:readonly Change[],document:Docum
|
||||
// 即使命令名字获准,最终仍不能产生悬空声明引用;整批失败不给调用方半份合法结果。
|
||||
references(cursor,registry);return cursor;
|
||||
}
|
||||
/** Protocol-2 receipts contain exactly these applied logical changes. */
|
||||
/** The caller's validated protocol-2 ledger supplies the applied logical effects. */
|
||||
// 缓存同一源上的已确认结构投影,只在applied意图前缀身份未变时从缓存后缀继续。
|
||||
const projectionCache=new WeakMap<Capture,{intents:readonly OperationIntent[];document:Document}>();
|
||||
/** 将resolved/applied意图按sequence叠到读取候选,并按精确协议2的参与者版本推进;不混入pending。 */
|
||||
/** 按 sequence 对 resolved+resolution=applied 意图叠原 changes,conditional=false,不加入 pending/elided 效果。
|
||||
* 不按 operationType 额外过滤,也不再次核验协议/回执或调用 validateDocument;依赖外围已验证账本与精确协议 2 合同。
|
||||
* 文档缓存仅在同 source 与 intent 引用前缀一致时续算;参与者版本另按每个 applied request 的 expected+1 取最大值。
|
||||
* 返回新的投影 document/versions,不修改 runtime ACK;最终合法性由加载合并/验证入口继续检查。 */
|
||||
export function appliedSource(runtime:RuntimeState,source:Capture):Capture {
|
||||
let document=source.document;const versions=new Map(source.versions);
|
||||
const applied=[...runtime.ledger.operations.values()].filter(record=>record.phase==='resolved'&&record.resolution==='applied').sort((a,b)=>a.intent.sequence-b.intent.sequence);
|
||||
@@ -98,9 +112,11 @@ export function appliedSource(runtime:RuntimeState,source:Capture):Capture {
|
||||
return{document,versions};
|
||||
}
|
||||
|
||||
// source/registry/intent/before/assets全身份绑定校验缓存,不能把另一捕获或不同资源授权的结果拿来复用。
|
||||
// 缓存键为 source/registry/intent 引用,再比较 before/assets 引用;不按内容 hash 或 LoadingState 单独建键。
|
||||
const intentProofs=new WeakMap<Capture,WeakMap<Registry,WeakMap<OperationIntent,{before:Document;after:Document;assets?:ReadonlyMap<Id,string>}>>>();
|
||||
/** 对同一候选上下文复用已验证结构结果,否则重新assert全部实际changes;不是跳过当前源范围验证。 */
|
||||
/** 命中同一 source/registry/intent/before/assets 上下文时直接复用候选,否则重新检查 changes 并缓存。
|
||||
* LoadingState 不在独立缓存键内,调用方须保证其与同源上下文一致,所有参与引用均按不可变合同使用。
|
||||
* 缓存只证明这次加载结构门禁曾通过,不证明任意后来的 state/权限变化也可沿用结果。 */
|
||||
export function validateStructuralIntent(intent:OperationIntent,document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap<Id,string>):Document {
|
||||
let registries=intentProofs.get(source);if(!registries){registries=new WeakMap();intentProofs.set(source,registries);}let intents=registries.get(registry);if(!intents){intents=new WeakMap();registries.set(registry,intents);}const previous=intents.get(intent);if(previous?.before===document&&previous.assets===assets)return previous.after;
|
||||
const after=assertStructuralChanges(intent.changes,document,registry,state,source,assets);intents.set(intent,{before:document,after,assets});return after;
|
||||
|
||||
@@ -16,9 +16,13 @@ import type {CanonicalProposal,RuntimeState} from './contracts.ts';
|
||||
* version 3 admits certified scopes with a retained read baseline. No HTTP cursor. */
|
||||
/** 当前捕获的稳定 token、声明 canvas 范围及接收数量;不保存或伪造可续传 HTTP 字节游标。 */
|
||||
type LoadingBase={readonly sourceToken:string;readonly declaredCanvasIds:readonly Id[];readonly receivedElements:number;readonly totalElements:number};
|
||||
/** 版本化范围证据:counts/owners/sourceCanvases 能力逐级增加,缺字段不能当有证明。 */
|
||||
/** 版本化范围证据:v1 无计数,v2/3 加直属计数,v4 加全 ID→owner,v5/6 再加完整源树。
|
||||
* 版本表示允许解释的协议能力,不表示收到全部 payload 或已可发送;loading 清除前项目仍标记未完整。 */
|
||||
export type LoadingState=LoadingBase&({readonly version:1;readonly canvasElementCounts?:never;readonly elementOwners?:never;readonly sourceCanvases?:never}|{readonly version:2|3;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners?:never;readonly sourceCanvases?:never}|{readonly version:4;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners;readonly sourceCanvases?:never}|{readonly version:5|6;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners;readonly sourceCanvases:readonly CanvasRecord[]});
|
||||
/** 验证前缀与捕获头一致:版本、树/ID命名空间、总数、直属计数和源版本向量;不是只比较已加载数量。 */
|
||||
/** 校验声明版本/token/范围/接收数及版本值,再按版本核对 counts、owners、源树和当前前缀成员。
|
||||
* document.elements.size 可以少于 receivedElements,允许已收但因依赖尚未发布的记录;两者都不能超过总量。
|
||||
* 不调用 validateDocument、不捕获/冻结输入或检验 HTTP 身份;外围须提供合法模型/源与不可变声明。
|
||||
* 旧版本不允许偷带新证据字段,但不是通用严格 JSON DTO/所有扩展字段过滤器。 */
|
||||
export function validateLoadingState(value:LoadingState,document:Document,versions:ReadonlyMap<Id,number>):void{
|
||||
if(!value||![1,2,3,4,5,6].includes(value.version))throw fault('loading_interpreter_unavailable');
|
||||
for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]);
|
||||
@@ -42,7 +46,9 @@ function pathAllowed(id:Id,document:Document,registry:Registry,loading?:LoadingS
|
||||
const element=document.elements.get(id);if((!loading||loading.version===1)||element?.structure!=='connection'||registry.resolve(element)!=='known')return false;
|
||||
return (directCounts(base).get(element.ownerCanvasId)??0)===loading.canvasElementCounts[element.ownerCanvasId];
|
||||
}
|
||||
/** partial 模式的命令预检;新结构证明可处理特定命令,否则仅已有节点几何/安全内容等受限编辑。 */
|
||||
/** 部分加载策略入口:v4+ 先问结构门,undefined 才落回已知 node 几何/安全普通内容/可证完整边路径。
|
||||
* enabled 仅表示加载策略允许,核心目标代次、capability、业务 policy 和提交校验仍需执行。
|
||||
* 本 helper 不是无 loading 时的通用命令放行器;调用方应只在 partial 模式将它作为额外约束。 */
|
||||
export function loadingCommand(command:Command,document:Document,registry:Registry,loading?:LoadingState,base=document,source?:CanonicalProposal,assets?:ReadonlyMap<Id,string>):Availability{
|
||||
if(loading&&(loading.version===4||loading.version===5||loading.version===6)&&source){const scoped=structuralCommand(command,document,registry,loading,source,assets);if(scoped)return scoped;}
|
||||
if(command.type==='connection.path')return pathAllowed(command.target.id,document,registry,loading,base)?{enabled:true}:denied;
|
||||
@@ -53,7 +59,9 @@ export function loadingCommand(command:Command,document:Document,registry:Regist
|
||||
}
|
||||
/** Check actual changes too: a feature handler cannot evade command admission by
|
||||
* modifying a protected reference field as a side effect of an allowed edit. */
|
||||
/** 再核对 handler 实际输出,防止表面是编辑 title,却顺带修改受保护引用字段;v4+走结构闭合校验。 */
|
||||
/** 对 handler 实际 changes 再做加载策略检查;v4+ 以结构候选验证,返回结果在此不发布。
|
||||
* 旧分支只允许字段类并逐项针对传入 document 检查,不按顺序执行 changes、不校验 before 或完整 schema。
|
||||
* 因此通过不表示变更可直接采用,后续事务执行/文档校验仍负责条件与最终合法性。 */
|
||||
export function assertLoadingChanges(changes:readonly Change[],document:Document,registry:Registry,loading?:LoadingState,source?:CanonicalProposal,assets?:ReadonlyMap<Id,string>):void{
|
||||
if(loading&&(loading.version===4||loading.version===5||loading.version===6)&&source){assertStructuralChanges(changes,document,registry,loading,source,assets);return;}
|
||||
for(const change of changes){
|
||||
@@ -62,7 +70,10 @@ export function assertLoadingChanges(changes:readonly Change[],document:Document
|
||||
if(!element||element.incarnation!==change.target.incarnation||registry.resolve(element)!=='known'||!(change.field==='path'&&pathAllowed(change.target.id,document,registry,loading)||change.field==='geometry'&&element.structure==='node'||change.field.startsWith('data.')&&dataKeyAllowed(element.componentId,change.field.slice(5),registry)))throw fault('model_loading',[change.target.id]);
|
||||
}
|
||||
}
|
||||
/** 恢复/发布时联合校验 loading/source/ACK/意图/资源/导入,不让单表合法但整体证据矛盾的 Root 通过。 */
|
||||
/** 校验部分加载 Root 的组合证据:原源/ACK 对应、各版本禁止的任务类别、资源/导入准入和逻辑回放。
|
||||
* 无 loading 时只禁止孤立 loadingRead 即返回,并不在此验证所有普通运行时字段;通用校验由恢复/引擎负责。
|
||||
* v4+ 按 sequence 回放所有文档意图而非只 pending,v6 允许合规 asset.upload 并在文档步骤后查首次资源范围。
|
||||
* 本函数只验证/构造私有候选,不提交 Root、写存储或放行网络,不能替代持久/身份围栏。 */
|
||||
export function validateLoadingRuntime(runtime:RuntimeState,registry:Registry):void{
|
||||
if(!runtime.loading){if(runtime.loadingRead)throw fault('invalid_loading_state');return;}
|
||||
// v3+必须保留独立 loadingRead,旧版本不可偷带该字段;源校验不使用已经推进的 ACK 代替。
|
||||
@@ -82,15 +93,19 @@ export function validateLoadingRuntime(runtime:RuntimeState,registry:Registry):v
|
||||
validateLoadingAssetUse(record.intent,logical,runtime,registry,assetUses);
|
||||
}
|
||||
}
|
||||
/** 同一读取的前缀扩展只能追加:token/版本/范围证据/墓碑固定,旧记录不得在后块被修改或移除。 */
|
||||
/** 验证 next 文档/声明后,与已验证 previous 比较:同项目/token/证据版本/头部/版本向量/墓碑不变。
|
||||
* received 单调,旧 canvas/element 记录逐个保留且相等,允许新闭合记录追加;不是用较新 ACK 覆盖旧源来比较。
|
||||
* 不发布/冻结 next,也不证明其传输来源;调用方完成捕获后再合并 ACK、重放 pending 并同 Root 采纳。 */
|
||||
export function validatePrefixExtension(previous:Document,previousVersions:ReadonlyMap<Id,number>,state:LoadingState,next:Document,versions:ReadonlyMap<Id,number>,nextState:LoadingState,registry:Registry):void{
|
||||
validateDocument(next,registry);validateLoadingState(nextState,next,versions);
|
||||
if(next.projectId!==previous.projectId||state.sourceToken!==nextState.sourceToken||state.version!==nextState.version||!equal(state.canvasElementCounts,nextState.canvasElementCounts)||!equal(state.elementOwners,nextState.elementOwners)||!equal(state.sourceCanvases,nextState.sourceCanvases)||state.totalElements!==nextState.totalElements||nextState.receivedElements<state.receivedElements||!equal(state.declaredCanvasIds,nextState.declaredCanvasIds)||!equal(Object.fromEntries(previousVersions),Object.fromEntries(versions))||!equal(Object.fromEntries(previous.tombstones),Object.fromEntries(next.tombstones))||[...previous.canvases].some(([id,record])=>!equal(record,next.canvases.get(id)))||[...previous.elements].some(([id,record])=>!equal(record,next.elements.get(id))))throw fault('loading_source_mismatch');
|
||||
}
|
||||
/** Full fresh reads may include new records in an unchanged-version scope. Known
|
||||
* records still cannot change without an owner version advancing. */
|
||||
/** 校验终结或 fresh read:已知版本不倒退,同版本已知记录不变,墓碑不倒退。
|
||||
* 原捕获补完要求相同 token/向量/总量;fresh read 可新增此前未收到项,v4+按新完整捕获协调。 */
|
||||
/** 校验完整 next:合法文档/活 canvas 版本、已知版本和墓碑不倒退,同 owner 版本的已知记录不得改变。
|
||||
* fresh=false 还要求原 token/向量,按原声明总量/范围/直属计数补完;此处不会自动叠本地已确认编辑。
|
||||
* fresh=true 可采用新的完整捕获,v4+ 即使版本向量相同也不强套旧总量/范围计数,但已知记录/生命周期约束仍在。
|
||||
* 旧版本 fresh 在向量相同时仍受原总量/计数约束;是否移除 loading/合并 working 由引擎决定。 */
|
||||
export function validateLoadingCompletion(previous:Document,previousVersions:ReadonlyMap<Id,number>,state:LoadingState,next:Document,versions:ReadonlyMap<Id,number>,registry:Registry,fresh=false,sourceToken?:string):void{
|
||||
validateDocument(next,registry);if(previous.projectId!==next.projectId)throw fault('project_mismatch');
|
||||
for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]);
|
||||
@@ -117,7 +132,10 @@ const namespaceSets=new WeakMap<ElementOwners,import('../canvas-core/types.ts').
|
||||
function namespace(owners:ElementOwners){let value=namespaceSets.get(owners);if(!value){value=readSet(Object.keys(owners));namespaceSets.set(owners,value);}return value;}
|
||||
/** 计算并缓存单文档 owner 的直属数量,节点/边都计入,不递归累计子树。 */
|
||||
function directCounts(document:Document):ReadonlyMap<Id,number>{let counts=membershipCounts.get(document);if(!counts){const next=new Map<Id,number>();for(const element of document.elements.values())next.set(element.ownerCanvasId,(next.get(element.ownerCanvasId)??0)+1);counts=next;membershipCounts.set(document,counts);}return counts;}
|
||||
/** 给 query 同次发布的完整性:整个项目未完仍可有已认证完整 owner;born canvas 另按结构证明处理。 */
|
||||
/** 派生查询覆盖状态:无 loading 时标全项目/拓扑完整,有 loading 时 projectComplete 固定 false。
|
||||
* v4+ 同时给全源 elementIds 与合法源前缀 loadedElementIds,后者不是传输 received 数;born scope 依 ownerReady 判定。
|
||||
* 拓扑完整只按合法前缀 canvas 与声明范围数量判断,成员完整另用源直属计数,不能相互替代。
|
||||
* WeakMap 索引依赖 document/owners 不可变;这里不改变 document 或验证网络捕获身份。 */
|
||||
export function loadingQueryCoverage(snapshot:import('./contracts.ts').EngineSnapshot):import('../canvas-core/queries.ts').QueryCoverage {
|
||||
const loading=snapshot.runtime.loading,base=snapshot.runtime.loadingRead?.document??snapshot.runtime.acknowledged;
|
||||
if(!loading)return{projectComplete:true,topologyComplete:true,completeCanvasIds:[...snapshot.document.canvases.keys()]};
|
||||
@@ -146,7 +164,9 @@ function validateLoadingRead(runtime:RuntimeState,registry:Registry):void {
|
||||
for(const request of runtime.ledger.requests.values())if(request.kind!==undefined&&request.kind!=='document.change'||!request.canvasIds.length||new Set(request.canvasIds).size!==request.canvasIds.length||Object.keys(request.expectedVersions).length!==request.canvasIds.length||request.canvasIds.some(id=>!ready.has(id)||!Number.isSafeInteger(request.expectedVersions[id])||request.expectedVersions[id]<(source.versions.get(id)??Infinity)||request.expectedVersions[id]>(runtime.versions.get(id)??-1)))throw fault('invalid_loading_request');
|
||||
}
|
||||
/** Overlay only scopes whose ACK version advanced since this immutable capture. */
|
||||
/** 在新源前缀上只叠已推进 ACK 的效果,不叠未确认 pending;pending 由后续条件重放重建。 */
|
||||
/** 无 v3+ 保留源时直接返回 read;v4+ 用 appliedSource 叠已确认逻辑效果并验文档。
|
||||
* v3 只将版本已超过原捕获的 owner 成员整组换成 ACK,保留新 read 其它 owner、树与墓碑,再更新对应版本。
|
||||
* 不叠 pending、不修改 runtime.loadingRead/ACK;引擎随后重放未决意图并发布同一 Root。 */
|
||||
export function mergeLoadingRead(runtime:RuntimeState,read:CanonicalProposal,registry:Registry):CanonicalProposal {
|
||||
const source=runtime.loadingRead;if((!runtime.loading||runtime.loading.version<3)||!source)return read;
|
||||
if(runtime.loading.version>=4){const projected=appliedSource(runtime,read);validateDocument(projected.document,registry);return projected;}
|
||||
@@ -159,13 +179,17 @@ export function mergeLoadingRead(runtime:RuntimeState,read:CanonicalProposal,reg
|
||||
}
|
||||
/** 以指定元素列表重建源文档,保留同一项目/canvas/墓碑,创建标准只读表而非返回可变拼接对象。 */
|
||||
function importDocument(document:Document,elements:readonly import('../canvas-core/types.ts').ElementRecord[]):Document{return createDocument(document.projectId,[...document.canvases.values()],elements,document.tombstones);}
|
||||
/** 发送门:v1/v2不发,v3要求全部参与owner闭合,v4+要求每项符合结构范围;无loading正常放行。 */
|
||||
/** 加载范围发送门:无 loading 返回 true,v1/2 返回 false,v3+ 要求非空且全部参与范围有证明。
|
||||
* 不检查实际 operation/请求 body、身份/政策、依赖、导入 seal 或磁盘水位;scheduler/engine 还须叠加那些门禁。
|
||||
* v6 asset.upload 的空文档参与者由引擎专门准入,不因这里对空数组返回 false 就一概禁止资源上传。 */
|
||||
export function loadingCanSend(runtime:RuntimeState,canvasIds:readonly Id[],document=runtime.acknowledged):boolean {
|
||||
if(!runtime.loading)return true;if(runtime.loading.version<3)return false;if(runtime.loading.version>=4)return canvasIds.length>0&&canvasIds.every(id=>structuralRequestScope(runtime,runtime.loadingRead!,id));
|
||||
const ready=new Set(loadingQueryCoverage({runtime,document} as import('./contracts.ts').EngineSnapshot).completeCanvasIds);return canvasIds.length>0&&canvasIds.every(id=>ready.has(id));
|
||||
}
|
||||
|
||||
/** source+已applied changes 必须精确等于 ACK 和版本向量,拒绝旧tail抹去确认后的创建/删除/重挂。 */
|
||||
/** 用原 source+applied 意图重建期望,逐表/版本与 ACK 比较,再 validateDocument(ACK)。
|
||||
* 请求需符合协议 2/非空参与者/结构范围及版本区间;v6 上传请求例外留给资源专用校验。
|
||||
* 原回执是否真实、ledger 内部状态是否合法仍由外围校验保证,此步骤不查询服务器或修复不一致。 */
|
||||
function validateStructuralRead(runtime:RuntimeState,registry:Registry){
|
||||
const source=runtime.loadingRead!,expected=appliedSource(runtime,source),ack=runtime.acknowledged;
|
||||
if(expected.document.projectId!==ack.projectId||!equal(Object.fromEntries(expected.document.canvases),Object.fromEntries(ack.canvases))||!equal(Object.fromEntries(expected.document.elements),Object.fromEntries(ack.elements))||!equal(Object.fromEntries(expected.document.tombstones),Object.fromEntries(ack.tombstones))||!equal(Object.fromEntries(expected.versions),Object.fromEntries(runtime.versions)))throw fault('invalid_loading_read');
|
||||
@@ -173,7 +197,9 @@ function validateStructuralRead(runtime:RuntimeState,registry:Registry){
|
||||
for(const request of runtime.ledger.requests.values())if(!(runtime.loading?.version===6&&request.kind==='asset.upload')&&(request.protocolVersion!==2||!request.canvasIds.length||Object.keys(request.expectedVersions).length!==request.canvasIds.length||request.canvasIds.some(id=>!structuralRequestScope(runtime,source,id)||!Number.isSafeInteger(request.expectedVersions[id])||request.expectedVersions[id]<(source.versions.get(id)??0)||request.expectedVersions[id]>(runtime.versions.get(id)??0))))throw fault('invalid_loading_request');
|
||||
}
|
||||
|
||||
/** 原源owner须完整;不在源版本表的新canvas须有明确创建意图,不能凭任意ID绕过范围门。 */
|
||||
/** 源版本表中的 owner 按原捕获成员数量判完整;不在源表的 ID 须在某条意图中明确创建过 canvas。
|
||||
* 新 canvas 分支不按 ledger phase/resolution 过滤,也不单独检查它当前仍存在;实际 changes/请求验证还要继续。
|
||||
* 这是范围来源判定,不是任意 canvas 的权限或完整生命周期证明。 */
|
||||
export function structuralRequestScope(runtime:RuntimeState,source:CanonicalProposal,id:Id):boolean {
|
||||
if(source.versions.has(id))return ownerReady(runtime.loading!,source,source.document,id);
|
||||
return [...runtime.ledger.operations.values()].some(record=>record.intent.changes.some(change=>change.kind==='canvas'&&change.id===id&&!change.before&&!!change.after));
|
||||
|
||||
@@ -16,7 +16,10 @@ export type LiveModelPreview=ModelPreview&{readonly loading:import('./loading.ts
|
||||
export type ModelPreview = { readonly canvasElementCounts?:CanvasElementCounts;readonly elementOwners?:ElementOwners;readonly sourceCanvases?:readonly CanvasRecord[]; readonly document:Document;readonly receivedElements:number;readonly totalElements:number;readonly waitingElements:number;readonly complete:false };
|
||||
/** An ephemeral read projection. It owns neither a working document nor ACKs,
|
||||
* history, operations, file bytes or a durable recovery cut. */
|
||||
/** 建立单捕获投影器;一旦失败/终结就关闭,避免后续块继续污染合法旧预览。 */
|
||||
/** 一个流捕获的临时解释器:canvases 保存全源树,records 保存已收未发布元素,known 保存已发布命名空间键。
|
||||
* published/snapshot 是最后合法前缀,received/ownerCounts 统计接收量,二者不等同可见/可编辑范围。
|
||||
* 更新为同步建图/校验,无独立预算/取消或等待表容量上限;源规模与调用节奏由 loader/宿主约束。
|
||||
* 任一更新失败后禁止继续,公开旧 snapshot 不变,但不保证所有内部计数/临时表事务回滚。 */
|
||||
export function createModelPreview(registry:Registry){
|
||||
let elementOwners:ElementOwners|undefined;let canvasElementCounts:CanvasElementCounts|undefined;const ownerCounts=new Map<Id,number>();
|
||||
let header=false,finished=false,projectId='',expected=0,received=0;
|
||||
@@ -26,10 +29,14 @@ export function createModelPreview(registry:Registry){
|
||||
const key=(kind:'canvas'|'element',id:Id)=>JSON.stringify([kind,id]);
|
||||
// known 只存已经进入合法 published 前缀的键;records 暂存已收到但还未闭合的元素。
|
||||
const known=new Set<string>();
|
||||
/** 接收 header/增量/terminal;每批先校验所有记录,再计算新的最大闭合候选集合。 */
|
||||
/** 接收单次 canonical update,先校验批,再按依赖排除求最大可发布候选并验证整份前缀。
|
||||
* 不比较 sourceToken/serverVersions 或网络 scope;这些捕获身份由外层 LoadChunk/loader 管理。
|
||||
* 只有 receiving 更新会生成 ModelPreview;终态仅封闭解释器并返回旧 snapshot,complete 仍为 false。 */
|
||||
function update(value:CanonicalReadUpdate):ModelPreview|undefined{
|
||||
if(finished)throw fault('model_preview_closed');
|
||||
// terminal 必须含相同项目/树/墓碑和全部已收到记录,不能用另一份 fresh read 假装补完当前源。
|
||||
// 已有 header 时,terminal 必须 ready 且项目/全树/墓碑/接收总数与所有已收记录精确相符。
|
||||
// 这里不再次 validateDocument(terminal) 或要求 waiting=0;合法完整终态由 adapter/engine 另验。
|
||||
// 无 header 就先遇非 receiving 时直接结束并返回 undefined,不据此生成一个完整预览。
|
||||
if(value.status!=='receiving'){
|
||||
if(header&&(value.status!=='ready'||value.document.projectId!==projectId||received!==expected||value.document.canvases.size!==canvases.size||[...canvases].some(([id,canvas])=>!equal(value.document.canvases.get(id),canvas))||!equal(Object.fromEntries(value.document.tombstones),Object.fromEntries(tombstones))||value.document.elements.size!==expected||[...(published?.elements??[]),...records].some(([id,record])=>!equal(value.document.elements.get(id),record))))throw fault('model_preview_changed');
|
||||
finished=true;return snapshot;
|
||||
@@ -56,6 +63,7 @@ export function createModelPreview(registry:Registry){
|
||||
for(const record of value.elements){if(records.has(record.id)||known.has(key('element',record.id))||incoming.has(record.id)||!canvases.has(record.ownerCanvasId))throw fault('invalid_model_preview');validateElementRecord(record,registry,tombstones);if(elementOwners&&(!Object.hasOwn(elementOwners,record.id)||elementOwners[record.id]!==record.ownerCanvasId))throw fault('invalid_model_identity');incoming.add(record.id);}
|
||||
for(const record of value.elements){const count=(ownerCounts.get(record.ownerCanvasId)??0)+1;if(canvasElementCounts&&count>canvasElementCounts[record.ownerCanvasId])throw fault('invalid_model_coverage');ownerCounts.set(record.ownerCanvasId,count);}
|
||||
for(const record of value.elements)records.set(record.id,freeze(clone(record)));received=value.receivedElements;
|
||||
// 已知组件只按 copy.references 加数据引用依赖,unknown 组件保持 opaque,不猜其私有 ID。
|
||||
// Greatest closed subset: removing an unavailable dependency also removes its
|
||||
// dependants. Fully received cycles survive and publish in one legal prefix.
|
||||
// 候选图包含尚未发布的 canvas/记录,依赖为父/入口/owner/端点及组件明确声明引用。
|
||||
@@ -68,17 +76,20 @@ export function createModelPreview(registry:Registry){
|
||||
for(const ref of definition?.copy?.references??[])for(const id of copyReferenceIds(element.data,ref))deps.add(key(ref.target,id));
|
||||
candidates.set(key('element',element.id),{element,deps});
|
||||
}
|
||||
// 从“缺少且未收到的依赖”向反向依赖传播排除;完全收到的环可保留并在同一合法前缀发布。
|
||||
// dependants 是依赖键→使用者集合;queue 数组+游标作 FIFO,将缺失依赖的影响向引用者传播。
|
||||
// 非拓扑逐项插入:完整收到的环不会因没有入度零节点被丢弃,仍需通过最终文档校验才可发布。
|
||||
const dependants=new Map<string,Set<string>>(),blocked=new Set<string>(),queue:string[]=[];
|
||||
for(const [id,item]of candidates)for(const dependency of item.deps){let users=dependants.get(dependency);if(!users){users=new Set();dependants.set(dependency,users);}users.add(id);if(!known.has(dependency)&&!candidates.has(dependency)&&!blocked.has(id)){blocked.add(id);queue.push(id);}}
|
||||
for(let index=0;index<queue.length;index++)for(const id of dependants.get(queue[index])??[])if(!blocked.has(id)){blocked.add(id);queue.push(id);}
|
||||
// 在已发布前缀上加全部未阻塞候选,整份文档校验后才更新 published/known。
|
||||
// 先在旧 published 上加全部未阻塞候选,create/validate 成功后才更新 published/known 并移除 records。
|
||||
// 无新增候选时复用原 document,但接收计数仍能生成新 snapshot;等待中不意味着已丢弃该记录。
|
||||
const nextCanvases=[...(published?.canvases.values()??[])],nextElements=[...(published?.elements.values()??[])],accepted:string[]=[];
|
||||
for(const [id,item]of candidates)if(!blocked.has(id)){if(item.canvas)nextCanvases.push(item.canvas);if(item.element)nextElements.push(item.element);accepted.push(id);}
|
||||
if(!published||accepted.length){const next=createDocument(projectId,nextCanvases,nextElements,tombstones);validateDocument(next,registry);published=next;for(const id of accepted){known.add(id);const item=candidates.get(id);if(item?.element)records.delete(item.element.id);}}
|
||||
// 预览计数始终来自同捕获;v5/v6使用的 sourceCanvases 保留完整源拓扑,不由当前可见树猜。
|
||||
snapshot=Object.freeze({...(elementOwners?{elementOwners,sourceCanvases:freeze([...canvases.values()])}:{}),...(canvasElementCounts?{canvasElementCounts}:{}),document:published,receivedElements:received,totalElements:expected,waitingElements:received-published.elements.size,complete:false});return snapshot;
|
||||
}
|
||||
/** 对外 update 失败即封闭解释器;getSnapshot 只读旧合法前缀,close 清临时表而不删除业务文档。 */
|
||||
/** update 失败即封闭且保留最后合法 snapshot 供诊断;getSnapshot 初始/显式 close 后为 undefined。
|
||||
* close 清内部表和快照引用,不修改调用方已持有的不可变前缀,也不删除/撤销业务 engine 内容。 */
|
||||
return Object.freeze({update(value:CanonicalReadUpdate){try{return update(value);}catch(error){finished=true;throw error;}},getSnapshot:()=>snapshot,close(){finished=true;records.clear();canvases.clear();known.clear();published=undefined;snapshot=undefined;}});
|
||||
}
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -10,12 +10,15 @@ import {resourceRetentionPins} from './resource-retention.ts';
|
||||
|
||||
/** 盘点确认的文件身份/摘要与退休状态,不是上传资源元数据,也不是 DOM 图片缓存。 */
|
||||
export type OrphanFile={readonly version:1;readonly id:Id;readonly byteLength:number;readonly mimeType:string;readonly digest:string;readonly retirement?:FileRetirement};
|
||||
/** 检查 ID、字节数、MIME、SHA256 和退休解释器;失败不默默忽略损坏文件记录。 */
|
||||
/** 检查记录版本、键与 ID 相同、安全字节数、MIME 字符串、64 位小写十六进制摘要和退休标记。
|
||||
* 不读取 Blob 或重算 SHA;MIME 只要求字符串,不在此证明真实内容类型或元数据来自可信盘点。 */
|
||||
export function validateOrphanFile(value:unknown,id:Id):asserts value is OrphanFile {
|
||||
const file=value as OrphanFile;
|
||||
if(!file||file.version!==1||file.id!==id||!id||!Number.isSafeInteger(file.byteLength)||file.byteLength<0||typeof file.mimeType!=='string'||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest)||file.retirement!==undefined&&!validFileRetirement(file.retirement))throw fault('invalid_orphan_file');
|
||||
}
|
||||
/** 结合 pin 与所有未解释任务/审计证据给出回收阻碍;blockers 非空即 complete=false。 */
|
||||
/** 在 resourceRetentionPins 上叠加孤立文件专用阻碍,返回已知 pins 与 blockers。
|
||||
* 任意 run/recovery、非终态导入、copy-admission 或未解决的其它动作都阻止孤儿回收。
|
||||
* complete 只表示本函数列出的引用/生产者条件通过;不代表盘点到末页、文件散列完成或删除已持久化。 */
|
||||
export function orphanCoverage(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap<Id,number>,temporary:ReadonlyMap<Id,readonly Id[]>) {
|
||||
const pins=resourceRetentionPins(snapshot,registry,displays,temporary),blockers:string[]=[];
|
||||
if(!pins.complete)blockers.push('opaque_references');
|
||||
@@ -27,7 +30,10 @@ export function orphanCoverage(snapshot:EngineSnapshot,registry:Registry,display
|
||||
return Object.freeze({...pins,complete:blockers.length===0,blockers:Object.freeze(blockers)});
|
||||
}
|
||||
/** Registered orphan identities survive repinning. Purged identities cannot be forgotten or reassigned. */
|
||||
/** 在发布前维护孤儿事实与回收 reservations:身份不得与资源/导入源冲突,已 purge 不得改写。 */
|
||||
/** 在发布前维护孤儿记录:禁止与资源/源文件重名,旧记录不得消失,已 purge 记录必须完整相等。
|
||||
* 未 purge 记录允许盘点方更新摘要/长度等事实,本函数不读取字节来证明这种更新。
|
||||
* referencesChanged 和 Map 引用用于快路判断;调用方须准确报告文档/历史/账本等引用变化。
|
||||
* reservation 仅检查 next.orphanFiles 中对应 ID;跨文件类别的统一阻拦由外围发布钩子负责。 */
|
||||
export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,coverage:()=>{complete:boolean;pins:ReadonlyMap<Id,unknown>},reserved:ReadonlySet<Id>,referencesChanged:boolean):RuntimeState {
|
||||
if(!previous.orphanFiles?.size&&!next.orphanFiles?.size)return next;
|
||||
const hasSoft=[...(next.orphanFiles?.values()??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined);
|
||||
@@ -37,6 +43,7 @@ export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,cover
|
||||
for(const [id,file]of next.orphanFiles??[]){validateOrphanFile(file,id);if(next.resources.has(id)||sources.has(id))throw fault('local_file_identity_conflict',[id]);}
|
||||
for(const [id,file]of previous.orphanFiles??[]){const after=next.orphanFiles?.get(id);if(!after||file.retirement?.purgedAt!==undefined&&!equal(file,after))throw fault('local_file_retired',[id]);}
|
||||
// 覆盖不足/重 pin 取消软退休;永久删除后重 pin 明确拒绝,不能让模型引用失去字节的 ID。
|
||||
// 需要慢路才调用覆盖回调;即使引用覆盖未知,已 purge 项也仅在有明确 pin 时拒绝。
|
||||
const pins=coverage(),clear:Id[]=[];
|
||||
for(const [id,file]of next.orphanFiles??[]){
|
||||
if(reserved.has(id)&&(!pins.complete||pins.pins.has(id)))throw fault('resource_collection_in_progress',[id]);
|
||||
@@ -48,7 +55,9 @@ export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,cover
|
||||
}
|
||||
|
||||
/** Read-only I/O may finish late; cancellation detaches its result without publishing it. */
|
||||
/** 取消只让调用者停止采纳读取结果,不保证底层只读 I/O 被终止;迟到成功/失败均已接住。 */
|
||||
/** 包装已启动的 Promise:取消以 file_inventory_aborted 拒绝,不沿用 signal.reason。
|
||||
* 不终止底层只读 I/O;仍安装成功/失败处理器接住迟到结果,并在完成/取消时移除监听。
|
||||
* 不持有 Root,也不校验结果是否过期;盘点调用方还需在 await 后核对当前快照身份。 */
|
||||
export function abortableFileRead<T>(task:Promise<T>,signal:AbortSignal):Promise<T>{
|
||||
return new Promise((resolve,reject)=>{const abort=()=>{signal.removeEventListener('abort',abort);reject(fault('file_inventory_aborted'));};signal.addEventListener('abort',abort,{once:true});if(signal.aborted)abort();task.then(value=>{signal.removeEventListener('abort',abort);if(!signal.aborted)resolve(value);},error=>{signal.removeEventListener('abort',abort);if(!signal.aborted)reject(error);});});
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ import type { LedgerSnapshot } from './ledger.ts';
|
||||
|
||||
/** A complete read cannot rewind known scopes or change an unchanged-version canvas. */
|
||||
/** 校验完整 fresh read:同项目、合法文档、活 canvas 版本齐全、已知版本和墓碑均不可倒退。
|
||||
* 某 canvas 版本没变时,其记录/直属元素必须相同;全向量没变时墓碑集合也不能偷偷改变。 */
|
||||
* 某 canvas 版本没变时,其记录/直属元素必须相同;全向量没变时墓碑集合也不能偷偷改变。
|
||||
* 只校验模型一致性/单调性,不证明响应身份或读取来自何服务;传输认证与实际完整覆盖由端口保证。 */
|
||||
export function validateCanonicalRead(previous: Document, previousVersions: ReadonlyMap<Id,number>, next: Document, versions: ReadonlyMap<Id,number>, registry: Registry) {
|
||||
if(next.projectId!==previous.projectId)throw fault('project_mismatch');
|
||||
validateDocument(next,registry);
|
||||
for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]);
|
||||
// 活 canvas 必须有版本,但不要求版本表只含活项;已移除 scope 的已知水位也可继续保留。
|
||||
for(const canvas of next.canvases.keys())if(!versions.has(canvas))throw fault('missing_server_version',[canvas]);
|
||||
// 记录服务声明“版本未变”的 owner,随后双向检查元素,既防遗漏删除也防夹带新增。
|
||||
const unchanged=new Set<Id>();
|
||||
@@ -34,10 +36,12 @@ export function validateCanonicalRead(previous: Document, previousVersions: Read
|
||||
}
|
||||
|
||||
/** The footprint follows ownership; geometry does not decide which canvas owns an object. */
|
||||
/** 计算跨 canvas 操作 footprint:有已冻结意图范围时沿用,否则收集新旧 owner/父级/端点归属。
|
||||
* 上传和 run.cancel 不占文档参与者;空间位置或画框相交不决定 owner。 */
|
||||
/** 计算跨 canvas footprint:声明了非空 intent.canvasIds 就直接沿用,不以是否已冻结为条件。
|
||||
* 否则从变化收集新旧 owner/父级/端点归属;上传和 run.cancel 总是返回空文档参与者。
|
||||
* 不根据画框相交或 run 的 boardId/action 自动猜参与者;ID 有效性与编译覆盖另由边界核对。 */
|
||||
export function operationCanvases(intent: OperationIntent, document: Document, after:Document=document): readonly Id[] {
|
||||
if(intent.operationType==='asset.upload'||intent.operationType==='run.cancel')return[];
|
||||
// 已声明范围按原引用/顺序返回,不在此排序去重;仅下方推导路径用 Set 并排序。
|
||||
if (intent.canvasIds?.length) return intent.canvasIds;
|
||||
// 字段变化不能只查 after;重挂/改端点时旧 owner 也参与版本条件和占用。
|
||||
const ids = new Set<Id>();
|
||||
@@ -64,18 +68,21 @@ export function operationCanvases(intent: OperationIntent, document: Document, a
|
||||
|
||||
/** Replay once, by logical sequence. A receipt can already contain a desired field value. */
|
||||
/** 将本地生命周期水位叠到 ACK 副本,再按 sequence 回放 document.change/run.apply。
|
||||
* 只有已满足的 after 可安全跳过,条件冲突仍拒绝;不会自动改写原意图或冻结请求。 */
|
||||
* 底层按同 incarnation/字段存在性判断已满足 after,并检查 field owner 范围;条件冲突仍拒绝。
|
||||
* 不改写意图/冻结请求,不移动 history;historyEffect 只在此验证形状,不重新执行尺寸历史规划。 */
|
||||
export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document {
|
||||
/** 已 resolved 项不重放;未冻结、冻结或 blocked 的未解决文档意图仍按原事实参与重建。 */
|
||||
function* pending(){
|
||||
for(const record of [...ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence)){
|
||||
if(record.phase==='resolved'||!['document.change','run.apply'].includes(record.intent.operationType))continue;
|
||||
// 只筛 document.change/run.apply 的变化;上传、run.start/cancel 不因存在 changes 字段就在这里执行。
|
||||
if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect);
|
||||
yield {changes:record.intent.changes,canvasIds:record.intent.canvasIds};
|
||||
}
|
||||
}
|
||||
// 本地抵消可能从未进入服务器,水位只加到 working 候选,不能冒充服务器已确认墓碑。
|
||||
const document=replayChanges(withLocalLifecycles(base,ledger),pending());
|
||||
// 全部回放成功后检查最终工作文档;任何异常让调用者丢弃这份候选,不在此处理冲突 UI 或覆盖 ACK。
|
||||
validateDocument(document,registry);
|
||||
return document;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ import type {EngineSnapshot,RuntimeState} from './contracts.ts';
|
||||
import {createResourcePinScan} from './resources.ts';
|
||||
|
||||
/** Complete local-byte protection for one project scope; no DOM/viewport filtering. */
|
||||
/** 同时扫描 working 与 acknowledged;ACK 只额外取元素引用,避免把历史重复当另一套业务事实。 */
|
||||
/** 派生 resource ID → 保留理由 Set,同时扫描 working 与 acknowledged。
|
||||
* 复用同一 history/ledger 解释器,但 ACK 只额外合入 element: 理由;两次扫描完整性仍都参与结果。
|
||||
* displays 是资源 ID→租用数,temporary/resourcePins 是 owner→资源 ID 列表,不是同方向的表。
|
||||
* 返回捕获的只读表/集合;不写 runtime、不增减持久引用计数,也不盘点文件是否实际存在。 */
|
||||
export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap<Id,number>=new Map(),temporary:ReadonlyMap<Id,readonly Id[]>=new Map()) {
|
||||
const {runtime}=snapshot;
|
||||
const scan=createResourcePinScan(snapshot.history,runtime.ledger,registry);
|
||||
@@ -26,20 +29,26 @@ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,
|
||||
// 未结束/未封存导入和 opaque run 保留已知资源仓库,不假设尚未解释的 envelope 没有引用。
|
||||
for(const job of runtime.imports?.values()??[])if(!['completed','partial','cancelled'].includes(job.status)||importNeedsSeal(job)){if(job.source)add(job.source.fileId,`import-source:${job.id}`);for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`import:${job.id}`);}
|
||||
for(const run of runtime.runs.values())for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`run:${run.id}`);
|
||||
// imports 只在非终态或仍需 seal 时 pin,runs 则不按 status 过滤;它们保留已知且未 purge 的资源。
|
||||
// 这种保守 pin 不会自行把 complete 改为 false,也无法列出未知仓库 ID;全局/孤儿回收还需更强覆盖门槛。
|
||||
// 等待解码或没有解释器的状态也 pin;显示 URL 租用数与临时任务另外合并。
|
||||
for(const resource of runtime.resources.values())if(resource.image&&(resource.image.version!==1||resource.image.status==='pending'))add(resource.id,resource.image.version!==1?'image-decoder-unavailable':'image-decode');
|
||||
for(const [owner,ids]of temporary)for(const id of ids)add(id,owner);
|
||||
for(const [id,count]of displays)if(count>0)add(id,'display');
|
||||
// sizing 未知版本也降低覆盖完整性,即使本轮没有形成额外 pin;complete 不是文件可读/可删证明。
|
||||
return Object.freeze({complete:current.complete&&base.complete&&![...runtime.resources.values()].some(resource=>resource.image&&resource.image.version!==1||resource.sizing&&resource.sizing.version!==1),pins:readMap(new Map([...pins].map(([id,owners])=>[id,readSet(owners)])))});
|
||||
}
|
||||
|
||||
/** Runs inside the editor's single publication boundary, including reconcile and metadata commits. */
|
||||
/** 所有 Root 发布路径共同检查回收:reserved 正在原子删除时,不允许新 pin 或不完整覆盖闯入。 */
|
||||
/** 由 engine 的 Root 发布钩子调用:检查全局 reserved 集合,清除失效的软退休标记。
|
||||
* reserved 非空时,任一保留 ID 相交或扫描不完整都会拒绝发布;它本身不获取/释放 reservation。
|
||||
* 只按 next 检查保留状态,不比较 previous 来证明退休时钟单调,也不执行磁盘删除或等待保留期。 */
|
||||
export function prepareResourceRetention(next:EngineSnapshot,registry:Registry,displays:ReadonlyMap<Id,number>,reserved:ReadonlySet<Id>,temporary:ReadonlyMap<Id,readonly Id[]>=new Map()):RuntimeState {
|
||||
// 没有待回收工作/软标记时快速复用 runtime,避免每次普通编辑重扫整个资源集合。
|
||||
if(!reserved.size&&![...next.runtime.resources.values()].some(resource=>resource.retirement&&(resource.retirement.purgedAt===undefined||!resource.stored)))return next.runtime;
|
||||
const pins=resourceRetentionPins(next,registry,displays,temporary);
|
||||
if(reserved.size&&(!pins.complete||[...reserved].some(id=>pins.pins.has(id))))throw fault('resource_collection_in_progress',[...reserved]);
|
||||
// 没有标记需清除时沿用 runtime;首次真正修改才复制 resources 表,其余资源记录继续共享。
|
||||
let resources=next.runtime.resources;
|
||||
for(const [id,resource]of next.runtime.resources){
|
||||
if(!resource.retirement)continue;
|
||||
|
||||
@@ -8,7 +8,9 @@ import {fault} from '../canvas-core/values.ts';
|
||||
/** Snapshot-bound direct membership counts, including nodes and connections. */
|
||||
/** 当前捕获快照的直属数量,包含节点和边;不是递归子树大小或 DOM 已挂载数量。 */
|
||||
export type CanvasElementCounts=Readonly<Record<Id,number>>;
|
||||
/** 要求 counts 精确覆盖 canvasIds、每项非负安全整数,且总和等于声明 total。 */
|
||||
/** 检查自有可枚举键数量及 canvasIds 的每个键存在,计数为非负安全整数,总和安全且等于 total。
|
||||
* canvasIds 须由调用方保证唯一;此函数不单独去重,重复声明会破坏“键数量相同即覆盖相同”的前提。
|
||||
* 只验证声明内部一致,不读取真实元素、认证服务或证明后续 payload 已齐;输入也不在此捕获/冻结。 */
|
||||
export function validateCanvasElementCounts(value:unknown,canvasIds:readonly Id[],total:number):asserts value is CanvasElementCounts {
|
||||
if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_model_coverage');
|
||||
const counts=value as Record<Id,unknown>,keys=Object.keys(counts);let sum=0;
|
||||
@@ -20,7 +22,9 @@ export function validateCanvasElementCounts(value:unknown,canvasIds:readonly Id[
|
||||
/** All active IDs in the capture, including records not yet received. */
|
||||
/** 完整活 ID 命名空间,即使部分记录尚未传到客户端,也预先知道其直接 owner。 */
|
||||
export type ElementOwners=Readonly<Record<Id,Id>>;
|
||||
/** 核验每个活 ID 的 owner 合法,并按 owner 反算数量与 counts 一致,不靠已读前缀猜完整性。 */
|
||||
/** 要求声明恰有 total 个自有可枚举活 ID,每个非空 ID 的 owner 是 counts 中已有键,再反算数量。
|
||||
* counts 应先经计数校验;此处不独立验证其值域,也不与已读 document 比较 ID/owner 是否对应。
|
||||
* 只做同步声明一致性检查,不捕获/冻结输入;实际 payload 到达后还需匹配这份源快照身份表。 */
|
||||
export function validateElementOwners(value:unknown,counts:CanvasElementCounts,total:number):asserts value is ElementOwners {
|
||||
if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).length!==total)throw fault('invalid_model_identity');
|
||||
// 无原型计数表避免特殊 owner 键污染计数;空 canvas 由缺省 0 与 counts 比较。
|
||||
@@ -30,8 +34,10 @@ export function validateElementOwners(value:unknown,counts:CanvasElementCounts,t
|
||||
}
|
||||
|
||||
/** Full captured topology, independent of which entry payloads have arrived. */
|
||||
/** 用完整 topology+owner 证明验证单根、父存在、入口唯一/归属正确和无环。
|
||||
* 此时入口记录可能尚未收到,入口是否确为 node 仍需 payload 到达后的文档校验。 */
|
||||
/** 用源 canvas 表和已验证 owners 声明检查唯一根=projectId、父存在、入口唯一/归属与无环。
|
||||
* 不是完整 CanvasRecord/payload schema 校验;未收到入口时不能证明其为 node、组件可用或实际拥有关系一致。
|
||||
* 每个子 canvas 重新走父链,深树会反复访问共享祖先;同步检查无预算/取消,应由捕获规模约束成本。
|
||||
* 不捕获输入或标记文档已完整;加载器另需核对已收到树/元素与此声明,再建立 owner 的覆盖状态。 */
|
||||
export function validateCanvasTopology(canvases:readonly import('../canvas-core/types.ts').CanvasRecord[],owners:ElementOwners,projectId:Id):void {
|
||||
if(!Array.isArray(canvases)||!canvases.length)throw fault('invalid_model_topology');
|
||||
const table=new Map(canvases.map(canvas=>[canvas?.id,canvas]));if(table.size!==canvases.length||!table.has(projectId))throw fault('invalid_model_topology');
|
||||
|
||||
@@ -20,7 +20,9 @@ const K=new Uint32Array([
|
||||
/** 32位循环右移;区别于简单逻辑右移,低位从左侧回填。 */
|
||||
const rotate=(n:number,bits:number)=>(n>>>bits)|(n<<(32-bits));
|
||||
/** Incremental bytes only; owns at most a partial block, state and message schedule. */
|
||||
/** 新建单次摘要状态;允许多次 update,hex 终结后只可重复读结果,不能继续追加。 */
|
||||
/** 新建单次摘要状态,闭包拥有可变 state/block/words;冻结 API 不代表内部计算状态不变。
|
||||
* update 可多次追加,hex 首次终结后缓存结果,不能继续追加;不支持复制状态或导出恢复游标。
|
||||
* 纯同步计算,无异步让出/取消;分块大小与调度由读取 Blob 的调用方控制。 */
|
||||
export function createSha256(){
|
||||
// length 计原输入字节;pending 计尾块已占字节;result 缓存最终十六进制并标记终结。
|
||||
const state=new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]),block=new Uint8Array(64),words=new Uint32Array(64);let pending=0,length=0,result:string|undefined;
|
||||
@@ -35,7 +37,9 @@ export function createSha256(){
|
||||
const values=[a,b,c,d,e,f,g,h];for(let i=0;i<8;i++)state[i]=(state[i]+values[i])>>>0;
|
||||
}
|
||||
return Object.freeze({
|
||||
/** 先补上次尾块,再就地压缩输入完整块,最后只复制不足64字节的尾部。 */
|
||||
/** 先补尾块,再直接读取输入完整块压缩,最后只复制不足 64 字节的尾部,不保留输入引用。
|
||||
* 不修改调用方字节;累计长度必须保持安全整数,终结后即使追加空数组也拒绝。
|
||||
* 这里的就地指同步消费输入,不代表每次 update 只处理固定工作量或自动让出主线程。 */
|
||||
update(bytes:Uint8Array){
|
||||
if(result!==undefined)throw fault('digest_finalized');if(!(bytes instanceof Uint8Array)||!Number.isSafeInteger(length+bytes.byteLength))throw fault('invalid_digest_input');length+=bytes.byteLength;let offset=0;
|
||||
if(pending){const count=Math.min(64-pending,bytes.byteLength);block.set(bytes.subarray(0,count),pending);pending+=count;offset=count;if(pending===64){compress(block,0);pending=0;}}
|
||||
|
||||
@@ -9,7 +9,10 @@ import { planHistoryChanges } from '../canvas-core/history.ts';
|
||||
import { fault, freeze, readMap } from '../canvas-core/values.ts';
|
||||
|
||||
/** Rewind only proved local stack moves, then replay the explicitly retained traversals. Never publish the temporary cursor. */
|
||||
/** 输入原 pending、已审阅替代意图、保留选择与文档边界,返回新双栈/entry 候选而不发布。 */
|
||||
/** 兼容的纯遍历恢复:pending 的非空 changes 必须全是 undo/redo,同一历史项最多遍历一次。
|
||||
* 输入须为原逻辑顺序且其移动已反映在 snapshot.history;本函数不排序,也不验证恢复选择/替代意图来源。
|
||||
* owner 索引由 entry.operationIds 建立,重复关联会覆盖;协调器须先拒绝歧义并提供按 entry ID 的文档边界。
|
||||
* 返回私有两栈/entry 候选,不发布文档、发送逆操作或写审计;混合编辑/重复遍历交有完整证据的日志恢复处理。 */
|
||||
export function replanTraversals(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap<Id,OperationIntent|undefined>,kept:ReadonlySet<Id>,boundaries:ReadonlyMap<Id,{before:Document;after:Document}>,registry:Registry):HistoryState {
|
||||
const traversals=pending.filter(intent=>intent.changes.length);
|
||||
if(traversals.some(intent=>!['undo','redo'].includes(intent.origin)))throw fault('recovery_history_mixed_traversal_not_supported');
|
||||
@@ -25,12 +28,14 @@ export function replanTraversals(snapshot:EngineSnapshot,pending:readonly Operat
|
||||
const id=entryId(intent);if(seen.has(id))throw fault('recovery_history_repeated_traversal_not_supported',[id]);seen.add(id);
|
||||
if(intent.origin==='undo')move(redo,undo,id);else move(undo,redo,id);
|
||||
}
|
||||
// 再按原逻辑顺序重放保留选择;每条都核对新的栈顶和正反向 changes 可执行性。
|
||||
// 按原顺序逐项审阅,连丢弃项也要求命中当前栈顶;丢弃不重做旧移动,导致后项不可达时明确拒绝。
|
||||
// planHistoryChanges 只验证/规划候选方向,结果不在此作为业务文档发布。
|
||||
for(const intent of traversals){
|
||||
const id=entryId(intent),entry=entries.get(id)!,source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo;
|
||||
if(source.at(-1)!==id)throw fault('recovery_history_traversal_order',[id]);
|
||||
const replacement=replacements.get(intent.id),boundary=boundaries.get(id)!;
|
||||
// 保留但新基线已满足、没有 replacement 时,退休该项而非制造一条无法重做的假历史。
|
||||
// keep 但无 replacement 按协调器给出的“已满足”决定退休该项,不在此重新证明 fresh 文档已满足旧意图。
|
||||
// 有 replacement 时将 changes 统一回 entry 的正向语义,并用 boundary.after 检验反向可行性。
|
||||
if(kept.has(intent.id)){
|
||||
if(!replacement){source.pop();entries.delete(id);continue;} // No remaining local effect, retired with an audit rather than a fabricated redo.
|
||||
const changes=intent.origin==='undo'?invert(replacement.changes):replacement.changes;
|
||||
@@ -38,7 +43,8 @@ export function replanTraversals(snapshot:EngineSnapshot,pending:readonly Operat
|
||||
catch{throw fault('recovery_history_traversal_conflict',[id]);}
|
||||
move(source,destination,id);
|
||||
entries.set(id,freeze({...entry,changes,operationIds:entry.operationIds.map(operationId=>operationId===intent.id?replacement.id:operationId)}));
|
||||
// 丢弃该 pending 遍历不等于丢弃原 entry:保留经过校验的正向 changes,并移除旧操作关联。
|
||||
// 丢弃 pending 遍历保留原 entry 的正向变化,在 boundary.before 检验原遍历仍可执行,不移动两栈。
|
||||
// 只移除此 intent 的关联,保留同 entry 其它贡献/元数据;不将丢弃等同于清空整项历史。
|
||||
}else{
|
||||
const changes=intent.origin==='undo'?invert(intent.changes):intent.changes;
|
||||
try{planHistoryChanges(boundary.before,intent.origin==='undo'?invert(changes):changes,registry);}
|
||||
@@ -46,5 +52,6 @@ export function replanTraversals(snapshot:EngineSnapshot,pending:readonly Operat
|
||||
entries.set(id,freeze({...entry,changes,operationIds:entry.operationIds.filter(operationId=>operationId!==intent.id)}));
|
||||
}
|
||||
}
|
||||
// 未改 entry 沿用不可变记录引用;冻结两栈并捕获表成员,不替代外围对恢复 Root 的整体校验。
|
||||
return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries:readMap(entries)});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ export type WorkspaceOptions = {
|
||||
};
|
||||
|
||||
/** Workspace owns persistent projects; a view only borrows one and never closes it. */
|
||||
/** 创建工作区容器,不立刻打开所有项目,只有 open/openPartial 才取得项目 engine。 */
|
||||
/** 建立 workspace 共享池和 projectId→打开 Promise 表,不在构造时 claim 项目存储或恢复文档。
|
||||
* 注入的 digests/images/storage 也由本容器在最终 close 时关闭,跨 workspace 共享它们需宿主另行管理所有权。
|
||||
* options 仍由闭包读取,身份/端口应保持稳定;公开标量与 assetDepot scope 并非每次随外部 options 重写。
|
||||
* getProjects 捕获只读表成员,但 Promise 兑现的 engine 仍是业务 API,不是不可操作的纯数据快照。 */
|
||||
export function createWorkspaceRuntime(options: WorkspaceOptions) {
|
||||
const digests=options.digests??createDigestPool();
|
||||
const displays=createDisplayCache({onError:options.onError});
|
||||
@@ -33,7 +36,9 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) {
|
||||
/** 准入同时要求工作区仍接受任务和宿主身份仍有效,关闭一开始就让旧调用失效。 */
|
||||
const current = () => accepting && !closed && options.isCurrentIdentity();
|
||||
const assetDepot=options.storage.assetDepot?createAssetDepot({scope:options,port:options.storage.assetDepot,current,digest:(bytes,signal)=>digests.digest(bytes,signal)}):undefined;
|
||||
/** 取得/创建共享项目;打开失败只移除同一个 Promise,迟到失败不能删掉后来重开的实例。 */
|
||||
/** current 通过后按 document.projectId 复用在途/已打开 Promise,避免同 workspace 同项目重复 claim leader。
|
||||
* 命中时不以新 document/versions/loading 替换初始参数;open/openPartial 随后再协调新输入。
|
||||
* 打开拒绝仅移除仍对应自己的 Promise,准入有效时下次可重试;adapters 的同步抛错直接传出。 */
|
||||
function acquire(document:Document,versions:ReadonlyMap<Id,number>,loading?:LoadingState){
|
||||
if(!current())return Promise.reject(fault('identity_changed'));
|
||||
const existing=projects.get(document.projectId);if(existing)return existing;
|
||||
@@ -46,20 +51,29 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) {
|
||||
assetDepot,
|
||||
/** 查看项目打开句柄的只读表,不授予外部关闭/替换内部 ownership 的能力。 */
|
||||
getProjects: () => readMap(projects),
|
||||
/** 完整源与已有 engine 协调:同捕获完成走 loading.complete,旧版本不覆盖较新的本地基线。 */
|
||||
/** 取得 engine 后复查身份:同 sourceToken 的 loading 用 complete,否则尝试普通 refresh。
|
||||
* 任一传入版本低于当前已知值就跳过整个 refresh;其余一致性由 engine 校验,不在这里合并版本向量。
|
||||
* complete 拒绝总会抛错;refresh 拒绝仅在原 engine 仍 loading 时抛错,非 loading 可保留原 engine 返回。
|
||||
* 因此返回 engine 表示复用成功,不等于传入 document 已被采纳或服务器写入已完成。 */
|
||||
open(document: Document, versions: ReadonlyMap<Id, number>,settings:{sourceToken?:string}={}) {
|
||||
return acquire(document,versions).then(async engine=>{if(!current())throw fault('identity_changed');const loading=engine.loading.getState();if(loading&&settings.sourceToken===loading.sourceToken){const result=await engine.loading.complete(document,versions,{sourceToken:settings.sourceToken});if(result.status==='rejected')throw fault(result.code,result.targets);}else{const previous=engine.getSnapshot().runtime.versions;if(![...versions].some(([id,value])=>(previous.get(id)??0)>value)){const result=engine.refresh(document,versions);if(result.status==='rejected'&&loading)throw fault(result.code,result.targets);}}return engine;});
|
||||
},
|
||||
/** 接入部分源并复用项目 engine,仅相同 sourceToken 的后续块继续 accept。 */
|
||||
/** 新项目以 partial 文档/loading 打开;已有项目只在当前 loading.sourceToken 匹配时 accept。
|
||||
* 不同 token 或已无 loading 时直接返回原 engine,不替换其文档/加载任务;匹配但 accept 拒绝则抛错。 */
|
||||
openPartial(document:Document,versions:ReadonlyMap<Id,number>,loading:LoadingState){
|
||||
return acquire(document,versions,loading).then(engine=>{if(!current())throw fault('identity_changed');const previous=engine.loading.getState();if(previous?.sourceToken===loading.sourceToken){const result=engine.loading.accept(document,versions,loading);if(result.status==='rejected')throw fault(result.code,result.targets);}return engine;});
|
||||
},
|
||||
/** view 离开后工作区仍可补完项目加载;await 前后都检查身份,错误交宿主报告。 */
|
||||
/** view 离开后仍可 resume 已打开项目的背景加载;等待 opening 前后检查 current。
|
||||
* loading.resume 的在途身份/结果围栏由 engine 负责,此方法在其返回后不额外再查 current。
|
||||
* 错误仅在当前身份仍有效时报告 onError,随后原样拒绝;不吞错或清除项目句柄。 */
|
||||
async resumeLoading(projectId:Id){
|
||||
try{if(!current())throw fault('identity_changed');const opening=projects.get(projectId);if(!opening)throw fault('project_not_open');const engine=await opening;if(!current())throw fault('identity_changed');const result=await engine.loading.resume();if(result.status==='rejected')throw fault(result.code,result.targets);return result;}
|
||||
catch(error){if(current()){try{options.onError?.(error);}catch{}}throw error;}
|
||||
},
|
||||
/** 幂等/可重试关闭:复用当前 closing Promise,全部 engine 成功后才释放池和 storage。 */
|
||||
/** 先 accepting=false 永久停止此实例新准入,再单飞等待全部在途打开/engine 关闭。
|
||||
* 打开失败视为无需关闭;engine 关闭失败聚合抛错并保留项目/共享池,后续 close 可重试但不重新 accepting。
|
||||
* 项目全成功后才依次同步关闭共享端口;这段不是原子事务,注入端口 close 应可安全重复,抛错也允许重试。
|
||||
* 全部收尾成功才清 projects/置 closed;此接口不证明外部服务或服务器生成任务也已停止。 */
|
||||
close() {
|
||||
accepting = false; // Fence commands/transports before awaiting pending opens or storage.
|
||||
if (closed) return Promise.resolve();
|
||||
|
||||
Reference in New Issue
Block a user