docs(runtime): explain browser storage and depot transaction boundaries
This commit is contained in:
@@ -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<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。 */
|
||||
/** 同步重建 pin 数组与标准引用字段,让外层能在第一个 await 前捕获 cut;Blob 沿用不可变字节对象。
|
||||
* pin ID 与 write ID 分别去重,不要求两表完全相等,也不证明 complete 声明覆盖全部 Root 引用。
|
||||
* sharedAssetRef 保留 asset 扩展属性的引用,冻结不等于任意外来对象的递归深复制。 */
|
||||
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);
|
||||
// seen 只检查资源 ID 唯一;每条 owner 列表还要非空、非重复,再复制冻结以脱离可变数组。
|
||||
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);});
|
||||
// write 可仅含引用以复用已存字节;提供 Blob 时这里只查类型,长度/摘要留给事务外 verifyDepotBytes。
|
||||
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 让事务提前失活。 */
|
||||
/** 在 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<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]);}
|
||||
// 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<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]));
|
||||
@@ -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=<T>(leader:StorageLeader,stores:string[],mode:IDBTransactionMode,run:(tx:IDBTransaction)=>Promise<T>)=>Promise<T>;
|
||||
/** 为主 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<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;完整性门禁覆盖所有本地项目。 */
|
||||
/** 一页至多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<string>();
|
||||
// 包括已关闭项目:其 manifest 必须与当前 Root 头一致,任一缺失/过期/opaque 都阻止删除。
|
||||
// getAllKeys/getAll 在同一事务按相同键序对应。包括同账号工作区的已关闭项目;尚无可靠 Root 的头跳过。
|
||||
// 每个有 Root 项目的本授权域 manifest 必须同 revision 且 complete;不要求项目当前持有 leader。
|
||||
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]);}
|
||||
@@ -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<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 游标截取一页,多取一条判断是否还有下一页。
|
||||
// 所有 fact 先验证再分页:页外损坏也阻止本次回收。复合 IDB 键序提供资源 ID 顺序,after 严格排除自身。
|
||||
// 多取一条只判断后续候选;已 purge/仍 pinned 的身份也占页名额,不是 limit 个实际删除。
|
||||
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;永久身份不重新分配。
|
||||
// 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]}:{})});
|
||||
});
|
||||
|
||||
@@ -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<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};
|
||||
@@ -68,7 +70,9 @@ const requestValue = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, rej
|
||||
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;缺能力时明确拒绝,不悄悄退成无法可靠恢复的内存存储。 */
|
||||
/** 需要 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<StoredImage|null>{
|
||||
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<StoredCut|undefined>(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<void>(resolve => { unlock = resolve; });
|
||||
const result = new Promise<StorageLeader>((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!<marker.unreferencedAt;}))throw fault('invalid_file_deletion');
|
||||
}
|
||||
check(leader);
|
||||
// 串联同 leader 的保存;前次失败不永久毒化链,但新提交仍必须匹配真实 DB 头与连续 revision。
|
||||
// 同 leader 的 crypto/编码/事务也串在一条 Promise 尾链上,避免各自准备不同基线后乱序提交。
|
||||
// 吞掉旧尾链失败只为继续调度,新请求错误仍向其调用者传播;不自动重试旧批次或跨 leader 排队。
|
||||
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 被迫从更高格式差分恢复。
|
||||
// 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<StoredCut>(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<unknown>(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(() => {}); },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user