@tanstack/db 0.4.15 → 0.4.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/cjs/duplicate-instance-check.d.cts +1 -0
  2. package/dist/cjs/errors.cjs +38 -0
  3. package/dist/cjs/errors.cjs.map +1 -1
  4. package/dist/cjs/errors.d.cts +6 -0
  5. package/dist/cjs/index.cjs +10 -0
  6. package/dist/cjs/index.cjs.map +1 -1
  7. package/dist/cjs/index.d.cts +2 -0
  8. package/dist/cjs/indexes/auto-index.cjs +17 -8
  9. package/dist/cjs/indexes/auto-index.cjs.map +1 -1
  10. package/dist/cjs/optimistic-action.cjs +6 -1
  11. package/dist/cjs/optimistic-action.cjs.map +1 -1
  12. package/dist/cjs/paced-mutations.cjs +52 -0
  13. package/dist/cjs/paced-mutations.cjs.map +1 -0
  14. package/dist/cjs/paced-mutations.d.cts +81 -0
  15. package/dist/cjs/query/optimizer.cjs +17 -2
  16. package/dist/cjs/query/optimizer.cjs.map +1 -1
  17. package/dist/cjs/strategies/debounceStrategy.cjs +21 -0
  18. package/dist/cjs/strategies/debounceStrategy.cjs.map +1 -0
  19. package/dist/cjs/strategies/debounceStrategy.d.cts +25 -0
  20. package/dist/cjs/strategies/index.d.cts +4 -0
  21. package/dist/cjs/strategies/queueStrategy.cjs +33 -0
  22. package/dist/cjs/strategies/queueStrategy.cjs.map +1 -0
  23. package/dist/cjs/strategies/queueStrategy.d.cts +43 -0
  24. package/dist/cjs/strategies/throttleStrategy.cjs +21 -0
  25. package/dist/cjs/strategies/throttleStrategy.cjs.map +1 -0
  26. package/dist/cjs/strategies/throttleStrategy.d.cts +45 -0
  27. package/dist/cjs/strategies/types.d.cts +103 -0
  28. package/dist/cjs/transactions.cjs +3 -1
  29. package/dist/cjs/transactions.cjs.map +1 -1
  30. package/dist/cjs/transactions.d.cts +3 -1
  31. package/dist/cjs/utils/type-guards.cjs +7 -0
  32. package/dist/cjs/utils/type-guards.cjs.map +1 -0
  33. package/dist/cjs/utils/type-guards.d.cts +6 -0
  34. package/dist/esm/duplicate-instance-check.d.ts +1 -0
  35. package/dist/esm/errors.d.ts +6 -0
  36. package/dist/esm/errors.js +38 -0
  37. package/dist/esm/errors.js.map +1 -1
  38. package/dist/esm/index.d.ts +2 -0
  39. package/dist/esm/index.js +11 -1
  40. package/dist/esm/index.js.map +1 -1
  41. package/dist/esm/indexes/auto-index.js +17 -8
  42. package/dist/esm/indexes/auto-index.js.map +1 -1
  43. package/dist/esm/optimistic-action.js +6 -1
  44. package/dist/esm/optimistic-action.js.map +1 -1
  45. package/dist/esm/paced-mutations.d.ts +81 -0
  46. package/dist/esm/paced-mutations.js +52 -0
  47. package/dist/esm/paced-mutations.js.map +1 -0
  48. package/dist/esm/query/optimizer.js +17 -2
  49. package/dist/esm/query/optimizer.js.map +1 -1
  50. package/dist/esm/strategies/debounceStrategy.d.ts +25 -0
  51. package/dist/esm/strategies/debounceStrategy.js +21 -0
  52. package/dist/esm/strategies/debounceStrategy.js.map +1 -0
  53. package/dist/esm/strategies/index.d.ts +4 -0
  54. package/dist/esm/strategies/queueStrategy.d.ts +43 -0
  55. package/dist/esm/strategies/queueStrategy.js +33 -0
  56. package/dist/esm/strategies/queueStrategy.js.map +1 -0
  57. package/dist/esm/strategies/throttleStrategy.d.ts +45 -0
  58. package/dist/esm/strategies/throttleStrategy.js +21 -0
  59. package/dist/esm/strategies/throttleStrategy.js.map +1 -0
  60. package/dist/esm/strategies/types.d.ts +103 -0
  61. package/dist/esm/transactions.d.ts +3 -1
  62. package/dist/esm/transactions.js +3 -1
  63. package/dist/esm/transactions.js.map +1 -1
  64. package/dist/esm/utils/type-guards.d.ts +6 -0
  65. package/dist/esm/utils/type-guards.js +7 -0
  66. package/dist/esm/utils/type-guards.js.map +1 -0
  67. package/package.json +3 -1
  68. package/src/duplicate-instance-check.ts +32 -0
  69. package/src/errors.ts +35 -0
  70. package/src/index.ts +2 -0
  71. package/src/indexes/auto-index.ts +23 -10
  72. package/src/optimistic-action.ts +7 -2
  73. package/src/paced-mutations.ts +169 -0
  74. package/src/query/optimizer.ts +31 -4
  75. package/src/strategies/debounceStrategy.ts +48 -0
  76. package/src/strategies/index.ts +17 -0
  77. package/src/strategies/queueStrategy.ts +75 -0
  78. package/src/strategies/throttleStrategy.ts +68 -0
  79. package/src/strategies/types.ts +130 -0
  80. package/src/transactions.ts +5 -1
  81. package/src/utils/type-guards.ts +12 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"throttleStrategy.js","sources":["../../../src/strategies/throttleStrategy.ts"],"sourcesContent":["import { Throttler } from \"@tanstack/pacer/throttler\"\nimport type { ThrottleStrategy, ThrottleStrategyOptions } from \"./types\"\nimport type { Transaction } from \"../transactions\"\n\n/**\n * Creates a throttle strategy that ensures transactions are evenly spaced\n * over time.\n *\n * Provides smooth, controlled execution patterns ideal for UI updates like\n * sliders, progress bars, or scroll handlers where you want consistent\n * execution timing.\n *\n * @param options - Configuration for throttle behavior\n * @returns A throttle strategy instance\n *\n * @example\n * ```ts\n * // Throttle slider updates to every 200ms\n * const mutate = usePacedMutations({\n * onMutate: (volume) => {\n * settingsCollection.update('volume', draft => { draft.value = volume })\n * },\n * mutationFn: async ({ transaction }) => {\n * await api.updateVolume(transaction.mutations)\n * },\n * strategy: throttleStrategy({ wait: 200 })\n * })\n * ```\n *\n * @example\n * ```ts\n * // Throttle with leading and trailing execution\n * const mutate = usePacedMutations({\n * onMutate: (data) => {\n * collection.update(id, draft => { Object.assign(draft, data) })\n * },\n * mutationFn: async ({ transaction }) => {\n * await api.save(transaction.mutations)\n * },\n * strategy: throttleStrategy({\n * wait: 500,\n * leading: true,\n * trailing: true\n * })\n * })\n * ```\n */\nexport function throttleStrategy(\n options: ThrottleStrategyOptions\n): ThrottleStrategy {\n const throttler = new Throttler(\n (callback: () => Transaction) => callback(),\n options\n )\n\n return {\n _type: `throttle`,\n options,\n execute: <T extends object = Record<string, unknown>>(\n fn: () => Transaction<T>\n ) => {\n throttler.maybeExecute(fn as () => Transaction)\n },\n cleanup: () => {\n throttler.cancel()\n },\n }\n}\n"],"names":[],"mappings":";AA+CO,SAAS,iBACd,SACkB;AAClB,QAAM,YAAY,IAAI;AAAA,IACpB,CAAC,aAAgC,SAAA;AAAA,IACjC;AAAA,EAAA;AAGF,SAAO;AAAA,IACL,OAAO;AAAA,IACP;AAAA,IACA,SAAS,CACP,OACG;AACH,gBAAU,aAAa,EAAuB;AAAA,IAChD;AAAA,IACA,SAAS,MAAM;AACb,gBAAU,OAAA;AAAA,IACZ;AAAA,EAAA;AAEJ;"}
