From cc7cfc61533f20b5c0694b28f5fee40cbf79912c Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 2 Aug 2026 04:45:57 -0400 Subject: [PATCH] P2-01: add SQLite WASM OPFS project worker --- README.md | 6 +- config/runtime-baseline.json | 4 +- docs/web-cad-implementation-plan.zh-CN.md | 18 +++- package-lock.json | 16 +++- package.json | 8 +- scripts/check-facade-boundary.mjs | 2 +- src/App.tsx | 6 +- src/facade/index.ts | 4 +- src/facade/mockFacade.ts | 16 +++- src/facade/persistenceWorker.ts | 107 ++++++++++++++++++++++ src/facade/projectSchema.ts | 87 ++++++++++++++++++ src/facade/projectStore.ts | 75 +++++++++++++++ src/facade/types.ts | 21 +++++ tests/facade.test.ts | 17 ++++ vite.config.ts | 13 +++ 15 files changed, 385 insertions(+), 15 deletions(-) create mode 100644 src/facade/persistenceWorker.ts create mode 100644 src/facade/projectSchema.ts create mode 100644 src/facade/projectStore.ts diff --git a/README.md b/README.md index 2bb16f3..b0f1cab 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ FreeCAD-aligned web CAD frontend baseline for the BitBybit runtime boundary. - Machine-readable workbench and command manifest in `src/freecadManifest.ts` - Chinese implementation plan in `docs/web-cad-implementation-plan.zh-CN.md` -The current milestone is a static interface prototype. Real BitBybit Facade, FreeCAD/OCCT WASM, Three.js geometry, SQLite WASM and OPFS persistence are scheduled for the following integration milestones. +The current milestone is a facade-first integration slice. Part Design document transactions and history are executable in the mock domain adapter; the internal SQLite WASM + OPFS Persistence Worker, schema and in-memory fallback are bundled behind `facade.project`. FreeCAD/OCCT geometry, solver behavior, FCStd interoperability and full workbench semantics remain staged behind the documented P3-P7 gates. ## Development @@ -19,8 +19,12 @@ npm install npm run dev ``` +Node.js 22 or newer is the supported development baseline, matching the SQLite WASM package engine requirement. + Build verification: ```bash npm run build ``` + +The SQLite OPFS worker requires cross-origin isolation headers. Vite dev and preview apply `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`; deployments must preserve them. diff --git a/config/runtime-baseline.json b/config/runtime-baseline.json index 35efc21..e31e583 100644 --- a/config/runtime-baseline.json +++ b/config/runtime-baseline.json @@ -4,7 +4,7 @@ "frontendFramework": "React 18 + Vite 6 + TypeScript 5", "geometry": { "provider": "BitBybit internal adapter", "status": "planned" }, "viewport": { "provider": "Three.js internal adapter", "package": "three", "version": "0.185.1", "baseline": "WebGL2", "status": "adapter-skeleton" }, - "persistence": { "database": "SQLite WASM", "filesystem": "OPFS", "writer": "single worker", "status": "planned" }, + "persistence": { "database": "@sqlite.org/sqlite-wasm 3.53.0-build1", "filesystem": "OPFS VFS with in-memory fallback", "writer": "single worker", "crossOriginIsolation": "required for OPFS worker", "status": "worker-skeleton" }, "targetBrowsers": "Capability matrix to be locked in P0-02", - "status": "contract-first-prototype" + "status": "facade-first-integration-slice" } diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 2c74a8f..510eb15 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1406,7 +1406,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部 | FreeCAD/OCCT 几何 WASM | `PLANNED` | 由 P3 工作包实施;当前只有确定性 Mock 几何结果 | | Three.js 真实视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts`,锁定当前最新 `three@0.185.1`,WebGL2 适配器已挂入 Facade | | React Facade 投影和静态替身 | `DONE` | 工作台、选择、文档树、通知已由 Facade 事件驱动 | -| SQLite WASM + OPFS 持久化 | `PLANNED` | 由 P2 工作包实施;当前页面仅展示存储状态 | +| SQLite WASM + OPFS 持久化 | `IN PROGRESS` | `projectSchema.ts`、`persistenceWorker.ts` 和 `projectStore.ts` 已落地;Worker 具备 OPFS VFS/内存降级和 Document/Object 保存恢复,自动保存、迁移回滚、配额与跨标签写者仍待 P2-02/P2-06 | | 标准格式与 FCStd 兼容 | `PLANNED` | 由 P7 工作包实施 | | Facade 单元测试和入口依赖守卫 | `DONE` | `tests/facade.test.ts`、`scripts/check-facade-boundary.mjs` | | 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | P8-01 第一批 Facade 测试已通过;跨浏览器和黄金几何仍待后续 | @@ -1590,7 +1590,23 @@ 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/内存模式;迁移回滚、队列压力和恢复演练仍待补齐 | | P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史;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 构建验证。 + +### 16.10 P2 持久化/视口验证记录 + +本次 P2 前置切片已产生真实浏览器证据,而不是只验证类型和打包: + +| 检查 | 结果 | 证据 | +|---|---|---| +| COOP/COEP | `PASS` | `curl -I http://localhost:5173/` 返回 `same-origin` 与 `require-corp` | +| SQLite WASM Worker | `PASS` | 构建产物包含 `persistenceWorker`、SQLite Worker、OPFS proxy 和 `sqlite3.wasm` | +| OPFS VFS | `PASS` | Chrome `crossOriginIsolated=true`、`navigator.storage.getDirectory()` 可用 | +| 数据库创建 | `PASS` | 保存文档后 OPFS 根目录出现 `bitbybit-project.sqlite3` | +| 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 负责。 diff --git a/package-lock.json b/package-lock.json index 3624108..16665c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,19 +8,23 @@ "name": "bitbybit-web-cad", "version": "0.1.0", "dependencies": { + "@sqlite.org/sqlite-wasm": "3.53.0-build1", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "three": "^0.185.1" + "three": "0.185.1" }, "devDependencies": { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@types/three": "^0.185.3", + "@types/three": "0.185.3", "@vitejs/plugin-react": "^4.3.4", "tsx": "^4.23.1", "typescript": "^5.7.2", "vite": "^6.0.5" + }, + "engines": { + "node": ">=22" } }, "node_modules/@babel/code-frame": { @@ -1100,6 +1104,14 @@ "win32" ] }, + "node_modules/@sqlite.org/sqlite-wasm": { + "version": "3.53.0-build1", + "resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.53.0-build1.tgz", + "integrity": "sha512-PfWPWN2n+/37doa8oh2/oUXk4OOsRYZsxc1W1sDXIGb/Pu5Yrb+f2eyYpgQMGITVX7HVgxhs9P18Rc6I97ym/g==", + "engines": { + "node": ">=22" + } + }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", diff --git a/package.json b/package.json index 77eb7d6..5a7ef66 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.1.0", "type": "module", + "engines": { + "node": ">=22" + }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", @@ -12,15 +15,16 @@ "verify": "npm run check:facade-boundary && npm run test:facade && npm run build" }, "dependencies": { + "@sqlite.org/sqlite-wasm": "3.53.0-build1", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "three": "^0.185.1" + "three": "0.185.1" }, "devDependencies": { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@types/three": "^0.185.3", + "@types/three": "0.185.3", "@vitejs/plugin-react": "^4.3.4", "tsx": "^4.23.1", "typescript": "^5.7.2", diff --git a/scripts/check-facade-boundary.mjs b/scripts/check-facade-boundary.mjs index d08d33b..c10ddda 100644 --- a/scripts/check-facade-boundary.mjs +++ b/scripts/check-facade-boundary.mjs @@ -5,7 +5,7 @@ const sourceRoot = new URL('../src/', import.meta.url) const forbidden = [ /from\s+["']three(?:\/|["'])/, /from\s+["']@types\/three/, /from\s+["']sqlite3?/, /from\s+["']@sqlite/, /from\s+["']opfs/, /SharedArrayBuffer/, /\bpostMessage\s*\(/, ] -const allowDirectRuntime = new Set(['facade/threeViewport.ts', 'facade/threeViewport.tsx', 'facade/persistenceWorker.ts', 'facade/geometryWorker.ts']) +const allowDirectRuntime = new Set(['facade/threeViewport.ts', 'facade/threeViewport.tsx', 'facade/persistenceWorker.ts', 'facade/projectStore.ts', 'facade/geometryWorker.ts']) async function walk(relative = '') { const directory = new URL(relative, sourceRoot) diff --git a/src/App.tsx b/src/App.tsx index fbfb73d..43f0722 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -168,7 +168,7 @@ function App() { const setSelectedObject = useCallback((id: string) => id ? facade.selection.select(id) : facade.selection.clear(), [facade]) return ( -
+
{page === 'workspace' ? ( } />
FreeCAD-compatible workspace

Model with a clear history.

Build parts through ordered features, inspect every dependency and keep your project local by default.

Feature historyParametric part
Quick access

Keep moving

onNavigate('projects')} /> onNavigate('import')} /> showNotice('Example gallery opened')} />
Recent projects

