@tanstack/db 0.5.11 → 0.5.13

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 (225) hide show
  1. package/dist/cjs/SortedMap.cjs +40 -26
  2. package/dist/cjs/SortedMap.cjs.map +1 -1
  3. package/dist/cjs/SortedMap.d.cts +10 -15
  4. package/dist/cjs/collection/change-events.cjs.map +1 -1
  5. package/dist/cjs/collection/changes.cjs +2 -0
  6. package/dist/cjs/collection/changes.cjs.map +1 -1
  7. package/dist/cjs/collection/events.cjs.map +1 -1
  8. package/dist/cjs/collection/events.d.cts +12 -4
  9. package/dist/cjs/collection/index.cjs +2 -1
  10. package/dist/cjs/collection/index.cjs.map +1 -1
  11. package/dist/cjs/collection/indexes.cjs.map +1 -1
  12. package/dist/cjs/collection/lifecycle.cjs.map +1 -1
  13. package/dist/cjs/collection/mutations.cjs +5 -2
  14. package/dist/cjs/collection/mutations.cjs.map +1 -1
  15. package/dist/cjs/collection/state.cjs +6 -5
  16. package/dist/cjs/collection/state.cjs.map +1 -1
  17. package/dist/cjs/collection/state.d.cts +4 -1
  18. package/dist/cjs/collection/subscription.cjs +91 -57
  19. package/dist/cjs/collection/subscription.cjs.map +1 -1
  20. package/dist/cjs/collection/subscription.d.cts +26 -4
  21. package/dist/cjs/collection/sync.cjs +11 -6
  22. package/dist/cjs/collection/sync.cjs.map +1 -1
  23. package/dist/cjs/errors.cjs +9 -0
  24. package/dist/cjs/errors.cjs.map +1 -1
  25. package/dist/cjs/errors.d.cts +3 -0
  26. package/dist/cjs/event-emitter.cjs.map +1 -1
  27. package/dist/cjs/index.cjs +2 -0
  28. package/dist/cjs/index.cjs.map +1 -1
  29. package/dist/cjs/index.d.cts +1 -1
  30. package/dist/cjs/indexes/auto-index.cjs.map +1 -1
  31. package/dist/cjs/indexes/base-index.cjs.map +1 -1
  32. package/dist/cjs/indexes/btree-index.cjs +8 -6
  33. package/dist/cjs/indexes/btree-index.cjs.map +1 -1
  34. package/dist/cjs/indexes/lazy-index.cjs.map +1 -1
  35. package/dist/cjs/indexes/reverse-index.cjs.map +1 -1
  36. package/dist/cjs/local-only.cjs.map +1 -1
  37. package/dist/cjs/local-storage.cjs.map +1 -1
  38. package/dist/cjs/optimistic-action.cjs.map +1 -1
  39. package/dist/cjs/paced-mutations.cjs.map +1 -1
  40. package/dist/cjs/proxy.cjs.map +1 -1
  41. package/dist/cjs/query/builder/functions.cjs.map +1 -1
  42. package/dist/cjs/query/builder/index.cjs.map +1 -1
  43. package/dist/cjs/query/builder/ref-proxy.cjs.map +1 -1
  44. package/dist/cjs/query/compiler/evaluators.cjs.map +1 -1
  45. package/dist/cjs/query/compiler/expressions.cjs.map +1 -1
  46. package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
  47. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  48. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  49. package/dist/cjs/query/compiler/order-by.cjs +91 -38
  50. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  51. package/dist/cjs/query/compiler/order-by.d.cts +6 -2
  52. package/dist/cjs/query/compiler/select.cjs.map +1 -1
  53. package/dist/cjs/query/expression-helpers.cjs.map +1 -1
  54. package/dist/cjs/query/index.d.cts +1 -1
  55. package/dist/cjs/query/ir.cjs.map +1 -1
  56. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  57. package/dist/cjs/query/live/collection-registry.cjs.map +1 -1
  58. package/dist/cjs/query/live/collection-subscriber.cjs +30 -15
  59. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  60. package/dist/cjs/query/live/internal.cjs.map +1 -1
  61. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  62. package/dist/cjs/query/optimizer.cjs.map +1 -1
  63. package/dist/cjs/query/predicate-utils.cjs +19 -2
  64. package/dist/cjs/query/predicate-utils.cjs.map +1 -1
  65. package/dist/cjs/query/predicate-utils.d.cts +32 -1
  66. package/dist/cjs/query/subset-dedupe.cjs.map +1 -1
  67. package/dist/cjs/scheduler.cjs.map +1 -1
  68. package/dist/cjs/strategies/debounceStrategy.cjs.map +1 -1
  69. package/dist/cjs/strategies/queueStrategy.cjs.map +1 -1
  70. package/dist/cjs/strategies/throttleStrategy.cjs.map +1 -1
  71. package/dist/cjs/transactions.cjs.map +1 -1
  72. package/dist/cjs/types.d.cts +53 -8
  73. package/dist/cjs/utils/browser-polyfills.cjs.map +1 -1
  74. package/dist/cjs/utils/btree.cjs.map +1 -1
  75. package/dist/cjs/utils/comparison.cjs.map +1 -1
  76. package/dist/cjs/utils/cursor.cjs +39 -0
  77. package/dist/cjs/utils/cursor.cjs.map +1 -0
  78. package/dist/cjs/utils/cursor.d.cts +18 -0
  79. package/dist/cjs/utils/index-optimization.cjs.map +1 -1
  80. package/dist/cjs/utils.cjs.map +1 -1
  81. package/dist/esm/SortedMap.d.ts +10 -15
  82. package/dist/esm/SortedMap.js +40 -26
  83. package/dist/esm/SortedMap.js.map +1 -1
  84. package/dist/esm/collection/change-events.js.map +1 -1
  85. package/dist/esm/collection/changes.js +2 -0
  86. package/dist/esm/collection/changes.js.map +1 -1
  87. package/dist/esm/collection/events.d.ts +12 -4
  88. package/dist/esm/collection/events.js.map +1 -1
  89. package/dist/esm/collection/index.js +2 -1
  90. package/dist/esm/collection/index.js.map +1 -1
  91. package/dist/esm/collection/indexes.js.map +1 -1
  92. package/dist/esm/collection/lifecycle.js.map +1 -1
  93. package/dist/esm/collection/mutations.js +6 -3
  94. package/dist/esm/collection/mutations.js.map +1 -1
  95. package/dist/esm/collection/state.d.ts +4 -1
  96. package/dist/esm/collection/state.js +6 -5
  97. package/dist/esm/collection/state.js.map +1 -1
  98. package/dist/esm/collection/subscription.d.ts +26 -4
  99. package/dist/esm/collection/subscription.js +92 -58
  100. package/dist/esm/collection/subscription.js.map +1 -1
  101. package/dist/esm/collection/sync.js +11 -6
  102. package/dist/esm/collection/sync.js.map +1 -1
  103. package/dist/esm/errors.d.ts +3 -0
  104. package/dist/esm/errors.js +9 -0
  105. package/dist/esm/errors.js.map +1 -1
  106. package/dist/esm/event-emitter.js.map +1 -1
  107. package/dist/esm/index.d.ts +1 -1
  108. package/dist/esm/index.js +4 -2
  109. package/dist/esm/indexes/auto-index.js.map +1 -1
  110. package/dist/esm/indexes/base-index.js.map +1 -1
  111. package/dist/esm/indexes/btree-index.js +8 -6
  112. package/dist/esm/indexes/btree-index.js.map +1 -1
  113. package/dist/esm/indexes/lazy-index.js.map +1 -1
  114. package/dist/esm/indexes/reverse-index.js.map +1 -1
  115. package/dist/esm/local-only.js.map +1 -1
  116. package/dist/esm/local-storage.js.map +1 -1
  117. package/dist/esm/optimistic-action.js.map +1 -1
  118. package/dist/esm/paced-mutations.js.map +1 -1
  119. package/dist/esm/proxy.js.map +1 -1
  120. package/dist/esm/query/builder/functions.js.map +1 -1
  121. package/dist/esm/query/builder/index.js.map +1 -1
  122. package/dist/esm/query/builder/ref-proxy.js.map +1 -1
  123. package/dist/esm/query/compiler/evaluators.js.map +1 -1
  124. package/dist/esm/query/compiler/expressions.js.map +1 -1
  125. package/dist/esm/query/compiler/group-by.js.map +1 -1
  126. package/dist/esm/query/compiler/index.js.map +1 -1
  127. package/dist/esm/query/compiler/joins.js.map +1 -1
  128. package/dist/esm/query/compiler/order-by.d.ts +6 -2
  129. package/dist/esm/query/compiler/order-by.js +91 -38
  130. package/dist/esm/query/compiler/order-by.js.map +1 -1
  131. package/dist/esm/query/compiler/select.js.map +1 -1
  132. package/dist/esm/query/expression-helpers.js.map +1 -1
  133. package/dist/esm/query/index.d.ts +1 -1
  134. package/dist/esm/query/ir.js.map +1 -1
  135. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  136. package/dist/esm/query/live/collection-registry.js.map +1 -1
  137. package/dist/esm/query/live/collection-subscriber.js +30 -15
  138. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  139. package/dist/esm/query/live/internal.js.map +1 -1
  140. package/dist/esm/query/live-query-collection.js.map +1 -1
  141. package/dist/esm/query/optimizer.js.map +1 -1
  142. package/dist/esm/query/predicate-utils.d.ts +32 -1
  143. package/dist/esm/query/predicate-utils.js +19 -2
  144. package/dist/esm/query/predicate-utils.js.map +1 -1
  145. package/dist/esm/query/subset-dedupe.js.map +1 -1
  146. package/dist/esm/scheduler.js.map +1 -1
  147. package/dist/esm/strategies/debounceStrategy.js.map +1 -1
  148. package/dist/esm/strategies/queueStrategy.js.map +1 -1
  149. package/dist/esm/strategies/throttleStrategy.js.map +1 -1
  150. package/dist/esm/transactions.js.map +1 -1
  151. package/dist/esm/types.d.ts +53 -8
  152. package/dist/esm/utils/browser-polyfills.js.map +1 -1
  153. package/dist/esm/utils/btree.js.map +1 -1
  154. package/dist/esm/utils/comparison.js.map +1 -1
  155. package/dist/esm/utils/cursor.d.ts +18 -0
  156. package/dist/esm/utils/cursor.js +39 -0
  157. package/dist/esm/utils/cursor.js.map +1 -0
  158. package/dist/esm/utils/index-optimization.js.map +1 -1
  159. package/dist/esm/utils.js.map +1 -1
  160. package/package.json +30 -28
  161. package/src/SortedMap.ts +50 -31
  162. package/src/collection/change-events.ts +20 -20
  163. package/src/collection/changes.ts +16 -12
  164. package/src/collection/events.ts +20 -10
  165. package/src/collection/index.ts +47 -46
  166. package/src/collection/indexes.ts +14 -14
  167. package/src/collection/lifecycle.ts +16 -16
  168. package/src/collection/mutations.ts +25 -20
  169. package/src/collection/state.ts +43 -36
  170. package/src/collection/subscription.ts +171 -90
  171. package/src/collection/sync.ts +34 -22
  172. package/src/duplicate-instance-check.ts +1 -1
  173. package/src/errors.ts +49 -40
  174. package/src/event-emitter.ts +5 -5
  175. package/src/index.ts +21 -21
  176. package/src/indexes/auto-index.ts +11 -11
  177. package/src/indexes/base-index.ts +13 -13
  178. package/src/indexes/btree-index.ts +21 -17
  179. package/src/indexes/index-options.ts +3 -3
  180. package/src/indexes/lazy-index.ts +8 -8
  181. package/src/indexes/reverse-index.ts +5 -5
  182. package/src/local-only.ts +12 -12
  183. package/src/local-storage.ts +17 -17
  184. package/src/optimistic-action.ts +5 -5
  185. package/src/paced-mutations.ts +6 -6
  186. package/src/proxy.ts +43 -43
  187. package/src/query/builder/functions.ts +28 -28
  188. package/src/query/builder/index.ts +22 -22
  189. package/src/query/builder/ref-proxy.ts +4 -4
  190. package/src/query/builder/types.ts +8 -8
  191. package/src/query/compiler/evaluators.ts +9 -9
  192. package/src/query/compiler/expressions.ts +6 -6
  193. package/src/query/compiler/group-by.ts +24 -24
  194. package/src/query/compiler/index.ts +44 -44
  195. package/src/query/compiler/joins.ts +37 -37
  196. package/src/query/compiler/order-by.ts +170 -77
  197. package/src/query/compiler/select.ts +13 -13
  198. package/src/query/compiler/types.ts +2 -2
  199. package/src/query/expression-helpers.ts +16 -16
  200. package/src/query/index.ts +10 -9
  201. package/src/query/ir.ts +13 -13
  202. package/src/query/live/collection-config-builder.ts +53 -53
  203. package/src/query/live/collection-registry.ts +6 -6
  204. package/src/query/live/collection-subscriber.ts +87 -48
  205. package/src/query/live/internal.ts +1 -1
  206. package/src/query/live/types.ts +4 -4
  207. package/src/query/live-query-collection.ts +15 -15
  208. package/src/query/optimizer.ts +29 -29
  209. package/src/query/predicate-utils.ts +105 -50
  210. package/src/query/subset-dedupe.ts +6 -6
  211. package/src/scheduler.ts +3 -3
  212. package/src/strategies/debounceStrategy.ts +6 -6
  213. package/src/strategies/index.ts +4 -4
  214. package/src/strategies/queueStrategy.ts +5 -5
  215. package/src/strategies/throttleStrategy.ts +6 -6
  216. package/src/strategies/types.ts +2 -2
  217. package/src/transactions.ts +9 -9
  218. package/src/types.ts +76 -18
  219. package/src/utils/array-utils.ts +1 -1
  220. package/src/utils/browser-polyfills.ts +2 -2
  221. package/src/utils/btree.ts +22 -22
  222. package/src/utils/comparison.ts +3 -3
  223. package/src/utils/cursor.ts +78 -0
  224. package/src/utils/index-optimization.ts +14 -14
  225. package/src/utils.ts +4 -4
