@tanstack/db 0.4.14 → 0.4.16

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 (59) hide show
  1. package/dist/cjs/index.cjs +8 -0
  2. package/dist/cjs/index.cjs.map +1 -1
  3. package/dist/cjs/index.d.cts +2 -0
  4. package/dist/cjs/indexes/auto-index.cjs +17 -8
  5. package/dist/cjs/indexes/auto-index.cjs.map +1 -1
  6. package/dist/cjs/local-storage.cjs +21 -18
  7. package/dist/cjs/local-storage.cjs.map +1 -1
  8. package/dist/cjs/local-storage.d.cts +9 -0
  9. package/dist/cjs/paced-mutations.cjs +52 -0
  10. package/dist/cjs/paced-mutations.cjs.map +1 -0
  11. package/dist/cjs/paced-mutations.d.cts +81 -0
  12. package/dist/cjs/query/optimizer.cjs +17 -2
  13. package/dist/cjs/query/optimizer.cjs.map +1 -1
  14. package/dist/cjs/strategies/debounceStrategy.cjs +21 -0
  15. package/dist/cjs/strategies/debounceStrategy.cjs.map +1 -0
  16. package/dist/cjs/strategies/debounceStrategy.d.cts +22 -0
  17. package/dist/cjs/strategies/index.d.cts +4 -0
  18. package/dist/cjs/strategies/queueStrategy.cjs +33 -0
  19. package/dist/cjs/strategies/queueStrategy.cjs.map +1 -0
  20. package/dist/cjs/strategies/queueStrategy.d.cts +43 -0
  21. package/dist/cjs/strategies/throttleStrategy.cjs +21 -0
  22. package/dist/cjs/strategies/throttleStrategy.cjs.map +1 -0
  23. package/dist/cjs/strategies/throttleStrategy.d.cts +39 -0
  24. package/dist/cjs/strategies/types.d.cts +103 -0
  25. package/dist/esm/index.d.ts +2 -0
  26. package/dist/esm/index.js +8 -0
  27. package/dist/esm/index.js.map +1 -1
  28. package/dist/esm/indexes/auto-index.js +17 -8
  29. package/dist/esm/indexes/auto-index.js.map +1 -1
  30. package/dist/esm/local-storage.d.ts +9 -0
  31. package/dist/esm/local-storage.js +21 -18
  32. package/dist/esm/local-storage.js.map +1 -1
  33. package/dist/esm/paced-mutations.d.ts +81 -0
  34. package/dist/esm/paced-mutations.js +52 -0
  35. package/dist/esm/paced-mutations.js.map +1 -0
  36. package/dist/esm/query/optimizer.js +17 -2
  37. package/dist/esm/query/optimizer.js.map +1 -1
  38. package/dist/esm/strategies/debounceStrategy.d.ts +22 -0
  39. package/dist/esm/strategies/debounceStrategy.js +21 -0
  40. package/dist/esm/strategies/debounceStrategy.js.map +1 -0
  41. package/dist/esm/strategies/index.d.ts +4 -0
  42. package/dist/esm/strategies/queueStrategy.d.ts +43 -0
  43. package/dist/esm/strategies/queueStrategy.js +33 -0
  44. package/dist/esm/strategies/queueStrategy.js.map +1 -0
  45. package/dist/esm/strategies/throttleStrategy.d.ts +39 -0
  46. package/dist/esm/strategies/throttleStrategy.js +21 -0
  47. package/dist/esm/strategies/throttleStrategy.js.map +1 -0
  48. package/dist/esm/strategies/types.d.ts +103 -0
  49. package/package.json +4 -1
  50. package/src/index.ts +2 -0
  51. package/src/indexes/auto-index.ts +23 -10
  52. package/src/local-storage.ts +41 -17
  53. package/src/paced-mutations.ts +169 -0
  54. package/src/query/optimizer.ts +31 -4
  55. package/src/strategies/debounceStrategy.ts +45 -0
  56. package/src/strategies/index.ts +17 -0
  57. package/src/strategies/queueStrategy.ts +75 -0
  58. package/src/strategies/throttleStrategy.ts +62 -0
  59. package/src/strategies/types.ts +130 -0
@@ -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
 
@@ -44,6 +44,11 @@ interface StoredItem<T> {
44
44
  data: T
45
45
  }
46
46
 
47
+ export interface Parser {
48
+ parse: (data: string) => unknown
49
+ stringify: (data: unknown) => string
50
+ }
51
+
47
52
  /**
48
53
  * Configuration interface for localStorage collection options
49
54
  * @template T - The type of items in the collection
@@ -71,6 +76,12 @@ export interface LocalStorageCollectionConfig<
71
76
  * Can be any object that implements addEventListener/removeEventListener for storage events
72
77
  */
