Files
Web_FreeCAD_Bitbybit/src/facade/persistenceWorker.ts

224 lines
16 KiB
TypeScript

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<PersistenceRequest>) => 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<string, { resource: ProjectResource; bytes: Uint8Array }>()
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<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, 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<string, string>()
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<Record<string, string | number | null>>
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<Record<string, string | number | null>>
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<Record<string, string>>
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<Record<string, string | null>>
const propertiesByObject = new Map<string, ObjectPropertySnapshot[]>()
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<Record<string, string>>
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<Record<string, string | number>>
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<ProjectResource> => {
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<Record<string, string | number>>
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<ArrayBuffer | null> => {
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<Record<string, string | number>>
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<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; 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)) }