P2-03: manage hashed OPFS project resources
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user