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