P2/P5: coordinate tabs and render feature preview
This commit is contained in:
@@ -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. 本轮后续实现记录与下一批任务
|
||||
|
||||
|
||||
@@ -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 构建验证。
|
||||
|
||||
|
||||
21
src/App.tsx
21
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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PersistenceCapabilities>
|
||||
capabilities(): PersistenceCapabilities
|
||||
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe
|
||||
save(document: DocumentSnapshot): Promise<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
recovery(documentId: string): Promise<ProjectRecoveryReport>
|
||||
@@ -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<PersistenceCapabilities> | 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<ProjectChangeNotice>) => {
|
||||
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<WorkerResponse>) => { 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,20 +171,38 @@ 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
|
||||
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<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (typeof navigator === 'undefined' || !navigator.locks) return operation()
|
||||
return navigator.locks.request<Promise<T>>('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[] = []) {
|
||||
if (!this.worker) return Promise.reject<WorkerResponse>(new Error(this.currentCapabilities.reason))
|
||||
|
||||
@@ -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<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
recovery(documentId: string): Promise<ProjectRecoveryReport>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user