diff --git a/src/lib/canvas-runtime/asset-depot-storage.ts b/src/lib/canvas-runtime/asset-depot-storage.ts index 250cfad..b4d32fb 100644 --- a/src/lib/canvas-runtime/asset-depot-storage.ts +++ b/src/lib/canvas-runtime/asset-depot-storage.ts @@ -2,6 +2,7 @@ * 共享原图的 IndexedDB 实现:asset-depot 存身份/退休事实,asset-bytes 按摘要共享 Blob, * asset-pins 按项目存与 Root 版本绑定的完整保留清单。所有 execute 都经过主存储的 leader/fence 检查。 * 回收只清本地共享字节,保留身份;同摘要还有其它活身份时不能删实际字节。 + * 本模块不创建/提交事务:写入辅助函数借用外层事务,read/collect 借用注入的 execute;错误由其统一 abort。 */ import {equal,fault,freeze} from '../canvas-core/values.ts'; import {sharedAssetRef,validateSharedAsset,type AssetDepotPort,type DepotCut,type DepotEntry,type SharedAsset} from './asset-depot.ts'; @@ -24,23 +25,31 @@ function authority(value:string){if(typeof value!=='string'||!value||value.lengt /** 读取身份记录时验证 ref 与键一致、退休时钟合法,损坏数据不能被回收过程忽略。 */ function validateFact(row:Fact,scopeAuthority:string,id:string){validateSharedAsset(row?.ref);if(row.ref.authority!==scopeAuthority||row.ref.asset.id!==id)throw fault('corrupt_asset_depot');for(const n of [row.unreferencedAt,row.purgedAt])if(n!==undefined&&(!Number.isSafeInteger(n)||n<0))throw fault('corrupt_asset_depot');if(row.purgedAt!==undefined&&(row.unreferencedAt===undefined||row.purgedAt(),pins=cut.pins.map(row=>{if(!Array.isArray(row)||row.length!==2||typeof row[0]!=='string'||!row[0]||seen.has(row[0])||!Array.isArray(row[1])||!row[1].length||row[1].some(owner=>typeof owner!=='string'||!owner)||new Set(row[1]).size!==row[1].length)throw fault('invalid_depot_cut');seen.add(row[0]);return Object.freeze([row[0],Object.freeze([...row[1]])] as const);}); + // write 可仅含引用以复用已存字节;提供 Blob 时这里只查类型,长度/摘要留给事务外 verifyDepotBytes。 const written=new Set(),writes=cut.writes.map(row=>{validateSharedAsset(row.ref);if(row.ref.authority!==cut.authority||written.has(row.ref.asset.id)||row.bytes!==undefined&&!(row.bytes instanceof Blob))throw fault('invalid_depot_cut');written.add(row.ref.asset.id);return Object.freeze({ref:sharedAssetRef(row.ref),...(row.bytes?{bytes:row.bytes}:{})});}); return Object.freeze({version:1,authority:cut.authority,complete:cut.complete,pins:Object.freeze(pins),writes:Object.freeze(writes)}); } -/** 在 IDB 事务外计算原图 SHA256 并验长度,避免 await crypto 让事务提前失活。 */ +/** 在 IDB 事务外逐项计算原图 SHA256 并验长度,避免 await crypto 让事务提前失活。 + * 借用已捕获 cut;未附 bytes 的项不重读存储。一次整 Blob 转 ArrayBuffer,没有分块预算/取消信号。 + * 摘要证明内容匹配声明,不验证服务权限、实际图片解码尺寸或 MIME;这些由其它端口负责。 */ 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。 */ +/** 必须与项目 Root 同事务:校验既存身份、按 digest 写共享字节,并替换该项目 pin manifest。 + * 调用前须 detach/verify;本函数不重算摘要,也不自行核对 rootRevision/leader。完成只表示请求已排入事务。 */ export async function writeDepotCut(tx:IDBTransaction,scope:StorageScope,rootRevision:number,cut:DepotCut){ const base=prefix(scope,cut.authority),facts=tx.objectStore('asset-depot'),bytes=tx.objectStore('asset-bytes'); for(const row of cut.writes){const key=[...base,row.ref.asset.id],previous=await value(facts.get(key));if(previous){validateFact(previous,cut.authority,row.ref.asset.id);if(!equal(previous.ref,row.ref))throw fault('shared_asset_identity_conflict',[row.ref.asset.id]);} - // Digest-addressed bytes are shared across resource IDs and projects in this authority. + // 同 account/workspace/authority 的不同资源 ID 共用 digest 字节行。 + // 再提供同一身份的已验证 bytes 可清掉软标记与 purgedAt;只有元信息的写入保持既有退休状态。 if(row.bytes)bytes.put(row.bytes,[...base,row.ref.digest]); facts.put({...previous,ref:row.ref,...(row.bytes?{unreferencedAt:undefined,purgedAt:undefined}:{})},key); } @@ -57,9 +66,10 @@ function depotFile(row:unknown,id:string):DepotFile{ if(file.kind!=='asset-depot'||file.resourceId!==id||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest)||!Number.isSafeInteger(file.byteLength)||file.byteLength<1||file.byteLength>20*1024*1024||typeof file.mimeType!=='string')throw fault('corrupt_file_store'); return file; } -/** Undefined means bytes were explicitly evicted; identity metadata remains. */ -/** Blob 旧行直接返回;指针行核对共享身份/长度,字节已显式清理则返回 undefined。 - * 返回时恢复项目原文件 MIME,不因服务检测 MIME 与本地不同而改变原文件合同。 */ +/** Blob 旧行与不存在的行直接返回;指针行核对共享身份/长度,已 purge 或共享字节缺失均返回 undefined。 + * undefined 本身不能区分从未写入、显式回收和缺失字节;缺少身份事实或字段冲突则抛损坏错误。 + * 返回时恢复项目原文件 MIME,不因服务检测 MIME 与本地不同而改变原文件合同。 + * 不重新哈希、不向服务器授权/下载;调用方须维持事务生命周期及外层读权限。 */ export async function resolveDepotFile(tx:IDBTransaction,scope:StorageScope,id:string,row:unknown):Promise{ if(row===undefined||row instanceof Blob)return row; const file=depotFile(row,id),base=prefix(scope,file.authority),fact=await value(tx.objectStore('asset-depot').get([...base,id])); @@ -73,7 +83,10 @@ 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 行改为指针;未确认上传绝不提前转。 */ +/** 从已校验 Root 中读取 stored upload 声明,把本次 cut.writes 对应的项目 Blob 行改为共享指针。 + * 只检查声明/身份与 Blob 长度和本地 MIME,不重新对比两份文件内容;字节摘要证明依赖外层流程。 + * 缺 resource.stored、资源已 purge、无项目文件或无共享字节时跳过;不是扫描迁移项目中所有文件。 + * 借用 Root/共享写入相同事务,后续任何冲突应由外层 abort,不能只保存部分已转换行。 */ 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; @@ -92,15 +105,19 @@ export async function promoteDepotFiles(tx:IDBTransaction,scope:StorageScope,key } /** 注入主 adapter 的事务执行器,统一复用 metadata/fence/事务终态屏障。 */ type Execute=(leader:StorageLeader,stores:string[],mode:IDBTransactionMode,run:(tx:IDBTransaction)=>Promise)=>Promise; -/** 为主 storage 装配 read/collect;调用者不能绕开有效 leader 直接操作跨项目共享行。 */ +/** 为主 storage 装配 read/collect;leader/fence/事务终态由注入的 execute 保证,而非本工厂自行实现。 + * 工厂不建立第二份缓存或连接;这些数据库身份检查也不替代远端资源授权。 */ export function createDepotStorage(execute:Execute):AssetDepotPort{return Object.freeze({ /** 读取本域身份与尚未 purge 的字节,元信息及 Blob 类型/长度都校验,缺字节不丢身份。 */ async read(leader,scopeAuthority,id){authority(scopeAuthority);if(typeof id!=='string'||!id)throw fault('invalid_resource_id');return execute(leader,depotStores,'readonly',async tx=>{const base=prefix(leader.scope,scopeAuthority),row=await value(tx.objectStore('asset-depot').get([...base,id]));if(!row)return;validateFact(row,scopeAuthority,id);const bytes=row.purgedAt===undefined?await value(tx.objectStore('asset-bytes').get([...base,row.ref.digest])):undefined;if(bytes!==undefined&&(!(bytes instanceof Blob)||bytes.size!==row.ref.asset.bytes))throw fault('corrupt_asset_depot');return freeze({...row,...(bytes?{bytes}:{})}) as DepotEntry;});}, - /** 一次至多128身份的两阶段回收:先软标记,保留期过后 purge;完整性门禁覆盖所有本地项目。 */ + /** 一页至多128身份的两阶段回收:先软标记,之后另一次访问满足保留期才 purge(即使 retentionMs=0)。 + * 每次仍 getAll 本地项目 metadata 与本授权域全部 fact,页大小不限制全量扫描/驻留量。 + * complete=true 只表示清单门禁通过;若有 nextAfter,调用者还需继续下一页,不等于整仓库已回收。 */ async collect(leader,input){authority(input.authority);const {now,retentionMs,limit,after}=input;if(!Number.isSafeInteger(now)||now<0||!Number.isSafeInteger(retentionMs)||retentionMs<0||!Number.isSafeInteger(limit)||limit<1||limit>128||after!==undefined&&(typeof after!=='string'||!after))throw fault('invalid_depot_collection'); return execute(leader,depotStores,'readwrite',async tx=>{ const scope=leader.scope,metadata=tx.objectStore('metadata'),keys=await value(metadata.getAllKeys()),heads=await value<{throughRevision?:number}[]>(metadata.getAll()),base=prefix(scope,input.authority),manifests=tx.objectStore('asset-pins'),blockedProjects:string[]=[],pinned=new Set(); - // 包括已关闭项目:其 manifest 必须与当前 Root 头一致,任一缺失/过期/opaque 都阻止删除。 + // getAllKeys/getAll 在同一事务按相同键序对应。包括同账号工作区的已关闭项目;尚无可靠 Root 的头跳过。 + // 每个有 Root 项目的本授权域 manifest 必须同 revision 且 complete;不要求项目当前持有 leader。 for(let i=0;itypeof p!=='string'||!p))throw fault('corrupt_storage_scope');if(parts[0]!==scope.accountId||parts[1]!==scope.workspaceId||heads[i].throughRevision===undefined)continue; const pins=await value(manifests.get([...base,parts[2]]));if(!pins||pins.version!==1||pins.rootRevision!==heads[i].throughRevision||pins.complete!==true){blockedProjects.push(parts[2]);continue;} if(!Array.isArray(pins.pins))throw fault('corrupt_depot_pins');for(const row of pins.pins){if(!Array.isArray(row)||row.length!==2||typeof row[0]!=='string'||!row[0]||!Array.isArray(row[1])||!row[1].length||row[1].some(owner=>typeof owner!=='string'||!owner))throw fault('corrupt_depot_pins');pinned.add(row[0]);} @@ -109,11 +126,14 @@ export function createDepotStorage(execute:Execute):AssetDepotPort{return Object if(blockedProjects.length)return freeze({complete:false,blockedProjects,markedIds:[],purgedIds:[],pinnedIds:[]}); const store=tx.objectStore('asset-depot'),factKeys=await value(store.getAllKeys(range(scope,input.authority))),rows=await value(store.getAll(range(scope,input.authority))),markedIds:string[]=[],purgedIds:string[]=[],pinnedIds:string[]=[]; for(let i=0;i({row,key:factKeys[i] as string[]})).filter(({key})=>after===undefined||key[3]>after).slice(0,limit+1),page=candidates.slice(0,limit); - // repin 清软标记;无引用首次记录时刻;时钟倒退不提前 purge;永久身份不重新分配。 + // repin 只清尚未 purge 的软标记;已 purge 身份即使重新被 pin 也不在此复活字节。 + // 未引用首次只记录时刻;之后满足保留期才 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. + // rows 与 page 共用本事务读出的 row 对象,已写的 purgedAt 可直接参与全域存活判断。 + // 同摘要任一身份尚未 purge(即使未 pin、仍在保留期)都保留字节;最后一份退休后才删 digest 行。 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]); return freeze({complete:true,blockedProjects:[],markedIds,purgedIds,pinnedIds,...(candidates.length>limit?{nextAfter:page.at(-1)!.key[3]}:{})}); }); diff --git a/src/lib/canvas-runtime/storage.ts b/src/lib/canvas-runtime/storage.ts index 721b15d..dbef25a 100644 --- a/src/lib/canvas-runtime/storage.ts +++ b/src/lib/canvas-runtime/storage.ts @@ -3,6 +3,7 @@ * 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'; @@ -18,7 +19,8 @@ export type {StorageScope,RecoveryImage,DurableBatch,StorageLeader,StoredFileInf type Metadata = { fence: number; throughRevision?: number; checkpointRevision?: number }; /** 兼容旧完整图像行:image 与对其 JSON 表示计算的摘要。 */ type StoredImage = { readonly image: RecoveryImage; readonly checksum: string }; -/** journal 差分:必须匹配前一 Root 的 revision/checksum;v1/v2 摘要覆盖范围不同。 */ +/** 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}; @@ -68,7 +70,9 @@ const requestValue = (request: IDBRequest) => new Promise((resolve, rej 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;缺能力时明确拒绝,不悄悄退成无法可靠恢复的内存存储。 */ +/** 需要 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'); @@ -113,13 +117,17 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: 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])); @@ -156,14 +164,17 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: 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。 */ + /** 用 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; }); - // Lock 回调等待 released Promise 保持锁;leader.release 幂等失效句柄后解锁并等回调结束。 + // 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; @@ -187,10 +198,12 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: 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. + // 入队前捕获图像与描述符,避免排在旧写入后等待时被调用者改写;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); - // 删除仅接受当前图像中已 purge 的版本化证明,不能在普通文件 API 中绕开 history/pin 保留。 + // 删除依据当前提交图像的退休声明:最多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[]}}][]}}; @@ -203,15 +216,18 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: if(image.formatVersion<3||deleteFiles.some(id=>{const marker=proofs.get(id)?.retirement;return !marker||marker.version!==1||!Number.isSafeInteger(marker.unreferencedAt)||marker.unreferencedAt<0||!Number.isSafeInteger(marker.purgedAt)||marker.purgedAt!{}).then(async()=>{ if(depot)await verifyDepotBytes(depot); let base=lastCuts.get(leader)??await recoverCut(leader); if(base?.image.rootRevision===image.rootRevision)base=await recoverCut(leader); if(files.length&&base){const retired=purgedFileIds(base.image);if(files.some(file=>retired.has(file.id)))throw fault('resource_identity_conflict');} - // 周期检查点或新版格式转换可切完整基线,避免旧 reader 被迫从更高格式差分恢复。 + // 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'])){ @@ -232,7 +248,8 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: 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 重试须图像/摘要相同、无新增文件,且请求删除的行已经不存在;不再写 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!}; @@ -258,7 +275,9 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: }); writeTails.set(leader,task);return task; }, - /** 按 expectedRootRevision 的稳定 ID 游标分页盘点,最多128项;Root 改变则拒绝拼接旧页。 */ + /** 按 expectedRootRevision 的稳定 ID 游标分页盘点,最多128项;Root 改变则拒绝拼接旧页。 + * limit 约束返回的可读取文件数,不约束游标经过的已清理指针数;不计算 digest,也不做远端读取。 + * 同 Root 的 depot 回收可能改变可读字节,revision 门禁不等于跨多次调用持有同一 IDB 快照。 */ 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')); @@ -280,7 +299,8 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: }, /** 在有效 fence 下读取本地 Blob 或共享字节指针;缺字节返回 undefined,不伪造空文件。 */ readFile(leader: StorageLeader, id: string) { return execute(leader, ['files',...depotStores], 'readonly', async(transaction, _metadata, key) => resolveDepotFile(transaction,leader.scope,id,await requestValue(transaction.objectStore('files').get([key, id])))); }, - /** 停止新存储操作、释放 leader 并关闭数据库;宿主须先等 engine 保存完成,再释放 adapter。 */ + /** 同步使 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(() => {}); }, }); }