Stream exported document records with versioned recovery
This commit is contained in:
@@ -19,6 +19,7 @@ import type { DigestPort } from './digest-pool.ts';
|
||||
import { importSourceDigest } from './import-integrity.ts';
|
||||
import { encodeDuplicateSource, prepareDuplicateImport, assertDuplicateExternal } from './import-duplicate.ts';
|
||||
import {openDocumentImportSource} from './import-document.ts';
|
||||
import {openStreamingDocumentImportSource} from './import-document-stream.ts';
|
||||
import {createJsonArrayImportSource} from './import-json-array.ts';
|
||||
import { createImportJob, createImportRunner, createNdjsonImportSource, type ImportJob } from './imports.ts';
|
||||
import { assertNativeImportDependencies, assertNativeImportTarget, prepareNativeImport } from './import-native.ts';
|
||||
@@ -674,7 +675,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
let expectedFingerprint=getImport(id).sourceFingerprint;
|
||||
const preparationController=new AbortController();if(['native-document','duplicate-snapshot','duplicate-plan'].includes(getImport(id).source?.kind??''))preparationControllers.set(id,preparationController);
|
||||
const task=(async()=>{
|
||||
let job=getImport(id),source=job.source;if(!source||!['native-document','native-json-array','native-ndjson','duplicate-ndjson','duplicate-snapshot','duplicate-plan'].includes(source.kind)||job.parserVersion!==(source.kind==='native-document'?4:source.kind==='native-json-array'?3:source.kind==='duplicate-plan'?2:1))throw fault('import_interpreter_unavailable');
|
||||
let job=getImport(id),source=job.source;if(!source||!['native-document','native-json-array','native-ndjson','duplicate-ndjson','duplicate-snapshot','duplicate-plan'].includes(source.kind)||(source.kind==='native-document'?![4,5].includes(job.parserVersion):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);
|
||||
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;
|
||||
@@ -690,7 +691,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-plan'&&source.snapshotVersion===2){indexed??=await openIndexedCopySnapshot(bytes,options.scope.projectId,options.registry,preparationController.signal);copyDocument=indexed.document;}
|
||||
if(source.kind==='duplicate-plan'&©Document)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):source.kind==='native-document'?await openDocumentImportSource({bytes,fingerprint:job.sourceFingerprint,signal:preparationController.signal}):source.kind==='native-json-array'?createJsonArrayImportSource({bytes,fingerprint:job.sourceFingerprint}):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-document'?await (job.parserVersion===5?openStreamingDocumentImportSource:openDocumentImportSource)({bytes,fingerprint:job.sourceFingerprint,signal:preparationController.signal}):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(importHolds.size){deferredImportLoads.add(id);return;}
|
||||
const authorizedImportAssets=new Map<Id,{ref:SharedAsset;bytes:Blob}>(),assetPreparationSignals=new WeakSet<AbortSignal>();
|
||||
@@ -723,7 +724,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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==='document'?4:format==='json-array'?3:1,source:{...(loadingAdmission?{loadingAdmission}:{}),kind:format==='document'?'native-document':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});
|
||||
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:format==='document'?5:format==='json-array'?3:1,source:{...(loadingAdmission?{loadingAdmission}:{}),kind:format==='document'?'native-document':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;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {ImportLine,ImportSource} from './imports.ts';
|
||||
import {createImportBudget,type ImportScheduling} from './import-budget.ts';
|
||||
import {openDocumentImportSource} from './import-document.ts';
|
||||
import {fault,validJson} from '../canvas-core/values.ts';
|
||||
|
||||
type Range={start:number;end:number};
|
||||
type ScanCache={start:number;bytes:Uint8Array};
|
||||
type Canvas={id:string;parent:null|{canvasId:string;entryElementId:string}};
|
||||
type Options=ImportScheduling&{bytes:Blob;fingerprint:string;signal:AbortSignal;maxRecordBytes?:number};
|
||||
const allowed=new Set(['documentFormatVersion','projectId','canvases','elements','tombstones']);
|
||||
const whitespace=(byte:number)=>byte===9||byte===10||byte===13||byte===32;
|
||||
|
||||
/** Each scan owns its cursor. An aborted scan cannot advance the resumable source. */
|
||||
function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={start:-1,bytes:new Uint8Array()}){
|
||||
const {bytes}=options,budget=createImportBudget(signal,options);
|
||||
let cursor=start;
|
||||
const check=()=>{if(signal.aborted)throw fault('import_aborted');};
|
||||
async function peek(){
|
||||
check();if(cursor>=bytes.size)return -1;
|
||||
if(cursor<cache.start||cursor>=cache.start+cache.bytes.length){const loaded=new Uint8Array(await bytes.slice(cursor,cursor+16384).arrayBuffer());cache.start=cursor;cache.bytes=loaded;check();}
|
||||
return cache.bytes[cursor-cache.start];
|
||||
}
|
||||
function step(){cursor++;return budget.step();}
|
||||
async function spaces(){while(whitespace(await peek()))await step();}
|
||||
async function take(byte:number){await spaces();if(await peek()!==byte)throw fault('invalid_document_import');await step();}
|
||||
async function value():Promise<Range>{
|
||||
await spaces();const start=cursor,stack:number[]=[];let quoted=false,escaped=false;
|
||||
while(true){
|
||||
const byte=await peek();
|
||||
if(byte<0){if(quoted||stack.length)throw fault('invalid_document_import');break;}
|
||||
if(!quoted&&!stack.length&&(byte===44||byte===93||byte===125||whitespace(byte)))break;
|
||||
if(quoted){if(escaped)escaped=false;else if(byte===92)escaped=true;else if(byte===34)quoted=false;}
|
||||
else if(byte===34)quoted=true;
|
||||
else if(byte===91||byte===123){stack.push(byte);if(stack.length>256)throw fault('json_nesting_limit');}
|
||||
else if(byte===93||byte===125){if(stack.pop()!==(byte===93?91:123))throw fault('invalid_document_import');}
|
||||
const ended=!quoted&&!stack.length&&(byte===34||byte===93||byte===125);
|
||||
await step();if(ended)break;
|
||||
}
|
||||
if(cursor===start)throw fault('invalid_document_import');return{start,end:cursor};
|
||||
}
|
||||
async function decode(range:Range,limit=options.maxRecordBytes??256*1024):Promise<any>{
|
||||
check();if(range.end-range.start>limit)throw fault('record_too_large');
|
||||
let text:string;
|
||||
try{text=new TextDecoder('utf-8',{fatal:true}).decode(await bytes.slice(range.start,range.end).arrayBuffer());}catch(error){check();throw fault('invalid_utf8');}
|
||||
check();try{const value=JSON.parse(text);if(!validJson(value))throw Error();return value;}catch{throw fault('invalid_json');}
|
||||
}
|
||||
return{peek,step,spaces,take,value,decode,position:()=>cursor};
|
||||
}
|
||||
|
||||
function tombstones(value:unknown){
|
||||
if(!value||typeof value!=='object'||Array.isArray(value)||Object.values(value).some(n=>!Number.isSafeInteger(n)||(n as number)<1))throw fault('invalid_document_import');
|
||||
}
|
||||
|
||||
/** Parser 5: canonical headers open before scanning the elements tail.
|
||||
* Out-of-order headers require the indexed interpreter to establish ownership first.
|
||||
* Ordinals remain stable; ranges are retained only as records are requested. */
|
||||
export async function openStreamingDocumentImportSource(input:Options):Promise<ImportSource>{
|
||||
const options:Options=Object.freeze({...input});
|
||||
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');
|
||||
// Only range extension scans bytes; ready-record decoding uses independent slices.
|
||||
// Keep one source-owned chunk across attempts instead of re-reading 16 KiB per row.
|
||||
const cache:ScanCache={start:-1,bytes:new Uint8Array()},scanAt=(signal:AbortSignal,start=0)=>scanner(options,signal,start,cache);
|
||||
const header=scanAt(signal),fields=new Map<string,any>();
|
||||
await header.take(123);
|
||||
while(true){
|
||||
const key=await header.decode(await header.value(),1024);
|
||||
if(typeof key!=='string'||!allowed.has(key)||fields.has(key))throw fault('invalid_document_import');
|
||||
await header.take(58);
|
||||
if(key==='elements'){
|
||||
if(!fields.has('documentFormatVersion')||!fields.has('projectId')||!fields.has('canvases')){
|
||||
const indexed=await openDocumentImportSource(options);
|
||||
return Object.freeze({...indexed,parserVersion:5});
|
||||
}
|
||||
await header.take(91);fields.set(key,true);break;
|
||||
}
|
||||
fields.set(key,await header.decode(await header.value(),4*1024*1024));
|
||||
await header.take(44);
|
||||
}
|
||||
if(fields.get('documentFormatVersion')!==1||typeof fields.get('projectId')!=='string'||!fields.get('projectId')||!Array.isArray(fields.get('canvases')))throw fault('invalid_document_import');
|
||||
if(fields.has('tombstones'))tombstones(fields.get('tombstones'));
|
||||
const canvases=fields.get('canvases') as Canvas[],byId=new Map<string,Canvas>();
|
||||
const budget=createImportBudget(signal,options);
|
||||
for(const canvas of canvases){
|
||||
if(!canvas||typeof canvas.id!=='string'||!canvas.id||byId.has(canvas.id)||canvas.parent!==null&&(!canvas.parent||typeof canvas.parent.canvasId!=='string'||typeof canvas.parent.entryElementId!=='string'))throw fault('invalid_document_import');
|
||||
byId.set(canvas.id,canvas);await budget.step();
|
||||
}
|
||||
const roots=canvases.filter(canvas=>canvas.parent===null);if(roots.length!==1)throw fault('invalid_document_import');
|
||||
const rootId=roots[0].id,rooted=new Set([rootId]);
|
||||
for(const canvas of canvases){
|
||||
const path=new Set<string>();let current=canvas;
|
||||
while(!rooted.has(current.id)){
|
||||
if(path.has(current.id)||!current.parent)throw fault('invalid_document_import');
|
||||
path.add(current.id);const parent=byId.get(current.parent.canvasId);if(!parent)throw fault('invalid_document_import');current=parent;await budget.step();
|
||||
}
|
||||
for(const id of path){rooted.add(id);await budget.step();}
|
||||
}
|
||||
const children=canvases.filter(canvas=>canvas.id!==rootId),ranges:Range[]=[];
|
||||
let cursor=header.position(),phase:'elements'|'tail'|'done'='elements',first=true,failure:string|undefined,serial=Promise.resolve();
|
||||
async function scanNext(readSignal:AbortSignal){
|
||||
const scan=scanAt(readSignal,cursor);
|
||||
if(phase==='elements'){
|
||||
await scan.spaces();
|
||||
if(await scan.peek()===93){if(!first)throw fault('invalid_document_import');await scan.step();cursor=scan.position();phase='tail';}
|
||||
else{
|
||||
const range=await scan.value();await scan.spaces();const delimiter=await scan.peek();
|
||||
if(delimiter!==44&&delimiter!==93)throw fault('invalid_document_import');
|
||||
await scan.step();cursor=scan.position();phase=delimiter===93?'tail':'elements';first=false;ranges.push(range);return;
|
||||
}
|
||||
}
|
||||
const seen=new Set(fields.keys());await scan.spaces();
|
||||
while(await scan.peek()!==125){
|
||||
await scan.take(44);const key=await scan.decode(await scan.value(),1024);
|
||||
if(typeof key!=='string'||!allowed.has(key)||seen.has(key))throw fault('invalid_document_import');
|
||||
seen.add(key);await scan.take(58);const value=await scan.decode(await scan.value(),4*1024*1024);
|
||||
if(key==='tombstones')tombstones(value);
|
||||
await scan.spaces();
|
||||
}
|
||||
await scan.take(125);await scan.spaces();if(scan.position()!==bytes.size)throw fault('invalid_document_import');
|
||||
cursor=scan.position();phase='done';
|
||||
}
|
||||
async function read(position:number,readSignal:AbortSignal):Promise<ImportLine|null>{
|
||||
if(readSignal.aborted)throw fault('import_aborted');
|
||||
if(!Number.isSafeInteger(position)||position<0)throw fault('invalid_import_cursor');
|
||||
const elementIndex=position-children.length;
|
||||
while(elementIndex>=ranges.length&&phase!=='done'){
|
||||
try{await scanNext(readSignal);}catch(error){if(readSignal.aborted)throw fault('import_aborted');failure=(error as {code?:string}).code??'invalid_document_import';phase='done';}
|
||||
}
|
||||
const count=children.length+ranges.length;
|
||||
if(position>=count){
|
||||
if(failure&&position===count)return{cursor:position,nextCursor:position+1,key:`record:${position}`,error:failure};
|
||||
if(position===count+(failure?1:0))return null;
|
||||
throw fault('invalid_import_cursor');
|
||||
}
|
||||
const base={cursor:position,nextCursor:position+1,key:`record:${position}`};
|
||||
try{
|
||||
const kind=position<children.length?'canvas':'element',record=kind==='canvas'?children[position]:await scanner(options,readSignal).decode(ranges[elementIndex]);
|
||||
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');return{...base,error:(error as {code?:string}).code??'invalid_document_import'};}
|
||||
}
|
||||
return Object.freeze({fingerprint:options.fingerprint,parserVersion:5,read(position:number,readSignal:AbortSignal){
|
||||
// A slow future scan must not block re-reading a pending unit for commit/recovery.
|
||||
if(position<children.length+ranges.length)return read(position,readSignal);
|
||||
const result=serial.then(()=>read(position,readSignal));serial=result.then(()=>{},()=>{});return result;
|
||||
}});
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function validateImportJob(job: ImportJob): void {
|
||||
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(job.source&&(!['native-document','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-document'&&job.parserVersion!==4)throw fault('import_interpreter_unavailable');
|
||||
if(job.source?.kind==='native-document'&&![4,5].includes(job.parserVersion))throw fault('import_interpreter_unavailable');
|
||||
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){
|
||||
|
||||
@@ -52,8 +52,8 @@ export function encodeRecoveryValue(input:unknown):Json {
|
||||
}
|
||||
|
||||
/** 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|14|15,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
|
||||
if(![1,2,3,4,5,6,7,8,9,10,11,12,13,14,15].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image');
|
||||
export function sealRecoveryImage(formatVersion:1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
|
||||
if(![1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16].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;
|
||||
}
|
||||
export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage {
|
||||
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] } },
|
||||
};
|
||||
return sealRecoveryImage([...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document')?15:[...(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);
|
||||
return sealRecoveryImage([...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document'&&job.parserVersion===5)?16:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document')?15:[...(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>;
|
||||
function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; }
|
||||
@@ -94,7 +94,7 @@ function readHistory(history:ObjectValue,operations:ReadonlyMap<Id,OperationInte
|
||||
return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries});
|
||||
}
|
||||
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,14,15].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
if (![1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
integer(image.rootRevision); integer(image.localRevision);
|
||||
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.
|
||||
@@ -250,7 +250,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
});
|
||||
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');
|
||||
const hasDocuments=[...imports.values()].some(job=>job.source?.kind==='native-document');if(hasDocuments&&image.formatVersion!==15||image.formatVersion===15&&!hasDocuments)throw fault('unsupported_recovery_version');
|
||||
const hasDocuments=[...imports.values()].some(job=>job.source?.kind==='native-document'),hasStreamingDocuments=[...imports.values()].some(job=>job.source?.kind==='native-document'&&job.parserVersion===5);if(hasDocuments&&image.formatVersion!==(hasStreamingDocuments?16:15)||image.formatVersion>=15&&!hasDocuments)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);});
|
||||
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]);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Json } from '../canvas-core/types.ts';
|
||||
|
||||
/** 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 RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15; 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 | 15 | 16; 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 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};
|
||||
|
||||
@@ -31,7 +31,7 @@ function purgedFileIds(image:RecoveryImage):Set<string>{
|
||||
return result;
|
||||
}
|
||||
function validateImage(image: RecoveryImage) {
|
||||
if (![1,2,3,4,5,6,7,8,9,10,11,12,13,14,15].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
if (![1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16].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');
|
||||
}
|
||||
const requestValue = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
|
||||
|
||||
Reference in New Issue
Block a user