@@ -1,16 +1,17 @@
1
- import { deepEquals } from "../utils"
2
- import { SortedMap } from "../SortedMap"
3
- import type { Transaction } from "../transactions"
4
- import type { StandardSchemaV1 } from "@standard-schema/spec"
1
+ import { deepEquals } from '../utils'
2
+ import { SortedMap } from '../SortedMap'
3
+ import type { Transaction } from '../transactions'
4
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
5
5
  import type {
6
6
  ChangeMessage,
7
7
  CollectionConfig,
8
8
  OptimisticChangeMessage,
9
- } from "../types"
10
- import type { CollectionImpl } from "./index.js"
11
- import type { CollectionLifecycleManager } from "./lifecycle"
12
- import type { CollectionChangesManager } from "./changes"
13
- import type { CollectionIndexesManager } from "./indexes"
9
+ } from '../types'
10
+ import type { CollectionImpl } from './index.js'
11
+ import type { CollectionLifecycleManager } from './lifecycle'
12
+ import type { CollectionChangesManager } from './changes'
13
+ import type { CollectionIndexesManager } from './indexes'
14
+ import type { CollectionEventsManager } from './events'
14
15
 
15
16
  interface PendingSyncedTransaction<
16
17
  T extends object = Record<string, unknown>,
@@ -37,13 +38,14 @@ export class CollectionStateManager<
37
38
  public lifecycle!: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
