Share detached JSON provenance with copy checkpoints and finish step-four verification
This commit is contained in:
+24
-7
@@ -1001,8 +1001,8 @@ is suspended; the old image still contains its captured state, and the next cut
|
||||
is saved after it. Request dispatch retains the durable-prefix barrier. Encoding
|
||||
or scheduling failures retain the pending Root for retry and produce no partial
|
||||
storage commit. Initial DTO membership copying, synchronous preparation, unknown
|
||||
subtrees and individual scalar operations remain synchronous. First-time element
|
||||
normalization can defer only when its detached JSON provenance is established. This is partial time slicing, not a strict whole-Root latency
|
||||
subtrees and individual scalar operations remain synchronous. First-time JSON
|
||||
normalization can defer only when its detached provenance is established. This is partial time slicing, not a strict whole-Root latency
|
||||
bound. Grouped history entries now keep shape ownership for newly built changes and
|
||||
operation-ID arrays, including undo/redo lifecycle remaps. Import runners keep it
|
||||
for their own job envelopes, completed-key arrays, binding dictionaries and
|
||||
@@ -1137,9 +1137,10 @@ when the same source occurs twice in one image, the second occurrence can reuse
|
||||
the first completed encoding. Preparation still captures unsafe occurrences
|
||||
independently; getters and mutable descendants never qualify for this reuse.
|
||||
|
||||
First-time element recovery encoding now uses the core's detached-JSON ownership
|
||||
proof instead of synchronously normalizing every field before the first yield.
|
||||
Only `detachElementData` results qualify: JSON-cloned, recursively frozen records
|
||||
First-time element and planning-checkpoint recovery encoding uses the core's
|
||||
detached-JSON ownership proof instead of synchronously normalizing every field
|
||||
before the first yield.
|
||||
Only `detachJsonData` results qualify: JSON-cloned, recursively frozen records
|
||||
with immutable JSON descendants. A shallow freeze, a document factory envelope,
|
||||
or an arbitrary host object does not qualify. Preparation requires the standard
|
||||
Object/Array prototype chain without inherited `toJSON`; otherwise it captures
|
||||
@@ -1151,6 +1152,22 @@ earlier record's meaning. Cold and cached subtrees both retain the depth limit.
|
||||
This moves the first full field/row walk into the shared budget; container-member
|
||||
capture and individual native/scalar operations still have synchronous work.
|
||||
|
||||
Step 4's current acceptance evidence, commands and explicit remaining checkpoint
|
||||
encoding work are recorded in [STEP4_ACCEPTANCE.md](STEP4_ACCEPTANCE.md). Passing
|
||||
Step 4's acceptance evidence, commands and checkpoint encoding boundaries
|
||||
are recorded in [STEP4_ACCEPTANCE.md](STEP4_ACCEPTANCE.md). Passing
|
||||
functional fixtures is not a claim that all performance limits are resolved.
|
||||
|
||||
The shared core `json-data.ts` factory gives element data and duplicate planner
|
||||
checkpoints the same unforgeable clone provenance; element validity and schema
|
||||
proofs remain separate. It replaces their existing `freeze(clone(...))` operation
|
||||
without a second clone or a wire-format change. Engine-created job/source/
|
||||
preparation envelopes keep shape ownership through admission, checkpoint refresh,
|
||||
ready mapping, source verification and pause/resume/cancel. Fresh allocation
|
||||
arrays, ready ID tables and root lists use the same shape constructors. Unknown
|
||||
children still require synchronous capture; copying an envelope never grants a
|
||||
deep-data fact to them. Legacy/restored data retains defensive capture until a
|
||||
new planner snapshot supplies fresh provenance.
|
||||
|
||||
A final ready plan has already crossed its required preparation durability
|
||||
barrier. Its encoded facts may therefore already exist, and the ready source
|
||||
can reuse them without an artificial yield. Tests distinguish first-time
|
||||
preparation slicing from ready-source byte/mapping preservation and reopening.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Step 4:渐进功能与呈现验收
|
||||
|
||||
状态:已完成最终功能入口复核,调度余项未完成,整体进度 3/7。此文件随实现提交;完整目标仍包含后续真实服务、迁移和集成验收。
|
||||
状态:第4步原定功能、合作调度及规模输入验收已通过;整体进度 4/7。长任务与非抢占边界见下文,不宣称生产实时性能保证。此文件随实现提交;完整目标仍包含后续真实服务、迁移和集成验收。
|
||||
|
||||
## 范围与直接证据
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
|
||||
1. `writePrefix` 是有序单 writer,逐个保存 Root,异步编码不会跳过中间 Root。
|
||||
2. 外部对象、getter、custom `toJSON` 和浅冻结值在第一次让出前按原 codec 捕获;私有容器只证明成员固定,内部未知值仍需捕获。
|
||||
3. core 的 JSON 脱离、深冻结元素可以延后逐字段编码。需要标准 Object/Array 原型且没有继承的 `toJSON`;每次未知值捕获后重新检查环境。外部回调不能改变已经捕获的前项,也不能让后项错误沿用旧环境。
|
||||
3. core 的 `detachJsonData` 为元素和复制规划 checkpoint 提供 JSON 脱离、深冻结事实,可以延后逐字段编码。需要标准 Object/Array 原型且没有继承的 `toJSON`;每次未知值捕获后重新检查环境。外部回调不能改变已经捕获的前项,也不能让后项错误沿用旧环境。
|
||||
4. 标量归一化、UTF-8 计数、history/import/pending 容器与首次元素遍历共用软预算。已完成的深不可变编码可复用,但不能省略 unsafe occurrence 的求值。
|
||||
5. 编码、调度或存储失败保留待存 Root;持久化没有确认,不解除网络发送屏障。旧 Root 与编码期间的新编辑保持各自内容。
|
||||
6. journal 比较先判断子值是否相同,再为变化项分配路径,避免为共享的整个前缀创建临时数组。patch 顺序与字节语义不变。
|
||||
|
||||
`recovery-encoding.test.mjs` 验证首次归一化确实在 yield 间发生、继承行为、外部回调、Proxy trap 次数、UTF-8、冷/热深度边界;`engine.test.mjs` 以 17000 元素验证暂停编码期间编辑、逐 Root 落盘和发送屏障。`journal-delta.test.mjs` 验证精确重放及属性顺序;浏览器检查验证真实 IndexedDB 重开。
|
||||
`json-data.test.mjs` 验证事实不能从外部冻结对象或结构克隆伪造;`duplicate-checkpoint.test.mjs` 验证首次 checkpoint 的真实字段编码在 yield 间发生,后续 planner.step 不改变已捕获值;`imports-engine.test.mjs` 验证真实 preparation/source envelope、字节和重开映射。ready plan 已先持久化时允许缓存复用,不要求空 yield。`recovery-encoding.test.mjs` 验证首次归一化、继承行为、外部回调、Proxy trap 次数、UTF-8、冷/热深度边界;`engine.test.mjs` 以 17000 元素验证暂停编码期间编辑、逐 Root 落盘和发送屏障。`journal-delta.test.mjs` 验证精确重放及属性顺序;浏览器检查验证真实 IndexedDB 重开。
|
||||
|
||||
## 可复现命令
|
||||
|
||||
@@ -34,7 +34,7 @@ node tests/headless/renderer.integration.mjs --turbopack
|
||||
node tests/headless/renderer.integration.mjs --hooks-only
|
||||
node tests/headless/browser-storage.mjs
|
||||
node tests/headless/package.integration.mjs
|
||||
HEADLESS_PERFORMANCE_COUNT=10000 HEADLESS_PERFORMANCE_EDIT_DURING_COPY=1 HEADLESS_PERFORMANCE_REPORT=/private/tmp/headless-step4-production-10000.json node tests/headless/renderer.integration.mjs --performance --production --turbopack
|
||||
HEADLESS_PERFORMANCE_COUNT=10000 HEADLESS_PERFORMANCE_EDIT_DURING_COPY=1 HEADLESS_PERFORMANCE_REPORT=/private/tmp/headless-checkpoint-production-10000.json node tests/headless/renderer.integration.mjs --performance --production --turbopack
|
||||
```
|
||||
|
||||
性能入口须在其它构建/测试结束后单独执行;功能检查可并行。这里验证真实 Chrome、IndexedDB、Web Locks 和实际前端,但 HTTP 为隔离 fixture,不能据此声称已完成真实 Foundry 集成。
|
||||
@@ -42,22 +42,22 @@ HEADLESS_PERFORMANCE_COUNT=10000 HEADLESS_PERFORMANCE_EDIT_DURING_COPY=1 HEADLES
|
||||
|
||||
## 本次运行结果(2026-11-11)
|
||||
|
||||
- Node:780 项通过;全量前端 TypeScript 检查通过。
|
||||
- Node:783 项通过;全量前端 TypeScript 检查通过。
|
||||
- 实际页面:webpack 51 项、Turbopack 51 项通过;公共 hooks 10 项通过。
|
||||
- 浏览器存储:57 组通过;SDK:263 个打包文件,类型、独立消费和编译后的浏览器/Worker 检查通过。
|
||||
- webpack 曾在全部断言结束后被 Chrome 日志管道阻止退出。`renderer.integration.mjs` 现于 Chrome 退出后释放 stdio;重新运行的 51 项检查自动正常退出。
|
||||
- 浏览器存储:57 组通过;SDK:265 个打包文件,类型、独立消费和编译后的浏览器/Worker 检查通过。
|
||||
- Chrome 退出后的 stdio 清理保留。此次五组并行检查中,webpack 有一次原生双击未打开恢复编辑器;同源码单独复跑51项通过。随后测试增加同步完成、两次rAF和实际可命中点击点的前置条件,保留原生事件/编辑焦点断言;两种构建器各51项均通过。未据此推定产品逻辑有错或仅是CPU争用。
|
||||
- 生产规模环境:本机 arm64、macOS 15.6、Node 23.10.0、Chrome 157.0.8103.39,Next production Turbopack;无并行构建/测试、无计时代码插桩。
|
||||
- 10000-child 展开分区深复制:首副本呈现 799.5ms、本地完成帧 36264.8ms、加入并同步 36469.9ms;帧间隔 P95 33.4ms/max316.6ms;25 次 longtask,max341ms/total1869ms。
|
||||
- early 五次 input→两次 rAF:50.5/31.3/22.6/20.9/41.3ms;middle(5255 项)21.5ms、tail(9131 项)39.4ms。全部发生于复制活跃期间,DOM/focus 保留;中尾 CDP 往返约18.1/27.4ms。它们不含精确的派发前排队延迟。
|
||||
- 服务端最终 20005 元素/3 canvas;原/新父层边、原文后续输入、副本旧快照、pan 后副本挂载通过。样本呈现时挂载35节点,全过程最大136,pan 后28;屏幕外 IME/Portal 各保留1,独立应用面板不额外保活,runStartsDuringScenario=0。
|
||||
- 10000-child 展开分区深复制:首副本呈现 784.1ms、本地完成帧 27949.7ms、加入并同步 28169.9ms;帧间隔 P95 33.4ms/max383.4ms;21 次 longtask,max386ms/total1663ms。
|
||||
- early 五次 input→两次 rAF:69.8/57.9/34.3/29.5/15.6ms;middle(5279 项)45.3ms、tail(9093 项)73.5ms。全部发生于复制活跃期间,DOM/focus 保留;中尾 CDP 往返约40.9/26.0ms。它们不含精确的派发前排队延迟。
|
||||
- 服务端最终 20005 元素/3 canvas;原/新父层边、原文后续输入、副本旧快照、pan 后副本挂载通过。样本呈现时挂载35节点,全过程最大137,pan 后28;屏幕外 IME/Portal 各保留1,独立应用面板不额外保活,runStartsDuringScenario=0。
|
||||
|
||||
原始报告 `/private/tmp/headless-step4-production-10000.json` 和同名 PNG 为本次本机观察;命令与断言已保存在仓库,可独立复现。单次运行不同 adaptive 批次和 GC 负载,不能比较前轮26.2秒而宣称确定改善或回退,更不能说所有停顿已解决。
|
||||
原始报告 `/private/tmp/headless-checkpoint-production-10000.json` 和同名 PNG 为本次本机观察;命令与断言已保存在仓库,可独立复现。单次运行有不同 adaptive 批次、native/GC 与系统调度负载,不能以此与前轮36.26秒比较而宣称确定提速,更不能说所有停顿已解决,或未经归因把386ms全部称为GC。
|
||||
|
||||
## 明确剩余项
|
||||
## 收口审查
|
||||
|
||||
复制规划的 `createDuplicatePlanner().checkpoint()` 当前返回 `freeze(clone(state))`;它具有 JSON 脱离性质,但这项来源尚未传递给 codec。`preparation.planner` / `source.plan` 因而仍可能经未知父对象走同步 normalization。下一项是由 core 提供同元素一致的、不可伪造的 JSON 脱离来源事实,并在 runtime 自建 checkpoint envelope 中保留这项事实,补首次 checkpoint 编码、外部回调与恢复测试。不能仅以 `Object.freeze` 推定来源,也不能跳过完整 checkpoint/发送屏障。
|
||||
前轮唯一明确的编码缺口已经接通:`createDuplicatePlanner().checkpoint()` 与元素复用同一 JSON 脱离工厂,runtime 自建 job/source/preparation envelope 保留 shape 来源;分配列表、ready ID表和根列表不再丢失这项边界。首次 preparation 确实分段,最终 ready plan 先经过持久屏障后复用已有编码,重开后映射保持。
|
||||
|
||||
此项完成并复验前,Step 4 保持未完成。本文件只确认已列入口和观察,未完成项不能转移到后端阶段来虚增进度。
|
||||
第4步验收按开工时的四类职责和6ms合作调度软目标完成,不把它改成“每次GC/native/原子发布均小于6ms”。此结论覆盖原定实现与列出的复现入口;它不代表无卡顿、低端设备SLA、任意selector皆O(1)或T01–T36全部完成。首项可见、输入保留、两侧快照正确、Root按序落盘及发送屏障均有直接断言;最终长任务观测作为已知限制保留,不假称已经消除。
|
||||
|
||||
## 调度限制与后续分工
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {detachJsonData} from './json-data.ts';
|
||||
import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts';
|
||||
import { ancestors, targetElement, validateDocumentSteps, validateDocumentTopologySteps } from './document.ts';
|
||||
import { duplicateTreeChange, duplicateEdgeChange, duplicateOverlay, type DuplicateOptions, type DuplicatePlan } from './duplicate.ts';
|
||||
@@ -42,7 +43,7 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
|
||||
return Object.freeze({
|
||||
ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;},
|
||||
progress(){return Object.freeze({phase:state.phase,steps:state.steps,allocated:state.elementIds.length+state.canvasIds.length});},
|
||||
checkpoint():DuplicateCheckpoint{return freeze(clone(state));},
|
||||
checkpoint():DuplicateCheckpoint{return detachJsonData(state);},
|
||||
step():boolean{
|
||||
if(sourceChecks){if(sourceChecks.next().done)sourceChecks=undefined;return false;}
|
||||
// Reconstruct only the derived output cache. Index/DFS/allocation work stays at its saved cursor.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type {ElementRecord,ComponentDefinition} from './types.ts';
|
||||
import {clone,freeze} from './values.ts';
|
||||
import {detachJsonData} from './json-data.ts';
|
||||
|
||||
// Provenance comes from detaching JSON data, never from an external freeze or
|
||||
// document envelope. This does not assert record validity or registry support.
|
||||
const owned=new WeakSet<object>();
|
||||
export function detachElementData(record:ElementRecord):ElementRecord{
|
||||
if(record&&typeof record==='object'&&owned.has(record))return record;
|
||||
const value=freeze(clone(record));
|
||||
const value=detachJsonData(record);
|
||||
if(value&&typeof value==='object')owned.add(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import {clone,freeze} from './values.ts';
|
||||
|
||||
// Private provenance for a fresh, recursively frozen JSON clone. A caller can
|
||||
// request a detached copy, but cannot grant this fact to an existing object.
|
||||
const detached=new WeakSet<object>();
|
||||
export function detachJsonData<T>(input:T):T {
|
||||
const value=freeze(clone(input));
|
||||
if(value&&typeof value==='object')detached.add(value);
|
||||
return value;
|
||||
}
|
||||
export const hasDetachedJsonData=(value:object)=>detached.has(value);
|
||||
@@ -1,4 +1,5 @@
|
||||
import {dataRecord} from '../canvas-core/data-containers.ts';
|
||||
import {dataArray,dataRecord} from '../canvas-core/data-containers.ts';
|
||||
import {detachJsonData} from '../canvas-core/json-data.ts';
|
||||
import {createImportReadProof,IMPORT_PROOF_BLOCK_BYTES} from './import-read-proof.ts';
|
||||
import {isImportByteFailure} from './import-byte-reader.ts';
|
||||
import {assertImportEvidence,importSeals,importsMaySend} from './import-verification.ts';
|
||||
@@ -670,16 +671,24 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
assertImportWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
|
||||
integrityOwner=job.id;try{const result=editor.updateRuntime(previous=>{const imports=new Map(previous.imports??[]);if(imports.get(job.id)?.generation!==expectedGeneration)throw fault('stale_import_job');imports.set(job.id,job.version===3?job:freeze({...job,version:3 as const}));return Object.freeze({...previous,imports:readMap(imports)});});if(result.status==='rejected')throw fault(result.code,result.targets);}finally{integrityOwner=undefined;}
|
||||
}
|
||||
// These records are newly constructed by the engine, including source and
|
||||
// preparation envelopes. Shape ownership never asserts ownership of children.
|
||||
function importCheckpoint(job:ImportJob):ImportJob {
|
||||
return dataRecord(freeze({...job,
|
||||
...(job.source?{source:dataRecord(freeze({...job.source}))}:{}),
|
||||
...(job.preparation?{preparation:dataRecord(freeze({...job.preparation,allocationIds:dataArray(freeze(job.preparation.allocationIds))}))}:{}),
|
||||
}));
|
||||
}
|
||||
function checkpointImportSource(job:ImportJob):ImportJob{
|
||||
if(job.source?.integrityVersion!==2)return job;
|
||||
const context=proofReaders.get(job.id),current=editor.getSnapshot().runtime.imports?.get(job.id)?.source;
|
||||
if(!context||context.fileId!==job.source.fileId)throw fault('import_source_unavailable');
|
||||
const {sha256:_,integrityError:__,...source}=job.source;
|
||||
return dataRecord(freeze({...job,source:{...source,readProof:context.reader.getProof(),...(current?.sha256?{sha256:current.sha256}:{}),...(current?.integrityError?{integrityError:current.integrityError}:{})}}));
|
||||
return importCheckpoint({...job,source:{...source,readProof:context.reader.getProof(),...(current?.sha256?{sha256:current.sha256}:{}),...(current?.integrityError?{integrityError:current.integrityError}:{})}});
|
||||
}
|
||||
function failImportVerification(job:ImportJob,error:unknown){
|
||||
const code=(error as {code?:string}).code??'import_source_verification_failed';
|
||||
publishImport(freeze({...job,generation:job.generation+1,status:['cancelled','partial'].includes(job.status)?job.status:'paused',source:{...job.source!,integrityError:code},error:code}),job.generation);
|
||||
publishImport(importCheckpoint({...job,generation:job.generation+1,status:['cancelled','partial'].includes(job.status)?job.status:'paused',source:{...job.source!,integrityError:code},error:code}),job.generation);
|
||||
const release=importRunners.get(job.id)?.hold();release?.();report(error);
|
||||
}
|
||||
function verifyImportSource(id:Id,context:{fileId:Id;bytes:Blob;reader:ReturnType<typeof createImportReadProof>}):Promise<void>{
|
||||
@@ -691,7 +700,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
try{await context.reader.verify(signal);sha256=await abortableFileRead(sourceDigest(context.bytes,signal),signal);const job=current();if(job.source?.sha256&&job.source.sha256!==sha256)throw fault('import_source_changed');}
|
||||
catch(error){if(signal.aborted||closing||closed||importHolds.size)return;failImportVerification(current(),error);try{await persist();}catch{}return;}
|
||||
const job=current();if(job.status==='cancelled'&&!job.committedSourceKeys.length)return;const{integrityError:oldError,...source}=job.source!,next={...job,source:{...source,sha256,readProof:context.reader.getProof()}};if(oldError&&next.error===oldError)delete next.error;
|
||||
publishImport(freeze(next),job.generation);await persist();
|
||||
publishImport(importCheckpoint(next),job.generation);await persist();
|
||||
})().catch(error=>{if(!signal.aborted&&!closing&&!closed)report(error);}).finally(()=>{if(verificationTasks.get(id)?.controller===controller)verificationTasks.delete(id);});
|
||||
verificationTasks.set(id,{controller,promise});return promise;
|
||||
}
|
||||
@@ -713,19 +722,19 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
if(preparation.version===3||preparation.version===4){
|
||||
const state=await prepareDuplicateStream(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId},{sourceToken:admitted.source.sha256!,schemaToken:preparation.registrySignature,checkpoint:preparation.planner,topologyOnly:!!indexed},{signal:controller.signal,checkpointIntervalMs:50,maxStepsPerCheckpoint:8192,checkpoint:async state=>{
|
||||
const job=check(),previous=job.preparation!;if(previous.planner&&state.steps<=previous.steps)return;
|
||||
publishImport(freeze({...job,generation:job.generation+1,preparation:{...previous,steps:state.steps,allocationIds:[...state.elementIds,...state.canvasIds].map(pair=>pair[1]),planner:state}}),job.generation);await persist();check();
|
||||
publishImport(importCheckpoint({...job,generation:job.generation+1,preparation:{...previous,steps:state.steps,allocationIds:[...state.elementIds,...state.canvasIds].map(pair=>pair[1]),planner:state}}),job.generation);await persist();check();
|
||||
}}),job=check();assertNativeImportTarget(job,editor.getSnapshot().document);
|
||||
const stableIds=Object.fromEntries([...state.elementIds.map(([old,id])=>[`element:${old}`,id]),...state.canvasIds.map(([old,id])=>[`canvas:${old}`,id])]),next={...job,generation:job.generation+1,parserVersion:2,status:'running' as const,stableIds,source:{...job.source!,kind:'duplicate-plan' as const,plan:state,copy:{sourceRevision:job.source!.copy!.sourceRevision,roots:state.roots.map(old=>stableIds[`element:${old}`])}}};delete next.preparation;delete next.error;
|
||||
publishImport(freeze(next),job.generation);return{job:getImport(id),bytes,document,indexed};
|
||||
const stableIds=dataRecord(Object.fromEntries([...state.elementIds.map(([old,id])=>[`element:${old}`,id]),...state.canvasIds.map(([old,id])=>[`canvas:${old}`,id])])),next={...job,generation:job.generation+1,parserVersion:2,status:'running' as const,stableIds,source:{...job.source!,kind:'duplicate-plan' as const,plan:state,copy:dataRecord({sourceRevision:job.source!.copy!.sourceRevision,roots:dataArray(state.roots.map(old=>stableIds[`element:${old}`]))})}};delete next.preparation;delete next.error;
|
||||
publishImport(importCheckpoint(next),job.generation);return{job:getImport(id),bytes,document,indexed};
|
||||
}
|
||||
const allocationIds=[...preparation.allocationIds];let cursor=0;
|
||||
const plan=preparation.version===2?await planDuplicateResumable(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId},{sourceToken:admitted.source.sha256!,schemaToken:preparation.registrySignature,checkpoint:preparation.planner},{signal:controller.signal,checkpoint:async state=>{
|
||||
const job=check(),previous=job.preparation!;if(previous.planner&&state.steps<=previous.steps)return;
|
||||
publishImport(freeze({...job,generation:job.generation+1,preparation:{...previous,steps:state.steps,allocationIds:[...state.elementIds,...state.canvasIds].map(pair=>pair[1]),planner:state}}),job.generation);await persist();check();
|
||||
publishImport(importCheckpoint({...job,generation:job.generation+1,preparation:{...previous,steps:state.steps,allocationIds:[...state.elementIds,...state.canvasIds].map(pair=>pair[1]),planner:state}}),job.generation);await persist();check();
|
||||
}}):await planDuplicateAsync(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId:()=>{const index=cursor++;if(index<allocationIds.length)return allocationIds[index];const id=createId();allocationIds.push(id);return id;}},{signal:controller.signal,checkpoint:async steps=>{
|
||||
const job=check(),previous=job.preparation!;
|
||||
if(steps<=previous.steps&&allocationIds.length===previous.allocationIds.length)return;
|
||||
publishImport(freeze({...job,generation:job.generation+1,preparation:{...previous,steps:Math.max(previous.steps,steps),allocationIds:[...allocationIds]}}),job.generation);await persist();check();
|
||||
publishImport(importCheckpoint({...job,generation:job.generation+1,preparation:{...previous,steps:Math.max(previous.steps,steps),allocationIds:[...allocationIds]}}),job.generation);await persist();check();
|
||||
}});
|
||||
check();if(preparation.version===1&&cursor!==allocationIds.length)throw fault('copy_interpreter_changed');
|
||||
const encoded=await encodeDuplicateSource(document,options.registry,plan,controller.signal),sha256=await sourceDigest(encoded,controller.signal),job=check();assertImportWritable();assertNativeImportTarget(job,editor.getSnapshot().document);
|
||||
@@ -733,7 +742,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const fileId=createId(),runtime=editor.getSnapshot().runtime;if(!fileId||fileWrites.has(fileId)||runtime.resources.has(fileId)||[...(runtime.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict');
|
||||
const stableIds=Object.fromEntries([...Object.entries(plan.elementIds).map(([old,id])=>[`element:${old}`,id]),...Object.entries(plan.canvasIds).map(([old,id])=>[`canvas:${old}`,id])]);
|
||||
const next={...job,generation:job.generation+1,status:'running' as const,stableIds,sourceFingerprint:`local-file:${fileId}:${encoded.size}`,source:{...job.source!,previousFiles:[...(job.source!.previousFiles??[]),{fileId:job.source!.fileId,byteLength:job.source!.byteLength,...(job.source!.sha256?{sha256:job.source!.sha256}:{})}],kind:'duplicate-ndjson' as const,fileId,byteLength:encoded.size,sha256,integrityVersion:1 as const,copy:{sourceRevision:job.source!.copy!.sourceRevision,roots:plan.roots}}};delete next.preparation;delete next.error;
|
||||
fileWrites.set(fileId,encoded);try{publishImport(freeze(next),job.generation);}catch(error){fileWrites.delete(fileId);throw error;}
|
||||
fileWrites.set(fileId,encoded);try{publishImport(importCheckpoint(next),job.generation);}catch(error){fileWrites.delete(fileId);throw error;}
|
||||
return{job:getImport(id),bytes:encoded,document:undefined,indexed:undefined};
|
||||
}
|
||||
function loadImport(id:Id):Promise<void>{
|
||||
@@ -760,7 +769,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const verified=getImport(id);if(verified.generation!==latest.generation||verified.sourceFingerprint!==job.sourceFingerprint||verified.source?.fileId!==source.fileId||['completed','partial','cancelled','paused'].includes(verified.status))return;
|
||||
if(importHolds.size){deferredImportLoads.add(id);return;}
|
||||
if(source.sha256&&source.sha256!==sha256)throw fault('import_source_changed');
|
||||
if(!source.sha256){publishImport(freeze({...verified,generation:verified.generation+1,source:{...source,sha256}}),verified.generation);job=getImport(id);source=job.source!;}else job=verified;
|
||||
if(!source.sha256){publishImport(importCheckpoint({...verified,generation:verified.generation+1,source:{...source,sha256}}),verified.generation);job=getImport(id);source=job.source!;}else job=verified;
|
||||
}
|
||||
let copyDocument:Document|undefined,indexed:IndexedCopySnapshot|undefined;
|
||||
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;}
|
||||
@@ -792,7 +801,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
});
|
||||
importRunners.set(id,runner);for(const releases of importHolds.values())releases.set(id,runner.hold());
|
||||
if(getImport(id).status==='running')void runner.start();
|
||||
})().catch(error=>{if(preparationController.signal.aborted)return;if(!closing&&!closed){try{const job=getImport(id);if(job.source?.integrityVersion===2&&!importRunners.has(id)&&!verificationTasks.has(id)){proofReaders.get(id)?.reader.close();proofReaders.delete(id);}if(job.sourceFingerprint!==expectedFingerprint)return;if(job.source?.integrityVersion===2&&(isImportByteFailure(error)||(error as {code?:string}).code==='import_source_unavailable')){failImportVerification(job,error);void persist().catch(report);return;}if(['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.source?.integrityVersion===2&&!importRunners.has(id)&&!verificationTasks.has(id)){proofReaders.get(id)?.reader.close();proofReaders.delete(id);}if(job.sourceFingerprint!==expectedFingerprint)return;if(job.source?.integrityVersion===2&&(isImportByteFailure(error)||(error as {code?:string}).code==='import_source_unavailable')){failImportVerification(job,error);void persist().catch(report);return;}if(['completed','partial','cancelled','paused'].includes(job.status))return;publishImport(importCheckpoint({...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;
|
||||
}
|
||||
function startNativeImport(bytes:Blob,targetCanvasId:Id,format:'ndjson'|'json-array'|'document'):Id{
|
||||
@@ -800,7 +809,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'?5:format==='json-array'?3:1,source:{...(loadingAdmission?{loadingAdmission}:{}),integrityVersion:2,readProof:{version:1,fileId,byteLength:bytes.size,blockBytes:IMPORT_PROOF_BLOCK_BYTES,blocks:[]},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=importCheckpoint({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:format==='document'?5:format==='json-array'?3:1,source:{...(loadingAdmission?{loadingAdmission}:{}),integrityVersion:2,readProof:{version:1,fileId,byteLength:bytes.size,blockBytes:IMPORT_PROOF_BLOCK_BYTES,blocks:[]},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);
|
||||
integrityOwner=id;try{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;}finally{integrityOwner=undefined;}
|
||||
@@ -821,7 +830,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
}
|
||||
const captured=editor.getSnapshot(),target=settings.viewCanvasId??(selection[0]?captured.document.elements.get(selection[0].id)?.ownerCanvasId:undefined),signal=settings.signal,controller=new AbortController(),abort=()=>controller.abort();
|
||||
const loadingAdmission=target?loadingImportAdmission(captured,target,selection):undefined;
|
||||
const targets=freeze(clone(selection)),offset=freeze(clone(settings.worldOffset??{x:32,y:32}));
|
||||
const targets=detachJsonData(selection),offset=detachJsonData(settings.worldOffset??{x:32,y:32});
|
||||
for(const selected of targets){const element=captured.document.elements.get(selected.id);if(!element||element.incarnation!==selected.incarnation)throw fault('stale_incarnation',[selected.id]);}if(![offset.x,offset.y].every(Number.isFinite))throw fault('invalid_copy_offset');
|
||||
signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)controller.abort();
|
||||
const task=(async()=>{
|
||||
@@ -831,15 +840,15 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
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?captured.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:'duplicate-snapshot',snapshotVersion:2,name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:[]},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'preparing' as const,preparation:{version:4 as const,registrySignature:copyRegistrySignature(captured.document,options.registry),selection:targets,worldOffset:offset,allocationIds:[],steps:0}});
|
||||
const job=importCheckpoint({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{...(loadingAdmission?{loadingAdmission}:{}),kind:'duplicate-snapshot',snapshotVersion:2,name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:[]},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'preparing' as const,preparation:{version:4 as const,registrySignature:copyRegistrySignature(captured.document,options.registry),selection:targets,worldOffset:offset,allocationIds:[],steps:0}});
|
||||
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;
|
||||
})();
|
||||
copyPreparations.set(controller,task);void task.finally(()=>{copyPreparations.delete(controller);signal?.removeEventListener('abort',abort);}).catch(()=>{});return task;
|
||||
},
|
||||
async pause(id:Id){assertImportWritable();const runner=importRunners.get(id);if(runner)await runner.pause();else{const job=getImport(id);if(['running','preparing'].includes(job.status)){if(job.source?.integrityVersion!==2)preparationControllers.get(id)?.abort();publishImport(freeze({...job,generation:job.generation+1,status:'paused'}),job.generation);await persist();}}},
|
||||
async resume(id:Id){assertImportWritable();const job=getImport(id);if(['completed','partial','cancelled'].includes(job.status))return;if(['running','preparing'].includes(job.status)){await loadImport(id);return;}await importLoads.get(id);await importRunners.get(id)?.settle();if(getImport(id).source?.integrityVersion===2)await resetImportReader(id);await persist();await scheduler.resume();assertImportWritable();const current=getImport(id);if(current.status!=='paused')return;const next={...current,generation:current.generation+1,status:current.source?.kind==='duplicate-snapshot'?'preparing' as const:'running' as const};delete next.error;publishImport(freeze(next),current.generation);await loadImport(id);},
|
||||
async cancel(id:Id){assertImportWritable();const runner=importRunners.get(id);if(runner)await runner.cancel();else{const job=getImport(id);if(['running','preparing','paused'].includes(job.status)){if(job.source?.integrityVersion!==2)preparationControllers.get(id)?.abort();const next={...job,generation:job.generation+1,status:'cancelled' as const};delete next.error;publishImport(freeze(next),job.generation);await persist();}}if(!sourceNeedsSeal(getImport(id)))verificationTasks.get(id)?.controller.abort();},
|
||||
async pause(id:Id){assertImportWritable();const runner=importRunners.get(id);if(runner)await runner.pause();else{const job=getImport(id);if(['running','preparing'].includes(job.status)){if(job.source?.integrityVersion!==2)preparationControllers.get(id)?.abort();publishImport(importCheckpoint({...job,generation:job.generation+1,status:'paused'}),job.generation);await persist();}}},
|
||||
async resume(id:Id){assertImportWritable();const job=getImport(id);if(['completed','partial','cancelled'].includes(job.status))return;if(['running','preparing'].includes(job.status)){await loadImport(id);return;}await importLoads.get(id);await importRunners.get(id)?.settle();if(getImport(id).source?.integrityVersion===2)await resetImportReader(id);await persist();await scheduler.resume();assertImportWritable();const current=getImport(id);if(current.status!=='paused')return;const next={...current,generation:current.generation+1,status:current.source?.kind==='duplicate-snapshot'?'preparing' as const:'running' as const};delete next.error;publishImport(importCheckpoint(next),current.generation);await loadImport(id);},
|
||||
async cancel(id:Id){assertImportWritable();const runner=importRunners.get(id);if(runner)await runner.cancel();else{const job=getImport(id);if(['running','preparing','paused'].includes(job.status)){if(job.source?.integrityVersion!==2)preparationControllers.get(id)?.abort();const next={...job,generation:job.generation+1,status:'cancelled' as const};delete next.error;publishImport(importCheckpoint(next),job.generation);await persist();}}if(!sourceNeedsSeal(getImport(id)))verificationTasks.get(id)?.controller.abort();},
|
||||
});
|
||||
scheduler.start(); prepareDigests();prepareImages(); runTracker.notify([...initial.runs.keys()]);
|
||||
for(const job of initial.imports?.values()??[])if(sourceNeedsSeal(job)||['running','preparing'].includes(job.status))void loadImport(job.id);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {hasOwnedElementData} from '../canvas-core/element-data.ts';
|
||||
import type {ElementRecord} from '../canvas-core/types.ts';
|
||||
import {hasDetachedJsonData} from '../canvas-core/json-data.ts';
|
||||
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';
|
||||
@@ -77,14 +76,14 @@ 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.
|
||||
// Detached element JSON can also defer after checking its prototype environment.
|
||||
// Detached JSON can also defer after checking its prototype environment.
|
||||
function prepareEncoding(input:unknown) {
|
||||
const normalize=createEncoder(),visiting=new Set<object>();
|
||||
let plainEnvironment:boolean|undefined;
|
||||
const capture=(value:unknown,depth:number,key:string)=>{
|
||||
try{return normalize(value,depth,key);}finally{plainEnvironment=undefined;}
|
||||
};
|
||||
const detached=(value:object)=>hasOwnedElementData(value as ElementRecord)&&(plainEnvironment??=(
|
||||
const detached=(value:object)=>hasDetachedJsonData(value)&&(plainEnvironment??=(
|
||||
Object.getPrototypeOf(Object.prototype)===null&&Object.getPrototypeOf(Array.prototype)===Object.prototype&&
|
||||
!Object.getOwnPropertyDescriptor(Object.prototype,'toJSON')&&!Object.getOwnPropertyDescriptor(Array.prototype,'toJSON')
|
||||
));
|
||||
@@ -99,7 +98,7 @@ function prepareEncoding(input:unknown) {
|
||||
if(typeof value!=='object')return capture(value,depth,key);
|
||||
const cached=immutable.get(value);
|
||||
if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;}
|
||||
// Detached element JSON has no accessors, custom prototypes or mutable
|
||||
// Detached JSON has no accessors, custom prototypes or mutable
|
||||
// descendants. Its full walk can wait; foreign freezes cannot grant this.
|
||||
if(detached(value))return {source:value,depth};
|
||||
if(!isDataContainer(value))return capture(value,depth,key);
|
||||
|
||||
@@ -30,3 +30,21 @@ test('checkpoint bindings, shape and source identities reject incompatible state
|
||||
test('cooperative resume checkpoints advance semantic cursors, yield and stop cleanly after cancellation',async()=>{
|
||||
const controller=new AbortController();let saved,ids=0,yields=0;await assert.rejects(planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},binding,{maxStepsPerSlice:1,yieldTask:async()=>{yields++;},checkpoint:async state=>{if(state.elementIds.length>=2){saved=state;controller.abort();}},signal:controller.signal}),/copy_aborted/);assert.ok(yields>0);assert.ok(saved.elementIds.length>=2);const old=ids,plan=await planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},{...binding,checkpoint:saved},{maxStepsPerSlice:2});let reference=0;assert.deepEqual(plan,planDuplicate(document,registry,targets(),{createId:()=>`async-${++reference}`}));assert.equal(ids,reference);assert.ok(ids>=old);
|
||||
});
|
||||
|
||||
test('a first planning checkpoint encodes cooperatively and is isolated from later planner steps',async()=>{
|
||||
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
const {hasDetachedJsonData}=await import('../../src/lib/canvas-core/json-data.ts');
|
||||
const rows=Array.from({length:1000},(_,i)=>({...document.elements.get('A'),id:'checkpoint-'+i,ownerCanvasId:'root'}));
|
||||
const source=createDocument('large',[{id:'root',parent:null}],rows),planner=createDuplicatePlanner(source,registry,rows.map(row=>({id:row.id,incarnation:row.incarnation})),{createId:()=>{throw Error('not allocating yet');}},binding);
|
||||
const checkpoint=planner.checkpoint(),before=JSON.stringify(checkpoint),stringify=JSON.stringify;let encoded=0,atFirst,tick=0,yields=0;
|
||||
assert.equal(hasDetachedJsonData(checkpoint),true);
|
||||
JSON.stringify=function(value,...args){if(typeof value==='string'&&value.startsWith('checkpoint-'))encoded++;return Reflect.apply(stringify,this,[value,...args]);};
|
||||
try{
|
||||
const pending=sealRecoveryImageAsync(17,1,1,checkpoint,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{atFirst??=encoded;yields++;}});
|
||||
for(let i=0;i<20000&&planner.progress().steps<=10;i++)planner.step();
|
||||
assert.ok(planner.progress().steps>checkpoint.steps);
|
||||
const image=await pending;assert.ok(yields>0);assert.ok(atFirst<128,'checkpoint strings were normalized before the first yield');
|
||||
assert.equal(stringify(image.payload),before);assert.equal(stringify(checkpoint),before);
|
||||
assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(stringify(image)).byteLength);
|
||||
}finally{JSON.stringify=stringify;}
|
||||
});
|
||||
|
||||
@@ -380,3 +380,29 @@ test('retry verifies a cancelled accepted prefix without restarting insertion',a
|
||||
test('legacy native array 14 and streaming document 16 retain their SHA-first interpreters',async()=>{
|
||||
for(const format of ['array','document']){const f=fixture();let engine=await f.open();try{const bytes=format==='array'?new Blob([JSON.stringify([node('legacy-native')])]):new Blob([JSON.stringify({documentFormatVersion:1,projectId:'legacy-source',canvases:[{id:'legacy-source',parent:null}],elements:[{...node('legacy-native').record,ownerCanvasId:'legacy-source'}],tombstones:{}})]),id=format==='array'?engine.imports.startJsonArray(bytes,'canvas'):engine.imports.startDocument(bytes,'canvas');await engine.settle();await engine.close();const image=structuredClone(f.image());image.formatVersion=format==='array'?14:16;const job=image.payload.runtime.imports.find(([key])=>key===id)[1];job.source.integrityVersion=1;delete job.source.readProof;delete job.source.integrityError;const decoded=decodeRecovery(image,registry);assert.equal(decoded.runtime.imports.get(id).source.integrityVersion,1);f.storage.recover=async()=>structuredClone(image);engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).parserVersion,format==='array'?3:5);assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.errors.length,0);}finally{await engine.close();}}
|
||||
});
|
||||
|
||||
test('engine preparation encodes in slices and the ready source retains checkpoint bytes and mappings on reopen', {timeout:20000},async()=>{
|
||||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
const rows=Array.from({length:1000},(_,i)=>({...node('live-checkpoint-'+i).record,incarnation:1,ownerCanvasId:'canvas'}));
|
||||
const initial=createDocument('project',[{id:'canvas',parent:null}],rows),f=fixture(initial),gate=deferred();f.gate=gate;
|
||||
let engine=await f.open(),unsubscribe=()=>{};const captured=[],seen=new Set();
|
||||
try{
|
||||
await engine.persist();
|
||||
unsubscribe=engine.editor.subscribe((_,snapshot)=>{
|
||||
for(const job of snapshot.runtime.imports.values()){
|
||||
const candidates=[['preparation',job.preparation?.planner?job.preparation:undefined],['source',job.source?.kind==='duplicate-plan'?job.source:undefined]];
|
||||
for(const [phase,part] of candidates){if(!part||seen.has(phase))continue;seen.add(phase);const before=JSON.stringify(part);let ticks=0,yields=0;
|
||||
const pending=sealRecoveryImageAsync(17,1,1,part,{now:()=>ticks++,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
|
||||
captured.push(pending.then(image=>({phase,image,before,yields}),error=>({phase,error})));
|
||||
}
|
||||
}
|
||||
});
|
||||
const id=await engine.imports.duplicate(rows.map(row=>({id:row.id,incarnation:1})));
|
||||
await until(engine,snapshot=>snapshot.runtime.imports.get(id)?.source?.kind==='duplicate-plan');
|
||||
await engine.imports.pause(id);unsubscribe();
|
||||
const results=await Promise.all(captured);assert.deepEqual([...seen].sort(),['preparation','source']);
|
||||
for(const result of results){assert.equal(result.error,undefined);if(result.phase==='preparation')assert.ok(result.yields>0,'preparation checkpoint was synchronously normalized');assert.equal(JSON.stringify(result.image.payload),result.before);}
|
||||
const ids=engine.imports.getJobs().get(id).stableIds;gate.resolve();await engine.close();engine=await f.open();
|
||||
assert.equal(engine.imports.getJobs().get(id).status,'paused');assert.deepEqual(engine.imports.getJobs().get(id).stableIds,ids);assert.equal(f.errors.length,0);
|
||||
}finally{unsubscribe();gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {detachJsonData,hasDetachedJsonData} from '../../src/lib/canvas-core/json-data.ts';
|
||||
|
||||
test('JSON detachment owns only its copied immutable result and preserves JSON semantics',()=>{
|
||||
let calls=0;const external={nested:{text:'before'},date:new Date('2026-01-01Z'),custom:{toJSON(){calls++;return{value:2};}},array:[,undefined,NaN],['__proto__']:{safe:true}};
|
||||
const value=detachJsonData(external);assert.equal(calls,1);assert.equal(hasDetachedJsonData(value),true);assert.equal(hasDetachedJsonData(external),false);
|
||||
external.nested.text='after';external.date.setUTCFullYear(2027);
|
||||
assert.equal(value.nested.text,'before');assert.equal(value.date,'2026-01-01T00:00:00.000Z');assert.deepEqual(value.array,[null,null,null]);assert.equal(Object.hasOwn(value,'__proto__'),true);
|
||||
assert.ok(Object.isFrozen(value.nested));assert.throws(()=>{value.nested.text='mutate';},TypeError);
|
||||
assert.equal(hasDetachedJsonData(Object.freeze({...value})),false);assert.equal(hasDetachedJsonData(structuredClone(value)),false);
|
||||
assert.equal(hasDetachedJsonData(Object.freeze({nested:{mutable:true}})),false);
|
||||
const cycle={};cycle.self=cycle;assert.throws(()=>detachJsonData(cycle));assert.throws(()=>detachJsonData({big:1n}));
|
||||
});
|
||||
@@ -312,7 +312,13 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
|
||||
assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length,beforeCurve+1);
|
||||
console.log('PASS actual GraphPage chooses canonical editor; create/undo/redo, drag/resize/curve final coordinates, Escape and IME text history publish correctly');
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);
|
||||
const recoveryPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]').getBoundingClientRect();return {x:r.x+50,y:r.y+30}})()`);
|
||||
await until(`[...document.querySelectorAll('[data-headless-canvas] [role="status"]')].some(element=>element.textContent.trim()==='已同步')`,'curve edit did not settle before opening recovery editor');
|
||||
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
|
||||
const recoveryPoint=await evaluate(`(()=>{const label=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]'),r=label.getBoundingClientRect();
|
||||
for(let y=Math.max(0,r.top)+8;y<Math.min(innerHeight,r.bottom);y+=8)for(let x=Math.max(0,r.left)+8;x<Math.min(innerWidth,r.right);x+=8)
|
||||
if(label.contains(document.elementFromPoint(x,y)))return{x,y};
|
||||
throw Error('recovery editor has no exposed native input point');
|
||||
})()`);
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...recoveryPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...recoveryPoint,button:'left',buttons:0,clickCount:2});
|
||||
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'recovery edit did not open');
|
||||
rejectNextEdit=true;await call('Input.insertText',{text:'Local recovery'});await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected content did not expose recovery');
|
||||
|
||||
Reference in New Issue
Block a user