55 lines
5.5 KiB
TypeScript
55 lines
5.5 KiB
TypeScript
/**
|
|
* 格式1的完整复制源快照与复制声明签名。源字节在任务准入前从一个不可变文档捕获,
|
|
* 后续规划/重开不读取当前用户正在改的live文档;源SHA与持久字节由外层完整性流程绑定。
|
|
* 编码器借用传入 Document 并在 await 后继续迭代,其不可变性是调用合同,并非函数进入时同步复制全图。
|
|
*/
|
|
import type { CanvasRecord, Document, ElementRecord, Registry } from '../canvas-core/types.ts';
|
|
import { createDocument, validateDocumentSteps } from '../canvas-core/document.ts';
|
|
import { fault } from '../canvas-core/values.ts';
|
|
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。
|
|
* 每条 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。 */
|
|
const append=(value:unknown)=>{if(signal.aborted)throw fault('copy_aborted');const text=JSON.stringify(value)+'\n',size=new TextEncoder().encode(text).byteLength;if(size>256*1024)throw fault('record_too_large');bytes+=size;if(bytes>64*1024*1024)throw fault('invalid_import_source');parts.push(text);return size;};
|
|
append({kind:'header',version:1,projectId:document.projectId});
|
|
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后才返回。
|
|
* 记录读到 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)));
|
|
}
|
|
if(!header||signal.aborted)throw fault(signal.aborted?'copy_aborted':'invalid_copy_snapshot');
|
|
// 不把合法header/记录数量当成完整图合法;parent/owner/端点/schema仍由核心校验。
|
|
const document=createDocument(projectId,canvases,elements,tombstones);
|
|
for(const _ of validateDocumentSteps(document,registry))await budget.step();
|
|
if(signal.aborted)throw fault('copy_aborted');
|
|
return document;
|
|
}
|
|
|
|
/** Copy declarations are a versioned input; never silently reinterpret a persisted preparation. */
|
|
/** 仅对源涉及组件的数据版本/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};}));
|
|
}
|