docs(canvas): explain host ownership, asset retention and durable storage
This commit is contained in:
@@ -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=<T>(request:IDBRequest<T>)=>new Promise<T>((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<row.unreferencedAt))throw fault('corrupt_asset_depot');}
|
||||
/** Detach descriptors before awaiting crypto. Blob data is immutable and never JSON cloned. */
|
||||
/** 第一个 await 前捕获/冻结 descriptors,校验 pin/写入唯一性;Blob 不做 JSON clone。 */
|
||||
export function detachDepotCut(cut:DepotCut):DepotCut{
|
||||
if(!cut||cut.version!==1||typeof cut.complete!=='boolean'||!Array.isArray(cut.pins)||!Array.isArray(cut.writes))throw fault('invalid_depot_cut');authority(cut.authority);
|
||||
const seen=new Set<string>(),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<string>(),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<Fact|undefined>(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<Blob|undefined>{
|
||||
if(row===undefined||row instanceof Blob)return row;
|
||||
const file=depotFile(row,id),base=prefix(scope,file.authority),fact=await value<Fact|undefined>(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<string,Resource>(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<unknown>(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<Blob|undefined>(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=<T>(leader:StorageLeader,stores:string[],mode:IDBTransactionMode,run:(tx:IDBTransaction)=>Promise<T>)=>Promise<T>;
|
||||
/** 为主 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<Fact|undefined>(tx.objectStore('asset-depot').get([...base,id]));if(!row)return;validateFact(row,scopeAuthority,id);const bytes=row.purgedAt===undefined?await value<Blob|undefined>(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<string>();
|
||||
// 包括已关闭项目:其 manifest 必须与当前 Root 头一致,任一缺失/过期/opaque 都阻止删除。
|
||||
for(let i=0;i<keys.length;i++){let parts:unknown;try{parts=JSON.parse(String(keys[i]));}catch{throw fault('corrupt_storage_scope');}if(!Array.isArray(parts)||parts.length!==3||parts.some(p=>typeof 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<Pins|undefined>(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<Fact[]>(store.getAll(range(scope,input.authority))),markedIds:string[]=[],purgedIds:string[]=[],pinnedIds:string[]=[];
|
||||
for(let i=0;i<rows.length;i++){const key=factKeys[i];if(!Array.isArray(key)||key.length!==4||typeof key[3]!=='string')throw fault('corrupt_asset_depot');validateFact(rows[i],input.authority,key[3]);}
|
||||
// 先验证本域记录,再按资源 ID 游标截取一页,多取一条判断是否还有下一页。
|
||||
const candidates=rows.map((row,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]);
|
||||
|
||||
@@ -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<SharedAsset>;read(id:Id,signal:AbortSignal):Promise<Blob>};
|
||||
/** 一次写入的不可变引用与可选原字节;已有共享字节时可只写身份。 */
|
||||
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<DepotEntry|undefined>;
|
||||
collect(leader:StorageLeader,input:{readonly authority:string;readonly now:number;readonly retentionMs:number;readonly limit:number;readonly after?:Id}):Promise<DepotCollection>;
|
||||
};
|
||||
/** 严格验证授权域、64位小写十六进制摘要、图片 MIME、正尺寸/字节及像素/20MiB上限。 */
|
||||
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<StorageScope,'accountId'|'workspaceId'>;port:AssetDepotPort;current():boolean;digest(bytes:Blob,signal?:AbortSignal):Promise<string>}){
|
||||
const scope=freeze({accountId:options.scope.accountId,workspaceId:options.scope.workspaceId});let closed=false;const records=new Map<string,SharedAsset>(),controllers=new Set<AbortController>();
|
||||
/** 每个 await 前后使用:宿主仍有效且 leader 属于同账号/工作区;允许不同项目共享仓库。 */
|
||||
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<AssetDepotPort['collect']>[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<typeof createAssetDepot>;
|
||||
|
||||
@@ -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<string,Blob>;displays:ReadonlyMap<string,number>;temporary:ReadonlyMap<string,readonly string[]>;adoptedFiles?:ReadonlyMap<string,{readonly bytes:Blob;readonly rootRevision:number}>}):Promise<DepotCut>{
|
||||
const {snapshot,storage,leader,authority}=input,pins=resourceRetentionPins(snapshot,input.registry,input.displays,input.temporary),writes:DepotWrite[]=[];
|
||||
if(!storage.assetDepot)throw fault('asset_depot_unavailable');
|
||||
// 仅 stored+digest 的原图可进入共享身份;未上传文件仍留在项目本地文件仓库。
|
||||
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};
|
||||
}
|
||||
|
||||
@@ -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<CanonicalReadUpdate>; onError?: (error: unknown) => void; onObserverError?: (error: unknown) => void }) {
|
||||
let accountId: Id | null = null, authEpoch = 0, disposed = false;
|
||||
// active 表只含当前身份,retired 持有尚未成功关闭的旧 runtime,不能因换账号就丢掉待保存内容。
|
||||
const workspaces = new Map<Id, WorkspaceRuntime>(), retired = new Set<WorkspaceRuntime>();
|
||||
const readers = new Map<Id, ScopedLoader<CanonicalReadUpdate>>();
|
||||
/** 并行尝试关闭退休实例,成功才移出集合;汇总失败而不提前释放失败实例。 */
|
||||
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<CanonicalReadUpdate>)=>{
|
||||
if(!preview||previewFailed||disposed||accountId!==account||authEpoch!==readEpoch||settings.signal?.aborted)return;
|
||||
try{for(;seen<state.chunks.length;seen++){const chunk=state.chunks[seen],value=preview.update(chunk.value);if(chunk.value.status==='receiving'&&value){if(chunk.sequence===0)declaredCanvasIds=Object.freeze(chunk.value.canvases.map(canvas=>canvas.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<typeof createRuntimeHost>;
|
||||
|
||||
@@ -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<Id,number>,temporary:ReadonlyMap<Id,readonly Id[]>) {
|
||||
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<Id,unknown>},reserved:ReadonlySet<Id>,referencesChanged:boolean):RuntimeState {
|
||||
if(!previous.orphanFiles?.size&&!next.orphanFiles?.size)return next;
|
||||
const hasSoft=[...(next.orphanFiles?.values()??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined);
|
||||
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<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);});});
|
||||
}
|
||||
|
||||
@@ -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<Id,number>=new Map(),temporary:ReadonlyMap<Id,readonly Id[]>=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<Id,Set<string>>();
|
||||
/** 合并 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<Id,number>,reserved:ReadonlySet<Id>,temporary:ReadonlyMap<Id,readonly Id[]>=new Map()):RuntimeState {
|
||||
// 没有待回收工作/软标记时快速复用 runtime,避免每次普通编辑重扫整个资源集合。
|
||||
if(!reserved.size&&![...next.runtime.resources.values()].some(resource=>resource.retirement&&(resource.retirement.purgedAt===undefined||!resource.stored)))return next.runtime;
|
||||
const pins=resourceRetentionPins(next,registry,displays,temporary);
|
||||
if(reserved.size&&(!pins.complete||[...reserved].some(id=>pins.pins.has(id))))throw fault('resource_collection_in_progress',[...reserved]);
|
||||
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<Id,typeof resource>).set(id,freeze(kept));
|
||||
|
||||
@@ -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<OperationStatus>;
|
||||
/** 查询原上传请求;缺回执不等于确定没存下字节。 */
|
||||
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
||||
/** 将执行证明校验为 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<Id> {
|
||||
const result = new Set<Id>(), 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<string>(), writes = new Set<string>();
|
||||
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<typeof operationFootprint>, next: ReturnType<typeof operationFootprint>) {
|
||||
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<OperationRecord,readonly Change[]>(),fieldTargets=new Set<Id>();
|
||||
/** 先记需要解释 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<Id,ElementRecord[]>();
|
||||
/** 每种 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<Id,readonly Id[]>=new Map())=>{
|
||||
const pins=new Map<Id,Set<string>>();let complete=interpreted;
|
||||
/** 同一 owner 只计一份保留理由;调用者得到集合,不拥有第二套可变引用计数。 */
|
||||
const pin=(id:Id,owner:string)=>{const set=pins.get(id)??new Set<string>();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<Id,readonly Id[]>){return createResourcePinScan(snapshot.history,ledger,registry)(snapshot.document,explicit);}
|
||||
|
||||
@@ -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<RecoveryImage,'payload'>;patches:JournalPatch[];checksum:string};
|
||||
/** 新完整 checkpoint 以 UTF-8 Blob 保存,header 便于事务内检查连续性,解析/校验在事务外。 */
|
||||
type StoredBlobImage={readonly checkpointVersion:1;readonly header:Omit<RecoveryImage,'payload'>;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<RecoveryImage,'payload'>{
|
||||
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<RecoveryImage,'payload'>{
|
||||
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<string>{
|
||||
const result=new Set<string>(),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 = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
|
||||
/** 唯一提交终态屏障:oncomplete 成功、onabort 失败;onerror 可能还会接着 abort,不提前 resolve。 */
|
||||
const transactionDone = (transaction: IDBTransaction) => new Promise<void>((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<StorageLeader>(), lastCuts=new WeakMap<StorageLeader,StoredImage>(), writeTails=new WeakMap<StorageLeader,Promise<unknown>>(),lastCheckpoints=new WeakMap<StorageLeader,number>();
|
||||
// 打开/升级一次数据库;blocked 必须显式上报,不能无限等待并让用户误以为已保存。
|
||||
const opening = new Promise<IDBDatabase>((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<T>(leader: StorageLeader, stores: string[], mode: IDBTransactionMode, run: (transaction: IDBTransaction, metadata: Metadata, key: string) => Promise<T>): Promise<T> {
|
||||
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<StoredImage|null>{
|
||||
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<StoredCut[]>(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.localRevision<header.localRevision)throw fault('recovery_journal_gap');header=next;}
|
||||
if(header.rootRevision!==metadata.throughRevision)throw fault('recovery_journal_gap');return{cuts:[checkpoint,...journal],checkpointRevision:metadata.checkpointRevision};
|
||||
});
|
||||
@@ -101,29 +134,36 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?:
|
||||
// Hash and apply after the readonly transaction; awaiting crypto inside IDB would close it.
|
||||
for(const cut of captured.cuts){
|
||||
let image:RecoveryImage;
|
||||
// Blob 摘要先验证再 fatal UTF-8/JSON 解码;解码信封头必须与外部 header 一致。
|
||||
if('checkpointVersion' in cut){
|
||||
const serialized=await cut.bytes.arrayBuffer();if(cut.checksum!==await bytesDigest(serialized))throw fault('recovery_checksum_mismatch');
|
||||
try{image=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(serialized));}catch{throw fault('corrupt_recovery_image');}
|
||||
if(!image||typeof image!=='object'||Array.isArray(image))throw fault('corrupt_recovery_image');validateImage(image);
|
||||
if(image.formatVersion!==cut.header.formatVersion||image.rootRevision!==cut.header.rootRevision||image.localRevision!==cut.header.localRevision)throw fault('recovery_checksum_mismatch');
|
||||
}else{
|
||||
// 差分必须串在 exact base revision/checksum 后;v1 哈希结果 image,v2 哈希差分 body。
|
||||
if('image' in cut)image=cut.image;
|
||||
else{if(!previous||cut.baseRevision!==previous.image.rootRevision||cut.baseChecksum!==previous.checksum)throw fault('recovery_delta_base_mismatch');image={...cut.header,payload:applyJournal(previous.image.payload,cut.patches)};validateImage(image);}
|
||||
if(cut.checksum!==(await imageDigest('image' in cut||cut.journalVersion===1?image:deltaBody(cut))).checksum)throw fault('recovery_checksum_mismatch');
|
||||
}
|
||||
previous=freeze({image,checksum:cut.checksum});
|
||||
}
|
||||
// crypto 可能较慢,结束后再查 leader;迟到旧恢复不能把 lastCuts 缓存倒退。
|
||||
check(leader);if(previous&&(!lastCuts.has(leader)||lastCuts.get(leader)!.image.rootRevision<=previous.image.rootRevision)){lastCuts.set(leader,previous);lastCheckpoints.set(leader,captured.checkpointRevision!);}return previous;
|
||||
}
|
||||
return Object.freeze({
|
||||
/** 显式声明支持与 Root 同事务删除文件,配合退休证明校验使用。 */
|
||||
fileDeletion:'atomic-v1' as const,
|
||||
/** 共享仓库也走 execute,因此同样受 metadata fence 与事务终态约束。 */
|
||||
assetDepot:createDepotStorage((leader,stores,mode,run)=>execute(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<void>(resolve => { unlock = resolve; });
|
||||
const result = new Promise<StorageLeader>((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<string,FileProof>(),owned=new Set<string>();
|
||||
/** 文件 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!<marker.unreferencedAt;}))throw fault('invalid_file_deletion');
|
||||
}
|
||||
check(leader);
|
||||
// 串联同 leader 的保存;前次失败不永久毒化链,但新提交仍必须匹配真实 DB 头与连续 revision。
|
||||
const task=(writeTails.get(leader)??Promise.resolve()).catch(()=>{}).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+78<fullBytes){stored={image,checksum:delta.checksum};candidate=delta;}
|
||||
}
|
||||
}
|
||||
// 编码/哈希在事务外,期间通过 check 防退休句柄继续计算或采纳结果。
|
||||
if(!stored||!candidate){const bytes=await serializeRecoveryImage(image,{check:()=>check(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<StoredCut>(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.localRevision<cutHeader(saved).localRevision))throw fault('storage_revision_conflict');
|
||||
if(previous!==undefined&&(metadata.checkpointRevision!==baseCheckpoint||base?.image.rootRevision!==previous||base.checksum!==saved?.checksum))throw fault('recovery_delta_base_mismatch');
|
||||
if(isDelta(cut)&&(cut.baseRevision!==previous||cut.baseChecksum!==saved?.checksum))throw fault('recovery_delta_base_mismatch');
|
||||
if(depot)await writeDepotCut(transaction,leader.scope,image.rootRevision,depot);
|
||||
// 新文件不可覆盖既有 ID;后面的 delete/promote 任一失败都回滚本事务全部更改。
|
||||
for(const file of files){if(!file.id||!(file.bytes instanceof Blob))throw fault('invalid_staged_file');if(await requestValue(transaction.objectStore('files').getKey([key,file.id])))throw fault('resource_identity_conflict',[file.id]);transaction.objectStore('files').put(file.bytes,[key,file.id]);}
|
||||
for(const id of deleteFiles)transaction.objectStore('files').delete([key,id]);
|
||||
if(depot)await promoteDepotFiles(transaction,leader.scope,key,image,depot);
|
||||
// A format transition must not depend on a newer partial checkpoint in an older reader.
|
||||
// 检查点持久化和旧 journal 压缩一起提交,崩溃后不会只剩半份回放链。
|
||||
if(checkpoint){
|
||||
transaction.objectStore('checkpoints').put(cut,[key,image.rootRevision]);if(metadata.checkpointRevision!==undefined)transaction.objectStore('checkpoints').delete([key,metadata.checkpointRevision]);transaction.objectStore('journal').delete(IDBKeyRange.bound([key,0],[key,image.rootRevision]));metadata.checkpointRevision=image.rootRevision;
|
||||
}else transaction.objectStore('journal').put(cut,[key,image.rootRevision]);
|
||||
transaction.objectStore('metadata').put({...metadata,throughRevision:image.rootRevision},key);return{durableRootRevision:image.rootRevision,checkpointRevision:metadata.checkpointRevision!};
|
||||
});
|
||||
// 只有 execute 已等到事务成功后才推进内存可靠水位,quota/abort 不虚报已保存。
|
||||
lastCuts.set(leader,freeze(accepted));lastCheckpoints.set(leader,receipt.checkpointRevision);return{durableRootRevision:receipt.durableRootRevision};
|
||||
});
|
||||
writeTails.set(leader,task);return task;
|
||||
},
|
||||
/** 按 expectedRootRevision 的稳定 ID 游标分页盘点,最多128项;Root 改变则拒绝拼接旧页。 */
|
||||
scanFiles(leader:StorageLeader,input:{expectedRootRevision:number;after?:string;limit:number}) {
|
||||
const {expectedRootRevision,after,limit}=input;
|
||||
if(!Number.isSafeInteger(expectedRootRevision)||expectedRootRevision<0||!Number.isSafeInteger(limit)||limit<1||limit>128||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<StoredFileInfo[]>((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<unknown>(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(() => {}); },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof openProjectEngine>>;
|
||||
/** 装配身份、存储、共享计算端口与每项目 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<Id, Promise<ProjectEngine>>();
|
||||
let accepting = true, closed = false, closing: Promise<void> | 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<Id,number>,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<Id, number>,settings:{sourceToken?:string}={}) {
|
||||
return acquire(document,versions).then(async engine=>{if(!current())throw fault('identity_changed');const loading=engine.loading.getState();if(loading&&settings.sourceToken===loading.sourceToken){const result=await engine.loading.complete(document,versions,{sourceToken:settings.sourceToken});if(result.status==='rejected')throw fault(result.code,result.targets);}else{const previous=engine.getSnapshot().runtime.versions;if(![...versions].some(([id,value])=>(previous.get(id)??0)>value)){const result=engine.refresh(document,versions);if(result.status==='rejected'&&loading)throw fault(result.code,result.targets);}}return engine;});
|
||||
},
|
||||
/** 接入部分源并复用项目 engine,仅相同 sourceToken 的后续块继续 accept。 */
|
||||
openPartial(document:Document,versions:ReadonlyMap<Id,number>,loading:LoadingState){
|
||||
return acquire(document,versions,loading).then(engine=>{if(!current())throw fault('identity_changed');const previous=engine.loading.getState();if(previous?.sourceToken===loading.sourceToken){const result=engine.loading.accept(document,versions,loading);if(result.status==='rejected')throw fault(result.code,result.targets);}return engine;});
|
||||
},
|
||||
/** view 离开后工作区仍可补完项目加载;await 前后都检查身份,错误交宿主报告。 */
|
||||
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<typeof createWorkspaceRuntime>;
|
||||
|
||||
Reference in New Issue
Block a user