P2-01: add SQLite WASM OPFS project worker

This commit is contained in:
2026-08-02 04:45:57 -04:00
parent bdaea21574
commit cc7cfc6153
15 changed files with 385 additions and 15 deletions

View File

@@ -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` - Machine-readable workbench and command manifest in `src/freecadManifest.ts`
- Chinese implementation plan in `docs/web-cad-implementation-plan.zh-CN.md` - 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 ## Development
@@ -19,8 +19,12 @@ npm install
npm run dev npm run dev
``` ```
Node.js 22 or newer is the supported development baseline, matching the SQLite WASM package engine requirement.
Build verification: Build verification:
```bash ```bash
npm run build 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.

View File

@@ -4,7 +4,7 @@
"frontendFramework": "React 18 + Vite 6 + TypeScript 5", "frontendFramework": "React 18 + Vite 6 + TypeScript 5",
"geometry": { "provider": "BitBybit internal adapter", "status": "planned" }, "geometry": { "provider": "BitBybit internal adapter", "status": "planned" },
"viewport": { "provider": "Three.js internal adapter", "package": "three", "version": "0.185.1", "baseline": "WebGL2", "status": "adapter-skeleton" }, "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", "targetBrowsers": "Capability matrix to be locked in P0-02",
"status": "contract-first-prototype" "status": "facade-first-integration-slice"
} }

View File

@@ -1406,7 +1406,7 @@ SQLite 是运行时的主存储,不要求项目包直接暴露数据库内部
| FreeCAD/OCCT 几何 WASM | `PLANNED` | 由 P3 工作包实施;当前只有确定性 Mock 几何结果 | | FreeCAD/OCCT 几何 WASM | `PLANNED` | 由 P3 工作包实施;当前只有确定性 Mock 几何结果 |
| Three.js 真实视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts`,锁定当前最新 `three@0.185.1`WebGL2 适配器已挂入 Facade | | Three.js 真实视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts`,锁定当前最新 `three@0.185.1`WebGL2 适配器已挂入 Facade |
| React Facade 投影和静态替身 | `DONE` | 工作台、选择、文档树、通知已由 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 工作包实施 | | 标准格式与 FCStd 兼容 | `PLANNED` | 由 P7 工作包实施 |
| Facade 单元测试和入口依赖守卫 | `DONE` | `tests/facade.test.ts``scripts/check-facade-boundary.mjs` | | Facade 单元测试和入口依赖守卫 | `DONE` | `tests/facade.test.ts``scripts/check-facade-boundary.mjs` |
| 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | P8-01 第一批 Facade 测试已通过;跨浏览器和黄金几何仍待后续 | | 自动化测试、性能门禁和发布流水线 | `IN PROGRESS` | P8-01 第一批 Facade 测试已通过;跨浏览器和黄金几何仍待后续 |
@@ -1590,7 +1590,23 @@ P0 基线/治理
| P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`React 工作区已通过事件投影工作台、选择、文档树和通知 | | 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 | | 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 和重计算仍待实现 | | 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 场景和资源释放 | | P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放 |
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史E2E/黄金几何待补齐 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史E2E/黄金几何待补齐 |
本迭代验证命令:`npm run check:facade-boundary``npm run test:facade``npm run build`。构建产物将 Three.js 拆为独立 chunk避免把全部渲染库重复打入应用主 chunk。当前 npm registry 的 `three` 最新版本为 `0.185.1`,已在 `package.json` 和运行时基线中锁定。下一迭代继续完成 P0-01/P0-02 的精确锁定、P2-01/P2-02 的 SQLite/OPFS schema 与 Worker 单写者实验,以及 P3-01 的 FreeCAD/OCCT WASM 构建验证。 本迭代验证命令:`npm run check:facade-boundary``npm run test:facade``npm run build`。构建产物将 Three.js 拆为独立 chunk避免把全部渲染库重复打入应用主 chunk。当前 npm registry 的 `three` 最新版本为 `0.185.1`,已在 `package.json` 和运行时基线中锁定。下一迭代继续完成 P0-01/P0-02 的精确锁定、P2-01/P2-02 的 SQLite/OPFS schema 与 Worker 单写者实验,以及 P3-01 的 FreeCAD/OCCT WASM 构建验证。
### 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
View File

