From 709cfea1eb119039f150374c1c6a2ac0e91729af Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 2 Aug 2026 18:09:03 -0400 Subject: [PATCH] P2/P5: coordinate tabs and render feature preview --- docs/freecad-full-parity-plan.zh-CN.md | 2 +- docs/web-cad-implementation-plan.zh-CN.md | 13 +++-- src/App.tsx | 21 +++++-- src/facade/mockFacade.ts | 2 +- src/facade/projectStore.ts | 70 ++++++++++++++++++----- src/facade/types.ts | 9 +++ tests/facade.test.ts | 23 +++++++- 7 files changed, 111 insertions(+), 29 deletions(-) diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index b19224e..76f8a98 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -316,7 +316,7 @@ Bitbybit 的 OCCT `filletEdges` 与 `chamferEdges` 已进入 Geometry Facade, Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 FC-08/FC-09 的第一条交换格式垂直切片。导出数据仍只代表当前 Shape;对象树、Expression、Sketch 约束、TopoRef 和重算历史必须由 FCStd/.webcad 格式任务独立保存,不能因“能导出 STEP”而宣称参数化文件兼容。 -本轮又补齐了拓扑和恢复边界:面之外增加了由 BitBybit 网格端点派生的 edge/vertex 签名,并通过 `geometry.topology()` 返回面/边/点引用,签名使用量化坐标、长度/端点而不使用面索引;这仍不是 OCCT `Generated/Modified/Deleted` 历史。FCStd inspection 现在同时生成只读 `proxyDocument`,对象属性带有 `recognized/proxy/blocked` 状态;SQLite/OPFS Project API 增加 `recovery(documentId)`,报告完整性、schema、最后保存版本和降级警告。代理文档和恢复报告都不能被解释为 FCStd 参数化写回或崩溃恢复已经完成。 +本轮又补齐了拓扑和恢复边界:面之外增加了由 BitBybit 网格端点派生的 edge/vertex 签名,并通过 `geometry.topology()` 返回面/边/点引用,签名使用量化坐标、长度/端点而不使用面索引;这仍不是 OCCT `Generated/Modified/Deleted` 历史。FCStd inspection 现在同时生成只读 `proxyDocument`,对象属性带有 `recognized/proxy/blocked` 状态;SQLite/OPFS Project API 增加 `recovery(documentId)`,报告完整性、schema、最后保存版本和降级警告。持久化写入现在在支持的浏览器使用 Web Locks 跨标签页互斥,并通过 BroadcastChannel 广播保存版本;无 Web Locks 环境明确报告 `local-queue`,不能解释为跨标签页安全。代理文档和恢复报告都不能被解释为 FCStd 参数化写回或崩溃恢复已经完成。 ## 17. 本轮后续实现记录与下一批任务 diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index d3c4412..c0fb2af 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1456,7 +1456,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部 | P2-02 | 建立 Persistence Worker 和单写者队列 | Worker、请求/响应协议 | P1-03 | 1000 次事务无丢失且不阻塞 UI | | P2-03 | 实现 OPFS 数据库/资源管理 | 哈希、引用计数、配额处理 | P2-01 | 资源可写入、校验和清理 | | P2-04 | 实现自动保存、恢复和备份 | Project API、恢复报告 | P2-02/P2-03 | 强刷后恢复最后有效保存点 | -| P2-05 | 实现多标签页通知和锁冲突 | BroadcastChannel、单写者提示 | P2-02 | 第二标签页不能静默覆盖 | +| P2-05 | 实现多标签页通知和锁冲突 | BroadcastChannel、Web Locks 互斥、`subscribeExternalChanges`、单写者提示 | P2-02 | 支持 Web Locks 时第二标签页不能静默覆盖;不支持时能力矩阵明确标记 `local-queue` 降级 | | P2-06 | 实现能力检测和降级 | capability matrix、备份入口 | P2-03 | 不支持 OPFS 时可显式导出恢复 | #### P3:FreeCAD/OCCT 几何运行时 @@ -1486,7 +1486,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部 | ID | 任务 | 输出 | 前置 | 退出条件 | |---|---|---|---|---| | P5-01 | 实现 WebGL2 renderer、相机和场景生命周期 | Viewport Adapter | P1-04 | 创建/销毁视口无 GPU 泄漏 | -| P5-02 | 实现 Shape/mesh 增量更新和缓存 | BufferGeometry、材质缓存 | P3-02 | 单特征更新不重建整个场景 | +| P5-02 | 实现 Shape/mesh 增量更新和缓存 | BufferGeometry、材质缓存;Pad/Pocket OCCT 预览链 | P3-02 | 单特征更新不重建整个场景;预览链在卸载时释放全部 Shape 句柄 | | P5-03 | 实现对象/面/边/点拾取和预选 | Selection mapping | P3-02/P4-01 | 拾取返回稳定 SubshapeRef | | P5-04 | 实现导航、标准视图、剖切、网格和测量 | Gui/Viewport commands | P5-01 | 视口和文档状态可区分 | | P5-05 | 建立百万三角形和大装配基准 | 性能/内存报告 | P5-02 | 达到预算或触发 LOD 后备 | @@ -1591,16 +1591,17 @@ P0 基线/治理 | P1-06 Facade-only 依赖守卫 | `DONE` | `scripts/check-facade-boundary.mjs`,禁止 UI 绕过入口导入 Three.js/SQLite/OPFS/Worker | | P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;文档版本、dirty、Undo/Redo、异步重算 generation 和过期结果拒绝已接通;真实 Shape 事务、容器规则和原子崩溃恢复仍待实现 | | P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite `object_properties`;基础表达式/单位/Link DAG 和常用数学函数已落地,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-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-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;`project.recovery(documentId)` 已提供 SQLite/内存完整性、最后保存版本和警告报告,崩溃点检测和用户可配置策略仍待补齐 | -| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | +| 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 | | P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM;尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 | | P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle`、`MeshAsset`、`SubshapeRef` 类型;句柄携带 documentId/version,底层 OCCT hash 不出运行时,跨文档、字段篡改和释放后访问被拒绝;`geometry.topology()` 已从 BitBybit 网格生成面/边/点基础签名,OCCT 历史命名仍未实现 | | P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵;0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm;自动化浏览器黄金测试尚未进入 CI | | P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;重算 generation/cancel 协议已完成,真实特征参数执行、Through all/Up to face 和几何结果回写尚未完成 | -| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;对象缓存与文档增量同步尚未实现 | -| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 35 个场景,包括 async generation 重算、FCStd 安全检查、schema、串行持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式;E2E/黄金几何待补齐 | +| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;视口预览链会释放 Pad/Pocket 全部 Shape 句柄,对象缓存与文档增量同步尚未实现 | +| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 41 个场景,包括 async generation 重算、DAG 分层并行、FCStd 安全检查、schema、串行/跨标签持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式;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/App.tsx b/src/App.tsx index 46c5c00..98351cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -136,6 +136,11 @@ function App() { } }), [facade]) + useEffect(() => facade.project.subscribeExternalChanges((notice) => { + setNotice(`Project saved in another tab · v${notice.documentVersion}`) + window.setTimeout(() => setNotice(''), 2600) + }), [facade]) + useEffect(() => () => facade.geometry.dispose(), [facade]) useEffect(() => { @@ -386,7 +391,7 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo const adapter = facade.viewport.createAdapter() adapterRef.current = adapter let cancelled = false - let shape: ShapeHandle | null = null + const shapes: ShapeHandle[] = [] try { adapter.mount(host) } catch (error) { @@ -399,13 +404,17 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo const capabilities = await facade.geometry.initialize() if (capabilities.status !== 'ready') throw new Error(capabilities.reason || 'OCCT geometry runtime unavailable') const document = facade.app.document.getActive() - shape = await facade.geometry.createBox({ width: 2.7, length: 1.4, height: 1.6, center: [0, 0, 0], originOnCenter: true, documentId: document.id, documentVersion: document.version }) + const profile = { outer: [[-1.35, -0.7, 0], [1.35, -0.7, 0], [1.35, 0.7, 0], [-1.35, 0.7, 0]] as [number, number, number][] } + const pad = await facade.geometry.pad({ profile, length: 1.6, direction: [0, 0, 1], documentId: document.id, documentVersion: document.version }) + shapes.push(pad) + const pocketProfile = { outer: [[-0.55, -0.32, 0], [0.55, -0.32, 0], [0.55, 0.32, 0], [-0.55, 0.32, 0]] as [number, number, number][] } + const pocket = await facade.geometry.pocket({ base: pad, profile: pocketProfile, length: 1.8, direction: [0, 0, 1], documentId: document.id, documentVersion: document.version }) + shapes.push(pocket) if (cancelled) { - await facade.geometry.release(shape) - shape = null + await Promise.all(shapes.map((entry) => facade.geometry.release(entry))) return } - const mesh = await facade.geometry.mesh(shape, 0.05) + const mesh = await facade.geometry.mesh(pocket, 0.05) if (!cancelled) adapter.setMesh(mesh) } void loadGeometry().catch((error: unknown) => { @@ -413,7 +422,7 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo }) return () => { cancelled = true - if (shape) void facade.geometry.release(shape) + void Promise.all(shapes.map((entry) => facade.geometry.release(entry))) adapter.dispose() adapterRef.current = null } diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 343a40e..e0b2000 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -463,7 +463,7 @@ export function createMockFacade(): BitBybitWebCadFacade { gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } }, 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() } }, - project: { capabilities: () => projectPersistence.capabilities(), 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), 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 }, 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), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() }, viewport: { createAdapter: () => new ThreeViewportAdapter() }, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, diff --git a/src/facade/projectStore.ts b/src/facade/projectStore.ts index 166d238..8d93fdb 100644 --- a/src/facade/projectStore.ts +++ b/src/facade/projectStore.ts @@ -1,4 +1,4 @@ -import type { DocumentSnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource, ProjectSaveResult } from './types' +import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, Unsubscribe } from './types' import { cloneSketch } from './sketcher' type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { 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 } @@ -11,6 +11,7 @@ const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...do export interface ProjectPersistenceClient { initialize(): Promise capabilities(): PersistenceCapabilities + subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe save(document: DocumentSnapshot): Promise load(documentId: string): Promise recovery(documentId: string): Promise @@ -68,6 +69,9 @@ export class ProjectAutosaveScheduler { export class SqliteProjectPersistence implements ProjectPersistenceClient { private readonly worker: Worker | null + private readonly senderId = `tab-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}` + private readonly changeChannel: BroadcastChannel | null + private readonly externalChangeListeners = new Set<(notice: ProjectChangeNotice) => void>() private nextRequestId = 0 private currentCapabilities = unavailable private initialized: Promise | null = null @@ -78,7 +82,14 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { constructor() { this.worker = typeof Worker === 'undefined' ? null : new Worker(new URL('./persistenceWorker.ts', import.meta.url), { type: 'module', name: 'bitbybit-persistence' }) - if (!this.worker) this.currentCapabilities = { mode: 'sqlite-memory', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Worker is unavailable; using a transient in-memory project store.' } + this.changeChannel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel('bitbybit-project-changes') + ;(this.changeChannel as (BroadcastChannel & { unref?: () => void }) | null)?.unref?.() + this.changeChannel?.addEventListener('message', (event: MessageEvent) => { + const notice = event.data + if (!notice || notice.senderId === this.senderId || notice.kind !== 'document-saved') return + this.externalChangeListeners.forEach((listener) => listener({ ...notice })) + }) + if (!this.worker) this.currentCapabilities = { mode: 'sqlite-memory', sqliteWasm: false, opfs: false, schemaVersion: 0, crossTabWriteLock: this.crossTabWriteLockMode(), reason: 'Worker is unavailable; using a transient in-memory project store.' } if (this.worker) { this.worker.onmessage = (event: MessageEvent) => { const request = this.pending.get(event.data.id); if (!request) return; this.pending.delete(event.data.id); request.resolve(event.data) } this.worker.onerror = (event) => { const error = new Error(event.message || 'Persistence Worker failed.'); this.currentCapabilities = { ...unavailable, reason: error.message }; this.initialized = null; for (const request of this.pending.values()) request.reject(error); this.pending.clear() } @@ -87,20 +98,33 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { capabilities() { return { ...this.currentCapabilities } } + subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe { + this.externalChangeListeners.add(listener) + return () => this.externalChangeListeners.delete(listener) + } + initialize() { if (!this.worker) return Promise.resolve(this.currentCapabilities) - if (!this.initialized) this.initialized = this.request({ type: 'initialize' }).then((response) => { if (!response.ok || response.type !== 'initialized') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error); this.currentCapabilities = response.capabilities; return this.capabilities() }) + if (!this.initialized) this.initialized = this.request({ type: 'initialize' }).then((response) => { if (!response.ok || response.type !== 'initialized') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error); this.currentCapabilities = { ...response.capabilities, crossTabWriteLock: this.crossTabWriteLockMode() }; return this.capabilities() }) return this.initialized } save(document: DocumentSnapshot) { - return this.writeQueue.run(async () => { + return this.writeQueue.run(() => this.withCrossTabWriteLock(async () => { await this.initialize() - if (!this.worker) { const snapshot = cloneDocument(document); this.fallbackSnapshots.set(snapshot.id, snapshot); return { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt: Date.now(), mode: this.currentCapabilities.mode } } + if (!this.worker) { + const snapshot = cloneDocument(document) + this.fallbackSnapshots.set(snapshot.id, snapshot) + const result = { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt: Date.now(), mode: this.currentCapabilities.mode } + this.announceSaved(result.documentId, result.documentVersion) + return result + } const response = await this.request({ type: 'save-document', document }) if (!response.ok || response.type !== 'saved') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error) - return { documentId: response.documentId, documentVersion: response.documentVersion, persistedAt: response.persistedAt, mode: response.mode } - }) + const result = { documentId: response.documentId, documentVersion: response.documentVersion, persistedAt: response.persistedAt, mode: response.mode } + this.announceSaved(result.documentId, result.documentVersion) + return result + })) } async load(documentId: string) { @@ -125,7 +149,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { } readonly resource = { - put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(async () => { + put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => { await this.initialize() if (!this.worker) { const hash = `memory-${[...bytes].map((value) => value.toString(16).padStart(2, '0')).join('')}` @@ -138,7 +162,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { const response = await this.request({ type: 'put-resource', bytes: payload.buffer as ArrayBuffer, mediaType }, [payload.buffer as ArrayBuffer]) if (!response.ok || response.type !== 'resource-put') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error) return response.resource - }), + })), get: async (hash: string) => { await this.writeQueue.drain() await this.initialize() @@ -147,19 +171,37 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { if (!response.ok || response.type !== 'resource-get') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error) return response.bytes ? new Uint8Array(response.bytes) : null }, - release: (hash: string) => this.writeQueue.run(async () => { + release: (hash: string) => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => { await this.initialize() if (!this.worker) { const existing = this.fallbackResources.get(hash); if (!existing) return; if (existing.resource.refCount > 1) existing.resource.refCount -= 1; else this.fallbackResources.delete(hash); return } 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) - }), + })), } async dispose() { await this.writeQueue.drain() - if (!this.worker) return - await this.request({ type: 'dispose' }) - this.worker.terminate() + if (this.worker) { + await this.request({ type: 'dispose' }) + this.worker.terminate() + } + this.changeChannel?.close() + } + + private crossTabWriteLockMode(): 'web-locks' | 'local-queue' { + return typeof navigator !== 'undefined' && 'locks' in navigator ? 'web-locks' : 'local-queue' + } + + private withCrossTabWriteLock(operation: () => Promise): Promise { + if (typeof navigator === 'undefined' || !navigator.locks) return operation() + return navigator.locks.request>('bitbybit-project-write', { mode: 'exclusive' }, (lock) => { + if (!lock) throw new Error('Unable to acquire the cross-tab project write lock.') + return operation() + }).then((result) => result) + } + + private announceSaved(documentId: string, documentVersion: number) { + this.changeChannel?.postMessage({ documentId, documentVersion, senderId: this.senderId, kind: 'document-saved' } satisfies ProjectChangeNotice) } private request(input: WorkerInput, transfer: Transferable[] = []) { diff --git a/src/facade/types.ts b/src/facade/types.ts index 9289d6b..6e95231 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -75,9 +75,17 @@ export type PersistenceCapabilities = { sqliteWasm: boolean opfs: boolean schemaVersion: number + crossTabWriteLock?: 'web-locks' | 'local-queue' reason?: string } +export type ProjectChangeNotice = { + documentId: string + documentVersion: number + senderId: string + kind: 'document-saved' +} + export type ProjectSaveResult = { documentId: string documentVersion: number @@ -382,6 +390,7 @@ export interface BitBybitWebCadFacade { } readonly project: { capabilities(): PersistenceCapabilities + subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe save(document?: DocumentSnapshot): Promise load(documentId: string): Promise recovery(documentId: string): Promise diff --git a/tests/facade.test.ts b/tests/facade.test.ts index b3afa60..7085465 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -3,7 +3,7 @@ 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 { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore' +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' @@ -376,6 +376,27 @@ test('persistence writes are serialized and continue after a failed write', asyn assert.equal(results[2].status, 'fulfilled') }) +test('project persistence broadcasts saved document versions across clients', async () => { + const writer = createSqliteProjectPersistence() + const observer = createSqliteProjectPersistence() + const notice = new Promise<{ documentId: string; documentVersion: number }>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Expected cross-tab save notice.')), 250) + const unsubscribe = observer.subscribeExternalChanges((change) => { + clearTimeout(timer) + unsubscribe() + resolve(change) + }) + }) + const document = recomputeDocumentFixture() + const saved = await writer.save(document) + assert.equal(writer.capabilities().crossTabWriteLock, 'local-queue') + const received = await notice + assert.equal(received.documentId, saved.documentId) + assert.equal(received.documentVersion, saved.documentVersion) + await writer.dispose() + await observer.dispose() +}) + test('autosave scheduler coalesces idle document changes and flushes the latest version', async () => { const savedVersions: number[] = [] const scheduler = new ProjectAutosaveScheduler(async (document) => { savedVersions.push(document.version); return { documentId: document.id, documentVersion: document.version, persistedAt: Date.now(), mode: 'sqlite-memory' } }, 5)