224 lines
16 KiB
TypeScript
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)) }
|