docs(runtime): explain indexed document and native import conversion
This commit is contained in:
@@ -11,7 +11,11 @@ import {fault,validJson} from '../canvas-core/values.ts';
|
||||
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/记录范围,验证头与树后返回可按ordinal重读的ImportSource。 */
|
||||
/** 在最多 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');
|
||||
@@ -35,7 +39,8 @@ export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:s
|
||||
}
|
||||
/** 对限定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');}}
|
||||
// envelope字段名去重;elements仅保留ranges,其它字段按有限metadata大小解码。
|
||||
// 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);
|
||||
@@ -48,7 +53,8 @@ export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:s
|
||||
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;
|
||||
// parent链必须存在且无环;入口实际节点/引用闭合仍由后续原生转换与文档校验完成。
|
||||
// 对每个 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');
|
||||
@@ -57,7 +63,9 @@ export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:s
|
||||
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;
|
||||
// 单记录payload失败返回error/next ordinal,保留后续合法记录;源级字节失败和取消仍抛出。
|
||||
// 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'};}
|
||||
}});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 原生导入记录到纯ImportUnit的转换:源ID经reserve映射为新身份,依赖保留为source key等待闭合。
|
||||
* $target或文档源根映射到显式选定的现有canvas;副本incarnation从1开始,不继承旧墓碑/任务执行。
|
||||
* $target或文档源根映射到显式选定的现有canvas;副本incarnation从1开始,不继承旧墓碑。
|
||||
* 任务字段只按组件copy.omit声明去除;转换不创建/启动生成任务,不能据此假定所有未知执行字段都已过滤。
|
||||
* 本模块只规划changes,不写文档、不上传/授权;资源地址和授权事实由宿主/engine另行提供与校验。
|
||||
*/
|
||||
import type { Document, ElementRecord, Id, Json, Registry } from '../canvas-core/types.ts';
|
||||
@@ -9,14 +10,18 @@ import { remapCopyData } from '../canvas-core/duplicate.ts';
|
||||
import { clone, fault } from '../canvas-core/values.ts';
|
||||
import type { ImportJob, ImportLine, ImportUnit } from './imports.ts';
|
||||
|
||||
/** 校验目标canvas仍存在且入口ID/代次或根身份未变,防旧任务把尾批导入后来替换的分区。 */
|
||||
/** 按 source.targetCanvasId 定位当前 canvas;有 targetEntry 时核对入口 ID 与元素代次,否则要求根。
|
||||
* 不比较整个祖先链/父 canvas、入口组件或 job.projectId;同入口移动后的目标仍可通过此局部身份检查。
|
||||
* 返回原 source 引用,不生成新任务或锁住目标,后续提交前还需再检查当前文档。 */
|
||||
export function assertNativeImportTarget(job:ImportJob,document:Document){
|
||||
const source=job.source,canvas=source?document.canvases.get(source.targetCanvasId):undefined;
|
||||
if(!source||!canvas)throw fault('import_target_missing');
|
||||
if(source.targetEntry?(canvas.parent?.entryElementId!==source.targetEntry.id||document.elements.get(source.targetEntry.id)?.incarnation!==source.targetEntry.incarnation):canvas.parent!==null)throw fault('import_target_changed');
|
||||
return source;
|
||||
}
|
||||
/** 已committed依赖必须仍是原绑定生命周期;用户删后重建同ID不能被后续导入误认领。 */
|
||||
/** 仅检查已在 committedSourceKeys 的依赖:stableIds 定位目标,再以 job.bindings 核对生命周期。
|
||||
* element 检查代次,canvas 检查仍存在、入口 ID 及已记录入口代次;不比较正文、几何、组件或全部 owner 链。
|
||||
* 未提交/SCC 内部依赖不在这里猜成存在,留给分组选取与原子创建后的文档校验;本函数不改变绑定。 */
|
||||
export function assertNativeImportDependencies(job:ImportJob,document:Document,dependencies:readonly string[]){
|
||||
for(const key of dependencies)if(job.committedSourceKeys.includes(key)){
|
||||
const id=job.stableIds[key],binding=job.bindings?.[id];
|
||||
@@ -27,7 +32,11 @@ export function assertNativeImportDependencies(job:ImportJob,document:Document,d
|
||||
}
|
||||
|
||||
/** Native NDJSON records use source IDs; $target names the explicitly chosen existing canvas. */
|
||||
/** 校验记录封装/源ID,预留自身与引用ID,生成一条创建变化及去除自引用后的依赖列表。 */
|
||||
/** 校验封装/源 ID 后克隆 record,reserve 自身与引用键,生成一条 before=null 的创建变化和依赖列表。
|
||||
* reserve 可能先于后续类型校验调用;本函数不回滚自定义 reserve 的副作用,runner 应使用私有捕获上下文。
|
||||
* registry.resolve 只确认已知类型/版本与 copy 声明,不等同完整数据/几何/引用校验;最终提交仍需验证文档。
|
||||
* 原几何/其它字段按记录携带,不在此统一转换世界坐标,也不检查所有目标 ID 的文档/墓碑冲突。
|
||||
* 返回的 ImportUnit/changes envelope 未整体冻结,交 runner 捕获、计成本、持久化和发布。 */
|
||||
export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:string)=>Id,document:Document,registry:Registry,resourceURL?:(id:Id)=>string):ImportUnit {
|
||||
const source=assertNativeImportTarget(job,document);
|
||||
const raw=line.value as Record<string,Json>|undefined;
|
||||
@@ -39,7 +48,8 @@ export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:s
|
||||
/** 根/$target直接重定向目标,子canvas则分配稳定新ID并登记canvas依赖。 */
|
||||
const owner=(id:Json):Id=>{if(id==='$target'||source.kind==='native-document'&&id===raw.sourceRootId){canvasIds.set(id as string,source.targetCanvasId);return source.targetCanvasId;}if(typeof id!=='string'||!id)throw fault('invalid_import_reference');const key=`canvas:${id}`;dependencies.add(key);const mapped=reserve(key);canvasIds.set(id,mapped);return mapped;};
|
||||
const key=`${raw.kind}:${raw.key}`,id=reserve(key);
|
||||
// 不把源根作为第二个根插入;这里只处理有parent的子canvas,parent与入口都使用新映射。
|
||||
// 只接受带 parent 的子 canvas;源根由读取器排除,再通过 sourceRootId/$target alias 接到现有目标。
|
||||
// parent/record 其它属性沿用克隆数据,额外 schema/树约束不会在此分支自动补齐。
|
||||
if(raw.kind==='canvas'){
|
||||
const parent=record.parent as Record<string,Json>|null;
|
||||
if(!parent||typeof parent!=='object'||Array.isArray(parent))throw fault('invalid_import_canvas');
|
||||
@@ -49,7 +59,8 @@ export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:s
|
||||
// record是脱离源的副本,original是其类型视图;未知组件/缺复制声明不猜字段含义。
|
||||
const original=record as unknown as ElementRecord,definition=registry.components.get(original.componentId);
|
||||
if(registry.resolve(original)!=='known'||!definition?.copy||!original.data||typeof original.data!=='object'||Array.isArray(original.data))throw fault('import_component_unavailable');
|
||||
// 文档图片需宿主提供规范资源URL;仅重建引用不代表完成授权,授权由运行时后续门禁证明。
|
||||
// native-document 的非空资源字段要求宿主 resourceURL:值须为非空 ID,assetId 还重建 data.src。
|
||||
// callback 只是地址提供者,此处不读取字节/校验权限;其它资源字段不自动改名,NDJSON 也不走此文档专用分支。
|
||||
if(source.kind==='native-document')for(const key of definition.resourceFields??[]){const value=original.data[key];if(value===undefined||value===null)continue;if(!resourceURL)throw fault('import_resource_authorization_required');if(typeof value!=='string'||!value)throw fault('invalid_import_reference');if(key==='assetId')record.data={...original.data,src:resourceURL(value)};}
|
||||
record.data=original.data;
|
||||
// 只遍历schema声明引用,避免重写普通文字里的ID;omit字段由共享remapCopyData处理。
|
||||
@@ -60,5 +71,7 @@ export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:s
|
||||
if(!original.source||!original.target)throw fault('invalid_import_endpoint');
|
||||
Object.assign(after,{source:{...original.source,elementId:element(original.source.elementId)},target:{...original.target,elementId:element(original.target.elementId)}});
|
||||
}else if(original.structure!=='node')throw fault('invalid_import_structure');
|
||||
// Set 保留引用首次遇到的顺序并去重;删除自身依赖避免单节点自引用被误当外部等待。
|
||||
// 尚未出现的定义仍有稳定 reserve ID/依赖键,缺失定义由 runner 后续报告,不直接连回源项目。
|
||||
dependencies.delete(key);return{key,dependencies:[...dependencies],changes:[{kind:'element',id,before:null,after:after as ElementRecord}]};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user