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

@@ -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 }) {

View File

@@ -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'

View File

@@ -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
}

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[]
}
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
}