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 workspaceModel with a clear history.
Build parts through ordered features, inspect every dependency and keep your project local by default.
Feature historyParametric part 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,