feat: add project recovery and resource governance
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"facadeCapabilities": {
|
||||
"geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] },
|
||||
"document": { "level": "experimental", "operations": ["typed-properties", "property-vector", "property-placement", "property-link-list", "property-string-list", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "signature-topology-history-fallback", "undo-redo"] },
|
||||
"persistence": { "level": "experimental", "provider": "SQLite WASM + OPFS with transient fallback", "operations": ["schema-v5", "transactional-migrations", "migration-rollback", "serialized-write-queue", "five-version-checkpoints", "checkpoint-load", "recovery-report", "content-addressed-resources", "quota-preflight", "resource-sweep-plan", "opfs-orphan-sweep", "cross-tab-lock-notice"] },
|
||||
"sketcher": { "level": "experimental", "provider": "typescript-basic; planegcs-wasm unavailable", "operations": ["point-line-circle-arc-model", "ellipse-bspline-model-only", "construction-geometry", "stable-external-geometry-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "persistence"] },
|
||||
"fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] }
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Web FreeCAD 接续执行状态
|
||||
|
||||
更新时间:2026-08-02
|
||||
更新时间:2026-08-03(P2 schema v5 编辑中暂停)
|
||||
|
||||
## 1. 当前目标
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
- 分支:`main`
|
||||
- 云仓库:`http://154.8.160.151:3000/wangdequan/Web_FreeCAD_Bitbybit.git`
|
||||
- 最新提交:`293cff6 feat: add structured placement properties`
|
||||
- 最近已推送里程碑:`63446d5 docs: add continuation execution status`
|
||||
- 最新完整门禁:`./npmw run verify` 通过
|
||||
- Facade 测试:55/55 通过
|
||||
- 生产构建:通过
|
||||
@@ -78,4 +78,10 @@ git log -5 --oneline
|
||||
3. 运行 `./npmw run verify`、`git diff --check` 和 JSON 校验。
|
||||
4. 单独提交并推送 `main`,不得把凭据写入仓库。
|
||||
|
||||
暂停点:从“P2 持久化压力与迁移回滚门禁”开始继续。
|
||||
## 7. 2026-08-03 P2 接续进展
|
||||
|
||||
本轮已完成 schema v5 迁移运行器、最近 5 版文档检查点、`loadCheckpoint()`、配额预检、资源清扫计划/API 和 1000 次写队列压力实现。聚焦测试与生产构建已通过;提交前仍需运行完整 `./npmw run verify` 和 JSON/diff 校验。
|
||||
|
||||
证据边界:Node/fallback 证明迁移合同、队列、检查点与纯资源策略;真实浏览器 SQLite v4→v5 升级、OPFS 孤儿清扫、QuotaExceeded、大文件与崩溃注入仍是 P2/P8 后续任务,不能因本轮测试而标记完整 G4/G5。
|
||||
|
||||
本里程碑提交后,下一执行点为“将对象 Placement 接入 Part 基本体 OCCT 重算并建立包围盒/保存黄金测试”,随后回到 TSN generation 历史写回。
|
||||
|
||||
@@ -334,7 +334,9 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
|
||||
| FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数;拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 |
|
||||
| FC-04/FC-07 只读报告 | `project.fcstd.inspect()` 解析 Document.xml 对象声明、Label、属性数量和类型;未知类型为 proxy,Python 类型为 blocked;脚本资源只告警且不执行 | recognized/proxy/blocked 分类和 Facade-only 测试通过 | `metadata-compatible` 仅表示 XML 元数据可读,不表示 FCStd 可编辑或 round-trip |
|
||||
| P2-04 Project list/load | `project.list()` 暴露 SQLite/内存快照摘要,`app.document.load()` 将选定快照作为活动 Document 事务恢复,清理旧重算/Shape/Task 状态、保留 Undo 边界并按完整对象图异步重算;Projects 页面按 documentId 调用 Facade API | 列表摘要、保存→修改→加载后属性恢复、选择清空、任务清空和加载重算调用通过测试 | FCStd 导入仍是只读 metadata/proxy;跨文档 ShapeHandle 和 Shape 持久化仍待完成 |
|
||||
| P2-03 Resource/recovery fallback | SQLite Worker 在无 OPFS 的浏览器中使用内存资源表,保留 SHA-256 身份和引用计数;恢复报告区分数据库完整但目标文档缺失的 `unavailable`;销毁 Worker 时清空临时资源 | Worker 类型检查、生产构建通过 | 内存资源会随刷新丢失;配额回收、持久资源校验和大文件压力仍待实现 |
|
||||
| P2-01/P2-02 Migration/write pressure | schema v5 与统一事务 migration runner;失败 rollback;写队列严格串行并在失败后继续 | migration 排序/跳过/回滚测试及 1000 次队列压力通过 | SQLite v4→v5 的真实 OPFS 浏览器升级、崩溃注入与发布回滚演练仍待完成 |
|
||||
| P2-03 Resource/recovery fallback | SQLite Worker 在无 OPFS 时使用内存资源表;OPFS 模式增加配额预留、重复内容免写、缺失文件重建和孤儿/零引用/缺失文件对账清扫;Facade 暴露 sweep 报告 | quota/sweep 纯策略、fallback API、Worker 类型检查和生产构建通过 | 真实 OPFS 孤儿清扫、QuotaExceeded、大文件压力及浏览器崩溃注入仍待完成 |
|
||||
| P2-04 Version checkpoints | 文档规范化保存与完整 JSON checkpoint 同事务;每文档保留最近 5 版;Recovery 列出版本,Facade 可加载指定/最新 checkpoint | 7 版保存后仅保留 3..7、指定版本/最新版加载、过期版本删除和深克隆测试通过 | 当前测试使用 transient fallback;SQLite/OPFS 断电点恢复和保留策略 UI 待完成 |
|
||||
| PART-07 Shape check | `check-shape` 使用 Facade `geometry.topology()` 生成面/边/顶点数量和 `SHAPE_CHECK_PASSED` 信息诊断;空缓存、空拓扑和 Worker 错误分别返回结构化失败诊断 | 无 Shape、错误选择前置和拓扑调用边界通过测试/构建 | 当前是网格派生拓扑检查,不等价于 OCCT `BRepCheck_Analyzer`,完整修复建议和自交诊断仍待实现 |
|
||||
| P1-02 命令准确性 | manifest 仍可完整展示;Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor;命令状态同时检查工作台和对象类型选择谓词;其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、选择不兼容时的禁用原因、`mirrored` 未实现状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 |
|
||||
| P3-05 Pattern 实验切片 | `linear-pattern` 和 `polar-pattern` 通过 Part Design 选择谓词、Task 和类型化属性创建对应对象;Linear 按总 Length 等距平移,Polar 在小于 360° 时覆盖首末角、360° 时不复制重合端点;两者均由 OCCT union 合并并释放临时副本 | 线性间距/方向、圆周完整/部分角度分布、联合输入、句柄释放、非法参数诊断、Base 链接、Body.Tip、属性校验和任务提交回归测试通过 | 当前为 FreeCAD 1.0+ 的 Transform body 近似,只复制整个 Base Shape;未实现 Transform tool shapes、连接单实体过滤、稳定 TopoRef、历史映射、反向轴和引用轴,因此保持 `experimental` |
|
||||
|
||||
@@ -1594,10 +1594,10 @@ P0 基线/治理
|
||||
| P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;新增 Part Design 特征会原子更新 Body.Tip,并按 Sketch/实体来源写入 Profile/Base;Feature 支持可持久化抑制、下游跳过、Shape 释放和解除后的最小闭包重算;文档版本、dirty、Undo/Redo、异步重算 generation 和过期结果拒绝已接通;真实 Shape 事务、完整容器/Tip 重定向规则和原子崩溃恢复仍待实现 |
|
||||
| P4-06 诊断树与修复动作 | `PASS (基础闭环)` | 同步/异步重算错误生成带文档版本、generation、根因对象和依赖路径的结构化诊断;Facade 提供诊断树以及定位对象、合并当前 dirty 集后重算根因闭包、合法特征抑制动作;循环不会错误提供抑制修复;Diagnostics 页面通过 Facade 投影并执行动作;最近有效 Shape 显式回滚、复杂修复建议和性能计数器仍待实现 |
|
||||
| P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite;基础表达式/单位/Link DAG 已落地;`App::PropertyLinkSub` 使用版本化 TopoRef,生成 `topo-ref` DAG 边并可保存加载。3D 子形状拾取、重算迁移写回、locale 和多选 mixed 仍待实现 |
|
||||
| P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v4;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写,1000 次压力、迁移回滚和恢复演练仍待补齐;跨标签写入已由 P2-05 通过 Web Locks/降级能力标记覆盖 |
|
||||
| P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 |
|
||||
| P2-01/P2-02 SQLite schema 与 Persistence Worker | `PASS (领域/压力门禁)` | schema v5 新增 document checkpoints;统一 migration runner 在一个事务中排序、跳过已应用版本并在失败时 rollback;`PersistenceWriteQueue` 的 1000 次写入压力覆盖严格顺序、最大并发 1 和 8 次失败后的继续执行。实际 SQLite v4→v5 浏览器升级演练仍须进入 E2E/发布矩阵 |
|
||||
| P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 提供 SHA-256 内容寻址、引用计数、读取/释放;新增 5% 配额预留、重复内容免重写、缺失文件重建,以及数据库记录/OPFS 文件对账清扫报告。配额与清扫策略单测和生产构建通过;真实 OPFS 孤儿文件、QuotaExceeded 和大资源浏览器压力仍待验证 |
|
||||
| Worker memory resource fallback | `PASS` | Worker 在 SQLite WASM 无 OPFS 时使用 transient content-addressed resource map,保持 `put/get/release` 引用计数语义;不再把资源 API 错误地绑定到 OPFS |
|
||||
| P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;`project.recovery(documentId)` 已提供 SQLite/内存完整性、最后保存版本和警告报告,崩溃点检测和用户可配置策略仍待补齐 |
|
||||
| P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本;每次保存与规范化文档同事务写入完整 checkpoint,每文档裁剪为最近 5 版;Recovery 报告列出版本,`loadCheckpoint()` 可无损加载指定/最新版且返回深克隆。内存回放测试通过;SQLite/OPFS 崩溃注入和用户可配置保留策略仍待补齐 |
|
||||
| Document list/load/recovery transaction | `PASS` | `project.list()` 从 SQLite/内存快照返回 documentId、版本、对象数、dirty/readOnly 和更新时间;`app.document.load(documentId)` 通过 Project Facade 恢复快照,取消旧重算、释放缓存 Shape、清空选择/任务、提交可撤销的活动文档并按完整对象图异步重算;Recovery 对“数据库完整但目标快照不存在”返回 `unavailable`,工程管理页按真实 ID 打开 |
|
||||
| P2-05 多标签页写入协调 | `DONE (降级可观测)` | `SqliteProjectPersistence` 在保存和资源写入外包 `navigator.locks` 独占锁,成功保存通过 `BroadcastChannel` 广播文档版本,Facade 暴露 `project.subscribeExternalChanges()`;无 Web Locks 的运行时能力标记为 `local-queue`,不宣称跨标签页互斥 |
|
||||
| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放;React 视口通过 Facade OCCT 预览链生成 Pad/Pocket Mesh |
|
||||
@@ -1607,7 +1607,7 @@ P0 基线/治理
|
||||
| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 executor 已在 Worker ready 时执行 Pad/Pocket/Revolution,Revolution Angle/Reversed 已接入,Fillet/Chamfer 也有 Shape 缓存回写;Up to face、FCStd Shape 持久化仍待完成 |
|
||||
| P3-05 Fillet/Chamfer/Pattern/Hole | `IN PROGRESS` | Fillet/Chamfer 已接入 Shape 缓存;Linear/Polar Pattern 已贯通选择谓词、Task、Placement/union 和清理;基础 Hole 用圆形 Profile 复用 Pocket,支持 Diameter、Depth、Dimension/Through all。Pattern 当前为整 Shape,Hole 当前固定原点法向;局部特征历史、连接单实体过滤、稳定 TopoRef、草图/面定位及沉孔/锪孔/螺纹仍待实现 |
|
||||
| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;视口优先使用重计算缓存的对象 Shape,失败时保留最近有效结果,无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 |
|
||||
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 50 个场景,包括 Part/PartDesign、Pattern/Hole、TopoRef/LinkSub、signature topology history fallback、几何重算、async generation、诊断修复、FCStd 安全、持久化、资源、任务、历史和表达式;E2E/黄金几何待补齐 |
|
||||
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 60 个场景,包括 Part/PartDesign、Pattern/Hole、TopoRef/LinkSub、Sketch provider、几何重算、诊断修复、FCStd 安全、schema migration、1000 次写队列、检查点和资源治理;E2E/黄金几何待补齐 |
|
||||
|
||||
本迭代验证命令:`npm run check:facade-boundary`、`npm run test:facade`、`npm run build`。构建产物将 Three.js 拆为独立 chunk,避免把全部渲染库重复打入应用主 chunk。当前 npm registry 的 `three` 最新版本为 `0.185.1`,已在 `package.json` 和运行时基线中锁定。下一迭代继续完成 P0-01/P0-02 的精确锁定、P2-01/P2-02 的 SQLite/OPFS schema 与 Worker 单写者实验,以及 P3-01 的 FreeCAD/OCCT WASM 构建验证。
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ export { buildDiagnosticTree, buildRecomputeDiagnostics } from './diagnostics'
|
||||
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
|
||||
export { ThreeViewportAdapter } from './threeViewport'
|
||||
export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime'
|
||||
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
|
||||
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot, TopoRefValue, VectorValue } from './types'
|
||||
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from './projectSchema'
|
||||
export type { ProjectMigrationTransaction, ProjectSchemaMigration } from './projectSchema'
|
||||
export { assessResourceQuota, planResourceSweep } from './resourcePolicy'
|
||||
export type { ResourceQuotaAssessment, ResourceSweepPlan, ResourceSweepRecord } from './resourcePolicy'
|
||||
export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot, TopoRefValue, VectorValue } from './types'
|
||||
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming'
|
||||
export { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from './topologyReferences'
|
||||
export type { PersistedTopoRef, TopoRefResolution, TopologyMigration } from './topologyReferences'
|
||||
|
||||
@@ -712,7 +712,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },
|
||||
task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } },
|
||||
diagnostics: { list: () => state.diagnostics.map(cloneDiagnostic), tree: () => buildDiagnosticTree(state.diagnostics), repair: repairDiagnostic },
|
||||
project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
|
||||
project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), loadCheckpoint: (documentId, version) => projectPersistence.loadCheckpoint(documentId, version), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
|
||||
geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } },
|
||||
viewport: { createAdapter: () => new ThreeViewportAdapter() },
|
||||
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import sqlite3InitModule, { type Database, type Sqlite3Static } from '@sqlite.org/sqlite-wasm'
|
||||
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION } from './projectSchema'
|
||||
import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource, ProjectSummary } from './types'
|
||||
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from './projectSchema'
|
||||
import { assessResourceQuota, planResourceSweep, type ResourceSweepRecord } from './resourcePolicy'
|
||||
import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSummary } from './types'
|
||||
|
||||
type PersistenceRequest =
|
||||
| { id: number; type: 'initialize' }
|
||||
| { id: number; type: 'list-projects' }
|
||||
| { id: number; type: 'save-document'; document: DocumentSnapshot }
|
||||
| { id: number; type: 'load-document'; documentId: string }
|
||||
| { id: number; type: 'load-checkpoint'; documentId: string; version?: number }
|
||||
| { id: number; type: 'recovery-report'; documentId: string }
|
||||
| { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string }
|
||||
| { id: number; type: 'get-resource'; hash: string }
|
||||
| { id: number; type: 'release-resource'; hash: string }
|
||||
| { id: number; type: 'sweep-resources' }
|
||||
| { id: number; type: 'dispose' }
|
||||
|
||||
type PersistenceResponse =
|
||||
| { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities }
|
||||
| { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] }
|
||||
| { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] }
|
||||
| { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null }
|
||||
| { id: number; ok: true; type: 'loaded' | 'checkpoint-loaded'; document: DocumentSnapshot | null }
|
||||
| { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport }
|
||||
| { id: number; ok: true; type: 'resource-put'; resource: ProjectResource }
|
||||
| { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null }
|
||||
| { id: number; ok: true; type: 'resource-released' }
|
||||
| { id: number; ok: true; type: 'resources-swept'; report: ProjectResourceSweepReport }
|
||||
| { id: number; ok: true; type: 'disposed' }
|
||||
| { id: number; ok: false; error: string }
|
||||
|
||||
@@ -64,20 +68,14 @@ const initialize = async (): Promise<PersistenceCapabilities> => {
|
||||
capabilities = { mode: 'sqlite-memory', sqliteWasm: true, opfs: false, schemaVersion: PROJECT_SCHEMA_VERSION, reason: error instanceof Error ? `OPFS initialization failed: ${error.message}` : 'OPFS initialization failed.' }
|
||||
}
|
||||
database.exec('PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);')
|
||||
database.exec('BEGIN;')
|
||||
try {
|
||||
for (const migration of PROJECT_SCHEMA_MIGRATIONS) {
|
||||
const applied = database.exec({ sql: 'SELECT version FROM schema_migrations WHERE version = ?', bind: [migration.version], returnValue: 'resultRows' }) as unknown[]
|
||||
if (applied.length === 0) {
|
||||
database.exec(migration.sql)
|
||||
database.exec({ sql: 'INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)', bind: [migration.version, Date.now()] })
|
||||
}
|
||||
}
|
||||
database.exec('COMMIT;')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK;')
|
||||
throw error
|
||||
}
|
||||
runProjectSchemaMigrations({
|
||||
begin: () => { database?.exec('BEGIN;') },
|
||||
isApplied: (version) => (database?.exec({ sql: 'SELECT version FROM schema_migrations WHERE version = ?', bind: [version], returnValue: 'resultRows' }) as unknown[]).length > 0,
|
||||
execute: (sql) => { database?.exec(sql) },
|
||||
markApplied: (version, appliedAt) => { database?.exec({ sql: 'INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)', bind: [version, appliedAt] }) },
|
||||
commit: () => { database?.exec('COMMIT;') },
|
||||
rollback: () => { database?.exec('ROLLBACK;') },
|
||||
}, PROJECT_SCHEMA_MIGRATIONS)
|
||||
return capabilities
|
||||
}
|
||||
|
||||
@@ -95,6 +93,8 @@ const saveDocument = (document: DocumentSnapshot) => {
|
||||
for (const object of document.objects) for (const property of object.properties) database.exec({ sql: 'INSERT INTO object_properties(document_id, object_id, name, value_json, property_type, updated_at) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, object.id, property.name, JSON.stringify(property), property.type, now] })
|
||||
database.exec({ sql: 'DELETE FROM dependencies WHERE document_id = ?', bind: [document.id] })
|
||||
for (const edge of document.dependencies ?? []) database.exec({ sql: 'INSERT INTO dependencies(document_id, source_id, target_id, relation, property_name, reference) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, edge.sourceId, edge.targetId, edge.relation, edge.propertyName ?? null, edge.reference ?? null] })
|
||||
database.exec({ sql: 'INSERT INTO document_checkpoints(document_id, version, snapshot_json, created_at) VALUES(?, ?, ?, ?) ON CONFLICT(document_id, version) DO UPDATE SET snapshot_json=excluded.snapshot_json, created_at=excluded.created_at', bind: [document.id, document.version, JSON.stringify(document), now] })
|
||||
database.exec({ sql: 'DELETE FROM document_checkpoints WHERE document_id = ? AND version NOT IN (SELECT version FROM document_checkpoints WHERE document_id = ? ORDER BY created_at DESC, version DESC LIMIT 5)', bind: [document.id, document.id] })
|
||||
database.exec('COMMIT;')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK;')
|
||||
@@ -158,10 +158,19 @@ const recoveryReport = (documentId: string): ProjectRecoveryReport => {
|
||||
const integrityRows = database.exec({ sql: 'PRAGMA integrity_check', rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string>>
|
||||
const integrityValue = String(integrityRows[0]?.integrity_check ?? '')
|
||||
const documents = database.exec({ sql: 'SELECT version, dirty FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>>
|
||||
const checkpoints = database.exec({ sql: 'SELECT version, created_at FROM document_checkpoints WHERE document_id = ? ORDER BY created_at DESC, version DESC', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>>
|
||||
const warnings: string[] = []
|
||||
if (!documents[0]) warnings.push('No saved document exists for this ID.')
|
||||
if (integrityValue !== 'ok') warnings.push(`SQLite integrity check returned: ${integrityValue || 'no result'}.`)
|
||||
return { documentId, mode: capabilities.mode, schemaVersion: capabilities.schemaVersion, integrity: !documents[0] ? 'unavailable' : integrityValue === 'ok' ? 'ok' : 'failed', lastSavedVersion: documents[0] ? Number(documents[0].version) : null, dirtyAtLastSave: documents[0] ? Boolean(documents[0].dirty) : null, warnings }
|
||||
return { documentId, mode: capabilities.mode, schemaVersion: capabilities.schemaVersion, integrity: !documents[0] ? 'unavailable' : integrityValue === 'ok' ? 'ok' : 'failed', lastSavedVersion: documents[0] ? Number(documents[0].version) : null, dirtyAtLastSave: documents[0] ? Boolean(documents[0].dirty) : null, checkpoints: checkpoints.map((checkpoint) => ({ version: Number(checkpoint.version), createdAt: Number(checkpoint.created_at) })), warnings }
|
||||
}
|
||||
|
||||
const loadCheckpoint = (documentId: string, version?: number): DocumentSnapshot | null => {
|
||||
if (!database) throw new Error('Persistence database is not initialized.')
|
||||
const rows = version === undefined
|
||||
? database.exec({ sql: 'SELECT snapshot_json FROM document_checkpoints WHERE document_id = ? ORDER BY created_at DESC, version DESC LIMIT 1', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string>>
|
||||
: database.exec({ sql: 'SELECT snapshot_json FROM document_checkpoints WHERE document_id = ? AND version = ?', bind: [documentId, version], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string>>
|
||||
return rows[0]?.snapshot_json ? JSON.parse(String(rows[0].snapshot_json)) as DocumentSnapshot : null
|
||||
}
|
||||
|
||||
const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise<ProjectResource> => {
|
||||
@@ -173,7 +182,20 @@ const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise<Proje
|
||||
transientResources.set(hash, { resource, bytes: new Uint8Array(bytes.slice(0)) })
|
||||
return { ...resource }
|
||||
}
|
||||
const existingRows = database.exec({ sql: 'SELECT hash, byte_length, media_type, ref_count FROM resources WHERE hash = ?', bind: [hash], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>>
|
||||
const directory = await getAssetsDirectory()
|
||||
if (existingRows[0]) {
|
||||
try {
|
||||
await directory.getFileHandle(hash)
|
||||
database.exec({ sql: 'UPDATE resources SET ref_count = ref_count + 1, updated_at = ? WHERE hash = ?', bind: [Date.now(), hash] })
|
||||
const existing = existingRows[0]
|
||||
return { hash, byteLength: Number(existing.byte_length), mediaType: String(existing.media_type), refCount: Number(existing.ref_count) + 1 }
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === 'NotFoundError')) throw error
|
||||
}
|
||||
}
|
||||
const quota = assessResourceQuota(navigator.storage.estimate ? await navigator.storage.estimate() : {}, bytes.byteLength)
|
||||
if (!quota.allowed) throw new DOMException(quota.reason, 'QuotaExceededError')
|
||||
const handle = await directory.getFileHandle(hash, { create: true })
|
||||
const writable = await handle.createWritable()
|
||||
await writable.write(bytes)
|
||||
@@ -185,6 +207,44 @@ const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise<Proje
|
||||
return { hash, byteLength: Number(row.byte_length), mediaType: String(row.media_type), refCount: Number(row.ref_count) }
|
||||
}
|
||||
|
||||
type IterableDirectoryHandle = FileSystemDirectoryHandle & { entries(): AsyncIterableIterator<[string, FileSystemHandle]> }
|
||||
|
||||
const sweepResources = async (): Promise<ProjectResourceSweepReport> => {
|
||||
if (!database) throw new Error('Persistence database is not initialized.')
|
||||
if (!capabilities.opfs) return { inspectedRecords: transientResources.size, inspectedFiles: transientResources.size, deletedRecords: 0, deletedFiles: 0, reclaimedBytes: 0, missingFiles: [], warnings: ['SQLite memory mode has no separately enumerable orphan files.'] }
|
||||
const rows = database.exec({ sql: 'SELECT hash, byte_length, ref_count FROM resources', rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number>>
|
||||
const records: ResourceSweepRecord[] = rows.map((row) => ({ hash: String(row.hash), byteLength: Number(row.byte_length), refCount: Number(row.ref_count) }))
|
||||
const directory = await getAssetsDirectory() as IterableDirectoryHandle
|
||||
const fileSizes = new Map<string, number>()
|
||||
for await (const [name, handle] of directory.entries()) {
|
||||
if (handle.kind !== 'file') continue
|
||||
try { fileSizes.set(name, (await (handle as FileSystemFileHandle).getFile()).size) } catch { fileSizes.set(name, 0) }
|
||||
}
|
||||
const plan = planResourceSweep(records, fileSizes.keys())
|
||||
let deletedFiles = 0
|
||||
let reclaimedBytes = 0
|
||||
const warnings: string[] = []
|
||||
for (const hash of plan.deleteFileHashes) {
|
||||
try {
|
||||
await directory.removeEntry(hash)
|
||||
deletedFiles += 1
|
||||
reclaimedBytes += fileSizes.get(hash) ?? 0
|
||||
} catch (error) {
|
||||
warnings.push(`Unable to delete orphan resource ${hash}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
database.exec('BEGIN;')
|
||||
try {
|
||||
for (const hash of plan.deleteRecordHashes) database.exec({ sql: 'DELETE FROM resources WHERE hash = ?', bind: [hash] })
|
||||
database.exec('COMMIT;')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK;')
|
||||
throw error
|
||||
}
|
||||
if (plan.missingFileHashes.length > 0) warnings.push(`${plan.missingFileHashes.length} referenced resource file(s) are missing from OPFS.`)
|
||||
return { inspectedRecords: records.length, inspectedFiles: fileSizes.size, deletedRecords: plan.deleteRecordHashes.length, deletedFiles, reclaimedBytes, missingFiles: plan.missingFileHashes, warnings }
|
||||
}
|
||||
|
||||
const getResource = async (hash: string): Promise<ArrayBuffer | null> => {
|
||||
if (!capabilities.opfs) {
|
||||
const resource = transientResources.get(hash)
|
||||
@@ -228,10 +288,12 @@ const handle = async (request: PersistenceRequest): Promise<PersistenceResponse>
|
||||
if (request.type === 'list-projects') return { id: request.id, ok: true, type: 'projects-listed', projects: listProjects() }
|
||||
if (request.type === 'save-document') { const result = saveDocument(request.document); return { id: request.id, ok: true, type: 'saved', ...result } }
|
||||
if (request.type === 'load-document') return { id: request.id, ok: true, type: 'loaded', document: loadDocument(request.documentId) }
|
||||
if (request.type === 'load-checkpoint') return { id: request.id, ok: true, type: 'checkpoint-loaded', document: loadCheckpoint(request.documentId, request.version) }
|
||||
if (request.type === 'recovery-report') return { id: request.id, ok: true, type: 'recovery-report', report: recoveryReport(request.documentId) }
|
||||
if (request.type === 'put-resource') return { id: request.id, ok: true, type: 'resource-put', resource: await putResource(request.bytes, request.mediaType) }
|
||||
if (request.type === 'get-resource') return { id: request.id, ok: true, type: 'resource-get', bytes: await getResource(request.hash) }
|
||||
if (request.type === 'release-resource') { await releaseResource(request.hash); return { id: request.id, ok: true, type: 'resource-released' } }
|
||||
if (request.type === 'sweep-resources') return { id: request.id, ok: true, type: 'resources-swept', report: await sweepResources() }
|
||||
throw new Error('Unknown persistence request.')
|
||||
} catch (error) {
|
||||
return { id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
|
||||
@@ -1,4 +1,42 @@
|
||||
export const PROJECT_SCHEMA_VERSION = 4
|
||||
export const PROJECT_SCHEMA_VERSION = 5
|
||||
|
||||
export type ProjectSchemaMigration = { version: number; sql: string }
|
||||
|
||||
export type ProjectMigrationTransaction = {
|
||||
begin(): void
|
||||
isApplied(version: number): boolean
|
||||
execute(sql: string): void
|
||||
markApplied(version: number, appliedAt: number): void
|
||||
commit(): void
|
||||
rollback(): void
|
||||
}
|
||||
|
||||
export const runProjectSchemaMigrations = (
|
||||
transaction: ProjectMigrationTransaction,
|
||||
migrations: readonly ProjectSchemaMigration[] = PROJECT_SCHEMA_MIGRATIONS,
|
||||
appliedAt = Date.now(),
|
||||
) => {
|
||||
const versions = new Set<number>()
|
||||
for (const migration of migrations) {
|
||||
if (!Number.isSafeInteger(migration.version) || migration.version < 1 || versions.has(migration.version)) throw new RangeError(`Invalid or duplicate project schema migration version: ${migration.version}.`)
|
||||
versions.add(migration.version)
|
||||
}
|
||||
transaction.begin()
|
||||
const applied: number[] = []
|
||||
try {
|
||||
for (const migration of [...migrations].sort((left, right) => left.version - right.version)) {
|
||||
if (transaction.isApplied(migration.version)) continue
|
||||
transaction.execute(migration.sql)
|
||||
transaction.markApplied(migration.version, appliedAt)
|
||||
applied.push(migration.version)
|
||||
}
|
||||
transaction.commit()
|
||||
return applied
|
||||
} catch (error) {
|
||||
transaction.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const PROJECT_SCHEMA_SQL = `
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -82,6 +120,18 @@ CREATE TABLE IF NOT EXISTS resources (
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_checkpoints (
|
||||
document_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (document_id, version),
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS checkpoints_document_created
|
||||
ON document_checkpoints(document_id, created_at DESC);
|
||||
`
|
||||
|
||||
export const PROJECT_SCHEMA_MIGRATIONS = [
|
||||
@@ -89,4 +139,5 @@ export const PROJECT_SCHEMA_MIGRATIONS = [
|
||||
{ version: 2, sql: `ALTER TABLE documents ADD COLUMN recompute_json TEXT NOT NULL DEFAULT '{"generation":0,"status":"idle","objectStates":{},"dirtyObjects":[],"order":[],"errors":[]}';` },
|
||||
{ version: 3, sql: 'ALTER TABLE dependencies ADD COLUMN property_name TEXT; ALTER TABLE dependencies ADD COLUMN reference TEXT;' },
|
||||
{ version: 4, sql: 'ALTER TABLE objects ADD COLUMN sketch_json TEXT;' },
|
||||
{ version: 5, sql: 'CREATE TABLE IF NOT EXISTS document_checkpoints (document_id TEXT NOT NULL, version INTEGER NOT NULL, snapshot_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (document_id, version), FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS checkpoints_document_created ON document_checkpoints(document_id, created_at DESC);' },
|
||||
] as const
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, Unsubscribe } from './types'
|
||||
import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, Unsubscribe } from './types'
|
||||
import { cloneSketch } from './sketcher'
|
||||
|
||||
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' | 'list-projects' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
|
||||
type WorkerInput = { type: 'initialize' | 'dispose' | 'list-projects' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string }
|
||||
type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string }
|
||||
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' | 'list-projects' | 'sweep-resources' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'load-checkpoint'; documentId: string; version?: number } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
|
||||
type WorkerInput = { type: 'initialize' | 'dispose' | 'list-projects' | 'sweep-resources' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'load-checkpoint'; documentId: string; version?: number } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string }
|
||||
type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded' | 'checkpoint-loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'resources-swept'; report: ProjectResourceSweepReport } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string }
|
||||
|
||||
const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' }
|
||||
const clonePropertyValue = (value: PropertyValue): PropertyValue => {
|
||||
@@ -22,11 +22,13 @@ export interface ProjectPersistenceClient {
|
||||
list(): Promise<ProjectSummary[]>
|
||||
save(document: DocumentSnapshot): Promise<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
loadCheckpoint(documentId: string, version?: number): Promise<DocumentSnapshot | null>
|
||||
recovery(documentId: string): Promise<ProjectRecoveryReport>
|
||||
resource: {
|
||||
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
|
||||
get(hash: string): Promise<Uint8Array | null>
|
||||
release(hash: string): Promise<void>
|
||||
sweep(): Promise<ProjectResourceSweepReport>
|
||||
}
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
@@ -84,6 +86,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
private currentCapabilities = unavailable
|
||||
private initialized: Promise<PersistenceCapabilities> | null = null
|
||||
private readonly fallbackSnapshots = new Map<string, { document: DocumentSnapshot; savedAt: number }>()
|
||||
private readonly fallbackCheckpoints = new Map<string, Map<number, { document: DocumentSnapshot; savedAt: number }>>()
|
||||
private readonly fallbackResources = new Map<string, { resource: ProjectResource; bytes: Uint8Array }>()
|
||||
private readonly pending = new Map<number, { resolve: (response: WorkerResponse) => void; reject: (error: Error) => void }>()
|
||||
private readonly writeQueue = new PersistenceWriteQueue()
|
||||
@@ -133,6 +136,10 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
const snapshot = cloneDocument(document)
|
||||
const persistedAt = Date.now()
|
||||
this.fallbackSnapshots.set(snapshot.id, { document: snapshot, savedAt: persistedAt })
|
||||
const checkpoints = this.fallbackCheckpoints.get(snapshot.id) ?? new Map()
|
||||
checkpoints.set(snapshot.version, { document: cloneDocument(snapshot), savedAt: persistedAt })
|
||||
const retained = [...checkpoints.entries()].sort((left, right) => right[1].savedAt - left[1].savedAt || right[0] - left[0]).slice(0, 5)
|
||||
this.fallbackCheckpoints.set(snapshot.id, new Map(retained))
|
||||
const result = { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt, mode: this.currentCapabilities.mode }
|
||||
this.announceSaved(result.documentId, result.documentVersion)
|
||||
return result
|
||||
@@ -159,13 +166,29 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
await this.initialize()
|
||||
if (!this.worker) {
|
||||
const snapshot = this.fallbackSnapshots.get(documentId)?.document
|
||||
return { documentId, mode: this.currentCapabilities.mode, schemaVersion: this.currentCapabilities.schemaVersion, integrity: snapshot ? 'ok' : 'unavailable', lastSavedVersion: snapshot?.version ?? null, dirtyAtLastSave: snapshot?.dirty ?? null, warnings: snapshot ? [] : ['No saved snapshot exists in the transient fallback store.'] }
|
||||
const checkpoints = [...(this.fallbackCheckpoints.get(documentId)?.entries() ?? [])].map(([version, value]) => ({ version, createdAt: value.savedAt })).sort((left, right) => right.createdAt - left.createdAt || right.version - left.version)
|
||||
return { documentId, mode: this.currentCapabilities.mode, schemaVersion: this.currentCapabilities.schemaVersion, integrity: snapshot ? 'ok' : 'unavailable', lastSavedVersion: snapshot?.version ?? null, dirtyAtLastSave: snapshot?.dirty ?? null, checkpoints, warnings: snapshot ? [] : ['No saved snapshot exists in the transient fallback store.'] }
|
||||
}
|
||||
const response = await this.request({ type: 'recovery-report', documentId })
|
||||
if (!response.ok || response.type !== 'recovery-report') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
|
||||
return response.report
|
||||
}
|
||||
|
||||
async loadCheckpoint(documentId: string, version?: number) {
|
||||
await this.writeQueue.drain()
|
||||
await this.initialize()
|
||||
if (!this.worker) {
|
||||
const checkpoints = [...(this.fallbackCheckpoints.get(documentId)?.entries() ?? [])]
|
||||
const selected = version === undefined
|
||||
? checkpoints.sort((left, right) => right[1].savedAt - left[1].savedAt || right[0] - left[0])[0]?.[1]
|
||||
: this.fallbackCheckpoints.get(documentId)?.get(version)
|
||||
return selected ? cloneDocument(selected.document) : null
|
||||
}
|
||||
const response = await this.request({ type: 'load-checkpoint', documentId, version })
|
||||
if (!response.ok || response.type !== 'checkpoint-loaded') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
|
||||
return response.document
|
||||
}
|
||||
|
||||
readonly resource = {
|
||||
put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
|
||||
await this.initialize()
|
||||
@@ -195,6 +218,13 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
const response = await this.request({ type: 'release-resource', hash })
|
||||
if (!response.ok || response.type !== 'resource-released') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
|
||||
})),
|
||||
sweep: () => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
|
||||
await this.initialize()
|
||||
if (!this.worker) return { inspectedRecords: this.fallbackResources.size, inspectedFiles: this.fallbackResources.size, deletedRecords: 0, deletedFiles: 0, reclaimedBytes: 0, missingFiles: [], warnings: ['Transient resource storage has no separately enumerable orphan files.'] }
|
||||
const response = await this.request({ type: 'sweep-resources' })
|
||||
if (!response.ok || response.type !== 'resources-swept') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
|
||||
return response.report
|
||||
})),
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
|
||||
48
src/facade/resourcePolicy.ts
Normal file
48
src/facade/resourcePolicy.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export type ResourceQuotaAssessment = {
|
||||
allowed: boolean
|
||||
known: boolean
|
||||
usage: number | null
|
||||
quota: number | null
|
||||
incomingBytes: number
|
||||
reservedBytes: number
|
||||
availableBytes: number | null
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export const assessResourceQuota = (
|
||||
estimate: { usage?: number; quota?: number },
|
||||
incomingBytes: number,
|
||||
reserveRatio = 0.05,
|
||||
): ResourceQuotaAssessment => {
|
||||
if (!Number.isSafeInteger(incomingBytes) || incomingBytes < 0) throw new RangeError('Resource byte length must be a non-negative safe integer.')
|
||||
if (!Number.isFinite(reserveRatio) || reserveRatio < 0 || reserveRatio >= 1) throw new RangeError('Resource reserve ratio must be between 0 and 1.')
|
||||
const usage = typeof estimate.usage === 'number' && Number.isFinite(estimate.usage) && estimate.usage >= 0 ? estimate.usage : null
|
||||
const quota = typeof estimate.quota === 'number' && Number.isFinite(estimate.quota) && estimate.quota >= 0 ? estimate.quota : null
|
||||
if (usage === null || quota === null) return { allowed: true, known: false, usage, quota, incomingBytes, reservedBytes: 0, availableBytes: null, reason: 'Browser storage quota is unavailable; write errors remain authoritative.' }
|
||||
const reservedBytes = Math.ceil(quota * reserveRatio)
|
||||
const availableBytes = Math.max(0, quota - usage - reservedBytes)
|
||||
const allowed = incomingBytes <= availableBytes
|
||||
return { allowed, known: true, usage, quota, incomingBytes, reservedBytes, availableBytes, reason: allowed ? undefined : `Resource requires ${incomingBytes} bytes but only ${availableBytes} bytes remain after the storage reserve.` }
|
||||
}
|
||||
|
||||
export type ResourceSweepRecord = { hash: string; byteLength: number; refCount: number }
|
||||
|
||||
export type ResourceSweepPlan = {
|
||||
deleteFileHashes: string[]
|
||||
deleteRecordHashes: string[]
|
||||
missingFileHashes: string[]
|
||||
}
|
||||
|
||||
export const planResourceSweep = (records: ResourceSweepRecord[], storedHashes: Iterable<string>): ResourceSweepPlan => {
|
||||
const recordByHash = new Map<string, ResourceSweepRecord>()
|
||||
for (const record of records) {
|
||||
if (!record.hash || !Number.isSafeInteger(record.byteLength) || record.byteLength < 0 || !Number.isSafeInteger(record.refCount) || record.refCount < 0) throw new RangeError('Resource sweep records require a hash and non-negative integer sizes/counts.')
|
||||
if (recordByHash.has(record.hash)) throw new RangeError(`Duplicate resource sweep record: ${record.hash}.`)
|
||||
recordByHash.set(record.hash, record)
|
||||
}
|
||||
const files = new Set(storedHashes)
|
||||
const deleteRecordHashes = records.filter((record) => record.refCount === 0).map((record) => record.hash).sort()
|
||||
const deleteFileHashes = [...files].filter((hash) => !recordByHash.has(hash) || recordByHash.get(hash)?.refCount === 0).sort()
|
||||
const missingFileHashes = records.filter((record) => record.refCount > 0 && !files.has(record.hash)).map((record) => record.hash).sort()
|
||||
return { deleteFileHashes, deleteRecordHashes, missingFileHashes }
|
||||
}
|
||||
@@ -129,6 +129,7 @@ export type ProjectRecoveryReport = {
|
||||
integrity: 'ok' | 'failed' | 'unavailable'
|
||||
lastSavedVersion: number | null
|
||||
dirtyAtLastSave: boolean | null
|
||||
checkpoints: Array<{ version: number; createdAt: number }>
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
@@ -139,6 +140,16 @@ export type ProjectResource = {
|
||||
refCount: number
|
||||
}
|
||||
|
||||
export type ProjectResourceSweepReport = {
|
||||
inspectedRecords: number
|
||||
inspectedFiles: number
|
||||
deletedRecords: number
|
||||
deletedFiles: number
|
||||
reclaimedBytes: number
|
||||
missingFiles: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export type GeometryCapabilities = {
|
||||
provider: 'Bitbybit OCCT'
|
||||
version: '1.1.1'
|
||||
@@ -459,6 +470,7 @@ export interface BitBybitWebCadFacade {
|
||||
list(): Promise<ProjectSummary[]>
|
||||
save(document?: DocumentSnapshot): Promise<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
loadCheckpoint(documentId: string, version?: number): Promise<DocumentSnapshot | null>
|
||||
recovery(documentId: string): Promise<ProjectRecoveryReport>
|
||||
fcstd: {
|
||||
inspect(bytes: Uint8Array, limits?: Partial<FcstdArchiveLimits>): FcstdInspection
|
||||
@@ -467,6 +479,7 @@ export interface BitBybitWebCadFacade {
|
||||
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
|
||||
get(hash: string): Promise<Uint8Array | null>
|
||||
release(hash: string): Promise<void>
|
||||
sweep(): Promise<ProjectResourceSweepReport>
|
||||
}
|
||||
}
|
||||
readonly geometry: {
|
||||
|
||||
@@ -2,13 +2,14 @@ import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { createMockFacade } from '../src/facade/mockFacade'
|
||||
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema'
|
||||
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from '../src/facade/projectSchema'
|
||||
import { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore'
|
||||
import { DependencyGraph } from '../src/facade/dependencyGraph'
|
||||
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
|
||||
import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming'
|
||||
import { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from '../src/facade/topologyReferences'
|
||||
import { captureSignatureTopologyHistory } from '../src/facade/topologyHistory'
|
||||
import { assessResourceQuota, planResourceSweep } from '../src/facade/resourcePolicy'
|
||||
import { cloneSketch, createSketch, solveSketch } from '../src/facade/sketcher'
|
||||
import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay, type SketchSolverProvider, type SketchSolverRequest } from '../src/facade/sketchSolverProtocol'
|
||||
import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
|
||||
@@ -109,12 +110,31 @@ test('Bitbybit face meshes are normalized into a facade-owned indexed asset', ()
|
||||
})
|
||||
|
||||
test('project schema is versioned and covers the FreeCAD document graph', () => {
|
||||
assert.equal(PROJECT_SCHEMA_VERSION, 4)
|
||||
assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4])
|
||||
for (const table of ['projects', 'documents', 'objects', 'object_properties', 'dependencies', 'transactions', 'resources']) assert.match(PROJECT_SCHEMA_SQL, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`))
|
||||
assert.equal(PROJECT_SCHEMA_VERSION, 5)
|
||||
assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4, 5])
|
||||
for (const table of ['projects', 'documents', 'objects', 'object_properties', 'dependencies', 'transactions', 'resources', 'document_checkpoints']) assert.match(PROJECT_SCHEMA_SQL, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`))
|
||||
assert.match(PROJECT_SCHEMA_SQL, /CREATE INDEX IF NOT EXISTS objects_document_ordinal/)
|
||||
})
|
||||
|
||||
test('project migrations are ordered, skip applied versions and roll back atomically', () => {
|
||||
const calls: string[] = []
|
||||
const applied = new Set([1])
|
||||
const transaction = {
|
||||
begin: () => { calls.push('begin') },
|
||||
isApplied: (version: number) => applied.has(version),
|
||||
execute: (sql: string) => { calls.push(`execute:${sql}`); if (sql === 'broken') throw new Error('migration failed') },
|
||||
markApplied: (version: number) => { calls.push(`mark:${version}`); applied.add(version) },
|
||||
commit: () => { calls.push('commit') },
|
||||
rollback: () => { calls.push('rollback') },
|
||||
}
|
||||
assert.deepEqual(runProjectSchemaMigrations(transaction, [{ version: 2, sql: 'second' }, { version: 1, sql: 'first' }, { version: 3, sql: 'third' }], 123), [2, 3])
|
||||
assert.deepEqual(calls, ['begin', 'execute:second', 'mark:2', 'execute:third', 'mark:3', 'commit'])
|
||||
calls.length = 0
|
||||
assert.throws(() => runProjectSchemaMigrations(transaction, [{ version: 4, sql: 'fourth' }, { version: 5, sql: 'broken' }], 124), /migration failed/)
|
||||
assert.deepEqual(calls, ['begin', 'execute:fourth', 'mark:4', 'execute:broken', 'rollback'])
|
||||
assert.throws(() => runProjectSchemaMigrations(transaction, [{ version: 6, sql: 'one' }, { version: 6, sql: 'duplicate' }]), /duplicate/)
|
||||
})
|
||||
|
||||
test('quantity expressions convert units and reject incompatible dimensions', () => {
|
||||
const result = evaluateQuantityExpression('1 in + 2 mm')
|
||||
assert.equal(result.value.dimension, 'length')
|
||||
@@ -737,6 +757,28 @@ test('persistence writes are serialized and continue after a failed write', asyn
|
||||
assert.equal(results[2].status, 'fulfilled')
|
||||
})
|
||||
|
||||
test('persistence write queue preserves all 1000 transaction slots under repeated failures', async () => {
|
||||
const queue = new PersistenceWriteQueue()
|
||||
const order: number[] = []
|
||||
let inFlight = 0
|
||||
let maximumInFlight = 0
|
||||
const writes = Array.from({ length: 1000 }, (_, index) => queue.run(async () => {
|
||||
inFlight += 1
|
||||
maximumInFlight = Math.max(maximumInFlight, inFlight)
|
||||
await Promise.resolve()
|
||||
order.push(index)
|
||||
inFlight -= 1
|
||||
if (index % 137 === 0) throw new Error(`expected failure ${index}`)
|
||||
return index
|
||||
}))
|
||||
const results = await Promise.allSettled(writes)
|
||||
await queue.drain()
|
||||
assert.equal(maximumInFlight, 1)
|
||||
assert.deepEqual(order, Array.from({ length: 1000 }, (_, index) => index))
|
||||
assert.equal(results.filter((result) => result.status === 'rejected').length, 8)
|
||||
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 992)
|
||||
})
|
||||
|
||||
test('project persistence broadcasts saved document versions across clients', async () => {
|
||||
const writer = createSqliteProjectPersistence()
|
||||
const observer = createSqliteProjectPersistence()
|
||||
@@ -1006,6 +1048,21 @@ test('recovery report identifies missing snapshots in the transient fallback sto
|
||||
assert.ok(recovery.warnings.some((warning) => /No saved snapshot/.test(warning)))
|
||||
})
|
||||
|
||||
test('project recovery checkpoints retain the latest five document versions', async () => {
|
||||
const persistence = createSqliteProjectPersistence()
|
||||
const base = createMockFacade().app.document.getActive()
|
||||
for (let version = 1; version <= 7; version += 1) await persistence.save({ ...base, version, label: `Checkpoint ${version}` })
|
||||
const recovery = await persistence.recovery(base.id)
|
||||
assert.deepEqual(recovery.checkpoints.map((checkpoint) => checkpoint.version), [7, 6, 5, 4, 3])
|
||||
assert.equal((await persistence.loadCheckpoint(base.id, 4))?.label, 'Checkpoint 4')
|
||||
assert.equal(await persistence.loadCheckpoint(base.id, 2), null)
|
||||
assert.equal((await persistence.loadCheckpoint(base.id))?.version, 7)
|
||||
const restored = await persistence.loadCheckpoint(base.id, 4)
|
||||
if (restored) restored.label = 'mutated clone'
|
||||
assert.equal((await persistence.loadCheckpoint(base.id, 4))?.label, 'Checkpoint 4')
|
||||
await persistence.dispose()
|
||||
})
|
||||
|
||||
test('FCStd inspection reports recognized, proxy, and Python-backed objects without executing code', () => {
|
||||
const documentXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document SchemaVersion="4">
|
||||
@@ -1073,6 +1130,28 @@ test('project resources use content identity and reference counting', async () =
|
||||
assert.equal(await facade.project.resource.get(first.hash), null)
|
||||
})
|
||||
|
||||
test('resource quota policy reserves capacity and sweep planning is conservative', () => {
|
||||
assert.deepEqual(assessResourceQuota({ usage: 600, quota: 1000 }, 300, 0.05), { allowed: true, known: true, usage: 600, quota: 1000, incomingBytes: 300, reservedBytes: 50, availableBytes: 350, reason: undefined })
|
||||
const denied = assessResourceQuota({ usage: 600, quota: 1000 }, 351, 0.05)
|
||||
assert.equal(denied.allowed, false)
|
||||
assert.match(denied.reason || '', /only 350 bytes/)
|
||||
assert.equal(assessResourceQuota({}, 10).known, false)
|
||||
assert.deepEqual(planResourceSweep([
|
||||
{ hash: 'active', byteLength: 10, refCount: 2 },
|
||||
{ hash: 'zero', byteLength: 20, refCount: 0 },
|
||||
{ hash: 'missing', byteLength: 30, refCount: 1 },
|
||||
], ['active', 'zero', 'orphan']), { deleteFileHashes: ['orphan', 'zero'], deleteRecordHashes: ['zero'], missingFileHashes: ['missing'] })
|
||||
})
|
||||
|
||||
test('resource sweep is exposed through the project facade in fallback mode', async () => {
|
||||
const facade = createMockFacade()
|
||||
await facade.project.resource.put(new Uint8Array([1, 2, 3]), 'application/octet-stream')
|
||||
const report = await facade.project.resource.sweep()
|
||||
assert.equal(report.inspectedRecords, 1)
|
||||
assert.equal(report.deletedFiles, 0)
|
||||
assert.match(report.warnings[0], /no separately enumerable orphan files/)
|
||||
})
|
||||
|
||||
test('Part Design feature tasks commit a document object and remain undoable', () => {
|
||||
const facade = createMockFacade()
|
||||
const before = facade.app.document.getActive()
|
||||
|
||||
Reference in New Issue
Block a user