P2-04: coalesce facade autosaves

This commit is contained in:
2026-08-02 05:21:31 -04:00
parent 5f0d9a8d0e
commit feb2eb8be6
5 changed files with 51 additions and 6 deletions

View File

@@ -1592,8 +1592,9 @@ P0 基线/治理
| P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body文档版本、dirty 标志和 Undo/Redo 已接通,完整属性、依赖 DAG 和重计算仍待实现 | | 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/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写1000 次压力、迁移回滚、恢复演练和跨标签写者仍待补齐 | | P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v1`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写1000 次压力、迁移回滚、恢复演练和跨标签写者仍待补齐 |
| P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | | P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 |
| P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;恢复报告、崩溃点检测和用户可配置策略仍待补齐 |
| P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | | P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 |
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、schema、串行持久化队列、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史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 构建验证。 本迭代验证命令:`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 构建验证。
@@ -1611,4 +1612,4 @@ P0 基线/治理
| Three.js WebGL2 | `PASS` | Chrome SwiftShader 工作区检测到 1 个 canvas 且 `getContext('webgl2')` 成功 | | Three.js WebGL2 | `PASS` | Chrome SwiftShader 工作区检测到 1 个 canvas 且 `getContext('webgl2')` 成功 |
| 降级路径 | `PASS` | Node Facade 测试使用显式内存 Project adapter不会假称 OPFS 已可用 | | 降级路径 | `PASS` | Node Facade 测试使用显式内存 Project adapter不会假称 OPFS 已可用 |
仍未通过 G4/G5自动保存触发策略、1000 次压力报告、崩溃恢复、迁移回滚、跨标签写者、几何 Shape 资源和完整 FreeCAD/OCCT 计算尚未接入;这些继续由 P2-02 至 P4-06 负责。 仍未通过 G4/G5自动保存恢复报告、1000 次压力报告、崩溃恢复、迁移回滚、跨标签写者、几何 Shape 资源和完整 FreeCAD/OCCT 计算尚未接入;这些继续由 P2-02 至 P4-06 负责。

View File

@@ -1,5 +1,5 @@
export { createMockFacade } from './mockFacade' export { createMockFacade } from './mockFacade'
export { createSqliteProjectPersistence, PersistenceWriteQueue, SqliteProjectPersistence } from './projectStore' export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
export { ThreeViewportAdapter } from './threeViewport' export { ThreeViewportAdapter } from './threeViewport'
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema' export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, PersistenceCapabilities, ProjectResource, ProjectSaveResult, TaskSnapshot } from './types' export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, PersistenceCapabilities, ProjectResource, ProjectSaveResult, TaskSnapshot } from './types'

View File

@@ -13,7 +13,7 @@ import type {
TaskSnapshot, TaskSnapshot,
Unsubscribe, Unsubscribe,
} from './types' } from './types'
import { createSqliteProjectPersistence } from './projectStore' import { createSqliteProjectPersistence, ProjectAutosaveScheduler } from './projectStore'
import { ThreeViewportAdapter } from './threeViewport' import { ThreeViewportAdapter } from './threeViewport'
const initialTree: ModelTreeItem[] = [ const initialTree: ModelTreeItem[] = [
@@ -51,6 +51,7 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
export function createMockFacade(): BitBybitWebCadFacade { export function createMockFacade(): BitBybitWebCadFacade {
const projectPersistence = createSqliteProjectPersistence() const projectPersistence = createSqliteProjectPersistence()
const autosave = new ProjectAutosaveScheduler((document) => projectPersistence.save(document))
let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] } let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] }
const listeners = new Set<FacadeListener>() const listeners = new Set<FacadeListener>()
const undoStack: FacadeState[] = [] const undoStack: FacadeState[] = []
@@ -60,7 +61,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event)) const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event))
const emitState = () => emit({ type: 'state.changed', state: getState() }) const emitState = () => emit({ type: 'state.changed', state: getState() })
const getState = () => ({ ...state, diagnostics: state.diagnostics.map((diagnostic) => ({ ...diagnostic })), document: { ...state.document, tree: state.document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) }, task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null }) const getState = () => ({ ...state, diagnostics: state.diagnostics.map((diagnostic) => ({ ...diagnostic })), document: { ...state.document, tree: state.document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) }, task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null })
const commit = (next: FacadeState) => { undoStack.push(getState()); redoStack.length = 0; state = next; emitState() } const commit = (next: FacadeState) => { undoStack.push(getState()); redoStack.length = 0; state = next; if (next.document.dirty) autosave.schedule(next.document); emitState() }
const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() } const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() }
const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) } const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) }
const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() } const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() }

View File

@@ -35,6 +35,35 @@ export class PersistenceWriteQueue {
} }
} }
export class ProjectAutosaveScheduler {
private timer: ReturnType<typeof setTimeout> | null = null
private pending: DocumentSnapshot | null = null
private flushPromise: Promise<ProjectSaveResult | null> | null = null
constructor(private readonly save: (document: DocumentSnapshot) => Promise<ProjectSaveResult>, private readonly idleMs = 800) {}
schedule(document: DocumentSnapshot) {
this.pending = cloneDocument(document)
if (this.timer) clearTimeout(this.timer)
this.timer = setTimeout(() => { this.timer = null; void this.flush() }, this.idleMs)
}
flush() {
if (this.flushPromise) return this.flushPromise
const document = this.pending
this.pending = null
if (!document) return Promise.resolve(null)
this.flushPromise = this.save(document).finally(() => { this.flushPromise = null; if (this.pending) void this.flush() })
return this.flushPromise
}
cancel() {
if (this.timer) clearTimeout(this.timer)
this.timer = null
this.pending = null
}
}
export class SqliteProjectPersistence implements ProjectPersistenceClient { export class SqliteProjectPersistence implements ProjectPersistenceClient {
private readonly worker: Worker | null private readonly worker: Worker | null
private nextRequestId = 0 private nextRequestId = 0

View File

@@ -2,7 +2,7 @@ import { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { createMockFacade } from '../src/facade/mockFacade' 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 } from '../src/facade/projectSchema'
import { PersistenceWriteQueue } from '../src/facade/projectStore' import { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore'
test('facade exposes a stable initial document projection', () => { test('facade exposes a stable initial document projection', () => {
const facade = createMockFacade() const facade = createMockFacade()
@@ -42,6 +42,20 @@ test('persistence writes are serialized and continue after a failed write', asyn
assert.equal(results[2].status, 'fulfilled') assert.equal(results[2].status, 'fulfilled')
}) })
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)
const document = (version: number) => ({ id: 'doc', label: 'Autosave', version, dirty: true, readOnly: false, units: 'mm', tree: [] })
scheduler.schedule(document(1))
scheduler.schedule(document(2))
await new Promise((resolve) => setTimeout(resolve, 15))
assert.deepEqual(savedVersions, [2])
scheduler.schedule(document(3))
const result = await scheduler.flush()
assert.equal(result?.documentVersion, 3)
scheduler.cancel()
})
test('workbench and selection changes are event driven', () => { test('workbench and selection changes are event driven', () => {
const facade = createMockFacade() const facade = createMockFacade()
const events: string[] = [] const events: string[] = []