From 6bfbdf5057f87a629581d78a51d81ca0c07831a6 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Mon, 3 Aug 2026 00:13:13 -0400 Subject: [PATCH] feat: add project recovery and resource governance --- config/compatibility-matrix.json | 1 + docs/continuation-status.zh-CN.md | 12 ++- docs/freecad-full-parity-plan.zh-CN.md | 4 +- docs/web-cad-implementation-plan.zh-CN.md | 8 +- src/facade/index.ts | 7 +- src/facade/mockFacade.ts | 2 +- src/facade/persistenceWorker.ts | 98 ++++++++++++++++++----- src/facade/projectSchema.ts | 53 +++++++++++- src/facade/projectStore.ts | 40 +++++++-- src/facade/resourcePolicy.ts | 48 +++++++++++ src/facade/types.ts | 13 +++ tests/facade.test.ts | 87 +++++++++++++++++++- 12 files changed, 334 insertions(+), 39 deletions(-) create mode 100644 src/facade/resourcePolicy.ts diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index 6e851eb..7ce1f59 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -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"] } }, diff --git a/docs/continuation-status.zh-CN.md b/docs/continuation-status.zh-CN.md index cb209d5..d38282f 100644 --- a/docs/continuation-status.zh-CN.md +++ b/docs/continuation-status.zh-CN.md @@ -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 历史写回。 diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 78a907a..b8fe67c 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -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` | diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 0ac02bb..80a2991 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -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 构建验证。 diff --git a/src/facade/index.ts b/src/facade/index.ts index a9daf88..cc751f1 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -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' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index b6fff9c..600fc29 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -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, diff --git a/src/facade/persistenceWorker.ts b/src/facade/persistenceWorker.ts index 3ebff74..3a81169 100644 --- a/src/facade/persistenceWorker.ts +++ b/src/facade/persistenceWorker.ts @@ -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 => { 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> 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> + 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> 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> + : database.exec({ sql: 'SELECT snapshot_json FROM document_checkpoints WHERE document_id = ? AND version = ?', bind: [documentId, version], rowMode: 'object', returnValue: 'resultRows' }) as Array> + return rows[0]?.snapshot_json ? JSON.parse(String(rows[0].snapshot_json)) as DocumentSnapshot : null } const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise => { @@ -173,7 +182,20 @@ const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise> 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 } + +const sweepResources = async (): Promise => { + 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> + 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() + 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 => { if (!capabilities.opfs) { const resource = transientResources.get(hash) @@ -228,10 +288,12 @@ const handle = async (request: PersistenceRequest): Promise 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) } diff --git a/src/facade/projectSchema.ts b/src/facade/projectSchema.ts index 520985a..f56e935 100644 --- a/src/facade/projectSchema.ts +++ b/src/facade/projectSchema.ts @@ -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() + 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 diff --git a/src/facade/projectStore.ts b/src/facade/projectStore.ts index be1b7ef..41db8b8 100644 --- a/src/facade/projectStore.ts +++ b/src/facade/projectStore.ts @@ -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 save(document: DocumentSnapshot): Promise load(documentId: string): Promise + loadCheckpoint(documentId: string, version?: number): Promise recovery(documentId: string): Promise resource: { put(bytes: Uint8Array, mediaType: string): Promise get(hash: string): Promise release(hash: string): Promise + sweep(): Promise } dispose(): Promise } @@ -84,6 +86,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { private currentCapabilities = unavailable private initialized: Promise | null = null private readonly fallbackSnapshots = new Map() + private readonly fallbackCheckpoints = new Map>() private readonly fallbackResources = new Map() private readonly pending = new Map 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() { diff --git a/src/facade/resourcePolicy.ts b/src/facade/resourcePolicy.ts new file mode 100644 index 0000000..6187e94 --- /dev/null +++ b/src/facade/resourcePolicy.ts @@ -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): ResourceSweepPlan => { + const recordByHash = new Map() + 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 } +} diff --git a/src/facade/types.ts b/src/facade/types.ts index a1fb72d..e32c3d2 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -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 save(document?: DocumentSnapshot): Promise load(documentId: string): Promise + loadCheckpoint(documentId: string, version?: number): Promise recovery(documentId: string): Promise fcstd: { inspect(bytes: Uint8Array, limits?: Partial): FcstdInspection @@ -467,6 +479,7 @@ export interface BitBybitWebCadFacade { put(bytes: Uint8Array, mediaType: string): Promise get(hash: string): Promise release(hash: string): Promise + sweep(): Promise } } readonly geometry: { diff --git a/tests/facade.test.ts b/tests/facade.test.ts index ea6d138..9827e2b 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -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 = ` @@ -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()