P2-01: add SQLite WASM OPFS project worker
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 负责。
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -168,7 +168,7 @@ function App() {
|
||||
const setSelectedObject = useCallback((id: string) => id ? facade.selection.select(id) : facade.selection.clear(), [facade])
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="app-shell" data-persistence-mode={facadeState.persistence.mode}>
|
||||
<TopBar page={page} workbench={workbench} facade={facade} onNavigate={navigate} onOpenWorkspace={openWorkspace} />
|
||||
{page === 'workspace' ? (
|
||||
<Workspace
|
||||
@@ -396,7 +396,7 @@ function StartPage({ onNavigate, onOpenWorkspace, showNotice }: { onNavigate: (p
|
||||
<PageHeader eyebrow="Local-first workspace" title="Start a new design." description="A focused CAD workspace for parametric parts, drawings and assemblies." actions={<><button className="button button-outline" onClick={() => onNavigate('projects')}><FolderOpen size={16} />Open project</button><button className="button button-primary" onClick={onOpenWorkspace}><Plus size={16} />New document</button></>} />
|
||||
<div className="start-grid"><section className="start-hero panel-surface"><div className="hero-copy"><span className="section-kicker"><Sparkles size={14} />FreeCAD-compatible workspace</span><h2>Model with a clear history.</h2><p>Build parts through ordered features, inspect every dependency and keep your project local by default.</p><div className="hero-actions"><button className="button button-primary" onClick={onOpenWorkspace}><FilePlus2 size={16} />Create blank document</button><button className="text-button" onClick={() => showNotice('Example gallery opened')}>Browse examples <ArrowRight size={14} /></button></div></div><div className="hero-schematic"><div className="schematic-axis axis-x-line" /><div className="schematic-axis axis-y-line" /><div className="schematic-block block-a" /><div className="schematic-block block-b" /><div className="schematic-ring" /><span className="schematic-label label-a">Feature history</span><span className="schematic-label label-b">Parametric part</span></div></section><section className="quick-panel panel-surface"><div className="section-title"><div><span className="section-kicker">Quick access</span><h3>Keep moving</h3></div><MoreHorizontal size={16} /></div><QuickAction icon={FolderOpen} title="Open a project" detail="Browse local files" onClick={() => onNavigate('projects')} /><QuickAction icon={Upload} title="Import a model" detail="STEP, IGES, STL and mesh" onClick={() => onNavigate('import')} /><QuickAction icon={WandSparkles} title="Start from an example" detail="Parametric reference models" onClick={() => showNotice('Example gallery opened')} /></section></div>
|
||||
<section className="recent-section"><div className="section-title"><div><span className="section-kicker">Recent projects</span><h3>Your local workspace</h3></div><button className="text-button" onClick={() => onNavigate('projects')}>View all <ArrowRight size={14} /></button></div><div className="recent-grid">{projects.slice(0, 3).map((project, index) => <ProjectCard project={project} key={project.name} index={index} onClick={onOpenWorkspace} />)}</div></section>
|
||||
<div className="start-footer"><div><HardDrive size={15} /><span>Local storage ready</span><Badge tone="green">OPFS available</Badge></div><div><span>BitBybit CAD Studio</span><span className="footer-separator">·</span><span>FreeCAD UI baseline 1.1.1</span></div></div>
|
||||
<div className="start-footer"><div><HardDrive size={15} /><span>Local storage capability</span><Badge tone="cyan">Probe on open</Badge></div><div><span>BitBybit CAD Studio</span><span className="footer-separator">·</span><span>FreeCAD UI baseline 1.1.1</span></div></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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 <div className="diagnostics-page"><PageHeader eyebrow="System status" title="Everything is ready." description="A concise view of browser capabilities, local storage and the current document runtime." onBack={() => onNavigate('start')} actions={<button className="button button-outline" onClick={() => showNotice('Diagnostic package prepared')}><Download size={16} />Export report</button>} /><div className="health-grid"><HealthCard label="WebAssembly" value="Ready" detail="BitBybit runtime available" tone="green" icon={Code2} /><HealthCard label="Local storage" value="Available" detail="OPFS · 3.4 GB used" tone="green" icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="WebGPU can be enabled" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value="2 warnings" detail="Pump Housing · v18" tone="amber" icon={AlertTriangle} /></div><section className="diagnostic-table panel-surface"><div className="section-title"><div><span className="section-kicker">Runtime</span><h3>Capability checks</h3></div><span className="last-checked">Last checked just now</span></div><DiagnosticRow name="BitBybit Facade" value="Connected" detail="API v0.1 · mock runtime" tone="green" /><DiagnosticRow name="Geometry Worker" value="Idle" detail="No active computation" tone="green" /><DiagnosticRow name="SQLite WASM" value="Connected" detail="Local database · schema v3" tone="green" /><DiagnosticRow name="OPFS" value="Available" detail="SharedAccessHandle pool" tone="green" /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded" tone="cyan" /><DiagnosticRow name="Document warnings" value="2" detail="One downstream reference needs review" tone="amber" onClick={() => showNotice('Document diagnostics opened')} /></section></div>
|
||||
return <div className="diagnostics-page"><PageHeader eyebrow="System status" title="Runtime checks." description="A concise view of browser capabilities, local storage and the current document runtime." onBack={() => onNavigate('start')} actions={<button className="button button-outline" onClick={() => showNotice('Diagnostic package prepared')}><Download size={16} />Export report</button>} /><div className="health-grid"><HealthCard label="WebAssembly" value="Ready" detail="Runtime package loaded on demand" tone="green" icon={Code2} /><HealthCard label="Local storage" value="Capability probe" detail="SQLite WASM + OPFS worker" tone="cyan" icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="WebGPU can be enabled" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value="2 warnings" detail="Pump Housing · v18" tone="amber" icon={AlertTriangle} /></div><section className="diagnostic-table panel-surface"><div className="section-title"><div><span className="section-kicker">Runtime</span><h3>Capability checks</h3></div><span className="last-checked">Last checked just now</span></div><DiagnosticRow name="BitBybit Facade" value="Connected" detail="API v0.1 · mock domain adapter" tone="green" /><DiagnosticRow name="Geometry Worker" value="Planned" detail="FreeCAD/OCCT WASM P3 gate" tone="cyan" /><DiagnosticRow name="SQLite WASM" value="Worker configured" detail="Schema v1 · OPFS VFS with memory fallback" tone="cyan" /><DiagnosticRow name="OPFS" value="Capability probe" detail="Requires cross-origin isolation; export fallback is explicit" tone="cyan" /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded" tone="cyan" /><DiagnosticRow name="Document warnings" value="2" detail="One downstream reference needs review" tone="amber" onClick={() => showNotice('Document diagnostics opened')} /></section></div>
|
||||
}
|
||||
|
||||
function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<FacadeListener>()
|
||||
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
|
||||
}
|
||||
|
||||
107
src/facade/persistenceWorker.ts
Normal file
107
src/facade/persistenceWorker.ts
Normal file
@@ -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<PersistenceRequest>) => 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<PersistenceCapabilities> => {
|
||||
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<string, string>()
|
||||
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<Record<string, string | number>>
|
||||
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<Record<string, string | number | null>>
|
||||
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<PersistenceResponse> => {
|
||||
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)) }
|
||||
87
src/facade/projectSchema.ts
Normal file
87
src/facade/projectSchema.ts
Normal file
@@ -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
|
||||
75
src/facade/projectStore.ts
Normal file
75
src/facade/projectStore.ts
Normal file
@@ -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<PersistenceCapabilities>
|
||||
capabilities(): PersistenceCapabilities
|
||||
save(document: DocumentSnapshot): Promise<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
private readonly worker: Worker | null
|
||||
private nextRequestId = 0
|
||||
private currentCapabilities = unavailable
|
||||
private initialized: Promise<PersistenceCapabilities> | null = null
|
||||
private readonly fallbackSnapshots = new Map<string, DocumentSnapshot>()
|
||||
private readonly pending = new Map<number, { resolve: (response: WorkerResponse) => 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<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() }
|
||||
}
|
||||
}
|
||||
|
||||
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<WorkerResponse>(new Error(this.currentCapabilities.reason))
|
||||
const id = ++this.nextRequestId
|
||||
return new Promise<WorkerResponse>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
this.worker?.postMessage({ ...input, id } satisfies WorkerRequest)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const createSqliteProjectPersistence = () => new SqliteProjectPersistence()
|
||||
@@ -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<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
}
|
||||
readonly viewport: {
|
||||
createAdapter(): BitBybitViewportAdapter
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user