Files
Endoleap/src/lib/canvas-runtime/import-document.ts
T

72 lines
9.1 KiB
TypeScript

/**
* 完整导出文档的索引解释器(parserVersion=4),用于旧任务和头字段乱序等回退路径。
* 准备阶段扫描O(bytes)并保留O(records)字节range,不解码全部元素payload,但并非常数内存/零预扫描。
* 生成ordinal记录流:先子canvas后元素;源根映到目标,不把旧ID/墓碑直接带入目标生命周期。
*/
import {readImportRange,isImportByteFailure,type ImportByteReader} from './import-byte-reader.ts';
import {createImportBudget,type ImportScheduling} from './import-budget.ts';
import type {ImportSource,ImportLine} from './imports.ts';
import {fault,validJson} from '../canvas-core/values.ts';
/** 一条JSON值的原始[start,end)字节范围,源Blob仍是权威数据,不保存另一份payload副本。 */
type Range={start:number;end:number};
/** Index the envelope without decoding element payloads. Original bytes remain the source.
* Preparation scans O(bytes) and retains O(records) ranges; this is not constant memory. */
/** 在最多 64MiB 源上完整扫描 envelope/元素 ranges,验证元数据与父链后才返回 parser 4。
* 字段名解码上限 1KiB、单元数据值 4MiB,元素默认 256KiB 且逐次 read 解码;坏 envelope 尾部会拒绝打开。
* bytes/signal/max 为入口局部值,readBytes 和返回 fingerprint 仍取原 options,调用方须保持选项稳定。
* 不生成完整源 SHA/读取证明或业务 ID;注入端口负责源字节一致性,runner 负责业务发布/恢复。
* 完整扫描至少访问全部源字节;父链校验另有树遍历成本,不能将全部准备工作都算作 O(bytes)。 */
export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:string;readBytes?:ImportByteReader;signal:AbortSignal;maxRecordBytes?:number}&ImportScheduling):Promise<ImportSource>{
const {bytes,signal}=options,max=options.maxRecordBytes??256*1024;
if(!(bytes instanceof Blob)||bytes.size>64*1024*1024||!options.fingerprint||!Number.isSafeInteger(max)||max<1)throw fault('invalid_import_source');
let cursor=0,cache={start:-1,bytes:new Uint8Array()};const budget=createImportBudget(signal,options);
/** JSON合法语法空白判定;不在这里做文本编码猜测或容错清洗。 */
const space=(b:number)=>[9,10,13,32].includes(b);
/** 16KiB分块缓存读取,异步边界检查signal,EOF用-1表示。 */
async function peek(){if(signal.aborted)throw fault('import_aborted');if(cursor>=bytes.size)return -1;if(cursor<cache.start||cursor>=cache.start+cache.bytes.length){cache={start:cursor,bytes:await readImportRange(bytes,options.readBytes,cursor,cursor+16384,signal)};if(signal.aborted)throw fault('import_aborted');}return cache.bytes[cursor-cache.start];}
/** 推进扫描cursor并按预算计工作量;单原生JSON.parse仍由大小上限约束。 */
function step(){cursor++;return budget.step();}
/** 跳过值/分隔符之间空白,保持range不含前导空白。 */
async function spaces(){while(space(await peek()))await step();}
/** 在跳空白后严格消费预期结构字节,错位立即拒绝,不猜字段/格式。 */
async function take(byte:number){await spaces();if(await peek()!==byte)throw fault('invalid_document_import');await step();}
/** 只定位一个值的范围,跟踪引号/转义/容器配对,扫描嵌套上限256;真正JSON解析稍后做。 */
async function value():Promise<Range>{await spaces();const start=cursor,stack:number[]=[];let quoted=false,escaped=false;
while(true){const b=await peek();if(b<0){if(quoted||stack.length)throw fault('invalid_document_import');break;}if(!quoted&&!stack.length&&(b===44||b===93||b===125||space(b)))break;
if(quoted){if(escaped)escaped=false;else if(b===92)escaped=true;else if(b===34)quoted=false;}else if(b===34)quoted=true;else if(b===91||b===123){stack.push(b);if(stack.length>256)throw fault('json_nesting_limit');}else if(b===93||b===125){if(stack.pop()!==(b===93?91:123))throw fault('invalid_document_import');}
const ended=!quoted&&stack.length===0&&(b===34||b===93||b===125);await step();if(ended)break;
}if(cursor===start)throw fault('invalid_document_import');return{start,end:cursor};
}
/** 对限定range执行fatal UTF8和JSON值域校验;key/元数据/记录可使用不同字节上限。 */
async function decode(range:Range,limit=max,readSignal=signal){if(range.end-range.start>limit)throw fault('record_too_large');const data=await readImportRange(bytes,options.readBytes,range.start,range.end,readSignal);let text:string;try{text=new TextDecoder('utf-8',{fatal:true}).decode(data);}catch{throw fault('invalid_utf8');}try{const result=JSON.parse(text);if(!validJson(result))throw Error();return result;}catch{throw fault('invalid_json');}}
// fields 用 Map 去重/保存元数据,elements 数组保存 [start,end);扫描 payload 时不缓存其完整 JSON 对象。
// 范围定位只检查括号/分隔,元素内部的 JSON/UTF-8 内容错误仍可能直到 read 才发现。
const fields=new Map<string,unknown>(),elements:Range[]=[];
await take(123);await spaces();if(await peek()===125)throw fault('invalid_document_import');
while(true){const key=await decode(await value(),1024);if(typeof key!=='string'||fields.has(key))throw fault('invalid_document_import');await take(58);
if(key==='elements'){fields.set(key,true);await take(91);await spaces();if(await peek()!==93)while(true){elements.push(await value());await spaces();if(await peek()===93)break;await take(44);}await take(93);}
else fields.set(key,await decode(await value(),4*1024*1024));
await spaces();if(await peek()===125){await step();break;}await take(44);
}
// 只支持显式format1与已知顶层字段,未知envelope不能偷偷当一组普通记录导入。
await spaces();if(cursor!==bytes.size||fields.get('documentFormatVersion')!==1||typeof fields.get('projectId')!=='string'||!fields.get('projectId')||!Array.isArray(fields.get('canvases'))||!fields.has('elements')||[...fields.keys()].some(key=>!['documentFormatVersion','projectId','canvases','elements','tombstones'].includes(key)))throw fault('invalid_document_import');
const canvases=fields.get('canvases') as {id:string;parent:null|{canvasId:string;entryElementId:string}}[],ids=new Set<string>();
for(const canvas of canvases){if(!canvas||typeof canvas.id!=='string'||!canvas.id||ids.has(canvas.id)||canvas.parent!==null&&(!canvas.parent||typeof canvas.parent.canvasId!=='string'||typeof canvas.parent.entryElementId!=='string'))throw fault('invalid_document_import');ids.add(canvas.id);}
const roots=canvases.filter(canvas=>canvas.parent===null);if(roots.length!==1)throw fault('invalid_document_import');const rootId=roots[0].id;
// 对每个 canvas 从头跟父链,用本路径 seen 检测环;不复用其它路径结果,深树可能反复遍历共同祖先。
// 这里只证明 canvas 单根/父链连通,不核对入口节点是否存在、组件引用是否闭合或 projectId 是否等于根 ID。
const byId=new Map(canvases.map(canvas=>[canvas.id,canvas]));
for(const canvas of canvases){const seen=new Set<string>();let current=canvas;while(current.parent){if(seen.has(current.id))throw fault('invalid_document_import');seen.add(current.id);await budget.step();const parent=byId.get(current.parent.canvasId);if(!parent)throw fault('invalid_document_import');current=parent;}}
const tombstones=fields.get('tombstones');if(tombstones!==undefined&&(!tombstones||typeof tombstones!=='object'||Array.isArray(tombstones)||Object.values(tombstones).some(n=>!Number.isSafeInteger(n)||(n as number)<1)))throw fault('invalid_document_import');
// 排除源根canvas,按固定ordinal拼接子canvas与元素;恢复cursor不是原Blob字节偏移。
const children=canvases.filter(canvas=>canvas.id!==rootId),count=children.length+elements.length;
return Object.freeze({fingerprint:options.fingerprint,parserVersion:4,async read(position:number,readSignal:AbortSignal):Promise<ImportLine|null>{
if(readSignal.aborted)throw fault('import_aborted');if(!Number.isSafeInteger(position)||position<0||position>count)throw fault('invalid_import_cursor');if(position===count)return null;
const base={cursor:position,nextCursor:position+1,key:`record:${position}`};let record:any,kind:string;
// children 返回已解析的元数据记录引用,元素则每次按 range 重读/新解析;消费者不应修改共享 children。
// 已完成索引的读取无需再移动扫描 cursor;本次 readSignal 独立于打开阶段 signal。
// 单记录 payload 失败返回 error/next ordinal,不影响其它项;源级字节错误/取消仍抛出,供同序号重试。
try{if(position<children.length){kind='canvas';record=children[position];}else{kind='element';record=await decode(elements[position-children.length],max,readSignal);}if(readSignal.aborted)throw fault('import_aborted');if(!record||typeof record!=='object'||Array.isArray(record)||typeof record.id!=='string'||!record.id)throw fault('invalid_native_import_record');return{...base,value:{kind,key:record.id,record,sourceRootId:rootId}};}catch(error){if(readSignal.aborted)throw fault('import_aborted');if(isImportByteFailure(error))throw error;return{...base,error:(error as {code?:string}).code??'invalid_document_import'};}
}});
}