import sqlite3InitModule, { type Database, type Sqlite3Static } from '@sqlite.org/sqlite-wasm' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION } from './projectSchema' import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource } 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: 'recovery-report'; documentId: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: 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: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string } type WorkerScope = { postMessage(message: PersistenceResponse): void onmessage: ((event: MessageEvent) => void) | null } const workerScope = globalThis as unknown as WorkerScope let sqlite3: Sqlite3Static | undefined let database: Database | undefined let assetsDirectory: FileSystemDirectoryHandle | undefined const transientResources = new Map() 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 getAssetsDirectory = async () => { if (assetsDirectory) return assetsDirectory if (!('storage' in navigator) || !navigator.storage.getDirectory) throw new Error('OPFS is unavailable for resource storage.') const root = await navigator.storage.getDirectory() assetsDirectory = await root.getDirectoryHandle('bitbybit-assets', { create: true }) return assetsDirectory } const hashBytes = async (bytes: ArrayBuffer) => { const digest = await crypto.subtle.digest('SHA-256', bytes) return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, '0')).join('') } const initialize = async (): Promise => { if (database) return capabilities sqlite3 = await sqlite3InitModule() const opfsAvailable = Boolean(sqlite3.oo1.OpfsDb) try { database = opfsAvailable ? new sqlite3.oo1.OpfsDb('/bitbybit-project.sqlite3') : new sqlite3.oo1.DB(':memory:', 'c') capabilities = { mode: opfsAvailable ? 'sqlite-opfs' : 'sqlite-memory', sqliteWasm: true, opfs: opfsAvailable, schemaVersion: PROJECT_SCHEMA_VERSION, reason: opfsAvailable ? undefined : 'OPFS VFS is unavailable; persistence is transient until export.' } } catch (error) { database = new sqlite3.oo1.DB(':memory:', 'c') capabilities = { mode: 'sqlite-memory', sqliteWasm: true, opfs: false, schemaVersion: PROJECT_SCHEMA_VERSION, reason: error instanceof Error ? `OPFS initialization failed: ${error.message}` : 'OPFS initialization failed.' } } database.exec('PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);') database.exec('BEGIN;') try { for (const migration of PROJECT_SCHEMA_MIGRATIONS) { const applied = database.exec({ sql: 'SELECT version FROM schema_migrations WHERE version = ?', bind: [migration.version], returnValue: 'resultRows' }) as unknown[] if (applied.length === 0) { database.exec(migration.sql) database.exec({ sql: 'INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)', bind: [migration.version, Date.now()] }) } } database.exec('COMMIT;') } catch (error) { database.exec('ROLLBACK;') throw error } return capabilities } const saveDocument = (document: DocumentSnapshot) => { if (!database) throw new Error('Persistence database is not initialized.') const now = Date.now() database.exec('BEGIN;') try { database.exec({ sql: 'INSERT INTO projects(id, name, schema_version, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, schema_version=excluded.schema_version, updated_at=excluded.updated_at', bind: [document.id, document.label, PROJECT_SCHEMA_VERSION, now, now] }) database.exec({ sql: 'INSERT INTO documents(id, project_id, label, version, dirty, read_only, units, recompute_json) VALUES(?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET label=excluded.label, version=excluded.version, dirty=excluded.dirty, read_only=excluded.read_only, units=excluded.units, recompute_json=excluded.recompute_json', bind: [document.id, document.id, document.label, document.version, document.dirty ? 1 : 0, document.readOnly ? 1 : 0, document.units, JSON.stringify(document.recompute ?? null)] }) database.exec({ sql: 'DELETE FROM objects WHERE document_id = ?', bind: [document.id] }) const parentByChild = new Map() for (const item of document.tree) for (const childId of item.children || []) parentByChild.set(childId, item.id) document.tree.forEach((item, ordinal) => { const object = document.objects.find((candidate) => candidate.id === item.id); database?.exec({ sql: 'INSERT INTO objects(id, document_id, parent_id, label, object_type, state, detail, children_json, ordinal, sketch_json) 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, object?.sketch ? JSON.stringify(object.sketch) : null] }) }) for (const object of document.objects) for (const property of object.properties) database.exec({ sql: 'INSERT INTO object_properties(document_id, object_id, name, value_json, property_type, updated_at) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, object.id, property.name, JSON.stringify(property), property.type, now] }) database.exec({ sql: 'DELETE FROM dependencies WHERE document_id = ?', bind: [document.id] }) for (const edge of document.dependencies ?? []) database.exec({ sql: 'INSERT INTO dependencies(document_id, source_id, target_id, relation, property_name, reference) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, edge.sourceId, edge.targetId, edge.relation, edge.propertyName ?? null, edge.reference ?? null] }) 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, recompute_json FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> const row = documents[0] if (!row) return null const objects = database.exec({ sql: 'SELECT id, label, object_type, state, detail, children_json, sketch_json FROM objects WHERE document_id = ? ORDER BY ordinal', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> const propertyRows = database.exec({ sql: 'SELECT object_id, value_json FROM object_properties WHERE document_id = ? ORDER BY object_id, name', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> const dependencyRows = database.exec({ sql: 'SELECT source_id, target_id, relation, property_name, reference FROM dependencies WHERE document_id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> const propertiesByObject = new Map() for (const propertyRow of propertyRows) { const properties = propertiesByObject.get(String(propertyRow.object_id)) ?? [] properties.push(JSON.parse(String(propertyRow.value_json)) as ObjectPropertySnapshot) propertiesByObject.set(String(propertyRow.object_id), properties) } const tree: ModelTreeItem[] = 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 objectSnapshots: DocumentObjectSnapshot[] = tree.map((item) => { const properties = propertiesByObject.get(item.id) ?? [] const typeId = properties.find((property) => property.name === 'TypeId')?.value const row = objects.find((candidate) => String(candidate.id) === item.id) return { id: item.id, typeId: typeof typeId === 'string' ? typeId : item.type, properties, sketch: row?.sketch_json ? JSON.parse(String(row.sketch_json)) : undefined } }) 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: objectSnapshots, dependencies: dependencyRows.map((dependency) => ({ sourceId: String(dependency.source_id), targetId: String(dependency.target_id), relation: String(dependency.relation) as 'link' | 'expression' | 'topo-ref' | 'container' | 'view', propertyName: dependency.property_name ? String(dependency.property_name) : undefined, reference: dependency.reference ? String(dependency.reference) : undefined })), recompute: row.recompute_json ? JSON.parse(String(row.recompute_json)) : undefined, } } const recoveryReport = (documentId: string): ProjectRecoveryReport => { if (!database) throw new Error('Persistence database is not initialized.') const integrityRows = database.exec({ sql: 'PRAGMA integrity_check', rowMode: 'object', returnValue: 'resultRows' }) as Array> const integrityValue = String(integrityRows[0]?.integrity_check ?? '') const documents = database.exec({ sql: 'SELECT version, dirty FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> const warnings: string[] = [] if (!documents[0]) warnings.push('No saved document exists for this ID.') if (integrityValue !== 'ok') warnings.push(`SQLite integrity check returned: ${integrityValue || 'no result'}.`) return { documentId, mode: capabilities.mode, schemaVersion: capabilities.schemaVersion, integrity: !documents[0] ? 'unavailable' : integrityValue === 'ok' ? 'ok' : 'failed', lastSavedVersion: documents[0] ? Number(documents[0].version) : null, dirtyAtLastSave: documents[0] ? Boolean(documents[0].dirty) : null, warnings } } const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise => { if (!database) throw new Error('Persistence database is not initialized.') const hash = await hashBytes(bytes) if (!capabilities.opfs) { const existing = transientResources.get(hash) const resource = { hash, byteLength: bytes.byteLength, mediaType, refCount: (existing?.resource.refCount || 0) + 1 } transientResources.set(hash, { resource, bytes: new Uint8Array(bytes.slice(0)) }) return { ...resource } } const directory = await getAssetsDirectory() const handle = await directory.getFileHandle(hash, { create: true }) const writable = await handle.createWritable() await writable.write(bytes) await writable.close() const now = Date.now() database.exec({ sql: 'INSERT INTO resources(hash, path, byte_length, media_type, ref_count, created_at, updated_at) VALUES(?, ?, ?, ?, 1, ?, ?) ON CONFLICT(hash) DO UPDATE SET ref_count=resources.ref_count + 1, updated_at=excluded.updated_at', bind: [hash, `bitbybit-assets/${hash}`, bytes.byteLength, mediaType, now, now] }) const resources = database.exec({ sql: 'SELECT hash, byte_length, media_type, ref_count FROM resources WHERE hash = ?', bind: [hash], rowMode: 'object', returnValue: 'resultRows' }) as Array> const row = resources[0] return { hash, byteLength: Number(row.byte_length), mediaType: String(row.media_type), refCount: Number(row.ref_count) } } const getResource = async (hash: string): Promise => { if (!capabilities.opfs) { const resource = transientResources.get(hash) return resource ? resource.bytes.slice().buffer : null } const directory = await getAssetsDirectory() try { const handle = await directory.getFileHandle(hash) return (await handle.getFile()).arrayBuffer() } catch (error) { if (error instanceof DOMException && error.name === 'NotFoundError') return null throw error } } const releaseResource = async (hash: string) => { if (!database) throw new Error('Persistence database is not initialized.') if (!capabilities.opfs) { const existing = transientResources.get(hash) if (!existing) return if (existing.resource.refCount > 1) existing.resource.refCount -= 1 else transientResources.delete(hash) return } const resources = database.exec({ sql: 'SELECT ref_count FROM resources WHERE hash = ?', bind: [hash], rowMode: 'object', returnValue: 'resultRows' }) as Array> const row = resources[0] if (!row) return if (Number(row.ref_count) > 1) { database.exec({ sql: 'UPDATE resources SET ref_count = ref_count - 1, updated_at = ? WHERE hash = ?', bind: [Date.now(), hash] }) return } database.exec({ sql: 'DELETE FROM resources WHERE hash = ?', bind: [hash] }) try { await (await getAssetsDirectory()).removeEntry(hash) } catch (error) { if (!(error instanceof DOMException && error.name === 'NotFoundError')) throw error } } const handle = async (request: PersistenceRequest): Promise => { try { if (request.type === 'initialize') return { id: request.id, ok: true, type: 'initialized', capabilities: await initialize() } if (request.type === 'dispose') { database?.close(); database = undefined; transientResources.clear(); 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 } } if (request.type === 'load-document') return { id: request.id, ok: true, type: 'loaded', document: loadDocument(request.documentId) } if (request.type === 'recovery-report') return { id: request.id, ok: true, type: 'recovery-report', report: recoveryReport(request.documentId) } if (request.type === 'put-resource') return { id: request.id, ok: true, type: 'resource-put', resource: await putResource(request.bytes, request.mediaType) } if (request.type === 'get-resource') return { id: request.id, ok: true, type: 'resource-get', bytes: await getResource(request.hash) } if (request.type === 'release-resource') { await releaseResource(request.hash); return { id: request.id, ok: true, type: 'resource-released' } } throw new Error('Unknown persistence request.') } 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)) }