12 Commits

39 changed files with 461 additions and 153 deletions
+20 -9
View File
@@ -27,32 +27,43 @@ export type AssetDepotPort={
read(leader:StorageLeader,authority:string,id:Id):Promise<DepotEntry|undefined>; 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>; 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{ 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'); 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'); 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}});} 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 一样就容忍摘要/尺寸变化。 */ /** 比较完整不可变引用,不能只因 ID 或 URL 一样就容忍摘要/尺寸变化。 */
const same=(a:SharedAsset,b:SharedAsset)=>equal(sharedAssetRef(a),sharedAssetRef(b)); const same=(a:SharedAsset,b:SharedAsset)=>equal(sharedAssetRef(a),sharedAssetRef(b));
/** Immutable metadata cache only. Every new adoption resolves current permission first. */ /** 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>}){ 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>(); 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');}; 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 的不可变引用;相同身份元数据改变视为冲突,不静默覆盖。 */ /** 缓存同 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));} 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), return Object.freeze({scope,getResources:()=>readMap(records),
/** 新采纳先远端 resolve,再比本地身份/读取原字节并验长度+SHA;每个 await 后重新检查取消/身份。 /** 新采纳先 resolve 当前权限,再核对缓存身份;有缓存字节仍验长度/摘要,否则向 authority 读取。
* 成功只返回 ref/bytes 和记元信息,目标 binding 与字节持久化由 engine 的单 Root 完成。 */ * 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);}}, 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;}, 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;}, 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();}, close(){closed=true;for(const controller of controllers)controller.abort();controllers.clear();records.clear();},
}); });
} }
+13 -5
View File
@@ -10,7 +10,8 @@ import { compressChanges, type LedgerSnapshot } from './ledger.ts';
/** 使用 UTF-8 实际字节衡量 wire 大小,不能把 JS 字符串长度当 HTTP payload 大小。 */ /** 使用 UTF-8 实际字节衡量 wire 大小,不能把 JS 字符串长度当 HTTP payload 大小。 */
const encoder = new TextEncoder(); const encoder = new TextEncoder();
/** 仅计算候选 JSON 表示的字节成本,不在这里发送或冻结请求。 */ /** 仅比较传入 JSON 的 UTF-8 字节;调用处测量 operation 数组,不含 HTTP 头/请求 envelope。
* 不在这里发送或冻结请求,也不估算压缩传输后的成本。 */
const wireBytes = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength; const wireBytes = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength;
/** wire 中保留的 logical ID 与对应条件 changes;不是新的本地历史单元。 */ /** 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))); 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. */ /** 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[] { export function projectBatch(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, participants: readonly Id[]): readonly WireOperation[] {
const scope = new Set(participants); const scope = new Set(participants);
// 回退表示创建新的外层数组/条目,但 changes 沿用输入引用;仅成功压缩的结果走末尾 freeze。
const ordered = intents.map(({ id, changes }) => ({ id, changes })); const ordered = intents.map(({ id, changes }) => ({ id, changes }));
if (intents.length < 2) return ordered; if (intents.length < 2) return ordered;
// 只收集这段触及的基线记录组成 sparse 文档,减少复制/比较完整项目的成本。 // 只收集这段触及的基线记录组成 sparse 文档,减少复制/比较完整项目的成本。
@@ -52,9 +57,11 @@ export function projectBatch(intents: readonly OperationIntent[], acknowledged:
// wire 投影保留生命周期和 no-op 检查,完整零效果段由独立 elision 证明处理。 // wire 投影保留生命周期和 no-op 检查,完整零效果段由独立 elision 证明处理。
const compacted = compressChanges(raw, { preserveLifecycle: true, preserveChecks: true }); const compacted = compressChanges(raw, { preserveLifecycle: true, preserveChecks: true });
if (!compacted.length) return ordered; if (!compacted.length) return ordered;
// 原逻辑 ID 顺序逐项保留;函数本身不验证输入 ID 唯一性,去重身份由 ledger/请求编译边界保证。
// Protocol 2 requires a nonempty change list per logical ID. Prefix IDs get an explicit // 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. // conditional no-op; the final ID carries the composed effect. Input ID order is retained.
// 从参与 owner 的少量元素 title/text 或根 canvas 选 no-op guard,不扩大原 claim 范围。 // 从参与 owner 的基线元素选 title/text 条件 no-op,最多查看 8 个候选元素,每个产生两个 guard。
// guard 表达的是基线值/存在性,不是某个前缀逻辑操作的独立最终效果;最后一项统一携带组合变化。
const guards: Change[] = []; const guards: Change[] = [];
let candidates = 0; let candidates = 0;
for (const element of elements.values()) if (scope.has(element.ownerCanvasId)) { 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; if (!guard) return ordered;
const projected = intents.map((intent, index) => ({ id: intent.id, changes: index === intents.length - 1 ? compacted : [guard!] })); 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; 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)); 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'); if (!sameMap(original.elements, result.elements) || !sameMap(original.canvases, result.canvases) || !sameMap(original.tombstones, result.tombstones)) throw fault('batch_projection_mismatch');
return freeze(projected); return freeze(projected);
+6 -1
View File
@@ -5,7 +5,10 @@
import {fault} from '../canvas-core/values.ts'; import {fault} from '../canvas-core/values.ts';
import {createDigestPool} from './digest-pool.ts'; import {createDigestPool} from './digest-pool.ts';
/** Browser capability stays at the adapter; WorkspaceRuntime owns the pool's lifetime. */ /** 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(){ export function createBrowserDigests(){
let next=0; let next=0;
return createDigestPool({execute:typeof Worker==='undefined'?undefined:(bytes,signal)=>new Promise<string>((resolve,reject)=>{ 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; const id=++next;let done=false;
/** 幂等完成:解绑所有消息/错误/abort 监听并终止 Worker,防止迟到响应泄漏或重复 resolve。 */ /** 幂等完成:解绑所有消息/错误/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')); 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位十六进制摘要;已知错误也必须匹配身份。 // 消息须精确匹配版本、任务 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')); 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。 // Blob 通过结构化克隆传递,不在 UI 主线程先读取整份 arrayBuffer。
+4 -1
View File
@@ -4,7 +4,9 @@
*/ */
import {fault} from '../canvas-core/values.ts'; import {fault} from '../canvas-core/values.ts';
import {createImageDecodePool,type ImageDimensions} from './image-decoder.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)=>{ 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;} if(typeof Worker==='undefined'){reject(fault('image_decoder_unavailable'));return;}
// 每任务一个 Worker,取消只 terminate 自己,不会打断其它图片的解码。 // 每任务一个 Worker,取消只 terminate 自己,不会打断其它图片的解码。
@@ -12,6 +14,7 @@ export function createBrowserImageDecoder(){let next=0;return createImageDecodeP
/** finish 幂等解除消息/错误/abort 监听并 terminate;abort 在监听安装后再检查一次覆盖竞态。 */ /** 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')); 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;} signal.addEventListener('abort',abort,{once:true});if(signal.aborted){abort();return;}
// Worker error 会 preventDefault 并转稳定错误,消息无法反序列化则拒绝;所有路径共用 finish 终止线程。
// 只采纳精确匹配请求的成功尺寸或已知错误;未知错误码/错误协议作为 invalid response 拒绝。 // 只采纳精确匹配请求的成功尺寸或已知错误;未知错误码/错误协议作为 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')); 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。 // 直接传不可变 Blob,不先在 UI 线程转成像素或整份 ArrayBuffer。
+26 -10
View File
@@ -13,7 +13,10 @@ import { equal, fault, freeze, readMap } from '../canvas-core/values.ts';
/** 原操作相关对象的前后记录及 owner 变换;用于区分用户意图与仅由坐标转换引起的差异。 */ /** 原操作相关对象的前后记录及 owner 变换;用于区分用户意图与仅由坐标转换引起的差异。 */
export type IntentContext = { readonly before:ReadonlyMap<Id,ElementRecord>; readonly after:ReadonlyMap<Id,ElementRecord>; readonly transforms:ReadonlyMap<Id,Transform>; readonly beforeTransforms:ReadonlyMap<Id,Transform> }; 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. */ /** 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> { 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 elements=new Map(document.elements),canvases=new Map(document.canvases),contexts=new Map<Id,IntentContext>();
const cursor={...document,elements,canvases}; 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 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)); 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()){ 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);} 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);} 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);}; 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);}); 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 { function presentationDelta(current:Json|undefined,before:Json|undefined,after:Json|undefined):Json|undefined {
if(equal(before,after))return current; 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'); 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。 */ * 用户明确改变的中心/尺寸/旋转取其意图值,再转到 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){ 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); 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 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 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); 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. */ /** 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[]{ function orderedCreates(changes:readonly Change[]):readonly Change[]{
const result:Change[]=[]; const result:Change[]=[];
/** before=null 且 after 存在才是创建,整记录替换不能冒充依赖排序候选。 */ /** before=null 且 after 存在才是创建,整记录替换不能冒充依赖排序候选。 */
@@ -101,17 +110,22 @@ function orderedCreates(changes:readonly Change[]):readonly Change[]{
return result; return result;
} }
/** Re-plan against current data. Whole records are used only for explicit creation/removal. */ /** Re-plan against current data; whole element replacement is unsupported. */
/** 在 fresh 私有文档上重新生成条件 changes,原 intent/context 保持不变;最终完整校验才返回。 */ /** 在 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[]} { export function rebaseIntent(document:Document,intent:OperationIntent,context:IntentContext,registry:Registry):{document:Document;changes:readonly Change[]} {
// moved 标记需整体世界空间重规划的节点;deferredPaths 等端点/树最终确定后再算曲线。 // moved 标记需整体世界空间重规划的节点;deferredPaths 等端点/树最终确定后再算曲线。
const before=document,changes:Change[]=[],moved=new Set<Id>(),deferredPaths:FieldChange[]=[]; 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); 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。 */ /** 每条新变化立即作用于私有 candidate,后面的条件读取已重规划后的实际 before。 */
function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));} 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;} 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]);} 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)){ 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))}; after={...after,ownerCanvasId:owner,path:convertPath(after.path,basis,canvasTransform(document,owner))};
} }
apply({...original,after}); apply({...original,after});
// 已经不存在的删除可满足;仍存在时按 fresh 拥有树规划移除,不能直接覆盖旧整记录。 // 删除目标已不存在时直接满足,不在此以墓碑证明它曾是哪一代;仍存在则要求旧代次和已知类型。
// 按 fresh 拥有树重新规划删除,可包含远端新增子内容/关联边,而不是照抄旧删除清单。
}else if(original.before&&original.after===null){ }else if(original.before&&original.after===null){
if(!current)continue; if(!current)continue;
if(current.incarnation!==original.before.incarnation)throw fault('stale_incarnation',[original.id]); 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(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}); 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); for(const change of repairConnectionOwners(before,document))apply(change);
validateDocument(document,registry);return{document,changes:freeze(changes)}; validateDocument(document,registry);return{document,changes:freeze(changes)};
} }
+5 -2
View File
@@ -5,12 +5,15 @@
import {fault} from '../canvas-core/values.ts'; import {fault} from '../canvas-core/values.ts';
import {createImportBudget,type ImportScheduling} from './import-budget.ts'; import {createImportBudget,type ImportScheduling} from './import-budget.ts';
/** Share import scheduling while retaining the copy cancellation/error contract. */ /** 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={}){ export function createCopyBudget(signal:AbortSignal,options:ImportScheduling={}){
if(signal.aborted)throw fault('copy_aborted'); if(signal.aborted)throw fault('copy_aborted');
if(options.frameBudgetMs!==undefined&&(!Number.isFinite(options.frameBudgetMs)||options.frameBudgetMs<=0))throw fault('invalid_copy_budget'); if(options.frameBudgetMs!==undefined&&(!Number.isFinite(options.frameBudgetMs)||options.frameBudgetMs<=0))throw fault('invalid_copy_budget');
const budget=createImportBudget(signal,options); 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;}; const fail=(error:unknown):never=>{if(signal.aborted)throw fault('copy_aborted');throw error;};
/** 每次step先查copy取消,保持底层同步返回undefined或异步让出的原调度语义。 */ /** 每次step先查copy取消,保持底层同步返回undefined或异步让出的原调度语义。 */
return Object.freeze({step(units=1):Promise<void>|undefined{ return Object.freeze({step(units=1):Promise<void>|undefined{
+10 -2
View File
@@ -12,11 +12,15 @@ import type {StorageLeader,StoragePort} from './storage-port.ts';
import {resourceRetentionPins} from './resource-retention.ts'; import {resourceRetentionPins} from './resource-retention.ts';
/** Derive one project's shared-byte manifest from the exact Root being persisted. */ /** 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>{ 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[]=[]; 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'); 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){ 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}); 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]); 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)){ if(resource.retirement?.purgedAt===undefined&&pins.pins.has(resource.id)){
const cached=await storage.assetDepot.read(leader,authority,resource.id); const cached=await storage.assetDepot.read(leader,authority,resource.id);
// The store enforces immutable identity even when bytes are already present. // 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); if(!cached?.bytes)bytes=input.files.get(resource.id)??await storage.readFile(leader,resource.id);
} }
writes.push({ref,...(bytes?{bytes}:{})}); 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]); 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}:{})}); 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. // 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. // Its complete resource interpreter must be installed before global deletion is allowed.
// 引用、加载与任务解释都完整才允许全局回收;opaque run/未完导入不能假定无共享资源引用。 // 引用、加载与任务解释都完整才允许全局回收;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; 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}; return{version:1,authority,complete,pins:[...pins.pins].map(([id,owners])=>[id,[...owners]] as const),writes};
} }
+11 -4
View File
@@ -8,7 +8,9 @@ import {importSourceDigest} from './import-integrity.ts';
/** 可替换摘要端口,输出完整 Blob 的小写 SHA256;close 终止本池,不清业务数据或外部回执。 */ /** 可替换摘要端口,输出完整 Blob 的小写 SHA256;close 终止本池,不清业务数据或外部回执。 */
export type DigestPort={digest(bytes:Blob,signal?:AbortSignal):Promise<string>;close():void}; 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. */ /** 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{ 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; 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'); 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}; type Task={id:number;bytes:Blob;controller:AbortController;resolve:(value:string)=>void;reject:(error:unknown)=>void;cleanup:()=>void;done:boolean};
// Map 既保留入队次序,又可在等待期取消时 O(1) 删除;active 保留真实未结束执行的容量占用。 // Map 既保留入队次序,又可在等待期取消时 O(1) 删除;active 保留真实未结束执行的容量占用。
const waiting=new Map<number,Task>(),active=new Map<number,Task>();let next=0,closed=false; 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!);} 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(){ function pump(){
if(closed)return; if(closed)return;
while(active.size<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task); 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')); 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();}); 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'));}}, 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. /** Dedicated Worker only: whole-buffer native hashing never moves onto the UI thread.
* The host still owns cancellation by terminating the request's Worker. */ * 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>{ 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'); if(!(bytes instanceof Blob))throw fault('invalid_digest_input');
// native digest 必须返回32字节;输出固定64字符十六进制,不接受变长响应。 // 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'); if(digest.byteLength!==32)throw fault('invalid_digest_response');
return [...new Uint8Array(digest)].map(value=>value.toString(16).padStart(2,'0')).join(''); 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()}); return importSourceDigest(bytes,undefined,{chunkBytes:1024*1024,yieldTask:()=>Promise.resolve()});
} }
+3 -1
View File
@@ -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}; const scope=globalThis as unknown as {onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void};
/** 首条有效请求被接受后保持 true,直到整个 Worker 被宿主终止。 */ /** 首条有效请求被接受后保持 true,直到整个 Worker 被宿主终止。 */
let accepted=false; let accepted=false;
/** 校验协议和请求后启动只读摘要;成功/失败都回显输入 ID 与字节数,供宿主核对。 */ /** 首条有效 version=1/正安全整数 ID/Blob 设置 accepted,之后包括失败后的重复消息都忽略。
* 无效请求直接忽略,不返回错误;这里不检查 MIME/大小或资源身份,不拥有持久任务。
* 算法拒绝会尝试回稳定错误;成功回信自身抛错不由同一个 then 的失败回调捕获,宿主仍需错误/取消收尾。 */
scope.onmessage=event=>{ 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; 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'})); 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'}));
+11 -4
View File
@@ -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 * durable bytes before acquiring. Scope objects are never serialized or reused
* across project engines/auth epochs. There is no idle URL retention. * 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 } = {}) { export function createDisplayCache(options: { onError?: (error: unknown) => void } = {}) {
// owners 仅保留活条目;retired WeakSet 禁止退役 owner 再 acquire,但不强持有其内存。 // owners 仅保留活条目;retired WeakSet 禁止退役 owner 再 acquire,但不强持有其内存。
const owners = new Map<object, Map<string, Entry>>(), retired = new WeakSet<object>(); const owners = new Map<object, Map<string, Entry>>(), retired = new WeakSet<object>();
let closed = false; 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. */ } } } function dispose(source: DisplaySource) { try { source.release?.(); } catch (error) { try { options.onError?.(error); } catch { /* Reporting cannot interrupt cleanup. */ } } }
/** 幂等退休共享条目,仅删除仍指向自己的表项,避免旧任务删掉同 key 的新 entry。 */ /** 幂等退休共享条目,仅删除仍指向自己的表项,避免旧任务删掉同 key 的新 entry。 */
function retire(entry: Entry) { function retire(entry: Entry) {
@@ -41,7 +44,9 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
} }
} }
return Object.freeze({ 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 { acquire(owner: object, key: string, load: (signal: AbortSignal) => Promise<DisplaySource>): DisplayLease {
if (closed || retired.has(owner)) throw fault('display_scope_closed'); if (closed || retired.has(owner)) throw fault('display_scope_closed');
if (!owner || typeof owner !== 'object' || typeof key !== 'string' || !key) throw fault('invalid_display_key'); 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'); if (created.retired) throw fault('display_released');
return load(created.controller.signal); return load(created.controller.signal);
}).then(source => { }).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'); } 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,不允许挂回已无消费者的缓存。 // 即使 load 不响应 abort,迟到返回也只能 dispose,不允许挂回已无消费者的缓存。
if (created.retired || !created.leases.size) { dispose(source); throw fault('display_released'); } 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); if (!shared.leases.size) retire(shared);
}; };
shared.leases.add(release); shared.leases.add(release);
// 将共享 promise 映射成每租约的独立 ready;来源失败时本租约也自动归还,不泄漏计数。 // 映射共享 promise 到各 lease.ready,失败逐个归还;校验 source 时抛错也沿这条失败路径清理。
// 缓存不吞 ready 的拒绝,消费者需接住取消/加载错误;释放后迟到成功不能再次 resolve 本租约。
const ready = new Promise<string>((resolve, reject) => { const ready = new Promise<string>((resolve, reject) => {
rejectReady = reject; rejectReady = reject;
shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } }); shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } });
+12 -3
View File
@@ -13,7 +13,10 @@ import { duplicateImportLine } from './import-duplicate.ts';
import type { ImportSource } from './imports.ts'; import type { ImportSource } from './imports.ts';
/** A seekable record cursor over the original SHA-bound snapshot, not a second mapped Blob. */ /** 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{ 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'); 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. // 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])); 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}`); 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. // 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>{ function projection<T extends ElementRecord|CanvasRecord>(base:ReadonlyMap<Id,T>,reverse:ReadonlyMap<Id,Id>,kind:'element'|'canvas'):ReadonlyMap<Id,T>{
// 未映射ID沿用原表,新ID转换源记录;缓存淘汰只影响计算成本,不改变稳定映射/源数据。 // 未映射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;}; 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总数。 // 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; 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){ 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; 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]}`; const task=state.order[cursor],key=task?`${task.kind}:${task.id}`:`element:${state.edges[cursor-state.order.length]}`;
+17 -4
View File
@@ -23,7 +23,10 @@ export type DuplicateScheduling = {
* No document publication or network effects. The caller must persist source/mapping and * 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. * 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> { 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; 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'); 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. */ /** 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>{ 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))); 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'); 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. */ /** 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>{ 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))); 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'); 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; 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'); if(!Number.isFinite(interval)||interval<0||!Number.isSafeInteger(maxCheckpointSteps)||maxCheckpointSteps<1)throw fault('invalid_copy_budget');
const planner=createDuplicatePlanner(document,registry,selection,options,binding); 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(); 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){ while(true){
const start=now();let count=0; const start=now();let count=0;
+15 -6
View File
@@ -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. */ /** Reconstruct a closed lifetime from an empty fragment. Existing objects cannot enter this proof. */
/** 在空记录片段上重建至多 64 意图/10000 changes,验证每个对象首触及都是创建且最终全删。 /** 在空记录片段上重建至多 64 意图/10000 changes,验证每个对象首触及都是创建且最终全删。
* 墓碑变化是允许保留的本地效果,不等于服务器执行过这些创建/删除。 */ * 本函数不读 ledger 阶段、basePresence、请求或依赖,不独自证明这些操作从未发送/实际基线 absent。
* 只做条件变化与生命周期闭合检查,不调用 registry/整图校验;临时片段可无根,最终只保留本地墓碑。 */
export function proveElision(intents: readonly OperationIntent[], baseTombstones: Readonly<Record<Id, number>>): ElisionProof { 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'); 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>(); 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'); 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)) }; const fragment: Document = { projectId: '', elements: new Map(), canvases: new Map(), tombstones: new Map(Object.entries(baseTombstones)) };
// 条件执行仍使用真实 applyChanges;before 不匹配不能因最终想删除就被忽略。 // 条件执行仍使用真实 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. */ /** Longest closed prefix before an existing-object write or another unsafe precondition. */
/** 寻找最长安全闭合前缀;遇 frozen/blocker/外部未解依赖/已有对象首写即停止扩展。 /** 在传入候选顺序内寻找最长闭合前缀,检查 pending/未绑请求、基线和依赖;不扫描全账本证明中间无漏项。
* preceding 只允许前面已选独立真实段作为依赖屏障,不把它当作已经 applied 的回执。 */ * 全账本连续性还由 ledger.elide/validateElisions 复核;preceding 只给批次边界规划暂时满足前段依赖。
* undefined 表示没找到;条件回放失败或闭合证明非法仍会抛错,不统一吞成 undefined。 */
export function findElision(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, preceding: ReadonlySet<Id> = new Set()): ElisionProof | 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); 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; 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> = {}; let bestLength = 0, bestBases: Record<Id, number> = {};
@@ -57,6 +61,7 @@ export function findElision(intents: readonly OperationIntent[], acknowledged: D
for (const intent of intents) { for (const intent of intents) {
const record = ledger.operations.get(intent.id); 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; 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; if (intent.dependencies.some(id => !selected.some(intent => intent.id === id) && !preceding.has(id) && ledger.operations.get(id)?.phase !== 'resolved')) break;
let safe = true; let safe = true;
for (const change of intent.changes) { 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. */ /** 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 { 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; 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>(); 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. */ /** Validate persisted local facts independently of the current (possibly newer) server baseline. */
/** 恢复时按逻辑顺序复验持久抵消组:成员连续、依赖闭合、anchor 唯一、证明与水位一致。 /** 恢复时按逻辑顺序复验持久抵消组:成员连续、依赖闭合、anchor 唯一、证明与水位一致。
* 不依赖当前可能更新过的 ACK 基线来重新猜历史 absent 事实。 */ * 不依赖当前可能更新过的 ACK 基线来重新猜历史 absent 事实,而是复核保存的 basePresence/proof。
* 它不向服务器查询当年是否接收过请求;此前的持久记录捕获与身份校验仍由恢复边界负责。 */
export function validateElisions(ledger: Pick<LedgerSnapshot, 'operations'>): void { 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 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>(); 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. */ /** 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 { export function withLocalLifecycles(document: Document, ledger: Pick<LedgerSnapshot, 'operations'>): Document {
const tombstones = new Map(document.tombstones); 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)); 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));
+6 -2
View File
@@ -11,13 +11,16 @@ export type HistoryReplayEvent={readonly kind:'operation';readonly operationId:I
/** version=1 的 anchor+有序事件;所有 transition 绑定同一次 adoption Root revision。 */ /** version=1 的 anchor+有序事件;所有 transition 绑定同一次 adoption Root revision。 */
export type HistoryReplay={readonly version:1;readonly anchor:HistoryTransition;readonly events:readonly HistoryReplayEvent[]}; 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. */ /** 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{ 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(!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>(); 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');} 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{ export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,hasOperation?:(id:Id)=>boolean):HistoryState{
validateHistoryReplay(replay,replay.anchor.rootRevision);let history=applyHistoryTransition(before,replay.anchor,'forward'); 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]);} 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 插入顺序差异被误认作历史不一致。 */ /** 比较两栈和全部 entry;entry 表按 ID 排序,避免 Map 插入顺序差异被误认作历史不一致。 */
export function sameHistory(left:HistoryState,right:HistoryState):boolean{ 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)); 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));
} }
+16 -6
View File
@@ -11,7 +11,10 @@ import { createScopedLoader, type LoadPort, type ScopedLoader } from './loader.t
import type { CanonicalReadUpdate } from './contracts.ts'; import type { CanonicalReadUpdate } from './contracts.ts';
/** Host lifetime matches the signed-in application, independently of navigation. */ /** 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 }) { 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; let accountId: Id | null = null, authEpoch = 0, disposed = false;
// active 表只含当前身份,retired 持有尚未成功关闭的旧 runtime,不能因换账号就丢掉待保存内容。 // active 表只含当前身份,retired 持有尚未成功关闭的旧 runtime,不能因换账号就丢掉待保存内容。
@@ -25,7 +28,8 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
}; };
/** 同步撤销当前身份:先换 epoch,再关闭读取句柄/移走 workspace;实际存储收尾另行等待。 */ /** 同步撤销当前身份:先换 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(); } 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) { function activate(account: Id) {
if (disposed) throw fault('host_disposed'); if (disposed) throw fault('host_disposed');
if (accountId !== account) { if (accountId !== account) {
@@ -42,7 +46,10 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
identity: () => Object.freeze({ accountId, authEpoch }), identity: () => Object.freeze({ accountId, authEpoch }),
/** 返回当前 workspace 表的安全只读快照,外部不能手动增删运行实例。 */ /** 返回当前 workspace 表的安全只读快照,外部不能手动增删运行实例。 */
getWorkspaces: () => readMap(workspaces), 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}={}) { async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:LiveModelPreview)=>void}={}) {
activate(account); activate(account);
const readEpoch=authEpoch; const readEpoch=authEpoch;
@@ -60,7 +67,8 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
}; };
// key 纳入账号、workspace、格式、查询与 authEpoch;view 导航代次不替代授权代次。 // 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}); 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();} 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;导航到同工作区不会重新创建资源池或项目。 */ /** 按当前账号激活并懒创建 workspace;导航到同工作区不会重新创建资源池或项目。 */
@@ -74,9 +82,11 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
} }
return runtime; return runtime;
}, },
/** 退出身份但保留 host 可再次绑定;返回旧 runtime 收尾 Promise,让宿主处理未保存错误。 */ /** 同步清账号并递增 epoch,关闭读句柄,再等待退休 runtime;不永久 disposed,之后可重新绑定。
* 不等待 close 成功也已使旧 current 失效;失败不会恢复旧身份或删除退休实例。 */
retireIdentity() { retire(); return closeRetired(); }, retireIdentity() { retire(); return closeRetired(); },
/** 永久关闭 host 并退休全部作用域;失败仍可再次调用 close 重试退休集合。 */ /** 永久 disposed 后再退休/等待关闭,后续 workspace/bind/load 准入拒绝。
* 失败可再次 close 重试;每次都会再次 retire/推进 epoch,返回值不是共享的单飞关闭 Promise。 */
close() { disposed = true; retire(); return closeRetired(); }, close() { disposed = true; retire(); return closeRetired(); },
}); });
} }
+12 -4
View File
@@ -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);} 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. */ /** 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{ 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; 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'); if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_image_decode_budget');
/** 每个解码请求独立控制器/完成标记;cleanup 解除外部取消监听,迟到执行结果不再采纳。 */ /** 每个解码请求独立控制器/完成标记;cleanup 解除外部取消监听,迟到执行结果不再采纳。 */
type Task={id:number;bytes:Blob;controller:AbortController;resolve:(size:ImageDimensions)=>void;reject:(error:unknown)=>void;done:boolean;cleanup():void}; 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; 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!}));} 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();});}} 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'));}}}); 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 -1
View File
@@ -4,11 +4,14 @@
*/ */
/** 仅声明本入口需要的 Worker 消息能力,不导入 renderer 或 DOM 状态。 */ /** 仅声明本入口需要的 Worker 消息能力,不导入 renderer 或 DOM 状态。 */
const worker=globalThis as unknown as{onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void}; 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=>{ worker.onmessage=async event=>{
const {version,id,bytes}=event.data??{};if(version!==1||!Number.isSafeInteger(id)||!(bytes instanceof Blob))return; const {version,id,bytes}=event.data??{};if(version!==1||!Number.isSafeInteger(id)||!(bytes instanceof Blob))return;
// bitmap 是临时原生资源,尺寸读取即使抛错也由 finally close;成功只发送 width/height 和输入长度。 // 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});} 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'});} catch{worker.postMessage({version:1,id,byteLength:bytes.size,error:'image_decode_failed'});}
}; };
// 标记为独立 TypeScript 模块,避免 Worker 全局声明与其它源码脚本命名空间合并。 // 标记为独立 TypeScript 模块,避免 Worker 全局声明与其它源码脚本命名空间合并。
+6 -2
View File
@@ -4,7 +4,9 @@
*/ */
/** Runner-owned feedback, not persisted job state. Probe bounded batches to /** Runner-owned feedback, not persisted job state. Probe bounded batches to
* distinguish fixed publication cost from the marginal cost of another unit. */ * 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){ export function createImportBatches(budget:number,max:number){
// 初始未观测时保守为1;observed只表示收到反馈,不是源校验或导入已完成。 // 初始未观测时保守为1;observed只表示收到反馈,不是源校验或导入已完成。
let limit=1,observed=false; let limit=1,observed=false;
@@ -13,7 +15,9 @@ export function createImportBatches(budget:number,max:number){
get limit(){return limit;}, get limit(){return limit;},
/** 是否已有至少一次发布成本样本,用于runner的调度决策。 */ /** 是否已有至少一次发布成本样本,用于runner的调度决策。 */
get observed(){return observed;}, get observed(){return observed;},
/** 超时按比例减小并至少降一档;不足当前limit的尾批不能证明更大批也便宜。 */ /** 首次反馈即 observed=true;elapsed > budget 按比例缩小,且尽量至少减 1,最低保留 1。
* 未超时且 units ≥ limit 才按约 1.5 倍增长并封顶 max;等于 budget 也算此分支。
* 不足当前 limit 的尾批不增长,避免将少量记录的固定发布成本误当成更大批仍便宜。 */
record(units:number,elapsed:number){ record(units:number,elapsed:number){
observed=true; observed=true;
if(elapsed>budget){limit=Math.max(1,Math.min(limit-1,Math.floor(units*budget/elapsed)));return;} if(elapsed>budget){limit=Math.max(1,Math.min(limit-1,Math.floor(units*budget/elapsed)));return;}
+8 -2
View File
@@ -13,7 +13,10 @@ export type ImportScheduling = {
/** A soft time slice with a work threshold, even when the clock is coarse. /** A soft time slice with a work threshold, even when the clock is coarse.
* The caller bounds each indivisible parse/decode operation separately. */ * 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 = {}) { export function createImportBudget(signal: AbortSignal, options: ImportScheduling = {}) {
const budget = options.frameBudgetMs ?? 6; const budget = options.frameBudgetMs ?? 6;
if (!Number.isFinite(budget) || budget <= 0) throw fault('invalid_import_budget'); if (!Number.isFinite(budget) || budget <= 0) throw fault('invalid_import_budget');
@@ -22,7 +25,10 @@ export function createImportBudget(signal: AbortSignal, options: ImportSchedulin
// 每128工作单位才读一次时钟,减少高频开销;总量16384强制让出,不能因时间测不准而无限执行。 // 每128工作单位才读一次时钟,减少高频开销;总量16384强制让出,不能因时间测不准而无限执行。
let started = now(), work = 0, unchecked = 0; let started = now(), work = 0, unchecked = 0;
return { return {
/** 记工作量并按阈值检查取消/时间;达到预算或时间异常时让出,回来再验取消并重置时间片。 */ /** 累加 units;未满 128 个未检查单位且总量未满 16384 时直接返回,连 signal 也尚未检查。
* 阈值到达才读时钟;未超时保留 work 累计,超时/时间倒退或非有限值/工作量封顶则 await yieldTask。
* yield 后复查取消,成功才重设 started/work;读取时钟或 yield 抛错直接传播,不隐式重试。
* units 无运行时校验,负数/NaN 会破坏计数假设;本函数不测量单次实际 CPU 成本或字节数。 */
step(units = 1): Promise<void> | undefined { step(units = 1): Promise<void> | undefined {
work += units; work += units;
unchecked += units; unchecked += units;
+6 -2
View File
@@ -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']); 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??''); export const isImportByteFailure=(error:unknown)=>failures.has((error as {code?:string}|undefined)?.code??'');
/** Optional immutable-source port. Implementations return exactly the requested bytes. */ /** 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>}; 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>>{ 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(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'); 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语法空白字节,不用于处理字符串内容。 */ /** JSON语法空白字节,不用于处理字符串内容。 */
const whitespace=(byte:number)=>byte===9||byte===10||byte===13||byte===32; 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. */ /** Each scan owns a private byte cursor; shared progress records completed boundaries. */
/** 每次扫描从指定位置建立私有cursor,完成边界后外层才更新可恢复源进度。 */ /** 从指定字节位置建立私有 cursor;仅外层 scanNext 将完整结构边界写回源级进度。
* 取消不发布业务结果,但已定位的完整 ranges 可保留用于重试,不承诺整次 read 的缓存/边界都回滚。
* options/缓存沿用内部引用;scanner 负责定位/单值解码,不验证完整文档引用关系。 */
function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={start:-1,bytes:new Uint8Array()}){ function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={start:-1,bytes:new Uint8Array()}){
const {bytes}=options,budget=createImportBudget(signal,options); const {bytes}=options,budget=createImportBudget(signal,options);
let cursor=start; 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 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();} 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>{ async function value():Promise<Range>{
await spaces();const start=cursor,stack:number[]=[];let quoted=false,escaped=false; await spaces();const start=cursor,stack:number[]=[];let quoted=false,escaped=false;
while(true){ 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}; 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>{ async function decode(range:Range,limit=options.maxRecordBytes??256*1024):Promise<any>{
check();if(range.end-range.start>limit)throw fault('record_too_large'); 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; 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. /** Parser 5: canonical headers open before scanning the elements tail.
* Out-of-order headers require the indexed interpreter to establish ownership first. * Out-of-order headers require the indexed interpreter to establish ownership first.
* Ordinals remain stable; ranges are retained only as records are requested. */ * 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>{ export async function openStreamingDocumentImportSource(input:Options):Promise<ImportSource>{
const options:Options=Object.freeze({...input}); const options:Options=Object.freeze({...input});
const {bytes,signal}=options,max=options.maxRecordBytes??256*1024; 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();} for(const id of path){rooted.add(id);await budget.step();}
} }
const children=canvases.filter(canvas=>canvas.id!==rootId),ranges:Range[]=[]; 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(); let cursor=header.position(),phase:'elements'|'tail'|'done'='elements',first=true,failure:string|undefined,serial=Promise.resolve();
/** 延伸一个element range,或验证剩余envelope与EOF;只在结构边界确认后更新cursor/phase。 */ /** 延伸一个element range,或验证剩余envelope与EOF;只在结构边界确认后更新cursor/phase。 */
async function scanNext(readSignal:AbortSignal){ 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'); await scan.take(125);await scan.spaces();if(scan.position()!==bytes.size)throw fault('invalid_document_import');
cursor=scan.position();phase='done'; 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>{ async function read(position:number,readSignal:AbortSignal):Promise<ImportLine|null>{
if(readSignal.aborted)throw fault('import_aborted'); if(readSignal.aborted)throw fault('import_aborted');
if(!Number.isSafeInteger(position)||position<0)throw fault('invalid_import_cursor'); if(!Number.isSafeInteger(position)||position<0)throw fault('invalid_import_cursor');
+12 -4
View File
@@ -11,7 +11,11 @@ import {fault,validJson} from '../canvas-core/values.ts';
type Range={start:number;end:number}; type Range={start:number;end:number};
/** Index the envelope without decoding element payloads. Original bytes remain the source. /** 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. */ * 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>{ 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; 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'); 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/元数据/记录可使用不同字节上限。 */ /** 对限定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');}} 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[]=[]; const fields=new Map<string,unknown>(),elements:Range[]=[];
await take(123);await spaces();if(await peek()===125)throw fault('invalid_document_import'); 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); 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>(); 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);} 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; 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])); 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;}} 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'); 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>{ 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; 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; 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'};} 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'};}
}}); }});
} }
+22 -7
View File
@@ -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[]}; 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. */ /** 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> { export async function encodeDuplicateSource(document: Document, registry: Registry, plan: DuplicatePlan, signal: AbortSignal, scheduling:ImportScheduling={}): Promise<Blob> {
const keys = new Map<Id,string>(),parts: BlobPart[] = []; const keys = new Map<Id,string>(),parts: BlobPart[] = [];
for (const [id,mapped] of Object.entries(plan.elementIds)) keys.set(mapped,`element:${id}`); 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'}); 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 { 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'); 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>(); 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 canvas=(id:Id)=>{
const key=keys.get(id);if(key){dependencies.add(key);return;} const key=keys.get(id);if(key){dependencies.add(key);return;}
let next:Id|undefined=id; 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()]}; 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 { function copyLine(line:ImportLine,job:ImportJob):CopyLine {
const value=line.value as unknown as 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'); 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; return value;
} }
/** Re-check the current reference contract when decoding old durable prepared sources. */ /** 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){ function assertCopyReferences(value:CopyLine,job:ImportJob,registry:Registry){
if(value.change.kind!=='element')return; if(value.change.kind!=='element')return;
const record=value.change.after!,schema=registry.components.get(record.componentId)?.copy; 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]); 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 { 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'); 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'); 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!)}; 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. */ /** 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){ export function assertDuplicateExternal(line:ImportLine,job:ImportJob,document:Document,registry:Registry){
const value=copyLine(line,job);assertCopyReferences(value,job,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]);} 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}; export type ImportFile={readonly jobId:Id;readonly current:boolean;readonly file:PreviousImportFile};
/** completed/partial/cancelled是本任务终态;paused/preparing不代表源已经不再需要。 */ /** completed/partial/cancelled是本任务终态;paused/preparing不代表源已经不再需要。 */
export const terminalImport=(status:ImportJob['status'])=>['completed','partial','cancelled'].includes(status); export const terminalImport=(status:ImportJob['status'])=>['completed','partial','cancelled'].includes(status);
/** 检查退休协议与安全时间值;只是结构合法,不证明引用扫描已完整。 */ /** 检查版本及非负安全整数时间,purgedAt 不得早于 unreferencedAt。
* 不与当前时钟比较,也不证明保留期已满、引用已清空或删除已提交;这些由收集器/存储边界保证。 */
export function validFileRetirement(value:unknown):value is FileRetirement { export function validFileRetirement(value:unknown):value is FileRetirement {
const marker=value as 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); 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> { export function importFiles(jobs:ReadonlyMap<Id,ImportJob>|undefined):ReadonlyMap<Id,ImportFile> {
const files=new Map<Id,ImportFile>(); const files=new Map<Id,ImportFile>();
for(const [jobId,job]of jobs??[]){ for(const [jobId,job]of jobs??[]){
@@ -32,18 +35,22 @@ export function importFiles(jobs:ReadonlyMap<Id,ImportJob>|undefined):ReadonlyMa
} }
return files; return files;
} }
/** 当前源仅终态可退休;旧源可独立退休,且同job文件ID互异、元数据和摘要合法。 */ /** 当前源带任何退休标记时都要求终态;previous 文件可独立退休,逐项检查 ID/长度/可选摘要/标记。
* 最后检查 current+previous ID 互异;当前源自身的 fileId/长度/摘要等仍由导入源完整校验负责。 */
export function validateImportFileRetention(source:NativeImportSource,status:ImportJob['status']) { 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.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'); 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'); 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 { function mark<T extends PreviousImportFile>(file:T,retirement:FileRetirement|undefined):T {
if(equal(file.retirement,retirement))return file; if(equal(file.retirement,retirement))return file;
const {retirement:_,...rest}=file;return freeze(retirement?{...rest,retirement}:rest) as T; 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 { export function markImportFiles(runtime:RuntimeState,markers:ReadonlyMap<Id,FileRetirement|undefined>):RuntimeState {
let imports=runtime.imports; let imports=runtime.imports;
for(const [id,job]of 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!)}); 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. */ /** 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 { 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)); 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; 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(!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]); 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; 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>(); const coverage=pins(),clear=new Map<Id,undefined>();
for(const [id,entry]of after){ for(const [id,entry]of after){
if(entry.file.retirement?.purgedAt!==undefined){if(coverage.pins.has(id))throw fault('import_source_retired',[id]);continue;} if(entry.file.retirement?.purgedAt!==undefined){if(coverage.pins.has(id))throw fault('import_source_retired',[id]);continue;}
+12 -5
View File
@@ -5,10 +5,15 @@
import type { ImportLocator } from './imports.ts'; import type { ImportLocator } from './imports.ts';
import { fault } from '../canvas-core/values.ts'; import { fault } from '../canvas-core/values.ts';
/** Ready SCCs are indivisible. Ordinary records retain source-order FIFO selection. */ /** Ready SCCs are indivisible; source order guides each readiness scan. */
/** pending按源顺序输入;返回有序原子组,不实际提交,缺依赖的组继续等待而非直接丢失。 */ /** 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[])[] { 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[]>(); 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) { for (const row of pending) {
const dependencies = row.dependencies.filter(key => byKey.has(key)); edges.set(row.key, dependencies); 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) { for (const group of components) {
const internal = new Set(group.map(row => row.key)); const internal = new Set(group.map(row => row.key));
if (!group.every(row => row.dependencies.every(key => available.has(key) || internal.has(key)))) continue; 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); 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'); 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. // 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; 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); chosen.push(group); units += group.length; bytes += size; changes += count; for (const row of group) available.add(row.key);
} }
+10 -4
View File
@@ -8,10 +8,14 @@ import {fault} from '../canvas-core/values.ts';
/** 源记录/依赖键到目标ID的只读字典,element/canvas前缀由调用者约定。 */ /** 源记录/依赖键到目标ID的只读字典,element/canvas前缀由调用者约定。 */
type Ids=Readonly<Record<string,Id>>; type Ids=Readonly<Record<string,Id>>;
/** One runner's last immutable ID index. Copies are deferred until allocation. */ /** 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(){ export function createImportIdentities(){
let cached:{ids:Ids;used:Set<Id>}|undefined; let cached:{ids:Ids;used:Set<Id>}|undefined;
/** 相同已冻结纯字符串属性表可复用used索引;其它输入先复制为无原型字典。 */ /** 同引用命中缓存时直接复用;否则仅将冻结且描述符为字符串数据值的输入视为可复用。
* 这是性能准入,不是完整 schema/普通对象证明;其余输入以 Object.assign 捕获到无原型字典,getter 会在此求值。
* 首次分配前共享基表/used;写时再分离,避免每条只查既有键的记录重扫整张映射。 */
const capture=(input:Ids,createId:()=>Id)=>{ const capture=(input:Ids,createId:()=>Id)=>{
let identity=cached?.ids===input?cached:undefined; let identity=cached?.ids===input?cached:undefined;
if(!identity){ if(!identity){
@@ -30,10 +34,12 @@ export function createImportIdentities(){
}, },
/** 判断目标ID是否已被本映射使用,不声称该ID已加入文档或已同步。 */ /** 判断目标ID是否已被本映射使用,不声称该ID已加入文档或已同步。 */
has:(id:Id)=>used.has(id), has:(id:Id)=>used.has(id),
/** 返回本次分配后的只读映射;没有新分配时保留输入身份便于下一记录复用索引。 */ /** owned 缓冲通过 dataRecord 再捕获不可变值,后续 reserve 不改变已经返回的结果。
* 可复用输入且无分配时沿用原表;可变/getter 输入即使未分配也返回捕获表,每次 finish 可重新捕获。
* 返回不代表映射已落盘;runner 须与导入进度/文档同次发布和持久化。 */
finish:():Ids=>owned?dataRecord(ids):ids, finish:():Ids=>owned?dataRecord(ids):ids,
}); });
}; };
/** clear只丢弃临时复用索引,不清除已持久化job的stableIds。 */ /** clear 只丢工厂最近索引;现有 capture 上下文仍能使用,不清已发布/持久 job.stableIds,也不回收 ID。 */
return Object.freeze(Object.assign(capture,{clear(){cached=undefined;}})); return Object.freeze(Object.assign(capture,{clear(){cached=undefined;}}));
} }
+6 -2
View File
@@ -5,13 +5,17 @@
import { fault } from '../canvas-core/values.ts'; import { fault } from '../canvas-core/values.ts';
import {createSha256} from './sha256.ts'; import {createSha256} from './sha256.ts';
/** Complete-source SHA-256, with bounded chunk memory and cooperative fallback scheduling. */ /** 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>{ 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))); 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'); if(!(bytes instanceof Blob)||!Number.isSafeInteger(chunk)||chunk<1||chunk>4*1024*1024)throw fault('invalid_digest_input');
/** 读/计算/让出边界统一检查 signal;异步读取即使无法中止,迟到字节也不再参与有效结果。 */ /** 读/计算/让出边界统一检查 signal;异步读取即使无法中止,迟到字节也不再参与有效结果。 */
const check=()=>{if(signal?.aborted)throw fault('import_aborted');};check();const hash=createSha256(); 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();}} 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(); check();return hash.hex();
} }
+11 -4
View File
@@ -9,7 +9,10 @@ import type {ImportSource,ImportLine} from './imports.ts';
import {fault,validJson} from '../canvas-core/values.ts'; import {fault,validJson} from '../canvas-core/values.ts';
/** Seekable JSON array reader. Cursors are byte offsets after '[' or a record comma. /** Seekable JSON array reader. Cursors are byte offsets after '[' or a record comma.
* Structure errors end the source; previously published records remain explicit. */ * 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{ 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; 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'); 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失败仍抛出而不伪装坏记录。 */ /** read每次返回一个ImportLine及nextCursor;EOF返回null,源级I/O失败仍抛出而不伪装坏记录。 */
return Object.freeze({fingerprint:options.fingerprint,parserVersion:3,async read(start:number,signal:AbortSignal):Promise<ImportLine|null>{ 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'); 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(start>0&&start===bytes.size){if(signal.aborted)throw fault('import_aborted');return null;}
if(bytes.size===0)throw fault('invalid_json_array'); if(bytes.size===0)throw fault('invalid_json_array');
let cursor=start;const budget=createImportBudget(signal,options); 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();} 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()===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'); 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; 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'); while(true){const byte=await peek();if(byte===-1)return failure('incomplete_json_array');
if(!quoted&&stack.length===0&&(byte===44||byte===93))break; 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');} 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(); await advance();
} }
// 消费外层逗号或]并检查尾部/尾逗号;内嵌数组的]已由stack处理,不会截断记录。 // 先确认记录后的外层分隔与尾部,再返回此记录;若尾逗号/垃圾在此处发现,本记录也返回结构失败。
// 数组内嵌 ] 已由 stack 处理;nextCursor 消费分隔和后续空白,最后一项则推进到真实 EOF。
const delimiter=await peek();await advance(); const delimiter=await peek();await advance();
if(delimiter===93){await spaces();if(cursor!==bytes.size)return failure('invalid_json_array');} 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');} else {await spaces();if(await peek()===93||await peek()===-1)return failure('invalid_json_array');}
const base={cursor:start,nextCursor:cursor,key:`byte:${start}`}; const base={cursor:start,nextCursor:cursor,key:`byte:${start}`};
if(oversized)return{...base,error:'record_too_large'}; 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'};} 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'};} try{const value=JSON.parse(text);return validJson(value)?{...base,value}:{...base,error:'invalid_json_value'};}catch{return{...base,error:'invalid_json'};}
}}); }});
+19 -6
View File
@@ -1,6 +1,7 @@
/** /**
* 原生导入记录到纯ImportUnit的转换:源ID经reserve映射为新身份,依赖保留为source key等待闭合。 * 原生导入记录到纯ImportUnit的转换:源ID经reserve映射为新身份,依赖保留为source key等待闭合。
* $target或文档源根映射到显式选定的现有canvas;副本incarnation从1开始,不继承旧墓碑/任务执行。 * $target或文档源根映射到显式选定的现有canvas;副本incarnation从1开始,不继承旧墓碑。
* 任务字段只按组件copy.omit声明去除;转换不创建/启动生成任务,不能据此假定所有未知执行字段都已过滤。
* 本模块只规划changes,不写文档、不上传/授权;资源地址和授权事实由宿主/engine另行提供与校验。 * 本模块只规划changes,不写文档、不上传/授权;资源地址和授权事实由宿主/engine另行提供与校验。
*/ */
import type { Document, ElementRecord, Id, Json, Registry } from '../canvas-core/types.ts'; 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 { clone, fault } from '../canvas-core/values.ts';
import type { ImportJob, ImportLine, ImportUnit } from './imports.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){ export function assertNativeImportTarget(job:ImportJob,document:Document){
const source=job.source,canvas=source?document.canvases.get(source.targetCanvasId):undefined; const source=job.source,canvas=source?document.canvases.get(source.targetCanvasId):undefined;
if(!source||!canvas)throw fault('import_target_missing'); 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'); 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; return source;
} }
/** 已committed依赖必须仍是原绑定生命周期;用户删后重建同ID不能被后续导入误认领。 */ /** 仅检查已在 committedSourceKeys 的依赖:stableIds 定位目标,再以 job.bindings 核对生命周期。
* element 检查代次,canvas 检查仍存在、入口 ID 及已记录入口代次;不比较正文、几何、组件或全部 owner 链。
* 未提交/SCC 内部依赖不在这里猜成存在,留给分组选取与原子创建后的文档校验;本函数不改变绑定。 */
export function assertNativeImportDependencies(job:ImportJob,document:Document,dependencies:readonly string[]){ export function assertNativeImportDependencies(job:ImportJob,document:Document,dependencies:readonly string[]){
for(const key of dependencies)if(job.committedSourceKeys.includes(key)){ for(const key of dependencies)if(job.committedSourceKeys.includes(key)){
const id=job.stableIds[key],binding=job.bindings?.[id]; 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. */ /** 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 { 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 source=assertNativeImportTarget(job,document);
const raw=line.value as Record<string,Json>|undefined; 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依赖。 */ /** 根/$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 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); const key=`${raw.kind}:${raw.key}`,id=reserve(key);
// 不把源根作为第二个根插入;这里只处理有parent的子canvas,parent与入口都使用新映射。 // 只接受带 parent 的子 canvas;源根由读取器排除,再通过 sourceRootId/$target alias 接到现有目标。
// parent/record 其它属性沿用克隆数据,额外 schema/树约束不会在此分支自动补齐。
if(raw.kind==='canvas'){ if(raw.kind==='canvas'){
const parent=record.parent as Record<string,Json>|null; const parent=record.parent as Record<string,Json>|null;
if(!parent||typeof parent!=='object'||Array.isArray(parent))throw fault('invalid_import_canvas'); 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是其类型视图;未知组件/缺复制声明不猜字段含义。 // record是脱离源的副本,original是其类型视图;未知组件/缺复制声明不猜字段含义。
const original=record as unknown as ElementRecord,definition=registry.components.get(original.componentId); 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'); 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)};} 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; record.data=original.data;
// 只遍历schema声明引用,避免重写普通文字里的ID;omit字段由共享remapCopyData处理。 // 只遍历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'); 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)}}); 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'); }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}]}; dependencies.delete(key);return{key,dependencies:[...dependencies],changes:[{kind:'element',id,before:null,after:after as ElementRecord}]};
} }
+22 -7
View File
@@ -15,7 +15,9 @@ const MAX_SOURCE_BYTES=64*1024*1024;
/** version=1是块证明DTO版本,与source.integrityVersion=2的业务封存协议不是同一版本域。 */ /** 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}[]}; 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. */ /** 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{ 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'); 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; 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. /** 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. */ * 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){ export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:ImportReadProof;maxResidentBlocks?:number;maxReadBytes?:number}&ImportScheduling){
const options=Object.freeze({...input}); const options=Object.freeze({...input});
const {bytes,fileId}=options,residentLimit=options.maxResidentBlocks??4,readLimit=options.maxReadBytes??4*1024*1024; 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}; type Block={index:number;sha256:string;data:Uint8Array};
const resident=new Map<number,Block>(),pending=new Map<number,Promise<Block>>(),lifecycle=new AbortController(); const resident=new Map<number,Block>(),pending=new Map<number,Promise<Block>>(),lifecycle=new AbortController();
let closed=false,activeReads=0,proof:ImportReadProof=initial; 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');}; const check=(signal:AbortSignal)=>{if(closed)throw fault('import_source_closed');if(signal.aborted)throw fault('import_aborted');};
/** 单调用可取消等待共享块Promise,不强制取消可能正被其它range复用的只读底层读取。 */ /** 单调用可取消等待共享块Promise,不强制取消可能正被其它range复用的只读底层读取。 */
function wait<T>(task:Promise<T>,signal:AbortSignal):Promise<T>{ 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);});}); 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>{ 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 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; 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; 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 port:ImportByteReader=Object.freeze({byteLength:bytes.size,async read(start:number,end:number,signal:AbortSignal){
const combined=AbortSignal.any([signal,lifecycle.signal]);check(combined); 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'); 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--;} }finally{activeReads--;}
}}); }});
return Object.freeze({port,getProof:()=>proof, 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);}, 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();}, close(){if(closed)return;closed=true;resident.clear();lifecycle.abort();},
/** 只读诊断:驻留/活range/pending块/证明块分别计数,不当作导入完成百分比。 */ /** 只读诊断:驻留/活range/pending块/证明块分别计数,不当作导入完成百分比。 */
stats:()=>Object.freeze({residentBlocks:resident.size,activeReads,pendingBlocks:pending.size,proofBlocks:hashes.size,closed}), stats:()=>Object.freeze({residentBlocks:resident.size,activeReads,pendingBlocks:pending.size,proofBlocks:hashes.size,closed}),
+12 -4
View File
@@ -8,20 +8,28 @@ import {importNeedsSeal,validateImportJob,type ImportJob} from './imports.ts';
import {validateImportReadProof} from './import-read-proof.ts'; import {validateImportReadProof} from './import-read-proof.ts';
/** A derived durable-cut index, never an in-memory permission boolean. */ /** 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){ 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; 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>){ 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; 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. */ /** 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){ export function assertImportEvidence(previous:ReadonlyMap<Id,ImportJob>|undefined,next:ReadonlyMap<Id,ImportJob>|undefined,owner:Id|undefined){
if(previous===next)return; if(previous===next)return;
for(const[id,job]of previous??[])if(job.source?.integrityVersion===2&&!next?.has(id))throw fault('import_integrity_owned',[id]); 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};}; 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;} 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); validateImportJob(job);const proof=validateImportReadProof(source.readProof,source.fileId,source.byteLength);
+7 -2
View File
@@ -7,7 +7,9 @@ import { fault } from '../canvas-core/values.ts';
import { createDeque } from './structures.ts'; import { createDeque } from './structures.ts';
/** Shared bounded transport capacity for writes, uploads and run reads. */ /** 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) { export function createNetworkGate(limit = 2, beforeRun?:()=>void) {
if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_concurrency'); if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_concurrency');
let active = 0; let active = 0;
@@ -15,6 +17,7 @@ export function createNetworkGate(limit = 2, beforeRun?:()=>void) {
/** 有空位直接占用,否则等待 grant;已取消的等待项标记保留,释放时惰性跳过。 */ /** 有空位直接占用,否则等待 grant;已取消的等待项标记保留,释放时惰性跳过。 */
async function enter(signal: AbortSignal) { async function enter(signal: AbortSignal) {
signal.throwIfAborted(); signal.throwIfAborted();
// enter 自己推进 active,或等 release 直接转交名额;等待者获准时不再重复 active++。
if (active < limit) { active++; return; } if (active < limit) { active++; return; }
await new Promise<void>((resolve,reject) => { await new Promise<void>((resolve,reject) => {
// grant 解除 abort 监听再唤醒;被唤醒后 run 还会再次检查 signal,避免取消竞态执行请求。 // 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(); let item=waiting.pop();while(item?.cancelled)item=waiting.pop();
if(item)item.grant();else active--; 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> { return Object.freeze({ async run<T>(signal: AbortSignal, callback: () => Promise<T>): Promise<T> {
await enter(signal); await enter(signal);
try { signal.throwIfAborted();beforeRun?.();return await callback(); } finally { release(); } try { signal.throwIfAborted();beforeRun?.();return await callback(); } finally { release(); }
+13 -4
View File
@@ -10,12 +10,15 @@ import {resourceRetentionPins} from './resource-retention.ts';
/** 盘点确认的文件身份/摘要与退休状态,不是上传资源元数据,也不是 DOM 图片缓存。 */ /** 盘点确认的文件身份/摘要与退休状态,不是上传资源元数据,也不是 DOM 图片缓存。 */
export type OrphanFile={readonly version:1;readonly id:Id;readonly byteLength:number;readonly mimeType:string;readonly digest:string;readonly retirement?:FileRetirement}; 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 { export function validateOrphanFile(value:unknown,id:Id):asserts value is OrphanFile {
const file=value as 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'); 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[]>) { 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[]=[]; const pins=resourceRetentionPins(snapshot,registry,displays,temporary),blockers:string[]=[];
if(!pins.complete)blockers.push('opaque_references'); 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)}); return Object.freeze({...pins,complete:blockers.length===0,blockers:Object.freeze(blockers)});
} }
/** Registered orphan identities survive repinning. Purged identities cannot be forgotten or reassigned. */ /** 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 { 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; if(!previous.orphanFiles?.size&&!next.orphanFiles?.size)return next;
const hasSoft=[...(next.orphanFiles?.values()??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined); 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 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]);} 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。 // 覆盖不足/重 pin 取消软退休;永久删除后重 pin 明确拒绝,不能让模型引用失去字节的 ID。
// 需要慢路才调用覆盖回调;即使引用覆盖未知,已 purge 项也仅在有明确 pin 时拒绝。
const pins=coverage(),clear:Id[]=[]; const pins=coverage(),clear:Id[]=[];
for(const [id,file]of next.orphanFiles??[]){ for(const [id,file]of next.orphanFiles??[]){
if(reserved.has(id)&&(!pins.complete||pins.pins.has(id)))throw fault('resource_collection_in_progress',[id]); 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. */ /** 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>{ 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);});}); 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);});});
} }
+11 -4
View File
@@ -12,11 +12,13 @@ import type { LedgerSnapshot } from './ledger.ts';
/** A complete read cannot rewind known scopes or change an unchanged-version canvas. */ /** A complete read cannot rewind known scopes or change an unchanged-version canvas. */
/** 校验完整 fresh read:同项目、合法文档、活 canvas 版本齐全、已知版本和墓碑均不可倒退。 /** 校验完整 fresh read:同项目、合法文档、活 canvas 版本齐全、已知版本和墓碑均不可倒退。
* 某 canvas 版本没变时,其记录/直属元素必须相同;全向量没变时墓碑集合也不能偷偷改变。 */ * 某 canvas 版本没变时,其记录/直属元素必须相同;全向量没变时墓碑集合也不能偷偷改变。
* 只校验模型一致性/单调性,不证明响应身份或读取来自何服务;传输认证与实际完整覆盖由端口保证。 */
export function validateCanonicalRead(previous: Document, previousVersions: ReadonlyMap<Id,number>, next: Document, versions: ReadonlyMap<Id,number>, registry: Registry) { 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'); if(next.projectId!==previous.projectId)throw fault('project_mismatch');
validateDocument(next,registry); validateDocument(next,registry);
for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]); 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]); for(const canvas of next.canvases.keys())if(!versions.has(canvas))throw fault('missing_server_version',[canvas]);
// 记录服务声明“版本未变”的 owner,随后双向检查元素,既防遗漏删除也防夹带新增。 // 记录服务声明“版本未变”的 owner,随后双向检查元素,既防遗漏删除也防夹带新增。
const unchanged=new Set<Id>(); 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. */ /** The footprint follows ownership; geometry does not decide which canvas owns an object. */
/** 计算跨 canvas 操作 footprint:有已冻结意图范围时沿用,否则收集新旧 owner/父级/端点归属。 /** 计算跨 canvas footprint:声明了非空 intent.canvasIds 就直接沿用,不以是否已冻结为条件。
* 上传和 run.cancel 不占文档参与者;空间位置或画框相交不决定 owner。 */ * 否则从变化收集新旧 owner/父级/端点归属;上传和 run.cancel 总是返回空文档参与者。
* 不根据画框相交或 run 的 boardId/action 自动猜参与者;ID 有效性与编译覆盖另由边界核对。 */
export function operationCanvases(intent: OperationIntent, document: Document, after:Document=document): readonly Id[] { export function operationCanvases(intent: OperationIntent, document: Document, after:Document=document): readonly Id[] {
if(intent.operationType==='asset.upload'||intent.operationType==='run.cancel')return[]; if(intent.operationType==='asset.upload'||intent.operationType==='run.cancel')return[];
// 已声明范围按原引用/顺序返回,不在此排序去重;仅下方推导路径用 Set 并排序。
if (intent.canvasIds?.length) return intent.canvasIds; if (intent.canvasIds?.length) return intent.canvasIds;
// 字段变化不能只查 after;重挂/改端点时旧 owner 也参与版本条件和占用。 // 字段变化不能只查 after;重挂/改端点时旧 owner 也参与版本条件和占用。
const ids = new Set<Id>(); 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. */ /** Replay once, by logical sequence. A receipt can already contain a desired field value. */
/** 将本地生命周期水位叠到 ACK 副本,再按 sequence 回放 document.change/run.apply。 /** 将本地生命周期水位叠到 ACK 副本,再按 sequence 回放 document.change/run.apply。
* 只有已满足的 after 可安全跳过,条件冲突仍拒绝;不会自动改写原意图或冻结请求。 */ * 底层按同 incarnation/字段存在性判断已满足 after,并检查 field owner 范围;条件冲突仍拒绝。
* 不改写意图/冻结请求,不移动 history;historyEffect 只在此验证形状,不重新执行尺寸历史规划。 */
export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document { export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document {
/** 已 resolved 项不重放;未冻结、冻结或 blocked 的未解决文档意图仍按原事实参与重建。 */ /** 已 resolved 项不重放;未冻结、冻结或 blocked 的未解决文档意图仍按原事实参与重建。 */
function* pending(){ function* pending(){
for(const record of [...ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence)){ 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; 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); if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect);
yield {changes:record.intent.changes,canvasIds:record.intent.canvasIds}; yield {changes:record.intent.changes,canvasIds:record.intent.canvasIds};
} }
} }
// 本地抵消可能从未进入服务器,水位只加到 working 候选,不能冒充服务器已确认墓碑。 // 本地抵消可能从未进入服务器,水位只加到 working 候选,不能冒充服务器已确认墓碑。
const document=replayChanges(withLocalLifecycles(base,ledger),pending()); const document=replayChanges(withLocalLifecycles(base,ledger),pending());
// 全部回放成功后检查最终工作文档;任何异常让调用者丢弃这份候选,不在此处理冲突 UI 或覆盖 ACK。
validateDocument(document,registry); validateDocument(document,registry);
return document; return document;
} }
+11 -2
View File
@@ -10,7 +10,10 @@ import type {EngineSnapshot,RuntimeState} from './contracts.ts';
import {createResourcePinScan} from './resources.ts'; import {createResourcePinScan} from './resources.ts';
/** Complete local-byte protection for one project scope; no DOM/viewport filtering. */ /** 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()) { export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap<Id,number>=new Map(),temporary:ReadonlyMap<Id,readonly Id[]>=new Map()) {
const {runtime}=snapshot; const {runtime}=snapshot;
const scan=createResourcePinScan(snapshot.history,runtime.ledger,registry); const scan=createResourcePinScan(snapshot.history,runtime.ledger,registry);
@@ -26,20 +29,26 @@ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,
// 未结束/未封存导入和 opaque run 保留已知资源仓库,不假设尚未解释的 envelope 没有引用。 // 未结束/未封存导入和 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 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}`); 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 租用数与临时任务另外合并。 // 等待解码或没有解释器的状态也 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 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 [owner,ids]of temporary)for(const id of ids)add(id,owner);
for(const [id,count]of displays)if(count>0)add(id,'display'); 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)])))}); 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. */ /** 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 { export function prepareResourceRetention(next:EngineSnapshot,registry:Registry,displays:ReadonlyMap<Id,number>,reserved:ReadonlySet<Id>,temporary:ReadonlyMap<Id,readonly Id[]>=new Map()):RuntimeState {
// 没有待回收工作/软标记时快速复用 runtime,避免每次普通编辑重扫整个资源集合。 // 没有待回收工作/软标记时快速复用 runtime,避免每次普通编辑重扫整个资源集合。
if(!reserved.size&&![...next.runtime.resources.values()].some(resource=>resource.retirement&&(resource.retirement.purgedAt===undefined||!resource.stored)))return next.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); 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]); 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; let resources=next.runtime.resources;
for(const [id,resource]of next.runtime.resources){ for(const [id,resource]of next.runtime.resources){
if(!resource.retirement)continue; if(!resource.retirement)continue;
+10 -4
View File
@@ -8,7 +8,9 @@ import {fault} from '../canvas-core/values.ts';
/** Snapshot-bound direct membership counts, including nodes and connections. */ /** Snapshot-bound direct membership counts, including nodes and connections. */
/** 当前捕获快照的直属数量,包含节点和边;不是递归子树大小或 DOM 已挂载数量。 */ /** 当前捕获快照的直属数量,包含节点和边;不是递归子树大小或 DOM 已挂载数量。 */
export type CanvasElementCounts=Readonly<Record<Id,number>>; 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 { 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'); 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; 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. */ /** All active IDs in the capture, including records not yet received. */
/** 完整活 ID 命名空间,即使部分记录尚未传到客户端,也预先知道其直接 owner。 */ /** 完整活 ID 命名空间,即使部分记录尚未传到客户端,也预先知道其直接 owner。 */
export type ElementOwners=Readonly<Record<Id,Id>>; 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 { 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'); if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).length!==total)throw fault('invalid_model_identity');
// 无原型计数表避免特殊 owner 键污染计数;空 canvas 由缺省 0 与 counts 比较。 // 无原型计数表避免特殊 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. */ /** Full captured topology, independent of which entry payloads have arrived. */
/** 用完整 topology+owner 证明验证单根、父存在、入口唯一/归属正确和无环。 /** 用源 canvas 表和已验证 owners 声明检查唯一根=projectId、父存在、入口唯一/归属与无环。
* 此时入口记录可能尚未收到,入口是否确为 node 仍需 payload 到达后的文档校验。 */ * 不是完整 CanvasRecord/payload schema 校验;未收到入口时不能证明其为 node、组件可用或实际拥有关系一致。
* 每个子 canvas 重新走父链,深树会反复访问共享祖先;同步检查无预算/取消,应由捕获规模约束成本。
* 不捕获输入或标记文档已完整;加载器另需核对已收到树/元素与此声明,再建立 owner 的覆盖状态。 */
export function validateCanvasTopology(canvases:readonly import('../canvas-core/types.ts').CanvasRecord[],owners:ElementOwners,projectId:Id):void { 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'); 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'); const table=new Map(canvases.map(canvas=>[canvas?.id,canvas]));if(table.size!==canvases.length||!table.has(projectId))throw fault('invalid_model_topology');
+6 -2
View File
@@ -20,7 +20,9 @@ const K=new Uint32Array([
/** 32位循环右移;区别于简单逻辑右移,低位从左侧回填。 */ /** 32位循环右移;区别于简单逻辑右移,低位从左侧回填。 */
const rotate=(n:number,bits:number)=>(n>>>bits)|(n<<(32-bits)); const rotate=(n:number,bits:number)=>(n>>>bits)|(n<<(32-bits));
/** Incremental bytes only; owns at most a partial block, state and message schedule. */ /** 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(){ export function createSha256(){
// length 计原输入字节;pending 计尾块已占字节;result 缓存最终十六进制并标记终结。 // 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; 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; 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({ return Object.freeze({
/** 先补上次尾块,再就地压缩输入完整块,最后只复制不足64字节的尾部。 */ /** 先补尾块,再直接读取输入完整块压缩,最后只复制不足 64 字节的尾部,不保留输入引用。
* 不修改调用方字节;累计长度必须保持安全整数,终结后即使追加空数组也拒绝。
* 这里的就地指同步消费输入,不代表每次 update 只处理固定工作量或自动让出主线程。 */
update(bytes:Uint8Array){ 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(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;}} 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;}}
+20 -6
View File
@@ -23,7 +23,10 @@ export type WorkspaceOptions = {
}; };
/** Workspace owns persistent projects; a view only borrows one and never closes it. */ /** 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) { export function createWorkspaceRuntime(options: WorkspaceOptions) {
const digests=options.digests??createDigestPool(); const digests=options.digests??createDigestPool();
const displays=createDisplayCache({onError:options.onError}); const displays=createDisplayCache({onError:options.onError});
@@ -33,7 +36,9 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) {
/** 准入同时要求工作区仍接受任务和宿主身份仍有效,关闭一开始就让旧调用失效。 */ /** 准入同时要求工作区仍接受任务和宿主身份仍有效,关闭一开始就让旧调用失效。 */
const current = () => accepting && !closed && options.isCurrentIdentity(); 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; 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){ function acquire(document:Document,versions:ReadonlyMap<Id,number>,loading?:LoadingState){
if(!current())return Promise.reject(fault('identity_changed')); if(!current())return Promise.reject(fault('identity_changed'));
const existing=projects.get(document.projectId);if(existing)return existing; const existing=projects.get(document.projectId);if(existing)return existing;
@@ -46,20 +51,29 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) {
assetDepot, assetDepot,
/** 查看项目打开句柄的只读表,不授予外部关闭/替换内部 ownership 的能力。 */ /** 查看项目打开句柄的只读表,不授予外部关闭/替换内部 ownership 的能力。 */
getProjects: () => readMap(projects), 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}={}) { 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;}); 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){ 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;}); 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){ 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;} 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;} 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() { close() {
accepting = false; // Fence commands/transports before awaiting pending opens or storage. accepting = false; // Fence commands/transports before awaiting pending opens or storage.
if (closed) return Promise.resolve(); if (closed) return Promise.resolve();