38
39
  public changes!: CollectionChangesManager<TOutput, TKey, TSchema, TInput>
39
40
  public indexes!: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
41
+ private _events!: CollectionEventsManager
40
42
 
41
43
  // Core state - make public for testing
42
44
  public transactions: SortedMap<string, Transaction<any>>
43
45
  public pendingSyncedTransactions: Array<
44
46
  PendingSyncedTransaction<TOutput, TKey>
45
47
  > = []
46
- public syncedData: Map<TKey, TOutput> | SortedMap<TKey, TOutput>
48
+ public syncedData: SortedMap<TKey, TOutput>
47
49
  public syncedMetadata = new Map<TKey, unknown>()
48
50
 
49
51
  // Optimistic state tracking - make public for testing
@@ -66,15 +68,12 @@ export class CollectionStateManager<
66
68
  constructor(config: CollectionConfig<TOutput, TKey, TSchema>) {
67
69
  this.config = config
68
70
  this.transactions = new SortedMap<string, Transaction<any>>((a, b) =>
69
- a.compareCreatedAt(b)
71
+ a.compareCreatedAt(b),
70
72
  )
71
73
 
72
- // Set up data storage with optional comparison function
73
- if (config.compare) {
74
- this.syncedData = new SortedMap<TKey, TOutput>(config.compare)
75
- } else {
76
- this.syncedData = new Map<TKey, TOutput>()
77
- }
74
+ // Set up data storage - always use SortedMap for deterministic iteration.
75
+ // If a custom compare function is provided, use it; otherwise entries are sorted by key only.
76
+ this.syncedData = new SortedMap<TKey, TOutput>(config.compare)
78
77
  }
