import type { Json } from '../canvas-core/types.ts'; import { fault, freeze, validJson } from '../canvas-core/values.ts'; import {isDataContainer} from '../canvas-core/data-containers.ts'; import {createImportBudget, type ImportScheduling} from './import-budget.ts'; import type { RecoveryImage } from './storage-port.ts'; type Encoded = {value:Json|undefined;height:number;reusable:boolean;bytes:number}; const immutable=new WeakMap(),sealed=new WeakSet(),encodedSizes=new WeakMap(),imageSizes=new WeakMap(); function utf8Length(text:string){let bytes=0;for(let i=0;i=0xd800&&c<=0xdbff&&i+1=0xdc00&&text.charCodeAt(i+1)<=0xdfff){bytes+=4;i++;}else bytes+=3;}return bytes;} const scalarBytes=(value:unknown)=>utf8Length(JSON.stringify(value)); /** JSON value semantics, with reuse only for transitively frozen plain data properties. */ function createEncoder() { const visiting=new Set(); function visit(value:unknown,depth:number,key:string):Encoded { if(value===null||typeof value==='string'||typeof value==='boolean'){if(depth>64)throw fault('invalid_recovery_payload');return{value,height:0,reusable:true,bytes:scalarBytes(value)};} if(typeof value==='number'){if(depth>64)throw fault('invalid_recovery_payload');const normalized=Number.isFinite(value)?(Object.is(value,-0)?0:value):null;return{value:normalized,height:0,reusable:true,bytes:scalarBytes(normalized)};} if(value===undefined||typeof value==='symbol')return{value:undefined,height:0,reusable:true,bytes:0}; const fallback=()=>{ // Preserve the previous JSON codec for toJSON, boxed values and custom prototypes. // These can depend on hidden mutable state and are deliberately never cached. const text=JSON.stringify({[key]:value}),encoded=JSON.parse(text)[key] as Json|undefined; if(encoded!==undefined&&!validJson(encoded,depth))throw fault('invalid_recovery_payload'); return{value:freeze(encoded),height:0,reusable:false,bytes:encoded===undefined?0:scalarBytes(encoded)}; }; if(typeof value==='bigint'||typeof value==='function')return fallback(); if(typeof value!=='object')throw fault('invalid_recovery_payload'); const object=value as object,cached=immutable.get(object); if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;} const prototype=Object.getPrototypeOf(object); // A callable toJSON getter must also be evaluated afresh on every encode. if(typeof (object as {toJSON?:unknown}).toJSON==='function'||(Array.isArray(object)?prototype!==Array.prototype&&prototype!==null:prototype!==Object.prototype&&prototype!==null))return fallback(); if(depth>64)throw fault('invalid_recovery_payload'); if(visiting.has(object))throw fault('invalid_recovery_payload');visiting.add(object); const toJSON=Object.getOwnPropertyDescriptor(object,'toJSON'); let reusable=Object.isFrozen(object)&&(!toJSON||Object.hasOwn(toJSON,'value')),height=0,bytes=2; try { if(Array.isArray(object)){ const result:Json[]=[]; for(let index=0;index64)throw fault('invalid_recovery_payload');result.push(child.value??null);bytes+=(index?1:0)+(child.value===undefined?4:child.bytes);height=Math.max(height,1+child.height);reusable&&=child.reusable; } const encoded={value:Object.freeze(result) as unknown as Json,height,reusable,bytes};encodedSizes.set(result,bytes);if(reusable)immutable.set(object,encoded);return encoded; } const result:Record={};let properties=0; for(const name of Object.keys(object)){ const descriptor=Object.getOwnPropertyDescriptor(object,name);if(!descriptor||!Object.hasOwn(descriptor,'value'))reusable=false; const child=visit((object as Record)[name],depth+1,name);reusable&&=child.reusable; if(child.value!==undefined){bytes+=(properties++?1:0)+scalarBytes(name)+1+child.bytes;Object.defineProperty(result,name,{value:child.value,enumerable:true});height=Math.max(height,1+child.height);} } const encoded={value:Object.freeze(result) as Json,height,reusable,bytes};encodedSizes.set(result,bytes);if(reusable)immutable.set(object,encoded);return encoded; }finally{visiting.delete(object);} } return visit; } export function encodeRecoveryValue(input:unknown):Json { const result=createEncoder()(input,0,'');if(result.value===undefined)throw fault('invalid_recovery_payload');return result.value; } type ImageVersion = RecoveryImage['formatVersion']; function validateHeader(formatVersion:ImageVersion,rootRevision:number,localRevision:number){ if(![1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image'); } function finishImage(formatVersion:ImageVersion,rootRevision:number,localRevision:number,encoded:Json):RecoveryImage { const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encoded});sealed.add(image);imageSizes.set(image,JSON.stringify({...image,payload:null}).length-4+(encoded&&typeof encoded==='object'?encodedSizes.get(encoded)??scalarBytes(encoded):scalarBytes(encoded)));return image; } /** Only codec-owned, validated immutable images qualify for the storage copy fast path. */ export function sealRecoveryImage(formatVersion:ImageVersion,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage { validateHeader(formatVersion,rootRevision,localRevision); return finishImage(formatVersion,rootRevision,localRevision,encodeRecoveryValue(payload)); } type Scalar = null|string|number|boolean|undefined|symbol; type Prepared = Scalar | Encoded | {source:object;depth:number;children:Prepared[];keys?:string[]}; // Read unknown objects synchronously, before the first suspension. Shape-owned // containers can include external values; each such occurrence is captured now. // Only our fresh container shape and previously proven encoded values defer. function prepareEncoding(input:unknown) { const normalize=createEncoder(),visiting=new Set(); function prepare(value:unknown,depth:number,key:string):Prepared { const kind=typeof value; if(value===null||kind==='string'||kind==='number'||kind==='boolean'||kind==='undefined'||kind==='symbol'){ if(depth>64&&value!==undefined&&typeof value!=='symbol')throw fault('invalid_recovery_payload'); // Primitives cannot change while suspended. Defer normalization/UTF-8 work // as well as allocation of their Encoded facts to the budgeted traversal. return value as Scalar; } if(typeof value!=='object')return normalize(value,depth,key); const cached=immutable.get(value); if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;} if(!isDataContainer(value))return normalize(value,depth,key); // Prototype toJSON changes must retain ordinary JSON semantics, including // accessors. Inspect descriptors without evaluating them an extra time. let prototype:object|null=value,hasToJSON=false; while(prototype){if(Object.getOwnPropertyDescriptor(prototype,'toJSON')){hasToJSON=true;break;}prototype=Object.getPrototypeOf(prototype);} if(hasToJSON)return normalize(value,depth,key); if(depth>64||visiting.has(value))throw fault('invalid_recovery_payload'); visiting.add(value); try { const keys=Array.isArray(value)?undefined:Object.keys(value),children:Prepared[]=[]; if(keys)for(const name of keys)children.push(prepare((value as Record)[name],depth+1,name)); else for(let index=0;index<(value as unknown[]).length;index++)children.push(prepare((value as unknown[])[index],depth+1,String(index))); return {source:value,depth,children,keys}; }finally{visiting.delete(value);} } return {plan:prepare(input,0,''),normalize}; } /** Capture external data immediately; cooperatively encode stable owned tables. * Time slicing is soft: synchronous capture of an unknown subtree remains indivisible. * The returned image has exactly the synchronous codec's wire representation. */ export async function sealRecoveryImageAsync(formatVersion:ImageVersion,rootRevision:number,localRevision:number,payload:unknown,options:ImportScheduling={}):Promise { validateHeader(formatVersion,rootRevision,localRevision); const {plan,normalize}=prepareEncoding(payload); const scheduler=(globalThis as typeof globalThis & {scheduler?:{yield?:()=>Promise}}).scheduler; const budget=createImportBudget(new AbortController().signal,{...options,yieldTask:options.yieldTask??(typeof scheduler?.yield==='function'?()=>scheduler.yield!():undefined)}); function* encode(plan:Prepared,depth=0,key=''):Generator,Encoded,void>{ if(plan===null||typeof plan!=='object')return normalize(plan,depth,key); if('height' in plan)return plan; let height=0,bytes=2,reusable=true,properties=0; const array=plan.keys===undefined,result:Json[]|Record=array?[]:{}; for(let index=0;index64)throw fault('invalid_recovery_payload'); (result as Json[]).push(child.value??null);bytes+=(index?1:0)+(child.value===undefined?4:child.bytes);height=Math.max(height,1+child.height); }else if(child.value!==undefined){ const name=plan.keys![index];bytes+=(properties++?1:0)+scalarBytes(name)+1+child.bytes; Object.defineProperty(result,name,{value:child.value,enumerable:true});height=Math.max(height,1+child.height); } const pause=budget.step();if(pause)yield pause; } const encoded={value:Object.freeze(result) as Json,height,reusable,bytes};encodedSizes.set(result,bytes);if(reusable)immutable.set(plan.source,encoded);return encoded; } const iterator=encode(plan);let step=iterator.next();while(!step.done){await step.value;step=iterator.next();} if(step.value.value===undefined)throw fault('invalid_recovery_payload'); return finishImage(formatVersion,rootRevision,localRevision,step.value.value); } export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);} /** Exact UTF-8 JSON size, available only for codec-owned immutable images. */ export function sealedRecoveryByteLength(image:RecoveryImage){return imageSizes.get(image);} /** Internal codec ownership/size fact, never inferred from external Object.freeze. */ export function encodedRecoveryByteLength(value:object){return encodedSizes.get(value);}