@tanstack/db
Version:
A reactive client store for building super fast apps on sync
455 lines (454 loc) • 14.9 kB
JavaScript
import { createDeferred } from "./deferred.js";
import { safeRandomUUID } from "./utils/uuid.js";
import { MissingMutationFunctionError, TransactionNotPendingMutateError, TransactionAlreadyCompletedRollbackError, TransactionNotPendingCommitError } from "./errors.js";
import { transactionScopedScheduler } from "./scheduler.js";
class TransactionScope {
constructor() {
this.transactions = [];
this.transactionStack = [];
this.sequenceNumber = 0;
}
createTransaction(config) {
const transaction = new Transaction(config, this, this.sequenceNumber++);
this.transactions.push(transaction);
return transaction;
}
getActiveTransaction() {
return this.transactionStack.at(-1);
}
getActiveTransactionForCollection() {
const activeTransaction = this.getActiveTransaction();
if (activeTransaction) {
return activeTransaction;
}
if (this === defaultTransactionScope) {
return void 0;
}
return defaultTransactionScope.claimActiveTransaction(this);
}
claimActiveTransaction(targetScope) {
const transaction = this.getActiveTransaction();
if (!transaction) {
return void 0;
}
const owner = getTransactionScope(transaction);
if (owner === targetScope) {
return transaction;
}
if (owner !== this) {
throw new Error(
`A transaction created with createTransaction() cannot mutate collections from multiple DbClient instances. Use dbClient.createTransaction() for explicit client scope.`
);
}
this.removeTransaction(transaction);
targetScope.transactions.push(transaction);
targetScope.transactionStack.push(transaction);
transaction.sequenceNumber = targetScope.sequenceNumber++;
transactionScopes.set(transaction, targetScope);
return transaction;
}
registerTransaction(transaction) {
transactionScopedScheduler.clear(transaction.id);
this.transactionStack.push(transaction);
}
unregisterTransaction(transaction) {
try {
transactionScopedScheduler.flush(transaction.id);
} finally {
this.transactionStack = this.transactionStack.filter(
(candidate) => candidate.id !== transaction.id
);
}
}
removeTransaction(transaction) {
const index = this.transactions.findIndex(
(candidate) => candidate.id === transaction.id
);
if (index !== -1) {
this.transactions.splice(index, 1);
}
}
rollbackConflictingTransactions(transaction, mutationIds) {
for (const candidate of [...this.transactions]) {
if (candidate !== transaction && candidate.state === `pending` && candidate.mutations.some(
(mutation) => mutationIds.has(mutation.globalKey)
)) {
candidate.rollback({ isSecondaryRollback: true });
}
}
}
clear() {
const transactionIds = /* @__PURE__ */ new Set([
...this.transactions.map((transaction) => transaction.id),
...this.transactionStack.map((transaction) => transaction.id)
]);
for (const transactionId of transactionIds) {
transactionScopedScheduler.clear(transactionId);
}
this.transactions = [];
this.transactionStack = [];
}
}
const defaultTransactionScope = new TransactionScope();
const transactionScopes = /* @__PURE__ */ new WeakMap();
const transactionAmbientScopes = /* @__PURE__ */ new WeakMap();
function getTransactionScope(transaction) {
const scope = transactionScopes.get(transaction);
if (!scope) {
throw new Error(`Transaction is not associated with a TransactionScope.`);
}
return scope;
}
function getTransactionAmbientScope(transaction) {
const scope = transactionAmbientScopes.get(transaction);
if (!scope) {
throw new Error(`Transaction is not associated with an ambient scope.`);
}
return scope;
}
function mergePendingMutations(existing, incoming) {
switch (`${existing.type}-${incoming.type}`) {
case `insert-update`: {
return {
...existing,
type: `insert`,
original: {},
modified: incoming.modified,
changes: { ...existing.changes, ...incoming.changes },
// Keep existing keys (key changes not allowed in updates)
key: existing.key,
globalKey: existing.globalKey,
// Merge metadata (last-write-wins)
metadata: incoming.metadata ?? existing.metadata,
syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata },
// Update tracking info
mutationId: incoming.mutationId,
updatedAt: incoming.updatedAt
};
}
case `insert-delete`:
return null;
case `update-delete`:
return incoming;
case `update-update`: {
return {
...incoming,
// Keep original from first update
original: existing.original,
// Union the changes from both updates
changes: { ...existing.changes, ...incoming.changes },
// Merge metadata
metadata: incoming.metadata ?? existing.metadata,
syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata }
};
}
case `delete-delete`:
case `insert-insert`:
return incoming;
default: {
const _exhaustive = `${existing.type}-${incoming.type}`;
throw new Error(`Unhandled mutation combination: ${_exhaustive}`);
}
}
}
function createTransaction(config) {
return defaultTransactionScope.createTransaction(config);
}
function getActiveTransaction() {
return defaultTransactionScope.getActiveTransaction();
}
class Transaction {
constructor(config, scope, sequenceNumber) {
if (typeof config.mutationFn === `undefined`) {
throw new MissingMutationFunctionError();
}
this.id = config.id ?? safeRandomUUID();
this.mutationFn = config.mutationFn;
this.state = `pending`;
this.mutations = [];
this.isPersisted = createDeferred();
this.autoCommit = config.autoCommit ?? true;
this.createdAt = /* @__PURE__ */ new Date();
this.sequenceNumber = sequenceNumber;
this.metadata = config.metadata ?? {};
transactionScopes.set(this, scope);
transactionAmbientScopes.set(this, scope);
}
setState(newState) {
this.state = newState;
if (newState === `completed` || newState === `failed`) {
getTransactionScope(this).removeTransaction(this);
}
}
/**
* Execute collection operations within this transaction
* @param callback - Synchronous function containing collection operations to group together.
* The transaction context is active only for the synchronous duration of this callback.
* Async work should happen in `mutationFn`; collection operations after `await` boundaries
* inside this callback will not be part of this transaction. For manual transactions, call
* `mutate` multiple times before committing to add more synchronous operations to the same
* transaction.
* @returns This transaction for chaining
* @example
* // Group multiple operations
* const tx = createTransaction({ mutationFn: async () => {
* // Send to API
* }})
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "Buy milk" })
* collection.update("2", draft => { draft.completed = true })
* collection.delete("3")
* })
*
* await tx.isPersisted.promise
*
* @example
* // Handle mutate errors
* try {
* tx.mutate(() => {
* collection.insert({ id: "invalid" }) // This might throw
* })
* } catch (error) {
* console.log('Mutation failed:', error)
* }
*
* @example
* // Manual commit control
* const tx = createTransaction({ autoCommit: false, mutationFn: async () => {} })
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "Item" })
* })
*
* // Add more synchronous mutations to the same transaction
* tx.mutate(() => {
* collection.update("1", draft => { draft.text = "Updated item" })
* })
*
* // Commit later when ready
* await tx.commit()
*/
mutate(callback) {
if (this.state !== `pending`) {
throw new TransactionNotPendingMutateError();
}
const initialScope = getTransactionScope(this);
const registeredScopes = /* @__PURE__ */ new Set([
initialScope,
getTransactionAmbientScope(this)
]);
for (const scope of registeredScopes) {
scope.registerTransaction(this);
}
try {
callback();
} finally {
registeredScopes.add(getTransactionScope(this));
for (const scope of registeredScopes) {
scope.unregisterTransaction(this);
}
}
if (this.autoCommit) {
this.commit().catch(() => {
});
}
return this;
}
/**
* Apply new mutations to this transaction, intelligently merging with existing mutations
*
* When mutations operate on the same item (same globalKey), they are merged according to
* the following rules:
*
* - **insert + update** → insert (merge changes, keep empty original)
* - **insert + delete** → removed (mutations cancel each other out)
* - **update + delete** → delete (delete dominates)
* - **update + update** → update (union changes, keep first original)
* - **same type** → replace with latest
*
* This merging reduces over-the-wire churn and keeps the optimistic local view
* aligned with user intent.
*
* @param mutations - Array of new mutations to apply
*/
applyMutations(mutations) {
const merged = /* @__PURE__ */ new Map();
for (const mutation of this.mutations) {
merged.set(mutation.globalKey, mutation);
}
for (const newMutation of mutations) {
const existingMutation = merged.get(newMutation.globalKey);
if (existingMutation) {
const mergeResult = mergePendingMutations(existingMutation, newMutation);
if (mergeResult === null) {
merged.delete(newMutation.globalKey);
} else {
merged.set(newMutation.globalKey, mergeResult);
}
} else {
merged.set(newMutation.globalKey, newMutation);
}
}
this.mutations.length = 0;
for (const mutation of merged.values()) {
this.mutations.push(mutation);
}
}
/**
* Rollback the transaction and any conflicting transactions
* @param config - Configuration for rollback behavior
* @returns This transaction for chaining
* @example
* // Manual rollback
* const tx = createTransaction({ mutationFn: async () => {
* // Send to API
* }})
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "Buy milk" })
* })
*
* // Rollback if needed
* if (shouldCancel) {
* tx.rollback()
* }
*
* @example
* // Handle rollback cascade (automatic)
* const tx1 = createTransaction({ mutationFn: async () => {} })
* const tx2 = createTransaction({ mutationFn: async () => {} })
*
* tx1.mutate(() => collection.update("1", draft => { draft.value = "A" }))
* tx2.mutate(() => collection.update("1", draft => { draft.value = "B" })) // Same item
*
* tx1.rollback() // This will also rollback tx2 due to conflict
*
* @example
* // Handle rollback in error scenarios
* try {
* await tx.isPersisted.promise
* } catch (error) {
* console.log('Transaction was rolled back:', error)
* // Transaction automatically rolled back on mutation function failure
* }
*/
rollback(config) {
const isSecondaryRollback = config?.isSecondaryRollback ?? false;
if (this.state === `completed`) {
throw new TransactionAlreadyCompletedRollbackError();
}
this.setState(`failed`);
if (!isSecondaryRollback) {
const mutationIds = new Set(
this.mutations.map((mutation) => mutation.globalKey)
);
getTransactionScope(this).rollbackConflictingTransactions(
this,
mutationIds
);
}
this.isPersisted.reject(this.error?.error);
this.touchCollection();
return this;
}
// Tell collection that something has changed with the transaction
touchCollection() {
const hasCalled = /* @__PURE__ */ new Set();
for (const mutation of this.mutations) {
if (!hasCalled.has(mutation.collection.id)) {
mutation.collection._state.onTransactionStateChange();
if (mutation.collection._state.pendingSyncedTransactions.length > 0) {
mutation.collection._state.commitPendingTransactions();
}
hasCalled.add(mutation.collection.id);
}
}
}
/**
* Commit the transaction and execute the mutation function
* @returns Promise that resolves to this transaction when complete
* @example
* // Manual commit (when autoCommit is false)
* const tx = createTransaction({
* autoCommit: false,
* mutationFn: async ({ transaction }) => {
* await api.saveChanges(transaction.mutations)
* }
* })
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "Buy milk" })
* })
*
* await tx.commit() // Manually commit
*
* @example
* // Handle commit errors
* try {
* const tx = createTransaction({
* mutationFn: async () => { throw new Error("API failed") }
* })
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "Item" })
* })
*
* await tx.commit()
* } catch (error) {
* console.log('Commit failed, transaction rolled back:', error)
* }
*
* @example
* // Check transaction state after commit
* await tx.commit()
* console.log(tx.state) // "completed" or "failed"
*/
async commit() {
if (this.state !== `pending`) {
throw new TransactionNotPendingCommitError();
}
this.setState(`persisting`);
if (this.mutations.length === 0) {
this.setState(`completed`);
this.isPersisted.resolve(this);
return this;
}
try {
await this.mutationFn({
transaction: this
});
this.setState(`completed`);
this.touchCollection();
this.isPersisted.resolve(this);
} catch (error) {
const originalError = error instanceof Error ? error : new Error(String(error));
this.error = {
message: originalError.message,
error: originalError
};
this.rollback();
throw originalError;
}
return this;
}
/**
* Compare two transactions by their createdAt time and sequence number in order
* to sort them in the order they were created.
* @param other - The other transaction to compare to
* @returns -1 if this transaction was created before the other, 1 if it was created after, 0 if they were created at the same time
*/
compareCreatedAt(other) {
const createdAtComparison = this.createdAt.getTime() - other.createdAt.getTime();
if (createdAtComparison !== 0) {
return createdAtComparison;
}
return this.sequenceNumber - other.sequenceNumber;
}
}
export {
TransactionScope,
createTransaction,
getActiveTransaction
};
//# sourceMappingURL=transactions.js.map