79
78
 
80
79
  setDeps(deps: {
@@ -82,11 +81,13 @@ export class CollectionStateManager<
82
81
  lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
83
82
  changes: CollectionChangesManager<TOutput, TKey, TSchema, TInput>
84
83
  indexes: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
84
+ events: CollectionEventsManager
85
85
  }) {
86
86
  this.collection = deps.collection
87
87
  this.lifecycle = deps.lifecycle
88
88
  this.changes = deps.changes
89
89
  this.indexes = deps.indexes
90
+ this._events = deps.events
90
91
  }
91
92
 
92
93
  /**
@@ -185,7 +186,7 @@ export class CollectionStateManager<
185
186
  * Execute a callback for each entry in the collection
186
187
  */
187
188
  public forEach(
188
- callbackfn: (value: TOutput, key: TKey, index: number) => void
189
+ callbackfn: (value: TOutput, key: TKey, index: number) => void,
189
190
  ): void {
190
191
  let index = 0
191
192
  for (const [key, value] of this.entries()) {
@@ -197,7 +198,7 @@ export class CollectionStateManager<
197
198
  * Create a new array with the results of calling a function for each entry in the collection
198
199
  */
199
200
  public map<U>(
200
- callbackfn: (value: TOutput, key: TKey, index: number) => U
201
+ callbackfn: (value: TOutput, key: TKey, index: number) => U,
201
202
  ): Array<U> {
202
203
  const result: Array<U> = []
203
204
  let index = 0
@@ -213,7 +214,7 @@ export class CollectionStateManager<
213
214
  * @returns True if the given collection is this collection, false otherwise
214
215
  */
215
216
  private isThisCollection(
216
- collection: CollectionImpl<any, any, any, any, any>
217
+ collection: CollectionImpl<any, any, any, any, any>,
217
218
  ): boolean {
218
219
  return collection === this.collection
219
220
  }
@@ -222,7 +223,7 @@ export class CollectionStateManager<
222
223
  * Recompute optimistic state from active transactions
223
224
  */
224
225
  public recomputeOptimisticState(
225
- triggeredByUserAction: boolean = false
226
+ triggeredByUserAction: boolean = false,
226
227
  ): void {
227
228
  // Skip redundant recalculations when we're in the middle of committing sync transactions
228
229
  // While the sync pipeline is replaying a large batch we still want to honour
@@ -257,7 +258,7 @@ export class CollectionStateManager<
257
258
  case `update`:
258
259
  this.optimisticUpserts.set(
259
260
  mutation.key,
260
- mutation.modified as TOutput
261
+ mutation.modified as TOutput,
261
262
  )
262
263
  this.optimisticDeletes.delete(mutation.key)
263
264
  break
@@ -316,8 +317,8 @@ export class CollectionStateManager<
316
317
  // We can infer this by checking if we have no remaining optimistic mutations for this key
317
318
  const hasActiveOptimisticMutation = activeTransactions.some((tx) =>
318
319
  tx.mutations.some(
319
- (m) => this.isThisCollection(m.collection) && m.key === event.key
320
- )
320
+ (m) => this.isThisCollection(m.collection) && m.key === event.key,
321
+ ),
321
322
  )
322
323
 
323
324
  if (!hasActiveOptimisticMutation) {
@@ -348,10 +349,10 @@ export class CollectionStateManager<
348
349
  private calculateSize(): number {
349
350
  const syncedSize = this.syncedData.size
350
351
  const deletesFromSynced = Array.from(this.optimisticDeletes).filter(
351
- (key) => this.syncedData.has(key) && !this.optimisticUpserts.has(key)
352
+ (key) => this.syncedData.has(key) && !this.optimisticUpserts.has(key),
352
353
  ).length
353
354
  const upsertsNotInSynced = Array.from(this.optimisticUpserts.keys()).filter(
354
- (key) => !this.syncedData.has(key)
355
+ (key) => !this.syncedData.has(key),
355
356
  ).length
356
357
 
357
358
  return syncedSize - deletesFromSynced + upsertsNotInSynced
@@ -363,7 +364,7 @@ export class CollectionStateManager<
363
364
  private collectOptimisticChanges(
364
365
  previousUpserts: Map<TKey, TOutput>,
365
366
  previousDeletes: Set<TKey>,
366
- events: Array<ChangeMessage<TOutput, TKey>>
367
+ events: Array<ChangeMessage<TOutput, TKey>>,
367
368
  ): void {
368
369
  const allKeys = new Set([
369
370
  ...previousUpserts.keys(),
@@ -377,7 +378,7 @@ export class CollectionStateManager<
377
378
  const previousValue = this.getPreviousValue(
378
379
  key,
379
380
  previousUpserts,
380
- previousDeletes
381
+ previousDeletes,
381
382
  )
382
383
 
383
384
  if (previousValue !== undefined && currentValue === undefined) {
@@ -405,7 +406,7 @@ export class CollectionStateManager<
405
406
  private getPreviousValue(
406
407
  key: TKey,
407
408
  previousUpserts: Map<TKey, TOutput>,
408
- previousDeletes: Set<TKey>
409
+ previousDeletes: Set<TKey>,
409
410
  ): TOutput | undefined {
410
411
  if (previousDeletes.has(key)) {
411
412
  return undefined
@@ -456,7 +457,7 @@ export class CollectionStateManager<
456
457
  PendingSyncedTransaction<TOutput, TKey>
457
458
  >,
458
459
  hasTruncateSync: false,
459
- }
460
+ },
460
461
  )
461
462
 
462
463
  if (!hasPersistingTransaction || hasTruncateSync) {
@@ -528,6 +529,12 @@ export class CollectionStateManager<
528
529
  for (const key of changedKeys) {
529
530
  currentVisibleState.delete(key)
530
531
  }
532
+
533
+ // 4) Emit truncate event so subscriptions can reset their cursor tracking state
534
+ this._events.emit(`truncate`, {
535
+ type: `truncate`,
536
+ collection: this.collection,
537
+ })
531
538
  }
532
539
 
533
540
  for (const operation of transaction.operations) {
@@ -545,8 +552,8 @@ export class CollectionStateManager<
545
552
  Object.assign(
546
553
  {},
547
554
  this.syncedMetadata.get(key),
548
- operation.metadata
549
- )
555
+ operation.metadata,
556
+ ),
550
557
  )
551
558
  break
552
559
  case `delete`:
@@ -564,7 +571,7 @@ export class CollectionStateManager<
564
571
  const updatedValue = Object.assign(
565
572
  {},
566
573
  this.syncedData.get(key),
567
- operation.value
574
+ operation.value,
568
575
  )
569
576
  this.syncedData.set(key, updatedValue)
570
577
  } else {
@@ -597,10 +604,10 @@ export class CollectionStateManager<
597
604
  // Build re-apply sets from the snapshot taken at the start of this function.
598
605
  // This prevents losing optimistic state if transactions complete during truncate processing.
599
606
  const reapplyUpserts = new Map<TKey, TOutput>(
600
- truncateOptimisticSnapshot!.upserts
607
+ truncateOptimisticSnapshot!.upserts,
601
608
  )
602
609
  const reapplyDeletes = new Set<TKey>(
603
- truncateOptimisticSnapshot!.deletes
610
+ truncateOptimisticSnapshot!.deletes,
604
611
  )
605
612
 
606
613
  // Emit inserts for re-applied upserts, skipping any keys that have an optimistic delete.
@@ -679,7 +686,7 @@ export class CollectionStateManager<
679
686
  case `update`:
680
687
  this.optimisticUpserts.set(
681
688
  mutation.key,
682
- mutation.modified as TOutput
689
+ mutation.modified as TOutput,
683
690
  )
684
691
  this.optimisticDeletes.delete(mutation.key)
685
692
  break
@@ -1,13 +1,14 @@
1
- import { ensureIndexForExpression } from "../indexes/auto-index.js"
2
- import { and, eq, gt, gte, lt } from "../query/builder/functions.js"
3
- import { Value } from "../query/ir.js"
4
- import { EventEmitter } from "../event-emitter.js"
1
+ import { ensureIndexForExpression } from '../indexes/auto-index.js'
2
+ import { and, eq, gte, lt } from '../query/builder/functions.js'
3
+ import { Value } from '../query/ir.js'
4
+ import { EventEmitter } from '../event-emitter.js'
5
+ import { buildCursor } from '../utils/cursor.js'
5
6
  import {
6
7
  createFilterFunctionFromExpression,
7
8
  createFilteredCallback,
8
- } from "./change-events.js"
9
- import type { BasicExpression, OrderBy } from "../query/ir.js"
10
- import type { IndexInterface } from "../indexes/base-index.js"
9
+ } from './change-events.js'
10
+ import type { BasicExpression, OrderBy } from '../query/ir.js'
11
+ import type { IndexInterface } from '../indexes/base-index.js'
11
12
  import type {
12
13
  ChangeMessage,
13
14
  LoadSubsetOptions,
@@ -15,19 +16,26 @@ import type {
15
16
  SubscriptionEvents,
16
17
  SubscriptionStatus,
17
18
  SubscriptionUnsubscribedEvent,
18
- } from "../types.js"
19
- import type { CollectionImpl } from "./index.js"
19
+ } from '../types.js'
20
+ import type { CollectionImpl } from './index.js'
20
21
 
21
22
  type RequestSnapshotOptions = {
22
23
  where?: BasicExpression<boolean>
23
24
  optimizedOnly?: boolean
24
25
  trackLoadSubsetPromise?: boolean
26
+ /** Optional orderBy to pass to loadSubset for backend optimization */
27
+ orderBy?: OrderBy
28
+ /** Optional limit to pass to loadSubset for backend optimization */
29
+ limit?: number
25
30
  }
26
31
 
27
32
  type RequestLimitedSnapshotOptions = {
28
33
  orderBy: OrderBy
29
34
  limit: number
30
- minValue?: any
35
+ /** All column values for cursor (first value used for local index, all values for sync layer) */
36
+ minValues?: Array<unknown>
37
+ /** Row offset for offset-based pagination (passed to sync layer) */
38
+ offset?: number
31
39
  }
32
40
 
33
41
  type CollectionSubscriptionOptions = {
@@ -44,6 +52,11 @@ export class CollectionSubscription
44
52
  {
45
53
  private loadedInitialState = false
46
54
 
55
+ // Flag to skip filtering in filterAndFlipChanges.
56
+ // This is separate from loadedInitialState because we want to allow
57
+ // requestSnapshot to still work even when filtering is skipped.
58
+ private skipFiltering = false
59
+
47
60
  // Flag to indicate that we have sent at least 1 snapshot.
48
61
  // While `snapshotSent` is false we filter out all changes from subscription to the collection.
49
62
  private snapshotSent = false
@@ -57,6 +70,12 @@ export class CollectionSubscription
57
70
  // Keep track of the keys we've sent (needed for join and orderBy optimizations)
58
71
  private sentKeys = new Set<string | number>()
59
72
 
73
+ // Track the count of rows sent via requestLimitedSnapshot for offset-based pagination
74
+ private limitedSnapshotRowCount = 0
75
+
76
+ // Track the last key sent via requestLimitedSnapshot for cursor-based pagination
77
+ private lastSentKey: string | number | undefined
78
+
60
79
  private filteredCallback: (changes: Array<ChangeMessage<any, any>>) => void
61
80
 
62
81
  private orderByIndex: IndexInterface<string | number> | undefined
@@ -72,7 +91,7 @@ export class CollectionSubscription
72
91
  constructor(
73
92
  private collection: CollectionImpl<any, any, any, any, any>,
74
93
  private callback: (changes: Array<ChangeMessage<any, any>>) => void,
75
- private options: CollectionSubscriptionOptions
94
+ private options: CollectionSubscriptionOptions,
76
95
  ) {
77
96
  super()
78
97
  if (options.onUnsubscribe) {
@@ -85,7 +104,7 @@ export class CollectionSubscription
85
104
  }
86
105
 
87
106
  const callbackWithSentKeysTracking = (
88
- changes: Array<ChangeMessage<any, any>>
107
+ changes: Array<ChangeMessage<any, any>>,
89
108
  ) => {
90
109
  callback(changes)
91
110
  this.trackSentKeys(changes)
@@ -203,6 +222,9 @@ export class CollectionSubscription
203
222
  const loadOptions: LoadSubsetOptions = {
204
223
  where: stateOpts.where,
205
224
  subscription: this,
225
+ // Include orderBy and limit if provided so sync layer can optimize the query
226
+ orderBy: opts?.orderBy,
227
+ limit: opts?.limit,
206
228
  }
207
229
  const syncResult = this.collection._sync.loadSubset(loadOptions)
208
230
 
@@ -224,35 +246,53 @@ export class CollectionSubscription
224
246
 
225
247
  // Only send changes that have not been sent yet
226
248
  const filteredSnapshot = snapshot.filter(
227
- (change) => !this.sentKeys.has(change.key)
249
+ (change) => !this.sentKeys.has(change.key),
228
250
  )
229
251
 
252
+ // Add keys to sentKeys BEFORE calling callback to prevent race condition.
253
+ // If a change event arrives while the callback is executing, it will see
254
+ // the keys already in sentKeys and filter out duplicates correctly.
255
+ for (const change of filteredSnapshot) {
256
+ this.sentKeys.add(change.key)
257
+ }
258
+
230
259
  this.snapshotSent = true
231
260
  this.callback(filteredSnapshot)
232
261
  return true
233
262
  }
234
263
 
235
264
  /**
236
- * Sends a snapshot that fulfills the `where` clause and all rows are bigger or equal to `minValue`.
265
+ * Sends a snapshot that fulfills the `where` clause and all rows are bigger or equal to the cursor.
237
266
  * Requires a range index to be set with `setOrderByIndex` prior to calling this method.
238
267
  * It uses that range index to load the items in the order of the index.
239
- * Note 1: it may load more rows than the provided LIMIT because it loads all values equal to `minValue` + limit values greater than `minValue`.
268
+ *
269
+ * For multi-column orderBy:
270
+ * - Uses first value from `minValues` for LOCAL index operations (wide bounds, ensures no missed rows)
271
+ * - Uses all `minValues` to build a precise composite cursor for SYNC layer loadSubset
272
+ *
273
+ * Note 1: it may load more rows than the provided LIMIT because it loads all values equal to the first cursor value + limit values greater.
240
274
  * This is needed to ensure that it does not accidentally skip duplicate values when the limit falls in the middle of some duplicated values.
241
275
  * Note 2: it does not send keys that have already been sent before.
242
276
  */
243
277
  requestLimitedSnapshot({
244
278
  orderBy,
245
279
  limit,
246
- minValue,
280
+ minValues,
281
+ offset,
247
282
  }: RequestLimitedSnapshotOptions) {
248
283
  if (!limit) throw new Error(`limit is required`)
249
284
 
250
285
  if (!this.orderByIndex) {
251
286
  throw new Error(
252
- `Ordered snapshot was requested but no index was found. You have to call setOrderByIndex before requesting an ordered snapshot.`
287
+ `Ordered snapshot was requested but no index was found. You have to call setOrderByIndex before requesting an ordered snapshot.`,
253
288
  )
254
289
  }
255
290
 
291
+ // Derive first column value from minValues (used for local index operations)
292
+ const minValue = minValues?.[0]
293
+ // Cast for index operations (index expects string | number)
294
+ const minValueForIndex = minValue as string | number | undefined
295
+
256
296
  const index = this.orderByIndex
257
297
  const where = this.options.whereExpression
258
298
  const whereFilterFn = where
@@ -272,7 +312,7 @@ export class CollectionSubscription
272
312
  return whereFilterFn?.(value) ?? true
273
313
  }
274
314
 
275
- let biggestObservedValue = minValue
315
+ let biggestObservedValue = minValueForIndex
276
316
  const changes: Array<ChangeMessage<any, string | number>> = []
277
317
 
278
318
  // If we have a minValue we need to handle the case
@@ -281,12 +321,16 @@ export class CollectionSubscription
281
321
  // so if minValue is 3 then the previous snapshot may not have included all 3s
282
322
  // e.g. if it was offset 0 and limit 3 it would only have loaded the first 3
283
323
  // so we load all rows equal to minValue first, to be sure we don't skip any duplicate values
324
+ //
325
+ // For multi-column orderBy, we use the first column value for index operations (wide bounds)
326
+ // This may load some duplicates but ensures we never miss any rows.
284
327
  let keys: Array<string | number> = []
285
- if (minValue !== undefined) {
286
- // First, get all items with the same value as minValue
328
+ if (minValueForIndex !== undefined) {
329
+ // First, get all items with the same FIRST COLUMN value as minValue
330
+ // This provides wide bounds for the local index
287
331
  const { expression } = orderBy[0]!
288
332
  const allRowsWithMinValue = this.collection.currentStateAsChanges({
289
- where: eq(expression, new Value(minValue)),
333
+ where: eq(expression, new Value(minValueForIndex)),
290
334
  })
291
335
 
292
336
  if (allRowsWithMinValue) {
@@ -300,15 +344,15 @@ export class CollectionSubscription
300
344
  // Then get items greater than minValue
301
345
  const keysGreaterThanMin = index.take(
302
346
  limit - keys.length,
303
- minValue,
304
- filterFn
347
+ minValueForIndex,
348
+ filterFn,
305
349
  )
306
350
  keys.push(...keysGreaterThanMin)
307
351
  } else {
308
- keys = index.take(limit, minValue, filterFn)
352
+ keys = index.take(limit, minValueForIndex, filterFn)
309
353
  }
310
354
  } else {
311
- keys = index.take(limit, minValue, filterFn)
355
+ keys = index.take(limit, minValueForIndex, filterFn)
312
356
  }
313
357
 
314
358
  const valuesNeeded = () => Math.max(limit - changes.length, 0)
@@ -331,76 +375,82 @@ export class CollectionSubscription
331
375
  keys = index.take(valuesNeeded(), biggestObservedValue, filterFn)
332
376
  }
333
377
 
378
+ // Track row count for offset-based pagination (before sending to callback)
379
+ // Use the current count as the offset for this load
380
+ const currentOffset = this.limitedSnapshotRowCount
381
+
382
+ // Add keys to sentKeys BEFORE calling callback to prevent race condition.
383
+ // If a change event arrives while the callback is executing, it will see
384
+ // the keys already in sentKeys and filter out duplicates correctly.
385
+ for (const change of changes) {
386
+ this.sentKeys.add(change.key)
387
+ }
388
+
334
389
  this.callback(changes)
335
390
 
336
- let whereWithValueFilter = where
337
- if (typeof minValue !== `undefined`) {
338
- // Only request data that we haven't seen yet (i.e. is bigger than the minValue)
339
- const { expression, compareOptions } = orderBy[0]!
340
- const operator = compareOptions.direction === `asc` ? gt : lt
341
- const valueFilter = operator(expression, new Value(minValue))
342
- whereWithValueFilter = where ? and(where, valueFilter) : valueFilter
391
+ // Update the row count and last key after sending (for next call's offset/cursor)
392
+ this.limitedSnapshotRowCount += changes.length
393
+ if (changes.length > 0) {
394
+ this.lastSentKey = changes[changes.length - 1]!.key
395
+ }
396
+
397
+ // Build cursor expressions for sync layer loadSubset
398
+ // The cursor expressions are separate from the main where clause
399
+ // so the sync layer can choose cursor-based or offset-based pagination
400
+ let cursorExpressions:
401
+ | {
402
+ whereFrom: BasicExpression<boolean>
403
+ whereCurrent: BasicExpression<boolean>
404
+ lastKey?: string | number
405
+ }
406
+ | undefined
407
+
408
+ if (minValues !== undefined && minValues.length > 0) {
409
+ const whereFromCursor = buildCursor(orderBy, minValues)
410
+
411
+ if (whereFromCursor) {
412
+ const { expression } = orderBy[0]!
413
+ const minValue = minValues[0]
414
+
415
+ // Build the whereCurrent expression for the first orderBy column
416
+ // For Date values, we need to handle precision differences between JS (ms) and backends (μs)
417
+ // A JS Date represents a 1ms range, so we query for all values within that range
418
+ let whereCurrentCursor: BasicExpression<boolean>
419
+ if (minValue instanceof Date) {
420
+ const minValuePlus1ms = new Date(minValue.getTime() + 1)
421
+ whereCurrentCursor = and(
422
+ gte(expression, new Value(minValue)),
423
+ lt(expression, new Value(minValuePlus1ms)),
424
+ )
425
+ } else {
426
+ whereCurrentCursor = eq(expression, new Value(minValue))
427
+ }
428
+
429
+ cursorExpressions = {
430
+ whereFrom: whereFromCursor,
431
+ whereCurrent: whereCurrentCursor,
432
+ lastKey: this.lastSentKey,
433
+ }
434
+ }
343
435
  }
344
436
 
345
437
  // Request the sync layer to load more data
346
438
  // don't await it, we will load the data into the collection when it comes in
347
- const loadOptions1: LoadSubsetOptions = {
348
- where: whereWithValueFilter,
439
+ // Note: `where` does NOT include cursor expressions - they are passed separately
440
+ // The sync layer can choose to use cursor-based or offset-based pagination
441
+ const loadOptions: LoadSubsetOptions = {
442
+ where, // Main filter only, no cursor
349
443
  limit,
350
444
  orderBy,
445
+ cursor: cursorExpressions, // Cursor expressions passed separately
446
+ offset: offset ?? currentOffset, // Use provided offset, or auto-tracked offset
351
447
  subscription: this,
352
448
  }
353
- const syncResult = this.collection._sync.loadSubset(loadOptions1)
449
+ const syncResult = this.collection._sync.loadSubset(loadOptions)
354
450
 
355
451
  // Track this loadSubset call
356
- this.loadedSubsets.push(loadOptions1)
357
-
358
- // Make parallel loadSubset calls for values equal to minValue and values greater than minValue
359
- const promises: Array<Promise<void>> = []
360
-
361
- // First promise: load all values equal to minValue
362
- if (typeof minValue !== `undefined`) {
363
- const { expression } = orderBy[0]!
364
-
365
- // For Date values, we need to handle precision differences between JS (ms) and backends (μs)
366
- // A JS Date represents a 1ms range, so we query for all values within that range
367
- let exactValueFilter
368
- if (minValue instanceof Date) {
369
- const minValuePlus1ms = new Date(minValue.getTime() + 1)
370
- exactValueFilter = and(
371
- gte(expression, new Value(minValue)),
372
- lt(expression, new Value(minValuePlus1ms))
373
- )
374
- } else {
375
- exactValueFilter = eq(expression, new Value(minValue))
376
- }
377
-
378
- const loadOptions2: LoadSubsetOptions = {
379
- where: exactValueFilter,
380
- subscription: this,
381
- }
382
- const equalValueResult = this.collection._sync.loadSubset(loadOptions2)
383
-
384
- // Track this loadSubset call
385
- this.loadedSubsets.push(loadOptions2)
386
-
387
- if (equalValueResult instanceof Promise) {
388
- promises.push(equalValueResult)
389
- }
390
- }
391
-
392
- // Second promise: load values greater than minValue
393
- if (syncResult instanceof Promise) {
394
- promises.push(syncResult)
395
- }
396
-
397
- // Track the combined promise
398
- if (promises.length > 0) {
399
- const combinedPromise = Promise.all(promises).then(() => {})
400
- this.trackLoadSubsetPromise(combinedPromise)
401
- } else {
402
- this.trackLoadSubsetPromise(syncResult)
403
- }
452
+ this.loadedSubsets.push(loadOptions)
453
+ this.trackLoadSubsetPromise(syncResult)
404
454
  }
405
455
 
406
456
  // TODO: also add similar test but that checks that it can also load it from the collection's loadSubset function
@@ -410,10 +460,11 @@ export class CollectionSubscription
410
460
  * Filters and flips changes for keys that have not been sent yet.
411
461
  * Deletes are filtered out for keys that have not been sent yet.
412
462
  * Updates are flipped into inserts for keys that have not been sent yet.
463
+ * Duplicate inserts are filtered out to prevent D2 multiplicity > 1.
413
464
  */
414
465
  private filterAndFlipChanges(changes: Array<ChangeMessage<any, any>>) {
415
- if (this.loadedInitialState) {
416
- // We loaded the entire initial state
466
+ if (this.loadedInitialState || this.skipFiltering) {
467
+ // We loaded the entire initial state or filtering is explicitly skipped
417
468
  // so no need to filter or flip changes
418
469
  return changes
419
470
  }
@@ -421,7 +472,9 @@ export class CollectionSubscription
421
472
  const newChanges = []
422
473
  for (const change of changes) {
423
474
  let newChange = change
424
- if (!this.sentKeys.has(change.key)) {
475
+ const keyInSentKeys = this.sentKeys.has(change.key)
476
+
477
+ if (!keyInSentKeys) {
425
478
  if (change.type === `update`) {
426
479
  newChange = { ...change, type: `insert`, previousValue: undefined }
427
480
  } else if (change.type === `delete`) {
@@ -429,6 +482,19 @@ export class CollectionSubscription
429
482
  continue
430
483
  }
431
484
  this.sentKeys.add(change.key)
485
+ } else {
486
+ // Key was already sent - handle based on change type
487
+ if (change.type === `insert`) {
488
+ // Filter out duplicate inserts - the key was already inserted.
489
+ // This prevents D2 multiplicity from going above 1, which would
490
+ // cause deletes to not properly remove items (multiplicity would
491
+ // go from 2 to 1 instead of 1 to 0).
492
+ continue
493
+ } else if (change.type === `delete`) {
494
+ // Remove from sentKeys so future inserts for this key are allowed
495
+ // (e.g., after truncate + reinsert)
496
+ this.sentKeys.delete(change.key)
497
+ }
432
498
  }
433
499
  newChanges.push(newChange)
434
500
  }
@@ -436,17 +502,32 @@ export class CollectionSubscription
436
502
  }
437
503
 
438
504
  private trackSentKeys(changes: Array<ChangeMessage<any, string | number>>) {
439
- if (this.loadedInitialState) {
440
- // No need to track sent keys if we loaded the entire state.
441
- // Since we sent everything, all keys must have been observed.
505
+ if (this.loadedInitialState || this.skipFiltering) {
506
+ // No need to track sent keys if we loaded the entire state or filtering is skipped.
507
+ // Since filtering won't be applied, all keys are effectively "observed".
442
508
  return
443
509
  }
444
510
 
445
511
  for (const change of changes) {
446
- this.sentKeys.add(change.key)
512
+ if (change.type === `delete`) {
513
+ // Remove deleted keys from sentKeys so future re-inserts are allowed
514
+ this.sentKeys.delete(change.key)
515
+ } else {
516
+ // For inserts and updates, track the key as sent
517
+ this.sentKeys.add(change.key)
518
+ }
447
519
  }
448
520
  }
449
521
 
522
+ /**
523
+ * Mark that the subscription should not filter any changes.
524
+ * This is used when includeInitialState is explicitly set to false,
525
+ * meaning the caller doesn't want initial state but does want ALL future changes.
526
+ */
527
+ markAllStateAsSeen() {
528
+ this.skipFiltering = true
529
+ }
530
+
450
531
  unsubscribe() {
451
532
  // Unload all subsets that this subscription loaded
452
533
  // We pass the exact same LoadSubsetOptions we used for loadSubset