@tanstack/offline-transactions
Version:
Offline-first transaction capabilities for TanStack DB
160 lines (159 loc) • 5.42 kB
JavaScript
import { withSpan } from "../telemetry/tracer.js";
import { TransactionSerializer, MissingTemporalConstructorError } from "./TransactionSerializer.js";
class OutboxTransactionNotFoundError extends Error {
}
class OutboxManager {
constructor(storage, collections) {
this.keyPrefix = `tx:`;
this.removalRevision = 0;
this.activeReads = /* @__PURE__ */ new Set();
this.removedAtRevision = /* @__PURE__ */ new Map();
this.storage = storage;
this.serializer = new TransactionSerializer(collections);
}
getStorageKey(id) {
return `${this.keyPrefix}${id}`;
}
recordRemovals(ids) {
this.removalRevision++;
if (this.activeReads.size === 0) return;
for (const id of ids) this.removedAtRevision.set(id, this.removalRevision);
this.pruneRemovalHistory();
}
pruneRemovalHistory() {
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) {
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) {
return withSpan(`outbox.get`, {}, 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() {
return this.withAll((transactions) => transactions);
}
async withAll(consume) {
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 = [];
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());
return consume(currentTransactions);
});
} finally {
this.activeReads.delete(read);
this.pruneRemovalHistory();
}
}
async getByKeys(keys) {
const allTransactions = await this.getAll();
const keySet = new Set(keys);
return allTransactions.filter(
(transaction) => transaction.keys.some((key) => keySet.has(key))
);
}
async update(id, updates) {
return withSpan(`outbox.update`, {}, 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) {
return withSpan(`outbox.remove`, {}, async () => {
const key = this.getStorageKey(id);
await this.storage.delete(key);
this.recordRemovals([id]);
});
}
async removeMany(ids) {
return withSpan(`outbox.removeMany`, { count: ids.length }, async () => {
await Promise.all(ids.map((id) => this.remove(id)));
});
}
async clear() {
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() {
const keys = await this.storage.keys();
return keys.filter((key) => key.startsWith(this.keyPrefix)).length;
}
}
export {
OutboxManager,
OutboxTransactionNotFoundError
};
//# sourceMappingURL=OutboxManager.js.map