Your local workspace

{projects.slice(0, 3).map((project, index) => )}
-
Local storage readyOPFS available
BitBybit CAD Studio·FreeCAD UI baseline 1.1.1
+
Local storage capabilityProbe on open
BitBybit CAD Studio·FreeCAD UI baseline 1.1.1
} @@ -443,7 +443,7 @@ function Shortcut({ keyName, label }: { keyName: string; label: string }) { } function DiagnosticsPage({ onNavigate, showNotice }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void }) { - return
onNavigate('start')} actions={} />
Runtime

Capability checks

Last checked just now
showNotice('Document diagnostics opened')} />
+ return
onNavigate('start')} actions={} />
Runtime

Capability checks

Last checked just now
showNotice('Document diagnostics opened')} />
} function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) { diff --git a/src/facade/index.ts b/src/facade/index.ts index d1cf89f..37f7c49 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -1,3 +1,5 @@ export { createMockFacade } from './mockFacade' +export { createSqliteProjectPersistence, SqliteProjectPersistence } from './projectStore' export { ThreeViewportAdapter } from './threeViewport' -export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, TaskSnapshot } from './types' +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/mockFacade.ts b/src/facade/mockFacade.ts index 8cc1188..b63303f 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -13,6 +13,7 @@ import type { TaskSnapshot, Unsubscribe, } from './types' +import { createSqliteProjectPersistence } from './projectStore' import { ThreeViewportAdapter } from './threeViewport' const initialTree: ModelTreeItem[] = [ @@ -49,7 +50,8 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO } export function createMockFacade(): BitBybitWebCadFacade { - let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), task: null, lastNotice: '', diagnostics: [] } + const projectPersistence = createSqliteProjectPersistence() + let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] } const listeners = new Set() const undoStack: FacadeState[] = [] const redoStack: FacadeState[] = [] @@ -106,7 +108,15 @@ export function createMockFacade(): BitBybitWebCadFacade { } emit({ type: 'command.started', commandId, context }) if (commandId === 'new-document') commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '' }) - else if (commandId === 'save') commit({ ...state, document: { ...state.document, dirty: false, version: state.document.version + 1 } }) + else if (commandId === 'save') { + const savedDocument = { ...state.document, dirty: false, version: state.document.version + 1 } + commit({ ...state, document: savedDocument }) + void projectPersistence.save(savedDocument).catch((error: unknown) => { + const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'error', code: 'PERSISTENCE_SAVE_FAILED', message: error instanceof Error ? error.message : String(error), requestId } + state = { ...state, diagnostics: [...state.diagnostics, diagnostic] } + emit({ type: 'diagnostic.added', diagnostic, context }); notify('Save failed; export a recovery package') + }) + } else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId) else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null }) emit({ type: 'command.completed', commandId, context }); emitState(); return requestId @@ -118,8 +128,10 @@ 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) }, viewport: { createAdapter: () => new ThreeViewportAdapter() }, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, } + void projectPersistence.initialize().then((nextCapabilities) => { state = { ...state, persistence: nextCapabilities }; emitState() }).catch((error: unknown) => { const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code: 'PERSISTENCE_INIT_FAILED', message: error instanceof Error ? error.message : String(error) }; state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }; emit({ type: 'diagnostic.added', diagnostic, context: { apiVersion: state.apiVersion, requestId: `req-${++requestSequence}`, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench } }) }) return facade } diff --git a/src/facade/persistenceWorker.ts b/src/facade/persistenceWorker.ts new file mode 100644 index 0000000..5d2002f --- /dev/null +++ b/src/facade/persistenceWorker.ts @@ -0,0 +1,107 @@ +import sqlite3InitModule, { type Database, type Sqlite3Static } from '@sqlite.org/sqlite-wasm' +import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION } from './projectSchema' +import type { DocumentSnapshot, ModelTreeItem, PersistenceCapabilities } from './types' + +type PersistenceRequest = + | { id: number; type: 'initialize' } + | { id: number; type: 'save-document'; document: DocumentSnapshot } + | { id: number; type: 'load-document'; documentId: string } + | { id: number; type: 'dispose' } + +type PersistenceResponse = + | { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } + | { 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: 'disposed' } + | { id: number; ok: false; error: string } + +type WorkerScope = { + postMessage(message: PersistenceResponse): void + onmessage: ((event: MessageEvent) => void) | null +} + +const workerScope = globalThis as unknown as WorkerScope +let sqlite3: Sqlite3Static | undefined +let database: Database | undefined +let capabilities: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: PROJECT_SCHEMA_VERSION, reason: 'SQLite WASM has not been initialized.' } + +const cloneTree = (tree: ModelTreeItem[]) => tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) + +const initialize = async (): Promise => { + if (database) return capabilities + sqlite3 = await sqlite3InitModule() + const opfsAvailable = Boolean(sqlite3.oo1.OpfsDb) + try { + database = opfsAvailable ? new sqlite3.oo1.OpfsDb('/bitbybit-project.sqlite3') : new sqlite3.oo1.DB(':memory:', 'c') + capabilities = { mode: opfsAvailable ? 'sqlite-opfs' : 'sqlite-memory', sqliteWasm: true, opfs: opfsAvailable, schemaVersion: PROJECT_SCHEMA_VERSION, reason: opfsAvailable ? undefined : 'OPFS VFS is unavailable; persistence is transient until export.' } + } catch (error) { + database = new sqlite3.oo1.DB(':memory:', 'c') + 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 + } + return capabilities +} + +const saveDocument = (document: DocumentSnapshot) => { + if (!database) throw new Error('Persistence database is not initialized.') + const now = Date.now() + database.exec('BEGIN;') + try { + database.exec({ sql: 'INSERT INTO projects(id, name, schema_version, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, schema_version=excluded.schema_version, updated_at=excluded.updated_at', bind: [document.id, document.label, PROJECT_SCHEMA_VERSION, now, now] }) + database.exec({ sql: 'INSERT INTO documents(id, project_id, label, version, dirty, read_only, units) VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET label=excluded.label, version=excluded.version, dirty=excluded.dirty, read_only=excluded.read_only, units=excluded.units', bind: [document.id, document.id, document.label, document.version, document.dirty ? 1 : 0, document.readOnly ? 1 : 0, document.units] }) + database.exec({ sql: 'DELETE FROM objects WHERE document_id = ?', bind: [document.id] }) + const parentByChild = new Map() + for (const item of document.tree) for (const childId of item.children || []) parentByChild.set(childId, item.id) + document.tree.forEach((item, ordinal) => database?.exec({ sql: 'INSERT INTO objects(id, document_id, parent_id, label, object_type, state, detail, children_json, ordinal) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)', bind: [item.id, document.id, parentByChild.get(item.id) || null, item.label, item.type, item.state || null, item.detail || null, JSON.stringify(item.children || []), ordinal] })) + database.exec('COMMIT;') + } catch (error) { + database.exec('ROLLBACK;') + throw error + } + return { documentId: document.id, documentVersion: document.version, persistedAt: now, mode: capabilities.mode } +} + +const loadDocument = (documentId: string): DocumentSnapshot | null => { + if (!database) throw new Error('Persistence database is not initialized.') + const documents = database.exec({ sql: 'SELECT id, label, version, dirty, read_only, units FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> + const row = documents[0] + if (!row) return null + const objects = database.exec({ sql: 'SELECT id, label, object_type, state, detail, children_json FROM objects WHERE document_id = ? ORDER BY ordinal', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> + return { + id: String(row.id), + label: String(row.label), + version: Number(row.version), + dirty: Boolean(row.dirty), + readOnly: Boolean(row.read_only), + units: String(row.units), + tree: objects.map((object) => ({ id: String(object.id), label: String(object.label), type: String(object.object_type) as ModelTreeItem['type'], state: object.state ? String(object.state) as ModelTreeItem['state'] : undefined, detail: object.detail ? String(object.detail) : undefined, children: object.children_json ? JSON.parse(String(object.children_json)) as string[] : undefined })), + } +} + +const handle = async (request: PersistenceRequest): Promise => { + try { + if (request.type === 'initialize') return { id: request.id, ok: true, type: 'initialized', capabilities: await initialize() } + if (request.type === 'dispose') { database?.close(); database = undefined; return { id: request.id, ok: true, type: 'disposed' } } + await initialize() + if (request.type === 'save-document') { const result = saveDocument(request.document); return { id: request.id, ok: true, type: 'saved', ...result } } + return { id: request.id, ok: true, type: 'loaded', document: loadDocument(request.documentId) } + } catch (error) { + return { id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) } + } +} + +workerScope.onmessage = (event) => { void handle(event.data).then((response) => workerScope.postMessage(response)) } diff --git a/src/facade/projectSchema.ts b/src/facade/projectSchema.ts new file mode 100644 index 0000000..0028115 --- /dev/null +++ b/src/facade/projectSchema.ts @@ -0,0 +1,87 @@ +export const PROJECT_SCHEMA_VERSION = 1 + +export const PROJECT_SCHEMA_SQL = ` +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + schema_version INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + label TEXT NOT NULL, + version INTEGER NOT NULL, + dirty INTEGER NOT NULL DEFAULT 0, + read_only INTEGER NOT NULL DEFAULT 0, + units TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS objects ( + id TEXT NOT NULL, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + parent_id TEXT, + label TEXT NOT NULL, + object_type TEXT NOT NULL, + state TEXT, + detail TEXT, + children_json TEXT NOT NULL DEFAULT '[]', + ordinal INTEGER NOT NULL, + PRIMARY KEY (document_id, id) +); + +CREATE INDEX IF NOT EXISTS objects_document_ordinal + ON objects(document_id, ordinal); + +CREATE TABLE IF NOT EXISTS object_properties ( + document_id TEXT NOT NULL, + object_id TEXT NOT NULL, + name TEXT NOT NULL, + value_json TEXT NOT NULL, + property_type TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (document_id, object_id, name), + FOREIGN KEY (document_id, object_id) REFERENCES objects(document_id, id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS dependencies ( + document_id TEXT NOT NULL, + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + relation TEXT NOT NULL, + PRIMARY KEY (document_id, source_id, target_id, relation), + FOREIGN KEY (document_id, source_id) REFERENCES objects(document_id, id) ON DELETE CASCADE, + FOREIGN KEY (document_id, target_id) REFERENCES objects(document_id, id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + command_id TEXT NOT NULL, + request_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS resources ( + hash TEXT PRIMARY KEY, + path TEXT NOT NULL, + byte_length INTEGER NOT NULL, + media_type TEXT NOT NULL, + ref_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +` + +export const PROJECT_SCHEMA_MIGRATIONS = [{ version: PROJECT_SCHEMA_VERSION, sql: PROJECT_SCHEMA_SQL }] as const diff --git a/src/facade/projectStore.ts b/src/facade/projectStore.ts new file mode 100644 index 0000000..6730865 --- /dev/null +++ b/src/facade/projectStore.ts @@ -0,0 +1,75 @@ +import type { DocumentSnapshot, PersistenceCapabilities, ProjectSaveResult } from './types' + +type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document'; documentId: string } +type WorkerInput = { type: 'initialize' | 'dispose' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document'; documentId: string } +type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { 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: '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 cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) }) + +export interface ProjectPersistenceClient { + initialize(): Promise + capabilities(): PersistenceCapabilities + save(document: DocumentSnapshot): Promise + load(documentId: string): Promise + dispose(): Promise +} + +export class SqliteProjectPersistence implements ProjectPersistenceClient { + private readonly worker: Worker | null + private nextRequestId = 0 + private currentCapabilities = unavailable + private initialized: Promise | null = null + private readonly fallbackSnapshots = new Map() + private readonly pending = new Map void; reject: (error: Error) => void }>() + + 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.' } + 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() } + } + } + + capabilities() { return { ...this.currentCapabilities } } + + 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() }) + 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 } + } + + async load(documentId: string) { + 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 }) + if (!response.ok || response.type !== 'loaded') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error) + return response.document + } + + async dispose() { + if (!this.worker) return + await this.request({ type: 'dispose' }) + this.worker.terminate() + } + + private request(input: WorkerInput) { + if (!this.worker) return Promise.reject(new Error(this.currentCapabilities.reason)) + const id = ++this.nextRequestId + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + this.worker?.postMessage({ ...input, id } satisfies WorkerRequest) + }) + } +} + +export const createSqliteProjectPersistence = () => new SqliteProjectPersistence() diff --git a/src/facade/types.ts b/src/facade/types.ts index f07de2c..f9b76dd 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -19,6 +19,21 @@ export type DocumentSnapshot = { tree: ModelTreeItem[] } +export type PersistenceCapabilities = { + mode: 'sqlite-opfs' | 'sqlite-memory' | 'unavailable' + sqliteWasm: boolean + opfs: boolean + schemaVersion: number + reason?: string +} + +export type ProjectSaveResult = { + documentId: string + documentVersion: number + persistedAt: number + mode: PersistenceCapabilities['mode'] +} + export type CommandState = { id: string status: 'hidden' | 'disabled' | 'enabled' | 'active' @@ -55,6 +70,7 @@ export type FacadeState = { activeWorkbench: WorkbenchId selectedObjectId: string document: DocumentSnapshot + persistence: PersistenceCapabilities task: TaskSnapshot | null lastNotice: string diagnostics: Diagnostic[] @@ -122,6 +138,11 @@ export interface BitBybitWebCadFacade { apply(): void cancel(): void } + readonly project: { + capabilities(): PersistenceCapabilities + save(document?: DocumentSnapshot): Promise + load(documentId: string): Promise + } readonly viewport: { createAdapter(): BitBybitViewportAdapter } diff --git a/tests/facade.test.ts b/tests/facade.test.ts index ff39346..c840bf2 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -1,6 +1,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' test('facade exposes a stable initial document projection', () => { const facade = createMockFacade() @@ -11,6 +12,13 @@ test('facade exposes a stable initial document projection', () => { assert.equal(facade.gui.workbench.getActive(), 'Part Design') }) +test('project schema is versioned and covers the FreeCAD document graph', () => { + assert.equal(PROJECT_SCHEMA_VERSION, 1) + assert.equal(PROJECT_SCHEMA_MIGRATIONS[0].version, PROJECT_SCHEMA_VERSION) + 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.match(PROJECT_SCHEMA_SQL, /CREATE INDEX IF NOT EXISTS objects_document_ordinal/) +}) + test('workbench and selection changes are event driven', () => { const facade = createMockFacade() const events: string[] = [] @@ -72,6 +80,15 @@ test('document mutations use the facade history boundary', () => { assert.equal(facade.app.document.getActive().label, 'Untitled document') }) +test('project persistence remains behind the facade contract', async () => { + const facade = createMockFacade() + const saved = await facade.project.save() + assert.equal(saved.documentId, 'doc-pump-housing') + const loaded = await facade.project.load('doc-pump-housing') + assert.equal(loaded?.label, 'Pump Housing') + assert.equal(facade.project.capabilities().mode, 'sqlite-memory') +}) + test('Part Design feature tasks commit a document object and remain undoable', () => { const facade = createMockFacade() const before = facade.app.document.getActive() diff --git a/vite.config.ts b/vite.config.ts index 0ddb78e..e3d5465 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,19 @@ export default defineConfig({ server: { port: 5173, strictPort: false, + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }, + }, + preview: { + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }, + }, + optimizeDeps: { + exclude: ['@sqlite.org/sqlite-wasm'], }, build: { chunkSizeWarningLimit: 600,