@tanstack/offline-transactions
Version:
Offline-first transaction capabilities for TanStack DB
143 lines (120 loc) • 4.23 kB
text/typescript
import { withSpan } from '../telemetry/tracer'
import { TransactionSerializer } from './TransactionSerializer'
import type { OfflineTransaction, StorageAdapter } from '../types'
import type { Collection } from '@tanstack/db'
export class OutboxManager {
private storage: StorageAdapter
private serializer: TransactionSerializer
private keyPrefix = `tx:`
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}`
}
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) {
console.warn(`Failed to deserialize transaction ${id}:`, error)
span.setAttribute(`result`, `deserialize_error`)
return null
}
})
}
async getAll(): Promise<Array<OfflineTransaction>> {
return 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) {
console.warn(
`Failed to deserialize transaction from key ${key}:`,
error,
)
}
}
}
return transactions.sort(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
)
})
}
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 Error(`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)
})
}
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 Promise.all(transactionKeys.map((key) => this.storage.delete(key)))
}
async count(): Promise<number> {
const keys = await this.storage.keys()
return keys.filter((key) => key.startsWith(this.keyPrefix)).length
}
}