Persist JSON array imports through recovery v14 and the canvas file picker

This commit is contained in:
2026-11-09 17:57:23 +08:00
parent c4dfaf50a5
commit 78dfbbf4d7
12 changed files with 52 additions and 30 deletions
+9 -2
View File
@@ -180,7 +180,7 @@ Whole-source completion preserves pending/stored resources and history. Default
image sizing and display handles reuse the existing decoder/cache lifecycle. image sizing and display handles reuse the existing decoder/cache lifecycle.
Undo/redo, source failure, quota retry and late chunks do not re-upload bytes or Undo/redo, source failure, quota retry and late chunks do not re-upload bytes or
restore deleted incarnations. Earlier loading/recovery versions retain their old restore deleted incarnations. Earlier loading/recovery versions retain their old
restrictions; future format 13 is rejected. Generation, cross-project durable depot restrictions; current recovery compatibility is described below. Generation, cross-project durable depot
sharing and broader lifecycle/acceptance work remain separate unfinished goals. sharing and broader lifecycle/acceptance work remain separate unfinished goals.
The package now exposes `/bindings` for pure projection, renderer descriptors, The package now exposes `/bindings` for pure projection, renderer descriptors,
@@ -333,7 +333,14 @@ claim of constant-time full-frame updates or complete region-based drawing.
array, with byte cursors, bounded record payloads, structural depth limits and array, with byte cursors, bounded record payloads, structural depth limits and
abort/yield checks. It uses parserVersion 3 and can feed a host-managed generic abort/yield checks. It uses parserVersion 3 and can feed a host-managed generic
ImportRunner. It does not yet interpret the exported document envelope, and ImportRunner. It does not yet interpret the exported document envelope, and
ProjectEngine's persisted imports/UI still accept their existing formats only. ProjectEngine now accepts native record arrays through `imports.startJsonArray(bytes, canvasId)`; the file picker routes `.json` to it. Exported document envelopes are still unsupported.
A malformed structural tail ends with an explicit failure; previously emitted A malformed structural tail ends with an explicit failure; previously emitted
records remain available. Empty source bytes reject, while `[]` is a valid empty records remain available. Empty source bytes reject, while `[]` is a valid empty
array. Array record errors are distinct from document schema/reference failures. array. Array record errors are distinct from document schema/reference failures.
JSON array jobs preserve the original Blob and integrity digest, parserVersion 3,
byte cursor, stable IDs, pending dependencies and failures using the same ImportJob
and commit boundary as NDJSON. Such roots use recovery envelope 14; readers accept
1–14 and reject 15. Downgrading an array-bearing image to 13 rejects. This changes
neither IndexedDB schema v4 nor the server wire protocol. Native array items have
the same `{kind,key,record}` schema as NDJSON; `$target` denotes the chosen canvas.
+4 -4
View File
@@ -7,7 +7,7 @@ import { IconButton } from "@/components/ui/icon-button";
import { errorText } from "@/lib/api"; import { errorText } from "@/lib/api";
import styles from "./canvas.module.css"; import styles from "./canvas.module.css";
const reason = (code: string) => ({ invalid_json: "记录格式错误", invalid_json_value: "记录包含不支持的数据", invalid_utf8: "文件编码不是 UTF-8", record_too_large: "单条记录过大", unresolved_dependency: "引用的记录未加入", invalid_native_import_record: "不是画布记录", import_component_unavailable: "元素类型暂不支持", invalid_geometry: "元素尺寸不合法", invalid_endpoint: "连线端点已不存在", duplicate_import_source_key: "记录标识重复", import_source_unavailable: "导入源文件不可用", import_source_changed: "源文件内容与保存时不一致,任务已暂停", import_source_integrity_unavailable: "旧任务缺少源内容校验,无法安全继续;已加入内容保留", import_source_integrity_missing: "源校验记录缺失,任务已暂停", import_source_integrity_interpreter_unavailable: "源校验版本暂不支持", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", copy_external_changed: "原图引用已删除、移动或恢复为新版本", invalid_duplicate_source: "复制源数据不可用", invalid_copy_snapshot: "复制快照不可用", invalid_copy_preparation: "复制准备记录不可用", copy_interpreter_changed: "复制规则已变化,需要重新复制", invalid_import_source: "文件过大或格式不支持" } as Record<string,string>)[code] ?? "这条记录暂时无法加入"; const reason = (code: string) => ({ invalid_json_array: "JSON 记录数组格式错误", incomplete_json_array: "JSON 数组尾部不完整", json_nesting_limit: "JSON 嵌套层数过多", invalid_json: "记录格式错误", invalid_json_value: "记录包含不支持的数据", invalid_utf8: "文件编码不是 UTF-8", record_too_large: "单条记录过大", unresolved_dependency: "引用的记录未加入", invalid_native_import_record: "不是画布记录", import_component_unavailable: "元素类型暂不支持", invalid_geometry: "元素尺寸不合法", invalid_endpoint: "连线端点已不存在", duplicate_import_source_key: "记录标识重复", import_source_unavailable: "导入源文件不可用", import_source_changed: "源文件内容与保存时不一致,任务已暂停", import_source_integrity_unavailable: "旧任务缺少源内容校验,无法安全继续;已加入内容保留", import_source_integrity_missing: "源校验记录缺失,任务已暂停", import_source_integrity_interpreter_unavailable: "源校验版本暂不支持", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", copy_external_changed: "原图引用已删除、移动或恢复为新版本", invalid_duplicate_source: "复制源数据不可用", invalid_copy_snapshot: "复制快照不可用", invalid_copy_preparation: "复制准备记录不可用", copy_interpreter_changed: "复制规则已变化,需要重新复制", invalid_import_source: "文件过大或格式不支持" } as Record<string,string>)[code] ?? "这条记录暂时无法加入";
export function HeadlessImports({ engine, canvasId }: { engine: ProjectEngine; canvasId: string }) { export function HeadlessImports({ engine, canvasId }: { engine: ProjectEngine; canvasId: string }) {
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]); const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
@@ -17,10 +17,10 @@ export function HeadlessImports({ engine, canvasId }: { engine: ProjectEngine; c
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse(); const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
const control = (work: Promise<void>) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); }; const control = (work: Promise<void>) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); };
return <aside className={styles.importPanel} aria-label="画布导入"> return <aside className={styles.importPanel} aria-label="画布导入">
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入 NDJSON 画布数据" disabled={!engine.imports.canStart(canvasId).enabled} onClick={() => input.current?.click()} /><span>逐行画布数据</span></div> <div className={styles.importActions}><IconButton icon={FolderInput} label="导入画布记录" disabled={!engine.imports.canStart(canvasId).enabled} onClick={() => input.current?.click()} /><span>NDJSON / JSON 记录数组</span></div>
<input data-canvas-import ref={input} type="file" accept=".ndjson,.jsonl,application/x-ndjson" hidden onChange={event => { <input data-canvas-import ref={input} type="file" accept=".ndjson,.jsonl,.json,application/json,application/x-ndjson" hidden onChange={event => {
const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; const file = event.target.files?.[0]; event.target.value = ""; if (!file) return;
try { engine.imports.startNdjson(file, canvasId); setOpen(true); setError(""); } catch (error) { setError(errorText(error)); } try { if(file.name.toLowerCase().endsWith('.json'))engine.imports.startJsonArray(file,canvasId);else engine.imports.startNdjson(file,canvasId); setOpen(true); setError(""); } catch (error) { setError(errorText(error)); }
}} /> }} />
{jobs.length > 0 && <details open={open} onToggle={event => setOpen(event.currentTarget.open)}><summary>导入与复制任务 · {jobs.some(job => job.status === "preparing") && "正在准备复制 · "}{jobs.reduce((count, job) => count + job.committedSourceKeys.length, 0)} 项已加入</summary> {jobs.length > 0 && <details open={open} onToggle={event => setOpen(event.currentTarget.open)}><summary>导入与复制任务 · {jobs.some(job => job.status === "preparing") && "正在准备复制 · "}{jobs.reduce((count, job) => count + job.committedSourceKeys.length, 0)} 项已加入</summary>
{jobs.map((job, index) => <section className={styles.importJob} key={job.id} aria-label={`导入任务 ${index + 1}`}> {jobs.map((job, index) => <section className={styles.importJob} key={job.id} aria-label={`导入任务 ${index + 1}`}>
+15 -11
View File
@@ -18,6 +18,7 @@ import { planDuplicateAsync, planDuplicateResumable, prepareDuplicateStream } fr
import type { DigestPort } from './digest-pool.ts'; import type { DigestPort } from './digest-pool.ts';
import { importSourceDigest } from './import-integrity.ts'; import { importSourceDigest } from './import-integrity.ts';
import { encodeDuplicateSource, prepareDuplicateImport, assertDuplicateExternal } from './import-duplicate.ts'; import { encodeDuplicateSource, prepareDuplicateImport, assertDuplicateExternal } from './import-duplicate.ts';
import {createJsonArrayImportSource} from './import-json-array.ts';
import { createImportJob, createImportRunner, createNdjsonImportSource, type ImportJob } from './imports.ts'; import { createImportJob, createImportRunner, createNdjsonImportSource, type ImportJob } from './imports.ts';
import { assertNativeImportDependencies, assertNativeImportTarget, prepareNativeImport } from './import-native.ts'; import { assertNativeImportDependencies, assertNativeImportTarget, prepareNativeImport } from './import-native.ts';
import { elisionBatchLimit, findElision } from './elision.ts'; import { elisionBatchLimit, findElision } from './elision.ts';
@@ -671,7 +672,7 @@ export async function openProjectEngine(options: EngineOptions) {
let expectedFingerprint=getImport(id).sourceFingerprint; let expectedFingerprint=getImport(id).sourceFingerprint;
const preparationController=new AbortController();if(['duplicate-snapshot','duplicate-plan'].includes(getImport(id).source?.kind??''))preparationControllers.set(id,preparationController); const preparationController=new AbortController();if(['duplicate-snapshot','duplicate-plan'].includes(getImport(id).source?.kind??''))preparationControllers.set(id,preparationController);
const task=(async()=>{ const task=(async()=>{
let job=getImport(id),source=job.source;if(!source||!['native-ndjson','duplicate-ndjson','duplicate-snapshot','duplicate-plan'].includes(source.kind)||job.parserVersion!==(source.kind==='duplicate-plan'?2:1))throw fault('import_interpreter_unavailable'); let job=getImport(id),source=job.source;if(!source||!['native-json-array','native-ndjson','duplicate-ndjson','duplicate-snapshot','duplicate-plan'].includes(source.kind)||job.parserVersion!==(source.kind==='native-json-array'?3:source.kind==='duplicate-plan'?2:1))throw fault('import_interpreter_unavailable');
let bytes=fileWrites.get(source.fileId)??await options.storage.readFile(leader,source.fileId); let bytes=fileWrites.get(source.fileId)??await options.storage.readFile(leader,source.fileId);
if(closing||closed)return; if(closing||closed)return;
const latest=getImport(id);if(latest.sourceFingerprint!==job.sourceFingerprint||latest.source?.fileId!==source.fileId||['completed','partial','cancelled'].includes(latest.status))return; const latest=getImport(id);if(latest.sourceFingerprint!==job.sourceFingerprint||latest.source?.fileId!==source.fileId||['completed','partial','cancelled'].includes(latest.status))return;
@@ -687,7 +688,7 @@ export async function openProjectEngine(options: EngineOptions) {
if(source.kind==='duplicate-snapshot'){if(preparationController.signal.aborted||importHolds.size||latest.status!=='preparing')return;const materialized=await materializeCopy(id,bytes,preparationController);job=materialized.job;source=job.source!;bytes=materialized.bytes;copyDocument=materialized.document;indexed=materialized.indexed;expectedFingerprint=job.sourceFingerprint;} if(source.kind==='duplicate-snapshot'){if(preparationController.signal.aborted||importHolds.size||latest.status!=='preparing')return;const materialized=await materializeCopy(id,bytes,preparationController);job=materialized.job;source=job.source!;bytes=materialized.bytes;copyDocument=materialized.document;indexed=materialized.indexed;expectedFingerprint=job.sourceFingerprint;}
if(source.kind==='duplicate-plan'&&source.snapshotVersion===2){indexed??=await openIndexedCopySnapshot(bytes,options.scope.projectId,options.registry,preparationController.signal);copyDocument=indexed.document;} if(source.kind==='duplicate-plan'&&source.snapshotVersion===2){indexed??=await openIndexedCopySnapshot(bytes,options.scope.projectId,options.registry,preparationController.signal);copyDocument=indexed.document;}
if(source.kind==='duplicate-plan'&&copyDocument)validateLoadingCopySource(editor.getSnapshot(),job,copyDocument); if(source.kind==='duplicate-plan'&&copyDocument)validateLoadingCopySource(editor.getSnapshot(),job,copyDocument);
const importSource=source.kind==='duplicate-plan'?createDuplicatePlanSource(copyDocument??await decodeCopySnapshot(bytes,options.scope.projectId,options.registry,preparationController.signal),options.registry,source.plan!,job.sourceFingerprint,indexed?.readElement):createNdjsonImportSource({bytes,fingerprint:job.sourceFingerprint}); const importSource=source.kind==='duplicate-plan'?createDuplicatePlanSource(copyDocument??await decodeCopySnapshot(bytes,options.scope.projectId,options.registry,preparationController.signal),options.registry,source.plan!,job.sourceFingerprint,indexed?.readElement):source.kind==='native-json-array'?createJsonArrayImportSource({bytes,fingerprint:job.sourceFingerprint}):createNdjsonImportSource({bytes,fingerprint:job.sourceFingerprint});
if(closing||closed||preparationController.signal.aborted||getImport(id).generation!==job.generation)return; if(closing||closed||preparationController.signal.aborted||getImport(id).generation!==job.generation)return;
if(importHolds.size){deferredImportLoads.add(id);return;} if(importHolds.size){deferredImportLoads.add(id);return;}
const runner=createImportRunner({optimisticLocal:true,awaitDurabilityBudget:stage=>pending.size>=32||stage!=='parsed'&&persistenceFailed?persist():undefined,source:importSource,getJob:()=>getImport(id),createId, const runner=createImportRunner({optimisticLocal:true,awaitDurabilityBudget:stage=>pending.size>=32||stage!=='parsed'&&persistenceFailed?persist():undefined,source:importSource,getJob:()=>getImport(id),createId,
@@ -704,20 +705,23 @@ export async function openProjectEngine(options: EngineOptions) {
})().catch(error=>{if(preparationController.signal.aborted)return;if(!closing&&!closed){try{const job=getImport(id);if(job.sourceFingerprint!==expectedFingerprint||['completed','partial','cancelled','paused'].includes(job.status))return;publishImport(freeze({...job,generation:job.generation+1,status:'paused',error:(error as {code?:string}).code??'import_failed'}),job.generation);}catch{/* Recovery/identity fences retain the original job. */}report(error);}}).finally(()=>{preparationControllers.delete(id);importLoads.delete(id);if(!['running','preparing'].includes(getImport(id).status))deferredImportLoads.delete(id);if(!closing&&!closed&&!importHolds.size&&deferredImportLoads.delete(id)&&!importRunners.has(id)&&['running','preparing'].includes(getImport(id).status))queueMicrotask(()=>{if(!closing&&!closed)void loadImport(id);});}); })().catch(error=>{if(preparationController.signal.aborted)return;if(!closing&&!closed){try{const job=getImport(id);if(job.sourceFingerprint!==expectedFingerprint||['completed','partial','cancelled','paused'].includes(job.status))return;publishImport(freeze({...job,generation:job.generation+1,status:'paused',error:(error as {code?:string}).code??'import_failed'}),job.generation);}catch{/* Recovery/identity fences retain the original job. */}report(error);}}).finally(()=>{preparationControllers.delete(id);importLoads.delete(id);if(!['running','preparing'].includes(getImport(id).status))deferredImportLoads.delete(id);if(!closing&&!closed&&!importHolds.size&&deferredImportLoads.delete(id)&&!importRunners.has(id)&&['running','preparing'].includes(getImport(id).status))queueMicrotask(()=>{if(!closing&&!closed)void loadImport(id);});});
importLoads.set(id,task);return task; importLoads.set(id,task);return task;
} }
function startNativeImport(bytes:Blob,targetCanvasId:Id,format:'ndjson'|'json-array'):Id{
if(!['ndjson','json-array'].includes(format))throw fault('import_interpreter_unavailable');
assertImportWritable();const loadingAdmission=loadingImportAdmission(editor.getSnapshot(),targetCanvasId);const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const canvas=editor.getSnapshot().document.canvases.get(targetCanvasId);if(!canvas)throw fault('canvas_not_loaded',[targetCanvasId]);if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source');
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||current.orphanFiles?.has(fileId)||fileWrites.has(fileId)||importFiles(current.imports).has(fileId))throw fault('import_identity_conflict');
const entry=canvas.parent?editor.getSnapshot().document.elements.get(canvas.parent.entryElementId)!:undefined;
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:format==='json-array'?3:1,source:{...(loadingAdmission?{loadingAdmission}:{}),kind:format==='json-array'?'native-json-array':'native-ndjson',name:((bytes as Blob&{name?:string}).name||'画布数据').slice(0,255),fileId,byteLength:bytes.size,targetCanvasId,...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'running' as const});
fileWrites.set(fileId,bytes);
const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});});
if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id;
}
const imports=Object.freeze({ const imports=Object.freeze({
getJobs:()=>readMap(editor.getSnapshot().runtime.imports??new Map()), getJobs:()=>readMap(editor.getSnapshot().runtime.imports??new Map()),
canStart(targetCanvasId:Id){try{assertImportWritable();const snapshot=editor.getSnapshot(),permission=options.policy?.({type:'changes.apply',changes:[]},snapshot.document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);if(!snapshot.document.canvases.has(targetCanvasId))throw fault('canvas_not_loaded',[targetCanvasId]);loadingImportAdmission(snapshot,targetCanvasId);return{enabled:true as const};}catch(error){const result=reject(error);return{enabled:false as const,code:result.code,targets:result.targets};}}, canStart(targetCanvasId:Id){try{assertImportWritable();const snapshot=editor.getSnapshot(),permission=options.policy?.({type:'changes.apply',changes:[]},snapshot.document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);if(!snapshot.document.canvases.has(targetCanvasId))throw fault('canvas_not_loaded',[targetCanvasId]);loadingImportAdmission(snapshot,targetCanvasId);return{enabled:true as const};}catch(error){const result=reject(error);return{enabled:false as const,code:result.code,targets:result.targets};}},
canDuplicate(selection:readonly Target[],settings:{viewCanvasId?:Id}={}){try{assertImportWritable();if(collectionReservations.size)throw fault('resource_collection_in_progress',[...collectionReservations]);const snapshot=editor.getSnapshot(),permission=options.policy?.({type:'changes.apply',changes:[]},snapshot.document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);if(!selection.length)throw fault('empty_copy_selection');for(const selected of selection){const element=snapshot.document.elements.get(selected.id);if(!element||element.incarnation!==selected.incarnation)throw fault('stale_incarnation',[selected.id]);}const target=settings.viewCanvasId??snapshot.document.elements.get(selection[0].id)!.ownerCanvasId;if(!snapshot.document.canvases.has(target))throw fault('canvas_not_loaded',[target]);loadingImportAdmission(snapshot,target,selection);return{enabled:true as const};}catch(error){const result=reject(error);return{enabled:false as const,code:result.code,targets:result.targets};}}, canDuplicate(selection:readonly Target[],settings:{viewCanvasId?:Id}={}){try{assertImportWritable();if(collectionReservations.size)throw fault('resource_collection_in_progress',[...collectionReservations]);const snapshot=editor.getSnapshot(),permission=options.policy?.({type:'changes.apply',changes:[]},snapshot.document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);if(!selection.length)throw fault('empty_copy_selection');for(const selected of selection){const element=snapshot.document.elements.get(selected.id);if(!element||element.incarnation!==selected.incarnation)throw fault('stale_incarnation',[selected.id]);}const target=settings.viewCanvasId??snapshot.document.elements.get(selection[0].id)!.ownerCanvasId;if(!snapshot.document.canvases.has(target))throw fault('canvas_not_loaded',[target]);loadingImportAdmission(snapshot,target,selection);return{enabled:true as const};}catch(error){const result=reject(error);return{enabled:false as const,code:result.code,targets:result.targets};}},
openSource:openImportSource, openSource:openImportSource,
startNdjson(bytes:Blob,targetCanvasId:Id):Id{ startNdjson:(bytes:Blob,targetCanvasId:Id)=>startNativeImport(bytes,targetCanvasId,'ndjson'),
assertImportWritable();const loadingAdmission=loadingImportAdmission(editor.getSnapshot(),targetCanvasId);const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const canvas=editor.getSnapshot().document.canvases.get(targetCanvasId);if(!canvas)throw fault('canvas_not_loaded',[targetCanvasId]);if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source'); startJsonArray:(bytes:Blob,targetCanvasId:Id)=>startNativeImport(bytes,targetCanvasId,'json-array'),
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||current.orphanFiles?.has(fileId)||fileWrites.has(fileId)||importFiles(current.imports).has(fileId))throw fault('import_identity_conflict');
const entry=canvas.parent?editor.getSnapshot().document.elements.get(canvas.parent.entryElementId)!:undefined;
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{...(loadingAdmission?{loadingAdmission}:{}),kind:'native-ndjson',name:((bytes as Blob&{name?:string}).name||'画布数据').slice(0,255),fileId,byteLength:bytes.size,targetCanvasId,...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'running' as const});
fileWrites.set(fileId,bytes);
const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});});
if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id;
},
async duplicate(selection:readonly Target[],settings:{readonly worldOffset?:Point;readonly signal?:AbortSignal;readonly viewCanvasId?:Id}={}):Promise<Id>{ async duplicate(selection:readonly Target[],settings:{readonly worldOffset?:Point;readonly signal?:AbortSignal;readonly viewCanvasId?:Id}={}):Promise<Id>{
assertImportWritable();if(collectionReservations.size)throw fault('resource_collection_in_progress',[...collectionReservations]); assertImportWritable();if(collectionReservations.size)throw fault('resource_collection_in_progress',[...collectionReservations]);
const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets); const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
+3 -2
View File
@@ -10,7 +10,7 @@ export type ImportUnit = { readonly key: string; readonly dependencies: readonly
export type ImportLocator = { readonly key: string; readonly cursor: number; readonly nextCursor: number; readonly dependencies: readonly string[]; readonly digest: string; readonly byteLength: number; readonly changeCount: number }; export type ImportLocator = { readonly key: string; readonly cursor: number; readonly nextCursor: number; readonly dependencies: readonly string[]; readonly digest: string; readonly byteLength: number; readonly changeCount: number };
export type ImportFailure = { readonly key: string; readonly cursor: number; readonly code: string; readonly dependencies?: readonly string[] }; export type ImportFailure = { readonly key: string; readonly cursor: number; readonly code: string; readonly dependencies?: readonly string[] };
export type LoadingImportAdmission={readonly version:1;readonly sourceToken:string;readonly operationSequence:number;readonly requiredCanvasIds:readonly Id[]}; export type LoadingImportAdmission={readonly version:1;readonly sourceToken:string;readonly operationSequence:number;readonly requiredCanvasIds:readonly Id[]};
export type NativeImportSource = { readonly loadingAdmission?:LoadingImportAdmission; readonly kind: 'native-ndjson' | 'duplicate-ndjson' | 'duplicate-snapshot' | 'duplicate-plan'; readonly retirement?:FileRetirement;readonly previousFiles?:readonly PreviousImportFile[];readonly snapshotVersion?:2;readonly plan?:DuplicateCheckpoint; readonly copy?: {readonly sourceRevision:number; readonly roots:readonly Id[]}; readonly integrityVersion?:1;readonly sha256?:string; readonly name?:string; readonly fileId: Id; readonly byteLength: number; readonly targetCanvasId: Id; readonly targetEntry?: {readonly id:Id;readonly incarnation:number} }; export type NativeImportSource = { readonly loadingAdmission?:LoadingImportAdmission; readonly kind: 'native-json-array' | 'native-ndjson' | 'duplicate-ndjson' | 'duplicate-snapshot' | 'duplicate-plan'; readonly retirement?:FileRetirement;readonly previousFiles?:readonly PreviousImportFile[];readonly snapshotVersion?:2;readonly plan?:DuplicateCheckpoint; readonly copy?: {readonly sourceRevision:number; readonly roots:readonly Id[]}; readonly integrityVersion?:1;readonly sha256?:string; readonly name?:string; readonly fileId: Id; readonly byteLength: number; readonly targetCanvasId: Id; readonly targetEntry?: {readonly id:Id;readonly incarnation:number} };
export type ImportBinding = {readonly kind:'element';readonly incarnation:number}|{readonly kind:'canvas';readonly entryElementId?:Id}; export type ImportBinding = {readonly kind:'element';readonly incarnation:number}|{readonly kind:'canvas';readonly entryElementId?:Id};
export type ImportJob = { export type ImportJob = {
readonly version: 1 | 2 | 3; readonly id: Id; readonly projectId: Id; readonly sourceFingerprint: string; readonly parserVersion: number; readonly version: 1 | 2 | 3; readonly id: Id; readonly projectId: Id; readonly sourceFingerprint: string; readonly parserVersion: number;
@@ -29,7 +29,8 @@ export function validateImportJob(job: ImportJob): void {
if (job.version !== 1 && job.version !== 2 && job.version !== 3) throw fault('import_interpreter_unavailable'); if (job.version !== 1 && job.version !== 2 && job.version !== 3) throw fault('import_interpreter_unavailable');
createImportJob(job); createImportJob(job);
if (!Number.isSafeInteger(job.generation) || job.generation<0 || !Number.isSafeInteger(job.cursor) || job.cursor<0 || typeof job.eof!=='boolean' || !['preparing','paused','running','completed','partial','cancelled'].includes(job.status)) throw fault('invalid_import_job'); if (!Number.isSafeInteger(job.generation) || job.generation<0 || !Number.isSafeInteger(job.cursor) || job.cursor<0 || typeof job.eof!=='boolean' || !['preparing','paused','running','completed','partial','cancelled'].includes(job.status)) throw fault('invalid_import_job');
if(job.source&&(!['native-ndjson','duplicate-ndjson','duplicate-snapshot','duplicate-plan'].includes(job.source.kind)||['duplicate-ndjson','duplicate-plan'].includes(job.source.kind)&&(!job.source.copy||!Number.isSafeInteger(job.source.copy.sourceRevision)||job.source.copy.sourceRevision<0||!Array.isArray(job.source.copy.roots)||!job.source.copy.roots.length||job.source.copy.roots.some(id=>typeof id!=='string'||!id))||job.source.name!==undefined&&(typeof job.source.name!=='string'||job.source.name.length>255)||!job.source.fileId||!job.source.targetCanvasId||!Number.isSafeInteger(job.source.byteLength)||job.source.byteLength<0||job.source.targetEntry&&(!job.source.targetEntry.id||!Number.isSafeInteger(job.source.targetEntry.incarnation)||job.source.targetEntry.incarnation<1)))throw fault('invalid_import_source'); if(job.source&&(!['native-json-array','native-ndjson','duplicate-ndjson','duplicate-snapshot','duplicate-plan'].includes(job.source.kind)||['duplicate-ndjson','duplicate-plan'].includes(job.source.kind)&&(!job.source.copy||!Number.isSafeInteger(job.source.copy.sourceRevision)||job.source.copy.sourceRevision<0||!Array.isArray(job.source.copy.roots)||!job.source.copy.roots.length||job.source.copy.roots.some(id=>typeof id!=='string'||!id))||job.source.name!==undefined&&(typeof job.source.name!=='string'||job.source.name.length>255)||!job.source.fileId||!job.source.targetCanvasId||!Number.isSafeInteger(job.source.byteLength)||job.source.byteLength<0||job.source.targetEntry&&(!job.source.targetEntry.id||!Number.isSafeInteger(job.source.targetEntry.incarnation)||job.source.targetEntry.incarnation<1)))throw fault('invalid_import_source');
if(job.source?.kind==='native-json-array'&&job.parserVersion!==3)throw fault('import_interpreter_unavailable');
if(job.source?.loadingAdmission){const proof=job.source.loadingAdmission;if(proof.version!==1||typeof proof.sourceToken!=='string'||!proof.sourceToken||proof.sourceToken.length>256||!Number.isSafeInteger(proof.operationSequence)||proof.operationSequence<0||!Array.isArray(proof.requiredCanvasIds)||!proof.requiredCanvasIds.length||proof.requiredCanvasIds.some(id=>typeof id!=='string'||!id)||new Set(proof.requiredCanvasIds).size!==proof.requiredCanvasIds.length)throw fault('loading_import_interpreter_unavailable');} if(job.source?.loadingAdmission){const proof=job.source.loadingAdmission;if(proof.version!==1||typeof proof.sourceToken!=='string'||!proof.sourceToken||proof.sourceToken.length>256||!Number.isSafeInteger(proof.operationSequence)||proof.operationSequence<0||!Array.isArray(proof.requiredCanvasIds)||!proof.requiredCanvasIds.length||proof.requiredCanvasIds.some(id=>typeof id!=='string'||!id)||new Set(proof.requiredCanvasIds).size!==proof.requiredCanvasIds.length)throw fault('loading_import_interpreter_unavailable');}
if(job.source){ if(job.source){
validateImportFileRetention(job.source,job.status); validateImportFileRetention(job.source,job.status);
+2 -2
View File
@@ -52,8 +52,8 @@ export function encodeRecoveryValue(input:unknown):Json {
} }
/** Only codec-owned, validated immutable images qualify for the storage copy fast path. */ /** Only codec-owned, validated immutable images qualify for the storage copy fast path. */
export function sealRecoveryImage(formatVersion:1|2|3|4|5|6|7|8|9|10|11|12|13,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage { export function sealRecoveryImage(formatVersion:1|2|3|4|5|6|7|8|9|10|11|12|13|14,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
if(![1,2,3,4,5,6,7,8,9,10,11,12,13].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image'); if(![1,2,3,4,5,6,7,8,9,10,11,12,13,14].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image');
const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encodeRecoveryValue(payload)});sealed.add(image);return image; const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encodeRecoveryValue(payload)});sealed.add(image);return image;
} }
export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);} export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);}
+5 -4
View File
@@ -30,7 +30,7 @@ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage {
operations: [...snapshot.operations], operations: [...snapshot.operations],
runtime: { ...(snapshot.runtime.assetBindings?.size?{assetBindings:[...snapshot.runtime.assetBindings]}:{}), ...(snapshot.runtime.loadingRead?{loadingRead:{document:documentValue(snapshot.runtime.loadingRead.document),versions:[...snapshot.runtime.loadingRead.versions]}}:{}), ...(snapshot.runtime.loading?{loading:snapshot.runtime.loading}:{}), ...(snapshot.runtime.orphanFiles?.size?{orphanFiles:[...snapshot.runtime.orphanFiles]}:{}), imports:[...(snapshot.runtime.imports??[])], recoveries:[...(snapshot.runtime.recoveries??[])], runs: [...snapshot.runtime.runs], resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } }, runtime: { ...(snapshot.runtime.assetBindings?.size?{assetBindings:[...snapshot.runtime.assetBindings]}:{}), ...(snapshot.runtime.loadingRead?{loadingRead:{document:documentValue(snapshot.runtime.loadingRead.document),versions:[...snapshot.runtime.loadingRead.versions]}}:{}), ...(snapshot.runtime.loading?{loading:snapshot.runtime.loading}:{}), ...(snapshot.runtime.orphanFiles?.size?{orphanFiles:[...snapshot.runtime.orphanFiles]}:{}), imports:[...(snapshot.runtime.imports??[])], recoveries:[...(snapshot.runtime.recoveries??[])], runs: [...snapshot.runtime.runs], resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } },
}; };
return sealRecoveryImage(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); return sealRecoveryImage([...(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);
} }
type ObjectValue = Record<string, unknown>; type ObjectValue = Record<string, unknown>;
function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; } function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; }
@@ -94,12 +94,12 @@ function readHistory(history:ObjectValue,operations:ReadonlyMap<Id,OperationInte
return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries}); return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries});
} }
export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSnapshot & { readonly rootRevision: number; readonly runtime: RuntimeState } { 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].includes(image.formatVersion)) throw fault('unsupported_recovery_version'); if (![1,2,3,4,5,6,7,8,9,10,11,12,13,14].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
integer(image.rootRevision); integer(image.localRevision); integer(image.rootRevision); integer(image.localRevision);
if (image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image'); 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. // 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); 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);
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(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'); 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); 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'); if(image.formatVersion<6&&[...operations.values()].some(intent=>intent.historyEffect))throw fault('unsupported_recovery_version');
@@ -188,7 +188,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
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'); 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');
const assetBindings=table<SharedAssetBinding>(runtime.assetBindings??[],(value,id)=>{ const assetBindings=table<SharedAssetBinding>(runtime.assetBindings??[],(value,id)=>{
const binding=value as SharedAssetBinding;validateSharedAsset(binding);const row=object(value); 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'); 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'); 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');} 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); return freeze(binding);
@@ -249,6 +249,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
return freeze({...value as RunRecord,outputEvents:array(raw.outputEvents ?? []) as never}); return freeze({...value as RunRecord,outputEvents:array(raw.outputEvents ?? []) as never});
}); });
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 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');
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');} 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');}
const orphanFiles=table<OrphanFile>(runtime.orphanFiles??[],(file,id)=>{validateOrphanFile(file,id);return freeze(file);}); 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]); 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]);
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Json } from '../canvas-core/types.ts';
/** Portable durable-store contract. Browser database types belong to the adapter. */ /** Portable durable-store contract. Browser database types belong to the adapter. */
export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string }; export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string };
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json }; export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
export type DurableBatch = { readonly image: RecoveryImage; readonly deleteFiles?:readonly string[]; readonly depot?:import('./asset-depot.ts').DepotCut; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] }; export type DurableBatch = { readonly image: RecoveryImage; readonly deleteFiles?:readonly string[]; readonly depot?:import('./asset-depot.ts').DepotCut; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] };
export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise<void> }; export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise<void> };
export type StoredFileInfo={readonly id:string;readonly byteLength:number;readonly mimeType:string}; export type StoredFileInfo={readonly id:string;readonly byteLength:number;readonly mimeType:string};
+1 -1
View File
@@ -31,7 +31,7 @@ function purgedFileIds(image:RecoveryImage):Set<string>{
return result; return result;
} }
function validateImage(image: RecoveryImage) { function validateImage(image: RecoveryImage) {
if (![1,2,3,4,5,6,7,8,9,10,11,12,13].includes(image.formatVersion)) throw fault('unsupported_recovery_version'); if (![1,2,3,4,5,6,7,8,9,10,11,12,13,14].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
if (!Number.isSafeInteger(image.rootRevision) || image.rootRevision < 0 || !Number.isSafeInteger(image.localRevision) || image.localRevision < 0 || image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image'); if (!Number.isSafeInteger(image.rootRevision) || image.rootRevision < 0 || !Number.isSafeInteger(image.localRevision) || image.localRevision < 0 || image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image');
} }
const requestValue = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const requestValue = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
+1 -1
View File
@@ -757,7 +757,7 @@ export async function run() {
await storage.commit(leader, { image: image(1) }); await storage.commit(leader, { image: image(1) });
await rejects(() => storage.commit(leader, { image: { ...image(1), payload: null } }), 'storage_revision_conflict'); await rejects(() => storage.commit(leader, { image: { ...image(1), payload: null } }), 'storage_revision_conflict');
await rejects(() => storage.commit(leader, { image: image(3) }), 'storage_revision_gap'); await rejects(() => storage.commit(leader, { image: image(3) }), 'storage_revision_gap');
await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 14 } }), 'unsupported_recovery_version'); await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 15 } }), 'unsupported_recovery_version');
results.push('same-cut retry is idempotent; conflicting, skipped and future-format cuts reject'); results.push('same-cut retry is idempotent; conflicting, skipped and future-format cuts reject');
// The first put succeeds before a later duplicate resource aborts the whole transaction. // The first put succeeds before a later duplicate resource aborts the whole transaction.
+3
View File
@@ -290,3 +290,6 @@ test('source read handles verify bound bytes and never publish a result after pr
f.storage.readFile=async(owner,key)=>{const value=await read(owner,key);if(key===fileId){entered.resolve();await gate.promise;}return value;};const opening=engine.imports.openSource(id);await entered.promise;await engine.close();gate.resolve();await assert.rejects(opening,/engine_closing/);assert.equal(engine.getResourcePins().pins.has(fileId),false); f.storage.readFile=async(owner,key)=>{const value=await read(owner,key);if(key===fileId){entered.resolve();await gate.promise;}return value;};const opening=engine.imports.openSource(id);await entered.promise;await engine.close();gate.resolve();await assert.rejects(opening,/engine_closing/);assert.equal(engine.getResourcePins().pins.has(fileId),false);
}finally{gate.resolve();await engine.close();} }finally{gate.resolve();await engine.close();}
}); });
test('JSON array import persists original bytes and byte cursor, resumes after reopen, and preserves edits without duplicating IDs',async()=>{const f=fixture();let engine=await f.open();try{const bytes=new Blob([JSON.stringify([node('json-one'),node('json-two'),node('json-three')])]),id=engine.imports.startJsonArray(bytes,'canvas');await until(engine,s=>s.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),first=job.stableIds['element:json-one'];assert.equal(job.parserVersion,3);assert.equal(job.source.kind,'native-json-array');assert.ok(job.cursor>0);assert.equal(f.image().formatVersion,14);decodeRecovery(f.image(),registry);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:13},registry),/unsupported_recovery_version/);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:15},registry),/unsupported_recovery_version/);engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'preserved edit'}});await engine.settle();await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'preserved edit');assert.equal(f.server().elements.size,3);assert.equal(f.errors.length,0);}finally{await engine.close();}});
test('JSON array malformed tail keeps its visible prefix and an explicit failure while backend waits',async()=>{const f=fixture(),engine=await f.open(),gate=deferred();f.gate=gate;try{const id=engine.imports.startJsonArray(new Blob(['['+JSON.stringify(node('json-first'))+','+JSON.stringify(node('json-bad'))+',]']),'canvas');await until(engine,s=>s.runtime.imports.get(id).status==='partial');const job=engine.imports.getJobs().get(id);assert.equal(job.committedSourceKeys.length,1);assert.equal(job.failures.at(-1).code,'invalid_json_array');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,0);gate.resolve();await engine.settle();assert.equal(f.server().elements.size,1);decodeRecovery(f.image(),registry);}finally{gate.resolve();await engine.close();}});
+1 -1
View File
@@ -24,5 +24,5 @@ test('cached subtree height cannot bypass the depth bound and omitted object fie
const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n})); const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n}));
}); });
test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{ test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(7,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(8,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(9,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(10,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(11,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(12,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(13,1,1,{})),true);assert.throws(()=>sealRecoveryImage(14,1,1,{}),/corrupt_recovery_image/); const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(7,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(8,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(9,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(10,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(11,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(12,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(13,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(14,1,1,{})),true);assert.throws(()=>sealRecoveryImage(15,1,1,{}),/corrupt_recovery_image/);
}); });
+7 -1
View File
@@ -389,7 +389,7 @@ try {
for(let i=0;i<150&&documents.get(scopedRoot).elements.get('scope-node').data.text!==scopeText;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get('scope-node').data.text,scopeText,'certified scope edit waited for another owner tail');assert.equal(versions.get(scopedRoot),2);assert.equal(versions.get(scopedChild),1);assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`),false); for(let i=0;i<150&&documents.get(scopedRoot).elements.get('scope-node').data.text!==scopeText;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get('scope-node').data.text,scopeText,'certified scope edit waited for another owner tail');assert.equal(versions.get(scopedRoot),2);assert.equal(versions.get(scopedChild),1);assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`),false);
assert.equal(await evaluate(`document.querySelector('button[aria-label="添加文本卡片"]').disabled`),false);await click('添加文本卡片'); assert.equal(await evaluate(`document.querySelector('button[aria-label="添加文本卡片"]').disabled`),false);await click('添加文本卡片');
for(let i=0;i<150&&documents.get(scopedRoot).elements.size!==5;i++)await pause(20);const born=[...documents.get(scopedRoot).elements.values()].find(element=>!['scope-node','scope-frame','scope-child','scope-tail'].includes(element.id));assert.ok(born,'certified scope did not create a new card before the tail');await click('撤销');for(let i=0;i<150&&documents.get(scopedRoot).elements.has(born.id);i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.has(born.id),false);await click('重做');for(let i=0;i<150&&documents.get(scopedRoot).elements.get(born.id)?.incarnation!==2;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get(born.id)?.incarnation,2);assert.equal(versions.get(scopedRoot),5); for(let i=0;i<150&&documents.get(scopedRoot).elements.size!==5;i++)await pause(20);const born=[...documents.get(scopedRoot).elements.values()].find(element=>!['scope-node','scope-frame','scope-child','scope-tail'].includes(element.id));assert.ok(born,'certified scope did not create a new card before the tail');await click('撤销');for(let i=0;i<150&&documents.get(scopedRoot).elements.has(born.id);i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.has(born.id),false);await click('重做');for(let i=0;i<150&&documents.get(scopedRoot).elements.get(born.id)?.incarnation!==2;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get(born.id)?.incarnation,2);assert.equal(versions.get(scopedRoot),5);
assert.equal(await evaluate(`document.querySelector('button[aria-label="导入 NDJSON 画布数据"]').disabled`),false); assert.equal(await evaluate(`document.querySelector('button[aria-label="导入画布记录"]').disabled`),false);
await evaluate(`(()=>{const input=document.querySelector('input[data-canvas-import]'),transfer=new DataTransfer();transfer.items.add(new File([JSON.stringify({kind:'element',key:'partial-import',record:{id:'partial-import',incarnation:1,ownerCanvasId:'$target',componentId:'text',dataVersion:1,structure:'node',data:{title:'Imported before tail',text:'streaming'},geometry:{x:0,y:0,width:100,height:80,rotation:0}}})+'\\n'],'partial.ndjson',{type:'application/x-ndjson'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`); await evaluate(`(()=>{const input=document.querySelector('input[data-canvas-import]'),transfer=new DataTransfer();transfer.items.add(new File([JSON.stringify({kind:'element',key:'partial-import',record:{id:'partial-import',incarnation:1,ownerCanvasId:'$target',componentId:'text',dataVersion:1,structure:'node',data:{title:'Imported before tail',text:'streaming'},geometry:{x:0,y:0,width:100,height:80,rotation:0}}})+'\\n'],'partial.ndjson',{type:'application/x-ndjson'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`);
for(let i=0;i<200&&![...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail');i++)await pause(20);assert.ok([...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail'),'partial page import did not reach server before tail'); for(let i=0;i<200&&![...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail');i++)await pause(20);assert.ok([...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail'),'partial page import did not reach server before tail');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('button[aria-label="深复制选中内容"]').disabled`,'closed source copy stayed disabled during loading');const copyCount=documents.get(scopedRoot).elements.size;await click('深复制选中内容'); await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('button[aria-label="深复制选中内容"]').disabled`,'closed source copy stayed disabled during loading');const copyCount=documents.get(scopedRoot).elements.size;await click('深复制选中内容');
@@ -410,6 +410,12 @@ try {
for(let i=0;i<150&&documents.get(backgroundRoot).elements.get('background-node').data.text!==backgroundText;i++)await pause(20);assert.equal(documents.get(backgroundRoot).elements.get('background-node').data.text,backgroundText,'leaving the view stranded a local edit behind the loading hold');assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/'+backgroundRoot+'/canvas/model')&&call.headers.accept!=='application/vnd.foundry.canvas-model+ndjson;version=1')); for(let i=0;i<150&&documents.get(backgroundRoot).elements.get('background-node').data.text!==backgroundText;i++)await pause(20);assert.equal(documents.get(backgroundRoot).elements.get('background-node').data.text,backgroundText,'leaving the view stranded a local edit behind the loading hold');assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/'+backgroundRoot+'/canvas/model')&&call.headers.accept!=='application/vnd.foundry.canvas-model+ndjson;version=1'));
releaseAbandoned();await pause(100);assert.ok((await evaluate('location.pathname')).endsWith(other));console.log('PASS leaving a partial editor hands completion to the project owner, persists and synchronizes its edit through a fresh read without reopening the view'); releaseAbandoned();await pause(100);assert.ok((await evaluate('location.pathname')).endsWith(other));console.log('PASS leaving a partial editor hands completion to the project owner, persists and synchronizes its edit through a fresh read without reopening the view');
await evaluate(`(()=>{const transfer=new DataTransfer(),record={kind:'element',key:'json-ui',record:{id:'json-ui',incarnation:1,ownerCanvasId:'$target',componentId:'text',dataVersion:1,structure:'node',data:{title:'JSON array UI',text:'JSON array UI text'},geometry:{x:80,y:100,width:240,height:160,rotation:0}}};transfer.items.add(new File([JSON.stringify([record])],'records.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelector('[aria-label="画布导入"]').textContent.includes('已加入画布')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].some(input=>input.value==='JSON array UI text')`,'JSON array did not render through the file picker');
for(let i=0;i<200&&![...documents.get(other).elements.values()].some(e=>e.data.title==='JSON array UI');i++)await pause(20);assert.ok([...documents.get(other).elements.values()].some(e=>e.data.title==='JSON array UI'));
await call('Page.reload');await until(`[...document.querySelectorAll('[data-headless-canvas] textarea')].some(input=>input.value==='JSON array UI text')&&document.querySelector('[aria-label="画布导入"]').textContent.includes('records.json')`,'JSON array task/content did not recover after reload');
console.log('PASS JSON array file picker imports and synchronizes native records then recovers its original task and content after reload');
await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`); await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`);
await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view'); await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view');
assert.ok(calls.some(call=>call.path.endsWith('/canvas/model')&&call.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1')); assert.ok(calls.some(call=>call.path.endsWith('/canvas/model')&&call.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'));