import {readImportRange,type ImportByteReader} from './import-byte-reader.ts'; import {createImportBudget,type ImportScheduling} from './import-budget.ts'; import type {ImportSource,ImportLine} from './imports.ts'; import {fault,validJson} from '../canvas-core/values.ts'; /** Seekable JSON array reader. Cursors are byte offsets after '[' or a record comma. * Structure errors end the source; previously published records remain explicit. */ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:string;readBytes?:ImportByteReader;maxRecordBytes?:number;chunkBytes?:number}&ImportScheduling):ImportSource{ const bytes=options.bytes,max=options.maxRecordBytes??256*1024,chunk=options.chunkBytes??16384; if(!(bytes instanceof Blob)||!options.fingerprint||!Number.isSafeInteger(max)||max<1||!Number.isSafeInteger(chunk)||chunk<1||chunk>max)throw fault('invalid_import_source'); let cache={offset:-1,value:new Uint8Array()};const whitespace=(b:number)=>[9,10,13,32].includes(b); return Object.freeze({fingerprint:options.fingerprint,parserVersion:3,async read(start:number,signal:AbortSignal):Promise{ if(!Number.isSafeInteger(start)||start<0||start>bytes.size)throw fault('invalid_import_cursor'); if(start>0&&start===bytes.size){if(signal.aborted)throw fault('import_aborted');return null;} if(bytes.size===0)throw fault('invalid_json_array'); let cursor=start;const budget=createImportBudget(signal,options); async function peek(){if(signal.aborted)throw fault('import_aborted');if(cursor>=bytes.size)return -1;if(cursor=cache.offset+cache.value.length){cache={offset:cursor,value:await readImportRange(bytes,options.readBytes,cursor,cursor+chunk,signal)};if(signal.aborted)throw fault('import_aborted');}return cache.value[cursor-cache.offset];} function advance(){cursor++;return budget.step();} async function spaces(){while(whitespace(await peek()))await advance();} const failure=(error:string):ImportLine=>({cursor:start,nextCursor:bytes.size,key:`byte:${start}`,error}); await spaces();if(start===0){if(await peek()!==91)return failure('invalid_json_array');await advance();await spaces();} if(await peek()===93){await advance();await spaces();if(cursor!==bytes.size)return failure('invalid_json_array');if(start!==0){/* Empty tail is valid only after a record, not a trailing comma. */return failure('invalid_json_array');}return null;} if(await peek()===-1)return failure('incomplete_json_array'); const recordStart=cursor,parts:number[]=[],stack:number[]=[];let quoted=false,escaped=false,oversized=false; while(true){const byte=await peek();if(byte===-1)return failure('incomplete_json_array'); if(!quoted&&stack.length===0&&(byte===44||byte===93))break; if(cursor-recordStart256)return failure('json_nesting_limit');}else if(byte===125||byte===93){if(stack.pop()!==(byte===125?123:91))return failure('invalid_json_array');} await advance(); } const delimiter=await peek();await advance(); if(delimiter===93){await spaces();if(cursor!==bytes.size)return failure('invalid_json_array');} else {await spaces();if(await peek()===93||await peek()===-1)return failure('invalid_json_array');} const base={cursor:start,nextCursor:cursor,key:`byte:${start}`}; if(oversized)return{...base,error:'record_too_large'}; let text:string;try{text=new TextDecoder('utf-8',{fatal:true}).decode(new Uint8Array(parts));}catch{return{...base,error:'invalid_utf8'};} try{const value=JSON.parse(text);return validJson(value)?{...base,value}:{...base,error:'invalid_json_value'};}catch{return{...base,error:'invalid_json'};} }}); }