73
78
  storageEventApi?: StorageEventApi
79
+
80
+ /**
81
+ * Parser to use for serializing and deserializing data to and from storage
82
+ * Defaults to JSON
83
+ */
84
+ parser?: Parser
74
85
  }
75
86
 
76
87
  /**
@@ -113,13 +124,18 @@ export interface LocalStorageCollectionUtils extends UtilsRecord {
113
124
 
114
125
  /**
115
126
  * Validates that a value can be JSON serialized
127
+ * @param parser - The parser to use for serialization
116
128
  * @param value - The value to validate for JSON serialization
117
129
  * @param operation - The operation type being performed (for error messages)
118
130
  * @throws Error if the value cannot be JSON serialized
119
131
  */
120
- function validateJsonSerializable(value: any, operation: string): void {
132
+ function validateJsonSerializable(
133
+ parser: Parser,
134
+ value: any,
135
+ operation: string
136
+ ): void {
121
137
  try {
122
- JSON.stringify(value)
138
+ parser.stringify(value)
123
139
  } catch (error) {
124
140
  throw new SerializationError(
125
141
  operation,
@@ -314,6 +330,9 @@ export function localStorageCollectionOptions(
314
330
  (typeof window !== `undefined` ? window : null) ||
315
331
  createNoOpStorageEventApi()
316
332
 
333
+ // Default to JSON parser if no parser is provided
334
+ const parser = config.parser || JSON
335
+
317
336
  // Track the last known state to detect changes
318
337
  const lastKnownData = new Map<string | number, StoredItem<any>>()
319
338
 
@@ -322,6 +341,7 @@ export function localStorageCollectionOptions(
322
341
  config.storageKey,
323
342
  storage,
324
343
  storageEventApi,
344
+ parser,
325
345
  config.getKey,
326
346
  lastKnownData
327
347
  )
@@ -349,7 +369,7 @@ export function localStorageCollectionOptions(
349
369
  dataMap.forEach((storedItem, key) => {
350
370
  objectData[String(key)] = storedItem
351
371
  })
352
- const serialized = JSON.stringify(objectData)
372
+ const serialized = parser.stringify(objectData)
353
373
  storage.setItem(config.storageKey, serialized)
354
374
  } catch (error) {
355
375
  console.error(
@@ -383,7 +403,7 @@ export function localStorageCollectionOptions(
383
403
  const wrappedOnInsert = async (params: InsertMutationFnParams<any>) => {
384
404
  // Validate that all values in the transaction can be JSON serialized
385
405
  params.transaction.mutations.forEach((mutation) => {
386
- validateJsonSerializable(mutation.modified, `insert`)
406
+ validateJsonSerializable(parser, mutation.modified, `insert`)
387
407
  })
388
408
 
389
409
  // Call the user handler BEFORE persisting changes (if provided)
@@ -394,7 +414,7 @@ export function localStorageCollectionOptions(
394
414
 
395
415
  // Always persist to storage
396
416
  // Load current data from storage
397
- const currentData = loadFromStorage<any>(config.storageKey, storage)
417
+ const currentData = loadFromStorage<any>(config.storageKey, storage, parser)
398
418
 
399
419
  // Add new items with version keys
400
420
  params.transaction.mutations.forEach((mutation) => {
@@ -418,7 +438,7 @@ export function localStorageCollectionOptions(
418
438
  const wrappedOnUpdate = async (params: UpdateMutationFnParams<any>) => {
419
439
  // Validate that all values in the transaction can be JSON serialized
420
440
  params.transaction.mutations.forEach((mutation) => {
421
- validateJsonSerializable(mutation.modified, `update`)
441
+ validateJsonSerializable(parser, mutation.modified, `update`)
422
442
  })
423
443
 
424
444
  // Call the user handler BEFORE persisting changes (if provided)
@@ -429,7 +449,7 @@ export function localStorageCollectionOptions(
429
449
 
430
450
  // Always persist to storage
431
451
  // Load current data from storage
432
- const currentData = loadFromStorage<any>(config.storageKey, storage)
452
+ const currentData = loadFromStorage<any>(config.storageKey, storage, parser)
433
453
 
434
454
  // Update items with new version keys
435
455
  params.transaction.mutations.forEach((mutation) => {
@@ -459,7 +479,7 @@ export function localStorageCollectionOptions(
459
479
 
460
480
  // Always persist to storage
461
481
  // Load current data from storage
462
- const currentData = loadFromStorage<any>(config.storageKey, storage)
482
+ const currentData = loadFromStorage<any>(config.storageKey, storage, parser)
463
483
 
464
484
  // Remove items
465
485
  params.transaction.mutations.forEach((mutation) => {
@@ -518,10 +538,10 @@ export function localStorageCollectionOptions(
518
538
  switch (mutation.type) {
519
539
  case `insert`:
520
540
  case `update`:
521
- validateJsonSerializable(mutation.modified, mutation.type)
541
+ validateJsonSerializable(parser, mutation.modified, mutation.type)
522
542
  break
523
543
  case `delete`:
524
- validateJsonSerializable(mutation.original, mutation.type)
544
+ validateJsonSerializable(parser, mutation.original, mutation.type)
525
545
  break
526
546
  }
527
547
  }
@@ -529,7 +549,8 @@ export function localStorageCollectionOptions(
529
549
  // Load current data from storage
530
550
  const currentData = loadFromStorage<Record<string, unknown>>(
531
551
  config.storageKey,
532
- storage
552
+ storage,
553
+ parser
533
554
  )
534
555
 
535
556
  // Apply each mutation
@@ -579,13 +600,15 @@ export function localStorageCollectionOptions(
579
600
 
580
601
  /**
581
602
  * Load data from storage and return as a Map
603
+ * @param parser - The parser to use for deserializing the data
582
604
  * @param storageKey - The key used to store data in the storage API
583
605
  * @param storage - The storage API to load from (localStorage, sessionStorage, etc.)
584
606
  * @returns Map of stored items with version tracking, or empty Map if loading fails
585
607
  */
586
608
  function loadFromStorage<T extends object>(
587
609
  storageKey: string,
588
- storage: StorageApi
610
+ storage: StorageApi,
611
+ parser: Parser
589
612
  ): Map<string | number, StoredItem<T>> {
590
613
  try {
591
614
  const rawData = storage.getItem(storageKey)
@@ -593,7 +616,7 @@ function loadFromStorage<T extends object>(
593
616
  return new Map()
594
617
  }
595
618
 
596
- const parsed = JSON.parse(rawData)
619
+ const parsed = parser.parse(rawData)
597
620
  const dataMap = new Map<string | number, StoredItem<T>>()
598
621
 
599
622
  // Handle object format where keys map to StoredItem values
@@ -644,6 +667,7 @@ function createLocalStorageSync<T extends object>(
644
667
  storageKey: string,
645
668
  storage: StorageApi,
646
669
  storageEventApi: StorageEventApi,
670
+ parser: Parser,
647
671
  _getKey: (item: T) => string | number,
648
672
  lastKnownData: Map<string | number, StoredItem<T>>
649
673
  ): SyncConfig<T> & {
@@ -704,7 +728,7 @@ function createLocalStorageSync<T extends object>(
704
728
  const { begin, write, commit } = syncParams
705
729
 
706
730
  // Load the new data
707
- const newData = loadFromStorage<T>(storageKey, storage)
731
+ const newData = loadFromStorage<T>(storageKey, storage, parser)
708
732
 
709
733
  // Find the specific changes
710
734
  const changes = findChanges(lastKnownData, newData)
@@ -713,7 +737,7 @@ function createLocalStorageSync<T extends object>(
713
737
  begin()
714
738
  changes.forEach(({ type, value }) => {
715
739
  if (value) {
716
- validateJsonSerializable(value, type)
740
+ validateJsonSerializable(parser, value, type)
717
741
  write({ type, value })
718
742
  }
719
743
  })
@@ -739,11 +763,11 @@ function createLocalStorageSync<T extends object>(
739
763
  collection = params.collection
740
764
 
741
765
  // Initial load
742
- const initialData = loadFromStorage<T>(storageKey, storage)
766
+ const initialData = loadFromStorage<T>(storageKey, storage, parser)
743
767
  if (initialData.size > 0) {
744
768
  begin()
745
769
  initialData.forEach((storedItem) => {
746
- validateJsonSerializable(storedItem.data, `load`)
770
+ validateJsonSerializable(parser, storedItem.data, `load`)
747
771
  write({ type: `insert`, value: storedItem.data })
748
772
  })
749
773
  commit()
@@ -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 (text, { 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 ({ text }, { 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
+ }
@@ -330,9 +330,22 @@ function applySingleLevelOptimization(query: QueryIR): QueryIR {
330
330
  return query
331
331
  }
332
332
 
333
- // Skip optimization if there are no joins - predicate pushdown only benefits joins
334
- // Single-table queries don't benefit from this optimization
333
+ // For queries without joins, combine multiple WHERE clauses into a single clause
334
+ // to avoid creating multiple filter operators in the pipeline
335
335
  if (!query.join || query.join.length === 0) {
336
+ // Only optimize if there are multiple WHERE clauses to combine
337
+ if (query.where.length > 1) {
338
+ // Combine multiple WHERE clauses into a single AND expression
339
+ const splitWhereClauses = splitAndClauses(query.where)
340
+ const combinedWhere = combineWithAnd(splitWhereClauses)
341
+
342
+ return {
343
+ ...query,
344
+ where: [combinedWhere],
345
+ }
346
+ }
347
+
348
+ // For single WHERE clauses, no optimization needed
336
349
  return query
337
350
  }
338
351
 
@@ -674,6 +687,20 @@ function applyOptimizations(
674
687
  // If optimized and no outer JOINs - don't keep (original behavior)
675
688
  }
676
689
 
690
+ // Combine multiple remaining WHERE clauses into a single clause to avoid
691
+ // multiple filter operations in the pipeline (performance optimization)
692
+ // First flatten any nested AND expressions to avoid and(and(...), ...)
693
+ const finalWhere: Array<Where> =
694
+ remainingWhereClauses.length > 1
695
+ ? [
696
+ combineWithAnd(
697
+ remainingWhereClauses.flatMap((clause) =>
698
+ splitAndClausesRecursive(getWhereExpression(clause))
699
+ )
700
+ ),
701
+ ]
702
+ : remainingWhereClauses
703
+
677
704
  // Create a completely new query object to ensure immutability
678
705
  const optimizedQuery: QueryIR = {
679
706
  // Copy all non-optimized fields as-is
@@ -692,8 +719,8 @@ function applyOptimizations(
692
719
  from: optimizedFrom,
693
720
  join: optimizedJoins,
694
721
 
695
- // Only include WHERE clauses that weren't successfully optimized
696
- where: remainingWhereClauses.length > 0 ? remainingWhereClauses : [],
722
+ // Include combined WHERE clauses
723
+ where: finalWhere.length > 0 ? finalWhere : [],
697
724
  }
698
725
 
699
726
  return optimizedQuery
@@ -0,0 +1,45 @@
1
+ import { Debouncer } from "@tanstack/pacer/debouncer"
2
+ import type { DebounceStrategy, DebounceStrategyOptions } from "./types"
3
+ import type { Transaction } from "../transactions"
4
+
5
+ /**
6
+ * Creates a debounce strategy that delays transaction execution until after
7
+ * a period of inactivity.
8
+ *
9
+ * Ideal for scenarios like search inputs or auto-save fields where you want
10
+ * to wait for the user to stop typing before persisting changes.
11
+ *
12
+ * @param options - Configuration for the debounce behavior
13
+ * @returns A debounce strategy instance
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * const mutate = useSerializedTransaction({
18
+ * mutationFn: async ({ transaction }) => {
19
+ * await api.save(transaction.mutations)
20
+ * },
21
+ * strategy: debounceStrategy({ wait: 500 })
22
+ * })
23
+ * ```
24
+ */
25
+ export function debounceStrategy(
26
+ options: DebounceStrategyOptions
27
+ ): DebounceStrategy {
28
+ const debouncer = new Debouncer(
29
+ (callback: () => Transaction) => callback(),
30
+ options
31
+ )
32
+
33
+ return {
34
+ _type: `debounce`,
35
+ options,
36
+ execute: <T extends object = Record<string, unknown>>(
37
+ fn: () => Transaction<T>
38
+ ) => {
39
+ debouncer.maybeExecute(fn as () => Transaction)
40
+ },
41
+ cleanup: () => {
42
+ debouncer.cancel()
43
+ },
44
+ }
45
+ }
@@ -0,0 +1,17 @@
1
+ // Export all strategy factories
2
+ export { debounceStrategy } from "./debounceStrategy"
3
+ export { queueStrategy } from "./queueStrategy"
4
+ export { throttleStrategy } from "./throttleStrategy"
5
+
6
+ // Export strategy types
7
+ export type {
8
+ Strategy,
9
+ BaseStrategy,
10
+ DebounceStrategy,
11
+ DebounceStrategyOptions,
12
+ QueueStrategy,
13
+ QueueStrategyOptions,
14
+ ThrottleStrategy,
15
+ ThrottleStrategyOptions,
16
+ StrategyOptions,
17
+ } from "./types"
@@ -0,0 +1,75 @@
1
+ import { AsyncQueuer } from "@tanstack/pacer/async-queuer"
2
+ import type { QueueStrategy, QueueStrategyOptions } from "./types"
3
+ import type { Transaction } from "../transactions"
4
+
5
+ /**
6
+ * Creates a queue strategy that processes all mutations in order with proper serialization.
7
+ *
8
+ * Unlike other strategies that may drop executions, queue ensures every
9
+ * mutation is processed sequentially. Each transaction commit completes before
10
+ * the next one starts. Useful when data consistency is critical and
11
+ * every operation must complete in order.
12
+ *
13
+ * @param options - Configuration for queue behavior (FIFO/LIFO, timing, size limits)
14
+ * @returns A queue strategy instance
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * // FIFO queue - process in order received
19
+ * const mutate = usePacedMutations({
20
+ * mutationFn: async ({ transaction }) => {
21
+ * await api.save(transaction.mutations)
22
+ * },
23
+ * strategy: queueStrategy({
24
+ * wait: 200,
25
+ * addItemsTo: 'back',
26
+ * getItemsFrom: 'front'
27
+ * })
28
+ * })
29
+ * ```
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * // LIFO queue - process most recent first
34
+ * const mutate = usePacedMutations({
35
+ * mutationFn: async ({ transaction }) => {
36
+ * await api.save(transaction.mutations)
37
+ * },
38
+ * strategy: queueStrategy({
39
+ * wait: 200,
40
+ * addItemsTo: 'back',
41
+ * getItemsFrom: 'back'
42
+ * })
43
+ * })
44
+ * ```
45
+ */
46
+ export function queueStrategy(options?: QueueStrategyOptions): QueueStrategy {
47
+ const queuer = new AsyncQueuer<void>({
48
+ concurrency: 1, // Process one at a time to ensure serialization
49
+ wait: options?.wait,
50
+ maxSize: options?.maxSize,
51
+ addItemsTo: options?.addItemsTo ?? `back`, // Default FIFO: add to back
52
+ getItemsFrom: options?.getItemsFrom ?? `front`, // Default FIFO: get from front
53
+ started: true, // Start processing immediately
54
+ })
55
+
56
+ return {
57
+ _type: `queue`,
58
+ options,
59
+ execute: <T extends object = Record<string, unknown>>(
60
+ fn: () => Transaction<T>
61
+ ) => {
62
+ // Wrap the callback in an async function that waits for persistence
63
+ queuer.addItem(async () => {
64
+ const transaction = fn()
65
+ // Wait for the transaction to be persisted before processing next item
66
+ // Note: fn() already calls commit(), we just wait for it to complete
67
+ await transaction.isPersisted.promise
68
+ })
69
+ },
70
+ cleanup: () => {
71
+ queuer.stop()
72
+ queuer.clear()
73
+ },
74
+ }
75
+ }
@@ -0,0 +1,62 @@
1
+ import { Throttler } from "@tanstack/pacer/throttler"
2
+ import type { ThrottleStrategy, ThrottleStrategyOptions } from "./types"
3
+ import type { Transaction } from "../transactions"
4
+
5
+ /**
6
+ * Creates a throttle strategy that ensures transactions are evenly spaced
7
+ * over time.
8
+ *
9
+ * Provides smooth, controlled execution patterns ideal for UI updates like
10
+ * sliders, progress bars, or scroll handlers where you want consistent
11
+ * execution timing.
12
+ *
13
+ * @param options - Configuration for throttle behavior
14
+ * @returns A throttle strategy instance
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * // Throttle slider updates to every 200ms
19
+ * const mutate = useSerializedTransaction({
20
+ * mutationFn: async ({ transaction }) => {
21
+ * await api.updateVolume(transaction.mutations)
22
+ * },
23
+ * strategy: throttleStrategy({ wait: 200 })
24
+ * })
25
+ * ```
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * // Throttle with leading and trailing execution
30
+ * const mutate = useSerializedTransaction({
31
+ * mutationFn: async ({ transaction }) => {
32
+ * await api.save(transaction.mutations)
33
+ * },
34
+ * strategy: throttleStrategy({
35
+ * wait: 500,
36
+ * leading: true,
37
+ * trailing: true
38
+ * })
39
+ * })
40
+ * ```
41
+ */
42
+ export function throttleStrategy(
43
+ options: ThrottleStrategyOptions
44
+ ): ThrottleStrategy {
45
+ const throttler = new Throttler(
46
+ (callback: () => Transaction) => callback(),
47
+ options
48
+ )
49
+
50
+ return {
51
+ _type: `throttle`,
52
+ options,
53
+ execute: <T extends object = Record<string, unknown>>(
54
+ fn: () => Transaction<T>
55
+ ) => {
56
+ throttler.maybeExecute(fn as () => Transaction)
57
+ },
58
+ cleanup: () => {
59
+ throttler.cancel()
60
+ },
61
+ }
62
+ }