@@ -0,0 +1,103 @@
1
+ import { Transaction } from '../transactions.js';
2
+ /**
3
+ * Base strategy interface that all strategy implementations must conform to
4
+ */
5
+ export interface BaseStrategy<TName extends string = string> {
6
+ /** Type discriminator for strategy identification */
7
+ _type: TName;
8
+ /**
9
+ * Execute a function according to the strategy's timing rules
10
+ * @param fn - The function to execute
11
+ * @returns The result of the function execution (if applicable)
12
+ */
13
+ execute: <T extends object = Record<string, unknown>>(fn: () => Transaction<T>) => void | Promise<void>;
14
+ /**
15
+ * Clean up any resources held by the strategy
16
+ * Should be called when the strategy is no longer needed
17
+ */
18
+ cleanup: () => void;
19
+ }
20
+ /**
21
+ * Options for debounce strategy
22
+ * Delays execution until after a period of inactivity
23
+ */
24
+ export interface DebounceStrategyOptions {
25
+ /** Wait time in milliseconds before execution */
26
+ wait: number;
27
+ /** Execute immediately on the first call */
28
+ leading?: boolean;
29
+ /** Execute after the wait period on the last call */
30
+ trailing?: boolean;
31
+ }
32
+ /**
33
+ * Debounce strategy that delays execution until activity stops
34
+ */
35
+ export interface DebounceStrategy extends BaseStrategy<`debounce`> {
36
+ options: DebounceStrategyOptions;
37
+ }
38
+ /**
39
+ * Options for queue strategy
40
+ * Processes all executions in order (FIFO/LIFO)
41
+ */
42
+ export interface QueueStrategyOptions {
43
+ /** Wait time between processing queue items (milliseconds) */
44
+ wait?: number;
45
+ /** Maximum queue size (items are dropped if exceeded) */
46
+ maxSize?: number;
47
+ /** Where to add new items in the queue */
48
+ addItemsTo?: `front` | `back`;
49
+ /** Where to get items from when processing */
50
+ getItemsFrom?: `front` | `back`;
51
+ }
52
+ /**
53
+ * Queue strategy that processes all executions in order
54
+ * FIFO: { addItemsTo: 'back', getItemsFrom: 'front' }
55
+ * LIFO: { addItemsTo: 'back', getItemsFrom: 'back' }
56
+ */
57
+ export interface QueueStrategy extends BaseStrategy<`queue`> {
58
+ options?: QueueStrategyOptions;
59
+ }
60
+ /**
61
+ * Options for throttle strategy
62
+ * Ensures executions are evenly spaced over time
63
+ */
64
+ export interface ThrottleStrategyOptions {
65
+ /** Minimum wait time between executions (milliseconds) */
66
+ wait: number;
67
+ /** Execute immediately on the first call */
68
+ leading?: boolean;
69
+ /** Execute on the last call after wait period */
70
+ trailing?: boolean;
71
+ }
72
+ /**
73
+ * Throttle strategy that spaces executions evenly over time
74
+ */
75
+ export interface ThrottleStrategy extends BaseStrategy<`throttle`> {
76
+ options: ThrottleStrategyOptions;
77
+ }
78
+ /**
79
+ * Options for batch strategy
80
+ * Groups multiple executions together
81
+ */
82
+ export interface BatchStrategyOptions {
83
+ /** Maximum items per batch */
84
+ maxSize?: number;
85
+ /** Maximum wait time before processing batch (milliseconds) */
86
+ wait?: number;
87
+ /** Custom logic to determine when to execute batch */
88
+ getShouldExecute?: (items: Array<any>) => boolean;
89
+ }
90
+ /**
91
+ * Batch strategy that groups multiple executions together
92
+ */
93
+ export interface BatchStrategy extends BaseStrategy<`batch`> {
94
+ options?: BatchStrategyOptions;
95
+ }
96
+ /**
97
+ * Union type of all available strategies
98
+ */
99
+ export type Strategy = DebounceStrategy | QueueStrategy | ThrottleStrategy | BatchStrategy;
100
+ /**
101
+ * Extract the options type from a strategy
102
+ */
103
+ export type StrategyOptions<T extends Strategy> = T extends DebounceStrategy ? DebounceStrategyOptions : T extends QueueStrategy ? QueueStrategyOptions : T extends ThrottleStrategy ? ThrottleStrategyOptions : T extends BatchStrategy ? BatchStrategyOptions : never;
@@ -83,7 +83,9 @@ declare class Transaction<T extends object = Record<string, unknown>> {
83
83
  setState(newState: TransactionState): void;
84
84
  /**
85
85
  * Execute collection operations within this transaction
86
- * @param callback - Function containing collection operations to group together
86
+ * @param callback - Function containing collection operations to group together. If the
87
+ * callback returns a Promise, the transaction context will remain active until the promise
88
+ * settles, allowing optimistic writes after `await` boundaries.
87
89
  * @returns This transaction for chaining
88
90
  * @example
89
91
  * // Group multiple operations
@@ -101,7 +101,9 @@ class Transaction {
101
101
  }
102
102
  /**
103
103
  * Execute collection operations within this transaction
104
- * @param callback - Function containing collection operations to group together
104
+ * @param callback - Function containing collection operations to group together. If the
105
+ * callback returns a Promise, the transaction context will remain active until the promise
106
+ * settles, allowing optimistic writes after `await` boundaries.
105
107
  * @returns This transaction for chaining
106
108
  * @example
107
109
  * // Group multiple operations
@@ -1 +1 @@
1
- {"version":3,"file":"transactions.js","sources":["../../src/transactions.ts"],"sourcesContent":["import { createDeferred } from \"./deferred\"\nimport {\n MissingMutationFunctionError,\n TransactionAlreadyCompletedRollbackError,\n TransactionNotPendingCommitError,\n TransactionNotPendingMutateError,\n} from \"./errors\"\nimport { transactionScopedScheduler } from \"./scheduler.js\"\nimport type { Deferred } from \"./deferred\"\nimport type {\n MutationFn,\n PendingMutation,\n TransactionConfig,\n TransactionState,\n TransactionWithMutations,\n} from \"./types\"\n\nconst transactions: Array<Transaction<any>> = []\nlet transactionStack: Array<Transaction<any>> = []\n\nlet sequenceNumber = 0\n\n/**\n * Merges two pending mutations for the same item within a transaction\n *\n * Merge behavior truth table:\n * - (insert, update) → insert (merge changes, keep empty original)\n * - (insert, delete) → null (cancel both mutations)\n * - (update, delete) → delete (delete dominates)\n * - (update, update) → update (replace with latest, union changes)\n * - (delete, delete) → delete (replace with latest)\n * - (insert, insert) → insert (replace with latest)\n *\n * Note: (delete, update) and (delete, insert) should never occur as the collection\n * layer prevents operations on deleted items within the same transaction.\n *\n * @param existing - The existing mutation in the transaction\n * @param incoming - The new mutation being applied\n * @returns The merged mutation, or null if both should be removed\n */\nfunction mergePendingMutations<T extends object>(\n existing: PendingMutation<T>,\n incoming: PendingMutation<T>\n): PendingMutation<T> | null {\n // Truth table implementation\n switch (`${existing.type}-${incoming.type}` as const) {\n case `insert-update`: {\n // Update after insert: keep as insert but merge changes\n // For insert-update, the key should remain the same since collections don't allow key changes\n return {\n ...existing,\n type: `insert` as const,\n original: {},\n modified: incoming.modified,\n changes: { ...existing.changes, ...incoming.changes },\n // Keep existing keys (key changes not allowed in updates)\n key: existing.key,\n globalKey: existing.globalKey,\n // Merge metadata (last-write-wins)\n metadata: incoming.metadata ?? existing.metadata,\n syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata },\n // Update tracking info\n mutationId: incoming.mutationId,\n updatedAt: incoming.updatedAt,\n }\n }\n\n case `insert-delete`:\n // Delete after insert: cancel both mutations\n return null\n\n case `update-delete`:\n // Delete after update: delete dominates\n return incoming\n\n case `update-update`: {\n // Update after update: replace with latest, union changes\n return {\n ...incoming,\n // Keep original from first update\n original: existing.original,\n // Union the changes from both updates\n changes: { ...existing.changes, ...incoming.changes },\n // Merge metadata\n metadata: incoming.metadata ?? existing.metadata,\n syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata },\n }\n }\n\n case `delete-delete`:\n case `insert-insert`:\n // Same type: replace with latest\n return incoming\n\n default: {\n // Exhaustiveness check\n const _exhaustive: never = `${existing.type}-${incoming.type}` as never\n throw new Error(`Unhandled mutation combination: ${_exhaustive}`)\n }\n }\n}\n\n/**\n * Creates a new transaction for grouping multiple collection operations\n * @param config - Transaction configuration with mutation function\n * @returns A new Transaction instance\n * @example\n * // Basic transaction usage\n * const tx = createTransaction({\n * mutationFn: async ({ transaction }) => {\n * // Send all mutations to API\n * await api.saveChanges(transaction.mutations)\n * }\n * })\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Buy milk\" })\n * collection.update(\"2\", draft => { draft.completed = true })\n * })\n *\n * await tx.isPersisted.promise\n *\n * @example\n * // Handle transaction errors\n * try {\n * const tx = createTransaction({\n * mutationFn: async () => { throw new Error(\"API failed\") }\n * })\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"New item\" })\n * })\n *\n * await tx.isPersisted.promise\n * } catch (error) {\n * console.log('Transaction failed:', error)\n * }\n *\n * @example\n * // Manual commit control\n * const tx = createTransaction({\n * autoCommit: false,\n * mutationFn: async () => {\n * // API call\n * }\n * })\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Item\" })\n * })\n *\n * // Commit later\n * await tx.commit()\n */\nexport function createTransaction<T extends object = Record<string, unknown>>(\n config: TransactionConfig<T>\n): Transaction<T> {\n const newTransaction = new Transaction<T>(config)\n transactions.push(newTransaction)\n return newTransaction\n}\n\n/**\n * Gets the currently active ambient transaction, if any\n * Used internally by collection operations to join existing transactions\n * @returns The active transaction or undefined if none is active\n * @example\n * // Check if operations will join an ambient transaction\n * const ambientTx = getActiveTransaction()\n * if (ambientTx) {\n * console.log('Operations will join transaction:', ambientTx.id)\n * }\n */\nexport function getActiveTransaction(): Transaction | undefined {\n if (transactionStack.length > 0) {\n return transactionStack.slice(-1)[0]\n } else {\n return undefined\n }\n}\n\nfunction registerTransaction(tx: Transaction<any>) {\n // Clear any stale work that may have been left behind if a previous mutate\n // scope aborted before we could flush.\n transactionScopedScheduler.clear(tx.id)\n transactionStack.push(tx)\n}\n\nfunction unregisterTransaction(tx: Transaction<any>) {\n // Always flush pending work for this transaction before removing it from\n // the ambient stack – this runs even if the mutate callback throws.\n // If flush throws (e.g., due to a job error), we still clean up the stack.\n try {\n transactionScopedScheduler.flush(tx.id)\n } finally {\n transactionStack = transactionStack.filter((t) => t.id !== tx.id)\n }\n}\n\nfunction removeFromPendingList(tx: Transaction<any>) {\n const index = transactions.findIndex((t) => t.id === tx.id)\n if (index !== -1) {\n transactions.splice(index, 1)\n }\n}\n\nclass Transaction<T extends object = Record<string, unknown>> {\n public id: string\n public state: TransactionState\n public mutationFn: MutationFn<T>\n public mutations: Array<PendingMutation<T>>\n public isPersisted: Deferred<Transaction<T>>\n public autoCommit: boolean\n public createdAt: Date\n public sequenceNumber: number\n public metadata: Record<string, unknown>\n public error?: {\n message: string\n error: Error\n }\n\n constructor(config: TransactionConfig<T>) {\n if (typeof config.mutationFn === `undefined`) {\n throw new MissingMutationFunctionError()\n }\n this.id = config.id ?? crypto.randomUUID()\n this.mutationFn = config.mutationFn\n this.state = `pending`\n this.mutations = []\n this.isPersisted = createDeferred<Transaction<T>>()\n this.autoCommit = config.autoCommit ?? true\n this.createdAt = new Date()\n this.sequenceNumber = sequenceNumber++\n this.metadata = config.metadata ?? {}\n }\n\n setState(newState: TransactionState) {\n this.state = newState\n\n if (newState === `completed` || newState === `failed`) {\n removeFromPendingList(this)\n }\n }\n\n /**\n * Execute collection operations within this transaction\n * @param callback - Function containing collection operations to group together\n * @returns This transaction for chaining\n * @example\n * // Group multiple operations\n * const tx = createTransaction({ mutationFn: async () => {\n * // Send to API\n * }})\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Buy milk\" })\n * collection.update(\"2\", draft => { draft.completed = true })\n * collection.delete(\"3\")\n * })\n *\n * await tx.isPersisted.promise\n *\n * @example\n * // Handle mutate errors\n * try {\n * tx.mutate(() => {\n * collection.insert({ id: \"invalid\" }) // This might throw\n * })\n * } catch (error) {\n * console.log('Mutation failed:', error)\n * }\n *\n * @example\n * // Manual commit control\n * const tx = createTransaction({ autoCommit: false, mutationFn: async () => {} })\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Item\" })\n * })\n *\n * // Commit later when ready\n * await tx.commit()\n */\n mutate(callback: () => void): Transaction<T> {\n if (this.state !== `pending`) {\n throw new TransactionNotPendingMutateError()\n }\n\n registerTransaction(this)\n try {\n callback()\n } finally {\n unregisterTransaction(this)\n }\n\n if (this.autoCommit) {\n this.commit().catch(() => {\n // Errors from autoCommit are handled via isPersisted.promise\n // This catch prevents unhandled promise rejections\n })\n }\n\n return this\n }\n\n /**\n * Apply new mutations to this transaction, intelligently merging with existing mutations\n *\n * When mutations operate on the same item (same globalKey), they are merged according to\n * the following rules:\n *\n * - **insert + update** → insert (merge changes, keep empty original)\n * - **insert + delete** → removed (mutations cancel each other out)\n * - **update + delete** → delete (delete dominates)\n * - **update + update** → update (union changes, keep first original)\n * - **same type** → replace with latest\n *\n * This merging reduces over-the-wire churn and keeps the optimistic local view\n * aligned with user intent.\n *\n * @param mutations - Array of new mutations to apply\n */\n applyMutations(mutations: Array<PendingMutation<any>>): void {\n for (const newMutation of mutations) {\n const existingIndex = this.mutations.findIndex(\n (m) => m.globalKey === newMutation.globalKey\n )\n\n if (existingIndex >= 0) {\n const existingMutation = this.mutations[existingIndex]!\n const mergeResult = mergePendingMutations(existingMutation, newMutation)\n\n if (mergeResult === null) {\n // Remove the mutation (e.g., delete after insert cancels both)\n this.mutations.splice(existingIndex, 1)\n } else {\n // Replace with merged mutation\n this.mutations[existingIndex] = mergeResult\n }\n } else {\n // Insert new mutation\n this.mutations.push(newMutation)\n }\n }\n }\n\n /**\n * Rollback the transaction and any conflicting transactions\n * @param config - Configuration for rollback behavior\n * @returns This transaction for chaining\n * @example\n * // Manual rollback\n * const tx = createTransaction({ mutationFn: async () => {\n * // Send to API\n * }})\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Buy milk\" })\n * })\n *\n * // Rollback if needed\n * if (shouldCancel) {\n * tx.rollback()\n * }\n *\n * @example\n * // Handle rollback cascade (automatic)\n * const tx1 = createTransaction({ mutationFn: async () => {} })\n * const tx2 = createTransaction({ mutationFn: async () => {} })\n *\n * tx1.mutate(() => collection.update(\"1\", draft => { draft.value = \"A\" }))\n * tx2.mutate(() => collection.update(\"1\", draft => { draft.value = \"B\" })) // Same item\n *\n * tx1.rollback() // This will also rollback tx2 due to conflict\n *\n * @example\n * // Handle rollback in error scenarios\n * try {\n * await tx.isPersisted.promise\n * } catch (error) {\n * console.log('Transaction was rolled back:', error)\n * // Transaction automatically rolled back on mutation function failure\n * }\n */\n rollback(config?: { isSecondaryRollback?: boolean }): Transaction<T> {\n const isSecondaryRollback = config?.isSecondaryRollback ?? false\n if (this.state === `completed`) {\n throw new TransactionAlreadyCompletedRollbackError()\n }\n\n this.setState(`failed`)\n\n // See if there's any other transactions w/ mutations on the same ids\n // and roll them back as well.\n if (!isSecondaryRollback) {\n const mutationIds = new Set()\n this.mutations.forEach((m) => mutationIds.add(m.globalKey))\n for (const t of transactions) {\n t.state === `pending` &&\n t.mutations.some((m) => mutationIds.has(m.globalKey)) &&\n t.rollback({ isSecondaryRollback: true })\n }\n }\n\n // Reject the promise\n this.isPersisted.reject(this.error?.error)\n this.touchCollection()\n\n return this\n }\n\n // Tell collection that something has changed with the transaction\n touchCollection(): void {\n const hasCalled = new Set()\n for (const mutation of this.mutations) {\n if (!hasCalled.has(mutation.collection.id)) {\n mutation.collection._state.onTransactionStateChange()\n\n // Only call commitPendingTransactions if there are pending sync transactions\n if (mutation.collection._state.pendingSyncedTransactions.length > 0) {\n mutation.collection._state.commitPendingTransactions()\n }\n\n hasCalled.add(mutation.collection.id)\n }\n }\n }\n\n /**\n * Commit the transaction and execute the mutation function\n * @returns Promise that resolves to this transaction when complete\n * @example\n * // Manual commit (when autoCommit is false)\n * const tx = createTransaction({\n * autoCommit: false,\n * mutationFn: async ({ transaction }) => {\n * await api.saveChanges(transaction.mutations)\n * }\n * })\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Buy milk\" })\n * })\n *\n * await tx.commit() // Manually commit\n *\n * @example\n * // Handle commit errors\n * try {\n * const tx = createTransaction({\n * mutationFn: async () => { throw new Error(\"API failed\") }\n * })\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Item\" })\n * })\n *\n * await tx.commit()\n * } catch (error) {\n * console.log('Commit failed, transaction rolled back:', error)\n * }\n *\n * @example\n * // Check transaction state after commit\n * await tx.commit()\n * console.log(tx.state) // \"completed\" or \"failed\"\n */\n async commit(): Promise<Transaction<T>> {\n if (this.state !== `pending`) {\n throw new TransactionNotPendingCommitError()\n }\n\n this.setState(`persisting`)\n\n if (this.mutations.length === 0) {\n this.setState(`completed`)\n this.isPersisted.resolve(this)\n\n return this\n }\n\n // Run mutationFn\n try {\n // At this point we know there's at least one mutation\n // We've already verified mutations is non-empty, so this cast is safe\n // Use a direct type assertion instead of object spreading to preserve the original type\n await this.mutationFn({\n transaction: this as unknown as TransactionWithMutations<T>,\n })\n\n this.setState(`completed`)\n this.touchCollection()\n\n this.isPersisted.resolve(this)\n } catch (error) {\n // Preserve the original error for rethrowing\n const originalError =\n error instanceof Error ? error : new Error(String(error))\n\n // Update transaction with error information\n this.error = {\n message: originalError.message,\n error: originalError,\n }\n\n // rollback the transaction\n this.rollback()\n\n // Re-throw the original error to preserve identity and stack\n throw originalError\n }\n\n return this\n }\n\n /**\n * Compare two transactions by their createdAt time and sequence number in order\n * to sort them in the order they were created.\n * @param other - The other transaction to compare to\n * @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\n */\n compareCreatedAt(other: Transaction<any>): number {\n const createdAtComparison =\n this.createdAt.getTime() - other.createdAt.getTime()\n if (createdAtComparison !== 0) {\n return createdAtComparison\n }\n return this.sequenceNumber - other.sequenceNumber\n }\n}\n\nexport type { Transaction }\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"transactions.js","sources":["../../src/transactions.ts"],"sourcesContent":["import { createDeferred } from \"./deferred\"\nimport \"./duplicate-instance-check\"\nimport {\n MissingMutationFunctionError,\n TransactionAlreadyCompletedRollbackError,\n TransactionNotPendingCommitError,\n TransactionNotPendingMutateError,\n} from \"./errors\"\nimport { transactionScopedScheduler } from \"./scheduler.js\"\nimport type { Deferred } from \"./deferred\"\nimport type {\n MutationFn,\n PendingMutation,\n TransactionConfig,\n TransactionState,\n TransactionWithMutations,\n} from \"./types\"\n\nconst transactions: Array<Transaction<any>> = []\nlet transactionStack: Array<Transaction<any>> = []\n\nlet sequenceNumber = 0\n\n/**\n * Merges two pending mutations for the same item within a transaction\n *\n * Merge behavior truth table:\n * - (insert, update) → insert (merge changes, keep empty original)\n * - (insert, delete) → null (cancel both mutations)\n * - (update, delete) → delete (delete dominates)\n * - (update, update) → update (replace with latest, union changes)\n * - (delete, delete) → delete (replace with latest)\n * - (insert, insert) → insert (replace with latest)\n *\n * Note: (delete, update) and (delete, insert) should never occur as the collection\n * layer prevents operations on deleted items within the same transaction.\n *\n * @param existing - The existing mutation in the transaction\n * @param incoming - The new mutation being applied\n * @returns The merged mutation, or null if both should be removed\n */\nfunction mergePendingMutations<T extends object>(\n existing: PendingMutation<T>,\n incoming: PendingMutation<T>\n): PendingMutation<T> | null {\n // Truth table implementation\n switch (`${existing.type}-${incoming.type}` as const) {\n case `insert-update`: {\n // Update after insert: keep as insert but merge changes\n // For insert-update, the key should remain the same since collections don't allow key changes\n return {\n ...existing,\n type: `insert` as const,\n original: {},\n modified: incoming.modified,\n changes: { ...existing.changes, ...incoming.changes },\n // Keep existing keys (key changes not allowed in updates)\n key: existing.key,\n globalKey: existing.globalKey,\n // Merge metadata (last-write-wins)\n metadata: incoming.metadata ?? existing.metadata,\n syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata },\n // Update tracking info\n mutationId: incoming.mutationId,\n updatedAt: incoming.updatedAt,\n }\n }\n\n case `insert-delete`:\n // Delete after insert: cancel both mutations\n return null\n\n case `update-delete`:\n // Delete after update: delete dominates\n return incoming\n\n case `update-update`: {\n // Update after update: replace with latest, union changes\n return {\n ...incoming,\n // Keep original from first update\n original: existing.original,\n // Union the changes from both updates\n changes: { ...existing.changes, ...incoming.changes },\n // Merge metadata\n metadata: incoming.metadata ?? existing.metadata,\n syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata },\n }\n }\n\n case `delete-delete`:\n case `insert-insert`:\n // Same type: replace with latest\n return incoming\n\n default: {\n // Exhaustiveness check\n const _exhaustive: never = `${existing.type}-${incoming.type}` as never\n throw new Error(`Unhandled mutation combination: ${_exhaustive}`)\n }\n }\n}\n\n/**\n * Creates a new transaction for grouping multiple collection operations\n * @param config - Transaction configuration with mutation function\n * @returns A new Transaction instance\n * @example\n * // Basic transaction usage\n * const tx = createTransaction({\n * mutationFn: async ({ transaction }) => {\n * // Send all mutations to API\n * await api.saveChanges(transaction.mutations)\n * }\n * })\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Buy milk\" })\n * collection.update(\"2\", draft => { draft.completed = true })\n * })\n *\n * await tx.isPersisted.promise\n *\n * @example\n * // Handle transaction errors\n * try {\n * const tx = createTransaction({\n * mutationFn: async () => { throw new Error(\"API failed\") }\n * })\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"New item\" })\n * })\n *\n * await tx.isPersisted.promise\n * } catch (error) {\n * console.log('Transaction failed:', error)\n * }\n *\n * @example\n * // Manual commit control\n * const tx = createTransaction({\n * autoCommit: false,\n * mutationFn: async () => {\n * // API call\n * }\n * })\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Item\" })\n * })\n *\n * // Commit later\n * await tx.commit()\n */\nexport function createTransaction<T extends object = Record<string, unknown>>(\n config: TransactionConfig<T>\n): Transaction<T> {\n const newTransaction = new Transaction<T>(config)\n transactions.push(newTransaction)\n return newTransaction\n}\n\n/**\n * Gets the currently active ambient transaction, if any\n * Used internally by collection operations to join existing transactions\n * @returns The active transaction or undefined if none is active\n * @example\n * // Check if operations will join an ambient transaction\n * const ambientTx = getActiveTransaction()\n * if (ambientTx) {\n * console.log('Operations will join transaction:', ambientTx.id)\n * }\n */\nexport function getActiveTransaction(): Transaction | undefined {\n if (transactionStack.length > 0) {\n return transactionStack.slice(-1)[0]\n } else {\n return undefined\n }\n}\n\nfunction registerTransaction(tx: Transaction<any>) {\n // Clear any stale work that may have been left behind if a previous mutate\n // scope aborted before we could flush.\n transactionScopedScheduler.clear(tx.id)\n transactionStack.push(tx)\n}\n\nfunction unregisterTransaction(tx: Transaction<any>) {\n // Always flush pending work for this transaction before removing it from\n // the ambient stack – this runs even if the mutate callback throws.\n // If flush throws (e.g., due to a job error), we still clean up the stack.\n try {\n transactionScopedScheduler.flush(tx.id)\n } finally {\n transactionStack = transactionStack.filter((t) => t.id !== tx.id)\n }\n}\n\nfunction removeFromPendingList(tx: Transaction<any>) {\n const index = transactions.findIndex((t) => t.id === tx.id)\n if (index !== -1) {\n transactions.splice(index, 1)\n }\n}\n\nclass Transaction<T extends object = Record<string, unknown>> {\n public id: string\n public state: TransactionState\n public mutationFn: MutationFn<T>\n public mutations: Array<PendingMutation<T>>\n public isPersisted: Deferred<Transaction<T>>\n public autoCommit: boolean\n public createdAt: Date\n public sequenceNumber: number\n public metadata: Record<string, unknown>\n public error?: {\n message: string\n error: Error\n }\n\n constructor(config: TransactionConfig<T>) {\n if (typeof config.mutationFn === `undefined`) {\n throw new MissingMutationFunctionError()\n }\n this.id = config.id ?? crypto.randomUUID()\n this.mutationFn = config.mutationFn\n this.state = `pending`\n this.mutations = []\n this.isPersisted = createDeferred<Transaction<T>>()\n this.autoCommit = config.autoCommit ?? true\n this.createdAt = new Date()\n this.sequenceNumber = sequenceNumber++\n this.metadata = config.metadata ?? {}\n }\n\n setState(newState: TransactionState) {\n this.state = newState\n\n if (newState === `completed` || newState === `failed`) {\n removeFromPendingList(this)\n }\n }\n\n /**\n * Execute collection operations within this transaction\n * @param callback - Function containing collection operations to group together. If the\n * callback returns a Promise, the transaction context will remain active until the promise\n * settles, allowing optimistic writes after `await` boundaries.\n * @returns This transaction for chaining\n * @example\n * // Group multiple operations\n * const tx = createTransaction({ mutationFn: async () => {\n * // Send to API\n * }})\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Buy milk\" })\n * collection.update(\"2\", draft => { draft.completed = true })\n * collection.delete(\"3\")\n * })\n *\n * await tx.isPersisted.promise\n *\n * @example\n * // Handle mutate errors\n * try {\n * tx.mutate(() => {\n * collection.insert({ id: \"invalid\" }) // This might throw\n * })\n * } catch (error) {\n * console.log('Mutation failed:', error)\n * }\n *\n * @example\n * // Manual commit control\n * const tx = createTransaction({ autoCommit: false, mutationFn: async () => {} })\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Item\" })\n * })\n *\n * // Commit later when ready\n * await tx.commit()\n */\n mutate(callback: () => void): Transaction<T> {\n if (this.state !== `pending`) {\n throw new TransactionNotPendingMutateError()\n }\n\n registerTransaction(this)\n\n try {\n callback()\n } finally {\n unregisterTransaction(this)\n }\n\n if (this.autoCommit) {\n this.commit().catch(() => {\n // Errors from autoCommit are handled via isPersisted.promise\n // This catch prevents unhandled promise rejections\n })\n }\n\n return this\n }\n\n /**\n * Apply new mutations to this transaction, intelligently merging with existing mutations\n *\n * When mutations operate on the same item (same globalKey), they are merged according to\n * the following rules:\n *\n * - **insert + update** → insert (merge changes, keep empty original)\n * - **insert + delete** → removed (mutations cancel each other out)\n * - **update + delete** → delete (delete dominates)\n * - **update + update** → update (union changes, keep first original)\n * - **same type** → replace with latest\n *\n * This merging reduces over-the-wire churn and keeps the optimistic local view\n * aligned with user intent.\n *\n * @param mutations - Array of new mutations to apply\n */\n applyMutations(mutations: Array<PendingMutation<any>>): void {\n for (const newMutation of mutations) {\n const existingIndex = this.mutations.findIndex(\n (m) => m.globalKey === newMutation.globalKey\n )\n\n if (existingIndex >= 0) {\n const existingMutation = this.mutations[existingIndex]!\n const mergeResult = mergePendingMutations(existingMutation, newMutation)\n\n if (mergeResult === null) {\n // Remove the mutation (e.g., delete after insert cancels both)\n this.mutations.splice(existingIndex, 1)\n } else {\n // Replace with merged mutation\n this.mutations[existingIndex] = mergeResult\n }\n } else {\n // Insert new mutation\n this.mutations.push(newMutation)\n }\n }\n }\n\n /**\n * Rollback the transaction and any conflicting transactions\n * @param config - Configuration for rollback behavior\n * @returns This transaction for chaining\n * @example\n * // Manual rollback\n * const tx = createTransaction({ mutationFn: async () => {\n * // Send to API\n * }})\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Buy milk\" })\n * })\n *\n * // Rollback if needed\n * if (shouldCancel) {\n * tx.rollback()\n * }\n *\n * @example\n * // Handle rollback cascade (automatic)\n * const tx1 = createTransaction({ mutationFn: async () => {} })\n * const tx2 = createTransaction({ mutationFn: async () => {} })\n *\n * tx1.mutate(() => collection.update(\"1\", draft => { draft.value = \"A\" }))\n * tx2.mutate(() => collection.update(\"1\", draft => { draft.value = \"B\" })) // Same item\n *\n * tx1.rollback() // This will also rollback tx2 due to conflict\n *\n * @example\n * // Handle rollback in error scenarios\n * try {\n * await tx.isPersisted.promise\n * } catch (error) {\n * console.log('Transaction was rolled back:', error)\n * // Transaction automatically rolled back on mutation function failure\n * }\n */\n rollback(config?: { isSecondaryRollback?: boolean }): Transaction<T> {\n const isSecondaryRollback = config?.isSecondaryRollback ?? false\n if (this.state === `completed`) {\n throw new TransactionAlreadyCompletedRollbackError()\n }\n\n this.setState(`failed`)\n\n // See if there's any other transactions w/ mutations on the same ids\n // and roll them back as well.\n if (!isSecondaryRollback) {\n const mutationIds = new Set()\n this.mutations.forEach((m) => mutationIds.add(m.globalKey))\n for (const t of transactions) {\n t.state === `pending` &&\n t.mutations.some((m) => mutationIds.has(m.globalKey)) &&\n t.rollback({ isSecondaryRollback: true })\n }\n }\n\n // Reject the promise\n this.isPersisted.reject(this.error?.error)\n this.touchCollection()\n\n return this\n }\n\n // Tell collection that something has changed with the transaction\n touchCollection(): void {\n const hasCalled = new Set()\n for (const mutation of this.mutations) {\n if (!hasCalled.has(mutation.collection.id)) {\n mutation.collection._state.onTransactionStateChange()\n\n // Only call commitPendingTransactions if there are pending sync transactions\n if (mutation.collection._state.pendingSyncedTransactions.length > 0) {\n mutation.collection._state.commitPendingTransactions()\n }\n\n hasCalled.add(mutation.collection.id)\n }\n }\n }\n\n /**\n * Commit the transaction and execute the mutation function\n * @returns Promise that resolves to this transaction when complete\n * @example\n * // Manual commit (when autoCommit is false)\n * const tx = createTransaction({\n * autoCommit: false,\n * mutationFn: async ({ transaction }) => {\n * await api.saveChanges(transaction.mutations)\n * }\n * })\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Buy milk\" })\n * })\n *\n * await tx.commit() // Manually commit\n *\n * @example\n * // Handle commit errors\n * try {\n * const tx = createTransaction({\n * mutationFn: async () => { throw new Error(\"API failed\") }\n * })\n *\n * tx.mutate(() => {\n * collection.insert({ id: \"1\", text: \"Item\" })\n * })\n *\n * await tx.commit()\n * } catch (error) {\n * console.log('Commit failed, transaction rolled back:', error)\n * }\n *\n * @example\n * // Check transaction state after commit\n * await tx.commit()\n * console.log(tx.state) // \"completed\" or \"failed\"\n */\n async commit(): Promise<Transaction<T>> {\n if (this.state !== `pending`) {\n throw new TransactionNotPendingCommitError()\n }\n\n this.setState(`persisting`)\n\n if (this.mutations.length === 0) {\n this.setState(`completed`)\n this.isPersisted.resolve(this)\n\n return this\n }\n\n // Run mutationFn\n try {\n // At this point we know there's at least one mutation\n // We've already verified mutations is non-empty, so this cast is safe\n // Use a direct type assertion instead of object spreading to preserve the original type\n await this.mutationFn({\n transaction: this as unknown as TransactionWithMutations<T>,\n })\n\n this.setState(`completed`)\n this.touchCollection()\n\n this.isPersisted.resolve(this)\n } catch (error) {\n // Preserve the original error for rethrowing\n const originalError =\n error instanceof Error ? error : new Error(String(error))\n\n // Update transaction with error information\n this.error = {\n message: originalError.message,\n error: originalError,\n }\n\n // rollback the transaction\n this.rollback()\n\n // Re-throw the original error to preserve identity and stack\n throw originalError\n }\n\n return this\n }\n\n /**\n * Compare two transactions by their createdAt time and sequence number in order\n * to sort them in the order they were created.\n * @param other - The other transaction to compare to\n * @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\n */\n compareCreatedAt(other: Transaction<any>): number {\n const createdAtComparison =\n this.createdAt.getTime() - other.createdAt.getTime()\n if (createdAtComparison !== 0) {\n return createdAtComparison\n }\n return this.sequenceNumber - other.sequenceNumber\n }\n}\n\nexport type { Transaction }\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Type guard to check if a value is promise-like (has a `.then` method)
3
+ * @param value - The value to check
4
+ * @returns True if the value is promise-like, false otherwise
5
+ */
6
+ export declare function isPromiseLike(value: unknown): value is PromiseLike<unknown>;
@@ -0,0 +1,7 @@
1
+ function isPromiseLike(value) {
2
+ return !!value && (typeof value === `object` || typeof value === `function`) && typeof value.then === `function`;
3
+ }
4
+ export {
5
+ isPromiseLike
6
+ };
7
+ //# sourceMappingURL=type-guards.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"type-guards.js","sources":["../../../src/utils/type-guards.ts"],"sourcesContent":["/**\n * Type guard to check if a value is promise-like (has a `.then` method)\n * @param value - The value to check\n * @returns True if the value is promise-like, false otherwise\n */\nexport function isPromiseLike(value: unknown): value is PromiseLike<unknown> {\n return (\n !!value &&\n (typeof value === `object` || typeof value === `function`) &&\n typeof (value as { then?: unknown }).then === `function`\n )\n}\n"],"names":[],"mappings":"AAKO,SAAS,cAAc,OAA+C;AAC3E,SACE,CAAC,CAAC,UACD,OAAO,UAAU,YAAY,OAAO,UAAU,eAC/C,OAAQ,MAA6B,SAAS;AAElD;"}
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@tanstack/db",
3
3
  "description": "A reactive client store for building super fast apps on sync",
4
- "version": "0.4.15",
4
+ "version": "0.4.17",
5
5
  "dependencies": {
6
6
  "@standard-schema/spec": "^1.0.0",
7
+ "@tanstack/pacer": "^0.1.0",
7
8
  "@tanstack/db-ivm": "0.1.12"
8
9
  },
9
10
  "devDependencies": {
@@ -51,6 +52,7 @@
51
52
  "types": "dist/esm/index.d.ts",
52
53
  "scripts": {
53
54
  "build": "vite build",
55
+ "build:minified": "vite build --minify",
54
56
  "dev": "vite build --watch",
55
57
  "lint": "eslint . --fix",
56
58
  "test": "npx vitest --run"
@@ -0,0 +1,32 @@
1
+ import { DuplicateDbInstanceError } from "./errors"
2
+
3
+ /**
4
+ * Check if we're in a browser top-level window (not a worker, SSR, or iframe).
5
+ * This helps avoid false positives in environments where multiple instances are legitimate.
6
+ */
7
+ function isBrowserTopWindow(): boolean {
8
+ const w = (globalThis as any).window
9
+ // Exclude workers and SSR-ish shims
10
+ if (!w || !(`document` in w)) return false
11
+ // Avoid triggering inside iframes (cross-origin iframes can throw)
12
+ try {
13
+ return w === w.top
14
+ } catch {
15
+ return true // If we can't access w.top due to cross-origin, assume we should check
16
+ }
17
+ }
18
+
19
+ // Detect duplicate @tanstack/db instances (dev-only, browser top-window only)
20
+ const DB_INSTANCE_MARKER = Symbol.for(`@tanstack/db/instance-marker`)
21
+ const DEV =
22
+ typeof process !== `undefined` && process.env.NODE_ENV !== `production`
23
+ const DISABLED =
24
+ typeof process !== `undefined` &&
25
+ process.env.TANSTACK_DB_DISABLE_DUP_CHECK === `1`
26
+
27
+ if (DEV && !DISABLED && isBrowserTopWindow()) {
28
+ if ((globalThis as any)[DB_INSTANCE_MARKER]) {
29
+ throw new DuplicateDbInstanceError()
30
+ }
31
+ ;(globalThis as any)[DB_INSTANCE_MARKER] = true
32
+ }
package/src/errors.ts CHANGED
@@ -41,6 +41,32 @@ export class SchemaValidationError extends TanStackDBError {
41
41
  }
42
42
  }
43
43
 
44
+ // Module Instance Errors
45
+ export class DuplicateDbInstanceError extends TanStackDBError {
46
+ constructor() {
47
+ super(
48
+ `Multiple instances of @tanstack/db detected!\n\n` +
49
+ `This causes transaction context to be lost because each instance maintains ` +
50
+ `its own transaction stack.\n\n` +
51
+ `Common causes:\n` +
52
+ `1. Different versions of @tanstack/db installed\n` +
53
+ `2. Incompatible peer dependency versions in packages\n` +
54
+ `3. Module resolution issues in bundler configuration\n\n` +
55
+ `To fix:\n` +
56
+ `1. Check installed versions: npm list @tanstack/db (or pnpm/yarn list)\n` +
57
+ `2. Force a single version using package manager overrides:\n` +
58
+ ` - npm: "overrides" in package.json\n` +
59
+ ` - pnpm: "pnpm.overrides" in package.json\n` +
60
+ ` - yarn: "resolutions" in package.json\n` +
61
+ `3. Clear node_modules and lockfile, then reinstall\n\n` +
62
+ `To temporarily disable this check (not recommended):\n` +
63
+ `Set environment variable: TANSTACK_DB_DISABLE_DUP_CHECK=1\n\n` +
64
+ `See: https://tanstack.com/db/latest/docs/troubleshooting#duplicate-instances`
65
+ )
66
+ this.name = `DuplicateDbInstanceError`
67
+ }
68
+ }
69
+
44
70
  // Collection Configuration Errors
45
71
  export class CollectionConfigurationError extends TanStackDBError {
46
72
  constructor(message: string) {
@@ -229,6 +255,15 @@ export class MissingMutationFunctionError extends TransactionError {
229
255
  }
230
256
  }
231
257
 
258
+ export class OnMutateMustBeSynchronousError extends TransactionError {
259
+ constructor() {
260
+ super(
261
+ `onMutate must be synchronous and cannot return a promise. Remove async/await or returned promises from onMutate.`
262
+ )
263
+ this.name = `OnMutateMustBeSynchronousError`
264
+ }
265
+ }
266
+
232
267
  export class TransactionNotPendingMutateError extends TransactionError {
233
268
  constructor() {
234
269
  super(
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ export * from "./optimistic-action"
13
13
  export * from "./local-only"
14
14
  export * from "./local-storage"
15
15
  export * from "./errors"
16
+ export * from "./paced-mutations"
17
+ export * from "./strategies/index.js"
16
18
 
17
19
  // Index system exports
18
20
  export * from "./indexes/base-index.js"
@@ -44,14 +44,25 @@ export function ensureIndexForField<
44
44
 
45
45
  // Create a new index for this field using the collection's createIndex method
46
46
  try {
47
- collection.createIndex((row) => (row as any)[fieldName], {
48
- name: `auto_${fieldName}`,
49
- indexType: BTreeIndex,
50
- options: compareFn ? { compareFn, compareOptions } : {},
51
- })
47
+ // Use the proxy-based approach to create the proper accessor for nested paths
48
+ collection.createIndex(
49
+ (row) => {
50
+ // Navigate through the field path
51
+ let current: any = row
52
+ for (const part of fieldPath) {
53
+ current = current[part]
54
+ }
55
+ return current
56
+ },
57
+ {
58
+ name: `auto:${fieldPath.join(`.`)}`,
59
+ indexType: BTreeIndex,
60
+ options: compareFn ? { compareFn, compareOptions } : {},
61
+ }
62
+ )
52
63
  } catch (error) {
53
64
  console.warn(
54
- `${collection.id ? `[${collection.id}] ` : ``}Failed to create auto-index for field "${fieldName}":`,
65
+ `${collection.id ? `[${collection.id}] ` : ``}Failed to create auto-index for field path "${fieldPath.join(`.`)}":`,
55
66
  error
56
67
  )
57
68
  }
@@ -108,7 +119,7 @@ function extractIndexableExpressions(
108
119
  return
109
120
  }
110
121
 
111
- // Check if the first argument is a property reference (single field)
122
+ // Check if the first argument is a property reference
112
123
  if (func.args.length < 1 || func.args[0].type !== `ref`) {
113
124
  return
114
125
  }
@@ -116,12 +127,14 @@ function extractIndexableExpressions(
116
127
  const fieldRef = func.args[0]
117
128
  const fieldPath = fieldRef.path
118
129
 
119
- // Skip if it's not a simple field (e.g., nested properties or array access)
120
- if (fieldPath.length !== 1) {
130
+ // Skip if the path is empty
131
+ if (fieldPath.length === 0) {
121
132
  return
122
133
  }
123
134
 
124
- const fieldName = fieldPath[0]
135
+ // For nested paths, use the full path joined with underscores as the field name
136
+ // For simple paths, use the first (and only) element
137
+ const fieldName = fieldPath.join(`_`)
125
138
  results.push({ fieldName, fieldPath })
126
139
  }
127
140
 
@@ -1,4 +1,6 @@
1
1
  import { createTransaction } from "./transactions"
2
+ import { OnMutateMustBeSynchronousError } from "./errors"
3
+ import { isPromiseLike } from "./utils/type-guards"
2
4
  import type { CreateOptimisticActionsOptions, Transaction } from "./types"
3
5
 
4
6
  /**
@@ -67,8 +69,11 @@ export function createOptimisticAction<TVariables = unknown>(
67
69
  // Execute the transaction. The mutationFn is called once mutate()
68
70
  // is finished.
69
71
  transaction.mutate(() => {
70
- // Call onMutate with variables to apply optimistic updates
71
- onMutate(variables)
72
+ const maybePromise = onMutate(variables) as unknown
73
+
74
+ if (isPromiseLike(maybePromise)) {
75
+ throw new OnMutateMustBeSynchronousError()
76
+ }
72
77
  })
73
78
 
74
79
  return transaction
@@ -0,0 +1,169 @@
1
+ import { createTransaction } from "./transactions"
2
+ import type { MutationFn, Transaction } from "./types"
3
+ import type { Strategy } from "./strategies/types"
4
+
5
+ /**
6
+ * Configuration for creating a paced mutations manager
7
+ */
8
+ export interface PacedMutationsConfig<
9
+ TVariables = unknown,
10
+ T extends object = Record<string, unknown>,
11
+ > {
12
+ /**
13
+ * Callback to apply optimistic updates immediately.
14
+ * Receives the variables passed to the mutate function.
15
+ */
16
+ onMutate: (variables: TVariables) => void
17
+ /**
18
+ * Function to execute the mutation on the server.
19
+ * Receives the transaction parameters containing all merged mutations.
20
+ */
21
+ mutationFn: MutationFn<T>
22
+ /**
23
+ * Strategy for controlling mutation execution timing
24
+ * Examples: debounceStrategy, queueStrategy, throttleStrategy
25
+ */
26
+ strategy: Strategy
27
+ /**
28
+ * Custom metadata to associate with transactions
29
+ */
30
+ metadata?: Record<string, unknown>
31
+ }
32
+
33
+ /**
34
+ * Creates a paced mutations manager with pluggable timing strategies.
35
+ *
36
+ * This function provides a way to control when and how optimistic mutations
37
+ * are persisted to the backend, using strategies like debouncing, queuing,
38
+ * or throttling. The optimistic updates are applied immediately via `onMutate`,
39
+ * and the actual persistence is controlled by the strategy.
40
+ *
41
+ * The returned function accepts variables of type TVariables and returns a
42
+ * Transaction object that can be awaited to know when persistence completes
43
+ * or to handle errors.
44
+ *
45
+ * @param config - Configuration including onMutate, mutationFn and strategy
46
+ * @returns A function that accepts variables and returns a Transaction
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * // Debounced mutations for auto-save
51
+ * const updateTodo = createPacedMutations<string>({
52
+ * onMutate: (text) => {
53
+ * // Apply optimistic update immediately
54
+ * collection.update(id, draft => { draft.text = text })
55
+ * },
56
+ * mutationFn: async ({ transaction }) => {
57
+ * await api.save(transaction.mutations)
58
+ * },
59
+ * strategy: debounceStrategy({ wait: 500 })
60
+ * })
61
+ *
62
+ * // Call with variables, returns a transaction
63
+ * const tx = updateTodo('New text')
64
+ *
65
+ * // Await persistence or handle errors
66
+ * await tx.isPersisted.promise
67
+ * ```
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * // Queue strategy for sequential processing
72
+ * const addTodo = createPacedMutations<{ text: string }>({
73
+ * onMutate: ({ text }) => {
74
+ * collection.insert({ id: uuid(), text, completed: false })
75
+ * },
76
+ * mutationFn: async ({ transaction }) => {
77
+ * await api.save(transaction.mutations)
78
+ * },
79
+ * strategy: queueStrategy({
80
+ * wait: 200,
81
+ * addItemsTo: 'back',
82
+ * getItemsFrom: 'front'
83
+ * })
84
+ * })
85
+ * ```
86
+ */
87
+ export function createPacedMutations<
88
+ TVariables = unknown,
89
+ T extends object = Record<string, unknown>,
90
+ >(
91
+ config: PacedMutationsConfig<TVariables, T>
92
+ ): (variables: TVariables) => Transaction<T> {
93
+ const { onMutate, mutationFn, strategy, ...transactionConfig } = config
94
+
95
+ // The currently active transaction (pending, not yet persisting)
96
+ let activeTransaction: Transaction<T> | null = null
97
+
98
+ // Commit callback that the strategy will call when it's time to persist
99
+ const commitCallback = () => {
100
+ if (!activeTransaction) {
101
+ throw new Error(
102
+ `Strategy callback called but no active transaction exists. This indicates a bug in the strategy implementation.`
103
+ )
104
+ }
105
+
106
+ if (activeTransaction.state !== `pending`) {
107
+ throw new Error(
108
+ `Strategy callback called but active transaction is in state "${activeTransaction.state}". Expected "pending".`
109
+ )
110
+ }
111
+
112
+ const txToCommit = activeTransaction
113
+
114
+ // Clear active transaction reference before committing
115
+ activeTransaction = null
116
+
117
+ // Commit the transaction
118
+ txToCommit.commit().catch(() => {
119
+ // Errors are handled via transaction.isPersisted.promise
120
+ // This catch prevents unhandled promise rejections
121
+ })
122
+
123
+ return txToCommit
124
+ }
125
+
126
+ /**
127
+ * Executes a mutation with the given variables. Creates a new transaction if none is active,
128
+ * or adds to the existing active transaction. The strategy controls when
129
+ * the transaction is actually committed.
130
+ */
131
+ function mutate(variables: TVariables): Transaction<T> {
132
+ // Create a new transaction if we don't have an active one
133
+ if (!activeTransaction || activeTransaction.state !== `pending`) {
134
+ activeTransaction = createTransaction<T>({
135
+ ...transactionConfig,
136
+ mutationFn,
137
+ autoCommit: false,
138
+ })
139
+ }
140
+
141
+ // Execute onMutate with variables to apply optimistic updates
142
+ activeTransaction.mutate(() => {
143
+ onMutate(variables)
144
+ })
145
+
146
+ // Save reference before calling strategy.execute
147
+ const txToReturn = activeTransaction
148
+
149
+ // For queue strategy, pass a function that commits the captured transaction
150
+ // This prevents the error when commitCallback tries to access the cleared activeTransaction
151
+ if (strategy._type === `queue`) {
152
+ const capturedTx = activeTransaction
153
+ activeTransaction = null // Clear so next mutation creates a new transaction
154
+ strategy.execute(() => {
155
+ capturedTx.commit().catch(() => {
156
+ // Errors are handled via transaction.isPersisted.promise
157
+ })
158
+ return capturedTx
159
+ })
160
+ } else {
161
+ // For debounce/throttle, use commitCallback which manages activeTransaction
162
+ strategy.execute(commitCallback)
163
+ }
164
+
165
+ return txToReturn
166
+ }
167
+
168
+ return mutate
169
+ }