P2-03: manage hashed OPFS project resources

This commit is contained in:
2026-08-02 05:15:13 -04:00
parent 657c4beb9f
commit 5f0d9a8d0e
7 changed files with 139 additions and 10 deletions

View File

@@ -1,17 +1,23 @@
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'
import type { DocumentSnapshot, ModelTreeItem, PersistenceCapabilities, 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: '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: '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 }
@@ -23,10 +29,24 @@ type WorkerScope = {
const workerScope = globalThis as unknown as WorkerScope
let sqlite3: Sqlite3Static | undefined
let database: Database | undefined
let assetsDirectory: FileSystemDirectoryHandle | 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 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()
@@ -92,13 +112,56 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => {
}
}
const putResource = async (bytes: ArrayBuffer, mediaType: string): Promise<ProjectResource> => {
if (!database) throw new Error('Persistence database is not initialized.')
const hash = await hashBytes(bytes)
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> => {
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.')
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; 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) }
if (request.type === 'load-document') return { id: request.id, ok: true, type: 'loaded', document: loadDocument(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) }
}