UNPKG

@tanstack/offline-transactions

Version:

Offline-first transaction capabilities for TanStack DB

199 lines (172 loc) • 6.3 kB
import { withSpan } from '../telemetry/tracer' import { MissingTemporalConstructorError, TransactionSerializer, } from './TransactionSerializer' import type { OfflineTransaction, StorageAdapter } from '../types' import type { Collection } from '@tanstack/db' export class OutboxTransactionNotFoundError extends Error {} export class OutboxManager { private storage: StorageAdapter private serializer: TransactionSerializer private keyPrefix = `tx:` private removalRevision = 0 private activeReads = new Set<{ revision: number }>() private removedAtRevision = new Map<string, number>() constructor( storage: StorageAdapter, collections: Record<string, Collection<any, any, any, any, any>>, ) { this.storage = storage this.serializer = new TransactionSerializer(collections) } private getStorageKey(id: string): string { return `${this.keyPrefix}${id}` } private recordRemovals(ids: Iterable<string>): void { this.removalRevision++ if (this.activeReads.size === 0) return for (const id of ids) this.removedAtRevision.set(id, this.removalRevision) this.pruneRemovalHistory() } private pruneRemovalHistory(): void { if (this.activeReads.size === 0) { this.removedAtRevision.clear() return } let oldestRead = Number.POSITIVE_INFINITY for (const read of this.activeReads) oldestRead = Math.min(oldestRead, read.revision) for (const [id, revision] of this.removedAtRevision) if (revision <= oldestRead) this.removedAtRevision.delete(id) } async add(transaction: OfflineTransaction): Promise<void> { return withSpan( `outbox.add`, { 'transaction.id': transaction.id, 'transaction.mutationFnName': transaction.mutationFnName, 'transaction.keyCount': transaction.keys.length, }, async () => { const key = this.getStorageKey(transaction.id) const serialized = this.serializer.serialize(transaction) await this.storage.set(key, serialized) }, ) } async get(id: string): Promise<OfflineTransaction | null> { return withSpan(`outbox.get`, { 'transaction.id': id }, async (span) => { const key = this.getStorageKey(id) const data = await this.storage.get(key) if (!data) { span.setAttribute(`result`, `not_found`) return null } try { const transaction = this.serializer.deserialize(data) span.setAttribute(`result`, `found`) return transaction } catch (error) { if (error instanceof MissingTemporalConstructorError) { error.message = `transaction ${id}: ${error.message}` throw error } console.warn(`Failed to deserialize transaction ${id}:`, error) span.setAttribute(`result`, `deserialize_error`) return null } }) } async getAll(): Promise<Array<OfflineTransaction>> { return this.withAll((transactions) => transactions) } async withAll<T>( consume: (transactions: Array<OfflineTransaction>) => T, ): Promise<T> { const read = { revision: this.removalRevision } this.activeReads.add(read) try { return await withSpan(`outbox.getAll`, {}, async (span) => { const keys = await this.storage.keys() const transactionKeys = keys.filter((key) => key.startsWith(this.keyPrefix), ) span.setAttribute(`transactionCount`, transactionKeys.length) const transactions: Array<OfflineTransaction> = [] for (const key of transactionKeys) { const data = await this.storage.get(key) if (data) { try { const transaction = this.serializer.deserialize(data) transactions.push(transaction) } catch (error) { if (error instanceof MissingTemporalConstructorError) { error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}` throw error } console.warn( `Failed to deserialize transaction from key ${key}:`, error, ) } } } const currentTransactions = transactions .filter( ({ id }) => (this.removedAtRevision.get(id) ?? 0) <= read.revision, ) .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) // Keep the read registered until replay admission completes. A durable // removal cannot land between filtering and this synchronous consumer. return consume(currentTransactions) }) } finally { this.activeReads.delete(read) this.pruneRemovalHistory() } } async getByKeys(keys: Array<string>): Promise<Array<OfflineTransaction>> { const allTransactions = await this.getAll() const keySet = new Set(keys) return allTransactions.filter((transaction) => transaction.keys.some((key) => keySet.has(key)), ) } async update( id: string, updates: Partial<OfflineTransaction>, ): Promise<void> { return withSpan(`outbox.update`, { 'transaction.id': id }, async () => { const existing = await this.get(id) if (!existing) { throw new OutboxTransactionNotFoundError(`Transaction ${id} not found`) } const updated = { ...existing, ...updates } await this.add(updated) }) } async remove(id: string): Promise<void> { return withSpan(`outbox.remove`, { 'transaction.id': id }, async () => { const key = this.getStorageKey(id) await this.storage.delete(key) this.recordRemovals([id]) }) } async removeMany(ids: Array<string>): Promise<void> { return withSpan(`outbox.removeMany`, { count: ids.length }, async () => { await Promise.all(ids.map((id) => this.remove(id))) }) } async clear(): Promise<void> { const keys = await this.storage.keys() const transactionKeys = keys.filter((key) => key.startsWith(this.keyPrefix)) await this.removeMany( transactionKeys.map((key) => key.slice(this.keyPrefix.length)), ) } async count(): Promise<number> { const keys = await this.storage.keys() return keys.filter((key) => key.startsWith(this.keyPrefix)).length } }