From 657c4beb9f669e3a1534daa1b5d315a0a5383d72 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 2 Aug 2026 04:58:52 -0400 Subject: [PATCH] P2-02: serialize persistence writes --- docs/web-cad-implementation-plan.zh-CN.md | 6 ++--- src/facade/index.ts | 2 +- src/facade/projectStore.ts | 32 ++++++++++++++++++----- tests/facade.test.ts | 23 ++++++++++++++++ 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 510eb15..bfdd802 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1590,9 +1590,9 @@ P0 基线/治理 | P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`,React 工作区已通过事件投影工作台、选择、文档树和通知 | | 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 已接通,完整属性、依赖 DAG 和重计算仍待实现 | -| P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v1;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;迁移回滚、队列压力和恢复演练仍待补齐 | +| P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v1;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写,1000 次压力、迁移回滚、恢复演练和跨标签写者仍待补齐 | | P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | -| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史;E2E/黄金几何待补齐 | +| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、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 构建验证。 @@ -1609,4 +1609,4 @@ P0 基线/治理 | Three.js WebGL2 | `PASS` | Chrome SwiftShader 工作区检测到 1 个 canvas 且 `getContext('webgl2')` 成功 | | 降级路径 | `PASS` | Node Facade 测试使用显式内存 Project adapter;不会假称 OPFS 已可用 | -仍未通过 G4/G5:自动保存队列、崩溃恢复、迁移回滚、跨标签写者、几何 Shape 资源和完整 FreeCAD/OCCT 计算尚未接入;这些继续由 P2-02 至 P4-06 负责。 +仍未通过 G4/G5:自动保存触发策略、1000 次压力报告、崩溃恢复、迁移回滚、跨标签写者、几何 Shape 资源和完整 FreeCAD/OCCT 计算尚未接入;这些继续由 P2-02 至 P4-06 负责。 diff --git a/src/facade/index.ts b/src/facade/index.ts index 37f7c49..b6f197e 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -1,5 +1,5 @@ export { createMockFacade } from './mockFacade' -export { createSqliteProjectPersistence, SqliteProjectPersistence } from './projectStore' +export { createSqliteProjectPersistence, PersistenceWriteQueue, SqliteProjectPersistence } from './projectStore' export { ThreeViewportAdapter } from './threeViewport' export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema' export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, PersistenceCapabilities, ProjectSaveResult, TaskSnapshot } from './types' diff --git a/src/facade/projectStore.ts b/src/facade/projectStore.ts index 6730865..37d1ba4 100644 --- a/src/facade/projectStore.ts +++ b/src/facade/projectStore.ts @@ -15,6 +15,21 @@ export interface ProjectPersistenceClient { dispose(): Promise } +/** Serializes persistence mutations while allowing reads to follow the write tail. */ +export class PersistenceWriteQueue { + private tail: Promise = Promise.resolve() + + run(operation: () => Promise): Promise { + const next = this.tail.then(operation) + this.tail = next.then(() => undefined, () => undefined) + return next + } + + drain(): Promise { + return this.tail + } +} + export class SqliteProjectPersistence implements ProjectPersistenceClient { private readonly worker: Worker | null private nextRequestId = 0 @@ -22,6 +37,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { private initialized: Promise | null = null private readonly fallbackSnapshots = new Map() private readonly pending = new Map void; reject: (error: Error) => void }>() + private readonly writeQueue = new PersistenceWriteQueue() constructor() { this.worker = typeof Worker === 'undefined' ? null : new Worker(new URL('./persistenceWorker.ts', import.meta.url), { type: 'module', name: 'bitbybit-persistence' }) @@ -40,15 +56,18 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { return this.initialized } - async save(document: DocumentSnapshot) { - 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 } } - 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 } + save(document: DocumentSnapshot) { + return this.writeQueue.run(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 } } + 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 } + }) } async load(documentId: string) { + await this.writeQueue.drain() await this.initialize() if (!this.worker) { const snapshot = this.fallbackSnapshots.get(documentId); return snapshot ? cloneDocument(snapshot) : null } const response = await this.request({ type: 'load-document', documentId }) @@ -57,6 +76,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { } async dispose() { + await this.writeQueue.drain() if (!this.worker) return await this.request({ type: 'dispose' }) this.worker.terminate() diff --git a/tests/facade.test.ts b/tests/facade.test.ts index c840bf2..3b08f60 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -2,6 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { createMockFacade } from '../src/facade/mockFacade' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema' +import { PersistenceWriteQueue } from '../src/facade/projectStore' test('facade exposes a stable initial document projection', () => { const facade = createMockFacade() @@ -19,6 +20,28 @@ test('project schema is versioned and covers the FreeCAD document graph', () => assert.match(PROJECT_SCHEMA_SQL, /CREATE INDEX IF NOT EXISTS objects_document_ordinal/) }) +test('persistence writes are serialized and continue after a failed write', async () => { + const queue = new PersistenceWriteQueue() + let inFlight = 0 + let maximumInFlight = 0 + const order: string[] = [] + const write = (id: string, shouldFail = false) => queue.run(async () => { + inFlight += 1 + maximumInFlight = Math.max(maximumInFlight, inFlight) + await new Promise((resolve) => setTimeout(resolve, 2)) + order.push(id) + inFlight -= 1 + if (shouldFail) throw new Error('expected write failure') + return id + }) + const results = await Promise.allSettled([write('one'), write('two', true), write('three')]) + assert.equal(maximumInFlight, 1) + assert.deepEqual(order, ['one', 'two', 'three']) + assert.equal(results[0].status, 'fulfilled') + assert.equal(results[1].status, 'rejected') + assert.equal(results[2].status, 'fulfilled') +}) + test('workbench and selection changes are event driven', () => { const facade = createMockFacade() const events: string[] = []