diff --git a/src/lib/canvas-runtime/asset-depot-storage.ts b/src/lib/canvas-runtime/asset-depot-storage.ts index e3d40e8..250cfad 100644 --- a/src/lib/canvas-runtime/asset-depot-storage.ts +++ b/src/lib/canvas-runtime/asset-depot-storage.ts @@ -1,26 +1,42 @@ +/** + * 共享原图的 IndexedDB 实现:asset-depot 存身份/退休事实,asset-bytes 按摘要共享 Blob, + * asset-pins 按项目存与 Root 版本绑定的完整保留清单。所有 execute 都经过主存储的 leader/fence 检查。 + * 回收只清本地共享字节,保留身份;同摘要还有其它活身份时不能删实际字节。 + */ import {equal,fault,freeze} from '../canvas-core/values.ts'; import {sharedAssetRef,validateSharedAsset,type AssetDepotPort,type DepotCut,type DepotEntry,type SharedAsset} from './asset-depot.ts'; import type {StorageLeader,StorageScope,RecoveryImage} from './storage-port.ts'; +/** 物理 store 名称,与 RecoveryImage 的逻辑版本/项目资源表不是同一版本域。 */ export const depotStores=['asset-depot','asset-bytes','asset-pins']; +/** 某资源身份的不可变引用与可变保留期标记,字节另以 digest 存储。 */ type Fact={ref:SharedAsset;unreferencedAt?:number;purgedAt?:number}; +/** 项目最新 Root 的保留清单;只有与 metadata.throughRevision 一致且 complete=true 才可信。 */ type Pins={version:1;rootRevision:number;complete:boolean;pins:readonly (readonly [string,readonly string[]])[]}; +/** 单 IDB request 的结果 Promise;可靠提交仍必须由外层 transaction.oncomplete 确认。 */ const value=(request:IDBRequest)=>new Promise((resolve,reject)=>{request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);}); +/** 仓库键按 account/workspace/authority 隔离,跨项目共享,跨授权域不混用。 */ const prefix=(scope:StorageScope,authority:string)=>[scope.accountId,scope.workspaceId,authority]; +/** 用数组复合键范围只扫描指定授权域,[] 作为该前缀下字符串 ID 的上界哨兵。 */ const range=(scope:StorageScope,authority:string)=>IDBKeyRange.bound(prefix(scope,authority),[...prefix(scope,authority),[]],false,true); +/** 验证非空且长度受限的授权域,不以可变显示 URL 充当缓存身份。 */ function authority(value:string){if(typeof value!=='string'||!value||value.length>8192)throw fault('invalid_depot_cut');} +/** 读取身份记录时验证 ref 与键一致、退休时钟合法,损坏数据不能被回收过程忽略。 */ function validateFact(row:Fact,scopeAuthority:string,id:string){validateSharedAsset(row?.ref);if(row.ref.authority!==scopeAuthority||row.ref.asset.id!==id)throw fault('corrupt_asset_depot');for(const n of [row.unreferencedAt,row.purgedAt])if(n!==undefined&&(!Number.isSafeInteger(n)||n<0))throw fault('corrupt_asset_depot');if(row.purgedAt!==undefined&&(row.unreferencedAt===undefined||row.purgedAt(),pins=cut.pins.map(row=>{if(!Array.isArray(row)||row.length!==2||typeof row[0]!=='string'||!row[0]||seen.has(row[0])||!Array.isArray(row[1])||!row[1].length||row[1].some(owner=>typeof owner!=='string'||!owner)||new Set(row[1]).size!==row[1].length)throw fault('invalid_depot_cut');seen.add(row[0]);return Object.freeze([row[0],Object.freeze([...row[1]])] as const);}); const written=new Set(),writes=cut.writes.map(row=>{validateSharedAsset(row.ref);if(row.ref.authority!==cut.authority||written.has(row.ref.asset.id)||row.bytes!==undefined&&!(row.bytes instanceof Blob))throw fault('invalid_depot_cut');written.add(row.ref.asset.id);return Object.freeze({ref:sharedAssetRef(row.ref),...(row.bytes?{bytes:row.bytes}:{})});}); return Object.freeze({version:1,authority:cut.authority,complete:cut.complete,pins:Object.freeze(pins),writes:Object.freeze(writes)}); } +/** 在 IDB 事务外计算原图 SHA256 并验长度,避免 await crypto 让事务提前失活。 */ export async function verifyDepotBytes(cut:DepotCut){ for(const row of cut.writes)if(row.bytes){if(row.bytes.size!==row.ref.asset.bytes)throw fault('shared_asset_bytes_changed',[row.ref.asset.id]);const digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await row.bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');if(digest!==row.ref.digest)throw fault('shared_asset_bytes_changed',[row.ref.asset.id]);} } /** Must run in the same transaction as the project Root, including idempotent reindexing. */ +/** 必须与项目 Root 同事务:校验既存身份、按 digest 写共享字节,并更新该项目 pin manifest。 */ export async function writeDepotCut(tx:IDBTransaction,scope:StorageScope,rootRevision:number,cut:DepotCut){ const base=prefix(scope,cut.authority),facts=tx.objectStore('asset-depot'),bytes=tx.objectStore('asset-bytes'); for(const row of cut.writes){const key=[...base,row.ref.asset.id],previous=await value(facts.get(key));if(previous){validateFact(previous,cut.authority,row.ref.asset.id);if(!equal(previous.ref,row.ref))throw fault('shared_asset_identity_conflict',[row.ref.asset.id]);} @@ -31,7 +47,9 @@ export async function writeDepotCut(tx:IDBTransaction,scope:StorageScope,rootRev tx.objectStore('asset-pins').put({version:1,rootRevision,complete:cut.complete,pins:cut.pins} satisfies Pins,[...base,scope.projectId]); } /** DB v4 file rows may refer to immutable shared bytes after a proven upload ACK. */ +/** 项目 files 中的共享字节指针;保留原文件 MIME/长度/资源身份,不复制另一份 Blob。 */ export type DepotFile={readonly fileVersion:1;readonly kind:'asset-depot';readonly authority:string;readonly resourceId:string;readonly digest:string;readonly byteLength:number;readonly mimeType:string}; +/** 解释已持久化指针版本与字段,不能将未知 fileVersion 当 Blob 或静默跳过。 */ function depotFile(row:unknown,id:string):DepotFile{ const file=row as DepotFile; if(!file||file.fileVersion!==1)throw fault('file_interpreter_unavailable'); @@ -40,6 +58,8 @@ function depotFile(row:unknown,id:string):DepotFile{ return file; } /** Undefined means bytes were explicitly evicted; identity metadata remains. */ +/** Blob 旧行直接返回;指针行核对共享身份/长度,字节已显式清理则返回 undefined。 + * 返回时恢复项目原文件 MIME,不因服务检测 MIME 与本地不同而改变原文件合同。 */ export async function resolveDepotFile(tx:IDBTransaction,scope:StorageScope,id:string,row:unknown):Promise{ if(row===undefined||row instanceof Blob)return row; const file=depotFile(row,id),base=prefix(scope,file.authority),fact=await value(tx.objectStore('asset-depot').get([...base,id])); @@ -53,6 +73,7 @@ export async function resolveDepotFile(tx:IDBTransaction,scope:StorageScope,id:s return bytes.type===file.mimeType?bytes:bytes.slice(0,bytes.size,file.mimeType); } /** Convert only Root-proven stored uploads, after shared bytes and local files are written. */ +/** Root 已证明 stored upload 且共享字节存在时,把项目 Blob 行改为指针;未确认上传绝不提前转。 */ export async function promoteDepotFiles(tx:IDBTransaction,scope:StorageScope,key:string,image:RecoveryImage,cut:DepotCut){ type Resource={id:string;digest?:string;byteLength:number;mimeType:string;stored?:SharedAsset['asset'];retirement?:{purgedAt?:number}}; const payload=image.payload as {runtime?:{resources?:readonly (readonly [string,Resource])[]}},entries=payload?.runtime?.resources; @@ -60,6 +81,7 @@ export async function promoteDepotFiles(tx:IDBTransaction,scope:StorageScope,key const resources=new Map(entries),files=tx.objectStore('files'),base=prefix(scope,cut.authority); for(const write of cut.writes){const id=write.ref.asset.id,resource=resources.get(id);if(!resource?.stored||resource.retirement?.purgedAt!==undefined)continue; if(resource.id!==id||resource.digest!==write.ref.digest||resource.byteLength!==write.ref.asset.bytes||typeof resource.mimeType!=='string'||!equal(resource.stored,write.ref.asset))throw fault('shared_asset_identity_conflict',[id]); + // 已是指针时只校验一致性;Blob 长度/MIME 与共享字节必须全部一致才可替换。 const local=await value(files.get([key,id]));if(local===undefined)continue; if(!(local instanceof Blob)){const pointer=depotFile(local,id);if(pointer.authority!==cut.authority||pointer.digest!==write.ref.digest||pointer.byteLength!==resource.byteLength||pointer.mimeType!==resource.mimeType)throw fault('shared_asset_identity_conflict',[id]);continue;} const bytes=await value(tx.objectStore('asset-bytes').get([...base,write.ref.digest])); @@ -68,20 +90,28 @@ export async function promoteDepotFiles(tx:IDBTransaction,scope:StorageScope,key files.put({fileVersion:1,kind:'asset-depot',authority:cut.authority,resourceId:id,digest:write.ref.digest,byteLength:resource.byteLength,mimeType:resource.mimeType} satisfies DepotFile,[key,id]); } } +/** 注入主 adapter 的事务执行器,统一复用 metadata/fence/事务终态屏障。 */ type Execute=(leader:StorageLeader,stores:string[],mode:IDBTransactionMode,run:(tx:IDBTransaction)=>Promise)=>Promise; +/** 为主 storage 装配 read/collect;调用者不能绕开有效 leader 直接操作跨项目共享行。 */ export function createDepotStorage(execute:Execute):AssetDepotPort{return Object.freeze({ + /** 读取本域身份与尚未 purge 的字节,元信息及 Blob 类型/长度都校验,缺字节不丢身份。 */ async read(leader,scopeAuthority,id){authority(scopeAuthority);if(typeof id!=='string'||!id)throw fault('invalid_resource_id');return execute(leader,depotStores,'readonly',async tx=>{const base=prefix(leader.scope,scopeAuthority),row=await value(tx.objectStore('asset-depot').get([...base,id]));if(!row)return;validateFact(row,scopeAuthority,id);const bytes=row.purgedAt===undefined?await value(tx.objectStore('asset-bytes').get([...base,row.ref.digest])):undefined;if(bytes!==undefined&&(!(bytes instanceof Blob)||bytes.size!==row.ref.asset.bytes))throw fault('corrupt_asset_depot');return freeze({...row,...(bytes?{bytes}:{})}) as DepotEntry;});}, + /** 一次至多128身份的两阶段回收:先软标记,保留期过后 purge;完整性门禁覆盖所有本地项目。 */ async collect(leader,input){authority(input.authority);const {now,retentionMs,limit,after}=input;if(!Number.isSafeInteger(now)||now<0||!Number.isSafeInteger(retentionMs)||retentionMs<0||!Number.isSafeInteger(limit)||limit<1||limit>128||after!==undefined&&(typeof after!=='string'||!after))throw fault('invalid_depot_collection'); return execute(leader,depotStores,'readwrite',async tx=>{ const scope=leader.scope,metadata=tx.objectStore('metadata'),keys=await value(metadata.getAllKeys()),heads=await value<{throughRevision?:number}[]>(metadata.getAll()),base=prefix(scope,input.authority),manifests=tx.objectStore('asset-pins'),blockedProjects:string[]=[],pinned=new Set(); + // 包括已关闭项目:其 manifest 必须与当前 Root 头一致,任一缺失/过期/opaque 都阻止删除。 for(let i=0;itypeof p!=='string'||!p))throw fault('corrupt_storage_scope');if(parts[0]!==scope.accountId||parts[1]!==scope.workspaceId||heads[i].throughRevision===undefined)continue; const pins=await value(manifests.get([...base,parts[2]]));if(!pins||pins.version!==1||pins.rootRevision!==heads[i].throughRevision||pins.complete!==true){blockedProjects.push(parts[2]);continue;} if(!Array.isArray(pins.pins))throw fault('corrupt_depot_pins');for(const row of pins.pins){if(!Array.isArray(row)||row.length!==2||typeof row[0]!=='string'||!row[0]||!Array.isArray(row[1])||!row[1].length||row[1].some(owner=>typeof owner!=='string'||!owner))throw fault('corrupt_depot_pins');pinned.add(row[0]);} } + // 发现阻碍时整次不标记/不清理,不用当前打开项目的 pin 代替其它项目证据。 if(blockedProjects.length)return freeze({complete:false,blockedProjects,markedIds:[],purgedIds:[],pinnedIds:[]}); const store=tx.objectStore('asset-depot'),factKeys=await value(store.getAllKeys(range(scope,input.authority))),rows=await value(store.getAll(range(scope,input.authority))),markedIds:string[]=[],purgedIds:string[]=[],pinnedIds:string[]=[]; for(let i=0;i({row,key:factKeys[i] as string[]})).filter(({key})=>after===undefined||key[3]>after).slice(0,limit+1),page=candidates.slice(0,limit); + // repin 清软标记;无引用首次记录时刻;时钟倒退不提前 purge;永久身份不重新分配。 for(const {row,key}of page){const id=key[3];if(pinned.has(id)){pinnedIds.push(id);if(row.purgedAt===undefined&&row.unreferencedAt!==undefined){delete row.unreferencedAt;store.put(row,key);}continue;}if(row.purgedAt!==undefined)continue;if(row.unreferencedAt===undefined){row.unreferencedAt=now;markedIds.push(id);store.put(row,key);}else if(now>=row.unreferencedAt&&now-row.unreferencedAt>=retentionMs){row.purgedAt=now;purgedIds.push(id);store.put(row,key);}} // Preserve a digest while any other live resource identity still owns its bytes. for(const digest of new Set(page.filter(({row})=>row.purgedAt===now).map(({row})=>row.ref.digest)))if(!rows.some(row=>row.ref.digest===digest&&row.purgedAt===undefined))tx.objectStore('asset-bytes').delete([...base,digest]); diff --git a/src/lib/canvas-runtime/asset-depot.ts b/src/lib/canvas-runtime/asset-depot.ts index 936455d..9335899 100644 --- a/src/lib/canvas-runtime/asset-depot.ts +++ b/src/lib/canvas-runtime/asset-depot.ts @@ -1,35 +1,60 @@ +/** + * 同账号/工作区内的共享原图仓库门面,authority 区分服务授权域,digest 标识原始不可变字节。 + * 复用图片是复用资源身份/字节,不复制成新上传任务;新 adoption 必须重新验证当前权限,不能仅信热缓存。 + * 本门面只管理元数据缓存和受控读取,可靠写入通过与项目 Root 同事务的 DepotCut 完成。 + */ import type {Id} from '../canvas-core/types.ts'; import {equal,fault,freeze,readMap} from '../canvas-core/values.ts'; import type {StoredAsset,LoadingAssetAdmission} from './resources.ts'; import type {StorageLeader,StorageScope} from './storage-port.ts'; +/** 版本化共享身份:授权域 + 原字节 SHA256 + 服务端已确认资产,不含临时 blob URL。 */ export type SharedAsset={readonly version:1;readonly authority:string;readonly digest:string;readonly asset:StoredAsset}; +/** 目标项目采纳资源的操作证据,可附部分加载来源准入,不等于重新上传。 */ export type SharedAssetBinding=SharedAsset&{readonly operationId:Id;readonly loadingAdmission?:(LoadingAssetAdmission&{readonly operationId:Id})}; +/** 当前服务授权端口:resolve 重新确认权限/元数据,read 取原字节,不用预览转码代替。 */ export type AssetAuthority={readonly scope:string;resolve(id:Id,signal:AbortSignal):Promise;read(id:Id,signal:AbortSignal):Promise}; +/** 一次写入的不可变引用与可选原字节;已有共享字节时可只写身份。 */ export type DepotWrite={readonly ref:SharedAsset;readonly bytes?:Blob}; +/** 某 Root 的仓库清单:完整性、资源→保留 owner 列表和写入,须与 Root 同次提交。 */ export type DepotCut={readonly version:1;readonly authority:string;readonly complete:boolean;readonly pins:readonly (readonly [Id,readonly string[]])[];readonly writes:readonly DepotWrite[]}; +/** 共享身份即使字节已回收仍保留,unreferencedAt/purgedAt 区分软标记与永久字节清理。 */ export type DepotEntry={readonly ref:SharedAsset;readonly bytes?:Blob;readonly unreferencedAt?:number;readonly purgedAt?:number}; +/** 分页回收结果;被任何项目的不完整/过期清单阻挡时 complete=false,不能当作安全空仓库。 */ export type DepotCollection={readonly complete:boolean;readonly blockedProjects:readonly Id[];readonly markedIds:readonly Id[];readonly purgedIds:readonly Id[];readonly pinnedIds:readonly Id[];readonly nextAfter?:Id}; +/** 存储侧共享仓库能力,leader/fence 检查由实际 adapter 执行。 */ export type AssetDepotPort={ read(leader:StorageLeader,authority:string,id:Id):Promise; collect(leader:StorageLeader,input:{readonly authority:string;readonly now:number;readonly retentionMs:number;readonly limit:number;readonly after?:Id}):Promise; }; +/** 严格验证授权域、64位小写十六进制摘要、图片 MIME、正尺寸/字节及像素/20MiB上限。 */ export function validateSharedAsset(value:SharedAsset):void{ if(!value||value.version!==1||typeof value.authority!=='string'||!value.authority||value.authority.length>8192||typeof value.digest!=='string'||!/^([a-f0-9]{64})$/.test(value.digest))throw fault('invalid_shared_asset'); const asset=value.asset;if(!asset||typeof asset.id!=='string'||!asset.id||typeof asset.url!=='string'||!asset.url||asset.url.length>8192||![asset.width,asset.height,asset.bytes].every(n=>Number.isSafeInteger(n)&&n>0)||asset.width*asset.height>40_000_000||asset.bytes>20*1024*1024||!['image/png','image/jpeg','image/gif','image/webp'].includes(asset.mime_type))throw fault('invalid_shared_asset'); } +/** 验证后捕获并冻结资源元信息,隔离调用者可变的 asset 对象。 */ export function sharedAssetRef(value:SharedAsset):SharedAsset{validateSharedAsset(value);return freeze({version:1 as const,authority:value.authority,digest:value.digest,asset:{...value.asset}});} +/** 比较完整不可变引用,不能只因 ID 或 URL 一样就容忍摘要/尺寸变化。 */ const same=(a:SharedAsset,b:SharedAsset)=>equal(sharedAssetRef(a),sharedAssetRef(b)); /** Immutable metadata cache only. Every new adoption resolves current permission first. */ +/** 建立工作区门面,绑定作用域与当前身份,不把跨账号缓存混入同一个 records 表。 */ export function createAssetDepot(options:{scope:Pick;port:AssetDepotPort;current():boolean;digest(bytes:Blob,signal?:AbortSignal):Promise}){ const scope=freeze({accountId:options.scope.accountId,workspaceId:options.scope.workspaceId});let closed=false;const records=new Map(),controllers=new Set(); + /** 每个 await 前后使用:宿主仍有效且 leader 属于同账号/工作区;允许不同项目共享仓库。 */ const check=(leader:StorageLeader)=>{if(closed||!options.current())throw fault('identity_changed');if(!leader.active||leader.scope.accountId!==scope.accountId||leader.scope.workspaceId!==scope.workspaceId)throw fault('asset_depot_scope_mismatch');}; + /** 缓存同 authority/assetId 的不可变引用;相同身份元数据改变视为冲突,不静默覆盖。 */ function remember(ref:SharedAsset){validateSharedAsset(ref);const key=JSON.stringify([ref.authority,ref.asset.id]),previous=records.get(key);if(previous&&!same(previous,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);records.set(key,sharedAssetRef(ref));} return Object.freeze({scope,getResources:()=>readMap(records), + /** 新采纳先远端 resolve,再比本地身份/读取原字节并验长度+SHA;每个 await 后重新检查取消/身份。 + * 成功只返回 ref/bytes 和记元信息,目标 binding 与字节持久化由 engine 的单 Root 完成。 */ 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。 */ 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 collect(leader:StorageLeader,input:Parameters[1]){check(leader);const result=await options.port.collect(leader,input);check(leader);return result;}, + /** 关闭门面并 abort 授权任务、清元缓存;不抹掉共享存储中的持久身份/字节。 */ close(){closed=true;for(const controller of controllers)controller.abort();controllers.clear();records.clear();}, }); } +/** 公共仓库门面 API 由工厂推导,与底层 AssetDepotPort 分开。 */ export type AssetDepot=ReturnType; diff --git a/src/lib/canvas-runtime/depot-cut.ts b/src/lib/canvas-runtime/depot-cut.ts index 4a803c0..7f9d0b0 100644 --- a/src/lib/canvas-runtime/depot-cut.ts +++ b/src/lib/canvas-runtime/depot-cut.ts @@ -1,3 +1,8 @@ +/** + * 从即将持久化的精确 EngineSnapshot 派生共享仓库清单。 + * 清单关联同一 rootRevision;不能把较新的采纳字节或旧的 pin 扫描混进这次 Root。 + * 这里只准备 descriptors,实际字节校验和写入交给 storage adapter 的原子事务。 + */ import type {Registry} from '../canvas-core/types.ts'; import {fault} from '../canvas-core/values.ts'; import type {EngineSnapshot} from './contracts.ts'; @@ -7,13 +12,16 @@ import type {StorageLeader,StoragePort} from './storage-port.ts'; import {resourceRetentionPins} from './resource-retention.ts'; /** Derive one project's shared-byte manifest from the exact Root being persisted. */ +/** 生成已确认上传/跨项目 binding 的共享身份、所需字节与保留清单;无 depot 能力明确拒绝。 */ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Registry;storage:StoragePort;leader:StorageLeader;authority:string;files:ReadonlyMap;displays:ReadonlyMap;temporary:ReadonlyMap;adoptedFiles?:ReadonlyMap}):Promise{ const {snapshot,storage,leader,authority}=input,pins=resourceRetentionPins(snapshot,input.registry,input.displays,input.temporary),writes:DepotWrite[]=[]; if(!storage.assetDepot)throw fault('asset_depot_unavailable'); + // 仅 stored+digest 的原图可进入共享身份;未上传文件仍留在项目本地文件仓库。 for(const resource of snapshot.runtime.resources.values())if(resource.stored&&resource.digest){ const ref=sharedAssetRef({version:1,authority,digest:resource.digest,asset:resource.stored}); if(ref.asset.id!==resource.id||ref.asset.bytes!==resource.byteLength)throw fault('shared_asset_identity_conflict',[resource.id]); let bytes:Blob|undefined; + // 仅未 purge 且有保留理由时补共享字节;仓库已有字节就不重复提供项目文件。 if(resource.retirement?.purgedAt===undefined&&pins.pins.has(resource.id)){ const cached=await storage.assetDepot.read(leader,authority,resource.id); // The store enforces immutable identity even when bytes are already present. @@ -21,12 +29,14 @@ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Re } writes.push({ref,...(bytes?{bytes}:{})}); } + // 跨项目采纳 binding 不应与本地 upload resource 重名;延后 Root 的 bytes 不得提前提交。 for(const [id,binding]of snapshot.runtime.assetBindings??[]){ 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}:{})}); } // A source/run envelope may refer to resources outside this project's upload table. // Its complete resource interpreter must be installed before global deletion is allowed. + // 引用、加载与任务解释都完整才允许全局回收;opaque run/未完导入不能假定无共享资源引用。 const complete=pins.complete&&!snapshot.runtime.loading&&![...(snapshot.runtime.imports?.values()??[])].some(job=>!['completed','partial','cancelled'].includes(job.status))&&snapshot.runtime.runs.size===0; return{version:1,authority,complete,pins:[...pins.pins].map(([id,owners])=>[id,[...owners]] as const),writes}; } diff --git a/src/lib/canvas-runtime/host.ts b/src/lib/canvas-runtime/host.ts index b3ad09b..7867fd8 100644 --- a/src/lib/canvas-runtime/host.ts +++ b/src/lib/canvas-runtime/host.ts @@ -1,3 +1,8 @@ +/** + * 应用级运行宿主,生命周期跨越页面导航;同一账号下复用 workspace runtime 和共享读取器。 + * 账号/退出登录变化先递增 authEpoch 使旧回调失效,再异步关闭退休 runtime;关闭失败仍保留退休实例供重试。 + * registry 是共享定义,工作文档归 project engine,读取预览也不能成为第二份可写文档。 + */ import {createModelPreview,type LiveModelPreview} from './model-preview.ts'; import type { Id, Registry } from '../canvas-core/types.ts'; import { fault, readMap } from '../canvas-core/values.ts'; @@ -6,16 +11,21 @@ import { createScopedLoader, type LoadPort, type ScopedLoader } from './loader.t import type { CanonicalReadUpdate } from './contracts.ts'; /** Host lifetime matches the signed-in application, independently of navigation. */ +/** 宿主注入 workspace/loader 工厂;current 闭包绑定精确账号与 epoch,作为异步结果准入围栏。 */ 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; onError?: (error: unknown) => void; onObserverError?: (error: unknown) => void }) { let accountId: Id | null = null, authEpoch = 0, disposed = false; + // active 表只含当前身份,retired 持有尚未成功关闭的旧 runtime,不能因换账号就丢掉待保存内容。 const workspaces = new Map(), retired = new Set(); const readers = new Map>(); + /** 并行尝试关闭退休实例,成功才移出集合;汇总失败而不提前释放失败实例。 */ const closeRetired = async () => { const results = await Promise.allSettled([...retired].map(async runtime => { await runtime.close(); retired.delete(runtime); })); const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []); if (errors.length) throw new AggregateError(errors, 'host_close_failed'); }; + /** 同步撤销当前身份:先换 epoch,再关闭读取句柄/移走 workspace;实际存储收尾另行等待。 */ function retire() { authEpoch++; accountId = null; for (const reader of readers.values()) reader.close();readers.clear();for (const runtime of workspaces.values()) retired.add(runtime); workspaces.clear(); } + /** 账号变化才退休旧作用域;后台关闭错误走 onError,报告失败不能改变新账号的身份事实。 */ function activate(account: Id) { if (disposed) throw fault('host_disposed'); if (accountId !== account) { @@ -24,25 +34,36 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou } } return Object.freeze({ + /** 只读共享定义,不含各 workspace 的可变运行状态。 */ registry: options.registry, + /** 返回绑定当前 epoch 的校验函数;同账号退出再登录也因 epoch 变化而使旧函数失效。 */ bindAccount(account: Id) { activate(account); const epoch = authEpoch; return () => !disposed && accountId === account && authEpoch === epoch; }, + /** 捕获当前身份摘要,供导航/宿主检查,不携带认证凭证。 */ identity: () => Object.freeze({ accountId, authEpoch }), + /** 返回当前 workspace 表的安全只读快照,外部不能手动增删运行实例。 */ getWorkspaces: () => readMap(workspaces), + /** 通过共享 scoped loader 读 canonical 源;增量可产生只读预览,terminal 完整后才返回最终模型。 */ async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:LiveModelPreview)=>void}={}) { activate(account); const readEpoch=authEpoch; if(!options.modelLoader)throw fault('model_loader_unavailable'); let reader=readers.get(workspace); + // 同 workspace 复用并发读取;maxCacheEntries=0 不把一次已完成读取当成长期授权缓存。 if(!reader){const epoch=authEpoch,current=()=>!disposed&&accountId===account&&authEpoch===epoch;reader=createScopedLoader({load:options.modelLoader(account,workspace,epoch,current),current:()=>current(),maxCacheEntries:0});readers.set(workspace,reader);} + // 每个订阅者自己解算预览/seen 游标,观察者异常只终止该预览,不更改共享读取事实。 const preview=settings.onPreview?createModelPreview(options.registry):undefined;let seen=0,previewFailed=false;let declaredCanvasIds:readonly Id[]=[]; + /** 先检查身份/取消,再消费尚未处理的块;头部范围证据决定 loading 版本与可用覆盖范围。 */ const onUpdate=(state:import('./loader.ts').LoadSnapshot)=>{ if(!preview||previewFailed||disposed||accountId!==account||authEpoch!==readEpoch||settings.signal?.aborted)return; try{for(;seencanvas.id));settings.onPreview?.(Object.freeze({...value,versions:readMap(new Map(Object.entries(chunk.serverVersions))),loading:Object.freeze({...(value.canvasElementCounts?value.elementOwners?{version:6 as const,canvasElementCounts:value.canvasElementCounts,elementOwners:value.elementOwners,sourceCanvases:value.sourceCanvases!}:{version:3 as const,canvasElementCounts:value.canvasElementCounts}:{version:1 as const}),sourceToken:chunk.snapshotToken,declaredCanvasIds,receivedElements:value.receivedElements,totalElements:value.totalElements})}));}}} catch(error){previewFailed=true;try{options.onObserverError?.(error);}catch{}} }; + // key 纳入账号、workspace、格式、查询与 authEpoch;view 导航代次不替代授权代次。 const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal,onUpdate}); + // ready 后再查身份和 terminal/complete;finally 始终释放本订阅,不能泄漏 reader 引用。 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(account: Id, workspace: Id) { activate(account); let runtime = workspaces.get(workspace); @@ -53,8 +74,11 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou } return runtime; }, + /** 退出身份但保留 host 可再次绑定;返回旧 runtime 收尾 Promise,让宿主处理未保存错误。 */ retireIdentity() { retire(); return closeRetired(); }, + /** 永久关闭 host 并退休全部作用域;失败仍可再次调用 close 重试退休集合。 */ close() { disposed = true; retire(); return closeRetired(); }, }); } +/** 公共宿主 API 由工厂返回值推导,避免接口声明与实现两份维护。 */ export type RuntimeHost = ReturnType; diff --git a/src/lib/canvas-runtime/orphan-files.ts b/src/lib/canvas-runtime/orphan-files.ts index 9cebe71..0e0ef05 100644 --- a/src/lib/canvas-runtime/orphan-files.ts +++ b/src/lib/canvas-runtime/orphan-files.ts @@ -1,14 +1,21 @@ +/** + * 历史残留文件的盘点、保留与身份保护。孤儿不是“当前画布没显示”,而是经过完整引用/任务检查 + * 仍无归属的实际文件;注册后保留身份,永久清理后也不能忘记记录或把 ID 重新分给其它内容。 + */ import type {Id,Registry} from '../canvas-core/types.ts'; import {equal,fault,freeze,readMap} from '../canvas-core/values.ts'; import type {EngineSnapshot,RuntimeState} from './contracts.ts'; import {importFiles,terminalImport,validFileRetirement,type FileRetirement} from './import-file-retention.ts'; import {resourceRetentionPins} from './resource-retention.ts'; +/** 盘点确认的文件身份/摘要与退休状态,不是上传资源元数据,也不是 DOM 图片缓存。 */ export type OrphanFile={readonly version:1;readonly id:Id;readonly byteLength:number;readonly mimeType:string;readonly digest:string;readonly retirement?:FileRetirement}; +/** 检查 ID、字节数、MIME、SHA256 和退休解释器;失败不默默忽略损坏文件记录。 */ export function validateOrphanFile(value:unknown,id:Id):asserts value is OrphanFile { const file=value as OrphanFile; if(!file||file.version!==1||file.id!==id||!id||!Number.isSafeInteger(file.byteLength)||file.byteLength<0||typeof file.mimeType!=='string'||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest)||file.retirement!==undefined&&!validFileRetirement(file.retirement))throw fault('invalid_orphan_file'); } +/** 结合 pin 与所有未解释任务/审计证据给出回收阻碍;blockers 非空即 complete=false。 */ export function orphanCoverage(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap,temporary:ReadonlyMap) { const pins=resourceRetentionPins(snapshot,registry,displays,temporary),blockers:string[]=[]; if(!pins.complete)blockers.push('opaque_references'); @@ -20,13 +27,16 @@ export function orphanCoverage(snapshot:EngineSnapshot,registry:Registry,display return Object.freeze({...pins,complete:blockers.length===0,blockers:Object.freeze(blockers)}); } /** Registered orphan identities survive repinning. Purged identities cannot be forgotten or reassigned. */ +/** 在发布前维护孤儿事实与回收 reservations:身份不得与资源/导入源冲突,已 purge 不得改写。 */ export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,coverage:()=>{complete:boolean;pins:ReadonlyMap},reserved:ReadonlySet,referencesChanged:boolean):RuntimeState { if(!previous.orphanFiles?.size&&!next.orphanFiles?.size)return next; const hasSoft=[...(next.orphanFiles?.values()??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined); if(previous.orphanFiles===next.orphanFiles&&previous.resources===next.resources&&previous.imports===next.imports&&!reserved.size&&!referencesChanged&&!hasSoft)return next; + // 即使孤儿后来被引用也保留其注册身份;只能撤销软退休,不能从表里悄悄删掉历史事实。 const sources=importFiles(next.imports); for(const [id,file]of next.orphanFiles??[]){validateOrphanFile(file,id);if(next.resources.has(id)||sources.has(id))throw fault('local_file_identity_conflict',[id]);} for(const [id,file]of previous.orphanFiles??[]){const after=next.orphanFiles?.get(id);if(!after||file.retirement?.purgedAt!==undefined&&!equal(file,after))throw fault('local_file_retired',[id]);} + // 覆盖不足/重 pin 取消软退休;永久删除后重 pin 明确拒绝,不能让模型引用失去字节的 ID。 const pins=coverage(),clear:Id[]=[]; for(const [id,file]of next.orphanFiles??[]){ if(reserved.has(id)&&(!pins.complete||pins.pins.has(id)))throw fault('resource_collection_in_progress',[id]); @@ -38,6 +48,7 @@ export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,cover } /** Read-only I/O may finish late; cancellation detaches its result without publishing it. */ +/** 取消只让调用者停止采纳读取结果,不保证底层只读 I/O 被终止;迟到成功/失败均已接住。 */ export function abortableFileRead(task:Promise,signal:AbortSignal):Promise{ 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);});}); } diff --git a/src/lib/canvas-runtime/resource-retention.ts b/src/lib/canvas-runtime/resource-retention.ts index 7af2ef2..4f63656 100644 --- a/src/lib/canvas-runtime/resource-retention.ts +++ b/src/lib/canvas-runtime/resource-retention.ts @@ -1,3 +1,8 @@ +/** + * 本地文件回收前的完整保留视图与发布协调。 + * working、ACK、history/ledger、导入/run、解码、临时准备和显示句柄分别保留资源;任何未知引用都保守阻止删除。 + * 退休标记不等于已删字节:重新 pin 会撤销软退休,已永久清理的本地独有字节不能假装仍可恢复。 + */ import {importNeedsSeal} from './imports.ts'; import type {Id,Registry} from '../canvas-core/types.ts'; import {fault,readMap,readSet,freeze} from '../canvas-core/values.ts'; @@ -5,19 +10,23 @@ import type {EngineSnapshot,RuntimeState} from './contracts.ts'; import {createResourcePinScan} from './resources.ts'; /** Complete local-byte protection for one project scope; no DOM/viewport filtering. */ +/** 同时扫描 working 与 acknowledged;ACK 只额外取元素引用,避免把历史重复当另一套业务事实。 */ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap=new Map(),temporary:ReadonlyMap=new Map()) { const {runtime}=snapshot; const scan=createResourcePinScan(snapshot.history,runtime.ledger,registry); const current=scan(snapshot.document,runtime.resourcePins); const base=scan(runtime.acknowledged); const pins=new Map>(); + /** 合并 resource ID→owner 理由集,删除一个理由不影响其它 owner 的保留权。 */ const add=(id:Id,owner:string)=>{let owners=pins.get(id);if(!owners){owners=new Set();pins.set(id,owners);}owners.add(owner);}; for(const [id,owners]of current.pins)for(const owner of owners)add(id,owner); for(const [id,owners]of base.pins)for(const owner of owners)if(owner.startsWith('element:'))add(id,`acknowledged:${owner}`); // Sources and server run envelopes are not all interpreted resource schemas yet. // Pin the whole known local depot rather than claiming opaque task data contains no references. + // 未结束/未封存导入和 opaque run 保留已知资源仓库,不假设尚未解释的 envelope 没有引用。 for(const job of runtime.imports?.values()??[])if(!['completed','partial','cancelled'].includes(job.status)||importNeedsSeal(job)){if(job.source)add(job.source.fileId,`import-source:${job.id}`);for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`import:${job.id}`);} for(const run of runtime.runs.values())for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`run:${run.id}`); + // 等待解码或没有解释器的状态也 pin;显示 URL 租用数与临时任务另外合并。 for(const resource of runtime.resources.values())if(resource.image&&(resource.image.version!==1||resource.image.status==='pending'))add(resource.id,resource.image.version!==1?'image-decoder-unavailable':'image-decode'); for(const [owner,ids]of temporary)for(const id of ids)add(id,owner); for(const [id,count]of displays)if(count>0)add(id,'display'); @@ -25,15 +34,19 @@ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry, } /** Runs inside the editor's single publication boundary, including reconcile and metadata commits. */ +/** 所有 Root 发布路径共同检查回收:reserved 正在原子删除时,不允许新 pin 或不完整覆盖闯入。 */ export function prepareResourceRetention(next:EngineSnapshot,registry:Registry,displays:ReadonlyMap,reserved:ReadonlySet,temporary:ReadonlyMap=new Map()):RuntimeState { + // 没有待回收工作/软标记时快速复用 runtime,避免每次普通编辑重扫整个资源集合。 if(!reserved.size&&![...next.runtime.resources.values()].some(resource=>resource.retirement&&(resource.retirement.purgedAt===undefined||!resource.stored)))return next.runtime; const pins=resourceRetentionPins(next,registry,displays,temporary); if(reserved.size&&(!pins.complete||[...reserved].some(id=>pins.pins.has(id))))throw fault('resource_collection_in_progress',[...reserved]); let resources=next.runtime.resources; for(const [id,resource]of next.runtime.resources){ if(!resource.retirement)continue; + // 本地独有且已删字节不能重新引用;有 stored 远端副本则可由显示/读取端口恢复。 if(resource.retirement.purgedAt!==undefined){ if(!resource.stored&&pins.pins.has(id))throw fault('resource_bytes_unavailable',[id]); + // 重新引用或覆盖失去完整性都清掉软退休,下一次无引用必须重新开始保留期。 }else if(!pins.complete||pins.pins.has(id)){ if(resources===next.runtime.resources)resources=new Map(resources); const {retirement:_,...kept}=resource;(resources as Map).set(id,freeze(kept)); diff --git a/src/lib/canvas-runtime/resources.ts b/src/lib/canvas-runtime/resources.ts index 6f2955d..7229ed6 100644 --- a/src/lib/canvas-runtime/resources.ts +++ b/src/lib/canvas-runtime/resources.ts @@ -1,3 +1,8 @@ +/** + * 资源元数据、对象级依赖足迹与保留引用扫描。ResourceId 指逻辑文件/远端资产,元素副本可共享它; + * Blob 字节、stored 回执、显示 URL、history pin 是不同事实,不能因为 DOM 卸载或队列为空就回收文件。 + * 扫描保守解释已知 schema,未知资源字段/历史效果会使 complete=false,阻止永久删除。 + */ import type {ImageDecodeState} from './image-decoder.ts'; import type { DefaultImageSizing } from './default-image-size.ts'; import type { Change, CoreSnapshot, Document, ElementRecord, Id, HistoryState, OperationIntent, Registry } from '../canvas-core/types.ts'; @@ -7,24 +12,40 @@ import type { FrozenRequest, LedgerSnapshot, OperationRecord } from './ledger.ts import type { OperationStatus } from './scheduler.ts'; import { readMap, readSet } from '../canvas-core/values.ts'; +/** 服务端已确认资源元信息;url 是服务记录,预览显示地址另由 displayURL 决定。 */ export type StoredAsset = { readonly id: Id; readonly url: string; readonly width: number; readonly height: number; readonly bytes: number; readonly mime_type: string }; +/** 部分加载阶段的资源准入证据,绑定源捕获、服务源 URL 与允许参与范围。 */ export type LoadingAssetAdmission = {readonly version:1;readonly sourceToken:string;readonly sourceURL:string;readonly canvasIds:readonly Id[]}; +/** 项目本地资源事实:上传操作、摘要、解码/默认尺寸进度、可选远端存储与退休水位。 */ export type ResourceRecord = { readonly loadingAdmission?:LoadingAssetAdmission; readonly id: Id; readonly operationId: Id; readonly name: string; readonly mimeType: string; readonly byteLength: number; readonly sizing?: DefaultImageSizing; readonly image?:ImageDecodeState; readonly digest?: string; readonly retirement?: {readonly version:1;readonly unreferencedAt:number;readonly purgedAt?:number}; readonly stored?: StoredAsset }; +/** 临时显示 URL 的租用句柄,release 归还引用;URL 本身不是可恢复资源身份。 */ export type DisplayHandle = { readonly url: string; release(): void }; +/** 宿主资源端口:原图读取身份、展示地址、原请求上传/查询与回执解释分开。 */ export type AssetPort = { + /** 可选共享资源授权/原字节读取端口,缓存有字节不等于当前仍有权限。 */ readonly authority?:import('./asset-depot.ts').AssetAuthority; + /** 原资源的规范来源,用于授权/身份与摘要验证,不应以转码预览替代。 */ sourceURL(resourceId: Id): string; + /** renderer 使用的可访问预览地址,认证仍由宿主负责。 */ displayURL(resourceId: Id): string; + /** 使用已冻结请求和不可变 Blob 上传,丢响应时不能换资源 ID 重新上传。 */ upload(request: FrozenRequest, bytes: Blob, signal: AbortSignal): Promise; + /** 查询原上传请求;缺回执不等于确定没存下字节。 */ query(request: FrozenRequest, signal: AbortSignal): Promise; + /** 将执行证明校验为 StoredAsset,只更新资源事实,不重新创建元素或用户历史。 */ accept(request: FrozenRequest, status: OperationStatus, resource: ResourceRecord): StoredAsset; }; +/** 调用者暂存的文件字节/名称及是否请求默认尺寸,元数据先入模型可立即显示占位。 */ export type StagedFile = { readonly bytes: Blob; readonly name: string; readonly defaultSize?: boolean }; +/** stageFiles 返回给同步创建回调的资源引用,src 不是 Blob 字节本身。 */ export type ResourceHandle = { readonly id: Id; readonly src: string }; +/** 单资源 20 MiB 上限,与解码像素预算是不同约束。 */ export const maxResourceBytes = 20 * 1024 * 1024; +/** 从 changes 收集可能依赖的资源;这里不返回完整性证明,不能直接作为安全回收扫描。 */ export function referencedResources(changes: readonly Change[], registry: Registry): Set { const result = new Set(), fields = new Set([...registry.components.values()].flatMap(component => component.resourceFields ?? [])); + /** 整记录按组件声明 resourceFields 收集;字段变化另用已安装字段名的保守并集。 */ const collect = (element: ElementRecord | null) => { if (!element) return; for (const field of registry.components.get(element.componentId)?.resourceFields ?? []) if (typeof element.data[field] === 'string') result.add(element.data[field] as string); @@ -40,13 +61,16 @@ export function referencedResources(changes: readonly Change[], registry: Regist } /** Read/write dependencies use object identities, rather than blocking every edit behind a pending image. */ +/** 构造对象级 read/write 集,避免独立文本编辑被未完成图片上传全局阻塞。 */ export function operationFootprint(intent: OperationIntent, document: Document) { const reads = new Set(), writes = new Set(); if (intent.operationType === 'asset.upload') { writes.add(`resource:${intent.resourceId}`); return { reads, writes }; } + /** 读取 owner 也依赖其祖先拥有树;暂未加载的 canvas 仍登记自身键,不能当作无依赖。 */ const readCanvas = (id: Id) => { if (document.canvases.has(id)) for (const ancestor of ancestors(document, id)) reads.add(`canvas:${ancestor}`); else reads.add(`canvas:${id}`); }; + /** 元素变化读取其 owner;连线还读取两端节点身份,和几何位置无关。 */ const record = (element: ElementRecord | null) => { if (!element) return; readCanvas(element.ownerCanvasId); @@ -68,15 +92,19 @@ export function operationFootprint(intent: OperationIntent, document: Document) } return { reads, writes }; } +/** 写/写、写/读或读/写相交才冲突,纯读/读可并行。 */ export function conflictsWith(prior: ReturnType, next: ReturnType) { return [...prior.writes].some(id => next.writes.has(id) || next.reads.has(id)) || [...prior.reads].some(id => next.writes.has(id)); } /** One synchronous retention pass shares history interpretation across working/ACK. * No snapshot or mutable reference-count state is retained after the caller returns. */ +/** 一次保留扫描的解释器,预处理 history/ledger 可复用到 working 与 ACK;不缓存新的业务快照。 */ export function createResourcePinScan(history:HistoryState,ledger:LedgerSnapshot,registry:Registry){ const transitions=new Map(),fieldTargets=new Set(); + /** 先记需要解释 data 字段变化的目标,再从历史整记录补齐它可能使用过的组件 schema。 */ const rememberTargets=(values:readonly Change[])=>{for(const change of values)if(change.kind==='field'&&change.field.startsWith('data.'))fieldTargets.add(change.target.id);}; + // 最早未解决 sequence 决定还需保留哪些历史 transition 证据;不能只扫描当前非空 undo 栈。 const pending=[...ledger.operations.values()].filter(record=>record.phase!=='resolved'),earliest=pending.reduce((value,record)=>Math.min(value,record.intent.sequence),Infinity); for(const entry of history.entries.values())rememberTargets(entry.changes); let interpreted=true; @@ -88,22 +116,29 @@ export function createResourcePinScan(history:HistoryState,ledger:LedgerSnapshot if(record.intent.historyEffect&&record.intent.historyEffect.version!==1)interpreted=false; if(record.intent.sequence>=earliest&&record.intent.historyTransition){if(record.intent.historyTransition.version!==1)interpreted=false;else rememberTargets(transitions.get(record)!);} } + // 同一 ID 的历史可能跨组件/版本,不只套用当前元素的 resourceFields。 const schemas=new Map(); + /** 每种 component/version 保留一个历史样本,只为需要解释字段变化的目标建立索引。 */ const remember=(record:ElementRecord|null)=>{if(record&&fieldTargets.has(record.id)){const list=schemas.get(record.id)??[];if(!list.some(item=>item.componentId===record.componentId&&item.dataVersion===record.dataVersion))list.push(record);schemas.set(record.id,list);}}; + /** 从整记录变化的 before/after 补历史 schema,删除后的资源仍可由 history 保留。 */ const rememberChanges=(values:readonly Change[])=>{for(const change of values)if(change.kind==='element'){remember(change.before);remember(change.after);}}; if(fieldTargets.size){ for(const entry of history.entries.values())rememberChanges(entry.changes); for(const record of ledger.operations.values()){rememberChanges(record.intent.changes);const changes=transitions.get(record);if(changes)rememberChanges(changes);} } const fields=new Set([...registry.components.values()].flatMap(component=>component.resourceFields??[])); + /** 为指定 document 生成 resource ID→owner 集;explicit 输入相反,是 owner→资源 ID 列表。 */ return(document:Document,explicit:ReadonlyMap=new Map())=>{ const pins=new Map>();let complete=interpreted; + /** 同一 owner 只计一份保留理由;调用者得到集合,不拥有第二套可变引用计数。 */ const pin=(id:Id,owner:string)=>{const set=pins.get(id)??new Set();set.add(owner);pins.set(id,set);}; + /** 已知组件逐资源字段收集,字段格式或 schema 不可解释时降低完整性,不能默认为空。 */ const element=(record:ElementRecord|null,owner:string)=>{ if(!record)return;const component=registry.components.get(record.componentId); if(registry.resolve(record)!=='known'||component?.resourceFields===undefined){complete=false;return;} for(const field of component.resourceFields){const ref=record.data[field];if(typeof ref==='string')pin(ref,owner);else if(ref!==undefined&&ref!==null)complete=false;} }; + /** 对历史/待办扫描 before+after,字段变化对当前和历史 schema 都检查,保守保留旧资源。 */ const changes=(values:readonly Change[],owner:string)=>{ for(const change of values){ if(change.kind==='element'){element(change.before,owner);element(change.after,owner);} @@ -121,6 +156,7 @@ export function createResourcePinScan(history:HistoryState,ledger:LedgerSnapshot for(const change of values)if(change.kind==='element'){collect(change.before);collect(change.after);}else if(change.kind==='field'&&change.field.startsWith('data.')&&fields.has(change.field.slice(5))){if(typeof change.before==='string')refs.add(change.before);if(typeof change.after==='string')refs.add(change.after);} for(const id of refs)pin(id,owner); }; + // 合并 working、history、未解决操作、相关 transition 及显式 owner,各保留独立可解释理由。 for(const record of document.elements.values())element(record,`element:${record.id}:${record.incarnation}`); for(const entry of history.entries.values())changes(entry.changes,`history:${entry.id}`); for(const record of ledger.operations.values())if(record.phase!=='resolved'){if(record.intent.operationType==='asset.upload')pin(record.intent.resourceId,`operation:${record.intent.id}`);else changes(record.intent.changes,`operation:${record.intent.id}`);} @@ -130,4 +166,5 @@ export function createResourcePinScan(history:HistoryState,ledger:LedgerSnapshot }; } /** Pins are derived from owners; callers receive no second mutable reference count. */ +/** 便捷单次扫描;更复杂回收用 resourceRetentionPins 叠加 ACK/显示/临时任务等保留事实。 */ export function resourcePins(snapshot:CoreSnapshot,ledger:LedgerSnapshot,registry:Registry,explicit:ReadonlyMap){return createResourcePinScan(snapshot.history,ledger,registry)(snapshot.document,explicit);} diff --git a/src/lib/canvas-runtime/storage.ts b/src/lib/canvas-runtime/storage.ts index 17c570c..721b15d 100644 --- a/src/lib/canvas-runtime/storage.ts +++ b/src/lib/canvas-runtime/storage.ts @@ -1,3 +1,9 @@ +/** + * 浏览器持久化 adapter:Web Locks 保证同 origin/数据库/scope 单写者,metadata.fence 拒绝旧代次句柄。 + * DB schema v5 的七个 store 保存 Root checkpoint/journal、文件与共享 depot,历史/ledger 等在恢复图像内。 + * crypto/JSON 编码在事务外完成;Root、文件增删和 depot 清单在同一事务提交,oncomplete 才算 durable。 + * DB schema、checkpointVersion、journalVersion、RecoveryImage.formatVersion 是独立兼容域,不能混用。 + */ import {serializeRecoveryImage} from './recovery-json.ts'; import {depotStores,createDepotStorage,detachDepotCut,verifyDepotBytes,writeDepotCut,promoteDepotFiles,resolveDepotFile} from './asset-depot-storage.ts'; import { applyJournal, diffJournal, type JournalPatch } from './journal-delta.ts'; @@ -6,13 +12,21 @@ import type { Json } from '../canvas-core/types.ts'; import { clone, fault, freeze, validJson } from '../canvas-core/values.ts'; import type {StorageScope,RecoveryImage,DurableBatch,StorageLeader,StoredFileInfo,FileInventoryPage,StoragePort} from './storage-port.ts'; +// 仅重导出便携端口类型;浏览器专属 IDB/Web Locks 实现留在本模块,不污染纯 runtime 入口。 export type {StorageScope,RecoveryImage,DurableBatch,StorageLeader,StoredFileInfo,FileInventoryPage,StoragePort} from './storage-port.ts'; +/** scope 头:fence 是 writer 代次,throughRevision 是可靠 Root 末端,checkpointRevision 是回放起点。 */ type Metadata = { fence: number; throughRevision?: number; checkpointRevision?: number }; +/** 兼容旧完整图像行:image 与对其 JSON 表示计算的摘要。 */ type StoredImage = { readonly image: RecoveryImage; readonly checksum: string }; +/** journal 差分:必须匹配前一 Root 的 revision/checksum;v1/v2 摘要覆盖范围不同。 */ type StoredDelta = {journalVersion:1|2;baseRevision:number;baseChecksum:string;header:Omit;patches:JournalPatch[];checksum:string}; +/** 新完整 checkpoint 以 UTF-8 Blob 保存,header 便于事务内检查连续性,解析/校验在事务外。 */ type StoredBlobImage={readonly checkpointVersion:1;readonly header:Omit;readonly bytes:Blob;readonly checksum:string}; +/** 恢复读取同时支持旧完整行、差分行与 Blob checkpoint,未知解释器版本明确拒绝。 */ type StoredCut = StoredImage | StoredDelta | StoredBlobImage; +/** 区分完整行与差分分支;真正格式/版本校验仍由 cutHeader 完成。 */ const isDelta=(cut:StoredCut):cut is StoredDelta=>!('image' in cut)&&!('checkpointVersion' in cut); +/** 在事务内抽取并校验版本头,避免为检查日志顺序就在事务中 await Blob/crypto。 */ function cutHeader(cut:StoredCut):Omit{ if(!cut||typeof cut!=='object')throw fault('corrupt_recovery_image'); if('image' in cut){validateImage(cut.image);return cut.image;} @@ -20,46 +34,60 @@ function cutHeader(cut:StoredCut):Omit{ if(![1,2].includes(cut.journalVersion))throw fault('recovery_delta_interpreter_unavailable'); if(!cut.header)throw fault('corrupt_recovery_delta');validateImage({...cut.header,payload:null});return cut.header; } +/** 对旧 image 或 v2 unsigned delta 的精确 JSON 字节做 SHA256,同时返回 UTF-8 长度用于成本选择。 */ async function imageDigest(image: unknown) { const bytes = new TextEncoder().encode(JSON.stringify(image)); return {checksum:[...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(value => value.toString(16).padStart(2, '0')).join(''),byteLength:bytes.byteLength}; } +/** ArrayBuffer 的 SHA256 小写十六进制表示,验证存储字节完整性,不代表服务权限。 */ async function bytesDigest(bytes:ArrayBuffer){return [...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(value=>value.toString(16).padStart(2,'0')).join('');} +/** 读取序列化 Blob 并哈希,调用方必须在 IDB 事务外等待。 */ async function blobDigest(bytes:Blob){return bytesDigest(await bytes.arrayBuffer());} +/** 剔除 checksum 自身,仅重建 v2 差分签名覆盖的原字段和顺序。 */ function deltaBody(cut:StoredDelta){const {journalVersion,baseRevision,baseChecksum,header,patches}=cut;return{journalVersion,baseRevision,baseChecksum,header,patches};} +/** 校验 account/workspace/project 并编码稳定复合 scope 键,防止相同项目跨账号串用。 */ const keyFor = (scope: StorageScope) => { if (![scope.accountId, scope.workspaceId, scope.projectId].every(value => typeof value === 'string' && value.length > 0)) throw fault('invalid_storage_scope'); return JSON.stringify([scope.accountId, scope.workspaceId, scope.projectId]); }; +/** 从已持久 Root 汇总资源/孤儿/导入源的永久清理 ID,拒绝后来用同 ID 写入新内容。 */ function purgedFileIds(image:RecoveryImage):Set{ const result=new Set(),payload=image.payload as {runtime?:{resources?:[string,{retirement?:{purgedAt?:number}}][];orphanFiles?:[string,{retirement?:{purgedAt?:number}}][];imports?:[string,{source?:{fileId:string;retirement?:{purgedAt?:number};previousFiles?:{fileId:string;retirement?:{purgedAt?:number}}[]}}][]}}; for(const rows of [payload?.runtime?.resources,payload?.runtime?.orphanFiles])if(Array.isArray(rows))for(const row of rows)if(Array.isArray(row)&&typeof row[0]==='string'&&row[1]?.retirement?.purgedAt!==undefined)result.add(row[0]); if(Array.isArray(payload?.runtime?.imports))for(const [,job]of payload.runtime.imports){if(!job?.source)continue;for(const file of [job.source,...(job.source.previousFiles??[])])if(file.retirement?.purgedAt!==undefined)result.add(file.fileId);} return result; } +/** 存储信封校验:版本受支持、revision 合法且 local≤root、payload 为 JSON;领域恢复另做深校验。 */ function validateImage(image: RecoveryImage) { if (![1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17].includes(image.formatVersion)) throw fault('unsupported_recovery_version'); if (!Number.isSafeInteger(image.rootRevision) || image.rootRevision < 0 || !Number.isSafeInteger(image.localRevision) || image.localRevision < 0 || image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image'); } +/** 将单条 IDB 请求转 Promise;put 成功不意味着整个事务成功。 */ const requestValue = (request: IDBRequest) => new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); +/** 唯一提交终态屏障:oncomplete 成功、onabort 失败;onerror 可能还会接着 abort,不提前 resolve。 */ const transactionDone = (transaction: IDBTransaction) => new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onabort = () => reject(transaction.error ?? fault('storage_aborted')); transaction.onerror = () => { /* onabort is the terminal barrier. */ }; }); /** Single-origin browser adapter. Web Locks serialize tabs; a DB fence rejects stale handles. */ +/** 需要 IndexedDB 与 Web Locks;缺能力时明确拒绝,不悄悄退成无法可靠恢复的内存存储。 */ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: LockManager; name?: string; checkpointEvery?: number } = {}): StoragePort { const factory = options.indexedDB ?? globalThis.indexedDB, locks = options.locks ?? globalThis.navigator?.locks; if (!factory || !locks) throw fault('persistent_storage_unavailable'); const checkpointEvery = options.checkpointEvery ?? 32; if (!Number.isSafeInteger(checkpointEvery) || checkpointEvery < 1) throw fault('invalid_checkpoint_interval'); let closed = false, openingFailure:Error|undefined; + // 每 leader 保存上一可靠 cut/检查点和顺序写尾链;未提交成功不能更新这些缓存。 const handles = new Set(), lastCuts=new WeakMap(), writeTails=new WeakMap>(),lastCheckpoints=new WeakMap(); + // 打开/升级一次数据库;blocked 必须显式上报,不能无限等待并让用户误以为已保存。 const opening = new Promise((resolve, reject) => { const request = factory.open(options.name ?? 'endoleap-headless', 5); + /** 增量建立缺失 store;不在 schema 升级阶段隐式迁移业务画布或重写冻结请求。 */ request.onupgradeneeded = () => { if(closed||openingFailure){request.transaction?.abort();return;} for (const name of ['metadata', 'checkpoints', 'journal', 'files',...depotStores]) if (!request.result.objectStoreNames.contains(name)) request.result.createObjectStore(name); }; request.onerror = () => reject(closed?fault('storage_closed'):openingFailure??request.error); request.onblocked = () => {openingFailure=fault('storage_upgrade_blocked');reject(openingFailure);}; + /** 迟到打开先检查关闭/blocked 状态;versionchange 及时关连接并使 leader 失效。 */ request.onsuccess = () => { if (closed||openingFailure) { request.result.close(); reject(closed?fault('storage_closed'):openingFailure); return; } request.result.onversionchange = () => { closed = true; request.result.close(); for (const handle of handles) void handle.release(); }; @@ -68,11 +96,14 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: }); // Opening failures are observable through operations without an unhandled background rejection. void opening.catch(() => {}); + /** 同步检查 adapter/leader 生命周期并返回 scope key;真正 DB fence 还要在事务内核对。 */ function check(leader: StorageLeader) { if (closed) throw fault('storage_closed'); if (!handles.has(leader) || !leader.active) throw fault('storage_leader_lost'); return keyFor(leader.scope); } + /** 统一事务包装:await DB 后再查句柄,事务内比 fence,运行回调后等待 oncomplete。 + * run 只能等待适合事务生命周期的 IDB 工作;任何失败 abort 整批并等待终态再抛原错误。 */ async function execute(leader: StorageLeader, stores: string[], mode: IDBTransactionMode, run: (transaction: IDBTransaction, metadata: Metadata, key: string) => Promise): Promise { const key = check(leader), database = await opening; check(leader); @@ -86,6 +117,7 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: return result; } catch (error) { try { transaction.abort(); } catch { /* Already terminal. */ } await done.catch(() => {}); throw error; } } + /** 先在只读事务捕获完整日志区间,再在事务外重建/哈希,最后才缓存可靠恢复结果。 */ async function recoverCut(leader:StorageLeader):Promise{ const captured=await execute(leader,['checkpoints','journal'],'readonly',async(transaction,metadata,key)=>{ if(metadata.throughRevision===undefined)return{cuts:[] as StoredCut[],checkpointRevision:undefined}; @@ -94,6 +126,7 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: if(!checkpoint)throw fault('recovery_checkpoint_missing'); let header=cutHeader(checkpoint);if(isDelta(checkpoint))throw fault('recovery_checkpoint_missing');if(header.rootRevision!==metadata.checkpointRevision)throw fault('corrupt_recovery_image'); const journal=metadata.checkpointRevision===metadata.throughRevision?[]:await requestValue(transaction.objectStore('journal').getAll(IDBKeyRange.bound([key,metadata.checkpointRevision+1],[key,metadata.throughRevision]))); + // 日志必须逐个 rootRevision 连续且 localRevision 不倒退,缺一段也不能恢复成较短成功前缀。 for(const row of journal){const next=cutHeader(row);if(next.rootRevision!==header.rootRevision+1||next.localRevisionexecute(leader,stores,mode,tx=>run(tx))), + /** 非阻塞申请 scope 排他 Web Lock;取得后先可靠递增 DB fence,再把 leader 暴露给 engine。 */ async claim(scope: StorageScope) { const key = keyFor(scope); if (closed) throw fault('storage_closed'); let unlock!: () => void, acquired!: (leader: StorageLeader) => void, failed!: (error: unknown) => void; const released = new Promise(resolve => { unlock = resolve; }); const result = new Promise((resolve, reject) => { acquired = resolve; failed = reject; }); + // Lock 回调等待 released Promise 保持锁;leader.release 幂等失效句柄后解锁并等回调结束。 const held = locks.request(`endoleap-headless:${options.name ?? 'endoleap-headless'}:${key}`, { mode: 'exclusive', ifAvailable: true }, async lock => { if (!lock) throw fault('storage_scope_in_use'); const database = await opening; @@ -143,15 +183,19 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: void held.catch(failed); return result; }, + /** 给调用者返回恢复图像副本,避免它改写 lastCuts 持有的可信基线。 */ async recover(leader: StorageLeader) {const cut=await recoverCut(leader);return cut?clone(cut.image):null;}, + /** 捕获批次→校验删除证明→顺序准备编码→原子写 Root/文件/depot;返回实际 durableRootRevision。 */ async commit(leader: StorageLeader, batch: DurableBatch) { // Detach mutable callers and descriptors before the first await, including queued commits. const depot=batch.depot?detachDepotCut(batch.depot):undefined; const image=isSealedRecoveryImage(batch.image)?batch.image:freeze(clone(batch.image)),files=(batch.files??[]).map(file=>({id:file.id,bytes:file.bytes})),deleteFiles=[...(batch.deleteFiles??[])];if(deleteFiles.length>128||new Set(deleteFiles).size!==deleteFiles.length||deleteFiles.some(id=>typeof id!=='string'||!id||files.some(file=>file.id===id)))throw fault('invalid_file_deletion');if(!isSealedRecoveryImage(image))validateImage(image); + // 删除仅接受当前图像中已 purge 的版本化证明,不能在普通文件 API 中绕开 history/pin 保留。 if(deleteFiles.length){ type FileProof={id?:string;version?:number;byteLength?:number;mimeType?:string;digest?:string;fileId?:string;retirement?:{version:number;unreferencedAt:number;purgedAt?:number}}; const payload=image.payload as {runtime?:{orphanFiles?:[string,FileProof][];resources?:[string,FileProof][];imports?:[string,{status:string;source?:FileProof&{previousFiles?:FileProof[]}}][]}}; const proofs=new Map(),owned=new Set(); + /** 文件 ID 只能属于一个资源/源/孤儿身份,按任务终态决定其退休证明是否可用于删除。 */ const own=(id:string,file:FileProof,eligible:boolean)=>{if(typeof id!=='string'||!id||owned.has(id))throw fault('invalid_file_deletion');owned.add(id);if(eligible)proofs.set(id,file);}; for(const [id,resource]of payload?.runtime?.resources??[])own(id,resource,true); if(image.formatVersion>=4)for(const [,job]of payload?.runtime?.imports??[]){if(job.source){own(job.source.fileId!,job.source,['completed','partial','cancelled'].includes(job.status));for(const file of job.source.previousFiles??[])own(file.fileId!,file,true);}} @@ -159,14 +203,17 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: if(image.formatVersion<3||deleteFiles.some(id=>{const marker=proofs.get(id)?.retirement;return !marker||marker.version!==1||!Number.isSafeInteger(marker.unreferencedAt)||marker.unreferencedAt<0||!Number.isSafeInteger(marker.purgedAt)||marker.purgedAt!{}).then(async()=>{ if(depot)await verifyDepotBytes(depot); let base=lastCuts.get(leader)??await recoverCut(leader); if(base?.image.rootRevision===image.rootRevision)base=await recoverCut(leader); if(files.length&&base){const retired=purgedFileIds(base.image);if(files.some(file=>retired.has(file.id)))throw fault('resource_identity_conflict');} + // 周期检查点或新版格式转换可切完整基线,避免旧 reader 被迫从更高格式差分恢复。 const baseCheckpoint=lastCheckpoints.get(leader),checkpoint=!base||((base.image.formatVersion>=7)&&base.image.formatVersion!==image.formatVersion)||image.rootRevision-(baseCheckpoint??0)>=checkpointEvery; let stored:StoredImage|undefined,candidate:StoredCut|undefined; if(base?.image.rootRevision===image.rootRevision){stored={image,checksum:base.checksum};candidate=stored;} + // 连续且标准信封才尝试 delta;比较真实字节成本,差分过大/不可表达则回退完整 Blob。 else if(base&&!checkpoint&&image.rootRevision===base.image.rootRevision+1&&JSON.stringify(Object.keys(image))===JSON.stringify(['formatVersion','rootRevision','localRevision','payload'])){ const patches=diffJournal(base.image.payload,image.payload); if(patches){ @@ -177,38 +224,47 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: if(digest.byteLength+78check(leader)}),checksum=await blobDigest(bytes);stored={image,checksum};candidate={checkpointVersion:1,header:{formatVersion:image.formatVersion,rootRevision:image.rootRevision,localRevision:image.localRevision},bytes,checksum};} const accepted=stored,cut=candidate; + // Root、文件、共享身份/pins 和 metadata 在同一 readwrite 事务中,不暴露已写字节但没写 Root 的状态。 const receipt=await execute(leader,['checkpoints','journal','files',...depotStores],'readwrite',async(transaction,metadata,key)=>{ const previous=metadata.throughRevision; if(previous===undefined&&base)throw fault('recovery_delta_base_mismatch'); const saved=previous===undefined?undefined:await requestValue(transaction.objectStore(metadata.checkpointRevision===previous?'checkpoints':'journal').get([key,previous])); + // 同 Root 重试只允许完全相同图像/摘要且无新文件写入;缺口或异内容一律拒绝。 if(previous!==undefined&&image.rootRevision!==previous+1){ if(image.rootRevision!==previous)throw fault('storage_revision_gap'); if(!saved||saved.checksum!==accepted.checksum||!base||JSON.stringify(base.image)!==JSON.stringify(image)||files.length)throw fault('storage_revision_conflict');for(const id of deleteFiles)if(await requestValue(transaction.objectStore('files').getKey([key,id])))throw fault('storage_revision_conflict');if(depot){await writeDepotCut(transaction,leader.scope,image.rootRevision,depot);await promoteDepotFiles(transaction,leader.scope,key,image,depot);}return{durableRootRevision:previous,checkpointRevision:metadata.checkpointRevision!}; } + // 事务内重新比对真实基线,防止准备编码时取的 lastCuts 已不对应当前 DB 头。 if(previous!==undefined&&(!saved||image.localRevision128||after!==undefined&&(typeof after!=='string'||!after))return Promise.reject(fault('invalid_file_inventory')); return execute(leader,['files',...depotStores],'readonly',async(transaction,metadata,key)=>{ if(metadata.throughRevision!==expectedRootRevision)throw fault('stale_file_inventory'); + // 游标多取一个实际有字节的文件判断下一页;共享指针先解算,已显式清理的字节不当活文件计数。 const rows=await new Promise((resolve,reject)=>{ const files:StoredFileInfo[]=[],range=IDBKeyRange.bound(after===undefined?[key]:[key,after],[key,[]],after!==undefined,true),request=transaction.objectStore('files').openCursor(range); request.onerror=()=>reject(request.error); @@ -222,7 +278,9 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: return Object.freeze({files:Object.freeze(files),rootRevision:expectedRootRevision,...(more?{nextAfter:files.at(-1)!.id}:{})}); }); }, + /** 在有效 fence 下读取本地 Blob 或共享字节指针;缺字节返回 undefined,不伪造空文件。 */ readFile(leader: StorageLeader, id: string) { return execute(leader, ['files',...depotStores], 'readonly', async(transaction, _metadata, key) => resolveDepotFile(transaction,leader.scope,id,await requestValue(transaction.objectStore('files').get([key, id])))); }, + /** 停止新存储操作、释放 leader 并关闭数据库;宿主须先等 engine 保存完成,再释放 adapter。 */ close() { closed = true; for (const handle of handles) void handle.release(); void opening.then(database => database.close()).catch(() => {}); }, }); } diff --git a/src/lib/canvas-runtime/workspace.ts b/src/lib/canvas-runtime/workspace.ts index 65d2e04..d232168 100644 --- a/src/lib/canvas-runtime/workspace.ts +++ b/src/lib/canvas-runtime/workspace.ts @@ -1,3 +1,8 @@ +/** + * 工作区级生命周期:共享 digest/display/asset depot,按 projectId 复用一个 ProjectEngine。 + * view 只借用 engine,离开页面不关闭持久任务;workspace.close 统一收尾所有项目及资源池。 + * 关闭前先停止新准入,任一项目关闭失败就保留共享池/存储供重试,不用强制清空掩盖未保存状态。 + */ import {createAssetDepot} from './asset-depot.ts'; import type {LoadingState} from './loading.ts'; import type { Document, Id, Registry } from '../canvas-core/types.ts'; @@ -7,7 +12,9 @@ import {createDigestPool,type DigestPort} from './digest-pool.ts'; import type { StoragePort } from './storage-port.ts'; import { createDisplayCache } from './display-cache.ts'; +/** 异步打开的项目引擎类型,文档、历史和运行事实的真正拥有者。 */ export type ProjectEngine = Awaited>; +/** 装配身份、存储、共享计算端口与每项目 service adapters;不把凭证写入持久文档。 */ export type WorkspaceOptions = { accountId: Id; workspaceId: Id; authEpoch: number; registry: Registry; storage: StoragePort; digests?:DigestPort; images?:import('./image-decoder.ts').ImageDecoderPort; queryBudget?:EngineOptions['queryBudget']; isCurrentIdentity(): boolean; @@ -16,13 +23,17 @@ export type WorkspaceOptions = { }; /** Workspace owns persistent projects; a view only borrows one and never closes it. */ +/** 创建工作区容器,不立刻打开所有项目,只有 open/openPartial 才取得项目 engine。 */ export function createWorkspaceRuntime(options: WorkspaceOptions) { const digests=options.digests??createDigestPool(); const displays=createDisplayCache({onError:options.onError}); + // 缓存打开中的 Promise 而非仅缓存完成实例,防止并发两个 view 为同项目申请两个 leader。 const projects = new Map>(); let accepting = true, closed = false, closing: Promise | undefined; + /** 准入同时要求工作区仍接受任务和宿主身份仍有效,关闭一开始就让旧调用失效。 */ const current = () => accepting && !closed && options.isCurrentIdentity(); const assetDepot=options.storage.assetDepot?createAssetDepot({scope:options,port:options.storage.assetDepot,current,digest:(bytes,signal)=>digests.digest(bytes,signal)}):undefined; + /** 取得/创建共享项目;打开失败只移除同一个 Promise,迟到失败不能删掉后来重开的实例。 */ function acquire(document:Document,versions:ReadonlyMap,loading?:LoadingState){ if(!current())return Promise.reject(fault('identity_changed')); const existing=projects.get(document.projectId);if(existing)return existing; @@ -33,22 +44,28 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) { accountId: options.accountId, workspaceId: options.workspaceId, authEpoch: options.authEpoch, registry: options.registry, isCurrentIdentity: current, assetDepot, + /** 查看项目打开句柄的只读表,不授予外部关闭/替换内部 ownership 的能力。 */ getProjects: () => readMap(projects), + /** 完整源与已有 engine 协调:同捕获完成走 loading.complete,旧版本不覆盖较新的本地基线。 */ open(document: Document, versions: ReadonlyMap,settings:{sourceToken?:string}={}) { return acquire(document,versions).then(async engine=>{if(!current())throw fault('identity_changed');const loading=engine.loading.getState();if(loading&&settings.sourceToken===loading.sourceToken){const result=await engine.loading.complete(document,versions,{sourceToken:settings.sourceToken});if(result.status==='rejected')throw fault(result.code,result.targets);}else{const previous=engine.getSnapshot().runtime.versions;if(![...versions].some(([id,value])=>(previous.get(id)??0)>value)){const result=engine.refresh(document,versions);if(result.status==='rejected'&&loading)throw fault(result.code,result.targets);}}return engine;}); }, + /** 接入部分源并复用项目 engine,仅相同 sourceToken 的后续块继续 accept。 */ openPartial(document:Document,versions:ReadonlyMap,loading:LoadingState){ return acquire(document,versions,loading).then(engine=>{if(!current())throw fault('identity_changed');const previous=engine.loading.getState();if(previous?.sourceToken===loading.sourceToken){const result=engine.loading.accept(document,versions,loading);if(result.status==='rejected')throw fault(result.code,result.targets);}return engine;}); }, + /** view 离开后工作区仍可补完项目加载;await 前后都检查身份,错误交宿主报告。 */ async resumeLoading(projectId:Id){ try{if(!current())throw fault('identity_changed');const opening=projects.get(projectId);if(!opening)throw fault('project_not_open');const engine=await opening;if(!current())throw fault('identity_changed');const result=await engine.loading.resume();if(result.status==='rejected')throw fault(result.code,result.targets);return result;} catch(error){if(current()){try{options.onError?.(error);}catch{}}throw error;} }, + /** 幂等/可重试关闭:复用当前 closing Promise,全部 engine 成功后才释放池和 storage。 */ close() { accepting = false; // Fence commands/transports before awaiting pending opens or storage. if (closed) return Promise.resolve(); if (closing) return closing; const task = (async () => { + // 未成功打开的项目无需关闭;已打开项目各自等待 durable 收尾,allSettled 不漏掉其它项目的错误。 const results = await Promise.allSettled([...projects.values()].map(async pending => { let engine: ProjectEngine; try { engine = await pending; } catch { return; } @@ -56,6 +73,7 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) { })); const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []); if (errors.length) throw new AggregateError(errors, 'workspace_close_failed'); + // 只有所有项目可靠关闭才到这里;失败走 finally 清 closing,下一次 close 可重试,资源不先释放。 assetDepot?.close();displays.close();digests.close();options.images?.close();options.storage.close(); projects.clear(); closed = true; })(); closing = task.finally(() => { closing = undefined; }); @@ -63,4 +81,5 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) { }, }); } +/** 公共工作区 API 从实现推导,避免另一份可变 store 合同。 */ export type WorkspaceRuntime = ReturnType;