@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.
- package/dist/cjs/duplicate-instance-check.d.cts +1 -0
- package/dist/cjs/errors.cjs +38 -0
- package/dist/cjs/errors.cjs.map +1 -1
- package/dist/cjs/errors.d.cts +6 -0
- package/dist/cjs/index.cjs +10 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +2 -0
- package/dist/cjs/indexes/auto-index.cjs +17 -8
- package/dist/cjs/indexes/auto-index.cjs.map +1 -1
- package/dist/cjs/optimistic-action.cjs +6 -1
- package/dist/cjs/optimistic-action.cjs.map +1 -1
- package/dist/cjs/paced-mutations.cjs +52 -0
- package/dist/cjs/paced-mutations.cjs.map +1 -0
- package/dist/cjs/paced-mutations.d.cts +81 -0
- package/dist/cjs/query/optimizer.cjs +17 -2
- package/dist/cjs/query/optimizer.cjs.map +1 -1
- package/dist/cjs/strategies/debounceStrategy.cjs +21 -0
- package/dist/cjs/strategies/debounceStrategy.cjs.map +1 -0
- package/dist/cjs/strategies/debounceStrategy.d.cts +25 -0
- package/dist/cjs/strategies/index.d.cts +4 -0
- package/dist/cjs/strategies/queueStrategy.cjs +33 -0
- package/dist/cjs/strategies/queueStrategy.cjs.map +1 -0
- package/dist/cjs/strategies/queueStrategy.d.cts +43 -0
- package/dist/cjs/strategies/throttleStrategy.cjs +21 -0
- package/dist/cjs/strategies/throttleStrategy.cjs.map +1 -0
- package/dist/cjs/strategies/throttleStrategy.d.cts +45 -0
- package/dist/cjs/strategies/types.d.cts +103 -0
- package/dist/cjs/transactions.cjs +3 -1
- package/dist/cjs/transactions.cjs.map +1 -1
- package/dist/cjs/transactions.d.cts +3 -1
- package/dist/cjs/utils/type-guards.cjs +7 -0
- package/dist/cjs/utils/type-guards.cjs.map +1 -0
- package/dist/cjs/utils/type-guards.d.cts +6 -0
- package/dist/esm/duplicate-instance-check.d.ts +1 -0
- package/dist/esm/errors.d.ts +6 -0
- package/dist/esm/errors.js +38 -0
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +11 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/indexes/auto-index.js +17 -8
- package/dist/esm/indexes/auto-index.js.map +1 -1
- package/dist/esm/optimistic-action.js +6 -1
- package/dist/esm/optimistic-action.js.map +1 -1
- package/dist/esm/paced-mutations.d.ts +81 -0
- package/dist/esm/paced-mutations.js +52 -0
- package/dist/esm/paced-mutations.js.map +1 -0
- package/dist/esm/query/optimizer.js +17 -2
- package/dist/esm/query/optimizer.js.map +1 -1
- package/dist/esm/strategies/debounceStrategy.d.ts +25 -0
- package/dist/esm/strategies/debounceStrategy.js +21 -0
- package/dist/esm/strategies/debounceStrategy.js.map +1 -0
- package/dist/esm/strategies/index.d.ts +4 -0
- package/dist/esm/strategies/queueStrategy.d.ts +43 -0
- package/dist/esm/strategies/queueStrategy.js +33 -0
- package/dist/esm/strategies/queueStrategy.js.map +1 -0
- package/dist/esm/strategies/throttleStrategy.d.ts +45 -0
- package/dist/esm/strategies/throttleStrategy.js +21 -0
- package/dist/esm/strategies/throttleStrategy.js.map +1 -0
- package/dist/esm/strategies/types.d.ts +103 -0
- package/dist/esm/transactions.d.ts +3 -1
- package/dist/esm/transactions.js +3 -1
- package/dist/esm/transactions.js.map +1 -1
- package/dist/esm/utils/type-guards.d.ts +6 -0
- package/dist/esm/utils/type-guards.js +7 -0
- package/dist/esm/utils/type-guards.js.map +1 -0
- package/package.json +3 -1
- package/src/duplicate-instance-check.ts +32 -0
- package/src/errors.ts +35 -0
- package/src/index.ts +2 -0
- package/src/indexes/auto-index.ts +23 -10
- package/src/optimistic-action.ts +7 -2
- package/src/paced-mutations.ts +169 -0
- package/src/query/optimizer.ts +31 -4
- package/src/strategies/debounceStrategy.ts +48 -0
- package/src/strategies/index.ts +17 -0
- package/src/strategies/queueStrategy.ts +75 -0
- package/src/strategies/throttleStrategy.ts +68 -0
- package/src/strategies/types.ts +130 -0
- package/src/transactions.ts +5 -1
- 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
|
package/dist/esm/transactions.js
CHANGED
|
@@ -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 @@
|
|
|
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.
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 "${
|
|
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
|
|
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
|
|
120
|
-
if (fieldPath.length
|
|
130
|
+
// Skip if the path is empty
|
|
131
|
+
if (fieldPath.length === 0) {
|
|
121
132
|
return
|
|
122
133
|
}
|
|
123
134
|
|
|
124
|
-
|
|
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
|
|
package/src/optimistic-action.ts
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
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
|
+
}
|