/** * 浏览器持久化 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 是独立兼容域,不能混用。 * adapter 实例持有连接与 leader 集合,per-leader WeakMap 保存写尾链/可靠基线;close 不代替 engine 的持久化排空。 */ 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'; import { isSealedRecoveryImage, sealedRecoveryByteLength } from './recovery-encoding.ts'; 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 哈希 deltaBody。 * 因此 StoredImage.checksum 在内存中也可能是当前日志链节点摘要,并非总是 image 的独立摘要。 */ 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;} if('checkpointVersion' in cut){if(cut.checkpointVersion!==1)throw fault('recovery_checkpoint_interpreter_unavailable');if(!(cut.bytes instanceof Blob)||!cut.header)throw fault('corrupt_recovery_image');validateImage({...cut.header,payload:null});return cut.header;} 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;缺能力时明确拒绝,不悄悄退成无法可靠恢复的内存存储。 * 构造即开始打开数据库,尚未持有项目写锁;claim 才按 scope 取锁。工厂返回冻结能力外壳,内部连接状态仍可变。 * name/options 应由宿主保持稳定,锁名读取 options.name;不能把同一数据库意外装配成不同锁域。 */ 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(); }; resolve(request.result); }; }); // 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); const transaction = database.transaction([...new Set(['metadata', ...stores])], mode), done = transactionDone(transaction); try { const metadata = await requestValue(transaction.objectStore('metadata').get(key)); if (!metadata || metadata.fence !== leader.fence) throw fault('storage_fence_lost'); check(leader); const result = await run(transaction, metadata, key); // readwrite 的原子可见性与可靠回执都在 oncomplete 后成立;callback 返回值只是待采纳结果。 await done; // Never resolve durability on an individual put callback. return result; } catch (error) { try { transaction.abort(); } catch { /* Already terminal. */ } await done.catch(() => {}); throw error; } } /** 先在只读事务捕获完整日志区间,再在事务外重建/哈希,最后才缓存可靠恢复结果。 * 返回的是该只读快照,不保证 crypto 完成时仍是最新 Root;并发较新恢复已入缓存时不将其倒退。 * 捕获整个回放区间,逐条 apply/hash 没有独立合作预算;存储信封通过后仍需领域 decodeRecovery 深校验。 */ async function recoverCut(leader:StorageLeader):Promise{ const captured=await execute(leader,['checkpoints','journal'],'readonly',async(transaction,metadata,key)=>{ // 没有可靠头就返回 null;不据此推断 files/depot 中没有尚未归属或共享的字节。 if(metadata.throughRevision===undefined)return{cuts:[] as StoredCut[],checkpointRevision:undefined}; if(metadata.checkpointRevision===undefined)throw fault('recovery_checkpoint_missing'); const checkpoint=await requestValue(transaction.objectStore('checkpoints').get([key,metadata.checkpointRevision])); 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))), /** 用 ifAvailable 申请 scope 排他 Web Lock:不排队等其它持有者,但仍异步等数据库与 fence 事务。 * scope 按入口标量构造锁键、取得后复制进 handle,调用者须在异步 claim 期间保持输入稳定。 * 同一 adapter 不同 scope 可持不同锁;execute 仍用 metadata fence 拒绝旧句柄。 */ 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; }); // result 只交付 handle,held 则覆盖整个持锁生命周期;released 是二者之间的释放闸门。 // 首次 release 先失效/移出集合再解锁并等 held,重复调用会立即返回,不是另一次排空等待。 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; if (closed) throw fault('storage_closed'); const transaction = database.transaction('metadata', 'readwrite'), done = transactionDone(transaction); try { const store = transaction.objectStore('metadata'), previous = await requestValue(store.get(key)); const fence = (previous?.fence ?? 0) + 1; if (!Number.isSafeInteger(fence)) throw fault('storage_fence_exhausted'); store.put({ ...previous, fence }, key); await done; let active = true; const handle: StorageLeader = Object.freeze({ scope: Object.freeze({ ...scope }), fence, get active() { return active; }, async release() { if (!active) return; active = false; handles.delete(handle); unlock(); await held; } }); handles.add(handle); acquired(handle); await released; } catch (error) { try { transaction.abort(); } catch { /* Already terminal. */ } await done.catch(() => {}); throw error; } }); 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) { // 入队前捕获图像与描述符,避免排在旧写入后等待时被调用者改写;codec-owned sealed 图像可直接复用。 // file 外壳复制而 Blob 原样借用;depot 用自己的捕获器,均不代表已经可靠落盘。 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); // 删除依据当前提交图像的退休声明:最多128个唯一 ID,不能同批又新增。 // 这里检查证明形状/归属/任务终态,不自行重算 history pin 或保留期;Root 完整性由引擎负责。 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);}} if(image.formatVersion>=5)for(const [id,file]of payload?.runtime?.orphanFiles??[]){if(file.version!==1||file.id!==id||!Number.isSafeInteger(file.byteLength)||file.byteLength!<0||typeof file.mimeType!=='string'||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest))throw fault('invalid_file_deletion');own(id,file,true);} 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');} // checkpoint 决定替换可靠回放起点;没有基线、达到间隔,或基线版本 >=7 且格式变化时建立新起点。 // delta 不划算也会写完整 Blob,但若 checkpoint=false,它仍是 journal 中的一行,不压缩旧链。 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; // 同 revision 先沿用已恢复的链摘要;事务内仍逐字比较整 image、查 DB 头,不能只凭 revision 接受重试。 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){ const unsigned={journalVersion:2 as const,baseRevision:base.image.rootRevision,baseChecksum:base.checksum,header:{formatVersion:image.formatVersion,rootRevision:image.rootRevision,localRevision:image.localRevision},patches},digest=await imageDigest(unsigned),delta:StoredDelta={...unsigned,checksum:digest.checksum}; // Adding the ASCII checksum member costs 78 UTF-8 bytes. Owned codec // images carry an exact size; mutable callers keep the defensive path. const fullBytes=sealedRecoveryByteLength(image)??new TextEncoder().encode(JSON.stringify(image)).byteLength; 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 重试须图像/摘要相同、无新增文件,且请求删除的行已经不存在;不再写 Root。 // 可在这条分支补写 depot 清单/字节与文件指针,支持幂等重建索引;事务失败仍整体回滚。 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); request.onsuccess=()=>{void(async()=>{const cursor=request.result;if(!cursor){resolve(files);return;}const parts=cursor.key; if(!Array.isArray(parts)||parts.length!==2||parts[0]!==key||typeof parts[1]!=='string'||!parts[1]){throw fault('corrupt_file_store');} const bytes=await resolveDepotFile(transaction,leader.scope,parts[1],cursor.value); if(bytes)files.push(Object.freeze({id:parts[1],byteLength:bytes.size,mimeType:bytes.type}));if(files.length>limit)resolve(files);else cursor.continue(); })().catch(reject);}; }); const more=rows.length>limit,files=more?rows.slice(0,limit):rows; 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])))); }, /** 同步使 adapter 关闭并触发 leader.release/连接关闭,不返回排空 Promise,也不主动 abort 所有在途事务。 * 已开始事务由 IDB 自行结束,未开始任务会在 check 被拒绝;宿主须先等 engine 保存完成再释放 adapter。 */ close() { closed = true; for (const handle of handles) void handle.release(); void opening.then(database => database.close()).catch(() => {}); }, }); }