379 lines
44 KiB
TypeScript
379 lines
44 KiB
TypeScript
/**
|
||
* EngineSnapshot 与可序列化 RecoveryImage 的边界。图像同时保存 working/history/operations 与 runtime,
|
||
* 不把它们分别恢复成可互相漂移的 store;Blob 字节走 DurableBatch.files,不放进 JSON payload。
|
||
* 编码按所需特性选择格式,解码校验表/跨表身份、依赖、请求、历史、资源与任务证据后才返回候选 Root。
|
||
* 解码不发 HTTP、不重启外部副作用,最终恢复准入和可靠存储仍由 engine/adapter 负责。
|
||
* 领域解码同步执行;存储字节/校验摘要、当前账号权限、外部文件可读性均不在本模块证明范围内。
|
||
*/
|
||
import {referencedResources} from './resources.ts';
|
||
import {validateSharedAsset,type SharedAssetBinding} from './asset-depot.ts';
|
||
import {validateLoadingRuntime,type LoadingState} from './loading.ts';
|
||
import {validateOrphanFile,type OrphanFile} from './orphan-files.ts';
|
||
import {importFiles} from './import-file-retention.ts';
|
||
import {resourceRetentionPins} from './resource-retention.ts';
|
||
import {dataArray,dataRecord} from '../canvas-core/data-containers.ts';
|
||
import type {ImportScheduling} from './import-budget.ts';
|
||
import { sealRecoveryImage, sealRecoveryImageAsync } from './recovery-encoding.ts';
|
||
import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
|
||
import { validateDefaultImageSizing } from './default-image-size.ts';
|
||
import { validateImportJob, type ImportJob } from './imports.ts';
|
||
import { validateElisions, withLocalLifecycles } from './elision.ts';
|
||
import type { Change, CoreSnapshot, Document, HistoryEntry, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||
import { validateHistoryReplay, replayHistoryEvidence, sameHistory } from './history-replay.ts';
|
||
import type { RecoveryAudit } from './conflicts.ts';
|
||
import { applyHistoryTransition, validateHistoryTransition, type HistoryTransition } from '../canvas-core/history-transition.ts';
|
||
import { createDocument, validateDocument, isFactoryDocument } from '../canvas-core/document.ts';
|
||
import { fault, freeze, readMap, validJson, equal, isOwnedMapView } from '../canvas-core/values.ts';
|
||
import type { EngineSnapshot, RuntimeState } from './contracts.ts';
|
||
import type { FrozenRequest, LedgerSnapshot, OperationRecord } from './ledger.ts';
|
||
import { validateRunInput, type RunRecord } from './runs.ts';
|
||
import type { ResourceRecord } from './resources.ts';
|
||
import type { RecoveryImage } from './storage-port.ts';
|
||
|
||
// Membership is stable only for core-owned read views. Never infer ownership
|
||
// from Object.freeze: a frozen native Map can still change through .set().
|
||
// 仅核心自有只读 Map 的成员稳定可缓存;Object.freeze(native Map) 并不禁止 set,不能作为来源证明。
|
||
const entryViews=new WeakMap<object,readonly (readonly [unknown,unknown])[]>();
|
||
/** 可选表缺省的共享空数组,只表示没有表成员,不表示其数据已完成业务校验。 */
|
||
const noEntries=dataArray([]);
|
||
/** 把 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];
|
||
let value=entryViews.get(map);
|
||
if(!value){value=dataArray([...map].map(pair=>dataArray(pair) as readonly [K,V]));entryViews.set(map,value);}
|
||
return value as readonly (readonly [K,V])[];
|
||
}
|
||
|
||
/** 显式选择规范化文档字段并建立框架拥有的外层容器,不假定嵌套外部记录天然不可变。 */
|
||
function encodeDocumentValue(document:Document){
|
||
// Freeze our own containers only. The codec still independently proves whether
|
||
// each nested value can be reused, including external mutable records.
|
||
return dataRecord({projectId:document.projectId,canvases:dataArray(document.canvases.values()),elements:dataArray(document.elements.values()),tombstones:dataArray([...document.tombstones].map(pair=>dataArray(pair)))});
|
||
}
|
||
// 缓存工厂信封的序列化外壳,不缓存/跳过组件数据校验,也不将该外壳视为发布文档。
|
||
const documentViews=new WeakMap<Document,ReturnType<typeof encodeDocumentValue>>();
|
||
/** 工厂文档可复用成员外壳;未知来源文档重新枚举,防止冻结外壳掩盖可变 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;
|
||
}
|
||
/** 显式选择模型/运行事实;不采集 view、renderer、显示句柄或 Blob 字节。
|
||
* stored asset URL 和 sourceURL 属于已选协议数据,会按原值保存;不应把本函数当成任意数据的脱敏器。 */
|
||
/** 从同一 snapshot 选择文档/双栈/意图及所有运行事实,按当前特性选择兼容 envelope 格式。
|
||
* 格式优先级覆盖读完整性、原生文档、数组、共享binding、partial loading、尺寸/文件退休等;
|
||
* 与 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),
|
||
history: dataRecord({ undo: snapshot.history.undo, redo: snapshot.history.redo, entries: mapValue(snapshot.history.entries) }),
|
||
operations: mapValue(snapshot.operations),
|
||
runtime: dataRecord({ ...(snapshot.runtime.assetBindings?.size?{assetBindings:mapValue(snapshot.runtime.assetBindings)}:{}), ...(snapshot.runtime.loadingRead?{loadingRead:dataRecord({document:documentValue(snapshot.runtime.loadingRead.document),versions:mapValue(snapshot.runtime.loadingRead.versions)})}:{}), ...(snapshot.runtime.loading?{loading:snapshot.runtime.loading}:{}), ...(snapshot.runtime.orphanFiles?.size?{orphanFiles:mapValue(snapshot.runtime.orphanFiles)}:{}), imports:mapValue(snapshot.runtime.imports), recoveries:mapValue(snapshot.runtime.recoveries), runs: mapValue(snapshot.runtime.runs), resources: mapValue(snapshot.runtime.resources), resourcePins: mapValue(snapshot.runtime.resourcePins), acknowledged: documentValue(snapshot.runtime.acknowledged), versions: mapValue(snapshot.runtime.versions), receipts: mapValue(snapshot.runtime.receipts), conflicts: mapValue(snapshot.runtime.conflicts), ledger: dataRecord({ ...snapshot.runtime.ledger, operations: mapValue(snapshot.runtime.ledger.operations), requests: mapValue(snapshot.runtime.ledger.requests) }) }),
|
||
});
|
||
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 不可变 JSON 图像;编码成功尚不代表领域校验通过或已 durable。
|
||
* sealed 是 codec 内部来源/字节成本标记,不是数据库提交回执或恢复准入证明。 */
|
||
export function encodeRecovery(snapshot:EngineSnapshot):RecoveryImage {
|
||
return sealRecoveryImage(...recoveryInput(snapshot));
|
||
}
|
||
/** 相同输入合同的分片编码入口,预算与外部数据捕获边界由 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 的非数组对象;仅是字段读取辅助器,不在这里证明普通原型或全部属性合法。 */
|
||
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; }
|
||
/** ID/代码等字段要求非空字符串;允许空标签的地方另行检查。 */
|
||
function string(value: unknown): string { if (typeof value !== 'string' || !value) throw fault('corrupt_recovery_image'); return value; }
|
||
/** 验证安全整数与调用点指定下限,用于 revision、计数、代次和版本。 */
|
||
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 与非二元组,返回隐藏 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)); }
|
||
return readMap(result);
|
||
}
|
||
/** 重建 document/tombstones,调用核心完整校验树、owner、端点与已知组件 schema。 */
|
||
function readDocument(value: unknown, registry: Registry): Document {
|
||
const raw = object(value);
|
||
const tombstones = table(raw.tombstones, entry => integer(entry, 1));
|
||
const document = createDocument(string(raw.projectId), array(raw.canvases) as never, array(raw.elements) as never, tombstones);
|
||
validateDocument(document, registry); return document;
|
||
}
|
||
/** 校验持久 Change 的基础形状、目标代次、字段白名单及 before/after 自有字段存在性。
|
||
* 不按当前文档执行 before 条件,不逐一验证整元素前后像的全部领域语义;历史回放另有验证入口。
|
||
* 输入已经过顶层 JSON 捕获,这里冻结同一份捕获数组,不 clone 第二份 changes。 */
|
||
function changes(value: unknown): readonly Change[] {
|
||
for (const item of array(value)) {
|
||
const change = object(item);
|
||
if (change.kind === 'field') {
|
||
const target = object(change.target); string(target.id); integer(target.incarnation, 1);
|
||
const field = string(change.field);
|
||
if (!['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(field) && (!field.startsWith('data.') || !field.slice(5))) throw fault('corrupt_recovery_image');
|
||
for (const flag of ['beforePresent', 'afterPresent']) if (change[flag] !== undefined && typeof change[flag] !== 'boolean') throw fault('corrupt_recovery_image');
|
||
} else if (change.kind === 'element' || change.kind === 'canvas') {
|
||
const id = string(change.id);
|
||
for (const key of ['before', 'after']) if (change[key] !== null && string(object(change[key]).id) !== id) throw fault('corrupt_recovery_image');
|
||
} else throw fault('corrupt_recovery_image');
|
||
if (!Object.hasOwn(change, 'before') || !Object.hasOwn(change, 'after')) throw fault('corrupt_recovery_image');
|
||
}
|
||
return freeze(value as Change[]);
|
||
}
|
||
/** 已知版本校验历史增量,未来版本原样保留;真正回放前必须另查解释器,不能自动重写。 */
|
||
function historyTransition(value:unknown){
|
||
const raw=object(value);integer(raw.version,1);
|
||
// 未知正版本保留捕获后的 JSON 值,不按 v1 重写字段;不保证输入原始序列化字节未变。
|
||
// 真正回放前仍须查对应解释器;“能留存”不等于当前实现能执行该证据。
|
||
if(raw.version===1){try{validateHistoryTransition(value,changes);}catch{throw fault('corrupt_recovery_image');}}
|
||
}
|
||
/** 校验意图身份与已知 upload/run 字段约束,保留 schema version;可恢复不等于可直接执行。 */
|
||
function intent(value: unknown, id: Id): OperationIntent {
|
||
const raw = object(value);
|
||
if (string(raw.id) !== id) throw fault('corrupt_recovery_image');
|
||
const kind = string(raw.operationType);
|
||
if (kind === 'asset.upload') { string(raw.resourceId); if (array(raw.changes).length || array(raw.canvasIds).length) throw fault('corrupt_recovery_image'); }
|
||
if (kind === 'run.start') { validateRunInput(raw.run as never);if(array(raw.changes).length || !strings(raw.canvasIds).includes(string(object(raw.run).boardId)))throw fault('corrupt_recovery_image'); }
|
||
if(kind==='run.cancel'||kind==='run.apply'){
|
||
const action=object(raw.action);string(action.runId);string(action.boardId);
|
||
if(kind==='run.cancel'){if(array(raw.changes).length||array(raw.canvasIds).length)throw fault('corrupt_recovery_image');}
|
||
else {const target=object(action.target);string(target.id);integer(target.incarnation,1);string(action.outputId);if(!strings(raw.canvasIds).includes(string(action.targetCanvasId)))throw fault('corrupt_recovery_image');}
|
||
}
|
||
if(raw.historyTransition!==undefined)historyTransition(raw.historyTransition);
|
||
if(raw.historyEffect!==undefined){const effect=object(raw.historyEffect);integer(effect.version,1);if(raw.origin!=='asset-resolution'||kind!=='document.change')throw fault('corrupt_recovery_image');if(effect.version===1)validateCreationSizeEffect(effect);}
|
||
integer(raw.operationSchemaVersion, 1); integer(raw.sequence, 1); string(raw.origin);
|
||
strings(raw.canvasIds); strings(raw.dependencies); changes(raw.changes);
|
||
return freeze(value as OperationIntent);
|
||
}
|
||
/** 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});
|
||
}
|
||
/** 防御恢复入口:同步捕获 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);
|
||
if (image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image');
|
||
// Own the parsed records; mutating a caller's RecoveryImage must not mutate live state.
|
||
const raw = object(JSON.parse(JSON.stringify(image.payload))), document = readDocument(raw.document, registry), history = object(raw.history), runtime = object(raw.runtime), ledger = object(runtime.ledger);
|
||
// 部分加载的解释器与 envelope 有明确兼容关系;不能把完整图像误按 partial 或反向解释。
|
||
if(runtime.loading!==undefined&&image.formatVersion<13&&image.formatVersion!==(object(runtime.loading).version===6?12:object(runtime.loading).version===5?11:object(runtime.loading).version===4?10:object(runtime.loading).version===3?9:object(runtime.loading).version===2?8:7))throw fault('unsupported_recovery_version');
|
||
if(image.formatVersion>=7&&image.formatVersion<=12&&runtime.loading===undefined)throw fault('invalid_loading_state');
|
||
const operations = table(raw.operations, intent),restoredHistory=readHistory(history,operations);
|
||
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 意图,并核对 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');
|
||
if(operations.has(id)&&!equal(operations.get(id)!.historyTransition,(raw.intent as OperationIntent).historyTransition))throw fault('corrupt_recovery_image');
|
||
if(operations.has(id)&&!equal(operations.get(id)!.historyEffect,(raw.intent as OperationIntent).historyEffect))throw fault('corrupt_recovery_image');
|
||
if(raw.resolution!==undefined&&!['applied','not-applied','superseded','discarded','satisfied','elided'].includes(string(raw.resolution)))throw fault('corrupt_recovery_image');
|
||
return freeze(value as OperationRecord);
|
||
});
|
||
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;
|
||
if (new Set(dependencies).size !== dependencies.length) throw fault('corrupt_recovery_image');
|
||
counts.set(id, dependencies.length); if (!dependencies.length) ready.push(id);
|
||
for (const dependency of dependencies) { if (!records.has(dependency)) throw fault('corrupt_recovery_image'); dependents.set(dependency, [...(dependents.get(dependency) ?? []), id]); }
|
||
}
|
||
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');
|
||
// 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');
|
||
integer(raw.protocolVersion, 1);
|
||
integer(raw.generation, 1); integer(raw.attempt); strings(raw.canvasIds); object(raw.expectedVersions);
|
||
if (raw.kind === 'run.start' && !strings(raw.canvasIds).includes(string(raw.ownerCanvasId)))throw fault('corrupt_recovery_image');
|
||
if (raw.kind === 'asset.upload' && (array(raw.canvasIds).length || Object.keys(object(raw.expectedVersions)).length || array(raw.operationIds).length !== 1)) throw fault('corrupt_recovery_image');
|
||
if (!['frozen', 'dispatch-marked', 'waiting', 'resolved'].includes(string(raw.phase)) || !['unresolved', 'accepted', 'applied', 'not-applied'].includes(string(raw.outcome))) throw fault('corrupt_recovery_image');
|
||
for (const operationId of strings(raw.operationIds)) if (records.get(operationId)?.requestId !== id || (raw.kind ?? 'document.change') !== records.get(operationId)?.intent.operationType) throw fault('corrupt_recovery_image');
|
||
// 已知 run.start body 必须与原操作目标/模型/参数/参与者完全对应,不能只因 request ID 相同就信任。
|
||
if(raw.kind==='run.start'){
|
||
if(array(raw.operationIds).length!==1)throw fault('corrupt_recovery_image');
|
||
const operation=records.get(string(array(raw.operationIds)[0]))!.intent;if(operation.operationType!=='run.start')throw fault('corrupt_recovery_image');
|
||
let body:ObjectValue;try{body=object(JSON.parse(raw.body as string));}catch{throw fault('corrupt_recovery_image');}
|
||
if(raw.ownerCanvasId!==operation.run.boardId||body.protocol_version!==2||body.request_id!==id||body.operation_id!==operation.id||body.node_id!==operation.run.target.id||body.target_incarnation!==operation.run.target.incarnation||body.model_id!==operation.run.modelId||body.prompt!==operation.run.prompt||body.propagate!==operation.run.propagate||!equal(body.parameters,operation.run.parameters)||!equal(body.expected_versions,raw.expectedVersions)||!equal([...strings(raw.canvasIds)].sort(),[...operation.canvasIds].sort()))throw fault('corrupt_recovery_image');
|
||
}
|
||
// cancel/apply 核对动作归属;cancel 无文档参与者,apply 要绑定目标代次与 output ID。
|
||
if(raw.kind==='run.cancel'||raw.kind==='run.apply'){
|
||
if(array(raw.operationIds).length!==1)throw fault('corrupt_recovery_image');const operation=records.get(string(array(raw.operationIds)[0]))!.intent;
|
||
if(operation.operationType!=='run.cancel'&&operation.operationType!=='run.apply')throw fault('corrupt_recovery_image');
|
||
const body=object(JSON.parse(raw.body as string));
|
||
if(raw.runId!==operation.action.runId||raw.ownerCanvasId!==operation.action.boardId||body.protocol_version!==2||body.request_id!==id||body.operation_id!==operation.id)throw fault('corrupt_recovery_image');
|
||
if(operation.operationType==='run.cancel'){if(array(raw.canvasIds).length||Object.keys(object(raw.expectedVersions)).length)throw fault('corrupt_recovery_image');}
|
||
else if(body.target_canvas_id!==operation.action.targetCanvasId||body.target_incarnation!==operation.action.target.incarnation||body.node_id!==operation.action.target.id||body.output_id!==operation.action.outputId||!equal([...strings(raw.canvasIds)].sort(),[...operation.canvasIds].sort()))throw fault('corrupt_recovery_image');
|
||
}
|
||
if (raw.retryAt !== undefined && (typeof raw.retryAt !== 'number' || !Number.isFinite(raw.retryAt))) throw fault('corrupt_recovery_image');
|
||
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 墓碑必须已经含其水位,不能因服务未见过而复用身份。
|
||
validateElisions({ operations: records });
|
||
for (const record of records.values()) if (record.resolution === 'elided') { const original = operations.get(record.intent.id); if (!original || !equal(original.changes, record.intent.changes) || original.sequence !== record.intent.sequence || original.origin !== record.intent.origin) throw fault('invalid_elision'); }
|
||
const local = withLocalLifecycles(document, { operations: records });
|
||
if (!equal([...local.tombstones], [...document.tombstones])) throw fault('invalid_elision');
|
||
} catch (error) { if ((error as {code?:string}).code === 'elision_interpreter_unavailable') throw error; throw fault('corrupt_recovery_image'); }
|
||
const receipts=table(runtime.receipts,value=>freeze(value as Json));
|
||
// 控制依赖被零效果解决时必须有原请求的确定终态证明,404/传输错误不够。
|
||
for(const record of records.values())if(record.resolution==='not-applied'){
|
||
const request=record.requestId?requests.get(record.requestId):undefined;
|
||
if(!['run.start','run.cancel'].includes(record.intent.operationType)||record.phase!=='resolved'||request?.outcome!=='not-applied'||request.phase!=='resolved')throw fault('corrupt_recovery_image');
|
||
const proof=object(receipts.get(request.id));
|
||
if(proof.protocol_version!==2||proof.request_id!==request.id||proof.operation_id!==record.intent.id||proof.action!==record.intent.operationType||proof.status!=='not-applied'||proof.terminal!==true)throw fault('corrupt_recovery_image');
|
||
}
|
||
const generation = integer(ledger.generation), durableGeneration = integer(ledger.durableGeneration);
|
||
if (durableGeneration > generation) throw fault('corrupt_recovery_image');
|
||
// 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));
|
||
for (const id of acknowledged.canvases.keys()) if (!versions.has(id)) throw fault('corrupt_recovery_image');
|
||
// 资源记录绑定唯一 upload 意图;解码/尺寸/退休子协议各自验版本,stored 不等于本地字节已存在。
|
||
const resources = table<ResourceRecord>(runtime.resources ?? [], (value, id) => {
|
||
const raw = object(value);
|
||
if (string(raw.id) !== id || string(raw.operationId) === id) throw fault('corrupt_recovery_image');
|
||
string(raw.name); string(raw.mimeType); integer(raw.byteLength, 1);
|
||
if(raw.loadingAdmission!==undefined){const proof=object(raw.loadingAdmission);if(proof.version!==1)throw fault('loading_asset_interpreter_unavailable');string(proof.sourceToken);string(proof.sourceURL);const canvases=strings(proof.canvasIds);if((proof.sourceToken as string).length>256||(proof.sourceURL as string).length>8192||!canvases.length||new Set(canvases).size!==canvases.length)throw fault('corrupt_recovery_image');}
|
||
if(raw.sizing!==undefined){if(image.formatVersion<6)throw fault('unsupported_recovery_version');const sizing=object(raw.sizing);integer(sizing.version,1);if(sizing.version===1)validateDefaultImageSizing(sizing);}
|
||
if(raw.retirement!==undefined){if(image.formatVersion<3)throw fault('unsupported_recovery_version');const marker=object(raw.retirement);if(marker.version!==1)throw fault('resource_retirement_interpreter_unavailable');integer(marker.unreferencedAt);if(marker.purgedAt!==undefined&&integer(marker.purgedAt)<(marker.unreferencedAt as number))throw fault('corrupt_recovery_image');}
|
||
if(raw.image!==undefined){const image=object(raw.image);integer(image.version,1);if(image.version===1){if(!['pending','ready','failed'].includes(String(image.status)))throw fault('corrupt_recovery_image');if(image.status==='ready'){integer(image.width,1);integer(image.height,1);if(!Number.isSafeInteger((image.width as number)*(image.height as number))||!['decode','server'].includes(String(image.source)))throw fault('corrupt_recovery_image');}if(image.status==='failed')string(image.code);}}
|
||
if (raw.digest !== undefined && !/^[a-f0-9]{64}$/.test(string(raw.digest))) throw fault('corrupt_recovery_image');
|
||
const operation = records.get(raw.operationId as string)?.intent;
|
||
if (operation?.operationType !== 'asset.upload' || operation.resourceId !== id) throw fault('corrupt_recovery_image');
|
||
if (raw.stored !== undefined) { const stored = object(raw.stored); if (string(stored.id) !== id || integer(stored.bytes, 1) !== raw.byteLength) throw fault('corrupt_recovery_image'); string(stored.url); string(stored.mime_type); integer(stored.width, 1); integer(stored.height, 1); }
|
||
return freeze(value as ResourceRecord);
|
||
});
|
||
for (const record of records.values()) if (record.intent.operationType === 'asset.upload' && resources.get(record.intent.resourceId)?.operationId !== record.intent.id) throw fault('corrupt_recovery_image');
|
||
// 跨项目采纳 binding 必须由对应 document.change 引用该资源,不能与本地 upload 资源表撞 ID。
|
||
const assetBindings=table<SharedAssetBinding>(runtime.assetBindings??[],(value,id)=>{
|
||
const binding=value as SharedAssetBinding;validateSharedAsset(binding);const row=object(value);
|
||
if(image.formatVersion<13||binding.asset.id!==id||resources.has(id)||!records.has(string(row.operationId)))throw fault('corrupt_asset_binding');
|
||
const operation=records.get(binding.operationId)!.intent;if(operation.operationType!=='document.change'||!referencedResources(operation.changes,registry).has(id))throw fault('corrupt_asset_binding');
|
||
if(row.loadingAdmission!==undefined){const proof=object(row.loadingAdmission);if(proof.version!==1||proof.operationId!==binding.operationId||string(proof.sourceURL)!==binding.asset.url||(string(proof.sourceToken)).length>256)throw fault('corrupt_asset_binding');const owners=strings(proof.canvasIds);if(!owners.length||new Set(owners).size!==owners.length)throw fault('corrupt_asset_binding');}
|
||
return freeze(binding);
|
||
});
|
||
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 标签。
|
||
const recoveries=table<RecoveryAudit>(runtime.recoveries??[],(value,id)=>{
|
||
const raw=object(value),backup=object(raw.backup);if(raw.historyTransition!==undefined){historyTransition(raw.historyTransition);if(object(raw.historyTransition).version===1&&object(raw.historyTransition).rootRevision!==(raw.baseRootRevision as number)+1)throw fault('corrupt_recovery_image');}if(string(raw.id)!==id||raw.formatVersion!==1||integer(raw.baseRootRevision)>=image.rootRevision)throw fault('corrupt_recovery_image');
|
||
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');
|
||
if(decision.resolution==='superseded'){const replacementId=string(decision.replacementId),replacement=records.get(replacementId);if(replacementOwners.has(replacementId))throw fault('corrupt_recovery_image');replacementOwners.add(replacementId);localReplacements.push(replacementId);if(!replacement||replacement.intent.sequence<=record.intent.sequence||replacement.intent.operationType!==record.intent.operationType)throw fault('corrupt_recovery_image');}
|
||
else if(!['discarded','satisfied'].includes(string(decision.resolution))||decision.replacementId!==undefined)throw fault('corrupt_recovery_image');
|
||
}
|
||
if(raw.historyReplay!==undefined){
|
||
const replay=object(raw.historyReplay);integer(replay.version,1);
|
||
if(replay.version===1){
|
||
try{
|
||
validateHistoryReplay(replay,(raw.baseRootRevision as number)+1,changes);
|
||
if(!raw.historyTransition)throw fault('corrupt_recovery_image');
|
||
const local=new Set(localReplacements);
|
||
// 新替代意图的事件顺序必须按其真实 sequence;anchor+events 的效果还要等于整体 historyTransition。
|
||
const expectedOrder=[...localReplacements].sort((a,b)=>records.get(a)!.intent.sequence-records.get(b)!.intent.sequence);
|
||
const actualOrder=replay.events.flatMap(event=>event.kind==='operation'&&local.has(event.operationId)?[event.operationId]:[]);
|
||
if(!equal(actualOrder,expectedOrder))throw fault('corrupt_recovery_image');
|
||
const original=readHistory(object(backup.history),operations);
|
||
const composed=replayHistoryEvidence(original,replay,id=>{
|
||
const record=records.get(id);
|
||
return!!record?.intent.changes.length&&(['user','undo','redo'].includes(record.intent.origin)||record.intent.origin==='asset-resolution'&&!!record.intent.historyEffect)&&(local.has(id)||['applied','elided'].includes(record.resolution??''));
|
||
});
|
||
const expected=applyHistoryTransition(original,raw.historyTransition as HistoryTransition,'forward');
|
||
if(!sameHistory(composed,expected))throw fault('corrupt_recovery_image');
|
||
}catch{throw fault('corrupt_recovery_image');}
|
||
}
|
||
}
|
||
if(!resourcePins.has(`recovery:${id}`))throw fault('corrupt_recovery_image');return freeze(value as RecoveryAudit);
|
||
});
|
||
for(const record of records.values()){
|
||
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 关联原 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');
|
||
if(!['queued','running','succeeded','failed','conflict','cancelled','partially_succeeded'].includes(string(raw.status))||typeof raw.hasMore!=='boolean')throw fault('corrupt_recovery_image');
|
||
if(raw.controlEpoch!==undefined)integer(raw.controlEpoch);
|
||
if(raw.appliedOutputIds!==undefined)strings(raw.appliedOutputIds);
|
||
integer(raw.cursor);integer(raw.attempt);object(raw.plan);array(raw.outputs);array(raw.steps);array(raw.outputEvents ?? []);
|
||
if(raw.blocked!==undefined&&typeof raw.blocked!=='boolean')throw fault('corrupt_recovery_image');
|
||
if(raw.error!==undefined)string(raw.error);
|
||
if(raw.nextPollAt!==undefined&&(typeof raw.nextPollAt!=='number'||!Number.isFinite(raw.nextPollAt)))throw fault('corrupt_recovery_image');
|
||
if(raw.pendingPage!==undefined){const page=object(raw.pendingPage);let cursor=integer(raw.cursor);for(const value of array(page.events)){const event=object(value);if(event.run_id!==id||integer(event.seq,1)!==++cursor)throw fault('corrupt_recovery_image');}if(integer(page.next)!==cursor||typeof page.hasMore!=='boolean'||!['queued','running','succeeded','failed','conflict','cancelled','partially_succeeded'].includes(string(page.status)))throw fault('corrupt_recovery_image');array(page.outputs);array(page.steps);}
|
||
const outputIds=new Set<string>();
|
||
for(const value of array(raw.outputEvents ?? [])){const event=object(value),step=string(event.step_id);if(event.run_id!==id||event.type!=='step.output'||outputIds.has(step)||integer(event.seq,1)>Math.max(integer(raw.cursor),raw.pendingPage===undefined?0:integer(object(raw.pendingPage).next)))throw fault('corrupt_recovery_image');object(event.output);outputIds.add(step);}
|
||
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');
|
||
const hasDocuments=[...imports.values()].some(job=>job.source?.kind==='native-document'),hasStreamingDocuments=[...imports.values()].some(job=>job.source?.kind==='native-document'&&job.parserVersion===5);if(hasDocuments&&image.formatVersion!==(hasReadProof?17:hasStreamingDocuments?16:15)||image.formatVersion>=15&&!hasDocuments&&!hasReadProof)throw fault('unsupported_recovery_version');
|
||
for(const [id,entry]of importFiles(imports)){if(resources.has(id)||assetBindings.has(id))throw fault('import_file_identity_conflict',[id]);if((entry.file.retirement||imports.get(entry.jobId)?.source?.previousFiles?.length)&&image.formatVersion<4)throw fault('unsupported_recovery_version');}
|
||
// 上传资源、采纳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,保留 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) }),
|
||
});
|
||
// 对已有 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');
|
||
}
|
||
if([...orphanFiles.values()].some(file=>file.retirement?.purgedAt!==undefined)){const pins=resourceRetentionPins(snapshot,registry);for(const [id,file]of orphanFiles)if(file.retirement?.purgedAt!==undefined&&pins.pins.has(id))throw fault('corrupt_recovery_image');}
|
||
// 最后把 loading、源捕获、ACK 与运行事实再联合校验,防止各表单独合法却组合矛盾。
|
||
validateLoadingRuntime(snapshot.runtime,registry);
|
||
return snapshot;
|
||
}
|