docs(runtime): clarify streaming import cursors and byte reads

This commit is contained in:
2026-11-12 13:10:35 +08:00
parent d02cd2b01d
commit 3c8953e842
3 changed files with 39 additions and 13 deletions
+6 -2
View File
@@ -6,13 +6,17 @@ import {fault} from '../canvas-core/values.ts';
/** 会破坏整个源读取可信性的错误集合,供上层区分单记录失败与源级暂停/失败。 */
const failures=new Set(['import_source_changed','invalid_import_byte_range','import_read_capacity','import_source_closed','import_source_read_failed','invalid_import_read_proof','import_read_proof_interpreter_unavailable']);
/** 仅按稳定领域错误码分类,不依赖展示 message 或错误文本包含某个词。 */
/** 只按 code 是否在固定集合分类,不校验错误对象的来源;其它码(含一般 import_aborted)不在集合内。
* 取消是否应优先由读取调用方看 signal;本分类器不是所有源错误/取消的通用判定器。 */
export const isImportByteFailure=(error:unknown)=>failures.has((error as {code?:string}|undefined)?.code??'');
/** Optional immutable-source port. Implementations return exactly the requested bytes. */
/** 可选只读端口声明完整长度,必须返回所请求区间的精确字节,不能截断或换源。 */
export type ImportByteReader={readonly byteLength:number;read(start:number,end:number,signal:AbortSignal):Promise<Uint8Array>};
/** 校验安全整数范围,end 可截至EOF;读取前后查取消,外来 reader 结果额外复制以隔离缓冲复用。 */
/** 校验安全整数范围,允许 end 超出 EOF 并截到 bytes.size;start 超出 EOF 则拒绝。
* reader 长度须等于 Blob 长度,但本函数不逐字节比较 Blob/reader,也不证明同长度源未被替换。
* 外来 Uint8Array 结果复制为自有缓冲,默认 Blob 路径返回此次新读缓冲;不缓存结果或做并发合并。
* 空范围仍会调用 reader/Blob 读取,是否优化零 I/O 由端口决定;bytes 依赖调用方已验证的 Blob 合同。 */
export async function readImportRange(bytes:Blob,reader:ImportByteReader|undefined,start:number,end:number,signal:AbortSignal):Promise<Uint8Array<ArrayBuffer>>{
if(signal.aborted)throw fault('import_aborted');
if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||end<start||start>bytes.size||reader&&reader.byteLength!==bytes.size)throw fault('invalid_import_byte_range');
@@ -22,8 +22,10 @@ const allowed=new Set(['documentFormatVersion','projectId','canvases','elements'
/** JSON语法空白字节,不用于处理字符串内容。 */
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. */
/** 每次扫描从指定位置建立私有cursor,完成边界后外层才更新可恢复源进度。 */
/** Each scan owns a private byte cursor; shared progress records completed boundaries. */
/** 从指定字节位置建立私有 cursor;仅外层 scanNext 将完整结构边界写回源级进度。
* 取消不发布业务结果,但已定位的完整 ranges 可保留用于重试,不承诺整次 read 的缓存/边界都回滚。
* options/缓存沿用内部引用;scanner 负责定位/单值解码,不验证完整文档引用关系。 */
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;
@@ -41,7 +43,9 @@ function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={sta
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();}
/** 引号/转义/栈配对扫描一个完整值range,限制嵌套深度,避免把字符串里的分隔符当边界。 */
/** 用 quoted/escaped/显式 stack 定位一个值范围,最多 256 层;不收集 payload 字节。
* 此步只找词法/括号边界,primitive 或字符串内容仍可能是非法 JSON,稍后 decode 才完整解析。
* 超大值也要扫描完才得到范围,maxRecordBytes 仅约束后续解码分配,不限制扫描总工作量。 */
async function value():Promise<Range>{
await spaces();const start=cursor,stack:number[]=[];let quoted=false,escaped=false;
while(true){
@@ -57,7 +61,9 @@ function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={sta
}
if(cursor===start)throw fault('invalid_document_import');return{start,end:cursor};
}
/** 对单range校验大小、fatal UTF8、JSON语法和值域,返回原值,不修改业务ID。 */
/** 读取单 range,按指定上限控制分配,再 fatal UTF-8/JSON.parse/validJson;不重写业务 ID。
* 默认 payload 256KiB,header 调用可传独立上限;合法 JSON 不等同元素 schema 或引用关系合法。
* JSON.parse 本身为同步工作,扫描预算不抢占这一步;字节端口异常保持源级错误,不转成坏记录。 */
async function decode(range:Range,limit=options.maxRecordBytes??256*1024):Promise<any>{
check();if(range.end-range.start>limit)throw fault('record_too_large');
const data=await readImportRange(bytes,options.readBytes,range.start,range.end,signal);let text:string;
@@ -75,7 +81,11 @@ function tombstones(value:unknown){
/** 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. */
/** 打开standard header后即可返回源;真正elements/tail结构检查跟随read进行,未扫描尾部不冒称合法。 */
/** 浅捕获 options 后打开标准头:源最多 64MiB,单个元数据值上限 4MiB,字段名上限 1KiB。
* 先解析整份 canvases 并验证单根/父链,再返回 canvas-first 的 ordinal 接口;此时元素/尾部可尚未读取。
* 头顺序不满足拥有树前提时委托索引解释器,仍暴露 parserVersion=5,不能据版本推断一定渐进扫描。
* 指纹只是上层传入身份;源字节证明/完整 SHA 与最终元素/入口校验由读取端口和导入准备层负责。
* options.signal 用于打开阶段,返回后的每次 read 使用自己的 readSignal,不自动合并原 signal。 */
export async function openStreamingDocumentImportSource(input:Options):Promise<ImportSource>{
const options:Options=Object.freeze({...input});
const {bytes,signal}=options,max=options.maxRecordBytes??256*1024;
@@ -120,7 +130,9 @@ export async function openStreamingDocumentImportSource(input:Options):Promise<I
for(const id of path){rooted.add(id);await budget.step();}
}
const children=canvases.filter(canvas=>canvas.id!==rootId),ranges:Range[]=[];
// 源级进度只记录完整range边界/phase,serial只串行化延伸;failure最多输出一条终结错误。
// ranges 随请求积累元素字节范围,children 是已捕获子 canvas;ordinal 与原文件 byte offset 不同。
// 重开源需重建这些内存范围,持久 runner 保存的是稳定 ordinal/源身份,不是这里的 Promise 链。
// serial 只串行化范围延伸,failure 最多输出一条终结错误;请求很远的 ordinal 仍会扫描此前全部未定位项。
let cursor=header.position(),phase:'elements'|'tail'|'done'='elements',first=true,failure:string|undefined,serial=Promise.resolve();
/** 延伸一个element range,或验证剩余envelope与EOF;只在结构边界确认后更新cursor/phase。 */
async function scanNext(readSignal:AbortSignal){
@@ -146,7 +158,10 @@ export async function openStreamingDocumentImportSource(input:Options):Promise<I
await scan.take(125);await scan.spaces();if(scan.position()!==bytes.size)throw fault('invalid_document_import');
cursor=scan.position();phase='done';
}
/** ordinal read:子canvas直接返回,元素按需scan定位;已定位范围逐记录解码,后续恢复可重读。 */
/** ordinal read:先 children,后 ranges 中元素;源根只作为 sourceRootId 别名,不作为导入子项输出。
* 子 canvas 可直接返回,元素定位后按独立 range 重读/解码;一个 payload 错误不抹掉其它已定位项。
* 结构错误记终结 failure;取消/已知字节证明错误只拒绝本次,允许同 ordinal 重试。
* EOF 仅在有效末序号返回 null,越过末尾仍拒绝,不能把任意大序号当作已完成。 */
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');
+11 -4
View File
@@ -9,7 +9,10 @@ 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. */
/** 捕获Blob/指纹与读取预算,缓存一块源字节;本解析器不分配业务ID、不发布history或发送请求。 */
/** 捕获 Blob/max/chunk 与返回指纹标量,按字节 offset 每次返回一条记录,缓存一个读取块。
* readBytes 与调度选项仍从原 options 读取,调用方应保持它们稳定;这里不建立源摘要/读取证明。
* 默认单条 256KiB/块 16KiB,未给 maxRecordBytes 设置总上限,也不限制源文件总长。
* 不分配业务 ID、不发布 history 或发送请求;缓存不是持久进度,合法边界由 runner 保存。 */
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');
@@ -18,6 +21,7 @@ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:stri
/** read每次返回一个ImportLine及nextCursor;EOF返回null,源级I/O失败仍抛出而不伪装坏记录。 */
return Object.freeze({fingerprint:options.fingerprint,parserVersion:3,async read(start:number,signal:AbortSignal):Promise<ImportLine|null>{
if(!Number.isSafeInteger(start)||start<0||start>bytes.size)throw fault('invalid_import_cursor');
// EOF offset 直接返回 null,不重扫先前数组结构;任意范围内 seek 也不证明是合法记录起点。
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);
@@ -32,7 +36,8 @@ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:stri
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');
// quoted/escaped隔离字符串分隔符,stack配对{}与[];超大小后仍扫描至合法边界但不再保留字节。
// parts 是至多 max 个字节数值的普通数组(内存成本不等于 max 字节),stack 至多保留 256 层括号。
// quoted/escaped 只定位边界,不完整校验 JSON;超大小后仍扫描但不再收集,坏结构仍可能终结整个源。
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;
@@ -41,13 +46,15 @@ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:stri
else if(byte===34)quoted=true;else if(byte===123||byte===91){stack.push(byte);if(stack.length>256)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();
}
// 消费外层逗号或]并检查尾部/尾逗号;内嵌数组的]已由stack处理,不会截断记录。
// 先确认记录后的外层分隔与尾部,再返回此记录;若尾逗号/垃圾在此处发现,本记录也返回结构失败。
// 数组内嵌 ] 已由 stack 处理;nextCursor 消费分隔和后续空白,最后一项则推进到真实 EOF。
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'};
// 只解码这一记录:fatal UTF8防替换字符掩盖损坏,再JSON.parse/validJson校验值域。
// fatal UTF-8 与 JSON.parse 在边界已确定后处理,失败可随 nextCursor 继续下一项;不做元素业务 schema 校验。
// JSON.parse 同步完成且无中途取消,记录扫描的预算不抢占解码/解析;调用方仍需在采纳结果前校验生命周期。
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'};}
}});