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,8 +1,8 @@
import type { DocumentSnapshot, PersistenceCapabilities, ProjectSaveResult } from './types'
import type { DocumentSnapshot, PersistenceCapabilities, ProjectResource, ProjectSaveResult } from './types'
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document'; documentId: string }
type WorkerInput = { type: 'initialize' | 'dispose' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document'; documentId: string }
type WorkerResponse = { 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: 'disposed' } | { id: number; ok: false; error: string }
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { 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 }
type WorkerInput = { type: 'initialize' | 'dispose' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document'; documentId: string } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string }
type WorkerResponse = { 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 }
const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' }
const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) })
@@ -12,6 +12,11 @@ export interface ProjectPersistenceClient {
capabilities(): PersistenceCapabilities
save(document: DocumentSnapshot): Promise<ProjectSaveResult>
load(documentId: string): Promise<DocumentSnapshot | null>
resource: {
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
get(hash: string): Promise<Uint8Array | null>
release(hash: string): Promise<void>
}
dispose(): Promise<void>
}
@@ -36,6 +41,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
private currentCapabilities = unavailable
private initialized: Promise<PersistenceCapabilities> | null = null
private readonly fallbackSnapshots = new Map<string, DocumentSnapshot>()
private readonly fallbackResources = new Map<string, { resource: ProjectResource; bytes: Uint8Array }>()
private readonly pending = new Map<number, { resolve: (response: WorkerResponse) => void; reject: (error: Error) => void }>()
private readonly writeQueue = new PersistenceWriteQueue()
@@ -75,6 +81,37 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
return response.document
}
readonly resource = {
put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(async () => {
await this.initialize()
if (!this.worker) {
const hash = `memory-${[...bytes].map((value) => value.toString(16).padStart(2, '0')).join('')}`
const existing = this.fallbackResources.get(hash)
const resource = { hash, byteLength: bytes.byteLength, mediaType, refCount: (existing?.resource.refCount || 0) + 1 }
this.fallbackResources.set(hash, { resource, bytes: new Uint8Array(bytes) })
return { ...resource }
}
const payload = bytes.slice()
const response = await this.request({ type: 'put-resource', bytes: payload.buffer as ArrayBuffer, mediaType }, [payload.buffer as ArrayBuffer])
if (!response.ok || response.type !== 'resource-put') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.resource
}),
get: async (hash: string) => {
await this.writeQueue.drain()
await this.initialize()
if (!this.worker) { const resource = this.fallbackResources.get(hash); return resource ? new Uint8Array(resource.bytes) : null }
const response = await this.request({ type: 'get-resource', hash })
if (!response.ok || response.type !== 'resource-get') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
return response.bytes ? new Uint8Array(response.bytes) : null
},
release: (hash: string) => this.writeQueue.run(async () => {
await this.initialize()
if (!this.worker) { const existing = this.fallbackResources.get(hash); if (!existing) return; if (existing.resource.refCount > 1) existing.resource.refCount -= 1; else this.fallbackResources.delete(hash); return }
const response = await this.request({ type: 'release-resource', hash })
if (!response.ok || response.type !== 'resource-released') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
}),
}
async dispose() {
await this.writeQueue.drain()
if (!this.worker) return
@@ -82,12 +119,12 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
this.worker.terminate()
}
private request(input: WorkerInput) {
private request(input: WorkerInput, transfer: Transferable[] = []) {
if (!this.worker) return Promise.reject<WorkerResponse>(new Error(this.currentCapabilities.reason))
const id = ++this.nextRequestId
return new Promise<WorkerResponse>((resolve, reject) => {
this.pending.set(id, { resolve, reject })
this.worker?.postMessage({ ...input, id } satisfies WorkerRequest)
this.worker?.postMessage({ ...input, id } satisfies WorkerRequest, transfer)
})
}
}