@@ -8,19 +8,23 @@
"name": "bitbybit-web-cad", "name": "bitbybit-web-cad",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"three": "^0.185.1" "three": "0.185.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/three": "^0.185.3", "@types/three": "0.185.3",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"tsx": "^4.23.1", "tsx": "^4.23.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.5" "vite": "^6.0.5"
},
"engines": {
"node": ">=22"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -1100,6 +1104,14 @@
"win32" "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": { "node_modules/@tweenjs/tween.js": {
"version": "23.1.3", "version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",

View File

@@ -3,6 +3,9 @@
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"engines": {
"node": ">=22"
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
@@ -12,15 +15,16 @@
"verify": "npm run check:facade-boundary && npm run test:facade && npm run build" "verify": "npm run check:facade-boundary && npm run test:facade && npm run build"
}, },
"dependencies": { "dependencies": {
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"three": "^0.185.1" "three": "0.185.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/three": "^0.185.3", "@types/three": "0.185.3",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"tsx": "^4.23.1", "tsx": "^4.23.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",

View File

@@ -5,7 +5,7 @@ const sourceRoot = new URL('../src/', import.meta.url)
const forbidden = [ const forbidden = [
/from\s+["']three(?:\/|["'])/, /from\s+["']@types\/three/, /from\s+["']sqlite3?/, /from\s+["']@sqlite/, /from\s+["']opfs/, /SharedArrayBuffer/, /\bpostMessage\s*\(/, /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 = '') { async function walk(relative = '') {
const directory = new URL(relative, sourceRoot) const directory = new URL(relative, sourceRoot)

View File

@@ -168,7 +168,7 @@ function App() {
const setSelectedObject = useCallback((id: string) => id ? facade.selection.select(id) : facade.selection.clear(), [facade]) const setSelectedObject = useCallback((id: string) => id ? facade.selection.select(id) : facade.selection.clear(), [facade])
return ( 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} /> <TopBar page={page} workbench={workbench} facade={facade} onNavigate={navigate} onOpenWorkspace={openWorkspace} />
{page === 'workspace' ? ( {page === 'workspace' ? (
<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></>} /> <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> <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> <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> </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 }) { 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 }) { function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) {

View File

@@ -1,3 +1,5 @@
export { createMockFacade } from './mockFacade' export { createMockFacade } from './mockFacade'
export { createSqliteProjectPersistence, SqliteProjectPersistence } from './projectStore'
export { ThreeViewportAdapter } from './threeViewport' 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'

View File

@@ -13,6 +13,7 @@ import type {
TaskSnapshot, TaskSnapshot,
Unsubscribe, Unsubscribe,
} from './types' } from './types'
import { createSqliteProjectPersistence } from './projectStore'
import { ThreeViewportAdapter } from './threeViewport' import { ThreeViewportAdapter } from './threeViewport'
const initialTree: ModelTreeItem[] = [ const initialTree: ModelTreeItem[] = [
@@ -49,7 +50,8 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
} }
export function createMockFacade(): BitBybitWebCadFacade { 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 listeners = new Set<FacadeListener>()
const undoStack: FacadeState[] = [] const undoStack: FacadeState[] = []
const redoStack: FacadeState[] = [] const redoStack: FacadeState[] = []
@@ -106,7 +108,15 @@ export function createMockFacade(): BitBybitWebCadFacade {
} }
emit({ type: 'command.started', commandId, context }) emit({ type: 'command.started', commandId, context })
if (commandId === 'new-document') commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '' }) 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 (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId)
else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null }) else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null })
emit({ type: 'command.completed', commandId, context }); emitState(); return requestId 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 } }, 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('') }, 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() } }, 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() }, viewport: { createAdapter: () => new ThreeViewportAdapter() },
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, 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 return facade
} }

View 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)) }

View 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

View 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()

View File

@@ -19,6 +19,21 @@ export type DocumentSnapshot = {
tree: ModelTreeItem[] 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 = { export type CommandState = {
id: string id: string
status: 'hidden' | 'disabled' | 'enabled' | 'active' status: 'hidden' | 'disabled' | 'enabled' | 'active'
@@ -55,6 +70,7 @@ export type FacadeState = {
activeWorkbench: WorkbenchId activeWorkbench: WorkbenchId
selectedObjectId: string selectedObjectId: string
document: DocumentSnapshot document: DocumentSnapshot
persistence: PersistenceCapabilities
task: TaskSnapshot | null task: TaskSnapshot | null
lastNotice: string lastNotice: string
diagnostics: Diagnostic[] diagnostics: Diagnostic[]
@@ -122,6 +138,11 @@ export interface BitBybitWebCadFacade {
apply(): void apply(): void
cancel(): void cancel(): void
} }
readonly project: {
capabilities(): PersistenceCapabilities
save(document?: DocumentSnapshot): Promise<ProjectSaveResult>
load(documentId: string): Promise<DocumentSnapshot | null>
}
readonly viewport: { readonly viewport: {
createAdapter(): BitBybitViewportAdapter createAdapter(): BitBybitViewportAdapter
} }

View File

@@ -1,6 +1,7 @@
import { test } from 'node:test' import { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { createMockFacade } from '../src/facade/mockFacade' import { createMockFacade } from '../src/facade/mockFacade'
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema'
test('facade exposes a stable initial document projection', () => { test('facade exposes a stable initial document projection', () => {
const facade = createMockFacade() const facade = createMockFacade()
@@ -11,6 +12,13 @@ test('facade exposes a stable initial document projection', () => {
assert.equal(facade.gui.workbench.getActive(), 'Part Design') 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', () => { test('workbench and selection changes are event driven', () => {
const facade = createMockFacade() const facade = createMockFacade()
const events: string[] = [] const events: string[] = []
@@ -72,6 +80,15 @@ test('document mutations use the facade history boundary', () => {
assert.equal(facade.app.document.getActive().label, 'Untitled document') 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', () => { test('Part Design feature tasks commit a document object and remain undoable', () => {
const facade = createMockFacade() const facade = createMockFacade()
const before = facade.app.document.getActive() const before = facade.app.document.getActive()

View File

@@ -6,6 +6,19 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
strictPort: false, 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: { build: {
chunkSizeWarningLimit: 600, chunkSizeWarningLimit: 600,