Compare commits

...

65 Commits

Author SHA1 Message Date
Zayden-Jung b995e659a6 Merge pull request 'docs: 完善协议、公共库与画布迁移注释' (#13) from codex/foundry-protocol-comments into main
Reviewed-on: #13
2026-11-12 08:03:32 +00:00
codex-bot 5803787910 docs(canvas): clarify entry and migration lifecycle 2026-11-12 16:01:25 +08:00
codex-bot 84c50fe595 docs(app): explain request and directory lifecycle boundaries 2026-11-12 15:57:33 +08:00
codex-bot f78618be2b docs(service): clarify Foundry protocol validation boundaries 2026-11-12 15:54:01 +08:00
Zayden-Jung 18913efab7 Merge pull request 'docs(canvas): 完善画布模块与函数的逻辑注释' (#12) from codex/runtime-replay-comments into main
Reviewed-on: #12
2026-11-12 07:50:38 +00:00
codex-bot 9fa0958eb8 docs(service): explain run admission and event validation 2026-11-12 15:46:27 +08:00
codex-bot 43af2aeceb docs(service): explain asset receipts and model stream boundaries 2026-11-12 15:42:24 +08:00
codex-bot 27311e831d docs(bindings): explain React hooks and renderer ownership 2026-11-12 15:37:19 +08:00
codex-bot ce05ab653d docs(bindings): explain canvas projection and connection paint bounds 2026-11-12 15:32:19 +08:00
codex-bot 04c4566679 docs(bindings): explain derived sources and renderer lease contracts 2026-11-12 15:28:00 +08:00
codex-bot 30978320c3 docs(bindings): explain scene patching and paint cache ownership 2026-11-12 15:23:17 +08:00
codex-bot 7297985829 docs(bindings): explain projection geometry and hit testing budgets 2026-11-12 15:18:42 +08:00
codex-bot cb14e8e6d2 docs(bindings): explain input ownership and subscription lifecycle 2026-11-12 15:14:02 +08:00
codex-bot 7cdd2db4d7 docs(runtime): explain public recovery and engine shutdown contracts 2026-11-12 15:09:51 +08:00
codex-bot 75342cb7ad docs(runtime): explain import runner assembly and controls 2026-11-12 15:04:33 +08:00
codex-bot 7e08dd996b docs(runtime): explain engine image and import preparation lifecycle 2026-11-12 15:00:22 +08:00
codex-bot b3a634ce6b docs(runtime): explain engine file checks and response reconciliation 2026-11-12 14:55:15 +08:00
codex-bot 56b83e2009 docs(runtime): explain engine assembly and durable prefix writes 2026-11-12 14:50:08 +08:00
codex-bot 9abbe9c029 docs(runtime): explain recovery format and cross-table validation 2026-11-12 14:44:29 +08:00
codex-bot e425b28064 docs(runtime): explain progressive import publication and recovery 2026-11-12 14:40:09 +08:00
codex-bot 9c1dd28f3a docs(runtime): explain generation polling state and lifecycle 2026-11-12 14:35:57 +08:00
codex-bot 85b038a93f docs(runtime): explain browser storage and depot transaction boundaries 2026-11-12 14:31:58 +08:00
codex-bot 0ca2af7a37 docs(runtime): clarify copy snapshot and journal replay contracts 2026-11-12 14:27:28 +08:00
codex-bot a927f36d96 docs(runtime): explain recovery capture and sealing lifecycle 2026-11-12 14:20:03 +08:00
codex-bot c3131a71bd docs(runtime): explain durable storage and recovery JSON contracts 2026-11-12 14:15:02 +08:00
codex-bot 5f2bf2d142 docs(runtime): explain default image sizing grant lifecycle 2026-11-12 14:09:25 +08:00
codex-bot 63c4098e4e docs(runtime): clarify resource footprints and retention scan ownership 2026-11-12 14:05:32 +08:00
codex-bot 9c6f17f236 docs(runtime): clarify applied recovery staging and port contracts 2026-11-12 14:01:52 +08:00
codex-bot 9c2ecb9fe2 docs(runtime): explain conflict recovery coordination and audit 2026-11-12 13:56:06 +08:00
codex-bot c83641417b docs(runtime): explain mixed history recovery planning lifecycle 2026-11-12 13:52:23 +08:00
codex-bot a3d92f9cd6 docs(runtime): clarify history event sources and traversal recovery 2026-11-12 13:47:41 +08:00
codex-bot 196866aaf3 docs(runtime): explain partial loading coordination and gates 2026-11-12 13:44:12 +08:00
codex-bot 59b59c2f03 docs(runtime): clarify partial loading structure and import gates 2026-11-12 13:39:25 +08:00
codex-bot 437a46ad36 docs(runtime): explain progressive previews and loading asset evidence 2026-11-12 13:33:34 +08:00
codex-bot 2a9667a287 docs(runtime): clarify scoped loader subscriptions and cache lifecycle 2026-11-12 13:30:05 +08:00
codex-bot 3e4833470a docs(runtime): explain host and workspace ownership lifecycles 2026-11-12 13:27:00 +08:00
codex-bot 896e6a0656 docs(runtime): clarify planning checkpoints and scope evidence 2026-11-12 13:23:33 +08:00
codex-bot 17c14c85e8 docs(runtime): explain prepared and lazy copy sources 2026-11-12 13:20:34 +08:00
codex-bot 020fb55084 docs(runtime): explain indexed document and native import conversion 2026-11-12 13:17:27 +08:00
codex-bot 238e4dd4c5 docs(runtime): explain import proof and identity ownership 2026-11-12 13:14:06 +08:00
codex-bot 3c8953e842 docs(runtime): clarify streaming import cursors and byte reads 2026-11-12 13:10:35 +08:00
codex-bot d02cd2b01d docs(runtime): explain import scheduling and dependency selection 2026-11-12 13:07:14 +08:00
codex-bot 2b0afddd07 docs(runtime): explain digest scheduling and streaming boundaries 2026-11-12 13:04:23 +08:00
codex-bot f6acdac8d8 docs(runtime): explain asset display and image decode lifecycles 2026-11-12 13:01:35 +08:00
codex-bot 9ea8463867 docs(runtime): clarify resource and file retention boundaries 2026-11-12 12:58:15 +08:00
codex-bot c491cd023c docs(runtime): explain wire projection and conflict replanning 2026-11-12 12:54:35 +08:00
codex-bot 20bdc7d44a docs(runtime): clarify elision and reconciliation evidence 2026-11-12 12:51:30 +08:00
Zayden-Jung e78c18b697 Merge pull request 'docs: 完善画布注释并复核核心数据边界' (#11) from codex/storage-case-comments into main
Reviewed-on: #11
2026-11-12 04:50:34 +00:00
codex-bot 56a9806bc0 docs(runtime): clarify ledger authority and scheduler durability boundaries 2026-11-12 12:44:51 +08:00
codex-bot dadfd0e41d docs(core): clarify query completeness and public type guarantees 2026-11-12 12:39:22 +08:00
codex-bot 5fd8312ef5 docs(core): clarify view session and gesture lifecycle boundaries 2026-11-12 12:35:23 +08:00
codex-bot f1c5853fea docs(core): clarify feature schemas and bounded geometry queries 2026-11-12 12:32:01 +08:00
codex-bot 22119358f5 docs(core): clarify copy checkpoint references and dissolve planning 2026-11-12 12:28:44 +08:00
codex-bot aac3813ce4 docs(core): clarify creation sizing and structural planning boundaries 2026-11-12 12:25:10 +08:00
codex-bot 8e3ad8c2a2 docs(core): clarify editor publication and history traversal contracts 2026-11-12 12:21:31 +08:00
codex-bot db3eb8ec5d docs(core): clarify capture validation and replay guarantees 2026-11-12 12:17:04 +08:00
codex-bot cda4db5ec8 docs(config): explain package settings and generated JSON contracts 2026-11-12 12:12:22 +08:00
codex-bot fe80df375c docs(styles): explain global page sections and responsive cascade 2026-11-12 12:07:45 +08:00
codex-bot 4f75dcb9fd docs(styles): explain canvas and workspace layout and interaction states 2026-11-12 12:02:26 +08:00
codex-bot 2a0b6bb9d9 docs(tests): explain legacy canvas scenario setup and recovery flows 2026-11-12 11:57:00 +08:00
codex-bot 412e31e898 docs(tests): explain legacy canvas and workspace unit coverage 2026-11-12 11:51:14 +08:00
codex-bot 6ad003eb37 docs(tests): explain renderer recovery AI import and retained ownership flows 2026-11-12 11:43:08 +08:00
codex-bot 50675ffed3 docs(tests): explain renderer fixture setup proxy routes and native gestures 2026-11-12 11:36:18 +08:00
codex-bot bd728d2d94 docs(tests): explain storage collection import recovery and loading barriers 2026-11-12 11:29:07 +08:00
Zayden-Jung 6298202dd7 Merge pull request 'docs(tests): 说明浏览器驱动、React fixture 与渲染探针' (#10) from codex/browser-fixture-comments into main
Reviewed-on: #10
2026-11-12 03:26:06 +00:00
174 changed files with 3193 additions and 834 deletions
+2
View File
@@ -1,2 +1,4 @@
# Next 服务端访问 Foundry 的源站;本地代理从这里发起后端 HTTP 请求,不是浏览器页面地址。
FOUNDRY_API_ORIGIN=https://api.foundry.entroia.com FOUNDRY_API_ORIGIN=https://api.foundry.entroia.com
# 公开的项目路由标识,可进入浏览器构建;不是密码、会话令牌或授权凭证。
NEXT_PUBLIC_FOUNDRY_PROJECT=endoleap NEXT_PUBLIC_FOUNDRY_PROJECT=endoleap
+7
View File
@@ -1,8 +1,15 @@
# 安装依赖不入库;依赖版本由包清单与 lock 文件记录。
node_modules/ node_modules/
# Next 开发/生产构建缓存与产物,不作为源代码提交。
.next/ .next/
# 独立 headless ESM 包由 scripts/build-headless.mjs 重建。
.headless-dist/ .headless-dist/
# 忽略本机环境变量文件;下一条只放行可公开的示例模板。
.env* .env*
!.env.example !.env.example
# TypeScript 增量检查缓存,由工具重新生成。
*.tsbuildinfo *.tsbuildinfo
# macOS Finder 的目录元数据。
.DS_Store .DS_Store
# 仓库根 docs 是本地研究/草案目录,当前约定不跟踪其中内容。
/docs/ /docs/
+70
View File
@@ -142,3 +142,73 @@ Core `subgraphs.member_positions` 能力启用时,最终落点(源画布局
上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。 上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。
上传中的图片也是正常本地节点,可立即选中、拖动、缩放、改名、删除和撤销;上传完成只补齐附件引用,不重置节点。上传前改变分区归属会先在本地完成,文件准备好后保存到当时的实际所属画布。临时上传标记和 blob 地址由传输层排除,不发送给 Core;文件仍未可用时,依赖服务端文件的 AI 生成需等待上传完成。 上传中的图片也是正常本地节点,可立即选中、拖动、缩放、改名、删除和撤销;上传完成只补齐附件引用,不重置节点。上传前改变分区归属会先在本地完成,文件准备好后保存到当时的实际所属画布。临时上传标记和 blob 地址由传输层排除,不发送给 Core;文件仍未可用时,依赖服务端文件的 AI 生成需等待上传完成。
## 配置与生成数据阅读说明
本节为不适合内嵌注释的配置和严格 JSON 提供旁注;实际值仍以对应文件为准。源码、脚本和测试中的函数说明保留在各自文件内。包版本、TypeScript 输出目标、服务协议版本、文档格式版本和组件数据版本是不同概念。
### 应用包与执行入口
根 `package.json` 的 `private: true` 防止常规 npm 发布,`version: 0.1.0` 是应用包版本。`dependencies` 是应用运行/构建引用的库,`devDependencies` 包含编译器、类型声明和 Tailwind 工具;它们的精确版本及传递依赖由 `package-lock.json` 锁定。lock 文件由包管理器维护,不在其中手写说明或改某个嵌套版本。
| scripts 项 | 执行内容与边界 |
| --- | --- |
| `dev` | 在 localhost:17321 启动 Next 开发服务器,会按需编译。 |
| `build` / `start` | 前者执行 Next 生产构建,后者在 localhost:17321 运行已有构建。 |
| `typecheck` | `tsc --noEmit` 检查类型,不生成应用 JS。 |
| `test:headless` | 运行 `tests/headless/*.test.mjs`;不自动执行所有 `.integration.mjs` 或浏览器场景。 |
| `test:headless:browser` | 浏览器存储、Worker 等适配器场景入口,环境装配见 `browser-storage.mjs`。 |
| `test:headless:proxy` | Foundry 同源代理的隔离集成入口,启动与替身链路见 `proxy.integration.mjs`。 |
| `test:headless:renderer` / `test:headless:performance` | 运行页面集成入口;后者附加 `--performance` 选择性能场景。 |
| `build:headless` | 清理并重建 `.headless-dist` 的 ESM、声明和 Worker 引用,再复制包清单/文档。 |
| `test:headless:package` | 构建、打包到隔离目录并运行消费方及浏览器验收,不是单纯语法检查。 |
集成入口可能编译或启动本地进程,具体动作见各脚本模块注释;本节列出命令不表示这些检查已经运行或通过。
### 应用 TypeScript 配置
`tsconfig.json` 面向应用检查;以下选项共同作用,并非运行时开关:
| 配置项 | 用途 |
| --- | --- |
| `target: ES2020` / `lib` | 声明语言输出目标和可用类型库;`dom`、`dom.iterable`、`esnext` 提供 API 类型,不会安装浏览器 polyfill。 |
| `allowJs` / `skipLibCheck` | 允许 JS 进入项目;跳过声明文件内部的完整检查。它们不关闭项目 TS 源码的检查。 |
| `strict` / `noEmit` | 启用严格类型检查,默认只检查而不生成 JS/声明。 |
| `allowImportingTsExtensions` | 允许源码相对导入使用 `.ts` 等 TS 扩展名;独立包输出由另一配置重写扩展名。 |
| `esModuleInterop` | 为 CommonJS/ESM 互操作提供 TypeScript 的兼容处理,不把整个项目改成 CommonJS。 |
| `module: esnext` / `moduleResolution: bundler` | 使用 ESM 语法并按 bundler 模式解析依赖;应用最终交由 Next 构建链处理。 |
| `resolveJsonModule` | 允许 TS 引用并推断 JSON 模块,例如图标 manifest。 |
| `isolatedModules` / `jsx: react-jsx` | 按单文件转换约束检查源码,并使用 React 自动 JSX 转换形式。 |
| `incremental` | 允许 TypeScript 使用增量检查缓存;生成的 `*.tsbuildinfo` 被忽略。 |
| `paths` | `@/*` 在 TS 解析时指向 `src/*`,这不是把字符串路径写进业务文档。 |
| `plugins` | 登记 Next TypeScript 插件供支持它的工具使用。 |
| `include` / `exclude` | 纳入应用 TS/TSX、`next-env.d.ts` 与 Next 生成类型入口;排除依赖和独立包输出目录。由这些源码导入的依赖仍可参与类型解析。 |
`next-env.d.ts` 是 Next 维护的类型入口,引用框架/图片和生成路由类型。其路径会随开发/构建环境变化,不在这个生成文件中追加维护注释。
### 独立 headless 包
`tsconfig.headless.json` 继承应用配置,显式覆盖输出部分:`target`/`module` 为 ES2022,`rootDir` 为 `src/lib`,`outDir` 为 `.headless-dist/modules`;`noEmit: false` 与 `declaration: true` 生成 JS 和 `.d.ts`。`incremental: false` 不使用这次编译的增量缓存,`rewriteRelativeImportExtensions` 将可重写的相对 TS 导入转成输出扩展名。`types: []` 和 `plugins: []` 清空自动纳入的类型包列表与继承插件条目,不禁止源码显式导入类型;未覆盖的 `lib` 等项仍继承。四组 `include` 从 core/runtime/service/bindings 的 `.ts` 入口收集源码,导入关系还会带入所需依赖。
`headless/package.json` 是复制到产物根的包模板,源目录本身没有生成后的 `modules`。它的 `type: module` 声明 ESM,Node 引擎要求为 `>=22`。`exports` 为每个公开入口分别指定类型声明和 ESM 文件:根及 `/core` 指向 core,另有 `/runtime`、`/browser`、`/service`、`/bindings`、`/react-flow` 与 `/react`。这些是产物目录中的路径,不是指向 `src` 的别名,也没有声明 CommonJS `require` 入口。
`files` 限定包收录的模块、消费示例和验收文档。React、React DOM、React Flow 以带版本范围的可选 peer 声明,供选择相关适配入口的宿主提供;“可选”不表示调用 UI 适配器时可以缺少它依赖的框架。便携入口的使用和依赖边界见 `headless/README.md`。`private: true` 保持预览包私有,不代表已经发布到 registry。
### 组件生成器配置
`components.json` 是 UI 组件生成器读取的配置,不是应用运行时组件 registry。`$schema` 指向配置的 schema;`style: new-york`、`tsx: true` 和 `rsc: false` 记录生成风格、TSX 和 RSC 选项,不改变整个 App Router 的路由/渲染模式。`tailwind.css` 指向 `src/styles/globals.css`,空 `tailwind.config` 没有指定独立配置路径,`baseColor: neutral` 与 `cssVariables: true` 是生成器的配色约定。`aliases` 指示生成文件的组件、工具、UI、lib 和 hooks 导入位置;其中 `@/hooks` 不保证已经存在对应目录。
### 图标生成数据
`src/components/canvas/icon-catalog-data.json` 是检索目录数组,每项包含 `name`(持久引用的图标 ID)、`slug`、`source`、`label`、`categories`、`common` 和 `keywords`;不包含 SVG 路径。`icon-catalog.ts` 用它构建名称 Map 并执行筛选,选择器的虚拟窗口另由 UI 控制。
`src/lib/canvas-icon-assets.json` 保存 `root` URL 前缀与 `assets` 桶名到摘要文件名的映射。`iconAsset()` 对 Lucide 名称做 31 进制滚动散列并模 96,Tabler 名称统一使用 `tabler` 桶。`public/canvas-icons/<版本目录>/*.json` 每个分片以图标名索引 `[tag, props][]`,消费端 `validateIconShard()` 再检查支持的图形与属性。
这三类数据由 `scripts/generate-canvas-icons.mjs` 同批生成,中文标注来自 `scripts/canvas-icon-labels.mjs`。文件名摘要是紧凑 JSON 内容的 SHA-256 前 12 位,写盘时另加换行;它与分桶散列用途不同。分片变更须同步目录/manifest 并更新摘要名;脚本不会自动删除旧分片。不要在 JSON 中插入注释或只手改其中一份,否则会破坏读取、引用或缓存命名约定。
### 协议样例与忽略规则
`tests/headless/fixtures/foundry-model-operation-v2.json` 是服务适配器单测的完整请求样例。协议 2 的 `request_id` 标识冻结请求,`expected_versions` 是参与 canvas 的已确认版本,`operations[].id` 是逻辑操作 ID。样例把第一代元素 `a` 的 `data.title` 从 `a` 改为 `renamed`;`beforePresent`/`afterPresent` 区分字段缺失与有值。字段旁注与断言在 `tests/headless/service.test.mjs`,静态样例不代表真实服务已执行。
`.gitignore` 排除依赖、Next/headless 产物、环境变量、增量缓存和系统文件;仅放行 `.env.example` 作为模板。根 `/docs/` 当前作为本地研究目录忽略。忽略规则只影响未跟踪文件,不会把已经跟踪的文件自动移出 Git。`.env.example` 的公开项目标识与服务端源站说明写在模板内;实际凭据不应写进示例或公开构建变量。
+3
View File
@@ -80,6 +80,9 @@ for (const [key, nodes] of shards) {
totalBytes += Buffer.byteLength(content); totalBytes += Buffer.byteLength(content);
} }
// 两份生成JSON分别服务搜索目录与按需加载器;严格JSON不能内嵌注释,其结构与生成流程在本脚本说明。 // 两份生成JSON分别服务搜索目录与按需加载器;严格JSON不能内嵌注释,其结构与生成流程在本脚本说明。
// catalog 每项是 name/slug/source/label/categories/common/keywords;不带 SVG node,搜索只读取这些元数据。
// manifest.root 为公开 URL 前缀,assets 将 lucide-N 或 tabler 逻辑桶映射到带内容摘要的 JSON 文件名。
// 分片对象按图标 name 索引 [tag, props][];props 为可序列化 SVG 属性,消费端还会执行标签/属性校验。
await writeFile("src/components/canvas/icon-catalog-data.json", JSON.stringify(catalog) + "\n"); await writeFile("src/components/canvas/icon-catalog-data.json", JSON.stringify(catalog) + "\n");
await writeFile("src/lib/canvas-icon-assets.json", JSON.stringify({ root: "/" + assetDir.replace(/^public\//, ""), assets }) + "\n"); await writeFile("src/lib/canvas-icon-assets.json", JSON.stringify({ root: "/" + assetDir.replace(/^public\//, ""), assets }) + "\n");
// 分发路径数据同时复制原包许可证;最后只打印资源统计,不启动应用或验证浏览器显示。 // 分发路径数据同时复制原包许可证;最后只打印资源统计,不启动应用或验证浏览器显示。
+66
View File
@@ -1,91 +1,124 @@
/* 画布与节点的局部样式:React Flow 全局类通过 :global 组合,状态类由组件/手势控制器切换;CSS 只控制呈现和命中,不负责数据归属、提交或锁。 */
/* 画布铺满定位父容器;随后覆盖 React Flow 背景、选中边框和 attribution 外观。 */
.editor { position: absolute; inset: 0; width: 100%; height: 100%; background: #fffefb; } .editor { position: absolute; inset: 0; width: 100%; height: 100%; background: #fffefb; }
.editor :global(.react-flow) { --xy-background-color-default: #fffefb; --xy-node-border-selected-default: 1px solid #a47764; } .editor :global(.react-flow) { --xy-background-color-default: #fffefb; --xy-node-border-selected-default: 1px solid #a47764; }
.editor :global(.react-flow__attribution) { bottom: 5px; right: 5px; font-size: 9px; background: #fffefbc9; } .editor :global(.react-flow__attribution) { bottom: 5px; right: 5px; font-size: 9px; background: #fffefbc9; }
/* 手形模式在 pane/节点上给 grab 光标,拖动 pane 时改为 grabbing。 */
.handMode :global(.react-flow__pane), .handMode :global(.react-flow__node) { cursor: grab; } .handMode :global(.react-flow__pane), .handMode :global(.react-flow__node) { cursor: grab; }
.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; } .handMode :global(.react-flow__pane.dragging) { cursor: grabbing; }
/* 卡片基础边框/圆角/阴影,selectedNode 单独标记选择;标题绝对放在节点上方。 */
.node { position: relative; padding: 0; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; } .node { position: relative; padding: 0; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; }
.selectedNode { border-color: #a47764; box-shadow: 0 0 0 2px #a4776425; } .selectedNode { border-color: #a47764; box-shadow: 0 0 0 2px #a4776425; }
.nodeHeading { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; max-width: 100%; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; } .nodeHeading { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; max-width: 100%; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; }
/* 文本填满 flex 容器,允许尺寸缩到零且关闭原生 resize;焦点时覆盖全局输入阴影,避免额外边框。 */
.cardText, .plainText { box-sizing: border-box; display: block; width: 100%; height: 100%; min-height: 0; min-width: 0; flex: 1; padding: 14px; resize: none; border: 0; border-radius: inherit; background: transparent; outline: none; box-shadow: none; } .cardText, .plainText { box-sizing: border-box; display: block; width: 100%; height: 100%; min-height: 0; min-width: 0; flex: 1; padding: 14px; resize: none; border: 0; border-radius: inherit; background: transparent; outline: none; box-shadow: none; }
.cardText:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; } .cardText:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; }
.cardText::placeholder, .plainText::placeholder { color: #b2a99e; } .cardText::placeholder, .plainText::placeholder { color: #b2a99e; }
/* 卡片图片保持原比例装入可用空间,不以图片固有尺寸撑开节点。 */
.image { display: block; flex: 1; min-height: 0; width: 100%; height: 100%; max-width: 100%; border-radius: inherit; object-fit: contain; } .image { display: block; flex: 1; min-height: 0; width: 100%; height: 100%; max-width: 100%; border-radius: inherit; object-fit: contain; }
/* 工具栏按 aria-pressed 高亮当前模式;disabled 光标不表示实际业务锁逻辑。 */
.toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; } .toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
.toolbar button:disabled { cursor: not-allowed; } .toolbar button:disabled { cursor: not-allowed; }
/* 右上工具条纵排;缩放值固定 48px 并使用等宽数字,避免百分比变化挤动旁边按钮。 */
.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; } .topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
.toolbar .zoomValue { box-sizing: border-box; flex: 0 0 48px; width: 48px; height: var(--toolbar-control-height, 32px); padding: 0; border-radius: 5px; font: inherit; font-variant-numeric: tabular-nums; } .toolbar .zoomValue { box-sizing: border-box; flex: 0 0 48px; width: 48px; height: var(--toolbar-control-height, 32px); padding: 0; border-radius: 5px; font: inherit; font-variant-numeric: tabular-nums; }
.toolbar .zoomValue:hover { background: #e9e7e3; } .toolbar .zoomValue:hover { background: #e9e7e3; }
/* 底部主工具栏相对画布居中;帮助、错误等浮层各自定位和设层级。 */
.bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; } .bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; }
/* 帮助面板固定在视口右下方,有宽高上限和内部滚动,长内容不溢出视口。 */
.helpPopup { position: fixed; inset: auto 12px 62px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; } .helpPopup { position: fixed; inset: auto 12px 62px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; }
.helpPopup strong { color: #49372e; font-size: 13px; } .helpPopup strong { color: #49372e; font-size: 13px; }
.helpPopup p { margin-top: 9px; } .helpPopup p { margin-top: 9px; }
/* 画布错误在上方居中显示,宽度随视口收缩,不参与节点几何计算。 */
.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; } .error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
.helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; } .helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; }
/* 迁移控制面板处于较高层级;按钮状态、是否允许迁移由组件判断。 */
.migrationPanel { position: absolute; right: 12px; top: 64px; z-index: 220; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 360px; padding: 8px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #78523f; font-size: 12px; } .migrationPanel { position: absolute; right: 12px; top: 64px; z-index: 220; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 360px; padding: 8px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #78523f; font-size: 12px; }
.migrationPanel button { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; } .migrationPanel button { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; }
.migrationPanel button:disabled { cursor: default; opacity: .5; } .migrationPanel button:disabled { cursor: default; opacity: .5; }
/* 旧/迁移画布表面铺满宿主,具体渲染内容由桥接组件决定。 */
.migrationSurface { position: absolute; inset: 0; } .migrationSurface { position: absolute; inset: 0; }
/* 窄屏把右上工具条下移避让标题,底部工具条改左对齐并横向滚动,预留右侧帮助空间。 */
@media (max-width: 700px) { @media (max-width: 700px) {
.topRight { top: 62px; right: 12px; } .topRight { top: 62px; right: 12px; }
.bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; } .bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; }
} }
/* 手形模式让文本/图片不拦截指针;右上工具条自身横向滚动以容纳窄视口。 */
.handMode .node textarea, .handMode .node img { pointer-events: none; } .handMode .node textarea, .handMode .node img { pointer-events: none; }
.topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; } .topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; }
/* 隐藏旧 handle 且禁用命中,新的中心点交互在后续规则控制。 */
.node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; } .node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; }
/* 变换容器以中心为 transform-origin,保留溢出给标题、handle 和提示;边框/阴影变化有短过渡。 */
.transformFrame { position: relative; box-sizing: border-box; display: flex; flex-direction: column; transform-origin: center; overflow: visible; transition: border-color 160ms ease, box-shadow 160ms ease; } .transformFrame { position: relative; box-sizing: border-box; display: flex; flex-direction: column; transform-origin: center; overflow: visible; transition: border-color 160ms ease, box-shadow 160ms ease; }
/* 纯内容 frame 默认透明边框,选中才显示;plainText 后置 padding 覆盖卡片文本的 14px。 */
.plainFrame { padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; } .plainFrame { padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; }
.plainFrame.selectedFrame { border-color: #a47764; } .plainFrame.selectedFrame { border-color: #a47764; }
.plainText { padding: 4px; } .plainText { padding: 4px; }
/* 中心连接点默认透明且不响应指针;选中只改变可见性,connectionindicator 才开放命中。 */
.node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 0; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; opacity: 0; pointer-events: none; transition: opacity 160ms ease, background 160ms ease, border-color 160ms ease; } .node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 0; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; opacity: 0; pointer-events: none; transition: opacity 160ms ease, background 160ms ease, border-color 160ms ease; }
.selectedNode :global(.react-flow__handle).centerHandle { opacity: 1; } .selectedNode :global(.react-flow__handle).centerHandle { opacity: 1; }
.node :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; } .node :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
.node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; } .node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; }
/* 右下缩放按钮单独命中、禁止触摸默认行为;旋转模式隐藏它,避免两种手势同时起动。 */
.cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 1; scale: 1; pointer-events: auto; transition: opacity 160ms ease, scale 160ms ease; } .cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 1; scale: 1; pointer-events: auto; transition: opacity 160ms ease, scale 160ms ease; }
.selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; } .selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; }
.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; } .rotatingFrame .cornerResize { opacity: 0; pointer-events: none; }
/* 旋转时由 frame 接收手势,文本和中心连接点让出指针。 */
.rotatingFrame { cursor: grabbing; touch-action: none; } .rotatingFrame { cursor: grabbing; touch-action: none; }
.rotatingFrame textarea, .node.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; } .rotatingFrame textarea, .node.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
/* 角度提示放在 frame 下方,禁止命中以免遮住正在进行的手势。 */
.rotationHint { position: absolute; left: 50%; bottom: -30px; translate: -50% 0; display: flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 7px; border-radius: 5px; background: #fffefbe8; color: #78523f; font-size: 10px; pointer-events: none; } .rotationHint { position: absolute; left: 50%; bottom: -30px; translate: -50% 0; display: flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 7px; border-radius: 5px; background: #fffefbe8; color: #78523f; font-size: 10px; pointer-events: none; }
/* 节点样式工具栏采用横排可滚动表面,按钮尺寸固定;数值/选择器共用高度。 */
.styleToolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid #ded7ce; border-radius: 8px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a12; max-width: calc(100vw - 24px); overflow-x: auto; } .styleToolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid #ded7ce; border-radius: 8px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a12; max-width: calc(100vw - 24px); overflow-x: auto; }
.styleToolbar button { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; padding: 0; border-radius: 4px; } .styleToolbar button { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; padding: 0; border-radius: 4px; }
.styleToolbar button:hover, .styleToolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; } .styleToolbar button:hover, .styleToolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
.styleToolbar select, .styleToolbar input[type="number"] { height: 28px; border: 0; border-radius: 3px; background: transparent; padding: 0 3px; font-size: 12px; } .styleToolbar select, .styleToolbar input[type="number"] { height: 28px; border: 0; border-radius: 3px; background: transparent; padding: 0 3px; font-size: 12px; }
.styleToolbar input[type="number"] { width: 48px; } .styleToolbar input[type="number"] { width: 48px; }
/* 可见圆点只显示颜色,透明原生 color input 覆盖整个点击区域。 */
.colorPicker { position: relative; display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; cursor: pointer; } .colorPicker { position: relative; display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; cursor: pointer; }
.colorPicker span { width: 17px; height: 17px; border: 1px solid #bfb4aa; border-radius: 50%; } .colorPicker span { width: 17px; height: 17px; border: 1px solid #bfb4aa; border-radius: 50%; }
.colorPicker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; } .colorPicker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
/* 分支菜单固定定位并高于普通工具条,菜单项整行点击;定位坐标由组件计算。 */
.branchMenu { position: fixed; z-index: 60; width: 210px; padding: 7px; border: 1px solid #ded7ce; border-radius: 10px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a1a; } .branchMenu { position: fixed; z-index: 60; width: 210px; padding: 7px; border: 1px solid #ded7ce; border-radius: 10px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a1a; }
.branchMenu header { display: flex; align-items: center; justify-content: space-between; padding: 3px 6px 6px; color: #92877c; font-size: 11px; } .branchMenu header { display: flex; align-items: center; justify-content: space-between; padding: 3px 6px 6px; color: #92877c; font-size: 11px; }
.branchMenu header button { display: grid; place-items: center; width: 24px; height: 24px; } .branchMenu header button { display: grid; place-items: center; width: 24px; height: 24px; }
.branchMenu > button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 36px; padding: 8px; border-radius: 5px; font-size: 13px; text-align: left; } .branchMenu > button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 36px; padding: 8px; border-radius: 5px; font-size: 13px; text-align: left; }
.branchMenu button:hover { background: #f1e6df; } .branchMenu button:hover { background: #f1e6df; }
/* 节点图标填满内容区域,SVG 自身不截获指针,事件交给外层节点。 */
.iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; } .iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; }
.iconContent svg { pointer-events: none; } .iconContent svg { pointer-events: none; }
/* 图标选择器固定在左侧,可用高度限定并采用纵向 flex,头/搜索/分类与列表分区。 */
.iconPicker { position: fixed; top: 70px; left: 12px; bottom: 70px; z-index: 60; display: flex; flex-direction: column; width: 272px; max-width: calc(100vw - 24px); max-height: 550px; padding: 14px; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a14; } .iconPicker { position: fixed; top: 70px; left: 12px; bottom: 70px; z-index: 60; display: flex; flex-direction: column; width: 272px; max-width: calc(100vw - 24px); max-height: 550px; padding: 14px; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a14; }
.iconPicker header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 14px; } .iconPicker header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 14px; }
.iconPicker header button { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 4px; } .iconPicker header button { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 4px; }
.iconPicker input { width: 100%; padding: 7px 9px; border: 1px solid #ded7ce; border-radius: 6px; font-size: 12px; margin-bottom: 12px; } .iconPicker input { width: 100%; padding: 7px 9px; border: 1px solid #ded7ce; border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
/* 普通图标网格五列并内部滚动;后续 virtualIconGrid 是虚拟列表视口,窗口项目由 JS 定位。 */
.iconGrid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; overflow-y: auto; } .iconGrid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; overflow-y: auto; }
.iconGrid button { display: grid; place-items: center; aspect-ratio: 1; border-radius: 6px; color: #6d6054; } .iconGrid button { display: grid; place-items: center; aspect-ratio: 1; border-radius: 6px; color: #6d6054; }
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; } .iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; } .iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; }
/* 来源选择器限制宽度,分类按钮可换行并按 aria-pressed 显示当前筛选。 */
.iconSource { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-size: 11px; } .iconSource { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-size: 11px; }
.iconSource select { min-width: 0; max-width: 175px; padding: 4px; border: 1px solid #ded7ce; border-radius: 5px; background: transparent; } .iconSource select { min-width: 0; max-width: 175px; padding: 4px; border: 1px solid #ded7ce; border-radius: 5px; background: transparent; }
.iconCategories { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; } .iconCategories { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
.iconCategories button { padding: 3px 6px; border-radius: 4px; color: #92877c; font-size: 10px; } .iconCategories button { padding: 3px 6px; border-radius: 4px; color: #92877c; font-size: 10px; }
.iconCategories button[aria-pressed="true"] { background: #f1e6df; color: #78523f; } .iconCategories button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
/* flex:1 让列表吃剩余高度,overscroll contain 减少滚动传到外层;CSS 不负责裁剪数据窗口。 */
.virtualIconGrid { flex: 1; min-height: 96px; overflow-y: auto; overscroll-behavior: contain; } .virtualIconGrid { flex: 1; min-height: 96px; overflow-y: auto; overscroll-behavior: contain; }
.virtualIconGrid button { display: grid; place-items: center; border: 3px solid transparent; border-radius: 8px; background-clip: padding-box; color: #6d6054; } .virtualIconGrid button { display: grid; place-items: center; border: 3px solid transparent; border-radius: 8px; background-clip: padding-box; color: #6d6054; }
.virtualIconGrid button:hover { background-color: #f1e6df; } .virtualIconGrid button:hover { background-color: #f1e6df; }
.virtualIconGrid button:focus-visible, .iconCategories button:focus-visible { outline: 2px solid #a47764; outline-offset: -2px; } .virtualIconGrid button:focus-visible, .iconCategories button:focus-visible { outline: 2px solid #a47764; outline-offset: -2px; }
/* 图标资源加载提示与选择器使用相近层级/起点。 */
.iconLoading { position: absolute; left: 12px; top: 70px; z-index: 60; padding: 14px; border-radius: 10px; background: #fffefb; font-size: 12px; } .iconLoading { position: absolute; left: 12px; top: 70px; z-index: 60; padding: 14px; border-radius: 10px; background: #fffefb; font-size: 12px; }
/* 归属徽标放在节点下方,限制长名称且不参与命中;旋转时隐藏以减少遮挡。 */
.membershipBadge { position: absolute; bottom: -25px; left: 0; display: flex; align-items: center; gap: 4px; max-width: 280px; padding: 3px 6px; border-radius: 4px; background: #e9f1e9; color: #3e684b; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; } .membershipBadge { position: absolute; bottom: -25px; left: 0; display: flex; align-items: center; gap: 4px; max-width: 280px; padding: 3px 6px; border-radius: 4px; background: #e9f1e9; color: #3e684b; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
.rotatingFrame .membershipBadge { display: none; } .rotatingFrame .membershipBadge { display: none; }
.frameMemberCount { font-size: 10px; color: #92877c; white-space: nowrap; } .frameMemberCount { font-size: 10px; color: #92877c; white-space: nowrap; }
/* 名称模式按钮宽度随文字,菜单固定定位且限制在视口内,勾选列保持对齐。 */
.styleToolbar .frameModeTrigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: auto; min-width: 96px; flex: 0 0 auto; padding: 0 7px; font-size: 12px; } .styleToolbar .frameModeTrigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: auto; min-width: 96px; flex: 0 0 auto; padding: 0 7px; font-size: 12px; }
.frameModeMenu { position: fixed; z-index: 120; width: 220px; max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); overflow-y: auto; padding: 5px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 8px 28px #32251a1f; } .frameModeMenu { position: fixed; z-index: 120; width: 220px; max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); overflow-y: auto; padding: 5px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 8px 28px #32251a1f; }
.frameModeMenu button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 7px; border-radius: 5px; text-align: left; } .frameModeMenu button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 7px; border-radius: 5px; text-align: left; }
@@ -94,33 +127,43 @@
.frameModeCheck { display: grid; place-items: center; width: 15px; flex: 0 0 15px; } .frameModeCheck { display: grid; place-items: center; width: 15px; flex: 0 0 15px; }
.frameModeMenu strong { display: block; font-size: 12px; font-weight: 500; } .frameModeMenu strong { display: block; font-size: 12px; font-weight: 500; }
.frameModeMenu small { display: block; margin-top: 2px; color: #92877c; font-size: 10px; } .frameModeMenu small { display: block; margin-top: 2px; color: #92877c; font-size: 10px; }
/* 加入目标用绿色边框/光晕,移出预览改虚线;提示元素本身不拦截拖放。 */
.frameNode.frameDropTarget { border-color: #538366; background: #5383660c; box-shadow: 0 0 0 3px #53836638; } .frameNode.frameDropTarget { border-color: #538366; background: #5383660c; box-shadow: 0 0 0 3px #53836638; }
.frameNode.frameDropExit { border-style: dashed; } .frameNode.frameDropExit { border-style: dashed; }
.frameDropHint { position: absolute; top: 12px; left: 12px; transform-origin: top left; border: 1px solid #9db8a4; border-radius: 6px; padding: 6px 9px; background: #f3f8f3; color: #3e684b; font-size: 12px; white-space: nowrap; pointer-events: none; } .frameDropHint { position: absolute; top: 12px; left: 12px; transform-origin: top left; border: 1px solid #9db8a4; border-radius: 6px; padding: 6px 9px; background: #f3f8f3; color: #3e684b; font-size: 12px; white-space: nowrap; pointer-events: none; }
/* 归属变更通知居中放在底部工具栏上方;空内容隐藏,非空也不接管指针。 */
.membershipNotice { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); z-index: 20; max-width: min(540px, calc(100vw - 32px)); padding: 8px 12px; border: 1px solid #c8d9ca; border-radius: 8px; background: #f3f8f3; color: #3e684b; font-size: 12px; pointer-events: none; } .membershipNotice { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); z-index: 20; max-width: min(540px, calc(100vw - 32px)); padding: 8px 12px; border: 1px solid #c8d9ca; border-radius: 8px; background: #f3f8f3; color: #3e684b; font-size: 12px; pointer-events: none; }
.membershipNotice:empty { display: none; } .membershipNotice:empty { display: none; }
/* 手形模式统一关闭文本、缩放、中心点和样式工具条的编辑入口。 */
.handMode .plainText { pointer-events: none; } .handMode .plainText { pointer-events: none; }
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; } .handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
.handMode .cornerResize { pointer-events: none; opacity: 0.6; } .handMode .cornerResize { pointer-events: none; opacity: 0.6; }
.handMode .styleToolbar { display: none; } .handMode .styleToolbar { display: none; }
/* 第一组减少动态效果规则关闭 frame、角缩放和中心点的过渡;后面还有分段滑块/连线动画的关闭规则。 */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle { transition: none; } .transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle { transition: none; }
} }
/* 分区主体 pointer-events:none,让内部节点/画布继续接收事件;标签和四边命中条另行启用 pointer-events。 */
.frameNode { border: 1px solid #c9b7a5; border-radius: 8px; background: transparent; pointer-events: none; } .frameNode { border: 1px solid #c9b7a5; border-radius: 8px; background: transparent; pointer-events: none; }
.frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; } .frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; }
/* 名称放在分区顶部外侧并以左下角缩放;文字截断但标签自身可拖动。 */
.frameLabel { position: absolute; bottom: calc(100% + 8px); left: 0; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 22px; color: #92806d; transform-origin: left bottom; pointer-events: auto; cursor: grab; } .frameLabel { position: absolute; bottom: calc(100% + 8px); left: 0; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 22px; color: #92806d; transform-origin: left bottom; pointer-events: auto; cursor: grab; }
/* 四条 10px 命中带跨在视觉边框两侧,扩大拖动区域,不给整个分区内部铺点击层。 */
.frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; } .frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; }
.frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; } .frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; }
.frameBorder_bottom { bottom: -5px; left: 0; right: 0; height: 10px; } .frameBorder_bottom { bottom: -5px; left: 0; right: 0; height: 10px; }
.frameBorder_left { left: -5px; top: 0; bottom: 0; width: 10px; } .frameBorder_left { left: -5px; top: 0; bottom: 0; width: 10px; }
.frameBorder_right { right: -5px; top: 0; bottom: 0; width: 10px; } .frameBorder_right { right: -5px; top: 0; bottom: 0; width: 10px; }
/* 分区改名输入沿用工具栏高度;手形模式下标签和命中边框也不接管事件。 */
.styleToolbar .frameNameInput { width: 150px; height: 28px; padding: 0 6px; border: 1px solid #ded7ce; border-radius: 4px; font-size: 12px; background: transparent; } .styleToolbar .frameNameInput { width: 150px; height: 28px; padding: 0 6px; border: 1px solid #ded7ce; border-radius: 4px; font-size: 12px; background: transparent; }
.handMode .frameBorderHit, .handMode .frameLabel { pointer-events: none; } .handMode .frameBorderHit, .handMode .frameLabel { pointer-events: none; }
/* 同步错误提示在顶部居中并允许按钮换行,说明文字独占一行。 */
.syncError { position: absolute; left: 50%; top: 70px; translate: -50% 0; z-index: 22; display: flex; flex-wrap: wrap; gap: 8px; max-width: min(580px, calc(100vw - 24px)); padding: 12px; border: 1px solid #e5c7b8; border-radius: 8px; background: #fff9f3; color: #a25439; font-size: 12px; } .syncError { position: absolute; left: 50%; top: 70px; translate: -50% 0; z-index: 22; display: flex; flex-wrap: wrap; gap: 8px; max-width: min(580px, calc(100vw - 24px)); padding: 12px; border: 1px solid #e5c7b8; border-radius: 8px; background: #fff9f3; color: #a25439; font-size: 12px; }
.syncError span { flex-basis: 100%; } .syncError span { flex-basis: 100%; }
/* 恢复对话框限制视口宽度并居中,backdrop 给遮罩色;模态焦点行为来自 dialog 而不是 CSS。 */
.recoveryDialog { max-width: min(460px, calc(100vw - 32px)); padding: 24px; margin: auto; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 12px 50px #32251a20; } .recoveryDialog { max-width: min(460px, calc(100vw - 32px)); padding: 24px; margin: auto; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 12px 50px #32251a20; }
.recoveryDialog::backdrop { background: #30251e33; } .recoveryDialog::backdrop { background: #30251e33; }
.recoveryDialog h2 { font-size: 16px; margin-bottom: 12px; } .recoveryDialog h2 { font-size: 16px; margin-bottom: 12px; }
@@ -128,24 +171,32 @@
.recoveryDialog > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; } .recoveryDialog > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
.recoveryDialog button { padding: 8px 12px; border: 1px solid #cbb8a8; border-radius: 5px; } .recoveryDialog button { padding: 8px 12px; border: 1px solid #cbb8a8; border-radius: 5px; }
/* 文本编辑容器允许 flex 收缩;readonly textarea 让出指针,由上层选择层处理选择/拖动。 */
.textEditor { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; border-radius: inherit; } .textEditor { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; border-radius: inherit; }
.textEditor textarea[readonly] { pointer-events: none; } .textEditor textarea[readonly] { pointer-events: none; }
/* 透明选择层盖住内容区域,选中后给 grab 光标;手形模式取消该层命中。 */
.textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; } .textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; }
.handMode .textSelectLayer { pointer-events: none; } .handMode .textSelectLayer { pointer-events: none; }
.selectedFrame .textSelectLayer { cursor: grab; } .selectedFrame .textSelectLayer { cursor: grab; }
.textSelectLayer:active { cursor: grabbing; } .textSelectLayer:active { cursor: grabbing; }
/* 纯图片没有卡片 padding,图片仅显示不接受选择/拖动,外层控制变换。 */
.plainImageFrame { padding: 0; } .plainImageFrame { padding: 0; }
.plainImage { width: 100%; height: 100%; display: block; object-fit: contain; pointer-events: none; user-select: none; } .plainImage { width: 100%; height: 100%; display: block; object-fit: contain; pointer-events: none; user-select: none; }
/* 标题省略与改名输入分开;输入固定目标宽度并继承字体。 */
.nodeTitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .nodeTitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.nodeTitleInput { width: 180px; min-width: 0; height: 22px; padding: 0 4px; border: 1px solid #cbb8a8; border-radius: 4px; background: #fffefb; font: inherit; color: #78523f; } .nodeTitleInput { width: 180px; min-width: 0; height: 22px; padding: 0 4px; border: 1px solid #cbb8a8; border-radius: 4px; background: #fffefb; font: inherit; color: #78523f; }
/* 三段图标选项用 isolation 建局部层叠,按钮在滑块上方,背景由滑块提供。 */
.frameLabelSegments { position: relative; display: flex; padding: 3px; border-radius: 6px; background: #f1eee8; isolation: isolate; } .frameLabelSegments { position: relative; display: flex; padding: 3px; border-radius: 6px; background: #f1eee8; isolation: isolate; }
.frameLabelSegments button { position: relative; z-index: 1; width: 28px; height: 28px; flex: 0 0 28px; background: transparent !important; color: #92877c; } .frameLabelSegments button { position: relative; z-index: 1; width: 28px; height: 28px; flex: 0 0 28px; background: transparent !important; color: #92877c; }
.frameLabelSegments button[aria-checked="true"] { color: #78523f; } .frameLabelSegments button[aria-checked="true"] { color: #78523f; }
/* 滑块绝对定位并用 transform 做短过渡,实际位移由组件按选项设置。 */
.frameLabelSlider { position: absolute; left: 3px; top: 3px; width: 28px; height: 28px; border-radius: 4px; background: #fffefb; box-shadow: 0 1px 4px #32251a16; transition: transform 160ms ease; } .frameLabelSlider { position: absolute; left: 3px; top: 3px; width: 28px; height: 28px; border-radius: 4px; background: #fffefb; box-shadow: 0 1px 4px #32251a16; transition: transform 160ms ease; }
/* 八向缩放 handle 按中心锚定,pointer-events:auto 覆盖外层让出命中的行为;手形模式再禁用。 */
.resizeHandle { position: absolute; width: 10px; height: 10px; transform: translate(-50%, -50%); border: 1px solid #a47764; border-radius: 2px; padding: 0; background: #fffefb; z-index: 12; touch-action: none; pointer-events: auto; } .resizeHandle { position: absolute; width: 10px; height: 10px; transform: translate(-50%, -50%); border: 1px solid #a47764; border-radius: 2px; padding: 0; background: #fffefb; z-index: 12; touch-action: none; pointer-events: auto; }
.handMode .resizeHandle { pointer-events: none; } .handMode .resizeHandle { pointer-events: none; }
/* 连线工具条可换行,数字、颜色、范围和选择器分别限宽,避免长表单撑出视口。 */
.edgeToolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 7px; max-width: min(680px, calc(100vw - 24px)); padding: 10px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a14; } .edgeToolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 7px; max-width: min(680px, calc(100vw - 24px)); padding: 10px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a14; }
.edgeToolbar label { display: flex; align-items: center; gap: 4px; font-size: 10px; } .edgeToolbar label { display: flex; align-items: center; gap: 4px; font-size: 10px; }
.edgeToolbar input[type="number"] { width: 50px; } .edgeToolbar input[type="number"] { width: 50px; }
@@ -156,17 +207,24 @@
.edgeToolbar button:hover { background: #f1e6df; } .edgeToolbar button:hover { background: #f1e6df; }
.edgeToolbar button:disabled { opacity: .4; } .edgeToolbar button:disabled { opacity: .4; }
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; } .edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
/* 曲线控制点用 pointer-events:all 开放 SVG 命中,触摸默认行为关闭,拖动逻辑由事件处理器负责。 */
.curveControl { cursor: move; touch-action: none; pointer-events: all; } .curveControl { cursor: move; touch-action: none; pointer-events: all; }
/* 连线效果分别用 dashoffset 流动和 opacity 脉冲;是否附加动画类由执行状态/配置决定。 */
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; } .edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; } .edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
/* 虚线一个周期移动 40 单位;脉冲从低透明到不透明再返回。 */
@keyframes edgeDash { to { stroke-dashoffset: -40; } } @keyframes edgeDash { to { stroke-dashoffset: -40; } }
@keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } } @keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
/* 第二组减少动态效果规则关闭滑块过渡和两种连线动画。 */
@media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } } @media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } }
/* 全画布 busy 覆盖层是独立呈现态;成员局部同步使用后部 memberSync,不应从这个类推断所有任务都会锁画布。 */
.canvasBusy { position: absolute; inset: 0; display: flex; align-items: start; justify-content: center; padding-top: 80px; z-index: 200; background: #fffefb35; cursor: progress; color: #78523f; font-size: 12px; } .canvasBusy { position: absolute; inset: 0; display: flex; align-items: start; justify-content: center; padding-top: 80px; z-index: 200; background: #fffefb35; cursor: progress; color: #78523f; font-size: 12px; }
/* 子图显示比例输入窄排布;折叠子图的摘要占内部 inset 区域并可点击。 */
.subgraphScale { display: flex; align-items: center; gap: 2px; font-size: 11px; } .subgraphScale { display: flex; align-items: center; gap: 2px; font-size: 11px; }
.subgraphScale input { width: 45px; } .subgraphScale input { width: 45px; }
.collapsedSubgraph { position: absolute; inset: 15px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: #92877c; pointer-events: auto; font-size: 12px; } .collapsedSubgraph { position: absolute; inset: 15px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: #92877c; pointer-events: auto; font-size: 12px; }
.collapsedSubgraph small { font-size: 10px; } .collapsedSubgraph small { font-size: 10px; }
/* 元素 AI 面板限制视口宽度;标题信息省略,提示词可纵向伸展,底部模型/传播/执行按钮横排。 */
.aiPanel { width: min(440px, calc(100vw - 28px)); border: 1px solid #ded7ce; border-radius: 13px; padding: 14px; background: #fffefb; box-shadow: 0 8px 30px #32251a16; color: #49372e; } .aiPanel { width: min(440px, calc(100vw - 28px)); border: 1px solid #ded7ce; border-radius: 13px; padding: 14px; background: #fffefb; box-shadow: 0 8px 30px #32251a16; color: #49372e; }
.aiPanel header { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 9px; } .aiPanel header { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 9px; }
.aiPanel header span { margin-left: auto; color: #92877c; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; } .aiPanel header span { margin-left: auto; color: #92877c; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
@@ -176,32 +234,40 @@
.aiPanel footer label { display: flex; align-items: center; gap: 4px; font-size: 10px; white-space: nowrap; } .aiPanel footer label { display: flex; align-items: center; gap: 4px; font-size: 10px; white-space: nowrap; }
.aiPanel footer button { width: 32px; height: 32px; flex: 0 0 32px; display: grid; place-items: center; border-radius: 8px; background: #78523f; color: white; } .aiPanel footer button { width: 32px; height: 32px; flex: 0 0 32px; display: grid; place-items: center; border-radius: 8px; background: #78523f; color: white; }
.aiPanel footer button:disabled { opacity: .35; } .aiPanel footer button:disabled { opacity: .35; }
/* 普通提示与 role=alert 错误采用不同颜色;冲突候选区域独立滚动并保留预格式文本换行。 */
.aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; } .aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; }
.aiPanel p[role="alert"] { color: #a25439; } .aiPanel p[role="alert"] { color: #a25439; }
.aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; } .aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; }
.aiConflict pre { white-space: pre-wrap; } .aiConflict pre { white-space: pre-wrap; }
.aiConflict img { max-height: 100px; } .aiConflict img { max-height: 100px; }
.aiConflict button { padding: 6px; color: #78523f; } .aiConflict button { padding: 6px; color: #78523f; }
/* 对通用变换 frame 再声明中心 handle:默认不可见不可命中,选中显示、connectionindicator 开启命中。 */
.transformFrame :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; border: 0; background: #a47764; opacity: 0; pointer-events: none; } .transformFrame :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; border: 0; background: #a47764; opacity: 0; pointer-events: none; }
.selectedFrame :global(.react-flow__handle).centerHandle { opacity: 1; } .selectedFrame :global(.react-flow__handle).centerHandle { opacity: 1; }
.transformFrame :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; } .transformFrame :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
/* 端口先定义几何,再在下一条默认隐藏/禁用;选中只显示,是否可连仍由 connectionindicator 决定。 */
.framePort { width: 8px; height: 8px; border: 1px solid #fffefb; background: #a47764; pointer-events: auto; } .framePort { width: 8px; height: 8px; border: 1px solid #fffefb; background: #a47764; pointer-events: auto; }
.framePort { opacity: 0; pointer-events: none; } .framePort { opacity: 0; pointer-events: none; }
.selectedFrame .framePort { opacity: 1; } .selectedFrame .framePort { opacity: 1; }
.framePort:global(.connectionindicator) { pointer-events: auto; } .framePort:global(.connectionindicator) { pointer-events: auto; }
.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; } .rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
/* busy 状态中的恢复/重试按钮仍显示明确可点表面。 */
.canvasBusy button { margin: 8px 5px; padding: 6px 9px; border: 1px solid #ded7ce; border-radius: 5px; background: #fffefb; cursor: pointer; } .canvasBusy button { margin: 8px 5px; padding: 6px 9px; border: 1px solid #ded7ce; border-radius: 5px; background: #fffefb; cursor: pointer; }
/* 图片上传状态浮在底部工具栏上方,宽度受容器限制,不作为节点持久数据。 */
.imageUploadStatus { position: absolute; bottom: 88px; left: 50%; transform: translateX(-50%); z-index: 120; display: flex; align-items: center; gap: 12px; max-width: min(600px,90%); padding: 10px 14px; background: #fffdf9; border: 1px solid #ded5cb; border-radius: 10px; color: #796e63; font-size: 12px; box-shadow: 0 4px 18px #372f2410; } .imageUploadStatus { position: absolute; bottom: 88px; left: 50%; transform: translateX(-50%); z-index: 120; display: flex; align-items: center; gap: 12px; max-width: min(600px,90%); padding: 10px 14px; background: #fffdf9; border: 1px solid #ded5cb; border-radius: 10px; color: #796e63; font-size: 12px; box-shadow: 0 4px 18px #372f2410; }
/* 成员同步只显示紧凑状态和下划线操作按钮,不使用全画布遮罩。 */
.memberSync { padding: 5px 9px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #796e63; font-size: 11px; box-shadow: 0 2px 8px #372f2410; } .memberSync { padding: 5px 9px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #796e63; font-size: 11px; box-shadow: 0 2px 8px #372f2410; }
.memberSync button { margin-left: 6px; border: 0; background: transparent; color: #78523f; text-decoration: underline; cursor: pointer; } .memberSync button { margin-left: 6px; border: 0; background: transparent; color: #78523f; text-decoration: underline; cursor: pointer; }
/* 导入状态面板位于右上,有宽高上限和内部滚动;summary 切换详情,任务之间用边线分隔。 */
.importPanel { position: absolute; top: 84px; right: 12px; z-index: 12; width: max-content; max-width: min(310px, calc(100vw - 28px)); max-height: 45vh; overflow: auto; border: 1px solid #ded7ce; border-radius: 10px; padding: 6px 9px; background: #fffefb; box-shadow: 0 4px 18px #32251a10; color: #49372e; font-size: 11px; } .importPanel { position: absolute; top: 84px; right: 12px; z-index: 12; width: max-content; max-width: min(310px, calc(100vw - 28px)); max-height: 45vh; overflow: auto; border: 1px solid #ded7ce; border-radius: 10px; padding: 6px 9px; background: #fffefb; box-shadow: 0 4px 18px #32251a10; color: #49372e; font-size: 11px; }
.importPanel summary { cursor: pointer; padding: 5px 0; } .importPanel summary { cursor: pointer; padding: 5px 0; }
.importPanel ul { padding-left: 16px; overflow-wrap: anywhere; } .importPanel ul { padding-left: 16px; overflow-wrap: anywhere; }
.importPanel p { margin: 5px 0; } .importPanel p { margin: 5px 0; }
.importPanel small { color: #92877c; } .importPanel small { color: #92877c; }
.importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; } .importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; }
/* 导入动作横排,其余任务按钮使用文字色与局部留白,任务状态由 runtime/组件决定。 */
.importActions { display: flex; align-items: center; gap: 8px; } .importActions { display: flex; align-items: center; gap: 8px; }
.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; } .importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; }
+34
View File
@@ -1,95 +1,121 @@
/* 当前 LandingPage 的局部配色、两栏首屏、可选预览卡片、功能区和响应式布局;预览交互由组件状态驱动,不使用真实画布模型。 */
/* 页面作用域内定义墨色/弱文字/边线变量,局部规则覆盖全局品牌类时使用 :global。 */
.page { --ink: #21221f; --muted: #797b73; --line: #e5e6df; background: #fff; color: var(--ink); font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; } .page { --ink: #21221f; --muted: #797b73; --line: #e5e6df; background: #fff; color: var(--ink); font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; }
/* 移除触摸高亮并保留明确的键盘可见焦点描边。 */
.page a, .page button { -webkit-tap-highlight-color: transparent; } .page a, .page button { -webkit-tap-highlight-color: transparent; }
.page a:focus-visible, .page button:focus-visible { outline: 2px solid #6879c2; outline-offset: 4px; } .page a:focus-visible, .page button:focus-visible { outline: 2px solid #6879c2; outline-offset: 4px; }
/* 导航内容限制最大宽度,品牌、导航链接和右侧操作区横排。 */
.nav { display: flex; align-items: center; gap: 40px; height: 88px; max-width: 1320px; padding: 0 48px; margin: auto; } .nav { display: flex; align-items: center; gap: 40px; height: 88px; max-width: 1320px; padding: 0 48px; margin: auto; }
.page :global(.brand) { color: var(--ink); gap: 8px; font-size: 19px; letter-spacing: -0.6px; } .page :global(.brand) { color: var(--ink); gap: 8px; font-size: 19px; letter-spacing: -0.6px; }
.page :global(.brand img) { width: 30px; height: 30px; } .page :global(.brand img) { width: 30px; height: 30px; }
.page :global(.brand small) { color: #92958c; font-size: 11px; font-weight: 400; letter-spacing: 0; } .page :global(.brand small) { color: #92958c; font-size: 11px; font-weight: 400; letter-spacing: 0; }
.nav nav { display: flex; gap: 28px; font-size: 13px; color: #72766c; } .nav nav { display: flex; gap: 28px; font-size: 13px; color: #72766c; }
.nav nav a:hover { color: var(--ink); } .nav nav a:hover { color: var(--ink); }
/* margin-left:auto 将登录和小 CTA 推到最右侧,hover 只改变表面色。 */
.navActions { margin-left: auto; display: flex; align-items: center; gap: 24px; font-size: 13px; } .navActions { margin-left: auto; display: flex; align-items: center; gap: 24px; font-size: 13px; }
.smallCta { display: inline-flex; align-items: center; gap: 12px; padding: 10px 15px; border: 1px solid #cfd2c8; border-radius: 5px; } .smallCta { display: inline-flex; align-items: center; gap: 12px; padding: 10px 15px; border: 1px solid #cfd2c8; border-radius: 5px; }
.smallCta:hover { background: #f5f6f0; } .smallCta:hover { background: #f5f6f0; }
/* 桌面首屏文案/预览用接近等宽的两列;clamp 让标题在边界内随视口缩放。 */
.hero { max-width: 1320px; margin: auto; padding: 82px 48px 90px; display: grid; grid-template-columns: 1fr 1.04fr; align-items: center; gap: 46px; } .hero { max-width: 1320px; margin: auto; padding: 82px 48px 90px; display: grid; grid-template-columns: 1fr 1.04fr; align-items: center; gap: 46px; }
.eyebrow { font: 10px/1.6 ui-monospace, "SFMono-Regular", monospace; letter-spacing: 1.25px; color: #90958a; } .eyebrow { font: 10px/1.6 ui-monospace, "SFMono-Regular", monospace; letter-spacing: 1.25px; color: #90958a; }
.heroCopy .eyebrow { display: flex; align-items: center; gap: 8px; white-space: nowrap; } .heroCopy .eyebrow { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.statusDot { width: 5px; height: 5px; background: #83966c; border-radius: 50%; } .statusDot { width: 5px; height: 5px; background: #83966c; border-radius: 50%; }
.hero h1 { font-size: clamp(38px, 4.4vw, 61px); font-weight: 600; line-height: 1.23; letter-spacing: -3px; margin: 22px 0; } .hero h1 { font-size: clamp(38px, 4.4vw, 61px); font-weight: 600; line-height: 1.23; letter-spacing: -3px; margin: 22px 0; }
.hero h1 > span { position: relative; white-space: nowrap; } .hero h1 > span { position: relative; white-space: nowrap; }
/* 标题装饰线通过伪元素绘制,不增加实际文本或影响行内宽度。 */
.hero h1 > span::after { content: ""; position: absolute; left: 0; bottom: -3px; width: 100%; height: 5px; border-bottom: 2px solid #b9c87c; border-radius: 50%; rotate: -2deg; } .hero h1 > span::after { content: ""; position: absolute; left: 0; bottom: -3px; width: 100%; height: 5px; border-bottom: 2px solid #b9c87c; border-radius: 50%; rotate: -2deg; }
.heroCopy > p { color: #6f7369; font-size: 15px; line-height: 1.9; font-weight: 400; } .heroCopy > p { color: #6f7369; font-size: 15px; line-height: 1.9; font-weight: 400; }
/* CTA 可换行,主按钮悬停上移 1px;旁边文字链接保持次级视觉权重。 */
.heroActions { display: flex; flex-wrap: wrap; align-items: center; gap: 22px; margin-top: 30px; } .heroActions { display: flex; flex-wrap: wrap; align-items: center; gap: 22px; margin-top: 30px; }
.primary { display: inline-flex; align-items: center; justify-content: center; gap: 16px; padding: 14px 20px; background: var(--ink); color: #fff; border: 1px solid var(--ink); border-radius: 5px; font-size: 13px; font-weight: 500; box-shadow: 0 2px 2px #21221f08; transition: background 160ms, translate 160ms; } .primary { display: inline-flex; align-items: center; justify-content: center; gap: 16px; padding: 14px 20px; background: var(--ink); color: #fff; border: 1px solid var(--ink); border-radius: 5px; font-size: 13px; font-weight: 500; box-shadow: 0 2px 2px #21221f08; transition: background 160ms, translate 160ms; }
.primary:hover { background: #41453b; translate: 0 -1px; } .primary:hover { background: #41453b; translate: 0 -1px; }
.textLink { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: #6e7565; } .textLink { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: #6e7565; }
.textLink:hover { color: #21221f; } .textLink:hover { color: #21221f; }
/* 手写风附注和星形装饰仅参与视觉排版。 */
.heroNote { margin-top: 34px; font-family: "Kaiti SC", "STKaiti", Georgia, serif; color: #969a8b; font-size: 13px; display: flex; align-items: center; gap: 8px; } .heroNote { margin-top: 34px; font-family: "Kaiti SC", "STKaiti", Georgia, serif; color: #969a8b; font-size: 13px; display: flex; align-items: center; gap: 8px; }
.scribbleStar { font-family: Georgia, serif; color: #929a74; font-size: 24px; } .scribbleStar { font-family: Georgia, serif; color: #929a74; font-size: 24px; }
/* 预览外壳轻微旋转并设置阴影,min-width:0 防止网格项被内容撑宽。 */
.preview { min-width: 0; border: 1px solid #e0e3d9; border-radius: 9px; background: #fafafa; box-shadow: 0 12px 50px #3c442508, 0 2px 8px #3c442505; rotate: -1deg; } .preview { min-width: 0; border: 1px solid #e0e3d9; border-radius: 9px; background: #fafafa; box-shadow: 0 12px 50px #3c442508, 0 2px 8px #3c442505; rotate: -1deg; }
.previewHeading { padding: 14px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; font-size: 10px; color: #777d70; } .previewHeading { padding: 14px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; font-size: 10px; color: #777d70; }
.previewHeading > span:first-child { display: flex; align-items: center; gap: 7px; color: #69745e; } .previewHeading > span:first-child { display: flex; align-items: center; gap: 7px; color: #69745e; }
.previewHeading > span:last-child { font: 8px ui-monospace, monospace; letter-spacing: 1px; } .previewHeading > span:last-child { font: 8px ui-monospace, monospace; letter-spacing: 1px; }
.tinyLogo { font-size: 20px; color: #4c5841; } .tinyLogo { font-size: 20px; color: #4c5841; }
/* 点阵预览固定宽高比,卡片和 SVG 连线在相同相对容器内绝对定位。 */
.scene { position: relative; aspect-ratio: 600 / 440; background-image: radial-gradient(#dce1d2 0.65px, transparent 0.65px); background-size: 17px 17px; } .scene { position: relative; aspect-ratio: 600 / 440; background-image: radial-gradient(#dce1d2 0.65px, transparent 0.65px); background-size: 17px 17px; }
.wires { position: absolute; inset: 0; width: 100%; height: 100%; stroke: #a3ad90; stroke-width: 1; } .wires { position: absolute; inset: 0; width: 100%; height: 100%; stroke: #a3ad90; stroke-width: 1; }
.wires circle { fill: #a3ad90; } .wires circle { fill: #a3ad90; }
/* 预览卡片作为按钮可点击,hover 上移,activeCard 通过边框/阴影反馈当前选择。 */
.scene > button { position: absolute; display: flex; flex-direction: column; text-align: left; border: 1px solid #e1e4d8; border-radius: 5px; padding: 14px; background: #fff; color: #373a35; box-shadow: 0 2px 5px #32471504; transition: box-shadow 180ms, border-color 180ms, translate 180ms; } .scene > button { position: absolute; display: flex; flex-direction: column; text-align: left; border: 1px solid #e1e4d8; border-radius: 5px; padding: 14px; background: #fff; color: #373a35; box-shadow: 0 2px 5px #32471504; transition: box-shadow 180ms, border-color 180ms, translate 180ms; }
.scene > button:hover { translate: 0 -3px; } .scene > button:hover { translate: 0 -3px; }
.scene .activeCard { border-color: #9aa77d; box-shadow: 0 0 0 2px #9aa77d15, 0 4px 12px #32471508; } .scene .activeCard { border-color: #9aa77d; box-shadow: 0 0 0 2px #9aa77d15, 0 4px 12px #32471508; }
.cardLabel { display: flex; align-items: center; gap: 5px; font-size: 9px; color: #9aab88; } .cardLabel { display: flex; align-items: center; gap: 5px; font-size: 9px; color: #9aab88; }
/* 三类预览卡片各有位置、旋转和排版;纸角、艺术图与标签是局部装饰。 */
.ideaCard { left: 5%; top: 17%; width: 42%; min-height: 41%; rotate: -3deg; } .ideaCard { left: 5%; top: 17%; width: 42%; min-height: 41%; rotate: -3deg; }
.ideaCard strong { margin-top: 14px; margin-bottom: 13px; font-size: clamp(13px, 1.6vw, 22px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.55; } .ideaCard strong { margin-top: 14px; margin-bottom: 13px; font-size: clamp(13px, 1.6vw, 22px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.55; }
.cardText { font-size: 9px; line-height: 1.8; color: #9ba48f; } .cardText { font-size: 9px; line-height: 1.8; color: #9ba48f; }
.paperCorner { position: absolute; bottom: 4px; right: 4px; width: 10px; height: 10px; border-right: 1px solid #d9dece; border-bottom: 1px solid #d9dece; } .paperCorner { position: absolute; bottom: 4px; right: 4px; width: 10px; height: 10px; border-right: 1px solid #d9dece; border-bottom: 1px solid #d9dece; }
/* 艺术卡片给 SVG 可用高度,使用无填充线稿;便签另有底色与字号。 */
.scene .artCard { top: 8%; right: 7%; width: 29%; min-height: 49%; background: #edeaf7; rotate: 4deg; } .scene .artCard { top: 8%; right: 7%; width: 29%; min-height: 49%; background: #edeaf7; rotate: 4deg; }
.artCard svg { width: 100%; max-height: 150px; flex: 1; stroke: #53584d; stroke-width: 1.2; fill: none; } .artCard svg { width: 100%; max-height: 150px; flex: 1; stroke: #53584d; stroke-width: 1.2; fill: none; }
.artCaption { font: italic 8px Georgia, serif; text-align: center; color: #909e79; } .artCaption { font: italic 8px Georgia, serif; text-align: center; color: #909e79; }
.scene .noteCard { left: 38%; bottom: 9%; width: 37%; min-height: 27%; background: #f0eedf; rotate: 2deg; } .scene .noteCard { left: 38%; bottom: 9%; width: 37%; min-height: 27%; background: #f0eedf; rotate: 2deg; }
.noteCard strong { margin: 9px 0 12px; font-size: clamp(11px, 1.4vw, 18px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.5; } .noteCard strong { margin: 9px 0 12px; font-size: clamp(11px, 1.4vw, 18px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.5; }
.noteTag { align-self: flex-start; font-size: 8px; padding: 3px 5px; border: 1px solid #d6d6ba; border-radius: 3px; color: #8f9278; } .noteTag { align-self: flex-start; font-size: 8px; padding: 3px 5px; border: 1px solid #d6d6ba; border-radius: 3px; color: #8f9278; }
/* 随手附注与假协作光标定位在预览中,光标不拦截鼠标事件。 */
.looseNote { position: absolute; left: 8%; bottom: 12%; color: #aab39c; rotate: -10deg; font: italic 14px Georgia, serif; } .looseNote { position: absolute; left: 8%; bottom: 12%; color: #aab39c; rotate: -10deg; font: italic 14px Georgia, serif; }
.cursor { position: absolute; right: 17%; bottom: 20%; color: #879567; rotate: -12deg; pointer-events: none; } .cursor { position: absolute; right: 17%; bottom: 20%; color: #879567; rotate: -12deg; pointer-events: none; }
.cursor span { position: absolute; top: 20px; left: 12px; background: #879567; color: white; border-radius: 3px; padding: 3px 7px; font-size: 9px; } .cursor span { position: absolute; top: 20px; left: 12px; background: #879567; color: white; border-radius: 3px; padding: 3px 7px; font-size: 9px; }
/* 预览页脚横排指示点与页码,aria-pressed 标示当前点,最后一项推到右侧。 */
.previewFooter { display: flex; align-items: center; gap: 10px; padding: 13px 18px; border-top: 1px solid var(--line); font-size: 9px; color: #a1a992; } .previewFooter { display: flex; align-items: center; gap: 10px; padding: 13px 18px; border-top: 1px solid var(--line); font-size: 9px; color: #a1a992; }
.previewFooter > div { display: flex; gap: 5px; } .previewFooter > div { display: flex; gap: 5px; }
.previewFooter button { width: 5px; height: 5px; border-radius: 50%; background: #d8dfcc; } .previewFooter button { width: 5px; height: 5px; border-radius: 50%; background: #d8dfcc; }
.previewFooter button[aria-pressed="true"] { background: #879567; } .previewFooter button[aria-pressed="true"] { background: #879567; }
.previewFooter > span:last-child { margin-left: auto; font: 9px ui-monospace, monospace; } .previewFooter > span:last-child { margin-left: auto; font: 9px ui-monospace, monospace; }
/* 宣言横条用上下边线与两侧注记分隔首屏和后续内容。 */
.manifestoStrip { display: flex; align-items: center; justify-content: space-between; gap: 20px; max-width: 1224px; margin: 0 auto; padding: 25px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } .manifestoStrip { display: flex; align-items: center; justify-content: space-between; gap: 20px; max-width: 1224px; margin: 0 auto; padding: 25px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.manifestoStrip > span { font: 8px ui-monospace, monospace; letter-spacing: 0.6px; color: #a0a494; } .manifestoStrip > span { font: 8px ui-monospace, monospace; letter-spacing: 0.6px; color: #a0a494; }
.manifestoStrip p { font-size: 13px; color: #646c58; letter-spacing: 0.3px; } .manifestoStrip p { font-size: 13px; color: #646c58; letter-spacing: 0.3px; }
.manifestoStrip p span { color: #9ba28e; } .manifestoStrip p span { color: #9ba28e; }
/* 功能区域限制最大宽度;标题与说明左右对齐,功能卡使用三列网格。 */
.space { max-width: 1320px; margin: auto; padding: 105px 48px; } .space { max-width: 1320px; margin: auto; padding: 105px 48px; }
.sectionHeading { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; margin-bottom: 48px; } .sectionHeading { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; margin-bottom: 48px; }
.sectionHeading h2, .faq h2 { font-size: 34px; line-height: 1.5; letter-spacing: -1px; font-weight: 500; margin-top: 18px; } .sectionHeading h2, .faq h2 { font-size: 34px; line-height: 1.5; letter-spacing: -1px; font-weight: 500; margin-top: 18px; }
.sectionHeading > p { padding-bottom: 5px; color: #6c7166; line-height: 1.9; font-size: 13px; } .sectionHeading > p { padding-bottom: 5px; color: #6c7166; line-height: 1.9; font-size: 13px; }
.featureGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; } .featureGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; }
.featureGrid article { min-width: 0; } .featureGrid article { min-width: 0; }
/* 每个功能插画使用固定高度裁剪容器,内部收集/连线/创作布局分别绘制。 */
.featureArt { position: relative; height: 185px; border: 1px solid #eaede4; border-radius: 6px; background: #f9faf6; overflow: hidden; margin-bottom: 25px; } .featureArt { position: relative; height: 185px; border: 1px solid #eaede4; border-radius: 6px; background: #f9faf6; overflow: hidden; margin-bottom: 25px; }
/* 收集示例用轻微旋转和水平位移错开三条素材,保持纵向居中。 */
.collectArt { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; } .collectArt { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; }
.collectArt span { display: flex; align-items: center; gap: 9px; padding: 8px 12px; min-width: 70%; border: 1px solid #e1e6d7; border-radius: 4px; background: #fff; font-size: 10px; color: #859471; rotate: -2deg; } .collectArt span { display: flex; align-items: center; gap: 9px; padding: 8px 12px; min-width: 70%; border: 1px solid #e1e6d7; border-radius: 4px; background: #fff; font-size: 10px; color: #859471; rotate: -2deg; }
.collectArt span:nth-child(2) { rotate: 2deg; translate: 8px 0; } .collectArt span:nth-child(2) { rotate: 2deg; translate: 8px 0; }
.collectArt span:nth-child(3) { rotate: -1deg; translate: -5px 0; } .collectArt span:nth-child(3) { rotate: -1deg; translate: -5px 0; }
.collectArt svg { width: 14px; } .collectArt svg { width: 14px; }
/* 连线示例 SVG 铺满插画,两个端点卡片和装饰字形单独定位。 */
.connectArt svg { width: 100%; height: 100%; stroke: #b1b99e; } .connectArt svg { width: 100%; height: 100%; stroke: #b1b99e; }
.connectArt span { position: absolute; top: 39px; left: 23px; padding: 10px 13px; border: 1px solid #e1e5d6; border-radius: 5px; background: #fff; color: #8a9877; font-size: 11px; } .connectArt span { position: absolute; top: 39px; left: 23px; padding: 10px 13px; border: 1px solid #e1e5d6; border-radius: 5px; background: #fff; color: #8a9877; font-size: 11px; }
.connectArt span:nth-of-type(2) { top: 110px; left: auto; right: 35px; background: #edf0e1; } .connectArt span:nth-of-type(2) { top: 110px; left: auto; right: 35px; background: #edf0e1; }
.connectArt i { position: absolute; top: 38px; right: 25px; color: #a0ad86; font: 32px Georgia, serif; } .connectArt i { position: absolute; top: 38px; right: 25px; color: #a0ad86; font: 32px Georgia, serif; }
/* 创作示例居中纸张和标题,编辑徽标独立固定在插画右下附近。 */
.createArt { display: grid; place-items: center; } .createArt { display: grid; place-items: center; }
.createArt > div { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px; background: #f1efe3; width: 72%; rotate: -2deg; border: 1px solid #e0dfcf; } .createArt > div { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px; background: #f1efe3; width: 72%; rotate: -2deg; border: 1px solid #e0dfcf; }
.createArt > div > span { font: 7px ui-monospace, monospace; letter-spacing: 1px; color: #9da184; } .createArt > div > span { font: 7px ui-monospace, monospace; letter-spacing: 1px; color: #9da184; }
.createArt strong { font: 25px "Songti SC", Georgia, serif; color: #6e7857; letter-spacing: 1px; } .createArt strong { font: 25px "Songti SC", Georgia, serif; color: #6e7857; letter-spacing: 1px; }
.editBadge { position: absolute; right: 14px; bottom: 24px; display: flex; align-items: center; gap: 5px; padding: 5px 8px; border: 1px solid #dce3cf; background: #fff; border-radius: 4px; font-size: 8px; color: #889874; } .editBadge { position: absolute; right: 14px; bottom: 24px; display: flex; align-items: center; gap: 5px; padding: 5px 8px; border: 1px solid #dce3cf; background: #fff; border-radius: 4px; font-size: 8px; color: #889874; }
/* 编号、功能标题与正文使用逐级字号,正文行高便于多行阅读。 */
.featureNumber { color: #a4ab98; font: 9px ui-monospace, monospace; letter-spacing: 0.7px; } .featureNumber { color: #a4ab98; font: 9px ui-monospace, monospace; letter-spacing: 0.7px; }
.featureGrid h3 { font-size: 18px; font-weight: 500; margin: 12px 0; letter-spacing: -0.4px; } .featureGrid h3 { font-size: 18px; font-weight: 500; margin: 12px 0; letter-spacing: -0.4px; }
.featureGrid p { font-size: 12px; color: #6c7166; line-height: 1.9; } .featureGrid p { font-size: 12px; color: #6c7166; line-height: 1.9; }
/* 工作流区域用浅底与上下边线分隔,两列分别承载引语和步骤。 */
.workflow { background: #f7f7f4; border-top: 1px solid #e9ece1; border-bottom: 1px solid #e9ece1; display: grid; grid-template-columns: 1fr 1fr; gap: 90px; padding: 85px max(48px, calc((100vw - 1224px) / 2)); } .workflow { background: #f7f7f4; border-top: 1px solid #e9ece1; border-bottom: 1px solid #e9ece1; display: grid; grid-template-columns: 1fr 1fr; gap: 90px; padding: 85px max(48px, calc((100vw - 1224px) / 2)); }
/* 引号绝对定位在文案旁,标题自身保持相对定位,签名字体与正文区分。 */
.quote { position: relative; } .quote { position: relative; }
.quoteMark { font: 80px/1 Georgia, serif; position: absolute; top: -30px; left: -12px; color: #d9dfca; } .quoteMark { font: 80px/1 Georgia, serif; position: absolute; top: -30px; left: -12px; color: #d9dfca; }
.quote h2 { position: relative; font: 31px/1.7 "Songti SC", Georgia, serif; color: #586449; } .quote h2 { position: relative; font: 31px/1.7 "Songti SC", Georgia, serif; color: #586449; }
.quote em { display: block; font-style: normal; } .quote em { display: block; font-style: normal; }
.quote p { font-size: 12px; color: #747a6d; line-height: 2; margin-top: 18px; } .quote p { font-size: 12px; color: #747a6d; line-height: 2; margin-top: 18px; }
.signature { display: block; font: italic 13px Georgia, serif; color: #a3ad94; margin-top: 30px; } .signature { display: block; font: italic 13px Georgia, serif; color: #a3ad94; margin-top: 30px; }
/* 步骤纵向居中,每项含编号、文字和右侧不收缩的图标。 */
.workflowList { display: flex; flex-direction: column; justify-content: center; } .workflowList { display: flex; flex-direction: column; justify-content: center; }
.workflowList > div { display: flex; gap: 18px; padding: 24px 0; border-bottom: 1px solid #dfe5d4; } .workflowList > div { display: flex; gap: 18px; padding: 24px 0; border-bottom: 1px solid #dfe5d4; }
.workflowList > div:first-child { padding-top: 0; } .workflowList > div:first-child { padding-top: 0; }
@@ -97,11 +123,13 @@
.workflowList h3 { font-size: 15px; font-weight: 500; color: #5e6e4e; margin-bottom: 8px; } .workflowList h3 { font-size: 15px; font-weight: 500; color: #5e6e4e; margin-bottom: 8px; }
.workflowList p { font-size: 11px; line-height: 1.8; color: #747a6d; } .workflowList p { font-size: 11px; line-height: 1.8; color: #747a6d; }
.workflowList svg { flex-shrink: 0; color: #a5b38e; margin-left: auto; } .workflowList svg { flex-shrink: 0; color: #a5b38e; margin-left: auto; }
/* FAQ 桌面为标题/问题两列,问题按钮整行可点击;展开内容是否存在由组件决定。 */
.faq { max-width: 1320px; padding: 105px 48px; margin: auto; display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 90px; } .faq { max-width: 1320px; padding: 105px 48px; margin: auto; display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 90px; }
.faqItem { border-bottom: 1px solid var(--line); } .faqItem { border-bottom: 1px solid var(--line); }
.faqItem button { display: flex; align-items: center; justify-content: space-between; gap: 20px; width: 100%; padding: 23px 0; font-size: 14px; text-align: left; } .faqItem button { display: flex; align-items: center; justify-content: space-between; gap: 20px; width: 100%; padding: 23px 0; font-size: 14px; text-align: left; }
.faqItem svg { flex-shrink: 0; color: #929b84; transition: rotate 160ms; } .faqItem svg { flex-shrink: 0; color: #929b84; transition: rotate 160ms; }
.faqItem p { padding: 0 28px 24px 0; font-size: 12px; line-height: 1.9; color: #8b947e; } .faqItem p { padding: 0 28px 24px 0; font-size: 12px; line-height: 1.9; color: #8b947e; }
/* 收尾 CTA 居中,星形装饰绝对定位;页脚内容横排并用 auto margin 推开链接。 */
.closing { position: relative; border-top: 1px solid var(--line); text-align: center; padding: 85px 24px 90px; } .closing { position: relative; border-top: 1px solid var(--line); text-align: center; padding: 85px 24px 90px; }
.closing h2 { font-size: 38px; letter-spacing: -1.3px; font-weight: 500; margin: 18px 0; } .closing h2 { font-size: 38px; letter-spacing: -1.3px; font-weight: 500; margin: 18px 0; }
.closing p { font-size: 13px; color: #74796d; margin-bottom: 28px; } .closing p { font-size: 13px; color: #74796d; margin-bottom: 28px; }
@@ -110,8 +138,11 @@
.footer > p { font-size: 11px; color: #a1a992; } .footer > p { font-size: 11px; color: #a1a992; }
.footer > a:not(:first-child) { margin-left: auto; display: flex; gap: 7px; align-items: center; font-size: 11px; color: #7e8b6a; } .footer > a:not(:first-child) { margin-left: auto; display: flex; gap: 7px; align-items: center; font-size: 11px; color: #7e8b6a; }
.footer > span { color: #abb39d; font: 8px ui-monospace, monospace; } .footer > span { color: #abb39d; font: 8px ui-monospace, monospace; }
/* 断行标记默认隐藏,本文件没有另行显示它的媒体查询。 */
.mobileBreak { display: none; } .mobileBreak { display: none; }
/* 大屏仅增大首屏上下留白。 */
@media (min-width: 1400px) { .hero { padding-top: 105px; padding-bottom: 110px; } } @media (min-width: 1400px) { .hero { padding-top: 105px; padding-bottom: 110px; } }
/* 中屏压缩页面边距、标题、按钮及区块间距,仍保留主要双栏/三栏结构。 */
@media (max-width: 1050px) { @media (max-width: 1050px) {
.nav, .hero, .space, .faq, .footer { padding-left: 30px; padding-right: 30px; } .nav, .hero, .space, .faq, .footer { padding-left: 30px; padding-right: 30px; }
.hero { gap: 26px; } .hero { gap: 26px; }
@@ -124,6 +155,7 @@
.featureGrid { gap: 20px; } .featureGrid { gap: 20px; }
.faq { gap: 45px; } .faq { gap: 45px; }
} }
/* 窄屏隐藏导航链接,将首屏、功能、工作流和 FAQ 转为单列,并缩小装饰及页脚内容。 */
@media (max-width: 760px) { @media (max-width: 760px) {
.nav { height: 74px; gap: 15px; padding-left: 22px; padding-right: 22px; } .nav { height: 74px; gap: 15px; padding-left: 22px; padding-right: 22px; }
.nav nav { display: none; } .nav nav { display: none; }
@@ -159,6 +191,7 @@
.footer > p { display: none; } .footer > p { display: none; }
.footer > span { flex-basis: 100%; } .footer > span { flex-basis: 100%; }
} }
/* 极窄屏进一步压缩边距和预览卡片文字,隐藏品牌副标,避免最小视口拥挤。 */
@media (max-width: 380px) { @media (max-width: 380px) {
.page :global(.brand small) { display: none; } .page :global(.brand small) { display: none; }
.hero { padding-left: 20px; padding-right: 20px; } .hero { padding-left: 20px; padding-right: 20px; }
@@ -170,4 +203,5 @@
.ideaCard strong { margin-top: 8px; margin-bottom: 8px; } .ideaCard strong { margin-top: 8px; margin-bottom: 8px; }
.noteTag { font-size: 7px; padding: 2px; } .noteTag { font-size: 7px; padding: 2px; }
} }
/* 按减少动态效果偏好关闭链接、按钮和 FAQ 图标的过渡。 */
@media (prefers-reduced-motion: reduce) { .page a, .page button, .faqItem svg { transition: none; } } @media (prefers-reduced-motion: reduce) { .page a, .page button, .faqItem svg { transition: none; } }
+5
View File
@@ -1,3 +1,5 @@
/* IconButton 的共享尺寸、交互态和 tooltip 外观;尺寸从所在 Toolbar 的自定义属性继承,缺省为 32px 控件/20px 图标。 */
/* 固定 flex 尺寸避免拥挤时压缩;inline-flex 居中图标,透明背景显露宿主表面,文字颜色继承宿主。 */
.button { .button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -13,10 +15,13 @@
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
} }
/* SVG 同时约束宽高与 flex-basis,防止图标在文字/布局挤压下变形。 */
.button svg { width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); flex: 0 0 var(--toolbar-icon-size, 20px); } .button svg { width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); flex: 0 0 var(--toolbar-icon-size, 20px); }
/* 悬停填底、键盘可见焦点描边、禁用态降低透明度并提示不可操作。 */
.button:hover { background: #e9e7e3; } .button:hover { background: #e9e7e3; }
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; } .button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
.button:disabled { opacity: 0.45; cursor: not-allowed; } .button:disabled { opacity: 0.45; cursor: not-allowed; }
/* tooltip 使用高层级深色面板与有限宽度;arrow 颜色与面板一致。 */
.tooltip { z-index: 1000; max-width: 240px; padding: 6px 9px; border-radius: 6px; background: #49372e; color: #fffdf9; font-size: 12px; line-height: 1.45; box-shadow: 0 3px 12px #372f2420; user-select: none; } .tooltip { z-index: 1000; max-width: 240px; padding: 6px 9px; border-radius: 6px; background: #49372e; color: #fffdf9; font-size: 12px; line-height: 1.45; box-shadow: 0 3px 12px #372f2420; user-select: none; }
.arrow { fill: #49372e; } .arrow { fill: #49372e; }
+4
View File
@@ -1,3 +1,5 @@
/* Toolbar 共享表面:统一控件尺寸变量、排列间距、边框和背景;不负责自身在画布中的定位。 */
/* 提供后代按钮消费的 CSS 变量;半透明背景与 backdrop blur 处理工具栏背后的内容。 */
.surface { .surface {
--toolbar-icon-size: 20px; --toolbar-icon-size: 20px;
--toolbar-control-width: 32px; --toolbar-control-width: 32px;
@@ -18,5 +20,7 @@
} }
/* Text in the view controls already creates wider visual spacing. */ /* Text in the view controls already creates wider visual spacing. */
/* 含缩放文字的视图工具栏增加横向留白,不改变控件本身的尺寸。 */
.relaxed { column-gap: 6px; padding-inline: 6px; } .relaxed { column-gap: 6px; padding-inline: 6px; }
/* 分隔线保持 1px flex 基准,避免被相邻控件挤宽或挤没。 */
.divider { flex: 0 0 1px; width: 1px; height: 16px; margin: 0 2px; background: #ece6df; } .divider { flex: 0 0 1px; width: 1px; height: 16px; margin: 0 2px; background: #ece6df; }
@@ -1,9 +1,15 @@
/* EditableTitle 的只读标题/输入框切换样式;壳体作为错误提示的定位参照,网络和提交状态由组件控制。 */
/* 相对定位保留标题所在行,绝对定位错误框不会把工具栏向下撑开。 */
.shell { position: relative; display: flex; align-items: center; } .shell { position: relative; display: flex; align-items: center; }
/* 两种状态共用高度、字体和透明边框,降低进入编辑时的尺寸跳变。 */
.title, .input { box-sizing: border-box; min-width: 0; max-width: 100%; height: 32px; padding: 0 6px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: inherit; font: inherit; line-height: 30px; } .title, .input { box-sizing: border-box; min-width: 0; max-width: 100%; height: 32px; padding: 0 6px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: inherit; font: inherit; line-height: 30px; }
/* 非编辑标题单行省略;悬停仅作用于可用按钮,选中态由 data-selected 控制。 */
.title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; } .title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; }
.title:hover:not(:disabled) { background: #f0ece6; } .title:hover:not(:disabled) { background: #f0ece6; }
.title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; } .title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; }
.title:disabled { cursor: default; } .title:disabled { cursor: default; }
/* 编辑态给定目标宽度但受上方 max-width 限制,readonly 时降低透明度。 */
.input { width: 220px; border-color: #a47764; background: white; outline: none; } .input { width: 220px; border-color: #a47764; background: white; outline: none; }
.input[readonly] { opacity: 0.65; } .input[readonly] { opacity: 0.65; }
/* 错误框显示在标题下方,并以视口宽度限制最大宽度;长说明允许换行。 */
.error { position: absolute; top: calc(100% + 10px); left: 0; width: 260px; max-width: calc(100vw - 40px); padding: 8px 10px; border: 1px solid #e5c7b8; border-radius: 7px; background: #fff9f3; color: #a25439; font-size: 12px; line-height: 1.6; white-space: normal; box-shadow: 0 4px 12px #32251a0a; } .error { position: absolute; top: calc(100% + 10px); left: 0; width: 260px; max-width: calc(100vw - 40px); padding: 8px 10px; border: 1px solid #e5c7b8; border-radius: 7px; background: #fff9f3; color: #a25439; font-size: 12px; line-height: 1.6; white-space: normal; box-shadow: 0 4px 12px #32251a0a; }
@@ -1,3 +1,5 @@
/* 工作区壳体、目录、账户菜单与画布浮动标题;CSS Module 负责显示,账号/画布生命周期和侧栏开关由组件管理。 */
/* 侧栏宽度变量供侧栏本体和浮动标题共同消费,根容器至少占满动态视口高度。 */
.shell { .shell {
--sidebar-width: 270px; --sidebar-width: 270px;
display: flex; display: flex;
@@ -5,6 +7,7 @@
background: #fffefb; background: #fffefb;
color: #37352f; color: #37352f;
} }
/* 侧栏插槽 sticky 固定在滚动视口顶部;裁剪展开过渡期间的内容,层级高于画布。 */
.sidebarSlot { .sidebarSlot {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -14,6 +17,7 @@
overflow: hidden; overflow: hidden;
z-index: 30; z-index: 30;
} }
/* 侧栏采用纵向 flex,导航区可滚动、账户区留在底部;最大宽度给主区域保留至少 40px。 */
.sidebar { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -29,30 +33,38 @@
letter-spacing: -0.15px; letter-spacing: -0.15px;
color: #37352f; color: #37352f;
} }
/* 顶行按钮分居两端;搜索框 focus-within 让输入聚焦时整个框有反馈。 */
.topRow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; color: #85817b; } .topRow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; color: #85817b; }
.search { display: flex; align-items: center; gap: 7px; min-height: 32px; border: 1px solid #e6e3df; border-radius: 7px; padding: 4px 8px; color: #97938d; } .search { display: flex; align-items: center; gap: 7px; min-height: 32px; border: 1px solid #e6e3df; border-radius: 7px; padding: 4px 8px; color: #97938d; }
.search:focus-within { border-color: #b2a292; box-shadow: 0 0 0 2px #b2a29218; } .search:focus-within { border-color: #b2a292; box-shadow: 0 0 0 2px #b2a29218; }
.search input { min-width: 0; width: 100%; padding: 0; background: transparent; border: 0; outline: none; font: inherit; color: #37352f; } .search input { min-width: 0; width: 100%; padding: 0; background: transparent; border: 0; outline: none; font: inherit; color: #37352f; }
.search input::placeholder { color: #9a9690; } .search input::placeholder { color: #9a9690; }
/* flex:1 配合 min-height:0 才允许长目录在侧栏内部滚动。 */
.navigation { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; } .navigation { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
/* 首页与目录链接共用行高/图标间距,长标题省略;aria-current 对当前页提供选中背景。 */
.home, .pageLink { display: flex; align-items: center; gap: 8px; padding: 4px 8px; min-height: 30px; border-radius: 6px; font-weight: 450; } .home, .pageLink { display: flex; align-items: center; gap: 8px; padding: 4px 8px; min-height: 30px; border-radius: 6px; font-weight: 450; }
.home { width: fit-content; font-weight: 600; background: #ecebe8; } .home { width: fit-content; font-weight: 600; background: #ecebe8; }
.pageLink { margin: 1px 0; } .pageLink { margin: 1px 0; }
.pageLink svg { flex-shrink: 0; color: #87837c; } .pageLink svg { flex-shrink: 0; color: #87837c; }
.pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pageLink:hover, .pageLink[aria-current="page"] { background: #eae7e2; } .pageLink:hover, .pageLink[aria-current="page"] { background: #eae7e2; }
/* 分组名称、空态提示和错误分别排版;错误允许在任意位置折行。 */
.sectionLabel { display: flex; justify-content: space-between; padding: 18px 7px 6px; color: #9b9690; font-size: 11px; font-weight: 400; letter-spacing: 0; } .sectionLabel { display: flex; justify-content: space-between; padding: 18px 7px 6px; color: #9b9690; font-size: 11px; font-weight: 400; letter-spacing: 0; }
.hint { padding: 4px 8px; color: #9a948c; line-height: 1.6; font-size: 12px; } .hint { padding: 4px 8px; color: #9a948c; line-height: 1.6; font-size: 12px; }
.error { margin: 6px 4px; font-size: 12px; color: #b14b3c; overflow-wrap: anywhere; } .error { margin: 6px 4px; font-size: 12px; color: #b14b3c; overflow-wrap: anywhere; }
/* 账户区域向侧栏底部靠齐,按钮占满宽度以便整行点击。 */
.accountArea { margin-top: auto; padding-top: 6px; } .accountArea { margin-top: auto; padding-top: 6px; }
.accountButton { display: flex; align-items: center; gap: 8px; padding: 6px 7px; min-height: 36px; width: 100%; border-radius: 6px; text-align: left; color: #37352f; } .accountButton { display: flex; align-items: center; gap: 8px; padding: 6px 7px; min-height: 36px; width: 100%; border-radius: 6px; text-align: left; color: #37352f; }
.accountButton:hover { background: #edeae5; } .accountButton:hover { background: #edeae5; }
/* 头像固定尺寸并裁成圆形,图片 object-fit:cover;缺图时容纳首字占位。 */
.avatar { display: grid; place-items: center; flex: 0 0 24px; width: 24px; height: 24px; overflow: hidden; border-radius: 50%; background: #e8e0d6; color: #705844; font-size: 12px; font-weight: 600; } .avatar { display: grid; place-items: center; flex: 0 0 24px; width: 24px; height: 24px; overflow: hidden; border-radius: 50%; background: #e8e0d6; color: #705844; font-size: 12px; font-weight: 600; }
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; } .avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
.avatarLarge { flex-basis: 36px; width: 36px; height: 36px; font-size: 16px; } .avatarLarge { flex-basis: 36px; width: 36px; height: 36px; font-size: 16px; }
/* 账户文字允许收缩和省略,副文字在此布局隐藏。 */
.accountText { display: flex; flex: 1; flex-direction: column; min-width: 0; } .accountText { display: flex; flex: 1; flex-direction: column; min-width: 0; }
.accountText strong { font-size: 13px; font-weight: 550; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .accountText strong { font-size: 13px; font-weight: 550; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.accountText small { display: none; } .accountText small { display: none; }
/* 原生 popover 的面板位置/宽度约束;自身可滚动且从左下角进入,不用 CSS 切换 open 状态。 */
.accountPopup { .accountPopup {
position: fixed; position: fixed;
inset: auto auto 50px 8px; inset: auto auto 50px 8px;
@@ -73,31 +85,40 @@
letter-spacing: -0.15px; letter-spacing: -0.15px;
transform-origin: bottom left; transform-origin: bottom left;
} }
/* 仅 popover 打开时播放进入动画;backdrop 保持透明,但是否可交互仍由 popover 行为决定。 */
.accountPopup:popover-open { animation: account-enter 130ms ease-out; } .accountPopup:popover-open { animation: account-enter 130ms ease-out; }
.accountPopup::backdrop { background: transparent; } .accountPopup::backdrop { background: transparent; }
/* 菜单头、工作区字母头像及身份行共用纵向文字布局,长名称省略。 */
.accountHeading { display: flex; align-items: center; gap: 10px; padding: 8px 7px 10px; } .accountHeading { display: flex; align-items: center; gap: 10px; padding: 8px 7px 10px; }
.workspaceAvatar { display: grid; place-items: center; flex: 0 0 36px; height: 36px; border-radius: 7px; color: #72533e; background: #eee8df; font-size: 19px; font-weight: 600; } .workspaceAvatar { display: grid; place-items: center; flex: 0 0 36px; height: 36px; border-radius: 7px; color: #72533e; background: #eee8df; font-size: 19px; font-weight: 600; }
.accountHeading > div, .popupIdentity > div { display: flex; flex-direction: column; min-width: 0; gap: 1px; } .accountHeading > div, .popupIdentity > div { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
.accountHeading strong { font-size: 14px; font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .accountHeading strong { font-size: 14px; font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.accountHeading small { color: #8b8780; font-size: 12px; } .accountHeading small { color: #8b8780; font-size: 12px; }
/* 菜单分组和身份区域提供内间距,邮箱允许断行;工作区列表限制高度后独立滚动。 */
.menuGroup { padding: 1px 0; } .menuGroup { padding: 1px 0; }
.popupIdentity { display: flex; align-items: center; gap: 8px; padding: 8px 7px 7px; } .popupIdentity { display: flex; align-items: center; gap: 8px; padding: 8px 7px 7px; }
.popupIdentity strong { font-size: 12px; font-weight: 500; } .popupIdentity strong { font-size: 12px; font-weight: 500; }
.popupIdentity small { color: #8b8780; font-size: 11px; overflow-wrap: anywhere; letter-spacing: 0; } .popupIdentity small { color: #8b8780; font-size: 11px; overflow-wrap: anywhere; letter-spacing: 0; }
.workspaceList { max-height: min(220px, 30dvh); overflow-y: auto; } .workspaceList { max-height: min(220px, 30dvh); overflow-y: auto; }
/* 菜单项整行可点,文字可收缩,图标保持宽度;disabled 态显式降低透明度。 */
.menuItem { display: flex; align-items: center; gap: 9px; width: 100%; min-height: 31px; padding: 5px 8px; border-radius: 5px; text-align: left; font-size: 13px; line-height: 20px; font-weight: 450; } .menuItem { display: flex; align-items: center; gap: 9px; width: 100%; min-height: 31px; padding: 5px 8px; border-radius: 5px; text-align: left; font-size: 13px; line-height: 20px; font-weight: 450; }
.menuItem > span:not(.workspaceMini) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .menuItem > span:not(.workspaceMini) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.menuItem > svg { flex-shrink: 0; } .menuItem > svg { flex-shrink: 0; }
.menuItem:hover { background: #f1f0ed; } .menuItem:hover { background: #f1f0ed; }
.menuItem:disabled { opacity: 0.45; cursor: not-allowed; } .menuItem:disabled { opacity: 0.45; cursor: not-allowed; }
/* 工作区小头像与新建/登出颜色区分,separator 分隔组而不引入额外布局容器。 */
.workspaceMini { display: grid; place-items: center; flex: 0 0 20px; height: 20px; border-radius: 4px; color: #82634c; background: #f1ebe3; font-size: 11px; font-weight: 600; } .workspaceMini { display: grid; place-items: center; flex: 0 0 20px; height: 20px; border-radius: 4px; color: #82634c; background: #f1ebe3; font-size: 11px; font-weight: 600; }
.newWorkspace { color: #2383e2; } .newWorkspace { color: #2383e2; }
.logout { color: #78746d; font-size: 12px; } .logout { color: #78746d; font-size: 12px; }
.separator { height: 1px; margin: 6px 7px; background: #eeece8; } .separator { height: 1px; margin: 6px 7px; background: #eeece8; }
/* 左上浮动条以 --floating-left 定位;侧栏展开时偏移到侧栏右侧,位置和宽度作 240ms 过渡。 */
.floating { --floating-left: 12px; position: fixed; left: var(--floating-left); top: 12px; max-width: calc(100vw - var(--floating-left) - 12px); z-index: 40; transition: left 240ms cubic-bezier(0.22, 1, 0.36, 1), max-width 240ms cubic-bezier(0.22, 1, 0.36, 1); } .floating { --floating-left: 12px; position: fixed; left: var(--floating-left); top: 12px; max-width: calc(100vw - var(--floating-left) - 12px); z-index: 40; transition: left 240ms cubic-bezier(0.22, 1, 0.36, 1), max-width 240ms cubic-bezier(0.22, 1, 0.36, 1); }
/* 展开侧栏时使用实际受视口约束的侧栏宽度计算偏移;账户 popover 也继承同一偏移变量。 */
.floating[data-sidebar-collapsed="false"], .accountPopup[data-sidebar-collapsed="false"] { --floating-left: calc(min(var(--sidebar-width), calc(100vw - 40px)) + 12px); } .floating[data-sidebar-collapsed="false"], .accountPopup[data-sidebar-collapsed="false"] { --floating-left: calc(min(var(--sidebar-width), calc(100vw - 40px)) + 12px); }
/* 浮动条错误绝对定位于条下方,账户设置面板则沿用对话框容器。 */
.floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; } .floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; }
.settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; } .settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; }
/* 设置表单纵向排布身份、字段及操作;提交按钮区分主动作,禁用时保留位置。 */
.settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; } .settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; }
.settingsIdentity { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #89837b; overflow-wrap: anywhere; } .settingsIdentity { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #89837b; overflow-wrap: anywhere; }
.settingsForm label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 500; } .settingsForm label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 500; }
@@ -106,35 +127,48 @@
.settingsActions button { padding: 6px 12px; border: 1px solid #e2ddd6; border-radius: 6px; font-size: 13px; } .settingsActions button { padding: 6px 12px; border: 1px solid #e2ddd6; border-radius: 6px; font-size: 13px; }
.settingsActions button[type="submit"] { background: #37352f; color: white; border-color: #37352f; } .settingsActions button[type="submit"] { background: #37352f; color: white; border-color: #37352f; }
.settingsActions button:disabled { opacity: 0.5; cursor: not-allowed; } .settingsActions button:disabled { opacity: 0.5; cursor: not-allowed; }
/* 固定全视口画布置于底层;点阵背景与加载/错误状态由后续类分别承担。 */
.graph { position: fixed; inset: 0; z-index: 0; display: flex; width: 100%; height: 100dvh; background: #fffefb; } .graph { position: fixed; inset: 0; z-index: 0; display: flex; width: 100%; height: 100dvh; background: #fffefb; }
.graphSurface { width: 100%; height: 100%; background-image: radial-gradient(#b8aea245 1px, transparent 1px); background-size: 22px 22px; } .graphSurface { width: 100%; height: 100%; background-image: radial-gradient(#b8aea245 1px, transparent 1px); background-size: 22px 22px; }
.graphStatus { position: absolute; left: 50%; top: 72px; transform: translateX(-50%); max-width: calc(100vw - 48px); padding: 8px 12px; border-radius: 6px; background: #fffefbe6; color: #9a948c; text-align: center; font-size: 13px; } .graphStatus { position: absolute; left: 50%; top: 72px; transform: translateX(-50%); max-width: calc(100vw - 48px); padding: 8px 12px; border-radius: 6px; background: #fffefbe6; color: #9a948c; text-align: center; font-size: 13px; }
/* 账户菜单从轻微下移/缩小过渡到正常位置与不透明状态。 */
@keyframes account-enter { from { opacity: 0; transform: translateY(4px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes account-enter { from { opacity: 0; transform: translateY(4px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
/* 较窄视口将侧栏目标宽度收为 240px;仍受上方 max-width 约束。 */
@media (max-width: 600px) { .shell { --sidebar-width: 240px; } } @media (max-width: 600px) { .shell { --sidebar-width: 240px; } }
/* 减少动态效果偏好下关闭菜单进入动画;浮动条过渡在文件后部单独关闭。 */
@media (prefers-reduced-motion: reduce) { .accountPopup:popover-open { animation: none; } } @media (prefers-reduced-motion: reduce) { .accountPopup:popover-open { animation: none; } }
/* 浮动条 logo 链接沿用 Toolbar 控件尺寸与键盘焦点样式,保证和旁边按钮对齐。 */
.logoLink { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); padding: 4px 6px; border-radius: 5px; } .logoLink { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); padding: 4px 6px; border-radius: 5px; }
.logoLink img { display: block; width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); } .logoLink img { display: block; width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); }
.logoLink:hover { background: #e9e7e3; } .logoLink:hover { background: #e9e7e3; }
.logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; } .logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
/* 浮动头像按钮同样使用共享尺寸;从浮动条打开的账户面板改为顶端定位。 */
.floatingAvatarButton { display: grid; place-items: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); border-radius: 5px; } .floatingAvatarButton { display: grid; place-items: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); border-radius: 5px; }
.floatingAvatarButton:hover { background: #e9e7e3; } .floatingAvatarButton:hover { background: #e9e7e3; }
/* clamp 把账户面板左边界限制在视口内;transform-origin 随顶部锚点改为左上。 */
.accountPopup[data-floating="true"] { inset: 62px auto auto clamp(8px, var(--floating-left, 12px), max(8px, calc(100vw - 308px))); max-height: calc(100dvh - 76px); transform-origin: top left; } .accountPopup[data-floating="true"] { inset: 62px auto auto clamp(8px, var(--floating-left, 12px), max(8px, calc(100vw - 308px))); max-height: calc(100dvh - 76px); transform-origin: top left; }
/* 浮动头像采用图标尺寸,标题允许 flex 收缩但保留溢出区域给编辑错误提示。 */
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; } .floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; } .floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
/* 中等窄屏降低标题最大宽度,不改变标题内容或编辑状态。 */
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } } @media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
/* 极窄且侧栏仍展开时隐藏 logo/标题/分隔,保留浮动条其余控件。 */
@media (max-width: 420px) { @media (max-width: 420px) {
.floating[data-sidebar-collapsed="false"] .logoLink, .floating[data-sidebar-collapsed="false"] .logoLink,
.floating[data-sidebar-collapsed="false"] .floatingTitle, .floating[data-sidebar-collapsed="false"] .floatingTitle,
.floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; } .floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; }
} }
/* 按用户偏好关闭浮动条位移/宽度过渡。 */
@media (prefers-reduced-motion: reduce) { .floating { transition: none; } } @media (prefers-reduced-motion: reduce) { .floating { transition: none; } }
/* 递归目录清除列表默认缩进,层级缩进由组件布局提供;行本身允许标题收缩。 */
.directoryList { list-style: none; padding: 0; margin: 0; } .directoryList { list-style: none; padding: 0; margin: 0; }
.directoryRow { display: flex; align-items: center; min-width: 0; } .directoryRow { display: flex; align-items: center; min-width: 0; }
.directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; } .directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; }
/* 可折叠项按钮与叶项占位共享 20px 列宽,保持各行标题对齐;焦点内描边不扩尺寸。 */
.directoryToggle, .directorySpacer { flex: 0 0 20px; width: 20px; height: 26px; } .directoryToggle, .directorySpacer { flex: 0 0 20px; width: 20px; height: 26px; }
.directoryToggle { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; } .directoryToggle { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; }
.directoryToggle:hover { background: #eae7e2; } .directoryToggle:hover { background: #eae7e2; }
+4 -1
View File
@@ -27,9 +27,11 @@ export async function api<T>(
path: string, path: string,
options: RequestInit = {}, options: RequestInit = {},
): Promise<T> { ): Promise<T> {
// fetch 本身拒绝(网络失败/发出前取消)时原异常直接传播;下面的 ApiError 包装只覆盖响应读取与 HTTP 错误。
const res = await fetch("/api/foundry/" + path, { const res = await fetch("/api/foundry/" + path, {
...options, ...options,
// FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。 // FormData让浏览器生成boundary,其它请求默认JSON;这里只展开 headers 的自有可枚举字段。
// 普通对象可覆盖同名默认字段;Headers 实例/二元组数组不会在这里被规范化成对象键值。
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers }, headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
}); });
let body: unknown; let body: unknown;
@@ -49,6 +51,7 @@ export async function api<T>(
const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error; const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error;
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after")); throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after"));
} }
// 204 的 null 同样通过类型断言返回;调用者选 T 时须包含真实的空响应约定。
return body as T; return body as T;
} }
/** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */ /** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */
+3
View File
@@ -10,6 +10,7 @@ type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
/** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。 /** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。
* next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */ * next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) { export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
// Map 是本次读取的临时去重表,全部分页成功才返回;某页失败不发布已积累的半份目录。
const boards = new Map<string, Board>(); const boards = new Map<string, Board>();
let offset = 0; let offset = 0;
const seen = new Set<number>(); const seen = new Set<number>();
@@ -28,6 +29,7 @@ export type BoardBranch = { board: Board; children: BoardBranch[] };
/** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。 /** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。
* 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */ * 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */
export function boardDirectory(boards: Board[]): BoardBranch[] { export function boardDirectory(boards: Board[]): BoardBranch[] {
// 重复 ID 采用最后一条数据、沿用首次插入顺序;分支节点是新对象,board 本身仍借用输入引用。
const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }])); const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }]));
const roots: BoardBranch[] = []; const roots: BoardBranch[] = [];
for (const entry of entries.values()) { for (const entry of entries.values()) {
@@ -56,6 +58,7 @@ export function filterBoardDirectory(branches: BoardBranch[], query: string): Bo
if (!term) return branches; if (!term) return branches;
return branches.flatMap((branch) => { return branches.flatMap((branch) => {
if (branch.board.title.toLocaleLowerCase().includes(term)) return [branch]; if (branch.board.title.toLocaleLowerCase().includes(term)) return [branch];
// 只重建需要裁剪 children 的祖先容器;直接命中的分支复用原对象,不深复制整棵树。
const children = filterBoardDirectory(branch.children, term); const children = filterBoardDirectory(branch.children, term);
return children.length ? [{ ...branch, children }] : []; return children.length ? [{ ...branch, children }] : [];
}); });
+3
View File
@@ -12,6 +12,7 @@ export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Reg
/** 锁/Storage可注入测试,生产默认使用navigator.locks/localStorage;身份和signal都由宿主管理。 */ /** 锁/Storage可注入测试,生产默认使用navigator.locks/localStorage;身份和signal都由宿主管理。 */
type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage}; type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
/** 同账户/工作区共享编辑锁键,与持久迁移记录同一作用域,跨页面形成读写互斥。 */ /** 同账户/工作区共享编辑锁键,与持久迁移记录同一作用域,跨页面形成读写互斥。 */
// 键不含 rootId:独占迁移锁会暂时排斥同账户/工作区所有共享旧编辑租约,而非仅目标树。
const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing'; const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
/** 缺少Web Locks明确拒绝安全迁移装配,不用不可靠的计时器冒充独占锁。 */ /** 缺少Web Locks明确拒绝安全迁移装配,不用不可靠的计时器冒充独占锁。 */
function ports(options:BrowserScope){ function ports(options:BrowserScope){
@@ -30,6 +31,7 @@ export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:st
let resolve!:(value:{release():Promise<void>})=>void,reject!:(error:unknown)=>void; let resolve!:(value:{release():Promise<void>})=>void,reject!:(error:unknown)=>void;
// ready在锁授予且本地pending检查通过后返回;request则在release/abort后才结束,两者用途不同。 // ready在锁授予且本地pending检查通过后返回;request则在release/abort后才结束,两者用途不同。
const ready=new Promise<{release():Promise<void>}>((yes,no)=>{resolve=yes;reject=no;}); const ready=new Promise<{release():Promise<void>}>((yes,no)=>{resolve=yes;reject=no;});
// signal 未传给 locks.request;取消在回调授予时检查,持锁后通过 abort 监听释放。
const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{ const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{
try{ try{
check(options);if(!lock)throw fault('migration_busy'); check(options);if(!lock)throw fault('migration_busy');
@@ -56,6 +58,7 @@ export function createBrowserCanvasMigration(options:BrowserScope&{registry:Regi
isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(), isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(),
// 任一共享旧编辑页仍开着时独占锁不可用;调用方先保存/释放自己的编辑租约,再进入迁移。 // 任一共享旧编辑页仍开着时独占锁不可用;调用方先保存/释放自己的编辑租约,再进入迁移。
lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{ lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{
// work 的 Promise 整体保持独占租约;拒绝同样交还锁,pending 记录不会随锁释放而自动删除。
check(options);if(!lock)throw fault('migration_busy');return work(); check(options);if(!lock)throw fault('migration_busy');return work();
}), }),
}); });
+1
View File
@@ -1,6 +1,7 @@
/** /**
* 旧卡片 renderer 使用的结构化输入类型,与 headless 元素 schema 分离。 * 旧卡片 renderer 使用的结构化输入类型,与 headless 元素 schema 分离。
* 这些类型描述投影/显示约定,不能用类型断言替代 registry 对原始数据、版本和引用的运行时校验。 * 这些类型描述投影/显示约定,不能用类型断言替代 registry 对原始数据、版本和引用的运行时校验。
* 本文件仅定义 TypeScript 形状,不运行默认值/迁移/编辑行为;投影器负责从标准 element 生成这些显示字段。
*/ */
/** 旧绘制路径使用的二维点;是否局部/场景由投影路径的调用约定决定。 */ /** 旧绘制路径使用的二维点;是否局部/场景由投影路径的调用约定决定。 */
type Point={x:number;y:number}; type Point={x:number;y:number};
+17 -7
View File
@@ -11,7 +11,9 @@ import {compatConnector} from './connectors.ts';
/** Conservative cubic control hull + half stroke, in the displayed scene space. /** Conservative cubic control hull + half stroke, in the displayed scene space.
* Missing/custom geometry stays unknown so a host cannot interpret it as invisible. */ * Missing/custom geometry stays unknown so a host cannot interpret it as invisible. */
/** 可选缓存的共享实现,限制本次边数和总曲线段数。 /** 可选缓存的共享实现,限制本次边数和总曲线段数。
* 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。 */ * 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。
* maxEdges 限访问的边数,maxSegments 同时作单边与总段数限;存在待查端点时仍遍历所有 projected nodes。
* 几何回调同步执行且没有时间抢占,回调产出数组前的成本不由预算控制;输入须属于同一有效显示帧。 */
function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096,cache?:Map<string,{signature:unknown;segments:number;region?:PaintRegion}>){ function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096,cache?:Map<string,{signature:unknown;segments:number;region?:PaintRegion}>){
if(![maxEdges,maxSegments].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1000000))throw Error('invalid_paint_budget'); if(![maxEdges,maxSegments].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1000000))throw Error('invalid_paint_budget');
const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map<string,typeof projection.nodes[number]>(),endpoints=new Set<string>(); const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map<string,typeof projection.nodes[number]>(),endpoints=new Set<string>();
@@ -29,21 +31,25 @@ function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Re
const anchor=(node:typeof source,handle:string|null|undefined,end:'source'|'target')=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left'); const anchor=(node:typeof source,handle:string|null|undefined,end:'source'|'target')=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
const from=anchor(source,edge.sourceHandle,'source'),to=anchor(target,edge.targetHandle,'target'),transform=projection.transforms.get(record.ownerCanvasId);if(!from||!to||!transform||![from.point,from.normal,to.point,to.normal].every(point=>point&&Number.isFinite(point.x)&&Number.isFinite(point.y))||![transform.x,transform.y,transform.scale,transform.rotation].every(Number.isFinite)||transform.scale<=0)throw Error(); const from=anchor(source,edge.sourceHandle,'source'),to=anchor(target,edge.targetHandle,'target'),transform=projection.transforms.get(record.ownerCanvasId);if(!from||!to||!transform||![from.point,from.normal,to.point,to.normal].every(point=>point&&Number.isFinite(point.x)&&Number.isFinite(point.y))||![transform.x,transform.y,transform.scale,transform.rotation].every(Number.isFinite)||transform.scale<=0)throw Error();
const protocol=edge.type!=='unsupported'&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined; const protocol=edge.type!=='unsupported'&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined;
// 同时捕获显示边、真实记录、端点位置/大小、锚点及变换;预览/移动都可能改变曲线范围。 // 签名复制锚点/端点数值/transform,但 record 与 edge 借用原不可变引用,缓存不深捕获它们。
// 外部几何服务/协议配置不在签名内,须通过下方 geometryRevision 或 registry 对象变化失效。
const signature={record,edge,from:{point:{...from.point},normal:{...from.normal}},to:{point:{...to.point},normal:{...to.normal}},transform:{...transform},source:{position:{...source.position},width:source.width,height:source.height},target:{position:{...target.position},width:target.width,height:target.height}},old=cache?.get(edge.id); const signature={record,edge,from:{point:{...from.point},normal:{...from.normal}},to:{point:{...to.point},normal:{...to.normal}},transform:{...transform},source:{position:{...source.position},width:source.width,height:source.height},target:{position:{...target.position},width:target.width,height:target.height}},old=cache?.get(edge.id);
// 即使本边范围是空,也可缓存“已证明没有额外范围”,避免重复调用几何服务。 // 锚点与基础输入每轮仍重新求取;签名相等才复用曲线范围/段数,跳过 segments/stroke 计算。
// 空 region 可表示笔画未超原生范围或空 segments,不能将其误当“整条边没有显示内容”。
if(old&&equal(old.signature,signature)){if(old.segments>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=old.segments;if(old.region)regions.push(old.region);continue;} if(old&&equal(old.signature,signature)){if(old.segments>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=old.segments;if(old.region)regions.push(old.region);continue;}
computed++; computed++;
// renderer 服务优先,随后用组件几何协议;stroke 也必须来自同一显示语义。 // renderer 服务优先,随后用组件几何协议;stroke 也必须来自同一显示语义。
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments({element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}):undefined),stroke=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined); const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments({element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}):undefined),stroke=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);
// 校验每边段数、线宽,再扣全局剩余段数;空段列表是已知无笔画,不应误标 unknown。 // 非空段数超过本次剩余预算则追加全局 paint_budget 并终止后续边;空数组仍要求有效 stroke。
// 空数组缓存0段/无region,属本几何协议声明无笔画,不误标 unknown。
if(!segments||!Array.isArray(segments)||segments.length>maxSegments||typeof stroke!=='number'||!Number.isFinite(stroke)||stroke<0)throw Error();if(!segments.length){cache?.set(edge.id,{signature,segments:0});continue;}if(segments.length>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=segments.length; if(!segments||!Array.isArray(segments)||segments.length>maxSegments||typeof stroke!=='number'||!Number.isFinite(stroke)||stroke<0)throw Error();if(!segments.length){cache?.set(edge.id,{signature,segments:0});continue;}if(segments.length>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=segments.length;
let left=Infinity,top=Infinity,right=-Infinity,bottom=-Infinity; let left=Infinity,top=Infinity,right=-Infinity,bottom=-Infinity;
// 三次曲线完全位于控制点凸包内,所有控制点的轴对齐包围盒提供保守范围。 // 三次曲线完全位于控制点凸包内,所有控制点的轴对齐包围盒提供保守范围。
for(const segment of segments){if(!Array.isArray(segment)||segment.length!==4)throw Error();for(const point of segment){if(!point||!Number.isFinite(point.x)||!Number.isFinite(point.y))throw Error();left=Math.min(left,point.x);top=Math.min(top,point.y);right=Math.max(right,point.x);bottom=Math.max(bottom,point.y);}} for(const segment of segments){if(!Array.isArray(segment)||segment.length!==4)throw Error();for(const point of segment){if(!point||!Number.isFinite(point.x)||!Number.isFinite(point.y))throw Error();left=Math.min(left,point.x);top=Math.min(top,point.y);right=Math.max(right,point.x);bottom=Math.max(bottom,point.y);}}
// 向四侧扩半线宽,同时检查端点相加不溢出,否则当前边转 unknown。 // 向四侧扩半线宽,同时检查端点相加不溢出,否则当前边转 unknown。
const half=stroke/2,x=left-half,y=top-half,width=right-left+stroke,height=bottom-top+stroke;if(![x,y,width,height,x+width,y+height].every(Number.isFinite))throw Error(); const half=stroke/2,x=left-half,y=top-half,width=right-left+stroke,height=bottom-top+stroke;if(![x,y,width,height,x+width,y+height].every(Number.isFinite))throw Error();
// 原生范围由两端节点矩形并集包围;只有实际笔画包围盒超出它,才添加补充空间行。 // 原生范围取两端节点未旋转矩形的联合 AABB;曲线控制点凸包+半线宽 AABB 超出时才添整块补充范围。
// 这是保守估计而非曲线精确极值,允许额外保留可见候选;不计算命中距离或直接控制 renderer 挂载。
const native={left:Math.min(source.position.x,target.position.x),top:Math.min(source.position.y,target.position.y),right:Math.max(source.position.x+source.width!,target.position.x+target.width!),bottom:Math.max(source.position.y+source.height!,target.position.y+target.height!)}; const native={left:Math.min(source.position.x,target.position.x),top:Math.min(source.position.y,target.position.y),right:Math.max(source.position.x+source.width!,target.position.x+target.width!),bottom:Math.max(source.position.y+source.height!,target.position.y+target.height!)};
if(!Object.values(native).every(Number.isFinite))throw Error();const region=x<native.left||y<native.top||x+width>native.right||y+height>native.bottom?{id:edge.id,bounds:{x,y,width,height}}:undefined;if(region)regions.push(region);cache?.set(edge.id,{signature,segments:segments.length,region}); if(!Object.values(native).every(Number.isFinite))throw Error();const region=x<native.left||y<native.top||x+width>native.right||y+height>native.bottom?{id:edge.id,bounds:{x,y,width,height}}:undefined;if(region)regions.push(region);cache?.set(edge.id,{signature,segments:segments.length,region});
// 异常几何不保留过时缓存;单边失败隔离为该 ID 的 unknown,不影响其它已知范围。 // 异常几何不保留过时缓存;单边失败隔离为该 ID 的 unknown,不影响其它已知范围。
@@ -56,11 +62,15 @@ function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Re
export function projectedConnectionPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096){return connectionPaint(projection,registry,services,maxEdges,maxSegments);} export function projectedConnectionPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096){return connectionPaint(projection,registry,services,maxEdges,maxSegments);}
/** Host revision invalidates geometry services whose external configuration changes. */ /** Host revision invalidates geometry services whose external configuration changes. */
/** 视图级曲线范围缓存与网格。外部锚点/样式服务配置改变时,宿主必须同步推进 geometryRevision, /** 视图级曲线范围缓存与网格。外部锚点/样式服务配置改变时,宿主必须同步推进 geometryRevision,
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。 */ * 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。
* 每视图独立实例;缓存按 registry 对象/project/canvas/revision 隔离,不另核 editor owner。
* cache 保留边几何结果,index 存本轮额外区域;超过预算的其它边可暂留缓存但不进入本轮 regions。 */
export function createConnectionPaintIndex(){ export function createConnectionPaintIndex(){
const cache=new Map<string,{signature:unknown;segments:number;region?:PaintRegion}>(),index=createPaintIndex();let owner:Registry|undefined,project:string|undefined,canvas:string|undefined,revision:unknown,computed=0; const cache=new Map<string,{signature:unknown;segments:number;region?:PaintRegion}>(),index=createPaintIndex();let owner:Registry|undefined,project:string|undefined,canvas:string|undefined,revision:unknown,computed=0;
/** 同步当前场景;范围身份或 geometryRevision 变化整体失效,删除已移出的边缓存。 /** 同步当前场景;范围身份或 geometryRevision 变化整体失效,删除已移出的边缓存。
* query 与 unknown 配套使用;computed 是累计重新计算数,不把缓存命中记成一次曲线计算。 */ * unknown 中元素 ID 表示该边无法解释,字符串 paint_budget 表示余下查询不完整,不能据空 regions 判不可见。
* query 是可变 index 的方法,旧返回对象也查最新网格,unknown 则是本轮冻结列表。
* computed 计通过锚点/变换检查后未命中的几何尝试(可随后失败),不等于成功区域数;clear 不重置累计值。 */
return Object.freeze({sync(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},geometryRevision?:unknown,budget:{maxEdges?:number;maxSegments?:number}={}){ return Object.freeze({sync(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},geometryRevision?:unknown,budget:{maxEdges?:number;maxSegments?:number}={}){
if(owner!==registry||project!==projection.document.projectId||canvas!==projection.canvasId||!Object.is(revision,geometryRevision)){cache.clear();index.clear();owner=registry;project=projection.document.projectId;canvas=projection.canvasId;revision=geometryRevision;} if(owner!==registry||project!==projection.document.projectId||canvas!==projection.canvasId||!Object.is(revision,geometryRevision)){cache.clear();index.clear();owner=registry;project=projection.document.projectId;canvas=projection.canvasId;revision=geometryRevision;}
const result=connectionPaint(projection,registry,services,budget.maxEdges??4096,budget.maxSegments??4096,cache),seen=new Set(projection.edges.map(edge=>edge.id));for(const id of cache.keys())if(!seen.has(id))cache.delete(id);index.sync(result.regions);computed+=result.computed;return Object.freeze({query:index.query,unknown:Object.freeze(result.unknown)}); const result=connectionPaint(projection,registry,services,budget.maxEdges??4096,budget.maxSegments??4096,cache),seen=new Set(projection.edges.map(edge=>edge.id));for(const id of cache.keys())if(!seen.has(id))cache.delete(id);index.sync(result.regions);computed+=result.computed;return Object.freeze({query:index.query,unknown:Object.freeze(result.unknown)});
+6 -2
View File
@@ -9,7 +9,9 @@ import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometr
type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']}; type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']};
/** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */ /** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */
/** 方向优先取降级外壳提示,再取标准 handle 名、port 方向或调用方 fallback。 /** 方向优先取降级外壳提示,再取标准 handle 名、port 方向或调用方 fallback。
* 同方向多个 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。 */ * 找到具名 port 时,同 direction 的 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。
* 即使 fallback hint 选择了另一侧,已识别 port 仍按高度做 y 偏移;这是旧布局协议,不适用于原生 Handle。
* nodeConnector 先负责节点边界/旋转,下面直接叠加 y 偏移,不再把这段偏移当局部向量旋转。 */
export function compatConnector(node:CompatNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{ export function compatConnector(node:CompatNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{
const port=Array.isArray(node.data.ports)?node.data.ports.find(port=>port&&`port:${port.id}`===handle):undefined,hints=(node.data as ProjectionNode['data']&{__headlessFallback?:{handles?:{id:string;side:ConnectorSide}[]}}).__headlessFallback?.handles,projected=Array.isArray(hints)?hints.find(hint=>hint?.id===handle&&['top','right','bottom','left'].includes(hint.side))?.side:undefined,side=projected??(['top','right','bottom','left'].includes(handle??'')?handle as ConnectorSide:port?.direction==='input'?'left':port?'right':fallback),result=nodeConnector(node,side); const port=Array.isArray(node.data.ports)?node.data.ports.find(port=>port&&`port:${port.id}`===handle):undefined,hints=(node.data as ProjectionNode['data']&{__headlessFallback?:{handles?:{id:string;side:ConnectorSide}[]}}).__headlessFallback?.handles,projected=Array.isArray(hints)?hints.find(hint=>hint?.id===handle&&['top','right','bottom','left'].includes(hint.side))?.side:undefined,side=projected??(['top','right','bottom','left'].includes(handle??'')?handle as ConnectorSide:port?.direction==='input'?'left':port?'right':fallback),result=nodeConnector(node,side);
// 输入/输出各自独立排序,用测量高度优先;默认400仅属于旧卡片布局约定。 // 输入/输出各自独立排序,用测量高度优先;默认400仅属于旧卡片布局约定。
@@ -21,7 +23,9 @@ export type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:numbe
type MeasuredHandle={readonly id?:string|null;readonly position:ConnectorSide;readonly x:number;readonly y:number;readonly width:number;readonly height:number}; type MeasuredHandle={readonly id?:string|null;readonly position:ConnectorSide;readonly x:number;readonly y:number;readonly width:number;readonly height:number};
/** Native Handle outer edge, matching the installed React Flow loose connection mode. */ /** Native Handle outer edge, matching the installed React Flow loose connection mode. */
/** 从实际 Handle 外侧中心生成锚点与朝外法线。target 可采用 source 把手以匹配 loose 模式, /** 从实际 Handle 外侧中心生成锚点与朝外法线。target 可采用 source 把手以匹配 loose 模式,
* 指定 handleId 未找到或尚无测量时返回 undefined,不编造中心点。 */ * source 仅查 source 列表;target 按 target 再 source 顺序查,匹配 loose 模式。
* 非空 handleId 精确查找,空字符串/null/undefined 取首项;无匹配或未测量返回 undefined。
* 假定 positionAbsolute 与 Handle 尺寸采用同一场景单位;直接叠加,不在这里验证有限值或再次处理节点旋转。 */
export function measuredConnector(node:MeasuredNode|undefined,handleId:string|null|undefined,end:'source'|'target'):Connector|undefined{ export function measuredConnector(node:MeasuredNode|undefined,handleId:string|null|undefined,end:'source'|'target'):Connector|undefined{
const bounds=node?.internals.handleBounds;if(!node||!bounds)return;const handles=end==='source'?bounds.source??[]:[...(bounds.target??[]),...(bounds.source??[])],handle=handleId?handles.find(handle=>handle.id===handleId):handles[0];if(!handle)return; const bounds=node?.internals.handleBounds;if(!node||!bounds)return;const handles=end==='source'?bounds.source??[]:[...(bounds.target??[]),...(bounds.source??[])],handle=handleId?handles.find(handle=>handle.id===handleId):handles[0];if(!handle)return;
const {x,y}=node.internals.positionAbsolute,side=handle.position,normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}},point={x:x+handle.x+(side==='left'?0:side==='right'?handle.width:handle.width/2),y:y+handle.y+(side==='top'?0:side==='bottom'?handle.height:handle.height/2)};return{point,normal:normals[side]}; const {x,y}=node.internals.positionAbsolute,side=handle.position,normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}},point={x:x+handle.x+(side==='left'?0:side==='right'?handle.width:handle.width/2),y:y+handle.y+(side==='top'?0:side==='bottom'?handle.height:handle.height/2)};return{point,normal:normals[side]};
+11 -5
View File
@@ -16,7 +16,9 @@ export type ElementSourcePorts={
}; };
/** One upstream subscription, ID buckets and cached lookup values, never another document. /** One upstream subscription, ID buckets and cached lookup values, never another document.
* Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */ * Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */
/** 创建惰性订阅中心;每个 forElement 源缓存自身读取值,同 ID 的监听共享上游桶。 */ /** buckets 按 elementId 共享上游查询结果/观察器,每个 forElement 另有即时读取缓存。
* 首次 subscribe 才接上游,全部桶退订才释放 previous 根引用;创建源或直接读不持有上游租约。
* 依赖不可变快照与完整 Commit.changes 合同,不从队列/网络结果猜测元素是否已改变。 */
export function createElementSources(ports:ElementSourcePorts){ export function createElementSources(ports:ElementSourcePorts){
// 失败标记使下次成功查询即使值等价也通知恢复;不把异常缓存成一条虚假元素记录。 // 失败标记使下次成功查询即使值等价也通知恢复;不把异常缓存成一条虚假元素记录。
type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean}; type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean};
@@ -28,11 +30,13 @@ export function createElementSources(ports:ElementSourcePorts){
const before=previous!;previous=next; const before=previous!;previous=next;
const ids=new Set<Id>(); const ids=new Set<Id>();
if(before.document!==next.document){ if(before.document!==next.document){
// 显式元素/字段改动走 ID 路由;无 changes 的文档替换无法精确定位,复查全部已订阅元素。 // 显式变化只路由 field.target.id / element.id,不按 canvas 变更自动扩散到全体子元素。
// 文档替换且无 changes 时复查全部已订阅桶;路由准确性依赖发布者提供完整变更及 lookup 的依赖合同。
if(commit.changes.length){for(const change of commit.changes)if(change.kind==='field')ids.add(change.target.id);else if(change.kind==='element')ids.add(change.id);} if(commit.changes.length){for(const change of commit.changes)if(change.kind==='field')ids.add(change.target.id);else if(change.kind==='element')ids.add(change.id);}
else for(const id of buckets.keys())ids.add(id); else for(const id of buckets.keys())ids.add(id);
} }
// loading 的已知范围改变会把 absent/unloaded 变成可查询;原先非 present 的桶必须复查。 // loading 对象或其读取基线变动可改变 absent/unloaded 判定,因此复查非 present 桶。
// 不将任意 runtime 更新都当元素变化;failed 桶也只有进入本次候选 IDs 才会在此重新查询。
const coverageChanged=before.runtime.loading!==next.runtime.loading||!!next.runtime.loading&&(before.runtime.loadingRead?.document??before.runtime.acknowledged)!==(next.runtime.loadingRead?.document??next.runtime.acknowledged); const coverageChanged=before.runtime.loading!==next.runtime.loading||!!next.runtime.loading&&(before.runtime.loadingRead?.document??before.runtime.acknowledged)!==(next.runtime.loadingRead?.document??next.runtime.acknowledged);
if(coverageChanged)for(const[id,bucket]of buckets)if(bucket.value.status!=='present')ids.add(id); if(coverageChanged)for(const[id,bucket]of buckets)if(bucket.value.status!=='present')ids.add(id);
const notifications=new Set<()=>void>(); const notifications=new Set<()=>void>();
@@ -50,7 +54,8 @@ export function createElementSources(ports:ElementSourcePorts){
return Object.freeze({forElement(id:Id):ExternalSource<ElementLookup>{ return Object.freeze({forElement(id:Id):ExternalSource<ElementLookup>{
let value:ElementLookup|undefined; let value:ElementLookup|undefined;
return Object.freeze({ return Object.freeze({
/** 每次从当前根查询并按值保留稳定引用,不能只依赖通知时机猜测最新数据。 */ /** 每次查询当前根,用 equal 保持等价 ElementLookup 的局部引用;读取不改共享桶通知基线。
* 不深复制查询值,lookup 必须返回适合不可变使用的结果;异常直接抛给调用方读取边界。 */
getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;}, getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;},
/** 每个订阅包装独立 listener,即使调用方传同一个函数也能分别释放。 */ /** 每个订阅包装独立 listener,即使调用方传同一个函数也能分别释放。 */
subscribe(notify:()=>void){ subscribe(notify:()=>void){
@@ -59,7 +64,8 @@ export function createElementSources(ports:ElementSourcePorts){
const listener=()=>notify();bucket.listeners.add(listener); const listener=()=>notify();bucket.listeners.add(listener);
// 首次连上游抛错时撤销这次 listener/空桶,避免半注册泄漏。 // 首次连上游抛错时撤销这次 listener/空桶,避免半注册泄漏。
if(!stop){previous=ports.getSnapshot();try{stop=ports.subscribe(changed);}catch(error){bucket.listeners.delete(listener);if(!bucket.listeners.size)buckets.delete(id);if(!buckets.size)previous=undefined;throw error;}} if(!stop){previous=ports.getSnapshot();try{stop=ports.subscribe(changed);}catch(error){bucket.listeners.delete(listener);if(!bucket.listeners.size)buckets.delete(id);if(!buckets.size)previous=undefined;throw error;}}
// 最后一个桶清空才退订上游,释放 previous 根引用;其它元素仍有监听时继续共享。 // 最后一个桶清空才 stop 上游并释放 previous;同回调的多次订阅因 listener 包装仍分别退订。
// stop 异常不被捕获,后续状态重置不保证执行;宿主的取消订阅端口应不抛错。
let active=true;return()=>{if(!active)return;active=false;bucket!.listeners.delete(listener);if(!bucket!.listeners.size)buckets.delete(id);if(!buckets.size){stop?.();stop=undefined;previous=undefined;}}; let active=true;return()=>{if(!active)return;active=false;bucket!.listeners.delete(listener);if(!bucket!.listeners.size)buckets.delete(id);if(!buckets.size){stop?.();stop=undefined;previous=undefined;}};
}, },
}); });
+10 -5
View File
@@ -6,23 +6,28 @@ import type {ExternalSource} from './selectors.ts';
/** Presentation invalidations only. Commands, history and network work never wait here. */ /** Presentation invalidations only. Commands, history and network work never wait here. */
/** 宿主注入帧请求/取消能力,可接 requestAnimationFrame 或测试时钟;handle 不保存业务身份。 */ /** 宿主注入帧请求/取消能力,可接 requestAnimationFrame 或测试时钟;handle 不保存业务身份。 */
export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void}; export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void};
/** 创建一条可手动 flush 的通知队列;同一 key 在一次批次中只出现一次。 */ /** 创建一条可手动 flush 的通知队列;Set 按键身份去重,并保留首次插入顺序。
* clock/publish 由宿主实现,不读取 DOM/模型;调度器异常原样抛出,不另设重试或全局错误处理。 */
export function createFrameQueue<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){ export function createFrameQueue<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){
let dirty=new Set<K>(),scheduled:{handle?:unknown}|undefined,disposed=false; let dirty=new Set<K>(),scheduled:{handle?:unknown}|undefined,disposed=false;
/** 同步排空已有脏项,先换新 Set 再回调,回调中的新失效留给下一批,不会被本批 clear 擦掉。 */ /** 同步排空:先撤当前调度并换新 Set,再 publish,回调中的 invalidate 留给下一批。
* batch 以 ReadonlySet 类型借出但未运行时冻结,与新 dirty 已分离;publish 抛错不把本批自动放回队列。 */
function flush(){if(disposed)return;const pending=scheduled;scheduled=undefined;if(pending)clock.cancel(pending.handle);if(!dirty.size)return;const batch=dirty;dirty=new Set();publish(batch);} function flush(){if(disposed)return;const pending=scheduled;scheduled=undefined;if(pending)clock.cancel(pending.handle);if(!dirty.size)return;const batch=dirty;dirty=new Set();publish(batch);}
return Object.freeze({ return Object.freeze({
/** 合并 keys 并最多安排一个帧回调;pending 对象身份防止已取消的旧回调误刷后来的批次。 */ /** 逐项加入 dirty,至多安排一个帧回调;pending 对象身份防旧回调误刷后来一批。
* request 抛错会清 scheduled,但保留 dirty 供后续 invalidate/flush 重试;不自动再 request。 */
invalidate(keys:Iterable<K>){if(disposed)return;for(const key of keys)dirty.add(key);if(!dirty.size||scheduled)return;const pending: {handle?:unknown}={};scheduled=pending;try{pending.handle=clock.request(()=>{if(scheduled!==pending)return;scheduled=undefined;flush();});}catch(error){scheduled=undefined;throw error;}}, invalidate(keys:Iterable<K>){if(disposed)return;for(const key of keys)dirty.add(key);if(!dirty.size||scheduled)return;const pending: {handle?:unknown}={};scheduled=pending;try{pending.handle=clock.request(()=>{if(scheduled!==pending)return;scheduled=undefined;flush();});}catch(error){scheduled=undefined;throw error;}},
flush, flush,
/** 幂等关闭并撤销帧句柄,关闭后失效通知无效果;不会替上游关闭 editor。 */ /** 标记 disposed 后尽力取消帧并清 dirty;后续 invalidate/flush 无效,不替上游关闭 editor。
* 这里不捕获 clock.cancel 异常,宿主取消端口须可靠清理;重复 dispose 不会重试一次失败取消。 */
dispose(){if(disposed)return;disposed=true;if(scheduled)clock.cancel(scheduled.handle);scheduled=undefined;dirty.clear();}, dispose(){if(disposed)return;disposed=true;if(scheduled)clock.cancel(scheduled.handle);scheduled=undefined;dirty.clear();},
}); });
} }
/** Coalesce presentation notifications; immediate invalidations flush pending work synchronously. */ /** Coalesce presentation notifications; immediate invalidations flush pending work synchronously. */
/** 给普通外部数据源加通知节流:读取始终透传当前快照,每个订阅独有可释放的帧队列。 /** 给普通外部数据源加通知节流:读取始终透传当前快照,每个订阅独有可释放的帧队列。
* 上游标 immediate 时同步 flush,常用于不能等下一帧才反映的状态边界。 */ * 上游标 immediate 时先 invalidate 再同步 flush,合并的旧失效也一起通知;读取从不等待帧。
* 同一工厂的不同订阅不共享队列;退订函数每次都会调用上游 stop,幂等性依赖该 stop 合同。 */
export function createFrameSource<S>(source:{getSnapshot():S;subscribe(notify:(immediate?:boolean)=>void):()=>void},clock:FrameClock):ExternalSource<S>{ export function createFrameSource<S>(source:{getSnapshot():S;subscribe(notify:(immediate?:boolean)=>void):()=>void},clock:FrameClock):ExternalSource<S>{
return Object.freeze({getSnapshot:()=>source.getSnapshot(),subscribe(notify:()=>void){ return Object.freeze({getSnapshot:()=>source.getSnapshot(),subscribe(notify:()=>void){
const queue=createFrameQueue<number>(clock,()=>notify()); const queue=createFrameQueue<number>(clock,()=>notify());
+9 -4
View File
@@ -12,21 +12,26 @@ const sameGeometry=(a:Geometry,b:Geometry)=>a.x===b.x&&a.y===b.y&&a.width===b.wi
/** 子画布变换只依赖 presentation 中 contentScale/unitScale,忽略无关样式字段。 */ /** 子画布变换只依赖 presentation 中 contentScale/unitScale,忽略无关样式字段。 */
const units=(value:Json|undefined)=>value&&typeof value==='object'&&!Array.isArray(value)?[value.contentScale,value.unitScale]:[]; const units=(value:Json|undefined)=>value&&typeof value==='object'&&!Array.isArray(value)?[value.contentScale,value.unitScale]:[];
/** View-local numeric/path projection only. No editable records, history, visibility or task facts. */ /** View-local numeric/path projection only. No editable records, history, visibility or task facts. */
/** 缓存实例应归一个显示上下文;计数器累计实际计算次数供性能诊断,不是模型版本。 */ /** 一个显示上下文使用一份缓存:三个 Map 分别缓存节点几何、子 canvas 变换和连线路径。
* 调用方须按 begin→若干 node/child/edge→end 使用;这里不校验调用配对,也不提供失败回滚/并发帧隔离。
* 累计计数只在实际重算后增加,不是文档版本或当前命中次数;返回投影值冻结,键只保留所需输入。 */
export function createGeometryProjectionCache(){ export function createGeometryProjectionCache(){
let owner:object|undefined,project:Id|undefined,canvas:Id|undefined,nodesComputed=0,childrenComputed=0,edgesComputed=0; let owner:object|undefined,project:Id|undefined,canvas:Id|undefined,nodesComputed=0,childrenComputed=0,edgesComputed=0;
type NodeResult={position:{x:number;y:number};width:number;height:number;rotation:number}; type NodeResult={position:{x:number;y:number};width:number;height:number;rotation:number};
const nodes=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;value:NodeResult}>(),children=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;units:unknown;value:Transform}>(),edges=new Map<Id,{incarnation:number;path:Json;transform:Transform;value:Json}>();let seenNodes=new Set<Id>(),seenChildren=new Set<Id>(),seenEdges=new Set<Id>(); const nodes=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;value:NodeResult}>(),children=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;units:unknown;value:Transform}>(),edges=new Map<Id,{incarnation:number;path:Json;transform:Transform;value:Json}>();let seenNodes=new Set<Id>(),seenChildren=new Set<Id>(),seenEdges=new Set<Id>();
return Object.freeze({ return Object.freeze({
/** 开始一轮投影并重置 seen;owner 身份隔离不同 renderer/editor,即使 projectId 相同也不能混用。 */ /** scope 按对象身份、project/canvas 按 ID 隔离;任一变化清三张表,累计计数不清。
* 每轮重建 seen,随后命中缓存也记一次访问;begin 不自行遍历文档或判断元素仍可见。 */
begin(scope:object,projectId:Id,canvasId:Id){if(owner!==scope||project!==projectId||canvas!==canvasId){nodes.clear();children.clear();edges.clear();owner=scope;project=projectId;canvas=canvasId;}seenNodes=new Set();seenChildren=new Set();seenEdges=new Set();}, begin(scope:object,projectId:Id,canvasId:Id){if(owner!==scope||project!==projectId||canvas!==canvasId){nodes.clear();children.clear();edges.clear();owner=scope;project=projectId;canvas=canvasId;}seenNodes=new Set();seenChildren=new Set();seenEdges=new Set();},
/** 将节点中心转到场景,按变换比例缩放宽高并合成旋转;缓存复制输入数值防止外部修改污染键。 */ /** 将节点中心转到场景,按变换比例缩放宽高并合成旋转;缓存复制输入数值防止外部修改污染键。 */
node(id:Id,incarnation:number,g:Geometry,t:Transform):NodeResult{seenNodes.add(id);const before=nodes.get(id);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t))return before.value;const center=toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width=g.width*t.scale,height=g.height*t.scale,value=freeze({position:{x:center.x-width/2,y:center.y-height/2},width,height,rotation:(g.rotation+t.rotation)%360});nodes.set(id,{incarnation,geometry:{...g},transform:{...t},value});nodesComputed++;return value;}, node(id:Id,incarnation:number,g:Geometry,t:Transform):NodeResult{seenNodes.add(id);const before=nodes.get(id);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t))return before.value;const center=toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width=g.width*t.scale,height=g.height*t.scale,value=freeze({position:{x:center.x-width/2,y:center.y-height/2},width,height,rotation:(g.rotation+t.rotation)%360});nodes.set(id,{incarnation,geometry:{...g},transform:{...t},value});nodesComputed++;return value;},
/** 计算分区内部坐标到场景的变换,单位比例变化也会使缓存失效。 */ /** 分区入口几何+父变换+contentScale/unitScale 生成内部 canvas→场景变换,单位变化也使缓存失效。
* 不含 collapsed 等显示决策,是否调用 child 由投影器决定;使用核心 childCanvasTransform 解释单位。 */
child(id:Id,incarnation:number,g:Geometry,t:Transform,presentation:Json|undefined):Transform{seenChildren.add(id);const before=children.get(id),currentUnits=units(presentation);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t)&&equal(before.units,currentUnits))return before.value;const value=freeze(childCanvasTransform(t,g,presentation));children.set(id,{incarnation,geometry:{...g},transform:{...t},units:currentUnits,value});childrenComputed++;return value;}, child(id:Id,incarnation:number,g:Geometry,t:Transform,presentation:Json|undefined):Transform{seenChildren.add(id);const before=children.get(id),currentUnits=units(presentation);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t)&&equal(before.units,currentUnits))return before.value;const value=freeze(childCanvasTransform(t,g,presentation));children.set(id,{incarnation,geometry:{...g},transform:{...t},units:currentUnits,value});childrenComputed++;return value;},
/** JSON 路径按值比较;先复制/冻结输入再投影,不持有调用方可变路径作为历史缓存。 */ /** JSON 路径按值比较;先复制/冻结输入再投影,不持有调用方可变路径作为历史缓存。 */
edge(id:Id,incarnation:number,path:Json,t:Transform):Json{seenEdges.add(id);const before=edges.get(id);if(before&&before.incarnation===incarnation&&equal(before.path,path)&&sameTransform(before.transform,t))return before.value;const captured=freeze(clone(path)),value=freeze(transformPath(captured,t));edges.set(id,{incarnation,path:captured,transform:{...t},value});edgesComputed++;return value;}, edge(id:Id,incarnation:number,path:Json,t:Transform):Json{seenEdges.add(id);const before=edges.get(id);if(before&&before.incarnation===incarnation&&equal(before.path,path)&&sameTransform(before.transform,t))return before.value;const captured=freeze(clone(path)),value=freeze(transformPath(captured,t));edges.set(id,{incarnation,path:captured,transform:{...t},value});edgesComputed++;return value;},
/** 删除本轮未访问的缓存项,避免浏览多个分区后无限保留旧节点和路径。 */ /** 扫描三张缓存表,删除本轮未访问项;保留规则按调用访问,不等于元素已经从文档删除。
* 调用方若提前抛错且不执行 end,未访问旧项可暂留至下次完整清理,缓存不是业务恢复事实。 */
end(){for(const id of nodes.keys())if(!seenNodes.has(id))nodes.delete(id);for(const id of children.keys())if(!seenChildren.has(id))children.delete(id);for(const id of edges.keys())if(!seenEdges.has(id))edges.delete(id);}, end(){for(const id of nodes.keys())if(!seenNodes.has(id))nodes.delete(id);for(const id of children.keys())if(!seenChildren.has(id))children.delete(id);for(const id of edges.keys())if(!seenEdges.has(id))edges.delete(id);},
/** 返回当前缓存大小和累计计算量的冻结快照;begin 不重置累计计数。 */ /** 返回当前缓存大小和累计计算量的冻结快照;begin 不重置累计计数。 */
stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}), stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}),
+22 -10
View File
@@ -29,7 +29,10 @@ const finitePoint=(point:Point)=>!!point&&Number.isFinite(point.x)&&Number.isFin
function geometry(node:ProjectionNode,t:Transform):Geometry{const width=node.width!/t.scale,height=node.height!/t.scale,center=toLocal(t,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2});return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-t.rotation+360)%360};} function geometry(node:ProjectionNode,t:Transform):Geometry{const width=node.width!/t.scale,height=node.height!/t.scale,center=toLocal(t,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2});return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-t.rotation+360)%360};}
/** Query the captured display frame. It neither edits nor replaces the native input router. */ /** Query the captured display frame. It neither edits nor replaces the native input router. */
/** 必须使用投影对应的同一 document 和 registry。先取空间候选再精测,返回有序命中及完整性诊断; /** 必须使用投影对应的同一 document 和 registry。先取空间候选再精测,返回有序命中及完整性诊断;
* 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。 */ * 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。
* 同步消费借入的 projection/query/services,不捕获另一份文档;几何服务须按此显示帧返回同坐标系的结果。
* maxCandidates 约束节点精测+边访问,maxCurveSteps 由全部边共享,maxSegments 则是每边上限;
* 前置 Map/层序/空间候选收集和用户几何回调不被这些计数抢占,不能据此承诺总耗时硬上限。 */
export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries,registry:Registry,viewSessionId:Id,query:HitPoint,services:HitServices={},budget:HitBudget={}){ export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries,registry:Registry,viewSessionId:Id,query:HitPoint,services:HitServices={},budget:HitBudget={}){
// 强制快照/registry 身份一致;旧投影搭配新索引可能产生看得见却点不中的结果,直接拒绝。 // 强制快照/registry 身份一致;旧投影搭配新索引可能产生看得见却点不中的结果,直接拒绝。
if(projection.document!==queries.document||!viewSessionId)throw fault('stale_query_snapshot');if(queries.registry!==registry)throw fault('query_registry_changed');const zoom=query.space==='scene'?query.zoom:query.viewport.zoom,tolerancePx=query.tolerancePx??6,errorPx=budget.errorPx??.25,maxCandidates=budget.maxCandidates??4096,maxSteps=budget.maxCurveSteps??8192,maxDepth=budget.maxCurveDepth??20,maxSegments=budget.maxSegments??4096; if(projection.document!==queries.document||!viewSessionId)throw fault('stale_query_snapshot');if(queries.registry!==registry)throw fault('query_registry_changed');const zoom=query.space==='scene'?query.zoom:query.viewport.zoom,tolerancePx=query.tolerancePx??6,errorPx=budget.errorPx??.25,maxCandidates=budget.maxCandidates??4096,maxSteps=budget.maxCurveSteps??8192,maxDepth=budget.maxCurveDepth??20,maxSegments=budget.maxSegments??4096;
@@ -38,34 +41,43 @@ export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries
// 屏幕位置减容器 origin 与 viewport 平移后除 zoom;容差同样换算,不随缩放改变视觉点击范围。 // 屏幕位置减容器 origin 与 viewport 平移后除 zoom;容差同样换算,不随缩放改变视觉点击范围。
const point=query.space==='scene'?query.point:{x:(query.point.x-query.viewport.origin.x-query.viewport.x)/zoom,y:(query.point.y-query.viewport.origin.y-query.viewport.y)/zoom},tolerance=tolerancePx/zoom,document=projection.document,nodes=new Map(projection.nodes.map(node=>[node.id,node])),rank=new Map(queries.orderedElements([...nodes.keys(),...projection.edges.map(edge=>edge.id)]).map((id,index)=>[id,index])),candidates=new Set<Id>(services.previewIds??[]),hits:SceneHit[]=[],diagnostics:{target?:Id;code:string}[]=[];let work=0,remaining=maxSteps; const point=query.space==='scene'?query.point:{x:(query.point.x-query.viewport.origin.x-query.viewport.x)/zoom,y:(query.point.y-query.viewport.origin.y-query.viewport.y)/zoom},tolerance=tolerancePx/zoom,document=projection.document,nodes=new Map(projection.nodes.map(node=>[node.id,node])),rank=new Map(queries.orderedElements([...nodes.keys(),...projection.edges.map(edge=>edge.id)]).map((id,index)=>[id,index])),candidates=new Set<Id>(services.previewIds??[]),hits:SceneHit[]=[],diagnostics:{target?:Id;code:string}[]=[];let work=0,remaining=maxSteps;
if(!finitePoint(point)||!Number.isFinite(tolerance)||!Number.isFinite(errorPx/zoom))throw fault('invalid_hit_query'); if(!finitePoint(point)||!Number.isFinite(tolerance)||!Number.isFinite(errorPx/zoom))throw fault('invalid_hit_query');
// 分别把场景点与容差变回各 canvas,查询本地节点索引;previewIds 可补上索引尚未覆盖的预览位置。 // 先收集 previewIds,再逐 canvas 反变换查局部索引,以 Set 合并;真正精测前会排除未出现在投影中的 ID。
// 索引几何未随瞬态 preview 改动时,renderer 必须补 previewIds,不能只靠原位置的空间候选。
for(const[id,t]of projection.transforms){if(!Number.isFinite(t.scale)||t.scale<=0)throw fault('invalid_hit_transform');const p=toLocal(t,point),r=tolerance/t.scale;for(const candidate of queries.candidateNodes(id,{x:p.x-r,y:p.y-r,width:2*r,height:2*r}))candidates.add(candidate);} for(const[id,t]of projection.transforms){if(!Number.isFinite(t.scale)||t.scale<=0)throw fault('invalid_hit_transform');const p=toLocal(t,point),r=tolerance/t.scale;for(const candidate of queries.candidateNodes(id,{x:p.x-r,y:p.y-r,width:2*r,height:2*r}))candidates.add(candidate);}
/** 由真实 owner 向当前显示根回溯,生成 canvas/入口路径;检测循环和无效父关系。 */ /** 从元素真实 owner 沿 parent 回到当前显示根,反转成根→owner 路径;不是修改元素归属。
* 每条命中都生成显示路径,循环/父关系断裂作为该目标诊断;viewSessionId 只沿用调用值,不查询 session 注册表。 */
function path(owner:Id){const canvases:Id[]=[],entries:Id[]=[],seen=new Set<Id>();let id=owner;while(id!==projection.canvasId){if(seen.has(id))throw fault('canvas_cycle');seen.add(id);const parent=document.canvases.get(id)?.parent;if(!parent)throw fault('invalid_display_path');canvases.push(id);entries.push(parent.entryElementId);id=parent.canvasId;}return{viewSessionId,canvasIds:[projection.canvasId,...canvases.reverse()],entryIds:entries.reverse()};} function path(owner:Id){const canvases:Id[]=[],entries:Id[]=[],seen=new Set<Id>();let id=owner;while(id!==projection.canvasId){if(seen.has(id))throw fault('canvas_cycle');seen.add(id);const parent=document.canvases.get(id)?.parent;if(!parent)throw fault('invalid_display_path');canvases.push(id);entries.push(parent.entryElementId);id=parent.canvasId;}return{viewSessionId,canvasIds:[projection.canvasId,...canvases.reverse()],entryIds:entries.reverse()};}
/** 自定义层级必须有限;否则记入对应目标诊断,而不是用无效数值打乱排序。 */ /** 自定义层级必须有限;否则记入对应目标诊断,而不是用无效数值打乱排序。 */
const layer=(id:Id,kind:'node'|'connection',fallback:number)=>{const value=services.layer?.(id,kind)??fallback;if(!Number.isFinite(value))throw fault('invalid_hit_layer');return value;}; const layer=(id:Id,kind:'node'|'connection',fallback:number)=>{const value=services.layer?.(id,kind)??fallback;if(!Number.isFinite(value))throw fault('invalid_hit_layer');return value;};
// 节点精测只处理投影中实际存在的候选;未知/降级组件使用矩形协议,单元素协议失败隔离为诊断。 // 节点精测只处理投影中实际存在的候选;unknown/fallback/非 node 几何协议统一用矩形显示外壳。
// confirmed 仅证明采用的显示几何命中,不宣称已经理解未知组件原始轮廓。目标回调须按 Error 风格抛错。
for(const id of candidates){const node=nodes.get(id);if(!node)continue;if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(id);if(!record||record.structure!=='node')throw fault('invalid_hit_projection');const t=projection.transforms.get(record.ownerCanvasId);if(!t)throw fault('invalid_hit_transform');const g=geometry(node,t),resolved=!node.data.__headlessFallback&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,protocol=resolved?.kind==='node'?resolved:rectangleGeometry,p=toLocal(t,point),localTolerance=tolerance/t.scale; for(const id of candidates){const node=nodes.get(id);if(!node)continue;if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(id);if(!record||record.structure!=='node')throw fault('invalid_hit_projection');const t=projection.transforms.get(record.ownerCanvasId);if(!t)throw fault('invalid_hit_transform');const g=geometry(node,t),resolved=!node.data.__headlessFallback&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,protocol=resolved?.kind==='node'?resolved:rectangleGeometry,p=toLocal(t,point),localTolerance=tolerance/t.scale;
// 组件协议返回的几何边界也要校验;精测依据局部旋转几何和按比例换算的容差。 // bounds 在此用于合法性检查,最终命中由 hit 返回布尔值;不能把通过 bounds 检查当成已经命中。
// geometry 使用 node 的显示尺寸/位置(含预览)反算,element 仍是同帧文档记录,容差换算为 owner 局部单位。
const bounds=protocol.bounds({element:record,geometry:g,role:'hit'});if(![g.x,g.y,g.width,g.height,g.rotation,bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)||g.width<0||g.height<0||bounds.width<0||bounds.height<0)throw fault('invalid_geometry_bounds'); const bounds=protocol.bounds({element:record,geometry:g,role:'hit'});if(![g.x,g.y,g.width,g.height,g.rotation,bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)||g.width<0||g.height<0||bounds.width<0||bounds.height<0)throw fault('invalid_geometry_bounds');
const result=protocol.hit({element:record,geometry:g,point:p,tolerance:localTolerance});if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push({target:{id,incarnation:record.incarnation},kind:'node',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),layer:layer(id,'node',node.zIndex??0),certainty:'confirmed'}); const result=protocol.hit({element:record,geometry:g,point:p,tolerance:localTolerance});if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push({target:{id,incarnation:record.incarnation},kind:'node',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),layer:layer(id,'node',node.zIndex??0),certainty:'confirmed'});
}catch(error){diagnostics.push({target:id,code:(error as{code?:string}).code??'geometry_handler_failed'});}} }catch(error){diagnostics.push({target:id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
// 优先采用 renderer 锚点。原生 headless 记录不能用旧卡片的兼容锚点猜位置,缺少服务就给出诊断。 // 优先采用 renderer 锚点。原生 headless 记录不能用旧卡片的兼容锚点猜位置,缺少服务就给出诊断。
const anchor=(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left'); const anchor=(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
// 连线与节点共用候选预算;逐边解析真实显示端点和画笔,失败不影响其它边的查询。 // 节点精测在先,连线随后遍历 projection.edges(不再做空间粗筛),两者共用 work 预算。
// 单边解析显示端点/画笔失败记诊断,继续其它边;某些边因节点预算已耗尽可能尚未访问。
for(const edge of projection.edges){if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(edge.id);if(!record||record.structure!=='connection')throw fault('invalid_hit_projection');const source=nodes.get(edge.source),target=nodes.get(edge.target);if(!source||!target)throw fault('geometry_anchor_unavailable');const from=anchor(source,edge.sourceHandle,'source',edge),to=anchor(target,edge.targetHandle,'target',edge);if(!from||!to||![from.point,from.normal,to.point,to.normal].every(finitePoint))throw fault('geometry_anchor_unavailable'); for(const edge of projection.edges){if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(edge.id);if(!record||record.structure!=='connection')throw fault('invalid_hit_projection');const source=nodes.get(edge.source),target=nodes.get(edge.target);if(!source||!target)throw fault('geometry_anchor_unavailable');const from=anchor(source,edge.sourceHandle,'source',edge),to=anchor(target,edge.targetHandle,'target',edge);if(!from||!to||![from.point,from.normal,to.point,to.normal].every(finitePoint))throw fault('geometry_anchor_unavailable');
const transform=projection.transforms.get(record.ownerCanvasId);if(!transform)throw fault('invalid_hit_transform');const protocol=edge.type!=='unsupported'&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,context={element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}; const transform=projection.transforms.get(record.ownerCanvasId);if(!transform)throw fault('invalid_hit_transform');const protocol=edge.type!=='unsupported'&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,context={element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform};
// renderer 曲线优先,随后才用组件协议;验证段数及每段四个有限点,避免异常几何耗尽查询。 // 自定义 edgeGeometry 非 null/undefined 的结果优先(包括空数组),否则调用已知 connection 协议。
// 先检查每段4个有限点及每边段数,再求距;回调本身仍同步执行,没有超时或抢占。
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments(context):undefined);if(!segments)throw fault('geometry_unavailable');if(!Array.isArray(segments)||segments.length>maxSegments||segments.some(segment=>!Array.isArray(segment)||segment.length!==4||!segment.every(finitePoint)))throw fault('invalid_geometry_response'); const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments(context):undefined);if(!segments)throw fault('geometry_unavailable');if(!Array.isArray(segments)||segments.length>maxSegments||segments.some(segment=>!Array.isArray(segment)||segment.length!==4||!segment.every(finitePoint)))throw fault('invalid_geometry_response');
// 有效点击半径 = 场景容差 + 实际线宽/2;不把只点中心线误当成整条可见线的命中范围。 // strokeWidth 同样优先服务回调,再用组件协议,要求是场景单位的有限非负数。
// 有效点击半径=场景容差+线宽/2;不能把模型局部宽度或屏幕像素宽度未经换算直接送进来。
const width=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);if(typeof width!=='number'||!Number.isFinite(width)||width<0)throw fault('invalid_geometry_paint');let possible:ReturnType<typeof hitCubic>|undefined,confirmed:ReturnType<typeof hitCubic>|undefined; const width=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);if(typeof width!=='number'||!Number.isFinite(width)||width<0)throw fault('invalid_geometry_paint');let possible:ReturnType<typeof hitCubic>|undefined,confirmed:ReturnType<typeof hitCubic>|undefined;
// 曲线细分步数由所有边共享剩余预算;无法继续证明时保守返回 possible,不伪造 confirmed。 // 曲线细分步数由所有边共享剩余预算;无法继续证明时保守返回 possible,不伪造 confirmed。
for(const segment of segments){if(remaining<=0){possible??={hit:true,certainty:'possible',steps:0,distance:Infinity,error:Infinity,budget:true};break;}const result=hitCubic(segment,point,tolerance+width/2,{error:errorPx/zoom,maxDepth,maxSteps:remaining});remaining-=result.steps;if(result.hit){if(result.certainty==='confirmed'){confirmed=result;break;}possible=result;}} for(const segment of segments){if(remaining<=0){possible??={hit:true,certainty:'possible',steps:0,distance:Infinity,error:Infinity,budget:true};break;}const result=hitCubic(segment,point,tolerance+width/2,{error:errorPx/zoom,maxDepth,maxSteps:remaining});remaining-=result.steps;if(result.hit){if(result.certainty==='confirmed'){confirmed=result;break;}possible=result;}}
// 同一边任一段确定命中即可 confirmed;只有不确定命中则同时说明精度或预算诊断。 // 同一边任一段确定命中即可 confirmed;只有不确定命中则同时说明精度或预算诊断。
const result=confirmed??possible;if(result?.hit){if(!confirmed)diagnostics.push({target:edge.id,code:result.budget?'geometry_query_budget':'geometry_precision'});hits.push({target:{id:edge.id,incarnation:record.incarnation},kind:'connection',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),displayedEndpoints:{source:edge.source,target:edge.target},layer:layer(edge.id,'connection',edge.zIndex??0),certainty:confirmed?'confirmed':'possible',...(Number.isFinite(result.distance)?{distanceScene:result.distance}:{}),...(Number.isFinite(result.error)?{errorScene:result.error}:{})});} const result=confirmed??possible;if(result?.hit){if(!confirmed)diagnostics.push({target:edge.id,code:result.budget?'geometry_query_budget':'geometry_precision'});hits.push({target:{id:edge.id,incarnation:record.incarnation},kind:'connection',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),displayedEndpoints:{source:edge.source,target:edge.target},layer:layer(edge.id,'connection',edge.zIndex??0),certainty:confirmed?'confirmed':'possible',...(Number.isFinite(result.distance)?{distanceScene:result.distance}:{}),...(Number.isFinite(result.error)?{errorScene:result.error}:{})});}
}catch(error){diagnostics.push({target:edge.id,code:(error as{code?:string}).code??'geometry_handler_failed'});}} }catch(error){diagnostics.push({target:edge.id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
// 尚未完整加载的显示分区可能漏掉未收到元素,命中列表存在也不代表整场景查询完整。 // 只核当前 projection.transforms 覆盖的显示 canvas 是否完整;不推断折叠内部的未显示元素。
// 命中列表非空仍可能不完整,diagnostics 汇总几何解释/预算/精度与模型覆盖问题。
if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'}); if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'});
// 排序按显示层级从高到低,同层节点优先于边,再按文档层序倒序;只返回结果,不直接改变选择。 // 排序:layer 降序→同层 node 优先→文档层序倒序,不以 confirmed/possible 抢改显示层级顺序。
// complete 仅等于本次 diagnostics 为空;消费者仍需理解命中 certainty,函数不改变 selection 或手势。
hits.sort((a,b)=>b.layer-a.layer||(a.kind===b.kind?0:a.kind==='node'?-1:1)||(rank.get(b.target.id)??0)-(rank.get(a.target.id)??0));return freeze({projectId:document.projectId,revision:queries.revision,viewSessionId,pointScene:{...point},toleranceScene:tolerance,hits,complete:diagnostics.length===0,diagnostics}); hits.sort((a,b)=>b.layer-a.layer||(a.kind===b.kind?0:a.kind==='node'?-1:1)||(rank.get(b.target.id)??0)-(rank.get(a.target.id)??0));return freeze({projectId:document.projectId,revision:queries.revision,viewSessionId,pointScene:{...point},toleranceScene:tolerance,hits,complete:diagnostics.length===0,diagnostics});
} }
+1
View File
@@ -1,6 +1,7 @@
/** /**
* 可移植 bindings 的公共入口,集中导出投影、查询、输入与显示租约工具。 * 可移植 bindings 的公共入口,集中导出投影、查询、输入与显示租约工具。
* 不导入 React/React Flow;框架 hooks、组件注册与命令 Context 通过 react-flow.ts 明确选择。 * 不导入 React/React Flow;框架 hooks、组件注册与命令 Context 通过 react-flow.ts 明确选择。
* 此入口只重导出,不创建 view、订阅或缓存实例;scene coordinator 与分桶源的内部接线不在此集中公开。
*/ */
/** Portable scene projection and hit queries: no React or React Flow imports. */ /** Portable scene projection and hit queries: no React or React Flow imports. */
// 模型到场景投影、几何缓存和受预算控制的命中查询。 // 模型到场景投影、几何缓存和受预算控制的命中查询。
+5 -2
View File
@@ -16,7 +16,8 @@ const overlayTarget = '[data-canvas-overlay-view],[role="dialog"],[role="menu"],
/** Inspect the composed path as well as the retargeted host (portals/shadow DOM). /** Inspect the composed path as well as the retargeted host (portals/shadow DOM).
* This uses a structural DOM port so pure consumers need no browser globals. */ * This uses a structural DOM port so pure consumers need no browser globals. */
/** 检查 target 和 composedPath 上所有可 closest 的节点,识别 shadow DOM 重定向后的输入上下文。 /** 检查 target 和 composedPath 上所有可 closest 的节点,识别 shadow DOM 重定向后的输入上下文。
* portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。 */ * portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。
* closest 可查节点的祖先;两个标记独立累计,可同时为 true。DOM 端口异常原样传播,不在此兜底成未编辑。 */
export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick<CanvasKeyInput,'textEditing'|'overlay'>{ export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick<CanvasKeyInput,'textEditing'|'overlay'>{
let textEditing=false,overlay=false; let textEditing=false,overlay=false;
for(const target of [event.target,...(event.composedPath?.()??[])]){ for(const target of [event.target,...(event.composedPath?.()??[])]){
@@ -28,7 +29,9 @@ export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:(
} }
return {textEditing,overlay}; return {textEditing,overlay};
} }
/** 已被宿主处理的事件不再认领;输入法/文本与浮层直接截住,随后按固定优先级短路调用处理器。 */ /** 已 defaultPrevented 的事件先返回 unclaimed,即使同时是文本/浮层输入也不再次认领。
* composing/textEditing 返回 text,overlay 返回 overlay;这两层只是上下文判断,不调用下方 handler。
* gesture→tool→shortcut 同步短路,抛错直接传播;返回 claim 不会自行 preventDefault/stopPropagation。 */
export function routeCanvasKey(input:CanvasKeyInput,handlers:{gesture?:KeyHandler;tool?:KeyHandler;shortcut?:KeyHandler}):InputClaim{ export function routeCanvasKey(input:CanvasKeyInput,handlers:{gesture?:KeyHandler;tool?:KeyHandler;shortcut?:KeyHandler}):InputClaim{
if(input.defaultPrevented)return 'unclaimed'; if(input.defaultPrevented)return 'unclaimed';
if(input.composing||input.textEditing)return 'text'; if(input.composing||input.textEditing)return 'text';
+6 -3
View File
@@ -3,10 +3,12 @@
* owner token 区分同 ID 的不同挂载实例;卸载实例的迟到失效不能触发后来新实例的测量。 * owner token 区分同 ID 的不同挂载实例;卸载实例的迟到失效不能触发后来新实例的测量。
*/ */
/** One renderer store's transient measurement requests. Not model or history state. */ /** One renderer store's transient measurement requests. Not model or history state. */
/** 每个 renderer store 独立创建;默认微任务调度,也可注入测试或宿主 defer。 */ /** 每个 renderer store 独立创建:owners 记录挂载 token→nodeId,dirty 只记录待测 token。
* defer 默认为微任务;update 接收执行时仍有效的 ID,不接触文档。工厂没有全局 dispose/flush 公开入口。 */
export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){ export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){
const owners=new Map<symbol,string>(),dirty=new Set<symbol>();let queued=false; const owners=new Map<symbol,string>(),dirty=new Set<symbol>();let queued=false;
/** 执行时才解析仍有效的 owner,按节点 ID 去重;先清 dirty 再回调,让重入失效进入下一轮。 */ /** 执行时解析仍有效 owner 并按 ID 去重,清 queued/dirty 后 update;重入 invalidate 可安排下一轮。
* update 异常直接传播,本批不自动回队重试;卸载 token 的请求被排除,新 token 须自行 invalidate 才入本批。 */
function flush(){ function flush(){
queued=false;const ids=new Set<string>();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear(); queued=false;const ids=new Set<string>();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear();
if(ids.size)update([...ids]); if(ids.size)update([...ids]);
@@ -16,7 +18,8 @@ export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flus
acquire(id:string){ acquire(id:string){
const owner=Symbol(id);owners.set(owner,id); const owner=Symbol(id);owners.set(owner,id);
return Object.freeze({ return Object.freeze({
/** 仅活跃租约可以变脏,同一批最多安排一次延后 flush。 */ /** 活跃 token 去重变脏,queued 防重复 defer;多个 token 若同 nodeId,flush 时再合成一个 ID。
* 不捕获 defer 异常:若宿主抛错但未排任务,queued 不自动复位,宿主应使用可靠调度端口。 */
invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}}, invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}},
/** 卸载同时移除 owner 和其未执行请求;已排微任务可自然执行为空,不取得新实例权限。 */ /** 卸载同时移除 owner 和其未执行请求;已排微任务可自然执行为空,不取得新实例权限。 */
dispose(){owners.delete(owner);dirty.delete(owner);}, dispose(){owners.delete(owner);dirty.delete(owner);},
+10 -5
View File
@@ -9,19 +9,24 @@ import {fault} from '../canvas-core/values.ts';
/** bounds 使用屏幕空间;target 仍是真实元素 ID+代次,viewId 限定所属交互视图。 */ /** bounds 使用屏幕空间;target 仍是真实元素 ID+代次,viewId 限定所属交互视图。 */
export type OverlayEntry={readonly id:string;readonly viewId:string;readonly placement:string;readonly target:Target;readonly bounds:Rect;readonly focused:boolean;readonly composing:boolean}; export type OverlayEntry={readonly id:string;readonly viewId:string;readonly placement:string;readonly target:Target;readonly bounds:Rect;readonly focused:boolean;readonly composing:boolean};
/** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */ /** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */
/** 为一个有效 viewId 创建租约表、冻结快照和可重建 paint 网格。 */ /** entries 保存 overlayId→{token,entry},snapshot 沿用 Map 插入顺序(替换同 ID 不移位),index 只按屏幕 bounds 查询。
* 不订阅文档/视口,不自行核 target 存活;调用者更新位置,并在 visible 时提供当前目标有效性判断。 */
export function createOverlayIndex(viewId:string){ export function createOverlayIndex(viewId:string){
if(typeof viewId!=='string'||!viewId)throw fault('view_session_required');let serial=0,snapshot:readonly OverlayEntry[]=Object.freeze([]);const entries=new Map<string,{token:number;entry:OverlayEntry}>(),listeners=new Set<()=>void>(),index=createPaintIndex(); if(typeof viewId!=='string'||!viewId)throw fault('view_session_required');let serial=0,snapshot:readonly OverlayEntry[]=Object.freeze([]);const entries=new Map<string,{token:number;entry:OverlayEntry}>(),listeners=new Set<()=>void>(),index=createPaintIndex();
/** 从当前条目重建快照和空间索引后通知;这里只同步位置事实,不决定浮层 DOM 的挂载。 */ /** 先更新 snapshot/index,再同步通知监听集合副本;只发布位置事实,不挂载 DOM。
* 监听器异常直接传播且会中断后续通知,不回滚已发布条目;不能据 register/update 抛错推断它未生效。 */
function publish(){snapshot=Object.freeze([...entries.values()].map(row=>row.entry));index.sync(snapshot.map(entry=>({id:entry.id,bounds:entry.bounds})));for(const listener of [...listeners])listener();} function publish(){snapshot=Object.freeze([...entries.values()].map(row=>row.entry));index.sync(snapshot.map(entry=>({id:entry.id,bounds:entry.bounds})));for(const listener of [...listeners])listener();}
/** 复制并冻结目标/矩形,验证身份和位置;复用 paint 索引校验矩形,拒绝不合法的外部边界。 */ /** 重建并冻结 entry 外壳/target/bounds,校验 ID/placement/目标代次和合法矩形。
* focused/composing 的布尔值由类型合同约束,此处不另验;扩展字段浅保留,不是任意对象深克隆。 */
function capture(id:string,value:Omit<OverlayEntry,'id'|'viewId'>):OverlayEntry{if(typeof id!=='string'||!id||typeof value.placement!=='string'||!value.placement||typeof value.target.id!=='string'||!value.target.id||!Number.isSafeInteger(value.target.incarnation)||value.target.incarnation<1)throw fault('invalid_overlay');const entry=Object.freeze({...value,id,viewId,target:Object.freeze({...value.target}),bounds:Object.freeze({...value.bounds})});const check=createPaintIndex();check.sync([{id,bounds:entry.bounds}]);return entry;} function capture(id:string,value:Omit<OverlayEntry,'id'|'viewId'>):OverlayEntry{if(typeof id!=='string'||!id||typeof value.placement!=='string'||!value.placement||typeof value.target.id!=='string'||!value.target.id||!Number.isSafeInteger(value.target.incarnation)||value.target.incarnation<1)throw fault('invalid_overlay');const entry=Object.freeze({...value,id,viewId,target:Object.freeze({...value.target}),bounds:Object.freeze({...value.bounds})});const check=createPaintIndex();check.sync([{id,bounds:entry.bounds}]);return entry;}
// 订阅只关注本视图位置变化,调用方需用返回函数解除监听;getSnapshot 返回稳定的最近发布值。 // 订阅只关注本视图位置变化,调用方需用返回函数解除监听;getSnapshot 返回稳定的最近发布值。
return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};}, return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};},
/** 注册或替换一个浮层,返回只能操作本 token 的 update/release。 /** 注册或替换一个浮层,返回只能操作本 token 的 update/release。
* update 先完整校验,新旧序列化值相同时不发布;过期租约的更新/释放均无效果。 */ * 注册时 ++serial 分配 token,覆盖注册使旧租约失效;update 先检查 token,再捕获并按 JSON 文本比较。
* 等值 update 不发布,register 替换始终发布;release/clear 只移除瞬态浮层,不删除元素。 */
register(id:string,value:Omit<OverlayEntry,'id'|'viewId'>){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit<OverlayEntry,'id'|'viewId'>){if(entries.get(id)?.token!==token)return;const entry=capture(id,value),old=entries.get(id)!.entry;if(JSON.stringify(old)===JSON.stringify(entry))return;entries.set(id,{token,entry});publish();},release(){if(entries.get(id)?.token!==token)return;entries.delete(id);publish();}});}, register(id:string,value:Omit<OverlayEntry,'id'|'viewId'>){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit<OverlayEntry,'id'|'viewId'>){if(entries.get(id)?.token!==token)return;const entry=capture(id,value),old=entries.get(id)!.entry;if(JSON.stringify(old)===JSON.stringify(entry))return;entries.set(id,{token,entry});publish();},release(){if(entries.get(id)?.token!==token)return;entries.delete(id);publish();}});},
/** 正常浮层按视口交集筛选;已聚焦或输入法组合中的浮层保留,但目标必须仍有效。 */ /** 先验证查询矩形并查网格,再按 snapshot 顺序筛选:current(target) 必须真,之后才考虑焦点/组合状态或交集。
* 焦点/输入法保留不会绕过目标有效性,也不自动移除过期 entries;返回新数组,不发布位置更新。 */
visible(bounds:Rect,current:(target:Target)=>boolean){const ids=index.query(bounds);return snapshot.filter(entry=>current(entry.target)&&(entry.focused||entry.composing||ids.has(entry.id)));}, visible(bounds:Rect,current:(target:Target)=>boolean){const ids=index.query(bounds);return snapshot.filter(entry=>current(entry.target)&&(entry.focused||entry.composing||ids.has(entry.id)));},
/** 清空所有当前浮层并发布空快照;不关闭监听服务,后续仍可注册新租约。 */ /** 清空所有当前浮层并发布空快照;不关闭监听服务,后续仍可注册新租约。 */
clear(){entries.clear();publish();}, clear(){entries.clear();publish();},
+9 -4
View File
@@ -12,18 +12,23 @@ const valid=(r:Rect)=>r&&[r.x,r.y,r.width,r.height,r.x+r.width,r.y+r.height].eve
/** 包含边界接触的保守交集,与 nativeRectVisible 的正面积判定用途不同。 */ /** 包含边界接触的保守交集,与 nativeRectVisible 的正面积判定用途不同。 */
const intersects=(a:Rect,b:Rect)=>a.x<=b.x+b.width&&b.x<=a.x+a.width&&a.y<=b.y+b.height&&b.y<=a.y+a.height; const intersects=(a:Rect,b:Rect)=>a.x<=b.x+b.width&&b.x<=a.x+a.width&&a.y<=b.y+b.height&&b.y<=a.y+a.height;
/** Rebuildable view-space grid. Oversized regions/queries fall back without false negatives. */ /** Rebuildable view-space grid. Oversized regions/queries fall back without false negatives. */
/** cellSize 决定网格尺度,maxCells 限制单区域/查询展开的格数,默认512和256。 */ /** cellSize 决定网格尺度,maxCells 限单区域/查询展开格数,默认512和256。
* rows 存矩形,cells 存格→ID桶,members 存 ID→格键,overflow 存无法安全展开的 ID;全部可从输入重建。
* maxCells 不限制总区域数/总表大小;大区域或大查询保守回退扫描,可能线性耗时。 */
export function createPaintIndex(cellSize=512,maxCells=256){ export function createPaintIndex(cellSize=512,maxCells=256){
if(!Number.isFinite(cellSize)||cellSize<=0||!Number.isSafeInteger(maxCells)||maxCells<1)throw fault('invalid_paint_index'); if(!Number.isFinite(cellSize)||cellSize<=0||!Number.isSafeInteger(maxCells)||maxCells<1)throw fault('invalid_paint_index');
const rows=new Map<Id,Rect>(),cells=new Map<string,Set<Id>>(),members=new Map<Id,readonly string[]>(),overflow=new Set<Id>(); const rows=new Map<Id,Rect>(),cells=new Map<string,Set<Id>>(),members=new Map<Id,readonly string[]>(),overflow=new Set<Id>();
/** 算出覆盖格键;负坐标用 floor,过大或不安全的格范围返回 undefined 走保守路径。 */ /** 用 floor 算包含两端边界的格区间,负坐标也一致;恰好压格线的矩形可占相邻两格。
* 不安全整数或格数超 maxCells 返回 undefined,避免巨大循环,随后通过真实矩形交集排除假阳性。 */
function keys(r:Rect){const x=Math.floor(r.x/cellSize),y=Math.floor(r.y/cellSize),right=Math.floor((r.x+r.width)/cellSize),bottom=Math.floor((r.y+r.height)/cellSize);if(![x,y,right,bottom].every(Number.isSafeInteger)||(right-x+1)*(bottom-y+1)>maxCells)return;const result:string[]=[];for(let a=x;a<=right;a++)for(let b=y;b<=bottom;b++)result.push(`${a},${b}`);return result;} function keys(r:Rect){const x=Math.floor(r.x/cellSize),y=Math.floor(r.y/cellSize),right=Math.floor((r.x+r.width)/cellSize),bottom=Math.floor((r.y+r.height)/cellSize);if(![x,y,right,bottom].every(Number.isSafeInteger)||(right-x+1)*(bottom-y+1)>maxCells)return;const result:string[]=[];for(let a=x;a<=right;a++)for(let b=y;b<=bottom;b++)result.push(`${a},${b}`);return result;}
/** 同时清除反向成员表、格桶与溢出集合,空桶立即释放。 */ /** 同时清除反向成员表、格桶与溢出集合,空桶立即释放。 */
function remove(id:Id){for(const key of members.get(id)??[]){const bucket=cells.get(key)!;bucket.delete(id);if(!bucket.size)cells.delete(key);}members.delete(id);overflow.delete(id);rows.delete(id);} function remove(id:Id){for(const key of members.get(id)??[]){const bucket=cells.get(key)!;bucket.delete(id);if(!bucket.size)cells.delete(key);}members.delete(id);overflow.delete(id);rows.delete(id);}
return Object.freeze({ return Object.freeze({
/** 先验证完整输入的 ID 唯一性和矩形,再更新差异;超大区域归入 overflow,每次查询都检查。 */ /** 先复制全部输入矩形并验证非空 ID/唯一性/合法矩形,再移除消失项并更新变化项。
* 不变行保留现有桶;大区域归 overflow 每次查。格式校验失败不开始改表,但不承诺任意运行时异常都回滚。 */
sync(regions:readonly PaintRegion[]){const incoming=new Map<Id,Rect>();for(const region of regions){if(!region.id||incoming.has(region.id)||!valid(region.bounds))throw fault('invalid_paint_region');incoming.set(region.id,{...region.bounds});}for(const id of rows.keys())if(!incoming.has(id))remove(id);for(const [id,r]of incoming){const old=rows.get(id);if(old&&old.x===r.x&&old.y===r.y&&old.width===r.width&&old.height===r.height)continue;remove(id);rows.set(id,r);const list=keys(r);if(!list){overflow.add(id);continue;}members.set(id,list);for(const key of list){let bucket=cells.get(key);if(!bucket)cells.set(key,bucket=new Set());bucket.add(id);}}}, sync(regions:readonly PaintRegion[]){const incoming=new Map<Id,Rect>();for(const region of regions){if(!region.id||incoming.has(region.id)||!valid(region.bounds))throw fault('invalid_paint_region');incoming.set(region.id,{...region.bounds});}for(const id of rows.keys())if(!incoming.has(id))remove(id);for(const [id,r]of incoming){const old=rows.get(id);if(old&&old.x===r.x&&old.y===r.y&&old.width===r.width&&old.height===r.height)continue;remove(id);rows.set(id,r);const list=keys(r);if(!list){overflow.add(id);continue;}members.set(id,list);for(const key of list){let bucket=cells.get(key);if(!bucket)cells.set(key,bucket=new Set());bucket.add(id);}}},
/** 普通查询查覆盖格加 overflow,大查询扫描全部行,最后统一按矩形相交过滤。 */ /** 覆盖格+overflow 合并候选,无法安全展开的大查询改扫全部 rows;最后含边界相交精筛。
* 返回新 Set(只读仅是类型),外部改它不影响索引;不排序,不能用迭代次序当绘制层序。 */
query(region:Rect):ReadonlySet<Id>{if(!valid(region))throw fault('invalid_paint_region');const list=keys(region),candidates=list?new Set([...overflow,...list.flatMap(key=>[...cells.get(key)??[]])]):new Set(rows.keys());return new Set([...candidates].filter(id=>intersects(rows.get(id)!,region)));}, query(region:Rect):ReadonlySet<Id>{if(!valid(region))throw fault('invalid_paint_region');const list=keys(region),candidates=list?new Set([...overflow,...list.flatMap(key=>[...cells.get(key)??[]])]):new Set(rows.keys());return new Set([...candidates].filter(id=>intersects(rows.get(id)!,region)));},
/** 释放所有可重建空间表,不保留跨场景的区域引用。 */ /** 释放所有可重建空间表,不保留跨场景的区域引用。 */
clear(){rows.clear();cells.clear();members.clear();overflow.clear();}, clear(){rows.clear();cells.clear();members.clear();overflow.clear();},
+4 -2
View File
@@ -6,9 +6,11 @@
/** 最小结构化 DOM 端口,让纯测试无需真实 HTMLElement。 */ /** 最小结构化 DOM 端口,让纯测试无需真实 HTMLElement。 */
export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void}; export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void};
/** 普通事件匹配 pointerId 即可;lostpointercapture 还要匹配原捕获目标且确认它已失去捕获。 /** 普通事件匹配 pointerId 即可;lostpointercapture 还要匹配原捕获目标且确认它已失去捕获。
* 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。 */ * 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。
* 缺 hasPointerCapture 的匹配目标也视为已丢失;此函数不释放 DOM 捕获、不提交/取消 editor 手势。 */
export function ownsPointerCapture(owner:{pointerId:number;capture:unknown}|null|undefined,input:{pointerId:number;type?:string;target?:unknown}){if(!owner||owner.pointerId!==input.pointerId)return false;if(input.type!=='lostpointercapture')return true;if(input.target!==owner.capture)return false;const capture=owner.capture as Partial<PointerCapturePort>|undefined;try{if(capture?.hasPointerCapture?.(owner.pointerId))return false;}catch{/* A detached matching target may still need cancellation. */}return true;} export function ownsPointerCapture(owner:{pointerId:number;capture:unknown}|null|undefined,input:{pointerId:number;type?:string;target?:unknown}){if(!owner||owner.pointerId!==input.pointerId)return false;if(input.type!=='lostpointercapture')return true;if(input.target!==owner.capture)return false;const capture=owner.capture as Partial<PointerCapturePort>|undefined;try{if(capture?.hasPointerCapture?.(owner.pointerId))return false;}catch{/* A detached matching target may still need cancellation. */}return true;}
/** 平台可能因指针失效/节点卸载拒绝捕获,以布尔结果交给宿主决定是否继续手势。 */ /** setPointerCapture 未抛错则返回 true,不再调用 hasPointerCapture 确认平台状态。
* 平台拒绝/节点卸载等异常统一 false,具体是否继续/取消手势仍由调用方决定。 */
export function acquirePointerCapture(target:PointerCapturePort,id:number){try{target.setPointerCapture(id);return true;}catch{return false;}} export function acquirePointerCapture(target:PointerCapturePort,id:number){try{target.setPointerCapture(id);return true;}catch{return false;}}
/** 尽力释放且吞掉平台清理异常;DOM 释放失败不能取得 editor 的提交或取消权限。 */ /** 尽力释放且吞掉平台清理异常;DOM 释放失败不能取得 editor 的提交或取消权限。 */
export function releasePointerCapture(target:PointerCapturePort,id:number){try{if(target.hasPointerCapture(id))target.releasePointerCapture(id);}catch{/* Detached DOM or an already released platform capture has no editor authority. */}} export function releasePointerCapture(target:PointerCapturePort,id:number){try{if(target.hasPointerCapture(id))target.releasePointerCapture(id);}catch{/* Detached DOM or an already released platform capture has no editor authority. */}}
+11 -4
View File
@@ -10,7 +10,9 @@ import type {projectCanvas,ProjectionNode} from './projection.ts';
import {createPaintIndex,type PaintRegion} from './paint-index.ts'; import {createPaintIndex,type PaintRegion} from './paint-index.ts';
/** Extra node paint extents beyond native unrotated React Flow bounds. */ /** Extra node paint extents beyond native unrotated React Flow bounds. */
/** 对每个显示节点还原局部几何,调用组件 paint 边界,再把四角变到场景取轴对齐包围盒。 /** 对每个显示节点还原局部几何,调用组件 paint 边界,再把四角变到场景取轴对齐包围盒。
* 只记录超出原生矩形的节点;单节点协议失败记 unknown,其余节点继续处理。 */ * 只输出超出原生矩形的节点完整 paint AABB,不是只输出外溢条带;每帧遍历所有显示 node。
* 单节点协议失败记 unknown 后继续,不包含边/selection/手势状态,也不决定 renderer 最终是否保留元素。
* 不检查 registry 与 projection 的来源身份,调用方须保持同解释上下文;fallback 按矩形外壳解释。 */
export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){ export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){
const regions:PaintRegion[]=[],unknown:string[]=[]; const regions:PaintRegion[]=[],unknown:string[]=[];
for(const node of projection.nodes)try{ for(const node of projection.nodes)try{
@@ -36,7 +38,8 @@ const sameGeometry=(a:Geometry|undefined,b:Geometry|undefined)=>a===undefined?b=
/** 变换值比较避免无关根发布反复计算 paint 边界。 */ /** 变换值比较避免无关根发布反复计算 paint 边界。 */
const sameTransform=(a:Transform|undefined,b:Transform|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation; const sameTransform=(a:Transform|undefined,b:Transform|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
/** 验证缓存输入是否等价;协议未声明 dataDependencies 时保守比较整个 element.data, /** 验证缓存输入是否等价;协议未声明 dataDependencies 时保守比较整个 element.data,
* 显式声明后只比较列出的字段,声明遗漏会由协议实现者负责,不能由缓存自动推导。 */ * 显式声明后只比较列出的 element.data 顶层字段(不是点分路径),空数组表示声明不依赖 data。
* 依赖列表须随同一 protocol 对象保持不变;遗漏字段/原地修改声明不会被缓存自动推导为新协议。 */
function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol){ function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol){
if(old.incarnation!==element?.incarnation||old.owner!==element?.ownerCanvasId||old.component!==element?.componentId||old.version!==element?.dataVersion||old.protocol!==protocol||!sameGeometry(old.geometry,element?.structure==='node'?element.geometry:undefined)||!sameTransform(old.transform,t)||old.x!==node.position.x||old.y!==node.position.y||old.width!==node.width||old.height!==node.height||old.rotation!==node.data.rotation)return false; if(old.incarnation!==element?.incarnation||old.owner!==element?.ownerCanvasId||old.component!==element?.componentId||old.version!==element?.dataVersion||old.protocol!==protocol||!sameGeometry(old.geometry,element?.structure==='node'?element.geometry:undefined)||!sameTransform(old.transform,t)||old.x!==node.position.x||old.y!==node.position.y||old.width!==node.width||old.height!==node.height||old.rotation!==node.data.rotation)return false;
const dependencies=protocol.dataDependencies; const dependencies=protocol.dataDependencies;
@@ -51,14 +54,18 @@ function captureSignature(node:ProjectionNode,element:ElementRecord|undefined,t:
return{incarnation:element?.incarnation,owner:element?.ownerCanvasId,component:element?.componentId,version:element?.dataVersion,geometry:element?.structure==='node'?{...element.geometry}:undefined,transform:t?{...t}:undefined,x:node.position.x,y:node.position.y,width:node.width,height:node.height,rotation:node.data.rotation,protocol,data:dependencies===undefined?clone(element?.data):dependencies.map(key=>clone(element?.data[key]))}; return{incarnation:element?.incarnation,owner:element?.ownerCanvasId,component:element?.componentId,version:element?.dataVersion,geometry:element?.structure==='node'?{...element.geometry}:undefined,transform:t?{...t}:undefined,x:node.position.x,y:node.position.y,width:node.width,height:node.height,rotation:node.data.rotation,protocol,data:dependencies===undefined?clone(element?.data):dependencies.map(key=>clone(element?.data[key]))};
} }
/** View-owned incremental paint grid; unchanged rows retain their captured dependencies. */ /** View-owned incremental paint grid; unchanged rows retain their captured dependencies. */
/** 视图级增量 paint 网格,每个节点缓存边界结果,只重新计算签名改变的条目。 */ /** 视图级增量 paint 网格:entries 缓存每个节点的签名及 regions/unknown 结果,index 只存额外范围。
* 不增量维护输入成员表,每次 sync 仍遍历全部 node 并 index.sync 全部 regions;只节省未变几何计算。
* 由 registry 对象/project/canvas 隔离,不额外核 editor/view owner;宿主应给每个视图独立实例。 */
export function createProjectedPaintIndex(){ export function createProjectedPaintIndex(){
let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0; let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0;
type Projection=ReturnType<typeof projectCanvas>; type Projection=ReturnType<typeof projectCanvas>;
type Entry={signature:PaintSignature;result:ReturnType<typeof projectedPaintOverflow>}; type Entry={signature:PaintSignature;result:ReturnType<typeof projectedPaintOverflow>};
const entries=new Map<string,Entry>(),index=createPaintIndex(); const entries=new Map<string,Entry>(),index=createPaintIndex();
return Object.freeze({ return Object.freeze({
/** 切 registry/project/canvas 清空缓存;逐节点收集额外范围与 unknown,再同步网格。 */ /** 切 registry/project/canvas 先清表,逐节点比较签名,变化才重新捕获依赖并调用完整单节点算法。
* unknown 结果同样可缓存至签名变化;数据捕获/比较异常可能直接抛出,整轮并非事务回滚。
* query 是同一可变 index 的方法,旧返回对象的 query 会查询后续 sync 的网格;unknown 则是本轮冻结列表。 */
sync(projection:Projection,registry:Registry){ sync(projection:Projection,registry:Registry){
if(registryOwner!==registry||canvas!==projection.canvasId||project!==projection.document.projectId){entries.clear();index.clear();registryOwner=registry;canvas=projection.canvasId;project=projection.document.projectId;} if(registryOwner!==registry||canvas!==projection.canvasId||project!==projection.document.projectId){entries.clear();index.clear();registryOwner=registry;canvas=projection.canvasId;project=projection.document.projectId;}
const seen=new Set<string>(),regions:PaintRegion[]=[],unknown:string[]=[]; const seen=new Set<string>(),regions:PaintRegion[]=[],unknown:string[]=[];
+9 -4
View File
@@ -12,14 +12,17 @@ type Projection=ReturnType<typeof projectCanvas>;
/** 原对象的生命周期证据;同 ID 的重建或移动 owner 后不能直接继承旧显示对象。 */ /** 原对象的生命周期证据;同 ID 的重建或移动 owner 后不能直接继承旧显示对象。 */
type Retained<T>={incarnation:number;ownerCanvasId:Id;value:T}; type Retained<T>={incarnation:number;ownerCanvasId:Id;value:T};
/** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */ /** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */
/** 创建视图专属结构共享器;reuse 会就地替换传入投影数组中的等价条目,调用方应提供派生数组。 */ /** 创建视图专属结构共享器;reuse 会就地改传入 nodes/edges 数组,调用方应提供自有可变派生数组。
* 不深克隆/冻结显示对象;缓存的对象须按不可变约定使用,不能传入业务记录数组或原地修改旧投影。 */
export function createProjectionCache(){ export function createProjectionCache(){
let scope:object|undefined,projectId:Id|undefined,canvasId:Id|undefined; let scope:object|undefined,projectId:Id|undefined,canvasId:Id|undefined;
let nodeArray:Projection['nodes']|undefined,edgeArray:Projection['edges']|undefined; let nodeArray:Projection['nodes']|undefined,edgeArray:Projection['edges']|undefined;
/** 数组长度与逐项引用完全一致时沿用旧数组,顺序改变也会产生新数组身份。 */ /** 数组长度与逐项引用完全一致时沿用旧数组,顺序改变也会产生新数组身份。 */
const stable=<T>(previous:T[]|undefined,next:T[])=>previous?.length===next.length&&next.every((value,index)=>value===previous[index])?previous:next; const stable=<T>(previous:T[]|undefined,next:T[])=>previous?.length===next.length&&next.every((value,index)=>value===previous[index])?previous:next;
let nodes=new Map<Id,Retained<Projection['nodes'][number]>>(),edges=new Map<Id,Retained<Projection['edges'][number]>>(); let nodes=new Map<Id,Retained<Projection['nodes'][number]>>(),edges=new Map<Id,Retained<Projection['edges'][number]>>();
/** 对每个显示 ID 核验模型身份,再逐层共享等价字段;返回仅包含本帧元素的新缓存表。 */ /** 每个显示 ID 须有文档记录;生命周期键只核 incarnation+owner,不另含 component/dataVersion,显示差异由值比较处理。
* 仅 data/style/position 三个嵌套字段单独等价共享,再比较整个显示对象;新表只含本帧项。
* 不查投影 ID 重复、不检查 node/edge 与记录的全部对应语义;这依赖上游合法投影。 */
function retain<T extends{id:Id}>(document:Document,values:T[],previous:Map<Id,Retained<T>>){ function retain<T extends{id:Id}>(document:Document,values:T[],previous:Map<Id,Retained<T>>){
const next=new Map<Id,Retained<T>>(); const next=new Map<Id,Retained<T>>();
for(let index=0;index<values.length;index++){ for(let index=0;index<values.length;index++){
@@ -35,7 +38,8 @@ export function createProjectionCache(){
return next; return next;
} }
return Object.freeze({ return Object.freeze({
/** 范围改变先整体清空;保存规范化后的对象表及稳定数组,返回包装后的投影。 */ /** owner/project/canvas 改变先清旧表/数组,再分别 retain 节点和边,逐项引用相同才复用整个旧数组。
* 总返回新的投影外壳,nodes/edges 可沿用旧引用;中途抛错可能已改传入数组,不提供事务回滚。 */
reuse(owner:object,document:Document,viewCanvasId:Id,projection:Projection):Projection{ reuse(owner:object,document:Document,viewCanvasId:Id,projection:Projection):Projection{
if(scope!==owner||projectId!==document.projectId||canvasId!==viewCanvasId){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=owner;projectId=document.projectId;canvasId=viewCanvasId;} if(scope!==owner||projectId!==document.projectId||canvasId!==viewCanvasId){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=owner;projectId=document.projectId;canvasId=viewCanvasId;}
const nextNodes=retain(document,projection.nodes,nodes),nextEdges=retain(document,projection.edges,edges); const nextNodes=retain(document,projection.nodes,nodes),nextEdges=retain(document,projection.edges,edges);
@@ -44,7 +48,8 @@ export function createProjectionCache(){
}, },
/** 读取本轮保留的节点显示对象,供 renderer 测量等显示流程使用。 */ /** 读取本轮保留的节点显示对象,供 renderer 测量等显示流程使用。 */
getNode(id:Id){return nodes.get(id)?.value;}, getNode(id:Id){return nodes.get(id)?.value;},
/** 只有上下文、生命周期、renderer type、尺寸和 data 均兼容才可沿用旧测量,不能只比较 ID。 */ /** 核上下文、incarnation/owner、renderer type、width/height 与 data 等价,决定能否借旧测量。
* 不要求 position/style/整个 node 等价,也不执行 DOM 测量;调用者仍负责应用测量结果的 UI 规则。 */
canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){ canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){
const old=nodes.get(value.id),record=document.elements.get(value.id); const old=nodes.get(value.id),record=document.elements.get(value.id);
return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data); return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data);
+21 -8
View File
@@ -16,7 +16,8 @@ import { childCanvasTransform, toWorld, transformPath, type Transform } from '..
export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts'; export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts';
/** 当前显示 canvas 自身作为场景原点,不叠加它在外层画布中的布局。 */ /** 当前显示 canvas 自身作为场景原点,不叠加它在外层画布中的布局。 */
const origin:Transform={x:0,y:0,scale:1,rotation:0}; const origin:Transform={x:0,y:0,scale:1,rotation:0};
/** 无法解释或缺 renderer 时的降级外壳;只暴露类型/版本及从已知结构派生的连接把手。 */ /** 降级原因/类型/版本及从已知结构派生的把手;节点显示还可保留字符串标题和通用身份/几何。
* 不透传整份未知 data 给旧 renderer,不等于删除原 payload 或保证显示完全不读取任何通用字段。 */
export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]}; export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]};
/** renderer 节点形状。__headlessTarget 绑定真实身份,record-v1 另带模型记录与所属 canvas 变换。 */ /** renderer 节点形状。__headlessTarget 绑定真实身份,record-v1 另带模型记录与所属 canvas 变换。 */
export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;height?:number;selected?:boolean;draggable?:boolean;zIndex?:number;measured?:{width?:number;height?:number};style?:{width?:number|string;height?:number|string;pointerEvents?:'none'|'auto'};data:CanvasNodeData&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform}}; export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;height?:number;selected?:boolean;draggable?:boolean;zIndex?:number;measured?:{width?:number;height?:number};style?:{width?:number|string;height?:number|string;pointerEvents?:'none'|'auto'};data:CanvasNodeData&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
@@ -28,7 +29,10 @@ const object = (value: Json | undefined) => value && typeof value === 'object' &
/** Construct one node. Scene updates and full traversal share exactly the same projection rules. */ /** Construct one node. Scene updates and full traversal share exactly the same projection rules. */
/** 优先采用手势预览几何,再计算场景中心/尺寸/旋转;已知组件的移动能力仍由 registry 判断。 /** 优先采用手势预览几何,再计算场景中心/尺寸/旋转;已知组件的移动能力仍由 registry 判断。
* renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。 */ * renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。
* 已知 data 字段浅展开后由通用显示字段覆盖,record-v1 另借用原 element/transform,不深复制模型记录。
* draggable 只按 known 与 geometry.move 能力判定,缺 renderer 的 fallback 不必自动只读;策略仍由 editor 检查。
* 返回可变派生对象供后续补把手/结构共享,只有 __headlessTarget 单独冻结,不是完整输出深冻结。 */
export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,canvasId:Id,registry:Registry,selected:{has(id:Id):boolean},geometry:Geometry|undefined,transform:Transform,depth:number,child:Id|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionNode{ export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,canvasId:Id,registry:Registry,selected:{has(id:Id):boolean},geometry:Geometry|undefined,transform:Transform,depth:number,child:Id|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionNode{
const g = geometry ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 }); const g = geometry ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 });
const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, resolved=registry.resolve(element),known=resolved==='known'; const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, resolved=registry.resolve(element),known=resolved==='known';
@@ -47,7 +51,9 @@ export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,ca
/** Endpoints are already resolved to their displayed node IDs by the caller. */ /** Endpoints are already resolved to their displayed node IDs by the caller. */
/** 调用方已确定显示 source/target;真实端点被入口替代时清除原 portId,避免端口指向错误外壳。 /** 调用方已确定显示 source/target;真实端点被入口替代时清除原 portId,避免端口指向错误外壳。
* 预览只替代本条边的路径;不把 data 内可能残留的 path 混入已验证的结构路径。 */ * compat-v1 只对 target.id 匹配的边采用预览路径,变到场景后通过 connectionPathValid 才输出。
* record-v1 接原记录/transform,不在此嵌入预览或转换结构路径;原生 renderer 须从自己的受控显示上下文读取预览。
* data.path 从兼容 payload 中剔除,避免残留字段覆盖结构 path;输出仍是派生边,不改真实端点。 */
export function projectEdge(element:Extract<ElementRecord,{structure:'connection'}>,source:Id,target:Id,registry:Registry,selected:{has(id:Id):boolean},transform:Transform,edgePreview:{target:{id:Id};path:Json}|null|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionEdge{ export function projectEdge(element:Extract<ElementRecord,{structure:'connection'}>,source:Id,target:Id,registry:Registry,selected:{has(id:Id):boolean},transform:Transform,edgePreview:{target:{id:Id};path:Json}|null|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionEdge{
const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer; const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer;
// 原生 record-v1 接记录和变换自行绘制;兼容路径转换到场景,未知边不解释自定义路径。 // 原生 record-v1 接记录和变换自行绘制;兼容路径转换到场景,未知边不解释自定义路径。
@@ -59,7 +65,10 @@ export function projectEdge(element:Extract<ElementRecord,{structure:'connection
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */ /** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
/** 从显示根深度优先投影展开分区,记录可见节点和被折叠祖先代理的子 canvas。 /** 从显示根深度优先投影展开分区,记录可见节点和被折叠祖先代理的子 canvas。
* 只输出 owner 在当前显示范围的连线,再把不可见子节点端点映射到其折叠入口。 */ * 只输出 owner 在当前展开范围的连线,再把隐藏后代端点映射到可见折叠入口。
* 输入须是已验证的文档树/registry;这里查显示根与可选 queries 身份,不重做完整文档校验或检测所有递归环。
* 不做 viewport 裁剪或网络/资源读取;输出全体展开场景,再由 renderer/paint 索引决定屏幕保留。
* 派生容器虽可变,消费者仍应按只读约定使用;transforms 可复用共享 origin/几何缓存值,不应原地改它们。 */
export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog,services:{queries?:DocumentQueries;geometry?:GeometryProjectionCache;owner?:object}={}) { export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog,services:{queries?:DocumentQueries;geometry?:GeometryProjectionCache;owner?:object}={}) {
if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]); if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]);
// 索引必须对应同一 document/registry;几何缓存以 owner/project/canvas 隔离。 // 索引必须对应同一 document/registry;几何缓存以 owner/project/canvas 隔离。
@@ -72,7 +81,8 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
/** 有查询索引用其稳定成员序;无索引时由模型直接归属派生一次成员表,不维护第二份事实。 */ /** 有查询索引用其稳定成员序;无索引时由模型直接归属派生一次成员表,不维护第二份事实。 */
const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[]; const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[];
const nodes: ProjectionNode[] = [], visible = new Set<Id>(); const nodes: ProjectionNode[] = [], visible = new Set<Id>();
/** 递归展开只改变投影:先输出入口,再按其当前几何/单位比例变换子分区。 */ /** 按成员稳定顺序深度优先:先投影入口,再用同一份预览几何/单位构造内部 canvas 变换。
* 隐藏分支用栈遍历后代建立 hiddenBy,不生成内部节点/边变换;无 queries 时反复从 canvas 表找子项。 */
const visit = (id: Id, depth: number) => { const visit = (id: Id, depth: number) => {
const transform = transforms.get(id)!; const transform = transforms.get(id)!;
for (const element of getMembers(id)) if (element.structure === 'node') { for (const element of getMembers(id)) if (element.structure === 'node') {
@@ -91,10 +101,12 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
} }
}; };
visit(canvasId, 0); visit(canvasId, 0);
// 只从已展开 owner 找边;边在模型中仍归其原 canvas,显示代理不是把边迁入子 canvas。 // 节点/hiddenBy 全部就绪后再投影边,避免端点代理依赖节点是否已遍历。
// queries 路径汇集展开 owner 的边再按文档层序排,无 queries 则扫描 element 表;模型归属始终不变。
const edges: ProjectionEdge[] = []; const edges: ProjectionEdge[] = [];
for (const element of queries?queries.orderedElements([...transforms.keys()].flatMap(id=>queries.members(id).filter(id=>document.elements.get(id)?.structure==='connection'))).map(id=>document.elements.get(id)!):document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) { for (const element of queries?queries.orderedElements([...transforms.keys()].flatMap(id=>queries.members(id).filter(id=>document.elements.get(id)?.structure==='connection'))).map(id=>document.elements.get(id)!):document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) {
/** 可见端点保留原 ID;隐藏端点改用折叠祖先入口。无法显示或两端同代理时仅省略本帧边。 */ /** 端点节点可见则保留 ID,否则按其 owner 的 hiddenBy 找折叠代理。
* 缺显示端点或两端同代理时仅省略本帧边,不从模型删除,也不改为自由端点。 */
const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? ''); const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? '');
const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId); const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId);
if (!source || !target || source === target) continue; if (!source || !target || source === target) continue;
@@ -121,7 +133,8 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
const field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]|| (end==='source'?'right':'left');edge[field]=handle; const field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]|| (end==='source'?'right':'left');edge[field]=handle;
const seen=handleIds.get(edge[end])!;if(!seen.has(handle)){seen.add(handle);fallback.handles.push({id:handle,side:end==='source'?'right':'left'});} const seen=handleIds.get(edge[end])!;if(!seen.has(handle)){seen.add(handle);fallback.handles.push({id:handle,side:end==='source'?'right':'left'});}
} }
// 本轮遍历结束后删除未访问的几何缓存项,只返回派生场景,不发布 editor 修改。 // 成功遍历结束才 end 清未访问几何项;不是 finally,异常时由下次完整轮次或视图 clear 清理缓存。
// document 是原不可变引用,nodes/edges/transforms 是本次派生容器,不发布 editor 修改。
cache?.end(); cache?.end();
return { document,canvasId,nodes, edges, transforms }; return { document,canvasId,nodes, edges, transforms };
} }
+1
View File
@@ -1,6 +1,7 @@
/** /**
* 显式 React/React Flow 框架入口。按需导出 hooks、组件 renderer 装配和命令 Context, * 显式 React/React Flow 框架入口。按需导出 hooks、组件 renderer 装配和命令 Context,
* 可移植 bindings/index.ts 不引用这些框架依赖;use client 指令保留模块客户端边界。 * 可移植 bindings/index.ts 不引用这些框架依赖;use client 指令保留模块客户端边界。
* 重导出不自动挂 Provider、attach view 或创建 engine;ReactFlowProjection* 只是结构类型,不运行数据转换。
*/ */
"use client"; "use client";
/** Explicit framework boundary; consumers install the declared optional peers. */ /** Explicit framework boundary; consumers install the declared optional peers. */
+9 -4
View File
@@ -15,14 +15,17 @@ export type ReactRenderers={readonly catalog:RendererCatalog;readonly definition
export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]}; export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]};
// 同一组件引用只创建一次 memo 包装,弱键不永久保留不再使用的插件组件。 // 同一组件引用只创建一次 memo 包装,弱键不永久保留不再使用的插件组件。
const memoizedRenderers=new WeakMap<object,NodeTypes[string]|EdgeTypes[string]>(); const memoizedRenderers=new WeakMap<object,NodeTypes[string]|EdgeTypes[string]>();
/** 已是 memo 的组件原样返回;其它合法组件复用/创建包装,便于配合投影结构共享降低重绘。 */ /** $$typeof 为 react.memo 则原样返回;其它受支持组件按原引用弱缓存 React.memo 包装。
* 不调用 render、不解析 lazy 内容,也不缓存组件状态;包装身份稳定不保证所有 props 变化都免渲染。 */
function compiledRenderer(render:NodeTypes[string]|EdgeTypes[string]):NodeTypes[string]|EdgeTypes[string]{ function compiledRenderer(render:NodeTypes[string]|EdgeTypes[string]):NodeTypes[string]|EdgeTypes[string]{
if((render as {$$typeof?:symbol}).$$typeof===Symbol.for('react.memo'))return render; if((render as {$$typeof?:symbol}).$$typeof===Symbol.for('react.memo'))return render;
const key=render as object,previous=memoizedRenderers.get(key);if(previous)return previous; const key=render as object,previous=memoizedRenderers.get(key);if(previous)return previous;
const compiled=memo(render as NodeTypes[string]);memoizedRenderers.set(key,compiled);return compiled; const compiled=memo(render as NodeTypes[string]);memoizedRenderers.set(key,compiled);return compiled;
} }
/** 先验证纯描述,再验证 React 组件形状与可选模型 registry。 /** 先验证纯描述,再验证 React 组件形状与可选模型 registry。
* 同 rendererId 可供多个元素类型共用,但普通/预览组件必须是同一实现,不能后注册静默覆盖。 */ * 同一 structure 内 rendererId 可别名共用,但普通/预览组件引用必须一致;node/edge 类型表彼此独立。
* 标记对象只做结构识别,不证明组件安全、纯净或必能成功渲染;安装来源仍须受信。
* 每次调用新建目录/表/外壳,只有组件 memo 包装共享;冻结注册表不冻结函数闭包或 React 内部状态。 */
export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{ export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{
// 类型表无对象原型,保留名也已被描述目录拒绝,防止类型键继承/原型污染歧义。 // 类型表无对象原型,保留名也已被描述目录拒绝,防止类型键继承/原型污染歧义。
const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),previewNodes:NodeTypes=Object.create(null),previewEdges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[]; const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),previewNodes:NodeTypes=Object.create(null),previewEdges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[];
@@ -31,11 +34,13 @@ export function createReactRenderers(definitions:readonly ReactRendererDefinitio
// 接受函数及 React memo/forward_ref/lazy 标记对象;不是组件的值在装配时拒绝。 // 接受函数及 React memo/forward_ref/lazy 标记对象;不是组件的值在装配时拒绝。
if(typeof render!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(tag as symbol))throw fault('invalid_react_renderer'); if(typeof render!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(tag as symbol))throw fault('invalid_react_renderer');
const previewTag=preview&&typeof preview==='object'?(preview as {$$typeof?:symbol}).$$typeof:undefined;if(preview!==undefined&&typeof preview!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(previewTag as symbol))throw fault('invalid_react_preview'); const previewTag=preview&&typeof preview==='object'?(preview as {$$typeof?:symbol}).$$typeof:undefined;if(preview!==undefined&&typeof preview!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(previewTag as symbol))throw fault('invalid_react_preview');
// 如提供模型 registry,就要求精确 componentId/dataVersion/structure 匹配,不能靠显示组件掩盖 schema 不兼容。 // 可选 registry 只核 componentId/dataVersion/structure 精确存在,不执行某个元素的数据 schema/迁移。
// 未提供 registry 也可装配纯显示目录,真正投影元素时仍需模型解释与 fallback 判断。
if(registry){const model=registry.components.get(descriptor.componentId);if(!model||model.dataVersion!==descriptor.dataVersion||model.structure!==descriptor.structure)throw fault('renderer_model_mismatch',[descriptor.componentId]);} if(registry){const model=registry.components.get(descriptor.componentId);if(!model||model.dataVersion!==descriptor.dataVersion||model.structure!==descriptor.structure)throw fault('renderer_model_mismatch',[descriptor.componentId]);}
const table=descriptor.structure==='node'?nodes:edges,previews=descriptor.structure==='node'?previewNodes:previewEdges,compiled=compiledRenderer(render),compiledPreview=preview?compiledRenderer(preview):undefined; const table=descriptor.structure==='node'?nodes:edges,previews=descriptor.structure==='node'?previewNodes:previewEdges,compiled=compiledRenderer(render),compiledPreview=preview?compiledRenderer(preview):undefined;
if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==compiled||previews[descriptor.rendererId]!==compiledPreview))throw fault('renderer_id_conflict',[descriptor.rendererId]); if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==compiled||previews[descriptor.rendererId]!==compiledPreview))throw fault('renderer_id_conflict',[descriptor.rendererId]);
// 采用目录捕获的标准描述,冻结暴露定义和最终类型表,装配后调用方不能替换注册项。 // 定义保留原 render/preview 引用,类型表存编译后的 memo 引用;不混淆这两层身份。
// 缺 preview 则不创建对应 previewTypes 项,工厂不自动用正式组件充当预览;宿主决定降级路径。
Object.defineProperty(table,descriptor.rendererId,{value:compiled,enumerable:true,configurable:true});if(compiledPreview)Object.defineProperty(previews,descriptor.rendererId,{value:compiledPreview,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render,...(preview?{preview}:{})}) as ReactRendererDefinition); Object.defineProperty(table,descriptor.rendererId,{value:compiled,enumerable:true,configurable:true});if(compiledPreview)Object.defineProperty(previews,descriptor.rendererId,{value:compiledPreview,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render,...(preview?{preview}:{})}) as ReactRendererDefinition);
} }
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges),previewNodeTypes:Object.freeze(previewNodes),previewEdgeTypes:Object.freeze(previewEdges)}); return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges),previewNodeTypes:Object.freeze(previewNodes),previewEdgeTypes:Object.freeze(previewEdges)});
+31 -13
View File
@@ -22,12 +22,15 @@ export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id};
// 默认空上下文用于明确检测漏装 Provider,避免 hooks 悄悄读错项目。 // 默认空上下文用于明确检测漏装 Provider,避免 hooks 悄悄读错项目。
const CanvasContext=createContext<CanvasBinding|null>(null); const CanvasContext=createContext<CanvasBinding|null>(null);
/** Borrows the engine and an already attached view. Unmount never closes the project. */ /** Borrows the engine and an already attached view. Unmount never closes the project. */
/** 只 memo 化借用引用并向子组件提供上下文;不 attach/detach 视图,也不创建/销毁项目。 */ /** value 只按 engine/viewId 引用 memo 并冻结外壳,children 使用 React Context;不是项目实例拥有者。
* 不 attach/detach view、不创建/销毁 engine;若使用交互 view,应由外层宿主先装配,卸载 Provider 也不关闭项目。 */
export function CanvasProvider({engine,viewId,children}:{engine:ProjectEngine;viewId?:Id;children?:ReactNode}){const value=useMemo(()=>Object.freeze({engine,viewId}),[engine,viewId]);return createElement(CanvasContext.Provider,{value},children);} export function CanvasProvider({engine,viewId,children}:{engine:ProjectEngine;viewId?:Id;children?:ReactNode}){const value=useMemo(()=>Object.freeze({engine,viewId}),[engine,viewId]);return createElement(CanvasContext.Provider,{value},children);}
/** 取得当前绑定,缺 Provider 时抛出稳定领域错误交给应用错误边界。 */ /** 取得当前绑定,缺 Provider 时抛出稳定领域错误交给应用错误边界。 */
export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;} export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;}
/** 将派生源接到 useSyncExternalStore。committed 保存最近已提交渲染的选中值, /** 将派生源接到 useSyncExternalStore。committed 保存最近已提交渲染的选中值,
* selector 或等价函数换引用后仍能复用等价结果;effect 才推进已提交基线,不在 render 中认领提交。 */ * selector 或等价函数换引用时重建派生 store,但可按 committed.current 复用等价结果,避免无意义选中值换身份。
* effect 才推进已提交基线,不在 render 中认领提交;source/selector/isEqual 须遵守同步不可变读取合同。
* getServerSnapshot 也使用同一 store.getSnapshot,不创建独立服务端模型,宿主须提供可稳定读取的绑定。 */
function useSelected<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{ function useSelected<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{
const committed=useRef<{value:T}|null>(null); const committed=useRef<{value:T}|null>(null);
const store=useMemo(()=>createSelectorStore(source,(snapshot:S)=>{const selected=selector(snapshot);return committed.current&&isEqual(committed.current.value,selected)?committed.current.value:selected;},isEqual),[source,selector,isEqual]); const store=useMemo(()=>createSelectorStore(source,(snapshot:S)=>{const selected=selector(snapshot);return committed.current&&isEqual(committed.current.value,selected)?committed.current.value:selected;},isEqual),[source,selector,isEqual]);
@@ -47,7 +50,9 @@ function elementSourcesFor(engine:ProjectEngine){let sources=elementSources.get(
/** 支持直接元素视图或自定义选中结果;下面两个签名只描述重载,实际订阅逻辑共用一个实现。 */ /** 支持直接元素视图或自定义选中结果;下面两个签名只描述重载,实际订阅逻辑共用一个实现。 */
export function useElement(ref:Id|Target):ElementView; export function useElement(ref:Id|Target):ElementView;
export function useElement<T>(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T; export function useElement<T>(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T;
/** ID 查询跟随当前元素;Target 查询额外核对代次。默认完整视图按值比较,自定义选择默认按引用。 */ /** 字符串 ID 跟随当前生命周期;Target 仅在 present 且 incarnation 不同返回 stale,absent/unloaded 原样保留。
* 每个 engine 共用按 ID 源;hook 再派生代次/selector 结果,默认完整视图 equal,自定义选择默认 Object.is。
* stale 带当前元素供展示,但不自动把旧 Target 升级成新代次的写入权限。 */
export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T|ElementView{ export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T|ElementView{
const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation; const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation;
const source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]); const source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]);
@@ -55,7 +60,9 @@ export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEq
} }
/** 复用 editor 的历史标签/分组选项,不在绑定层发明另一套提交参数。 */ /** 复用 editor 的历史标签/分组选项,不在绑定层发明另一套提交参数。 */
export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1]; export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1];
/** 捕获不可变 ID+代次并提供普通数据/几何/删除/移动命令;旧组件的动作由 editor 拒绝旧代次。 */ /** 按 engine/id/incarnation memo 动作外壳并捕获冻结 Target,之后改传入 target 对象不影响旧闭包。
* rename/setData/resize/remove/moveTo 都只是普通 command;resize 接 Partial<Geometry>,不只限 width/height。
* 调用时 editor 再核代次/策略/事务,不乐观修改 React 局部文档,也不在动作闭包里排网络请求。 */
export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({ export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({
// 各操作只转成正常 command;校验、history 和同步依赖由 editor/runtime 在统一边界处理。 // 各操作只转成正常 command;校验、history 和同步依赖由 editor/runtime 在统一边界处理。
rename:(title:string,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch:{title}},options), rename:(title:string,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch:{title}},options),
@@ -69,16 +76,20 @@ const sessionSources=new WeakMap<ProjectEngine,ReturnType<typeof createSessionSo
/** 惰性绑定 session 源;已关闭 engine 不再订阅其会话。 */ /** 惰性绑定 session 源;已关闭 engine 不再订阅其会话。 */
function sessionSourcesFor(engine:ProjectEngine){let sources=sessionSources.get(engine);if(!sources){sources=createSessionSources({getSnapshot:engine.sessions.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.sessions.subscribe(notify)});sessionSources.set(engine,sources);}return sources;} function sessionSourcesFor(engine:ProjectEngine){let sources=sessionSources.get(engine);if(!sources){sources=createSessionSources({getSnapshot:engine.sessions.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.sessions.subscribe(notify)});sessionSources.set(engine,sources);}return sources;}
/** 把文档身份与瞬态状态成对读取,按两项引用保留稳定元组;第二条订阅失败时撤销第一条。 /** 把文档身份与瞬态状态成对读取,按两项引用保留稳定元组;第二条订阅失败时撤销第一条。
* 它不是跨两源的独立事务,合法性由使用方的目标代次校验保证。 */ * 顺序读两源,不是跨源事务或原子快照锁;目标合法性仍由 hooks/命令校验。
* 正常退订依次 a()/b(),没有 finally 隔离,宿主取消端口须不抛错;右侧注册失败时尽量撤回左侧。 */
function pairedSource<A,B>(left:ExternalSource<A>,right:ExternalSource<B>):ExternalSource<readonly[A,B]>{ function pairedSource<A,B>(left:ExternalSource<A>,right:ExternalSource<B>):ExternalSource<readonly[A,B]>{
let previous:readonly[A,B]|undefined; let previous:readonly[A,B]|undefined;
return{getSnapshot(){const a=left.getSnapshot(),b=right.getSnapshot();if(!previous||!Object.is(previous[0],a)||!Object.is(previous[1],b))previous=Object.freeze([a,b] as const);return previous;},subscribe(notify){const a=left.subscribe(notify);try{const b=right.subscribe(notify);return()=>{a();b();};}catch(error){a();throw error;}}}; return{getSnapshot(){const a=left.getSnapshot(),b=right.getSnapshot();if(!previous||!Object.is(previous[0],a)||!Object.is(previous[1],b))previous=Object.freeze([a,b] as const);return previous;},subscribe(notify){const a=left.subscribe(notify);try{const b=right.subscribe(notify);return()=>{a();b();};}catch(error){a();throw error;}}};
} }
/** 需要交互所有权的 hook 必须有 viewId,不能从任意其它视图借用会话。 */ /** 要求 Provider 有非空 viewId;这里只补绑定形状,不在此 attach 或验证该视图此刻仍注册。
* session 操作入口继续核视图/手势生命周期,不能凭这个返回对象绕过所有权检查。 */
function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};} function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};}
/** 将绑定控制异常转为 Availability,保留领域码/目标,未知异常归 binding_failed。 */ /** 将 Error 风格异常的 code/targets 转为 Availability,缺 code 用 binding_failed。
* 不归一化任意 thrown 值;null/undefined 不符合这里直接读属性的错误合同。 */
function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};} function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};}
/** 同时订阅目标身份与该视图模式;旧代次返回 null,设置模式前再次读取当前元素校验。 */ /** 成对订阅元素查询和 view/element 模式;缺元素或旧代次读成 null,不把旧元素的编辑模式套给新元素。
* setMode 调用时再核当前 incarnation,然后由 sessions.mode 检查视图;返回可用性,不直接编辑内容/history。 */
export function useMode(target:Target){ export function useMode(target:Target){
const {engine,viewId}=useView(),{id,incarnation}=target; const {engine,viewId}=useView(),{id,incarnation}=target;
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).mode(viewId,id)),[engine,viewId,id]); const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).mode(viewId,id)),[engine,viewId,id]);
@@ -87,7 +98,9 @@ export function useMode(target:Target){
return useMemo(()=>Object.freeze({mode,setMode}),[mode,setMode]); return useMemo(()=>Object.freeze({mode,setMode}),[mode,setMode]);
} }
/** 组合文档几何和 session 预览。begin 取得手势 token,update/commit/cancel 必须仍拥有同 token/view。 /** 组合文档几何和 session 预览。begin 取得手势 token,update/commit/cancel 必须仍拥有同 token/view。
* effect 清理只取消本绑定取得的手势,不能因旧组件卸载取消后来另一视图的新手势。 */ * controls 内 lease 仅是本 hook 捕获的数字 token,不是另一份手势事实;真实 active/preview 在 sessions。
* effect 清理先核 token/view/active,仅取消自己取得的手势;旧组件卸载不能取消后来另一视图的新手势。
* begin/update/commit 沿用 session 的结果或异常;拥有手势后进入 commit 的 try/finally 才保证清 lease,未拥有则直接拒绝。 */
export function useGestureBindings(target:Target){ export function useGestureBindings(target:Target){
const {engine,viewId}=useView(),{id,incarnation}=target; const {engine,viewId}=useView(),{id,incarnation}=target;
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).gesture(viewId,{id,incarnation})),[engine,viewId,id,incarnation]); const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).gesture(viewId,{id,incarnation})),[engine,viewId,id,incarnation]);
@@ -106,7 +119,8 @@ export function useGestureBindings(target:Target){
const runtimeSources=new WeakMap<ProjectEngine,ReturnType<typeof createRuntimeSources>>(); const runtimeSources=new WeakMap<ProjectEngine,ReturnType<typeof createRuntimeSources>>();
/** 惰性建立 runtime 源,在 editor 成功根发布时观察表引用变化。 */ /** 惰性建立 runtime 源,在 editor 成功根发布时观察表引用变化。 */
function runtimeSourcesFor(engine:ProjectEngine){let sources=runtimeSources.get(engine);if(!sources){sources=createRuntimeSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)});runtimeSources.set(engine,sources);}return sources;} function runtimeSourcesFor(engine:ProjectEngine){let sources=runtimeSources.get(engine);if(!sources){sources=createRuntimeSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)});runtimeSources.set(engine,sources);}return sources;}
/** 单操作诊断读取,返回逻辑操作及关联冻结请求,不提供直接改任务状态的方法。 */ /** 每 engine/id memo 派生源,useSyncExternalStore 读取逻辑操作和关联请求;缺 ID 返回 undefined。
* 这是诊断订阅,不轮询服务、不授予直接修改 phase/request 的接口。 */
export function useOperation(id:Id):OperationView{const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).operation(id),[engine,id]);return useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);} export function useOperation(id:Id):OperationView{const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).operation(id),[engine,id]);return useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);}
/** 本地可靠保存状态,与网络 ACK/任务结果分开观察。 */ /** 本地可靠保存状态,与网络 ACK/任务结果分开观察。 */
type Durability=ReturnType<ProjectEngine['getDurability']>; type Durability=ReturnType<ProjectEngine['getDurability']>;
@@ -117,7 +131,8 @@ const inactiveOwner=(value:Durability)=>value.closing||value.closed;
/** 使用 engine 独立的 durability 订阅,磁盘完成/错误不必伪造一次文档编辑来通知 UI。 */ /** 使用 engine 独立的 durability 订阅,磁盘完成/错误不必伪造一次文档编辑来通知 UI。 */
function useDurability<T>(selector:(value:Durability)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){const {engine}=useCanvasBinding();const source=useMemo(()=>({getSnapshot:engine.getDurability,subscribe:engine.subscribeDurability}),[engine]);return useSelected(source,selector,isEqual);} function useDurability<T>(selector:(value:Durability)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){const {engine}=useCanvasBinding();const source=useMemo(()=>({getSnapshot:engine.getDurability,subscribe:engine.subscribeDurability}),[engine]);return useSelected(source,selector,isEqual);}
/** 合成任务计数与项目保存状态,展示优先级为 closed→blocked→closing→saving→syncing→loading→synced。 /** 合成任务计数与项目保存状态,展示优先级为 closed→blocked→closing→saving→syncing→loading→synced。
* canvas 计数局部化,但 dirty/error/closing 是项目保存层级,不能当作仅该 canvas 的独立磁盘状态。 */ * canvas 计数局部化,但 dirty/error/closing 是项目保存层级,不能当作仅该 canvas 的独立磁盘状态。
* 计数与 durability 来自两条订阅,不建立新的事务根;status 是显示优先级归纳,synced 不证明远端生成已结束。 */
export function useSyncStatus(canvasId?:Id){ export function useSyncStatus(canvasId?:Id){
const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).counts(canvasId),[engine,canvasId]); const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).counts(canvasId),[engine,canvasId]);
const durable=useDurability(syncDurability,equal),counts=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot); const durable=useDurability(syncDurability,equal),counts=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
@@ -125,7 +140,10 @@ export function useSyncStatus(canvasId?:Id){
return useMemo(()=>Object.freeze({...counts,dirty,error:durable.error,status:durable.closed?'closed' as const:durable.error||counts.blocked?'blocked' as const:durable.closing?'closing' as const:dirty?'saving' as const:counts.pending?'syncing' as const:counts.loading?'loading' as const:'synced' as const}),[counts,dirty,durable.error,durable.closing,durable.closed]); return useMemo(()=>Object.freeze({...counts,dirty,error:durable.error,status:durable.closed?'closed' as const:durable.error||counts.blocked?'blocked' as const:durable.closing?'closing' as const:dirty?'saving' as const:counts.pending?'syncing' as const:counts.loading?'loading' as const:'synced' as const}),[counts,dirty,durable.error,durable.closing,durable.closed]);
} }
/** 订阅资源元数据并在 effect 借用显示句柄。engine/id/来源 key 变化重建尝试,retry 显式重取。 /** 订阅资源元数据并在 effect 借用显示句柄。engine/id/来源 key 变化重建尝试,retry 显式重取。
* 异步返回先核对 abort;渲染也核对 state 所属 engine/id/key,不能短暂显示上个项目或旧来源的 URL。 */ * 异步返回核 abort,渲染再核 state.engine/id/key;metadata 与句柄分别来自根和 effect,不另存原资源字节。
* key 只含上传 operationId/purge、binding.authority/digest,普通元信息变化不全都重开显示请求。
* attempt 只触发 effect 重试,不在返回 state 的身份键中;同来源 retry 的 loading 状态由 effect 启动时设置。
* ready 表示 URL 租约已取得,不等于图片 DOM 已解码/绘制或上传/同步完成。 */
export function useAsset(id:Id){ export function useAsset(id:Id){
const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner); const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner);
const source=useMemo(()=>runtimeSourcesFor(engine).asset(id),[engine,id]),metadata=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot); const source=useMemo(()=>runtimeSourcesFor(engine).asset(id),[engine,id]),metadata=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
@@ -134,7 +152,7 @@ export function useAsset(id:Id){
const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>(); const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>();
// 清理中止本次读取并归还 handle;迟到成功也立即释放,不把旧结果写入当前显示。 // 清理中止本次读取并归还 handle;迟到成功也立即释放,不把旧结果写入当前显示。
useEffect(()=>{if(inactive){setState({engine,id,key,status:'error',error:fault('engine_closing')});return;}const controller=new AbortController();let handle:DisplayHandle|undefined;setState({engine,id,key,status:'loading'});void engine.displayResource(id,{signal:controller.signal}).then(value=>{if(controller.signal.aborted){value.release();return;}handle=value;setState({engine,id,key,status:'ready',url:value.url});},error=>{if(!controller.signal.aborted)setState({engine,id,key,status:'error',error});});return()=>{controller.abort();handle?.release();};},[engine,id,key,attempt,inactive]); useEffect(()=>{if(inactive){setState({engine,id,key,status:'error',error:fault('engine_closing')});return;}const controller=new AbortController();let handle:DisplayHandle|undefined;setState({engine,id,key,status:'loading'});void engine.displayResource(id,{signal:controller.signal}).then(value=>{if(controller.signal.aborted){value.release();return;}handle=value;setState({engine,id,key,status:'ready',url:value.url});},error=>{if(!controller.signal.aborted)setState({engine,id,key,status:'error',error});});return()=>{controller.abort();handle?.release();};},[engine,id,key,attempt,inactive]);
/** 重试仅增加本 hook 的尝试序号,由 effect 清理旧租约再请求新显示句柄。 */ /** retry 仅增本 hook 的尝试序号,effect 清旧租约再取新显示句柄;不调用 engine.retry 或重新上传。 */
const retry=useCallback(()=>setAttempt(value=>value+1),[]); const retry=useCallback(()=>setAttempt(value=>value+1),[]);
return useMemo(()=>Object.freeze({...metadata,...(inactive?{status:'error' as const,url:undefined,error:fault('engine_closing')}:state?.engine===engine&&state.id===id&&state.key===key?{status:state.status,url:state.url,error:state.error}:{status:'loading' as const,url:undefined,error:undefined}),retry}),[metadata,state,engine,id,key,retry,inactive]); return useMemo(()=>Object.freeze({...metadata,...(inactive?{status:'error' as const,url:undefined,error:fault('engine_closing')}:state?.engine===engine&&state.id===id&&state.key===key?{status:state.status,url:state.url,error:state.error}:{status:'loading' as const,url:undefined,error:undefined}),retry}),[metadata,state,engine,id,key,retry,inactive]);
} }
+2 -1
View File
@@ -13,5 +13,6 @@ export type CanvasRendererCommands={dispatch(command:Command,options?:RendererCo
/** Provider 由上层 renderer 装配;默认 null 表示组件尚未处于合法的编辑上下文。 */ /** Provider 由上层 renderer 装配;默认 null 表示组件尚未处于合法的编辑上下文。 */
export const CanvasRendererCommandsContext=createContext<CanvasRendererCommands|null>(null); export const CanvasRendererCommandsContext=createContext<CanvasRendererCommands|null>(null);
/** Trusted installed renderers submit normal validated commands, never write a second model. */ /** Trusted installed renderers submit normal validated commands, never write a second model. */
/** 已安装的受信 renderer 通过此 hook 获取命令门面,所有业务修改仍经 editor 发布边界。 */ /** 读取最近 Provider 的命令对象,缺失就同步抛错;hook 不缓存命令、不建立订阅或另建 editor。
* Provider 须注入 editor 的受检门面;Context 本身不验证任意自定义函数是否真的经过发布边界。 */
export function useCanvasRendererCommands(){const commands=useContext(CanvasRendererCommandsContext);if(!commands)throw Error('Canvas renderer commands are unavailable');return commands;} export function useCanvasRendererCommands(){const commands=useContext(CanvasRendererCommandsContext);if(!commands)throw Error('Canvas renderer commands are unavailable');return commands;}
+4 -2
View File
@@ -11,7 +11,8 @@ export type RendererCatalog={readonly definitions:readonly RendererDescriptor[];
/** JSON 元组键避免拼接分隔符歧义,节点与连线即使 componentId 相同也独立匹配。 */ /** JSON 元组键避免拼接分隔符歧义,节点与连线即使 componentId 相同也独立匹配。 */
const key=(value:Pick<RendererDescriptor,'componentId'|'dataVersion'|'structure'>)=>JSON.stringify([value.componentId,value.dataVersion,value.structure]); const key=(value:Pick<RendererDescriptor,'componentId'|'dataVersion'|'structure'>)=>JSON.stringify([value.componentId,value.dataVersion,value.structure]);
/** 校验并复制描述项,拒绝重复定义、危险/保留 renderer 名和未知投影协议。 /** 校验并复制描述项,拒绝重复定义、危险/保留 renderer 名和未知投影协议。
* 工厂不加载代码、不自动迁移 dataVersion,也不把高版本当成兼容旧版本。 */ * 工厂按声明顺序重建标准标量字段/默认 projection,未知扩展字段不复制,Map 键是精确三元组。
* 不加载 React/插件代码、不执行 schema 校验、不自动迁移版本;resolve 不授予编辑或网络权限。 */
export function createRendererCatalog(definitions:readonly RendererDescriptor[]):RendererCatalog{ export function createRendererCatalog(definitions:readonly RendererDescriptor[]):RendererCatalog{
const entries=new Map<string,RendererDescriptor>(); const entries=new Map<string,RendererDescriptor>();
for(const value of definitions){ for(const value of definitions){
@@ -31,5 +32,6 @@ export const builtinRendererCatalog=createRendererCatalog(builtinRendererDefinit
/** Installed renderers without a reference declaration wait for full context. */ /** Installed renderers without a reference declaration wait for full context. */
/** 渐进加载仅开放 registry 中声明 copy 引用规则的组件 renderer;缺声明先走降级等待完整上下文。 /** 渐进加载仅开放 registry 中声明 copy 引用规则的组件 renderer;缺声明先走降级等待完整上下文。
* 保留 definitions 供装配检查,但 resolve 才决定当前是否允许显示实现。 */ * 保留原 catalog.definitions 供装配检查,resolve 每次先查 registry 中 component 是否有 copy 声明。
* 这仅是局部加载门禁,不验证声明覆盖所有引用、schema 或源记录版本,也不证明依赖已加载;精确 renderer 匹配仍由 catalog 执行。 */
export function createLoadingRendererCatalog(registry:Registry,catalog:RendererCatalog):RendererCatalog{return Object.freeze({definitions:catalog.definitions,resolve:(element:Parameters<RendererCatalog['resolve']>[0])=>registry.components.get(element.componentId)?.copy?catalog.resolve(element):undefined});} export function createLoadingRendererCatalog(registry:Registry,catalog:RendererCatalog):RendererCatalog{return Object.freeze({definitions:catalog.definitions,resolve:(element:Parameters<RendererCatalog['resolve']>[0])=>registry.components.get(element.componentId)?.copy?catalog.resolve(element):undefined});}
+12 -5
View File
@@ -14,7 +14,9 @@ export type ResourceViewSource = { readonly resourceId: Id; readonly url: string
* unchanged nodes, publish each ready source independently, and fence late work. * unchanged nodes, publish each ready source independently, and fence late work.
*/ */
/** resolve 由 engine/display service 注入,onChange 收到新的已就绪集合和当前失败数。 /** resolve 由 engine/display service 注入,onChange 收到新的已就绪集合和当前失败数。
* sync 输入是完整目标集合,retry 只重建失败项,dispose 幂等释放所有在途/已完成句柄。 */ * sync 输入是完整目标集合,retry 只重建失败项,dispose 请求中止在途并归还已有句柄。
* 工厂不立即 resolve;每个元素一次独立尝试,同资源的多个元素不在此合并(底层缓存可共享读取)。
* resolve 须返回合法 DisplayHandle;本模块不校验 URL/下载权限或图片内容,回调错误仅报告。 */
export function createResourceView(options: { export function createResourceView(options: {
resolve(resource: Id, signal: AbortSignal): Promise<DisplayHandle>; resolve(resource: Id, signal: AbortSignal): Promise<DisplayHandle>;
onChange(sources: Map<Id, ResourceViewSource>, failures: number): void; onChange(sources: Map<Id, ResourceViewSource>, failures: number): void;
@@ -24,13 +26,16 @@ export function createResourceView(options: {
const records = new Map<Id, Record>(); let targets = new Map<Id, Id>(), disposed = false; const records = new Map<Id, Record>(); let targets = new Map<Id, Id>(), disposed = false;
/** 错误展示抛错不影响租约释放或其它图片的加载。 */ /** 错误展示抛错不影响租约释放或其它图片的加载。 */
const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Observer errors do not own leases. */ } }; const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Observer errors do not own leases. */ } };
/** 只公布已拿到 handle 的元素,失败数另报;先复制 Map,不把内部记录交给界面修改。 */ /** 每次重建 ready 元素→{resourceId,url} 的新 Map,另计 failed;pending 不计失败也不带空 URL。
* Map/条目未运行时冻结,但不暴露内部 record/handle;onChange 抛错被 report 隔离,不撤销已采纳句柄。 */
const publish = () => { try { options.onChange(new Map([...records].flatMap(([id, record]) => record.handle ? [[id, {resourceId:record.resource,url:record.handle.url}] as [Id, ResourceViewSource]] : [])), [...records.values()].filter(record => record.failed).length); } catch (error) { report(error); } }; const publish = () => { try { options.onChange(new Map([...records].flatMap(([id, record]) => record.handle ? [[id, {resourceId:record.resource,url:record.handle.url}] as [Id, ResourceViewSource]] : [])), [...records.values()].filter(record => record.failed).length); } catch (error) { report(error); } };
/** 释放器自身抛错只报告,不让一张图片阻止其它图片清理。 */ /** 释放器自身抛错只报告,不让一张图片阻止其它图片清理。 */
const releaseHandle = (handle: DisplayHandle) => { try { handle.release(); } catch (error) { report(error); } }; const releaseHandle = (handle: DisplayHandle) => { try { handle.release(); } catch (error) { report(error); } };
/** 先中止在途解析,再归还已完成句柄;未完成解析的迟到成功也由下方结果边界归还。 */ /** 先中止在途解析,再归还已完成句柄;未完成解析的迟到成功也由下方结果边界归还。 */
const release = (record: Record) => { record.controller.abort(); if(record.handle)releaseHandle(record.handle); }; const release = (record: Record) => { record.controller.abort(); if(record.handle)releaseHandle(record.handle); };
/** 完整验证元素/资源 ID 与重复项后再更新;删除或换源立即释放旧记录,未变化项继续复用。 */ /** 先验证并捕获完整目标映射(元素 ID 唯一,资源 ID 可重复),再删除/换源/补新尝试。
* 未变化项包括 failed 都保留,因此同集合 sync 不隐式重试;显式 retry 才重建失败项。
* 变化时先发布当前 ready 集合,各 resolve 独立完成再各自发布,不等全组 Promise.all。 */
function sync(next: readonly (readonly [Id, Id])[]) { function sync(next: readonly (readonly [Id, Id])[]) {
if (disposed) throw fault('resource_view_disposed'); if (disposed) throw fault('resource_view_disposed');
const desired = new Map<Id, Id>(); const desired = new Map<Id, Id>();
@@ -60,9 +65,11 @@ export function createResourceView(options: {
} }
return Object.freeze({ return Object.freeze({
sync, sync,
/** 移除失败记录后按 targets 重新 sync;成功项不重新取 URL。 */ /** 先撤掉失败尝试并释放,再用最近 targets 重新 sync;ready/pending 项继续复用。
* 没有失败项也不额外发布,不重新授权所有成功资源或重建业务元素。 */
retry() { if (disposed) throw fault('resource_view_disposed'); for (const [id, record] of records) if (record.failed) { records.delete(id); release(record); } sync([...targets]); }, retry() { if (disposed) throw fault('resource_view_disposed'); for (const [id, record] of records) if (record.failed) { records.delete(id); release(record); } sync([...targets]); },
/** 中止并归还全部显示租约,发布空集合,后续 sync/retry 明确拒绝。 */ /** 同步标 disposed、请求 abort 并 release 已有句柄,清目标/记录后发布空集合。
* 不等待 resolve Promise 退出;迟到成功由记录身份/ disposed 检查归还,后续 sync/retry 明确拒绝。 */
dispose() { if (disposed) return; disposed = true; for (const record of records.values()) release(record); records.clear(); targets.clear(); publish(); }, dispose() { if (disposed) return; disposed = true; for (const record of records.values()) release(record); records.clear(); targets.clear(); publish(); },
}); });
} }
+13 -6
View File
@@ -20,7 +20,9 @@ export type SyncCounts={readonly pending:number;readonly blocked:number;readonly
type Dependencies=readonly(object|boolean|undefined)[]; type Dependencies=readonly(object|boolean|undefined)[];
/** Read-only table projections: one upstream listener, no per-hook Root/table snapshots. */ /** Read-only table projections: one upstream listener, no per-hook Root/table snapshots. */
/** 装配 operation/asset/counts 三类通道,首次订阅连上游,最后一个通道离开后断开。 */ /** 装配 operation/asset/counts 三组:组内按 key 共享桶,所有活动组共用一个上游订阅。
* 只按所声明表对象身份/布尔值检测依赖,要求上游不可变;原地改 Map 成员不会自动生成新依赖戳。
* factories/getSnapshot 不启动网络或修改业务状态,选中记录仍借用 Root 中的不可变事实。 */
export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
// 每个 group 负责自己的桶更新,上游先推进所有组,再运行去重后的外部通知。 // 每个 group 负责自己的桶更新,上游先推进所有组,再运行去重后的外部通知。
type Group={update(root:EngineSnapshot,notifications:Set<()=>void>):void}; type Group={update(root:EngineSnapshot,notifications:Set<()=>void>):void};
@@ -33,7 +35,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
for(const group of active)group.update(root,notifications); for(const group of active)group.update(root,notifications);
let failed=false,error:unknown;for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error; let failed=false,error:unknown;for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
} }
/** 为共享依赖的一类按键查询创建桶。依赖不变跳过计算,失败桶即使签名未变仍可重新尝试。 */ /** 一组依赖对应多个 key 桶;observedSignature 决定路由,failed 键可在戳未变时再试。
* cachedSignature 只说明读取缓存对应哪个依赖戳,不能替代 observed 的通知基线。 */
function group<K,V>(dependencies:(root:EngineSnapshot)=>Dependencies,read:(root:EngineSnapshot,key:K)=>V){ function group<K,V>(dependencies:(root:EngineSnapshot)=>Dependencies,read:(root:EngineSnapshot,key:K)=>V){
// Direct reads may precede notification, so read caches cannot replace observed baselines. // Direct reads may precede notification, so read caches cannot replace observed baselines.
// cached 服务即时读取,observed 是上次通知基线;两者不能合并,否则渲染提前读取会漏通知。 // cached 服务即时读取,observed 是上次通知基线;两者不能合并,否则渲染提前读取会漏通知。
@@ -44,7 +47,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
if(bucket.cachedSignature!==stamp){const value=read(root,key);if(!equal(bucket.cached,value))bucket.cached=value;bucket.cachedSignature=stamp;} if(bucket.cachedSignature!==stamp){const value=read(root,key);if(!equal(bucket.cached,value))bucket.cached=value;bucket.cachedSignature=stamp;}
return bucket.cached; return bucket.cached;
} }
// 只更新有订阅的键;失败时仍通知使用方,让其读取/错误边界重新解释,不造一个成功值。 // 戳变化扫描全部已订阅 key,否则只扫描 failed;readCached 失败不会推进该桶缓存戳。
// 失败/失败后恢复都通知,cached/observed 不造一个假成功值;其它正常等价桶不通知。
const owner:Group={update(root,notifications){ const owner:Group={update(root,notifications){
const stamp=signature(dependencies(root)),all=stamp!==observedSignature;if(!all&&!failed.size)return;observedSignature=stamp; const stamp=signature(dependencies(root)),all=stamp!==observedSignature;if(!all&&!failed.size)return;observedSignature=stamp;
for(const key of all?buckets.keys():[...failed]){ for(const key of all?buckets.keys():[...failed]){
@@ -67,7 +71,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
const listener=()=>notify();bucket.listeners.add(listener); const listener=()=>notify();bucket.listeners.add(listener);
/** 撤销本订阅;空桶和空组立即删除,失败键也同时清理。 */ /** 撤销本订阅;空桶和空组立即删除,失败键也同时清理。 */
const remove=()=>{bucket!.listeners.delete(listener);if(!bucket!.listeners.size){buckets.delete(key);failed.delete(key);}if(!buckets.size){active.delete(owner);observedSignature=undefined;}}; const remove=()=>{bucket!.listeners.delete(listener);if(!bucket!.listeners.size){buckets.delete(key);failed.delete(key);}if(!buckets.size){active.delete(owner);observedSignature=undefined;}};
// 首次注册上游失败撤回桶;正常退订只有最后一个活动组离开才停止共享监听。 // 首次注册上游失败撤回本次 listener/空桶;各订阅包装回调身份,传同一个 notify 也可独立退订。
// 最后活动组离开才 stop 上游;stop 抛错不兜底重置,宿主取消端口须可靠完成。
if(!stop)try{stop=source.subscribe(changed);}catch(error){remove();throw error;} if(!stop)try{stop=source.subscribe(changed);}catch(error){remove();throw error;}
let live=true;return()=>{if(!live)return;live=false;remove();if(!active.size){stop?.();stop=undefined;}}; let live=true;return()=>{if(!live)return;live=false;remove();if(!active.size){stop?.();stop=undefined;}};
}}); }});
@@ -80,7 +85,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
let countStamp:string|undefined,projectCounts:SyncCounts,emptyCounts:SyncCounts,canvasCounts=new Map<Id,SyncCounts>(); let countStamp:string|undefined,projectCounts:SyncCounts,emptyCounts:SyncCounts,canvasCounts=new Map<Id,SyncCounts>();
/** 同步计数只取操作表、请求表和是否 loading,不因文档几何或历史变化全表重数。 */ /** 同步计数只取操作表、请求表和是否 loading,不因文档几何或历史变化全表重数。 */
const countDependencies=(root:EngineSnapshot):Dependencies=>[root.runtime.ledger.operations,root.runtime.ledger.requests,!!root.runtime.loading]; const countDependencies=(root:EngineSnapshot):Dependencies=>[root.runtime.ledger.operations,root.runtime.ledger.requests,!!root.runtime.loading];
// 同一依赖戳一次扫描得出项目及各画布计数,多个 canvas hook 共享结果。 // 同一依赖戳只扫描一次得到项目/各 canvas 计数,空 canvas 共享零计数;loading 是项目级布尔值。
// canvas 归属沿用 intent.canvasIds,不在这里从当前 owner 推导;上传等空参与者任务只进入项目计数。
const counts=group<Id|undefined,SyncCounts>(countDependencies,(root,canvasId)=>{ const counts=group<Id|undefined,SyncCounts>(countDependencies,(root,canvasId)=>{
const stamp=signature(countDependencies(root)); const stamp=signature(countDependencies(root));
if(stamp!==countStamp){ if(stamp!==countStamp){
@@ -92,7 +98,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
const ids=operation.intent.canvasIds.length>1?new Set(operation.intent.canvasIds):operation.intent.canvasIds,blocked=operation.phase==='blocked'&&operation.blockers.some(reason=>reason!=='waiting_digest');project.pending++;if(blocked)project.blocked++; const ids=operation.intent.canvasIds.length>1?new Set(operation.intent.canvasIds):operation.intent.canvasIds,blocked=operation.phase==='blocked'&&operation.blockers.some(reason=>reason!=='waiting_digest');project.pending++;if(blocked)project.blocked++;
for(const id of ids){const row=scope(id);row.pending++;if(blocked)row.blocked++;} for(const id of ids){const row=scope(id);row.pending++;if(blocked)row.blocked++;}
} }
// 请求只统计仍关联未决操作的项。无 retryAt 的请求错误计 blocked,已发出但结果未知计 unknown。 // 请求仅在仍关联未决操作时计数,canvas 集合取这些操作参与者的并集,不直接用 request.canvasIds。
// 请求 blocked 可与操作 blocked 叠加,unknown 也可同时非零;它们不是互斥任务/节点数量。
for(const request of root.runtime.ledger.requests.values())if(request.phase!=='resolved'){ for(const request of root.runtime.ledger.requests.values())if(request.phase!=='resolved'){
let matched=false;const ids=new Set<Id>();for(const id of request.operationIds){const operation=root.runtime.ledger.operations.get(id);if(operation&&operation.phase!=='resolved'){matched=true;for(const canvas of operation.intent.canvasIds)ids.add(canvas);}} let matched=false;const ids=new Set<Id>();for(const id of request.operationIds){const operation=root.runtime.ledger.operations.get(id);if(operation&&operation.phase!=='resolved'){matched=true;for(const canvas of operation.intent.canvasIds)ids.add(canvas);}}
if(!matched)continue;const blocked=!!request.error&&request.retryAt===undefined,unknown=request.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(request.phase); if(!matched)continue;const blocked=!!request.error&&request.retryAt===undefined,unknown=request.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(request.phase);
+14 -6
View File
@@ -11,13 +11,16 @@ import type {RendererCatalog} from './renderers.ts';
/** 与完整 projectCanvas 保持同一结果结构,避免增量路径自行定义另一套显示规则。 */ /** 与完整 projectCanvas 保持同一结果结构,避免增量路径自行定义另一套显示规则。 */
type Projection=ReturnType<typeof projectCanvas>; type Projection=ReturnType<typeof projectCanvas>;
/** 连续发布的可信写集合;from/to 必须恰好连接旧投影与新文档,topology 表示禁止局部修补。 */ /** from/to 按 Document 对象身份连接连续发布,ids 是宿主提供的完整写集合;这里不重算其覆盖证明。
* topology=true 禁用局部修补,Set 可由 scene coordinator 累加,不是持久 Change 日志或业务事件队列。 */
export type SceneChangeHint={from:Document;to:Document;ids:Set<Id>;topology:boolean}; export type SceneChangeHint={from:Document;to:Document;ids:Set<Id>;topology:boolean};
/** 几何手势的瞬态预览,不将 pointermove 变成文档修改。 */ /** 几何手势的瞬态预览,不将 pointermove 变成文档修改。 */
type Preview=NonNullable<SessionSnapshot['gesture']>['preview']|undefined; type Preview=NonNullable<SessionSnapshot['gesture']>['preview']|undefined;
/** 真正影响场景内容的输入;viewport/tool/mode 等状态由别的显示通道处理。 */ /** 真正影响场景内容的输入;viewport/tool/mode 等状态由别的显示通道处理。 */
export type SceneInputs={owner:object;document:Document;registry:Registry;catalog:RendererCatalog;canvasId:Id;selection:ViewState['selection'];preview:Preview;edgeId:Id|undefined;path:NonNullable<SessionSnapshot['connectionGesture']>['path']|undefined}; export type SceneInputs={owner:object;document:Document;registry:Registry;catalog:RendererCatalog;canvasId:Id;selection:ViewState['selection'];preview:Preview;edgeId:Id|undefined;path:NonNullable<SessionSnapshot['connectionGesture']>['path']|undefined};
/** 为上一帧建立 ID→数组位置、分区入口及深度索引;发现降级节点/边后禁用快速修补。 */ /** 建上一帧 ID→数组下标、入口 elementId→子 canvasId、显示 canvas 深度索引。
* 深度沿 transforms 迭代顺序累计,依赖完整投影按父先子后产出;不在这里重新排序/校验整棵树。
* unsupported 标记当前已有 fallback node/unsupported edge,供 patchScene 直接回退。 */
export function indexScene(projection:Projection){ export function indexScene(projection:Projection){
const nodes=new Map(projection.nodes.map((node,index)=>[node.id,index])),edges=new Map(projection.edges.map((edge,index)=>[edge.id,index])),entries=new Map<Id,Id>(),depths=new Map<Id,number>([[projection.canvasId,0]]); const nodes=new Map(projection.nodes.map((node,index)=>[node.id,index])),edges=new Map(projection.edges.map((edge,index)=>[edge.id,index])),entries=new Map<Id,Id>(),depths=new Map<Id,number>([[projection.canvasId,0]]);
for(const canvas of projection.document.canvases.values())if(canvas.parent)entries.set(canvas.parent.entryElementId,canvas.id); for(const canvas of projection.document.canvases.values())if(canvas.parent)entries.set(canvas.parent.entryElementId,canvas.id);
@@ -26,14 +29,17 @@ export function indexScene(projection:Projection){
} }
/** Reuse existing projections for checked field edits and ordinary node insertions. */ /** Reuse existing projections for checked field edits and ordinary node insertions. */
/** 纯候选投影修补:合并文档/选择/预览脏项,先核对全体脏项再构造新数组。 /** 纯候选投影修补:合并文档/选择/预览脏项,先核对全体脏项再构造新数组。
* 允许普通节点新增与受检字段编辑;删除、代次/类型/owner/连线端点变更交给完整路径。 */ * 允许普通节点新增与受检字段编辑;删除、代次/类型/owner/连线端点变更交给完整路径。
* 调用者须先核 owner/registry/catalog/canvas 兼容,layout 对应 base,queries 对应 next.document;本函数不重复全部护栏。
* undefined 表示本次不适合快路径,不是业务拒绝;投影器/外部服务抛错则直接传播,没有错误吞并或业务发布。 */
export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,next:SceneInputs,previous:SceneInputs,connection:SessionSnapshot['connectionGesture'],hint?:SceneChangeHint,queries?:DocumentQueries):Projection|undefined{ export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,next:SceneInputs,previous:SceneInputs,connection:SessionSnapshot['connectionGesture'],hint?:SceneChangeHint,queries?:DocumentQueries):Projection|undefined{
const {document,selection,preview,registry,catalog,canvasId}=next; const {document,selection,preview,registry,catalog,canvasId}=next;
const {selection:oldSelection,preview:oldPreview,edgeId:oldEdgeId,path:oldPath}=previous; const {selection:oldSelection,preview:oldPreview,edgeId:oldEdgeId,path:oldPath}=previous;
if(layout.unsupported||document.projectId!==base.document.projectId)return; if(layout.unsupported||document.projectId!==base.document.projectId)return;
const dirty=new Set<Id>(),created=new Set<Id>(); const dirty=new Set<Id>(),created=new Set<Id>();
if(document!==base.document){ if(document!==base.document){
// 可信连续 changes 可直接路由 ID;拓扑提示则立刻放弃。无提示时需要逐项核对表顺序及内容。 // 匹配 from/to 的 hint 按完整写集合合同直接取 ids,不额外全表证明“没有遗漏其它修改”。
// 缺可信 hint 才比较 canvas 表及 element 表大小/顺序/内容;这条扫描路径本身不支持插入或删除。
if(hint?.from===base.document&&hint.to===document){if(hint.topology)return;for(const id of hint.ids)dirty.add(id);} if(hint?.from===base.document&&hint.to===document){if(hint.topology)return;for(const id of hint.ids)dirty.add(id);}
else{ else{
// Canonical replacement and missed publications carry no trustworthy write set. // Canonical replacement and missed publications carry no trustworthy write set.
@@ -59,7 +65,8 @@ export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,
// 入口几何/单位展示或其预览变化会影响整棵子树变换,不能只修补入口本身。 // 入口几何/单位展示或其预览变化会影响整棵子树变换,不能只修补入口本身。
if(before.structure==='node'&&after.structure==='node'&&layout.entries.has(id)&&(!equal(before.geometry,after.geometry)||!equal(before.data.presentation,after.data.presentation)||!equal(oldPreview?.get(id),preview?.get(id))))return; if(before.structure==='node'&&after.structure==='node'&&layout.entries.has(id)&&(!equal(before.geometry,after.geometry)||!equal(before.data.presentation,after.data.presentation)||!equal(oldPreview?.get(id),preview?.get(id))))return;
} }
// 懒复制:未受影响的数组与节点/边对象沿用原引用,新对象仍调用完整路径共用的投影函数。 // 先通过脏项形状检查再懒复制受影响数组,未变对象仍共享;所有新节点/边调用完整路径共用投影函数。
// base.transforms 只在拓扑兼容时复用;未显示 canvas 的普通变更可以只更新返回 document 而不增加显示节点。
let nodes=base.nodes,edges=base.edges;const inserted=new Map<Id,Projection['nodes'][number]>(); let nodes=base.nodes,edges=base.edges;const inserted=new Map<Id,Projection['nodes'][number]>();
for(const id of dirty){const element=document.elements.get(id)!; for(const id of dirty){const element=document.elements.get(id)!;
if(element.structure==='node'){ if(element.structure==='node'){
@@ -81,7 +88,8 @@ export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,
// without reconstructing unaffected nodes, transforms or edge projections. // without reconstructing unaffected nodes, transforms or edge projections.
const members=new Map<Id,Id[]>();if(!queries)for(const element of document.elements.values()){const list=members.get(element.ownerCanvasId)??[];list.push(element.id);members.set(element.ownerCanvasId,list);} const members=new Map<Id,Id[]>();if(!queries)for(const element of document.elements.values()){const list=members.get(element.ownerCanvasId)??[];list.push(element.id);members.set(element.ownerCanvasId,list);}
const ordered:Projection['nodes']=[];let complete=true; const ordered:Projection['nodes']=[];let complete=true;
/** 只重排节点引用,不重算未变化节点;任一所需投影缺失就放弃这份候选。 */ /** 按 owner 成员顺序深度优先重排节点引用,只进已在 transforms 显示的子 canvas。
* 缺任一所需投影就令 complete=false 回退;依赖已验证树,无独立循环/递归深度预算。 */
const visit=(owner:Id)=>{for(const id of queries?queries.members(owner):members.get(owner)??[]){const element=document.elements.get(id)!;if(element.structure!=='node')continue;const projected=inserted.get(id)??nodes[layout.nodes.get(id)!];if(!projected){complete=false;return;}ordered.push(projected);const child=layout.entries.get(id);if(child&&base.transforms.has(child))visit(child);}}; const visit=(owner:Id)=>{for(const id of queries?queries.members(owner):members.get(owner)??[]){const element=document.elements.get(id)!;if(element.structure!=='node')continue;const projected=inserted.get(id)??nodes[layout.nodes.get(id)!];if(!projected){complete=false;return;}ordered.push(projected);const child=layout.entries.get(id);if(child&&base.transforms.has(child))visit(child);}};
visit(canvasId);if(!complete)return;nodes=ordered; visit(canvasId);if(!complete)return;nodes=ordered;
} }
+11 -4
View File
@@ -12,13 +12,17 @@ import {indexScene,patchScene,type SceneChangeHint,type SceneInputs} from './sce
import type {RendererCatalog} from './renderers.ts'; import type {RendererCatalog} from './renderers.ts';
/** One view's derived scene. Viewport/tool/mode and task status do not change scene inputs. */ /** One view's derived scene. Viewport/tool/mode and task status do not change scene inputs. */
/** 每个视图独立创建;advance 接 editor 发布,project 接当前显示输入,clear 释放全部派生缓存。 */ /** 每个视图一份 previous/value/layout/pending 与几何缓存;advance 只积写集合,project 才按需算场景。
* 缓存借用不可变文档/选择/预览/registry/catalog 身份,不能在原对象中改字段后期待自动发现失效。
* 实例不订阅 editor、不请求帧,宿主负责接线 advance/project 与视图 clear 生命周期。 */
export function createSceneProjection(){ export function createSceneProjection(){
let geometry=createGeometryProjectionCache(); let geometry=createGeometryProjectionCache();
let previous:SceneInputs|undefined,value:ReturnType<typeof projectCanvas>|undefined,layout:ReturnType<typeof indexScene>|undefined,pending:SceneChangeHint|undefined; let previous:SceneInputs|undefined,value:ReturnType<typeof projectCanvas>|undefined,layout:ReturnType<typeof indexScene>|undefined,pending:SceneChangeHint|undefined;
return Object.freeze({ return Object.freeze({
/** 累积从上次投影文档到当前文档的连续写集合;丢发布、无 changes 的替换或大范围改动退回全量。 */ /** 累积上次投影文档→当前文档的连续发布链;同一文档对象直接忽略,未有场景时无需积累。
* 断链只在 previous.document===before 时能重起;空 changes 丢提示,canvas 改动/脏 ID>16384 只标 topology。
* 这里只影响快路径选择,不重放 Change、不写 doc,也不保证输入 changes 本身语义完整。 */
advance(before:Document,after:Document,changes:readonly Change[]){ advance(before:Document,after:Document,changes:readonly Change[]){
if(before===after)return; if(before===after)return;
if(!previous||previous.document===after){pending=undefined;return;} if(!previous||previous.document===after){pending=undefined;return;}
@@ -31,7 +35,9 @@ export function createSceneProjection(){
if(pending.topology)pending.ids.clear(); if(pending.topology)pending.ids.clear();
}, },
/** 固定 document/registry/catalog/显示 canvas 和捕获的 session 输入;相同引用直接复用场景。 /** 固定 document/registry/catalog/显示 canvas 和捕获的 session 输入;相同引用直接复用场景。
* 可兼容才尝试 patch,失败正常回退 projectCanvas;查询必须与同一份文档/registry 绑定。 */ * 可兼容才尝试 patch,undefined 正常回退 projectCanvas;异常不自动吞成另一份成功结果。
* queries 先查 document/registry 身份;其对象身份不在场景键中,单独更换 queries 不触发重算。
* 空 preview 规范为 undefined,viewport/tool/mode 不在键中;传入的 sessions 是捕获帧,不读取更新的 live 会话。 */
project(owner:object,document:Document,view:ViewState,sessions:SessionSnapshot,registry:Registry,catalog:RendererCatalog,queries?:DocumentQueries){ project(owner:object,document:Document,view:ViewState,sessions:SessionSnapshot,registry:Registry,catalog:RendererCatalog,queries?:DocumentQueries){
if(queries&&queries.document!==document)throw fault('stale_query_snapshot'); if(queries&&queries.document!==document)throw fault('stale_query_snapshot');
if(queries&&queries.registry!==registry)throw fault('query_registry_changed'); if(queries&&queries.registry!==registry)throw fault('query_registry_changed');
@@ -43,7 +49,8 @@ export function createSceneProjection(){
const compatible=previous&&value&&layout&&previous.owner===owner&&previous.registry===registry&&previous.catalog===catalog&&previous.canvasId===view.canvasId; const compatible=previous&&value&&layout&&previous.owner===owner&&previous.registry===registry&&previous.catalog===catalog&&previous.canvasId===view.canvasId;
const patched=compatible?patchScene(value!,layout!,next,previous!,connection,pending,queries):undefined; const patched=compatible?patchScene(value!,layout!,next,previous!,connection,pending,queries):undefined;
const result=patched??projectCanvas(document,view.canvasId,registry,view.selection,id=>next.preview?.get(id),connection,catalog,{queries,geometry,owner}); const result=patched??projectCanvas(document,view.canvasId,registry,view.selection,id=>next.preview?.get(id),connection,catalog,{queries,geometry,owner});
// 全量重建或节点数量改变才重建布局索引;成功采用结果后消费并清掉 changes 提示。 // 全量结果或节点数变化才重建 layout,其余兼容 patch 沿用数组下标/拓扑索引。
// 成功后一次推进 previous/value 并清 pending;异常不写这三项,但本轮几何缓存可能已有计算,不承诺整缓存回滚。
if(!patched||result.nodes.length!==value!.nodes.length)layout=indexScene(result); if(!patched||result.nodes.length!==value!.nodes.length)layout=indexScene(result);
previous=next;value=result;pending=undefined;return result; previous=next;value=result;pending=undefined;return result;
}, },
+7 -3
View File
@@ -5,15 +5,19 @@
/** One immutable source, one cached selection; no second document or operation state. */ /** One immutable source, one cached selection; no second document or operation state. */
/** 最小快照/订阅协议,可由 editor、session 或派生源实现,订阅返回释放函数。 */ /** 最小快照/订阅协议,可由 editor、session 或派生源实现,订阅返回释放函数。 */
export type ExternalSource<S>={getSnapshot():S;subscribe(notify:()=>void):()=>void}; export type ExternalSource<S>={getSnapshot():S;subscribe(notify:()=>void):()=>void};
/** 用 selector 与等价判断派生稳定快照;等价的新值沿用旧引用,避免 UI 的无效刷新。 */ /** 一个 store 共享一份最近源快照/selection 缓存,每个订阅另存 observed 引用。
* selector/isEqual 须同步且适合不可变源;不 clone/freeze 选中值,不修复上游原地修改后身份不变的问题。 */
export function createSelectorStore<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){ export function createSelectorStore<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){
let initialized=false,snapshot:S,selection:T; let initialized=false,snapshot:S,selection:T;
/** 根未变则跳过计算;根变但结果等价也保持 selection 引用,只更新已观察的根身份。 */ /** 根身份相同则跳过 selector;新根等价则沿用旧 selection,仅推进已观察源身份。
* selector/isEqual 抛错发生在缓存赋值前,原缓存保留,getSnapshot 仍向调用者抛错。 */
function getSnapshot():T{ function getSnapshot():T{
const current=source.getSnapshot();if(initialized&&Object.is(snapshot,current))return selection; const current=source.getSnapshot();if(initialized&&Object.is(snapshot,current))return selection;
const next=selector(current),same=initialized&&isEqual(selection,next); const next=selector(current),same=initialized&&isEqual(selection,next);
snapshot=current;if(!same)selection=next;initialized=true;return selection; snapshot=current;if(!same)selection=next;initialized=true;return selection;
} }
// 每个订阅单独跟踪 observed;计算失败仍通知,让使用方在自己的读取/错误边界重新处理。 // subscribe 先同步读取初值(失败则尚未接上游);每个 observer 独立比较 selection 身份。
// 上游通知时计算失败仍 notify,供使用方重读/错误边界处理;不把 notify 的异常吞成成功。
// 直接返回上游 stop,无额外 dispose:退订幂等性、异常与通知时序沿用源合同。
return Object.freeze({getSnapshot,subscribe(notify:()=>void){let observed=getSnapshot();return source.subscribe(()=>{let next:T;try{next=getSnapshot();}catch{notify();return;}if(!Object.is(observed,next)){observed=next;notify();}});}}); return Object.freeze({getSnapshot,subscribe(notify:()=>void){let observed=getSnapshot();return source.subscribe(()=>{let next:T;try{next=getSnapshot();}catch{notify();return;}if(!Object.is(observed,next)){observed=next;notify();}});}});
} }
+4 -1
View File
@@ -6,7 +6,9 @@ import type {SessionSnapshot} from '../canvas-core/gestures.ts';
import type {ExternalSource} from './selectors.ts'; import type {ExternalSource} from './selectors.ts';
import {createFrameQueue,type FrameClock} from './frame-queue.ts'; import {createFrameQueue,type FrameClock} from './frame-queue.ts';
/** One subscription owns one disposable frame queue. Reads always see current sessions. */ /** One subscription owns one disposable frame queue. Reads always see current sessions. */
/** 对比前后会话引用生成 view/element 脏 key;退订时同步撤销帧队列。 */ /** 每个订阅保留上一份 session 引用,按不可变 view/gesture 身份发现失效,任意 dirty 最终只触发 notify()。
* key 集合是帧内去重依据,不向消费者发布逐元素差分;getSnapshot 直接借用源方法,须不依赖 this 绑定。
* 此包装不使用 immediate 标志,也不对上游 subscribe/stop 抛错作 finally 清理,宿主须保证正常订阅生命周期。 */
export function createSessionFrameSource(source:ExternalSource<SessionSnapshot>,clock:FrameClock):ExternalSource<SessionSnapshot>{ export function createSessionFrameSource(source:ExternalSource<SessionSnapshot>,clock:FrameClock):ExternalSource<SessionSnapshot>{
return Object.freeze({getSnapshot:source.getSnapshot,subscribe(notify:()=>void){ return Object.freeze({getSnapshot:source.getSnapshot,subscribe(notify:()=>void){
let previous=source.getSnapshot();const queue=createFrameQueue<string>(clock,()=>notify()); let previous=source.getSnapshot();const queue=createFrameQueue<string>(clock,()=>notify());
@@ -17,6 +19,7 @@ export function createSessionFrameSource(source:ExternalSource<SessionSnapshot>,
if(previous.gesture!==next.gesture)for(const state of [previous.gesture,next.gesture])for(const target of state?.targets??[])dirty.add(`element:${target.id}`); if(previous.gesture!==next.gesture)for(const state of [previous.gesture,next.gesture])for(const target of state?.targets??[])dirty.add(`element:${target.id}`);
// 连线手势与普通几何手势分别跟踪,改变或取消时都让旧/新目标失效。 // 连线手势与普通几何手势分别跟踪,改变或取消时都让旧/新目标失效。
if(previous.connectionGesture!==next.connectionGesture)for(const state of [previous.connectionGesture,next.connectionGesture])if(state)dirty.add(`element:${state.target.id}`); if(previous.connectionGesture!==next.connectionGesture)for(const state of [previous.connectionGesture,next.connectionGesture])if(state)dirty.add(`element:${state.target.id}`);
// 先推进 previous 再排队,因此一帧内多次变化相对每次上游快照计算,不只比较帧起点与终点。
previous=next;queue.invalidate(dirty); previous=next;queue.invalidate(dirty);
});return()=>{stop();queue.dispose();}; });return()=>{stop();queue.dispose();};
}}); }});
+16 -7
View File
@@ -10,7 +10,9 @@ import type {ExternalSource} from './selectors.ts';
/** preview 可被所有匹配代次的视图读取,active 则只标记拥有该手势的视图。 */ /** preview 可被所有匹配代次的视图读取,active 则只标记拥有该手势的视图。 */
export type GestureSelection={readonly preview:Geometry|undefined;readonly active:boolean}; export type GestureSelection={readonly preview:Geometry|undefined;readonly active:boolean};
/** Derived view/target subscriptions. Values retain no document or full per-hook session. */ /** Derived view/target subscriptions. Values retain no document or full per-hook session. */
/** 建立按视图模式和元素手势分离的订阅通道,比较派生值后才通知观察者。 */ /** 建立按视图模式和几何手势分离的通道;只追踪 modes/gesture,不把 selection/viewport/connectionGesture 混入。
* modes 是 viewId→elementId→观察器桶,gestures 是 elementId→观察器桶,同一上游变化先选候选再比值。
* 有观察器时 previous 持有一份上游快照引用供比较,不复制完整 session;最后退订后释放该引用。 */
export function createSessionSources(source:ExternalSource<SessionSnapshot>){ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
// update 先推进内部观察值,notify 后执行外部回调,避免某监听器读到其它桶尚未更新。 // update 先推进内部观察值,notify 后执行外部回调,避免某监听器读到其它桶尚未更新。
type Observer={update(snapshot:SessionSnapshot):boolean;notify():void}; type Observer={update(snapshot:SessionSnapshot):boolean;notify():void};
@@ -18,9 +20,11 @@ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
// 按不可变 targets 数组的身份弱缓存 ID→代次索引,避免每个元素订阅逐次线性搜索目标列表。 // 按不可变 targets 数组的身份弱缓存 ID→代次索引,避免每个元素订阅逐次线性搜索目标列表。
const targetIndexes=new WeakMap<readonly Target[],ReadonlyMap<Id,number>>(); const targetIndexes=new WeakMap<readonly Target[],ReadonlyMap<Id,number>>();
let stop:(()=>void)|undefined,previous:SessionSnapshot|undefined; let stop:(()=>void)|undefined,previous:SessionSnapshot|undefined;
/** 首次遇到一组目标时建索引;相同 ID 的新 incarnation 不会匹配旧手势。 */ /** 按 targets 数组身份缓存 elementId→incarnation,要求上游不原地改 targets。
* 合法会话应保证同组 ID 唯一;Map 对重复 ID 留最后一项,此 helper 不另验目标表形状。 */
function targets(gesture:GestureState){let index=targetIndexes.get(gesture.targets);if(!index){index=new Map(gesture.targets.map(target=>[target.id,target.incarnation]));targetIndexes.set(gesture.targets,index);}return index;} function targets(gesture:GestureState){let index=targetIndexes.get(gesture.targets);if(!index){index=new Map(gesture.targets.map(target=>[target.id,target.incarnation]));targetIndexes.set(gesture.targets,index);}return index;}
/** 收集可能受影响的观察器,全部 update 完再逐个 notify;一个监听器失败也不饿死其余监听器。 */ /** 在 changed 入口取得同一份 next,先推进 previous,再用 Set 合并所有候选,避免观察器重复更新。
* 先更新全部候选 observed 后 notify;各 notify 异常暂存,余者仍执行,最后重抛第一个异常(包括假值)。 */
function changed(){ function changed(){
if(!previous)return;const before=previous,next=source.getSnapshot();previous=next; if(!previous)return;const before=previous,next=source.getSnapshot();previous=next;
const candidates=new Set<Observer>(),add=(items:Set<Observer>|undefined)=>{for(const item of items??[])candidates.add(item);}; const candidates=new Set<Observer>(),add=(items:Set<Observer>|undefined)=>{for(const item of items??[])candidates.add(item);};
@@ -40,7 +44,9 @@ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
const notifications:Observer[]=[];for(const observer of candidates)if(observer.update(next))notifications.push(observer); const notifications:Observer[]=[];for(const observer of candidates)if(observer.update(next))notifications.push(observer);
let failed=false,error:unknown;for(const observer of notifications)try{observer.notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error; let failed=false,error:unknown;for(const observer of notifications)try{observer.notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
} }
/** 一个派生通道的稳定读取和订阅装配。读取失败通过通知触发使用方重读,不把错误吞成旧成功值。 */ /** 每个 channel 独享 getSnapshot 的派生值缓存;每个订阅另存 observed,二者不靠回调共享可变业务数据。
* equal 深比较派生 JSON 风格值,等价时保留旧引用;选中 preview 仍借用原不可变 Geometry,不做深克隆。
* observer.read 失败会标记失败并通知,后续成功也通知一次恢复;直接 getSnapshot 的读取异常仍向外抛出。 */
function channel<T>(read:(snapshot:SessionSnapshot)=>T,register:(observer:Observer)=>()=>void):ExternalSource<T>{ function channel<T>(read:(snapshot:SessionSnapshot)=>T,register:(observer:Observer)=>()=>void):ExternalSource<T>{
let initialized=false,value:T; let initialized=false,value:T;
return Object.freeze({ return Object.freeze({
@@ -51,18 +57,21 @@ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
const remove=register(observer); const remove=register(observer);
// 第一个观察器才连上游;注册失败撤回本次桶,防止留下无人管理的观察器。 // 第一个观察器才连上游;注册失败撤回本次桶,防止留下无人管理的观察器。
if(!stop){previous=source.getSnapshot();try{stop=source.subscribe(changed);}catch(error){remove();previous=undefined;throw error;}} if(!stop){previous=source.getSnapshot();try{stop=source.subscribe(changed);}catch(error){remove();previous=undefined;throw error;}}
// 单次退订幂等;所有桶清空后释放全局上游订阅及上一份会话快照。 // 退订 token 先置 inactive,remove 清桶;最后一桶消失才 stop 上游并清 previous。
// stop 异常不在这里捕获,状态重置不保证继续执行;正常宿主清理端口须不抛错。
let active=true;return()=>{if(!active)return;active=false;remove();if(!modes.size&&!gestures.size){stop?.();stop=undefined;previous=undefined;}}; let active=true;return()=>{if(!active)return;active=false;remove();if(!modes.size&&!gestures.size){stop?.();stop=undefined;previous=undefined;}};
}, },
}); });
} }
return Object.freeze({ return Object.freeze({
/** 派生某视图中单元素的编辑模式;视图/模式缺失时返回 null。 */ /** 捕获 viewId/id 两个标量,按 view→element 桶登记观察器;视图/模式缺失返回 null。
* 调用 mode 只创建派生通道,首次 subscribe 才注册桶和必要的上游监听。 */
mode(viewId:Id,id:Id):ExternalSource<string|null>{return channel(snapshot=>snapshot.views.get(viewId)?.modes.get(id)??null,observer=>{ mode(viewId:Id,id:Id):ExternalSource<string|null>{return channel(snapshot=>snapshot.views.get(viewId)?.modes.get(id)??null,observer=>{
let byId=modes.get(viewId);if(!byId){byId=new Map();modes.set(viewId,byId);}let bucket=byId.get(id);if(!bucket){bucket=new Set();byId.set(id,bucket);}bucket.add(observer); let byId=modes.get(viewId);if(!byId){byId=new Map();modes.set(viewId,byId);}let bucket=byId.get(id);if(!bucket){bucket=new Set();byId.set(id,bucket);}bucket.add(observer);
return()=>{bucket!.delete(observer);if(!bucket!.size)byId!.delete(id);if(!byId!.size)modes.delete(viewId);}; return()=>{bucket!.delete(observer);if(!bucket!.size)byId!.delete(id);if(!byId!.size)modes.delete(viewId);};
});}, });},
/** 按目标 ID+代次读取预览;同元素跨视图可见预览,但只有原 viewId 拥有 active。 */ /** 入口捕获 target.id/incarnation,之后改调用者 target 不影响本通道;订阅按 ID 分桶,读值再匹配代次。
* 匹配元素跨视图可见 preview,只有 gesture.viewId 对应视图 active;没有 preview 不代表不在手势 targets 中。 */
gesture(viewId:Id,target:Target):ExternalSource<GestureSelection>{const{id,incarnation}=target;return channel(snapshot=>{ gesture(viewId:Id,target:Target):ExternalSource<GestureSelection>{const{id,incarnation}=target;return channel(snapshot=>{
const gesture=snapshot.gesture,matched=!!gesture&&targets(gesture).get(id)===incarnation; const gesture=snapshot.gesture,matched=!!gesture&&targets(gesture).get(id)===incarnation;
return{preview:matched?gesture!.preview.get(id):undefined,active:matched&&gesture!.viewId===viewId}; return{preview:matched?gesture!.preview.get(id):undefined,active:matched&&gesture!.viewId===viewId};
+2 -1
View File
@@ -4,5 +4,6 @@
import type {Rect} from '../canvas-core/geometry.ts'; import type {Rect} from '../canvas-core/geometry.ts';
/** Positive-area native node visibility. Degenerate nodes are retained for measurement. */ /** Positive-area native node visibility. Degenerate nodes are retained for measurement. */
/** 正面积矩形要求与视口有正面积交叠,只有边界接触不算可见。 /** 正面积矩形要求与视口有正面积交叠,只有边界接触不算可见。
* 宽/高非正的退化节点保留,让 renderer 有机会测量它,而不是提前裁掉。 */ * 宽/高非正的节点直接保留,即使远离视口,让 renderer 有机会测量;负数也走同一分支,并不在此修正。
* 调用方须提供同一坐标系中的轴对齐矩形;此函数不处理旋转、zoom 或 NaN/Infinity 合法性。 */
export function nativeRectVisible(node:Rect,viewport:Rect){if(node.width<=0||node.height<=0)return true;return Math.min(node.x+node.width,viewport.x+viewport.width)>Math.max(node.x,viewport.x)&&Math.min(node.y+node.height,viewport.y+viewport.height)>Math.max(node.y,viewport.y);} export function nativeRectVisible(node:Rect,viewport:Rect){if(node.width<=0||node.height<=0)return true;return Math.min(node.x+node.width,viewport.x+viewport.width)>Math.max(node.x,viewport.x)&&Math.min(node.y+node.height,viewport.y+viewport.height)>Math.max(node.y,viewport.y);}
+8 -3
View File
@@ -2,6 +2,7 @@
* 嵌套 canvas 的坐标换算:Transform 把一个 canvas 的局部空间映射到项目世界空间。 * 嵌套 canvas 的坐标换算:Transform 把一个 canvas 的局部空间映射到项目世界空间。
* 统一采用平移、等比缩放和旋转,不处理 skew/非等比父变换。owner 改变时通过世界空间中转, * 统一采用平移、等比缩放和旋转,不处理 skew/非等比父变换。owner 改变时通过世界空间中转,
* 保持节点外观和连线曲线位置;表现单位的 data 修正由结构/编辑规划器另行生成。 * 保持节点外观和连线曲线位置;表现单位的 data 修正由结构/编辑规划器另行生成。
* 数学辅助函数依赖已校验的有限几何/正比例;它们不逐项代替 schema、registry 或可见性校验。
*/ */
import type { Document, Geometry, Id, Json, Point } from './types.ts'; import type { Document, Geometry, Id, Json, Point } from './types.ts';
import { ancestors } from './document.ts'; import { ancestors } from './document.ts';
@@ -22,9 +23,10 @@ export function toWorld(transform: Transform, point: Point): Point { const value
export function toLocal(transform: Transform, point: Point): Point { return rotate({ x: (point.x - transform.x) / transform.scale, y: (point.y - transform.y) / transform.scale }, -transform.rotation); } export function toLocal(transform: Transform, point: Point): Point { return rotate({ x: (point.x - transform.x) / transform.scale, y: (point.y - transform.y) / transform.scale }, -transform.rotation); }
/** 将可识别的 JSON 对象视为字段表,空值/原始值/数组按空表处理,不修改源数据。 */ /** 将可识别的 JSON 对象视为字段表,空值/原始值/数组按空表处理,不修改源数据。 */
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
/** 对已识别的 bezier-chain 换空间;未知路径原样返回,严格写入边界由 convertPath 把关。 /** type/knots 可识别时逐点换空间;其它路径原样返回,不在本函数完整验证曲线形状。
* knot.position 是位置,in/out 与首尾 control 是偏移向量,向量不能叠加平移。 */ * knot.position 是位置,in/out 与首尾 control 是偏移向量,向量不能叠加平移。 */
export function transformPath(path: Json, transform: Transform, inverse = false): Json { export function transformPath(path: Json, transform: Transform, inverse = false): Json {
// 直接调用可对部分字段做转换;实际需要换空间时,convertPath 会先检查支持的路径格式。
const raw = object(path); const raw = object(path);
if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path; if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path;
/** 保留点的扩展字段;vector 模式把变换平移归零,仅施加旋转/比例。 */ /** 保留点的扩展字段;vector 模式把变换平移归零,仅施加旋转/比例。 */
@@ -42,13 +44,15 @@ export function transformPath(path: Json, transform: Transform, inverse = false)
/** 合成父变换与分区入口几何,得到子 canvas 内容区原点/比例/旋转。 /** 合成父变换与分区入口几何,得到子 canvas 内容区原点/比例/旋转。
* 24/36 是格式 1 的内容内边距,要乘 presentation.unitScale,和 renderer 的投影保持一致。 */ * 24/36 是格式 1 的内容内边距,要乘 presentation.unitScale,和 renderer 的投影保持一致。 */
export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform { export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform {
// unitScale 由专用读取器检查;contentScale 在这里仅按 number 且 >0 选择,有限性等约束依赖上游 schema。
const data=object(presentation),units=unitScale(presentation),scale=(typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1)*units; const data=object(presentation),units=unitScale(presentation),scale=(typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1)*units;
// 入口绕中心旋转,因此先把内容内边距写成相对中心偏移,再旋转并加回中心。 // 入口绕中心旋转,因此先把内容内边距写成相对中心偏移,再旋转并加回中心。
const offset=rotate({x:24*units-geometry.width/2,y:36*units-geometry.height/2},geometry.rotation); const offset=rotate({x:24*units-geometry.width/2,y:36*units-geometry.height/2},geometry.rotation);
const start=toWorld(parent,{x:geometry.x+geometry.width/2+offset.x,y:geometry.y+geometry.height/2+offset.y}); const start=toWorld(parent,{x:geometry.x+geometry.width/2+offset.x,y:geometry.y+geometry.height/2+offset.y});
return{...start,scale:parent.scale*scale,rotation:((parent.rotation+geometry.rotation)%360+360)%360}; return{...start,scale:parent.scale*scale,rotation:((parent.rotation+geometry.rotation)%360+360)%360};
} }
/** 从根向目标逐级合成入口变换;ancestors 同时检查缺失父项与循环。 */ /** 从根向目标逐级合成入口变换;ancestors 检查缺失父项与循环。
* 这里只要求每层入口存在且为 node,不检查组件是否已知/展开;可编辑性与可见性由上层决定。 */
export function canvasTransform(document:Document,canvasId:Id):Transform { export function canvasTransform(document:Document,canvasId:Id):Transform {
let result:Transform={...origin}; let result:Transform={...origin};
for(const id of ancestors(document,canvasId).reverse()){ for(const id of ancestors(document,canvasId).reverse()){
@@ -65,7 +69,8 @@ export function convertGeometry(geometry:Geometry,from:Transform,to:Transform):G
const center=toLocal(to,toWorld(from,{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2})),ratio=from.scale/to.scale,width=geometry.width*ratio,height=geometry.height*ratio; const center=toLocal(to,toWorld(from,{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2})),ratio=from.scale/to.scale,width=geometry.width*ratio,height=geometry.height*ratio;
return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((geometry.rotation+from.rotation-to.rotation)%360+360)%360}; return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((geometry.rotation+from.rotation-to.rotation)%360+360)%360};
} }
/** 严格曲线换空间:null/同变换无需改;不支持的路径拒绝,不擅自解释未知 payload。 */ /** null/同变换先原样返回;只有实际需要换空间时才检查支持的曲线格式,不支持就拒绝。
* 两次转换分别完成旧局部→世界、世界→新局部;无须改变 knot ID 或曲线段拓扑。 */
export function convertPath(path:Json,from:Transform,to:Transform):Json { export function convertPath(path:Json,from:Transform,to:Transform):Json {
if(path===null||sameTransform(from,to))return path; if(path===null||sameTransform(from,to))return path;
if(!connectionPathValid(path))throw fault('unsupported_coordinate_path'); if(!connectionPathValid(path))throw fault('unsupported_coordinate_path');
+5 -3
View File
@@ -1,6 +1,7 @@
/** /**
* 组件声明式引用路径的遍历器,供复制/解散等纯规划复用。 * 组件声明式引用路径的遍历器,供复制/解散等纯规划复用。
* 只访问自有字段,不用字符串相似度猜 ID;这里 yield 可写 slot,调用者必须先复制数据再改。 * 普通路径段只访问自有字段,不用字符串相似度猜 ID;这里 yield 可写 slot,调用者必须先复制数据再改。
* path 应先经组件定义校验:本遍历器不重新禁止空路径或原型敏感键,数组 * 按下标位置枚举。
*/ */
import type { Json } from './types.ts'; import type { Json } from './types.ts';
import { fault } from './values.ts'; import { fault } from './values.ts';
@@ -14,7 +15,7 @@ export function* copySlots(data: Json, path: readonly string[]): Generator<{pare
const {value, depth} = work.pop()!, key = path[depth]; const {value, depth} = work.pop()!, key = path[depth];
if (key === '*') { if (key === '*') {
if (!Array.isArray(value)) throw fault('invalid_copy_reference'); if (!Array.isArray(value)) throw fault('invalid_copy_reference');
// 终端 * 直接枚举数组项位置;非终端 * 继续匹配下一路径段。 // 终端 * 直接枚举 0..length-1,不另用 hasOwn 检查空洞;非终端 * 继续匹配下一路径段。
if (depth === path.length - 1) { for (let i = 0; i < value.length; i++) yield {parent: value as unknown as Record<string, Json>, key: String(i)}; } if (depth === path.length - 1) { for (let i = 0; i < value.length; i++) yield {parent: value as unknown as Record<string, Json>, key: String(i)}; }
else for (let i = value.length - 1; i >= 0; i--) work.push({value: value[i], depth: depth + 1}); else for (let i = value.length - 1; i >= 0; i--) work.push({value: value[i], depth: depth + 1});
} else if (value && typeof value === 'object' && Object.hasOwn(value, key)) { } else if (value && typeof value === 'object' && Object.hasOwn(value, key)) {
@@ -23,7 +24,8 @@ export function* copySlots(data: Json, path: readonly string[]): Generator<{pare
} }
} }
} }
/** 在 slot 上读取引用 ID;null 代表无引用,many 要求数组,每个 ID 必须非空字符串。 */ /** 在 slot 上读取引用 ID;整个 slot 为 null 表示无引用,many 要求数组,每个 ID 必须非空字符串。
* many 数组里的 null 不等于无引用,会拒绝;many 与路径中的 * 是独立的两层展开规则。 */
export function* copyReferenceIds(data: Json, ref: {path: readonly string[]; many?: boolean}): Generator<string> { export function* copyReferenceIds(data: Json, ref: {path: readonly string[]; many?: boolean}): Generator<string> {
for (const {parent, key} of copySlots(data, ref.path)) { for (const {parent, key} of copySlots(data, ref.path)) {
const value = parent[key]; if (value === null) continue; const value = parent[key]; if (value === null) continue;
+11 -5
View File
@@ -1,6 +1,7 @@
/** /**
* 同步事务内的大批创建优化。追加表用固定成员的 base+added 视图避免每新增一项都复制整表, * 同步事务内的大批创建优化。追加表用固定成员的 base+added 视图避免每新增一项都复制整表,
* 累计 64 项后折叠为新 base。只用于尚未发布的 draft,提交前统一物化为标准文档表。 * 已有追加层达到 64 项时,下一次追加先折叠为新 base。只用于未发布 draft,提交前统一物化文档表。
* 输入表须已具固定成员;视图借用 base 和未变记录,不是任意可变 Map 的隔离器。
*/ */
import {detachElementData} from './element-data.ts'; import {detachElementData} from './element-data.ts';
import type {CanvasRecord,Change,Document,ElementRecord,Id} from './types.ts'; import type {CanvasRecord,Change,Document,ElementRecord,Id} from './types.ts';
@@ -14,12 +15,14 @@ const overlays=new WeakMap<object,{base:ReadonlyMap<Id,unknown>;added:ReadonlyMa
function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{ function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
if(input.has(id))throw fault('duplicate_identity',[id]); if(input.has(id))throw fault('duplicate_identity',[id]);
let previous=overlays.get(input) as {base:ReadonlyMap<Id,T>;added:ReadonlyMap<Id,T>}|undefined; let previous=overlays.get(input) as {base:ReadonlyMap<Id,T>;added:ReadonlyMap<Id,T>}|undefined;
// 控制覆盖层大小;到阈值后重建 base,而不是让链条随着事务长度无限加深。 // 当前 input 若含 64 个 added,本次先通过 readMap(input) 折叠,再以新 added 放入第 65 个;不是整个事务最多 64 项。
if(previous&&previous.added.size>=64)previous=undefined; if(previous&&previous.added.size>=64)previous=undefined;
// overlays 记录的是框架创建的视图身份;普通输入直接借用,只有已知旧 overlay 才需要折叠。
const base=previous?.base??(overlays.has(input)?readMap(input):input),added=new Map(previous?.added); const base=previous?.base??(overlays.has(input)?readMap(input):input),added=new Map(previous?.added);
added.set(id,value); added.set(id,value);
/** 迭代先 base 后 added,保持插入顺序;视图的 get/has 优先查 appended 成员。 */ /** 迭代先 base 后 added,保持插入顺序;视图的 get/has 优先查 appended 成员。 */
const entries=function*():Generator<[Id,T],undefined,unknown>{yield* base;yield* added;return undefined;}; const entries=function*():Generator<[Id,T],undefined,unknown>{yield* base;yield* added;return undefined;};
// get 用 has 区分“added 中显式存 undefined”与缺失;迭代顺序始终 base 在前、追加成员在后。
// keys/values/iterator 复用 entries;forEach 第三个参数仍是安全视图,不泄漏 added Map。 // keys/values/iterator 复用 entries;forEach 第三个参数仍是安全视图,不泄漏 added Map。
const view:ReadonlyMap<Id,T>=Object.freeze({size:base.size+added.size,get:(key:Id)=>added.has(key)?added.get(key):base.get(key),has:(key:Id)=>added.has(key)||base.has(key),entries, const view:ReadonlyMap<Id,T>=Object.freeze({size:base.size+added.size,get:(key:Id)=>added.has(key)?added.get(key):base.get(key),has:(key:Id)=>added.has(key)||base.has(key),entries,
*keys():Generator<Id,undefined,unknown>{for(const [key]of entries())yield key;return undefined;},*values():Generator<T,undefined,unknown>{for(const [,item]of entries())yield item;return undefined;},[Symbol.iterator]:entries, *keys():Generator<Id,undefined,unknown>{for(const [key]of entries())yield key;return undefined;},*values():Generator<T,undefined,unknown>{for(const [,item]of entries())yield item;return undefined;},[Symbol.iterator]:entries,
@@ -29,14 +32,17 @@ function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
} }
/** Internal editor path: input tables already have fixed membership. */ /** Internal editor path: input tables already have fixed membership. */
/** 仅接收 before=null 且 after 存在的创建变化,校验 JSON/ID 后捕获记录并追加相应表。 /** 仅接收 before=null 且 after 存在的创建变化,校验 JSON/ID 后捕获记录并追加相应表。
* 整图依赖可在后续命令补齐,因此此处不提前做完整树/端点校验。 */ * 整图依赖可在后续命令补齐,因此此处不提前做完整树/端点校验。
* 本层只检查创建形状、JSON、ID 一致及活表重复;registry 支持与墓碑禁复用由 editor/最终校验负责。 */
export function stageCreation(document:Document,change:Extract<Change,{kind:'canvas'|'element'}>):Document{ export function stageCreation(document:Document,change:Extract<Change,{kind:'canvas'|'element'}>):Document{
if(change.before!==null||!change.after)throw fault('invalid_creation_draft'); if(change.before!==null||!change.after)throw fault('invalid_creation_draft');
if(!validJson(change.after))throw fault(change.kind==='canvas'?'invalid_canvas':'invalid_element',[change.id]); if(!validJson(change.after))throw fault(change.kind==='canvas'?'invalid_canvas':'invalid_element',[change.id]);
if(change.after.id!==change.id)throw fault('identity_mismatch',[change.id]); if(change.after.id!==change.id)throw fault('identity_mismatch',[change.id]);
const value=change.kind==='element'?detachElementData(change.after as ElementRecord):freeze(clone(change.after)); const value=change.kind==='element'?detachElementData(change.after as ElementRecord):freeze(clone(change.after));
// 只替换被追加的表,另一表/墓碑沿用;新 Document 标记为 draft,旧视图不随本次 added 改变。
const next=Object.freeze({...document,...(change.kind==='canvas'?{canvases:append(document.canvases,change.id,value as CanvasRecord)}:{elements:append(document.elements,change.id,value as ElementRecord)})});drafts.add(next);return next; const next=Object.freeze({...document,...(change.kind==='canvas'?{canvases:append(document.canvases,change.id,value as CanvasRecord)}:{elements:append(document.elements,change.id,value as ElementRecord)})});drafts.add(next);return next;
} }
/** Only published documents use the core's owned table envelopes. */ /** Materialize transaction-local append tables before publication. */
/** 只有已标记的 draft 需要通过空 changes 重新捕获表;普通文档原样复用。 */ /** 只有已标记 draft 通过空 changes 物化成工厂 envelope/readMap;其它文档原样返回。
* 不在这里校验 schema/树,也不把任何外来 Document 自动视为已校验或深度不可变。 */
export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):document; export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):document;
+17 -8
View File
@@ -22,12 +22,14 @@ export function validateCreationSizeEffect(value: unknown): asserts value is Cre
* Already-issued operation bodies are deliberately outside this function. * Already-issued operation bodies are deliberately outside this function.
*/ */
/** 仅原创建仍在 undo、目标仍活且未被用户缩放时,准备默认尺寸与历史修正。 /** 仅原创建仍在 undo、目标仍活且未被用户缩放时,准备默认尺寸与历史修正。
* 返回候选 document/history/changes;原输入始终不变,失败不留下半份历史。 */ * 返回候选 document/history/changes,不发布状态;输入须来自已捕获的不可变文档与历史。
* 这里按 geometry.resize 能力判定,不限定 componentId 必须是 image;图片授权条件由外层 grant 决定。 */
export function planCreationSize(document: Document, history: HistoryState, registry: Registry, export function planCreationSize(document: Document, history: HistoryState, registry: Registry,
entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) { entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) {
/** 统一将失败绑定到目标 ID,便于调用方解释为何不能再自动调整。 */ /** 统一将失败绑定到目标 ID,便于调用方解释为何不能再自动调整。 */
const fail = (code: string): never => { throw fault(code, [target.id]); }; const fail = (code: string): never => { throw fault(code, [target.id]); };
if (![size.width, size.height].every(value => Number.isFinite(value) && value > 0)) fail('invalid_creation_size'); if (![size.width, size.height].every(value => Number.isFinite(value) && value > 0)) fail('invalid_creation_size');
// 创建项必须仍在已应用 undo 时间线上;已撤销到 redo、被淘汰或目标换代的尺寸结果不能采用。
const index = history.undo.indexOf(entryId), entry = history.entries.get(entryId); const index = history.undo.indexOf(entryId), entry = history.entries.get(entryId);
const current = document.elements.get(target.id); const current = document.elements.get(target.id);
if (index < 0 || !entry || current?.incarnation !== target.incarnation || current.structure !== 'node') fail('creation_size_target_changed'); if (index < 0 || !entry || current?.incarnation !== target.incarnation || current.structure !== 'node') fail('creation_size_target_changed');
@@ -40,7 +42,7 @@ export function planCreationSize(document: Document, history: HistoryState, regi
const units = unitScale(created.data.presentation); const units = unitScale(created.data.presentation);
const baseline = { width: created.geometry.width / units, height: created.geometry.height / units }; const baseline = { width: created.geometry.width / units, height: created.geometry.height / units };
const ratio = { width: size.width / baseline.width, height: size.height / baseline.height }; const ratio = { width: size.width / baseline.width, height: size.height / baseline.height };
/** 同一生命周期的节点才属于本次自动尺寸修正对象。 */ /** 调用处已按 target.id 取记录,这里再限制 incarnation 与 node 结构;不单独比较元素 ID。 */
const matches = (element: ElementRecord | undefined) => element?.incarnation === target.incarnation && element.structure === 'node'; const matches = (element: ElementRecord | undefined) => element?.incarnation === target.incarnation && element.structure === 'node';
/** 比较宽高除 unitScale 后的视觉值,移动/换 owner 不等同于用户主动缩放。 */ /** 比较宽高除 unitScale 后的视觉值,移动/换 owner 不等同于用户主动缩放。 */
const unchanged = (element: ElementRecord | undefined) => matches(element) && element!.structure === 'node' const unchanged = (element: ElementRecord | undefined) => matches(element) && element!.structure === 'node'
@@ -52,9 +54,11 @@ export function planCreationSize(document: Document, history: HistoryState, regi
// Rewind on private roots. applyChanges intentionally preserves historical // Rewind on private roots. applyChanges intentionally preserves historical
// incarnations here; this is inspection, not a user-visible delete/restore. // incarnations here; this is inspection, not a user-visible delete/restore.
let original = document; let original = document;
// 从创建项起回退已应用历史,得到创建前私有基线;不产生用户可见的删除/恢复代次。 // 从创建项起回退全部已应用项,得到创建前私有基线;此处用 applyChanges 而非 planHistoryChanges,
// 因而允许检查途中存在历史代次/墓碑组合,不把它们作为最终文档发布。
const applied = history.undo.slice(index); const applied = history.undo.slice(index);
for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes)); for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes));
// original 追踪旧时间线,amended 追踪修正后的时间线;inheriting 只在匹配创建之后开启。
let amended = original, inheriting = false; let amended = original, inheriting = false;
const entries = new Map(history.entries); const entries = new Map(history.entries);
// 先按 undo 的正向顺序重建已应用项,再倒序遍历 redo 栈还原未来重做时间线。 // 先按 undo 的正向顺序重建已应用项,再倒序遍历 redo 栈还原未来重做时间线。
@@ -63,11 +67,12 @@ export function planCreationSize(document: Document, history: HistoryState, regi
const id = timeline[cursor], source = entries.get(id)!; const id = timeline[cursor], source = entries.get(id)!;
const end = applyChanges(original, source.changes), isApplied = cursor < applied.length; const end = applyChanges(original, source.changes), isApplied = cursor < applied.length;
if (isApplied && !unchanged(end.elements.get(target.id))) fail('creation_size_user_resized'); if (isApplied && !unchanged(end.elements.get(target.id))) fail('creation_size_user_resized');
// A future explicit resize remains a resize to the user's chosen dimensions. // A future resize preserves each dimension explicitly changed by the user.
// Only its before value inherits the asynchronous default correction. // Its before value and unchanged dimensions may inherit the default correction.
const futureResize = !isApplied && inheriting && matches(end.elements.get(target.id)) && !unchanged(end.elements.get(target.id)); const futureResize = !isApplied && inheriting && matches(end.elements.get(target.id)) && !unchanged(end.elements.get(target.id));
let resizeConsumed = false; let resizeConsumed = false;
/** redo 中用户显式改过的维度停止继承默认比例;仅修正其 before,不改用户指定的 after。 */ /** redo 中按宽/高分别判断:被用户改过的维度将 ratio 置 1,保留其 after;
* before 重取修正时间线,未被用户改变的另一维仍可继承默认比例。每项最多消费一次。 */
const preserveFutureResize = () => { const preserveFutureResize = () => {
if (!futureResize || resizeConsumed) return; if (!futureResize || resizeConsumed) return;
const value = end.elements.get(target.id) as ElementRecord & { structure: 'node' }; const value = end.elements.get(target.id) as ElementRecord & { structure: 'node' };
@@ -76,9 +81,10 @@ export function planCreationSize(document: Document, history: HistoryState, regi
resizeConsumed = true; resizeConsumed = true;
}; };
const changes: Change[] = []; const changes: Change[] = [];
// 每条 next 都基于 amended 的实际 before 重建,保证修正后的连续历史仍条件匹配。 // 目标整记录/geometry 变化从 amended 重取 before;其它变化保留原条件,继续用 applyChanges 校验。
for (const change of source.changes) { for (const change of source.changes) {
let next = change; let next = change;
// 整记录变化也要重取真实 before;其它对象/字段沿用原变化,保证整条时间线仍按原次序检查。
if (change.kind === 'element' && change.id === target.id) { if (change.kind === 'element' && change.id === target.id) {
const before = amended.elements.get(target.id) ?? null; const before = amended.elements.get(target.id) ?? null;
const birth = id === entryId && change === creations[0]; const birth = id === entryId && change === creations[0];
@@ -98,6 +104,7 @@ export function planCreationSize(document: Document, history: HistoryState, regi
amended = applyChanges(amended, [next]); amended = applyChanges(amended, [next]);
changes.push(next); changes.push(next);
} }
// 整项完成后再推进旧时间线;只有变化内容确实不同才替换 entries 中这一项,operationIds/标签保持。
original = end; original = end;
if (!equal(changes, source.changes)) entries.set(id, freeze({ ...source, changes })); if (!equal(changes, source.changes)) entries.set(id, freeze({ ...source, changes }));
} }
@@ -112,10 +119,12 @@ export function planCreationSize(document: Document, history: HistoryState, regi
]); ]);
const nextDocument = applyChanges(document, changes); const nextDocument = applyChanges(document, changes);
validateDocument(nextDocument, registry); validateDocument(nextDocument, registry);
// 最后用组件自己的 resize handler 对照约束,自动尺寸不能绕过组件 min/max/aspect 政策。 // 调用已安装 resize handler,把其生成变化应用后的目标记录与直接候选比较;不一致就拒绝。
// 这能执行组件实际实现的限制,不凭 capability 名称宣称所有组件都有 min/max/aspect 政策。
const handler = registry.handlers.get(current!.componentId)?.get('geometry.resize'); const handler = registry.handlers.get(current!.componentId)?.get('geometry.resize');
const proposed = handler!({ element: current!, input: freeze({ patch: { width: after.width, height: after.height } }) }); const proposed = handler!({ element: current!, input: freeze({ patch: { width: after.width, height: after.height } }) });
for (const change of proposed) if (change.target.id !== target.id || change.target.incarnation !== target.incarnation || change.field !== 'geometry') fail('invalid_handler_change'); for (const change of proposed) if (change.target.id !== target.id || change.target.incarnation !== target.incarnation || change.field !== 'geometry') fail('invalid_handler_change');
if (!equal(applyChanges(document, proposed).elements.get(target.id), nextDocument.elements.get(target.id))) fail('creation_size_constraint'); if (!equal(applyChanges(document, proposed).elements.get(target.id), nextDocument.elements.get(target.id))) fail('creation_size_constraint');
// 仅返回当前几何 changes,历史 entries 是整条修正时间线;两栈顺序沿用,不把上方 redo 检查变成实际重做。
return Object.freeze({ document: nextDocument, changes, history: Object.freeze({ undo: history.undo, redo: history.redo, entries: readMap(entries) }) }); return Object.freeze({ document: nextDocument, changes, history: Object.freeze({ undo: history.undo, redo: history.redo, entries: readMap(entries) }) });
} }
+9 -2
View File
@@ -1,6 +1,7 @@
/** /**
* 有限预算的三次曲线命中精判。先用控制点包围盒排除,再用弦线距离与平坦度误差判断; * 有限预算的三次曲线命中精判。先用控制点包围盒排除,再用弦线距离与平坦度误差判断;
* 不能确定时用 de Casteljau 二分。预算/数值精度不足明确返回 possible,不能伪称已证明 miss。 * 不能确定时用 de Casteljau 二分。预算/数值精度不足明确保留可能命中,不能伪称已证明 miss。
* 上层须提供合法曲线/点、非负半径和有效预算,本函数没有独立参数校验或 registry 检查。
*/ */
import type {Point} from './types.ts'; import type {Point} from './types.ts';
import type {Cubic} from './geometry.ts'; import type {Cubic} from './geometry.ts';
@@ -16,12 +17,18 @@ function split([a,b,c,d]:Cubic):[Cubic,Cubic]{const ab=mid(a,b),bc=mid(b,c),cd=m
* maxSteps 限总工作,maxDepth 限细分深度,error 控制可接受平坦度;stack 用显式 DFS。 * maxSteps 限总工作,maxDepth 限细分深度,error 控制可接受平坦度;stack 用显式 DFS。
* 每轮:包围盒排除→near±flat 判定→足够平/深度已满则记 possible→否则入栈左右子曲线。 * 每轮:包围盒排除→near±flat 判定→足够平/深度已满则记 possible→否则入栈左右子曲线。
* near 是到端点弦线段距离,flat 包括两控制点到该弦的偏差及 roundoff。 * near 是到端点弦线段距离,flat 包括两控制点到该弦的偏差及 roundoff。
* 剩余 stack 非空表示总预算耗尽,仍可能有未检查的命中区域。 */ * 剩余 stack 非空表示总预算耗尽,仍可能有未检查的命中区域。
* certainty 只在 hit=true 时区分 confirmed/possible;hit=false 的正常返回也固定带 possible,不是“可能没命中”的三态枚举。 */
export function hitCubic(curve:Cubic,point:Point,radius:number,options:{error:number;maxDepth:number;maxSteps:number}){ export function hitCubic(curve:Cubic,point:Point,radius:number,options:{error:number;maxDepth:number;maxSteps:number}){
// 栈中保存尚未证明可排除的子曲线,steps 按实际取出的子曲线计数;possible 记住已停止细分的模糊区域。
const stack:{curve:Cubic;depth:number}[]=[{curve,depth:0}];let steps=0,possible=false,estimate=Infinity,error=0; const stack:{curve:Cubic;depth:number}[]=[{curve,depth:0}];let steps=0,possible=false,estimate=Infinity,error=0;
while(stack.length&&steps<options.maxSteps){const item=stack.pop()!;steps++;const xs=item.curve.map(p=>p.x),ys=item.curve.map(p=>p.y),dx=Math.max(0,Math.min(...xs)-point.x,point.x-Math.max(...xs)),dy=Math.max(0,Math.min(...ys)-point.y,point.y-Math.max(...ys));if(Math.hypot(dx,dy)>radius)continue; while(stack.length&&steps<options.maxSteps){const item=stack.pop()!;steps++;const xs=item.curve.map(p=>p.x),ys=item.curve.map(p=>p.y),dx=Math.max(0,Math.min(...xs)-point.x,point.x-Math.max(...xs)),dy=Math.max(0,Math.min(...ys)-point.y,point.y-Math.max(...ys));if(Math.hypot(dx,dy)>radius)continue;
// 弦距离 near 与 flat 给出保守区间:near-flat 超半径可排除,near+flat 在半径内可确认。
// 数值非有限时直接返回 possible 且 budget=true;该标志此时不只是“栈还有工作”的含义。
const[a,b,c,d]=item.curve,roundoff=Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),...xs.map(Math.abs),...ys.map(Math.abs)),flat=Math.max(distance(b,a,d),distance(c,a,d))+roundoff,near=distance(point,a,d);if(!Number.isFinite(flat)||!Number.isFinite(near))return{hit:true,certainty:'possible' as const,steps,distance:Infinity,error:Infinity,budget:true};if(near-flat>radius)continue;if(near+flat<=radius)return{hit:true,certainty:'confirmed' as const,steps,distance:near,error:flat,budget:false}; const[a,b,c,d]=item.curve,roundoff=Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),...xs.map(Math.abs),...ys.map(Math.abs)),flat=Math.max(distance(b,a,d),distance(c,a,d))+roundoff,near=distance(point,a,d);if(!Number.isFinite(flat)||!Number.isFinite(near))return{hit:true,certainty:'possible' as const,steps,distance:Infinity,error:Infinity,budget:true};if(near-flat>radius)continue;if(near+flat<=radius)return{hit:true,certainty:'confirmed' as const,steps,distance:near,error:flat,budget:false};
// 达到平坦误差或深度阈值只保留 possible,budget 可仍为 false;继续二分时先压右再压左,按左优先 DFS。
if(flat<=options.error||item.depth>=options.maxDepth){possible=true;if(near<estimate){estimate=near;error=flat;}continue;}const[left,right]=split(item.curve);stack.push({curve:right,depth:item.depth+1},{curve:left,depth:item.depth+1}); if(flat<=options.error||item.depth>=options.maxDepth){possible=true;if(near<estimate){estimate=near;error=flat;}continue;}const[left,right]=split(item.curve);stack.push({curve:right,depth:item.depth+1},{curve:left,depth:item.depth+1});
} }
// estimate/error 来自已记住的最小 near 模糊区,不是所有曲线的精确最近距离;全被排除时 estimate 仍是 Infinity。
return{hit:possible||stack.length>0,certainty:'possible' as const,steps,distance:estimate,error,budget:stack.length>0}; return{hit:possible||stack.length>0,certainty:'possible' as const,steps,distance:estimate,error,budget:stack.length>0};
} }
+3 -1
View File
@@ -4,12 +4,14 @@
*/ */
// Private shape provenance: values may still be mutable and require capture. // Private shape provenance: values may still be mutable and require capture.
// Always copy; external Object.freeze is never accepted as ownership evidence. // Always copy; external Object.freeze is never accepted as ownership evidence.
// WeakSet 以新容器身份为键,不按内容相等判断;容器不再被外部引用时,标记不会单独阻止回收。
const owned = new WeakSet<object>(); const owned = new WeakSet<object>();
/** 消费 iterable 得到新数组并浅冻结;不会冻结数组中的业务值。 */ /** 消费 iterable 得到新数组并浅冻结;不会冻结数组中的业务值。 */
export function dataArray<T>(values: Iterable<T>): readonly T[] { export function dataArray<T>(values: Iterable<T>): readonly T[] {
const result = Object.freeze(Array.from(values)); owned.add(result); return result; const result = Object.freeze(Array.from(values)); owned.add(result); return result;
} }
/** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。 */ /** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。
* 对象展开会读取 getter 并保留嵌套值引用,也不保留原对象原型或属性描述符。 */
export function dataRecord<T extends object>(value: T): Readonly<T> { export function dataRecord<T extends object>(value: T): Readonly<T> {
const result = Object.freeze({...value}); owned.add(result); return result; const result = Object.freeze({...value}); owned.add(result); return result;
} }
+8 -4
View File
@@ -12,7 +12,8 @@ import {repairConnectionOwners} from './structure.ts';
import {clone,equal,fault,freeze} from './values.ts'; import {clone,equal,fault,freeze} from './values.ts';
/** Remove an owning canvas, retaining its entry and promoting only direct members. */ /** Remove an owning canvas, retaining its entry and promoting only direct members. */
/** 验证 canvas/入口生命周期,规划成员和引用转换;根不能解散,任何不支持路径整体拒绝。 */ /** 验证 canvas/入口生命周期并规划成员/引用转换;根不能解散,必需转换遇到不支持能力或格式时抛错。
* 源应是有效不可变文档;本函数不执行最终 validateDocument,也不调用宿主 policy 或直接发布。 */
export function planDissolveCanvas(document:Document,registry:Registry,canvasId:Id,entryTarget:Target):{readonly changes:readonly Change[];readonly commands:readonly Command[]}{ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:Id,entryTarget:Target):{readonly changes:readonly Change[];readonly commands:readonly Command[]}{
const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]); const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]);
if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]); if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]);
@@ -23,7 +24,7 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
for(const element of document.elements.values())if(element.ownerCanvasId===canvasId&&element.structure==='node'){ for(const element of document.elements.values())if(element.ownerCanvasId===canvasId&&element.structure==='node'){
if(!registry.supports(element,'geometry.move'))throw fault('unsupported_capability',[element.id,'geometry.move']); if(!registry.supports(element,'geometry.move'))throw fault('unsupported_capability',[element.id,'geometry.move']);
const target={id:element.id,incarnation:element.incarnation},geometry=convertGeometry(element.geometry,from,to),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*from.scale/to.scale); const target={id:element.id,incarnation:element.incarnation},geometry=convertGeometry(element.geometry,from,to),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*from.scale/to.scale);
// 即使最终用 changes 原子提交,也要向 editor 暴露逐成员 policy 检查,防止入口权限越权。 // commands 仅给 editor 逐成员做 policy 检查,不在此执行 element.move handler;几何转换直接由上面的坐标数学生成。
commands.push({type:'element.move',target,canvasId:parent}); commands.push({type:'element.move',target,canvasId:parent});
changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent}); changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent});
if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry}); if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry});
@@ -35,7 +36,8 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
// 在私有候选文档上计算边的新公共上级,并把曲线从旧 owner 空间一并转换。 // 在私有候选文档上计算边的新公共上级,并把曲线从旧 owner 空间一并转换。
const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted)); const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted));
// Only declared canvas references have meaning here. Opaque payloads are retained. // Only declared canvas references have meaning here. Opaque payloads are retained.
// 只解释已知组件明确声明的 canvas 引用;opaque payload 和普通字面 ID 文本保留。 // 扫描整个 promoted 文档中已知组件声明的 canvas 引用,因此子树外的引用也可能需要修正。
// opaque payload 与未声明的普通字面 ID 保留;引用路径复用 copy schema,但这里不执行复制的 external 策略。
for(const element of promoted.elements.values()){ for(const element of promoted.elements.values()){
if(registry.resolve(element)!=='known')continue; if(registry.resolve(element)!=='known')continue;
const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[]; const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[];
@@ -48,12 +50,14 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
const map=(value:Json):Json=>{if(typeof value!=='string'||!value)throw fault('invalid_copy_reference',[element.id]);return value===canvasId?parent:value;}; const map=(value:Json):Json=>{if(typeof value!=='string'||!value)throw fault('invalid_copy_reference',[element.id]);return value===canvasId?parent:value;};
slot.parent[slot.key]=reference.many?(value as Json[]).map(map):map(value); slot.parent[slot.key]=reference.many?(value as Json[]).map(map):map(value);
} }
// 仅产生实际改变的顶层 data 字段,并要求 content.edit 能力/宿主 policy 通过。 // 仅产生实际改变的顶层 data 字段;此处检查 content.edit 已安装,宿主 policy 由返回 commands 交给 editor 重查。
// 不调用 content.edit handler 生成变化,最终 schema/关系检查仍由事务发布路径执行。
for(const key of Object.keys(data))if(!equal(data[key],element.data[key])){ for(const key of Object.keys(data))if(!equal(data[key],element.data[key])){
if(!registry.supports(element,'content.edit'))throw fault('unsupported_capability',[element.id,'content.edit']); if(!registry.supports(element,'content.edit'))throw fault('unsupported_capability',[element.id,'content.edit']);
changes.push({kind:'field',target:{id:element.id,incarnation:element.incarnation},field:`data.${key}`,before:element.data[key],after:data[key]}); changes.push({kind:'field',target:{id:element.id,incarnation:element.incarnation},field:`data.${key}`,before:element.data[key],after:data[key]});
commands.push({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{[key]:data[key]}}); commands.push({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{[key]:data[key]}});
} }
} }
// 保留原入口元素与边 ID;只移除 canvas 记录,不用 removalChanges 的子树删除语义。
return freeze({changes,commands}); return freeze({changes,commands});
} }
+20 -7
View File
@@ -16,7 +16,8 @@ function documentEnvelope(document:Document):Document{const value=Object.freeze(
export const isFactoryDocument=(document:Document)=>factoryDocuments.has(document); export const isFactoryDocument=(document:Document)=>factoryDocuments.has(document);
/** 捕获输入记录、拒绝同一表内重复 ID,并脱离调用者的可变数据。 /** 捕获输入记录、拒绝同一表内重复 ID,并脱离调用者的可变数据。
* 不在此处验证整棵树或所有跨记录引用;完整发布前仍需 validateDocument。 */ * 不在此处验证整棵树、组件 schema 或所有跨记录引用;完整发布前仍需 validateDocument。
* canvas 总是复制冻结,element 可复用元素捕获模块已持有的记录;墓碑复制表成员,元素固有证明只省去重复形状遍历。 */
export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap<Id, number> = new Map()): Document { export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap<Id, number> = new Map()): Document {
for (const element of elements) { const id = element.id; if (!staticElementProofs.has(element)&&!validJson(element)) throw fault('invalid_element', [id]); } for (const element of elements) { const id = element.id; if (!staticElementProofs.has(element)&&!validJson(element)) throw fault('invalid_element', [id]); }
for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas'); for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas');
@@ -61,14 +62,19 @@ const staticElementProofs=new WeakSet<object>();
/** 校验单条记录的基础形状、墓碑代次和已知组件数据。 /** 校验单条记录的基础形状、墓碑代次和已知组件数据。
* staticElementProofs 仅缓存已捕获记录的固有形状;墓碑和 registry 相关检查每次仍执行。 */ * staticElementProofs 仅缓存已捕获记录的固有形状;墓碑和 registry 相关检查每次仍执行。 */
export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap<Id,number> = new Map(), componentData = true): void { export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap<Id,number> = new Map(), componentData = true): void {
// 静态证明仅随不可变记录身份复用,不能因浅冻结或一个文档 envelope 标记就跳过校验。
const proven=!!element&&staticElementProofs.has(element); const proven=!!element&&staticElementProofs.has(element);
if (!proven&&(!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data))) throw fault('invalid_element', [element?.id]); if (!proven&&(!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data))) throw fault('invalid_element', [element?.id]);
// 墓碑属于当前文档上下文,即使同一记录以前通过过形状校验,也要重新核对已死亡代次。
const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]); const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]);
// 只有精确支持的组件版本进入 schema 检查;未知组件/不支持版本仍须通过通用形状约束,不在这里解释其 payload。
const definition=registry.components.get(element.componentId); const definition=registry.components.get(element.componentId);
if(componentData&&registry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!componentDataValid(element,definition)))throw fault('invalid_component_data',[element.id]); if(componentData&&registry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!componentDataValid(element,definition)))throw fault('invalid_component_data',[element.id]);
// 命中固有证明只能略过下方几何/端点形状遍历,不能略过上方墓碑与本次启用的 schema 检查。
if(proven)return; if(proven)return;
if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);} if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);}
else if(!element.source||!element.target||typeof element.source.elementId!=='string'||!element.source.elementId||typeof element.target.elementId!=='string'||!element.target.elementId||[element.source,element.target].some(endpoint=>endpoint.portId!==undefined&&(typeof endpoint.portId!=='string'||!endpoint.portId))||!validJson(element.path))throw fault('invalid_endpoint',[element.id]); else if(!element.source||!element.target||typeof element.source.elementId!=='string'||!element.source.elementId||typeof element.target.elementId!=='string'||!element.target.elementId||[element.source,element.target].some(endpoint=>endpoint.portId!==undefined&&(typeof endpoint.portId!=='string'||!endpoint.portId))||!validJson(element.path))throw fault('invalid_endpoint',[element.id]);
// 拓扑规划可 componentData=false;此时仍可记固有证明,但以后启用 schema 的调用依然会重查 data。
if(hasOwnedElementData(element))staticElementProofs.add(element); if(hasOwnedElementData(element))staticElementProofs.add(element);
} }
@@ -101,6 +107,7 @@ function validateElementInDocument(document:Document,element:ElementRecord,regis
function* validateDocumentChecks(document:Document,registry:Registry,componentData:boolean):Generator<void,void>{ function* validateDocumentChecks(document:Document,registry:Registry,componentData:boolean):Generator<void,void>{
if(!document.canvases.size)throw fault('missing_root'); if(!document.canvases.size)throw fault('missing_root');
for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);} for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);}
// 一次遍历累计入口占用和根数;必须继续到最后,才能得到根数及所有元素的完整结论。
const entries=new Set<Id>();let roots=0; const entries=new Set<Id>();let roots=0;
for(const canvas of document.canvases.values()){yield;roots+=validateCanvasInDocument(document,canvas,entries);} for(const canvas of document.canvases.values()){yield;roots+=validateCanvasInDocument(document,canvas,entries);}
if(roots!==1)throw fault('invalid_roots'); if(roots!==1)throw fault('invalid_roots');
@@ -111,7 +118,8 @@ function* validateDocumentChecks(document:Document,registry:Registry,componentDa
export function* validateDocumentTopologySteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,false);} export function* validateDocumentTopologySteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,false);}
/** 含组件 payload 的完整分片校验;必须迭代到结束才证明整份文档通过。 */ /** 含组件 payload 的完整分片校验;必须迭代到结束才证明整份文档通过。 */
export function* validateDocumentSteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,true);} export function* validateDocumentSteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,true);}
/** 同步完整校验,要求恰好一个根,所有拥有关系、端点和组件数据均合法。 */ /** 同步走完整文档检查:要求一个根,检查拥有关系、入口、节点端点及连线共同 owner。
* 已支持的精确组件版本检查 data schema;未知/不支持版本按通用记录规则保留,不冒称已理解其 payload。 */
export function validateDocument(document:Document,registry:Registry):void{ export function validateDocument(document:Document,registry:Registry):void{
if(!document.canvases.size)throw fault('missing_root'); if(!document.canvases.size)throw fault('missing_root');
for(const[id,incarnation]of document.tombstones)validateTombstone(id,incarnation); for(const[id,incarnation]of document.tombstones)validateTombstone(id,incarnation);
@@ -120,21 +128,24 @@ export function validateDocument(document:Document,registry:Registry):void{
if(roots!==1)throw fault('invalid_roots'); if(roots!==1)throw fault('invalid_roots');
for(const element of document.elements.values())validateElementInDocument(document,element,registry,true); for(const element of document.elements.values())validateElementInDocument(document,element,registry,true);
} }
/** 一批有序修改及其允许的 canvas 上下文;回放不能跨越已冻结的操作范围。 */ /** 一批有序修改及可选 canvas 上下文;当前执行器用它检查 field 目标的 owner。
* 这不是覆盖所有 change 种类的授权器;整元素/树修改还需调用方规划、权限及发布前校验。 */
export type ReplayChangeBatch = {readonly changes:readonly Change[];readonly canvasIds?:readonly Id[]}; export type ReplayChangeBatch = {readonly changes:readonly Change[];readonly canvasIds?:readonly Id[]};
/** 在私有表副本中应用一批变化;默认校验 before 条件,失败不会修改输入文档。 */ /** 在已捕获的不可变文档上,以私有表副本应用变化;默认校验 before 条件。
* 未改记录沿用原引用,不是任意外部 Document 的深隔离入口;修改记录的 freeze 也可能触及共享嵌套值。 */
export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document { export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document {
return applyChangeBatches(document,[{changes}],conditional,false); return applyChangeBatches(document,[{changes}],conditional,false);
} }
/** Ordered conditional replay against one private draft; no intermediate document escapes. */ /** Ordered conditional replay against one private draft; no intermediate document escapes. */
/** 顺序回放多批变化,已经满足的 after 可跳过,但仍检查目标的 scope。 */ /** 顺序回放多批变化,已经满足的 after 可跳过;field 变化在跳过前仍检查当前 owner 范围。
* 批次未提供 canvasIds 或提供空数组时,这一局部 owner 范围检查不启用。 */
export function replayChanges(document:Document,batches:Iterable<ReplayChangeBatch>):Document { export function replayChanges(document:Document,batches:Iterable<ReplayChangeBatch>):Document {
return applyChangeBatches(document,batches,true,true); return applyChangeBatches(document,batches,true,true);
} }
/** 三类变化共用的执行器:field 改字段、element 增删元素、canvas 改树记录。 /** 三类变化共用的执行器:field 改字段、element 增删元素、canvas 改树记录。
* 仅在全部循环成功后返回冻结 envelope,不泄漏半成品;此处不做整图合法性校验。 */ * 仅在全部循环成功后返回冻结 envelope,不泄漏半成品;此处不做整图合法性校验。 */
function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch>,conditional:boolean,replay:boolean):Document { function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch>,conditional:boolean,replay:boolean):Document {
// 复制表成员而复用未改记录;出错时丢弃这些私有 Map,输入快照保持不变。 // 复制表成员而复用未改记录;失败不返回私有 Map。对有效不可变输入,不会发布或改写原快照。
const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones); const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones);
for (const batch of batches) for (const change of batch.changes) { for (const batch of batches) for (const change of batch.changes) {
if(replay){ if(replay){
@@ -158,7 +169,8 @@ function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch
if (change.afterPresent === false) delete data[dataKey]; else data[dataKey] = clone(change.after); if (change.afterPresent === false) delete data[dataKey]; else data[dataKey] = clone(change.after);
elements.set(element.id, freeze({ ...element, data })); elements.set(element.id, freeze({ ...element, data }));
} else elements.set(element.id, freeze({ ...element, [change.field]: clone(change.after) }) as ElementRecord); } else elements.set(element.id, freeze({ ...element, [change.field]: clone(change.after) }) as ElementRecord);
// 整元素修改必须匹配 before;删除同时保留墓碑,使旧 incarnation 永远不能复活。 // 整元素修改在 conditional=true 时匹配 before;删除只记录已死亡的最大代次。
// 此执行器不直接以墓碑拒绝后续插入,发布前 validateElementRecord/validateDocument 才拦截旧代次。
} else if (change.kind === 'element') { } else if (change.kind === 'element') {
if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]); if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]);
if (change.after) { if (change.after) {
@@ -184,6 +196,7 @@ function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch
} }
/** 检测回放目标效果是否已经存在;字段目标必须仍是同一个 incarnation 才可视为满足。 */ /** 检测回放目标效果是否已经存在;字段目标必须仍是同一个 incarnation 才可视为满足。 */
function changeSatisfied(elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>,change:Change):boolean { function changeSatisfied(elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>,change:Change):boolean {
// canvas/element 使用整记录 after 比较;删除后的缺失等同于 after:null,重复删除可跳过。
if(change.kind==='canvas')return equal(canvases.get(change.id)??null,change.after); if(change.kind==='canvas')return equal(canvases.get(change.id)??null,change.after);
if(change.kind==='element')return equal(elements.get(change.id)??null,change.after); if(change.kind==='element')return equal(elements.get(change.id)??null,change.after);
const element=elements.get(change.target.id); const element=elements.get(change.target.id);
+13 -6
View File
@@ -2,6 +2,7 @@
* 可暂停/恢复的深复制准备状态机。检查点保存 ID、阶段、游标、DFS stack 和稳定映射, * 可暂停/恢复的深复制准备状态机。检查点保存 ID、阶段、游标、DFS stack 和稳定映射,
* 不保存元素 payload 的第二份副本;输出记录是由源与映射确定性重建的缓存。 * 不保存元素 payload 的第二份副本;输出记录是由源与映射确定性重建的缓存。
* 每次 step 只推进有限语义工作,但持久源校验、分片调度、checkpoint 保存和渐进加入由 runtime 负责。 * 每次 step 只推进有限语义工作,但持久源校验、分片调度、checkpoint 保存和渐进加入由 runtime 负责。
* 构造器仍同步展开源表并校验保存状态,step 也没有按字节/毫秒强制切断单记录转换。
*/ */
import {detachJsonData} from './json-data.ts'; import {detachJsonData} from './json-data.ts';
import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts'; import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts';
@@ -22,7 +23,8 @@ type Immutable<T>=T extends object?{readonly [K in keyof T]:Immutable<T[K]>}:T;
export type DuplicateCheckpoint=Immutable<MutableCheckpoint>; export type DuplicateCheckpoint=Immutable<MutableCheckpoint>;
/** 形状辅助检查:数组成员必须是非空字符串;是否存在/重复由后续检查负责。 */ /** 形状辅助检查:数组成员必须是非空字符串;是否存在/重复由后续检查负责。 */
const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id); const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id);
/** 校验外部检查点格式、阶段/计数/去重与前缀边界;与实际源的一致性在工厂中进一步核验。 */ /** 检查 DTO 中明确列出的格式、阶段/计数、去重与前缀边界;不是按源重新推导整个已保存执行轨迹。
* 与实际源的部分成员/映射一致性在工厂核验;可信持久记录与 token 来源由 runtime 边界保证。 */
export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{ export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{
if(!value||!validJson(value)||value.version!==1)throw fault('copy_checkpoint_interpreter_unavailable'); if(!value||!validJson(value)||value.version!==1)throw fault('copy_checkpoint_interpreter_unavailable');
if(![value.projectId,value.sourceToken,value.schemaToken].every(id=>typeof id==='string'&&!!id)||!phases.includes(value.phase)||!Number.isSafeInteger(value.cursor)||value.cursor<(value.phase==='seed'?-1:0)||![value.steps,value.emitted,value.emittedEdges].every(n=>Number.isSafeInteger(n)&&n>=0)||!Array.isArray(value.selection)||!value.selection.length||value.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!value.offset||![value.offset.x,value.offset.y].every(Number.isFinite))throw fault('invalid_copy_checkpoint'); if(![value.projectId,value.sourceToken,value.schemaToken].every(id=>typeof id==='string'&&!!id)||!phases.includes(value.phase)||!Number.isSafeInteger(value.cursor)||value.cursor<(value.phase==='seed'?-1:0)||![value.steps,value.emitted,value.emittedEdges].every(n=>Number.isSafeInteger(n)&&n>=0)||!Array.isArray(value.selection)||!value.selection.length||value.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!value.offset||![value.offset.x,value.offset.y].every(Number.isFinite))throw fault('invalid_copy_checkpoint');
@@ -33,7 +35,8 @@ export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{
if(!Array.isArray(value.members)||value.members.some(pair=>!Array.isArray(pair)||pair.length!==2||typeof pair[0]!=='string'||!pair[0]||!ids(pair[1]))||new Set(value.members.map(pair=>pair[0])).size!==value.members.length||value.emitted>value.order.length||value.emittedEdges>value.edges.length||value.emittedEdges>0&&value.emitted!==value.order.length||value.expanding&&(typeof value.expanding.canvas!=='string'||!value.expanding.canvas||!Number.isSafeInteger(value.expanding.index)||value.expanding.index< -1))throw fault('invalid_copy_checkpoint'); if(!Array.isArray(value.members)||value.members.some(pair=>!Array.isArray(pair)||pair.length!==2||typeof pair[0]!=='string'||!pair[0]||!ids(pair[1]))||new Set(value.members.map(pair=>pair[0])).size!==value.members.length||value.emitted>value.order.length||value.emittedEdges>value.edges.length||value.emittedEdges>0&&value.emitted!==value.order.length||value.expanding&&(typeof value.expanding.canvas!=='string'||!value.expanding.canvas||!Number.isSafeInteger(value.expanding.index)||value.expanding.index< -1))throw fault('invalid_copy_checkpoint');
} }
/** 创建或恢复解释器;绑定 project/sourceToken/schemaToken/选区/偏移/预留映射,任一不符拒绝续跑。 /** 创建或恢复解释器;绑定 project/sourceToken/schemaToken/选区/偏移/预留映射,任一不符拒绝续跑。
* token 的真实源摘要/定义签名由调用方提供并验证,这里不读取 Blob 或计算 SHA。 */ * token 的真实源摘要/定义签名由调用方提供并验证,这里只比较字符串,不读取 Blob 或计算 SHA。
* 绑定匹配不等于任意外来检查点已获防篡改证明;范围/阶段状态应来自可信恢复记录。 */
export function createDuplicatePlanner(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean}){ export function createDuplicatePlanner(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean}){
const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined; const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined;
// 从检查点复制出私有可变 state,旧 checkpoint 保持不变;新任务从 selection 开始。 // 从检查点复制出私有可变 state,旧 checkpoint 保持不变;新任务从 selection 开始。
@@ -56,7 +59,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
const supplied=suppliedMapping?[...Object.keys(suppliedMapping.elements).map(id=>({id,kind:'element' as const})),...Object.keys(suppliedMapping.canvases).map(id=>({id,kind:'canvas' as const}))]:[]; const supplied=suppliedMapping?[...Object.keys(suppliedMapping.elements).map(id=>({id,kind:'element' as const})),...Object.keys(suppliedMapping.canvases).map(id=>({id,kind:'canvas' as const}))]:[];
// 这些 Map 是派生输出缓存,overlay 内部可读其增长,不作为第二份发布文档。 // 这些 Map 是派生输出缓存,overlay 内部可读其增长,不作为第二份发布文档。
const outputElements=new Map<Id,ElementRecord>(),outputCanvases=new Map<Id,CanvasRecord>(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[]; const outputElements=new Map<Id,ElementRecord>(),outputCanvases=new Map<Id,CanvasRecord>(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[];
// 源校验每次重开重新执行;topologyOnly 仅用于明确的结构源,最终输出仍执行完整数据校验。 // 源校验每次重开重新执行;topologyOnly 跳过源的组件 payload schema。
// 本解释器继续到 result 时校验源+副本完整输出;若 runtime 在 ready 接管逐记录生成,则由那条消费路径完成后续校验。
let treeCache=0,edgeCache=0,sourceChecks:Generator<void>|undefined=(binding.topologyOnly?validateDocumentTopologySteps:validateDocumentSteps)(document,registry),outputChecks:Generator<void>|undefined,validated=false; let treeCache=0,edgeCache=0,sourceChecks:Generator<void>|undefined=(binding.topologyOnly?validateDocumentTopologySteps:validateDocumentSteps)(document,registry),outputChecks:Generator<void>|undefined,validated=false;
/** 切换解释阶段并重置/指定该阶段 cursor,不改稳定 ID 或输出前缀。 */ /** 切换解释阶段并重置/指定该阶段 cursor,不改稳定 ID 或输出前缀。 */
const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;}; const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;};
@@ -69,7 +73,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
/** 只为尚未分配的源项取新 ID;优先宿主预留值,used 同时覆盖源、墓碑与全部新值。 */ /** 只为尚未分配的源项取新 ID;优先宿主预留值,used 同时覆盖源、墓碑与全部新值。 */
function allocate(id:Id,kind:'elements'|'canvases'){const map=suppliedMapping?.[kind],value=map&&Object.hasOwn(map,id)?map[id]:idFactory();if(typeof value!=='string'||!value||used.has(value))throw fault('duplicate_identity',[value]);used.add(value);return value;} function allocate(id:Id,kind:'elements'|'canvases'){const map=suppliedMapping?.[kind],value=map&&Object.hasOwn(map,id)?map[id]:idFactory();if(typeof value!=='string'||!value||used.has(value))throw fault('duplicate_identity',[value]);used.add(value);return value;}
return Object.freeze({ return Object.freeze({
/** 源已校验且刚到 emit,范围/映射固定,可以交给运行时逐记录转换,不要求完整计划已生成。 */ /** 源检查迭代已结束、阶段刚到 emit 且 emitted=0 时可接管范围/映射;不是完整计划已生成或已加入画布。
* topologyOnly 下“源检查结束”仍只证明拓扑路径通过,不证明所有组件 payload schema。 */
ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;}, ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;},
/** 返回阶段、语义 steps 和已分配数;不冒称解析/加入/网络已完成。 */ /** 返回阶段、语义 steps 和已分配数;不冒称解析/加入/网络已完成。 */
progress(){return Object.freeze({phase:state.phase,steps:state.steps,allocated:state.elementIds.length+state.canvasIds.length});}, progress(){return Object.freeze({phase:state.phase,steps:state.steps,allocated:state.elementIds.length+state.canvasIds.length});},
@@ -98,7 +103,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
if(task.kind==='canvas'){if(canvases.has(task.id))break;canvases.add(task.id);state.canvases.push(task.id);state.order.push(task);state.expanding={canvas:task.id,index:(members.get(task.id)?.length??0)-1};} if(task.kind==='canvas'){if(canvases.has(task.id))break;canvases.add(task.id);state.canvases.push(task.id);state.order.push(task);state.expanding={canvas:task.id,index:(members.get(task.id)?.length??0)-1};}
else{if(copied.has(task.id))break;copied.add(task.id);state.copied.push(task.id);state.order.push(task);const child=children.get(task.id);if(child)state.work.push({kind:'canvas',id:child});}break; else{if(copied.has(task.id))break;copied.add(task.id);state.copied.push(task.id);state.order.push(task);const child=children.get(task.id);if(child)state.work.push({kind:'canvas',id:child});}break;
} }
// 扫描上级入边与范围内边;随后 element/canvas 分配都把新 ID 写回 state,恢复可重用。 // 与同步规划器相同:显式选边、复制 canvas 的边及指向复制节点的入边;只有源端被复制不足以自动带上出边。
// 随后 element/canvas 分配都把新 ID 写回 state,恢复重用已有分配,不为同一前缀再造身份。
case 'edges':if(state.cursor<sourceElements.length){const record=sourceElements[state.cursor++];if(record.structure==='connection'&&(selected.has(record.id)||canvases.has(record.ownerCanvasId)||copied.has(record.target.elementId))){state.edges.push(record.id);copied.add(record.id);state.copied.push(record.id);}}else phase('allocate-elements');break; case 'edges':if(state.cursor<sourceElements.length){const record=sourceElements[state.cursor++];if(record.structure==='connection'&&(selected.has(record.id)||canvases.has(record.ownerCanvasId)||copied.has(record.target.elementId))){state.edges.push(record.id);copied.add(record.id);state.copied.push(record.id);}}else phase('allocate-elements');break;
case 'allocate-elements':if(state.cursor<state.copied.length){const id=state.copied[state.cursor++],value=allocate(id,'elements');elementIds.set(id,value);state.elementIds.push([id,value]);}else phase('allocate-canvases');break; case 'allocate-elements':if(state.cursor<state.copied.length){const id=state.copied[state.cursor++],value=allocate(id,'elements');elementIds.set(id,value);state.elementIds.push([id,value]);}else phase('allocate-canvases');break;
case 'allocate-canvases':if(state.cursor<state.canvases.length){const id=state.canvases[state.cursor++],value=allocate(id,'canvases');canvasIds.set(id,value);state.canvasIds.push([id,value]);}else phase('mapping');break; case 'allocate-canvases':if(state.cursor<state.canvases.length){const id=state.canvases[state.cursor++],value=allocate(id,'canvases');canvasIds.set(id,value);state.canvasIds.push([id,value]);}else phase('mapping');break;
@@ -110,7 +116,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
// 每次状态机工作/阶段切换单调计数;step 的 wall time 和 yield 时机由宿主软预算决定。 // 每次状态机工作/阶段切换单调计数;step 的 wall time 和 yield 时机由宿主软预算决定。
state.steps++;return false; state.steps++;return false;
}, },
/** 仅 done 且本轮最终校验成功后返回完整冻结计划;不能把未完成缓存当成可提交计划。 */ /** 仅 done 且本轮最终校验成功后返回完整冻结计划;不能把未完成缓存当成可提交计划。
* 返回的是准备 changes,不是 editor 提交/持久化/服务端确认结果;调用 result 也不会发网络请求。 */
result():DuplicatePlan{if(!validated||state.phase!=='done')throw fault('copy_plan_not_ready');return freeze({version:1,roots:state.roots.map(id=>elementIds.get(id)!),elementIds:Object.fromEntries(elementIds),canvasIds:Object.fromEntries(canvasIds),changes:[...changes]});}, result():DuplicatePlan{if(!validated||state.phase!=='done')throw fault('copy_plan_not_ready');return freeze({version:1,roots:state.roots.map(id=>elementIds.get(id)!),elementIds:Object.fromEntries(elementIds),canvasIds:Object.fromEntries(canvasIds),changes:[...changes]});},
}); });
} }
+15 -5
View File
@@ -2,6 +2,7 @@
* 完整快照上的深复制纯规划:先确定范围和稳定 ID 映射,再生成节点/canvas,最后生成边。 * 完整快照上的深复制纯规划:先确定范围和稳定 ID 映射,再生成节点/canvas,最后生成边。
* 同时选分区入口和其内部节点时只复制一次;原上级 C→F 保留,新增边可连接原 C 与新 F′。 * 同时选分区入口和其内部节点时只复制一次;原上级 C→F 保留,新增边可连接原 C 与新 F′。
* 这里只返回计划,不改 editor、不上传图片、不启动生成;runtime 负责调度、持久源和逐批加入。 * 这里只返回计划,不改 editor、不上传图片、不启动生成;runtime 负责调度、持久源和逐批加入。
* 源须在规划期间保持不可变;资源 ID 沿 data/copy 声明处理,不在这里复制文件字节或创建资源绑定。
*/ */
import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts'; import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts';
import { copySlots } from './copy-paths.ts'; import { copySlots } from './copy-paths.ts';
@@ -20,6 +21,7 @@ export type DuplicateOptions = { readonly createId: () => Id; readonly worldOffs
export function remapCopyData(element: ElementRecord, registry: Registry, elements: ReadonlyMap<Id, Id>, canvases: ReadonlyMap<Id, Id>, document: Document): Data { export function remapCopyData(element: ElementRecord, registry: Registry, elements: ReadonlyMap<Id, Id>, canvases: ReadonlyMap<Id, Id>, document: Document): Data {
const definition = registry.components.get(element.componentId), schema = definition?.copy; const definition = registry.components.get(element.componentId), schema = definition?.copy;
if (registry.resolve(element) !== 'known' || !schema) throw fault('copy_schema_unavailable', [element.id]); if (registry.resolve(element) !== 'known' || !schema) throw fault('copy_schema_unavailable', [element.id]);
// data 完整 JSON 复制后再改声明路径;未声明为引用的字符串(即使看起来像 ID)原样保留。
const data = clone(element.data) as Record<string, Json>; const data = clone(element.data) as Record<string, Json>;
for (const ref of schema.references) { for (const ref of schema.references) {
for (const slot of copySlots(data, ref.path)) { for (const slot of copySlots(data, ref.path)) {
@@ -37,13 +39,15 @@ export function remapCopyData(element: ElementRecord, registry: Registry, elemen
slot.parent[slot.key] = ref.many ? (value as Json[]).map(mapped) : mapped(value); slot.parent[slot.key] = ref.many ? (value as Json[]).map(mapped) : mapped(value);
} }
} }
// omit 删除对象字段,拒绝删除数组下标造成稀疏数据;生成任务状态通过声明省略,不复制成新任务。 // omit 只删除声明的对象字段,数组下标不能直接删成空洞;是否清除任务状态取决于该组件 copy.omit,
// 规划器本身不识别所有插件的任务字段,也不把复制出的配置当成启动生成命令。
for (const path of schema.omit) for (const slot of copySlots(data, path)) { if (Array.isArray(slot.parent)) throw fault('invalid_copy_omit', [element.id]); delete slot.parent[slot.key]; } for (const path of schema.omit) for (const slot of copySlots(data, path)) { if (Array.isArray(slot.parent)) throw fault('invalid_copy_omit', [element.id]); delete slot.parent[slot.key]; }
return data; return data;
} }
/** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */ /** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */
/** 分步迭代器:yield 给调用者调度机会,本身不 await、不落盘;耗尽后才返回完整已校验计划。 */ /** 分步迭代器:yield 给调用者调度机会,本身不 await、不落盘;耗尽后才返回完整已校验计划。
* yield 点不是字节/耗时硬上限,单条记录的 clone、引用转换或祖先遍历仍可能较重。 */
export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator<void, DuplicatePlan> { export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator<void, DuplicatePlan> {
yield* validateDocumentSteps(document, registry); yield* validateDocumentSteps(document, registry);
const offset = options.worldOffset ?? { x: 32, y: 32 }; const offset = options.worldOffset ?? { x: 32, y: 32 };
@@ -78,6 +82,7 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele
} }
} }
// 边另外扫描:显式选中、属于复制 canvas、或指向复制节点的入边均纳入,源端可仍指向原上级节点。 // 边另外扫描:显式选中、属于复制 canvas、或指向复制节点的入边均纳入,源端可仍指向原上级节点。
// 仅 source 被复制、target 在外部的出边,不因 source 在范围内就自动纳入;显式选中等其它条件仍可纳入。
const edges: ElementRecord[] = []; const edges: ElementRecord[] = [];
for (const element of document.elements.values()) { yield; if (element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))) { edges.push(element); copied.add(element.id); } } for (const element of document.elements.values()) { yield; if (element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))) { edges.push(element); copied.add(element.id); } }
// ID 分配前先保留源元素/canvas/墓碑全部 ID;同一 used 集合还防止两种新 ID 互撞。 // ID 分配前先保留源元素/canvas/墓碑全部 ID;同一 used 集合还防止两种新 ID 互撞。
@@ -124,6 +129,7 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele
/** 共享确定性转换器:canvas 改 parent/entry 映射,节点改身份/owner/data 与根级偏移。 /** 共享确定性转换器:canvas 改 parent/entry 映射,节点改身份/owner/data 与根级偏移。
* 同步及 checkpoint planner 都使用这里,避免两套复制产品规则。 */ * 同步及 checkpoint planner 都使用这里,避免两套复制产品规则。 */
export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,copiedCanvases:ReadonlySet<Id>,offset:Point):Exclude<Change,{kind:'field'}>{ export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,copiedCanvases:ReadonlySet<Id>,offset:Point):Exclude<Change,{kind:'field'}>{
// 调用方须提供已固定且覆盖范围的映射;此转换器不自行分配缺失 ID,canvas 分支依赖非空 parent、只供子 canvas 使用。
if (kind === 'canvas') { if (kind === 'canvas') {
const canvas=record as CanvasRecord,parent=canvas.parent!; const canvas=record as CanvasRecord,parent=canvas.parent!;
const after = { ...clone(canvas), id: canvasIds.get(canvas.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } }; const after = { ...clone(canvas), id: canvasIds.get(canvas.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } };
@@ -140,9 +146,11 @@ export function duplicateTreeChange(document:Document,registry:Registry,kind:'el
return { kind: 'element', id: after.id, before: null, after }; return { kind: 'element', id: after.id, before: null, after };
} }
} }
/** 映射边端点(外部端点沿用原 ID),按新端点算共同 owner,转换曲线后创建新 ID/incarnation=1。 */ /** 映射边端点(外部端点沿用原 ID),按新端点算共同 owner,转换曲线后创建新 ID/incarnation=1。
* 端点结构与组件定义的 copy.references 是两套路径:前者在此映射,后者交给 remapCopyData。 */
export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,nodes:Document):Extract<Change,{kind:'element'}>{ export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,nodes:Document):Extract<Change,{kind:'element'}>{
if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint'); if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint');
// 与 convertPath 的同变换早返回不同,这里即使 owner 空间不变,也先要求复制支持该曲线格式。
if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]); if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]);
const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId }; const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId };
const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner); const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner);
@@ -155,7 +163,8 @@ export function duplicateEdgeChange(document:Document,registry:Registry,edge:Ele
const from = toWorld(canvasTransform(document, a.ownerCanvasId), { x: a.geometry.x + a.geometry.width / 2, y: a.geometry.y + a.geometry.height / 2 }), to = toWorld(canvasTransform(nodes, b.ownerCanvasId), { x: b.geometry.x + b.geometry.width / 2, y: b.geometry.y + b.geometry.height / 2 }); const from = toWorld(canvasTransform(document, a.ownerCanvasId), { x: a.geometry.x + a.geometry.width / 2, y: a.geometry.y + a.geometry.height / 2 }), to = toWorld(canvasTransform(nodes, b.ownerCanvasId), { x: b.geometry.x + b.geometry.width / 2, y: b.geometry.y + b.geometry.height / 2 });
return { x: to.x - from.x, y: to.y - from.y }; return { x: to.x - from.x, y: to.y - from.y };
}; };
// knot 按链中序号在两端位移间插值;一端仍为原节点时,不把整条线简单平移。 // knot 按序号 t=(index+1)/(count+1) 在线两端世界位移间插值,不按弧长或几何距离加权。
// 这里只移动 knot.position,in/out 等偏移向量已在前面的空间转换中处理,不再次加位移。
const from = displacement(edge.source.elementId, source.elementId), to = displacement(edge.target.elementId, target.elementId), knots = path.knots; const from = displacement(edge.source.elementId, source.elementId), to = displacement(edge.target.elementId, target.elementId), knots = path.knots;
path = { ...path, knots: knots.map((raw, index) => { const knot = raw as Record<string, Json>, point = knot.position as { x: number; y: number }, world = toWorld(transform, point), t = (index + 1) / (knots.length + 1); return { ...knot, position: toLocal(transform, { x: world.x + from.x * (1 - t) + to.x * t, y: world.y + from.y * (1 - t) + to.y * t }) }; }) }; path = { ...path, knots: knots.map((raw, index) => { const knot = raw as Record<string, Json>, point = knot.position as { x: number; y: number }, world = toWorld(transform, point), t = (index + 1) / (knots.length + 1); return { ...knot, position: toLocal(transform, { x: world.x + from.x * (1 - t) + to.x * t, y: world.y + from.y * (1 - t) + to.y * t }) }; }) };
} }
@@ -167,7 +176,8 @@ export function duplicateOverlay(document:Document,elements:ReadonlyMap<Id,Eleme
/** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */ /** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */
/** 只读接口覆盖层:新增 ID 保证唯一,先查 additions,迭代则 base 后 additions。 /** 只读接口覆盖层:新增 ID 保证唯一,先查 additions,迭代则 base 后 additions。
* additions 在规划过程中仍会增长,故这个 view 只供内部计算,不是固定成员的发布快照。 */ * additions 在规划过程中仍会增长,故这个 view 只供内部计算,不是固定成员的发布快照。
* 本函数不验证 base/additions 的 ID 不相交;规划器分配时保证此约定,否则 size/迭代会出现重复。 */
function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> { function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> {
const view: ReadonlyMap<K,V> = { const view: ReadonlyMap<K,V> = {
// get/has 与所有迭代器读取同一组当前表;forEach 的第三参数仍是这个只读接口。 // get/has 与所有迭代器读取同一组当前表;forEach 的第三参数仍是这个只读接口。
+39 -15
View File
@@ -1,7 +1,8 @@
/** /**
* 纯编辑器与单 Root 发布协调器:命令先在私有 draft 上规划,再统一发布 document/history/operations/runtime。 * 纯编辑器与单 Root 发布协调器:命令先在私有 draft 上规划,再统一发布 document/history/operations/runtime。
* 不在这里执行网络或 IndexedDB;engine 通过同步准备钩子装配 ledger 等事实,调度器在发布后异步处理。 * 不在这里执行网络或 IndexedDB;engine 通过同步准备钩子装配 ledger 等事实,调度器在发布后异步处理。
* 修改入口共用 entered 防重入和 disposed 生命周期检查;协调提案另用 rootRevision 拒绝过期计划。 * 命令/协调发布入口共用 entered 防重入和 disposed 检查;协调提案另用 rootRevision 拒绝过期计划。
* dispose/subscribe 和只读查询有各自生命周期规则,entered 是同步重入闸门,不是跨线程或网络锁。
*/ */
import {dataArray,dataRecord} from './data-containers.ts'; import {dataArray,dataRecord} from './data-containers.ts';
import {stageCreation,materializeCreations} from './creation-draft.ts'; import {stageCreation,materializeCreations} from './creation-draft.ts';
@@ -29,9 +30,13 @@ export type EditorOptions<Runtime = undefined> = {
policy?: (command: Command, document: Document) => Availability; policy?: (command: Command, document: Document) => Availability;
/** 撤销/重做独立准入钩子,runtime 可据依赖、未知请求或权限阻止栈移动。 */ /** 撤销/重做独立准入钩子,runtime 可据依赖、未知请求或权限阻止栈移动。 */
traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability; traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability;
/** listener 抛错后的诊断回调;诊断本身失败也不能回滚已经发布的状态。 */
onListenerError?: (error: unknown) => void; onListenerError?: (error: unknown) => void;
/** 明确开放 changes.apply 给可信宿主;仍执行 policy 和发布前文档检查。 */
allowRawChanges?: boolean; allowRawChanges?: boolean;
/** 恢复层预先校验的核心快照;初始化直接沿用其中 history/operations,不在此完整解码恢复 DTO。 */
recovery?: CoreSnapshot & { readonly rootRevision: number }; recovery?: CoreSnapshot & { readonly rootRevision: number };
/** 与初始核心快照配套的宿主 runtime;其内部不可变性由宿主负责。 */
runtime?: Runtime; runtime?: Runtime;
/** engine 安装的同步 reducer,将核心提交变成下一份 runtime;抛错拒绝整次发布。 */ /** engine 安装的同步 reducer,将核心提交变成下一份 runtime;抛错拒绝整次发布。 */
reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime; reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime;
@@ -58,17 +63,20 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// 文档索引与 runtime 覆盖范围同次绑定;同一个 doc 对象的加载事实也可能更新。 // 文档索引与 runtime 覆盖范围同次绑定;同一个 doc 对象的加载事实也可能更新。
const bindCoverage=(snapshot:Snapshot<Runtime>,queries:ReturnType<typeof createDocumentQueries>)=>options.queryCoverage?withQueryCoverage(queries,options.queryCoverage(snapshot)):queries; const bindCoverage=(snapshot:Snapshot<Runtime>,queries:ReturnType<typeof createDocumentQueries>)=>options.queryCoverage?withQueryCoverage(queries,options.queryCoverage(snapshot)):queries;
let queries=bindCoverage(state,createDocumentQueries(document,options.registry,state.revision,options.queryBudget)); let queries=bindCoverage(state,createDocumentQueries(document,options.registry,state.revision,options.queryBudget));
// WeakMap 按确切快照身份保存查询,既拒绝错误快照,也不永久保留已废弃文档。 // WeakMap 按 Document 对象身份保存查询,不按整个 Root 身份;同文档的 runtime-only 发布会更新这条关联。
// 旧 Document 尚有外部引用时可查旧索引;未登记的外来 Document 明确拒绝,WeakMap 不独自保活旧文档。
const queriesByDocument=new WeakMap([[document,queries]]); const queriesByDocument=new WeakMap([[document,queries]]);
// entered 覆盖准备、发布和通知,防止 handler/reducer/observer 同步重入造成半份状态。 // entered 覆盖准备、发布和通知,防止 handler/reducer/observer 同步重入造成半份状态。
let entered = false, disposed = false, sequence = 0; let entered = false, disposed = false, sequence = 0;
for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence); for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence);
const listeners = new Set<(commit: Commit, snapshot: Snapshot<Runtime>) => void>(); const listeners = new Set<(commit: Commit, snapshot: Snapshot<Runtime>) => void>();
// createId 可能在最终发布前被调用;失败的候选不会回收已经向宿主申请过的 ID。
const createId = options.createId || (() => globalThis.crypto.randomUUID()); const createId = options.createId || (() => globalThis.crypto.randomUUID());
const limit = options.historyLimit ?? 100; const limit = options.historyLimit ?? 100;
if (!Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_history_limit'); if (!Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_history_limit');
/** 命令→候选文档/changes 的纯规划;不创建历史、不发送请求,也不改当前 state。 */ /** 命令→候选文档/changes 的同步规划,不创建历史、不发送请求,也不替换当前 state。
* policy/handler 由宿主提供,须遵守无外部写入约定;本函数不能回滚插件自己的副作用。 */
function plan(document: Document, command: Command): { document: Document; changes: readonly Change[] } { function plan(document: Document, command: Command): { document: Document; changes: readonly Change[] } {
const permission = options.policy?.(command, document); const permission = options.policy?.(command, document);
if (permission && !permission.enabled) throw fault(permission.code, permission.targets); if (permission && !permission.enabled) throw fault(permission.code, permission.targets);
@@ -107,11 +115,13 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (!validJson(command.path)) throw fault('invalid_connection_path', [element.id]); if (!validJson(command.path)) throw fault('invalid_connection_path', [element.id]);
changes = options.registry.handlers.get(element.componentId)!.get('connection.edit')!({ element, input: freeze({ patch: { path: clone(command.path) } }) }); changes = options.registry.handlers.get(element.componentId)!.get('connection.edit')!({ element, input: freeze({ patch: { path: clone(command.path) } }) });
for (const change of changes) if (change.kind !== 'field' || change.field !== 'path' || change.target.id !== element.id || change.target.incarnation !== element.incarnation) throw fault('invalid_handler_change', [element.id]); for (const change of changes) if (change.kind !== 'field' || change.field !== 'path' || change.target.id !== element.id || change.target.incarnation !== element.incarnation) throw fault('invalid_handler_change', [element.id]);
// 端点命令只要求 connection.edit 已安装,直接生成 source/target 变化,不调用该 handler。
} else if(command.type==='connection.endpoints'){ } else if(command.type==='connection.endpoints'){
if(element.structure!=='connection'||!options.registry.supports(element,'connection.edit'))throw fault('unsupported_capability',[element.id]); if(element.structure!=='connection'||!options.registry.supports(element,'connection.edit'))throw fault('unsupported_capability',[element.id]);
changes=[]; changes=[];
for(const [field,endpoint]of [['source',command.source],['target',command.destination]] as const)if(endpoint&&!equal(element[field],endpoint))changes=[...changes,{kind:'field',target:command.target,field,before:element[field],after:clone(endpoint)}]; for(const [field,endpoint]of [['source',command.source],['target',command.destination]] as const)if(endpoint&&!equal(element[field],endpoint))changes=[...changes,{kind:'field',target:command.target,field,before:element[field],after:clone(endpoint)}];
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))]; if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
// 删除使用结构规划器收集子树/附边,不通过单一内容或几何 handler。
} else if (command.type === 'element.remove') { } else if (command.type === 'element.remove') {
changes = removalChanges(document,command.target,queriesByDocument.get(document)); changes = removalChanges(document,command.target,queriesByDocument.get(document));
// 跨 owner 移动需要同时处理归属、几何、视觉单位、入口子 canvas 与相关边。 // 跨 owner 移动需要同时处理归属、几何、视觉单位、入口子 canvas 与相关边。
@@ -142,7 +152,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (command.type === 'element.data') run('content.edit', command.patch); if (command.type === 'element.data') run('content.edit', command.patch);
else { else {
if (element.structure !== 'node' || Object.keys(command.patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [element.id]); if (element.structure !== 'node' || Object.keys(command.patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [element.id]);
// 混合几何 patch 按 move→resize→rotate 分发,各 handler 只能处理自己的字段。 // 按输入字段拆成 move→resize→rotate;上方检查输出目标与 geometry 字段,具体几何语义仍由 handler 实现。
const parts: [Capability, Partial<Geometry>][] = [ const parts: [Capability, Partial<Geometry>][] = [
['geometry.move', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'x' || key === 'y'))], ['geometry.move', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'x' || key === 'y'))],
['geometry.resize', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'width' || key === 'height'))], ['geometry.resize', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'width' || key === 'height'))],
@@ -153,7 +163,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
changes = output; changes = output;
} }
} }
// 创建可先放入临时 draft 容器,整批结束统一物化,减少大量创建时的重复表复制。 // 创建先放入临时 draft 容器,整批结束统一物化;其它修改沿有序 changes 应用,返回的变化另做 JSON 捕获。
const next = command.type==='element.create'||command.type==='canvas.create'?stageCreation(document,changes[0] as Extract<Change,{kind:'canvas'|'element'}>):applyChanges(document, changes); const next = command.type==='element.create'||command.type==='canvas.create'?stageCreation(document,changes[0] as Extract<Change,{kind:'canvas'|'element'}>):applyChanges(document, changes);
return { document: next, changes: freeze(clone(changes)) }; return { document: next, changes: freeze(clone(changes)) };
} }
@@ -170,7 +180,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if(isThenable(runtime))throw fault('async_transaction'); if(isThenable(runtime))throw fault('async_transaction');
return runtime===next.runtime?next:Object.freeze({...next,runtime}); return runtime===next.runtime?next:Object.freeze({...next,runtime});
} }
/** 发布后通知 listener 快照;逐个隔离异常,观察者失败不能回滚已提交的 Root。 */ /** 发布后通知 listener 快照;逐个隔离异常,观察者失败不能回滚已提交的 Root。
* 本轮遍历 listener 集合的数组副本,通知期间退订不撤回副本中尚未调用的 listener。 */
function emit(commit: Commit) { function emit(commit: Commit) {
for (const listener of [...listeners]) try { listener(commit, state); } catch (error) { try { options.onListenerError?.(error); } catch { /* Observers cannot roll back a committed state. */ } } for (const listener of [...listeners]) try { listener(commit, state); } catch (error) { try { options.onListenerError?.(error); } catch { /* Observers cannot roll back a committed state. */ } }
} }
@@ -199,7 +210,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
history = Object.freeze({ ...history, entries: readMap(entries) }); history = Object.freeze({ ...history, entries: readMap(entries) });
} }
if (outbound) { if (outbound) {
// 收集旧/新 owner、父 canvas 和端点 owner,形成跨图版本/claim 的完整参与者范围。 // 按变化种类收集参与者:canvas 自身及父项、元素旧/新 owner、新建边或端点变化涉及的端点 owner。
// 这是出站意图的 canvasIds 来源,不是一次对所有可能插件引用的全图扫描。
const canvasIds = new Set<Id>(); const canvasIds = new Set<Id>();
for (const change of changes) { for (const change of changes) {
if (change.kind === 'canvas') { if (change.kind === 'canvas') {
@@ -239,6 +251,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
} }
const next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) }); const next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) });
const commit = freeze({ revision: next.revision, origin, changes, operationIds }); const commit = freeze({ revision: next.revision, origin, changes, operationIds });
// reducer/prepareRuntime/查询构建都在 state 赋值前完成;拒绝只丢弃候选,不能撤回宿主钩子的外部副作用。
const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime; const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime;
if (isThenable(runtime)) throw fault('async_transaction'); if (isThenable(runtime)) throw fault('async_transaction');
// 查询更新也在赋值之前准备;订阅者不会看到新文档搭配旧索引的短暂组合。 // 查询更新也在赋值之前准备;订阅者不会看到新文档搭配旧索引的短暂组合。
@@ -249,14 +262,15 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) }); return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) });
} }
/** 同步事务:多条 dispatch 共享 draft,任一命令失败使整批拒绝;回调结束后 tx 关闭。 */ /** 同步事务:多条 dispatch 共享 draft,正常 Error 失败会阻止发布;回调结束后 tx 关闭。
* failed 是 unknown 但当前分支用真值判断;宿主 policy/handler 应抛 Error,不应依赖抛 null/false/0 的拒绝语义。 */
function transaction(callback: (transaction: Transaction) => unknown, settings: { label?: string; groupId?: Id; history?: boolean; origin?: Origin } = {}): Result { function transaction(callback: (transaction: Transaction) => unknown, settings: { label?: string; groupId?: Id; history?: boolean; origin?: Origin } = {}): Result {
try { assertOpen(); } catch (error) { return reject(error); } try { assertOpen(); } catch (error) { return reject(error); }
entered = true; entered = true;
let active = true, document = state.document, failed: unknown; let active = true, document = state.document, failed: unknown;
const changes: Change[] = []; const changes: Change[] = [];
const tx: Transaction = Object.freeze({ const tx: Transaction = Object.freeze({
/** 私有 dispatch 记住第一次失败,后续命令不再继续推进 draft。 */ /** 私有 dispatch 记录规划抛错;常规 Error 被 failed 真值分支记住,后续命令停止推进。 */
dispatch(command: Command): Availability { dispatch(command: Command): Availability {
if (!active) throw fault('transaction_closed'); if (!active) throw fault('transaction_closed');
if (failed) return { enabled: false, ...reject(failed) }; if (failed) return { enabled: false, ...reject(failed) };
@@ -277,6 +291,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// 全部命令规划结束才物化创建记录并做整图校验,允许同批先创建节点再接边。 // 全部命令规划结束才物化创建记录并做整图校验,允许同批先创建节点再接边。
document=materializeCreations(document); document=materializeCreations(document);
validateDocument(document, options.registry); validateDocument(document, options.registry);
// 无变化或表内容/迭代顺序与原文档相同,直接报告 applied;不分配历史/操作 ID,也不通知订阅者。
if (!changes.length || documentsEqual(document, state.document)) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) }); if (!changes.length || documentsEqual(document, state.document)) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
let history = state.history, historyEntryId: Id | undefined; let history = state.history, historyEntryId: Id | undefined;
if (settings.history !== false && (!settings.origin || settings.origin === 'user')) { if (settings.history !== false && (!settings.origin || settings.origin === 'user')) {
@@ -292,10 +307,11 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
entries.set(historyEntryId, dataRecord(freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: dataArray(freeze([...changes])), operationIds: dataArray<Id>([]), ...(settings.groupId ? { groupId: settings.groupId } : {}) }))); entries.set(historyEntryId, dataRecord(freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: dataArray(freeze([...changes])), operationIds: dataArray<Id>([]), ...(settings.groupId ? { groupId: settings.groupId } : {}) })));
undo.push(historyEntryId); undo.push(historyEntryId);
} }
// 容量淘汰移除最旧 entry;资源 pin 的后续扫描以更新后的 history 为准。 // 仅从当前 history 淘汰最旧 entry;未解决操作的 historyTransition 等证据仍可能 pin 住旧资源。
while (undo.length > limit) entries.delete(undo.shift()!); while (undo.length > limit) entries.delete(undo.shift()!);
history = Object.freeze({ undo: Object.freeze(undo), redo: Object.freeze([]), entries: readMap(entries) }); history = Object.freeze({ undo: Object.freeze(undo), redo: Object.freeze([]), entries: readMap(entries) });
} }
// history:false 或非 user origin 可不建历史项,但 outbound 由 publish 的 origin 规则独立决定。
return publish(document, freeze([...changes]), settings.origin || 'user', history, historyEntryId); return publish(document, freeze([...changes]), settings.origin || 'user', history, historyEntryId);
} catch (error) { active = false; return reject(error); } } catch (error) { active = false; return reject(error); }
finally { entered = false; } finally { entered = false; }
@@ -304,7 +320,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
function dispatch(command: Command, settings?: Parameters<typeof transaction>[1]): Result { function dispatch(command: Command, settings?: Parameters<typeof transaction>[1]): Result {
return transaction(tx => { tx.dispatch(command); }, settings); return transaction(tx => { tx.dispatch(command); }, settings);
} }
/** 只规划和校验可用性,不分配操作 ID、不发布;提交时还要重新校验当前状态。 */ /** 只规划和校验可用性,不分配操作 ID、不发布;提交时还要重新校验当前状态。
* can 只检查 disposed,不占 entered;宿主提供的 policy/handler 可能仍被调用,不应有外部写入。 */
function can(command: Command): Availability { function can(command: Command): Availability {
try { if (disposed) throw fault('editor_disposed'); const result = plan(state.document, command); validateDocument(result.document, options.registry); return { enabled: true }; } try { if (disposed) throw fault('editor_disposed'); const result = plan(state.document, command); validateDocument(result.document, options.registry); return { enabled: true }; }
catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
@@ -319,6 +336,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
const entry = state.history.entries.get(id)!; const entry = state.history.entries.get(id)!;
const allowed = options.traversalPolicy?.(entry, direction); const allowed = options.traversalPolicy?.(entry, direction);
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets); if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
// undo 反转变化顺序和值,redo 使用正向变化;私有执行先通过最终文档校验,才构造新的两栈。
const planned=direction==='undo'?invert(entry.changes):entry.changes; const planned=direction==='undo'?invert(entry.changes):entry.changes;
const {document,changes,remap,restored}=planHistoryChanges(state.document,planned,options.registry); const {document,changes,remap,restored}=planHistoryChanges(state.document,planned,options.registry);
const entries=new Map(state.history.entries); const entries=new Map(state.history.entries);
@@ -326,6 +344,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// Frozen operations retain their original identities and incarnations. // Frozen operations retain their original identities and incarnations.
// 显式恢复的生命周期影响可继续遍历的历史目标;已冻结操作身份不在这里改写。 // 显式恢复的生命周期影响可继续遍历的历史目标;已冻结操作身份不在这里改写。
if(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,dataRecord(freeze({...other,changes:dataArray(freeze(other.changes.map(remap)))}))); if(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,dataRecord(freeze({...other,changes:dataArray(freeze(other.changes.map(remap)))})));
// 当前 entry 仍存“可重做的正向 changes”;undo 后把实际逆向变化再 invert,保留新恢复代次。
entries.set(id,dataRecord(freeze({...entry,changes:dataArray(freeze(direction==='undo'?invert(changes):changes))}))); entries.set(id,dataRecord(freeze({...entry,changes:dataArray(freeze(direction==='undo'?invert(changes):changes))})));
const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) }); const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) });
return publish(document, changes, direction, history, id); return publish(document, changes, direction, history, id);
@@ -335,7 +354,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// 公开只读快照与绑定查询;指定未知 document 查查询会拒绝,不隐式混用当前索引。 // 公开只读快照与绑定查询;指定未知 document 查查询会拒绝,不隐式混用当前索引。
return Object.freeze({ getSnapshot: () => state,getQueries:(document=state.document)=>{const value=queriesByDocument.get(document);if(!value)throw fault('query_snapshot_unavailable');return value;}, registry: options.registry, dispatch, transaction, can, return Object.freeze({ getSnapshot: () => state,getQueries:(document=state.document)=>{const value=queriesByDocument.get(document);if(!value)throw fault('query_snapshot_unavailable');return value;}, registry: options.registry, dispatch, transaction, can,
/** Coordinator entry: document, amended history and outbound fact share one Root. */ /** Coordinator entry: document, amended history and outbound fact share one Root. */
/** 异步图片尺寸解析只提交仍匹配 Root/目标的计划,并修正原创建历史,不额外造用户项。 */ /** 接收异步解码后的尺寸,入口本身同步;匹配 Root/目标后修正原创建历史,不额外造用户项。
* asset-resolution 仍可产生出站 operation,并与原创建 entry 关联,不是完全跳过 history/runtime。 */
resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result { resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result {
try { assertOpen(); } catch (error) { return reject(error); } try { assertOpen(); } catch (error) { return reject(error); }
entered = true; entered = true;
@@ -363,6 +383,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan'); if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
const runtime = reduce(state.runtime); const runtime = reduce(state.runtime);
if (isThenable(runtime)) throw fault('async_transaction'); if (isThenable(runtime)) throw fault('async_transaction');
// 相同 runtime 引用视为 no-op,prepareRuntime 不执行;调用方须返回新对象而非原地改旧状态。
if (runtime !== state.runtime) { if (runtime !== state.runtime) {
const nextState=prepareState(Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,queries); const nextState=prepareState(Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,queries);
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries); state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
@@ -373,7 +394,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
finally { entered = false; } finally { entered = false; }
}, },
/** Engine-only canonical proposal: validation and runtime state publish as one root. */ /** Engine-only canonical proposal: validation and runtime state publish as one root. */
/** 将已校验的 ACK/恢复提案与 runtime 一起发布;旧 rootRevision 提案明确拒绝。 /** 接收 ACK/恢复提案并重新捕获校验文档,与 runtime 一起发布;旧 rootRevision 提案明确拒绝。
* 可替换 history/operations,但不允许改写原有操作身份;文档实际变化才推进 revision。 */ * 可替换 history/operations,但不允许改写原有操作身份;文档实际变化才推进 revision。 */
reconcile(document: Document, runtime: Runtime, expectedRootRevision: number, replacement?: { readonly history: HistoryState; readonly operations: ReadonlyMap<Id,OperationIntent> }): Result { reconcile(document: Document, runtime: Runtime, expectedRootRevision: number, replacement?: { readonly history: HistoryState; readonly operations: ReadonlyMap<Id,OperationIntent> }): Result {
try { assertOpen(); } catch (error) { return reject(error); } try { assertOpen(); } catch (error) { return reject(error); }
@@ -384,7 +405,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
const next = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones); const next = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones);
validateDocument(next, options.registry); validateDocument(next, options.registry);
let nextSequence=sequence; let nextSequence=sequence;
// 恢复替换必须让两栈精确覆盖 entry,entry 的 operation ID 必须存在,序列保持单调。 // 替换时要求两栈精确覆盖 entries、历史关联的 operation ID 存在,已有操作内容完全相等。
// 新操作的 sequence 在此仅检查正安全整数,再取最大值供后续分配;完整恢复校验由宿主边界完成。
if(replacement){ if(replacement){
const {history,operations}=replacement,ids=[...history.undo,...history.redo]; const {history,operations}=replacement,ids=[...history.undo,...history.redo];
if(new Set(ids).size!==ids.length||ids.length!==history.entries.size||ids.some(id=>!history.entries.has(id)))throw fault('invalid_history'); if(new Set(ids).size!==ids.length||ids.length!==history.entries.size||ids.some(id=>!history.entries.has(id)))throw fault('invalid_history');
@@ -392,6 +414,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
for(const [id,intent]of operations){if(id!==intent.id||!Number.isSafeInteger(intent.sequence)||intent.sequence<1)throw fault('invalid_operation_sequence');nextSequence=Math.max(nextSequence,intent.sequence);} for(const [id,intent]of operations){if(id!==intent.id||!Number.isSafeInteger(intent.sequence)||intent.sequence<1)throw fault('invalid_operation_sequence');nextSequence=Math.max(nextSequence,intent.sequence);}
for(const [id,entry]of history.entries)if(entry.id!==id||entry.operationIds.some(operationId=>!operations.has(operationId)))throw fault('invalid_history'); for(const [id,entry]of history.entries)if(entry.id!==id||entry.operationIds.some(operationId=>!operations.has(operationId)))throw fault('invalid_history');
} }
// 即使文档相等,reconcile 仍发布一个新 rootRevision 与 ACK 通知;仅文档变化推进 revision。
const changed = !documentsEqual(next, state.document); const changed = !documentsEqual(next, state.document);
const nextState=prepareState(Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 })),nextQueries=bindCoverage(nextState,changed?advanceDocumentQueries(queries,next,options.registry,state.revision+1):queries); const nextState=prepareState(Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 })),nextQueries=bindCoverage(nextState,changed?advanceDocumentQueries(queries,next,options.registry,state.revision+1):queries);
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries); state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
@@ -407,7 +430,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
dispose() { disposed = true; listeners.clear(); }, dispose() { disposed = true; listeners.clear(); },
}); });
} }
/** 比较规范化表,先做身份/大小快判;无净变化的事务不增加 revision 或历史。 */ /** 比较表的迭代顺序与内容,先做身份/大小快判;不是忽略插入顺序的 Map 集合等价。
* transaction 用它省去重复发布,reconcile 用它决定是否推进文档 revision。 */
function documentsEqual(a: Document, b: Document): boolean { function documentsEqual(a: Document, b: Document): boolean {
if(a===b)return true; if(a===b)return true;
if(a.canvases.size!==b.canvases.size||a.elements.size!==b.elements.size||a.tombstones.size!==b.tombstones.size)return false; if(a.canvases.size!==b.canvases.size||a.elements.size!==b.elements.size||a.tombstones.size!==b.tombstones.size)return false;
+5 -2
View File
@@ -8,7 +8,8 @@ import {detachJsonData} from './json-data.ts';
// Provenance comes from detaching JSON data, never from an external freeze or // Provenance comes from detaching JSON data, never from an external freeze or
// document envelope. This does not assert record validity or registry support. // document envelope. This does not assert record validity or registry support.
const owned=new WeakSet<object>(); const owned=new WeakSet<object>();
/** 已由本模块捕获的记录直接复用;外部记录重新复制冻结,再标记新记录身份。 */ /** 已由本模块捕获的记录直接复用;外部记录重新按 JSON 语义复制冻结,再标记新记录身份。
* 此处不调用组件 schema;捕获成功与记录合法是两项独立事实。 */
export function detachElementData(record:ElementRecord):ElementRecord{ export function detachElementData(record:ElementRecord):ElementRecord{
if(record&&typeof record==='object'&&owned.has(record))return record; if(record&&typeof record==='object'&&owned.has(record))return record;
const value=detachJsonData(record); const value=detachJsonData(record);
@@ -21,7 +22,8 @@ export const hasOwnedElementData=(record:ElementRecord)=>!!record&&typeof record
// Framework-owned validators depend only on detached immutable data. Custom // Framework-owned validators depend only on detached immutable data. Custom
// validators remain live, including their method receiver and external context. // validators remain live, including their method receiver and external context.
const builtinValidators=new WeakSet<Function>(),dataProofs=new WeakMap<object,ComponentDefinition['validate']>(); const builtinValidators=new WeakSet<Function>(),dataProofs=new WeakMap<object,ComponentDefinition['validate']>();
/** 标记框架内置、仅依赖输入的纯校验器;不要用它包装依赖外部可变状态的校验器。 */ /** 标记框架内置、仅依赖输入的纯校验器;不要用它包装依赖外部可变状态的校验器。
* WeakSet 只记录这项声明,不能自动证明函数无副作用或不读取闭包状态。 */
export function builtinDataValidator<T extends ComponentDefinition['validate']>(validate:T):T{builtinValidators.add(validate);return validate;} export function builtinDataValidator<T extends ComponentDefinition['validate']>(validate:T):T{builtinValidators.add(validate);return validate;}
/** 检查组件 data,只有“owned 记录 + 内置 validator”组合才允许复用成功结果。 /** 检查组件 data,只有“owned 记录 + 内置 validator”组合才允许复用成功结果。
* 失败不缓存;更换 validator 后缓存也不命中;Reflect.apply 保留定义对象作为 this。 */ * 失败不缓存;更换 validator 后缓存也不命中;Reflect.apply 保留定义对象作为 this。 */
@@ -29,6 +31,7 @@ export function componentDataValid(record:ElementRecord,definition:ComponentDefi
// record 身份和具体 validator 函数同时匹配,才能跳过一次已成功的 schema 检查。 // record 身份和具体 validator 函数同时匹配,才能跳过一次已成功的 schema 检查。
const validate=definition.validate,reusable=hasOwnedElementData(record)&&builtinValidators.has(validate); const validate=definition.validate,reusable=hasOwnedElementData(record)&&builtinValidators.has(validate);
if(reusable&&dataProofs.get(record)===validate)return true; if(reusable&&dataProofs.get(record)===validate)return true;
// 校验器抛错会直接传播给上层;这里只缓存正常返回且通过的结果,不吞错或缓存失败。
const valid=Reflect.apply(validate,definition,[record.data]); const valid=Reflect.apply(validate,definition,[record.data]);
if(valid&&reusable)dataProofs.set(record,validate); if(valid&&reusable)dataProofs.set(record,validate);
return valid; return valid;
+20 -6
View File
@@ -1,6 +1,7 @@
/** /**
* 内置 feature 与 component 装配。Handler 是纯规划器:读取目标与 patch,只返回字段变化。 * 内置 feature 与 component 装配。Handler 是纯规划器:读取目标与 patch,只返回字段变化。
* 权限、事务与发布仍由 editor 负责;UI 不能绕过 editor 直接调用 handler 来写文档。 * 权限、事务与发布仍由 editor 负责;UI 不能绕过 editor 直接调用 handler 来写文档。
* capability 表示安装了哪类行为,schema 与各 handler 的实际检查仍须分别阅读。
*/ */
import {builtinDataValidator} from './element-data.ts'; import {builtinDataValidator} from './element-data.ts';
import {rectangleGeometry,bezierChainSegments,type BezierChain} from './geometry.ts'; import {rectangleGeometry,bezierChainSegments,type BezierChain} from './geometry.ts';
@@ -15,12 +16,14 @@ const geometryHandler = (allowed: readonly (keyof Geometry)[], constrain?: (geom
if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]); if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]);
const patch = input.patch as Partial<Geometry>; const patch = input.patch as Partial<Geometry>;
if (!patch || Object.values(patch).some(value => typeof value !== 'number' || !Number.isFinite(value)) || Object.keys(patch).some(key => !allowed.includes(key as keyof Geometry))) throw fault('invalid_geometry_patch', [element.id]); if (!patch || Object.values(patch).some(value => typeof value !== 'number' || !Number.isFinite(value)) || Object.keys(patch).some(key => !allowed.includes(key as keyof Geometry))) throw fault('invalid_geometry_patch', [element.id]);
// constrain 在字段形状检查后执行;此处不归一 rotation 或单独证明最终宽高为正,交给文档几何校验。
const after = constrain ? constrain({ ...element.geometry, ...patch }) : { ...element.geometry, ...patch }; const after = constrain ? constrain({ ...element.geometry, ...patch }) : { ...element.geometry, ...patch };
return equal(element.geometry, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'geometry', before: element.geometry, after }]; return equal(element.geometry, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'geometry', before: element.geometry, after }];
}; };
/** 移动仅允许 x/y,不能顺带修改大小、旋转或 owner。 */ /** 移动仅允许 x/y,不能顺带修改大小、旋转或 owner。 */
export const movable = () => defineFeature('geometry.move', { 'geometry.move': geometryHandler(['x', 'y']) }); export const movable = () => defineFeature('geometry.move', { 'geometry.move': geometryHandler(['x', 'y']) });
/** 捕获 min/max/aspect 配置,构造只修改 width/height 的缩放行为。 */ /** 浅捕获 min/max/aspect 选项,检查显式 min/max,再构造只修改 width/height 的缩放行为。
* 缺省 min=1、max=100000;aspect 默认不启用,内置图片也复用这份默认缩放 feature。 */
export const resizable = (inputOptions: { min?: number; max?: number; aspect?: boolean } = {}) => { export const resizable = (inputOptions: { min?: number; max?: number; aspect?: boolean } = {}) => {
const options = Object.freeze({ ...inputOptions }); const options = Object.freeze({ ...inputOptions });
if (options.min !== undefined && (!Number.isFinite(options.min) || options.min <= 0) || options.max !== undefined && (!Number.isFinite(options.max) || options.max < (options.min ?? 1))) throw fault('invalid_resize_constraint'); if (options.min !== undefined && (!Number.isFinite(options.min) || options.min <= 0) || options.max !== undefined && (!Number.isFinite(options.max) || options.max < (options.min ?? 1))) throw fault('invalid_resize_constraint');
@@ -32,6 +35,7 @@ export const resizable = (inputOptions: { min?: number; max?: number; aspect?: b
return geometryHandler(['width', 'height'], geometry => { return geometryHandler(['width', 'height'], geometry => {
// owner 变换后 unitScale 也会改变,因此 min/max 要乘它才能维持同一视觉尺寸约束。 // owner 变换后 unitScale 也会改变,因此 min/max 要乘它才能维持同一视觉尺寸约束。
const units=unitScale(element.data.presentation),min=(options.min??1)*units,max=(options.max??100000)*units; const units=unitScale(element.data.presentation),min=(options.min??1)*units,max=(options.max??100000)*units;
// 先逐维 clamp,再按原 width/height 比例重算另一维;同时给宽高且 aspect=true 时以宽度为主。
let width = Math.min(max, Math.max(min, geometry.width)), height = Math.min(max, Math.max(min, geometry.height)); let width = Math.min(max, Math.max(min, geometry.width)), height = Math.min(max, Math.max(min, geometry.height));
// 宽度优先;只给高度时反算宽度。保持原比例后越界则拒绝,不分别截断而破坏比例。 // 宽度优先;只给高度时反算宽度。保持原比例后越界则拒绝,不分别截断而破坏比例。
if (options.aspect) { if (options.aspect) {
@@ -44,13 +48,16 @@ export const resizable = (inputOptions: { min?: number; max?: number; aspect?: b
}, },
}); });
}; };
/** 旋转只产生 rotation 修改;[0,360) 等最终范围约束由几何校验处理。 */ /** 旋转 handler 只接受 rotation patch,但输出的是整份 geometry 字段变化。
* 不把 360/-1 自动归一到 [0,360),最终几何校验负责拒绝越界结果。 */
export const rotatable = () => defineFeature('geometry.rotate', { 'geometry.rotate': geometryHandler(['rotation']) }); export const rotatable = () => defineFeature('geometry.rotate', { 'geometry.rotate': geometryHandler(['rotation']) });
/** 内容编辑按顶层 data 键生成独立条件变化,保存存在性,区分缺失字段与 null。 */ /** 内容编辑按顶层 data 键生成独立条件变化,保存存在性,区分缺失字段与 null。 */
export const editable = () => defineFeature('content.edit', { export const editable = () => defineFeature('content.edit', {
'content.edit': ({ element, input }) => { 'content.edit': ({ element, input }) => {
const patch = input.patch as Data; const patch = input.patch as Data;
if (!patch || !validJson(patch) || Array.isArray(patch)) throw fault('invalid_data_patch', [element.id]); if (!patch || !validJson(patch) || Array.isArray(patch)) throw fault('invalid_data_patch', [element.id]);
// 签名要求 Data 对象;上面的运行时 guard 检查 JSON 值域并排除数组,不是任意未知输入的完整对象 schema。
// patch 中出现的键都表示写入,null 是显式值;此 handler 没有以 undefined 表示删除的约定。
// 每个字段携带 before,ACK/重放时才能检查冲突,而不是用整份节点覆盖后来的编辑。 // 每个字段携带 before,ACK/重放时才能检查冲突,而不是用整份节点覆盖后来的编辑。
return Object.entries(patch).flatMap(([key, after]): FieldChange[] => { return Object.entries(patch).flatMap(([key, after]): FieldChange[] => {
const before = element.data[key] ?? null; const before = element.data[key] ?? null;
@@ -58,7 +65,8 @@ export const editable = () => defineFeature('content.edit', {
}); });
}, },
}); });
/** 内置节点的基础 data 合同:title/text 为字符串,允许其它合法 JSON 扩展字段。 */ /** 内置节点共用的基础 data 合同:title/text 为字符串,检查已识别 unitScale,允许其它合法 JSON 扩展。
* 图片 URL/资源身份、图标名或任务字段并不由此函数逐项验证;它也不代表所有表现字段都有专门 schema。 */
const textData = builtinDataValidator((data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string'); const textData = builtinDataValidator((data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string');
/** 安装文本、图片、脚本、图标、frame 和 connection 的默认定义。 /** 安装文本、图片、脚本、图标、frame 和 connection 的默认定义。
* 节点共用基础行为;frame 不安装旋转行为。复制声明与资源字段也是定义的一部分。 */ * 节点共用基础行为;frame 不安装旋转行为。复制声明与资源字段也是定义的一部分。 */
@@ -67,18 +75,23 @@ export function builtinComponents(): readonly ComponentDefinition[] {
id, dataVersion: 1, structure: 'node', validate: textData, id, dataVersion: 1, structure: 'node', validate: textData,
features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()], features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()],
resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [], resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [],
// frame 的 ports 引用必须随复制映射;任务/run/progress 不复制成新的执行任务。 // frame ports[].nodeId 是显式 element 引用,复制范围外引用按 reject;局部 port id/handle 不是这里的映射目标。
// 只 omit 下列四个顶层任务状态字段,不递归猜测任意插件任务结构;资源字段按声明另供 runtime 扫描。
geometry:rectangleGeometry,copy: { references: id === 'frame' ? [{path:['ports','*','nodeId'],target:'element',external:'reject'}] : [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] }, geometry:rectangleGeometry,copy: { references: id === 'frame' ? [{path:['ports','*','nodeId'],target:'element',external:'reject'}] : [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] },
// 后续 connection 使用单独定义:strokeWidth 取 appearance.width 的真值或 1.5,并不在这里验证其类型/正数范围。
// segments 要求 displayPath 已提供且可解释,再调用共享 Bezier 生成器;输出有效性由几何查询边界继续检查。
})).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: builtinDataValidator((data:Data) => validJson(data)), features: [editable(), connectionEditable()], resourceFields: [],geometry:{kind:'connection',version:1,strokeWidth:({element})=>{const appearance=element.data.appearance;return (appearance&&typeof appearance==='object'&&!Array.isArray(appearance)?appearance.width:undefined) as number||1.5;},segments:context=>{if(context.displayPath===undefined||!connectionPathValid(context.displayPath))throw fault('unsupported_connection_geometry');return bezierChainSegments(context.source,context.target,context.displayPath as unknown as BezierChain|null);}}, copy: { references: [], omit: [] } })]); })).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: builtinDataValidator((data:Data) => validJson(data)), features: [editable(), connectionEditable()], resourceFields: [],geometry:{kind:'connection',version:1,strokeWidth:({element})=>{const appearance=element.data.appearance;return (appearance&&typeof appearance==='object'&&!Array.isArray(appearance)?appearance.width:undefined) as number||1.5;},segments:context=>{if(context.displayPath===undefined||!connectionPathValid(context.displayPath))throw fault('unsupported_connection_geometry');return bezierChainSegments(context.source,context.target,context.displayPath as unknown as BezierChain|null);}}, copy: { references: [], omit: [] } })]);
} }
/** null 表示默认连线;否则仅支持最多 64 个 knot 的 bezier-chain,并校验控制点/唯一 ID。 */ /** null 表示默认连线;否则仅支持最多 64 个 knot 的 bezier-chain,并校验控制点/唯一 ID。
* 这里只检查所需路径字段,不拒绝所有扩展键;corner/smooth/symmetric 是模式标签,不在此验证控制柄相切或镜像关系。 */
export function connectionPathValid(path: Json): boolean { export function connectionPathValid(path: Json): boolean {
if (path === null) return true; if (path === null) return true;
if (typeof path !== 'object' || Array.isArray(path) || path.type !== 'bezier-chain' || !Array.isArray(path.knots) || path.knots.length > 64) return false; if (typeof path !== 'object' || Array.isArray(path) || path.type !== 'bezier-chain' || !Array.isArray(path.knots) || path.knots.length > 64) return false;
// 控制点只能是有限 x/y;缺少坐标、数组或非对象值均不接受。 // 控制点只能是有限 x/y;缺少坐标、数组或非对象值均不接受。
const point = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.x === 'number' && typeof value.y === 'number' && Number.isFinite(value.x) && Number.isFinite(value.y); const point = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.x === 'number' && typeof value.y === 'number' && Number.isFinite(value.x) && Number.isFinite(value.y);
if (path.startControl !== undefined && !point(path.startControl) || path.endControl !== undefined && !point(path.endControl)) return false; if (path.startControl !== undefined && !point(path.startControl) || path.endControl !== undefined && !point(path.endControl)) return false;
// 每个 knot 的 ID 非空且本路径内唯一;位置和 in/out 都必须是有限点,空 knots 数组也合法。
const ids = new Set<string>(); const ids = new Set<string>();
for (const knot of path.knots) { for (const knot of path.knots) {
if (!knot || typeof knot !== 'object' || Array.isArray(knot) || typeof knot.id !== 'string' || !knot.id || ids.has(knot.id) || !['corner', 'smooth', 'symmetric'].includes(knot.mode as string) || !point(knot.position) || !point(knot.in) || !point(knot.out)) return false; if (!knot || typeof knot !== 'object' || Array.isArray(knot) || typeof knot.id !== 'string' || !knot.id || ids.has(knot.id) || !['corner', 'smooth', 'symmetric'].includes(knot.mode as string) || !point(knot.position) || !point(knot.in) || !point(knot.out)) return false;
@@ -86,7 +99,8 @@ export function connectionPathValid(path: Json): boolean {
} }
return true; return true;
} }
/** 连线编辑 handler:旧 path 也必须可解释,拒绝用当前协议重写未知曲线路径。 */ /** 连线编辑 handler:旧 path 也必须可解释,拒绝用当前协议重写未知曲线路径。
* feature ID 为 connection.path,但注册 capability 键是 connection.edit;editor 按后者查 handler。 */
export const connectionEditable = () => defineFeature('connection.path', { 'connection.edit': ({ element, input }) => { export const connectionEditable = () => defineFeature('connection.path', { 'connection.edit': ({ element, input }) => {
const path = (input.patch as { path?: Json }).path; const path = (input.patch as { path?: Json }).path;
if (element.structure !== 'connection' || !connectionPathValid(element.path)) throw fault('unsupported_connection_path', [element.id]); if (element.structure !== 'connection' || !connectionPathValid(element.path)) throw fault('unsupported_connection_path', [element.id]);
+7 -3
View File
@@ -1,6 +1,7 @@
/** /**
* 与渲染框架无关的基本几何协议。所有计算输入必须在同一坐标空间; * 与渲染框架无关的基本几何协议。所有计算输入必须在同一坐标空间;
* bounds 负责粗筛,hit 负责矩形精判,曲线 segments 由模型命中和 SVG 绘制共享。 * bounds 负责粗筛,hit 负责矩形精判,曲线 segments 由模型命中和 SVG 绘制共享。
* 函数不主动校验输入几何/容差/控制点,也不换算 viewport 或 presentation.unitScale,调用方先统一空间。
*/ */
import type {Geometry,Point} from './types.ts'; import type {Geometry,Point} from './types.ts';
/** 轴对齐包围盒,用于空间索引粗筛;旋转节点的盒会大于其实际轮廓。 */ /** 轴对齐包围盒,用于空间索引粗筛;旋转节点的盒会大于其实际轮廓。 */
@@ -14,12 +15,15 @@ export type BezierChain={readonly knots:readonly{readonly position:Point;readonl
/** 绕节点中心旋转后的 AABB:宽=|cos|·w+|sin|·h,高=|sin|·w+|cos|·h。 */ /** 绕节点中心旋转后的 AABB:宽=|cos|·w+|sin|·h,高=|sin|·w+|cos|·h。 */
export function rectangleBounds(g:Geometry):Rect{const angle=g.rotation*Math.PI/180,c=Math.abs(Math.cos(angle)),s=Math.abs(Math.sin(angle)),width=g.width*c+g.height*s,height=g.width*s+g.height*c;return{x:g.x+g.width/2-width/2,y:g.y+g.height/2-height/2,width,height};} export function rectangleBounds(g:Geometry):Rect{const angle=g.rotation*Math.PI/180,c=Math.abs(Math.cos(angle)),s=Math.abs(Math.sin(angle)),width=g.width*c+g.height*s,height=g.width*s+g.height*c;return{x:g.x+g.width/2-width/2,y:g.y+g.height/2-height/2,width,height};}
/** 将点反向旋转到矩形自身坐标,计算到矩形的距离,再与容差比较。 /** 将点反向旋转到矩形自身坐标,计算到矩形的距离,再与容差比较。
* 角外区域使用欧氏距离而非扩展 AABB;浮点误差余量随坐标尺度调整。 */ * 角外区域使用欧氏距离而非扩展 AABB;浮点误差余量随坐标尺度调整。
* 点在矩形内部时距离为 0;tolerance 由调用方保证非负,正常边界含在命中内。 */
export function hitRectangle(g:Geometry,point:Point,tolerance=0):boolean{const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);return Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height);} export function hitRectangle(g:Geometry,point:Point,tolerance=0):boolean{const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);return Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height);}
/** 默认矩形节点协议;空 dataDependencies 表示形状仅依赖 geometry,不因 title 等数据变化重建。 */ /** 默认矩形节点协议;空 dataDependencies 表示形状仅依赖 geometry,不因 title 等数据变化重建。 */
export const rectangleGeometry=Object.freeze({kind:'node' as const,version:1 as const,dataDependencies:Object.freeze([] as string[]),bounds:({geometry}:{geometry:Geometry})=>rectangleBounds(geometry),hit:({geometry,point,tolerance}:{geometry:Geometry;point:Point;tolerance:number})=>hitRectangle(geometry,point,tolerance)}); export const rectangleGeometry=Object.freeze({kind:'node' as const,version:1 as const,dataDependencies:Object.freeze([] as string[]),bounds:({geometry}:{geometry:Geometry})=>rectangleBounds(geometry),hit:({geometry,point,tolerance}:{geometry:Geometry;point:Point;tolerance:number})=>hitRectangle(geometry,point,tolerance)});
/** Shared by the built-in model protocol and the SVG renderer; all arguments are in one display space. */ /** Shared by the built-in model protocol and the SVG renderer; all arguments are in one display space. */
/** 由锚点链生成每段三次曲线:上一 knot.out 和下一 knot.in 分别作为控制偏移。 /** 由锚点链生成每段三次曲线:上一 knot.out 和下一 knot.in 分别作为控制偏移。
* 无显式首尾控制柄时,按端点距离的 0.4 倍选择长度并限制在 40–240。 * 无显式首尾控制柄时,reach 按两端直线距离的 0.4 倍限制在 40–240,再乘相应法向量。
* add 把相对偏移变成绝对控制点;map 对每对相邻 anchors 输出一段。 */ * 本函数不归一 normal;只有单位法向量才使 reach 等于控制柄的实际长度。
* add 把相对偏移变成绝对控制点;map 对每对相邻 anchors 输出一段,共 knots.length+1 段。
* 无 knot 时直接生成端点间一段;这里不根据 smooth/symmetric 标签自动修正控制柄。 */
export function bezierChainSegments(from:Connector,to:Connector,path?:BezierChain|null):Cubic[]{const reach=Math.min(240,Math.max(40,Math.hypot(to.point.x-from.point.x,to.point.y-from.point.y)*.4)),knots=path?.knots??[],anchors=[from.point,...knots.map(knot=>knot.position),to.point],add=(a:Point,b:Point)=>({x:a.x+b.x,y:a.y+b.y});return anchors.slice(0,-1).map((point,index)=>[point,add(point,index?knots[index-1].out:path?.startControl??{x:from.normal.x*reach,y:from.normal.y*reach}),add(anchors[index+1],index<knots.length?knots[index].in:path?.endControl??{x:to.normal.x*reach,y:to.normal.y*reach}),anchors[index+1]]);} export function bezierChainSegments(from:Connector,to:Connector,path?:BezierChain|null):Cubic[]{const reach=Math.min(240,Math.max(40,Math.hypot(to.point.x-from.point.x,to.point.y-from.point.y)*.4)),knots=path?.knots??[],anchors=[from.point,...knots.map(knot=>knot.position),to.point],add=(a:Point,b:Point)=>({x:a.x+b.x,y:a.y+b.y});return anchors.slice(0,-1).map((point,index)=>[point,add(point,index?knots[index-1].out:path?.startControl??{x:from.normal.x*reach,y:from.normal.y*reach}),add(anchors[index+1],index<knots.length?knots[index].in:path?.endControl??{x:to.normal.x*reach,y:to.normal.y*reach}),anchors[index+1]]);}
+5 -1
View File
@@ -12,8 +12,10 @@ type PlacementStep={canvasId:Id;parentCanvasId:Id;entryId:Id;incarnation:number;
/** A short-lived reader of the visible owner chain relative to one view, not the project root. /** A short-lived reader of the visible owner chain relative to one view, not the project root.
* Cache by owner only during this admission/publication, so multi-selection shares ancestry work. */ * Cache by owner only during this admission/publication, so multi-selection shares ancestry work. */
/** 给定不可变文档/view/registry,返回短生命周期 owner→入口链读取器。 /** 给定不可变文档/view/registry,返回短生命周期 owner→入口链读取器。
* Map 只在一次准入或发布比较中复用,多选共享祖先扫描,不能跨文档更新继续使用。 */ * Map 只在一次准入或发布比较中复用,多选共享祖先扫描,不能跨文档更新继续使用。
* 返回数组/步骤没有运行时 freeze,geometry 也沿用记录引用;readonly 是调用约定,不应修改缓存结果。 */
export function createGesturePlacementReader(document:Document,viewCanvasId:Id,registry:Registry){ export function createGesturePlacementReader(document:Document,viewCanvasId:Id,registry:Registry){
// 链按 owner→view 方向排列,不包含 view 自身在更上级的入口;读同一 owner 会返回同一个数组。
const values=new Map<Id,readonly PlacementStep[]>(); const values=new Map<Id,readonly PlacementStep[]>();
/** 向 view 回溯目标拥有链,缺失/循环/隐藏入口明确拒绝;成功后才缓存完整链。 */ /** 向 view 回溯目标拥有链,缺失/循环/隐藏入口明确拒绝;成功后才缓存完整链。 */
return(ownerCanvasId:Id):readonly PlacementStep[]=>{ return(ownerCanvasId:Id):readonly PlacementStep[]=>{
@@ -24,6 +26,7 @@ export function createGesturePlacementReader(document:Document,viewCanvasId:Id,r
while(id!==viewCanvasId){ while(id!==viewCanvasId){
if(seen.has(id))throw fault('canvas_cycle',[id]);seen.add(id); if(seen.has(id))throw fault('canvas_cycle',[id]);seen.add(id);
const parent=document.canvases.get(id)?.parent; const parent=document.canvases.get(id)?.parent;
// owner 缺失或已回溯到无父根,统一视为不在此 view;仅 view 本身缺失在上方报 canvas_missing。
if(!parent)throw fault('target_outside_view',[ownerCanvasId]); if(!parent)throw fault('target_outside_view',[ownerCanvasId]);
const entry=document.elements.get(parent.entryElementId); const entry=document.elements.get(parent.entryElementId);
// A future/unknown entry is a collapsed placeholder; never interpret its payload. // A future/unknown entry is a collapsed placeholder; never interpret its payload.
@@ -31,6 +34,7 @@ export function createGesturePlacementReader(document:Document,viewCanvasId:Id,r
// 未知/未来入口已在上方拒绝;已知入口仍可能折叠,折叠子内容不允许开始手势。 // 未知/未来入口已在上方拒绝;已知入口仍可能折叠,折叠子内容不允许开始手势。
const raw=entry.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?raw:{}; const raw=entry.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?raw:{};
if(presentation.collapsed===true)throw fault('target_hidden',[ownerCanvasId]); if(presentation.collapsed===true)throw fault('target_hidden',[ownerCanvasId]);
// 签名只读这些坐标字段,不校验整个 presentation;contentScale 这里仅检查 number 且 >0。
const contentScale=typeof presentation.contentScale==='number'&&presentation.contentScale>0?presentation.contentScale:1; const contentScale=typeof presentation.contentScale==='number'&&presentation.contentScale>0?presentation.contentScale:1;
chain.push({canvasId:id,parentCanvasId:parent.canvasId,entryId:entry.id,incarnation:entry.incarnation,componentId:entry.componentId,dataVersion:entry.dataVersion,geometry:entry.geometry,contentScale,unitScale:unitScale(raw)}); chain.push({canvasId:id,parentCanvasId:parent.canvasId,entryId:entry.id,incarnation:entry.incarnation,componentId:entry.componentId,dataVersion:entry.dataVersion,geometry:entry.geometry,contentScale,unitScale:unitScale(raw)});
id=parent.canvasId; id=parent.canvasId;
+35 -17
View File
@@ -1,7 +1,9 @@
/** /**
* 每个 editor 的临时交互会话:多个 view 各自保存选择/视口/工具/mode,共用一份已提交文档。 * 一个 manager 的临时交互会话:多个 view 各自保存选择/视口/工具/mode,共用 editor 已提交文档。
* 同时至多存在一个节点或连线手势;baseline/preview 均是短生命周期状态,不写 history/ledger。 * 单个 manager 内至多一个节点或连线手势;同一 editor 若另建 manager,并不共享这项互斥。
* pointerup 时才通过 editor 提交净修改;取消仅清预览,不回写旧整节点,因此不会撤掉晚到资源 ACK。 * baseline/preview 均是短生命周期状态,不写 history/ledger。
* 输入绑定通常在 pointerup 调用 commit;本模块不接 DOM 事件,只有显式 commit 才发 editor 命令。
* 取消仅清预览,不回写旧整节点,因此不会撤掉晚到资源 ACK;session listener 仍由本模块同步通知。
*/ */
import type { Editor } from './editor.ts'; import type { Editor } from './editor.ts';
import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.ts'; import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.ts';
@@ -24,10 +26,11 @@ export function createSessions(editor: Editor) {
let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map<Id, ViewState>()), gesture: null, connectionGesture: null, lastCancelReason: null }); let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map<Id, ViewState>()), gesture: null, connectionGesture: null, lastCancelReason: null });
// 开始手势时捕获原记录,用它计算净几何差;提交时仍以 editor 当前数据为准,不恢复整条记录。 // 开始手势时捕获原记录,用它计算净几何差;提交时仍以 editor 当前数据为准,不恢复整条记录。
let baseline = new Map<Id, ElementRecord>(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0; let baseline = new Map<Id, ElementRecord>(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0;
// Compare adjacent published identities without duplicating the per-view mode table. // 相邻 editor 发布的文档身份用于 mode/坐标签名比较;runtime-only 发布仍会重新检查 editor.can 的当前准入。
let publishedDocument=editor.getSnapshot().document; let publishedDocument=editor.getSnapshot().document;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
/** 对监听列表做副本迭代并隔离异常,renderer 出错不能影响已提交模型。 */ /** 对监听列表做副本迭代并隔离异常,renderer 出错不能影响已提交模型。
* 会话本身没有 entered 重入闸门;监听器不应同步递归制造新会话操作。 */
const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } }; const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } };
/** 一次发布 views;若被中断的是手势拥有者,同时清 baseline 和预览,只通知一次。 */ /** 一次发布 views;若被中断的是手势拥有者,同时清 baseline 和预览,只通知一次。 */
function publishViews(views:Map<Id,ViewState>,cancelOwnedView?:Id,reason='view_interrupted'){ function publishViews(views:Map<Id,ViewState>,cancelOwnedView?:Id,reason='view_interrupted'){
@@ -37,7 +40,8 @@ export function createSessions(editor: Editor) {
} }
/** 复制视图表并替换一项,可在同一发布中带上切工具/切 canvas 等取消原因。 */ /** 复制视图表并替换一项,可在同一发布中带上切工具/切 canvas 等取消原因。 */
function setView(view: ViewState,cancelOwnedReason?:string) { const views = new Map(snapshot.views); views.set(view.id, view); publishViews(views,cancelOwnedReason?view.id:undefined,cancelOwnedReason); } function setView(view: ViewState,cancelOwnedReason?:string) { const views = new Map(snapshot.views); views.set(view.id, view); publishViews(views,cancelOwnedReason?view.id:undefined,cancelOwnedReason); }
/** 幂等取消当前手势,保留 committed 文档及资源状态;重复 cancel 不重复通知。 */ /** 幂等取消本 manager 当前手势,保留 committed 文档及资源状态;重复 cancel 不重复通知。
* cancel 不接 viewId,属于 manager 级清理入口,不执行单 view 所有权校验。 */
function cancel(reason = 'canceled') { function cancel(reason = 'canceled') {
if (!snapshot.gesture && !snapshot.connectionGesture) return; if (!snapshot.gesture && !snapshot.connectionGesture) return;
baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify(); baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify();
@@ -49,6 +53,7 @@ export function createSessions(editor: Editor) {
for (const [id, view] of views) { for (const [id, view] of views) {
// 当前 canvas 被删除时回到根;失效 selection 清理,模式还需检查相邻发布的元素身份。 // 当前 canvas 被删除时回到根;失效 selection 清理,模式还需检查相邻发布的元素身份。
const canvasMissing = !document.canvases.has(view.canvasId); const canvasMissing = !document.canvases.has(view.canvasId);
// selection 只保留仍存在的 ID,不按 incarnation/owner/可见性重新筛;mode 才比较相邻发布的完整身份字段。
const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => { const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => {
const before=beforeDocument.elements.get(target),after=document.elements.get(target); const before=beforeDocument.elements.get(target),after=document.elements.get(target);
return !!before&&!!after&&before.incarnation===after.incarnation&&before.ownerCanvasId===after.ownerCanvasId&&before.structure===after.structure&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion; return !!before&&!!after&&before.incarnation===after.incarnation&&before.ownerCanvasId===after.ownerCanvasId&&before.structure===after.structure&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion;
@@ -64,7 +69,8 @@ export function createSessions(editor: Editor) {
const oldPlacement=activeView?createGesturePlacementReader(beforeDocument,activeView.canvasId,editor.registry):undefined,newPlacement=activeView?createGesturePlacementReader(document,activeView.canvasId,editor.registry):undefined; const oldPlacement=activeView?createGesturePlacementReader(beforeDocument,activeView.canvasId,editor.registry):undefined,newPlacement=activeView?createGesturePlacementReader(document,activeView.canvasId,editor.registry):undefined;
/** 同文档无需重算;旧/新链不再可读或值变化都按坐标上下文失效处理。 */ /** 同文档无需重算;旧/新链不再可读或值变化都按坐标上下文失效处理。 */
const placementChanged=(owner:Id)=>{if(beforeDocument===document)return false;try{return !oldPlacement||!newPlacement||!equal(oldPlacement(owner),newPlacement(owner));}catch{return true;}}; const placementChanged=(owner:Id)=>{if(beforeDocument===document)return false;try{return !oldPlacement||!newPlacement||!equal(oldPlacement(owner),newPlacement(owner));}catch{return true;}};
// 连线预览只依赖 path、身份、owner 和准入;普通内容或资源 ACK 不应自动取消它。 // 连线预览检查 path、身份、owner、坐标祖先链和当前准入;普通内容/资源 ACK 本身不直接取消,
// 但若该发布使 policy 不再允许 connection.path,后面的 editor.can 仍会取消。
if (!cancelReason&&snapshot.connectionGesture?.phase === 'active') { if (!cancelReason&&snapshot.connectionGesture?.phase === 'active') {
const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline; const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline;
if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.path, before.path)) cancelReason='target_changed'; if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.path, before.path)) cancelReason='target_changed';
@@ -100,7 +106,8 @@ export function createSessions(editor: Editor) {
['geometry.resize', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'width' || key === 'height'))], ['geometry.resize', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'width' || key === 'height'))],
['geometry.rotate', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'rotation'))], ['geometry.rotate', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'rotation'))],
]; ];
// move/resize/rotate 依次读取上一步预览结果;handler 只能给同目标生成合法 geometry。 // update 不以空 patch 做整命令 policy 预检,而是逐项检查实际使用的 capability、handler 输出目标与几何。
// move/resize/rotate 依次读取上一步预览结果;真正 commit 仍会通过 editor 再查 policy。
for (const [capability, value] of parts) if (Object.keys(value).length) { for (const [capability, value] of parts) if (Object.keys(value).length) {
if (!editor.registry.supports(element, capability)) throw fault('unsupported_capability', [target.id]); if (!editor.registry.supports(element, capability)) throw fault('unsupported_capability', [target.id]);
const changes = editor.registry.handlers.get(element.componentId)!.get(capability)!({ element, input: freeze({ patch: value }) }); const changes = editor.registry.handlers.get(element.componentId)!.get(capability)!({ element, input: freeze({ patch: value }) });
@@ -112,6 +119,7 @@ export function createSessions(editor: Editor) {
} }
} }
// 更新 preview 不触发 editor 命令,所以 pointermove 不制造 history 或出站请求。 // 更新 preview 不触发 editor 命令,所以 pointermove 不制造 history 或出站请求。
// 即使 patch 结果相同,本路径仍替换会话快照并 notify,不像 tool/mode 有相同值早返回。
const preview = new Map(snapshot.gesture.preview); preview.set(target.id, (element as ElementRecord & { structure: 'node' }).geometry); const preview = new Map(snapshot.gesture.preview); preview.set(target.id, (element as ElementRecord & { structure: 'node' }).geometry);
snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...snapshot.gesture, preview: readMap(preview) }) }); notify(); snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...snapshot.gesture, preview: readMap(preview) }) }); notify();
return { enabled: true }; return { enabled: true };
@@ -120,36 +128,42 @@ export function createSessions(editor: Editor) {
return Object.freeze({ return Object.freeze({
/** 外部订阅读取当前只读会话,不能直接修改内部 Map/Set。 */ /** 外部订阅读取当前只读会话,不能直接修改内部 Map/Set。 */
getSnapshot: () => snapshot, getSnapshot: () => snapshot,
/** 创建独立 view,返回带 attached 标记的幂等卸载函数;只释放这个 view 与其手势。 */ /** 创建独立 view,返回内部带 attached 布尔标记的幂等卸载闭包;只释放这个 view 与其手势。
* 已 dispose 或重复 view ID 都报 duplicate_view;卸载不关闭 editor,也不停止 runtime 持久任务。 */
attach(id: Id, canvasId = [...editor.getSnapshot().document.canvases.values()].find(canvas => !canvas.parent)!.id) { attach(id: Id, canvasId = [...editor.getSnapshot().document.canvases.values()].find(canvas => !canvas.parent)!.id) {
if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]); if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]);
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]); if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
setView(freeze({ id, canvasId, selection: readSet<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) })); setView(freeze({ id, canvasId, selection: readSet<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) }));
let attached=true;return () => { if(!attached)return;attached=false;if(!snapshot.views.has(id))return;const views=new Map(snapshot.views);views.delete(id);publishViews(views,id,'view_detached'); }; let attached=true;return () => { if(!attached)return;attached=false;if(!snapshot.views.has(id))return;const views=new Map(snapshot.views);views.delete(id);publishViews(views,id,'view_detached'); };
}, },
/** 切换查看的 canvas,清 selection/modes,并取消属于该 view 的旧坐标手势。 */ /** 设置查看的 canvas,清 selection/modes,并取消属于该 view 的旧坐标手势。
* 即使传相同 canvasId 也走清理/通知;它不是相同值 no-op 的入口。 */
canvas(id: Id, canvasId: Id) { canvas(id: Id, canvasId: Id) {
const current = view(id); const current = view(id);
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]); if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
setView(freeze({ ...current, canvasId, selection: readSet<Id>([]), modes: readMap(new Map<Id, string>()) }),'canvas_changed'); setView(freeze({ ...current, canvasId, selection: readSet<Id>([]), modes: readMap(new Map<Id, string>()) }),'canvas_changed');
}, },
/** 捕获有效元素 ID 为只读选择集合;这是会话选择,不是文档命令。 */ /** 捕获存在的元素 ID 为去重选择集合;不要求属于此 view、可见或已安装组件。
* selection 不携带 incarnation,也不是授权证据;begin/提交另行核对目标与准入。 */
select(id: Id, ids: readonly Id[]) { select(id: Id, ids: readonly Id[]) {
const current = view(id), document = editor.getSnapshot().document; const current = view(id), document = editor.getSnapshot().document;
for (const target of ids) if (!document.elements.has(target)) throw fault('element_missing', [target]); for (const target of ids) if (!document.elements.has(target)) throw fault('element_missing', [target]);
setView(freeze({ ...current, selection: readSet(ids) })); setView(freeze({ ...current, selection: readSet(ids) }));
}, },
/** 保存有限 pan 和正 zoom;改变观察视口不修改元素几何或 history。 */ /** 保存有限 pan 和正 zoom;不修改元素几何/history,也不自动取消当前手势。
* 屏幕输入如何随 pan/zoom 换算由输入绑定负责,此处不保存 DOM 指针坐标。 */
viewport(id: Id, viewport: ViewState['viewport']) { viewport(id: Id, viewport: ViewState['viewport']) {
if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport'); if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport');
setView(freeze({ ...view(id), viewport: { ...viewport } })); setView(freeze({ ...view(id), viewport: { ...viewport } }));
}, },
/** select/pan 切换会取消本 view 的手势;重复设置相同工具不发通知。 */ /** select/pan 切换会取消本 view 的手势;重复设置相同工具不发通知。 */
tool(id: Id, tool: ViewState['tool']) { const current=view(id);if(tool!=='select'&&tool!=='pan')throw fault('invalid_tool');if(current.tool===tool)return;setView(freeze({ ...current, tool }),'tool_changed'); }, tool(id: Id, tool: ViewState['tool']) { const current=view(id);if(tool!=='select'&&tool!=='pan')throw fault('invalid_tool');if(current.tool===tool)return;setView(freeze({ ...current, tool }),'tool_changed'); },
/** 保存某元素的临时编辑模式;空值移除,不创建持久的业务字段。 */ /** 保存某元素的临时模式字符串;null/空串移除,非空值要求元素存在。
* 不在此枚举模式名或检查 capability;mode 不是可编辑权限,也不是持久业务字段。 */
mode(id: Id, target: Id, mode: string | null) { const current = view(id);if(mode!==null&&typeof mode!=='string')throw fault('invalid_mode');if(mode&&!editor.getSnapshot().document.elements.has(target))throw fault('element_missing',[target]);if((current.modes.get(target)??null)===(mode||null))return;const modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); }, mode(id: Id, target: Id, mode: string | null) { const current = view(id);if(mode!==null&&typeof mode!=='string')throw fault('invalid_mode');if(mode&&!editor.getSnapshot().document.elements.has(target))throw fault('element_missing',[target]);if((current.modes.get(target)??null)===(mode||null))return;const modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); },
/** 节点手势准入:select 工具、无其它手势、目标去重/版本支持、可见拥有链及 policy 全部通过。 /** 节点手势准入:select 工具、无其它手势、目标去重/版本支持、可见拥有链及 policy 全部通过。
* 先把整组选区检查完再替换 baseline,拒绝不会留下半组手势。 */ * 先把整组目标检查完再替换 baseline,拒绝不会留下半组手势;targets 不必等于 view.selection。
* 空 geometry patch 的 editor.can 不调用 move/resize/rotate handler,实际能力仍在 update/commit 检查。 */
begin(viewId: Id, targets: readonly Target[]): Availability { begin(viewId: Id, targets: readonly Target[]): Availability {
try { try {
if(view(viewId).tool!=='select')throw fault('tool_not_select'); if(view(viewId).tool!=='select')throw fault('tool_not_select');
@@ -188,7 +202,8 @@ export function createSessions(editor: Editor) {
return { enabled: true }; return { enabled: true };
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
}, },
/** 连线结束只 dispatch 一次;先标 committing,结束后无论成功/拒绝都清预览。 */ /** 连线结束只 dispatch 一次;先标 committing,使 editor 发布监听暂不按 active 取消自己的提交。
* 正常提交和 catch 都调用 manager 级 cancel;错误 view 的提交调用也可能取消当前另一 view 的手势。 */
commitConnection(viewId: Id): Result { commitConnection(viewId: Id): Result {
try { try {
view(viewId); view(viewId);
@@ -200,7 +215,9 @@ export function createSessions(editor: Editor) {
} catch (error) { cancel('commit_failed'); return reject(error); } } catch (error) { cancel('commit_failed'); return reject(error); }
}, },
update, update,
/** 节点手势提交一个同步事务,把各目标相对 baseline 的净字段变化合成一个 Transform 历史项。 */ /** 节点手势提交一个同步事务,仅遍历 preview 中的目标,合并相对 baseline 的净字段变化。
* 有实际文档变化时通常形成一个 Transform 历史项;空预览/无净变化遵守 editor 的 no-op 语义。
* 与连线提交相同,异常路径会取消本 manager 当前手势,而不只清理传入的 view。 */
commit(viewId: Id): Result { commit(viewId: Id): Result {
try { try {
view(viewId); view(viewId);
@@ -221,7 +238,8 @@ export function createSessions(editor: Editor) {
} catch (error) { cancel('commit_failed'); return reject(error); } } catch (error) { cancel('commit_failed'); return reject(error); }
}, },
cancel, cancel,
/** 渲染时优先读取预览,否则读取最新 committed 几何;取消后自然显露最新模型。 */ /** 按元素 ID 优先读取本 manager 预览,否则读取最新 committed 几何;取消后自然显露最新模型。
* 无 viewId 参数,不过滤调用者视图;投影/renderer 负责决定这个元素是否应在该 view 显示。 */
effectiveGeometry(id: Id): Geometry | undefined { effectiveGeometry(id: Id): Geometry | undefined {
const element = editor.getSnapshot().document.elements.get(id); const element = editor.getSnapshot().document.elements.get(id);
return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined); return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined);
+11 -6
View File
@@ -18,11 +18,12 @@ export type HistoryTransition={readonly version:1;readonly rootRevision:number;r
// only its flattened changes, never registry-dependent references or callbacks. // only its flattened changes, never registry-dependent references or callbacks.
const transitionChanges=new WeakMap<HistoryTransition,readonly Change[]>(); const transitionChanges=new WeakMap<HistoryTransition,readonly Change[]>();
const immutableData=new WeakSet<object>(); const immutableData=new WeakSet<object>();
/** 检查可安全跨持久切点复用的深不可变普通数据,最多 64 层。 /** 检查缓存候选的冻结数据描述符与普通原型,最多递归 64 层;不是单独的 JSON/schema 校验器。
* 冻结的 getter 仍可能返回不同值,稀疏数组也不是完整区间,因此都不允许缓存。 */ * 冻结的 getter 仍可能返回不同值,稀疏数组也不是完整区间,因此都不允许缓存。 */
function immutablePlainData(value:unknown,depth=0):boolean{ function immutablePlainData(value:unknown,depth=0):boolean{
if(depth>64)return false; if(depth>64)return false;
if(value===null||typeof value!=='object')return typeof value!=='function'; if(value===null||typeof value!=='object')return typeof value!=='function';
// 只记住已遍历成功的对象身份;祖先链若太深仍会先被 depth 条件拒绝。
if(immutableData.has(value))return true; if(immutableData.has(value))return true;
if(!Object.isFrozen(value)||Object.getPrototypeOf(value)!==(Array.isArray(value)?Array.prototype:Object.prototype))return false; if(!Object.isFrozen(value)||Object.getPrototypeOf(value)!==(Array.isArray(value)?Array.prototype:Object.prototype))return false;
if(Array.isArray(value))for(let i=0;i<value.length;i++)if(!Object.hasOwn(value,i))return false; if(Array.isArray(value))for(let i=0;i<value.length;i++)if(!Object.hasOwn(value,i))return false;
@@ -44,7 +45,8 @@ function splice<T>(before:readonly T[],after:readonly T[]):Splice<T>{
} }
/** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */ /** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */
/** 对比两份 history,记录新增、删除和修改的 entry 及两栈顺序。 /** 对比两份 history,记录新增、删除和修改的 entry 及两栈顺序。
* 默认无变化不造记录;recordNoop 用于调用方显式需要记录发布边界的情形。 */ * 默认无变化不造记录;recordNoop 用于调用方显式需要记录发布边界的情形。
* 来源应是不可变 history:两栈和区间内的 Change 沿用引用,最终 freeze 不等于任意外部输入的深复制。 */
export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{ export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{
const entries:EntryPatch[]=[]; const entries:EntryPatch[]=[];
for(const id of new Set([...before.entries.keys(),...after.entries.keys()])){const a=before.entries.get(id),b=after.entries.get(id);if(equal(a,b))continue;entries.push({id,before:meta(a),after:meta(b),changes:splice(a?.changes??[],b?.changes??[]),operationIds:splice(a?.operationIds??[],b?.operationIds??[])});} for(const id of new Set([...before.entries.keys(),...after.entries.keys()])){const a=before.entries.get(id),b=after.entries.get(id);if(equal(a,b))continue;entries.push({id,before:meta(a),after:meta(b),changes:splice(a?.changes??[],b?.changes??[]),operationIds:splice(a?.operationIds??[],b?.operationIds??[])});}
@@ -52,9 +54,10 @@ export function describeHistoryTransition(before:HistoryState,after:HistoryState
return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries}); return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries});
} }
/** 验证外部/恢复的增量 DTO,未知 version 明确要求其它解释器。 /** 验证外部/恢复的增量 DTO,未知 version 明确要求其它解释器。
* checkChanges 可由调用方注入更严格的 Change 校验,本函数本身不依赖 registry。 */ * checkChanges 可由调用方注入更严格的 Change 校验;未传时只检查 DTO/区间形状,不解释每条 Change 的领域语义。
* rootRevision 只在此检查非负整数,不在此证明 transition 属于哪次实际发布。 */
export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{ export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{
// 四个局部校验器统一抛领域错误,分别约束普通记录、非负整数、单 ID 和 ID 数组。 // 四个局部校验器统一抛领域错误,分别约束非数组记录形状、非负整数、非空 ID 和 ID 数组。
const object=(v:unknown):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw fault('invalid_history_transition');return v as Record<string,unknown>;}; const object=(v:unknown):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw fault('invalid_history_transition');return v as Record<string,unknown>;};
const integer=(v:unknown)=>{if(typeof v!=='number'||!Number.isSafeInteger(v)||v<0)throw fault('invalid_history_transition');}; const integer=(v:unknown)=>{if(typeof v!=='number'||!Number.isSafeInteger(v)||v<0)throw fault('invalid_history_transition');};
const id=(v:unknown)=>{if(typeof v!=='string'||!v)throw fault('invalid_history_transition');}; const id=(v:unknown)=>{if(typeof v!=='string'||!v)throw fault('invalid_history_transition');};
@@ -72,8 +75,9 @@ export function validateHistoryTransition(value:unknown,checkChanges?:(value:unk
} }
} }
/** Conditional metadata-only replay. A mismatch leaves the supplied history untouched. */ /** Conditional metadata-only replay. A mismatch leaves the supplied history untouched. */
/** 先检查栈和每个区间仍匹配预期,再在私有副本上正向/反向回放元数据。 /** 先检查两栈,再逐 entry 检查元数据/数组区间并在私有副本上正向或反向回放。
* 任一冲突抛出而不修改输入 history;不能拿旧 journal 覆盖新的历史顺序。 */ * 不改写输入两栈/entries 表;不能拿旧 journal 覆盖新的历史顺序。
* history/transition 应已捕获:补丁内 Change 会沿用并冻结,不应把此接口当任意外部值的深隔离入口。 */
export function applyHistoryTransition(history:HistoryState,transition:HistoryTransition,direction:'forward'|'reverse'):HistoryState{ export function applyHistoryTransition(history:HistoryState,transition:HistoryTransition,direction:'forward'|'reverse'):HistoryState{
validateHistoryTransition(transition);const reverse=direction==='reverse',source=reverse?transition.after:transition.before,destination=reverse?transition.before:transition.after; validateHistoryTransition(transition);const reverse=direction==='reverse',source=reverse?transition.after:transition.before,destination=reverse?transition.before:transition.after;
if(!equal({undo:history.undo,redo:history.redo},source))throw fault('history_transition_conflict'); if(!equal({undo:history.undo,redo:history.redo},source))throw fault('history_transition_conflict');
@@ -91,6 +95,7 @@ export function applyHistoryTransition(history:HistoryState,transition:HistoryTr
export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{ export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{
const cached=transitionChanges.get(transition);if(cached)return cached; const cached=transitionChanges.get(transition);if(cached)return cached;
validateHistoryTransition(transition); validateHistoryTransition(transition);
// 仅收集被记录的变化区间两侧,未重复保存的共同前后缀不在本结果中;它不是完整 history 的展开。
const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]); const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]);
// 属性来自原型或 getter 时不可把一次结果当永久事实,避免错误释放资源 pin。 // 属性来自原型或 getter 时不可把一次结果当永久事实,避免错误释放资源 pin。
const ownRanges=Object.hasOwn(transition,'entries')&&transition.entries.every(patch=>Object.hasOwn(patch,'changes')&&Object.hasOwn(patch.changes,'before')&&Object.hasOwn(patch.changes,'after')); const ownRanges=Object.hasOwn(transition,'entries')&&transition.entries.every(patch=>Object.hasOwn(patch,'changes')&&Object.hasOwn(patch.changes,'before')&&Object.hasOwn(patch.changes,'after'));
+5 -2
View File
@@ -9,15 +9,17 @@ import { freeze } from './values.ts';
/** Pure history execution, also used to validate recovery proposals before publication. */ /** Pure history execution, also used to validate recovery proposals before publication. */
/** 顺序回放一组历史修改,完成整份文档校验后返回候选文档和实际 changes。 /** 顺序回放一组历史修改,完成整份文档校验后返回候选文档和实际 changes。
* remap 供调用者把其它历史目标同步映射到恢复后的 incarnation;失败时原文档不变。 */ * remap 供调用者把其它历史目标同步映射到恢复后的 incarnation;函数不发布任何候选。
* initial/planned 应来自已捕获的不可变状态,底层 applyChanges/freeze 不是任意外部对象的深隔离入口。 */
export function planHistoryChanges(initial:Document,planned:readonly Change[],registry:Registry){ export function planHistoryChanges(initial:Document,planned:readonly Change[],registry:Registry){
// ID →(旧 incarnation → 新 incarnation);同一批恢复可能形成多级映射链。 // ID →(旧 incarnation → 新 incarnation);同一批恢复可能形成多级映射链。
const generations=new Map<Id,Map<number,number>>(); const generations=new Map<Id,Map<number,number>>();
// 追踪到最终代次,避免后面的字段修改仍指向本批较早的已替换代次。 // 追踪到本批最终代次;每次恢复分配更大代次,因此按这些内部生成的映射链前进。
const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;}; const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;};
// 只改生命周期身份:字段目标与整元素 before/after 都要一致,canvas 变更原样通过。 // 只改生命周期身份:字段目标与整元素 before/after 都要一致,canvas 变更原样通过。
const remap=(change:Change):Change=>{ const remap=(change:Change):Change=>{
if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});} if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});}
// 整元素 before/after 只替换顶层 incarnation,记录内其它字段与引用不在此重新解释。
if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});} if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});}
return change; return change;
}; };
@@ -31,5 +33,6 @@ export function planHistoryChanges(initial:Document,planned:readonly Change[],re
} }
// 暂态允许先恢复端点再恢复边;只有整批完成后才检查最终树、引用和 owner 一致性。 // 暂态允许先恢复端点再恢复边;只有整批完成后才检查最终树、引用和 owner 一致性。
validateDocument(document,registry); validateDocument(document,registry);
// restored 只表示是否建立过恢复代次映射;即使 false,文档也可能因普通字段/删除变化而改变。
return{document,changes:freeze(changes),remap,restored:generations.size>0}; return{document,changes:freeze(changes),remap,restored:generations.size>0};
} }
+3 -1
View File
@@ -21,8 +21,10 @@ export type {DuplicatePlan,DuplicateOptions} from './duplicate.ts';
export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts'; export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts';
export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts'; export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts';
// 查询按不可变文档快照绑定;更新 API 用于沿发布变化推进关系与空间索引。 // 查询按不可变文档快照绑定;此处公开建表/增量推进与查询类型。
// withQueryCoverage 是宿主加载链直接使用的模块 API,没有在这个根入口重导出。
export {createDocumentQueries,advanceDocumentQueries} from './queries.ts'; export {createDocumentQueries,advanceDocumentQueries} from './queries.ts';
export type {DocumentQueries,Bounds,QueryBudget,QueryCoverage,ElementLookup,IdQueryResult} from './queries.ts'; export type {DocumentQueries,Bounds,QueryBudget,QueryCoverage,ElementLookup,IdQueryResult} from './queries.ts';
// 公开共享几何函数;预算曲线细分 hitCubic 和 history 执行规划器等仍从内部模块使用,不在此整包重导出。
export {rectangleGeometry,rectangleBounds,hitRectangle,bezierChainSegments} from './geometry.ts'; export {rectangleGeometry,rectangleBounds,hitRectangle,bezierChainSegments} from './geometry.ts';
+3 -2
View File
@@ -7,8 +7,9 @@ import {clone,freeze} from './values.ts';
// Private provenance for a fresh, recursively frozen JSON clone. A caller can // 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. // request a detached copy, but cannot grant this fact to an existing object.
const detached=new WeakSet<object>(); const detached=new WeakSet<object>();
/** JSON 复制后递归冻结;对象结果记录到 WeakSet,原始值直接返回。 /** JSON 复制后递归冻结;仅对象结果记录到 WeakSet。
* clone 会按 JSON 规则处理数据,所以不可用它代替迁移输入的严格捕获校验。 */ * 输入先经过 JSON 序列化语义:toJSON/getter 可能执行,稀疏槽/数组中的非法 JSON 值可能转为 null。
* 循环或 BigInt 等仍会抛错;它不保留任意输入原貌,不能替代迁移输入的严格捕获校验。 */
export function detachJsonData<T>(input:T):T { export function detachJsonData<T>(input:T):T {
const value=freeze(clone(input)); const value=freeze(clone(input));
if(value&&typeof value==='object')detached.add(value); if(value&&typeof value==='object')detached.add(value);
+27 -13
View File
@@ -3,6 +3,7 @@
* 关系表描述 owner、子 canvas、关联边与声明引用;空间表按 owner canvas 分网格存节点包围盒。 * 关系表描述 owner、子 canvas、关联边与声明引用;空间表按 owner canvas 分网格存节点包围盒。
* 更新采用 touched bucket 的 copy-on-write,未变索引保持身份,供局部订阅复用。 * 更新采用 touched bucket 的 copy-on-write,未变索引保持身份,供局部订阅复用。
* 查询完整性与命中成功分开:没查到不一定不存在,加载不完整/协议失败必须显式报告。 * 查询完整性与命中成功分开:没查到不一定不存在,加载不完整/协议失败必须显式报告。
* 工厂不执行 validateDocument;输入文档须已校验且不可变,局部加载的完整性由宿主绑定 coverage。
*/ */
import {rectangleBounds,hitRectangle} from './geometry.ts'; import {rectangleBounds,hitRectangle} from './geometry.ts';
import type {Change,Document,ElementRecord,Geometry,Id,Point,ReadonlyValueSet,Registry} from './types.ts'; import type {Change,Document,ElementRecord,Geometry,Id,Point,ReadonlyValueSet,Registry} from './types.ts';
@@ -18,7 +19,8 @@ type Relation=Map<Id,ReadonlyValueSet<Id>>;
type Spatial={cells:Relation;entries:Map<Id,Box>;oversized:ReadonlyValueSet<Id>}; type Spatial={cells:Relation;entries:Map<Id,Box>;oversized:ReadonlyValueSet<Id>};
/** 单元素声明引用的正向集合;complete=false 时不能据此证明它没有其它引用。 */ /** 单元素声明引用的正向集合;complete=false 时不能据此证明它没有其它引用。 */
type References={elements:ReadonlySet<Id>;canvases:ReadonlySet<Id>;complete:boolean}; type References={elements:ReadonlySet<Id>;canvases:ReadonlySet<Id>;complete:boolean};
/** cellSize 控制格子边长,两项上限限制单对象/单查询覆盖的格子数,避免坐标范围拖垮枚举。 */ /** cellSize 控制格子边长,两项上限限制单对象/单查询覆盖的格子数,避免坐标范围拖垮枚举。
* 这是格子枚举预算,不限制结果 ID 数或扫描耗时;超限查询可能转而扫描此 owner 全部节点。 */
export type QueryBudget={readonly cellSize?:number;readonly maxElementCells?:number;readonly maxQueryCells?:number}; export type QueryBudget={readonly cellSize?:number;readonly maxElementCells?:number;readonly maxQueryCells?:number};
/** 已补默认值并验证后的预算,后续同一索引链不能静默换配置。 */ /** 已补默认值并验证后的预算,后续同一索引链不能静默换配置。 */
type ResolvedBudget=Required<QueryBudget>; type ResolvedBudget=Required<QueryBudget>;
@@ -49,7 +51,8 @@ function cells(box:Box,limit:number,cellSize:number):string[]|undefined{
if(![x0,x1,y0,y1,count].every(Number.isSafeInteger)||count<1||count>limit)return; if(![x0,x1,y0,y1,count].every(Number.isSafeInteger)||count<1||count>limit)return;
const keys:string[]=[];for(let x=x0;x<=x1;x++)for(let y=y0;y<=y1;y++)keys.push(`${x},${y}`);return keys; const keys:string[]=[];for(let x=x0;x<=x1;x++)for(let y=y0;y<=y1;y++)keys.push(`${x},${y}`);return keys;
} }
/** 只解析组件明确声明的复制引用;未知 schema/解析失败标记不完整,不能猜 payload 中的 ID。 */ /** 只解析组件 copy.references 的声明路径;未知 schema/解析失败标记不完整,不猜 payload 中的 ID。
* complete 仅说明这些声明可解析,不验证被引用对象存在、external 策略或隐藏在未声明字段中的引用。 */
function refs(record:ElementRecord,registry:Registry):References{ function refs(record:ElementRecord,registry:Registry):References{
const elements=new Set<Id>(),canvases=new Set<Id>(),schema=registry.components.get(record.componentId)?.copy; const elements=new Set<Id>(),canvases=new Set<Id>(),schema=registry.components.get(record.componentId)?.copy;
if(registry.resolve(record)!=='known'||!schema)return{elements,canvases,complete:false}; if(registry.resolve(record)!=='known'||!schema)return{elements,canvases,complete:false};
@@ -72,7 +75,8 @@ export type ElementLookup={readonly status:'present';readonly element:ElementRec
export type IdQueryResult={readonly ids:readonly Id[];readonly complete:boolean}; export type IdQueryResult={readonly ids:readonly Id[];readonly complete:boolean};
/** 与一个确切 document/registry/revision 绑定的公共查询。 /** 与一个确切 document/registry/revision 绑定的公共查询。
* members/child/incident 提供稳定排序;Result 版本另外携带加载完整性。 * members/child/incident 提供稳定排序;Result 版本另外携带加载完整性。
* candidateNodes 是空间粗筛,hitNodeBounds 用矩形精判,hitNodes 使用组件 hit 协议。 */ * candidateNodes 是空间粗筛,hitNodeBounds 用矩形精判,hitNodes 使用组件 hit 协议。
* 查询的是指定 owner 的直接节点,不自动投影整棵子树、折叠重定向或检查编辑权限。 */
export type DocumentQueries={ export type DocumentQueries={
readonly coverage:QueryCoverage; readonly coverage:QueryCoverage;
lookupElement(id:Id):ElementLookup; lookupElement(id:Id):ElementLookup;
@@ -101,7 +105,8 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume
// 关系子表按身份复用;referenceComplete 只有全部记录引用声明均可解释时才为真。 // 关系子表按身份复用;referenceComplete 只有全部记录引用声明均可解释时才为真。
const relation=Object.freeze({ownerMembers:previous&&tables.members===privateTables.get(previous)!.members?previous.relations.ownerMembers:readMap(tables.members),canvasChildren:previous&&tables.children===privateTables.get(previous)!.children?previous.relations.canvasChildren:readMap(tables.children),childCanvasByEntry:previous&&tables.childByEntry===privateTables.get(previous)!.childByEntry?previous.relations.childCanvasByEntry:readMap(tables.childByEntry),incidentEdges:previous&&tables.incident===privateTables.get(previous)!.incident?previous.relations.incidentEdges:readMap(tables.incident),elementReferrers:previous&&tables.elementRefs===privateTables.get(previous)!.elementRefs?previous.relations.elementReferrers:readMap(tables.elementRefs),canvasReferrers:previous&&tables.canvasRefs===privateTables.get(previous)!.canvasRefs?previous.relations.canvasReferrers:readMap(tables.canvasRefs),opaqueReferences:tables.opaque,referenceComplete:tables.opaque.size===0}); const relation=Object.freeze({ownerMembers:previous&&tables.members===privateTables.get(previous)!.members?previous.relations.ownerMembers:readMap(tables.members),canvasChildren:previous&&tables.children===privateTables.get(previous)!.children?previous.relations.canvasChildren:readMap(tables.children),childCanvasByEntry:previous&&tables.childByEntry===privateTables.get(previous)!.childByEntry?previous.relations.childCanvasByEntry:readMap(tables.childByEntry),incidentEdges:previous&&tables.incident===privateTables.get(previous)!.incident?previous.relations.incidentEdges:readMap(tables.incident),elementReferrers:previous&&tables.elementRefs===privateTables.get(previous)!.elementRefs?previous.relations.elementReferrers:readMap(tables.elementRefs),canvasReferrers:previous&&tables.canvasRefs===privateTables.get(previous)!.canvasRefs?previous.relations.canvasReferrers:readMap(tables.canvasRefs),opaqueReferences:tables.opaque,referenceComplete:tables.opaque.size===0});
const spatial=spatialViews(tables,previous); const spatial=spatialViews(tables,previous);
/** 从网格收集候选并做 AABB 过滤;超大查询扫描全部 entries,普通查询同时加入 oversized。 */ /** 从网格收集候选并做 AABB 过滤;超大查询扫描全部 entries,普通查询同时加入 oversized。
* 空间表只含 node;曲线命中由其它适配路径完成,返回顺序按模型插入 rank,不是渲染命中优先级。 */
function candidates(canvas:Id,rect:Bounds){ function candidates(canvas:Id,rect:Bounds){
if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);if(![rect.x,rect.y,rect.width,rect.height].every(Number.isFinite)||rect.width<0||rect.height<0)throw fault('invalid_query_bounds'); if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);if(![rect.x,rect.y,rect.width,rect.height].every(Number.isFinite)||rect.width<0||rect.height<0)throw fault('invalid_query_bounds');
// Set 合并多个格子的重复 ID;无效 Box 保留为候选,后续精判负责报告不完整。 // Set 合并多个格子的重复 ID;无效 Box 保留为候选,后续精判负责报告不完整。
@@ -118,9 +123,11 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume
document,registry:tables.registry,revision,budget:tables.budget,relations:relation,spatial, document,registry:tables.registry,revision,budget:tables.budget,relations:relation,spatial,
// 关系查询首先检查目标表项存在,再按元素/画布各自 rank 排序;不是遍历请求 queue。 // 关系查询首先检查目标表项存在,再按元素/画布各自 rank 排序;不是遍历请求 queue。
members:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.members.get(canvas)??empty,tables.order);},childCanvases:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.children.get(canvas)??empty,tables.canvasOrder);},incident:(element:Id)=>{if(!document.elements.has(element))throw fault('element_missing',[element]);return sortedBucket(tables.incident.get(element)??empty,tables.order);},orderedElements:(ids:Iterable<Id>)=>sorted(ids),orderedCanvases:(ids:Iterable<Id>)=>sorted(ids,tables.canvasOrder),candidateNodes:candidates, members:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.members.get(canvas)??empty,tables.order);},childCanvases:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.children.get(canvas)??empty,tables.canvasOrder);},incident:(element:Id)=>{if(!document.elements.has(element))throw fault('element_missing',[element]);return sortedBucket(tables.incident.get(element)??empty,tables.order);},orderedElements:(ids:Iterable<Id>)=>sorted(ids),orderedCanvases:(ids:Iterable<Id>)=>sorted(ids,tables.canvasOrder),candidateNodes:candidates,
/** 点周围容差框粗筛,再调用自定义 hit(无协议则矩形回退);抛错项进入 unavailable。 */ /** 点周围容差框粗筛,再调用自定义 hit;无 node 协议或未知组件时回退到矩形,不自动列入 unavailable。
* 自定义 hit 抛错/返回非 boolean,或缓存 Box 非有限时才列 unavailable;命中不等于 registry 支持编辑。 */
hitNodes(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const hits:Id[]=[],unavailable:Id[]=[];for(const id of candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance})){const record=document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>;try{if(!finite(tables.spatial.get(canvas)!.entries.get(id)!))throw fault('geometry_unavailable');const protocol=tables.registry.resolve(record)==='known'?tables.registry.components.get(record.componentId)?.geometry:undefined,result=protocol?.kind==='node'?protocol.hit({element:record,geometry:record.geometry,point,tolerance}):hitRectangle(record.geometry,point,tolerance);if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push(id);}catch{unavailable.push(id);}}return Object.freeze({ids:Object.freeze(hits),complete:!unavailable.length,unavailable:Object.freeze(unavailable)});}, hitNodes(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const hits:Id[]=[],unavailable:Id[]=[];for(const id of candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance})){const record=document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>;try{if(!finite(tables.spatial.get(canvas)!.entries.get(id)!))throw fault('geometry_unavailable');const protocol=tables.registry.resolve(record)==='known'?tables.registry.components.get(record.componentId)?.geometry:undefined,result=protocol?.kind==='node'?protocol.hit({element:record,geometry:record.geometry,point,tolerance}):hitRectangle(record.geometry,point,tolerance);if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push(id);}catch{unavailable.push(id);}}return Object.freeze({ids:Object.freeze(hits),complete:!unavailable.length,unavailable:Object.freeze(unavailable)});},
/** 对候选按旋转矩形距离精判;无效几何使 complete=false,不当作可靠 miss。 */ /** 对候选按旋转矩形距离精判;缓存 Box 无效使 complete=false,不当作可靠 miss。
* 即使本次只需矩形,前面自定义 bounds 失败产生的 NaN Box 也会让该项跳过,不绕过共享粗筛失败。 */
hitNodeBounds(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const ids=candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance}),hits:Id[]=[];let complete=true;for(const id of ids){const g=(document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>).geometry,box=tables.spatial.get(canvas)!.entries.get(id)!;if(!finite(box)){complete=false;continue;}const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);if(Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height))hits.push(id);}return Object.freeze({ids:Object.freeze(hits),complete});}, hitNodeBounds(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const ids=candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance}),hits:Id[]=[];let complete=true;for(const id of ids){const g=(document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>).geometry,box=tables.spatial.get(canvas)!.entries.get(id)!;if(!finite(box)){complete=false;continue;}const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);if(Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height))hits.push(id);}return Object.freeze({ids:Object.freeze(hits),complete});},
});privateTables.set(view,tables);return view; });privateTables.set(view,tables);return view;
} }
@@ -128,10 +135,12 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume
const queryBases=new WeakMap<DocumentQueries,DocumentQueries>(); const queryBases=new WeakMap<DocumentQueries,DocumentQueries>();
// 相同 namespace 来源只捕获一次只读副本,防止宿主后来修改集合污染已发布证据。 // 相同 namespace 来源只捕获一次只读副本,防止宿主后来修改集合污染已发布证据。
const namespaceCopies=new WeakMap<object,ReadonlyValueSet<Id>>(); const namespaceCopies=new WeakMap<object,ReadonlyValueSet<Id>>();
/** 捕获外部 ID 集合的成员,缓存仅按原对象身份复用;输入合同应视为不可变值。 */ /** 捕获外部 ID 集合的成员,缓存仅按原对象身份复用;输入合同应视为不可变值。
* 同一来源对象后续被原地改动,再绑定也仍取第一次副本;新的证据必须传新的集合对象。 */
function ownedNamespace(ids:ReadonlyValueSet<Id>){let value=namespaceCopies.get(ids);if(!value){value=readSet(ids);namespaceCopies.set(ids,value);}return value;} function ownedNamespace(ids:ReadonlyValueSet<Id>){let value=namespaceCopies.get(ids);if(!value){value=readSet(ids);namespaceCopies.set(ids,value);}return value;}
/** Bind completeness to the same publication as the model, without rebuilding indexes. */ /** Bind completeness to the same publication as the model, without rebuilding indexes. */
/** 不重建索引,仅替换同次发布中的加载证据与完整性方法;未知 canvas/矛盾完整声明拒绝。 */ /** 不重建索引,只绑定调用方提供的加载证据;检查重复/未知完整 canvas 以及 projectComplete 的显式矛盾。
* 不读取服务端 manifest,也不推导声明是否真实;基础 candidateNodes/关系读取仍只返回现有记录。 */
export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):DocumentQueries { export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):DocumentQueries {
const base=queryBases.get(queries)??queries,ids=Object.freeze([...input.completeCanvasIds]); const base=queryBases.get(queries)??queries,ids=Object.freeze([...input.completeCanvasIds]);
if(new Set(ids).size!==ids.length||ids.some(id=>!base.document.canvases.has(id))||input.projectComplete&&(!input.topologyComplete||ids.length!==base.document.canvases.size))throw fault('invalid_query_coverage'); if(new Set(ids).size!==ids.length||ids.some(id=>!base.document.canvases.has(id))||input.projectComplete&&(!input.topologyComplete||ids.length!==base.document.canvases.size))throw fault('invalid_query_coverage');
@@ -139,7 +148,8 @@ export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):D
const coverage=Object.freeze({...(input.elementIds?{elementIds:ownedNamespace(input.elementIds)}:{}),...(input.loadedElementIds?{loadedElementIds:ownedNamespace(input.loadedElementIds)}:{}),projectComplete:input.projectComplete,topologyComplete:input.topologyComplete,completeCanvasIds:ids}),complete=new Set(ids); const coverage=Object.freeze({...(input.elementIds?{elementIds:ownedNamespace(input.elementIds)}:{}),...(input.loadedElementIds?{loadedElementIds:ownedNamespace(input.loadedElementIds)}:{}),projectComplete:input.projectComplete,topologyComplete:input.topologyComplete,completeCanvasIds:ids}),complete=new Set(ids);
const view:DocumentQueries=Object.freeze({...base,coverage, const view:DocumentQueries=Object.freeze({...base,coverage,
relations:Object.freeze({...base.relations,referenceComplete:base.relations.referenceComplete&&coverage.projectComplete}), relations:Object.freeze({...base.relations,referenceComplete:base.relations.referenceComplete&&coverage.projectComplete}),
/** 已有记录优先 present;完整项目、命名空间排除、或已加载且有墓碑时才能判 absent。 */ /** 已有记录优先 present;projectComplete 可直接判缺失为 absent。
* 部分项目须有 elementIds:命名空间排除该 ID,或该 ID 已加载且有墓碑时才判 absent,其余 unloaded。 */
lookupElement:(id:Id):ElementLookup=>{const element=base.document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:coverage.projectComplete||coverage.elementIds&&(!coverage.elementIds.has(id)||coverage.loadedElementIds?.has(id)&&base.document.tombstones.has(id))?'absent':'unloaded'});}, lookupElement:(id:Id):ElementLookup=>{const element=base.document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:coverage.projectComplete||coverage.elementIds&&(!coverage.elementIds.has(id)||coverage.loadedElementIds?.has(id)&&base.document.tombstones.has(id))?'absent':'unloaded'});},
// 成员完整性按 owner,子树按 topology,关联边按整个项目,三者不能互相替代。 // 成员完整性按 owner,子树按 topology,关联边按整个项目,三者不能互相替代。
membersResult:(canvas:Id)=>Object.freeze({ids:base.members(canvas),complete:complete.has(canvas)}), membersResult:(canvas:Id)=>Object.freeze({ids:base.members(canvas),complete:complete.has(canvas)}),
@@ -152,13 +162,16 @@ export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):D
});queryBases.set(view,base);const tables=privateTables.get(base);if(tables)privateTables.set(view,tables);return view; });queryBases.set(view,base);const tables=privateTables.get(base);if(tables)privateTables.set(view,tables);return view;
} }
/** Pure derived query snapshot. Never serialize these tables or publish them as a second document. */ /** Pure derived query snapshot. Never serialize these tables or publish them as a second document. */
/** 从零构造纯派生索引,复用同一 advance 算法,避免维护两套建表规则。 */ /** 从零构造纯派生索引,复用 advance 建表;默认把输入当完整文档,不自动检测部分加载。
* 部分模型调用方必须随后 withQueryCoverage 绑定真实证据,否则默认 complete=true 不代表读取已完成。 */
export function createDocumentQueries(document:Document,registry:Registry,revision=0,options:QueryBudget={}):DocumentQueries{return advanceDocumentQueries(undefined,document,registry,revision,undefined,options);} export function createDocumentQueries(document:Document,registry:Registry,revision=0,options:QueryBudget={}):DocumentQueries{return advanceDocumentQueries(undefined,document,registry,revision,undefined,options);}
/** 在当前发布前准备下一份索引;changes 精确列出受影响 ID,否则按新旧表并集检查。 /** 在当前发布前准备下一份索引;changes 精确列出受影响 ID,否则按新旧表并集检查。
* 必须与同一项目/registry/预算配合,revision 不可倒退,同 revision 不接受另一份 document。 */ * 必须与同一项目/registry/预算配合,revision 不可倒退,同 revision 不接受另一份 document。
* changes 是可信提示,不核对是否漏列真实差异;提供不完整/错误提示可能留下旧索引,无清单时才全表比较。 */
export function advanceDocumentQueries(previous:DocumentQueries|undefined,document:Document,registry:Registry,revision:number,changes?:readonly Change[],options:QueryBudget={}):DocumentQueries{ export function advanceDocumentQueries(previous:DocumentQueries|undefined,document:Document,registry:Registry,revision:number,changes?:readonly Change[],options:QueryBudget={}):DocumentQueries{
if(!Number.isSafeInteger(revision)||revision<0||previous&&(previous.document.projectId!==document.projectId||revision<previous.revision||revision===previous.revision&&document!==previous.document))throw fault('invalid_query_revision'); if(!Number.isSafeInteger(revision)||revision<0||previous&&(previous.document.projectId!==document.projectId||revision<previous.revision||revision===previous.revision&&document!==previous.document))throw fault('invalid_query_revision');
const old=previous?privateTables.get(previous):undefined;if(previous&&!old)throw fault('invalid_query_snapshot');if(old&&old.registry!==registry)throw fault('query_registry_changed');if(old&&Object.keys(options).length&&!equal(old.budget,budget(options)))throw fault('query_budget_changed');if(previous?.document===document&&previous.revision===revision)return previous; const old=previous?privateTables.get(previous):undefined;if(previous&&!old)throw fault('invalid_query_snapshot');if(old&&old.registry!==registry)throw fault('query_registry_changed');if(old&&Object.keys(options).length&&!equal(old.budget,budget(options)))throw fault('query_budget_changed');if(previous?.document===document&&previous.revision===revision)return previous;
// 非空 options 是按默认值补齐后的完整预算声明,不是叠加旧预算的 patch;须与旧配置相等。
// 精确 changes 限定扫描范围;ACK 等没有差异列表的路径保守比较新旧记录。 // 精确 changes 限定扫描范围;ACK 等没有差异列表的路径保守比较新旧记录。
const configuration=old?.budget??budget(options),oldDoc=previous?.document,ids=changes?new Set(changes.filter(change=>change.kind!=='canvas').map(change=>change.kind==='field'?change.target.id:change.id)):new Set([...(oldDoc?.elements.keys()??[]),...document.elements.keys()]),canvasIds=changes?new Set(changes.filter(change=>change.kind==='canvas').map(change=>change.id)):new Set([...(oldDoc?.canvases.keys()??[]),...document.canvases.keys()]); const configuration=old?.budget??budget(options),oldDoc=previous?.document,ids=changes?new Set(changes.filter(change=>change.kind!=='canvas').map(change=>change.kind==='field'?change.target.id:change.id)):new Set([...(oldDoc?.elements.keys()??[]),...document.elements.keys()]),canvasIds=changes?new Set(changes.filter(change=>change.kind==='canvas').map(change=>change.id)):new Set([...(oldDoc?.canvases.keys()??[]),...document.canvases.keys()]);
const members=editRelation(old?.members??new Map()),children=editRelation(old?.children??new Map()),incident=editRelation(old?.incident??new Map()),elementRefs=editRelation(old?.elementRefs??new Map()),canvasRefs=editRelation(old?.canvasRefs??new Map());let childByEntry=old?.childByEntry??new Map<Id,Id>(),forward=old?.forward??new Map<Id,References>(),opaque=old?.opaque??empty,spatial=old?.spatial??new Map<Id,Spatial>(); const members=editRelation(old?.members??new Map()),children=editRelation(old?.children??new Map()),incident=editRelation(old?.incident??new Map()),elementRefs=editRelation(old?.elementRefs??new Map()),canvasRefs=editRelation(old?.canvasRefs??new Map());let childByEntry=old?.childByEntry??new Map<Id,Id>(),forward=old?.forward??new Map<Id,References>(),opaque=old?.opaque??empty,spatial=old?.spatial??new Map<Id,Spatial>();
@@ -177,7 +190,7 @@ export function advanceDocumentQueries(previous:DocumentQueries|undefined,docume
const oldRefs=forward.get(id),schema=after?registry.components.get(after.componentId)?.copy:undefined,referenceFieldsUnchanged=!!changes&&!replaced.has(id)&&!!schema&&schema.references.every(ref=>!dataFields.get(id)?.has(ref.path[0])),nextRefs=after?(before&&oldRefs&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion&&(referenceFieldsUnchanged||equal(before.data,after.data))?oldRefs:refs(after,registry)):undefined; const oldRefs=forward.get(id),schema=after?registry.components.get(after.componentId)?.copy:undefined,referenceFieldsUnchanged=!!changes&&!replaced.has(id)&&!!schema&&schema.references.every(ref=>!dataFields.get(id)?.has(ref.path[0])),nextRefs=after?(before&&oldRefs&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion&&(referenceFieldsUnchanged||equal(before.data,after.data))?oldRefs:refs(after,registry)):undefined;
if(oldRefs!==nextRefs)for(const kind of ['elements','canvases'] as const){const table=kind==='elements'?elementRefs:canvasRefs;for(const target of oldRefs?.[kind]??[])if(!nextRefs?.[kind].has(target))table.change(target,id,false);for(const target of nextRefs?.[kind]??[])if(!oldRefs?.[kind].has(target))table.change(target,id,true);} if(oldRefs!==nextRefs)for(const kind of ['elements','canvases'] as const){const table=kind==='elements'?elementRefs:canvasRefs;for(const target of oldRefs?.[kind]??[])if(!nextRefs?.[kind].has(target))table.change(target,id,false);for(const target of nextRefs?.[kind]??[])if(!oldRefs?.[kind].has(target))table.change(target,id,true);}
if(oldRefs!==nextRefs&&(!!oldRefs?.complete!==!!nextRefs?.complete||!equal([...(oldRefs?.elements??[])],[...(nextRefs?.elements??[])])||!equal([...(oldRefs?.canvases??[])],[...(nextRefs?.canvases??[])])||!oldRefs&&nextRefs||oldRefs&&!nextRefs)){if(forward===old?.forward)forward=new Map(forward);if(nextRefs)forward.set(id,nextRefs);else forward.delete(id);} if(oldRefs!==nextRefs&&(!!oldRefs?.complete!==!!nextRefs?.complete||!equal([...(oldRefs?.elements??[])],[...(nextRefs?.elements??[])])||!equal([...(oldRefs?.canvases??[])],[...(nextRefs?.canvases??[])])||!oldRefs&&nextRefs||oldRefs&&!nextRefs)){if(forward===old?.forward)forward=new Map(forward);if(nextRefs)forward.set(id,nextRefs);else forward.delete(id);}
// 无法解释引用的活元素进入 opaque 集合,提示调用方不能把空引用结果当作完整证明。 // 无法解释引用的活元素进入 opaque 集合;opaqueReferences 描述引用可解释性,不表示整元素已失去几何/显示能力。
const shouldOpaque=!!after&&!nextRefs?.complete;if(opaque.has(id)!==shouldOpaque){const next=new Set(opaque);if(shouldOpaque)next.add(id);else next.delete(id);opaque=readSet(next);} const shouldOpaque=!!after&&!nextRefs?.complete;if(opaque.has(id)!==shouldOpaque){const next=new Set(opaque);if(shouldOpaque)next.add(id);else next.delete(id);opaque=readSet(next);}
// 自定义几何未声明 dataDependencies 时保守重建;明确空数组则普通 data 编辑不重算 Box。 // 自定义几何未声明 dataDependencies 时保守重建;明确空数组则普通 data 编辑不重算 Box。
const was=before?.structure==='node'?before:undefined,now=after?.structure==='node'?after:undefined,protocol=now&&registry.resolve(now)==='known'?registry.components.get(now.componentId)?.geometry:undefined,shapeDataChanged=protocol?.kind==='node'&&!equal(before?.data,after?.data)&&(protocol.dataDependencies===undefined||protocol.dataDependencies.some(key=>!equal(before?.data[key],after?.data[key])));if(was?.ownerCanvasId!==now?.ownerCanvasId||!equal(was?.geometry,now?.geometry)||before?.componentId!==after?.componentId||before?.dataVersion!==after?.dataVersion||shapeDataChanged){if(was)spatialChange(was.ownerCanvasId,id,null);if(now)spatialChange(now.ownerCanvasId,id,nodeBox(now,registry));} const was=before?.structure==='node'?before:undefined,now=after?.structure==='node'?after:undefined,protocol=now&&registry.resolve(now)==='known'?registry.components.get(now.componentId)?.geometry:undefined,shapeDataChanged=protocol?.kind==='node'&&!equal(before?.data,after?.data)&&(protocol.dataDependencies===undefined||protocol.dataDependencies.some(key=>!equal(before?.data[key],after?.data[key])));if(was?.ownerCanvasId!==now?.ownerCanvasId||!equal(was?.geometry,now?.geometry)||before?.componentId!==after?.componentId||before?.dataVersion!==after?.dataVersion||shapeDataChanged){if(was)spatialChange(was.ownerCanvasId,id,null);if(now)spatialChange(now.ownerCanvasId,id,nodeBox(now,registry));}
@@ -187,6 +200,7 @@ export function advanceDocumentQueries(previous:DocumentQueries|undefined,docume
for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after)||!after)continue;children.change(after.canvasId,id,true);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.set(after.entryElementId,id);} for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after)||!after)continue;children.change(after.canvasId,id,true);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.set(after.entryElementId,id);}
// 删除旧格子占用、再插新 Box;超限/无效 Box 放 oversized,不能因预算或错误漏出候选。 // 删除旧格子占用、再插新 Box;超限/无效 Box 放 oversized,不能因预算或错误漏出候选。
if(spatialChanges.size){spatial=new Map(spatial);for(const[canvas,updates]of spatialChanges){const base=spatial.get(canvas),entries=new Map(base?.entries),grid=editRelation(base?.cells??new Map()),large=new Set(base?.oversized??[]);for(const[id,box]of updates){const before=entries.get(id);if(before)for(const key of cells(before,configuration.maxElementCells,configuration.cellSize)??[])grid.change(key,id,false);large.delete(id);if(box){entries.set(id,box);const keys=cells(box,configuration.maxElementCells,configuration.cellSize);if(keys)for(const key of keys)grid.change(key,id,true);else large.add(id);}else entries.delete(id);}if(entries.size)spatial.set(canvas,{entries,cells:grid.finish(),oversized:readSet(large)});else spatial.delete(canvas);}} if(spatialChanges.size){spatial=new Map(spatial);for(const[canvas,updates]of spatialChanges){const base=spatial.get(canvas),entries=new Map(base?.entries),grid=editRelation(base?.cells??new Map()),large=new Set(base?.oversized??[]);for(const[id,box]of updates){const before=entries.get(id);if(before)for(const key of cells(before,configuration.maxElementCells,configuration.cellSize)??[])grid.change(key,id,false);large.delete(id);if(box){entries.set(id,box);const keys=cells(box,configuration.maxElementCells,configuration.cellSize);if(keys)for(const key of keys)grid.change(key,id,true);else large.add(id);}else entries.delete(id);}if(entries.size)spatial.set(canvas,{entries,cells:grid.finish(),oversized:readSet(large)});else spatial.delete(canvas);}}
// 关系桶 finish 后装配;只在增删记录时重算全局插入顺序,最后返回与文档同 revision 的视图。 // 关系桶 finish 后装配;精确 changes 未报告增删时复用旧 rank,否则按文档迭代顺序重算/比较。
// expose 返回基础完整 coverage,不继承旧查询的加载声明;宿主应在同次发布重新绑定当前 coverage。
const tables:Tables={registry,budget:configuration,members:members.finish(),children:children.finish(),childByEntry,incident:incident.finish(),elementRefs:elementRefs.finish(),canvasRefs:canvasRefs.finish(),forward,opaque,spatial,order:old&&changes&&!changes.some(change=>change.kind==='element'&&(!change.before||!change.after))?old.order:order(old?.order,document.elements.keys()),canvasOrder:old&&changes&&!changes.some(change=>change.kind==='canvas'&&(!change.before||!change.after))?old.canvasOrder:order(old?.canvasOrder,document.canvases.keys())};return expose(document,revision,tables,previous); const tables:Tables={registry,budget:configuration,members:members.finish(),children:children.finish(),childByEntry,incident:incident.finish(),elementRefs:elementRefs.finish(),canvasRefs:canvasRefs.finish(),forward,opaque,spatial,order:old&&changes&&!changes.some(change=>change.kind==='element'&&(!change.before||!change.after))?old.order:order(old?.order,document.elements.keys()),canvasOrder:old&&changes&&!changes.some(change=>change.kind==='canvas'&&(!change.before||!change.after))?old.canvasOrder:order(old?.canvasOrder,document.canvases.keys())};return expose(document,revision,tables,previous);
} }
+11 -5
View File
@@ -2,6 +2,7 @@
* 组件定义的装配与显式数据迁移。Registry 保存可复用的定义/handler,不保存元素实例或 view 状态。 * 组件定义的装配与显式数据迁移。Registry 保存可复用的定义/handler,不保存元素实例或 view 状态。
* 装配时把 feature 合成为 componentId→capability→handler 查找表,冲突即拒绝,避免依赖安装顺序。 * 装配时把 feature 合成为 componentId→capability→handler 查找表,冲突即拒绝,避免依赖安装顺序。
* 迁移只生成候选记录,不写 editor;调用方负责把结果作为一次合法事务发布。 * 迁移只生成候选记录,不写 editor;调用方负责把结果作为一次合法事务发布。
* 冻结的是定义配置容器,handler 函数及其闭包仍沿用;容器只读不证明外部上下文不会变化。
*/ */
import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Json, Registry, ReadonlyValueSet } from './types.ts'; import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Json, Registry, ReadonlyValueSet } from './types.ts';
import { clone, fault, freeze, readMap, readSet } from './values.ts'; import { clone, fault, freeze, readMap, readSet } from './values.ts';
@@ -11,7 +12,8 @@ export function defineFeature(id: string, handlers: Partial<Record<Capability, C
if (!id || !Object.values(handlers).length || Object.values(handlers).some(value => typeof value !== 'function')) throw fault('invalid_feature'); if (!id || !Object.values(handlers).length || Object.values(handlers).some(value => typeof value !== 'function')) throw fault('invalid_feature');
return freeze({ id, handlers: { ...handlers } }); return freeze({ id, handlers: { ...handlers } });
} }
/** 校验组件定义与复制/几何协议,捕获其配置容器,防止装配后被外部改写。 */ /** 校验组件定义与复制/几何协议,并复制声明过的配置容器后冻结。
* validate、几何方法、feature handler 和 migration 函数本身保持原引用,不复制函数闭包。 */
export function defineComponent(definition: ComponentDefinition): ComponentDefinition { export function defineComponent(definition: ComponentDefinition): ComponentDefinition {
if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component'); if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component');
// 复制路径访问自有字段,禁止原型污染键;数组遍历由 * 路径段表达。 // 复制路径访问自有字段,禁止原型污染键;数组遍历由 * 路径段表达。
@@ -47,7 +49,8 @@ export function createRegistry(definitions: readonly ComponentDefinition[]): Reg
} }
components.set(definition.id, definition); capabilities.set(definition.id, readSet(found.keys())); handlers.set(definition.id, readMap(found)); components.set(definition.id, definition); capabilities.set(definition.id, readSet(found.keys())); handlers.set(definition.id, readMap(found));
} }
// 只识别已安装的精确数据版本;可保留未知记录,但不能假设它可被当前 handler 编辑。 // 这里只按 componentId+dataVersion 分派,不执行 data schema 或结构校验;known 不是整条记录合法的证明。
// 可保留未知记录,但不能假设它可被当前 handler 编辑。
const resolve: Registry['resolve'] = element => { const resolve: Registry['resolve'] = element => {
const definition = components.get(element.componentId); const definition = components.get(element.componentId);
if (!definition) return 'unknown-component'; if (!definition) return 'unknown-component';
@@ -59,10 +62,10 @@ export function createRegistry(definitions: readonly ComponentDefinition[]): Reg
supports: (element: ElementRecord, capability: Capability) => resolve(element) === 'known' && !!capabilities.get(element.componentId)?.has(capability) }); supports: (element: ElementRecord, capability: Capability) => resolve(element) === 'known' && !!capabilities.get(element.componentId)?.has(capability) });
} }
/** Capture each field once; JSON.stringify would silently erase invalid values. */ /** Capture each field once; JSON.stringify would silently erase invalid values. */
/** 迁移边界的严格 JSON 捕获:每个字段只读取一次,捕获后递归冻结。 /** 迁移边界的 JSON 捕获:遍历的自有可枚举字符串字段取值一次,捕获后递归冻结。
* 不先 JSON.stringify,因为 undefined、函数、非有限数会被丢弃/改值,掩盖非法迁移输出。 */ * 不先 JSON.stringify,因为 undefined、函数、非有限数会被丢弃/改值,掩盖非法迁移输出。 */
function captureMigrationData(input:unknown,id:string):Data { function captureMigrationData(input:unknown,id:string):Data {
// 统一带原元素 ID 抛出 invalid_migration,便于调用方保留原记录并报告失败对象。 // 本函数发现形状非法时带原元素 ID 抛 invalid_migration;getter/转换器自身抛出的异常不在这里包装。
const invalid=():never=>{throw fault('invalid_migration',[id]);}; const invalid=():never=>{throw fault('invalid_migration',[id]);};
// 递归接收 JSON 原子、稠密数组和普通对象;深度上限防止循环/异常深输入无限递归。 // 递归接收 JSON 原子、稠密数组和普通对象;深度上限防止循环/异常深输入无限递归。
const capture=(value:unknown,depth=0):Json=>{ const capture=(value:unknown,depth=0):Json=>{
@@ -78,6 +81,7 @@ function captureMigrationData(input:unknown,id:string):Data {
const result:Json[]=[];for(let index=0;index<value.length;index++){if(!Object.hasOwn(value,index))return invalid();result.push(capture(value[index],depth+1));}return freeze(result); const result:Json[]=[];for(let index=0;index<value.length;index++){if(!Object.hasOwn(value,index))return invalid();result.push(capture(value[index],depth+1));}return freeze(result);
} }
if(Object.getPrototypeOf(value)!==Object.prototype)return invalid(); if(Object.getPrototypeOf(value)!==Object.prototype)return invalid();
// Object.keys 不遍历不可枚举字段;getter 在这里读取,结果复制为普通数据属性,不保留原描述符。
const source=value as Record<string,unknown>; const source=value as Record<string,unknown>;
return freeze(Object.fromEntries(Object.keys(source).map(key=>[key,capture(source[key],depth+1)]))); return freeze(Object.fromEntries(Object.keys(source).map(key=>[key,capture(source[key],depth+1)])));
}; };
@@ -88,9 +92,11 @@ function captureMigrationData(input:unknown,id:string):Data {
/** 按 migrations[旧版本] 逐级转换到当前版本,每步重新捕获,最后通过目标 schema。 /** 按 migrations[旧版本] 逐级转换到当前版本,每步重新捕获,最后通过目标 schema。
* 未知组件/未来版本原样保留;缺 converter 或任一步失败抛错,不返回半迁移记录。 * 未知组件/未来版本原样保留;缺 converter 或任一步失败抛错,不返回半迁移记录。
* ID、incarnation、owner 与其它信封字段不变,转换器只负责 data。 */ * ID、incarnation、owner 与其它信封字段值不变,转换器只负责 data。
* 其余信封对象按引用沿用,返回时 freeze 可能冻结这些共享嵌套值;调用方应传入已捕获的记录。 */
export function migrateElement(element: ElementRecord, registry: Registry): ElementRecord { export function migrateElement(element: ElementRecord, registry: Registry): ElementRecord {
const definition = registry.components.get(element.componentId); const definition = registry.components.get(element.componentId);
// 未安装/未来版本保持原引用且不捕获;即使版本已是当前版本,下方仍捕获 data 并校验 schema。
if (!definition || element.dataVersion > definition.dataVersion) return element; if (!definition || element.dataVersion > definition.dataVersion) return element;
let data = captureMigrationData(element.data,element.id), version = element.dataVersion; let data = captureMigrationData(element.data,element.id), version = element.dataVersion;
// 一个 converter 仅前进一个版本,不把“有版本号”当成自动跨版本兼容承诺。 // 一个 converter 仅前进一个版本,不把“有版本号”当成自动跨版本兼容承诺。
+8 -2
View File
@@ -1,6 +1,7 @@
/** /**
* 文档结构修改的纯规划函数:删除拥有子树,以及重挂后的连线 owner 修复。 * 文档结构修改的纯规划函数:删除拥有子树,以及重挂后的连线 owner 修复。
* 只返回条件 Change,不直接发布 editor 状态;调用方把它们合入一次事务再校验。 * 只返回条件 Change,不直接发布 editor 状态;调用方把它们合入一次事务再校验。
* 文档应是有效、已捕获的不可变快照;返回 before 保留记录引用,freeze 不提供任意外部输入隔离。
*/ */
import type {DocumentQueries} from './queries.ts'; import type {DocumentQueries} from './queries.ts';
import type { Change, Document, Id, Target } from './types.ts'; import type { Change, Document, Id, Target } from './types.ts';
@@ -18,11 +19,13 @@ export function removalChanges(document:Document,target:Target,queries?:Document
while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);stack.push(...queries.childCanvases(id));for(const member of queries.members(id))elements.add(member);} while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);stack.push(...queries.childCanvases(id));for(const member of queries.members(id))elements.add(member);}
// 关联边可能属于更上级 canvas,即使不在子树成员表中也必须随端点删除。 // 关联边可能属于更上级 canvas,即使不在子树成员表中也必须随端点删除。
for(const id of [...elements])for(const edge of queries.incident(id))elements.add(edge); for(const id of [...elements])for(const edge of queries.incident(id))elements.add(edge);
// 按原表顺序输出元素删除,再输出 canvas 删除;最终关系合法性在事务整批完成后检查。
return freeze([...queries.orderedElements(elements).map(id=>({kind:'element' as const,id,before:document.elements.get(id)!,after:null})),...queries.orderedCanvases(canvases).map(id=>({kind:'canvas' as const,id,before:document.canvases.get(id)!,after:null}))]); return freeze([...queries.orderedElements(elements).map(id=>({kind:'element' as const,id,before:document.elements.get(id)!,after:null})),...queries.orderedCanvases(canvases).map(id=>({kind:'canvas' as const,id,before:document.canvases.get(id)!,after:null}))]);
} }
// 扫描路径构造 parent→children 临时表;Set 去重,stack 避免 JS 递归调用栈增长。 // 扫描路径构造 parent→children 临时表;Set 去重,stack 避免 JS 递归调用栈增长。
const element=targetElement(document,target),elements=new Set<Id>([element.id]),canvases=new Set<Id>(),children=new Map<Id,Id[]>(); const element=targetElement(document,target),elements=new Set<Id>([element.id]),canvases=new Set<Id>(),children=new Map<Id,Id[]>();
for(const canvas of document.canvases.values())if(canvas.parent){const list=children.get(canvas.parent.canvasId)??[];list.push(canvas.id);children.set(canvas.parent.canvasId,list);} for(const canvas of document.canvases.values())if(canvas.parent){const list=children.get(canvas.parent.canvasId)??[];list.push(canvas.id);children.set(canvas.parent.canvasId,list);}
// 只从“目标是入口”的子 canvas 开始,不因节点视觉上处在某个矩形内就扩大删除范围。
const stack=[...document.canvases.values()].filter(canvas=>canvas.parent?.entryElementId===element.id).map(canvas=>canvas.id); const stack=[...document.canvases.values()].filter(canvas=>canvas.parent?.entryElementId===element.id).map(canvas=>canvas.id);
while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);for(const child of children.get(id)??[])stack.push(child);} while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);for(const child of children.get(id)??[])stack.push(child);}
// 先收集全部被删成员,再找相关边,防止边扫描先后顺序影响删除结果。 // 先收集全部被删成员,再找相关边,防止边扫描先后顺序影响删除结果。
@@ -35,11 +38,13 @@ export function removalChanges(document:Document,target:Target,queries?:Document
} }
/** Ownership changes and their curve coordinates are one operation, with the same stable edge identity. */ /** Ownership changes and their curve coordinates are one operation, with the same stable edge identity. */
/** 端点重挂后,把边移到两端所属 canvas 的最近共同上级,保持边 ID/incarnation。 /** 端点或端点 owner 改变后,把边移到两端所属 canvas 的最近共同上级,保持边 ID/incarnation。
* 曲线坐标属于旧 owner 空间,因此 owner 与 path 必须作为同一操作一起转换。 */ * 曲线坐标属于旧 owner 空间,因此需要转换时 owner 与 path 必须作为同一操作一起提交。
* 只修复 owner 发生变化的边;不会仅因祖先几何变了就重写同 owner 的局部路径。 */
export function repairConnectionOwners(before:Document,document:Document):readonly Change[] { export function repairConnectionOwners(before:Document,document:Document):readonly Change[] {
const changes:Change[]=[]; const changes:Change[]=[];
for(const edge of document.elements.values())if(edge.structure==='connection'){ for(const edge of document.elements.values())if(edge.structure==='connection'){
// 缺端点暂时跳过,交给整图校验拒绝;此规划器不会自行补端点或删除悬空边。
const source=document.elements.get(edge.source.elementId),target=document.elements.get(edge.target.elementId);if(!source||!target)continue; const source=document.elements.get(edge.source.elementId),target=document.elements.get(edge.target.elementId);if(!source||!target)continue;
const owner=commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId);if(owner===edge.ownerCanvasId)continue; const owner=commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId);if(owner===edge.ownerCanvasId)continue;
// 旧 owner 可能已从新文档移除:优先用 before 的变换解释旧曲线,再转到新 owner。 // 旧 owner 可能已从新文档移除:优先用 before 的变换解释旧曲线,再转到新 owner。
@@ -47,5 +52,6 @@ export function repairConnectionOwners(before:Document,document:Document):readon
changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner}); changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner});
if(!equal(edge.path,path))changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'path',before:edge.path,after:path}); if(!equal(edge.path,path))changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'path',before:edge.path,after:path});
} }
// 这里只返回补偿变化;document 参数不在本函数内被替换或发布。
return freeze(changes); return freeze(changes);
} }
+23 -11
View File
@@ -17,7 +17,8 @@ export type Point = { readonly x: number; readonly y: number };
export type Geometry = Point & { readonly width: number; readonly height: number; readonly rotation: number }; export type Geometry = Point & { readonly width: number; readonly height: number; readonly rotation: number };
/** 拥有树的一条记录;根的 parent 为 null,子 canvas 指向父 canvas 及父内的入口元素。 */ /** 拥有树的一条记录;根的 parent 为 null,子 canvas 指向父 canvas 及父内的入口元素。 */
export type CanvasRecord = { readonly id: Id; readonly parent: null | { readonly canvasId: Id; readonly entryElementId: Id } }; export type CanvasRecord = { readonly id: Id; readonly parent: null | { readonly canvasId: Id; readonly entryElementId: Id } };
/** 元素 ID 加生命周期 incarnation;删除后恢复同 ID 时换代,旧回调不能命中新对象。 */ /** 元素 ID 加生命周期 incarnation;删除后恢复同 ID 时换代,由 targetElement 等运行时检查拦截旧目标。
* 类型本身不验证代次数值,也不是权限凭证。 */
export type Target = { readonly id: Id; readonly incarnation: number }; export type Target = { readonly id: Id; readonly incarnation: number };
/** 连线指向的节点及可选端口;端点的 owner 不必等于连线 owner。 */ /** 连线指向的节点及可选端口;端点的 owner 不必等于连线 owner。 */
export type Endpoint = { readonly elementId: Id; readonly portId?: string }; export type Endpoint = { readonly elementId: Id; readonly portId?: string };
@@ -28,11 +29,13 @@ export type ElementRecord = ElementBase & (
| { readonly structure: 'node'; readonly geometry: Geometry } | { readonly structure: 'node'; readonly geometry: Geometry }
| { readonly structure: 'connection'; readonly source: Endpoint; readonly target: Endpoint; readonly path: Json } | { readonly structure: 'connection'; readonly source: Endpoint; readonly target: Endpoint; readonly path: Json }
); );
/** 项目规范化事实表;父子/引用关系通过 ID 连接,tombstones 保留已死代次下限。 */ /** 项目规范化事实表;父子/引用关系通过 ID 连接,tombstones 保存每个 ID 已死亡的最大代次。
* Map 在运行时使用,持久化要经 codec;readonly 类型不等于 JSON 可直接序列化或输入已校验。 */
export type Document = { readonly projectId: Id; readonly canvases: ReadonlyMap<Id, CanvasRecord>; readonly elements: ReadonlyMap<Id, ElementRecord>; readonly tombstones: ReadonlyMap<Id, number> }; export type Document = { readonly projectId: Id; readonly canvases: ReadonlyMap<Id, CanvasRecord>; readonly elements: ReadonlyMap<Id, ElementRecord>; readonly tombstones: ReadonlyMap<Id, number> };
/** 可修改字段白名单;data. 后缀表示一个顶层键,整份 geometry 是一个条件字段。 */ /** 可修改字段白名单;data. 后缀表示一个顶层键,整份 geometry 是一个条件字段。 */
export type Field = `data.${string}` | 'geometry' | 'ownerCanvasId' | 'source' | 'target' | 'path'; export type Field = `data.${string}` | 'geometry' | 'ownerCanvasId' | 'source' | 'target' | 'path';
/** 字段条件变化:before/after 与存在性一起比较,使缺失和显式 null 不混淆。 */ /** 字段条件变化;data 顶层键结合 beforePresent/afterPresent 区分缺失与显式 null,缺省存在性为 true。
* 非 data 字段按整体 before/after 比较,不用这两个标记表达信封字段缺失。 */
export type FieldChange = { readonly kind: 'field'; readonly target: Target; readonly field: Field; readonly before: Json; readonly after: Json; readonly beforePresent?: boolean; readonly afterPresent?: boolean }; export type FieldChange = { readonly kind: 'field'; readonly target: Target; readonly field: Field; readonly before: Json; readonly after: Json; readonly beforePresent?: boolean; readonly afterPresent?: boolean };
/** 统一变化格式;元素/canvas 的 null 表示不存在,可表达创建、删除、替换及反向修改。 */ /** 统一变化格式;元素/canvas 的 null 表示不存在,可表达创建、删除、替换及反向修改。 */
export type Change = FieldChange export type Change = FieldChange
@@ -59,13 +62,15 @@ export type Rejected = { readonly status: 'rejected'; readonly code: string; rea
export type Applied = { readonly status: 'applied'; readonly revision: number; readonly operationIds: readonly Id[]; readonly historyEntryId?: Id }; export type Applied = { readonly status: 'applied'; readonly revision: number; readonly operationIds: readonly Id[]; readonly historyEntryId?: Id };
/** 公共命令结果的判别联合,调用方必须按 status 处理失败。 */ /** 公共命令结果的判别联合,调用方必须按 status 处理失败。 */
export type Result = Applied | Rejected; export type Result = Applied | Rejected;
/** 一个逻辑撤销单元,包含正向变化和关联操作 ID;不把每个 pointermove 当一项。 */ /** 一个逻辑撤销单元,保存正向 changes 和关联操作 ID;一次 entry 可按 groupId 合并多次提交。
* undo 时计算逆变化,恢复代次后可修订这些正向 changes;它不是永远不变的原请求 body。 */
export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id }; export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id };
/** undo/redo 两个 ID 栈(尾部为栈顶),entries 保存记录,栈项与 ledger 事实分开。 */ /** undo/redo 两个 ID 栈(尾部为栈顶),entries 保存记录,栈项与 ledger 事实分开。 */
export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap<Id, HistoryEntry> }; export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap<Id, HistoryEntry> };
/** 图片默认尺寸解析对原创建历史的修正计划;绑定目标代次与创建 entry,避免多造用户步骤。 */ /** 图片默认尺寸解析对原创建历史的修正计划;绑定目标代次与创建 entry,避免多造用户步骤。 */
export type CreationSizeEffect = { readonly version: 1; readonly kind: 'creation-size'; readonly entryId: Id; readonly target: Target; readonly size: { readonly width: number; readonly height: number } }; export type CreationSizeEffect = { readonly version: 1; readonly kind: 'creation-size'; readonly entryId: Id; readonly target: Target; readonly size: { readonly width: number; readonly height: number } };
/** 所有意图共用的身份、单调序列、参与 canvas、changes、依赖及可选历史修正证据。 */ /** 所有意图共用的身份、单调序列、参与 canvas、changes、依赖及可选历史修正证据。
* operationSchemaVersion 是意图解释版本,historyTransition/historyEffect 是本地历史证据,不是服务端请求版本。 */
type IntentBase = { readonly historyEffect?:CreationSizeEffect; readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] }; type IntentBase = { readonly historyEffect?:CreationSizeEffect; readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
/** 生成任务的稳定输入;目标代次、模型/提示词/参数随逻辑操作保存,凭证不入文档。 */ /** 生成任务的稳定输入;目标代次、模型/提示词/参数随逻辑操作保存,凭证不入文档。 */
export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean }; export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean };
@@ -84,24 +89,31 @@ export type CoreSnapshot = { readonly revision: number; readonly document: Docum
export type Snapshot<Runtime = unknown> = CoreSnapshot & { readonly rootRevision: number; readonly runtime: Runtime }; export type Snapshot<Runtime = unknown> = CoreSnapshot & { readonly rootRevision: number; readonly runtime: Runtime };
/** 命令或能力预检查结果;enabled=false 给出原因,不以按钮是否灰掉代替真正提交校验。 */ /** 命令或能力预检查结果;enabled=false 给出原因,不以按钮是否灰掉代替真正提交校验。 */
export type Availability = { readonly enabled: true } | { readonly enabled: false; readonly code: string; readonly targets: readonly Id[] }; export type Availability = { readonly enabled: true } | { readonly enabled: false; readonly code: string; readonly targets: readonly Id[] };
/** 只暴露读能力的集合合同,forEach 也只接触只读视图。 */ /** 类型合同仅列读方法;真正隐藏 add/delete 和保护 forEach 第三个参数要由 readSet 等工厂实现。
* TypeScript 结构兼容并不阻止外部把原生 Set 当作这个接口传入。 */
export type ReadonlyValueSet<T> = Pick<ReadonlySet<T>, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet<T>) => void): void }; export type ReadonlyValueSet<T> = Pick<ReadonlySet<T>, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet<T>) => void): void };
/** 编辑行为的稳定标识,组件通过安装 feature 获得对应 handler。 */ /** 编辑行为的稳定标识,组件通过安装 feature 获得对应 handler。 */
export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit'; export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit';
/** 读取当前元素和输入,返回字段变化;不能越权写其它元素,也不在这里发布或访问网络。 */ /** 读取当前元素和输入,返回字段变化;约定只规划而不发布/访问网络。
* editor 再检查输出目标/字段;函数类型不自动证明实现纯净,也不能回滚 handler 自己的外部副作用。 */
export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly<Record<string, unknown>> }) => readonly FieldChange[]; export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly<Record<string, unknown>> }) => readonly FieldChange[];
/** 可复用行为集合;同一组件安装重复 capability 时 registry 明确拒绝。 */ /** 可复用行为集合;同一组件安装重复 capability 时 registry 明确拒绝。 */
export type Feature = { readonly id: string; readonly handlers: Readonly<Partial<Record<Capability, CapabilityHandler>>> }; export type Feature = { readonly id: string; readonly handlers: Readonly<Partial<Record<Capability, CapabilityHandler>>> };
/** Path segments address own fields; * iterates array entries. many marks a terminal ID array. */ /** Path segments address own fields; * iterates array entries. many marks a terminal ID array. */
/** 声明 data 内的 ID 路径;* 遍历数组,many 表示终端 ID 数组,external 决定外部引用保留/拒绝。 */ /** 声明 data 内的 ID 路径;* 遍历数组,many 表示终端 ID 数组。
* external 是深复制的外部引用策略;其它规划器可复用路径,但不必沿用复制的 preserve/reject 语义。 */
export type CopyReference = { readonly path: readonly string[]; readonly target: 'element' | 'canvas'; readonly many?: boolean; readonly external: 'preserve' | 'reject' }; export type CopyReference = { readonly path: readonly string[]; readonly target: 'element' | 'canvas'; readonly many?: boolean; readonly external: 'preserve' | 'reject' };
/** 深复制时只重写声明引用并删 omit 字段;不能扫描任意相似字符串猜测引用。 */ /** 深复制时只重写声明引用并删 omit 字段;不能扫描任意相似字符串猜测引用。 */
export type CopySchema = { readonly references: readonly CopyReference[]; readonly omit: readonly (readonly string[])[] }; export type CopySchema = { readonly references: readonly CopyReference[]; readonly omit: readonly (readonly string[])[] };
/** 节点几何协议的计算输入;geometry 可是预览值,不要求等于记录的 committed 几何。 */ /** 节点几何协议的计算输入;geometry 可是预览值,不要求等于记录的 committed 几何。 */
export type NodeGeometryContext={readonly element:Extract<ElementRecord,{structure:'node'}>;readonly geometry:Geometry}; export type NodeGeometryContext={readonly element:Extract<ElementRecord,{structure:'node'}>;readonly geometry:Geometry};
/** 版本化的纯几何扩展:节点 bounds/hit,边 strokeWidth/segments;不依赖 renderer 实例。 */ /** 版本化的纯几何扩展约定:节点 bounds/hit,边 strokeWidth/segments;不依赖 renderer 实例。
* node dataDependencies 缺省时数据变化保守重算,空数组表示不依赖 data;role 提示 bounds 的用途。
* connection 输入已含展示锚点/变换,displayPath 可为 undefined;返回值仍由查询边界验证,类型不代替校验。 */
export type GeometryProtocol=({readonly kind:'node';readonly version:1;readonly dataDependencies?:readonly string[];bounds(context:NodeGeometryContext&{readonly role:'logical'|'hit'|'paint'}):import('./geometry.ts').Rect;hit(context:NodeGeometryContext&{readonly point:Point;readonly tolerance:number}):boolean}|{readonly kind:'connection';readonly version:1;strokeWidth(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):number;segments(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly source:import('./geometry.ts').Connector;readonly target:import('./geometry.ts').Connector;readonly displayPath:Json|undefined;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):readonly import('./geometry.ts').Cubic[]}); export type GeometryProtocol=({readonly kind:'node';readonly version:1;readonly dataDependencies?:readonly string[];bounds(context:NodeGeometryContext&{readonly role:'logical'|'hit'|'paint'}):import('./geometry.ts').Rect;hit(context:NodeGeometryContext&{readonly point:Point;readonly tolerance:number}):boolean}|{readonly kind:'connection';readonly version:1;strokeWidth(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):number;segments(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly source:import('./geometry.ts').Connector;readonly target:import('./geometry.ts').Connector;readonly displayPath:Json|undefined;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):readonly import('./geometry.ts').Cubic[]});
/** 一种组件的定义:精确版本/schema、feature、迁移、资源字段、几何及复制声明,可服务多个实例。 */ /** 一种组件的定义,可服务多个实例:dataVersion/validate、features、逐级 migrations、顶层 resourceFields、geometry 和 copy。
* 未提供 copy 不表示可以猜引用复制;resourceFields 只声明资源扫描入口,不在定义里保存文件字节或用户凭证。 */
export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly<Record<number, (data: Data) => Data>>; readonly resourceFields?: readonly string[]; readonly geometry?:GeometryProtocol; readonly copy?: CopySchema }; export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly<Record<number, (data: Data) => Data>>; readonly resourceFields?: readonly string[]; readonly geometry?:GeometryProtocol; readonly copy?: CopySchema };
/** 只读定义与编译后能力表;resolve 区分未知组件和不支持版本,supports 只允许精确已知版本。 */ /** 只读定义与编译后能力表;resolve 只按组件 ID/精确数据版本分类,不执行 data schema。
* supports 还要求 capability 已安装,仍不代替 policy、命令规划或最终文档校验。 */
export type Registry = { readonly components: ReadonlyMap<string, ComponentDefinition>; readonly capabilities: ReadonlyMap<string, ReadonlyValueSet<Capability>>; readonly handlers: ReadonlyMap<string, ReadonlyMap<Capability, CapabilityHandler>>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean }; export type Registry = { readonly components: ReadonlyMap<string, ComponentDefinition>; readonly capabilities: ReadonlyMap<string, ReadonlyValueSet<Capability>>; readonly handlers: ReadonlyMap<string, ReadonlyMap<Capability, CapabilityHandler>>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean };
+3 -1
View File
@@ -30,13 +30,15 @@ export function presentationRecord(value:Json|undefined):Readonly<Record<string,
unitScale(value);return value; unitScale(value);return value;
} }
/** Normalize only the framework-owned unit field; all other presentation properties survive. */ /** Normalize only the framework-owned unit field; all other presentation properties survive. */
/** 返回替换比例后的元数据:等效值复用原对象,比例为 1 时省略保留字段。 /** 返回替换比例后的元数据:等效值原样复用;需要重写且新比例为 1 时才删除保留字段。
* 仅更新 unitScale,不清掉宿主扩展字段;字段全部清空时返回 undefined。 */ * 仅更新 unitScale,不清掉宿主扩展字段;字段全部清空时返回 undefined。 */
export function withUnitScale(value:Json|undefined,scale:number):Json|undefined { export function withUnitScale(value:Json|undefined,scale:number):Json|undefined {
if(!Number.isFinite(scale)||scale<=0)throw fault('invalid_presentation_units'); if(!Number.isFinite(scale)||scale<=0)throw fault('invalid_presentation_units');
// 坐标换算可能产生接近 1 的浮点误差,统一表示能减少无意义的模型修改。 // 坐标换算可能产生接近 1 的浮点误差,统一表示能减少无意义的模型修改。
const normalized=closeNumber(scale,1)?1:scale; const normalized=closeNumber(scale,1)?1:scale;
// no-op 先返回,因此显式 unitScale:1 不一定被删;原始值/数组按默认比例 1 等效时也可原样通过。
if(closeScale(unitScale(value),normalized))return value; if(closeScale(unitScale(value),normalized))return value;
// 只有确实改比例才要求元数据可合并,拒绝原始值/数组,避免把宿主扩展值覆盖成对象。
const fields=new Map(Object.entries(presentationRecord(value))); const fields=new Map(Object.entries(presentationRecord(value)));
if(normalized===1)fields.delete('unitScale');else fields.set('unitScale',normalized); if(normalized===1)fields.delete('unitScale');else fields.set('unitScale',normalized);
return fields.size?Object.fromEntries(fields):undefined; return fields.size?Object.fromEntries(fields):undefined;
+14 -6
View File
@@ -5,11 +5,13 @@
*/ */
import type { Id, Json, ReadonlyValueSet } from './types.ts'; import type { Id, Json, ReadonlyValueSet } from './types.ts';
/** 构造携带稳定错误码和相关对象 ID 的错误,供命令层返回可解释的拒绝结果。 */ /** 构造携带稳定错误码和相关对象 ID 的错误,供命令层返回可解释的拒绝结果。
* targets 沿用传入数组引用;这个辅助函数本身不捕获或冻结错误明细。 */
export function fault(code: string, targets: readonly Id[] = []): Error & { code: string; targets: readonly Id[] } { export function fault(code: string, targets: readonly Id[] = []): Error & { code: string; targets: readonly Id[] } {
return Object.assign(new Error(code), { code, targets }); return Object.assign(new Error(code), { code, targets });
} }
/** 将捕获的异常归一化为只读 rejected 结果;无领域错误码时使用 invalid_command。 */ /** 将捕获的异常归一化为浅冻结的 rejected 外壳;无领域错误码时使用 invalid_command。
* 已有 targets 数组仍沿用原引用,不应把外壳冻结解释为整个错误结果深度不可变。 */
export function reject(error: unknown) { export function reject(error: unknown) {
const value = error as { code?: string; targets?: readonly string[] }; const value = error as { code?: string; targets?: readonly string[] };
return Object.freeze({ status: 'rejected' as const, code: value?.code || 'invalid_command', targets: value?.targets || [] }); return Object.freeze({ status: 'rejected' as const, code: value?.code || 'invalid_command', targets: value?.targets || [] });
@@ -24,13 +26,15 @@ export function freeze<T>(value: T): T {
return value; return value;
} }
/** JSON 往返复制已验证的数据,切断共享引用;undefined 原样返回。 /** JSON 往返复制已验证的数据,切断共享引用;undefined 原样返回。
* 此处不是校验器:JSON 会丢弃某些非法字段,因此外部输入必须在复制前验证。 */ * 此处不是校验器:JSON 会执行 toJSON、读取可枚举字段并丢弃/改写不支持的值。
* 不保留原型、描述符或共享子对象身份;需要严格输入语义时须在调用边界捕获。 */
export function clone<T>(value: T): T { export function clone<T>(value: T): T {
if (value === undefined) return value; if (value === undefined) return value;
return JSON.parse(JSON.stringify(value)) as T; return JSON.parse(JSON.stringify(value)) as T;
} }
/** 按可枚举自有键递归比较 JSON 风格的值;数组与对象不能互相视为相等。 /** 先以 Object.is 判断,再按可枚举自有字符串键递归比较 JSON 风格的值;数组与对象不能互相视为相等。
* 不处理任意循环对象,也不把 Map/Set 等实例当成业务值来比较。 */ * 不检查 Symbol/不可枚举字段,数组长度本身也不在 Object.keys 中;稀疏数组不适合作为此比较器的输入。
* 不处理任意循环对象,也不提供 Map/Set 成员比较语义。 */
export function equal(a: unknown, b: unknown): boolean { export function equal(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true; if (Object.is(a, b)) return true;
if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false; if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false;
@@ -46,6 +50,7 @@ export function isOwnedMapView(value:object){return ownedMapViews.has(value);}
/** 复制表成员并仅暴露读取能力,外部既拿不到原 Map,也拿不到 set/delete。 /** 复制表成员并仅暴露读取能力,外部既拿不到原 Map,也拿不到 set/delete。
* value 保留原引用,深度不可变性由生产这些 value 的模块负责。 */ * value 保留原引用,深度不可变性由生产这些 value 的模块负责。 */
export function readMap<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> { export function readMap<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> {
// 从此以后 input 的增删与本视图无关;但 K/V 对象仍共享引用,键/值本身不是在此深复制的。
const map = new Map(input); const map = new Map(input);
const view: ReadonlyMap<K, V> = { const view: ReadonlyMap<K, V> = {
get size() { return map.size; }, get: key => map.get(key), has: key => map.has(key), get size() { return map.size; }, get: key => map.get(key), has: key => map.has(key),
@@ -58,6 +63,7 @@ export function readMap<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> {
} }
/** 捕获一份成员集合并隐藏 add/delete;回调同样只接触只读视图。 */ /** 捕获一份成员集合并隐藏 add/delete;回调同样只接触只读视图。 */
export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> { export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> {
// 成员表独立于原 iterable,成员若为对象则仍保留原身份;隐藏可变方法并不冻结对象成员。
const set = new Set(input); const set = new Set(input);
const view: ReadonlyValueSet<T> = { const view: ReadonlyValueSet<T> = {
get size() { return set.size; }, has: key => set.has(key), entries: () => set.entries(), keys: () => set.keys(), values: () => set.values(), get size() { return set.size; }, has: key => set.has(key), entries: () => set.entries(), keys: () => set.keys(), values: () => set.values(),
@@ -66,7 +72,9 @@ export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> {
return Object.freeze(view); return Object.freeze(view);
} }
/** 递归检查 JSON 值域:排除 undefined/函数/非有限数,最多允许 64 层。 /** 递归检查 JSON 值域:排除 undefined/函数/非有限数,最多允许 64 层。
* 这里只检查值形状,不验证组件 schema,也不是所有原型/属性描述符的严格捕获器。 */ * 对象要求 Object.prototype 原型,但只遍历 Object.values;Symbol/不可枚举属性不参与。
* 数组用 every 跳过空洞,也不检查额外属性;它不是稠密数组或所有属性描述符的严格捕获器。
* 校验读取可能触发 getter;这里不证明组件 schema、输入稳定性或数据所有权。 */
export function validJson(value: unknown, depth = 0): value is Json { export function validJson(value: unknown, depth = 0): value is Json {
if (depth > 64) return false; if (depth > 64) return false;
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
+3
View File
@@ -17,6 +17,7 @@ import { loadFoundryModel, type ModelLoad, type ServiceTransport } from './canva
export async function loadCanvasEntry(options: { request: CanvasRequest; workspaceRef: string; boardRef: string; registry: Registry; transport?(accountId: string): ServiceTransport; readModel?(input:{accountId:string;workspaceId:string;canvasId:string;signal?:AbortSignal;navigationGeneration:number;onPreview?:(preview:LiveModelPreview)=>void}):Promise<ModelLoad>;onPreview?:(preview:LiveModelPreview,board:Board,profile:Profile)=>void;signal?: AbortSignal;navigationGeneration?:number }) { export async function loadCanvasEntry(options: { request: CanvasRequest; workspaceRef: string; boardRef: string; registry: Registry; transport?(accountId: string): ServiceTransport; readModel?(input:{accountId:string;workspaceId:string;canvasId:string;signal?:AbortSignal;navigationGeneration:number;onPreview?:(preview:LiveModelPreview)=>void}):Promise<ModelLoad>;onPreview?:(preview:LiveModelPreview,board:Board,profile:Profile)=>void;signal?: AbortSignal;navigationGeneration?:number }) {
const path = `workspaces/${encodeURIComponent(options.workspaceRef)}/materials/${encodeURIComponent(options.boardRef)}`; const path = `workspaces/${encodeURIComponent(options.workspaceRef)}/materials/${encodeURIComponent(options.boardRef)}`;
// slug/路由ref先解析成真实ID,画布kind、引用和工作区/账户信息必须匹配再选择服务作用域。 // slug/路由ref先解析成真实ID,画布kind、引用和工作区/账户信息必须匹配再选择服务作用域。
// 两个读取共享取消信号,但任一拒绝不会自动取消另一个;这里不分配页面状态或编辑租约。
const [board, profile] = await Promise.all([options.request<Board>(path, { signal: options.signal }), options.request<Profile>('profile', { signal: options.signal })]); const [board, profile] = await Promise.all([options.request<Board>(path, { signal: options.signal }), options.request<Profile>('profile', { signal: options.signal })]);
options.signal?.throwIfAborted(); options.signal?.throwIfAborted();
if (board.kind !== 'board' || !matchesRef(board, options.boardRef) || !board.workspace_id || !profile.id) throw new Error('invalid_reference'); if (board.kind !== 'board' || !matchesRef(board, options.boardRef) || !board.workspace_id || !profile.id) throw new Error('invalid_reference');
@@ -24,6 +25,7 @@ export async function loadCanvasEntry(options: { request: CanvasRequest; workspa
let migration:Extract<ModelLoad,{status:'migration-required'}>|undefined; let migration:Extract<ModelLoad,{status:'migration-required'}>|undefined;
try { try {
// 预览回调携带同次读取的board/profile,navigationGeneration由共享loader隔离导航结果。 // 预览回调携带同次读取的board/profile,navigationGeneration由共享loader隔离导航结果。
// 注入的 readModel 优先于 transport;入口本身不去重请求,也不保存或撤销已发出的预览。
const model = options.readModel?await options.readModel({accountId:profile.id,workspaceId:board.workspace_id,canvasId:board.id,signal:options.signal,navigationGeneration:options.navigationGeneration??0,...(options.onPreview?{onPreview:(preview:LiveModelPreview)=>options.onPreview!(preview,board,profile)}:{})}):await loadFoundryModel(options.transport!(profile.id), board.workspace_id, board.id, options.registry, options.signal); const model = options.readModel?await options.readModel({accountId:profile.id,workspaceId:board.workspace_id,canvasId:board.id,signal:options.signal,navigationGeneration:options.navigationGeneration??0,...(options.onPreview?{onPreview:(preview:LiveModelPreview)=>options.onPreview!(preview,board,profile)}:{})}):await loadFoundryModel(options.transport!(profile.id), board.workspace_id, board.id, options.registry, options.signal);
options.signal?.throwIfAborted(); options.signal?.throwIfAborted();
// 新模型一旦ready立即走新版,不能因旧数据端点仍存在就继续让旧writer写它。 // 新模型一旦ready立即走新版,不能因旧数据端点仍存在就继续让旧writer写它。
@@ -39,5 +41,6 @@ export async function loadCanvasEntry(options: { request: CanvasRequest; workspa
} }
// 旧请求显式协商v2快照;调用页面仍负责对当前导航/身份校验,不在这里安装页面状态。 // 旧请求显式协商v2快照;调用页面仍负责对当前导航/身份校验,不在这里安装页面状态。
const snapshot = decodeSnapshot(await options.request(path + '/canvas', { signal: options.signal, headers: { Accept: CANVAS_ACCEPT } })); const snapshot = decodeSnapshot(await options.request(path + '/canvas', { signal: options.signal, headers: { Accept: CANVAS_ACCEPT } }));
// 此回退路径在 await 后没有再次 throwIfAborted;宿主仍须核对导航/身份后才能采用结果。
return { mode: 'legacy' as const, board, profile, snapshot,...(migration?{migration}:{}) }; return { mode: 'legacy' as const, board, profile, snapshot,...(migration?{migration}:{}) };
} }
+2
View File
@@ -10,7 +10,9 @@ import { matchesRef, type Board } from "./workspace.ts";
* 返回数据不直接更新UI,导航是否仍有效由调用方控制。 */ * 返回数据不直接更新UI,导航是否仍有效由调用方控制。 */
export async function loadBoardCanvas(request: CanvasRequest, workspaceRef: string, boardRef: string, signal?: AbortSignal) { export async function loadBoardCanvas(request: CanvasRequest, workspaceRef: string, boardRef: string, signal?: AbortSignal) {
const path = `workspaces/${encodeURIComponent(workspaceRef)}/materials/${encodeURIComponent(boardRef)}`; const path = `workspaces/${encodeURIComponent(workspaceRef)}/materials/${encodeURIComponent(boardRef)}`;
// 两次请求同时启动;没有结果发布,也不会因一个失败主动取消另一个。
const [board, snapshot] = await Promise.all([request<Board>(path, { signal }), request<CanvasSnapshot>(path + "/canvas", { signal, headers: { Accept: CANVAS_ACCEPT } })]); const [board, snapshot] = await Promise.all([request<Board>(path, { signal }), request<CanvasSnapshot>(path + "/canvas", { signal, headers: { Accept: CANVAS_ACCEPT } })]);
if (board.kind !== "board" || !matchesRef(board, boardRef) || typeof board.workspace_id !== "string" || !board.workspace_id) throw new Error("invalid_reference"); if (board.kind !== "board" || !matchesRef(board, boardRef) || typeof board.workspace_id !== "string" || !board.workspace_id) throw new Error("invalid_reference");
// decodeSnapshot 验证旧协议快照;此函数不另做 await 后取消检查或 canonical 迁移状态探测。
return { board, snapshot: decodeSnapshot(snapshot) }; return { board, snapshot: decodeSnapshot(snapshot) };
} }
+5 -1
View File
@@ -85,13 +85,14 @@ export function createCanvasMigration(options:{
isCurrentIdentity():boolean;createId():Id;lock<T>(work:()=>Promise<T>):Promise<T>;signal?:AbortSignal; isCurrentIdentity():boolean;createId():Id;lock<T>(work:()=>Promise<T>):Promise<T>;signal?:AbortSignal;
}){ }){
const key=canvasMigrationStorageKey(options.scope);let active=false; const key=canvasMigrationStorageKey(options.scope);let active=false;
/** 每个异步边界重查账户与取消状态,避免迟到结果删除另一身份正在使用的记录。 */ /** 在协调流程标明的异步交接点重查账户与取消;注入 transport/drain/lock 也须遵守自身生命周期契约。 */
const current=()=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal?.throwIfAborted();}; const current=()=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal?.throwIfAborted();};
/** 本实例读取也受当前身份护栏约束,原始readCanvasMigration仅负责数据格式。 */ /** 本实例读取也受当前身份护栏约束,原始readCanvasMigration仅负责数据格式。 */
const read=()=>{current();return readCanvasMigration(options.storage,options.scope);}; const read=()=>{current();return readCanvasMigration(options.storage,options.scope);};
/** 迁移/回执查询始终按冻结root寻址,不按当前页面任意子分区重定向。 */ /** 迁移/回执查询始终按冻结root寻址,不按当前页面任意子分区重定向。 */
const path=(root:Id)=>`workspaces/${encodeURIComponent(options.scope.workspaceId)}/materials/${encodeURIComponent(root)}/canvas/model`; const path=(root:Id)=>`workspaces/${encodeURIComponent(options.scope.workspaceId)}/materials/${encodeURIComponent(root)}/canvas/model`;
/** 写入并回读完全相同的编码,失败不继续发请求;这是本地恢复前置条件,不是服务端成功证明。 */ /** 写入并回读完全相同的编码,失败不继续发请求;这是本地恢复前置条件,不是服务端成功证明。 */
// 同步回读验证当前 Storage 可见值;不等于跨设备副本、数据库事务或磁盘刷写证明。
function save(job:CanvasMigrationJob){ function save(job:CanvasMigrationJob){
current();const encoded=JSON.stringify(job);options.storage.setItem(key,encoded); current();const encoded=JSON.stringify(job);options.storage.setItem(key,encoded);
if(options.storage.getItem(key)!==encoded)throw fault('migration_not_durable'); if(options.storage.getItem(key)!==encoded)throw fault('migration_not_durable');
@@ -123,6 +124,7 @@ export function createCanvasMigration(options:{
sources.add(source);targets.add(target); sources.add(source);targets.add(target);
} }
// 目标ID唯一且结构匹配,映射数量必须覆盖迁移后每个元素,不能漏一部分就切换编辑器。 // 目标ID唯一且结构匹配,映射数量必须覆盖迁移后每个元素,不能漏一部分就切换编辑器。
// 这里没有重新读旧快照逐项核对 sourceId;源清单真实性仍依赖服务端迁移协议和冻结版本。
if(targets.size!==loaded.document.elements.size)throw fault('invalid_migration_receipt'); if(targets.size!==loaded.document.elements.size)throw fault('invalid_migration_receipt');
}catch(error){current();return{status:'pending',job,error:(error as {code?:string}).code??'invalid_migration_receipt'};} }catch(error){current();return{status:'pending',job,error:(error as {code?:string}).code??'invalid_migration_receipt'};}
// 只在整个成功回执通过后删除本地pending;删除也须回读确认,失败保留重试义务。 // 只在整个成功回执通过后删除本地pending;删除也须回读确认,失败保留重试义务。
@@ -143,6 +145,7 @@ export function createCanvasMigration(options:{
// The current canonical receipt endpoint omits protocol_version on unknown. // The current canonical receipt endpoint omits protocol_version on unknown.
// This is permission to retry the exact idempotent migration, never zero-write proof. // This is permission to retry the exact idempotent migration, never zero-write proof.
// 此协议允许unknown后重发同一幂等请求,绝不因此改requestId/body或宣称先前零写入。 // 此协议允许unknown后重发同一幂等请求,绝不因此改requestId/body或宣称先前零写入。
// 仅 query 分支转一次 POST;POST 仍未知则 accept 返回 pending,不在这里无限轮询。
if(query&&response.status===404&&(body?.protocol_version===undefined||body.protocol_version===2)&&body?.request_id===job.id&&body.status==='unknown')return send(job,false); if(query&&response.status===404&&(body?.protocol_version===undefined||body.protocol_version===2)&&body?.request_id===job.id&&body.status==='unknown')return send(job,false);
return accept(job,response); return accept(job,response);
} }
@@ -165,6 +168,7 @@ export function createCanvasMigration(options:{
if(previous.rootId!==rootId)throw fault('workspace_migration_pending'); if(previous.rootId!==rootId)throw fault('workspace_migration_pending');
return send(previous,true); // Never flush old writers after an unknown identity change. return send(previous,true); // Never flush old writers after an unknown identity change.
} }
// drain 的范围由宿主保证;本协调器等待它完成,不自行枚举/停止旧 writer。
await drain();current(); await drain();current();
const source=await loadFoundryModel(options.transport,options.scope.workspaceId,rootId,options.registry,options.signal);current(); const source=await loadFoundryModel(options.transport,options.scope.workspaceId,rootId,options.registry,options.signal);current();
if(source.status==='ready')return{status:'already-migrated',rootId:source.document.projectId}; if(source.status==='ready')return{status:'already-migrated',rootId:source.document.projectId};
+3 -1
View File
@@ -14,6 +14,7 @@ export async function fetchCanvasResponse(url: string, options: RequestInit, lim
const abort = new AbortController(); const abort = new AbortController();
// 外部请求取消与内部超时任一生效都中止同一上游fetch,避免客户端已离开却继续传输。 // 外部请求取消与内部超时任一生效都中止同一上游fetch,避免客户端已离开却继续传输。
const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal; const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal;
/** 三种时限共用内部取消源;只发送取消信号,实际唤醒等待依赖 request/reader 遵守取消契约。 */
const timeout = () => abort.abort(new DOMException("Canvas transfer timed out", "TimeoutError")); const timeout = () => abort.abort(new DOMException("Canvas transfer timed out", "TimeoutError"));
// 初始计时器只约束拿响应头,取得Response后清掉,再给body设置独立总时限。 // 初始计时器只约束拿响应头,取得Response后清掉,再给body设置独立总时限。
let timer = setTimeout(timeout, limits.headersMs); let timer = setTimeout(timeout, limits.headersMs);
@@ -38,7 +39,8 @@ export async function fetchCanvasResponse(url: string, options: RequestInit, lim
}; };
let idle: ReturnType<typeof setTimeout> | undefined; let idle: ReturnType<typeof setTimeout> | undefined;
let finished = false; let finished = false;
/** 标记结束并清除总/空闲计时与组合signal监听,后续迟到read不能再次enqueue或重复结束。 */ /** 标记结束并清除总/空闲计时与组合signal监听,后续迟到read不能再次enqueue或重复结束。
* 这里只清理调度资源,不 releaseLock;reader 的取消在中断/下游 cancel 路径另行执行。 */
const cleanup = () => { finished = true; clearTimeout(timer); clearTimeout(idle); signal.removeEventListener("abort", interrupted); }; const cleanup = () => { finished = true; clearTimeout(timer); clearTimeout(idle); signal.removeEventListener("abort", interrupted); };
let interrupt: (reason: unknown) => void; let interrupt: (reason: unknown) => void;
/** 将组合signal的取消原因交给流start中安装的中断处理器。 */ /** 将组合signal的取消原因交给流start中安装的中断处理器。 */
+17 -7
View File
@@ -11,13 +11,17 @@ import { runTerminal, type RunPort } from './runs.ts';
import type { AssetPort } from './resources.ts'; import type { AssetPort } from './resources.ts';
/** Stage acknowledgements only. The caller must validate a complete fresh read and durably publish it. */ /** Stage acknowledgements only. The caller must validate a complete fresh read and durably publish it. */
/** 在私有表上解释已保存回执,返回 staged 快照与 fresh read 断言;缺证据/解释器即整体拒绝。 */ /** 取全部非 resolved 请求,要求已有 outcome=applied 且有保存回执,再按最早逻辑 sequence 解释。
* 只构造 ledger/runs/resources/conflicts 候选,document/history/acknowledged/versions 仍沿用原 snapshot。
* 没有待处理请求时也可返回空 stage;不会自己读取 canonical、重放 pending、发布 Root 或等待持久化。
* 依赖输入账本及 ports 的验证/纯解释合同,表复制不隔离恶意 adapter 对传入记录的修改;now 合法性也由宿主保证。 */
export function stageAppliedRecovery(snapshot:EngineSnapshot,ports:{persistence:PersistencePort;runs?:RunPort;assets?:AssetPort;now:number}) { export function stageAppliedRecovery(snapshot:EngineSnapshot,ports:{persistence:PersistencePort;runs?:RunPort;assets?:AssetPort;now:number}) {
const waiting=[...snapshot.runtime.ledger.requests.values()].filter(request=>request.phase!=='resolved'); const waiting=[...snapshot.runtime.ledger.requests.values()].filter(request=>request.phase!=='resolved');
// 只要还有 accepted/unresolved,就不能把整组当成确定已执行恢复,也不能新建替代请求重发。 // 只要还有 accepted/unresolved,就不能把整组当成确定已执行恢复,也不能新建替代请求重发。
const unknown=waiting.filter(request=>request.outcome!=='applied'); const unknown=waiting.filter(request=>request.outcome!=='applied');
if(unknown.length)throw fault('recovery_waiting_for_receipts',unknown.map(request=>request.id)); if(unknown.length)throw fault('recovery_waiting_for_receipts',unknown.map(request=>request.id));
/** 同一请求可能包含多个意图,以最早逻辑 sequence 排序,保证 run 受理先于其控制动作解释。 */ /** 以请求成员中最小 sequence 排序;成员缺失会取 Infinity,不在此独立证明账本成员/依赖合法。
* 合法顺序让 run 受理先建立候选记录,后续控制动作才能定位它;不是按回执到达时间解释。 */
const sequence=(request:FrozenRequest)=>request.operationIds.reduce((result,id)=>Math.min(result,snapshot.runtime.ledger.operations.get(id)?.intent.sequence??Infinity),Infinity); const sequence=(request:FrozenRequest)=>request.operationIds.reduce((result,id)=>Math.min(result,snapshot.runtime.ledger.operations.get(id)?.intent.sequence??Infinity),Infinity);
waiting.sort((a,b)=>sequence(a)-sequence(b)); waiting.sort((a,b)=>sequence(a)-sequence(b));
// 新建候选账本和资源/run 表,后面的任何校验失败不会污染原 snapshot。 // 新建候选账本和资源/run 表,后面的任何校验失败不会污染原 snapshot。
@@ -37,7 +41,8 @@ export function stageAppliedRecovery(snapshot:EngineSnapshot,ports:{persistence:
}else if(['run.start','run.cancel','run.apply'].includes(request.kind??'')){ }else if(['run.start','run.cancel','run.apply'].includes(request.kind??'')){
if(!ports.runs?.verifyApplied)throw fault('run_receipt_interpreter_unavailable',[request.id]); if(!ports.runs?.verifyApplied)throw fault('run_receipt_interpreter_unavailable',[request.id]);
const verified=ports.runs.verifyApplied(request,receipt);proof=verified.model; const verified=ports.runs.verifyApplied(request,receipt);proof=verified.model;
// 受理必须绑定原 intent/request/board/target;已有 run 也必须具有相同身份和计划。 // 新受理核对原 intent/request/board/target,已有同 runId 时再比较 request/operation/plan。
// 已有记录保留其当前进度,不用旧 admission 覆盖;完整 run 形状/服务身份还依赖 verifyApplied 与外围校验。
if(request.kind==='run.start'){ if(request.kind==='run.start'){
const run=verified.admission,intent=snapshot.runtime.ledger.operations.get(request.operationIds[0])?.intent; const run=verified.admission,intent=snapshot.runtime.ledger.operations.get(request.operationIds[0])?.intent;
if(!run||verified.action||intent?.operationType!=='run.start'||run.operationId!==intent.id||run.requestId!==request.id||run.boardId!==intent.run.boardId||!equal(run.target,intent.run.target))throw fault('run_identity_mismatch'); if(!run||verified.action||intent?.operationType!=='run.start'||run.operationId!==intent.id||run.requestId!==request.id||run.boardId!==intent.run.boardId||!equal(run.target,intent.run.target))throw fault('run_identity_mismatch');
@@ -50,23 +55,28 @@ export function stageAppliedRecovery(snapshot:EngineSnapshot,ports:{persistence:
if(request.kind==='run.cancel'){ if(request.kind==='run.cancel'){
if(!action.runStatus||action.outputId||runTerminal(run.status)&&runTerminal(action.runStatus)&&run.status!==action.runStatus)throw fault('run_status_conflict'); if(!action.runStatus||action.outputId||runTerminal(run.status)&&runTerminal(action.runStatus)&&run.status!==action.runStatus)throw fault('run_status_conflict');
}else if(!action.outputId||action.runStatus)throw fault('run_identity_mismatch'); }else if(!action.outputId||action.runStatus)throw fault('run_identity_mismatch');
// 控制 epoch 递增阻止旧 poll 盖回新状态;标 hasMore/nextPollAt 让任务按原 ID 继续追尾。 // 控制动作推进 epoch 并去重登记已采用 output ID,清旧 pendingPage/错误,让原 run ID 从 now 继续追尾。
// 这里只暂存状态,不开始 poll;clock/epoch 范围与当前身份仍需宿主最终采纳时验证。
runs.set(run.id,freeze({...run,controlEpoch:(run.controlEpoch??0)+1,...(action.runStatus?{status:action.runStatus}:{}),...(action.outputId?{appliedOutputIds:[...new Set([...(run.appliedOutputIds??[]),action.outputId])]}:{}),hasMore:true,blocked:false,error:undefined,pendingPage:undefined,nextPollAt:ports.now}));runIds.add(run.id); runs.set(run.id,freeze({...run,controlEpoch:(run.controlEpoch??0)+1,...(action.runStatus?{status:action.runStatus}:{}),...(action.outputId?{appliedOutputIds:[...new Set([...(run.appliedOutputIds??[]),action.outputId])]}:{}),hasMore:true,blocked:false,error:undefined,pendingPage:undefined,nextPollAt:ports.now}));runIds.add(run.id);
} }
}else{ }else{
if(!ports.persistence.verifyApplied)throw fault('document_receipt_interpreter_unavailable',[request.id]); if(!ports.persistence.verifyApplied)throw fault('document_receipt_interpreter_unavailable',[request.id]);
proof=ports.persistence.verifyApplied(request,receipt); proof=ports.persistence.verifyApplied(request,receipt);
} }
// 各请求有不同写版本范围:start 只 owner,cancel/upload 无文档写,普通/apply 要完整参与者。 // 证明版本键必须恰好覆盖写范围:start 只 owner,cancel/upload 空,普通/apply 为 request.canvasIds。
// 这里只要求 proof 版本是安全整数且高于原 expected,不独自要求恰好 +1;精确协议规则由解释器保证。
const writes=request.kind==='run.start'?[request.ownerCanvasId!]:request.kind==='run.cancel'||request.kind==='asset.upload'?[]:request.canvasIds; const writes=request.kind==='run.start'?[request.ownerCanvasId!]:request.kind==='run.cancel'||request.kind==='asset.upload'?[]:request.canvasIds;
if(writes.length!==(proof?.versions.size??0)||writes.some(id=>!proof?.versions.has(id)))throw fault('receipt_coverage_mismatch',[request.id]); if(writes.length!==(proof?.versions.size??0)||writes.some(id=>!proof?.versions.has(id)))throw fault('receipt_coverage_mismatch',[request.id]);
if(proof){for(const [canvas,version]of proof.versions)if(!Number.isSafeInteger(version)||version<=request.expectedVersions[canvas])throw fault('receipt_version_mismatch',[canvas]);proofs.push(proof);} if(proof){for(const [canvas,version]of proof.versions)if(!Number.isSafeInteger(version)||version<=request.expectedVersions[canvas])throw fault('receipt_version_mismatch',[canvas]);proofs.push(proof);}
// 仅在候选账本标已协调 applied;真正采用仍需外层 fresh read 校验和持久化。 // result 默认按 reconciled applied 归约候选账本并移除此 request 的冲突,原账本/原冻结 body 仍不变。
// staged 未通过 fresh read 断言前不可直接发布,也不因这一步就释放真实 writer 或开始下一请求。
ledger.result(request.id,'applied');conflicts.delete(request.id); ledger.result(request.id,'applied');conflicts.delete(request.id);
} }
const staged:EngineSnapshot=Object.freeze({...snapshot,runtime:Object.freeze({...snapshot.runtime,ledger:ledger.getSnapshot(),runs:readMap(runs),resources:readMap(resources),conflicts:readMap(conflicts)})}); const staged:EngineSnapshot=Object.freeze({...snapshot,runtime:Object.freeze({...snapshot.runtime,ledger:ledger.getSnapshot(),runs:readMap(runs),resources:readMap(resources),conflicts:readMap(conflicts)})});
return Object.freeze({snapshot:staged,requestIds:Object.freeze(waiting.map(request=>request.id)),runIds:Object.freeze([...runIds]), return Object.freeze({snapshot:staged,requestIds:Object.freeze(waiting.map(request=>request.id)),runIds:Object.freeze([...runIds]),
/** fresh read 版本不得落后于任何执行证明,并逐个执行协议专用内容断言。 */ /** 核对 fresh 版本至少覆盖各 proof 水位,再逐个调用协议内容断言;不拿旧回执模型覆盖 fresh。
* 不在此调用 validateCanonicalRead/validateDocument 或验证读取来源,engine 须先完成这些检查。
* proof Map/回调按端口不可变纯证据合同保留;断言成功也不是持久化完成回执。 */
assertCanonical(canonical:CanonicalProposal){for(const proof of proofs){for(const [canvas,version]of proof.versions)if((canonical.versions.get(canvas)??-1)<version)throw fault('recovery_read_behind_receipt',[canvas]);proof.assertCanonical(canonical);}}, assertCanonical(canonical:CanonicalProposal){for(const proof of proofs){for(const [canvas,version]of proof.versions)if((canonical.versions.get(canvas)??-1)<version)throw fault('recovery_read_behind_receipt',[canvas]);proof.assertCanonical(canonical);}},
}); });
} }
+34 -14
View File
@@ -2,6 +2,7 @@
* 共享原图的 IndexedDB 实现:asset-depot 存身份/退休事实,asset-bytes 按摘要共享 Blob, * 共享原图的 IndexedDB 实现:asset-depot 存身份/退休事实,asset-bytes 按摘要共享 Blob,
* asset-pins 按项目存与 Root 版本绑定的完整保留清单。所有 execute 都经过主存储的 leader/fence 检查。 * asset-pins 按项目存与 Root 版本绑定的完整保留清单。所有 execute 都经过主存储的 leader/fence 检查。
* 回收只清本地共享字节,保留身份;同摘要还有其它活身份时不能删实际字节。 * 回收只清本地共享字节,保留身份;同摘要还有其它活身份时不能删实际字节。
* 本模块不创建/提交事务:写入辅助函数借用外层事务,read/collect 借用注入的 execute;错误由其统一 abort。
*/ */
import {equal,fault,freeze} from '../canvas-core/values.ts'; import {equal,fault,freeze} from '../canvas-core/values.ts';
import {sharedAssetRef,validateSharedAsset,type AssetDepotPort,type DepotCut,type DepotEntry,type SharedAsset} from './asset-depot.ts'; import {sharedAssetRef,validateSharedAsset,type AssetDepotPort,type DepotCut,type DepotEntry,type SharedAsset} from './asset-depot.ts';
@@ -24,23 +25,31 @@ function authority(value:string){if(typeof value!=='string'||!value||value.lengt
/** 读取身份记录时验证 ref 与键一致、退休时钟合法,损坏数据不能被回收过程忽略。 */ /** 读取身份记录时验证 ref 与键一致、退休时钟合法,损坏数据不能被回收过程忽略。 */
function validateFact(row:Fact,scopeAuthority:string,id:string){validateSharedAsset(row?.ref);if(row.ref.authority!==scopeAuthority||row.ref.asset.id!==id)throw fault('corrupt_asset_depot');for(const n of [row.unreferencedAt,row.purgedAt])if(n!==undefined&&(!Number.isSafeInteger(n)||n<0))throw fault('corrupt_asset_depot');if(row.purgedAt!==undefined&&(row.unreferencedAt===undefined||row.purgedAt<row.unreferencedAt))throw fault('corrupt_asset_depot');} function validateFact(row:Fact,scopeAuthority:string,id:string){validateSharedAsset(row?.ref);if(row.ref.authority!==scopeAuthority||row.ref.asset.id!==id)throw fault('corrupt_asset_depot');for(const n of [row.unreferencedAt,row.purgedAt])if(n!==undefined&&(!Number.isSafeInteger(n)||n<0))throw fault('corrupt_asset_depot');if(row.purgedAt!==undefined&&(row.unreferencedAt===undefined||row.purgedAt<row.unreferencedAt))throw fault('corrupt_asset_depot');}
/** Detach descriptors before awaiting crypto. Blob data is immutable and never JSON cloned. */ /** Detach descriptors before awaiting crypto. Blob data is immutable and never JSON cloned. */
/** 第一个 await 前捕获/冻结 descriptors,校验 pin/写入唯一性;Blob 不做 JSON clone。 */ /** 同步重建 pin 数组与标准引用字段,让外层能在第一个 await 前捕获 cut;Blob 沿用不可变字节对象。
* pin ID 与 write ID 分别去重,不要求两表完全相等,也不证明 complete 声明覆盖全部 Root 引用。
* sharedAssetRef 保留 asset 扩展属性的引用,冻结不等于任意外来对象的递归深复制。 */
export function detachDepotCut(cut:DepotCut):DepotCut{ export function detachDepotCut(cut:DepotCut):DepotCut{
if(!cut||cut.version!==1||typeof cut.complete!=='boolean'||!Array.isArray(cut.pins)||!Array.isArray(cut.writes))throw fault('invalid_depot_cut');authority(cut.authority); if(!cut||cut.version!==1||typeof cut.complete!=='boolean'||!Array.isArray(cut.pins)||!Array.isArray(cut.writes))throw fault('invalid_depot_cut');authority(cut.authority);
// seen 只检查资源 ID 唯一;每条 owner 列表还要非空、非重复,再复制冻结以脱离可变数组。
const seen=new Set<string>(),pins=cut.pins.map(row=>{if(!Array.isArray(row)||row.length!==2||typeof row[0]!=='string'||!row[0]||seen.has(row[0])||!Array.isArray(row[1])||!row[1].length||row[1].some(owner=>typeof owner!=='string'||!owner)||new Set(row[1]).size!==row[1].length)throw fault('invalid_depot_cut');seen.add(row[0]);return Object.freeze([row[0],Object.freeze([...row[1]])] as const);}); const seen=new Set<string>(),pins=cut.pins.map(row=>{if(!Array.isArray(row)||row.length!==2||typeof row[0]!=='string'||!row[0]||seen.has(row[0])||!Array.isArray(row[1])||!row[1].length||row[1].some(owner=>typeof owner!=='string'||!owner)||new Set(row[1]).size!==row[1].length)throw fault('invalid_depot_cut');seen.add(row[0]);return Object.freeze([row[0],Object.freeze([...row[1]])] as const);});
// write 可仅含引用以复用已存字节;提供 Blob 时这里只查类型,长度/摘要留给事务外 verifyDepotBytes。
const written=new Set<string>(),writes=cut.writes.map(row=>{validateSharedAsset(row.ref);if(row.ref.authority!==cut.authority||written.has(row.ref.asset.id)||row.bytes!==undefined&&!(row.bytes instanceof Blob))throw fault('invalid_depot_cut');written.add(row.ref.asset.id);return Object.freeze({ref:sharedAssetRef(row.ref),...(row.bytes?{bytes:row.bytes}:{})});}); const written=new Set<string>(),writes=cut.writes.map(row=>{validateSharedAsset(row.ref);if(row.ref.authority!==cut.authority||written.has(row.ref.asset.id)||row.bytes!==undefined&&!(row.bytes instanceof Blob))throw fault('invalid_depot_cut');written.add(row.ref.asset.id);return Object.freeze({ref:sharedAssetRef(row.ref),...(row.bytes?{bytes:row.bytes}:{})});});
return Object.freeze({version:1,authority:cut.authority,complete:cut.complete,pins:Object.freeze(pins),writes:Object.freeze(writes)}); return Object.freeze({version:1,authority:cut.authority,complete:cut.complete,pins:Object.freeze(pins),writes:Object.freeze(writes)});
} }
/** 在 IDB 事务外计算原图 SHA256 并验长度,避免 await crypto 让事务提前失活。 */ /** 在 IDB 事务外逐项计算原图 SHA256 并验长度,避免 await crypto 让事务提前失活。
* 借用已捕获 cut;未附 bytes 的项不重读存储。一次整 Blob 转 ArrayBuffer,没有分块预算/取消信号。
* 摘要证明内容匹配声明,不验证服务权限、实际图片解码尺寸或 MIME;这些由其它端口负责。 */
export async function verifyDepotBytes(cut:DepotCut){ export async function verifyDepotBytes(cut:DepotCut){
for(const row of cut.writes)if(row.bytes){if(row.bytes.size!==row.ref.asset.bytes)throw fault('shared_asset_bytes_changed',[row.ref.asset.id]);const digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await row.bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');if(digest!==row.ref.digest)throw fault('shared_asset_bytes_changed',[row.ref.asset.id]);} for(const row of cut.writes)if(row.bytes){if(row.bytes.size!==row.ref.asset.bytes)throw fault('shared_asset_bytes_changed',[row.ref.asset.id]);const digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await row.bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');if(digest!==row.ref.digest)throw fault('shared_asset_bytes_changed',[row.ref.asset.id]);}
} }
/** Must run in the same transaction as the project Root, including idempotent reindexing. */ /** Must run in the same transaction as the project Root, including idempotent reindexing. */
/** 必须与项目 Root 同事务:校验既存身份、按 digest 写共享字节,并更新该项目 pin manifest。 */ /** 必须与项目 Root 同事务:校验既存身份、按 digest 写共享字节,并替换该项目 pin manifest。
* 调用前须 detach/verify;本函数不重算摘要,也不自行核对 rootRevision/leader。完成只表示请求已排入事务。 */
export async function writeDepotCut(tx:IDBTransaction,scope:StorageScope,rootRevision:number,cut:DepotCut){ export async function writeDepotCut(tx:IDBTransaction,scope:StorageScope,rootRevision:number,cut:DepotCut){
const base=prefix(scope,cut.authority),facts=tx.objectStore('asset-depot'),bytes=tx.objectStore('asset-bytes'); const base=prefix(scope,cut.authority),facts=tx.objectStore('asset-depot'),bytes=tx.objectStore('asset-bytes');
for(const row of cut.writes){const key=[...base,row.ref.asset.id],previous=await value<Fact|undefined>(facts.get(key));if(previous){validateFact(previous,cut.authority,row.ref.asset.id);if(!equal(previous.ref,row.ref))throw fault('shared_asset_identity_conflict',[row.ref.asset.id]);} for(const row of cut.writes){const key=[...base,row.ref.asset.id],previous=await value<Fact|undefined>(facts.get(key));if(previous){validateFact(previous,cut.authority,row.ref.asset.id);if(!equal(previous.ref,row.ref))throw fault('shared_asset_identity_conflict',[row.ref.asset.id]);}
// Digest-addressed bytes are shared across resource IDs and projects in this authority. // 同 account/workspace/authority 的不同资源 ID 共用 digest 字节行。
// 再提供同一身份的已验证 bytes 可清掉软标记与 purgedAt;只有元信息的写入保持既有退休状态。
if(row.bytes)bytes.put(row.bytes,[...base,row.ref.digest]); if(row.bytes)bytes.put(row.bytes,[...base,row.ref.digest]);
facts.put({...previous,ref:row.ref,...(row.bytes?{unreferencedAt:undefined,purgedAt:undefined}:{})},key); facts.put({...previous,ref:row.ref,...(row.bytes?{unreferencedAt:undefined,purgedAt:undefined}:{})},key);
} }
@@ -57,9 +66,10 @@ function depotFile(row:unknown,id:string):DepotFile{
if(file.kind!=='asset-depot'||file.resourceId!==id||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest)||!Number.isSafeInteger(file.byteLength)||file.byteLength<1||file.byteLength>20*1024*1024||typeof file.mimeType!=='string')throw fault('corrupt_file_store'); if(file.kind!=='asset-depot'||file.resourceId!==id||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest)||!Number.isSafeInteger(file.byteLength)||file.byteLength<1||file.byteLength>20*1024*1024||typeof file.mimeType!=='string')throw fault('corrupt_file_store');
return file; return file;
} }
/** Undefined means bytes were explicitly evicted; identity metadata remains. */ /** Blob 旧行与不存在的行直接返回;指针行核对共享身份/长度,已 purge 或共享字节缺失均返回 undefined。
/** Blob 旧行直接返回;指针行核对共享身份/长度,字节已显式清理则返回 undefined。 * undefined 本身不能区分从未写入、显式回收和缺失字节;缺少身份事实或字段冲突则抛损坏错误。
* 返回时恢复项目原文件 MIME,不因服务检测 MIME 与本地不同而改变原文件合同。 */ * 返回时恢复项目原文件 MIME,不因服务检测 MIME 与本地不同而改变原文件合同。
* 不重新哈希、不向服务器授权/下载;调用方须维持事务生命周期及外层读权限。 */
export async function resolveDepotFile(tx:IDBTransaction,scope:StorageScope,id:string,row:unknown):Promise<Blob|undefined>{ export async function resolveDepotFile(tx:IDBTransaction,scope:StorageScope,id:string,row:unknown):Promise<Blob|undefined>{
if(row===undefined||row instanceof Blob)return row; if(row===undefined||row instanceof Blob)return row;
const file=depotFile(row,id),base=prefix(scope,file.authority),fact=await value<Fact|undefined>(tx.objectStore('asset-depot').get([...base,id])); const file=depotFile(row,id),base=prefix(scope,file.authority),fact=await value<Fact|undefined>(tx.objectStore('asset-depot').get([...base,id]));
@@ -73,7 +83,10 @@ export async function resolveDepotFile(tx:IDBTransaction,scope:StorageScope,id:s
return bytes.type===file.mimeType?bytes:bytes.slice(0,bytes.size,file.mimeType); return bytes.type===file.mimeType?bytes:bytes.slice(0,bytes.size,file.mimeType);
} }
/** Convert only Root-proven stored uploads, after shared bytes and local files are written. */ /** Convert only Root-proven stored uploads, after shared bytes and local files are written. */
/** Root 已证明 stored upload 且共享字节存在时,把项目 Blob 行改为指针;未确认上传绝不提前转。 */ /** 从已校验 Root 中读取 stored upload 声明,把本次 cut.writes 对应的项目 Blob 行改为共享指针。
* 只检查声明/身份与 Blob 长度和本地 MIME,不重新对比两份文件内容;字节摘要证明依赖外层流程。
* 缺 resource.stored、资源已 purge、无项目文件或无共享字节时跳过;不是扫描迁移项目中所有文件。
* 借用 Root/共享写入相同事务,后续任何冲突应由外层 abort,不能只保存部分已转换行。 */
export async function promoteDepotFiles(tx:IDBTransaction,scope:StorageScope,key:string,image:RecoveryImage,cut:DepotCut){ export async function promoteDepotFiles(tx:IDBTransaction,scope:StorageScope,key:string,image:RecoveryImage,cut:DepotCut){
type Resource={id:string;digest?:string;byteLength:number;mimeType:string;stored?:SharedAsset['asset'];retirement?:{purgedAt?:number}}; type Resource={id:string;digest?:string;byteLength:number;mimeType:string;stored?:SharedAsset['asset'];retirement?:{purgedAt?:number}};
const payload=image.payload as {runtime?:{resources?:readonly (readonly [string,Resource])[]}},entries=payload?.runtime?.resources; const payload=image.payload as {runtime?:{resources?:readonly (readonly [string,Resource])[]}},entries=payload?.runtime?.resources;
@@ -92,15 +105,19 @@ export async function promoteDepotFiles(tx:IDBTransaction,scope:StorageScope,key
} }
/** 注入主 adapter 的事务执行器,统一复用 metadata/fence/事务终态屏障。 */ /** 注入主 adapter 的事务执行器,统一复用 metadata/fence/事务终态屏障。 */
type Execute=<T>(leader:StorageLeader,stores:string[],mode:IDBTransactionMode,run:(tx:IDBTransaction)=>Promise<T>)=>Promise<T>; type Execute=<T>(leader:StorageLeader,stores:string[],mode:IDBTransactionMode,run:(tx:IDBTransaction)=>Promise<T>)=>Promise<T>;
/** 为主 storage 装配 read/collect;调用者不能绕开有效 leader 直接操作跨项目共享行。 */ /** 为主 storage 装配 read/collect;leader/fence/事务终态由注入的 execute 保证,而非本工厂自行实现。
* 工厂不建立第二份缓存或连接;这些数据库身份检查也不替代远端资源授权。 */
export function createDepotStorage(execute:Execute):AssetDepotPort{return Object.freeze({ export function createDepotStorage(execute:Execute):AssetDepotPort{return Object.freeze({
/** 读取本域身份与尚未 purge 的字节,元信息及 Blob 类型/长度都校验,缺字节不丢身份。 */ /** 读取本域身份与尚未 purge 的字节,元信息及 Blob 类型/长度都校验,缺字节不丢身份。 */
async read(leader,scopeAuthority,id){authority(scopeAuthority);if(typeof id!=='string'||!id)throw fault('invalid_resource_id');return execute(leader,depotStores,'readonly',async tx=>{const base=prefix(leader.scope,scopeAuthority),row=await value<Fact|undefined>(tx.objectStore('asset-depot').get([...base,id]));if(!row)return;validateFact(row,scopeAuthority,id);const bytes=row.purgedAt===undefined?await value<Blob|undefined>(tx.objectStore('asset-bytes').get([...base,row.ref.digest])):undefined;if(bytes!==undefined&&(!(bytes instanceof Blob)||bytes.size!==row.ref.asset.bytes))throw fault('corrupt_asset_depot');return freeze({...row,...(bytes?{bytes}:{})}) as DepotEntry;});}, async read(leader,scopeAuthority,id){authority(scopeAuthority);if(typeof id!=='string'||!id)throw fault('invalid_resource_id');return execute(leader,depotStores,'readonly',async tx=>{const base=prefix(leader.scope,scopeAuthority),row=await value<Fact|undefined>(tx.objectStore('asset-depot').get([...base,id]));if(!row)return;validateFact(row,scopeAuthority,id);const bytes=row.purgedAt===undefined?await value<Blob|undefined>(tx.objectStore('asset-bytes').get([...base,row.ref.digest])):undefined;if(bytes!==undefined&&(!(bytes instanceof Blob)||bytes.size!==row.ref.asset.bytes))throw fault('corrupt_asset_depot');return freeze({...row,...(bytes?{bytes}:{})}) as DepotEntry;});},
/** 一次至多128身份的两阶段回收:先软标记,保留期过后 purge;完整性门禁覆盖所有本地项目。 */ /** 一页至多128身份的两阶段回收:先软标记,之后另一次访问满足保留期才 purge(即使 retentionMs=0)。
* 每次仍 getAll 本地项目 metadata 与本授权域全部 fact,页大小不限制全量扫描/驻留量。
* complete=true 只表示清单门禁通过;若有 nextAfter,调用者还需继续下一页,不等于整仓库已回收。 */
async collect(leader,input){authority(input.authority);const {now,retentionMs,limit,after}=input;if(!Number.isSafeInteger(now)||now<0||!Number.isSafeInteger(retentionMs)||retentionMs<0||!Number.isSafeInteger(limit)||limit<1||limit>128||after!==undefined&&(typeof after!=='string'||!after))throw fault('invalid_depot_collection'); async collect(leader,input){authority(input.authority);const {now,retentionMs,limit,after}=input;if(!Number.isSafeInteger(now)||now<0||!Number.isSafeInteger(retentionMs)||retentionMs<0||!Number.isSafeInteger(limit)||limit<1||limit>128||after!==undefined&&(typeof after!=='string'||!after))throw fault('invalid_depot_collection');
return execute(leader,depotStores,'readwrite',async tx=>{ return execute(leader,depotStores,'readwrite',async tx=>{
const scope=leader.scope,metadata=tx.objectStore('metadata'),keys=await value(metadata.getAllKeys()),heads=await value<{throughRevision?:number}[]>(metadata.getAll()),base=prefix(scope,input.authority),manifests=tx.objectStore('asset-pins'),blockedProjects:string[]=[],pinned=new Set<string>(); const scope=leader.scope,metadata=tx.objectStore('metadata'),keys=await value(metadata.getAllKeys()),heads=await value<{throughRevision?:number}[]>(metadata.getAll()),base=prefix(scope,input.authority),manifests=tx.objectStore('asset-pins'),blockedProjects:string[]=[],pinned=new Set<string>();
// 包括已关闭项目:其 manifest 必须与当前 Root 头一致,任一缺失/过期/opaque 都阻止删除。 // getAllKeys/getAll 在同一事务按相同键序对应。包括同账号工作区的已关闭项目;尚无可靠 Root 的头跳过。
// 每个有 Root 项目的本授权域 manifest 必须同 revision 且 complete;不要求项目当前持有 leader。
for(let i=0;i<keys.length;i++){let parts:unknown;try{parts=JSON.parse(String(keys[i]));}catch{throw fault('corrupt_storage_scope');}if(!Array.isArray(parts)||parts.length!==3||parts.some(p=>typeof p!=='string'||!p))throw fault('corrupt_storage_scope');if(parts[0]!==scope.accountId||parts[1]!==scope.workspaceId||heads[i].throughRevision===undefined)continue; for(let i=0;i<keys.length;i++){let parts:unknown;try{parts=JSON.parse(String(keys[i]));}catch{throw fault('corrupt_storage_scope');}if(!Array.isArray(parts)||parts.length!==3||parts.some(p=>typeof p!=='string'||!p))throw fault('corrupt_storage_scope');if(parts[0]!==scope.accountId||parts[1]!==scope.workspaceId||heads[i].throughRevision===undefined)continue;
const pins=await value<Pins|undefined>(manifests.get([...base,parts[2]]));if(!pins||pins.version!==1||pins.rootRevision!==heads[i].throughRevision||pins.complete!==true){blockedProjects.push(parts[2]);continue;} const pins=await value<Pins|undefined>(manifests.get([...base,parts[2]]));if(!pins||pins.version!==1||pins.rootRevision!==heads[i].throughRevision||pins.complete!==true){blockedProjects.push(parts[2]);continue;}
if(!Array.isArray(pins.pins))throw fault('corrupt_depot_pins');for(const row of pins.pins){if(!Array.isArray(row)||row.length!==2||typeof row[0]!=='string'||!row[0]||!Array.isArray(row[1])||!row[1].length||row[1].some(owner=>typeof owner!=='string'||!owner))throw fault('corrupt_depot_pins');pinned.add(row[0]);} if(!Array.isArray(pins.pins))throw fault('corrupt_depot_pins');for(const row of pins.pins){if(!Array.isArray(row)||row.length!==2||typeof row[0]!=='string'||!row[0]||!Array.isArray(row[1])||!row[1].length||row[1].some(owner=>typeof owner!=='string'||!owner))throw fault('corrupt_depot_pins');pinned.add(row[0]);}
@@ -109,11 +126,14 @@ export function createDepotStorage(execute:Execute):AssetDepotPort{return Object
if(blockedProjects.length)return freeze({complete:false,blockedProjects,markedIds:[],purgedIds:[],pinnedIds:[]}); if(blockedProjects.length)return freeze({complete:false,blockedProjects,markedIds:[],purgedIds:[],pinnedIds:[]});
const store=tx.objectStore('asset-depot'),factKeys=await value(store.getAllKeys(range(scope,input.authority))),rows=await value<Fact[]>(store.getAll(range(scope,input.authority))),markedIds:string[]=[],purgedIds:string[]=[],pinnedIds:string[]=[]; const store=tx.objectStore('asset-depot'),factKeys=await value(store.getAllKeys(range(scope,input.authority))),rows=await value<Fact[]>(store.getAll(range(scope,input.authority))),markedIds:string[]=[],purgedIds:string[]=[],pinnedIds:string[]=[];
for(let i=0;i<rows.length;i++){const key=factKeys[i];if(!Array.isArray(key)||key.length!==4||typeof key[3]!=='string')throw fault('corrupt_asset_depot');validateFact(rows[i],input.authority,key[3]);} for(let i=0;i<rows.length;i++){const key=factKeys[i];if(!Array.isArray(key)||key.length!==4||typeof key[3]!=='string')throw fault('corrupt_asset_depot');validateFact(rows[i],input.authority,key[3]);}
// 先验证本域记录,再按资源 ID 游标截取一页,多取一条判断是否还有下一页。 // 所有 fact 先验证再分页:页外损坏也阻止本次回收。复合 IDB 键序提供资源 ID 顺序,after 严格排除自身。
// 多取一条只判断后续候选;已 purge/仍 pinned 的身份也占页名额,不是 limit 个实际删除。
const candidates=rows.map((row,i)=>({row,key:factKeys[i] as string[]})).filter(({key})=>after===undefined||key[3]>after).slice(0,limit+1),page=candidates.slice(0,limit); const candidates=rows.map((row,i)=>({row,key:factKeys[i] as string[]})).filter(({key})=>after===undefined||key[3]>after).slice(0,limit+1),page=candidates.slice(0,limit);
// repin 清软标记;无引用首次记录时刻;时钟倒退不提前 purge;永久身份不重新分配。 // repin 只清尚未 purge 的软标记;已 purge 身份即使重新被 pin 也不在此复活字节。
// 未引用首次只记录时刻;之后满足保留期才 purge,时钟倒退不提前清理;身份行始终保留。
for(const {row,key}of page){const id=key[3];if(pinned.has(id)){pinnedIds.push(id);if(row.purgedAt===undefined&&row.unreferencedAt!==undefined){delete row.unreferencedAt;store.put(row,key);}continue;}if(row.purgedAt!==undefined)continue;if(row.unreferencedAt===undefined){row.unreferencedAt=now;markedIds.push(id);store.put(row,key);}else if(now>=row.unreferencedAt&&now-row.unreferencedAt>=retentionMs){row.purgedAt=now;purgedIds.push(id);store.put(row,key);}} for(const {row,key}of page){const id=key[3];if(pinned.has(id)){pinnedIds.push(id);if(row.purgedAt===undefined&&row.unreferencedAt!==undefined){delete row.unreferencedAt;store.put(row,key);}continue;}if(row.purgedAt!==undefined)continue;if(row.unreferencedAt===undefined){row.unreferencedAt=now;markedIds.push(id);store.put(row,key);}else if(now>=row.unreferencedAt&&now-row.unreferencedAt>=retentionMs){row.purgedAt=now;purgedIds.push(id);store.put(row,key);}}
// Preserve a digest while any other live resource identity still owns its bytes. // rows 与 page 共用本事务读出的 row 对象,已写的 purgedAt 可直接参与全域存活判断。
// 同摘要任一身份尚未 purge(即使未 pin、仍在保留期)都保留字节;最后一份退休后才删 digest 行。
for(const digest of new Set(page.filter(({row})=>row.purgedAt===now).map(({row})=>row.ref.digest)))if(!rows.some(row=>row.ref.digest===digest&&row.purgedAt===undefined))tx.objectStore('asset-bytes').delete([...base,digest]); for(const digest of new Set(page.filter(({row})=>row.purgedAt===now).map(({row})=>row.ref.digest)))if(!rows.some(row=>row.ref.digest===digest&&row.purgedAt===undefined))tx.objectStore('asset-bytes').delete([...base,digest]);
return freeze({complete:true,blockedProjects:[],markedIds,purgedIds,pinnedIds,...(candidates.length>limit?{nextAfter:page.at(-1)!.key[3]}:{})}); return freeze({complete:true,blockedProjects:[],markedIds,purgedIds,pinnedIds,...(candidates.length>limit?{nextAfter:page.at(-1)!.key[3]}:{})});
}); });
+20 -9
View File
@@ -27,32 +27,43 @@ export type AssetDepotPort={
read(leader:StorageLeader,authority:string,id:Id):Promise<DepotEntry|undefined>; read(leader:StorageLeader,authority:string,id:Id):Promise<DepotEntry|undefined>;
collect(leader:StorageLeader,input:{readonly authority:string;readonly now:number;readonly retentionMs:number;readonly limit:number;readonly after?:Id}):Promise<DepotCollection>; collect(leader:StorageLeader,input:{readonly authority:string;readonly now:number;readonly retentionMs:number;readonly limit:number;readonly after?:Id}):Promise<DepotCollection>;
}; };
/** 严格验证授权域、64位小写十六进制摘要、图片 MIME、正尺寸/字节及像素/20MiB上限。 */ /** 校验版本、非空授权域/URL、64 字符小写摘要、图片 MIME、正整数尺寸/字节及像素/20MiB上限。
* 只检查元数据:URL 未限制协议/域,摘要未重算,尺寸未通过实际解码核实;授权真实性由服务端口保证。 */
export function validateSharedAsset(value:SharedAsset):void{ export function validateSharedAsset(value:SharedAsset):void{
if(!value||value.version!==1||typeof value.authority!=='string'||!value.authority||value.authority.length>8192||typeof value.digest!=='string'||!/^([a-f0-9]{64})$/.test(value.digest))throw fault('invalid_shared_asset'); if(!value||value.version!==1||typeof value.authority!=='string'||!value.authority||value.authority.length>8192||typeof value.digest!=='string'||!/^([a-f0-9]{64})$/.test(value.digest))throw fault('invalid_shared_asset');
const asset=value.asset;if(!asset||typeof asset.id!=='string'||!asset.id||typeof asset.url!=='string'||!asset.url||asset.url.length>8192||![asset.width,asset.height,asset.bytes].every(n=>Number.isSafeInteger(n)&&n>0)||asset.width*asset.height>40_000_000||asset.bytes>20*1024*1024||!['image/png','image/jpeg','image/gif','image/webp'].includes(asset.mime_type))throw fault('invalid_shared_asset'); const asset=value.asset;if(!asset||typeof asset.id!=='string'||!asset.id||typeof asset.url!=='string'||!asset.url||asset.url.length>8192||![asset.width,asset.height,asset.bytes].every(n=>Number.isSafeInteger(n)&&n>0)||asset.width*asset.height>40_000_000||asset.bytes>20*1024*1024||!['image/png','image/jpeg','image/gif','image/webp'].includes(asset.mime_type))throw fault('invalid_shared_asset');
} }
/** 验证后捕获并冻结资源元信息,隔离调用者可变的 asset 对象。 */ /** 验证后重建标准外层字段并浅复制 asset,再递归冻结,隔离标准标量元信息。
* asset 的扩展属性仍由 spread 保留,嵌套扩展值不深克隆,不能当作任意外部对象的完整隔离入口。 */
export function sharedAssetRef(value:SharedAsset):SharedAsset{validateSharedAsset(value);return freeze({version:1 as const,authority:value.authority,digest:value.digest,asset:{...value.asset}});} export function sharedAssetRef(value:SharedAsset):SharedAsset{validateSharedAsset(value);return freeze({version:1 as const,authority:value.authority,digest:value.digest,asset:{...value.asset}});}
/** 比较完整不可变引用,不能只因 ID 或 URL 一样就容忍摘要/尺寸变化。 */ /** 比较完整不可变引用,不能只因 ID 或 URL 一样就容忍摘要/尺寸变化。 */
const same=(a:SharedAsset,b:SharedAsset)=>equal(sharedAssetRef(a),sharedAssetRef(b)); const same=(a:SharedAsset,b:SharedAsset)=>equal(sharedAssetRef(a),sharedAssetRef(b));
/** Immutable metadata cache only. Every new adoption resolves current permission first. */ /** Immutable metadata cache only. Every new adoption resolves current permission first. */
/** 建立工作区门面,绑定作用域与当前身份,不把跨账号缓存混入同一个 records 表。 */ /** 捕获 account/workspace 标量,建立仅存元信息的 Map;字节仍由 port 持有,不建立内存 Blob 缓存。
* 宿主 current/leader 校验约束使用生命周期,实际存储 fence/权限检查仍由端口实现。
* 每次 getResources 返回成员快照,旧快照不随 remember/close 改变;它不另做授权或关闭检查。 */
export function createAssetDepot(options:{scope:Pick<StorageScope,'accountId'|'workspaceId'>;port:AssetDepotPort;current():boolean;digest(bytes:Blob,signal?:AbortSignal):Promise<string>}){ export function createAssetDepot(options:{scope:Pick<StorageScope,'accountId'|'workspaceId'>;port:AssetDepotPort;current():boolean;digest(bytes:Blob,signal?:AbortSignal):Promise<string>}){
const scope=freeze({accountId:options.scope.accountId,workspaceId:options.scope.workspaceId});let closed=false;const records=new Map<string,SharedAsset>(),controllers=new Set<AbortController>(); const scope=freeze({accountId:options.scope.accountId,workspaceId:options.scope.workspaceId});let closed=false;const records=new Map<string,SharedAsset>(),controllers=new Set<AbortController>();
/** 每个 await 前后使用:宿主仍有效且 leader 属于同账号/工作区;允许不同项目共享仓库。 */ /** 入口及异步结果采纳前检查宿主/leader,允许同账号工作区的不同项目共享。
* 不比较 projectId,不读取远端 fence;在途 I/O 是否立即停止由端口/信号决定。 */
const check=(leader:StorageLeader)=>{if(closed||!options.current())throw fault('identity_changed');if(!leader.active||leader.scope.accountId!==scope.accountId||leader.scope.workspaceId!==scope.workspaceId)throw fault('asset_depot_scope_mismatch');}; const check=(leader:StorageLeader)=>{if(closed||!options.current())throw fault('identity_changed');if(!leader.active||leader.scope.accountId!==scope.accountId||leader.scope.workspaceId!==scope.workspaceId)throw fault('asset_depot_scope_mismatch');};
/** 缓存同 authority/assetId 的不可变引用;相同身份元数据改变视为冲突,不静默覆盖。 */ /** 缓存同 authority/assetId 的不可变引用;相同身份元数据改变视为冲突,不静默覆盖。 */
function remember(ref:SharedAsset){validateSharedAsset(ref);const key=JSON.stringify([ref.authority,ref.asset.id]),previous=records.get(key);if(previous&&!same(previous,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);records.set(key,sharedAssetRef(ref));} function remember(ref:SharedAsset){validateSharedAsset(ref);const key=JSON.stringify([ref.authority,ref.asset.id]),previous=records.get(key);if(previous&&!same(previous,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);records.set(key,sharedAssetRef(ref));}
return Object.freeze({scope,getResources:()=>readMap(records), return Object.freeze({scope,getResources:()=>readMap(records),
/** 新采纳先远端 resolve,再比本地身份/读取原字节并验长度+SHA;每个 await 后重新检查取消/身份。 /** 新采纳先 resolve 当前权限,再核对缓存身份;有缓存字节仍验长度/摘要,否则向 authority 读取。
* 成功只返回 ref/bytes 和记元信息,目标 binding 与字节持久化由 engine 的单 Root 完成。 */ * combined 信号交端口,异步返回后再检查;入口不先 throwIfAborted,已取消信号仍可能传给 resolve。
* remember 捕获元信息,但返回 ref 沿用 resolve 结果(只冻结返回 envelope),端口须提供不可变身份。
* 成功不写共享字节/项目 binding;这些由 engine 同 Root 持久化,不是新上传任务。 */
async authorize(leader:StorageLeader,authority:AssetAuthority,id:Id,signal:AbortSignal){check(leader);const controller=new AbortController(),combined=AbortSignal.any([signal,controller.signal]);controllers.add(controller);try{const ref=await authority.resolve(id,combined);check(leader);combined.throwIfAborted();validateSharedAsset(ref);if(ref.authority!==authority.scope||ref.asset.id!==id)throw fault('asset_authorization_mismatch');const cached=await options.port.read(leader,authority.scope,id);check(leader);combined.throwIfAborted();if(cached&&!same(cached.ref,ref))throw fault('shared_asset_identity_conflict',[id]);let bytes=cached?.bytes;if(!bytes)bytes=await authority.read(id,combined);check(leader);combined.throwIfAborted();if(bytes.size!==ref.asset.bytes||await options.digest(bytes,combined)!==ref.digest)throw fault('shared_asset_bytes_changed',[id]);check(leader);combined.throwIfAborted();remember(ref);return Object.freeze({ref,bytes});}finally{controllers.delete(controller);}}, async authorize(leader:StorageLeader,authority:AssetAuthority,id:Id,signal:AbortSignal){check(leader);const controller=new AbortController(),combined=AbortSignal.any([signal,controller.signal]);controllers.add(controller);try{const ref=await authority.resolve(id,combined);check(leader);combined.throwIfAborted();validateSharedAsset(ref);if(ref.authority!==authority.scope||ref.asset.id!==id)throw fault('asset_authorization_mismatch');const cached=await options.port.read(leader,authority.scope,id);check(leader);combined.throwIfAborted();if(cached&&!same(cached.ref,ref))throw fault('shared_asset_identity_conflict',[id]);let bytes=cached?.bytes;if(!bytes)bytes=await authority.read(id,combined);check(leader);combined.throwIfAborted();if(bytes.size!==ref.asset.bytes||await options.digest(bytes,combined)!==ref.digest)throw fault('shared_asset_bytes_changed',[id]);check(leader);combined.throwIfAborted();remember(ref);return Object.freeze({ref,bytes});}finally{controllers.delete(controller);}},
/** 读取已绑定本地共享字节并验身份/摘要;不是新 adoption 的授权捷径,缺字节返回 undefined。 */ /** 按已绑定 ref 读取本地共享字节并验身份/摘要,不调用 authority.resolve 或回源下载。
* 无记录直接 undefined;有记录无字节也记下元信息再返回 undefined,不能据此判定服务器资产消失。
* 输入 ref 沿用调用方身份;本方法不单独证明调用者有权新采纳该资源。 */
async read(leader:StorageLeader,ref:SharedAsset,signal?:AbortSignal){check(leader);signal?.throwIfAborted();const row=await options.port.read(leader,ref.authority,ref.asset.id);check(leader);signal?.throwIfAborted();if(!row)return;if(!same(row.ref,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);if(row.bytes&&(row.bytes.size!==ref.asset.bytes||await options.digest(row.bytes,signal)!==ref.digest))throw fault('shared_asset_bytes_changed',[ref.asset.id]);check(leader);signal?.throwIfAborted();remember(ref);return row.bytes;}, async read(leader:StorageLeader,ref:SharedAsset,signal?:AbortSignal){check(leader);signal?.throwIfAborted();const row=await options.port.read(leader,ref.authority,ref.asset.id);check(leader);signal?.throwIfAborted();if(!row)return;if(!same(row.ref,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);if(row.bytes&&(row.bytes.size!==ref.asset.bytes||await options.digest(row.bytes,signal)!==ref.digest))throw fault('shared_asset_bytes_changed',[ref.asset.id]);check(leader);signal?.throwIfAborted();remember(ref);return row.bytes;},
/** 作用域检查后委托存储扫描/保留期回收,结束再检查身份,不自行删除服务器资产。 */ /** 作用域检查后直接委托 port.collect,结果由端口负责结构与回收证明,不在此重验。
* 返回后再核对身份;检查失败不代表端口之前的本地存储事务未执行,也不删除服务器资产。 */
async collect(leader:StorageLeader,input:Parameters<AssetDepotPort['collect']>[1]){check(leader);const result=await options.port.collect(leader,input);check(leader);return result;}, async collect(leader:StorageLeader,input:Parameters<AssetDepotPort['collect']>[1]){check(leader);const result=await options.port.collect(leader,input);check(leader);return result;},
/** 关闭门面并 abort 授权任务、清元缓存;不抹掉共享存储中的持久身份/字节。 */ /** 关闭后拒绝新操作,abort 本门面追踪的 authorize 并清元缓存;不删除持久身份/字节。
* read/collect 没有被加入 controllers,在途结果由后续 check 拒绝采纳;close 不等待底层 I/O 退出。 */
close(){closed=true;for(const controller of controllers)controller.abort();controllers.clear();records.clear();}, close(){closed=true;for(const controller of controllers)controller.abort();controllers.clear();records.clear();},
}); });
} }
+13 -5
View File
@@ -10,7 +10,8 @@ import { compressChanges, type LedgerSnapshot } from './ledger.ts';
/** 使用 UTF-8 实际字节衡量 wire 大小,不能把 JS 字符串长度当 HTTP payload 大小。 */ /** 使用 UTF-8 实际字节衡量 wire 大小,不能把 JS 字符串长度当 HTTP payload 大小。 */
const encoder = new TextEncoder(); const encoder = new TextEncoder();
/** 仅计算候选 JSON 表示的字节成本,不在这里发送或冻结请求。 */ /** 仅比较传入 JSON 的 UTF-8 字节;调用处测量 operation 数组,不含 HTTP 头/请求 envelope。
* 不在这里发送或冻结请求,也不估算压缩传输后的成本。 */
const wireBytes = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength; const wireBytes = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength;
/** wire 中保留的 logical ID 与对应条件 changes;不是新的本地历史单元。 */ /** wire 中保留的 logical ID 与对应条件 changes;不是新的本地历史单元。 */
@@ -19,9 +20,13 @@ export type WireOperation = { readonly id: Id; readonly changes: readonly Change
const sameMap = <T>(a: ReadonlyMap<Id, T>, b: ReadonlyMap<Id, T>) => a.size === b.size && [...a].every(([id, value]) => b.has(id) && equal(value, b.get(id))); const sameMap = <T>(a: ReadonlyMap<Id, T>, b: ReadonlyMap<Id, T>) => a.size === b.size && [...a].every(([id, value]) => b.has(id) && equal(value, b.get(id)));
/** Pure projection for an already-selected mutable segment. Original ledger/history records never change. */ /** Pure projection for an already-selected mutable segment. Original ledger/history records never change. */
/** 尝试无损压缩多意图的 wire 投影;不足两项或任一不安全前提直接返回 ordered。 */ /** 为调用方已选好的未发送段尝试 wire 压缩;不足两项、基线不明或无收益时返回 ordered。
* 原始条件链非法会抛错,投影效果不等价也会抛错,并非所有失败都回退。
* 此处只比对 ledger 的 changes/phase 等局部条件,不验证完整意图身份、依赖、类型/版本或段连续性。
* 参与范围与选择合法性由调用方保证;这里不调用 registry/整图校验来证明所有权或权限。 */
export function projectBatch(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, participants: readonly Id[]): readonly WireOperation[] { export function projectBatch(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, participants: readonly Id[]): readonly WireOperation[] {
const scope = new Set(participants); const scope = new Set(participants);
// 回退表示创建新的外层数组/条目,但 changes 沿用输入引用;仅成功压缩的结果走末尾 freeze。
const ordered = intents.map(({ id, changes }) => ({ id, changes })); const ordered = intents.map(({ id, changes }) => ({ id, changes }));
if (intents.length < 2) return ordered; if (intents.length < 2) return ordered;
// 只收集这段触及的基线记录组成 sparse 文档,减少复制/比较完整项目的成本。 // 只收集这段触及的基线记录组成 sparse 文档,减少复制/比较完整项目的成本。
@@ -52,9 +57,11 @@ export function projectBatch(intents: readonly OperationIntent[], acknowledged:
// wire 投影保留生命周期和 no-op 检查,完整零效果段由独立 elision 证明处理。 // wire 投影保留生命周期和 no-op 检查,完整零效果段由独立 elision 证明处理。
const compacted = compressChanges(raw, { preserveLifecycle: true, preserveChecks: true }); const compacted = compressChanges(raw, { preserveLifecycle: true, preserveChecks: true });
if (!compacted.length) return ordered; if (!compacted.length) return ordered;
// 原逻辑 ID 顺序逐项保留;函数本身不验证输入 ID 唯一性,去重身份由 ledger/请求编译边界保证。
// Protocol 2 requires a nonempty change list per logical ID. Prefix IDs get an explicit // Protocol 2 requires a nonempty change list per logical ID. Prefix IDs get an explicit
// conditional no-op; the final ID carries the composed effect. All IDs remain deduplicated. // conditional no-op; the final ID carries the composed effect. Input ID order is retained.
// 从参与 owner 的少量元素 title/text 或根 canvas 选 no-op guard,不扩大原 claim 范围。 // 从参与 owner 的基线元素选 title/text 条件 no-op,最多查看 8 个候选元素,每个产生两个 guard。
// guard 表达的是基线值/存在性,不是某个前缀逻辑操作的独立最终效果;最后一项统一携带组合变化。
const guards: Change[] = []; const guards: Change[] = [];
let candidates = 0; let candidates = 0;
for (const element of elements.values()) if (scope.has(element.ownerCanvasId)) { for (const element of elements.values()) if (scope.has(element.ownerCanvasId)) {
@@ -73,7 +80,8 @@ export function projectBatch(intents: readonly OperationIntent[], acknowledged:
if (!guard) return ordered; if (!guard) return ordered;
const projected = intents.map((intent, index) => ({ id: intent.id, changes: index === intents.length - 1 ? compacted : [guard!] })); const projected = intents.map((intent, index) => ({ id: intent.id, changes: index === intents.length - 1 ? compacted : [guard!] }));
if (projected.reduce((count, operation) => count + operation.changes.length, 0) > 10_000 || wireBytes(projected) >= wireBytes(ordered)) return ordered; if (projected.reduce((count, operation) => count + operation.changes.length, 0) > 10_000 || wireBytes(projected) >= wireBytes(ordered)) return ordered;
// 最后执行投影并对比 elements/canvases/tombstones 全效果,不只比较可见节点内容。 // 在同一 sparse 片段执行投影,比较它的三张表而非完整项目;未触及记录从未纳入片段。
// 这证明该基线上的组合效果一致,不替代请求执行时的版本检查或最终文档校验。
const result = applyChanges(sparse, projected.flatMap(operation => operation.changes)); const result = applyChanges(sparse, projected.flatMap(operation => operation.changes));
if (!sameMap(original.elements, result.elements) || !sameMap(original.canvases, result.canvases) || !sameMap(original.tombstones, result.tombstones)) throw fault('batch_projection_mismatch'); if (!sameMap(original.elements, result.elements) || !sameMap(original.canvases, result.canvases) || !sameMap(original.tombstones, result.tombstones)) throw fault('batch_projection_mismatch');
return freeze(projected); return freeze(projected);
+6 -1
View File
@@ -5,7 +5,10 @@
import {fault} from '../canvas-core/values.ts'; import {fault} from '../canvas-core/values.ts';
import {createDigestPool} from './digest-pool.ts'; import {createDigestPool} from './digest-pool.ts';
/** Browser capability stays at the adapter; WorkspaceRuntime owns the pool's lifetime. */ /** Browser capability stays at the adapter; WorkspaceRuntime owns the pool's lifetime. */
/** 构建 workspace 可共享的 DigestPort;任务 ID/长度/版本共同验证响应归属。 */ /** 在构造时选择能力:Worker 不存在就使用池的默认分块执行器,否则每个 execute 新建专用 Worker。
* 此选择不随后续 Worker 全局变化自动重算;创建/运行失败也不降级重试。
* 使用默认池预算,响应按实例内递增 ID/输入长度/版本和摘要格式核对,不独立重算摘要。
* 没有超时,线程无响应需外部取消/close;Worker 内原生哈希阈值由 digestWorkerSource 控制。 */
export function createBrowserDigests(){ export function createBrowserDigests(){
let next=0; let next=0;
return createDigestPool({execute:typeof Worker==='undefined'?undefined:(bytes,signal)=>new Promise<string>((resolve,reject)=>{ return createDigestPool({execute:typeof Worker==='undefined'?undefined:(bytes,signal)=>new Promise<string>((resolve,reject)=>{
@@ -15,6 +18,8 @@ export function createBrowserDigests(){
const id=++next;let done=false; const id=++next;let done=false;
/** 幂等完成:解绑所有消息/错误/abort 监听并终止 Worker,防止迟到响应泄漏或重复 resolve。 */ /** 幂等完成:解绑所有消息/错误/abort 监听并终止 Worker,防止迟到响应泄漏或重复 resolve。 */
const finish=(error?:unknown,sha256?:string)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(sha256!);},abort=()=>finish(fault('import_aborted')); const finish=(error?:unknown,sha256?:string)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(sha256!);},abort=()=>finish(fault('import_aborted'));
// 稳定 digest_worker_failed 只有身份匹配才采纳;其它响应走摘要字段校验,不把任意 error 字段当服务错误。
// 格式正确只表明消息可解释,真实性来自受信的专用 Worker 算法;不在 UI 线程重复计算。
// 消息须精确匹配版本、任务 ID 和输入长度,再验64位十六进制摘要;已知错误也必须匹配身份。 // 消息须精确匹配版本、任务 ID 和输入长度,再验64位十六进制摘要;已知错误也必须匹配身份。
signal.addEventListener('abort',abort,{once:true});worker.onmessage=event=>{const value=event.data;if(value?.version===1&&value.id===id&&value.byteLength===bytes.size&&value.error==='digest_worker_failed'){finish(fault('digest_worker_failed'));return;}if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size||typeof value.sha256!=='string'||!(/^[a-f0-9]{64}$/.test(value.sha256)))finish(fault('invalid_digest_response'));else finish(undefined,value.sha256);};worker.onerror=event=>{event.preventDefault();finish(fault('digest_worker_failed'));};worker.onmessageerror=()=>finish(fault('invalid_digest_response')); signal.addEventListener('abort',abort,{once:true});worker.onmessage=event=>{const value=event.data;if(value?.version===1&&value.id===id&&value.byteLength===bytes.size&&value.error==='digest_worker_failed'){finish(fault('digest_worker_failed'));return;}if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size||typeof value.sha256!=='string'||!(/^[a-f0-9]{64}$/.test(value.sha256)))finish(fault('invalid_digest_response'));else finish(undefined,value.sha256);};worker.onerror=event=>{event.preventDefault();finish(fault('digest_worker_failed'));};worker.onmessageerror=()=>finish(fault('invalid_digest_response'));
// Blob 通过结构化克隆传递,不在 UI 主线程先读取整份 arrayBuffer。 // Blob 通过结构化克隆传递,不在 UI 主线程先读取整份 arrayBuffer。
+4 -1
View File
@@ -4,7 +4,9 @@
*/ */
import {fault} from '../canvas-core/values.ts'; import {fault} from '../canvas-core/values.ts';
import {createImageDecodePool,type ImageDimensions} from './image-decoder.ts'; import {createImageDecodePool,type ImageDimensions} from './image-decoder.ts';
/** 返回 ImageDecoderPort;版本/id/字节数验证响应归属,尺寸合法性由 pool 再检查。 */ /** 将每次 execute 转成专用 module Worker,请求 ID 在此 decoder 实例内递增。
* 外围池使用默认并发/排队预算;此适配按响应版本/id/输入长度核对归属,尺寸合法性由池检查。
* 没有超时;Worker 无响应时要由外部取消/close 才能终止,构造失败也不回退主线程解码。 */
export function createBrowserImageDecoder(){let next=0;return createImageDecodePool({execute:(bytes,signal)=>new Promise<ImageDimensions>((resolve,reject)=>{ export function createBrowserImageDecoder(){let next=0;return createImageDecodePool({execute:(bytes,signal)=>new Promise<ImageDimensions>((resolve,reject)=>{
if(typeof Worker==='undefined'){reject(fault('image_decoder_unavailable'));return;} if(typeof Worker==='undefined'){reject(fault('image_decoder_unavailable'));return;}
// 每任务一个 Worker,取消只 terminate 自己,不会打断其它图片的解码。 // 每任务一个 Worker,取消只 terminate 自己,不会打断其它图片的解码。
@@ -12,6 +14,7 @@ export function createBrowserImageDecoder(){let next=0;return createImageDecodeP
/** finish 幂等解除消息/错误/abort 监听并 terminate;abort 在监听安装后再检查一次覆盖竞态。 */ /** finish 幂等解除消息/错误/abort 监听并 terminate;abort 在监听安装后再检查一次覆盖竞态。 */
const id=++next;let done=false;const finish=(error?:unknown,size?:ImageDimensions)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(size!);},abort=()=>finish(fault('image_decode_aborted')); const id=++next;let done=false;const finish=(error?:unknown,size?:ImageDimensions)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(size!);},abort=()=>finish(fault('image_decode_aborted'));
signal.addEventListener('abort',abort,{once:true});if(signal.aborted){abort();return;} signal.addEventListener('abort',abort,{once:true});if(signal.aborted){abort();return;}
// Worker error 会 preventDefault 并转稳定错误,消息无法反序列化则拒绝;所有路径共用 finish 终止线程。
// 只采纳精确匹配请求的成功尺寸或已知错误;未知错误码/错误协议作为 invalid response 拒绝。 // 只采纳精确匹配请求的成功尺寸或已知错误;未知错误码/错误协议作为 invalid response 拒绝。
worker.onmessage=event=>{const value=event.data;if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size){finish(fault('invalid_image_response'));return;}if(value.error){finish(fault(value.error==='image_decode_failed'?'image_decode_failed':'invalid_image_response'));return;}finish(undefined,{width:value.width,height:value.height});};worker.onerror=event=>{event.preventDefault();finish(fault('image_decode_failed'));};worker.onmessageerror=()=>finish(fault('invalid_image_response')); worker.onmessage=event=>{const value=event.data;if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size){finish(fault('invalid_image_response'));return;}if(value.error){finish(fault(value.error==='image_decode_failed'?'image_decode_failed':'invalid_image_response'));return;}finish(undefined,{width:value.width,height:value.height});};worker.onerror=event=>{event.preventDefault();finish(fault('image_decode_failed'));};worker.onmessageerror=()=>finish(fault('invalid_image_response'));
// 直接传不可变 Blob,不先在 UI 线程转成像素或整份 ArrayBuffer。 // 直接传不可变 Blob,不先在 UI 线程转成像素或整份 ArrayBuffer。
+26 -10
View File
@@ -13,7 +13,10 @@ import { equal, fault, freeze, readMap } from '../canvas-core/values.ts';
/** 原操作相关对象的前后记录及 owner 变换;用于区分用户意图与仅由坐标转换引起的差异。 */ /** 原操作相关对象的前后记录及 owner 变换;用于区分用户意图与仅由坐标转换引起的差异。 */
export type IntentContext = { readonly before:ReadonlyMap<Id,ElementRecord>; readonly after:ReadonlyMap<Id,ElementRecord>; readonly transforms:ReadonlyMap<Id,Transform>; readonly beforeTransforms:ReadonlyMap<Id,Transform> }; export type IntentContext = { readonly before:ReadonlyMap<Id,ElementRecord>; readonly after:ReadonlyMap<Id,ElementRecord>; readonly transforms:ReadonlyMap<Id,Transform>; readonly beforeTransforms:ReadonlyMap<Id,Transform> };
/** Reconstruct only immutable intention metadata. This mutable cursor is never published as a document. */ /** Reconstruct only immutable intention metadata. This mutable cursor is never published as a document. */
/** 从当前 working 反向遍历意图,重建每项的局部前后上下文;可变 cursor 永不作为业务文档发布。 */ /** 从已包含这些意图效果的 working 逆序退回,重建局部前后记录及 owner 变换。
* 输入顺序就是应用顺序;函数不按 sequence 排序,也不验证当前文档确由这些意图产生。
* 这里只还原意图元数据,不按 before/after 条件执行撤销,不回退墓碑、不分配新代次或发布文档。
* 只读 Map 捕获表成员,记录值沿用不可变输入;不是任意外部对象的深克隆边界。 */
export function intentContexts(document:Document,intents:readonly OperationIntent[]):ReadonlyMap<Id,IntentContext> { export function intentContexts(document:Document,intents:readonly OperationIntent[]):ReadonlyMap<Id,IntentContext> {
const elements=new Map(document.elements),canvases=new Map(document.canvases),contexts=new Map<Id,IntentContext>(); const elements=new Map(document.elements),canvases=new Map(document.canvases),contexts=new Map<Id,IntentContext>();
const cursor={...document,elements,canvases}; const cursor={...document,elements,canvases};
@@ -27,7 +30,8 @@ export function intentContexts(document:Document,intents:readonly OperationInten
} }
const after=new Map<Id,ElementRecord>();for(const id of ids){const record=elements.get(id);if(record){after.set(id,record);owners.add(record.ownerCanvasId);}} const after=new Map<Id,ElementRecord>();for(const id of ids){const record=elements.get(id);if(record){after.set(id,record);owners.add(record.ownerCanvasId);}}
const transforms=new Map<Id,Transform>();for(const owner of owners)if(canvases.has(owner))transforms.set(owner,canvasTransform(cursor,owner)); const transforms=new Map<Id,Transform>();for(const owner of owners)if(canvases.has(owner))transforms.set(owner,canvasTransform(cursor,owner));
// 私有 cursor 按原 before 逆向退回,不执行用户可见的 restore,也不因此分配新 incarnation。 // 直接把记录/字段写回原 before,不检查当前是否等于 after;field 缺对象/代次不符时跳过。
// cursor 中间树可不完整,随后仅在 owner 仍存在时取变换;无法解析的祖先链仍可能抛错。
for(const change of [...intent.changes].reverse()){ for(const change of [...intent.changes].reverse()){
if(change.kind==='element'){if(change.before)elements.set(change.id,change.before);else elements.delete(change.id);} if(change.kind==='element'){if(change.before)elements.set(change.id,change.before);else elements.delete(change.id);}
else if(change.kind==='canvas'){if(change.before)canvases.set(change.id,change.before);else canvases.delete(change.id);} else if(change.kind==='canvas'){if(change.before)canvases.set(change.id,change.before);else canvases.delete(change.id);}
@@ -51,7 +55,9 @@ function sameConvertedPath(left:Json,right:Json):boolean {
const point=(a:Json|undefined,b:Json|undefined)=>{if(a===undefined||b===undefined)return a===b;if(!a||!b||typeof a!=='object'||typeof b!=='object'||Array.isArray(a)||Array.isArray(b))return false;const{x:ax,y:ay,...restA}=a,{x:bx,y:by,...restB}=b;return typeof ax==='number'&&typeof ay==='number'&&typeof bx==='number'&&typeof by==='number'&&closeNumber(ax,bx)&&closeNumber(ay,by)&&equal(restA,restB);}; const point=(a:Json|undefined,b:Json|undefined)=>{if(a===undefined||b===undefined)return a===b;if(!a||!b||typeof a!=='object'||typeof b!=='object'||Array.isArray(a)||Array.isArray(b))return false;const{x:ax,y:ay,...restA}=a,{x:bx,y:by,...restB}=b;return typeof ax==='number'&&typeof ay==='number'&&typeof bx==='number'&&typeof by==='number'&&closeNumber(ax,bx)&&closeNumber(ay,by)&&equal(restA,restB);};
return equal(ar,br)&&point(as,bs)&&point(ae,be)&&a.length===b.length&&a.every((value,index)=>{const other=b[index];if(!value||!other||typeof value!=='object'||typeof other!=='object'||Array.isArray(value)||Array.isArray(other))return false;const{position:p,in:i,out:o,...rest}=value,{position:q,in:j,out:k,...remaining}=other;return equal(rest,remaining)&&point(p,q)&&point(i,j)&&point(o,k);}); return equal(ar,br)&&point(as,bs)&&point(ae,be)&&a.length===b.length&&a.every((value,index)=>{const other=b[index];if(!value||!other||typeof value!=='object'||typeof other!=='object'||Array.isArray(value)||Array.isArray(other))return false;const{position:p,in:i,out:o,...rest}=value,{position:q,in:j,out:k,...remaining}=other;return equal(rest,remaining)&&point(p,q)&&point(i,j)&&point(o,k);});
} }
/** 只把原意图实际改过的 presentation 键应用到 current;unitScale 另走视觉单位换算。 */ /** 以键的存在性和值比较找 presentation 顶层差异,再覆盖/删除 current 的对应键。
* 嵌套键值作为整体处理,不递归合并;unitScale 排除在本函数外,交几何分支做视觉单位换算。
* 无变化时沿用 current;有变化后若结果无键则返回 undefined,交调用方处理字段存在性。 */
function presentationDelta(current:Json|undefined,before:Json|undefined,after:Json|undefined):Json|undefined { function presentationDelta(current:Json|undefined,before:Json|undefined,after:Json|undefined):Json|undefined {
if(equal(before,after))return current; if(equal(before,after))return current;
const start=presentationRecord(before),end=presentationRecord(after),keys=new Set([...Object.keys(start),...Object.keys(end)]);keys.delete('unitScale'); const start=presentationRecord(before),end=presentationRecord(after),keys=new Set([...Object.keys(start),...Object.keys(end)]);keys.delete('unitScale');
@@ -63,6 +69,7 @@ function presentationDelta(current:Json|undefined,before:Json|undefined,after:Js
* 用户明确改变的中心/尺寸/旋转取其意图值,再转到 fresh destination;不是盲加一份旧 local patch。 */ * 用户明确改变的中心/尺寸/旋转取其意图值,再转到 fresh destination;不是盲加一份旧 local patch。 */
function movedNode(current:Extract<ElementRecord,{structure:'node'}>,originalBefore:Extract<ElementRecord,{structure:'node'}>,originalAfter:Extract<ElementRecord,{structure:'node'}>,beforeBasis:Transform,afterBasis:Transform,source:Transform,destination:Transform,intent:OperationIntent){ function movedNode(current:Extract<ElementRecord,{structure:'node'}>,originalBefore:Extract<ElementRecord,{structure:'node'}>,originalAfter:Extract<ElementRecord,{structure:'node'}>,beforeBasis:Transform,afterBasis:Transform,source:Transform,destination:Transform,intent:OperationIntent){
const world:Transform={x:0,y:0,scale:1,rotation:0},start=convertGeometry(originalBefore.geometry,beforeBasis,world),end=convertGeometry(originalAfter.geometry,afterBasis,world),fresh=convertGeometry(current.geometry,source,world); const world:Transform={x:0,y:0,scale:1,rotation:0},start=convertGeometry(originalBefore.geometry,beforeBasis,world),end=convertGeometry(originalAfter.geometry,afterBasis,world),fresh=convertGeometry(current.geometry,source,world);
// 世界中心用于分别判断横/纵移动意图;未变化的维度取 fresh,修改维度取原意图终值,而非叠加 delta。
const center=(g:typeof start)=>({x:g.x+g.width/2,y:g.y+g.height/2}),a=center(start),b=center(end),c=center(fresh),width=closeNumber(start.width,end.width)?fresh.width:end.width,height=closeNumber(start.height,end.height)?fresh.height:end.height; const center=(g:typeof start)=>({x:g.x+g.width/2,y:g.y+g.height/2}),a=center(start),b=center(end),c=center(fresh),width=closeNumber(start.width,end.width)?fresh.width:end.width,height=closeNumber(start.height,end.height)?fresh.height:end.height;
const x=closeNumber(a.x,b.x)?c.x:b.x,y=closeNumber(a.y,b.y)?c.y:b.y,rotation=Math.abs(((start.rotation-end.rotation+540)%360)-180)<1e-9?fresh.rotation:end.rotation; const x=closeNumber(a.x,b.x)?c.x:b.x,y=closeNumber(a.y,b.y)?c.y:b.y,rotation=Math.abs(((start.rotation-end.rotation+540)%360)-180)<1e-9?fresh.rotation:end.rotation;
const geometry=convertGeometry({x:x-width/2,y:y-height/2,width,height,rotation},world,destination); const geometry=convertGeometry({x:x-width/2,y:y-height/2,width,height,rotation},world,destination);
@@ -75,7 +82,9 @@ function movedNode(current:Extract<ElementRecord,{structure:'node'}>,originalBef
} }
/** Restore batches may have inverse storage order (canvas before entry). Only reorder adjacent creates, never move across an edit/delete. */ /** Restore batches may have inverse storage order (canvas before entry). Only reorder adjacent creates, never move across an edit/delete. */
/** 只在连续创建段内按 parent/entry/owner/端点依赖拓扑排序,绝不跨编辑/删除重排。 */ /** 只在连续创建段内按 parent/entry/owner/端点依赖拓扑排序,绝不跨编辑/删除重排。
* 仅段内依赖参与 Kahn 计数;段外依赖是否存在留给执行/最终校验。元素与 canvas 分命名空间。
* ready 按发现顺序排队,不保证每次都选择剩余项中原索引最小者;重 ID 或环直接拒绝。 */
function orderedCreates(changes:readonly Change[]):readonly Change[]{ function orderedCreates(changes:readonly Change[]):readonly Change[]{
const result:Change[]=[]; const result:Change[]=[];
/** before=null 且 after 存在才是创建,整记录替换不能冒充依赖排序候选。 */ /** before=null 且 after 存在才是创建,整记录替换不能冒充依赖排序候选。 */
@@ -101,17 +110,22 @@ function orderedCreates(changes:readonly Change[]):readonly Change[]{
return result; return result;
} }
/** Re-plan against current data. Whole records are used only for explicit creation/removal. */ /** Re-plan against current data; whole element replacement is unsupported. */
/** 在 fresh 私有文档上重新生成条件 changes,原 intent/context 保持不变;最终完整校验才返回。 */ /** 在 fresh 私有候选上重新生成条件 changes,保留当前未被用户意图修改的数据,最终整图校验才返回。
* 本函数不验证恢复选择/账本依赖,不发布 Root、不记录 history、不修改原冻结请求;这些由冲突协调器负责。
* 整元素仅支持显式增删,canvas 记录可重建 parent;普通字段的 before 从 fresh 实值重新生成。
* 输入应为已捕获的不可变模型/上下文,辅助 apply 的冻结不构成任意外部对象的深隔离保证。 */
export function rebaseIntent(document:Document,intent:OperationIntent,context:IntentContext,registry:Registry):{document:Document;changes:readonly Change[]} { export function rebaseIntent(document:Document,intent:OperationIntent,context:IntentContext,registry:Registry):{document:Document;changes:readonly Change[]} {
// moved 标记需整体世界空间重规划的节点;deferredPaths 等端点/树最终确定后再算曲线。 // moved 标记需整体世界空间重规划的节点;deferredPaths 等端点/树最终确定后再算曲线。
const before=document,changes:Change[]=[],moved=new Set<Id>(),deferredPaths:FieldChange[]=[]; const before=document,changes:Change[]=[],moved=new Set<Id>(),deferredPaths:FieldChange[]=[];
for(const change of intent.changes)if(change.kind==='field'&&change.field==='ownerCanvasId'&&context.after.get(change.target.id)?.structure==='node')moved.add(change.target.id); for(const change of intent.changes)if(change.kind==='field'&&change.field==='ownerCanvasId'&&context.after.get(change.target.id)?.structure==='node')moved.add(change.target.id);
/** 每条新变化立即作用于私有 candidate,后面的条件读取已重规划后的实际 before。 */ /** 每条新变化立即作用于私有 candidate,后面的条件读取已重规划后的实际 before。 */
function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));} function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));}
/** 同 ID 还必须同 incarnation/结构/组件/版本且 registry 可解释,防止旧意图套到新对象上。 */ /** 目标必须存在、代次相同且 registry 已知;上下文含 prior 时再比较结构/组件/版本。
* 这是字段分支共用准入,具体能力检查在各分支;上下文缺 prior 不在这里补造类型依据。 */
function target(change:FieldChange){const current=document.elements.get(change.target.id),prior=context.before.get(change.target.id)??context.after.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)throw fault('stale_incarnation',[change.target.id]);if(prior&&(current.componentId!==prior.componentId||current.dataVersion!==prior.dataVersion||current.structure!==prior.structure))throw fault('recovery_target_type_changed',[current.id]);if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);return current;} function target(change:FieldChange){const current=document.elements.get(change.target.id),prior=context.before.get(change.target.id)??context.after.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)throw fault('stale_incarnation',[change.target.id]);if(prior&&(current.componentId!==prior.componentId||current.dataVersion!==prior.dataVersion||current.structure!==prior.structure))throw fault('recovery_target_type_changed',[current.id]);if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);return current;}
/** 端点必须仍为原上下文中的同代次节点;不能把恢复出来的新节点视为旧外部引用。 */ /** 当前端点须为 node,且与 context.after 中对象代次一致;无上下文也拒绝。
* 这里不单独比较端点的 componentId/dataVersion,图关系是否合法仍由最终校验判断。 */
function endpoint(id:Id){const current=document.elements.get(id),expected=context.after.get(id);if(!current||current.structure!=='node'||!expected||current.incarnation!==expected.incarnation)throw fault('stale_endpoint',[id]);} function endpoint(id:Id){const current=document.elements.get(id),expected=context.after.get(id);if(!current||current.structure!=='node'||!expected||current.incarnation!==expected.incarnation)throw fault('stale_endpoint',[id]);}
// 按受限依赖顺序处理创建;删除/字段意图的原语义顺序仍保留。 // 按受限依赖顺序处理创建;删除/字段意图的原语义顺序仍保留。
for(const original of orderedCreates(intent.changes)){ for(const original of orderedCreates(intent.changes)){
@@ -132,7 +146,8 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In
after={...after,ownerCanvasId:owner,path:convertPath(after.path,basis,canvasTransform(document,owner))}; after={...after,ownerCanvasId:owner,path:convertPath(after.path,basis,canvasTransform(document,owner))};
} }
apply({...original,after}); apply({...original,after});
// 已经不存在的删除可满足;仍存在时按 fresh 拥有树规划移除,不能直接覆盖旧整记录。 // 删除目标已不存在时直接满足,不在此以墓碑证明它曾是哪一代;仍存在则要求旧代次和已知类型。
// 按 fresh 拥有树重新规划删除,可包含远端新增子内容/关联边,而不是照抄旧删除清单。
}else if(original.before&&original.after===null){ }else if(original.before&&original.after===null){
if(!current)continue; if(!current)continue;
if(current.incarnation!==original.before.incarnation)throw fault('stale_incarnation',[original.id]); if(current.incarnation!==original.before.incarnation)throw fault('stale_incarnation',[original.id]);
@@ -210,7 +225,8 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In
if(edge.ownerCanvasId!==owner)apply({kind:'field',target:original.target,field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner}); if(edge.ownerCanvasId!==owner)apply({kind:'field',target:original.target,field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner});
if(!equal(edge.path,path))apply({...original,before:edge.path,after:path}); if(!equal(edge.path,path))apply({...original,before:edge.path,after:path});
} }
// 所有端点/树变化结束后统一修复边 owner/path,再验证最终文档,避免暴露半份图。 // 使用初始 fresh 与最终候选修复受影响边的 owner/path,再做整图校验;空 changes 也要校验。
// 抛错不返回半成品,成功仍只是规划结果;是否采纳以及新的 operation/history 身份由调用方决定。
for(const change of repairConnectionOwners(before,document))apply(change); for(const change of repairConnectionOwners(before,document))apply(change);
validateDocument(document,registry);return{document,changes:freeze(changes)}; validateDocument(document,registry);return{document,changes:freeze(changes)};
} }
+25 -11
View File
@@ -26,7 +26,8 @@ export type RecoveryChoice = 'keep-local' | 'discard-local';
export type RecoveryDecision = { readonly operationId: Id; readonly choice: RecoveryChoice; readonly replacementId?: Id; readonly resolution: 'superseded' | 'discarded' | 'satisfied' }; export type RecoveryDecision = { readonly operationId: Id; readonly choice: RecoveryChoice; readonly replacementId?: Id; readonly resolution: 'superseded' | 'discarded' | 'satisfied' };
/** 恢复审计记录:绑定规划时的根 revision,保留备份及可用的历史重放证据。 */ /** 恢复审计记录:绑定规划时的根 revision,保留备份及可用的历史重放证据。 */
export type RecoveryAudit = { readonly id: Id; readonly formatVersion: 1; readonly baseRootRevision: number; readonly decisions: readonly RecoveryDecision[]; readonly backup: Json; readonly historyTransition?:HistoryTransition; readonly historyReplay?:HistoryReplay }; export type RecoveryAudit = { readonly id: Id; readonly formatVersion: 1; readonly baseRootRevision: number; readonly decisions: readonly RecoveryDecision[]; readonly backup: Json; readonly historyTransition?:HistoryTransition; readonly historyReplay?:HistoryReplay };
/** 待一次性发布的候选根及引用资源;baseRootRevision 供提交方拒绝过时方案。 */ /** 待采纳的文档/历史/操作/账本候选及审计,非完整 EngineSnapshot;引擎还需结合 canonical 版本等事实发布。
* baseRootRevision 用于拒绝过时方案,resourceIds 是本次已知保留集合,operationIds 只列新替代操作。 */
export type ConflictPlan = { readonly id: Id; readonly baseRootRevision: number; readonly document: Document; readonly history: HistoryState; readonly operations: EngineSnapshot['operations']; readonly ledger: EngineSnapshot['runtime']['ledger']; readonly audit: RecoveryAudit; readonly resourceIds: readonly Id[]; readonly operationIds: readonly Id[] }; export type ConflictPlan = { readonly id: Id; readonly baseRootRevision: number; readonly document: Document; readonly history: HistoryState; readonly operations: EngineSnapshot['operations']; readonly ledger: EngineSnapshot['runtime']['ledger']; readonly audit: RecoveryAudit; readonly resourceIds: readonly Id[]; readonly operationIds: readonly Id[] };
/** 字段身份包括元素代次,避免把删除后重建的同 ID 元素算成同一字段历史。 */ /** 字段身份包括元素代次,避免把删除后重建的同 ID 元素算成同一字段历史。 */
const fieldKey = (change: FieldChange) => JSON.stringify([change.target.id,change.target.incarnation,change.field]); const fieldKey = (change: FieldChange) => JSON.stringify([change.target.id,change.target.incarnation,change.field]);
@@ -41,8 +42,11 @@ function value(document: Document, change: FieldChange, allowMissing=false): {be
/** 把文档 Map 转成备份用的 JSON 形状;序列化的是数据,不是运行时方法或索引。 */ /** 把文档 Map 转成备份用的 JSON 形状;序列化的是数据,不是运行时方法或索引。 */
const documentValue = (document: Document) => ({projectId:document.projectId,canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:[...document.tombstones]}); const documentValue = (document: Document) => ({projectId:document.projectId,canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:[...document.tombstones]});
/** 纯规划入口:先校验权威读取与回执已全部落定,再按操作顺序投影用户决策。 /** 在不可变 snapshot 上规划恢复:先验 canonical 单调性/完整性,并要求 ledger 中请求 phase 全为 resolved。
* 不复用旧请求键/请求体;未知结果尚未确认时拒绝规划,防止重复执行已经生效的修改。 */ * 不自己查询回执、认证来源或等待磁盘;canonical/账本已捕获且可信由 engine/端口负责。
* 取全部非 resolved 且非 asset.upload 意图,要求 choices 精确覆盖每项;依次重规划、重建 history、退休旧项并加入替代项。
* 不发布/持久化/提交请求;createId 可消耗外部编号,失败不会回滚该回调副作用。
* 小输入/模型按既有不可变合同使用,备份 JSON 编码与全部恢复规划同步完成,没有时间片/容量预算。 */
export function planConflictRecovery(snapshot: EngineSnapshot, canonical: CanonicalProposal, choices: ReadonlyMap<Id,RecoveryChoice>, registry: Registry, createId:()=>Id,historyLimit=100): ConflictPlan { export function planConflictRecovery(snapshot: EngineSnapshot, canonical: CanonicalProposal, choices: ReadonlyMap<Id,RecoveryChoice>, registry: Registry, createId:()=>Id,historyLimit=100): ConflictPlan {
if(snapshot.runtime.loading)validateLoadingCompletion(snapshot.runtime.acknowledged,snapshot.runtime.versions,snapshot.runtime.loading,canonical.document,canonical.versions,registry,true);else validateCanonicalRead(snapshot.runtime.acknowledged,snapshot.runtime.versions,canonical.document,canonical.versions,registry); if(snapshot.runtime.loading)validateLoadingCompletion(snapshot.runtime.acknowledged,snapshot.runtime.versions,snapshot.runtime.loading,canonical.document,canonical.versions,registry,true);else validateCanonicalRead(snapshot.runtime.acknowledged,snapshot.runtime.versions,canonical.document,canonical.versions,registry);
if([...snapshot.runtime.ledger.requests.values()].some(request=>request.phase!=='resolved'))throw fault('recovery_waiting_for_receipts'); if([...snapshot.runtime.ledger.requests.values()].some(request=>request.phase!=='resolved'))throw fault('recovery_waiting_for_receipts');
@@ -59,7 +63,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
// 边界记录历史单元恢复前后的文档;starts 保存首次触及字段时的基线,供混合 ACK 历史核对。 // 边界记录历史单元恢复前后的文档;starts 保存首次触及字段时的基线,供混合 ACK 历史核对。
const boundaries=new Map<Id,{before:Document;after:Document}>(),starts=new Map<string,ReturnType<typeof value>>(),historyOwner=new Map<Id,Id>(),contexts=intentContexts(snapshot.document,records.map(record=>record.intent)); const boundaries=new Map<Id,{before:Document;after:Document}>(),starts=new Map<string,ReturnType<typeof value>>(),historyOwner=new Map<Id,Id>(),contexts=intentContexts(snapshot.document,records.map(record=>record.intent));
for(const entry of snapshot.history.entries.values())for(const operationId of entry.operationIds){if(historyOwner.has(operationId))throw fault('recovery_history_ambiguous');historyOwner.set(operationId,entry.id);} for(const entry of snapshot.history.entries.values())for(const operationId of entry.operationIds){if(historyOwner.has(operationId))throw fault('recovery_history_ambiguous');historyOwner.set(operationId,entry.id);}
// 有历史转移证据或之前已采用重放的操作时,优先用日志恢复保持 undo/redo 的语义。 // 全部 content 有原生 transition、某个遍历有 transition,或命中已有 adoption 时,进入严格日志恢复。
// 这只是分支选择,不证明证据齐全/可回放;journal 构造和后续 project/accept 仍可拒绝不完整/矛盾记录。
const adopted=new Set([...snapshot.runtime.recoveries.values()].filter(audit=>audit.historyReplay!==undefined).flatMap(audit=>audit.decisions.flatMap(decision=>decision.replacementId?[decision.replacementId]:[]))); const adopted=new Set([...snapshot.runtime.recoveries.values()].filter(audit=>audit.historyReplay!==undefined).flatMap(audit=>audit.decisions.flatMap(decision=>decision.replacementId?[decision.replacementId]:[])));
const content=records.filter(record=>record.intent.changes.length),hasEvidence=content.length>0&&content.every(record=>record.intent.historyTransition)||records.some(record=>['undo','redo'].includes(record.intent.origin)&&record.intent.historyTransition)||content.some(record=>adopted.has(record.intent.id)); const content=records.filter(record=>record.intent.changes.length),hasEvidence=content.length>0&&content.every(record=>record.intent.historyTransition)||records.some(record=>['undo','redo'].includes(record.intent.origin)&&record.intent.historyTransition)||content.some(record=>adopted.has(record.intent.id));
const journal=hasEvidence?createHistoryJournalRecovery(snapshot,records.map(record=>record.intent),registry,historyLimit):undefined; const journal=hasEvidence?createHistoryJournalRecovery(snapshot,records.map(record=>record.intent),registry,historyLimit):undefined;
@@ -78,7 +83,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
} }
// 用户新建 canvas 的身份若已被服务端占用,不能悄悄视为同一对象。 // 用户新建 canvas 的身份若已被服务端占用,不能悄悄视为同一对象。
if(choice==='keep-local'&&intent.origin==='user')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]); if(choice==='keep-local'&&intent.origin==='user')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]);
// keep-local 在新基线上重新计算改动;discard-local 不施加该意图,日志仍记录该决定。 // 每项边界 before→after 反映此前全部审阅结果;keep 在该新候选上规划,discard 不施加原文档变化。
// journal.project 还可能先推进已有结算历史;没有 journal 时直接走 rebaseIntent,不能并行按同一旧基线处理。
const rebased=journal?journal.project(document,intent,contexts.get(intent.id)!,choice==='keep-local'):choice==='keep-local'?rebaseIntent(document,intent,contexts.get(intent.id)!,registry):{document,changes:[]}; const rebased=journal?journal.project(document,intent,contexts.get(intent.id)!,choice==='keep-local'):choice==='keep-local'?rebaseIntent(document,intent,contexts.get(intent.id)!,registry):{document,changes:[]};
const changes=rebased.changes;document=rebased.document; const changes=rebased.changes;document=rebased.document;
if(entryId)boundaries.get(entryId)!.after=document; if(entryId)boundaries.get(entryId)!.after=document;
@@ -87,7 +93,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
// 生成启动、取消等动作需重新发起命令;冲突审阅不能顺手重新执行可能付费的任务。 // 生成启动、取消等动作需重新发起命令;冲突审阅不能顺手重新执行可能付费的任务。
if(intent.operationType!=='document.change'&&intent.operationType!=='run.apply')throw fault('recovery_action_requires_new_command',[intent.id]); if(intent.operationType!=='document.change'&&intent.operationType!=='run.apply')throw fault('recovery_action_requires_new_command',[intent.id]);
if(changes.length){ if(changes.length){
// 依赖替换为对应的新操作,丢弃或已满足的依赖不再排队;再串联此前所有替代操作。 // 原依赖若已审阅:改指对应 replacement,或在丢弃/已满足时移除;未审阅的既有依赖保留原 ID。
// 再依赖所有此前新 replacement,保证后项看到前项的串行效果;新 sequence 从账本最大值后递增。
const dependencyIds=intent.dependencies.flatMap(dependency=>replacements.has(dependency)?replacements.get(dependency)?[replacements.get(dependency)!.id]:[]:[dependency]); const dependencyIds=intent.dependencies.flatMap(dependency=>replacements.has(dependency)?replacements.get(dependency)?[replacements.get(dependency)!.id]:[]:[dependency]);
// New dependencies reference new logical facts; a discarded parent never silently starts a paid run. // New dependencies reference new logical facts; a discarded parent never silently starts a paid run.
const dependencySet=new Set(dependencyIds); const dependencySet=new Set(dependencyIds);
@@ -102,6 +109,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
} }
journal?.accept(intent,replacement,choice==='keep-local',before,document); journal?.accept(intent,replacement,choice==='keep-local',before,document);
replacements.set(intent.id,replacement); replacements.set(intent.id,replacement);
// satisfied 只表示这次规划无需新 changes;不是在此收到另一份服务器 applied 回执。
decisions.push(freeze({operationId:intent.id,choice,resolution:choice==='discard-local'?'discarded':replacement?'superseded':'satisfied',...(replacement?{replacementId:replacement.id}:{})})); decisions.push(freeze({operationId:intent.id,choice,resolution:choice==='discard-local'?'discarded':replacement?'superseded':'satisfied',...(replacement?{replacementId:replacement.id}:{})}));
} }
// 根据现有证据选日志重放、撤销/重做重规划或兼容历史重写,拒绝无法证明安全的路径。 // 根据现有证据选日志重放、撤销/重做重规划或兼容历史重写,拒绝无法证明安全的路径。
@@ -113,20 +121,24 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
ledger.add(replacement,presence); ledger.add(replacement,presence);
} }
const historyReplay=journal?.evidence(); const historyReplay=journal?.evidence();
// Conservative pins include the archived document/history and unresolved opaque local resources. // 已知资源保留集合:先纳入本地 resources 全部 ID,再补原/替代 changes 与历史 replay 中可解释引用。
// 不在此推断 opaque payload 的所有私有引用,也不把资源 ID 清单当作已保存 Blob 字节的证明。
const resourceIds=new Set(snapshot.runtime.resources.keys()); const resourceIds=new Set(snapshot.runtime.resources.keys());
for(const record of records)for(const resource of referencedResources(record.intent.changes,registry))resourceIds.add(resource); for(const record of records)for(const resource of referencedResources(record.intent.changes,registry))resourceIds.add(resource);
for(const replacement of replacements.values())if(replacement)for(const resource of referencedResources(replacement.changes,registry))resourceIds.add(resource); for(const replacement of replacements.values())if(replacement)for(const resource of referencedResources(replacement.changes,registry))resourceIds.add(resource);
if(historyReplay)for(const transition of [historyReplay.anchor,...historyReplay.events.map(event=>event.transition)])for(const resource of referencedResources(historyTransitionChanges(transition),registry))resourceIds.add(resource); if(historyReplay)for(const transition of [historyReplay.anchor,...historyReplay.events.map(event=>event.transition)])for(const resource of referencedResources(historyTransitionChanges(transition),registry))resourceIds.add(resource);
// 同时备份旧 working、ACK 基线和历史;资源 pin 保护恢复证据,防止审阅后被清理。 // backup 捕获旧 working/ACK/版本/history 和资源 ID;不嵌入 Blob、服务回执或完整 workspace 快照。
// 顶层 audit transition 描述旧→新历史,journal evidence 另解释内部事件;两者都须与恢复 Root 同次保存。
const backup=JSON.parse(JSON.stringify({document:documentValue(snapshot.document),acknowledged:documentValue(snapshot.runtime.acknowledged),versions:[...snapshot.runtime.versions],resourceIds:[...resourceIds],history:{undo:snapshot.history.undo,redo:snapshot.history.redo,entries:[...snapshot.history.entries]}})) as Json; const backup=JSON.parse(JSON.stringify({document:documentValue(snapshot.document),acknowledged:documentValue(snapshot.runtime.acknowledged),versions:[...snapshot.runtime.versions],resourceIds:[...resourceIds],history:{undo:snapshot.history.undo,redo:snapshot.history.redo,entries:[...snapshot.history.entries]}})) as Json;
const historyTransition=describeHistoryTransition(snapshot.history,history,snapshot.rootRevision+1,undefined,!!historyReplay); const historyTransition=describeHistoryTransition(snapshot.history,history,snapshot.rootRevision+1,undefined,!!historyReplay);
const audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup,...(historyTransition?{historyTransition}:{}),...(historyReplay?{historyReplay}:{})}); const audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup,...(historyTransition?{historyTransition}:{}),...(historyReplay?{historyReplay}:{})});
return Object.freeze({id,baseRootRevision:snapshot.rootRevision,document,history,operations:readMap(operations),ledger:ledger.getSnapshot(),audit,resourceIds:Object.freeze([...resourceIds]),operationIds:Object.freeze([...replacements.values()].flatMap(intent=>intent?[intent.id]:[]))}); return Object.freeze({id,baseRootRevision:snapshot.rootRevision,document,history,operations:readMap(operations),ledger:ledger.getSnapshot(),audit,resourceIds:Object.freeze([...resourceIds]),operationIds:Object.freeze([...replacements.values()].flatMap(intent=>intent?[intent.id]:[]))});
} }
/** 无完整日志证据时的兼容历史重写。仅支持能够证明前缀/字段基线的撤销栈项, /** 无 journal 的兼容分支,仅重写受本批 pending 影响的 undo 项,且其关联操作都必须是已知 user origin。
* 不推测交错 ACK、结构变更或不支持的历史遍历;无法验证时抛错让整个规划保持未发布。 */ * 全项未决时直接用 replacements 拼回;混合已确认贡献时,结构项要求确认贡献为完整前缀,纯字段按身份键分别审阅。
* 不生成完整事件 replay,不重规划 redo 或任意交错遍历;不满足证据条件即拒绝本次方案,交外层保持未发布。
* 返回私有 entry 表/undo 候选,未影响 entry 与 redo 数组沿用不可变快照引用。 */
function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap<Id,OperationIntent|undefined>,starts:ReadonlyMap<string,ReturnType<typeof value>>,boundaries:ReadonlyMap<Id,{before:Document;after:Document}>,registry:Registry):HistoryState { function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap<Id,OperationIntent|undefined>,starts:ReadonlyMap<string,ReturnType<typeof value>>,boundaries:ReadonlyMap<Id,{before:Document;after:Document}>,registry:Registry):HistoryState {
const entries=new Map(snapshot.history.entries),affected=new Set(pending.map(intent=>intent.id)); const entries=new Map(snapshot.history.entries),affected=new Set(pending.map(intent=>intent.id));
for(const entry of snapshot.history.entries.values()){ for(const entry of snapshot.history.entries.values()){
@@ -155,7 +167,8 @@ function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id); if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
continue; continue;
} }
// 纯字段混合历史按“目标代次 + 字段”分组;确认部分是否保留取决于恢复起点的值和存在性。 // 纯字段按 ID/代次/field 分组,先验证该字段历史尾部确为 oldPending,再分离已确认 prefix。
// starts 记录本次审阅首次触及该字段的 fresh 值/存在性;只在它仍等于旧 pending 的 before 时保留确认前缀。
const keys=new Map<string,FieldChange>();for(const intent of intents)for(const change of intent.changes as readonly FieldChange[])keys.set(fieldKey(change),change); const keys=new Map<string,FieldChange>();for(const intent of intents)for(const change of intent.changes as readonly FieldChange[])keys.set(fieldKey(change),change);
const fields:FieldChange[]=[]; const fields:FieldChange[]=[];
for(const [key,example]of keys){ for(const [key,example]of keys){
@@ -169,6 +182,7 @@ function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent
if(!start.missing&&equal(start.before,first.before)&&(start.beforePresent??true)===(first.beforePresent??true))fields.push(...prefix); if(!start.missing&&equal(start.before,first.before)&&(start.beforePresent??true)===(first.beforePresent??true))fields.push(...prefix);
fields.push(...intents.flatMap(intent=>replacements.get(intent.id)?.changes as readonly FieldChange[]??[]).filter(change=>fieldKey(change)===key)); fields.push(...intents.flatMap(intent=>replacements.get(intent.id)?.changes as readonly FieldChange[]??[]).filter(change=>fieldKey(change)===key));
} }
// 未触及字段保留;重建字段链追加到后面。此分支已排除 owner/端点等结构变化,不用该方式重排结构依赖。
const changes:Change[]=[...entry.changes.filter(change=>change.kind!=='field'||!keys.has(fieldKey(change))),...fields]; const changes:Change[]=[...entry.changes.filter(change=>change.kind!=='field'||!keys.has(fieldKey(change))),...fields];
const operationIds=entry.operationIds.flatMap(operationId=>affected.has(operationId)?replacements.get(operationId)?[replacements.get(operationId)!.id]:[]:[operationId]); const operationIds=entry.operationIds.flatMap(operationId=>affected.has(operationId)?replacements.get(operationId)?[replacements.get(operationId)!.id]:[]:[operationId]);
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id); if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
+19 -7
View File
@@ -12,7 +12,9 @@ import type { RunRecord } from './runs.ts';
import type { ResourceRecord } from './resources.ts'; import type { ResourceRecord } from './resources.ts';
import type { RecoveryAudit } from './conflicts.ts'; import type { RecoveryAudit } from './conflicts.ts';
/** 持久运行事实集合;view/selection/gesture 和渲染缓存不放入该结构。 */ /** 与 core 文档/历史同 Root 发布的运行事实;view/selection/gesture 和渲染缓存不在此持久状态中。
* 这是类型合同,不执行输入校验/序列化或深冻结;Map/记录的实际捕获由 engine、codec 和生产模块负责。
* 内存 Root 的变化不等于磁盘 durable 切点,调度仍需存储确认与 durableGeneration/rootRevision 等屏障。 */
export type RuntimeState = { export type RuntimeState = {
/** 项目资源与共享 AssetDepot 的授权/身份绑定,非第二份图片字节。 */ /** 项目资源与共享 AssetDepot 的授权/身份绑定,非第二份图片字节。 */
readonly assetBindings?:ReadonlyMap<Id,import('./asset-depot.ts').SharedAssetBinding>; readonly assetBindings?:ReadonlyMap<Id,import('./asset-depot.ts').SharedAssetBinding>;
@@ -46,17 +48,25 @@ export type RuntimeState = {
}; };
/** core Snapshot 的 runtime 具象化,单次订阅可同时看到模型和对应运行事实。 */ /** core Snapshot 的 runtime 具象化,单次订阅可同时看到模型和对应运行事实。 */
export type EngineSnapshot = Snapshot<RuntimeState>; export type EngineSnapshot = Snapshot<RuntimeState>;
/** 经过服务 codec 校验的文档与版本向量候选;只有协调完成后才能成为新基线。 */ /** 服务 codec 提供的文档/版本候选,按合同应已校验;类型没有品牌或运行时校验,任意外部对象仍需边界验证。
* 不包含 history/ledger/Root revision,也不自动证明完整捕获或当前授权;协调完成后才可作为 ACK 基线。 */
export type CanonicalProposal = { readonly document: Document; readonly versions: ReadonlyMap<Id, number> }; export type CanonicalProposal = { readonly document: Document; readonly versions: ReadonlyMap<Id, number> };
/** Receiving records are read deltas, not a validated Document or an ACK base. */ /** Receiving records are read deltas, not a validated Document or an ACK base. */
/** receiving 是尚未完成的读取增量,数量/owner 证据不表示整份 payload 已通过校验。 */ /** receiving 为尚未完成的 delta:头部一次声明 canvas/墓碑/可选范围证据,后续按读取协议增加元素。
* received 可包括依赖未闭合、尚不能发布的元素;不是完整 Document、ACK 或可直接提交给 editor 的 working。
* CanonicalReadUpdate 不携带网络 query/token/sequence 的完整围栏,那些由外层 LoadChunk 绑定。 */
export type CanonicalReadUpdate = CanonicalReadResult | { readonly status:'receiving'; readonly canvasElementCounts?:import('./scope-counts.ts').CanvasElementCounts;readonly elementOwners?:import('./scope-counts.ts').ElementOwners; readonly projectId:Id; readonly canvases:readonly CanvasRecord[]; readonly elements:readonly ElementRecord[]; readonly tombstones:ReadonlyMap<Id,number>; readonly versions:ReadonlyMap<Id,number>; readonly receivedElements:number; readonly totalElements:number }; export type CanonicalReadUpdate = CanonicalReadResult | { readonly status:'receiving'; readonly canvasElementCounts?:import('./scope-counts.ts').CanvasElementCounts;readonly elementOwners?:import('./scope-counts.ts').ElementOwners; readonly projectId:Id; readonly canvases:readonly CanvasRecord[]; readonly elements:readonly ElementRecord[]; readonly tombstones:ReadonlyMap<Id,number>; readonly versions:ReadonlyMap<Id,number>; readonly receivedElements:number; readonly totalElements:number };
/** ready 提供新模型;migration-required 描述显式迁移资格,不在读取时偷偷执行迁移。 */ /** ready 提供新模型;migration-required 描述显式迁移资格,不在读取时偷偷执行迁移。 */
export type CanonicalReadResult = ({ readonly status: 'ready';readonly sourceToken?:string } & CanonicalProposal) | { readonly status: 'migration-required'; readonly rootId: Id; readonly sourceVersions: ReadonlyMap<Id, number> }; export type CanonicalReadResult = ({ readonly status: 'ready';readonly sourceToken?:string } & CanonicalProposal) | { readonly status: 'migration-required'; readonly rootId: Id; readonly sourceVersions: ReadonlyMap<Id, number> };
/** A validated historical execution receipt; its old model is never published over a fresh read. */ /** A validated historical execution receipt; its old model is never published over a fresh read. */
/** 验证历史 applied 回执的执行证据;assertCanonical 比对 fresh read,不发布旧回执里的旧模型。 */ /** 由请求解释器验证历史 applied 回执后构造:versions 表示该次写入下界,assertCanonical 检查 fresh 内容兼容。
* proof 不包含待发布旧模型,也不持久化函数;恢复时由原请求/回执重新解释,回调应纯且基于不可变证据。
* 证明服务执行不等于本地已协调/已保存,更不授权再提交相同变化。 */
export type AppliedModelProof = { readonly versions:ReadonlyMap<Id,number>; assertCanonical(proposal:CanonicalProposal):void }; export type AppliedModelProof = { readonly versions:ReadonlyMap<Id,number>; assertCanonical(proposal:CanonicalProposal):void };
/** 增量服务端口:compile/canonical 是纯编解码,submit/query/read 是可取消外部 I/O。 */ /** 服务适配合同:compile/canonical/verifyApplied 等同步方法负责纯编解码/验证,异步方法负责外部 I/O。
* 引擎拥有本地发布/重放/持久化顺序;adapter 不应直接修改 snapshot、history 或 ledger。
* AbortSignal 只表达取消要求,不证明远端操作没生效;任何失败/超时都须按原 request 身份处理。
* 接口声明不会自动执行这些规则,具体实现和协调入口必须分别校验。 */
export type PersistencePort = { export type PersistencePort = {
/** Complete canonical read for a quiescent recovery session; never a legacy fallback. */ /** Complete canonical read for a quiescent recovery session; never a legacy fallback. */
/** 恢复协调所需的完整 canonical read;不能拿 legacy fallback 当目标协议基线。 */ /** 恢复协调所需的完整 canonical read;不能拿 legacy fallback 当目标协议基线。 */
@@ -65,7 +75,8 @@ export type PersistencePort = {
verifyApplied?: (request:FrozenRequest,receipt:Json) => AppliedModelProof; verifyApplied?: (request:FrozenRequest,receipt:Json) => AppliedModelProof;
/** 判断是否有原冻结协议解释器,不支持时保留原 body/key 并阻塞,不能重编码换身份。 */ /** 判断是否有原冻结协议解释器,不支持时保留原 body/key 并阻塞,不能重编码换身份。 */
supportsRequest?: (request: FrozenRequest) => boolean; supportsRequest?: (request: FrozenRequest) => boolean;
/** 将稳定意图与当前基线编成待冻结请求;网络发送前仍需满足持久化屏障。 */ /** 从稳定 intent/requestId/当前快照计算参与范围、expected versions 与待冻结 payload,不执行网络写入。
* scheduler/ledger 随后冻结字节并过持久屏障;重试已经冻结的请求应复用原 body,不以最新快照重编。 */
compile(intent: OperationIntent, requestId: Id, snapshot: EngineSnapshot): CompiledRequest; compile(intent: OperationIntent, requestId: Id, snapshot: EngineSnapshot): CompiledRequest;
/** Compile an ordered mutable batch; logical IDs and original intents remain independent. */ /** Compile an ordered mutable batch; logical IDs and original intents remain independent. */
/** 可把有序未冻结意图合并为一份 wire 请求,原 logical IDs 与意图事实仍独立保留。 */ /** 可把有序未冻结意图合并为一份 wire 请求,原 logical IDs 与意图事实仍独立保留。 */
@@ -78,6 +89,7 @@ export type PersistencePort = {
/** 可选精简回执 codec,仅在联合已协调发布时采用;失败应保留原恢复输入。 */ /** 可选精简回执 codec,仅在联合已协调发布时采用;失败应保留原恢复输入。 */
compactResolvedReceipt?: (request:FrozenRequest,receipt:Json,applied:CanonicalProposal)=>Json; compactResolvedReceipt?: (request:FrozenRequest,receipt:Json,applied:CanonicalProposal)=>Json;
/** Pure codec, merging only participants into the latest base, never an old working snapshot. */ /** Pure codec, merging only participants into the latest base, never an old working snapshot. */
/** 将回执参与者合并进最新已确认基线,不能拿旧 working 快照替换新本地编辑。 */ /** 解释原请求/结果,对最新 acknowledged 合并参与者并返回提案,不能拿旧 working 覆盖本地新编辑。
* 不自行发布 ACK 或重放 pending;引擎还需校验候选与版本,重建 working 后统一发布。 */
canonical(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot): CanonicalProposal; canonical(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot): CanonicalProposal;
}; };
+5 -2
View File
@@ -5,12 +5,15 @@
import {fault} from '../canvas-core/values.ts'; import {fault} from '../canvas-core/values.ts';
import {createImportBudget,type ImportScheduling} from './import-budget.ts'; import {createImportBudget,type ImportScheduling} from './import-budget.ts';
/** Share import scheduling while retaining the copy cancellation/error contract. */ /** Share import scheduling while retaining the copy cancellation/error contract. */
/** 验证copy预算后包装ImportBudget;调用者只有真正await返回Promise才会交出执行权。 */ /** 复制任务的 ImportBudget 包装:构造与每次 step 都先检查取消,错误码使用 copy_aborted。
* 只预校验 frameBudgetMs;units/时钟/调度器沿用底层合同,单位可以是记录数或由调用方折算的字节工作量。
* 与同一个包装交互应顺序 await;没有独立计数器、持久进度或强制抢占能力。 */
export function createCopyBudget(signal:AbortSignal,options:ImportScheduling={}){ export function createCopyBudget(signal:AbortSignal,options:ImportScheduling={}){
if(signal.aborted)throw fault('copy_aborted'); if(signal.aborted)throw fault('copy_aborted');
if(options.frameBudgetMs!==undefined&&(!Number.isFinite(options.frameBudgetMs)||options.frameBudgetMs<=0))throw fault('invalid_copy_budget'); if(options.frameBudgetMs!==undefined&&(!Number.isFinite(options.frameBudgetMs)||options.frameBudgetMs<=0))throw fault('invalid_copy_budget');
const budget=createImportBudget(signal,options); const budget=createImportBudget(signal,options);
/** signal已取消时将同步/异步预算失败统一为copy_aborted,其它错误原样抛给宿主。 */ /** 只在处理错误时 signal 已取消才改成 copy_aborted;其它错误保留原对象。
* 因此取消和宿主调度失败并发出现时,取消优先,不一定暴露原始调度错误。 */
const fail=(error:unknown):never=>{if(signal.aborted)throw fault('copy_aborted');throw error;}; const fail=(error:unknown):never=>{if(signal.aborted)throw fault('copy_aborted');throw error;};
/** 每次step先查copy取消,保持底层同步返回undefined或异步让出的原调度语义。 */ /** 每次step先查copy取消,保持底层同步返回undefined或异步让出的原调度语义。 */
return Object.freeze({step(units=1):Promise<void>|undefined{ return Object.freeze({step(units=1):Promise<void>|undefined{
+17 -6
View File
@@ -2,6 +2,7 @@
* 格式2的复制源:前段保存轻量topology和payload字节定位,后段保存原完整元素记录。 * 格式2的复制源:前段保存轻量topology和payload字节定位,后段保存原完整元素记录。
* 准备阶段只扫描索引/结构;真正复制某记录前才按range读取和校验payload,支持边转换边加入。 * 准备阶段只扫描索引/结构;真正复制某记录前才按range读取和校验payload,支持边转换边加入。
* 编码仍生成完整Blob,索引/定位仍占O(records)内存,不能据lazy解码宣称总内存常数。 * 编码仍生成完整Blob,索引/定位仍占O(records)内存,不能据lazy解码宣称总内存常数。
* 打开索引不证明所有 payload 已读/哈希通过;字节完整性、权限和任务准入由外层负责。
*/ */
import type {CanvasRecord,Document,ElementRecord,Id,Json,Registry,Data} from '../canvas-core/types.ts'; import type {CanvasRecord,Document,ElementRecord,Id,Json,Registry,Data} from '../canvas-core/types.ts';
import {createDocument,validateDocumentTopologySteps} from '../canvas-core/document.ts'; import {createDocument,validateDocumentTopologySteps} from '../canvas-core/document.ts';
@@ -11,7 +12,8 @@ import {createCopyBudget} from './copy-budget.ts';
import type {ImportScheduling} from './import-budget.ts'; import type {ImportScheduling} from './import-budget.ts';
/** Only ownership, endpoint and geometry fields needed to decide scope/coordinates. No component content. */ /** Only ownership, endpoint and geometry fields needed to decide scope/coordinates. No component content. */
/** 保留ID/owner/端点/几何及contentScale/unitScale,清组件内容,边path置null用于范围/坐标规划。 /** 保留ID/owner/端点/几何及contentScale/unitScale,清组件内容,边path置null用于范围/坐标规划。
* 返回结构记录不能直接作为用户元素发布,也不证明组件payload符合schema。 */ * 返回结构记录不能直接作为用户元素发布,也不证明组件payload符合schema。
* 本函数只创建外壳:geometry/端点和保留的 presentation 值仍借用原引用,没有校验或深捕获。 */
export function copyTopology(record:ElementRecord):ElementRecord{ export function copyTopology(record:ElementRecord):ElementRecord{
const raw=record.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?Object.fromEntries(['contentScale','unitScale'].filter(key=>Object.hasOwn(raw,key)).map(key=>[key,raw[key]])):{}; const raw=record.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?Object.fromEntries(['contentScale','unitScale'].filter(key=>Object.hasOwn(raw,key)).map(key=>[key,raw[key]])):{};
const data:Data=Object.keys(presentation).length?{presentation}:{}; const data:Data=Object.keys(presentation).length?{presentation}:{};
@@ -19,7 +21,9 @@ export function copyTopology(record:ElementRecord):ElementRecord{
return record.structure==='node'?{...common,structure:'node',geometry:record.geometry}:{...common,structure:'connection',source:record.source,target:record.target,path:null}; return record.structure==='node'?{...common,structure:'node',geometry:record.geometry}:{...common,structure:'connection',source:record.source,target:record.target,path:null};
} }
/** Format 2 places a compact topology/byte-locator index before the uninterpreted payload region. */ /** Format 2 places a compact topology/byte-locator index before the uninterpreted payload region. */
/** 输出完整format2源:header/canvas/element-index/tombstone/payload标记,然后原元素NDJSON区域。 */ /** 输出完整format2源:header/canvas/element-index/tombstone/payload标记,然后原元素NDJSON区域。
* 借用不可变 Document 合同,逐记录 await 后仍读取原表;没有在入口同步捕获整图或执行完整校验。
* index/payload 两个数组都留存到最终 Blob,预算只能在记录间让出,不能打断一次原生 stringify。 */
export async function encodeIndexedCopySnapshot(document:Document,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Blob>{ export async function encodeIndexedCopySnapshot(document:Document,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Blob>{
const index:BlobPart[]=[],payload:BlobPart[]=[];const budget=createCopyBudget(signal,scheduling);let indexBytes=0,payloadBytes=0; const index:BlobPart[]=[],payload:BlobPart[]=[];const budget=createCopyBudget(signal,scheduling);let indexBytes=0,payloadBytes=0;
/** 单记录序列化、UTF8计长与256KiB上限,保持原始记录/索引locator成本可核验。 */ /** 单记录序列化、UTF8计长与256KiB上限,保持原始记录/索引locator成本可核验。 */
@@ -38,7 +42,9 @@ export async function encodeIndexedCopySnapshot(document:Document,signal:AbortSi
/** document只含结构投影,readElement取得完整record;调用方必须明确使用topologyOnly规划模式。 */ /** document只含结构投影,readElement取得完整record;调用方必须明确使用topologyOnly规划模式。 */
export type IndexedCopySnapshot={readonly document:Document;readElement(id:Id,signal:AbortSignal):Promise<ElementRecord>}; export type IndexedCopySnapshot={readonly document:Document;readElement(id:Id,signal:AbortSignal):Promise<ElementRecord>};
/** Scan only the small index. Component JSON/schema checks occur just before each record enters the runner. */ /** Scan only the small index. Component JSON/schema checks occur just before each record enters the runner. */
/** 只扫描前段索引,验证locator连续/总长/项目与结构,再返回有界窗口读取器。 */ /** 只解析前段索引,验证locator连续/总长/项目与结构,再返回有界窗口读取器。
* NDJSON reader 可预读越过 payload 标记的原始字节,但打开时不会解析/验证每条完整元素内容。
* document 是独立捕获的结构文档,locators/窗口表留在闭包;对象冻结不替代 readElement 的后续校验。 */
export async function openIndexedCopySnapshot(bytes:Blob,projectId:Id,registry:Registry,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<IndexedCopySnapshot>{ export async function openIndexedCopySnapshot(bytes:Blob,projectId:Id,registry:Registry,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<IndexedCopySnapshot>{
if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source'); if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source');
const budget=createCopyBudget(signal,scheduling),source=createNdjsonImportSource({bytes,fingerprint:'indexed-copy-snapshot'}),canvases:CanvasRecord[]=[],elements:ElementRecord[]=[],tombstones=new Map<Id,number>(),locators=new Map<Id,{offset:number;byteLength:number}>();let cursor=0,header=false,payloadOffset=-1,expectedOffset=0; const budget=createCopyBudget(signal,scheduling),source=createNdjsonImportSource({bytes,fingerprint:'indexed-copy-snapshot'}),canvases:CanvasRecord[]=[],elements:ElementRecord[]=[],tombstones=new Map<Id,number>(),locators=new Map<Id,{offset:number;byteLength:number}>();let cursor=0,header=false,payloadOffset=-1,expectedOffset=0;
@@ -62,17 +68,22 @@ export async function openIndexedCopySnapshot(bytes:Blob,projectId:Id,registry:R
for(const _ of validateDocumentTopologySteps(document,registry))await budget.step(); for(const _ of validateDocumentTopologySteps(document,registry))await budget.step();
if(signal.aborted)throw fault('copy_aborted'); if(signal.aborted)throw fault('copy_aborted');
// Cache raw byte windows, never decoded component objects. Preserve parser batching across Blob task boundaries. // Cache raw byte windows, never decoded component objects. Preserve parser batching across Blob task boundaries.
// 缓存原始byte窗口而非解码对象,最多4窗口/1MiB,减少每记录Blob读取任务又限制常驻字节。 // 缓存原始 byte 窗口而非解码对象,表内最多4窗口/1MiB;命中不更新顺序,是插入/替换顺序淘汰。
// 上限只约束表内窗口,不包括 Blob、本次 raw、并发未完成的读取或解码结果;没有并发请求去重。
const windows=new Map<number,Uint8Array>(),windowBytes=64*1024;let cachedBytes=0; const windows=new Map<number,Uint8Array>(),windowBytes=64*1024;let cachedBytes=0;
/** readElement按locator截取原payload,fatal UTF8/JSON、topology一致及已知schema全部通过才返回冻结record。 */ /** readElement按locator截取原payload,fatal UTF8/JSON、topology一致及已知schema全部通过才返回冻结record。
* 每次命中仍重新解码/JSON.parse/校验,不缓存组件对象;此路径没有独立合作预算或异步 schema 执行。
* AbortSignal 只在入口及实际 Blob 读取后检查,不会打断同步 parse/validate;失败不清空已缓存字节。 */
return Object.freeze({document,async readElement(id:Id,signal:AbortSignal){ return Object.freeze({document,async readElement(id:Id,signal:AbortSignal){
if(signal.aborted)throw fault('copy_aborted');const locator=locators.get(id);if(!locator)throw fault('element_missing',[id]); if(signal.aborted)throw fault('copy_aborted');const locator=locators.get(id);if(!locator)throw fault('element_missing',[id]);
// 从 payload 相对的64KiB边界起读,跨窗大记录扩大本次窗口;不靠 JS 字符下标定位多字节文本。
const start=Math.floor(locator.offset/windowBytes)*windowBytes,required=locator.offset+locator.byteLength-start;let window=windows.get(start); const start=Math.floor(locator.offset/windowBytes)*windowBytes,required=locator.offset+locator.byteLength-start;let window=windows.get(start);
if(!window||window.byteLength<required){const raw=await bytes.slice(payloadOffset+start,Math.min(bytes.size,payloadOffset+start+Math.max(windowBytes,required))).arrayBuffer();if(signal.aborted)throw fault('copy_aborted');const previous=windows.get(start);if(previous)cachedBytes-=previous.byteLength;window=new Uint8Array(raw);windows.delete(start);windows.set(start,window);cachedBytes+=window.byteLength;while(windows.size>4||cachedBytes>1024*1024){const[key,value]=windows.entries().next().value!;windows.delete(key);cachedBytes-=value.byteLength;}} if(!window||window.byteLength<required){const raw=await bytes.slice(payloadOffset+start,Math.min(bytes.size,payloadOffset+start+Math.max(windowBytes,required))).arrayBuffer();if(signal.aborted)throw fault('copy_aborted');const previous=windows.get(start);if(previous)cachedBytes-=previous.byteLength;window=new Uint8Array(raw);windows.delete(start);windows.set(start,window);cachedBytes+=window.byteLength;while(windows.size>4||cachedBytes>1024*1024){const[key,value]=windows.entries().next().value!;windows.delete(key);cachedBytes-=value.byteLength;}}
const raw=window.subarray(locator.offset-start,required);let value:{kind:string;record:ElementRecord}; const raw=window.subarray(locator.offset-start,required);let value:{kind:string;record:ElementRecord};
try{value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(raw));}catch{throw fault('invalid_copy_snapshot');} try{value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(raw));}catch{throw fault('invalid_copy_snapshot');}
if(!value||value.kind!=='element'||!validJson(value.record)||!value.record.data||typeof value.record.data!=='object'||Array.isArray(value.record.data)||!equal(copyTopology(value.record),document.elements.get(id)))throw fault('invalid_copy_snapshot'); if(!value||value.kind!=='element'||!validJson(value.record)||!value.record.data||typeof value.record.data!=='object'||Array.isArray(value.record.data)||!equal(copyTopology(value.record),document.elements.get(id)))throw fault('invalid_copy_snapshot');
// 当前registry已知版本才执行其schema;后续复制转换还会要求对应copy解释器存在。 // 当前 registry 已知版本才执行其 schema;未知版本保留通用 JSON/拓扑,不等于已理解内容。
// readElement 不迁移版本、不执行复制解释器;这些要求由后续复制转换流程处理。
const record=value.record,definition=registry.components.get(record.componentId);if(registry.resolve(record)==='known'&&(!definition||definition.structure!==record.structure||!definition.validate(record.data)))throw fault('invalid_component_data',[id]); const record=value.record,definition=registry.components.get(record.componentId);if(registry.resolve(record)==='known'&&(!definition||definition.structure!==record.structure||!definition.validate(record.data)))throw fault('invalid_component_data',[id]);
return freeze(record); return freeze(record);
}}); }});
+12 -3
View File
@@ -1,6 +1,7 @@
/** /**
* 格式1的完整复制源快照与复制声明签名。源字节在任务准入前从一个不可变文档捕获, * 格式1的完整复制源快照与复制声明签名。源字节在任务准入前从一个不可变文档捕获,
* 后续规划/重开不读取当前用户正在改的live文档;源SHA与持久字节由外层完整性流程绑定。 * 后续规划/重开不读取当前用户正在改的live文档;源SHA与持久字节由外层完整性流程绑定。
* 编码器借用传入 Document 并在 await 后继续迭代,其不可变性是调用合同,并非函数进入时同步复制全图。
*/ */
import type { CanvasRecord, Document, ElementRecord, Registry } from '../canvas-core/types.ts'; import type { CanvasRecord, Document, ElementRecord, Registry } from '../canvas-core/types.ts';
import { createDocument, validateDocumentSteps } from '../canvas-core/document.ts'; import { createDocument, validateDocumentSteps } from '../canvas-core/document.ts';
@@ -10,7 +11,9 @@ import { createNdjsonImportSource } from './imports.ts';
import {createCopyBudget} from './copy-budget.ts'; import {createCopyBudget} from './copy-budget.ts';
import type {ImportScheduling} from './import-budget.ts'; import type {ImportScheduling} from './import-budget.ts';
/** Capture immutable source bytes before a preparation job is admitted. No mutable live getters. */ /** Capture immutable source bytes before a preparation job is admitted. No mutable live getters. */
/** 逐项编码header/canvas/element/tombstone为NDJSON,检查记录/总源上限并合作让出,最终返回完整Blob。 */ /** 逐项编码header/canvas/element/tombstone为NDJSON,检查记录/总源上限并合作让出,最终返回完整Blob。
* 每条 stringify/UTF8 计长仍同步完成,按含换行字节数折算预算;parts 留存全源,不是向持久层流式写入。
* 这里不验证 Document;借入的 getter/toJSON 如有副作用或抛错,也没有回滚机制。 */
export async function encodeCopySnapshot(document:Document,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Blob>{ export async function encodeCopySnapshot(document:Document,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Blob>{
const budget=createCopyBudget(signal,scheduling),parts:BlobPart[]=[];let bytes=0; const budget=createCopyBudget(signal,scheduling),parts:BlobPart[]=[];let bytes=0;
/** 生成一条记录并累计实际UTF8字节,单条≤256KiB、总源≤64MiB;不省略未知组件payload。 */ /** 生成一条记录并累计实际UTF8字节,单条≤256KiB、总源≤64MiB;不省略未知组件payload。 */
@@ -19,13 +22,16 @@ export async function encodeCopySnapshot(document:Document,signal:AbortSignal,sc
for(const[kind,records]of [['canvas',document.canvases.values()],['element',document.elements.values()],['tombstone',document.tombstones.entries()]] as const)for(const record of records){const size=append({kind,record});await budget.step(Math.max(1,Math.ceil(size/1024)));} for(const[kind,records]of [['canvas',document.canvases.values()],['element',document.elements.values()],['tombstone',document.tombstones.entries()]] as const)for(const record of records){const size=append({kind,record});await budget.step(Math.max(1,Math.ceil(size/1024)));}
if(signal.aborted)throw fault('copy_aborted');return new Blob(parts,{type:'application/x-ndjson'}); if(signal.aborted)throw fault('copy_aborted');return new Blob(parts,{type:'application/x-ndjson'});
} }
/** 旧源完整解码,验证同project/header、墓碑唯一,重建并分片校验完整Document后才返回。 */ /** 旧源完整解码,验证同project/header、墓碑唯一,重建并分片校验完整Document后才返回。
* 记录读到 EOF 后才发布返回值,途中数组/Map 仅是私有暂存;不向 editor 逐项提交。
* 记录读取上限沿用 NDJSON reader,此入口没有 format2 那样的 Blob 总长检查;可信持久源大小由外层负责。 */
export async function decodeCopySnapshot(bytes:Blob,projectId:string,registry:Registry,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Document>{ export async function decodeCopySnapshot(bytes:Blob,projectId:string,registry:Registry,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Document>{
const budget=createCopyBudget(signal,scheduling),source=createNdjsonImportSource({bytes,fingerprint:'captured-copy-snapshot'}),canvases:CanvasRecord[]=[],elements:ElementRecord[]=[],tombstones=new Map<string,number>();let cursor=0,header=false; const budget=createCopyBudget(signal,scheduling),source=createNdjsonImportSource({bytes,fingerprint:'captured-copy-snapshot'}),canvases:CanvasRecord[]=[],elements:ElementRecord[]=[],tombstones=new Map<string,number>();let cursor=0,header=false;
while(true){ while(true){
if(signal.aborted)throw fault('copy_aborted');const start=cursor,line=await source.read(cursor,signal);if(!line)break;cursor=line.nextCursor; if(signal.aborted)throw fault('copy_aborted');const start=cursor,line=await source.read(cursor,signal);if(!line)break;cursor=line.nextCursor;
const value=line.value as unknown as {kind:string;version?:number;projectId?:string;record:never};if(line.error||line.skip||!value)throw fault('invalid_copy_snapshot'); const value=line.value as unknown as {kind:string;version?:number;projectId?:string;record:never};if(line.error||line.skip||!value)throw fault('invalid_copy_snapshot');
if(!header){if(value.kind!=='header'||value.version!==1||value.projectId!==projectId)throw fault('invalid_copy_snapshot');header=true;continue;} if(!header){if(value.kind!=='header'||value.version!==1||value.projectId!==projectId)throw fault('invalid_copy_snapshot');header=true;continue;}
// 分派只检查行种类;canvas/element 的重复 ID、结构和 schema 留给后续工厂与完整文档检查。
if(value.kind==='canvas')canvases.push(value.record);else if(value.kind==='element')elements.push(value.record);else if(value.kind==='tombstone'){const record=value.record as [string,number];if(!Array.isArray(record)||record.length!==2||tombstones.has(record[0]))throw fault('invalid_copy_snapshot');tombstones.set(record[0],record[1]);}else throw fault('invalid_copy_snapshot'); if(value.kind==='canvas')canvases.push(value.record);else if(value.kind==='element')elements.push(value.record);else if(value.kind==='tombstone'){const record=value.record as [string,number];if(!Array.isArray(record)||record.length!==2||tombstones.has(record[0]))throw fault('invalid_copy_snapshot');tombstones.set(record[0],record[1]);}else throw fault('invalid_copy_snapshot');
await budget.step(Math.max(1,Math.ceil((cursor-start)/1024))); await budget.step(Math.max(1,Math.ceil((cursor-start)/1024)));
} }
@@ -38,7 +44,10 @@ export async function decodeCopySnapshot(bytes:Blob,projectId:string,registry:Re
} }
/** Copy declarations are a versioned input; never silently reinterpret a persisted preparation. */ /** Copy declarations are a versioned input; never silently reinterpret a persisted preparation. */
/** 仅对源涉及组件的数据版本/structure/copy声明生成稳定签名,防持久准备被新声明静默重解释。 */ /** 仅对源涉及组件的数据版本/structure/copy声明生成签名,防持久准备被新声明静默重解释。
* Set 去重并排序 componentId,让元素遍历顺序不影响签名;缺失定义也明确编码为 missing。
* 结果是 JSON 文本而非加密摘要;copy 内部键序沿用定义,不覆盖 schema/handler 函数或整个 registry。
* 比较与拒绝发生在消费此签名的任务流程,本函数不锁定 registry 或校验源文档。 */
export function copyRegistrySignature(document:Document,registry:Registry):string{ export function copyRegistrySignature(document:Document,registry:Registry):string{
const ids=new Set([...document.elements.values()].map(element=>element.componentId)); const ids=new Set([...document.elements.values()].map(element=>element.componentId));
return JSON.stringify([...ids].sort().map(id=>{const definition=registry.components.get(id);return definition?{id,dataVersion:definition.dataVersion,structure:definition.structure,copy:definition.copy??null}: {id,missing:true};})); return JSON.stringify([...ids].sort().map(id=>{const definition=registry.components.get(id);return definition?{id,dataVersion:definition.dataVersion,structure:definition.structure,copy:definition.copy??null}: {id,missing:true};}));
+19 -9
View File
@@ -1,7 +1,7 @@
/** /**
* 为新建图片保留一次可恢复的自动默认尺寸授权。解码/服务元数据异步到达后,只有创建历史、 * 为新建图片保留一次可恢复的自动默认尺寸授权。解码/服务元数据异步到达后,只有创建历史、
* 目标身份/类型、资源引用及初始视觉尺寸仍有效时才允许调整;实际文档/历史改写走专用 planner。 * 目标身份/类型、资源引用及初始视觉尺寸仍有效时才允许调整;实际文档/历史改写走专用 planner。
* 用户已提交的手动 resize 撤销授权,即使以后 undo 回到初始尺寸也不自动复活。 * 已判为用户 resize 的授权转为 revoked,正常后续 undo 不自动复活;本模块不负责执行异步 resize 或保存磁盘。
*/ */
import type { Commit, CoreSnapshot, Id, Registry, Target } from '../canvas-core/types.ts'; import type { Commit, CoreSnapshot, Id, Registry, Target } from '../canvas-core/types.ts';
import { closeNumber, unitScale } from '../canvas-core/units.ts'; import { closeNumber, unitScale } from '../canvas-core/units.ts';
@@ -9,7 +9,9 @@ import { fault, freeze, readMap } from '../canvas-core/values.ts';
import type { EngineSnapshot, RuntimeState } from './contracts.ts'; import type { EngineSnapshot, RuntimeState } from './contracts.ts';
import type { ResourceRecord } from './resources.ts'; import type { ResourceRecord } from './resources.ts';
/** 目标代次/创建entry/组件版本和初始视觉尺寸共同限定授权,status/code 描述可恢复结果。 */ /** 以目标代次/创建 entry/组件版本和 width,height÷unitScale 的初值限定一次自动尺寸授权。
* pending 等候元数据/调度,blocked 可由引擎显式重试,applied/revoked 不再由本检查器自动执行。
* 这些状态不等同上传状态或另一个 undo 单元,也不单独证明用户权限/创建历史仍可用。 */
export type DefaultSizeGrant = { export type DefaultSizeGrant = {
readonly target: Target; readonly entryId: Id; readonly componentId: string; readonly dataVersion: number; readonly target: Target; readonly entryId: Id; readonly componentId: string; readonly dataVersion: number;
readonly initialSize: { readonly width: number; readonly height: number }; readonly initialSize: { readonly width: number; readonly height: number };
@@ -18,7 +20,9 @@ export type DefaultSizeGrant = {
/** 每资源的版本化授权列表;图片副本可有多个目标,各 grant 独立判断。 */ /** 每资源的版本化授权列表;图片副本可有多个目标,各 grant 独立判断。 */
export type DefaultImageSizing = { readonly version: 1; readonly grants: readonly DefaultSizeGrant[] }; export type DefaultImageSizing = { readonly version: 1; readonly grants: readonly DefaultSizeGrant[] };
/** 校验解释器、目标/entry/尺寸/状态,并禁止同一 ID+incarnation 的重复 grant。 */ /** 只验 v1、目标/entry/组件版本、正有限初始尺寸、状态/code 与单资源内目标代次唯一性。
* 不查真实 document/history/resource,不证明所有 grant 有效,也不捕获/冻结输入或要求尺寸为整数。
* 未知版本报解释器不可用;保留未来 opaque sizing 的兼容策略由恢复边界处理,不在此按 v1 解释。 */
export function validateDefaultImageSizing(value: unknown): asserts value is DefaultImageSizing { export function validateDefaultImageSizing(value: unknown): asserts value is DefaultImageSizing {
const state = value as DefaultImageSizing; const state = value as DefaultImageSizing;
if (!state || state.version !== 1) throw fault('default_size_interpreter_unavailable'); if (!state || state.version !== 1) throw fault('default_size_interpreter_unavailable');
@@ -34,7 +38,10 @@ export function validateDefaultImageSizing(value: unknown): asserts value is Def
} }
} }
/** 从当前创建提交与关联 history 捕获授权,只针对引用该 assetId 的已知且可 resize 节点。 */ /** 从有本 commit 操作贡献的 history 项与 commit 创建变化,匹配当前同代次、同 assetId、已知且能 resize 的 node。
* 保存提交后 width/height÷unitScale,不取文件像素/世界坐标;此时尚不需要 stored 回执或解码尺寸。
* entry 按“含 commit 操作 ID”筛选,不逐项证明该 entry 的 changes 恰含该目标创建;调用方须提供真实创建提交。
* 替换 resource.sizing 为本次 grants(可为空),不合并旧 grant 或做重复校验;完整 grant 校验由发布/恢复边界负责。 */
export function captureDefaultImageSizing(resource: ResourceRecord, next: CoreSnapshot, commit: Commit, registry: Registry): ResourceRecord { export function captureDefaultImageSizing(resource: ResourceRecord, next: CoreSnapshot, commit: Commit, registry: Registry): ResourceRecord {
const grants: DefaultSizeGrant[] = []; const grants: DefaultSizeGrant[] = [];
for (const entry of next.history.entries.values()) { for (const entry of next.history.entries.values()) {
@@ -54,10 +61,11 @@ export function captureDefaultImageSizing(resource: ResourceRecord, next: CoreSn
return freeze({ ...resource, sizing: { version: 1, grants } }); return freeze({ ...resource, sizing: { version: 1, grants } });
} }
/** Runs on every publication path. Revocation is durable and does not revive when /** Return a revocation candidate from each publication path; the engine persists the Root. */
* undo happens to return to the default geometry. Preview-only gestures do not publish. /** 对 next 的 v1 pending/blocked 授权检查身份/类型/资源、创建项仍在 undo、归一化尺寸和新用户意图。
*/ * 失效则返回带 revoked/code 的 copy-on-write runtime,不执行 resize/网络/磁盘;未变化复用 next.runtime。
/** 每条 Root 发布路径检查 pending/blocked grant;失效时持久标 revoked,未变化则复用 runtime。 */ * applied/revoked 输入直接保留,未来版本不解释;不独自阻止受信调用方手动改写这些状态,状态所有权由引擎管理。
* 不读取 registry/手势/解码结果,真正消费 grant 的资格与尺寸计划由引擎随后复核。 */
export function prepareDefaultImageSizing(previous: EngineSnapshot, next: EngineSnapshot): RuntimeState { export function prepareDefaultImageSizing(previous: EngineSnapshot, next: EngineSnapshot): RuntimeState {
let resources = next.runtime.resources; let resources = next.runtime.resources;
for (const [id, resource] of resources) { for (const [id, resource] of resources) {
@@ -76,7 +84,9 @@ export function prepareDefaultImageSizing(previous: EngineSnapshot, next: Engine
if (!closeNumber(element.geometry.width / units, grant.initialSize.width) || !closeNumber(element.geometry.height / units, grant.initialSize.height)) code = 'user_resized'; if (!closeNumber(element.geometry.width / units, grant.initialSize.width) || !closeNumber(element.geometry.height / units, grant.initialSize.height)) code = 'user_resized';
// Detect committed resize-away-and-back transactions. Pure coordinate // Detect committed resize-away-and-back transactions. Pure coordinate
// conversions may alter geometry sizes and unitScale together. // conversions may alter geometry sizes and unitScale together.
// 已提交事务即使最终尺寸回原值,也检查用户 geometry changes;纯 owner/unit 转换例外。 // 在文档变化且 operations 数量增加时扫描新 user 意图,识别 resize-away-and-back;假设操作表按正常发布追加。
// 同一意图含 owner 字段或 unitScale 变化就整体按转换豁免此附加检查;不逐轴拆分任意混合转换/手动缩放。
// 净归一化尺寸已改变仍会在上方撤销;纯预览不发布 operations,因此不会因 pointermove 单独触发。
if (!code && previous.document !== next.document && previous.operations.size !== next.operations.size) { if (!code && previous.document !== next.document && previous.operations.size !== next.operations.size) {
for (const [operationId, intent] of next.operations) { for (const [operationId, intent] of next.operations) {
if (previous.operations.has(operationId) || intent.origin !== 'user') continue; if (previous.operations.has(operationId) || intent.origin !== 'user') continue;
+10 -2
View File
@@ -12,11 +12,15 @@ import type {StorageLeader,StoragePort} from './storage-port.ts';
import {resourceRetentionPins} from './resource-retention.ts'; import {resourceRetentionPins} from './resource-retention.ts';
/** Derive one project's shared-byte manifest from the exact Root being persisted. */ /** Derive one project's shared-byte manifest from the exact Root being persisted. */
/** 生成已确认上传/跨项目 binding 的共享身份、所需字节与保留清单;无 depot 能力明确拒绝。 */ /** 从指定不可变 snapshot 派生共享身份/字节写入项与 pins,缺少 assetDepot 端口则拒绝。
* 会异步读取 depot/本地文件;不提交存储事务、不重算摘要,也不在 await 后自行校验 leader/宿主身份。
* 返回可变描述数组而非冻结 Root;调用方须把清单与该 snapshot 同次提交,rootRevision 不另存于返回值。
* snapshot 和各输入缓存需遵循宿主捕获约定,尤其 adoptedFiles 用 revision 防止混入较晚的采纳。 */
export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Registry;storage:StoragePort;leader:StorageLeader;authority:string;files:ReadonlyMap<string,Blob>;displays:ReadonlyMap<string,number>;temporary:ReadonlyMap<string,readonly string[]>;adoptedFiles?:ReadonlyMap<string,{readonly bytes:Blob;readonly rootRevision:number}>}):Promise<DepotCut>{ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Registry;storage:StoragePort;leader:StorageLeader;authority:string;files:ReadonlyMap<string,Blob>;displays:ReadonlyMap<string,number>;temporary:ReadonlyMap<string,readonly string[]>;adoptedFiles?:ReadonlyMap<string,{readonly bytes:Blob;readonly rootRevision:number}>}):Promise<DepotCut>{
const {snapshot,storage,leader,authority}=input,pins=resourceRetentionPins(snapshot,input.registry,input.displays,input.temporary),writes:DepotWrite[]=[]; const {snapshot,storage,leader,authority}=input,pins=resourceRetentionPins(snapshot,input.registry,input.displays,input.temporary),writes:DepotWrite[]=[];
if(!storage.assetDepot)throw fault('asset_depot_unavailable'); if(!storage.assetDepot)throw fault('asset_depot_unavailable');
// 仅 stored+digest 的原图可进入共享身份;未上传文件仍留在项目本地文件仓库。 // 每个 stored+digest 资源都登记身份(包括已 purge 的项目记录),但只有仍保留的才尝试补字节。
// sharedAssetRef 校验并捕获元数据,随后要求资产 ID/长度对应本地记录;未上传资源不进入共享写入清单。
for(const resource of snapshot.runtime.resources.values())if(resource.stored&&resource.digest){ for(const resource of snapshot.runtime.resources.values())if(resource.stored&&resource.digest){
const ref=sharedAssetRef({version:1,authority,digest:resource.digest,asset:resource.stored}); const ref=sharedAssetRef({version:1,authority,digest:resource.digest,asset:resource.stored});
if(ref.asset.id!==resource.id||ref.asset.bytes!==resource.byteLength)throw fault('shared_asset_identity_conflict',[resource.id]); if(ref.asset.id!==resource.id||ref.asset.bytes!==resource.byteLength)throw fault('shared_asset_identity_conflict',[resource.id]);
@@ -25,6 +29,8 @@ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Re
if(resource.retirement?.purgedAt===undefined&&pins.pins.has(resource.id)){ if(resource.retirement?.purgedAt===undefined&&pins.pins.has(resource.id)){
const cached=await storage.assetDepot.read(leader,authority,resource.id); const cached=await storage.assetDepot.read(leader,authority,resource.id);
// The store enforces immutable identity even when bytes are already present. // The store enforces immutable identity even when bytes are already present.
// 缓存已有字节则只提交身份;否则优先项目内存文件,再读项目存储。
// 两处均无字节时仍可形成仅身份写入项;是否满足事务要求、摘要与不可变身份由 adapter 检查。
if(!cached?.bytes)bytes=input.files.get(resource.id)??await storage.readFile(leader,resource.id); if(!cached?.bytes)bytes=input.files.get(resource.id)??await storage.readFile(leader,resource.id);
} }
writes.push({ref,...(bytes?{bytes}:{})}); writes.push({ref,...(bytes?{bytes}:{})});
@@ -34,9 +40,11 @@ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Re
if(binding.authority!==authority||binding.asset.id!==id||snapshot.runtime.resources.has(id))throw fault('shared_asset_identity_conflict',[id]); if(binding.authority!==authority||binding.asset.id!==id||snapshot.runtime.resources.has(id))throw fault('shared_asset_identity_conflict',[id]);
const pending=input.adoptedFiles?.get(id),bytes=pending&&pending.rootRevision<=snapshot.rootRevision?pending.bytes:undefined;writes.push({ref:sharedAssetRef(binding),...(bytes?{bytes}:{})}); const pending=input.adoptedFiles?.get(id),bytes=pending&&pending.rootRevision<=snapshot.rootRevision?pending.bytes:undefined;writes.push({ref:sharedAssetRef(binding),...(bytes?{bytes}:{})});
} }
// binding 不以 pins 决定是否写入,只有其暂存 bytes 的采纳 revision 不晚于本 Root 才携带字节。
// A source/run envelope may refer to resources outside this project's upload table. // A source/run envelope may refer to resources outside this project's upload table.
// Its complete resource interpreter must be installed before global deletion is allowed. // Its complete resource interpreter must be installed before global deletion is allowed.
// 引用、加载与任务解释都完整才允许全局回收;opaque run/未完导入不能假定无共享资源引用。 // 引用、加载与任务解释都完整才允许全局回收;opaque run/未完导入不能假定无共享资源引用。
const complete=pins.complete&&!snapshot.runtime.loading&&![...(snapshot.runtime.imports?.values()??[])].some(job=>!['completed','partial','cancelled'].includes(job.status))&&snapshot.runtime.runs.size===0; const complete=pins.complete&&!snapshot.runtime.loading&&![...(snapshot.runtime.imports?.values()??[])].some(job=>!['completed','partial','cancelled'].includes(job.status))&&snapshot.runtime.runs.size===0;
// complete 是共享仓库全局删除所需的覆盖声明,不是字节写入完成回执;真正删除由仓库协议决定。
return{version:1,authority,complete,pins:[...pins.pins].map(([id,owners])=>[id,[...owners]] as const),writes}; return{version:1,authority,complete,pins:[...pins.pins].map(([id,owners])=>[id,[...owners]] as const),writes};
} }
+11 -4
View File
@@ -8,7 +8,9 @@ import {importSourceDigest} from './import-integrity.ts';
/** 可替换摘要端口,输出完整 Blob 的小写 SHA256;close 终止本池,不清业务数据或外部回执。 */ /** 可替换摘要端口,输出完整 Blob 的小写 SHA256;close 终止本池,不清业务数据或外部回执。 */
export type DigestPort={digest(bytes:Blob,signal?:AbortSignal):Promise<string>;close():void}; export type DigestPort={digest(bytes:Blob,signal?:AbortSignal):Promise<string>;close():void};
/** Read-only compute scheduling: bounded active slots + FIFO Map, never durable operation facts. */ /** Read-only compute scheduling: bounded active slots + FIFO Map, never durable operation facts. */
/** 默认2并发/64等待,上限8并发;可注入 Worker/native/测试执行器,默认使用分块摘要。 */ /** 默认 2 并发/64 等待,上限 8 并发;可注入 execute,默认 importSourceDigest 分块计算。
* maxQueued 只计算 waiting,不含 active;不限制 Blob 大小/MIME,不做相同文件去重或结果缓存。
* execute 负责计算真实完整摘要,池仅用正则检查输出格式,不能证明输出确实对应输入字节。 */
export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSignal)=>Promise<string>;concurrency?:number;maxQueued?:number}={}):DigestPort{ export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSignal)=>Promise<string>;concurrency?:number;maxQueued?:number}={}):DigestPort{
const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64,execute=options.execute??importSourceDigest; const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64,execute=options.execute??importSourceDigest;
if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_digest_budget'); if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_digest_budget');
@@ -16,9 +18,13 @@ export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSigna
type Task={id:number;bytes:Blob;controller:AbortController;resolve:(value:string)=>void;reject:(error:unknown)=>void;cleanup:()=>void;done:boolean}; type Task={id:number;bytes:Blob;controller:AbortController;resolve:(value:string)=>void;reject:(error:unknown)=>void;cleanup:()=>void;done:boolean};
// Map 既保留入队次序,又可在等待期取消时 O(1) 删除;active 保留真实未结束执行的容量占用。 // Map 既保留入队次序,又可在等待期取消时 O(1) 删除;active 保留真实未结束执行的容量占用。
const waiting=new Map<number,Task>(),active=new Map<number,Task>();let next=0,closed=false; const waiting=new Map<number,Task>(),active=new Map<number,Task>();let next=0,closed=false;
/** 只 settle 一次并移除等待项/监听;故意不在这里 active.delete,迟到结果不能二次采纳。 */ /** 只 settle 一次并移除 waiting/外部监听,不在这里 active.delete,迟到结果不能二次采纳。
* error 用真值分支,execute 应以 Error 等真值拒绝;falsy rejection 不保证被当成失败处理。 */
function finish(task:Task,error?:unknown,value?:string){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(value!);} function finish(task:Task,error?:unknown,value?:string){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(value!);}
/** 有空槽就取最早等待项,先登记 active 再微任务执行;结果校验/取消后 finally 归还真实槽。 */ /** 从 Map 首项 FIFO 取任务,先占 active 再微任务调用 execute;启动前、结果返回后均复查 abort。
* execute 的拒绝由第二个 then 回调处理,结果校验抛错再由 catch 处理,最终统一归还槽位并泵下一项。
* 正则会按 JS 规则强制转字符串,未另做 typeof 检查;注入执行器应遵守 Promise<string> 合同。
* 无视 abort 且不 settle 的执行器会一直占槽;池没有超时或强制终止通用执行器的能力。 */
function pump(){ function pump(){
if(closed)return; if(closed)return;
while(active.size<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task); while(active.size<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task);
@@ -31,7 +37,8 @@ export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSigna
if(closed)return Promise.reject(fault('digest_pool_closed'));if(signal?.aborted)return Promise.reject(fault('import_aborted'));if(!(bytes instanceof Blob))return Promise.reject(fault('invalid_digest_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('digest_capacity')); if(closed)return Promise.reject(fault('digest_pool_closed'));if(signal?.aborted)return Promise.reject(fault('import_aborted'));if(!(bytes instanceof Blob))return Promise.reject(fault('invalid_digest_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('digest_capacity'));
return new Promise((resolve,reject)=>{const task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,cleanup:()=>signal?.removeEventListener('abort',abort),done:false},abort=()=>{task.controller.abort();finish(task,fault('import_aborted'));pump();};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();}); return new Promise((resolve,reject)=>{const task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,cleanup:()=>signal?.removeEventListener('abort',abort),done:false},abort=()=>{task.controller.abort();finish(task,fault('import_aborted'));pump();};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();});
}, },
/** 幂等关闭,取消等待/活跃任务的调用结果;无视 signal 的执行仍由其 Promise 收尾后释放 active。 */ /** 幂等关闭并取消 waiting/active 的调用结果,停止接受新任务;不返回 drain Promise。
* 无视 signal 的执行仍由自身 Promise 收尾后释放 active,close 返回不证明底层计算已停止。 */
close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('import_aborted'));}}, close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('import_aborted'));}},
}); });
} }
@@ -11,7 +11,10 @@ export const nativeDigestByteLimit=64*1024*1024;
/** Dedicated Worker only: whole-buffer native hashing never moves onto the UI thread. /** Dedicated Worker only: whole-buffer native hashing never moves onto the UI thread.
* The host still owns cancellation by terminating the request's Worker. */ * The host still owns cancellation by terminating the request's Worker. */
/** 验证 Blob,按能力/大小选 native 或增量实现,均返回相同的完整原字节摘要。 */ /** 验证 Blob,SubtleCrypto 存在且大小 ≤64MiB 时读取整个 ArrayBuffer 做 SHA-256,否则流式计算。
* 原生读/算失败直接传播,不自动切到流式重试;注入 subtle 应遵守原生 digest 的 ArrayBuffer 合同。
* 本函数无 signal,取消靠宿主终止 Worker;代码本身不检测当前线程,Worker 限制由装配调用方遵守。
* 阈值只限制此输入的整缓冲分配,未计 Blob/原生算法内部内存及其它并发 Worker。 */
export async function digestWorkerSource(bytes:Blob,subtle:Pick<SubtleCrypto,'digest'>|null|undefined=globalThis.crypto?.subtle):Promise<string>{ export async function digestWorkerSource(bytes:Blob,subtle:Pick<SubtleCrypto,'digest'>|null|undefined=globalThis.crypto?.subtle):Promise<string>{
if(!(bytes instanceof Blob))throw fault('invalid_digest_input'); if(!(bytes instanceof Blob))throw fault('invalid_digest_input');
// native digest 必须返回32字节;输出固定64字符十六进制,不接受变长响应。 // native digest 必须返回32字节;输出固定64字符十六进制,不接受变长响应。
@@ -20,6 +23,7 @@ export async function digestWorkerSource(bytes:Blob,subtle:Pick<SubtleCrypto,'di
if(digest.byteLength!==32)throw fault('invalid_digest_response'); if(digest.byteLength!==32)throw fault('invalid_digest_response');
return [...new Uint8Array(digest)].map(value=>value.toString(16).padStart(2,'0')).join(''); return [...new Uint8Array(digest)].map(value=>value.toString(16).padStart(2,'0')).join('');
} }
// Worker fallback 每块1MiB,只做微任务让出;UI 让出策略由 UI fallback 的端口另行决定。 // 每块 1MiB,块间 yieldTask 仅完成一个 Promise 微任务,不保证 Worker 消息队列获得宏任务机会。
// UI fallback 用 importSourceDigest 默认定时器让出;不要把这里的微任务策略移作 UI 响应性承诺。
return importSourceDigest(bytes,undefined,{chunkBytes:1024*1024,yieldTask:()=>Promise.resolve()}); return importSourceDigest(bytes,undefined,{chunkBytes:1024*1024,yieldTask:()=>Promise.resolve()});
} }
+3 -1
View File
@@ -8,7 +8,9 @@ import {digestWorkerSource} from './digest-worker-source.ts';
const scope=globalThis as unknown as {onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void}; const scope=globalThis as unknown as {onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void};
/** 首条有效请求被接受后保持 true,直到整个 Worker 被宿主终止。 */ /** 首条有效请求被接受后保持 true,直到整个 Worker 被宿主终止。 */
let accepted=false; let accepted=false;
/** 校验协议和请求后启动只读摘要;成功/失败都回显输入 ID 与字节数,供宿主核对。 */ /** 首条有效 version=1/正安全整数 ID/Blob 设置 accepted,之后包括失败后的重复消息都忽略。
* 无效请求直接忽略,不返回错误;这里不检查 MIME/大小或资源身份,不拥有持久任务。
* 算法拒绝会尝试回稳定错误;成功回信自身抛错不由同一个 then 的失败回调捕获,宿主仍需错误/取消收尾。 */
scope.onmessage=event=>{ scope.onmessage=event=>{
const request=event.data;if(accepted||!request||request.version!==1||!Number.isSafeInteger(request.id)||request.id<1||!(request.bytes instanceof Blob))return;accepted=true; const request=event.data;if(accepted||!request||request.version!==1||!Number.isSafeInteger(request.id)||request.id<1||!(request.bytes instanceof Blob))return;accepted=true;
void digestWorkerSource(request.bytes).then(sha256=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,sha256}),()=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,error:'digest_worker_failed'})); void digestWorkerSource(request.bytes).then(sha256=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,sha256}),()=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,error:'digest_worker_failed'}));
+11 -4
View File
@@ -16,12 +16,15 @@ type Entry = { readonly key: string; readonly owner: object; readonly controller
* durable bytes before acquiring. Scope objects are never serialized or reused * durable bytes before acquiring. Scope objects are never serialized or reused
* across project engines/auth epochs. There is no idle URL retention. * across project engines/auth epochs. There is no idle URL retention.
*/ */
/** 创建纯显示生命周期容器;owner 应绑定 engine/auth epoch,不能序列化或跨账号复用。 */ /** 创建纯显示生命周期容器,owners 两层 Map 用对象身份+字符串 key 索引共享条目。
* owner 应绑定 engine/auth epoch;本模块不解释账号、资源 ID、URL 权限或实际文件 pin。
* 没有容量上限/空闲保留,最后 lease 释放即清理;永久撤销 owner 则另记到 WeakSet。 */
export function createDisplayCache(options: { onError?: (error: unknown) => void } = {}) { export function createDisplayCache(options: { onError?: (error: unknown) => void } = {}) {
// owners 仅保留活条目;retired WeakSet 禁止退役 owner 再 acquire,但不强持有其内存。 // owners 仅保留活条目;retired WeakSet 禁止退役 owner 再 acquire,但不强持有其内存。
const owners = new Map<object, Map<string, Entry>>(), retired = new WeakSet<object>(); const owners = new Map<object, Map<string, Entry>>(), retired = new WeakSet<object>();
let closed = false; let closed = false;
/** 统一调用来源 release 并隔离错误,清理/日志失败不阻止释放其它 URL。 */ /** 同步调用来源 release,隔离其抛错和 onError 的抛错;onError 只报告清理失败。
* load 的失败通过各 lease.ready 传出,不在这里自动记录或重试;release 合同也不是异步清理任务。 */
function dispose(source: DisplaySource) { try { source.release?.(); } catch (error) { try { options.onError?.(error); } catch { /* Reporting cannot interrupt cleanup. */ } } } function dispose(source: DisplaySource) { try { source.release?.(); } catch (error) { try { options.onError?.(error); } catch { /* Reporting cannot interrupt cleanup. */ } } }
/** 幂等退休共享条目,仅删除仍指向自己的表项,避免旧任务删掉同 key 的新 entry。 */ /** 幂等退休共享条目,仅删除仍指向自己的表项,避免旧任务删掉同 key 的新 entry。 */
function retire(entry: Entry) { function retire(entry: Entry) {
@@ -41,7 +44,9 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
} }
} }
return Object.freeze({ return Object.freeze({
/** 借用显示来源:同 owner/key 合并加载,但每个调用者可独立取消自己的等待。 */ /** 同 owner/key 复用进行中或已加载来源,命中时忽略本次传入的 load,不逐次重验权限。
* 每个调用者独立 ready/release;释放已兑现的 ready 不会改变 Promise 的成功结果,但 URL 可能随最后释放失效。
* 不自动接收 AbortSignal,取消通过 lease.release;单个释放不取消仍有其它消费者的共享加载。 */
acquire(owner: object, key: string, load: (signal: AbortSignal) => Promise<DisplaySource>): DisplayLease { acquire(owner: object, key: string, load: (signal: AbortSignal) => Promise<DisplaySource>): DisplayLease {
if (closed || retired.has(owner)) throw fault('display_scope_closed'); if (closed || retired.has(owner)) throw fault('display_scope_closed');
if (!owner || typeof owner !== 'object' || typeof key !== 'string' || !key) throw fault('invalid_display_key'); if (!owner || typeof owner !== 'object' || typeof key !== 'string' || !key) throw fault('invalid_display_key');
@@ -55,6 +60,7 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
if (created.retired) throw fault('display_released'); if (created.retired) throw fault('display_released');
return load(created.controller.signal); return load(created.controller.signal);
}).then(source => { }).then(source => {
// 这里只检查 URL 为非空字符串及可选 release 为函数,不验证 URL 协议/可访问性或图片内容。
if (!source || typeof source.url !== 'string' || !source.url || source.release !== undefined && typeof source.release !== 'function') { if (typeof source?.release === 'function') dispose(source); throw fault('invalid_display_source'); } if (!source || typeof source.url !== 'string' || !source.url || source.release !== undefined && typeof source.release !== 'function') { if (typeof source?.release === 'function') dispose(source); throw fault('invalid_display_source'); }
// 即使 load 不响应 abort,迟到返回也只能 dispose,不允许挂回已无消费者的缓存。 // 即使 load 不响应 abort,迟到返回也只能 dispose,不允许挂回已无消费者的缓存。
if (created.retired || !created.leases.size) { dispose(source); throw fault('display_released'); } if (created.retired || !created.leases.size) { dispose(source); throw fault('display_released'); }
@@ -70,7 +76,8 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
if (!shared.leases.size) retire(shared); if (!shared.leases.size) retire(shared);
}; };
shared.leases.add(release); shared.leases.add(release);
// 将共享 promise 映射成每租约的独立 ready;来源失败时本租约也自动归还,不泄漏计数。 // 映射共享 promise 到各 lease.ready,失败逐个归还;校验 source 时抛错也沿这条失败路径清理。
// 缓存不吞 ready 的拒绝,消费者需接住取消/加载错误;释放后迟到成功不能再次 resolve 本租约。
const ready = new Promise<string>((resolve, reject) => { const ready = new Promise<string>((resolve, reject) => {
rejectReady = reject; rejectReady = reject;
shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } }); shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } });
+12 -3
View File
@@ -13,7 +13,10 @@ import { duplicateImportLine } from './import-duplicate.ts';
import type { ImportSource } from './imports.ts'; import type { ImportSource } from './imports.ts';
/** A seekable record cursor over the original SHA-bound snapshot, not a second mapped Blob. */ /** A seekable record cursor over the original SHA-bound snapshot, not a second mapped Blob. */
/** 要求未输出的emit起点、当前registry签名及有效绑定,ID工厂在恢复重校验中禁止意外再分配。 */ /** 要求 emit 起点/零输出计数与当前 registry 签名,调用 planner 复核 checkpoint 结构绑定且禁止再分配 ID。
* 映射复制为私有 Map,但 document/registry/state 及 readElement 仍沿用调用方引用,须保持不可变/稳定。
* sourceToken/fingerprint 的真实字节绑定由上层验证;本函数不读取文件或重新计算 SHA。
* 构造后可按 ordinal 任意重读,read 不推进 state.emitted;持久进度由 runner 的 job.cursor 保存。 */
export function createDuplicatePlanSource(document:Document,registry:Registry,state:DuplicateCheckpoint,fingerprint:string,readElement?:(id:Id,signal:AbortSignal)=>Promise<ElementRecord>):ImportSource{ export function createDuplicatePlanSource(document:Document,registry:Registry,state:DuplicateCheckpoint,fingerprint:string,readElement?:(id:Id,signal:AbortSignal)=>Promise<ElementRecord>):ImportSource{
if(state.phase!=='emit'||state.emitted!==0||state.emittedEdges!==0||state.schemaToken!==copyRegistrySignature(document,registry))throw fault('copy_interpreter_changed'); if(state.phase!=='emit'||state.emitted!==0||state.emittedEdges!==0||state.schemaToken!==copyRegistrySignature(document,registry))throw fault('copy_interpreter_changed');
// Structural/source binding checks; the decoded source has already passed document validation. // Structural/source binding checks; the decoded source has already passed document validation.
@@ -22,7 +25,9 @@ export function createDuplicatePlanSource(document:Document,registry:Registry,st
const elements=new Map(state.elementIds),canvases=new Map(state.canvasIds),copiedCanvases=new Set(state.canvases),keys=new Map<Id,string>(),reverseElements=new Map(state.elementIds.map(([id,mapped])=>[mapped,id])),reverseCanvases=new Map(state.canvasIds.map(([id,mapped])=>[mapped,id])); const elements=new Map(state.elementIds),canvases=new Map(state.canvasIds),copiedCanvases=new Set(state.canvases),keys=new Map<Id,string>(),reverseElements=new Map(state.elementIds.map(([id,mapped])=>[mapped,id])),reverseCanvases=new Map(state.canvasIds.map(([id,mapped])=>[mapped,id]));
for(const[id,mapped]of elements)keys.set(mapped,`element:${id}`);for(const[id,mapped]of canvases)keys.set(mapped,`canvas:${id}`); for(const[id,mapped]of elements)keys.set(mapped,`element:${id}`);for(const[id,mapped]of canvases)keys.set(mapped,`canvas:${id}`);
// Edge geometry needs mapped endpoint/owner records. Compute these on demand; retain a bounded cache. // Edge geometry needs mapped endpoint/owner records. Compute these on demand; retain a bounded cache.
/** 给边计算提供新端点/owner视图,get按需转换并只保留32条缓存,不批量生成所有副本对象。 */ /** 给边计算提供原表+映射表的只读投影,get 按需转换映射 ID,每个投影最多缓存 32 项。
* 淘汰按插入顺序,缓存命中不刷新顺序,因此是 FIFO 而非 LRU;不缓存 readElement 的完整 payload。
* size/迭代将两组成员拼接,依赖映射不与原 ID 碰撞的 planner 合同,不能当可发布的规范文档表。 */
function projection<T extends ElementRecord|CanvasRecord>(base:ReadonlyMap<Id,T>,reverse:ReadonlyMap<Id,Id>,kind:'element'|'canvas'):ReadonlyMap<Id,T>{ function projection<T extends ElementRecord|CanvasRecord>(base:ReadonlyMap<Id,T>,reverse:ReadonlyMap<Id,Id>,kind:'element'|'canvas'):ReadonlyMap<Id,T>{
// 未映射ID沿用原表,新ID转换源记录;缓存淘汰只影响计算成本,不改变稳定映射/源数据。 // 未映射ID沿用原表,新ID转换源记录;缓存淘汰只影响计算成本,不改变稳定映射/源数据。
const cache=new Map<Id,T>();const get=(id:Id):T|undefined=>{const old=reverse.get(id);if(!old)return base.get(id);const found=cache.get(id);if(found)return found;const original=base.get(old);if(!original)throw fault('invalid_copy_stream_checkpoint');const record=duplicateTreeChange(document,registry,kind,original,elements,canvases,copiedCanvases,state.offset).after as T;cache.set(id,record);if(cache.size>32)cache.delete(cache.keys().next().value!);return record;}; const cache=new Map<Id,T>();const get=(id:Id):T|undefined=>{const old=reverse.get(id);if(!old)return base.get(id);const found=cache.get(id);if(found)return found;const original=base.get(old);if(!original)throw fault('invalid_copy_stream_checkpoint');const record=duplicateTreeChange(document,registry,kind,original,elements,canvases,copiedCanvases,state.offset).after as T;cache.set(id,record);if(cache.size>32)cache.delete(cache.keys().next().value!);return record;};
@@ -32,7 +37,11 @@ export function createDuplicatePlanSource(document:Document,registry:Registry,st
} }
// projected只服务边端点和公共owner的坐标计算;length是tree order+edges的固定ordinal总数。 // projected只服务边端点和公共owner的坐标计算;length是tree order+edges的固定ordinal总数。
const projected:Document={...document,elements:projection(document.elements,reverseElements,'element'),canvases:projection(document.canvases,reverseCanvases,'canvas')},length=state.order.length+state.edges.length; const projected:Document={...document,elements:projection(document.elements,reverseElements,'element'),canvases:projection(document.canvases,reverseCanvases,'canvas')},length=state.order.length+state.edges.length;
/** read按ordinal选择共享tree/edge转换器;单记录错误返回error,源指纹/完整性与调度另由宿主约束。 */ /** 前 state.order.length 项用树转换器,其余用边转换器,生成同一预映射 CopyLine。
* readElement 可按需异步加载元素;其身份/源一致性由注入端口保证,canvas 直接从已捕获文档取。
* 预先取消/非法 ordinal 在 try 外拒绝;try 内失败(含读取后取消)转成 ImportLine.error,runner 必须复查信号。
* 成功检查单行 JSON 的 UTF-8 长度 ≤256KiB(不含 NDJSON 换行),不限制完整输出总字节或在单条内让出。
* 失败不会推进此源的全局游标;是否保留失败、跳过或停止由 runner 管理。 */
return Object.freeze({fingerprint,parserVersion:2,async read(cursor:number,signal:AbortSignal){ return Object.freeze({fingerprint,parserVersion:2,async read(cursor:number,signal:AbortSignal){
if(signal.aborted)throw fault('import_aborted');if(!Number.isSafeInteger(cursor)||cursor<0||cursor>length)throw fault('invalid_import_cursor');if(cursor===length)return null; if(signal.aborted)throw fault('import_aborted');if(!Number.isSafeInteger(cursor)||cursor<0||cursor>length)throw fault('invalid_import_cursor');if(cursor===length)return null;
const task=state.order[cursor],key=task?`${task.kind}:${task.id}`:`element:${state.edges[cursor-state.order.length]}`; const task=state.order[cursor],key=task?`${task.kind}:${task.id}`:`element:${state.edges[cursor-state.order.length]}`;
+17 -4
View File
@@ -23,7 +23,10 @@ export type DuplicateScheduling = {
* No document publication or network effects. The caller must persist source/mapping and * No document publication or network effects. The caller must persist source/mapping and
* revalidate external identities before applying a plan; this is not the import executor. * revalidate external identities before applying a plan; this is not the import executor.
*/ */
/** 分片消费完整plan迭代器,每片默认6ms/512步;返回前全部记录已生成并校验,但不执行导入。 */ /** 分片消费完整 plan 生成器,默认每片 6ms/512 个语义步,完成后返回全量计划而非逐条发布。
* checkpoint 仅接收累计步数,不含映射/生成器状态,不能用该数字恢复规划;最终返回前不保证再调用一次。
* 每次 next 本身可包含同步固定成本,软预算不能抢占它;now/yieldTask 决定实际调度,默认 timer 让出。
* 异常/取消关闭生成器,不回滚已调用 createId 或 checkpoint 的外部副作用;调用方负责可靠源/映射保存。 */
export async function planDuplicateAsync(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions, scheduling: DuplicateScheduling = {}): Promise<DuplicatePlan> { export async function planDuplicateAsync(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions, scheduling: DuplicateScheduling = {}): Promise<DuplicatePlan> {
const budget = scheduling.frameBudgetMs ?? 6, limit = scheduling.maxStepsPerSlice ?? 512; const budget = scheduling.frameBudgetMs ?? 6, limit = scheduling.maxStepsPerSlice ?? 512;
if (!Number.isFinite(budget) || budget <= 0 || !Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_copy_budget'); if (!Number.isFinite(budget) || budget <= 0 || !Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_copy_budget');
@@ -58,7 +61,11 @@ export async function planDuplicateAsync(document: Document, registry: Registry,
} }
/** Resume semantic planning cursors. Source/output validation and derived output caches are rebuilt. */ /** Resume semantic planning cursors. Source/output validation and derived output caches are rebuilt. */
/** 以完整checkpoint恢复语义游标,仍重做源验证/派生输出缓存,不声称从任意字节零重算。 */ /** 从完整 DuplicateCheckpoint 恢复语义游标/已分配 ID,源验证和派生输出缓存仍由 planner 重建。
* 每个未完成时间片可将不可变 checkpoint 交宿主 await;回调成功只代表宿主 Promise 成功,不独自证明落盘。
* planner.step 完成后直接 result,不额外回调最终状态;需要最终可靠切点时由调用方保存返回计划/状态。
* selection/偏移/映射在入口捕获,document/registry 沿不可变快照合同使用;调度选项应保持稳定。
* 与完整生成器入口一样,不发布业务文档、不补偿已分配 ID 或宿主 checkpoint 副作用。 */
export async function planDuplicateResumable(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>}={}):Promise<DuplicatePlan>{ export async function planDuplicateResumable(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>}={}):Promise<DuplicatePlan>{
const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0))); const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget'); if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget');
@@ -73,7 +80,12 @@ export async function planDuplicateResumable(document:Document,registry:Registry
/** Stop before record conversion: the stable order/mappings are sufficient for seekable lazy output. */ /** Stop before record conversion: the stable order/mappings are sufficient for seekable lazy output. */
/** 只推进到emit起点:范围/顺序/全部新ID已固定,但尚未转换全部payload,可交lazy source逐条输出。 */ /** 仅推进至 ready/emit 起点,返回顺序/映射已固定的 checkpoint;尚未完成所有 payload 转换与输出校验。
* planner 捕获小输入与私有语义 state;此层只调度 step/checkpoint,不用另一个 live 文档替换源。
* checkpointIntervalMs=0 默认每个未完成切片保存;>0 时按阶段/首次分配/时间/步数门槛节奏化保存。
* 阈值只在切片末检查,maxStepsPerCheckpoint 可能多跨一片;不是逐语义步的硬持久间隔。
* ready 时只有 interval>0 且有回调才补一次保存;默认路径可直接返回,宿主仍须持久化返回状态。
* topologyOnly 仅选择源拓扑检查,不证明全部组件 payload 可复制;懒源逐条转换时仍可报记录失败。 */
export async function prepareDuplicateStream(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>;checkpointIntervalMs?:number;maxStepsPerCheckpoint?:number}={}):Promise<DuplicateCheckpoint>{ export async function prepareDuplicateStream(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>;checkpointIntervalMs?:number;maxStepsPerCheckpoint?:number}={}):Promise<DuplicateCheckpoint>{
const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0))); const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget'); if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget');
@@ -82,7 +94,8 @@ export async function prepareDuplicateStream(document:Document,registry:Registry
const interval=scheduling.checkpointIntervalMs??0,maxCheckpointSteps=scheduling.maxStepsPerCheckpoint??8192; const interval=scheduling.checkpointIntervalMs??0,maxCheckpointSteps=scheduling.maxStepsPerCheckpoint??8192;
if(!Number.isFinite(interval)||interval<0||!Number.isSafeInteger(maxCheckpointSteps)||maxCheckpointSteps<1)throw fault('invalid_copy_budget'); if(!Number.isFinite(interval)||interval<0||!Number.isSafeInteger(maxCheckpointSteps)||maxCheckpointSteps<1)throw fault('invalid_copy_budget');
const planner=createDuplicatePlanner(document,registry,selection,options,binding); const planner=createDuplicatePlanner(document,registry,selection,options,binding);
// savedSteps等只记录上次已等待checkpoint回调成功的位置,保存耗时不冒充新增规划工作。 // saved* 是调度内存,不是另一份持久 checkpoint;首次 savedPhase 未知会在首片触发回调。
// savedAt 只在回调成功后重取,保存耗时不算新增规划工作,也不会单因慢存储强迫下一片立即再存。
let savedPhase:DuplicateCheckpoint['phase']|undefined,savedSteps=binding.checkpoint?.steps??0,savedAllocated=(binding.checkpoint?.elementIds.length??0)+(binding.checkpoint?.canvasIds.length??0),savedAt=now(); let savedPhase:DuplicateCheckpoint['phase']|undefined,savedSteps=binding.checkpoint?.steps??0,savedAllocated=(binding.checkpoint?.elementIds.length??0)+(binding.checkpoint?.canvasIds.length??0),savedAt=now();
while(true){ while(true){
const start=now();let count=0; const start=now();let count=0;
+15 -6
View File
@@ -13,7 +13,8 @@ export type ElisionProof = { readonly version: 1; readonly operationIds: readonl
/** Reconstruct a closed lifetime from an empty fragment. Existing objects cannot enter this proof. */ /** Reconstruct a closed lifetime from an empty fragment. Existing objects cannot enter this proof. */
/** 在空记录片段上重建至多 64 意图/10000 changes,验证每个对象首触及都是创建且最终全删。 /** 在空记录片段上重建至多 64 意图/10000 changes,验证每个对象首触及都是创建且最终全删。
* 墓碑变化是允许保留的本地效果,不等于服务器执行过这些创建/删除。 */ * 本函数不读 ledger 阶段、basePresence、请求或依赖,不独自证明这些操作从未发送/实际基线 absent。
* 只做条件变化与生命周期闭合检查,不调用 registry/整图校验;临时片段可无根,最终只保留本地墓碑。 */
export function proveElision(intents: readonly OperationIntent[], baseTombstones: Readonly<Record<Id, number>>): ElisionProof { export function proveElision(intents: readonly OperationIntent[], baseTombstones: Readonly<Record<Id, number>>): ElisionProof {
if (!intents.length || intents.length > 64 || new Set(intents.map(intent => intent.id)).size !== intents.length) throw fault('invalid_elision'); if (!intents.length || intents.length > 64 || new Set(intents.map(intent => intent.id)).size !== intents.length) throw fault('invalid_elision');
const created = new Set<Id>(), canvases = new Set<Id>(); const created = new Set<Id>(), canvases = new Set<Id>();
@@ -37,6 +38,7 @@ export function proveElision(intents: readonly OperationIntent[], baseTombstones
} }
} }
} }
// 起始墓碑只能描述本段确实创建过的元素,不能趁抵消夹带无关 ID 的生命周期水位。
if (Object.keys(baseTombstones).some(id => !created.has(id))) throw fault('invalid_elision'); if (Object.keys(baseTombstones).some(id => !created.has(id))) throw fault('invalid_elision');
const fragment: Document = { projectId: '', elements: new Map(), canvases: new Map(), tombstones: new Map(Object.entries(baseTombstones)) }; const fragment: Document = { projectId: '', elements: new Map(), canvases: new Map(), tombstones: new Map(Object.entries(baseTombstones)) };
// 条件执行仍使用真实 applyChanges;before 不匹配不能因最终想删除就被忽略。 // 条件执行仍使用真实 applyChanges;before 不匹配不能因最终想删除就被忽略。
@@ -46,10 +48,12 @@ export function proveElision(intents: readonly OperationIntent[], baseTombstones
} }
/** Longest closed prefix before an existing-object write or another unsafe precondition. */ /** Longest closed prefix before an existing-object write or another unsafe precondition. */
/** 寻找最长安全闭合前缀;遇 frozen/blocker/外部未解依赖/已有对象首写即停止扩展。 /** 在传入候选顺序内寻找最长闭合前缀,检查 pending/未绑请求、基线和依赖;不扫描全账本证明中间无漏项。
* preceding 只允许前面已选独立真实段作为依赖屏障,不把它当作已经 applied 的回执。 */ * 全账本连续性还由 ledger.elide/validateElisions 复核;preceding 只给批次边界规划暂时满足前段依赖。
* undefined 表示没找到;条件回放失败或闭合证明非法仍会抛错,不统一吞成 undefined。 */
export function findElision(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, preceding: ReadonlySet<Id> = new Set()): ElisionProof | undefined { export function findElision(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, preceding: ReadonlySet<Id> = new Set()): ElisionProof | undefined {
const selected: OperationIntent[] = [], seen = new Set<Id>(), canvases = new Set<Id>(), bases: Record<Id, number> = Object.create(null); const selected: OperationIntent[] = [], seen = new Set<Id>(), canvases = new Set<Id>(), bases: Record<Id, number> = Object.create(null);
// 候选超过 64 直接不寻找,不自动截取前 64;真正闭合后的 changes 上限由 proveElision 检查。
if (intents.length > 64 || !intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null))) return undefined; if (intents.length > 64 || !intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null))) return undefined;
// 每次片段归空时保存最佳前缀;后续又创建未删对象,不影响之前已找到的闭合边界。 // 每次片段归空时保存最佳前缀;后续又创建未删对象,不影响之前已找到的闭合边界。
let bestLength = 0, bestBases: Record<Id, number> = {}; let bestLength = 0, bestBases: Record<Id, number> = {};
@@ -57,6 +61,7 @@ export function findElision(intents: readonly OperationIntent[], acknowledged: D
for (const intent of intents) { for (const intent of intents) {
const record = ledger.operations.get(intent.id); const record = ledger.operations.get(intent.id);
if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || !equal(record.intent, intent) || intent.operationType !== 'document.change' || intent.operationSchemaVersion !== 1) break; if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || !equal(record.intent, intent) || intent.operationType !== 'document.change' || intent.operationSchemaVersion !== 1) break;
// resolved 只表示依赖已被本地解除,不在此区分 applied/elided/retired 的业务理由;preceding 不是远端执行证明。
if (intent.dependencies.some(id => !selected.some(intent => intent.id === id) && !preceding.has(id) && ledger.operations.get(id)?.phase !== 'resolved')) break; if (intent.dependencies.some(id => !selected.some(intent => intent.id === id) && !preceding.has(id) && ledger.operations.get(id)?.phase !== 'resolved')) break;
let safe = true; let safe = true;
for (const change of intent.changes) { for (const change of intent.changes) {
@@ -78,7 +83,8 @@ export function findElision(intents: readonly OperationIntent[], acknowledged: D
} }
/** Keep a later closed segment mutable while earlier real edits obtain their own receipt. */ /** Keep a later closed segment mutable while earlier real edits obtain their own receipt. */
/** 若后面存在可抵消段,限制前面的真实请求前缀,避免一次冻结把可抵消段也锁死。 */ /** 若后面存在可抵消候选段,缩短前面的真实请求前缀,避免本次 freeze 锁住后段。
* 这里只返回前缀长度,不标 ledger.elided、不证明前段已成功;原前缀可直接抵消时交其它钩子处理。 */
export function elisionBatchLimit(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot): number { export function elisionBatchLimit(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot): number {
if (!intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null)) || findElision(intents, acknowledged, ledger)) return intents.length; if (!intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null)) || findElision(intents, acknowledged, ledger)) return intents.length;
const preceding = new Set<Id>(); const preceding = new Set<Id>();
@@ -99,7 +105,8 @@ export function assertElisionLineage(proof: ElisionProof, intents: readonly Oper
/** Validate persisted local facts independently of the current (possibly newer) server baseline. */ /** Validate persisted local facts independently of the current (possibly newer) server baseline. */
/** 恢复时按逻辑顺序复验持久抵消组:成员连续、依赖闭合、anchor 唯一、证明与水位一致。 /** 恢复时按逻辑顺序复验持久抵消组:成员连续、依赖闭合、anchor 唯一、证明与水位一致。
* 不依赖当前可能更新过的 ACK 基线来重新猜历史 absent 事实。 */ * 不依赖当前可能更新过的 ACK 基线来重新猜历史 absent 事实,而是复核保存的 basePresence/proof。
* 它不向服务器查询当年是否接收过请求;此前的持久记录捕获与身份校验仍由恢复边界负责。 */
export function validateElisions(ledger: Pick<LedgerSnapshot, 'operations'>): void { export function validateElisions(ledger: Pick<LedgerSnapshot, 'operations'>): void {
const ordered = [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence), positions = new Map(ordered.map((record, index) => [record.intent.id, index])); const ordered = [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence), positions = new Map(ordered.map((record, index) => [record.intent.id, index]));
const previous = new Map<Id, number>(); const previous = new Map<Id, number>();
@@ -128,7 +135,9 @@ export function validateElisions(ledger: Pick<LedgerSnapshot, 'operations'>): vo
} }
/** Local high-water marks belong to working state, never to the server ACK document. */ /** Local high-water marks belong to working state, never to the server ACK document. */
/** 只给 working 候选补本地墓碑最大值,不修改源文档,也不向服务器 ACK 事实表灌入本地证明。 */ /** 只为候选复制墓碑表并补本地最大值,其余文档表沿用;不改传入文档。
* 调用方只能将结果当 working,不应覆写 acknowledged;这里不自动区分两种变量的用途。
* 直接读取保存的 elision 字段,不在此复验证据;ledger 应先经可信生成或 validateElisions。 */
export function withLocalLifecycles(document: Document, ledger: Pick<LedgerSnapshot, 'operations'>): Document { export function withLocalLifecycles(document: Document, ledger: Pick<LedgerSnapshot, 'operations'>): Document {
const tombstones = new Map(document.tombstones); const tombstones = new Map(document.tombstones);
for (const record of ledger.operations.values()) if (record.elision) for (const [id, incarnation] of Object.entries(record.elision.tombstones)) tombstones.set(id, Math.max(tombstones.get(id) ?? 0, incarnation)); for (const record of ledger.operations.values()) if (record.elision) for (const [id, incarnation] of Object.entries(record.elision.tombstones)) tombstones.set(id, Math.max(tombstones.get(id) ?? 0, incarnation));
+260 -113
View File
@@ -4,6 +4,8 @@
* engine 把持久化、网络调度、资源、导入、生成和冲突恢复连接到这个边界。 * engine 把持久化、网络调度、资源、导入、生成和冲突恢复连接到这个边界。
* 这里的 Promise/Map/Set 大多是当前进程的任务、租约或索引,不替代可恢复根中的业务事实。 * 这里的 Promise/Map/Set 大多是当前进程的任务、租约或索引,不替代可恢复根中的业务事实。
* 打开项目先取得存储领导权并解码恢复镜像;视图挂载/卸载与项目关闭是不同的生命周期。 * 打开项目先取得存储领导权并解码恢复镜像;视图挂载/卸载与项目关闭是不同的生命周期。
* 阅读顺序可按“打开/初始根→editor hooks→持久队列→资源清理→请求协调→任务执行→公开能力/关闭”。
* 临时上下文只在同步提交期间授予对应 reducer 权限,异步准备不得跨 await 长期占用这些上下文。
*/ */
import {dataArray,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 {detachJsonData} from '../canvas-core/json-data.ts';
@@ -58,7 +60,9 @@ const digestFailures=new Set(['digest_capacity','digest_worker_failed','digest_w
/** 打开项目的依赖注入参数。scope/document 必须属于同一项目;registry 解释模型和恢复数据。 /** 打开项目的依赖注入参数。scope/document 必须属于同一项目;registry 解释模型和恢复数据。
* storage 是本地可靠保存,persistence 是文档网络协议;assets/runs 为可选外部业务服务。 * storage 是本地可靠保存,persistence 是文档网络协议;assets/runs 为可选外部业务服务。
* digest/image/display/depot 可共享注入;clock 和 createId 允许宿主或测试控制时间与身份。 * digest/image/display/depot 可共享注入;clock 和 createId 允许宿主或测试控制时间与身份。
* loading 表示尚未读完的模型,不能把已有前缀当成完整项目。 */ * loading 表示尚未读完的模型,不能把已有前缀当成完整项目。
* options/端口大多借用而非深复制,宿主须保持配置与 scope 身份稳定;动态认证撤销走 isCurrentIdentity。
* 注入共享池的拥有者负责其全局关闭,本项目只释放自己持有的任务、显示租约和存储 leader。 */
export type EngineOptions = { export type EngineOptions = {
loading?:LoadingState; loading?:LoadingState;
scope: StorageScope; registry: Registry; document: Document; versions: ReadonlyMap<Id, number>; scope: StorageScope; registry: Registry; document: Document; versions: ReadonlyMap<Id, number>;
@@ -84,7 +88,9 @@ export type RecoverySession = {
}; };
/** 打开一个项目所有者实例。先独占本地写入,再选择恢复状态或初始服务端状态, /** 打开一个项目所有者实例。先独占本地写入,再选择恢复状态或初始服务端状态,
* 接线 editor 与异步执行器;此工厂是异步入口,普通编辑提交仍在 editor 内同步发布。 */ * 接线 editor 与异步执行器;此工厂是异步入口,普通编辑提交仍在 editor 内同步发布。
* scope/document 先核项目 ID,loading/versions 在首个 await 前捕获;document/registry/其它 options 仍借用。
* 本地 recovered Root 优先于外部初始模型,但传入模型的每张 canvas 仍须带有效 openingVersions。 */
export async function openProjectEngine(options: EngineOptions) { export async function openProjectEngine(options: EngineOptions) {
if (options.scope.projectId !== options.document.projectId) throw fault('project_mismatch'); if (options.scope.projectId !== options.document.projectId) throw fault('project_mismatch');
// 在第一次 await 前捕获加载状态和版本,防止调用方随后修改原容器改变这次打开的基线。 // 在第一次 await 前捕获加载状态和版本,防止调用方随后修改原容器改变这次打开的基线。
@@ -100,7 +106,8 @@ export async function openProjectEngine(options: EngineOptions) {
// 渐进读取的在途任务、准入暂停句柄及完成 revision:完成读取后仍要等对应根可靠保存。 // 渐进读取的在途任务、准入暂停句柄及完成 revision:完成读取后仍要等对应根可靠保存。
let loadingRead:Promise<Result>|undefined,loadingReadController:AbortController|undefined; let loadingRead:Promise<Result>|undefined,loadingReadController:AbortController|undefined;
let acceptingLoading=false,loadingRelease:(()=>void)|undefined,loadingCompletionRevision:number|undefined; let acceptingLoading=false,loadingRelease:(()=>void)|undefined,loadingCompletionRevision:number|undefined;
// closing 阻止新工作,closed 表示生命周期结束;durableRootRevision 仅由存储回执推进。 // closing 阻止新工作,closed 表示生命周期结束;durableRootRevision 从恢复镜像起步,之后只认存储回执。
// 新项目设为-1,保证 revision=0 的初始根也必须持久化,不能误当成已保存。
let closing = false, closed = false, durableRootRevision = image?.rootRevision ?? -1; let closing = false, closed = false, durableRootRevision = image?.rootRevision ?? -1;
// 项目级摘要中止源,与单次任务 signal 合并;关闭时可撤销仍在处理的字节计算。 // 项目级摘要中止源,与单次任务 signal 合并;关闭时可撤销仍在处理的字节计算。
const digestLifecycle=new AbortController(); const digestLifecycle=new AbortController();
@@ -110,13 +117,15 @@ export async function openProjectEngine(options: EngineOptions) {
const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Error reporting cannot alter state. */ } }; const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Error reporting cannot alter state. */ } };
// 持久化订阅与文档订阅分开;缓存同值对象,便于界面按快照身份避免无意义更新。 // 持久化订阅与文档订阅分开;缓存同值对象,便于界面按快照身份避免无意义更新。
const durabilityListeners=new Set<()=>void>();let durabilityError:string|undefined,durabilityCache:Readonly<{rootRevision:number;durableRootRevision:number;closing:boolean;closed:boolean;error?:string}>|undefined; const durabilityListeners=new Set<()=>void>();let durabilityError:string|undefined,durabilityCache:Readonly<{rootRevision:number;durableRootRevision:number;closing:boolean;closed:boolean;error?:string}>|undefined;
/** 返回当前根/已保存根、关闭阶段和存储错误的稳定只读快照。 */ /** 返回当前根/已保存根、关闭阶段和存储错误的稳定只读快照;同字段值复用对象供订阅比较。
* 这是本地持久化诊断,不是网络 ACK/服务同步进度;editor 初始化后才允许调用此闭包。 */
function getDurability(){const rootRevision=editor.getSnapshot().rootRevision;if(!durabilityCache||durabilityCache.rootRevision!==rootRevision||durabilityCache.durableRootRevision!==durableRootRevision||durabilityCache.closing!==closing||durabilityCache.closed!==closed||durabilityCache.error!==durabilityError)durabilityCache=Object.freeze({rootRevision,durableRootRevision,closing,closed,...(durabilityError?{error:durabilityError}:{})});return durabilityCache;} function getDurability(){const rootRevision=editor.getSnapshot().rootRevision;if(!durabilityCache||durabilityCache.rootRevision!==rootRevision||durabilityCache.durableRootRevision!==durableRootRevision||durabilityCache.closing!==closing||durabilityCache.closed!==closed||durabilityCache.error!==durabilityError)durabilityCache=Object.freeze({rootRevision,durableRootRevision,closing,closed,...(durabilityError?{error:durabilityError}:{})});return durabilityCache;}
/** 拷贝监听集合后逐个通知,订阅过程中增删监听不影响本轮,也不传播监听器异常。 */ /** 拷贝监听集合后逐个通知,订阅过程中增删监听不影响本轮,也不传播监听器异常。 */
function notifyDurability(){for(const listener of [...durabilityListeners])try{listener();}catch(error){report(error);}} function notifyDurability(){for(const listener of [...durabilityListeners])try{listener();}catch(error){report(error);}}
// 所有新任务/请求需要独立身份;宿主可注入生成器,默认使用浏览器 UUID。 // 所有新任务/请求需要独立身份;宿主可注入生成器,默认使用浏览器 UUID。
const createId = options.createId ?? (() => crypto.randomUUID()); const createId = options.createId ?? (() => crypto.randomUUID());
// 网络门限制并发为 2,并在准入时核对存储领导权和认证代次。 // 网络门限制同时在途为2,在准入时查 leader.active 与当前认证;这里不比较远端数据库 fence。
// 本地可靠屏障/任务依赖由 scheduler 另管,network 不自己判断请求是否已经安全落盘。
const network = createNetworkGate(2,()=>{if(!leader.active)throw fault('storage_leader_lost');if(options.isCurrentIdentity&&!options.isCurrentIdentity())throw fault('identity_changed');}); const network = createNetworkGate(2,()=>{if(!leader.active)throw fault('storage_leader_lost');if(options.isCurrentIdentity&&!options.isCurrentIdentity())throw fault('identity_changed');});
// 有共享存储端口时可装配资源仓库;跨项目共享仅限同账户、同工作区。 // 有共享存储端口时可装配资源仓库;跨项目共享仅限同账户、同工作区。
const depot=options.assetDepot??(options.storage.assetDepot?createAssetDepot({scope:options.scope,port:options.storage.assetDepot,current:()=>!closing&&!closed&&(!options.isCurrentIdentity||options.isCurrentIdentity()),digest:sourceDigest}):undefined); const depot=options.assetDepot??(options.storage.assetDepot?createAssetDepot({scope:options.scope,port:options.storage.assetDepot,current:()=>!closing&&!closed&&(!options.isCurrentIdentity||options.isCurrentIdentity()),digest:sourceDigest}):undefined);
@@ -134,7 +143,8 @@ export async function openProjectEngine(options: EngineOptions) {
const displayOwner = Object.freeze({}), displayCache = options.displays ?? createDisplayCache({ onError: options.onError }); const displayOwner = Object.freeze({}), displayCache = options.displays ?? createDisplayCache({ onError: options.onError });
/** 即使缓存已有显示结果,也先检查项目/身份仍有效,不能跨账户复用旧视图授权。 */ /** 即使缓存已有显示结果,也先检查项目/身份仍有效,不能跨账户复用旧视图授权。 */
const assertDisplayIdentity=()=>{if(closing||closed)throw fault('engine_closing');if(options.isCurrentIdentity&&!options.isCurrentIdentity())throw fault('identity_changed');}; const assertDisplayIdentity=()=>{if(closing||closed)throw fault('engine_closing');if(options.isCurrentIdentity&&!options.isCurrentIdentity())throw fault('identity_changed');};
// 显示租约计数阻止清理;reservation 阻止领用即将删除的文件;durablyPurged 记录已删字节。 // displayPins 计数显示/文件借用租约;reservation 在规划 purge 后阻止新领用,直到可靠回执释放。
// durablyPurged 是当前 leader 已可靠记录的 purge 身份集合,用来避免重复安排物理删除,并非 Blob 缓存。
const displayPins=new Map<Id,number>(),collectionReservations=new Set<Id>(),durablyPurged=new Set<Id>([...(recovered?.runtime.resources.values()??[])].filter(resource=>resource.retirement?.purgedAt!==undefined).map(resource=>resource.id)); const displayPins=new Map<Id,number>(),collectionReservations=new Set<Id>(),durablyPurged=new Set<Id>([...(recovered?.runtime.resources.values()??[])].filter(resource=>resource.retirement?.purgedAt!==undefined).map(resource=>resource.id));
/** 清理结果同时报告检查根、当前根和可靠保存根,区分“规划删除”和“字节已删除”。 */ /** 清理结果同时报告检查根、当前根和可靠保存根,区分“规划删除”和“字节已删除”。 */
type CollectionReport={readonly markedIds:readonly Id[];readonly purgedIds:readonly Id[];readonly complete:boolean;readonly checkedRootRevision:number;readonly rootRevision:number;readonly durableRootRevision:number}; type CollectionReport={readonly markedIds:readonly Id[];readonly purgedIds:readonly Id[];readonly complete:boolean;readonly checkedRootRevision:number;readonly rootRevision:number;readonly durableRootRevision:number};
@@ -149,9 +159,11 @@ export async function openProjectEngine(options: EngineOptions) {
// 解码/尺寸补写/摘要等在途索引只用于生命周期等待与去重;恢复事实仍在 runtime 中。 // 解码/尺寸补写/摘要等在途索引只用于生命周期等待与去重;恢复事实仍在 runtime 中。
const imageTasks=new Map<Id,Promise<void>>(); const imageTasks=new Map<Id,Promise<void>>();
let sizingTask: Promise<void> | undefined; let sizingTask: Promise<void> | undefined;
// 待写 Blob 与根快照分开保存,writePrefix 只把当前快照登记的资源/导入源文件纳入原子提交。 // fileWrites 暂存未可靠落盘的 Blob;digestTasks 只去重并等待摘要工作,二者都不是持久任务表。
// writePrefix 按某一捕获根的资源/源归属筛选文件,不能把未来编辑的字节提前配给旧根。
const fileWrites = new Map<Id, Blob>(), digestTasks = new Map<Id, Promise<void>>(); const fileWrites = new Map<Id, Blob>(), digestTasks = new Map<Id, Promise<void>>();
// 文件暂存及默认尺寸标记仅覆盖同步编辑事务;事务 reducer 将其写入可恢复资源记录。 // staged 与 stagedDefaultSizes 仅是同步编辑事务的授权上下文;reduceRuntime 将其转成根中的资源事实。
// 分配/上传/解码不能在 reducer 内启动,失败候选也不能留下第二份已接纳任务。
let staged: readonly ResourceRecord[] = []; let staged: readonly ResourceRecord[] = [];
const stagedDefaultSizes = new Set<Id>(); const stagedDefaultSizes = new Set<Id>();
// 将用户提交标为 run.apply 的短暂上下文;不能让一次应用操作泄漏到后续普通编辑。 // 将用户提交标为 run.apply 的短暂上下文;不能让一次应用操作泄漏到后续普通编辑。
@@ -168,7 +180,8 @@ export async function openProjectEngine(options: EngineOptions) {
const importRunners=new Map<Id,ReturnType<typeof createImportRunner>>(),importLoads=new Map<Id,Promise<void>>(),importHolds=new Map<symbol,Map<Id,()=>void>>(); const importRunners=new Map<Id,ReturnType<typeof createImportRunner>>(),importLoads=new Map<Id,Promise<void>>(),importHolds=new Map<symbol,Map<Id,()=>void>>();
// 恢复会话存在期间阻止一般写入;会话负责持有/释放调度暂停及中止控制。 // 恢复会话存在期间阻止一般写入;会话负责持有/释放调度暂停及中止控制。
let recovery: { controller: AbortController; release(): void; cancel():Promise<void> } | undefined; let recovery: { controller: AbortController; release(): void; cancel():Promise<void> } | undefined;
/** 复制准备期间保守保护未清除资源,解码期间保护正在读取的图片;供每次保留扫描派生。 */ /** 每次派生临时保留清单:任一复制准备仍活跃就保守 pin 全部未 purge 的本地资源,解码按任务 ID pin。
* Map 是本次扫描的输入,不发布第二份 resourcePins;任务结束后下次扫描自然不再包含该临时 owner。 */
const admissionPins=(snapshot:EngineSnapshot)=>{const pins=new Map<Id,readonly Id[]>();if(copyPreparations.size)pins.set('copy-admission',[...snapshot.runtime.resources.values()].filter(resource=>resource.retirement?.purgedAt===undefined).map(resource=>resource.id));if(imageTasks.size)pins.set('image-decode',[...imageTasks.keys()]);return pins;}; const admissionPins=(snapshot:EngineSnapshot)=>{const pins=new Map<Id,readonly Id[]>();if(copyPreparations.size)pins.set('copy-admission',[...snapshot.runtime.resources.values()].filter(resource=>resource.retirement?.purgedAt===undefined).map(resource=>resource.id));if(imageTasks.size)pins.set('image-decode',[...imageTasks.keys()]);return pins;};
/** 通用写入护栏。complete=false 仅放宽“模型必须完整”,不绕过身份、租约或恢复会话限制。 */ /** 通用写入护栏。complete=false 仅放宽“模型必须完整”,不绕过身份、租约或恢复会话限制。 */
const assertWritable = (complete=true) => { if(!leader.active)throw fault('storage_leader_lost');if(options.isCurrentIdentity&&!options.isCurrentIdentity())throw fault('identity_changed');if(complete&&editor.getSnapshot().runtime.loading)throw fault('model_loading');if(closing||closed)throw fault('engine_closing');if(recovery)throw fault('recovery_in_progress'); }; const assertWritable = (complete=true) => { if(!leader.active)throw fault('storage_leader_lost');if(options.isCurrentIdentity&&!options.isCurrentIdentity())throw fault('identity_changed');if(complete&&editor.getSnapshot().runtime.loading)throw fault('model_loading');if(closing||closed)throw fault('engine_closing');if(recovery)throw fault('recovery_in_progress'); };
@@ -184,7 +197,8 @@ export async function openProjectEngine(options: EngineOptions) {
// 已领用资源绑定必须能被当前服务 authority 和 URL 解释;换服务不能静默复用旧授权。 // 已领用资源绑定必须能被当前服务 authority 和 URL 解释;换服务不能静默复用旧授权。
for(const [id,binding]of initial.assetBindings??[])if(!depot||!options.assets?.authority||binding.authority!==options.assets.authority.scope||binding.asset.url!==options.assets.sourceURL(id))throw fault('asset_binding_interpreter_unavailable',[id]); for(const [id,binding]of initial.assetBindings??[])if(!depot||!options.assets?.authority||binding.authority!==options.assets.authority.scope||binding.asset.url!==options.assets.sourceURL(id))throw fault('asset_binding_interpreter_unavailable',[id]);
if(initial.loading?.version===6&&options.assets)for(const resource of initial.resources.values())if(resource.loadingAdmission?.sourceURL!==options.assets.sourceURL(resource.id))throw fault('asset_source_changed',[resource.id]); if(initial.loading?.version===6&&options.assets)for(const resource of initial.resources.values())if(resource.loadingAdmission?.sourceURL!==options.assets.sourceURL(resource.id))throw fault('asset_source_changed',[resource.id]);
// editor 是唯一状态根的发布者,下面的 hooks 在发布前整合 runtime,而非先改文档后补队列。 // editor 是唯一状态根的发布者,hooks 在候选发布前整合/检查 runtime,而非先改文档后补队列。
// prepareRuntime 是全部发布的保留/所有权边界,reduceRuntime 只处理带 commit 的编辑;二者不做 I/O。
const editor = createEditor({ registry: options.registry, document: options.document, recovery: recovered, runtime: initial, createId: options.createId, historyLimit: options.historyLimit,queryBudget:options.queryBudget,queryCoverage:loadingQueryCoverage, const editor = createEditor({ registry: options.registry, document: options.document, recovery: recovered, runtime: initial, createId: options.createId, historyLimit: options.historyLimit,queryBudget:options.queryBudget,queryCoverage:loadingQueryCoverage,
/** 命令入口先检查基础生命周期,再检查渐进加载允许范围,最后应用宿主业务策略。 */ /** 命令入口先检查基础生命周期,再检查渐进加载允许范围,最后应用宿主业务策略。 */
policy(command, document) { if(!leader.active)return{enabled:false,code:'storage_leader_lost',targets:[]};if(options.isCurrentIdentity&&!options.isCurrentIdentity())return{enabled:false,code:'identity_changed',targets:[]};if (closing || closed || recovery) return { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] }; if(editor.getSnapshot().runtime.loading){const allowed=loadingCommand(command,document,options.registry,editor.getSnapshot().runtime.loading,editor.getSnapshot().runtime.loadingRead?.document??editor.getSnapshot().runtime.acknowledged,editor.getSnapshot().runtime.loadingRead,assetURLs());if(!allowed.enabled)return allowed;}return options.policy?.(command, document) ?? { enabled: true }; }, policy(command, document) { if(!leader.active)return{enabled:false,code:'storage_leader_lost',targets:[]};if(options.isCurrentIdentity&&!options.isCurrentIdentity())return{enabled:false,code:'identity_changed',targets:[]};if (closing || closed || recovery) return { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] }; if(editor.getSnapshot().runtime.loading){const allowed=loadingCommand(command,document,options.registry,editor.getSnapshot().runtime.loading,editor.getSnapshot().runtime.loadingRead?.document??editor.getSnapshot().runtime.acknowledged,editor.getSnapshot().runtime.loadingRead,assetURLs());if(!allowed.enabled)return allowed;}return options.policy?.(command, document) ?? { enabled: true }; },
@@ -207,6 +221,8 @@ export async function openProjectEngine(options: EngineOptions) {
for(const id of next.runtime.assetBindings?.keys()??[])if(next.runtime.resources.has(id)||next.runtime.orphanFiles?.has(id)||importFiles(next.runtime.imports).has(id))throw fault('resource_identity_conflict',[id]); for(const id of next.runtime.assetBindings?.keys()??[])if(next.runtime.resources.has(id)||next.runtime.orphanFiles?.has(id)||importFiles(next.runtime.imports).has(id))throw fault('resource_identity_conflict',[id]);
validateLoadingRuntime(next.runtime,options.registry); validateLoadingRuntime(next.runtime,options.registry);
// 先计算尺寸权限,再依文档/历史/账本/显示引用派生资源保留,最后处理源文件和孤立字节。 // 先计算尺寸权限,再依文档/历史/账本/显示引用派生资源保留,最后处理源文件和孤立字节。
// 后一步使用前一步返回的候选:先撤销失效尺寸权限,再算资源 pin,再算导入源和孤儿退休状态。
// previous/next 只是候选快照,此处抛错交给 editor 拒绝整次发布,不在这里自行修改旧根。
const sizing=prepareDefaultImageSizing(previous,next); const sizing=prepareDefaultImageSizing(previous,next);
const runtime=prepareResourceRetention({...next,runtime:sizing},options.registry,displayPins,collectionReservations,admissionPins(next)); const runtime=prepareResourceRetention({...next,runtime:sizing},options.registry,displayPins,collectionReservations,admissionPins(next));
// 这些引用事实变化会影响“是否仍无人使用”;保留准备函数据此重新检查退休标记。 // 这些引用事实变化会影响“是否仍无人使用”;保留准备函数据此重新检查退休标记。
@@ -216,12 +232,13 @@ export async function openProjectEngine(options: EngineOptions) {
}, },
/** 只把当前应用输出的用户意图转换为 run.apply,保留普通 editor 的命令生成路径。 */ /** 只把当前应用输出的用户意图转换为 run.apply,保留普通 editor 的命令生成路径。 */
prepareIntent: intent => applying && intent.origin==='user' ? {...intent,operationType:'run.apply',action:applying} : intent, prepareIntent: intent => applying && intent.origin==='user' ? {...intent,operationType:'run.apply',action:applying} : intent,
/** 在现有账本最大序号后分配;预留 staged 上传操作的位置,让上传先于引用它的文档操作。 */ /** 取 fallback 与账本最大 sequence+1 的较大值,再为 staged 上传留连续序号。
* reduceRuntime 从首个文档操作序号倒推上传起点,保证资源任务排在依赖它的文档意图之前。 */
nextOperationSequence(runtime, fallback) { let next = fallback; for (const record of runtime.ledger.operations.values()) next = Math.max(next, record.intent.sequence + 1); return next + staged.length; }, nextOperationSequence(runtime, fallback) { let next = fallback; for (const record of runtime.ledger.operations.values()) next = Math.max(next, record.intent.sequence + 1); return next + staged.length; },
/** 把一次 editor 提交转换为账本、资源及导入作业的同根更新;不在这里发送请求。 */ /** 把一次 editor 提交转换为账本、资源及导入作业的同根更新;不在这里发送请求。 */
reduceRuntime(previous, next, commit) { reduceRuntime(previous, next, commit) {
if(previous.loading)assertLoadingChanges(commit.changes,previous.loading.version>=4?editor.getSnapshot().document:previous.acknowledged,options.registry,previous.loading,previous.loadingRead,assetURLs()); if(previous.loading)assertLoadingChanges(commit.changes,previous.loading.version>=4?editor.getSnapshot().document:previous.acknowledged,options.registry,previous.loading,previous.loadingRead,assetURLs());
// 无逻辑操作的提交不创建上传依赖;保留原 runtime 对象。 // 没有逻辑 operationIds 就保持原 runtime;资源暂存本身不能脱离文档提交变成上传任务。
if (!commit.operationIds.length) return previous; if (!commit.operationIds.length) return previous;
const ledger = createLedger(previous.ledger), resources = new Map(previous.resources); const ledger = createLedger(previous.ledger), resources = new Map(previous.resources);
const firstSequence = next.operations.get(commit.operationIds[0])!.sequence - staged.length; const firstSequence = next.operations.get(commit.operationIds[0])!.sequence - staged.length;
@@ -247,7 +264,8 @@ export async function openProjectEngine(options: EngineOptions) {
if (grants.some((grant, index) => grant !== resource.sizing!.grants[index])) resources.set(resourceId, freeze({ ...resource, sizing: { version: 1 as const, grants } })); if (grants.some((grant, index) => grant !== resource.sizing!.grants[index])) resources.set(resourceId, freeze({ ...resource, sizing: { version: 1 as const, grants } }));
} }
} }
// 所有未决 run.start,以及与本操作足迹冲突的文档/应用操作,都成为先行依赖。 // 未决 run.start 全部作为先行依赖;document.change/run.apply 只在对象足迹冲突时加入。
// Set 合并原依赖、先前任务和下方资源上传依赖,保留因果关系而不复制第二份调度状态。
const dependencies = new Set(intent.dependencies); const dependencies = new Set(intent.dependencies);
for (const record of ledger.getSnapshot().operations.values()) if (record.phase !== 'resolved' && (record.intent.operationType === 'run.start' || ['document.change','run.apply'].includes(record.intent.operationType) && conflictsWith(operationFootprint(record.intent, next.document), footprint))) dependencies.add(record.intent.id); for (const record of ledger.getSnapshot().operations.values()) if (record.phase !== 'resolved' && (record.intent.operationType === 'run.start' || ['document.change','run.apply'].includes(record.intent.operationType) && conflictsWith(operationFootprint(record.intent, next.document), footprint))) dependencies.add(record.intent.id);
// 引用尚未上传完成的资源时,文档同步必须等待该资源的上传操作。 // 引用尚未上传完成的资源时,文档同步必须等待该资源的上传操作。
@@ -255,7 +273,8 @@ export async function openProjectEngine(options: EngineOptions) {
const resource = resources.get(id); const resource = resources.get(id);
if (resource && !resource.stored) dependencies.add(resource.operationId); if (resource && !resource.stored) dependencies.add(resource.operationId);
} }
// 合并器需要起始存在性,才能区分本地 create→delete 可消除与已存在对象必须发送删除。 // 同一对象首个变化决定 basePresence:element.before 空为 absent,field 默认 exists,后续不覆盖。
// 它不是“最后节点对象”;给合并/抵消保留起始事实,是否已发出仍由 ledger/request 证据约束。
const basePresence: Record<Id, 'absent' | 'exists'> = Object.create(null); const basePresence: Record<Id, 'absent' | 'exists'> = Object.create(null);
for (const change of intent.changes) if (change.kind === 'element') basePresence[change.id] ??= change.before ? 'exists' : 'absent'; else if (change.kind === 'field') basePresence[change.target.id] ??= 'exists'; for (const change of intent.changes) if (change.kind === 'element') basePresence[change.id] ??= change.before ? 'exists' : 'absent'; else if (change.kind === 'field') basePresence[change.target.id] ??= 'exists';
ledger.add(freeze({ ...intent, dependencies: [...dependencies] }), basePresence); ledger.add(freeze({ ...intent, dependencies: [...dependencies] }), basePresence);
@@ -278,7 +297,8 @@ export async function openProjectEngine(options: EngineOptions) {
return Object.freeze({ ...previous, imports:readMap(imports), ...(assetBindings.size?{assetBindings:readMap(assetBindings)}:{}), resources: readMap(resources), ledger: ledger.getSnapshot() }); return Object.freeze({ ...previous, imports:readMap(imports), ...(assetBindings.size?{assetBindings:readMap(assetBindings)}:{}), resources: readMap(resources), ledger: ledger.getSnapshot() });
}, },
}); });
// 待持久化快照按根 revision 递增插入 Map,形成写前缀队列;不会用最新根覆盖未保存旧根。 // pending 是 revision→不可变完整根的插入有序 Map,订阅按发布顺序追加;不是只保留最新根的防抖槽。
// writePrefix 总取首项,成功后才移除;每项共享不可变子树,但积压仍会占内存,导入背压在后面限制增长。
const pending = new Map<number, EngineSnapshot>(); const pending = new Map<number, EngineSnapshot>();
// 新项目的初始根也要保存;恢复镜像已经在磁盘上,不重复当成新提交。 // 新项目的初始根也要保存;恢复镜像已经在磁盘上,不重复当成新提交。
if (!recovered) pending.set(editor.getSnapshot().rootRevision, editor.getSnapshot()); if (!recovered) pending.set(editor.getSnapshot().rootRevision, editor.getSnapshot());
@@ -288,11 +308,14 @@ export async function openProjectEngine(options: EngineOptions) {
// 记录由可靠回执确认的 ledger generation;即使派生标记发布失败,也保留这项推进义务。 // 记录由可靠回执确认的 ledger generation;即使派生标记发布失败,也保留这项推进义务。
let confirmedLedgerGeneration=editor.getSnapshot().runtime.ledger.durableGeneration; let confirmedLedgerGeneration=editor.getSnapshot().runtime.ledger.durableGeneration;
/** 保存不超过 throughRevision 的已捕获前缀。每份根镜像与对应字节写入/删除同事务提交, /** 保存不超过 throughRevision 的已捕获前缀。每份根镜像与对应字节写入/删除同事务提交,
* 存储回执核对通过后才移除 pending、释放字节和推进可靠游标。已有写入时共享该 Promise。 */ * 存储回执核对通过后才移除 pending、释放字节和推进可靠游标。已有写入时共享该 Promise。
* 后来的调用不会扩大当前 throughRevision;persistThrough 在旧写入完成后继续补写所需边界。
* pending Map 的插入序由根订阅维护,不在此排序/补洞;存储 adapter 再校验连续 revision。 */
function writePrefix(throughRevision:number):Promise<void> { function writePrefix(throughRevision:number):Promise<void> {
if (writing) return writing; if (writing) return writing;
const task = Promise.resolve().then(async () => { const task = Promise.resolve().then(async () => {
/** 把已可靠写入的 generation 发布回 ledger;此更新可能产生新根,不能冒充它也已保存。 */ /** 一次前缀结束后把已确认 generation 反映到 ledger;mutate 可能产生新的待保存根。
* confirmedLedgerGeneration 在回执后保留推进义务,即使本次派生发布失败,下次也可补办。 */
const publishDurability=()=>{if(editor.getSnapshot().runtime.ledger.durableGeneration<confirmedLedgerGeneration)mutate(ledger=>ledger.markDurable(confirmedLedgerGeneration));}; const publishDurability=()=>{if(editor.getSnapshot().runtime.ledger.durableGeneration<confirmedLedgerGeneration)mutate(ledger=>ledger.markDurable(confirmedLedgerGeneration));};
try { try {
while (pending.size) { while (pending.size) {
@@ -304,7 +327,8 @@ export async function openProjectEngine(options: EngineOptions) {
const deleteFiles=[...[...snapshot.runtime.resources.values()].filter(resource=>resource.retirement?.purgedAt!==undefined&&!durablyPurged.has(resource.id)).map(resource=>resource.id),...[...sourceFiles].filter(([id,entry])=>entry.file.retirement?.purgedAt!==undefined&&!durablyPurged.has(id)).map(([id])=>id),...[...(snapshot.runtime.orphanFiles??[])].filter(([id,file])=>file.retirement?.purgedAt!==undefined&&!durablyPurged.has(id)).map(([id])=>id)]; const deleteFiles=[...[...snapshot.runtime.resources.values()].filter(resource=>resource.retirement?.purgedAt!==undefined&&!durablyPurged.has(resource.id)).map(resource=>resource.id),...[...sourceFiles].filter(([id,entry])=>entry.file.retirement?.purgedAt!==undefined&&!durablyPurged.has(id)).map(([id])=>id),...[...(snapshot.runtime.orphanFiles??[])].filter(([id,file])=>file.retirement?.purgedAt!==undefined&&!durablyPurged.has(id)).map(([id])=>id)];
// 共享仓库写入和项目 pin 清单也由同一快照准备,避免项目镜像与资源引用各自提交。 // 共享仓库写入和项目 pin 清单也由同一快照准备,避免项目镜像与资源引用各自提交。
const depot=options.assets?.authority&&options.storage.assetDepot?await prepareDepotCut({snapshot,registry:options.registry,storage:options.storage,leader,authority:options.assets.authority.scope,files:fileWrites,adoptedFiles,displays:displayPins,temporary:admissionPins(snapshot)}):undefined; const depot=options.assets?.authority&&options.storage.assetDepot?await prepareDepotCut({snapshot,registry:options.registry,storage:options.storage,leader,authority:options.assets.authority.scope,files:fileWrites,adoptedFiles,displays:displayPins,temporary:admissionPins(snapshot)}):undefined;
// 编码可能让出线程;snapshot 保持不可变,不能改用等待后出现的新 working 根。 // depot 准备/编码都可能 await,始终使用本轮 snapshot;不能等待后改取 live 根再配旧 revision/files。
// storage.commit 必须把 image/本批 Blob/删除/depot 同事务处理;这里只在匹配回执后承认可靠。
const receipt = await options.storage.commit(leader, { image: await encodeRecoveryAsync(snapshot), files,deleteFiles,...(depot?{depot}:{}) }); const receipt = await options.storage.commit(leader, { image: await encodeRecoveryAsync(snapshot), files,deleteFiles,...(depot?{depot}:{}) });
if (receipt.durableRootRevision !== revision) throw fault('invalid_durable_receipt'); if (receipt.durableRootRevision !== revision) throw fault('invalid_durable_receipt');
// 回执之后才承认 generation/源 seal 可靠;后续网络准入依赖这些事实。 // 回执之后才承认 generation/源 seal 可靠;后续网络准入依赖这些事实。
@@ -319,7 +343,7 @@ export async function openProjectEngine(options: EngineOptions) {
// 部分前缀成功、后续失败时仍发布成功证据,不能把已可靠写入的操作退回“从未保存”。 // 部分前缀成功、后续失败时仍发布成功证据,不能把已可靠写入的操作退回“从未保存”。
publishDurability();throw error; publishDurability();throw error;
} }
// One derived publication per captured prefix, not one per receipt. // 一次前缀尽量只发布一次派生 durableGeneration,避免每个回执都再制造一个待保存根。
publishDurability(); publishDurability();
persistenceFailed = false;durabilityError=undefined;notifyDurability(); persistenceFailed = false;durabilityError=undefined;notifyDurability();
}); });
@@ -327,18 +351,23 @@ export async function openProjectEngine(options: EngineOptions) {
writing = task.catch(error => { persistenceFailed = true;const failure=error&&typeof error==='object'?error as {code?:unknown;message?:unknown}:{};durabilityError=typeof failure.code==='string'&&failure.code?failure.code:typeof failure.message==='string'&&failure.message?failure.message:'persistence_failed';notifyDurability(); throw error; }).finally(() => { writing = undefined;if(!persistenceFailed){if(loadingCompletionRevision!==undefined&&durableRootRevision>=loadingCompletionRevision&&!editor.getSnapshot().runtime.loading){const release=loadingRelease;loadingRelease=undefined;loadingCompletionRevision=undefined;release?.();}if(!closing&&!closed)scheduler.refreshAdmission();} }); writing = task.catch(error => { persistenceFailed = true;const failure=error&&typeof error==='object'?error as {code?:unknown;message?:unknown}:{};durabilityError=typeof failure.code==='string'&&failure.code?failure.code:typeof failure.message==='string'&&failure.message?failure.message:'persistence_failed';notifyDurability(); throw error; }).finally(() => { writing = undefined;if(!persistenceFailed){if(loadingCompletionRevision!==undefined&&durableRootRevision>=loadingCompletionRevision&&!editor.getSnapshot().runtime.loading){const release=loadingRelease;loadingRelease=undefined;loadingCompletionRevision=undefined;release?.();}if(!closing&&!closed)scheduler.refreshAdmission();} });
return writing; return writing;
} }
/** 仅保证指定 revision 已可靠保存。若共享的在途写入目标更早,完成后继续写到所需边界。 */ /** 捕获 requiredRevision(缺省为调用时根 revision),只保证这一边界已可靠保存。
* 共用在途写入可能只到更早根,因此 .then 再查水位并补写;不是保证所有未来编辑都已保存。
* 写失败向调用者传播,不在这里无限自动重试;必须由后续显式/自动保存重新触发。 */
function persistThrough(requiredRevision=editor.getSnapshot().rootRevision):Promise<void> { function persistThrough(requiredRevision=editor.getSnapshot().rootRevision):Promise<void> {
if(durableRootRevision>=requiredRevision)return Promise.resolve(); if(durableRootRevision>=requiredRevision)return Promise.resolve();
return writePrefix(requiredRevision).then(()=>durableRootRevision<requiredRevision?persistThrough(requiredRevision):undefined); return writePrefix(requiredRevision).then(()=>durableRootRevision<requiredRevision?persistThrough(requiredRevision):undefined);
} }
/** 排空当前保存义务,包含写回 durableGeneration 派生的新根;不等于网络同步完成。 */ /** 循环排空 pending 和 durableGeneration 派生义务,不等于等待网络 ACK/生成/导入任务全部完成。
* 与 persistThrough 的固定边界不同,循环期间新增本地根也纳入等待;调用方须先阻止需关闭的生产者。 */
async function persist():Promise<void> { async function persist():Promise<void> {
do {await writePrefix(editor.getSnapshot().rootRevision);} do {await writePrefix(editor.getSnapshot().rootRevision);}
while(pending.size||writing||confirmedLedgerGeneration>editor.getSnapshot().runtime.ledger.durableGeneration); while(pending.size||writing||confirmedLedgerGeneration>editor.getSnapshot().runtime.ledger.durableGeneration);
} }
/** 本地上传资源的两阶段清理:先持久化无人引用时间,再于保留期后重新扫描并原子删字节。 /** 本地上传资源的两阶段清理:先持久化无人引用时间,再于保留期后重新扫描并原子删字节。
* 引用覆盖不完整就停止,限制每批文件数,返回标记与真正保存成功的删除结果。 */ * 引用覆盖不完整就停止,限制每阶段候选数;先保存现有根,再标记,再复取根/pin 判定删除。
* markedIds/purgedIds 只随成功返回报告;中途失败抛错,已保存标记保留供下一轮继续。
* maxFiles 分别限制标记/删除,不是全表扫描或总工作量上限;各资源仍需遍历后筛选。 */
function collectResources(settings:{retentionMs:number;maxFiles?:number}) { function collectResources(settings:{retentionMs:number;maxFiles?:number}) {
assertWritable(); assertWritable();
if(options.storage.fileDeletion!=='atomic-v1')throw fault('resource_collection_unavailable'); if(options.storage.fileDeletion!=='atomic-v1')throw fault('resource_collection_unavailable');
@@ -347,9 +376,11 @@ export async function openProjectEngine(options: EngineOptions) {
if(collection)return collection; if(collection)return collection;
const task=(async()=>{ const task=(async()=>{
await persist();assertWritable(); await persist();assertWritable();
// 一轮使用一个 now;保存后重查引用,但不把等待时间追加进本轮保留期判断。
const now=runClock.now();if(!Number.isSafeInteger(now)||now<0)throw fault('invalid_resource_collection_clock'); const now=runClock.now();if(!Number.isSafeInteger(now)||now<0)throw fault('invalid_resource_collection_clock');
let root=editor.getSnapshot(),pins=resourceRetentionPins(root,options.registry,displayPins,admissionPins(root)); let root=editor.getSnapshot(),pins=resourceRetentionPins(root,options.registry,displayPins,admissionPins(root));
const markedIds:Id[]=[],purgedIds:Id[]=[]; const markedIds:Id[]=[],purgedIds:Id[]=[];
// complete 只表示引用扫描可解释完整,不表示仓库中所有资源都已 purge。
const report=()=>Object.freeze({markedIds:Object.freeze(markedIds),purgedIds:Object.freeze(purgedIds),complete:pins.complete,checkedRootRevision:root.rootRevision,rootRevision:editor.getSnapshot().rootRevision,durableRootRevision}); const report=()=>Object.freeze({markedIds:Object.freeze(markedIds),purgedIds:Object.freeze(purgedIds),complete:pins.complete,checkedRootRevision:root.rootRevision,rootRevision:editor.getSnapshot().rootRevision,durableRootRevision});
if(!pins.complete)return report(); if(!pins.complete)return report();
// 待写或摘要处理中资源不参与清理;必须先证明没有文档、历史、任务或显示租约引用。 // 待写或摘要处理中资源不参与清理;必须先证明没有文档、历史、任务或显示租约引用。
@@ -362,6 +393,7 @@ export async function openProjectEngine(options: EngineOptions) {
root=editor.getSnapshot();pins=resourceRetentionPins(root,options.registry,displayPins,admissionPins(root)); root=editor.getSnapshot();pins=resourceRetentionPins(root,options.registry,displayPins,admissionPins(root));
if(!pins.complete)return report(); if(!pins.complete)return report();
// 标记后的 await 期间可能出现新引用,所以重新取根和 pins,再判定到期资源。 // 标记后的 await 期间可能出现新引用,所以重新取根和 pins,再判定到期资源。
// retentionMs=0 时本轮新标记也可能直接进入 purge,但前面的标记根已先可靠保存。
const eligible=[...root.runtime.resources.values()].filter(resource=>resource.retirement&&resource.retirement.purgedAt===undefined&&now-resource.retirement.unreferencedAt>=retentionMs&&!pins.pins.has(resource.id)&&!fileWrites.has(resource.id)&&!digestTasks.has(resource.id)).slice(0,maxFiles); const eligible=[...root.runtime.resources.values()].filter(resource=>resource.retirement&&resource.retirement.purgedAt===undefined&&now-resource.retirement.unreferencedAt>=retentionMs&&!pins.pins.has(resource.id)&&!fileWrites.has(resource.id)&&!digestTasks.has(resource.id)).slice(0,maxFiles);
if(eligible.length){ if(eligible.length){
// 先保留删除席位阻止新领用;若根 revision 冲突则撤销保留,不报告已删除。 // 先保留删除席位阻止新领用;若根 revision 冲突则撤销保留,不报告已删除。
@@ -376,7 +408,9 @@ export async function openProjectEngine(options: EngineOptions) {
collection=task.finally(()=>{collection=undefined;});return collection; collection=task.finally(()=>{collection=undefined;});return collection;
} }
/** 导入源文件的同类两阶段清理;当前源只允许在任务终结且没有载入器时成为候选。 /** 导入源文件的同类两阶段清理;当前源只允许在任务终结且没有载入器时成为候选。
* 旧分片/历史源也要经过引用覆盖和保留期,不按任务结束就立即丢弃全部原始字节。 */ * 旧分片/历史源也要经过引用覆盖和保留期,不按任务结束就立即丢弃全部原始字节。
* single-flight 只覆盖本类 sourceCollection;运行中重复调用共用首次 settings,不另开一轮不同参数扫描。
* 与资源回收一样 maxFiles 分阶段限候选,一轮 now 固定,可靠回执后才报告 purgedIds。 */
function collectImportSources(settings:{retentionMs:number;maxFiles?:number}):Promise<CollectionReport> { function collectImportSources(settings:{retentionMs:number;maxFiles?:number}):Promise<CollectionReport> {
assertWritable();if(options.storage.fileDeletion!=='atomic-v1')throw fault('resource_collection_unavailable'); assertWritable();if(options.storage.fileDeletion!=='atomic-v1')throw fault('resource_collection_unavailable');
const retentionMs=settings.retentionMs,maxFiles=settings.maxFiles??64; const retentionMs=settings.retentionMs,maxFiles=settings.maxFiles??64;
@@ -410,8 +444,10 @@ export async function openProjectEngine(options: EngineOptions) {
})(); })();
sourceCollection=task.finally(()=>{sourceCollection=undefined;});return sourceCollection; sourceCollection=task.finally(()=>{sourceCollection=undefined;});return sourceCollection;
} }
/** 为备份/查看借出导入原文件。先加引用 pin 再异步读字节,校验长度及已有 SHA, /** 为备份/查看借出当前或指定历史源文件。同步选定文件描述并 pin,再从暂存/可靠存储读 Blob。
* 成功返回必须释放的句柄;失败自动释放,关闭期间的迟到结果不再交给调用方。 */ * 校验长度;仅已有 sha256 时重算比对,不因返回 Blob 就推断 integrity-v2 已完成整源封存。
* 成功返回 {bytes,release},调用方必须释放;失败自动释放,关闭期间的迟到结果不交付。
* 不调用完整 assertWritable:恢复会话/加载期也可借出文件,远端权限不由此入口重新授权。 */
async function openImportSource(jobId:Id,fileId?:Id) { async function openImportSource(jobId:Id,fileId?:Id) {
if(closing||closed)throw fault('engine_closing');const job=getImport(jobId),file=fileId!==undefined?importFiles(new Map([[jobId,job]])).get(fileId)?.file:job.source;if(!file)throw fault('import_source_unavailable'); if(closing||closed)throw fault('engine_closing');const job=getImport(jobId),file=fileId!==undefined?importFiles(new Map([[jobId,job]])).get(fileId)?.file:job.source;if(!file)throw fault('import_source_unavailable');
if(collectionReservations.has(file.fileId))throw fault('resource_collection_in_progress',[file.fileId]); if(collectionReservations.has(file.fileId))throw fault('resource_collection_in_progress',[file.fileId]);
@@ -420,7 +456,8 @@ export async function openProjectEngine(options: EngineOptions) {
/** 幂等归还本次 pin;从 displays 释放表移除,项目关闭不会重复扣除引用计数。 */ /** 幂等归还本次 pin;从 displays 释放表移除,项目关闭不会重复扣除引用计数。 */
const release=()=>{if(released)return;released=true;displays.delete(release);const count=displayPins.get(file.fileId)??1;if(count<=1)displayPins.delete(file.fileId);else displayPins.set(file.fileId,count-1);};displays.add(release); const release=()=>{if(released)return;released=true;displays.delete(release);const count=displayPins.get(file.fileId)??1;if(count<=1)displayPins.delete(file.fileId);else displayPins.set(file.fileId,count-1);};displays.add(release);
try{ try{
// pin 已建立后发布一次 runtime 更新,让保留规则撤销已过时的退休标记。 // pin 先建立,空 runtime 更新触发 prepareRuntime 的保留规则,撤掉因无人引用产生的软退休标记。
// 只是重新派生引用状态,不更换本次已捕获的文件身份;本函数不等待该更新的持久化回执。
if(file.retirement){const result=editor.updateRuntime(previous=>Object.freeze({...previous}));if(result.status==='rejected')throw fault(result.code,result.targets);} if(file.retirement){const result=editor.updateRuntime(previous=>Object.freeze({...previous}));if(result.status==='rejected')throw fault(result.code,result.targets);}
const bytes=fileWrites.get(file.fileId)??await options.storage.readFile(leader,file.fileId); const bytes=fileWrites.get(file.fileId)??await options.storage.readFile(leader,file.fileId);
if(closing||closed)throw fault('engine_closing');if(!bytes||bytes.size!==file.byteLength)throw fault('import_source_unavailable',[file.fileId]); if(closing||closed)throw fault('engine_closing');if(!bytes||bytes.size!==file.byteLength)throw fault('import_source_unavailable',[file.fileId]);
@@ -429,7 +466,8 @@ export async function openProjectEngine(options: EngineOptions) {
}catch(error){release();if(closing||closed)throw fault('engine_closing');throw error;} }catch(error){release();if(closing||closed)throw fault('engine_closing');throw error;}
} }
/** 校验存储提供的文件分页:绑定根 revision、ID 严格递增、大小合法、尾游标匹配。 /** 校验存储提供的文件分页:绑定根 revision、ID 严格递增、大小合法、尾游标匹配。
* 读取期间根变更则拒绝旧清单,不能把两个时点的所有权证据拼成同一份库存。 */ * 不只比较 revision:await 后要求 editor 仍是同一个 root 对象,避免把变更前清单用于当前归属判断。
* 这里只验证页结构/顺序,不读 Blob、不哈希,也不证明跨多页调用持有同一个存储快照。 */
async function scanLocalFiles(root:EngineSnapshot,after:Id|undefined,limit:number){ async function scanLocalFiles(root:EngineSnapshot,after:Id|undefined,limit:number){
if(!options.storage.scanFiles)throw fault('file_inventory_unavailable'); if(!options.storage.scanFiles)throw fault('file_inventory_unavailable');
const page=await options.storage.scanFiles(leader,{expectedRootRevision:root.rootRevision,after,limit}); const page=await options.storage.scanFiles(leader,{expectedRootRevision:root.rootRevision,after,limit});
@@ -440,7 +478,9 @@ export async function openProjectEngine(options: EngineOptions) {
if(page.nextAfter!==undefined&&(!page.files.length||page.nextAfter!==previous))throw fault('invalid_file_inventory');return page; if(page.nextAfter!==undefined&&(!page.files.length||page.nextAfter!==previous))throw fault('invalid_file_inventory');return page;
} }
/** 先保存根再读取有界库存,给每个文件附上资源/导入源/孤立文件归属与 pin 依据。 /** 先保存根再读取有界库存,给每个文件附上资源/导入源/孤立文件归属与 pin 依据。
* 这是诊断读取;unregistered 只表示未登记,不直接证明该文件可安全删除。 */ * 这是诊断读取;unregistered 只表示未登记,不直接证明该文件可安全删除。
* ownership 来自当前捕获根,retired 表示 purge 声明,complete 表示引用覆盖;三者不是同一个判定。
* 本次只返回一页,页尾 nextAfter 供调用者后续继续,没有自动遍历整仓库。 */
async function inventoryLocalFiles(settings:{after?:Id;limit?:number}={}){ async function inventoryLocalFiles(settings:{after?:Id;limit?:number}={}){
assertWritable();const limit=settings.limit??128;if(!Number.isSafeInteger(limit)||limit<1||limit>128||settings.after!==undefined&&(typeof settings.after!=='string'||!settings.after))throw fault('invalid_file_inventory'); assertWritable();const limit=settings.limit??128;if(!Number.isSafeInteger(limit)||limit<1||limit>128||settings.after!==undefined&&(typeof settings.after!=='string'||!settings.after))throw fault('invalid_file_inventory');
await persist();assertWritable();const root=editor.getSnapshot(),page=await scanLocalFiles(root,settings.after,limit),sources=importFiles(root.runtime.imports),pins=orphanCoverage(root,options.registry,displayPins,admissionPins(root)); await persist();assertWritable();const root=editor.getSnapshot(),page=await scanLocalFiles(root,settings.after,limit),sources=importFiles(root.runtime.imports),pins=orphanCoverage(root,options.registry,displayPins,admissionPins(root));
@@ -448,7 +488,9 @@ export async function openProjectEngine(options: EngineOptions) {
} }
/** 分页盘点并清理没有资源/导入源归属的本地文件。 /** 分页盘点并清理没有资源/导入源归属的本地文件。
* 仅对本批核对过长度、MIME 和 SHA 的候选字节安排清理;先记无人引用时间,再经保留期删除。 * 仅对本批核对过长度、MIME 和 SHA 的候选字节安排清理;先记无人引用时间,再经保留期删除。
* 文件数与散列字节预算分别限流,nextAfter 指向已处理边界,调用方可继续下一页。 */ * 一次只取 pageSize 一页;maxFiles 限实际验证候选数,maxHashBytes 限本页实际散列字节。
* 不够预算的候选不推进 nextAfter,调用者需提高预算或换下一次策略,不会悄悄跳过这个文件。
* orphanCollection 单飞共用首轮 settings;失败/取消只清执行索引,已保存的退休事实保留供后续继续。 */
function collectOrphanFiles(settings:{retentionMs:number;maxFiles?:number;after?:Id;pageSize?:number;maxHashBytes?:number;signal?:AbortSignal}){ function collectOrphanFiles(settings:{retentionMs:number;maxFiles?:number;after?:Id;pageSize?:number;maxHashBytes?:number;signal?:AbortSignal}){
assertWritable();if(options.storage.fileDeletion!=='atomic-v1'||!options.storage.scanFiles)throw fault('file_inventory_unavailable'); assertWritable();if(options.storage.fileDeletion!=='atomic-v1'||!options.storage.scanFiles)throw fault('file_inventory_unavailable');
const retentionMs=settings.retentionMs,maxFiles=settings.maxFiles??64,pageSize=settings.pageSize??128,maxHashBytes=settings.maxHashBytes??64*1024*1024; const retentionMs=settings.retentionMs,maxFiles=settings.maxFiles??64,pageSize=settings.pageSize??128,maxHashBytes=settings.maxHashBytes??64*1024*1024;
@@ -461,6 +503,7 @@ export async function openProjectEngine(options: EngineOptions) {
const task=(async()=>{ const task=(async()=>{
await persist();check();const now=runClock.now();if(!Number.isSafeInteger(now)||now<0)throw fault('invalid_resource_collection_clock'); await persist();check();const now=runClock.now();if(!Number.isSafeInteger(now)||now<0)throw fault('invalid_resource_collection_clock');
let root=editor.getSnapshot(),pins=orphanCoverage(root,options.registry,displayPins,admissionPins(root)),scanned=0,nextAfter=settings.after,hashBudgetExhausted=false,scanFinished=false; let root=editor.getSnapshot(),pins=orphanCoverage(root,options.registry,displayPins,admissionPins(root)),scanned=0,nextAfter=settings.after,hashBudgetExhausted=false,scanFinished=false;
// verified 只存本轮已实际核对的内容证据;不能仅用此前持久 orphan 元信息授权本轮物理删除。
const markedIds:Id[]=[],purgedIds:Id[]=[],changedIds:Id[]=[],verified=new Map<Id,OrphanFile>(); const markedIds:Id[]=[],purgedIds:Id[]=[],changedIds:Id[]=[],verified=new Map<Id,OrphanFile>();
/** 区分引用覆盖完整、扫描到末页与字节预算耗尽;这些状态不能只压成一个 complete。 */ /** 区分引用覆盖完整、扫描到末页与字节预算耗尽;这些状态不能只压成一个 complete。 */
const result=()=>Object.freeze({markedIds:Object.freeze(markedIds),purgedIds:Object.freeze(purgedIds),changedIds:Object.freeze(changedIds),complete:pins.complete,blockers:pins.blockers,checkedRootRevision:root.rootRevision,scanned,nextAfter,hashBudgetExhausted,scanFinished,durableRootRevision}); const result=()=>Object.freeze({markedIds:Object.freeze(markedIds),purgedIds:Object.freeze(purgedIds),changedIds:Object.freeze(changedIds),complete:pins.complete,blockers:pins.blockers,checkedRootRevision:root.rootRevision,scanned,nextAfter,hashBudgetExhausted,scanFinished,durableRootRevision});
@@ -484,7 +527,8 @@ export async function openProjectEngine(options: EngineOptions) {
// 整页走完才使用存储尾游标;部分处理时保留最后实际检查过的文件 ID。 // 整页走完才使用存储尾游标;部分处理时保留最后实际检查过的文件 ID。
if(finished)nextAfter=page.nextAfter;scanFinished=finished&&page.nextAfter===undefined; if(finished)nextAfter=page.nextAfter;scanFinished=finished&&page.nextAfter===undefined;
pins=orphanCoverage(root,options.registry,displayPins,admissionPins(root));if(!pins.complete)return result(); pins=orphanCoverage(root,options.registry,displayPins,admissionPins(root));if(!pins.complete)return result();
// 同 ID 字节发生变化时重新起算保留期;旧退休证据不能授权删除一份新内容。 // 同 ID 的 digest/长度/MIME 任一变化都重新起算保留期;changedIds 不是自动恢复成原有资源的许可。
// now 在本轮开头捕获,重查引用不顺带延长本轮时钟;retentionMs=0 时本轮新标记也可进入删除阶段。
const updates=new Map<Id,OrphanFile>(); const updates=new Map<Id,OrphanFile>();
for(const [id,file]of verified){if(pins.pins.has(id))continue;const old=root.runtime.orphanFiles?.get(id),changed=!!old&&(old.digest!==file.digest||old.byteLength!==file.byteLength||old.mimeType!==file.mimeType); for(const [id,file]of verified){if(pins.pins.has(id))continue;const old=root.runtime.orphanFiles?.get(id),changed=!!old&&(old.digest!==file.digest||old.byteLength!==file.byteLength||old.mimeType!==file.mimeType);
if(!old?.retirement||changed){updates.set(id,freeze({...file,retirement:{version:1 as const,unreferencedAt:now}}));markedIds.push(id);if(changed)changedIds.push(id);} if(!old?.retirement||changed){updates.set(id,freeze({...file,retirement:{version:1 as const,unreferencedAt:now}}));markedIds.push(id);if(changed)changedIds.push(id);}
@@ -504,23 +548,29 @@ export async function openProjectEngine(options: EngineOptions) {
// 成功、失败或取消都撤销单飞索引;不会删除根中的退休记录或伪造 purged 回执。 // 成功、失败或取消都撤销单飞索引;不会删除根中的退休记录或伪造 purged 回执。
orphanCollection=task.finally(()=>{orphanCollection=undefined;if(orphanController===controller)orphanController=undefined;});return orphanCollection; orphanCollection=task.finally(()=>{orphanCollection=undefined;if(orphanController===controller)orphanController=undefined;});return orphanCollection;
} }
/** 将可变 ledger helper 的一次归约转换为 editor 的同根 runtime 发布;拒绝结果转成领域异常。 */ /** 以当前 ledger 快照建立私有 helper,执行同步 reduce,再把其新快照交 editor 一次发布。
* 不允许 helper 绕过根发布单独成为事实源;reduce 须保持纯同步,外部副作用不由这里回滚。
* Result.rejected 转异常供 scheduler/持久化调用者处理,成功不等于该根已经 durable。 */
function mutate(reduce: (ledger: ReturnType<typeof createLedger>) => void) { function mutate(reduce: (ledger: ReturnType<typeof createLedger>) => void) {
const result = editor.updateRuntime(previous => { const ledger = createLedger(previous.ledger); reduce(ledger); return Object.freeze({ ...previous, ledger: ledger.getSnapshot() }); }); const result = editor.updateRuntime(previous => { const ledger = createLedger(previous.ledger); reduce(ledger); return Object.freeze({ ...previous, ledger: ledger.getSnapshot() }); });
if (result.status === 'rejected') throw fault(result.code, result.targets); if (result.status === 'rejected') throw fault(result.code, result.targets);
} }
/** 网络结果的统一协调入口。先记录请求是否生效,再按请求种类解释回执。 /** 网络结果的统一协调入口。先记录请求是否生效,再按请求种类解释回执。
* applied 只证明服务端生效;仍需验证身份/版本并重放本地未决操作,才能发布新的 ACK 和 working。 * applied 只证明服务端生效;仍需验证身份/版本并重放本地未决操作,才能发布新的 ACK 和 working。
* 协调失败保留原回执与原工作文档,标为“已生效但未协调”,不当作可重新提交的新请求。 */ * 协调失败保留原回执与原工作文档,标为“已生效但未协调”,不当作可重新提交的新请求。
* 全部协调在同一同步调用内规划/发布;磁盘屏障及请求 generation/身份采纳由外层 scheduler 负责。
* 此处 resolved 快速返回检查传入 request 快照,不把任意迟到响应视为可直接采纳的许可。 */
function complete(request: FrozenRequest, status: OperationStatus): { readonly blocked: true } | void { function complete(request: FrozenRequest, status: OperationStatus): { readonly blocked: true } | void {
const root = editor.getSnapshot(), previous = root.runtime, ledger = createLedger(previous.ledger); const root = editor.getSnapshot(), previous = root.runtime, ledger = createLedger(previous.ledger);
if (request.phase === 'resolved') return; if (request.phase === 'resolved') return;
// 未生效/未知结果只更新账本和回执;明确未生效的启动、取消动作可终结为无效果。 // 非 applied 结果不改 ACK/working,只更新 ledger 与可选回执;not-applied 仍是“确定未生效”,不是传输失败。
// run.start/run.cancel 的确定无效果可解决控制依赖;其它重试/阻塞与 writer 占用由 ledger/scheduler 管。
if (status.outcome !== 'applied') { if (status.outcome !== 'applied') {
const result=editor.updateRuntime(runtime=>{const ledger=createLedger(runtime.ledger),receipts=new Map(runtime.receipts);ledger.result(request.id,status.outcome,status.error);if(status.outcome==='not-applied'&&['run.start','run.cancel'].includes(request.kind??''))ledger.resolveNoEffect(request.id);if(status.canonical!==undefined)receipts.set(request.id,freeze(status.canonical));return Object.freeze({...runtime,ledger:ledger.getSnapshot(),receipts:readMap(receipts)});},root.rootRevision); const result=editor.updateRuntime(runtime=>{const ledger=createLedger(runtime.ledger),receipts=new Map(runtime.receipts);ledger.result(request.id,status.outcome,status.error);if(status.outcome==='not-applied'&&['run.start','run.cancel'].includes(request.kind??''))ledger.resolveNoEffect(request.id);if(status.canonical!==undefined)receipts.set(request.id,freeze(status.canonical));return Object.freeze({...runtime,ledger:ledger.getSnapshot(),receipts:readMap(receipts)});},root.rootRevision);
if(result.status==='rejected')throw fault(result.code,result.targets);return; if(result.status==='rejected')throw fault(result.code,result.targets);return;
} }
// 取消是控制结果,不直接用响应替换文档;提高 controlEpoch 使在途旧轮询失效,并继续读尾事件。 // run.cancel 没有文档提案:只更新任务事实/回执/ledger,再异步通知 tracker 追尾。
// controlEpoch++ 让旧轮询页失效;hasMore=true 继续取取消后的事件,不把“取消受理”当作事件已读完。
if(request.kind==='run.cancel'){ if(request.kind==='run.cancel'){
const receipts=new Map(previous.receipts);if(status.canonical!==undefined)receipts.set(request.id,freeze(status.canonical)); const receipts=new Map(previous.receipts);if(status.canonical!==undefined)receipts.set(request.id,freeze(status.canonical));
try{ try{
@@ -531,7 +581,8 @@ export async function openProjectEngine(options: EngineOptions) {
queueMicrotask(()=>runTracker.notify([run.id]));return; queueMicrotask(()=>runTracker.notify([run.id]));return;
}catch(error){ledger.result(request.id,'applied',(error as Error).message,false);const result=editor.updateRuntime(()=>Object.freeze({...previous,receipts:readMap(receipts),ledger:ledger.getSnapshot()}),root.rootRevision);if(result.status==='rejected')throw fault(result.code,result.targets);report(error);return{blocked:true};} }catch(error){ledger.result(request.id,'applied',(error as Error).message,false);const result=editor.updateRuntime(()=>Object.freeze({...previous,receipts:readMap(receipts),ledger:ledger.getSnapshot()}),root.rootRevision);if(result.status==='rejected')throw fault(result.code,result.targets);report(error);return{blocked:true};}
} }
// 上传回执只更新资源已存储事实;资源 ID 和原字节长度必须与冻结请求关联的记录一致。 // asset.upload 成功只把 stored 写回资源及解决 ledger,不替换元素数据,不在此删除本地原文件。
// 先由 assets.accept 校验协议证据,再核 stored ID/长度;显示 URL、共享仓库提升与回收走各自链路。
if (request.kind === 'asset.upload') { if (request.kind === 'asset.upload') {
const receipts = new Map(previous.receipts); if (status.canonical !== undefined) receipts.set(request.id, freeze(status.canonical)); const receipts = new Map(previous.receipts); if (status.canonical !== undefined) receipts.set(request.id, freeze(status.canonical));
try { try {
@@ -572,12 +623,14 @@ export async function openProjectEngine(options: EngineOptions) {
const version = proposal.versions.get(canvas); const version = proposal.versions.get(canvas);
if (!version || version <= request.expectedVersions[canvas] || version < (previous.versions.get(canvas) ?? 0)) throw fault('stale_canonical_version', [canvas]); if (!version || version <= request.expectedVersions[canvas] || version < (previous.versions.get(canvas) ?? 0)) throw fault('stale_canonical_version', [canvas]);
} }
// A codec cannot roll back an unrelated canvas while reconciling this request. // 不在本次写入集合中的已知 canvas,版本、canvas 记录及直接 owner 成员必须保持 ACK 原值。
// 这是参与范围检查,不是以局部响应替换全图的许可;最终重放/发布还会校验候选文档。
for (const [canvas, version] of previous.versions) if (!writtenCanvases.includes(canvas)) { for (const [canvas, version] of previous.versions) if (!writtenCanvases.includes(canvas)) {
if (proposal.versions.get(canvas) !== version || !equal(proposal.document.canvases.get(canvas), previous.acknowledged.canvases.get(canvas)) || !equal([...proposal.document.elements.values()].filter(element => element.ownerCanvasId === canvas), [...previous.acknowledged.elements.values()].filter(element => element.ownerCanvasId === canvas))) throw fault('canonical_scope_mismatch', [canvas]); if (proposal.versions.get(canvas) !== version || !equal(proposal.document.canvases.get(canvas), previous.acknowledged.canvases.get(canvas)) || !equal([...proposal.document.elements.values()].filter(element => element.ownerCanvasId === canvas), [...previous.acknowledged.elements.values()].filter(element => element.ownerCanvasId === canvas))) throw fault('canonical_scope_mismatch', [canvas]);
} }
ledger.result(request.id, 'applied'); ledger.result(request.id, 'applied');
// 先把回执模型复制为独立 ACK,再只重放仍未解决的本地意图,保留用户随后做出的编辑。 // 在私有 ledger 中先解决本次请求,再从回执模型捕获 ACK,重放其它未决意图得到 working。
// 本地后续编辑不会因为服务端返回了较早模型而丢失;ACK、working、ledger 直到 reconcile 才一起公开。
const acknowledged = createDocument(proposal.document.projectId, [...proposal.document.canvases.values()], [...proposal.document.elements.values()], proposal.document.tombstones); const acknowledged = createDocument(proposal.document.projectId, [...proposal.document.canvases.values()], [...proposal.document.elements.values()], proposal.document.tombstones);
const document = replayPending(acknowledged, ledger.getSnapshot(), options.registry); const document = replayPending(acknowledged, ledger.getSnapshot(), options.registry);
conflicts.delete(request.id); conflicts.delete(request.id);
@@ -600,14 +653,14 @@ export async function openProjectEngine(options: EngineOptions) {
if(compact!==status.canonical){const owned=freeze(clone(compact));if(!validJson(owned))throw fault('invalid_receipt_compaction');resolvedReceipts=new Map(receipts);resolvedReceipts.set(request.id,owned);} if(compact!==status.canonical){const owned=freeze(clone(compact));if(!validJson(owned))throw fault('invalid_receipt_compaction');resolvedReceipts=new Map(receipts);resolvedReceipts.set(request.id,owned);}
}catch(error){report(error);} }catch(error){report(error);}
} }
// Keep the original map intact for a rejected final publication below. // receipts 原表保留原回执;resolvedReceipts 只供成功候选使用,最终 reconcile 拒绝时仍回到原证据。
const runtime: RuntimeState = Object.freeze({ ...previous, runs: readMap(runs), ledger: ledger.getSnapshot(), acknowledged, versions: appliedVersions, receipts: readMap(resolvedReceipts), conflicts: readMap(conflicts) }); const runtime: RuntimeState = Object.freeze({ ...previous, runs: readMap(runs), ledger: ledger.getSnapshot(), acknowledged, versions: appliedVersions, receipts: readMap(resolvedReceipts), conflicts: readMap(conflicts) });
// loading v3+ 允许已就绪范围的 ACK 更新;只在这次同步 reconcile 内打开受控发布窗口。 // loading v3+ 允许已就绪范围的 ACK 更新;只在这次同步 reconcile 内打开受控发布窗口。
const oldAccepting=acceptingLoading;acceptingLoading=oldAccepting||(previous.loading?.version??0)>=3;let result:Result;try{result=editor.reconcile(document, runtime, root.rootRevision);}finally{acceptingLoading=oldAccepting;} const oldAccepting=acceptingLoading;acceptingLoading=oldAccepting||(previous.loading?.version??0)>=3;let result:Result;try{result=editor.reconcile(document, runtime, root.rootRevision);}finally{acceptingLoading=oldAccepting;}
if (result.status === 'rejected') throw fault(result.code, result.targets); if (result.status === 'rejected') throw fault(result.code, result.targets);
if(action)queueMicrotask(()=>runTracker.notify([action.runId])); if(action)queueMicrotask(()=>runTracker.notify([action.runId]));
} catch (error) { } catch (error) {
// Preserve both the known receipt and the user's working document. No rollback or lane release. // 失败候选的 ACK/working/runs/压缩回执均不发布,另用旧 runtime 保存 applied-but-unreconciled 及原回执。
// 从旧账本另起失败分支,避免候选 ledger 中的成功推进泄漏;保留参与画布占用等待恢复。 // 从旧账本另起失败分支,避免候选 ledger 中的成功推进泄漏;保留参与画布占用等待恢复。
const waiting = createLedger(previous.ledger); waiting.result(request.id, 'applied', (error as Error).message, false); const waiting = createLedger(previous.ledger); waiting.result(request.id, 'applied', (error as Error).message, false);
conflicts.set(request.id, (error as Error).message || 'reconcile_conflict'); conflicts.set(request.id, (error as Error).message || 'reconcile_conflict');
@@ -617,10 +670,12 @@ export async function openProjectEngine(options: EngineOptions) {
} }
} }
// scheduler 只挑选和执行账本事实;注入的 persistThrough 保证请求身份及前置根先可靠保存。 // scheduler 只挑选和执行账本事实;注入的 persistThrough 保证请求身份及前置根先可靠保存。
// admit 另检查导入 seal、加载覆盖和加载完成保存屏障;v1/2 加载的整体暂停由下方 hold 实现。 // admit 另检查导入 seal、加载覆盖和加载完成保存屏障;v1/2 分支返回 true 不代表能发送,整体 hold 在下方。
// admit 是额外范围门禁,依赖、冻结/不确定结果、writer 占用及 durableGeneration 仍由 scheduler 自己校验。
const scheduler = createScheduler({ admit:intent=>{const root=editor.getSnapshot();if(!importsMaySend(root.runtime.imports,durableImportSeals))return false;if(root.runtime.loading&&root.runtime.loading.version<3)return true;if(closing||closed||loadingCompletionRevision!==undefined&&durableRootRevision<loadingCompletionRevision)return false;return (root.runtime.loading?.version===6&&intent.operationType==='asset.upload'||loadingCanSend(root.runtime,operationCanvases(intent,root.document),root.document))&&(!root.runtime.loading||durableRootRevision>=root.rootRevision);},getLedger: () => editor.getSnapshot().runtime.ledger, mutate, persist:persistThrough, const scheduler = createScheduler({ admit:intent=>{const root=editor.getSnapshot();if(!importsMaySend(root.runtime.imports,durableImportSeals))return false;if(root.runtime.loading&&root.runtime.loading.version<3)return true;if(closing||closed||loadingCompletionRevision!==undefined&&durableRootRevision<loadingCompletionRevision)return false;return (root.runtime.loading?.version===6&&intent.operationType==='asset.upload'||loadingCanSend(root.runtime,operationCanvases(intent,root.document),root.document))&&(!root.runtime.loading||durableRootRevision>=root.rootRevision);},getLedger: () => editor.getSnapshot().runtime.ledger, mutate, persist:persistThrough,
createId: options.createId ?? (() => crypto.randomUUID()), createId: options.createId ?? (() => crypto.randomUUID()),
/** 计算实际写入画布,自动尺寸补写还需验证 historyEffect 的版本与结构。 */ /** 沿用非空 intent.canvasIds,否则按变化和当前 working 推导参与画布;尺寸补写先验证 historyEffect。
* 这里只计算写入参与者,不分配锁;writer claim 与冻结 expectedVersions 在 scheduler/ledger 中建立。 */
participants: intent => { if(intent.historyEffect)validateCreationSizeEffect(intent.historyEffect);return operationCanvases(intent, editor.getSnapshot().document); }, participants: intent => { if(intent.historyEffect)validateCreationSizeEffect(intent.historyEffect);return operationCanvases(intent, editor.getSnapshot().document); },
/** 按操作种类生成请求描述。调度器负责随后冻结身份/请求体;这里不发网络请求。 */ /** 按操作种类生成请求描述。调度器负责随后冻结身份/请求体;这里不发网络请求。 */
compile(intent, id) { compile(intent, id) {
@@ -643,7 +698,8 @@ export async function openProjectEngine(options: EngineOptions) {
// 批量编译是端口的显式能力,缺少时不假设协议支持多操作请求。 // 批量编译是端口的显式能力,缺少时不假设协议支持多操作请求。
compileBatch: options.persistence.compileBatch ? (intents, id) => options.persistence.compileBatch!(intents, id, editor.getSnapshot()) : undefined, compileBatch: options.persistence.compileBatch ? (intents, id) => options.persistence.compileBatch!(intents, id, editor.getSnapshot()) : undefined,
/** 所有服务共用网络容量;上传先从可靠本地存储取字节并核对冻结长度/摘要。 /** 所有服务共用网络容量;上传先从可靠本地存储取字节并核对冻结长度/摘要。
* 不能用 fileWrites 里的尚未保存文件绕过持久化屏障,也不为重试重新生成请求身份。 */ * 不能用 fileWrites 里的尚未保存文件绕过持久化屏障,也不为重试重新生成请求身份。
* 上传的本地读取/摘要发生在 network.run 之前,不占远端网络槽;只把原字节交给 assets.upload。 */
async submit(request, signal) { async submit(request, signal) {
if (['run.start','run.cancel','run.apply'].includes(request.kind??'')) return options.runs ? network.run(signal, () => options.runs!.submit(request,signal)) : { outcome: 'unresolved', blocked: true, error: 'run_interpreter_unavailable' }; if (['run.start','run.cancel','run.apply'].includes(request.kind??'')) return options.runs ? network.run(signal, () => options.runs!.submit(request,signal)) : { outcome: 'unresolved', blocked: true, error: 'run_interpreter_unavailable' };
if (request.kind !== 'asset.upload') return network.run(signal, () => options.persistence.submit(request, signal)); if (request.kind !== 'asset.upload') return network.run(signal, () => options.persistence.submit(request, signal));
@@ -661,7 +717,9 @@ export async function openProjectEngine(options: EngineOptions) {
if(initial.loading&&initial.loading.version<3)loadingRelease=scheduler.hold(); if(initial.loading&&initial.loading.version<3)loadingRelease=scheduler.hold();
/** 把完整读取合入渐进模型。fresh 表示新的权威读取;否则是当前来源流的最终补全。 /** 把完整读取合入渐进模型。fresh 表示新的权威读取;否则是当前来源流的最终补全。
* 对照来源令牌、版本和所有权证据校验,再合成 ACK 并重放本地意图,一次发布退出 loading。 * 对照来源令牌、版本和所有权证据校验,再合成 ACK 并重放本地意图,一次发布退出 loading。
* 这里只记录完成 revision,真正解除发送屏障要等 writePrefix 确认该根可靠保存。 */ * 这里只记录完成 revision,真正解除发送屏障要等 writePrefix 确认该根可靠保存。
* 完成流读取与 fresh 刷新走不同证据:前者可合并加载期间 ACK,后者须先排除未确定请求。
* 函数返回领域 Result;异常可登记 loading 冲突,但不把失败的候选部分合入 working。 */
function completeLoading(document:Document,versions:ReadonlyMap<Id,number>,settings:{fresh?:boolean;sourceToken?:string}={}):Result{ function completeLoading(document:Document,versions:ReadonlyMap<Id,number>,settings:{fresh?:boolean;sourceToken?:string}={}):Result{
try{assertWritable(false);const root=editor.getSnapshot(),previous=root.runtime;if(!previous.loading)throw fault('loading_not_active'); try{assertWritable(false);const root=editor.getSnapshot(),previous=root.runtime;if(!previous.loading)throw fault('loading_not_active');
// 新权威读取不能越过结果未确认的请求,否则无法区分读取包含了哪些本地写入。 // 新权威读取不能越过结果未确认的请求,否则无法区分读取包含了哪些本地写入。
@@ -686,7 +744,9 @@ export async function openProjectEngine(options: EngineOptions) {
if(result.status==='rejected')throw fault(result.code,result.targets); if(result.status==='rejected')throw fault(result.code,result.targets);
} }
/** 协调一页生成事件:校验任务/游标/控制代次,依次采用新的模型事件,最后与游标一起发布。 /** 协调一页生成事件:校验任务/游标/控制代次,依次采用新的模型事件,最后与游标一起发布。
* 有未确认写入或模型冲突时保留 pendingPage 和输出预览,working、ACK 与游标保持原值。 */ * 有未确认写入或模型冲突时保留 pendingPage 和输出预览,working、ACK 与游标保持原值。
* 依赖 RunPort.poll 保证页协议与事件顺序;此协调器不重复完整解析所有 RunPage 字段。
* 页内模型在私有 acknowledged/versions 上依次累积,最后一次 reconcile;不逐事件发布中间文档。 */
function acceptRunPage(run: RunRecord, page: RunPage) { function acceptRunPage(run: RunRecord, page: RunPage) {
const root=editor.getSnapshot(),previous=root.runtime,current=previous.runs.get(run.id); const root=editor.getSnapshot(),previous=root.runtime,current=previous.runs.get(run.id);
if(!current || current.requestId!==run.requestId)throw fault('run_identity_mismatch'); if(!current || current.requestId!==run.requestId)throw fault('run_identity_mismatch');
@@ -704,7 +764,7 @@ export async function openProjectEngine(options: EngineOptions) {
if(event.seq<=current.cursor || event.type!=='step.applied'&&!(event.type==='step.output'&&event.model&&typeof event.model==='object'&&!Array.isArray(event.model)&&event.model.complete===true))continue; if(event.seq<=current.cursor || event.type!=='step.applied'&&!(event.type==='step.output'&&event.model&&typeof event.model==='object'&&!Array.isArray(event.model)&&event.model.complete===true))continue;
const canvas=event.board_id as Id,version=event.canvas_version as number; const canvas=event.board_id as Id,version=event.canvas_version as number;
if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_run_response'); if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_run_response');
// A later document receipt already includes this event; never rewind its participant. // 本地 ACK 版本已包含该事件就跳过,避免旧模型倒退;其余事件仍要按本页顺序继续协调。
if(version<=(versions.get(canvas)??0))continue; if(version<=(versions.get(canvas)??0))continue;
// 完整上下文可能涉及多画布,所有参与者的未决请求都需先确认,不只检查事件主画布。 // 完整上下文可能涉及多画布,所有参与者的未决请求都需先确认,不只检查事件主画布。
const model=event.model as {[key:string]:Json}|undefined,context=model?.complete===true,participants=context?Object.keys(model.versions as {[key:string]:Json}):[canvas]; const model=event.model as {[key:string]:Json}|undefined,context=model?.complete===true,participants=context?Object.keys(model.versions as {[key:string]:Json}):[canvas];
@@ -713,7 +773,8 @@ export async function openProjectEngine(options: EngineOptions) {
// 同页前一个事件已更新 ACK/版本,后一个事件的解释应看到该临时累计结果。 // 同页前一个事件已更新 ACK/版本,后一个事件的解释应看到该临时累计结果。
const snapshot={...root,runtime:{...previous,acknowledged,versions}}; const snapshot={...root,runtime:{...previous,acknowledged,versions}};
const proposal=options.runs.canonical(event,snapshot); const proposal=options.runs.canonical(event,snapshot);
// 本地尚有几何/路径编辑时,服务端变更元素归属会改变其坐标语义,拒绝盲目重放。 // 额外检查未决 field geometry/path 目标的 owner 变化,避免将旧坐标语义重放到新分区。
// 此局部检查不宣称涵盖任意复合结构冲突;其它 before/owner/schema 条件仍由 replayPending 校验。
for(const record of previous.ledger.operations.values())if(record.phase!=='resolved')for(const change of record.intent.changes)if(change.kind==='field'&&(change.field==='geometry'||change.field==='path')){const before=acknowledged.elements.get(change.target.id),after=proposal.document.elements.get(change.target.id);if(before&&after&&before.ownerCanvasId!==after.ownerCanvasId)throw fault('ownership_conflict',[change.target.id]);} for(const record of previous.ledger.operations.values())if(record.phase!=='resolved')for(const change of record.intent.changes)if(change.kind==='field'&&(change.field==='geometry'||change.field==='path')){const before=acknowledged.elements.get(change.target.id),after=proposal.document.elements.get(change.target.id);if(before&&after&&before.ownerCanvasId!==after.ownerCanvasId)throw fault('ownership_conflict',[change.target.id]);}
acknowledged=proposal.document;versions=proposal.versions; acknowledged=proposal.document;versions=proposal.versions;
} }
@@ -728,7 +789,8 @@ export async function openProjectEngine(options: EngineOptions) {
} catch(error) { } catch(error) {
// 等待写入回执属于可自动再试的协调条件;其它错误阻塞任务并登记冲突。 // 等待写入回执属于可自动再试的协调条件;其它错误阻塞任务并登记冲突。
const code=(error as Error).message; const code=(error as Error).message;
// Save the page as evidence without overwriting local content or advancing the cursor. // 整页保存为 pendingPage,同时保留可预览输出;不采用 try 中的临时 ACK/版本,也不推进 cursor。
// 仅 run_waiting_for_receipts 可自动轮询重试,协议/合并等其它错误登记 run 冲突并 blocked。
const result=editor.updateRuntime(runtime=>{const runs=new Map(runtime.runs);runs.set(run.id,freeze({...current,outputs:page.outputs,outputEvents:[...outputEvents.values()],steps:page.steps,pendingPage:page,error:code,blocked:code!=='run_waiting_for_receipts'}));if(code!=='run_waiting_for_receipts')conflicts.set(`run:${run.id}`,code);return Object.freeze({...runtime,runs:readMap(runs),conflicts:readMap(conflicts)});},root.rootRevision); const result=editor.updateRuntime(runtime=>{const runs=new Map(runtime.runs);runs.set(run.id,freeze({...current,outputs:page.outputs,outputEvents:[...outputEvents.values()],steps:page.steps,pendingPage:page,error:code,blocked:code!=='run_waiting_for_receipts'}));if(code!=='run_waiting_for_receipts')conflicts.set(`run:${run.id}`,code);return Object.freeze({...runtime,runs:readMap(runs),conflicts:readMap(conflicts)});},root.rootRevision);
if(result.status==='rejected')throw fault(result.code,result.targets); if(result.status==='rejected')throw fault(result.code,result.targets);
if(code!=='run_waiting_for_receipts')report(error); if(code!=='run_waiting_for_receipts')report(error);
@@ -743,7 +805,9 @@ export async function openProjectEngine(options: EngineOptions) {
const sessions = createSessions(editor); const sessions = createSessions(editor);
// 手势结束后重试默认尺寸补写,避免解码结果在用户拖动/缩放中途抢改几何。 // 手势结束后重试默认尺寸补写,避免解码结果在用户拖动/缩放中途抢改几何。
const unsubscribeSizing = sessions.subscribe(() => { if (!sessions.getSnapshot().gesture) queueDefaultSizes(); }); const unsubscribeSizing = sessions.subscribe(() => { if (!sessions.getSnapshot().gesture) queueDefaultSizes(); });
/** 每次成功发布后登记保存义务,再安排后台任务;通知只传现有操作 ID,不另造一份操作状态。 */ /** 每次成功发布先把完整 snapshot 按 rootRevision 加入 pending,再安排保存/后台发现微任务。
* scheduled 只合并保存触发,不丢中间根;任务通知在监听回调返回后执行,避免在发布栈里发 I/O。
* 订阅不创建第二份业务任务:operationIds/runs 均已在本次根中,后续只建立执行索引。 */
const unsubscribe = editor.subscribe((commit, snapshot) => { const unsubscribe = editor.subscribe((commit, snapshot) => {
pending.set(snapshot.rootRevision, snapshot);notifyDurability(); pending.set(snapshot.rootRevision, snapshot);notifyDurability();
// 新受理任务在后续微任务启动追尾,让本次根发布和监听通知先完成。 // 新受理任务在后续微任务启动追尾,让本次根发布和监听通知先完成。
@@ -759,7 +823,9 @@ export async function openProjectEngine(options: EngineOptions) {
/** 统一摘要入口,继承 sourceDigest 的注入选择与项目生命周期中止。 */ /** 统一摘要入口,继承 sourceDigest 的注入选择与项目生命周期中止。 */
async function digest(bytes:Blob,signal?:AbortSignal){return sourceDigest(bytes,signal);} async function digest(bytes:Blob,signal?:AbortSignal){return sourceDigest(bytes,signal);}
/** 最多并发两个原文件摘要。只发现 waiting_digest 的上传事实,成功原子写摘要并解除阻塞。 /** 最多并发两个原文件摘要。只发现 waiting_digest 的上传事实,成功原子写摘要并解除阻塞。
* 失败保留可识别原因并退出自动发现条件,等待显式 retry,而非无限计算同一损坏文件。 */ * 失败保留可识别原因并退出自动发现条件,等待显式 retry,而非无限计算同一损坏文件。
* digestTasks 只是本项目两项并发的索引;共享 digest pool 可能再排队,摘要可先于本地 Blob 落盘完成。
* hash 与解除 waiting_digest 同根发布,通知 scheduler 后仍须过保存门禁才能上传。 */
function prepareDigests() { function prepareDigests() {
if (closing || closed) return; if (closing || closed) return;
for (const resource of editor.getSnapshot().runtime.resources.values()) { for (const resource of editor.getSnapshot().runtime.resources.values()) {
@@ -782,20 +848,24 @@ export async function openProjectEngine(options: EngineOptions) {
return Object.freeze({ ...previous, resources: readMap(resources), ledger: ledger.getSnapshot() }); return Object.freeze({ ...previous, resources: readMap(resources), ledger: ledger.getSnapshot() });
}); });
if (result.status === 'rejected') throw fault(result.code, result.targets); if (result.status === 'rejected') throw fault(result.code, result.targets);
// 这里只通知已有上传操作变得可调度,不直接发送字节或生成新的 requestId。
scheduler.notify([resource.operationId]); scheduler.notify([resource.operationId]);
}).catch(error => { if (!closing && !closed) { const code=(error as {code?:string}).code;mutate(ledger => ledger.block(resource.operationId, [code&&digestFailures.has(code)?code:'resource_bytes_unavailable'])); report(error); } }).finally(() => { digestTasks.delete(resource.id); prepareDigests(); }); }).catch(error => { if (!closing && !closed) { const code=(error as {code?:string}).code;mutate(ledger => ledger.block(resource.operationId, [code&&digestFailures.has(code)?code:'resource_bytes_unavailable'])); report(error); } }).finally(() => { digestTasks.delete(resource.id); prepareDigests(); });
digestTasks.set(resource.id, task); digestTasks.set(resource.id, task);
} }
} }
/** 并发两项图片尺寸准备,与上传摘要分别调度。优先解码本地原件,无字节时可用已存服务端尺寸。 /** 并发两项图片尺寸准备,与上传摘要分别调度。优先解码本地原件,无字节时可用已存服务端尺寸。
* image 状态先写成 pending,完成时只更新仍指向同一 image 记录的资源,失败不会覆盖后来重试。 */ * image 状态先发布为 pending,完成时核 operationId 与原 image 对象身份,失败也不覆盖后来重试的记录。
* 有 options.images 才启动本函数的解码链;其缺省时 stored 尺寸由 prepareDefaultSizes 直接采用。
* imageTasks 在 finally 清理并继续发现下一项,任务完成不代表默认几何或上传已经完成。 */
function prepareImages(){ function prepareImages(){
queueDefaultSizes(); queueDefaultSizes();
if(closing||closed||recovery||!options.images)return; if(closing||closed||recovery||!options.images)return;
for(const resource of editor.getSnapshot().runtime.resources.values()){ for(const resource of editor.getSnapshot().runtime.resources.values()){
if(imageTasks.size>=2)break; if(imageTasks.size>=2)break;
if(imageTasks.has(resource.id)||resource.image&&(resource.image.version!==1||resource.image.status!=='pending')||resource.retirement?.purgedAt!==undefined)continue; if(imageTasks.has(resource.id)||resource.image&&(resource.image.version!==1||resource.image.status!=='pending')||resource.retirement?.purgedAt!==undefined)continue;
// 先发布可恢复 pending,再由发布通知进入实际解码,避免隐形在途任务没有状态依据。 // 缺 image 时本轮只发布 pending 并返回,由根订阅再次发现任务;不在同一发布栈里启动解码。
// “可恢复”表示根中已有任务声明,可靠性仍需后续 persist,不要求解码开始前单独等磁盘回执。
if(!resource.image){editor.updateRuntime(previous=>{const current=previous.resources.get(resource.id);if(!current||current.image)return previous;const resources=new Map(previous.resources);resources.set(resource.id,freeze({...current,image:{version:1 as const,status:'pending' as const}}));return Object.freeze({...previous,resources:readMap(resources)});});return;} if(!resource.image){editor.updateRuntime(previous=>{const current=previous.resources.get(resource.id);if(!current||current.image)return previous;const resources=new Map(previous.resources);resources.set(resource.id,freeze({...current,image:{version:1 as const,status:'pending' as const}}));return Object.freeze({...previous,resources:readMap(resources)});});return;}
const task=(async()=>{ const task=(async()=>{
const bytes=fileWrites.get(resource.id)??await options.storage.readFile(leader,resource.id); const bytes=fileWrites.get(resource.id)??await options.storage.readFile(leader,resource.id);
@@ -809,7 +879,8 @@ export async function openProjectEngine(options: EngineOptions) {
})().catch(error=>{if(closing||closed||recovery)return;const code=(error as{code?:string}).code??'image_decode_failed';editor.updateRuntime(previous=>{const current=previous.resources.get(resource.id);if(!current||current.operationId!==resource.operationId||current.image!==resource.image)return previous;const resources=new Map(previous.resources);resources.set(resource.id,freeze({...current,image:{version:1 as const,status:'failed' as const,code}}));return Object.freeze({...previous,resources:readMap(resources)});});}).finally(()=>{imageTasks.delete(resource.id);prepareImages();});imageTasks.set(resource.id,task); })().catch(error=>{if(closing||closed||recovery)return;const code=(error as{code?:string}).code??'image_decode_failed';editor.updateRuntime(previous=>{const current=previous.resources.get(resource.id);if(!current||current.operationId!==resource.operationId||current.image!==resource.image)return previous;const resources=new Map(previous.resources);resources.set(resource.id,freeze({...current,image:{version:1 as const,status:'failed' as const,code}}));return Object.freeze({...previous,resources:readMap(resources)});});}).finally(()=>{imageTasks.delete(resource.id);prepareImages();});imageTasks.set(resource.id,task);
} }
} }
/** 将多个尺寸补写通知合并为一次宏任务;不是网络重试时刻,也不忙等尺寸解码。 */ /** 将多个尺寸补写通知合并为一个0ms timer;只要有 pending grant 就可排队,不保证尺寸现在已可用。
* 定时器执行前先清 sizingTask,允许本轮发布触发后续 grant 的新检查;Promise 供关闭流程等待。 */
function queueDefaultSizes() { function queueDefaultSizes() {
if (sizingTask || closing || closed || recovery || ![...editor.getSnapshot().runtime.resources.values()].some(resource => resource.sizing?.version === 1 && resource.sizing.grants.some(grant => grant.status === 'pending'))) return; if (sizingTask || closing || closed || recovery || ![...editor.getSnapshot().runtime.resources.values()].some(resource => resource.sizing?.version === 1 && resource.sizing.grants.some(grant => grant.status === 'pending'))) return;
// A scheduling yield, not a logical retry deadline: avoid a self-extending // A scheduling yield, not a logical retry deadline: avoid a self-extending
@@ -817,7 +888,9 @@ export async function openProjectEngine(options: EngineOptions) {
sizingTask = new Promise(resolve => { setTimeout(() => { sizingTask = undefined; try { prepareDefaultSizes(); } catch (error) { report(error); } finally { resolve(); } }, 0); }); sizingTask = new Promise(resolve => { setTimeout(() => { sizingTask = undefined; try { prepareDefaultSizes(); } catch (error) { report(error); } finally { resolve(); } }, 0); });
} }
/** 一次最多处理一项创建尺寸授权。仅在无手势/无恢复时,把原始比例适配到当前宽度和单位约束。 /** 一次最多处理一项创建尺寸授权。仅在无手势/无恢复时,把原始比例适配到当前宽度和单位约束。
* resolveCreationSize 验证目标代次与创建历史,补写同一历史单元;不能改写任意已编辑对象。 */ * resolveCreationSize 验证目标代次与创建历史,补写同一历史单元;不能改写任意已编辑对象。
* 尺寸已知且目标为 node 才尝试:保留当前宽度/图片比例,按单位尺度约束双边,再转为接口单位。
* 成功的 historyEffect 在 reduceRuntime 中结清 grant;拒绝或无净变化则在下面显式结清,避免反复空转。 */
function prepareDefaultSizes() { function prepareDefaultSizes() {
if (closing || closed || recovery || sessions.getSnapshot().gesture) return; if (closing || closed || recovery || sessions.getSnapshot().gesture) return;
const root = editor.getSnapshot(); const root = editor.getSnapshot();
@@ -847,11 +920,13 @@ export async function openProjectEngine(options: EngineOptions) {
}); });
if (result.status === 'rejected') report(fault(result.code, result.targets)); if (result.status === 'rejected') report(fault(result.code, result.targets));
} }
return; // One publication per pump; its notification schedules further grants. // 每轮只尝试一项可处理 grant;可能还有结清状态的派生发布,后续项由根/手势/任务通知再次触发。
return;
} }
} }
} }
/** 显式重试把 blocked grant 恢复为 pending,不改已应用项;重新排一次尺寸检查。 */ /** 显式重试把 blocked grant 恢复为 pending 并清错误;不复活 revoked,不修改 applied 项。
* 只重排尺寸检查,不重新上传/解码文件;入口调用者负责生命周期准入。 */
function retryDefaultSizes() { function retryDefaultSizes() {
editor.updateRuntime(previous => { editor.updateRuntime(previous => {
const resources = new Map(previous.resources); let changed = false; const resources = new Map(previous.resources); let changed = false;
@@ -864,7 +939,8 @@ export async function openProjectEngine(options: EngineOptions) {
} }
/** 将没有文档 changes 的取消/应用动作登记为逻辑操作。调用方先做业务校验, /** 将没有文档 changes 的取消/应用动作登记为逻辑操作。调用方先做业务校验,
* apply 等待所有旧未决操作;cancel 不添加这些依赖,以便及时控制服务端任务。 * apply 等待所有旧未决操作;cancel 不添加这些依赖,以便及时控制服务端任务。
* 只更新账本,返回的 revision 仍是当前文档 revision,发送由后续调度完成。 */ * 只更新 ledger/runtime,产生新 rootRevision 而文档 revision 保持原值;返回 applied 表示本地接纳。
* id/sequence 只分配一次,依赖冻结在意图里;发送/查询/服务回执由后续 scheduler 使用相同身份处理。 */
function queueRunAction(operationType:'run.cancel'|'run.apply',action:RunCancelInput|RunApplyInput):Result { function queueRunAction(operationType:'run.cancel'|'run.apply',action:RunCancelInput|RunApplyInput):Result {
const root=editor.getSnapshot();let sequence=1;for(const record of root.runtime.ledger.operations.values())sequence=Math.max(sequence,record.intent.sequence+1); const root=editor.getSnapshot();let sequence=1;for(const record of root.runtime.ledger.operations.values())sequence=Math.max(sequence,record.intent.sequence+1);
const id=createId(),intent={id,operationType,action,operationSchemaVersion:1,sequence,origin:'user',canvasIds:operationType==='run.apply'?[(action as RunApplyInput).targetCanvasId]:[],changes:[],dependencies:operationType==='run.cancel'?[]:[...root.runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id)} as OperationIntent; const id=createId(),intent={id,operationType,action,operationSchemaVersion:1,sequence,origin:'user',canvasIds:operationType==='run.apply'?[(action as RunApplyInput).targetCanvasId]:[],changes:[],dependencies:operationType==='run.cancel'?[]:[...root.runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id)} as OperationIntent;
@@ -876,13 +952,16 @@ export async function openProjectEngine(options: EngineOptions) {
/** 从唯一根读取当前作业,不以 runner 捕获的旧对象代替可恢复事实。 */ /** 从唯一根读取当前作业,不以 runner 捕获的旧对象代替可恢复事实。 */
function getImport(id:Id){const job=editor.getSnapshot().runtime.imports?.get(id);if(!job)throw fault('import_missing',[id]);return job;} function getImport(id:Id){const job=editor.getSnapshot().runtime.imports?.get(id);if(!job)throw fault('import_missing',[id]);return job;}
/** 仅发布作业元数据;generation 乐观校验防止旧异步结果覆盖暂停/取消/替换后的作业。 /** 仅发布作业元数据;generation 乐观校验防止旧异步结果覆盖暂停/取消/替换后的作业。
* integrityOwner 只在本次同步更新内允许该 job 推进校验信息,finally 撤销权限。 */ * integrityOwner 只在本次同步更新内允许该 job 推进校验信息,finally 撤销权限。
* 此内部入口再查宿主 changes.apply 策略,原样采用 job(旧版仅升外层 version);不是任意外来 job 的完整捕获器。
* 不在此 persist 或调整传入 generation,调用方须先构造正确候选并等待所需可靠屏障。 */
function publishImport(job:ImportJob,expectedGeneration:number){ function publishImport(job:ImportJob,expectedGeneration:number){
assertImportWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets); 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;} 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;}
} }
/** 为新建检查点声明各层记录容器的所有权,便于增量恢复编码复用。 /** 为新建检查点声明各层记录容器的所有权,便于增量恢复编码复用。
* dataRecord/dataArray 不代表所有子数据也归这里所有,不跳过后续结构与内容校验。 */ * dataRecord/dataArray 不代表所有子数据也归这里所有,不跳过后续结构与内容校验。
* 仅重建 job/source/preparation 外壳并标记 allocationIds 容器,嵌套只读值可复用,未进行 JSON 深复制。 */
function importCheckpoint(job:ImportJob):ImportJob { function importCheckpoint(job:ImportJob):ImportJob {
return dataRecord(freeze({...job, return dataRecord(freeze({...job,
...(job.source?{source:dataRecord(freeze({...job.source}))}:{}), ...(job.source?{source:dataRecord(freeze({...job.source}))}:{}),
@@ -890,7 +969,8 @@ export async function openProjectEngine(options: EngineOptions) {
})); }));
} }
/** 将当前分块读取证明附入检查点;SHA/校验错误以根中最新值为准, /** 将当前分块读取证明附入检查点;SHA/校验错误以根中最新值为准,
* 防止前台解析的旧 job 覆盖后台全文件验证刚发布的结果。 */ * 防止前台解析的旧 job 覆盖后台全文件验证刚发布的结果。
* context.reader 提供当前读证明;源 fileId 必须对应该 context,本函数不读字节或自行生成 SHA。 */
function checkpointImportSource(job:ImportJob):ImportJob{ function checkpointImportSource(job:ImportJob):ImportJob{
if(job.source?.integrityVersion!==2)return job; if(job.source?.integrityVersion!==2)return job;
const context=proofReaders.get(job.id),current=editor.getSnapshot().runtime.imports?.get(job.id)?.source; const context=proofReaders.get(job.id),current=editor.getSnapshot().runtime.imports?.get(job.id)?.source;
@@ -899,19 +979,23 @@ export async function openProjectEngine(options: EngineOptions) {
return importCheckpoint({...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}:{})}});
} }
/** 文件校验失败增加 generation,保留已取消/部分完成状态,其余转为暂停。 /** 文件校验失败增加 generation,保留已取消/部分完成状态,其余转为暂停。
* 错误写入源证据,既有已提交内容保留;短暂 hold 触发 runner 的暂停边界,不持有永久暂停租约。 */ * 错误写入 source.integrityError 与 job.error,阻止封存/发送,既有已提交内容不回滚。
* 短暂 hold 请求中止 runner 旧 pump 后立即释放;job 已 paused(或保留终态),不会因此自动续跑。 */
function failImportVerification(job:ImportJob,error:unknown){ function failImportVerification(job:ImportJob,error:unknown){
const code=(error as {code?:string}).code??'import_source_verification_failed'; const code=(error as {code?:string}).code??'import_source_verification_failed';
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); 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); const release=importRunners.get(job.id)?.hold();release?.();report(error);
} }
/** 每 job 单飞的后台完整性校验:先核对已有分块证明,再计算整文件 SHA。 /** 每 job 单飞的后台完整性校验:先核对已有分块证明,再计算整文件 SHA。
* 中止/关闭/全局 hold 时不把取消当损坏;真正失败记录证据,成功保存 seal 后才允许相应同步。 */ * 中止/关闭/全局 hold 时不把取消当损坏;真正失败记录证据,成功保存 seal 后才允许相应同步。
* 工作基于固定 Blob/reader,采纳时查来源身份而非要求 generation 不变,允许解析进度同期正常前进。
* 成功封存沿用当前 job.generation,只替换完整性证据;publish 的授权窗口与 durableImportSeals 保存门仍生效。 */
function verifyImportSource(id:Id,context:{fileId:Id;bytes:Blob;reader:ReturnType<typeof createImportReadProof>}):Promise<void>{ function verifyImportSource(id:Id,context:{fileId:Id;bytes:Blob;reader:ReturnType<typeof createImportReadProof>}):Promise<void>{
// 已中止的旧实例要先结束,再确认上下文仍有效才重建;不能并行验证两个同名来源。 // 已中止的旧实例要先结束,再确认上下文仍有效才重建;不能并行验证两个同名来源。
const active=verificationTasks.get(id);if(active)return active.controller.signal.aborted?active.promise.then(()=>{if(!closing&&!closed&&!importHolds.size&&proofReaders.get(id)===context&&sourceNeedsSeal(getImport(id)))return verifyImportSource(id,context);}):active.promise; const active=verificationTasks.get(id);if(active)return active.controller.signal.aborted?active.promise.then(()=>{if(!closing&&!closed&&!importHolds.size&&proofReaders.get(id)===context&&sourceNeedsSeal(getImport(id)))return verifyImportSource(id,context);}):active.promise;
const admitted=getImport(id),controller=new AbortController(),signal=AbortSignal.any([controller.signal,digestLifecycle.signal]); const admitted=getImport(id),controller=new AbortController(),signal=AbortSignal.any([controller.signal,digestLifecycle.signal]);
/** 每次跨 await 后按 fingerprint、fileId、reader 身份复查,不把旧文件结果写给新作业。 */ /** 在采纳验证结果/记录错误时复查 fingerprint、fileId、reader 身份及 hold/关闭/取消信号。
* 不比较解析 generation;本函数只取当前事实,不自动发布或锁住整个 job。 */
const current=()=>{signal.throwIfAborted();if(closing||closed||importHolds.size)throw fault('import_aborted');const job=getImport(id);if(job.sourceFingerprint!==admitted.sourceFingerprint||job.source?.fileId!==context.fileId||proofReaders.get(id)!==context)throw fault('import_identity_changed');return job;}; const current=()=>{signal.throwIfAborted();if(closing||closed||importHolds.size)throw fault('import_aborted');const job=getImport(id);if(job.sourceFingerprint!==admitted.sourceFingerprint||job.source?.fileId!==context.fileId||proofReaders.get(id)!==context)throw fault('import_identity_changed');return job;};
const promise=(async()=>{ const promise=(async()=>{
let sha256:string; let sha256:string;
@@ -923,7 +1007,8 @@ export async function openProjectEngine(options: EngineOptions) {
})().catch(error=>{if(!signal.aborted&&!closing&&!closed)report(error);}).finally(()=>{if(verificationTasks.get(id)?.controller===controller)verificationTasks.delete(id);}); })().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; verificationTasks.set(id,{controller,promise});return promise;
} }
/** 重建读取器前先中止并等验证结束、关闭 runner,最后释放分块证明缓存;持久作业不删除。 */ /** 重建读取器前先请求中止并等待验证结束,再 close runner,最后释放 reader 缓存。
* 删除的是 verification/runner/proof 执行索引,不删持久 job、源 Blob、已加入内容或已存 readProof。 */
async function resetImportReader(id:Id){ async function resetImportReader(id:Id){
const active=verificationTasks.get(id);active?.controller.abort();if(active)await active.promise; const active=verificationTasks.get(id);active?.controller.abort();if(active)await active.promise;
const runner=importRunners.get(id);if(runner){await runner.close();importRunners.delete(id);} const runner=importRunners.get(id);if(runner){await runner.close();importRunners.delete(id);}
@@ -934,28 +1019,33 @@ export async function openProjectEngine(options: EngineOptions) {
// 暂停期间被中断、尚未建立 runner 的任务要记下,释放 hold 后才能重新装配。 // 暂停期间被中断、尚未建立 runner 的任务要记下,释放 hold 后才能重新装配。
const deferredImportLoads=new Set<Id>(); const deferredImportLoads=new Set<Id>();
/** 可嵌套全局导入暂停:中止准备/验证,给已装配 runner 各加一个 hold。 /** 可嵌套全局导入暂停:中止准备/验证,给已装配 runner 各加一个 hold。
* 释放只撤销本 token 的租约,再按根中状态启动需要恢复的任务;不把业务 status 改成 cancelled。 */ * 释放只撤销本 token 的租约,再按根中状态启动需要恢复的任务;不把业务 status 改成 cancelled。
* hold 本身同步返回,不等待这些任务退出;runner 的 hold 可嵌套,最后一个租约释放后才允许续跑。 */
function holdImports(){for(const id of importLoads.keys())if(!importRunners.has(id))deferredImportLoads.add(id);const token=Symbol(),releases=new Map<Id,()=>void>();importHolds.set(token,releases);for(const controller of preparationControllers.values())controller.abort();for(const {controller}of verificationTasks.values())controller.abort();for(const[id,runner]of importRunners)releases.set(id,runner.hold());return()=>{if(!importHolds.delete(token))return;for(const release of releases.values())release();if(!closing&&!closed)for(const job of editor.getSnapshot().runtime.imports?.values()??[])if(sourceNeedsSeal(job)||['running','preparing'].includes(job.status)&&!importRunners.has(job.id))void loadImport(job.id);};} function holdImports(){for(const id of importLoads.keys())if(!importRunners.has(id))deferredImportLoads.add(id);const token=Symbol(),releases=new Map<Id,()=>void>();importHolds.set(token,releases);for(const controller of preparationControllers.values())controller.abort();for(const {controller}of verificationTasks.values())controller.abort();for(const[id,runner]of importRunners)releases.set(id,runner.hold());return()=>{if(!importHolds.delete(token))return;for(const release of releases.values())release();if(!closing&&!closed)for(const job of editor.getSnapshot().runtime.imports?.values()??[])if(sourceNeedsSeal(job)||['running','preparing'].includes(job.status)&&!importRunners.has(job.id))void loadImport(job.id);};}
/** 从固定复制快照恢复规划。规划版本决定生成可流式读取的计划,或旧版 NDJSON 文件。 /** 从固定复制快照恢复规划。规划版本决定生成可流式读取的计划,或旧版 NDJSON 文件。
* 期间不断保存 ID 分配/步骤检查点,保证恢复后新元素身份稳定;此阶段还未向画布加入元素。 */ * 按检查点保存 ID 分配/步骤,续跑重用已保存的映射;尚未被 checkpoint 持久化的工作仍可重算。
* 本阶段只发布 preparing/计划/源文件事实,不创建画布元素;实际内容随后由普通导入 runner 逐批加入。 */
async function materializeCopy(id:Id,bytes:Blob,controller:AbortController){ async function materializeCopy(id:Id,bytes:Blob,controller:AbortController){
const admitted=getImport(id),preparation=admitted.preparation; const admitted=getImport(id),preparation=admitted.preparation;
if(!preparation||admitted.source?.kind!=='duplicate-snapshot')throw fault('invalid_copy_preparation'); if(!preparation||admitted.source?.kind!=='duplicate-snapshot')throw fault('invalid_copy_preparation');
/** 复制规划只接受原 fingerprint 且仍 preparing 的作业;关闭、hold、取消均使旧结果失效。 */ /** 要求同 fingerprint 且仍 preparing,另查 signal/关闭/hold;允许本规划器自己的 generation 前进。
* 这不是逐字段验证全部 source 的入口,源文件稳定身份仍由装配与持久任务合同约束。 */
const check=()=>{const job=getImport(id);if(controller.signal.aborted||closing||closed||importHolds.size||job.status!=='preparing'||job.sourceFingerprint!==admitted.sourceFingerprint)throw fault('copy_aborted');return job;}; const check=()=>{const job=getImport(id);if(controller.signal.aborted||closing||closed||importHolds.size||job.status!=='preparing'||job.sourceFingerprint!==admitted.sourceFingerprint)throw fault('copy_aborted');return job;};
// 先保存快照作业再规划;v2 索引快照可按需读取,旧快照走完整解码。 // 先保存快照作业再规划;v2 索引快照可按需读取,旧快照走完整解码。
await persist();check();const indexed=admitted.source.snapshotVersion===2?await openIndexedCopySnapshot(bytes,options.scope.projectId,options.registry,controller.signal):undefined,document=indexed?.document??await decodeCopySnapshot(bytes,options.scope.projectId,options.registry,controller.signal); await persist();check();const indexed=admitted.source.snapshotVersion===2?await openIndexedCopySnapshot(bytes,options.scope.projectId,options.registry,controller.signal):undefined,document=indexed?.document??await decodeCopySnapshot(bytes,options.scope.projectId,options.registry,controller.signal);
// 渐进模型还要证明复制快照对应合法来源范围;类型/复制声明变化则拒绝续用旧规划器。 // 渐进模型还要证明复制快照对应合法来源范围;类型/复制声明变化则拒绝续用旧规划器。
validateLoadingCopySource(editor.getSnapshot(),admitted,document); validateLoadingCopySource(editor.getSnapshot(),admitted,document);
if(copyRegistrySignature(document,options.registry)!==preparation.registrySignature)throw fault('copy_interpreter_changed'); if(copyRegistrySignature(document,options.registry)!==preparation.registrySignature)throw fault('copy_interpreter_changed');
// 新规划器持久化拓扑与稳定 ID,不先展开所有复制数据为另一份 NDJSON。 // v3/v4 准备协议产出 lazy emit 计划;v4 可配 format2 索引快照,避免再展开一份完整复制 NDJSON。
// checkpoint 回调发布向前的步骤并 persist;最终返回的计划仍需下方另行发布,不能只依赖最后一次回调。
if(preparation.version===3||preparation.version===4){ 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 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=>{
// 只采纳向前推进的 planner 检查点,保存完成后再继续规划,避免恢复丢失已分配身份。 // 只采纳向前推进的 planner 检查点,保存完成后再继续规划,避免恢复丢失已分配身份。
const job=check(),previous=job.preparation!;if(previous.planner&&state.steps<=previous.steps)return; const job=check(),previous=job.preparation!;if(previous.planner&&state.steps<=previous.steps)return;
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(); 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); }}),job=check();assertNativeImportTarget(job,editor.getSnapshot().document);
// 将旧 ID → 新 ID 和计划一起发布为 running;移除 preparation 后由普通导入 runner 逐批落图。 // 以 element:/canvas: 命名空间把旧 ID→新 ID 放进 stableIds,与 emit 计划一起转 running,清 preparation。
// 原快照字节继续作读取源,parserVersion 改2表示按计划 ordinal 读取;此发布本身尚不等于已经落盘。
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; 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}; publishImport(importCheckpoint(next),job.generation);return{job:getImport(id),bytes,document,indexed};
} }
@@ -969,21 +1059,25 @@ export async function openProjectEngine(options: EngineOptions) {
if(steps<=previous.steps&&allocationIds.length===previous.allocationIds.length)return; if(steps<=previous.steps&&allocationIds.length===previous.allocationIds.length)return;
publishImport(importCheckpoint({...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();
}}); }});
// 旧解释器若消耗的 ID 数量变了,原分配顺序已不可信,不能静默生成另一份复制结果。 // v1 续跑按 allocationIds 消费顺序重放分配;消耗数量不同就拒绝,防旧 checkpoint 被新解释器静默重解释。
// 新 createId 分配不做外部回滚,持久化检查点才定义后续重开必须复用的身份集合。
check();if(preparation.version===1&&cursor!==allocationIds.length)throw fault('copy_interpreter_changed'); check();if(preparation.version===1&&cursor!==allocationIds.length)throw fault('copy_interpreter_changed');
// 旧版计划编码成固定 NDJSON 并算 SHA;异步完成后再次校验目标入口及当前业务策略。 // 旧版计划编码成固定 NDJSON 并算 SHA;异步完成后再次校验目标入口及当前业务策略。
const encoded=await encodeDuplicateSource(document,options.registry,plan,controller.signal),sha256=await sourceDigest(encoded,controller.signal),job=check();assertImportWritable();assertNativeImportTarget(job,editor.getSnapshot().document); const encoded=await encodeDuplicateSource(document,options.registry,plan,controller.signal),sha256=await sourceDigest(encoded,controller.signal),job=check();assertImportWritable();assertNativeImportTarget(job,editor.getSnapshot().document);
const permission=options.policy?.({type:'changes.apply',changes:plan.changes},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets); const permission=options.policy?.({type:'changes.apply',changes:plan.changes},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
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 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 stableIds=Object.fromEntries([...Object.entries(plan.elementIds).map(([old,id])=>[`element:${old}`,id]),...Object.entries(plan.canvasIds).map(([old,id])=>[`canvas:${old}`,id])]);
// 新源替换旧快照时把原文件保留在 previousFiles,后续清理仍需保留期与引用证明。 // 旧路径把复制结果写成新的固定 NDJSON 源,指纹绑定新 fileId/长度;不是原地改写旧快照 Blob。
// 原文件进入 previousFiles,引用/保留期证明仍约束后续清理,不能在转换成功时直接删除。
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; 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(importCheckpoint(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}; return{job:getImport(id),bytes:encoded,document:undefined,indexed:undefined};
} }
/** 按 job 装配或复用导入执行器。验证来源、选择协议解析器、准备依赖与资源,再逐批提交。 /** 按 job 装配或复用导入执行器。验证来源、选择协议解析器、准备依赖与资源,再逐批提交。
* importLoads 是装配任务的单飞表,importRunners 是执行器表,两者都从持久 job 派生。 */ * importLoads 是装配任务的单飞表,importRunners 是执行器表,两者都从持久 job 派生。
* 返回 Promise 等装配/校验前置步骤,不等 runner 消费完整源;runner.start 与后台 seal 可继续执行。
* 需要时重开证明读取器,按持久 source.kind/parserVersion 选实现;不把扩展名或当前 UI 选择当协议。 */
function loadImport(id:Id):Promise<void>{ function loadImport(id:Id):Promise<void>{
// 重复调用共享正在装配的任务;已有 runner 时只补 seal/继续运行,不重复解析和提交。 // 重复调用共享正在装配的任务;已有 runner 时只补 seal/继续运行,不重复解析和提交。
if(importLoads.has(id))return importLoads.get(id)!; if(importLoads.has(id))return importLoads.get(id)!;
@@ -1001,14 +1095,16 @@ export async function openProjectEngine(options: EngineOptions) {
const latest=getImport(id);if(latest.sourceFingerprint!==job.sourceFingerprint||latest.source?.fileId!==source.fileId||['completed','partial','cancelled'].includes(latest.status)&&(source.integrityVersion!==2||!sourceNeedsSeal(latest)))return; const latest=getImport(id);if(latest.sourceFingerprint!==job.sourceFingerprint||latest.source?.fileId!==source.fileId||['completed','partial','cancelled'].includes(latest.status)&&(source.integrityVersion!==2||!sourceNeedsSeal(latest)))return;
if(!bytes)throw fault('import_source_unavailable');if(bytes.size!==source.byteLength)throw fault('import_source_changed'); if(!bytes)throw fault('import_source_unavailable');if(bytes.size!==source.byteLength)throw fault('import_source_changed');
let readBytes:ReturnType<typeof createImportReadProof>['port']|undefined; let readBytes:ReturnType<typeof createImportReadProof>['port']|undefined;
// 分块协议可在按需验证读取的同时做全文件校验;已有 SHA 则先确认整文件匹配再继续。 // integrity-v2 先重验已保存 proof,再把 reader.port 注入解析器,后续每次读仍受块证明约束。
// 无 SHA 时后台 seal 与解析并行;已有 SHA 时先等 verifying,校验失败会把 job 暂停而不继续加入。
if(source.integrityVersion===2){ if(source.integrityVersion===2){
let context=proofReaders.get(id);if(!context||context.fileId!==source.fileId){context={fileId:source.fileId,bytes,reader:createImportReadProof({bytes,fileId:source.fileId,proof:source.readProof})};proofReaders.set(id,context);} let context=proofReaders.get(id);if(!context||context.fileId!==source.fileId){context={fileId:source.fileId,bytes,reader:createImportReadProof({bytes,fileId:source.fileId,proof:source.readProof})};proofReaders.set(id,context);}
// 恢复先重验已保存分块证明;后续解析读口同样经过 reader,不能直接绕过它读取任意字节。 // 恢复先重验已保存分块证明;后续解析读口同样经过 reader,不能直接绕过它读取任意字节。
await context.reader.verify(preparationController.signal);const checking=verifyImportSource(id,context);if(source.sha256)await checking; await context.reader.verify(preparationController.signal);const checking=verifyImportSource(id,context);if(source.sha256)await checking;
job=getImport(id);source=job.source!;if(preparationController.signal.aborted||['completed','partial','cancelled','paused'].includes(job.status)||importHolds.size)return;readBytes=context.reader.port; job=getImport(id);source=job.source!;if(preparationController.signal.aborted||['completed','partial','cancelled','paused'].includes(job.status)||importHolds.size)return;readBytes=context.reader.port;
}else{ }else{
// 旧完整性协议在开始解析前计算全文件摘要,不用新的分块语义猜测旧格式。 // integrity-v1 先全文件 SHA,再装配解析器;缺 SHA 的旧空任务可发布一份新证据并推进 generation。
// 这条路径没有 v2 的按块渐进读证明,不能仅因文件相同就交换解释语义。
if(source.integrityVersion!==1)throw fault('import_source_integrity_unavailable'); if(source.integrityVersion!==1)throw fault('import_source_integrity_unavailable');
const sha256=await sourceDigest(bytes,preparationController.signal); const sha256=await sourceDigest(bytes,preparationController.signal);
if(closing||closed)return; if(closing||closed)return;
@@ -1027,15 +1123,20 @@ export async function openProjectEngine(options: EngineOptions) {
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,readBytes,fingerprint:job.sourceFingerprint,signal:preparationController.signal}):source.kind==='native-json-array'?createJsonArrayImportSource({bytes,readBytes,fingerprint:job.sourceFingerprint}):createNdjsonImportSource({bytes,readBytes,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,readBytes,fingerprint:job.sourceFingerprint,signal:preparationController.signal}):source.kind==='native-json-array'?createJsonArrayImportSource({bytes,readBytes,fingerprint:job.sourceFingerprint}):createNdjsonImportSource({bytes,readBytes,fingerprint:job.sourceFingerprint});
if(closing||closed||preparationController.signal.aborted||getImport(id).generation!==job.generation)return; if(closing||closed||preparationController.signal.aborted||getImport(id).generation!==job.generation)return;
if(importHolds.size){deferredImportLoads.add(id);return;} if(importHolds.size){deferredImportLoads.add(id);return;}
// beforeCommit 与同步 commit 之间的临时资源授权;不是第二份持久资源表。 // authorizedImportAssets 只在一次 beforeCommit→commit 之间借用,下一次准备/中止会清空。
// WeakSet 只避免同一 signal 重复装 abort 监听;授权/Blob 缓存不是已发布 assetBindings 或 durable 写入。
const authorizedImportAssets=new Map<Id,{ref:SharedAsset;bytes:Blob}>(),assetPreparationSignals=new WeakSet<AbortSignal>(); const authorizedImportAssets=new Map<Id,{ref:SharedAsset;bytes:Blob}>(),assetPreparationSignals=new WeakSet<AbortSignal>();
// 乐观本地模式允许边解析边落图;普通批次最多128项,积压32根或存储失败时施加保存背压。 // 乐观本地模式允许边解析边落图;普通批次最多128项,积压32根或存储失败时施加保存背压。
// parsed 阶段只有积压>=32根才等 persist;committed 阶段还会因 persistenceFailed 等保存恢复。
// 32是触发背压的当前队列观察值,不是跨所有生产者的严格内存容量;网络仍独立受可靠 seal 门禁限制。
// SCC 等不可分依赖单元另有限制;128 并不承诺每128项一定让出或一定耗时很短。 // SCC 等不可分依赖单元另有限制;128 并不承诺每128项一定让出或一定耗时很短。
const runner=createImportRunner({maxBatch:128,optimisticLocal:true,checkpointSource:source.integrityVersion===2?checkpointImportSource:undefined,awaitDurabilityBudget:stage=>pending.size>=32||stage!=='parsed'&&persistenceFailed?persist():undefined,source:importSource,getJob:()=>getImport(id),createId, const runner=createImportRunner({maxBatch:128,optimisticLocal:true,checkpointSource:source.integrityVersion===2?checkpointImportSource:undefined,awaitDurabilityBudget:stage=>pending.size>=32||stage!=='parsed'&&persistenceFailed?persist():undefined,source:importSource,getJob:()=>getImport(id),createId,
// prepare 只生成候选 unit/稳定 ID,实际文档修改由后面的同步事务执行。 // prepare 只生成候选 unit/稳定 ID,实际文档修改由后面的同步事务执行。
publish:publishImport,prepare:(line,job,reserve)=>['duplicate-ndjson','duplicate-plan'].includes(job.source?.kind??'')?prepareDuplicateImport(line,job,reserve,options.registry):prepareNativeImport(line,job,reserve,editor.getSnapshot().document,options.registry,depot&&options.assets?.authority?options.assets.sourceURL:undefined),persist,onError:report, publish:publishImport,prepare:(line,job,reserve)=>['duplicate-ndjson','duplicate-plan'].includes(job.source?.kind??'')?prepareDuplicateImport(line,job,reserve,options.registry):prepareNativeImport(line,job,reserve,editor.getSnapshot().document,options.registry,depot&&options.assets?.authority?options.assets.sourceURL:undefined),persist,onError:report,
/** 原生文档格式引用的共享资源先逐项授权、校验 URL、读取字节;批次累计不超过32MiB。 /** 仅 native-document 对引用资源逐项重新授权、校验 URL 并备好原 Blob;其它源由各自规则处理。
* 异步阶段不改业务根,失败清空准备材料;同步 commit 仍须重新核对 job/目标/依赖。 */ * 已有本地资源也先走授权,commit 再核对是否同一身份;不能用热缓存绕过当前远端权限。
* 32MiB 按每次 authorize 返回后的 Blob 长度累加,超限拒绝,不是下载过程中的总内存硬限。
* 异步阶段不发布业务根,失败清材料;runner 检查 generation 后,同步 commit 再查目标/依赖。 */
async beforeCommit(units,job,signal){ async beforeCommit(units,job,signal){
// 其它源格式不走该外部资源领用路径;其引用规则由各自 unit 准备/守卫处理。 // 其它源格式不走该外部资源领用路径;其引用规则由各自 unit 准备/守卫处理。
authorizedImportAssets.clear();if(job.source?.kind!=='native-document')return{enabled:true}; authorizedImportAssets.clear();if(job.source?.kind!=='native-document')return{enabled:true};
@@ -1045,14 +1146,16 @@ export async function openProjectEngine(options: EngineOptions) {
try{for(const id of ids){const authorized=await depot.authorize(leader,{scope:options.assets.authority.scope,resolve:(id,abort)=>network.run(abort,()=>options.assets!.authority!.resolve(id,abort)),read:(id,abort)=>network.run(abort,()=>options.assets!.authority!.read(id,abort))},id,signal);signal.throwIfAborted();assertImportWritable();if(authorized.ref.asset.url!==options.assets.sourceURL(id))throw fault('asset_authorization_mismatch');total+=authorized.bytes.size;if(total>32*1024*1024)throw fault('import_asset_capacity');authorizedImportAssets.set(id,authorized);}return{enabled:true};}catch(error){authorizedImportAssets.clear();return{enabled:false,code:(error as {code?:string}).code??'import_asset_authorization_failed',targets:[]};} try{for(const id of ids){const authorized=await depot.authorize(leader,{scope:options.assets.authority.scope,resolve:(id,abort)=>network.run(abort,()=>options.assets!.authority!.resolve(id,abort)),read:(id,abort)=>network.run(abort,()=>options.assets!.authority!.read(id,abort))},id,signal);signal.throwIfAborted();assertImportWritable();if(authorized.ref.asset.url!==options.assets.sourceURL(id))throw fault('asset_authorization_mismatch');total+=authorized.bytes.size;if(total>32*1024*1024)throw fault('import_asset_capacity');authorizedImportAssets.set(id,authorized);}return{enabled:true};}catch(error){authorizedImportAssets.clear();return{enabled:false,code:(error as {code?:string}).code??'import_asset_authorization_failed',targets:[]};}
}, },
/** 将一批元素/canvas、授权资源绑定及 next job 检查点放入一次 editor 发布。 /** 将一批元素/canvas、授权资源绑定及 next job 检查点放入一次 editor 发布。
* 失败回滚的只是本批临时领用材料;此前成功加入并显示的批次保持不变。 */ * 事务拒绝不发布本批文档/job,finally 撤回临时领用字节与授权上下文;此前成功批次保持不变。
* 允许的 unit 只有 before=null 的 element/canvas 创建;任意字段修改或删除都拒绝。 */
commit(units,next,expected){ commit(units,next,expected){
// 消费一次 beforeCommit 材料,防止后续批次或重入误用上次授权。 // 消费一次 beforeCommit 材料,防止后续批次或重入误用上次授权。
const preparedAssets=new Map(authorizedImportAssets);authorizedImportAssets.clear(); const preparedAssets=new Map(authorizedImportAssets);authorizedImportAssets.clear();
assertImportWritable();if(importing)throw fault('reentrant_dispatch');const current=getImport(id);if(current.generation!==expected)throw fault('stale_import_job'); assertImportWritable();if(importing)throw fault('reentrant_dispatch');const current=getImport(id);if(current.generation!==expected)throw fault('stale_import_job');
// 等待资源授权期间目标或外部连线端点可能变化,提交前重新检查入口代次和依赖。 // 等待资源授权期间目标或外部连线端点可能变化,提交前重新检查入口代次和依赖。
try{assertNativeImportTarget(current,editor.getSnapshot().document);for(const unit of units){assertNativeImportDependencies(current,editor.getSnapshot().document,unit.dependencies);if(['duplicate-ndjson','duplicate-plan'].includes(current.source?.kind??''))assertDuplicateExternal({cursor:0,nextCursor:1,key:unit.key,value:unit.guard},current,editor.getSnapshot().document,options.registry);}}catch(error){return reject(error);} try{assertNativeImportTarget(current,editor.getSnapshot().document);for(const unit of units){assertNativeImportDependencies(current,editor.getSnapshot().document,unit.dependencies);if(['duplicate-ndjson','duplicate-plan'].includes(current.source?.kind??''))assertDuplicateExternal({cursor:0,nextCursor:1,key:unit.key,value:unit.guard},current,editor.getSnapshot().document,options.registry);}}catch(error){return reject(error);}
// 临时登记待保存的领用字节,同时保存旧值,保证事务拒绝后可准确恢复原暂存集合。 // 已有上传资源须匹配 stored/digest;新领用先占 adoptedFiles 并记旧值,防和孤儿/源/待删文件撞 ID。
// 临时 rootRevision=MAX_SAFE_INTEGER 让旧根保存不提前采纳;事务成功后再绑定真实发布 revision。
const previousFiles=new Map<Id,{readonly bytes:Blob;readonly rootRevision:number}|undefined>();let accepted=false; const previousFiles=new Map<Id,{readonly bytes:Blob;readonly rootRevision:number}|undefined>();let accepted=false;
try{for(const [assetId,authorized]of preparedAssets){const runtime=editor.getSnapshot().runtime,own=runtime.resources.get(assetId);if(own){if(!own.stored||own.digest!==authorized.ref.digest||!equal(own.stored,authorized.ref.asset))throw fault('shared_asset_identity_conflict',[assetId]);continue;}if(runtime.orphanFiles?.has(assetId)||importFiles(runtime.imports).has(assetId)||fileWrites.has(assetId)||collectionReservations.has(assetId))throw fault('resource_identity_conflict',[assetId]);previousFiles.set(assetId,adoptedFiles.get(assetId));adoptedFiles.set(assetId,{bytes:authorized.bytes,rootRevision:Number.MAX_SAFE_INTEGER});}importAdoptions=[...preparedAssets].filter(([assetId])=>!editor.getSnapshot().runtime.resources.has(assetId)).map(([,value])=>value.ref);}catch(error){for(const [assetId,previous]of previousFiles)if(previous)adoptedFiles.set(assetId,previous);else adoptedFiles.delete(assetId);authorizedImportAssets.clear();return reject(error);} try{for(const [assetId,authorized]of preparedAssets){const runtime=editor.getSnapshot().runtime,own=runtime.resources.get(assetId);if(own){if(!own.stored||own.digest!==authorized.ref.digest||!equal(own.stored,authorized.ref.asset))throw fault('shared_asset_identity_conflict',[assetId]);continue;}if(runtime.orphanFiles?.has(assetId)||importFiles(runtime.imports).has(assetId)||fileWrites.has(assetId)||collectionReservations.has(assetId))throw fault('resource_identity_conflict',[assetId]);previousFiles.set(assetId,adoptedFiles.get(assetId));adoptedFiles.set(assetId,{bytes:authorized.bytes,rootRevision:Number.MAX_SAFE_INTEGER});}importAdoptions=[...preparedAssets].filter(([assetId])=>!editor.getSnapshot().runtime.resources.has(assetId)).map(([,value])=>value.ref);}catch(error){for(const [assetId,previous]of previousFiles)if(previous)adoptedFiles.set(assetId,previous);else adoptedFiles.delete(assetId);authorizedImportAssets.clear();return reject(error);}
// reducer 在此同步窗口读 importing/importAdoptions,使 job 游标和新增元素同根更新。 // reducer 在此同步窗口读 importing/importAdoptions,使 job 游标和新增元素同根更新。
@@ -1061,16 +1164,21 @@ export async function openProjectEngine(options: EngineOptions) {
try{const result=editor.transaction(tx=>{for(const unit of units)for(const change of unit.changes){if(change.kind==='element'&&change.before===null&&change.after)tx.dispatch({type:'element.create',element:change.after});else if(change.kind==='canvas'&&change.before===null&&change.after)tx.dispatch({type:'canvas.create',canvas:change.after});else throw fault('invalid_native_import_unit');}},{label:['duplicate-ndjson','duplicate-plan'].includes(current.source?.kind??'')?'Duplicate selection':'Import canvas content',groupId:`import:${id}`});accepted=result.status==='applied';if(accepted)for(const assetId of previousFiles.keys())adoptedFiles.set(assetId,{bytes:preparedAssets.get(assetId)!.bytes,rootRevision:editor.getSnapshot().rootRevision});return result;}finally{importing=undefined;importAdoptions=[];if(!accepted)for(const [assetId,previous]of previousFiles)if(previous)adoptedFiles.set(assetId,previous);else adoptedFiles.delete(assetId);authorizedImportAssets.clear();} try{const result=editor.transaction(tx=>{for(const unit of units)for(const change of unit.changes){if(change.kind==='element'&&change.before===null&&change.after)tx.dispatch({type:'element.create',element:change.after});else if(change.kind==='canvas'&&change.before===null&&change.after)tx.dispatch({type:'canvas.create',canvas:change.after});else throw fault('invalid_native_import_unit');}},{label:['duplicate-ndjson','duplicate-plan'].includes(current.source?.kind??'')?'Duplicate selection':'Import canvas content',groupId:`import:${id}`});accepted=result.status==='applied';if(accepted)for(const assetId of previousFiles.keys())adoptedFiles.set(assetId,{bytes:preparedAssets.get(assetId)!.bytes,rootRevision:editor.getSnapshot().rootRevision});return result;}finally{importing=undefined;importAdoptions=[];if(!accepted)for(const [assetId,previous]of previousFiles)if(previous)adoptedFiles.set(assetId,previous);else adoptedFiles.delete(assetId);authorizedImportAssets.clear();}
}, },
}); });
// 装配完成后继承仍有效的全局 holds;业务 status=running 才启动加入画布的工作。 // 注册 runner 后继承所有仍有效的全局 hold,避免装配期间新增 hold 被漏掉。
// 只有根中 status=running 才启动消费;void start 不延长 loadImport Promise 到导入结束。
importRunners.set(id,runner);for(const releases of importHolds.values())releases.set(id,runner.hold()); importRunners.set(id,runner);for(const releases of importHolds.values())releases.set(id,runner.hold());
if(getImport(id).status==='running')void runner.start(); if(getImport(id).status==='running')void runner.start();
// 中止不改业务终态;真实读取损坏留下完整性错误,其它装配失败转 paused。 // 中止不改业务终态;真实读取损坏留下完整性错误,其它装配失败转 paused。
// finally 清除执行索引,仅在仍有延迟恢复义务且 job 可运行时重新装配,避免覆盖后来换源的状态。 // 失败收尾按最终 expectedFingerprint 判断归属:复制物化合法换源后也只能修改对应来源。
// v2 字节错误留 integrityError,其它非终态装配错误转 paused;失败不能清空已加入内容或重置稳定映射。
// finally 清执行索引;仅有 deferred 恢复义务、无全局 hold、且仍 running/preparing 时安排重装,不无限重试错误。
})().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);});}); })().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; importLoads.set(id,task);return task;
} }
/** 接收本地 Blob 并同步登记一个可恢复作业(源文件上限64MiB),随后后台读取解析。 /** 接收本地 Blob 并同步登记一个可恢复作业(源文件上限64MiB),随后后台读取解析。
* 文件 ID、源证明和目标入口代次从一开始就写进根,页面可立即观察 running,不等待全文件解析。 */ * 文件 ID、空读证明和目标入口代次从一开始写进根,页面可立即观察 running,不等全文件解析。
* fileWrites 先暂存源 Blob,登记根被拒绝则撤回;接受仅表示本地任务存在,磁盘保存与 SHA 封存随后完成。
* targetEntry 固定子分区入口代次,parserVersion 按显式 format 固定5/3/1,创建时不猜内容格式。 */
function startNativeImport(bytes:Blob,targetCanvasId:Id,format:'ndjson'|'json-array'|'document'):Id{ function startNativeImport(bytes:Blob,targetCanvasId:Id,format:'ndjson'|'json-array'|'document'):Id{
if(!['ndjson','json-array','document'].includes(format))throw fault('import_interpreter_unavailable'); if(!['ndjson','json-array','document'].includes(format))throw fault('import_interpreter_unavailable');
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'); 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');
@@ -1098,7 +1206,9 @@ export async function openProjectEngine(options: EngineOptions) {
startDocument:(bytes:Blob,targetCanvasId:Id)=>startNativeImport(bytes,targetCanvasId,'document'), startDocument:(bytes:Blob,targetCanvasId:Id)=>startNativeImport(bytes,targetCanvasId,'document'),
startJsonArray:(bytes:Blob,targetCanvasId:Id)=>startNativeImport(bytes,targetCanvasId,'json-array'), startJsonArray:(bytes:Blob,targetCanvasId:Id)=>startNativeImport(bytes,targetCanvasId,'json-array'),
/** 捕获当前不可变文档和独立选择/偏移,异步编码索引快照,再登记 preparing 复制任务。 /** 捕获当前不可变文档和独立选择/偏移,异步编码索引快照,再登记 preparing 复制任务。
* 后续原画布编辑不会改变复制来源;完成编码不代表已创建全部副本。 */ * 先编码 captured.document 的完整索引快照,再用 selection 规划复制闭包;不是只序列化选中记录。
* Promise 返回的是已登记 preparing 的 job ID,不等待副本创建;快照编码后目标入口仍要在后续提交重查。
* 外部 signal 只覆盖本次编码/登记准备句柄,返回后由 imports.pause/cancel 控制作业生命周期。 */
async duplicate(selection:readonly Target[],settings:{readonly worldOffset?:Point;readonly signal?:AbortSignal;readonly viewCanvasId?:Id}={}):Promise<Id>{ async duplicate(selection:readonly Target[],settings:{readonly worldOffset?:Point;readonly signal?:AbortSignal;readonly viewCanvasId?:Id}={}):Promise<Id>{
assertImportWritable();if(collectionReservations.size)throw fault('resource_collection_in_progress',[...collectionReservations]); assertImportWritable();if(collectionReservations.size)throw fault('resource_collection_in_progress',[...collectionReservations]);
const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets); const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
@@ -1129,26 +1239,34 @@ export async function openProjectEngine(options: EngineOptions) {
// 从编码开始到作业登记结束持有准备任务/pin;所有结束路径清除任务及外部 abort 监听。 // 从编码开始到作业登记结束持有准备任务/pin;所有结束路径清除任务及外部 abort 监听。
copyPreparations.set(controller,task);void task.finally(()=>{copyPreparations.delete(controller);signal?.removeEventListener('abort',abort);}).catch(()=>{});return task; copyPreparations.set(controller,task);void task.finally(()=>{copyPreparations.delete(controller);signal?.removeEventListener('abort',abort);}).catch(()=>{});return task;
}, },
/** 有 runner 就停在其批次边界;未装配时直接推进 job generation 并保存 paused。 /** 有 runner 则调用其 pause:发布 paused/保存并请求 abort,不额外等待 active 完全退出。
* 分块来源的验证仍可能继续补齐已提交前缀的证据,不能把暂停当作删除来源。 */ * 未装配则推进 generation;v1 请求中止准备,v2 可继续补齐来源证明,但不继续加入内容。
* 暂停不删除源文件、已加入内容或稳定映射;需要排空时仍须使用相关 settle/关闭流程。 */
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 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();}}},
/** 终态不重启;paused 先等旧装配/runner 结束,必要时重建证明读取器,再保存并恢复调度。 /** completed/partial/cancelled 不重启;已 running/preparing 仅确保 loadImport 装配。
* 复制快照回 preparing,其它源回 running,避免跳过尚未完成的复制规划。 */ * paused 先等旧装配/runner 收尾,v2 重建 reader,再保存现有根并恢复 scheduler,随后重新查状态。
* 复制快照回 preparing,其它源回 running;清 job.error 但不擅自清 source.integrityError,重验成功才解封。 */
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 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);},
/** 取消后续加入,保留已有内容;尚欠 seal 时仍可继续校验,证据完整才中止额外验证。 */ /** 取消后续加入并保存取消事实,保留成功内容;无 runner 的 preparing 任务也可在此取消。
* v2 若已加入内容但欠 seal,仍保留验证任务;无成功源项的取消任务不欠 seal,可请求中止额外验证。
* 不等同于撤销已同步操作、删除源文件或调用后台生成取消接口。 */
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();}, 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();},
}); });
// 全部协调回调已接线后启动调度,恢复既有生成轮询和资源任务。 // 协调回调与根订阅都装配完才启动执行器;恢复的 run IDs 显式首次 notify,不能只等新发布发现。
// 工厂将控制面交回宿主时后台任务可已在运行,并非等所有网络/导入完成才返回。
scheduler.start(); prepareDigests();prepareImages(); runTracker.notify([...initial.runs.keys()]); scheduler.start(); prepareDigests();prepareImages(); runTracker.notify([...initial.runs.keys()]);
// 终态作业若仍欠 seal 也装配验证;running/preparing 则恢复尚未完成的内容处理。 // 终态作业若仍欠 seal 也装配验证;running/preparing 则恢复尚未完成的内容处理。
for(const job of initial.imports?.values()??[])if(sourceNeedsSeal(job)||['running','preparing'].includes(job.status))void loadImport(job.id); for(const job of initial.imports?.values()??[])if(sourceNeedsSeal(job)||['running','preparing'].includes(job.status))void loadImport(job.id);
// 对外只暴露冻结的控制面;根快照由 editor 读取,异步执行器和持久化内部表不直接交给界面修改。 // 对外只暴露冻结的控制面;根快照由 editor 读取,异步执行器和持久化内部表不直接交给界面修改。
return Object.freeze({ return Object.freeze({
editor, sessions, imports, editor, sessions, imports,
/** 渐进加载控制面:accept 合入同一流的前缀,complete 提交完整结果,resume 重新读取权威模型。 */ /** 渐进加载控制面:accept 合入同一流前缀,complete 提交完整结果,resume 重新读取权威模型。
* getState 返回当前根中的状态引用;accept 同步发布候选,complete/resume 成功路径另等本地保存。 */
loading:Object.freeze({getState:()=>editor.getSnapshot().runtime.loading, loading:Object.freeze({getState:()=>editor.getSnapshot().runtime.loading,
/** 同一时间共享一个恢复读取。v3+ 等旧请求确认后先 hold 新发送,再读服务端, /** 同一时间共享一个恢复读取。v3+ 等旧请求确认后先 hold 新发送,再读服务端,
* 避免读取期间又发出写入造成模型包含范围不明;finally 释放本次读取的暂停租约。 */ * 避免读取期间又发出写入造成模型包含范围不明;finally 释放本次读取的暂停租约。
* 等所有 request.phase=resolved,不只等 HTTP 回调;已 applied 但尚未协调的请求仍会阻止刷新。
* 不暂停本地编辑,completeLoading 会叠回期间未决意图;此读取不重启导入或替用户解决冲突。 */
resume():Promise<Result>{ resume():Promise<Result>{
if(loadingRead)return loadingRead; if(loadingRead)return loadingRead;
const task=(async()=>{assertWritable(false);if(!editor.getSnapshot().runtime.loading)return{status:'applied' as const,revision:editor.getSnapshot().revision,operationIds:[]};if(!options.persistence.read)throw fault('canonical_reader_unavailable');const controller=new AbortController();loadingReadController=controller;let releaseRead:(()=>void)|undefined; const task=(async()=>{assertWritable(false);if(!editor.getSnapshot().runtime.loading)return{status:'applied' as const,revision:editor.getSnapshot().revision,operationIds:[]};if(!options.persistence.read)throw fault('canonical_reader_unavailable');const controller=new AbortController();loadingReadController=controller;let releaseRead:(()=>void)|undefined;
@@ -1160,7 +1278,8 @@ export async function openProjectEngine(options: EngineOptions) {
})();loadingRead=task.finally(()=>{if(loadingRead===wrapped)loadingRead=undefined;});const wrapped=loadingRead;return wrapped; })();loadingRead=task.finally(()=>{if(loadingRead===wrapped)loadingRead=undefined;});const wrapped=loadingRead;return wrapped;
}, },
/** 校验来源前缀只能合法扩展,再合并期间已确认的修改并重放 pending; /** 校验来源前缀只能合法扩展,再合并期间已确认的修改并重放 pending;
* 原始读取 nextRead 与 working 分开保存,本地编辑不冒充服务端新增内容。 */ * 原始读取 nextRead 与 working 分开保存,本地编辑不冒充服务端新增内容。
* acceptingLoading 仅覆盖本次同步 reconcile,finally 撤销;返回 applied 仍须后台保存,并不等待持久回执。 */
accept(document:Document,versions:ReadonlyMap<Id,number>,state:LoadingState):Result{ accept(document:Document,versions:ReadonlyMap<Id,number>,state:LoadingState):Result{
try{assertWritable(false);const root=editor.getSnapshot(),previous=root.runtime;if(!previous.loading)throw fault('loading_not_active');const source=previous.loadingRead;validatePrefixExtension(source?.document??previous.acknowledged,source?.versions??previous.versions,previous.loading,document,versions,state,options.registry);const nextRead=Object.freeze({document:createDocument(document.projectId,[...document.canvases.values()],[...document.elements.values()],document.tombstones),versions:readMap(versions)}),proposal=mergeLoadingRead(previous,nextRead,options.registry),acknowledged=proposal.document,working=replayPending(acknowledged,previous.ledger,options.registry);acceptingLoading=true;return editor.reconcile(working,Object.freeze({...previous,loading:freeze(clone(state)),...(state.version>=3?{loadingRead:nextRead}:{}),versions:readMap(proposal.versions),acknowledged}),root.rootRevision);}catch(error){return reject(error);}finally{acceptingLoading=false;} try{assertWritable(false);const root=editor.getSnapshot(),previous=root.runtime;if(!previous.loading)throw fault('loading_not_active');const source=previous.loadingRead;validatePrefixExtension(source?.document??previous.acknowledged,source?.versions??previous.versions,previous.loading,document,versions,state,options.registry);const nextRead=Object.freeze({document:createDocument(document.projectId,[...document.canvases.values()],[...document.elements.values()],document.tombstones),versions:readMap(versions)}),proposal=mergeLoadingRead(previous,nextRead,options.registry),acknowledged=proposal.document,working=replayPending(acknowledged,previous.ledger,options.registry);acceptingLoading=true;return editor.reconcile(working,Object.freeze({...previous,loading:freeze(clone(state)),...(state.version>=3?{loadingRead:nextRead}:{}),versions:readMap(proposal.versions),acknowledged}),root.rootRevision);}catch(error){return reject(error);}finally{acceptingLoading=false;}
}, },
@@ -1171,7 +1290,9 @@ export async function openProjectEngine(options: EngineOptions) {
collectResources,collectImportSources,inventoryLocalFiles,collectOrphanFiles, collectResources,collectImportSources,inventoryLocalFiles,collectOrphanFiles,
getResourcePins:()=>resourceRetentionPins(editor.getSnapshot(),options.registry,displayPins,admissionPins(editor.getSnapshot())), getResourcePins:()=>resourceRetentionPins(editor.getSnapshot(),options.registry,displayPins,admissionPins(editor.getSnapshot())),
/** 暂停调度/生成轮询/导入,排空在途协调并保存,再读取服务端模型生成审阅会话。 /** 暂停调度/生成轮询/导入,排空在途协调并保存,再读取服务端模型生成审阅会话。
* prepare 本身不替用户丢弃编辑或改写原请求;发现冲突只返回诊断,发布发生在 apply/resolve。 */ * 不提前发布恢复候选;在途已准入任务仍可先完成并更新当前根,排空后捕获固定底稿。
* 暂停、保存、读服务端并验证已生效回执后尝试条件重放;重放冲突以诊断返回会话,不提前改文档。
* prepare 期间仍保存原 working/history/request 事实,用户取舍引起的新发布发生在 apply/resolve。 */
async prepareRecovery(): Promise<RecoverySession> { async prepareRecovery(): Promise<RecoverySession> {
assertWritable(false); assertWritable(false);
if(!options.persistence.read)throw fault('canonical_reader_unavailable'); if(!options.persistence.read)throw fault('canonical_reader_unavailable');
@@ -1191,6 +1312,7 @@ export async function openProjectEngine(options: EngineOptions) {
await persist();assertCurrent(); await persist();assertCurrent();
// 从已知 applied 回执推导可采用的候选证据;先保存现有根,不将未验证的候选提前发布。 // 从已知 applied 回执推导可采用的候选证据;先保存现有根,不将未验证的候选提前发布。
const staged=stageAppliedRecovery(editor.getSnapshot(),{persistence:options.persistence,runs:options.runs,assets:options.assets,now:runClock.now()}); const staged=stageAppliedRecovery(editor.getSnapshot(),{persistence:options.persistence,runs:options.runs,assets:options.assets,now:runClock.now()});
// staged 只在私有候选中解决已验证 applied 证据;它保持底稿 rootRevision,用于后续乐观发布。
const root=staged.snapshot,previous=root.runtime; const root=staged.snapshot,previous=root.runtime;
// 读取后的根 revision 必须仍一致;后续计划不能建立在与当前状态不同的旧底稿上。 // 读取后的根 revision 必须仍一致;后续计划不能建立在与当前状态不同的旧底稿上。
const loaded=await network.run(controller.signal,()=>options.persistence.read!(controller.signal));assertCurrent(); const loaded=await network.run(controller.signal,()=>options.persistence.read!(controller.signal));assertCurrent();
@@ -1205,21 +1327,24 @@ export async function openProjectEngine(options: EngineOptions) {
let committed:ConflictPlan|'refresh'|undefined,committedIds:readonly Id[]=[]; let committed:ConflictPlan|'refresh'|undefined,committedIds:readonly Id[]=[];
// 只接受本会话实际生成的 plan 对象,拒绝调用方拼接或跨会话重用的方案。 // 只接受本会话实际生成的 plan 对象,拒绝调用方拼接或跨会话重用的方案。
const plans=new WeakSet<ConflictPlan>(); const plans=new WeakSet<ConflictPlan>();
/** 已发布后先过保存屏障,再恢复调度/轮询并释放会话。 /** 发布成功后:persist→scheduler.resume→通知新操作/受影响 run→释放三类 hold。
* 保存失败继续持有暂停;再次调用可重试屏障,不重复发布相同恢复操作。 */ * persist 或 resume 失败都保留已提交标记及会话,可再试 finish 而不重复发布恢复操作。
* 返回 applied 表示本地恢复发布/保存完成,不等于替代操作已获服务端 ACK 或生成任务结束。 */
const finish=async()=>{if(previous.loading&&!editor.getSnapshot().runtime.loading)loadingCompletionRevision=editor.getSnapshot().rootRevision;await persist();assertCurrent();await scheduler.resume();assertCurrent();scheduler.notify(committedIds);runTracker.notify(staged.runIds);active.release();return{status:'applied' as const,revision:editor.getSnapshot().revision,operationIds:committedIds};}; const finish=async()=>{if(previous.loading&&!editor.getSnapshot().runtime.loading)loadingCompletionRevision=editor.getSnapshot().rootRevision;await persist();assertCurrent();await scheduler.resume();assertCurrent();scheduler.notify(committedIds);runTracker.notify(staged.runIds);active.release();return{status:'applied' as const,revision:editor.getSnapshot().revision,operationIds:committedIds};};
// 返回会话后,未发布可直接退出;已经发布则必须完成保存,cancel 不等于回滚已发布恢复。 // 返回会话后,未发布可直接退出;已经发布则必须完成保存,cancel 不等于回滚已发布恢复。
active.cancel=async()=>{if(recovery!==active)return;if(committed){await finish();return;}active.release();}; active.cancel=async()=>{if(recovery!==active)return;if(committed){await finish();return;}active.release();};
return Object.freeze({baseRootRevision:root.rootRevision,canonical,acknowledgedRequestIds:staged.requestIds, return Object.freeze({baseRootRevision:root.rootRevision,canonical,acknowledgedRequestIds:staged.requestIds,
pendingOperationIds:Object.freeze([...previous.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id)), pendingOperationIds:Object.freeze([...previous.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id)),
...(conflict?{conflict}:{}), ...(conflict?{conflict}:{}),
/** 在固定根上纯计算明确取舍的恢复方案,分配新操作身份并保存可审计备份。 */ /** 在固定底稿上计算恢复候选并分配新操作 ID,生成含审计备份的 plan;不发布/写磁盘。
* WeakSet 只登记本会话生成的对象身份,拒绝外部拼造;createId 的分配计数不会因放弃 plan 回滚。 */
plan(choices:ReadonlyMap<Id,RecoveryChoice>){ plan(choices:ReadonlyMap<Id,RecoveryChoice>){
assertCurrent();if(committed)throw fault('recovery_already_committed');if(editor.getSnapshot().rootRevision!==root.rootRevision)throw fault('stale_plan'); assertCurrent();if(committed)throw fault('recovery_already_committed');if(editor.getSnapshot().rootRevision!==root.rootRevision)throw fault('stale_plan');
const plan=planConflictRecovery(root,canonical,choices,options.registry,createId,options.historyLimit??100);plans.add(plan);return plan; const plan=planConflictRecovery(root,canonical,choices,options.registry,createId,options.historyLimit??100);plans.add(plan);return plan;
}, },
/** 采用本会话方案:重查策略/生成输出适用性,发布文档、历史、账本、审计及资源 pin。 /** 采用本会话方案:重查策略/生成输出适用性,发布文档、历史、账本、审计及资源 pin。
* 同一方案已发布时只重试 finish;不能接着换另一方案覆盖第一次发布。 */ * 同一方案已发布时只重试 finish;不能接着换另一方案覆盖第一次发布。
* 策略拒绝返回 rejected 并保留会话;通过后 reconcile 按原 rootRevision 同时发布 plan 文档/历史/操作和 runtime。 */
async resolve(plan:ConflictPlan):Promise<Result>{ async resolve(plan:ConflictPlan):Promise<Result>{
assertCurrent();if(!plans.has(plan))throw fault('invalid_recovery_plan');if(committed&&committed!==plan)throw fault('recovery_already_committed'); assertCurrent();if(!plans.has(plan))throw fault('invalid_recovery_plan');if(committed&&committed!==plan)throw fault('recovery_already_committed');
if(!committed){ if(!committed){
@@ -1245,7 +1370,7 @@ export async function openProjectEngine(options: EngineOptions) {
if(result.status==='rejected')return result; if(result.status==='rejected')return result;
committed='refresh'; committed='refresh';
} }
// Persistence failure retains the hold; apply/cancel can retry this barrier without republishing. // 首次发布后记 committed=refresh;保存失败时 apply/cancel 复用 finish,不再发布第二份刷新。
return finish(); return finish();
}, },
// 使用更新后的 cancel 语义,已提交会话仍需先完成可靠保存。 // 使用更新后的 cancel 语义,已提交会话仍需先完成可靠保存。
@@ -1253,7 +1378,8 @@ export async function openProjectEngine(options: EngineOptions) {
}); });
}catch(error){active.release();throw error;} }catch(error){active.release();throw error;}
}, },
/** 读取当前项目画布的模型目录,经过共享网络门;不创建生成任务。 */ /** 读取当前项目画布的模型目录,经过共享网络门;不创建生成任务或更新 Root。
* 只查画布存在/服务可用及网络门身份,不要求无恢复会话或完整模型;目录返回仍由端口解释。 */
models(boardId: Id, signal: AbortSignal) { models(boardId: Id, signal: AbortSignal) {
if(closing||closed||!options.runs)return Promise.reject(fault('run_interpreter_unavailable')); if(closing||closed||!options.runs)return Promise.reject(fault('run_interpreter_unavailable'));
if(!editor.getSnapshot().document.canvases.has(boardId))return Promise.reject(fault('canvas_missing')); if(!editor.getSnapshot().document.canvases.has(boardId))return Promise.reject(fault('canvas_missing'));
@@ -1270,7 +1396,9 @@ export async function openProjectEngine(options: EngineOptions) {
}catch(error){return reject(error);} }catch(error){return reject(error);}
}, },
/** 用户明确采用冲突任务的一份输出。校验目标代次/任务成员/输出身份并防重复, /** 用户明确采用冲突任务的一份输出。校验目标代次/任务成员/输出身份并防重复,
* 通过普通数据编辑取得 history;若内容本已相同,仍登记空 changes 的 run.apply 控制动作。 */ * 通过普通数据编辑取得 history;若内容本已相同,仍登记空 changes 的 run.apply 控制动作。
* 文本采用 text;图片采用 src/assetId,不把输出复制成新的正在执行任务或重复上传任务。
* applied 只表示本地接纳,服务应用回执仍需版本/目标身份核验,appliedOutputIds 在回执协调后更新。 */
applyRunOutput(runId:Id,target:RunApplyInput['target'],outputId:Id):Result { applyRunOutput(runId:Id,target:RunApplyInput['target'],outputId:Id):Result {
try{ try{
assertWritable();if(!options.runs)throw fault('run_interpreter_unavailable');if(applying)throw fault('reentrant_dispatch'); assertWritable();if(!options.runs)throw fault('run_interpreter_unavailable');if(applying)throw fault('reentrant_dispatch');
@@ -1290,7 +1418,9 @@ export async function openProjectEngine(options: EngineOptions) {
}catch(error){return reject(error);}finally{applying=undefined;} }catch(error){return reject(error);}finally{applying=undefined;}
}, },
/** 登记生成启动意图,真正受理/执行由服务完成。只允许当前已知的支持类型节点, /** 登记生成启动意图,真正受理/执行由服务完成。只允许当前已知的支持类型节点,
* 启动依赖所有旧未决操作,保证服务端生成所见输入先包含当前编辑;不在此直接请求模型。 */ * 启动依赖所有旧未决操作,保证服务端生成所见输入先包含当前编辑;不在此直接请求模型。
* 校验通用输入与目标类型/编辑能力,不在这里刷新模型目录或证明远端模型此刻可用。
* 未决 run.start 按目标 ID 保守占用;已有任务用 ID+incarnation/传播目标及 hasMore/pendingPage 判忙。 */
startRun(input: RunInput): Result { startRun(input: RunInput): Result {
try { try {
assertWritable(); assertWritable();
@@ -1313,7 +1443,8 @@ export async function openProjectEngine(options: EngineOptions) {
}catch(error){return reject(error);} }catch(error){return reject(error);}
}, },
/** 用新的权威模型刷新 ACK,再条件重放本地未决修改;未确认请求存在时拒绝模糊刷新。 /** 用新的权威模型刷新 ACK,再条件重放本地未决修改;未确认请求存在时拒绝模糊刷新。
* 与 prepareRecovery 不同,此入口只尝试安全重放,冲突时保留文档并登记诊断。 */ * 与 prepareRecovery 不同,此入口只尝试安全重放,冲突时保留文档并登记诊断。
* 不自行读取网络;调用方提供文档/版本。同步 applied 不等待 persist;loading 中转 completeLoading 的同类同步路径。 */
refresh(document: Document, versions: ReadonlyMap<Id, number>): Result { refresh(document: Document, versions: ReadonlyMap<Id, number>): Result {
if(editor.getSnapshot().runtime.loading)return completeLoading(document,versions,{fresh:true}); if(editor.getSnapshot().runtime.loading)return completeLoading(document,versions,{fresh:true});
const root = editor.getSnapshot(), previous = root.runtime; const root = editor.getSnapshot(), previous = root.runtime;
@@ -1336,7 +1467,9 @@ export async function openProjectEngine(options: EngineOptions) {
/** 查询图片暂存能力;渐进加载仅 v6 已完整画布开放,真正事务还需经过 editor 策略校验。 */ /** 查询图片暂存能力;渐进加载仅 v6 已完整画布开放,真正事务还需经过 editor 策略校验。 */
canStageFiles(canvasId:Id){try{assertWritable(false);if(!options.assets)throw fault('asset_interpreter_unavailable');const snapshot=editor.getSnapshot();if(!snapshot.document.canvases.has(canvasId))throw fault('canvas_not_loaded',[canvasId]);if(snapshot.runtime.loading&&(snapshot.runtime.loading.version!==6||!loadingQueryCoverage(snapshot).completeCanvasIds.includes(canvasId)))throw fault('loading_scope_incomplete',[canvasId]);const permission=options.policy?.({type:'changes.apply',changes:[]},snapshot.document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);return{enabled:true as const};}catch(error){const result=reject(error);return{enabled:false as const,code:result.code,targets:result.targets};}}, canStageFiles(canvasId:Id){try{assertWritable(false);if(!options.assets)throw fault('asset_interpreter_unavailable');const snapshot=editor.getSnapshot();if(!snapshot.document.canvases.has(canvasId))throw fault('canvas_not_loaded',[canvasId]);if(snapshot.runtime.loading&&(snapshot.runtime.loading.version!==6||!loadingQueryCoverage(snapshot).completeCanvasIds.includes(canvasId)))throw fault('loading_scope_incomplete',[canvasId]);const permission=options.policy?.({type:'changes.apply',changes:[]},snapshot.document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);return{enabled:true as const};}catch(error){const result=reject(error);return{enabled:false as const,code:result.code,targets:result.targets};}},
/** 同步图片加入入口:校验字节/身份,暂存 Blob,再由 callback 在同一事务创建资源引用。 /** 同步图片加入入口:校验字节/身份,暂存 Blob,再由 callback 在同一事务创建资源引用。
* reducer 同根建立上传意图和资源记录,后续异步摘要/上传;事务失败清理未被根采用的字节。 */ * reducer 同根建立上传意图和资源记录,后续异步摘要/上传;事务失败清理未被根采用的字节。
* callback 须同步使用 handles/tx 创建引用,不能 await 后继续提交;这是本地编辑接纳,不等上传完成。
* 文件名/MIME/长度在此筛选,实际图片解码与摘要另行验证;空净事务撤回本次待写 Blob。 */
stageFiles(files: readonly StagedFile[], callback: (resources: readonly ResourceHandle[], transaction: Transaction) => unknown) { stageFiles(files: readonly StagedFile[], callback: (resources: readonly ResourceHandle[], transaction: Transaction) => unknown) {
assertWritable(false);if(editor.getSnapshot().runtime.loading&&editor.getSnapshot().runtime.loading?.version!==6)throw fault('model_loading'); assertWritable(false);if(editor.getSnapshot().runtime.loading&&editor.getSnapshot().runtime.loading?.version!==6)throw fault('model_loading');
if (staged.length||adopting) throw fault('reentrant_dispatch'); if (staged.length||adopting) throw fault('reentrant_dispatch');
@@ -1358,7 +1491,9 @@ export async function openProjectEngine(options: EngineOptions) {
} finally { staged = []; stagedDefaultSizes.clear();for (const resource of resources) if (!editor.getSnapshot().runtime.resources.has(resource.id)) fileWrites.delete(resource.id); } } finally { staged = []; stagedDefaultSizes.clear();for (const resource of resources) if (!editor.getSnapshot().runtime.resources.has(resource.id)) fileWrites.delete(resource.id); }
}, },
/** 复用同工作区已存在的图片:先走服务授权/取字节,再用同步事务建立本项目绑定。 /** 复用同工作区已存在的图片:先走服务授权/取字节,再用同步事务建立本项目绑定。
* 这条路径不重复创建上传任务;采用失败则还原原来的 adoptedFiles 暂存值。 */ * 这条路径不重复创建上传任务;采用失败或无净操作则还原原 adoptedFiles 暂存值。
* await 授权后重查写入身份与取消,再设置同步 adopting 上下文;授权成功本身不发布元素/绑定。
* Promise 结束于本地事务结果,已接纳字节随后随相应 Root/depot cut 保存,不在此单独等待 durable 回执。 */
async adoptResource(id:Id,callback:(resource:ResourceHandle&{readonly asset:StoredAsset},transaction:Transaction)=>unknown,settings:{signal?:AbortSignal}={}):Promise<Result>{ async adoptResource(id:Id,callback:(resource:ResourceHandle&{readonly asset:StoredAsset},transaction:Transaction)=>unknown,settings:{signal?:AbortSignal}={}):Promise<Result>{
assertWritable(false);if(!depot||!options.assets?.authority||!options.storage.assetDepot)throw fault('asset_depot_unavailable');if(editor.getSnapshot().runtime.loading&&editor.getSnapshot().runtime.loading?.version!==6)throw fault('model_loading'); assertWritable(false);if(!depot||!options.assets?.authority||!options.storage.assetDepot)throw fault('asset_depot_unavailable');if(editor.getSnapshot().runtime.loading&&editor.getSnapshot().runtime.loading?.version!==6)throw fault('model_loading');
if(staged.length||adopting)throw fault('reentrant_dispatch'); if(staged.length||adopting)throw fault('reentrant_dispatch');
@@ -1379,7 +1514,8 @@ export async function openProjectEngine(options: EngineOptions) {
adoptionTasks.set(controller,task);void task.finally(()=>adoptionTasks.delete(controller)).catch(()=>{});return task; adoptionTasks.set(controller,task);void task.finally(()=>adoptionTasks.delete(controller)).catch(()=>{});return task;
}, },
/** 借出图片显示句柄:先 pin 防清理,再取得共享显示 lease,调用方用完必须 release。 /** 借出图片显示句柄:先 pin 防清理,再取得共享显示 lease,调用方用完必须 release。
* 外部 signal、项目关闭和失败路径也会释放;Blob URL 由缓存的最后一个使用者撤销。 */ * 外部 signal、项目关闭和失败路径也会释放;Blob URL 由缓存的最后一个使用者撤销。
* pin 是本项目瞬态计数,不另存到 Root;共享缓存 key 命中可复用 URL,新句柄仍独立检查身份/取消。 */
async displayResource(id: Id, settings: { signal?: AbortSignal } = {}) { async displayResource(id: Id, settings: { signal?: AbortSignal } = {}) {
assertDisplayIdentity(); assertDisplayIdentity();
if(collectionReservations.has(id))throw fault('resource_collection_in_progress',[id]); if(collectionReservations.has(id))throw fault('resource_collection_in_progress',[id]);
@@ -1409,7 +1545,8 @@ export async function openProjectEngine(options: EngineOptions) {
// 缓存键带授权/摘要/操作身份或退休代次,同 ID 换来源不会误用旧 URL。 // 缓存键带授权/摘要/操作身份或退休代次,同 ID 换来源不会误用旧 URL。
const key=JSON.stringify(binding?['shared-asset',id,binding.authority,binding.digest]:retained?['asset',id,retained.operationId,retained.retirement?.purgedAt??null]:orphan?['orphan',id,orphan.digest]:source?['source',id,source.sha256??null]:['unmanaged',id]); const key=JSON.stringify(binding?['shared-asset',id,binding.authority,binding.digest]:retained?['asset',id,retained.operationId,retained.retirement?.purgedAt??null]:orphan?['orphan',id,orphan.digest]:source?['source',id,source.sha256??null]:['unmanaged',id]);
/** 加载回调先找待保存/本地字节,再尝试共享仓库,最后使用服务端显示 URL。 /** 加载回调先找待保存/本地字节,再尝试共享仓库,最后使用服务端显示 URL。
* 孤立文件必须再次核对内容证据;所有 await 后复查项目身份,迟到 URL 不交给旧用户。 */ * 孤立文件重算摘要并核长度/MIME;普通资源/导入源不在这条显示路径统一重新哈希。
* 在字节采纳及最终句柄交付前检查身份/取消;depot.read 不是新的跨项目 adoption 授权。 */
lease=displayCache.acquire(displayOwner,key,async signal=>{ lease=displayCache.acquire(displayOwner,key,async signal=>{
let bytes=adoptedFiles.get(id)?.bytes??fileWrites.get(id)??await options.storage.readFile(leader,id); let bytes=adoptedFiles.get(id)?.bytes??fileWrites.get(id)??await options.storage.readFile(leader,id);
if(!bytes&&binding&&depot)bytes=await depot.read(leader,binding,signal); if(!bytes&&binding&&depot)bytes=await depot.read(leader,binding,signal);
@@ -1430,7 +1567,9 @@ export async function openProjectEngine(options: EngineOptions) {
}catch(error){release();assertDisplayIdentity();if(settings.signal?.aborted)throw fault('display_aborted');throw error;} }catch(error){release();assertDisplayIdentity();if(settings.signal?.aborted)throw fault('display_aborted');throw error;}
}, },
/** 用户主动按原始图片比例调整大小,形成独立编辑事务;与创建时默认尺寸补写区别在历史语义。 /** 用户主动按原始图片比例调整大小,形成独立编辑事务;与创建时默认尺寸补写区别在历史语义。
* 要求资源确属目标、代次未变且无手势,事务后核对实际几何,策略约束不满足则整次拒绝。 */ * 要求资源确属目标、代次未变且无手势,在事务草稿中核对实际几何,约束不满足则整次拒绝。
* 只从本项目 resources 的已解码/服务尺寸取比例,不对共享 binding 自动发起解码或下载。
* 和默认 grant 补写不同,这次是用户显式几何编辑,由普通 transaction 形成独立历史语义。 */
fitImage(target:Target,resourceId:Id):Result{ fitImage(target:Target,resourceId:Id):Result{
try{assertWritable();if(sessions.getSnapshot().gesture)throw fault('gesture_in_progress');const root=editor.getSnapshot(),element=root.document.elements.get(target.id),resource=root.runtime.resources.get(resourceId),size=resource?.image?.version===1&&resource.image.status==='ready'?resource.image:resource?.stored; try{assertWritable();if(sessions.getSnapshot().gesture)throw fault('gesture_in_progress');const root=editor.getSnapshot(),element=root.document.elements.get(target.id),resource=root.runtime.resources.get(resourceId),size=resource?.image?.version===1&&resource.image.status==='ready'?resource.image:resource?.stored;
if(!element||element.incarnation!==target.incarnation||element.structure!=='node'||options.registry.resolve(element)!=='known'||!options.registry.components.get(element.componentId)?.resourceFields?.includes('assetId')||element.data.assetId!==resourceId)throw fault('target_changed');if(!size||!validImageDimensions(size))throw fault('image_dimensions_unavailable'); if(!element||element.incarnation!==target.incarnation||element.structure!=='node'||options.registry.resolve(element)!=='known'||!options.registry.components.get(element.componentId)?.resourceFields?.includes('assetId')||element.data.assetId!==resourceId)throw fault('target_changed');if(!size||!validImageDimensions(size))throw fault('image_dimensions_unavailable');
@@ -1446,10 +1585,13 @@ export async function openProjectEngine(options: EngineOptions) {
/** 订阅本地耐久性变化,返回取消订阅;已关闭时不再登记监听。 */ /** 订阅本地耐久性变化,返回取消订阅;已关闭时不再登记监听。 */
subscribeDurability(listener:()=>void){if(closed)return()=>{};durabilityListeners.add(listener);return()=>{durabilityListeners.delete(listener);};}, subscribeDurability(listener:()=>void){if(closed)return()=>{};durabilityListeners.add(listener);return()=>{durabilityListeners.delete(listener);};},
/** 显式重试先解决恢复会话和保存屏障,再重置可重试摘要/解码/尺寸授权及来源验证。 /** 显式重试先解决恢复会话和保存屏障,再重置可重试摘要/解码/尺寸授权及来源验证。
* 最后恢复调度与生成追尾;复用原逻辑事实和冻结请求,不把失败重试变成重复创建。 */ * 最后恢复调度与生成追尾;复用原逻辑事实和冻结请求,不把失败重试变成重复创建。
* 只重置约定的可重试状态:不复活 revoked grant、不重启所有已终结导入,也不等待未来服务任务结束。 */
retry: async () => { if(recovery)await recovery.cancel();assertWritable(false);await persist();retryDefaultSizes();const runtime=editor.getSnapshot().runtime,ids=[...runtime.resources.values()].filter(resource=>!resource.digest&&runtime.ledger.operations.get(resource.operationId)?.blockers.some(code=>digestFailures.has(code))).map(resource=>resource.operationId);if(ids.length){mutate(ledger=>{for(const id of ids)ledger.block(id,['waiting_digest']);});prepareDigests();}if(options.images){editor.updateRuntime(previous=>{const resources=new Map(previous.resources);let changed=false;for(const [id,resource]of resources)if(resource.image?.version===1&&resource.image.status==='failed'){resources.set(id,freeze({...resource,image:{version:1 as const,status:'pending' as const}}));changed=true;}return changed?Object.freeze({...previous,resources:readMap(resources)}):previous;});prepareImages();}for(const job of editor.getSnapshot().runtime.imports?.values()??[])if(sourceNeedsSeal(job)){await importLoads.get(job.id);await resetImportReader(job.id);await loadImport(job.id);}await scheduler.resume();await runTracker.retry(); }, retry: async () => { if(recovery)await recovery.cancel();assertWritable(false);await persist();retryDefaultSizes();const runtime=editor.getSnapshot().runtime,ids=[...runtime.resources.values()].filter(resource=>!resource.digest&&runtime.ledger.operations.get(resource.operationId)?.blockers.some(code=>digestFailures.has(code))).map(resource=>resource.operationId);if(ids.length){mutate(ledger=>{for(const id of ids)ledger.block(id,['waiting_digest']);});prepareDigests();}if(options.images){editor.updateRuntime(previous=>{const resources=new Map(previous.resources);let changed=false;for(const [id,resource]of resources)if(resource.image?.version===1&&resource.image.status==='failed'){resources.set(id,freeze({...resource,image:{version:1 as const,status:'pending' as const}}));changed=true;}return changed?Object.freeze({...previous,resources:readMap(resources)}):previous;});prepareImages();}for(const job of editor.getSnapshot().runtime.imports?.values()??[])if(sourceNeedsSeal(job)){await importLoads.get(job.id);await resetImportReader(job.id);await loadImport(job.id);}await scheduler.resume();await runTracker.retry(); },
/** 排空当前执行链及其派生保存,根变化时再次检查直到稳定。 /** 排空当前执行链及其派生保存,根变化时再次检查直到稳定。
* 不等待未来轮询时刻或服务端生成终结;暂停任务和未到期重试也不等于已经同步成功。 */ * 不等待未来轮询时刻或服务端生成终结;暂停任务和未到期重试也不等于已经同步成功。
* 不主动取得 hold:期间新增本地根/任务可延长等待;以根 revision、pending 和任务表重新检查稳定性。
* adoption/copy 的拒绝用 allSettled 等收尾,其它链路仍可能抛错,settle 不是所有领域错误的汇总报告。 */
async settle() { async settle() {
let revision:number; let revision:number;
do { do {
@@ -1459,21 +1601,26 @@ export async function openProjectEngine(options: EngineOptions) {
await scheduler.settle();await runTracker.settle();await persist();await scheduler.settle();await persist(); await scheduler.settle();await runTracker.settle();await persist();await scheduler.settle();await persist();
} while(revision!==editor.getSnapshot().rootRevision||pending.size||writing||adoptionTasks.size||copyPreparations.size||importLoads.size||verificationTasks.size||digestTasks.size||imageTasks.size||sizingTask); } while(revision!==editor.getSnapshot().rootRevision||pending.size||writing||adoptionTasks.size||copyPreparations.size||importLoads.size||verificationTasks.size||digestTasks.size||imageTasks.size||sizingTask);
}, },
/** 关闭项目所有者:先阻止新准入,中止并排空执行器,再归还显示资源,最后可靠保存与释放领导权。 /** 关闭项目所有者:先 closing、请求中止后台工作,等待列出的本地任务,再释放显示/保存根/归还 leader。
* 保存失败时保留 leader/根与字节材料,允许再次 close 重试;视图 detach 不应调用本方法。 */ * scheduler/runTracker.stop 仅请求 abort 并阻止迟到采纳,这里未另外 await 它们的传输 Promise 全部结束。
* 末尾 persist 失败时保留 leader/根/待写字节与 closing 状态,后续 close 可重试;不是把实例恢复可编辑。
* 只有 closed 后重复调用立即返回,没有额外 close 单飞 Promise;宿主负责串行关闭,视图 detach 不应调用。 */
async close() { async close() {
if (closed) return; if (closed) return;
// 关闭标志先发布,再逐类取消/等待;共享 display/depot 只有本项目自己创建时才负责关闭整个实例。 // 关闭标志先发布,再逐类取消/等待;共享 display/depot 只有本项目自己创建时才负责关闭整个实例。
closing = true;notifyDurability();loadingReadController?.abort();await loadingRead?.catch(()=>{});unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const {controller}of verificationTasks.values())controller.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]);for(const controller of adoptionTasks.keys())controller.abort();await Promise.allSettled([...adoptionTasks.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop(); closing = true;notifyDurability();loadingReadController?.abort();await loadingRead?.catch(()=>{});unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const {controller}of verificationTasks.values())controller.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]);for(const controller of adoptionTasks.keys())controller.abort();await Promise.allSettled([...adoptionTasks.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop();
// runner close 保留已提交前缀;等所有载入/校验/摘要/解码结束后才能释放读取器和底层存储。 // 先等 runner.close、装配/验证,再关闭 proof reader,接着等摘要/解码及尺寸 timer。
// runner 关闭保留已经提交内容;上述步骤完成才进入最后根保存,不等待服务器生成任务本身终结。
await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(verificationTasks.size)await Promise.allSettled([...verificationTasks.values()].map(task=>task.promise));for(const {reader}of proofReaders.values())reader.close();proofReaders.clear();while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]);while(imageTasks.size)await Promise.allSettled([...imageTasks.values()]);while(sizingTask)await sizingTask; await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(verificationTasks.size)await Promise.allSettled([...verificationTasks.values()].map(task=>task.promise));for(const {reader}of proofReaders.values())reader.close();proofReaders.clear();while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]);while(imageTasks.size)await Promise.allSettled([...imageTasks.values()]);while(sizingTask)await sizingTask;
recovery?.controller.abort();recovery?.release(); recovery?.controller.abort();recovery?.release();
// 释放本项目所有显示句柄和 cache owner;其它项目仍可继续使用注入的共享缓存。 // 释放本项目所有显示句柄和 cache owner;其它项目仍可继续使用注入的共享缓存。
for (const release of [...displays]) release();displayCache.releaseOwner(displayOwner);if(!options.displays)displayCache.close(); for (const release of [...displays]) release();displayCache.releaseOwner(displayOwner);if(!options.displays)displayCache.close();
// Failure keeps the leader/recovery material available for an explicit retry of close. // 只有 persist 成功才取消根订阅、dispose editor、释放自建 depot 和 leader,再标 closed。
// 注入的 storage 连接及共享池由宿主/Workspace 管,engine 不在这里调用 storage.close。
await persist(); unsubscribe(); editor.dispose();if(!options.assetDepot)depot?.close();adoptedFiles.clear(); await leader.release(); closed = true;notifyDurability();durabilityListeners.clear(); await persist(); unsubscribe(); editor.dispose();if(!options.assetDepot)depot?.close();adoptedFiles.clear(); await leader.release(); closed = true;notifyDurability();durabilityListeners.clear();
}, },
}); });
// 工厂打开失败时释放已取得的领导权;成功返回后的关闭责任交给实例 close。 // 工厂 try 内失败至少归还已取得的 leader,并传播异常;不是已返回实例 close 的完整清理路径。
// 成功返回后由宿主调用实例 close 管理项目所有权,视图挂载/卸载不重建这个 leader。
} catch (error) { await leader.release(); throw error; } } catch (error) { await leader.release(); throw error; }
} }
+15 -6
View File
@@ -15,17 +15,24 @@ export type HistorySourceEvent={readonly record:OperationRecord|undefined;readon
/** 事件序列与可选 owning adoption 证据;count 标识虚拟事件前缀长度。 */ /** 事件序列与可选 owning adoption 证据;count 标识虚拟事件前缀长度。 */
export type HistoryEventSource={readonly events:readonly HistorySourceEvent[];readonly adoption?:{readonly count:number;readonly replay:HistoryReplay;readonly transition:HistoryTransition}}; export type HistoryEventSource={readonly events:readonly HistorySourceEvent[];readonly adoption?:{readonly count:number;readonly replay:HistoryReplay;readonly transition:HistoryTransition}};
/** Resolve either a native interval or the owning adoption's proved virtual events, followed by later native events. */ /** Resolve either a native interval or the owning adoption's proved virtual events, followed by later native events. */
/** 找最早有文档效果的 pending 所属事件源;缺失/歧义/陈旧审计不能猜,直接拒绝。 */ /** 从 pending 中有 changes 的最早 sequence 选择事件源;全为空则返回空列表,不读取其它证据。
* 无 owning adoption 时取原生区间;有时先取其虚拟 replay.events,再接 revision 之后的原生事件。
* 这里只解析形状/解释器/先后关系,不反向执行历史或核验事件文档效果;resolution 由消费规划器继续检查。
* pending 应来自同一已验证账本;ID 顺序检查不等于逐字段比对传入 intent 与 ledger 对象。
* 返回数组/envelope 未冻结,record/transition 沿用快照引用,调用方按不可变证据使用。 */
export function historyEventSource(snapshot:EngineSnapshot,pending:readonly OperationIntent[]):HistoryEventSource{ export function historyEventSource(snapshot:EngineSnapshot,pending:readonly OperationIntent[]):HistoryEventSource{
const content=pending.filter(intent=>intent.changes.length).sort((a,b)=>a.sequence-b.sequence),first=content[0];if(!first)return{events:[]}; const content=pending.filter(intent=>intent.changes.length).sort((a,b)=>a.sequence-b.sequence),first=content[0];if(!first)return{events:[]};
const records=snapshot.runtime.ledger.operations,audits=[...snapshot.runtime.recoveries.values()],owners=audits.filter(audit=>audit.decisions.some(decision=>decision.replacementId===first.id)); const records=snapshot.runtime.ledger.operations,audits=[...snapshot.runtime.recoveries.values()],owners=audits.filter(audit=>audit.decisions.some(decision=>decision.replacementId===first.id));
if(owners.length>1)throw fault('recovery_history_adoption_ambiguous',[first.id]); if(owners.length>1)throw fault('recovery_history_adoption_ambiguous',[first.id]);
/** 确认操作/尺寸效果解释器存在,未来数据保留不等于允许当前代码解释执行。 */ /** 要求 operationSchemaVersion=1,存在尺寸效果时验其解释器形状;不在此执行尺寸计划或校验操作类别。
* 未来数据可被上层保留,不表示此路径能据它恢复历史。 */
const interpreter=(record:OperationRecord)=>{if(record.intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[record.intent.id]);if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect);}; const interpreter=(record:OperationRecord)=>{if(record.intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[record.intent.id]);if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect);};
const pendingIds=new Set(content.map(intent=>intent.id)); const pendingIds=new Set(content.map(intent=>intent.id));
/** 校验事件不重复,且 pending 子序列与逻辑 sequence 精确一致,不能遗漏或交换。 */ /** 带 record 的所有事件 ID 不重复,抽出的 pending 子序列须与按 sequence 排序的 content 完全对应。
* adjustment 无 operation ID 不参与此计数;不要求所有账本 sequence 数值连续,也不检查服务执行顺序。 */
const order=(events:readonly HistorySourceEvent[])=>{const seen=new Set<string>(),active:string[]=[];for(const event of events)if(event.record){const id=event.record.intent.id;if(seen.has(id))throw fault('recovery_history_event_order',[id]);seen.add(id);if(pendingIds.has(id))active.push(id);}if(active.length!==content.length||active.some((id,index)=>id!==content[index].id))throw fault('recovery_history_event_order');}; const order=(events:readonly HistorySourceEvent[])=>{const seen=new Set<string>(),active:string[]=[];for(const event of events)if(event.record){const id=event.record.intent.id;if(seen.has(id))throw fault('recovery_history_event_order',[id]);seen.add(id);if(pendingIds.has(id))active.push(id);}if(active.length!==content.length||active.some((id,index)=>id!==content[index].id))throw fault('recovery_history_event_order');};
/** 从操作提取已验证的原生历史 transition,缺证据不构造推测事件。 */ /** 提取原生 transition 并验结构;未传领域 checkChanges,也不在这里重放其 before/after 元数据。
* 缺失证据直接拒绝,完整可回放性由 createHistoryJournalRecovery 的反向条件执行验证。 */
const native=(record:OperationRecord):HistorySourceEvent=>{interpreter(record);const transition=record.intent.historyTransition;if(!transition)throw fault('recovery_history_transition_missing',[record.intent.id]);validateHistoryTransition(transition);return{record,transition};}; const native=(record:OperationRecord):HistorySourceEvent=>{interpreter(record);const transition=record.intent.historyTransition;if(!transition)throw fault('recovery_history_transition_missing',[record.intent.id]);validateHistoryTransition(transition);return{record,transition};};
// 无 owning adoption 时用原生区间;区间内还有 adoption 就不能越过其边界盲目回放。 // 无 owning adoption 时用原生区间;区间内还有 adoption 就不能越过其边界盲目回放。
if(!owners.length){ if(!owners.length){
@@ -33,7 +40,8 @@ export function historyEventSource(snapshot:EngineSnapshot,pending:readonly Oper
const start=events[0]?.transition.rootRevision??Infinity;if(audits.some(audit=>(audit.historyTransition?.rootRevision??audit.baseRootRevision+1)>=start))throw fault('recovery_history_adoption_replay_required'); const start=events[0]?.transition.rootRevision??Infinity;if(audits.some(audit=>(audit.historyTransition?.rootRevision??audit.baseRootRevision+1)>=start))throw fault('recovery_history_adoption_replay_required');
for(let i=1;i<events.length;i++)if(events[i].transition.rootRevision<=events[i-1].transition.rootRevision)throw fault('recovery_history_event_order');order(events);return{events}; for(let i=1;i<events.length;i++)if(events[i].transition.rootRevision<=events[i-1].transition.rootRevision)throw fault('recovery_history_event_order');order(events);return{events};
} }
// 有 adoption 时要求其 replay/transition 齐全且没有更晚审计覆盖;先重建虚拟历史事实。 // owning audit 必须有 replay/transition,replay 绑定 baseRootRevision+1,且不允许其它更晚审计遮盖。
// 此处只组织虚拟事件,anchor 与整体 historyTransition 是否真的一致,留给日志规划器反向验证。
const audit=owners[0],revision=audit.baseRootRevision+1,replay=audit.historyReplay; const audit=owners[0],revision=audit.baseRootRevision+1,replay=audit.historyReplay;
if(!replay||!audit.historyTransition)throw fault('recovery_history_adoption_evidence_missing',[audit.id]);validateHistoryReplay(replay,revision);validateHistoryTransition(audit.historyTransition); if(!replay||!audit.historyTransition)throw fault('recovery_history_adoption_evidence_missing',[audit.id]);validateHistoryReplay(replay,revision);validateHistoryTransition(audit.historyTransition);
if(audits.some(other=>other.baseRootRevision+1>revision))throw fault('recovery_history_adoption_stale',[audit.id]); if(audits.some(other=>other.baseRootRevision+1>revision))throw fault('recovery_history_adoption_stale',[audit.id]);
@@ -42,7 +50,8 @@ export function historyEventSource(snapshot:EngineSnapshot,pending:readonly Oper
if(event.kind==='adjustment')return{record:undefined,transition:event.transition};const record=records.get(event.operationId);if(!record?.intent.changes.length)throw fault('history_replay_operation_missing',[event.operationId]);interpreter(record);witnessed.add(event.operationId);return{record,transition:event.transition}; if(event.kind==='adjustment')return{record:undefined,transition:event.transition};const record=records.get(event.operationId);if(!record?.intent.changes.length)throw fault('history_replay_operation_missing',[event.operationId]);interpreter(record);witnessed.add(event.operationId);return{record,transition:event.transition};
}); });
for(const intent of content)if(!witnessed.has(intent.id)&&!(intent.historyTransition&&intent.historyTransition.rootRevision>revision))throw fault('recovery_history_transition_missing',[intent.id]); for(const intent of content)if(!witnessed.has(intent.id)&&!(intent.historyTransition&&intent.historyTransition.rootRevision>revision))throw fault('recovery_history_transition_missing',[intent.id]);
// 只接 adoption revision 之后的原生事件,再核对 revision 单调与 pending 完整顺序。 // 后续原生事件按逻辑 sequence 排序,再要求 rootRevision 严格递增,不以网络回执顺序拼接。
// 本层不按 phase/resolution 过滤;消费端仍须拒绝非 pending 但结果未明确的历史贡献。
const later=[...records.values()].filter(record=>record.intent.changes.length&&record.intent.historyTransition&&record.intent.historyTransition.rootRevision>revision).sort((a,b)=>a.intent.sequence-b.intent.sequence).map(native); const later=[...records.values()].filter(record=>record.intent.changes.length&&record.intent.historyTransition&&record.intent.historyTransition.rootRevision>revision).sort((a,b)=>a.intent.sequence-b.intent.sequence).map(native);
let previous=revision;for(const event of later){if(event.transition.rootRevision<=previous)throw fault('recovery_history_event_order');previous=event.transition.rootRevision;} let previous=revision;for(const event of later){if(event.transition.rootRevision<=previous)throw fault('recovery_history_event_order');previous=event.transition.rootRevision;}
order([...events,...later]);return{events:[...events,...later],adoption:{count:events.length,replay,transition:audit.historyTransition}}; order([...events,...later]);return{events:[...events,...later],adoption:{count:events.length,replay,transition:audit.historyTransition}};
@@ -15,7 +15,11 @@ import type { EngineSnapshot } from './contracts.ts';
import { planCreationSize, validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts'; import { planCreationSize, validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
/** Native history events are reconstructed from evidence; all cursors remain private until the recovery root is accepted. */ /** Native history events are reconstructed from evidence; all cursors remain private until the recovery root is accepted. */
/** 创建一次恢复会话,要求有效 history 容量和完整事件证据;返回 project→accept→finish/evidence 协作接口。 */ /** 一次同步恢复规划会话:从已验证 snapshot/pending 取事件,逆向条件回放历史元数据得到 baseline。
* 不逆向执行文档 changes 或访问服务;document 的 fresh 重规划由 project/rebaseIntent 处理。
* 调用方按事件顺序为每项 project→分配替代操作→accept,最后 finish→evidence,并同一个恢复 Root 采纳。
* snapshot/registry 沿用不可变输入;方法会改变私有游标/history,失败不自动回滚或封闭会话,应丢弃本次规划对象。
* limit 约束重建 user 贡献时的 undo 容量,不代表所有外部事件证据都可截断;无异步预算/网络/持久化。 */
export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:readonly OperationIntent[],registry:Registry,limit:number){ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:readonly OperationIntent[],registry:Registry,limit:number){
if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_history_limit'); if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_history_limit');
// historyEventSource 可能提供 adoption 虚拟事件;给每项补私有 before/after 以便核对后续取舍。 // historyEventSource 可能提供 adoption 虚拟事件;给每项补私有 before/after 以便核对后续取舍。
@@ -31,15 +35,19 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
const baseline=history,replayEvents:HistoryReplayEvent[]=[],adoptionRevision=snapshot.rootRevision+1; const baseline=history,replayEvents:HistoryReplayEvent[]=[],adoptionRevision=snapshot.rootRevision+1;
/** 记录私有 history 的一次真实变化;关联操作即使 no-op 也留证据,调整事件不造 operation。 */ /** 记录私有 history 的一次真实变化;关联操作即使 no-op 也留证据,调整事件不造 operation。 */
function recordStep(before:HistoryState,entryId:Id|undefined,operationId?:Id){const transition=describeHistoryTransition(before,history,adoptionRevision,entryId,!!operationId);if(transition)replayEvents.push(operationId?freeze({kind:'operation' as const,operationId,transition}):freeze({kind:'adjustment' as const,transition}));} function recordStep(before:HistoryState,entryId:Id|undefined,operationId?:Id){const transition=describeHistoryTransition(before,history,adoptionRevision,entryId,!!operationId);if(transition)replayEvents.push(operationId?freeze({kind:'operation' as const,operationId,transition}):freeze({kind:'adjustment' as const,transition}));}
// next 是严格事件游标;touched 区分本轮已审阅项,retired 解释被清redo/容量淘汰或无效果移除的项。 // events 保存证据中的原始 before/after,history 是随取舍变化的新候选,不能混成同一份状态。
// next 指下个事件;touched 标本轮审阅项,retired 解释清 redo/容量淘汰/无效果删除;remap/sizeHistory 仅服务当前 project→accept。
let next=0;const touched=new Set<Id>(),retired=new Set<Id>();let remap:((change:Change)=>Change)|undefined; let next=0;const touched=new Set<Id>(),retired=new Set<Id>();let remap:((change:Change)=>Change)|undefined;
let sizeHistory: HistoryState | undefined; let sizeHistory: HistoryState | undefined;
/** 捕获双栈数组和 entry 表成员为新只读 history,不暴露私有可变容器。 */ /** 捕获双栈数组和 entry 表成员为新只读 history,不暴露私有可变容器。 */
const state=(undo:readonly Id[],redo:readonly Id[],entries:ReadonlyMap<Id,HistoryEntry>)=>Object.freeze({undo:freeze([...undo]),redo:freeze([...redo]),entries:readMap(entries)}); const state=(undo:readonly Id[],redo:readonly Id[],entries:ReadonlyMap<Id,HistoryEntry>)=>Object.freeze({undo:freeze([...undo]),redo:freeze([...redo]),entries:readMap(entries)});
/** 当前处理的 intent 必须恰好位于 next;不能按网络返回先后跳过或交换历史。 */ /** 只按下个 record 的 intent.id 检查位置,不逐字段比对外来 intent;协调器须传同快照中受信意图。
* 不能按网络返回顺序跳过/交换,adjustment 和已结算事件由 advance 先消费。 */
const eventFor=(intent:OperationIntent)=>{const event=events[next];if(!event||event.record?.intent.id!==intent.id)throw fault('recovery_history_event_order',[intent.id]);return event;}; const eventFor=(intent:OperationIntent)=>{const event=events[next];if(!event||event.record?.intent.id!==intent.id)throw fault('recovery_history_event_order',[intent.id]);return event;};
/** 处理一个已有分组项的确认前缀:纯字段仅保留 fresh 仍含其最终效果的字段链。 /** 审阅已有分组项:纯字段按 target ID/代次/field 的最后 after 判断 fresh 是否还保留该贡献。
* 几何/path 要求 owner 未变;结构性前缀必须可完整逆向执行,否则拒绝猜测部分历史。 */ * 最终值/存在性匹配才保留该字段的原整条链,不将每条都压成一次最后值;不匹配就移除此字段贡献。
* geometry/path 的 owner 还须与原 ACK 一致;含 owner/端点或整记录的前缀必须可完整逆向规划。
* 只返回 changes,不移动文档;不在这里恢复被远端覆盖的旧字段值。 */
function reviewPrefix(entry:HistoryEntry,document:Document):readonly Change[]{ function reviewPrefix(entry:HistoryEntry,document:Document):readonly Change[]{
const prefix=entry.changes; const prefix=entry.changes;
if(prefix.every(change=>change.kind==='field'&&!['ownerCanvasId','source','target'].includes(change.field))){ if(prefix.every(change=>change.kind==='field'&&!['ownerCanvasId','source','target'].includes(change.field))){
@@ -50,14 +58,17 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
} }
try{planHistoryChanges(document,invert(prefix),registry);}catch{throw fault('recovery_history_prefix_conflict',[entry.id]);}return prefix; try{planHistoryChanges(document,invert(prefix),registry);}catch{throw fault('recovery_history_prefix_conflict',[entry.id]);}return prefix;
} }
/** 丢弃一个未保留的 user 贡献时,仍审查同 entry 中已确认的旧前缀,不能连旧贡献一起删除。 */ /** 无 replacement 的 user 贡献不追加新 changes;首次触及且还在 undo 的旧项才审阅原前缀。
* 空前缀退休并从两栈移除,非空则保留;无 entryId(history=false)、已 touched 或不在 undo 时不重复处理。 */
function skipUser(intent:OperationIntent,document:Document){ function skipUser(intent:OperationIntent,document:Document){
const event=eventFor(intent),id=event.transition.entryId;if(!id)return;const entry=history.entries.get(id); const event=eventFor(intent),id=event.transition.entryId;if(!id)return;const entry=history.entries.get(id);
if(!entry){retired.add(id);return;} if(!entry){retired.add(id);return;}
if(touched.has(id)||!history.undo.includes(id))return; if(touched.has(id)||!history.undo.includes(id))return;
const changes=reviewPrefix(entry,document),entries=new Map(history.entries);if(changes.length)entries.set(id,freeze({...entry,changes}));else{entries.delete(id);retired.add(id);}history=state(history.undo.filter(key=>entries.has(key)),history.redo.filter(key=>entries.has(key)),entries); const changes=reviewPrefix(entry,document),entries=new Map(history.entries);if(changes.length)entries.set(id,freeze({...entry,changes}));else{entries.delete(id);retired.add(id);}history=state(history.undo.filter(key=>entries.has(key)),history.redo.filter(key=>entries.has(key)),entries);
} }
/** 加入保留/已确认的 user 贡献,沿用原 entry 元数据并维护分组顺序、清redo和容量淘汰。 */ /** 以原事件 after 的 label/group 等元数据加入贡献,append 必须与原有分组/栈顶关系相容。
* 先清 redo 并标 retired;pending 首次触及旧项才审阅 fresh 前缀,随后追加本轮 changes/operationId。
* 新项压 undo,超过 limit 从栈底淘汰;已退休原组可按证据重新建立,不借旧 ID 静默改写任意已有项。 */
function addUser(intent:OperationIntent,changes:readonly Change[],operationId:Id,pending:boolean,document?:Document){ function addUser(intent:OperationIntent,changes:readonly Change[],operationId:Id,pending:boolean,document?:Document){
const event=eventFor(intent),id=event.transition.entryId;if(!id)return; const event=eventFor(intent),id=event.transition.entryId;if(!id)return;
const original=event.after!.entries.get(id);if(!original)throw fault('recovery_history_transition_conflict',[id]); const original=event.after!.entries.get(id);if(!original)throw fault('recovery_history_transition_conflict',[id]);
@@ -77,7 +88,9 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
for(const patch of event.transition.entries){const current=entries.get(patch.id),original=before.entries.get(patch.id),desired=after.entries.get(patch.id);if(equal(current,desired))continue;if(!original||!equal(current,original))throw fault('recovery_history_adjustment_conflict',[patch.id]);if(desired)entries.set(patch.id,desired);else{entries.delete(patch.id);retired.add(patch.id);}} for(const patch of event.transition.entries){const current=entries.get(patch.id),original=before.entries.get(patch.id),desired=after.entries.get(patch.id);if(equal(current,desired))continue;if(!original||!equal(current,original))throw fault('recovery_history_adjustment_conflict',[patch.id]);if(desired)entries.set(patch.id,desired);else{entries.delete(patch.id);retired.add(patch.id);}}
history=state(history.undo.filter(id=>entries.has(id)),history.redo.filter(id=>entries.has(id)),entries); history=state(history.undo.filter(id=>entries.has(id)),history.redo.filter(id=>entries.has(id)),entries);
} }
/** 先消费下一个待审阅意图之前的已确认/调整事件;碰到另一 pending 不能越过它自动处理。 */ /** 消费目标 pending 之前(或 finish 时剩余)的 adjustment 与已有结果事件,逐个 recordStep/推进 next。
* 带 record 者不得是另一个 pending,resolution 只接受 applied/elided;不在此独立校验 phase/服务回执真实性。
* 默认尺寸、user、undo/redo 分别走专用逻辑,其它 origin 明确拒绝;不会重发这些已结算操作。 */
function advance(intent?:OperationIntent){ function advance(intent?:OperationIntent){
while(next<events.length&&(!intent||events[next].record?.intent.id!==intent.id)){ while(next<events.length&&(!intent||events[next].record?.intent.id!==intent.id)){
const event=events[next],record=event.record,before=history; const event=events[next],record=event.record,before=history;
@@ -90,7 +103,9 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
recordStep(before,event.transition.entryId,record?.intent.id);next++; recordStep(before,event.transition.entryId,record?.intent.id);next++;
} }
} }
/** 对应已验证前后 entry 的变化位置提取代次映射,供其它历史目标跟随显式 restore。 */ /** 按原/新 entry changes 的对应索引提取同 ID 的 old incarnation→new incarnation Map。
* 不是任意两组 changes 的通用差异算法,依赖证据保证对应位置语义一致;不按资源/正文字符串猜身份。
* 返回纯 change 改写函数供其它历史目标跟随 restore,未映射代次保留原值。 */
function incarnationRemap(before:HistoryEntry,after:HistoryEntry){ function incarnationRemap(before:HistoryEntry,after:HistoryEntry){
const maps=new Map<Id,Map<number,number>>(); const maps=new Map<Id,Map<number,number>>();
/** 仅同 ID 且代次确实改变时登记,不能把两个不同元素意外映为同一生命周期。 */ /** 仅同 ID 且代次确实改变时登记,不能把两个不同元素意外映为同一生命周期。 */
@@ -99,7 +114,8 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
/** 仅重写 field target 与整元素 before/after 代次,不修改 canvas 记录或冻结服务请求。 */ /** 仅重写 field target 与整元素 before/after 代次,不修改 canvas 记录或冻结服务请求。 */
return(change:Change):Change=>{const record=<T extends {id:Id;incarnation:number}>(value:T|null)=>value?{...value,incarnation:maps.get(value.id)?.get(value.incarnation)??value.incarnation}:null;if(change.kind==='field')return freeze({...change,target:record(change.target)!});if(change.kind==='element')return freeze({...change,before:record(change.before),after:record(change.after)});return change;}; return(change:Change):Change=>{const record=<T extends {id:Id;incarnation:number}>(value:T|null)=>value?{...value,incarnation:maps.get(value.id)?.get(value.incarnation)??value.incarnation}:null;if(change.kind==='field')return freeze({...change,target:record(change.target)!});if(change.kind==='element')return freeze({...change,before:record(change.before),after:record(change.after)});return change;};
} }
/** 已 applied 的 undo/redo 是事实:只有当前审阅前缀仍表示完全相同效果才允许移动栈。 */ /** 已有 applied/elided 结果的遍历必须命中当前栈顶,且原事件前后变化与审阅后的正向效果精确对应。
* 只重建历史移动/关联并为其它项重映代次,不执行已结算文档变化,也不改旧 operation/request。 */
function addConfirmedTraversal(intent:OperationIntent){ function addConfirmedTraversal(intent:OperationIntent){
const event=eventFor(intent),id=event.transition.entryId,undo=[...history.undo],redo=[...history.redo],source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo; const event=eventFor(intent),id=event.transition.entryId,undo=[...history.undo],redo=[...history.redo],source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo;
if(!id||source.at(-1)!==id)throw fault('recovery_history_traversal_order',id?[id]:[]); if(!id||source.at(-1)!==id)throw fault('recovery_history_traversal_order',id?[id]:[]);
@@ -110,7 +126,8 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
const entries=new Map(history.entries),map=incarnationRemap(before,after);for(const[otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(map)})); const entries=new Map(history.entries),map=incarnationRemap(before,after);for(const[otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(map)}));
entries.set(id,freeze({...entry,changes,operationIds:[...entry.operationIds,intent.id]}));source.pop();destination.push(id);history=state(undo,redo,entries); entries.set(id,freeze({...entry,changes,operationIds:[...entry.operationIds,intent.id]}));source.pop();destination.push(id);history=state(undo,redo,entries);
} }
/** 已确认默认尺寸效果只修原 entry/后续条件,不移动双栈;当前历史必须仍匹配原前置证据。 */ /** 已结算默认尺寸使用原 transition 的 entry changes 修正,要求该事件两栈不动、当前 changes/label/group 仍匹配。
* 不再次运行尺寸文档命令;只给所属创建项追加逻辑操作关联,其它被修正项保留各自关联。 */
function addConfirmedSize(intent: OperationIntent) { function addConfirmedSize(intent: OperationIntent) {
validateCreationSizeEffect(intent.historyEffect); validateCreationSizeEffect(intent.historyEffect);
const event = eventFor(intent), entries = new Map(history.entries); const event = eventFor(intent), entries = new Map(history.entries);
@@ -123,7 +140,10 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
history = state(history.undo, history.redo, entries); history = state(history.undo, history.redo, entries);
} }
return{ return{
/** 为当前 intent 计算文档候选,不立即移动对应新历史;accept 在替代操作身份确定后接续。 */ /** 先 advance 结算前置事件,再为当前 pending 返回 fresh 文档候选;自身对应的历史贡献等 accept。
* 尺寸专用 planner 暂存 sizeHistory;touched 遍历按审阅后的 entry 重新规划,其它 keep 意图走 rebaseIntent。
* 丢弃返回零文档变化但仍可能校验原遍历可行性;空 changes 直接返回,不消费事件游标。
* 此调用可能已经改变前置私有 history,不能当无状态 query,也不能并发交错不同项的 project/accept。 */
project(document:Document,intent:OperationIntent,context:IntentContext,keep:boolean){ project(document:Document,intent:OperationIntent,context:IntentContext,keep:boolean){
if(!intent.changes.length)return{document,changes:[] as readonly Change[]};advance(intent);const event=eventFor(intent),id=event.transition.entryId;remap=undefined;sizeHistory=undefined; if(!intent.changes.length)return{document,changes:[] as readonly Change[]};advance(intent);const event=eventFor(intent),id=event.transition.entryId;remap=undefined;sizeHistory=undefined;
// 尺寸效果通过专用 planner 重算 history;keep=false 不发布旧自动尺寸。 // 尺寸效果通过专用 planner 重算 history;keep=false 不发布旧自动尺寸。
@@ -150,7 +170,10 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
// 普通保留意图经显式审阅后重新规划;丢弃返回零文档变化,不自行伪造成功回执。 // 普通保留意图经显式审阅后重新规划;丢弃返回零文档变化,不自行伪造成功回执。
return keep?rebaseIntent(document,intent,context,registry):{document,changes:[] as readonly Change[]}; return keep?rebaseIntent(document,intent,context,registry):{document,changes:[] as readonly Change[]};
}, },
/** 接受 project 的取舍/替代操作,更新私有双栈并记 replay;每项完成后 next 单调前进。 */ /** 在协调器确定 replacement 后,按当前 project 结果更新私有历史、记 replay 并将 next 加一。
* replacement/keep/before/after 须与此前 project 配对;接口不独立比对该语义或自动生成新 ID/账本记录。
* user 按贡献维护组,尺寸采用暂存计划,keep 遍历有剩余效果才移到另一栈,否则退休该项。
* 不发布 Root、不持久化审计;调用失败后不要继续使用可能已部分推进的同一规划会话。 */
accept(intent:OperationIntent,replacement:OperationIntent|undefined,keep:boolean,before:Document,after:Document){ accept(intent:OperationIntent,replacement:OperationIntent|undefined,keep:boolean,before:Document,after:Document){
if(!intent.changes.length)return;const previous=history,event=eventFor(intent),id=event.transition.entryId; if(!intent.changes.length)return;const previous=history,event=eventFor(intent),id=event.transition.entryId;
if (intent.historyEffect) { if (intent.historyEffect) {
@@ -174,9 +197,12 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
} }
recordStep(previous,id,replacement?.id);next++; recordStep(previous,id,replacement?.id);next++;
}, },
/** 消费剩余已确认事件后返回最终候选 history;未审阅 pending 仍会被 advance 拒绝。 */ /** 消费剩余已结算/调整事件并返回候选 history;未处理 pending 会拒绝,不静默跳过。
* 不设置 finished 锁或自动保存,协调器仍需整体校验并原子采纳文档/历史/账本。 */
finish(){advance();return history;}, finish(){advance();return history;},
/** 输出原 history→baseline 的 anchor 及有序增量,和本次 adoption 一起持久化以支持再次重开。 */ /** 完成 finish 后作为最后步骤输出证据:原 history→baseline 的 anchor,加本次有序元数据增量。
* 所有 transition 绑定 snapshot.rootRevision+1,不表示多次服务写入或已持久化成功。
* freeze 直接包含 replayEvents 数组,会冻结该会话的事件缓冲;提前调用后不能再向它追加规划事件。 */
evidence():HistoryReplay{return freeze({version:1 as const,anchor:describeHistoryTransition(snapshot.history,baseline,adoptionRevision,undefined,true)!,events:replayEvents});}, evidence():HistoryReplay{return freeze({version:1 as const,anchor:describeHistoryTransition(snapshot.history,baseline,adoptionRevision,undefined,true)!,events:replayEvents});},
}; };
} }
+6 -2
View File
@@ -11,13 +11,16 @@ export type HistoryReplayEvent={readonly kind:'operation';readonly operationId:I
/** version=1 的 anchor+有序事件;所有 transition 绑定同一次 adoption Root revision。 */ /** version=1 的 anchor+有序事件;所有 transition 绑定同一次 adoption Root revision。 */
export type HistoryReplay={readonly version:1;readonly anchor:HistoryTransition;readonly events:readonly HistoryReplayEvent[]}; export type HistoryReplay={readonly version:1;readonly anchor:HistoryTransition;readonly events:readonly HistoryReplayEvent[]};
/** Local evidence only. The anchor and event deltas explain one atomic adoption, not new service writes. */ /** Local evidence only. The anchor and event deltas explain one atomic adoption, not new service writes. */
/** 检查证据版本/JSON、anchor无entry、事件 revision 一致和 operation ID 唯一;未知版本拒绝解释。 */ /** 检查证据版本/JSON、anchor 无 entry、事件 revision 一致及 operation ID 唯一;未知版本拒绝解释。
* revision 由调用方提供;不查询真实发布日志,checkChanges 缺省时不完整解释各条领域 Change。 */
export function validateHistoryReplay(value:unknown,revision:number,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryReplay{ export function validateHistoryReplay(value:unknown,revision:number,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryReplay{
if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const raw=value as Record<string,unknown>; if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const raw=value as Record<string,unknown>;
if(raw.version!==1)throw fault('history_replay_interpreter_unavailable');if(!validJson(value))throw fault('invalid_history_replay');validateHistoryTransition(raw.anchor,checkChanges);if(raw.anchor.rootRevision!==revision||raw.anchor.entryId!==undefined||!Array.isArray(raw.events))throw fault('invalid_history_replay');const ids=new Set<Id>(); if(raw.version!==1)throw fault('history_replay_interpreter_unavailable');if(!validJson(value))throw fault('invalid_history_replay');validateHistoryTransition(raw.anchor,checkChanges);if(raw.anchor.rootRevision!==revision||raw.anchor.entryId!==undefined||!Array.isArray(raw.events))throw fault('invalid_history_replay');const ids=new Set<Id>();
for(const value of raw.events){if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const event=value as Record<string,unknown>;validateHistoryTransition(event.transition,checkChanges);if(event.transition.rootRevision!==revision)throw fault('invalid_history_replay');if(event.kind==='operation'){if(typeof event.operationId!=='string'||!event.operationId||ids.has(event.operationId))throw fault('invalid_history_replay');ids.add(event.operationId);}else if(event.kind!=='adjustment'||event.operationId!==undefined)throw fault('invalid_history_replay');} for(const value of raw.events){if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const event=value as Record<string,unknown>;validateHistoryTransition(event.transition,checkChanges);if(event.transition.rootRevision!==revision)throw fault('invalid_history_replay');if(event.kind==='operation'){if(typeof event.operationId!=='string'||!event.operationId||ids.has(event.operationId))throw fault('invalid_history_replay');ids.add(event.operationId);}else if(event.kind!=='adjustment'||event.operationId!==undefined)throw fault('invalid_history_replay');}
} }
/** 纯条件回放 anchor/events;可选校验逻辑 ID 存在,带 entry 的事件必须在该项关联同 operation。 */ /** 条件回放历史元数据:先正向 anchor,再按 events 顺序更新两栈/entries,不执行文档 changes 或网络。
* 用 anchor 自身 revision 调结构校验,不独自核对外部 adoption 的 Root;调用方另需绑定那次发布。
* hasOperation 可选检查 ID 存在,带 entry 的操作事件需关联同 ID;不在此比较逻辑意图内容与 transition 是否语义等价。 */
export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,hasOperation?:(id:Id)=>boolean):HistoryState{ export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,hasOperation?:(id:Id)=>boolean):HistoryState{
validateHistoryReplay(replay,replay.anchor.rootRevision);let history=applyHistoryTransition(before,replay.anchor,'forward'); validateHistoryReplay(replay,replay.anchor.rootRevision);let history=applyHistoryTransition(before,replay.anchor,'forward');
for(const event of replay.events){if(event.kind==='operation'&&hasOperation&&!hasOperation(event.operationId))throw fault('history_replay_operation_missing',[event.operationId]);history=applyHistoryTransition(history,event.transition,'forward');if(event.kind==='operation'&&event.transition.entryId&&!history.entries.get(event.transition.entryId)?.operationIds.includes(event.operationId))throw fault('history_replay_operation_mismatch',[event.operationId]);} for(const event of replay.events){if(event.kind==='operation'&&hasOperation&&!hasOperation(event.operationId))throw fault('history_replay_operation_missing',[event.operationId]);history=applyHistoryTransition(history,event.transition,'forward');if(event.kind==='operation'&&event.transition.entryId&&!history.entries.get(event.transition.entryId)?.operationIds.includes(event.operationId))throw fault('history_replay_operation_mismatch',[event.operationId]);}
@@ -25,5 +28,6 @@ export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,h
} }
/** 比较两栈和全部 entry;entry 表按 ID 排序,避免 Map 插入顺序差异被误认作历史不一致。 */ /** 比较两栈和全部 entry;entry 表按 ID 排序,避免 Map 插入顺序差异被误认作历史不一致。 */
export function sameHistory(left:HistoryState,right:HistoryState):boolean{ export function sameHistory(left:HistoryState,right:HistoryState):boolean{
// 仅规范化 entries 表的顺序;undo/redo 数组顺序仍按栈语义精确比较,不能排序后当集合。
const value=(history:HistoryState)=>({undo:history.undo,redo:history.redo,entries:[...history.entries].sort(([a],[b])=>a.localeCompare(b))});return equal(value(left),value(right)); const value=(history:HistoryState)=>({undo:history.undo,redo:history.redo,entries:[...history.entries].sort(([a],[b])=>a.localeCompare(b))});return equal(value(left),value(right));
} }
+16 -6
View File
@@ -11,7 +11,10 @@ import { createScopedLoader, type LoadPort, type ScopedLoader } from './loader.t
import type { CanonicalReadUpdate } from './contracts.ts'; import type { CanonicalReadUpdate } from './contracts.ts';
/** Host lifetime matches the signed-in application, independently of navigation. */ /** Host lifetime matches the signed-in application, independently of navigation. */
/** 宿主注入 workspace/loader 工厂;current 闭包绑定精确账号与 epoch,作为异步结果准入围栏。 */ /** 一个应用级宿主:workspaces/readers 按当前账号下的 workspaceId 复用,retired 单独保留待关闭实例。
* registry 由注入方按不可变定义合同共享,本函数不冻结或复制它;也不保存 token/密码等认证凭证。
* current 闭包绑定账号+authEpoch,拒绝旧身份结果;业务/存储实际副作用仍需 adapters 自行遵守该围栏。
* 后台退休关闭可与新身份运行并行;Host 不为 closeRetired 建单飞锁,workspace.close 应支持重复/并发调用。 */
export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; modelLoader?(accountId:Id,workspaceId:Id,authEpoch:number,current:()=>boolean):LoadPort<CanonicalReadUpdate>; onError?: (error: unknown) => void; onObserverError?: (error: unknown) => void }) { export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; modelLoader?(accountId:Id,workspaceId:Id,authEpoch:number,current:()=>boolean):LoadPort<CanonicalReadUpdate>; onError?: (error: unknown) => void; onObserverError?: (error: unknown) => void }) {
let accountId: Id | null = null, authEpoch = 0, disposed = false; let accountId: Id | null = null, authEpoch = 0, disposed = false;
// active 表只含当前身份,retired 持有尚未成功关闭的旧 runtime,不能因换账号就丢掉待保存内容。 // active 表只含当前身份,retired 持有尚未成功关闭的旧 runtime,不能因换账号就丢掉待保存内容。
@@ -25,7 +28,8 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
}; };
/** 同步撤销当前身份:先换 epoch,再关闭读取句柄/移走 workspace;实际存储收尾另行等待。 */ /** 同步撤销当前身份:先换 epoch,再关闭读取句柄/移走 workspace;实际存储收尾另行等待。 */
function retire() { authEpoch++; accountId = null; for (const reader of readers.values()) reader.close();readers.clear();for (const runtime of workspaces.values()) retired.add(runtime); workspaces.clear(); } function retire() { authEpoch++; accountId = null; for (const reader of readers.values()) reader.close();readers.clear();for (const runtime of workspaces.values()) retired.add(runtime); workspaces.clear(); }
/** 账号变化才退休旧作用域;后台关闭错误走 onError,报告失败不能改变新账号的身份事实。 */ /** 相同账号复用 epoch,换账号先 retire 再同步启用新账号,不等待旧 workspace 关闭。
* 旧关闭失败通过 onError 报告且保留 retired,后续身份退休/close 可重试;异常报告也被隔离。 */
function activate(account: Id) { function activate(account: Id) {
if (disposed) throw fault('host_disposed'); if (disposed) throw fault('host_disposed');
if (accountId !== account) { if (accountId !== account) {
@@ -42,7 +46,10 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
identity: () => Object.freeze({ accountId, authEpoch }), identity: () => Object.freeze({ accountId, authEpoch }),
/** 返回当前 workspace 表的安全只读快照,外部不能手动增删运行实例。 */ /** 返回当前 workspace 表的安全只读快照,外部不能手动增删运行实例。 */
getWorkspaces: () => readMap(workspaces), getWorkspaces: () => readMap(workspaces),
/** 通过共享 scoped loader 读 canonical 源;增量可产生只读预览,terminal 完整后才返回最终模型。 */ /** 只读加载不创建 workspace engine/storage;每 workspace 复用 scoped loader、每订阅维护独立 preview/seen。
* 导航代次属于订阅者,授权 epoch 才参与共享身份;已完成读取不作长期缓存(maxCacheEntries=0)。
* ready 后核对身份与终结完整标志,再返回 ready 模型或其它终态;不是在 Host 再做全量 Document/schema 校验。
* preview/observer 出错只停止此订阅预览并上报 onObserverError,不取消其它订阅或替换共享读取事实。 */
async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:LiveModelPreview)=>void}={}) { async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:LiveModelPreview)=>void}={}) {
activate(account); activate(account);
const readEpoch=authEpoch; const readEpoch=authEpoch;
@@ -60,7 +67,8 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
}; };
// key 纳入账号、workspace、格式、查询与 authEpoch;view 导航代次不替代授权代次。 // key 纳入账号、workspace、格式、查询与 authEpoch;view 导航代次不替代授权代次。
const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal,onUpdate}); const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal,onUpdate});
// ready 后再查身份和 terminal/complete;finally 始终释放本订阅,不能泄漏 reader 引用。 // handle 成功取得后,ready 成功/拒绝都在 finally 释放订阅并 close preview;不关闭共享 reader/其它订阅。
// 端口返回模型的领域合法性仍由 canonical adapter/预览解释器和后续 engine 准入负责。
try{const snapshot=await handle.ready;if(disposed||accountId!==account||authEpoch!==readEpoch)throw fault('identity_changed');const last=snapshot.chunks.at(-1);if(!last||!last.complete||last.value.status==='receiving')throw fault('incomplete_load');return last.value.status==='ready'?Object.freeze({...last.value,sourceToken:last.snapshotToken}):last.value;}finally{handle.release();preview?.close();} try{const snapshot=await handle.ready;if(disposed||accountId!==account||authEpoch!==readEpoch)throw fault('identity_changed');const last=snapshot.chunks.at(-1);if(!last||!last.complete||last.value.status==='receiving')throw fault('incomplete_load');return last.value.status==='ready'?Object.freeze({...last.value,sourceToken:last.snapshotToken}):last.value;}finally{handle.release();preview?.close();}
}, },
/** 按当前账号激活并懒创建 workspace;导航到同工作区不会重新创建资源池或项目。 */ /** 按当前账号激活并懒创建 workspace;导航到同工作区不会重新创建资源池或项目。 */
@@ -74,9 +82,11 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
} }
return runtime; return runtime;
}, },
/** 退出身份但保留 host 可再次绑定;返回旧 runtime 收尾 Promise,让宿主处理未保存错误。 */ /** 同步清账号并递增 epoch,关闭读句柄,再等待退休 runtime;不永久 disposed,之后可重新绑定。
* 不等待 close 成功也已使旧 current 失效;失败不会恢复旧身份或删除退休实例。 */
retireIdentity() { retire(); return closeRetired(); }, retireIdentity() { retire(); return closeRetired(); },
/** 永久关闭 host 并退休全部作用域;失败仍可再次调用 close 重试退休集合。 */ /** 永久 disposed 后再退休/等待关闭,后续 workspace/bind/load 准入拒绝。
* 失败可再次 close 重试;每次都会再次 retire/推进 epoch,返回值不是共享的单飞关闭 Promise。 */
close() { disposed = true; retire(); return closeRetired(); }, close() { disposed = true; retire(); return closeRetired(); },
}); });
} }
+12 -4
View File
@@ -15,17 +15,25 @@ export type ImageDecoderPort={decode(bytes:Blob,signal?:AbortSignal):Promise<Ima
/** 这里只验证正安全整数和乘积可安全表示,不等同于已应用产品的像素预算。 */ /** 这里只验证正安全整数和乘积可安全表示,不等同于已应用产品的像素预算。 */
export function validImageDimensions(value:unknown):value is ImageDimensions{const size=value as ImageDimensions;return !!size&&Number.isSafeInteger(size.width)&&size.width>0&&Number.isSafeInteger(size.height)&&size.height>0&&Number.isSafeInteger(size.width*size.height);} export function validImageDimensions(value:unknown):value is ImageDimensions{const size=value as ImageDimensions;return !!size&&Number.isSafeInteger(size.width)&&size.width>0&&Number.isSafeInteger(size.height)&&size.height>0&&Number.isSafeInteger(size.width*size.height);}
/** Bounded read-only compute. Cancellation rejects the caller; a slot remains occupied until execution settles. */ /** Bounded read-only compute. Cancellation rejects the caller; a slot remains occupied until execution settles. */
/** 默认2并发/64等待,最多8并发;取消结果与执行容量分开,防止无视 abort 的任务挤爆池。 */ /** 默认 2 并发/64 等待,最多 8 并发;waiting/active 各用按插入顺序的 Map,不是持久任务队列。
* maxQueued 只限制尚未进入 active 的任务;active 包括已取消但执行器仍未退出的任务。
* 池只检查 Blob 的 image/ MIME 前缀和尺寸数值,不限制文件字节/实际像素预算,也不缓存解码结果。 */
export function createImageDecodePool(options:{execute:(bytes:Blob,signal:AbortSignal)=>Promise<ImageDimensions>;concurrency?:number;maxQueued?:number}):ImageDecoderPort{ export function createImageDecodePool(options:{execute:(bytes:Blob,signal:AbortSignal)=>Promise<ImageDimensions>;concurrency?:number;maxQueued?:number}):ImageDecoderPort{
const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64; const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64;
if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_image_decode_budget'); if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_image_decode_budget');
/** 每个解码请求独立控制器/完成标记;cleanup 解除外部取消监听,迟到执行结果不再采纳。 */ /** 每个解码请求独立控制器/完成标记;cleanup 解除外部取消监听,迟到执行结果不再采纳。 */
type Task={id:number;bytes:Blob;controller:AbortController;resolve:(size:ImageDimensions)=>void;reject:(error:unknown)=>void;done:boolean;cleanup():void}; type Task={id:number;bytes:Blob;controller:AbortController;resolve:(size:ImageDimensions)=>void;reject:(error:unknown)=>void;done:boolean;cleanup():void};
const waiting=new Map<number,Task>(),active=new Map<number,Task>();let next=0,closed=false; const waiting=new Map<number,Task>(),active=new Map<number,Task>();let next=0,closed=false;
/** 幂等 settle 调用者,成功捕获冻结尺寸;不提前归还仍在执行的 active 槽。 */ /** 幂等 settle 调用者并清理等待项;成功浅复制/冻结执行器结果,扩展属性不会被过滤。
* error 用真值区分成败,执行器应以 Error 等真值拒绝;falsy rejection 不保证按失败解释。
* 不删除 active,取消后的名额仍要等 execute 链的 finally 才归还。 */
function finish(task:Task,error?:unknown,size?:ImageDimensions){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(freeze({...size!}));} function finish(task:Task,error?:unknown,size?:ImageDimensions){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(freeze({...size!}));}
/** FIFO 取任务并在微任务执行,校验返回尺寸,最后 finally 删除 active 再继续泵;异常不毒化后续队列。 */ /** 取 waiting 首项立即占 active,再在微任务调用 execute;启动前复查内部 abort。
* 成功尺寸先校验再 finish;正常错误交 finish,finally 负责释放 active 并继续 FIFO。
* execute 若不响应取消且永不 settle,名额会持续占用;池没有超时或强制终止通用执行器的能力。 */
function pump(){while(!closed&&active.size<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task);void Promise.resolve().then(()=>{if(task.controller.signal.aborted)throw fault('image_decode_aborted');return options.execute(task.bytes,task.controller.signal);}).then(size=>{if(!validImageDimensions(size))throw fault('invalid_image_dimensions');finish(task,undefined,size);}).catch(error=>finish(task,error)).finally(()=>{active.delete(task.id);pump();});}} function pump(){while(!closed&&active.size<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task);void Promise.resolve().then(()=>{if(task.controller.signal.aborted)throw fault('image_decode_aborted');return options.execute(task.bytes,task.controller.signal);}).then(size=>{if(!validImageDimensions(size))throw fault('invalid_image_dimensions');finish(task,undefined,size);}).catch(error=>finish(task,error)).finally(()=>{active.delete(task.id);pump();});}}
/** decode 校验图片 MIME/容量并建立任务;close 取消所有调用结果,active 实际结束仍由 finally 收尾。 */ /** decode 拒绝关闭/预先取消/非图片 Blob/等待容量已满,随后创建任务、绑定外部 abort 并入 waiting。
* abort 转发给内部 controller,立即拒绝调用者并移除 waiting;运行中的 active 留待 finally。
* close 幂等取消全部调用结果并禁止继续泵,不返回 drain Promise,也不证明所有 execute 已停止。 */
return Object.freeze({decode(bytes:Blob,signal?:AbortSignal){if(closed)return Promise.reject(fault('image_decoder_closed'));if(signal?.aborted)return Promise.reject(fault('image_decode_aborted'));if(!(bytes instanceof Blob)||!bytes.type.startsWith('image/'))return Promise.reject(fault('invalid_image_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('image_decode_capacity'));return new Promise<ImageDimensions>((resolve,reject)=>{const abort=()=>{task.controller.abort();finish(task,fault('image_decode_aborted'));pump();},task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,done:false,cleanup:()=>signal?.removeEventListener('abort',abort)};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();});},close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('image_decode_aborted'));}}}); return Object.freeze({decode(bytes:Blob,signal?:AbortSignal){if(closed)return Promise.reject(fault('image_decoder_closed'));if(signal?.aborted)return Promise.reject(fault('image_decode_aborted'));if(!(bytes instanceof Blob)||!bytes.type.startsWith('image/'))return Promise.reject(fault('invalid_image_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('image_decode_capacity'));return new Promise<ImageDimensions>((resolve,reject)=>{const abort=()=>{task.controller.abort();finish(task,fault('image_decode_aborted'));pump();},task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,done:false,cleanup:()=>signal?.removeEventListener('abort',abort)};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();});},close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('image_decode_aborted'));}}});
} }
+4 -1
View File
@@ -4,11 +4,14 @@
*/ */
/** 仅声明本入口需要的 Worker 消息能力,不导入 renderer 或 DOM 状态。 */ /** 仅声明本入口需要的 Worker 消息能力,不导入 renderer 或 DOM 状态。 */
const worker=globalThis as unknown as{onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void}; const worker=globalThis as unknown as{onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void};
/** 接受正确版本/id/Blob,按 from-image 选项解码;失败回稳定 image_decode_failed。 */ /** 仅接收 version=1、安全整数 id、Blob;非法请求直接忽略,不回错误消息。
* 不在此限制 id 为正数、MIME、字节或像素预算;单任务 Worker 的创建/并发/取消由宿主控制。
* 异步 handler 本身不排队,重复消息可并行解码;正常宿主每 Worker 只发一个请求。 */
worker.onmessage=async event=>{ worker.onmessage=async event=>{
const {version,id,bytes}=event.data??{};if(version!==1||!Number.isSafeInteger(id)||!(bytes instanceof Blob))return; const {version,id,bytes}=event.data??{};if(version!==1||!Number.isSafeInteger(id)||!(bytes instanceof Blob))return;
// bitmap 是临时原生资源,尺寸读取即使抛错也由 finally close;成功只发送 width/height 和输入长度。 // bitmap 是临时原生资源,尺寸读取即使抛错也由 finally close;成功只发送 width/height 和输入长度。
try{const bitmap=await createImageBitmap(bytes,{imageOrientation:'from-image'});let width:number,height:number;try{width=bitmap.width;height=bitmap.height;}finally{bitmap.close();}worker.postMessage({version:1,id,byteLength:bytes.size,width,height});} try{const bitmap=await createImageBitmap(bytes,{imageOrientation:'from-image'});let width:number,height:number;try{width=bitmap.width;height=bitmap.height;}finally{bitmap.close();}worker.postMessage({version:1,id,byteLength:bytes.size,width,height});}
// 解码/读取尺寸/close/成功回信失败均尝试发送稳定错误;若通信本身不可用,宿主仍需取消/错误事件收尾。
catch{worker.postMessage({version:1,id,byteLength:bytes.size,error:'image_decode_failed'});} catch{worker.postMessage({version:1,id,byteLength:bytes.size,error:'image_decode_failed'});}
}; };
// 标记为独立 TypeScript 模块,避免 Worker 全局声明与其它源码脚本命名空间合并。 // 标记为独立 TypeScript 模块,避免 Worker 全局声明与其它源码脚本命名空间合并。

Some files were not shown because too many files have changed in this diff Show More