docs(runtime): explain recovery format and cross-table validation

This commit is contained in:
2026-11-12 14:44:29 +08:00
parent e425b28064
commit 9abbe9c029
+47 -18
View File
@@ -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<object,readonly (readonly [unknown,unknown])[]>();
/** 可选表缺省的共享空数组,只表示没有表成员,不表示其数据已完成业务校验。 */
const noEntries=dataArray([]);
/** 把 Map 编成有序元组数组;自有读视图可复用外壳,其 value 是否可复用由 codec 另查。 */
/** 把 Map 编成按迭代顺序排列的元组数组;自有 readMap 视图可缓存成员外壳,WeakMap 不额外保活源视图。
* 未知来源表每次重新枚举,value 仍借用原引用;codec 后续捕获值,不以稳定成员表推断值已不可变。 */
function mapValue<K,V>(map?:ReadonlyMap<K,V>):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<Document,ReturnType<typeof encodeDocumentValue>>();
/** 工厂文档可复用成员外壳;未知来源文档重新枚举,防止冻结外壳掩盖可变 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<RecoveryImage> {
return sealRecoveryImageAsync(...recoveryInput(snapshot),options);
}
/** 解码边界的未验证对象字段表,后续每个字段仍需具体校验。 */
type ObjectValue = Record<string, unknown>;
/** 要求非 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<T>(value: unknown, decode: (entry: unknown, id: Id) => T): ReadonlyMap<Id, T> {
const result = new Map<Id, T>();
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<Id,OperationIntent>):HistoryState {
const entries=table<HistoryEntry>(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<OperationRecord>(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<Id, number>(), dependents = new Map<Id, Id[]>(), 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<FrozenRequest>(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<Id>();
// 审计备份、选择、原/替代操作和 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<Id>();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<RunRecord>(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<ImportJob>(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<OrphanFile>(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');
}