docs(runtime): clarify copy snapshot and journal replay contracts
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
* 格式2的复制源:前段保存轻量topology和payload字节定位,后段保存原完整元素记录。
|
||||
* 准备阶段只扫描索引/结构;真正复制某记录前才按range读取和校验payload,支持边转换边加入。
|
||||
* 编码仍生成完整Blob,索引/定位仍占O(records)内存,不能据lazy解码宣称总内存常数。
|
||||
* 打开索引不证明所有 payload 已读/哈希通过;字节完整性、权限和任务准入由外层负责。
|
||||
*/
|
||||
import type {CanvasRecord,Document,ElementRecord,Id,Json,Registry,Data} from '../canvas-core/types.ts';
|
||||
import {createDocument,validateDocumentTopologySteps} from '../canvas-core/document.ts';
|
||||
@@ -11,7 +12,8 @@ import {createCopyBudget} from './copy-budget.ts';
|
||||
import type {ImportScheduling} from './import-budget.ts';
|
||||
/** Only ownership, endpoint and geometry fields needed to decide scope/coordinates. No component content. */
|
||||
/** 保留ID/owner/端点/几何及contentScale/unitScale,清组件内容,边path置null用于范围/坐标规划。
|
||||
* 返回结构记录不能直接作为用户元素发布,也不证明组件payload符合schema。 */
|
||||
* 返回结构记录不能直接作为用户元素发布,也不证明组件payload符合schema。
|
||||
* 本函数只创建外壳:geometry/端点和保留的 presentation 值仍借用原引用,没有校验或深捕获。 */
|
||||
export function copyTopology(record:ElementRecord):ElementRecord{
|
||||
const raw=record.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?Object.fromEntries(['contentScale','unitScale'].filter(key=>Object.hasOwn(raw,key)).map(key=>[key,raw[key]])):{};
|
||||
const data:Data=Object.keys(presentation).length?{presentation}:{};
|
||||
@@ -19,7 +21,9 @@ export function copyTopology(record:ElementRecord):ElementRecord{
|
||||
return record.structure==='node'?{...common,structure:'node',geometry:record.geometry}:{...common,structure:'connection',source:record.source,target:record.target,path:null};
|
||||
}
|
||||
/** Format 2 places a compact topology/byte-locator index before the uninterpreted payload region. */
|
||||
/** 输出完整format2源:header/canvas/element-index/tombstone/payload标记,然后原元素NDJSON区域。 */
|
||||
/** 输出完整format2源:header/canvas/element-index/tombstone/payload标记,然后原元素NDJSON区域。
|
||||
* 借用不可变 Document 合同,逐记录 await 后仍读取原表;没有在入口同步捕获整图或执行完整校验。
|
||||
* index/payload 两个数组都留存到最终 Blob,预算只能在记录间让出,不能打断一次原生 stringify。 */
|
||||
export async function encodeIndexedCopySnapshot(document:Document,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Blob>{
|
||||
const index:BlobPart[]=[],payload:BlobPart[]=[];const budget=createCopyBudget(signal,scheduling);let indexBytes=0,payloadBytes=0;
|
||||
/** 单记录序列化、UTF8计长与256KiB上限,保持原始记录/索引locator成本可核验。 */
|
||||
@@ -38,7 +42,9 @@ export async function encodeIndexedCopySnapshot(document:Document,signal:AbortSi
|
||||
/** document只含结构投影,readElement取得完整record;调用方必须明确使用topologyOnly规划模式。 */
|
||||
export type IndexedCopySnapshot={readonly document:Document;readElement(id:Id,signal:AbortSignal):Promise<ElementRecord>};
|
||||
/** Scan only the small index. Component JSON/schema checks occur just before each record enters the runner. */
|
||||
/** 只扫描前段索引,验证locator连续/总长/项目与结构,再返回有界窗口读取器。 */
|
||||
/** 只解析前段索引,验证locator连续/总长/项目与结构,再返回有界窗口读取器。
|
||||
* NDJSON reader 可预读越过 payload 标记的原始字节,但打开时不会解析/验证每条完整元素内容。
|
||||
* document 是独立捕获的结构文档,locators/窗口表留在闭包;对象冻结不替代 readElement 的后续校验。 */
|
||||
export async function openIndexedCopySnapshot(bytes:Blob,projectId:Id,registry:Registry,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<IndexedCopySnapshot>{
|
||||
if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source');
|
||||
const budget=createCopyBudget(signal,scheduling),source=createNdjsonImportSource({bytes,fingerprint:'indexed-copy-snapshot'}),canvases:CanvasRecord[]=[],elements:ElementRecord[]=[],tombstones=new Map<Id,number>(),locators=new Map<Id,{offset:number;byteLength:number}>();let cursor=0,header=false,payloadOffset=-1,expectedOffset=0;
|
||||
@@ -62,17 +68,22 @@ export async function openIndexedCopySnapshot(bytes:Blob,projectId:Id,registry:R
|
||||
for(const _ of validateDocumentTopologySteps(document,registry))await budget.step();
|
||||
if(signal.aborted)throw fault('copy_aborted');
|
||||
// Cache raw byte windows, never decoded component objects. Preserve parser batching across Blob task boundaries.
|
||||
// 缓存原始byte窗口而非解码对象,最多4窗口/1MiB,减少每记录Blob读取任务又限制常驻字节。
|
||||
// 缓存原始 byte 窗口而非解码对象,表内最多4窗口/1MiB;命中不更新顺序,是插入/替换顺序淘汰。
|
||||
// 上限只约束表内窗口,不包括 Blob、本次 raw、并发未完成的读取或解码结果;没有并发请求去重。
|
||||
const windows=new Map<number,Uint8Array>(),windowBytes=64*1024;let cachedBytes=0;
|
||||
/** readElement按locator截取原payload,fatal UTF8/JSON、topology一致及已知schema全部通过才返回冻结record。 */
|
||||
/** readElement按locator截取原payload,fatal UTF8/JSON、topology一致及已知schema全部通过才返回冻结record。
|
||||
* 每次命中仍重新解码/JSON.parse/校验,不缓存组件对象;此路径没有独立合作预算或异步 schema 执行。
|
||||
* AbortSignal 只在入口及实际 Blob 读取后检查,不会打断同步 parse/validate;失败不清空已缓存字节。 */
|
||||
return Object.freeze({document,async readElement(id:Id,signal:AbortSignal){
|
||||
if(signal.aborted)throw fault('copy_aborted');const locator=locators.get(id);if(!locator)throw fault('element_missing',[id]);
|
||||
// 从 payload 相对的64KiB边界起读,跨窗大记录扩大本次窗口;不靠 JS 字符下标定位多字节文本。
|
||||
const start=Math.floor(locator.offset/windowBytes)*windowBytes,required=locator.offset+locator.byteLength-start;let window=windows.get(start);
|
||||
if(!window||window.byteLength<required){const raw=await bytes.slice(payloadOffset+start,Math.min(bytes.size,payloadOffset+start+Math.max(windowBytes,required))).arrayBuffer();if(signal.aborted)throw fault('copy_aborted');const previous=windows.get(start);if(previous)cachedBytes-=previous.byteLength;window=new Uint8Array(raw);windows.delete(start);windows.set(start,window);cachedBytes+=window.byteLength;while(windows.size>4||cachedBytes>1024*1024){const[key,value]=windows.entries().next().value!;windows.delete(key);cachedBytes-=value.byteLength;}}
|
||||
const raw=window.subarray(locator.offset-start,required);let value:{kind:string;record:ElementRecord};
|
||||
try{value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(raw));}catch{throw fault('invalid_copy_snapshot');}
|
||||
if(!value||value.kind!=='element'||!validJson(value.record)||!value.record.data||typeof value.record.data!=='object'||Array.isArray(value.record.data)||!equal(copyTopology(value.record),document.elements.get(id)))throw fault('invalid_copy_snapshot');
|
||||
// 当前registry已知版本才执行其schema;后续复制转换还会要求对应copy解释器存在。
|
||||
// 当前 registry 已知版本才执行其 schema;未知版本保留通用 JSON/拓扑,不等于已理解内容。
|
||||
// readElement 不迁移版本、不执行复制解释器;这些要求由后续复制转换流程处理。
|
||||
const record=value.record,definition=registry.components.get(record.componentId);if(registry.resolve(record)==='known'&&(!definition||definition.structure!==record.structure||!definition.validate(record.data)))throw fault('invalid_component_data',[id]);
|
||||
return freeze(record);
|
||||
}});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 格式1的完整复制源快照与复制声明签名。源字节在任务准入前从一个不可变文档捕获,
|
||||
* 后续规划/重开不读取当前用户正在改的live文档;源SHA与持久字节由外层完整性流程绑定。
|
||||
* 编码器借用传入 Document 并在 await 后继续迭代,其不可变性是调用合同,并非函数进入时同步复制全图。
|
||||
*/
|
||||
import type { CanvasRecord, Document, ElementRecord, Registry } from '../canvas-core/types.ts';
|
||||
import { createDocument, validateDocumentSteps } from '../canvas-core/document.ts';
|
||||
@@ -10,7 +11,9 @@ import { createNdjsonImportSource } from './imports.ts';
|
||||
import {createCopyBudget} from './copy-budget.ts';
|
||||
import type {ImportScheduling} from './import-budget.ts';
|
||||
/** Capture immutable source bytes before a preparation job is admitted. No mutable live getters. */
|
||||
/** 逐项编码header/canvas/element/tombstone为NDJSON,检查记录/总源上限并合作让出,最终返回完整Blob。 */
|
||||
/** 逐项编码header/canvas/element/tombstone为NDJSON,检查记录/总源上限并合作让出,最终返回完整Blob。
|
||||
* 每条 stringify/UTF8 计长仍同步完成,按含换行字节数折算预算;parts 留存全源,不是向持久层流式写入。
|
||||
* 这里不验证 Document;借入的 getter/toJSON 如有副作用或抛错,也没有回滚机制。 */
|
||||
export async function encodeCopySnapshot(document:Document,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Blob>{
|
||||
const budget=createCopyBudget(signal,scheduling),parts:BlobPart[]=[];let bytes=0;
|
||||
/** 生成一条记录并累计实际UTF8字节,单条≤256KiB、总源≤64MiB;不省略未知组件payload。 */
|
||||
@@ -19,13 +22,16 @@ export async function encodeCopySnapshot(document:Document,signal:AbortSignal,sc
|
||||
for(const[kind,records]of [['canvas',document.canvases.values()],['element',document.elements.values()],['tombstone',document.tombstones.entries()]] as const)for(const record of records){const size=append({kind,record});await budget.step(Math.max(1,Math.ceil(size/1024)));}
|
||||
if(signal.aborted)throw fault('copy_aborted');return new Blob(parts,{type:'application/x-ndjson'});
|
||||
}
|
||||
/** 旧源完整解码,验证同project/header、墓碑唯一,重建并分片校验完整Document后才返回。 */
|
||||
/** 旧源完整解码,验证同project/header、墓碑唯一,重建并分片校验完整Document后才返回。
|
||||
* 记录读到 EOF 后才发布返回值,途中数组/Map 仅是私有暂存;不向 editor 逐项提交。
|
||||
* 记录读取上限沿用 NDJSON reader,此入口没有 format2 那样的 Blob 总长检查;可信持久源大小由外层负责。 */
|
||||
export async function decodeCopySnapshot(bytes:Blob,projectId:string,registry:Registry,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Document>{
|
||||
const budget=createCopyBudget(signal,scheduling),source=createNdjsonImportSource({bytes,fingerprint:'captured-copy-snapshot'}),canvases:CanvasRecord[]=[],elements:ElementRecord[]=[],tombstones=new Map<string,number>();let cursor=0,header=false;
|
||||
while(true){
|
||||
if(signal.aborted)throw fault('copy_aborted');const start=cursor,line=await source.read(cursor,signal);if(!line)break;cursor=line.nextCursor;
|
||||
const value=line.value as unknown as {kind:string;version?:number;projectId?:string;record:never};if(line.error||line.skip||!value)throw fault('invalid_copy_snapshot');
|
||||
if(!header){if(value.kind!=='header'||value.version!==1||value.projectId!==projectId)throw fault('invalid_copy_snapshot');header=true;continue;}
|
||||
// 分派只检查行种类;canvas/element 的重复 ID、结构和 schema 留给后续工厂与完整文档检查。
|
||||
if(value.kind==='canvas')canvases.push(value.record);else if(value.kind==='element')elements.push(value.record);else if(value.kind==='tombstone'){const record=value.record as [string,number];if(!Array.isArray(record)||record.length!==2||tombstones.has(record[0]))throw fault('invalid_copy_snapshot');tombstones.set(record[0],record[1]);}else throw fault('invalid_copy_snapshot');
|
||||
await budget.step(Math.max(1,Math.ceil((cursor-start)/1024)));
|
||||
}
|
||||
@@ -38,7 +44,10 @@ export async function decodeCopySnapshot(bytes:Blob,projectId:string,registry:Re
|
||||
}
|
||||
|
||||
/** Copy declarations are a versioned input; never silently reinterpret a persisted preparation. */
|
||||
/** 仅对源涉及组件的数据版本/structure/copy声明生成稳定签名,防持久准备被新声明静默重解释。 */
|
||||
/** 仅对源涉及组件的数据版本/structure/copy声明生成签名,防持久准备被新声明静默重解释。
|
||||
* Set 去重并排序 componentId,让元素遍历顺序不影响签名;缺失定义也明确编码为 missing。
|
||||
* 结果是 JSON 文本而非加密摘要;copy 内部键序沿用定义,不覆盖 schema/handler 函数或整个 registry。
|
||||
* 比较与拒绝发生在消费此签名的任务流程,本函数不锁定 registry 或校验源文档。 */
|
||||
export function copyRegistrySignature(document:Document,registry:Registry):string{
|
||||
const ids=new Set([...document.elements.values()].map(element=>element.componentId));
|
||||
return JSON.stringify([...ids].sort().map(id=>{const definition=registry.components.get(id);return definition?{id,dataVersion:definition.dataVersion,structure:definition.structure,copy:definition.copy??null}: {id,missing:true};}));
|
||||
|
||||
@@ -10,7 +10,9 @@ type Path = readonly (string | number)[];
|
||||
/** set 替换子树,splice 修改数组尾/区间;不提供任意执行代码或隐式创建路径。 */
|
||||
export type JournalPatch = {op:'set';path:Path;value:Json}|{op:'splice';path:Path;start:number;deleteCount:number;items:Json[]};
|
||||
/** Exact JSON property order is retained because recovery checksums cover serialized bytes. */
|
||||
/** 生成至多10000项差分;超限返回 null,让存储层选择完整图像,不返回被截断的补丁。 */
|
||||
/** 生成至多10000项差分;超限返回 null,让存储层选择完整图像,不返回被截断的补丁。
|
||||
* 输入应是已捕获的 JSON 树:本函数不校验深度/循环、不 clone/freeze,set 与 splice.items 的子值可借用 after。
|
||||
* 10000 限制补丁数而非遍历量/编码字节;共同前缀仍逐项检查,没有时间预算或异步让出。 */
|
||||
export function diffJournal(before:Json,after:Json):JournalPatch[]|null {
|
||||
const patches:JournalPatch[]=[];
|
||||
/** 相同身份立即跳过;数组递归共同前缀并 splice 长度差,对象仅同键同序时逐字段递归。 */
|
||||
@@ -24,6 +26,7 @@ export function diffJournal(before:Json,after:Json):JournalPatch[]|null {
|
||||
}
|
||||
if(a.length!==b.length)patches.push({op:'splice',path,start:common,deleteCount:a.length-common,items:b.slice(common)});
|
||||
}else if(a&&b&&typeof a==='object'&&typeof b==='object'&&!Array.isArray(a)&&!Array.isArray(b)){
|
||||
// 增删键或键序变化时替换整个对象,避免逐字段重建改变精确序列化顺序。
|
||||
const left=Object.keys(a),right=Object.keys(b);
|
||||
if(left.length===right.length&&left.every((key,i)=>key===right[i]))for(const key of right){
|
||||
const previous=a[key],next=b[key];if(previous!==next)visit(previous,next,[...path,key]);
|
||||
@@ -34,15 +37,20 @@ export function diffJournal(before:Json,after:Json):JournalPatch[]|null {
|
||||
visit(before,after,[]);return patches.length>10000?null:patches;
|
||||
}
|
||||
/** Copy only changed ancestors; never mutate the preceding checkpoint or another patch's input. */
|
||||
/** 逐补丁重放到私有结果,每次仅复制受影响祖先;原 checkpoint 和其它已持有值不被改动。 */
|
||||
/** 逐补丁重放到私有结果,每次仅复制受影响祖先;原 checkpoint 和其它已持有值不被改动。
|
||||
* 不检查 before 整树,不验证前像/摘要/业务身份;可信恢复链和最终 Root 校验由调用方完成。
|
||||
* set 直接采用 patch.value,splice 只新建数组外壳,未改子树也共享;输出不自动冻结或深隔离。
|
||||
* 后续补丁失败会抛错且不返回中间结果,但外来 getter 副作用不在此处回滚。 */
|
||||
export function applyJournal(before:Json,patches:readonly JournalPatch[]):Json {
|
||||
if(!Array.isArray(patches)||patches.length>10000)throw fault('corrupt_recovery_delta');
|
||||
let result=before;
|
||||
for(const patch of patches){
|
||||
if(!patch||!['set','splice'].includes(patch.op)||!Array.isArray(patch.path)||patch.path.length>64||patch.path.some((key:unknown)=>typeof key!=='string'&&(typeof key!=='number'||!Number.isSafeInteger(key)||key<0)))throw fault('corrupt_recovery_delta');
|
||||
// validJson 限定值域;它不是稠密数组/描述符捕获器,恢复字节的 JSON.parse 边界仍很关键。
|
||||
if(patch.op==='set'?!validJson(patch.value):!Array.isArray(patch.items)||!validJson(patch.items))throw fault('corrupt_recovery_delta');
|
||||
/** 验证路径自有属性/数组下标后递归替换,set/splice 终点另外校验 JSON 与区间边界。 */
|
||||
function replace(value:Json,depth:number):Json {
|
||||
// 空路径直接替换根或对根数组 splice;非空路径只允许已有自有属性,不隐式创建中间对象。
|
||||
if(depth===patch.path.length){
|
||||
if(patch.op==='set')return patch.value;
|
||||
if(!Array.isArray(value)||!Number.isSafeInteger(patch.start)||!Number.isSafeInteger(patch.deleteCount)||patch.start<0||patch.deleteCount<0||patch.start>value.length||patch.deleteCount>value.length-patch.start)throw fault('corrupt_recovery_delta');
|
||||
|
||||
Reference in New Issue
Block a user