162 lines
10 KiB
TypeScript
162 lines
10 KiB
TypeScript
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 } | { 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 })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })) })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined })
|
|
|
|
export interface ProjectPersistenceClient {
|
|
initialize(): Promise<PersistenceCapabilities>
|
|
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>
|
|
}
|
|
|
|
/** Serializes persistence mutations while allowing reads to follow the write tail. */
|
|
export class PersistenceWriteQueue {
|
|
private tail: Promise<void> = Promise.resolve()
|
|
|
|
run<T>(operation: () => Promise<T>): Promise<T> {
|
|
const next = this.tail.then(operation)
|
|
this.tail = next.then(() => undefined, () => undefined)
|
|
return next
|
|
}
|
|
|
|
drain(): Promise<void> {
|
|
return this.tail
|
|
}
|
|
}
|
|
|
|
export class ProjectAutosaveScheduler {
|
|
private timer: ReturnType<typeof setTimeout> | null = null
|
|
private pending: DocumentSnapshot | null = null
|
|
private flushPromise: Promise<ProjectSaveResult | null> | null = null
|
|
|
|
constructor(private readonly save: (document: DocumentSnapshot) => Promise<ProjectSaveResult>, private readonly idleMs = 800) {}
|
|
|
|
schedule(document: DocumentSnapshot) {
|
|
this.pending = cloneDocument(document)
|
|
if (this.timer) clearTimeout(this.timer)
|
|
this.timer = setTimeout(() => { this.timer = null; void this.flush() }, this.idleMs)
|
|
}
|
|
|
|
flush() {
|
|
if (this.flushPromise) return this.flushPromise
|
|
const document = this.pending
|
|
this.pending = null
|
|
if (!document) return Promise.resolve(null)
|
|
this.flushPromise = this.save(document).finally(() => { this.flushPromise = null; if (this.pending) void this.flush() })
|
|
return this.flushPromise
|
|
}
|
|
|
|
cancel() {
|
|
if (this.timer) clearTimeout(this.timer)
|
|
this.timer = null
|
|
this.pending = null
|
|
}
|
|
}
|
|
|
|
export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
|
private readonly worker: Worker | null
|
|
private nextRequestId = 0
|
|
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()
|
|
|
|
constructor() {
|
|
this.worker = typeof Worker === 'undefined' ? null : new Worker(new URL('./persistenceWorker.ts', import.meta.url), { type: 'module', name: 'bitbybit-persistence' })
|
|
if (!this.worker) this.currentCapabilities = { mode: 'sqlite-memory', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Worker is unavailable; using a transient in-memory project store.' }
|
|
if (this.worker) {
|
|
this.worker.onmessage = (event: MessageEvent<WorkerResponse>) => { const request = this.pending.get(event.data.id); if (!request) return; this.pending.delete(event.data.id); request.resolve(event.data) }
|
|
this.worker.onerror = (event) => { const error = new Error(event.message || 'Persistence Worker failed.'); this.currentCapabilities = { ...unavailable, reason: error.message }; this.initialized = null; for (const request of this.pending.values()) request.reject(error); this.pending.clear() }
|
|
}
|
|
}
|
|
|
|
capabilities() { return { ...this.currentCapabilities } }
|
|
|
|
initialize() {
|
|
if (!this.worker) return Promise.resolve(this.currentCapabilities)
|
|
if (!this.initialized) this.initialized = this.request({ type: 'initialize' }).then((response) => { if (!response.ok || response.type !== 'initialized') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error); this.currentCapabilities = response.capabilities; return this.capabilities() })
|
|
return this.initialized
|
|
}
|
|
|
|
save(document: DocumentSnapshot) {
|
|
return this.writeQueue.run(async () => {
|
|
await this.initialize()
|
|
if (!this.worker) { const snapshot = cloneDocument(document); this.fallbackSnapshots.set(snapshot.id, snapshot); return { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt: Date.now(), mode: this.currentCapabilities.mode } }
|
|
const response = await this.request({ type: 'save-document', document })
|
|
if (!response.ok || response.type !== 'saved') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
|
|
return { documentId: response.documentId, documentVersion: response.documentVersion, persistedAt: response.persistedAt, mode: response.mode }
|
|
})
|
|
}
|
|
|
|
async load(documentId: string) {
|
|
await this.writeQueue.drain()
|
|
await this.initialize()
|
|
if (!this.worker) { const snapshot = this.fallbackSnapshots.get(documentId); return snapshot ? cloneDocument(snapshot) : null }
|
|
const response = await this.request({ type: 'load-document', documentId })
|
|
if (!response.ok || response.type !== 'loaded') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
|
|
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
|
|
await this.request({ type: 'dispose' })
|
|
this.worker.terminate()
|
|
}
|
|
|
|
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, transfer)
|
|
})
|
|
}
|
|
}
|
|
|
|
export const createSqliteProjectPersistence = () => new SqliteProjectPersistence()
|