diff --git a/src/lib/canvas-runtime/recovery.ts b/src/lib/canvas-runtime/recovery.ts index cd39898..cd7e78b 100644 --- a/src/lib/canvas-runtime/recovery.ts +++ b/src/lib/canvas-runtime/recovery.ts @@ -3,6 +3,7 @@ * 不把它们分别恢复成可互相漂移的 store;Blob 字节走 DurableBatch.files,不放进 JSON payload。 * 编码按所需特性选择格式,解码校验表/跨表身份、依赖、请求、历史、资源与任务证据后才返回候选 Root。 * 解码不发 HTTP、不重启外部副作用,最终恢复准入和可靠存储仍由 engine/adapter 负责。 + * 领域解码同步执行;存储字节/校验摘要、当前账号权限、外部文件可读性均不在本模块证明范围内。 */ import {referencedResources} from './resources.ts'; import {validateSharedAsset,type SharedAssetBinding} from './asset-depot.ts'; @@ -35,7 +36,8 @@ import type { RecoveryImage } from './storage-port.ts'; const entryViews=new WeakMap(); /** 可选表缺省的共享空数组,只表示没有表成员,不表示其数据已完成业务校验。 */ const noEntries=dataArray([]); -/** 把 Map 编成有序元组数组;自有读视图可复用外壳,其 value 是否可复用由 codec 另查。 */ +/** 把 Map 编成按迭代顺序排列的元组数组;自有 readMap 视图可缓存成员外壳,WeakMap 不额外保活源视图。 + * 未知来源表每次重新枚举,value 仍借用原引用;codec 后续捕获值,不以稳定成员表推断值已不可变。 */ function mapValue(map?:ReadonlyMap):readonly (readonly [K,V])[]{ if(!map)return noEntries; if(!isOwnedMapView(map))return [...map]; @@ -52,7 +54,8 @@ function encodeDocumentValue(document:Document){ } // 缓存工厂信封的序列化外壳,不缓存/跳过组件数据校验,也不将该外壳视为发布文档。 const documentViews=new WeakMap>(); -/** 工厂文档可复用成员外壳;未知来源文档重新枚举,防止冻结外壳掩盖可变 Map 成员。 */ +/** 工厂文档可复用成员外壳;未知来源文档重新枚举,防止冻结外壳掩盖可变 Map 成员。 + * documentViews 只缓存 documentValue 的表示,不缓存“该文档已经通过完整 schema/图校验”的结论。 */ function documentValue(document:Document){ if(!isFactoryDocument(document))return encodeDocumentValue(document); let value=documentViews.get(document);if(!value){value=encodeDocumentValue(document);documentViews.set(document,value);}return value; @@ -61,7 +64,10 @@ function documentValue(document:Document){ * stored asset URL 和 sourceURL 属于已选协议数据,会按原值保存;不应把本函数当成任意数据的脱敏器。 */ /** 从同一 snapshot 选择文档/双栈/意图及所有运行事实,按当前特性选择兼容 envelope 格式。 * 格式优先级覆盖读完整性、原生文档、数组、共享binding、partial loading、尺寸/文件退休等; - * 与 parserVersion、component dataVersion、IDB/journalVersion 无关,不能互相代替。 */ + * 与 parserVersion、component dataVersion、IDB/journalVersion 无关,不能互相代替。 + * 三元条件按优先级挑一个格式:17读证明 >16流式文档 >15文档 >14数组 >13采纳 >7–12加载 >6尺寸/历史效果, + * 再到5孤儿/4导入退休/3资源退休/2导入/1基础;不是逐项独立版本取最大值,兼容组合仍由解码检查。 + * 只投影字段,不在此调用 decode 的完整校验;snapshot 各域必须由宿主作为同一稳定根提供。 */ function recoveryInput(snapshot: EngineSnapshot) { const payload = dataRecord({ document: documentValue(snapshot.document), @@ -71,17 +77,19 @@ function recoveryInput(snapshot: EngineSnapshot) { }); return [[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.integrityVersion===2)?17:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document'&&job.parserVersion===5)?16:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document')?15:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-json-array')?14:snapshot.runtime.assetBindings?.size?13:snapshot.runtime.loading?(snapshot.runtime.loading.version===6?12:snapshot.runtime.loading.version===5?11:snapshot.runtime.loading.version===4?10:snapshot.runtime.loading.version===3?9:snapshot.runtime.loading.version===2?8:7):[...snapshot.operations.values()].some(intent=>intent.historyEffect)||[...snapshot.runtime.resources.values()].some(resource=>resource.sizing)?6:snapshot.runtime.orphanFiles?.size?5:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload] as const; } -/** 同步编码完整 Root,返回 sealed 不可变图像;编码成功尚不代表已 durable。 */ +/** 同步投影并捕获 Root,返回 sealed 不可变 JSON 图像;编码成功尚不代表领域校验通过或已 durable。 + * sealed 是 codec 内部来源/字节成本标记,不是数据库提交回执或恢复准入证明。 */ export function encodeRecovery(snapshot:EngineSnapshot):RecoveryImage { return sealRecoveryImage(...recoveryInput(snapshot)); } -/** 相同输入合同的分片编码入口,预算与外部数据捕获边界由 recovery-encoding 明确处理。 */ +/** 相同输入合同的分片编码入口,预算与外部数据捕获边界由 recovery-encoding 明确处理。 + * recoveryInput 的表枚举/格式选择先同步完成;不是异步遍历 live editor,也不返回部分可用 Root。 */ export function encodeRecoveryAsync(snapshot:EngineSnapshot,options:ImportScheduling={}):Promise { return sealRecoveryImageAsync(...recoveryInput(snapshot),options); } /** 解码边界的未验证对象字段表,后续每个字段仍需具体校验。 */ type ObjectValue = Record; -/** 要求非 null 的非数组对象,否则统一报告图像损坏。 */ +/** 要求非 null 的非数组对象;仅是字段读取辅助器,不在这里证明普通原型或全部属性合法。 */ function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; } /** 要求数组;不能把缺失字段或对象静默转成空数组。 */ function array(value: unknown): unknown[] { if (!Array.isArray(value)) throw fault('corrupt_recovery_image'); return value; } @@ -91,7 +99,8 @@ function string(value: unknown): string { if (typeof value !== 'string' || !valu function integer(value: unknown, min = 0): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min) throw fault('corrupt_recovery_image'); return value; } /** 验证非空字符串数组;是否允许重复由具体持久字段/关联约束决定。 */ function strings(value: unknown): string[] { return array(value).map(string); } -/** 解码二元组表,拒绝重复 ID 与非二元组;完成后返回运行时安全只读 Map。 */ +/** 顺序解码二元组表,拒绝重复非空 ID 与非二元组,返回隐藏 set/delete 的 readMap 视图。 + * 每行 decode 决定 value 的校验与冻结;表外壳本身不递归捕获任意值,任一行抛错就不返回该表。 */ function table(value: unknown, decode: (entry: unknown, id: Id) => T): ReadonlyMap { const result = new Map(); for (const item of array(value)) { const pair = array(item), id = string(pair[0]); if (pair.length !== 2 || result.has(id)) throw fault('corrupt_recovery_image'); result.set(id, decode(pair[1], id)); } @@ -104,7 +113,9 @@ function readDocument(value: unknown, registry: Registry): Document { const document = createDocument(string(raw.projectId), array(raw.canvases) as never, array(raw.elements) as never, tombstones); validateDocument(document, registry); return document; } -/** 校验持久 Change 的基础形状/目标/字段白名单/存在性;不在恢复解码时执行历史修改。 */ +/** 校验持久 Change 的基础形状、目标代次、字段白名单及 before/after 自有字段存在性。 + * 不按当前文档执行 before 条件,不逐一验证整元素前后像的全部领域语义;历史回放另有验证入口。 + * 输入已经过顶层 JSON 捕获,这里冻结同一份捕获数组,不 clone 第二份 changes。 */ function changes(value: unknown): readonly Change[] { for (const item of array(value)) { const change = object(item); @@ -124,7 +135,8 @@ function changes(value: unknown): readonly Change[] { /** 已知版本校验历史增量,未来版本原样保留;真正回放前必须另查解释器,不能自动重写。 */ function historyTransition(value:unknown){ const raw=object(value);integer(raw.version,1); - // Preserve future evidence with its original bytes. Its interpreter must be checked before any replay. + // 未知正版本保留捕获后的 JSON 值,不按 v1 重写字段;不保证输入原始序列化字节未变。 + // 真正回放前仍须查对应解释器;“能留存”不等于当前实现能执行该证据。 if(raw.version===1){try{validateHistoryTransition(value,changes);}catch{throw fault('corrupt_recovery_image');}} } /** 校验意图身份与已知 upload/run 字段约束,保留 schema version;可恢复不等于可直接执行。 */ @@ -145,14 +157,18 @@ function intent(value: unknown, id: Id): OperationIntent { strings(raw.canvasIds); strings(raw.dependencies); changes(raw.changes); return freeze(value as OperationIntent); } -/** 双栈 ID 必须唯一且精确覆盖 entries;每个 entry 关联的 operation 必须存在,不从网络队列推断历史。 */ +/** undo/redo 合起来必须无重复且精确覆盖 entries;栈顺序原样保留,不从网络队列重新推断。 + * entry.operationIds 要在 core operations 存在,但此处不尝试撤销/重做或证明当前每个栈项都可执行。 */ function readHistory(history:ObjectValue,operations:ReadonlyMap):HistoryState { const entries=table(history.entries,(value,id)=>{const raw=object(value);if(string(raw.id)!==id||typeof raw.label!=='string')throw fault('corrupt_recovery_image');changes(raw.changes);for(const operationId of strings(raw.operationIds))if(!operations.has(operationId))throw fault('corrupt_recovery_image');if(raw.groupId!==undefined)string(raw.groupId);return freeze(value as HistoryEntry);}); const undo=strings(history.undo),redo=strings(history.redo),ids=[...undo,...redo]; if(new Set(ids).size!==ids.length||ids.length!==entries.size||ids.some(id=>!entries.has(id)))throw fault('corrupt_recovery_image'); return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries}); } -/** 防御恢复入口:捕获 JSON 后逐域校验,所有通过才返回冻结 Root;不部分发布合法前缀。 */ +/** 防御恢复入口:同步捕获 payload 后逐域校验,所有通过才返回候选冻结 Root;不部分发布合法前缀。 + * 先读 working/history/ledger,再核依赖和请求,再读 ACK/资源/审计/任务,最后联合检查 loading 与 pin。 + * 不读取 IDB/Blob/服务端,不对快照做自动修复;失败直接抛出(子校验器可保留自己的错误码)。 + * JSON 往返隔离调用者记录,但 getter/toJSON 可能在捕获时执行,其外部副作用不会被撤销。 */ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSnapshot & { readonly rootRevision: number; readonly runtime: RuntimeState } { 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'); integer(image.rootRevision); integer(image.localRevision); @@ -166,7 +182,8 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn if(image.formatVersion<6&&[...operations.values()].some(intent=>intent.historyEffect))throw fault('unsupported_recovery_version'); for(const operation of operations.values())if(operation.historyTransition?.version===1&&(operation.historyTransition.rootRevision<1||operation.historyTransition.rootRevision>image.rootRevision))throw fault('corrupt_recovery_image'); if(image.formatVersion===1&&array(runtime.imports??[]).length)throw fault('unsupported_recovery_version'); - // core operations 与 ledger 的文档意图/历史证据互相核对;上传/无文档效果的控制动作另有归属。 + // core operations 保存文档意图;ledger 还含上传/控制任务,因此不是两个表逐项一对一同大小。 + // 文档效果项必须能关联 core 意图,并核对 historyTransition/historyEffect;这一步不比较所有 intent 字段。 const records = table(ledger.operations, (value, id) => { const raw = object(value); intent(raw.intent, id); strings(raw.blockers); object(raw.basePresence); if (((object(raw.intent).operationType === 'document.change'||object(raw.intent).operationType==='run.apply'&&array(object(raw.intent).changes).length>0) && !operations.has(id)) || !['pending', 'frozen', 'resolved', 'blocked'].includes(string(raw.phase))) throw fault('corrupt_recovery_image'); @@ -178,6 +195,8 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn if (operations.size !== [...records.values()].filter(record => (record.intent.operationType === 'document.change'||record.intent.operationType==='run.apply'&&record.intent.changes.length>0)).length) throw fault('corrupt_recovery_image'); // Validate the dependency DAG iteratively, including missing dependencies, without recursion overflow. // 用 Kahn 迭代检查整个依赖 DAG,避免深链递归溢出;缺依赖/重复依赖/环均拒绝。 + // counts 是未消解入度,dependents 是反向邻接表,ready 用数组+递增下标作 FIFO,不用反复 shift。 + // 所有顶点出队才证明无环;这个次序只供校验,不重排持久 operation.sequence 或执行任何操作。 const counts = new Map(), dependents = new Map(), ready: Id[] = []; for (const [id, record] of records) { const dependencies = record.intent.dependencies; @@ -187,7 +206,8 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn } for (let index = 0; index < ready.length; index++) for (const id of dependents.get(ready[index]) ?? []) { counts.set(id, counts.get(id)! - 1); if (!counts.get(id)) ready.push(id); } if (ready.length !== records.size) throw fault('corrupt_recovery_image'); - // 恢复原 body 字符串,不 parse 后重新 stringify 以免改变冻结字节;仅为身份核验读取已知协议字段。 + // FrozenRequest.body 保留捕获后的字符串内容,不 parse 后重新 stringify 替换请求体。 + // 只对已识别 run 协议另 parse 核身份;普通文档/未知协议不在此编译或发请求,执行前仍须解释器准入。 const requests = table(ledger.requests, (value, id) => { const raw = object(value); if (string(raw.id) !== id || typeof raw.body !== 'string') throw fault('corrupt_recovery_image'); @@ -217,7 +237,8 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn if (raw.retryAction !== undefined && !['submit', 'query'].includes(string(raw.retryAction))) throw fault('corrupt_recovery_image'); return freeze(value as FrozenRequest); }); - // 请求→操作和操作→请求双向校验,冻结状态不能缺少原请求身份。 + // 请求列出的操作必须反指该请求;操作声明的 requestId 必须存在,frozen 操作不能缺 requestId。 + // 这是这里实现的关联检查,并不单独证明所有服务执行结果或整份冻结请求语义。 for (const record of records.values()) if (record.requestId && !requests.has(record.requestId) || record.phase === 'frozen' && !record.requestId) throw fault('corrupt_recovery_image'); try { // 本地零效果证明也要独立复验,working 墓碑必须已经含其水位,不能因服务未见过而复用身份。 @@ -236,7 +257,8 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn } const generation = integer(ledger.generation), durableGeneration = integer(ledger.durableGeneration); if (durableGeneration > generation) throw fault('corrupt_recovery_image'); - // ACK 与 working 同项目,但保持两份不同意义的文档;服务版本要求覆盖 ACK 的全部活 canvas。 + // ACK 是服务端已确认基线,working 是含本地编辑的当前模型;分别完整校验,并要求属于同一项目。 + // versions 必须覆盖 ACK 全部活 canvas,允许仍带其它版本项;这里不把 ACK 覆盖到 working。 const acknowledged = readDocument(runtime.acknowledged, registry); if (acknowledged.projectId !== document.projectId) throw fault('project_mismatch'); const versions = table(runtime.versions, value => integer(value, 1)); @@ -267,6 +289,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn }); if(image.formatVersion===13&&!assetBindings.size||assetBindings.size&&runtime.loading!==undefined&&object(runtime.loading).version!==6)throw fault('unsupported_recovery_version'); // 显式 owner→资源 ID 清单与恢复 audit 互证;完整反向 pin 集仍由保留扫描派生。 + // 这些都是逻辑引用,不能证明原文件字节仍存在、摘要已重新计算或当前还有远端读权限。 const resourcePins = table(runtime.resourcePins ?? [], strings); const replacementOwners=new Set(); // 审计备份、选择、原/替代操作和 history replay 是一个闭合证明,不能只保留一句 keep-local 标签。 @@ -275,6 +298,8 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn const working=readDocument(backup.document,registry),base=readDocument(backup.acknowledged,registry),versions=table(backup.versions,value=>integer(value,1)); if(working.projectId!==document.projectId||base.projectId!==document.projectId||[...base.canvases.keys()].some(id=>!versions.has(id)))throw fault('corrupt_recovery_image'); readHistory(object(backup.history),operations);if(!equal(strings(backup.resourceIds),resourcePins.get(`recovery:${id}`)))throw fault('corrupt_recovery_image'); + // 每次审计内去重原操作,全局 replacementOwners 防同一替代操作被两次审计重复认领。 + // keep-local 可 superseded/satisfied,discard-local 必须 discarded;替代意图的 sequence 必须更晚。 const localReplacements:Id[]=[],ids=new Set();for(const value of array(raw.decisions)){const decision=object(value),operationId=string(decision.operationId),record=records.get(operationId),request=record?.requestId?requests.get(record.requestId):undefined; if(ids.has(operationId)||!record||record.phase!=='resolved'||record.recoveryId!==id||record.resolution!==decision.resolution||record.replacementId!==decision.replacementId||record.intent.operationType==='asset.upload'||record.requestId&&(!request||request.phase!=='resolved'||request.outcome!=='not-applied'))throw fault('corrupt_recovery_image');ids.add(operationId); if(!['keep-local','discard-local'].includes(string(decision.choice))||(decision.choice==='discard-local')!==(decision.resolution==='discarded'))throw fault('corrupt_recovery_image'); @@ -308,7 +333,8 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn if(['superseded','discarded','satisfied'].includes(record.resolution??'')){const audit=record.recoveryId?recoveries.get(record.recoveryId):undefined;if(!audit||!audit.decisions.some(decision=>decision.operationId===record.intent.id))throw fault('corrupt_recovery_image');} else if(record.recoveryId!==undefined||record.replacementId!==undefined)throw fault('corrupt_recovery_image'); } - // run 必须由已 applied 的原受理请求产生,轮询游标、待合并页和 output 事件均有身份/顺序约束。 + // run 关联原 run.start 操作和 outcome=applied 请求;不向服务查询任务现在是否仍存在/已经完成。 + // pendingPage 从当前 cursor 连续编号到 next,outputEvents 按 step 唯一且 seq 不得超出已知页尾。 const runs=table(runtime.runs ?? [],(value,id)=>{ const raw=object(value),operation=records.get(string(raw.operationId)),request=requests.get(string(raw.requestId)),target=object(raw.target); if(string(raw.id)!==id||operation?.intent.operationType!=='run.start'||operation.requestId!==raw.requestId||request?.kind!=='run.start'||request.outcome!=='applied'||operation.intent.run.boardId!==string(raw.boardId)||operation.intent.run.target.id!==string(target.id)||operation.intent.run.target.incarnation!==integer(target.incarnation,1))throw fault('corrupt_recovery_image'); @@ -325,6 +351,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn return freeze({...value as RunRecord,outputEvents:array(raw.outputEvents ?? []) as never}); }); // 导入 job 有独立 validator;source/parser/read-proof 特性与 envelope 版本必须互相匹配。 + // 只验证已存任务声明与项目归属,不重读源文件或启动 runner;恢复执行由 engine 后续装配。 const imports=table(runtime.imports??[],(value,id)=>{const job=value as ImportJob;validateImportJob(job);if(job.id!==id||job.projectId!==document.projectId)throw fault('corrupt_recovery_image');return freeze(job);}); const hasJsonArrays=[...imports.values()].some(job=>job.source?.kind==='native-json-array');if(hasJsonArrays&&image.formatVersion<14||image.formatVersion===14&&!hasJsonArrays)throw fault('unsupported_recovery_version'); const hasReadProof=[...imports.values()].some(job=>job.source?.integrityVersion===2);if(hasReadProof!==(image.formatVersion===17))throw fault('unsupported_recovery_version'); @@ -333,12 +360,14 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn // 上传资源、采纳binding、导入源和孤儿文件共享本地文件身份空间,任何交叉冲突均拒绝。 const orphanFiles=table(runtime.orphanFiles??[],(file,id)=>{validateOrphanFile(file,id);return freeze(file);}); if(orphanFiles.size&&image.formatVersion<5)throw fault('unsupported_recovery_version');for(const id of orphanFiles.keys())if(resources.has(id)||assetBindings.has(id)||importFiles(imports).has(id))throw fault('local_file_identity_conflict',[id]); - // 直到所有分表校验完成才组装一个候选 Root;不在这里启动 scheduler 或创建 view。 + // 分表及关联检查完成后组装候选 Root,保留 localRevision/rootRevision 和 ledger 两种水位。 + // readMap/冻结外壳让已捕获事实只读;不在这里推进 durableGeneration、启动 scheduler 或创建 view。 const restoredLedger: LedgerSnapshot = Object.freeze({ generation, durableGeneration, operations: records, requests }); const snapshot=Object.freeze({ revision: image.localRevision, rootRevision: image.rootRevision, document, history:restoredHistory, operations, runtime: Object.freeze({ ...(runtime.loadingRead!==undefined?{loadingRead:Object.freeze({document:readDocument(object(runtime.loadingRead).document,registry),versions:table(object(runtime.loadingRead).versions,(value)=>integer(value,1))})}:{}), ...(runtime.loading!==undefined?{loading:freeze(runtime.loading as LoadingState)}:{}), orphanFiles, imports, recoveries, runs, resources, ...(assetBindings.size?{assetBindings}:{}), resourcePins, ledger: restoredLedger, acknowledged, versions, receipts, conflicts: table(runtime.conflicts, string) }), }); - // 永久清理的本地独有字节仍被 pin 是损坏状态,不能恢复成看似可用的图片引用。 + // 对已有 purge 声明的本地独有资源/孤儿再查保留引用:仍被 pin 则拒绝,而非静默删除引用。 + // 有 stored 的资源不走此“本地唯一字节”拒绝分支,后续能否再取原图仍由资源端口决定。 if([...resources.values()].some(resource=>resource.retirement?.purgedAt!==undefined&&!resource.stored)){ const pins=resourceRetentionPins(snapshot,registry);for(const [id,resource]of resources)if(resource.retirement?.purgedAt!==undefined&&!resource.stored&&pins.pins.has(id))throw fault('corrupt_recovery_image'); }