@tanstack/db 0.4.0 → 0.4.2

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 (67) hide show
  1. package/dist/cjs/collection/events.cjs +2 -1
  2. package/dist/cjs/collection/events.cjs.map +1 -1
  3. package/dist/cjs/collection/lifecycle.cjs +69 -17
  4. package/dist/cjs/collection/lifecycle.cjs.map +1 -1
  5. package/dist/cjs/collection/lifecycle.d.cts +12 -1
  6. package/dist/cjs/collection/state.cjs +1 -1
  7. package/dist/cjs/collection/state.cjs.map +1 -1
  8. package/dist/cjs/indexes/btree-index.cjs +19 -13
  9. package/dist/cjs/indexes/btree-index.cjs.map +1 -1
  10. package/dist/cjs/query/builder/functions.cjs.map +1 -1
  11. package/dist/cjs/query/builder/functions.d.cts +1 -1
  12. package/dist/cjs/query/compiler/evaluators.cjs +3 -2
  13. package/dist/cjs/query/compiler/evaluators.cjs.map +1 -1
  14. package/dist/cjs/query/compiler/group-by.cjs +6 -2
  15. package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
  16. package/dist/cjs/query/compiler/joins.cjs +2 -1
  17. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  18. package/dist/cjs/query/optimizer.cjs +8 -3
  19. package/dist/cjs/query/optimizer.cjs.map +1 -1
  20. package/dist/cjs/query/optimizer.d.cts +2 -0
  21. package/dist/cjs/types.d.cts +8 -2
  22. package/dist/cjs/utils/browser-polyfills.cjs +22 -0
  23. package/dist/cjs/utils/browser-polyfills.cjs.map +1 -0
  24. package/dist/cjs/utils/browser-polyfills.d.cts +9 -0
  25. package/dist/cjs/utils/comparison.cjs +7 -0
  26. package/dist/cjs/utils/comparison.cjs.map +1 -1
  27. package/dist/cjs/utils/comparison.d.cts +4 -0
  28. package/dist/esm/collection/events.js +2 -1
  29. package/dist/esm/collection/events.js.map +1 -1
  30. package/dist/esm/collection/lifecycle.d.ts +12 -1
  31. package/dist/esm/collection/lifecycle.js +70 -18
  32. package/dist/esm/collection/lifecycle.js.map +1 -1
  33. package/dist/esm/collection/state.js +1 -1
  34. package/dist/esm/collection/state.js.map +1 -1
  35. package/dist/esm/indexes/btree-index.js +20 -14
  36. package/dist/esm/indexes/btree-index.js.map +1 -1
  37. package/dist/esm/query/builder/functions.d.ts +1 -1
  38. package/dist/esm/query/builder/functions.js.map +1 -1
  39. package/dist/esm/query/compiler/evaluators.js +3 -2
  40. package/dist/esm/query/compiler/evaluators.js.map +1 -1
  41. package/dist/esm/query/compiler/group-by.js +6 -2
  42. package/dist/esm/query/compiler/group-by.js.map +1 -1
  43. package/dist/esm/query/compiler/joins.js +2 -1
  44. package/dist/esm/query/compiler/joins.js.map +1 -1
  45. package/dist/esm/query/optimizer.d.ts +2 -0
  46. package/dist/esm/query/optimizer.js +8 -3
  47. package/dist/esm/query/optimizer.js.map +1 -1
  48. package/dist/esm/types.d.ts +8 -2
  49. package/dist/esm/utils/browser-polyfills.d.ts +9 -0
  50. package/dist/esm/utils/browser-polyfills.js +22 -0
  51. package/dist/esm/utils/browser-polyfills.js.map +1 -0
  52. package/dist/esm/utils/comparison.d.ts +4 -0
  53. package/dist/esm/utils/comparison.js +8 -1
  54. package/dist/esm/utils/comparison.js.map +1 -1
  55. package/package.json +2 -2
  56. package/src/collection/events.ts +1 -1
  57. package/src/collection/lifecycle.ts +105 -25
  58. package/src/collection/state.ts +1 -1
  59. package/src/indexes/btree-index.ts +24 -14
  60. package/src/query/builder/functions.ts +3 -3
  61. package/src/query/compiler/evaluators.ts +3 -2
  62. package/src/query/compiler/group-by.ts +15 -2
  63. package/src/query/compiler/joins.ts +6 -1
  64. package/src/query/optimizer.ts +28 -6
  65. package/src/types.ts +8 -2
  66. package/src/utils/browser-polyfills.ts +39 -0
  67. package/src/utils/comparison.ts +10 -0
@@ -1 +1 @@
1
- {"version":3,"file":"comparison.js","sources":["../../../src/utils/comparison.ts"],"sourcesContent":["import type { CompareOptions } from \"../query/builder/types\"\n\n// WeakMap to store stable IDs for objects\nconst objectIds = new WeakMap<object, number>()\nlet nextObjectId = 1\n\n/**\n * Get or create a stable ID for an object\n */\nfunction getObjectId(obj: object): number {\n if (objectIds.has(obj)) {\n return objectIds.get(obj)!\n }\n const id = nextObjectId++\n objectIds.set(obj, id)\n return id\n}\n\n/**\n * Universal comparison function for all data types\n * Handles null/undefined, strings, arrays, dates, objects, and primitives\n * Always sorts null/undefined values first\n */\nexport const ascComparator = (a: any, b: any, opts: CompareOptions): number => {\n const { nulls } = opts\n\n // Handle null/undefined\n if (a == null && b == null) return 0\n if (a == null) return nulls === `first` ? -1 : 1\n if (b == null) return nulls === `first` ? 1 : -1\n\n // if a and b are both strings, compare them based on locale\n if (typeof a === `string` && typeof b === `string`) {\n if (opts.stringSort === `locale`) {\n return a.localeCompare(b, opts.locale, opts.localeOptions)\n }\n // For lexical sort we rely on direct comparison for primitive values\n }\n\n // if a and b are both arrays, compare them element by element\n if (Array.isArray(a) && Array.isArray(b)) {\n for (let i = 0; i < Math.min(a.length, b.length); i++) {\n const result = ascComparator(a[i], b[i], opts)\n if (result !== 0) {\n return result\n }\n }\n // All elements are equal up to the minimum length\n return a.length - b.length\n }\n\n // If both are dates, compare them\n if (a instanceof Date && b instanceof Date) {\n return a.getTime() - b.getTime()\n }\n\n // If at least one of the values is an object, use stable IDs for comparison\n const aIsObject = typeof a === `object`\n const bIsObject = typeof b === `object`\n\n if (aIsObject || bIsObject) {\n // If both are objects, compare their stable IDs\n if (aIsObject && bIsObject) {\n const aId = getObjectId(a)\n const bId = getObjectId(b)\n return aId - bId\n }\n\n // If only one is an object, objects come after primitives\n if (aIsObject) return 1\n if (bIsObject) return -1\n }\n\n // For primitive values, use direct comparison\n if (a < b) return -1\n if (a > b) return 1\n return 0\n}\n\n/**\n * Descending comparator function for ordering values\n * Handles null/undefined as largest values (opposite of ascending)\n */\nexport const descComparator = (\n a: unknown,\n b: unknown,\n opts: CompareOptions\n): number => {\n return ascComparator(b, a, {\n ...opts,\n nulls: opts.nulls === `first` ? `last` : `first`,\n })\n}\n\nexport function makeComparator(\n opts: CompareOptions\n): (a: any, b: any) => number {\n return (a, b) => {\n if (opts.direction === `asc`) {\n return ascComparator(a, b, opts)\n } else {\n return descComparator(a, b, opts)\n }\n }\n}\n\n/** Default comparator orders values in ascending order with nulls first and locale string comparison. */\nexport const defaultComparator = makeComparator({\n direction: `asc`,\n nulls: `first`,\n stringSort: `locale`,\n})\n"],"names":[],"mappings":"AAGA,MAAM,gCAAgB,QAAA;AACtB,IAAI,eAAe;AAKnB,SAAS,YAAY,KAAqB;AACxC,MAAI,UAAU,IAAI,GAAG,GAAG;AACtB,WAAO,UAAU,IAAI,GAAG;AAAA,EAC1B;AACA,QAAM,KAAK;AACX,YAAU,IAAI,KAAK,EAAE;AACrB,SAAO;AACT;AAOO,MAAM,gBAAgB,CAAC,GAAQ,GAAQ,SAAiC;AAC7E,QAAM,EAAE,UAAU;AAGlB,MAAI,KAAK,QAAQ,KAAK,KAAM,QAAO;AACnC,MAAI,KAAK,KAAM,QAAO,UAAU,UAAU,KAAK;AAC/C,MAAI,KAAK,KAAM,QAAO,UAAU,UAAU,IAAI;AAG9C,MAAI,OAAO,MAAM,YAAY,OAAO,MAAM,UAAU;AAClD,QAAI,KAAK,eAAe,UAAU;AAChC,aAAO,EAAE,cAAc,GAAG,KAAK,QAAQ,KAAK,aAAa;AAAA,IAC3D;AAAA,EAEF;AAGA,MAAI,MAAM,QAAQ,CAAC,KAAK,MAAM,QAAQ,CAAC,GAAG;AACxC,aAAS,IAAI,GAAG,IAAI,KAAK,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,KAAK;AACrD,YAAM,SAAS,cAAc,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI;AAC7C,UAAI,WAAW,GAAG;AAChB,eAAO;AAAA,MACT;AAAA,IACF;AAEA,WAAO,EAAE,SAAS,EAAE;AAAA,EACtB;AAGA,MAAI,aAAa,QAAQ,aAAa,MAAM;AAC1C,WAAO,EAAE,YAAY,EAAE,QAAA;AAAA,EACzB;AAGA,QAAM,YAAY,OAAO,MAAM;AAC/B,QAAM,YAAY,OAAO,MAAM;AAE/B,MAAI,aAAa,WAAW;AAE1B,QAAI,aAAa,WAAW;AAC1B,YAAM,MAAM,YAAY,CAAC;AACzB,YAAM,MAAM,YAAY,CAAC;AACzB,aAAO,MAAM;AAAA,IACf;AAGA,QAAI,UAAW,QAAO;AACtB,QAAI,UAAW,QAAO;AAAA,EACxB;AAGA,MAAI,IAAI,EAAG,QAAO;AAClB,MAAI,IAAI,EAAG,QAAO;AAClB,SAAO;AACT;AAMO,MAAM,iBAAiB,CAC5B,GACA,GACA,SACW;AACX,SAAO,cAAc,GAAG,GAAG;AAAA,IACzB,GAAG;AAAA,IACH,OAAO,KAAK,UAAU,UAAU,SAAS;AAAA,EAAA,CAC1C;AACH;AAEO,SAAS,eACd,MAC4B;AAC5B,SAAO,CAAC,GAAG,MAAM;AACf,QAAI,KAAK,cAAc,OAAO;AAC5B,aAAO,cAAc,GAAG,GAAG,IAAI;AAAA,IACjC,OAAO;AACL,aAAO,eAAe,GAAG,GAAG,IAAI;AAAA,IAClC;AAAA,EACF;AACF;AAGO,MAAM,oBAAoB,eAAe;AAAA,EAC9C,WAAW;AAAA,EACX,OAAO;AAAA,EACP,YAAY;AACd,CAAC;"}
1
+ {"version":3,"file":"comparison.js","sources":["../../../src/utils/comparison.ts"],"sourcesContent":["import type { CompareOptions } from \"../query/builder/types\"\n\n// WeakMap to store stable IDs for objects\nconst objectIds = new WeakMap<object, number>()\nlet nextObjectId = 1\n\n/**\n * Get or create a stable ID for an object\n */\nfunction getObjectId(obj: object): number {\n if (objectIds.has(obj)) {\n return objectIds.get(obj)!\n }\n const id = nextObjectId++\n objectIds.set(obj, id)\n return id\n}\n\n/**\n * Universal comparison function for all data types\n * Handles null/undefined, strings, arrays, dates, objects, and primitives\n * Always sorts null/undefined values first\n */\nexport const ascComparator = (a: any, b: any, opts: CompareOptions): number => {\n const { nulls } = opts\n\n // Handle null/undefined\n if (a == null && b == null) return 0\n if (a == null) return nulls === `first` ? -1 : 1\n if (b == null) return nulls === `first` ? 1 : -1\n\n // if a and b are both strings, compare them based on locale\n if (typeof a === `string` && typeof b === `string`) {\n if (opts.stringSort === `locale`) {\n return a.localeCompare(b, opts.locale, opts.localeOptions)\n }\n // For lexical sort we rely on direct comparison for primitive values\n }\n\n // if a and b are both arrays, compare them element by element\n if (Array.isArray(a) && Array.isArray(b)) {\n for (let i = 0; i < Math.min(a.length, b.length); i++) {\n const result = ascComparator(a[i], b[i], opts)\n if (result !== 0) {\n return result\n }\n }\n // All elements are equal up to the minimum length\n return a.length - b.length\n }\n\n // If both are dates, compare them\n if (a instanceof Date && b instanceof Date) {\n return a.getTime() - b.getTime()\n }\n\n // If at least one of the values is an object, use stable IDs for comparison\n const aIsObject = typeof a === `object`\n const bIsObject = typeof b === `object`\n\n if (aIsObject || bIsObject) {\n // If both are objects, compare their stable IDs\n if (aIsObject && bIsObject) {\n const aId = getObjectId(a)\n const bId = getObjectId(b)\n return aId - bId\n }\n\n // If only one is an object, objects come after primitives\n if (aIsObject) return 1\n if (bIsObject) return -1\n }\n\n // For primitive values, use direct comparison\n if (a < b) return -1\n if (a > b) return 1\n return 0\n}\n\n/**\n * Descending comparator function for ordering values\n * Handles null/undefined as largest values (opposite of ascending)\n */\nexport const descComparator = (\n a: unknown,\n b: unknown,\n opts: CompareOptions\n): number => {\n return ascComparator(b, a, {\n ...opts,\n nulls: opts.nulls === `first` ? `last` : `first`,\n })\n}\n\nexport function makeComparator(\n opts: CompareOptions\n): (a: any, b: any) => number {\n return (a, b) => {\n if (opts.direction === `asc`) {\n return ascComparator(a, b, opts)\n } else {\n return descComparator(a, b, opts)\n }\n }\n}\n\n/** Default comparator orders values in ascending order with nulls first and locale string comparison. */\nexport const defaultComparator = makeComparator({\n direction: `asc`,\n nulls: `first`,\n stringSort: `locale`,\n})\n\n/**\n * Normalize a value for comparison\n */\nexport function normalizeValue(value: any): any {\n if (value instanceof Date) {\n return value.getTime()\n }\n return value\n}\n"],"names":[],"mappings":"AAGA,MAAM,gCAAgB,QAAA;AACtB,IAAI,eAAe;AAKnB,SAAS,YAAY,KAAqB;AACxC,MAAI,UAAU,IAAI,GAAG,GAAG;AACtB,WAAO,UAAU,IAAI,GAAG;AAAA,EAC1B;AACA,QAAM,KAAK;AACX,YAAU,IAAI,KAAK,EAAE;AACrB,SAAO;AACT;AAOO,MAAM,gBAAgB,CAAC,GAAQ,GAAQ,SAAiC;AAC7E,QAAM,EAAE,UAAU;AAGlB,MAAI,KAAK,QAAQ,KAAK,KAAM,QAAO;AACnC,MAAI,KAAK,KAAM,QAAO,UAAU,UAAU,KAAK;AAC/C,MAAI,KAAK,KAAM,QAAO,UAAU,UAAU,IAAI;AAG9C,MAAI,OAAO,MAAM,YAAY,OAAO,MAAM,UAAU;AAClD,QAAI,KAAK,eAAe,UAAU;AAChC,aAAO,EAAE,cAAc,GAAG,KAAK,QAAQ,KAAK,aAAa;AAAA,IAC3D;AAAA,EAEF;AAGA,MAAI,MAAM,QAAQ,CAAC,KAAK,MAAM,QAAQ,CAAC,GAAG;AACxC,aAAS,IAAI,GAAG,IAAI,KAAK,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,KAAK;AACrD,YAAM,SAAS,cAAc,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI;AAC7C,UAAI,WAAW,GAAG;AAChB,eAAO;AAAA,MACT;AAAA,IACF;AAEA,WAAO,EAAE,SAAS,EAAE;AAAA,EACtB;AAGA,MAAI,aAAa,QAAQ,aAAa,MAAM;AAC1C,WAAO,EAAE,YAAY,EAAE,QAAA;AAAA,EACzB;AAGA,QAAM,YAAY,OAAO,MAAM;AAC/B,QAAM,YAAY,OAAO,MAAM;AAE/B,MAAI,aAAa,WAAW;AAE1B,QAAI,aAAa,WAAW;AAC1B,YAAM,MAAM,YAAY,CAAC;AACzB,YAAM,MAAM,YAAY,CAAC;AACzB,aAAO,MAAM;AAAA,IACf;AAGA,QAAI,UAAW,QAAO;AACtB,QAAI,UAAW,QAAO;AAAA,EACxB;AAGA,MAAI,IAAI,EAAG,QAAO;AAClB,MAAI,IAAI,EAAG,QAAO;AAClB,SAAO;AACT;AAMO,MAAM,iBAAiB,CAC5B,GACA,GACA,SACW;AACX,SAAO,cAAc,GAAG,GAAG;AAAA,IACzB,GAAG;AAAA,IACH,OAAO,KAAK,UAAU,UAAU,SAAS;AAAA,EAAA,CAC1C;AACH;AAEO,SAAS,eACd,MAC4B;AAC5B,SAAO,CAAC,GAAG,MAAM;AACf,QAAI,KAAK,cAAc,OAAO;AAC5B,aAAO,cAAc,GAAG,GAAG,IAAI;AAAA,IACjC,OAAO;AACL,aAAO,eAAe,GAAG,GAAG,IAAI;AAAA,IAClC;AAAA,EACF;AACF;AAGO,MAAM,oBAAoB,eAAe;AAAA,EAC9C,WAAW;AAAA,EACX,OAAO;AAAA,EACP,YAAY;AACd,CAAC;AAKM,SAAS,eAAe,OAAiB;AAC9C,MAAI,iBAAiB,MAAM;AACzB,WAAO,MAAM,QAAA;AAAA,EACf;AACA,SAAO;AACT;"}
package/package.json CHANGED
@@ -1,10 +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.0",
4
+ "version": "0.4.2",
5
5
  "dependencies": {
6
6
  "@standard-schema/spec": "^1.0.0",
7
- "@tanstack/db-ivm": "0.1.8"
7
+ "@tanstack/db-ivm": "0.1.9"
8
8
  },
9
9
  "devDependencies": {
10
10
  "@vitest/coverage-istanbul": "^3.2.4",
@@ -70,7 +70,7 @@ export class CollectionEventsManager {
70
70
  this.listeners.get(event)!.add(callback)
71
71
 
72
72
  return () => {
73
- this.listeners.get(event)!.delete(callback)
73
+ this.listeners.get(event)?.delete(callback)
74
74
  }
75
75
  }
76
76
 
@@ -1,7 +1,13 @@
1
1
  import {
2
2
  CollectionInErrorStateError,
3
+ CollectionStateError,
3
4
  InvalidCollectionStatusTransitionError,
4
5
  } from "../errors"
6
+ import {
7
+ safeCancelIdleCallback,
8
+ safeRequestIdleCallback,
9
+ } from "../utils/browser-polyfills"
10
+ import type { IdleCallbackDeadline } from "../utils/browser-polyfills"
5
11
  import type { StandardSchemaV1 } from "@standard-schema/spec"
6
12
  import type { CollectionConfig, CollectionStatus } from "../types"
7
13
  import type { CollectionEventsManager } from "./events"
@@ -29,6 +35,7 @@ export class CollectionLifecycleManager<
29
35
  public hasReceivedFirstCommit = false
30
36
  public onFirstReadyCallbacks: Array<() => void> = []
31
37
  public gcTimeoutId: ReturnType<typeof setTimeout> | null = null
38
+ private idleCallbackId: number | null = null
32
39
 
33
40
  /**
34
41
  * Creates a new CollectionLifecycleManager instance
@@ -84,7 +91,18 @@ export class CollectionLifecycleManager<
84
91
  * Safely update the collection status with validation
85
92
  * @private
86
93
  */
87
- public setStatus(newStatus: CollectionStatus): void {
94
+ public setStatus(
95
+ newStatus: CollectionStatus,
96
+ allowReady: boolean = false
97
+ ): void {
98
+ if (newStatus === `ready` && !allowReady) {
99
+ // setStatus('ready') is an internal method that should not be called directly
100
+ // Instead, use markReady to transition to ready triggering the necessary events
101
+ // and side effects.
102
+ throw new CollectionStateError(
103
+ `You can't directly call "setStatus('ready'). You must use markReady instead.`
104
+ )
105
+ }
88
106
  this.validateStatusTransition(this.status, newStatus)
89
107
  const previousStatus = this.status
90
108
  this.status = newStatus
@@ -123,9 +141,10 @@ export class CollectionLifecycleManager<
123
141
  * @private - Should only be called by sync implementations
124
142
  */
125
143
  public markReady(): void {
144
+ this.validateStatusTransition(this.status, `ready`)
126
145
  // Can transition to ready from loading or initialCommit states
127
146
  if (this.status === `loading` || this.status === `initialCommit`) {
128
- this.setStatus(`ready`)
147
+ this.setStatus(`ready`, true)
129
148
 
130
149
  // Call any registered first ready callbacks (only on first time becoming ready)
131
150
  if (!this.hasBeenReady) {
@@ -140,12 +159,11 @@ export class CollectionLifecycleManager<
140
159
  this.onFirstReadyCallbacks = []
141
160
  callbacks.forEach((callback) => callback())
142
161
  }
143
- }
144
-
145
- // Always notify dependents when markReady is called, after status is set
146
- // This ensures live queries get notified when their dependencies become ready
147
- if (this.changes.changeSubscriptions.size > 0) {
148
- this.changes.emitEmptyReadyEvent()
162
+ // Notify dependents when markReady is called, after status is set
163
+ // This ensures live queries get notified when their dependencies become ready
164
+ if (this.changes.changeSubscriptions.size > 0) {
165
+ this.changes.emitEmptyReadyEvent()
166
+ }
149
167
  }
150
168
  }
151
169
 
@@ -167,9 +185,8 @@ export class CollectionLifecycleManager<
167
185
 
168
186
  this.gcTimeoutId = setTimeout(() => {
169
187
  if (this.changes.activeSubscribersCount === 0) {
170
- // We call the main collection cleanup, not just the one for the
171
- // lifecycle manager
172
- this.cleanup()
188
+ // Schedule cleanup during idle time to avoid blocking the UI thread
189
+ this.scheduleIdleCleanup()
173
190
  }
174
191
  }, gcTime)
175
192
  }
@@ -183,6 +200,77 @@ export class CollectionLifecycleManager<
183
200
  clearTimeout(this.gcTimeoutId)
184
201
  this.gcTimeoutId = null
185
202
  }
203
+ // Also cancel any pending idle cleanup
204
+ if (this.idleCallbackId !== null) {
205
+ safeCancelIdleCallback(this.idleCallbackId)
206
+ this.idleCallbackId = null
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Schedule cleanup to run during browser idle time
212
+ * This prevents blocking the UI thread during cleanup operations
213
+ */
214
+ private scheduleIdleCleanup(): void {
215
+ // Cancel any existing idle callback
216
+ if (this.idleCallbackId !== null) {
217
+ safeCancelIdleCallback(this.idleCallbackId)
218
+ }
219
+
220
+ // Schedule cleanup with a timeout of 1 second
221
+ // This ensures cleanup happens even if the browser is busy
222
+ this.idleCallbackId = safeRequestIdleCallback(
223
+ (deadline) => {
224
+ // Perform cleanup if we still have no subscribers
225
+ if (this.changes.activeSubscribersCount === 0) {
226
+ const cleanupCompleted = this.performCleanup(deadline)
227
+ // Only clear the callback ID if cleanup actually completed
228
+ if (cleanupCompleted) {
229
+ this.idleCallbackId = null
230
+ }
231
+ } else {
232
+ // No need to cleanup, clear the callback ID
233
+ this.idleCallbackId = null
234
+ }
235
+ },
236
+ { timeout: 1000 }
237
+ )
238
+ }
239
+
240
+ /**
241
+ * Perform cleanup operations, optionally in chunks during idle time
242
+ * @returns true if cleanup was completed, false if it was rescheduled
243
+ */
244
+ private performCleanup(deadline?: IdleCallbackDeadline): boolean {
245
+ // If we have a deadline, we can potentially split cleanup into chunks
246
+ // For now, we'll do all cleanup at once but check if we have time
247
+ const hasTime =
248
+ !deadline || deadline.timeRemaining() > 0 || deadline.didTimeout
249
+
250
+ if (hasTime) {
251
+ // Perform all cleanup operations
252
+ this.events.cleanup()
253
+ this.sync.cleanup()
254
+ this.state.cleanup()
255
+ this.changes.cleanup()
256
+ this.indexes.cleanup()
257
+
258
+ if (this.gcTimeoutId) {
259
+ clearTimeout(this.gcTimeoutId)
260
+ this.gcTimeoutId = null
261
+ }
262
+
263
+ this.hasBeenReady = false
264
+ this.onFirstReadyCallbacks = []
265
+
266
+ // Set status to cleaned-up
267
+ this.setStatus(`cleaned-up`)
268
+ return true
269
+ } else {
270
+ // If we don't have time, reschedule for the next idle period
271
+ this.scheduleIdleCleanup()
272
+ return false
273
+ }
186
274
  }
187
275
 
188
276
  /**
@@ -201,21 +289,13 @@ export class CollectionLifecycleManager<
201
289
  }
202
290
 
203
291
  public cleanup(): void {
204
- this.events.cleanup()
205
- this.sync.cleanup()
206
- this.state.cleanup()
207
- this.changes.cleanup()
208
- this.indexes.cleanup()
209
-
210
- if (this.gcTimeoutId) {
211
- clearTimeout(this.gcTimeoutId)
212
- this.gcTimeoutId = null
292
+ // Cancel any pending idle cleanup
293
+ if (this.idleCallbackId !== null) {
294
+ safeCancelIdleCallback(this.idleCallbackId)
295
+ this.idleCallbackId = null
213
296
  }
214
297
 
215
- this.hasBeenReady = false
216
- this.onFirstReadyCallbacks = []
217
-
218
- // Set status to cleaned-up
219
- this.setStatus(`cleaned-up`)
298
+ // Perform cleanup immediately (used when explicitly called)
299
+ this.performCleanup()
220
300
  }
221
301
  }
@@ -644,7 +644,7 @@ export class CollectionStateManager<
644
644
 
645
645
  // Ensure listeners are active before emitting this critical batch
646
646
  if (this.lifecycle.status !== `ready`) {
647
- this.lifecycle.setStatus(`ready`)
647
+ this.lifecycle.markReady()
648
648
  }
649
649
  }
650
650
 
@@ -1,5 +1,5 @@
1
1
  import { BTree } from "../utils/btree.js"
2
- import { defaultComparator } from "../utils/comparison.js"
2
+ import { defaultComparator, normalizeValue } from "../utils/comparison.js"
3
3
  import { BaseIndex } from "./base-index.js"
4
4
  import type { BasicExpression } from "../query/ir.js"
5
5
  import type { IndexOperation } from "./base-index.js"
@@ -71,15 +71,18 @@ export class BTreeIndex<
71
71
  )
72
72
  }
73
73
 
74
+ // Normalize the value for Map key usage
75
+ const normalizedValue = normalizeValue(indexedValue)
76
+
74
77
  // Check if this value already exists
75
- if (this.valueMap.has(indexedValue)) {
78
+ if (this.valueMap.has(normalizedValue)) {
76
79
  // Add to existing set
77
- this.valueMap.get(indexedValue)!.add(key)
80
+ this.valueMap.get(normalizedValue)!.add(key)
78
81
  } else {
79
82
  // Create new set for this value
80
83
  const keySet = new Set<TKey>([key])
81
- this.valueMap.set(indexedValue, keySet)
82
- this.orderedEntries.set(indexedValue, undefined)
84
+ this.valueMap.set(normalizedValue, keySet)
85
+ this.orderedEntries.set(normalizedValue, undefined)
83
86
  }
84
87
 
85
88
  this.indexedKeys.add(key)
@@ -101,16 +104,19 @@ export class BTreeIndex<
101
104
  return
102
105
  }
103
106
 
104
- if (this.valueMap.has(indexedValue)) {
105
- const keySet = this.valueMap.get(indexedValue)!
107
+ // Normalize the value for Map key usage
108
+ const normalizedValue = normalizeValue(indexedValue)
109
+
110
+ if (this.valueMap.has(normalizedValue)) {
111
+ const keySet = this.valueMap.get(normalizedValue)!
106
112
  keySet.delete(key)
107
113
 
108
114
  // If set is now empty, remove the entry entirely
109
115
  if (keySet.size === 0) {
110
- this.valueMap.delete(indexedValue)
116
+ this.valueMap.delete(normalizedValue)
111
117
 
112
118
  // Remove from ordered entries
113
- this.orderedEntries.delete(indexedValue)
119
+ this.orderedEntries.delete(normalizedValue)
114
120
  }
115
121
  }
116
122
 
@@ -195,7 +201,8 @@ export class BTreeIndex<
195
201
  * Performs an equality lookup
196
202
  */
197
203
  equalityLookup(value: any): Set<TKey> {
198
- return new Set(this.valueMap.get(value) ?? [])
204
+ const normalizedValue = normalizeValue(value)
205
+ return new Set(this.valueMap.get(normalizedValue) ?? [])
199
206
  }
200
207
 
201
208
  /**
@@ -206,8 +213,10 @@ export class BTreeIndex<
206
213
  const { from, to, fromInclusive = true, toInclusive = true } = options
207
214
  const result = new Set<TKey>()
208
215
 
209
- const fromKey = from ?? this.orderedEntries.minKey()
210
- const toKey = to ?? this.orderedEntries.maxKey()
216
+ const normalizedFrom = normalizeValue(from)
217
+ const normalizedTo = normalizeValue(to)
218
+ const fromKey = normalizedFrom ?? this.orderedEntries.minKey()
219
+ const toKey = normalizedTo ?? this.orderedEntries.maxKey()
211
220
 
212
221
  this.orderedEntries.forRange(
213
222
  fromKey,
@@ -240,7 +249,7 @@ export class BTreeIndex<
240
249
  const keysInResult: Set<TKey> = new Set()
241
250
  const result: Array<TKey> = []
242
251
  const nextKey = (k?: any) => this.orderedEntries.nextHigherKey(k)
243
- let key = from
252
+ let key = normalizeValue(from)
244
253
 
245
254
  while ((key = nextKey(key)) && result.length < n) {
246
255
  const keys = this.valueMap.get(key)
@@ -266,7 +275,8 @@ export class BTreeIndex<
266
275
  const result = new Set<TKey>()
267
276
 
268
277
  for (const value of values) {
269
- const keys = this.valueMap.get(value)
278
+ const normalizedValue = normalizeValue(value)
279
+ const keys = this.valueMap.get(normalizedValue)
270
280
  if (keys) {
271
281
  keys.forEach((key) => result.add(key))
272
282
  }
@@ -53,10 +53,10 @@ type ExtractType<T> =
53
53
  // Helper type to determine aggregate return type based on input nullability
54
54
  type AggregateReturnType<T> =
55
55
  ExtractType<T> extends infer U
56
- ? U extends number | undefined | null
56
+ ? U extends number | undefined | null | Date | bigint
57
57
  ? Aggregate<U>
58
- : Aggregate<number | undefined | null>
59
- : Aggregate<number | undefined | null>
58
+ : Aggregate<number | undefined | null | Date | bigint>
59
+ : Aggregate<number | undefined | null | Date | bigint>
60
60
 
61
61
  // Helper type to determine string function return type based on input nullability
62
62
  type StringFunctionReturnType<T> =
@@ -3,6 +3,7 @@ import {
3
3
  UnknownExpressionTypeError,
4
4
  UnknownFunctionError,
5
5
  } from "../../errors.js"
6
+ import { normalizeValue } from "../../utils/comparison.js"
6
7
  import type { BasicExpression, Func, PropRef } from "../ir.js"
7
8
  import type { NamespacedRow } from "../../types.js"
8
9
 
@@ -142,8 +143,8 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
142
143
  const argA = compiledArgs[0]!
143
144
  const argB = compiledArgs[1]!
144
145
  return (data) => {
145
- const a = argA(data)
146
- const b = argB(data)
146
+ const a = normalizeValue(argA(data))
147
+ const b = normalizeValue(argB(data))
147
148
  return a === b
148
149
  }
149
150
  }
@@ -349,6 +349,19 @@ function getAggregateFunction(aggExpr: Aggregate) {
349
349
  return typeof value === `number` ? value : value != null ? Number(value) : 0
350
350
  }
351
351
 
352
+ // Create a value extractor function for the expression to aggregate
353
+ const valueExtractorWithDate = ([, namespacedRow]: [
354
+ string,
355
+ NamespacedRow,
356
+ ]) => {
357
+ const value = compiledExpr(namespacedRow)
358
+ return typeof value === `number` || value instanceof Date
359
+ ? value
360
+ : value != null
361
+ ? Number(value)
362
+ : 0
363
+ }
364
+
352
365
  // Create a raw value extractor function for the expression to aggregate
353
366
  const rawValueExtractor = ([, namespacedRow]: [string, NamespacedRow]) => {
354
367
  return compiledExpr(namespacedRow)
@@ -363,9 +376,9 @@ function getAggregateFunction(aggExpr: Aggregate) {
363
376
  case `avg`:
364
377
  return avg(valueExtractor)
365
378
  case `min`:
366
- return min(valueExtractor)
379
+ return min(valueExtractorWithDate)
367
380
  case `max`:
368
- return max(valueExtractor)
381
+ return max(valueExtractorWithDate)
369
382
  default:
370
383
  throw new UnsupportedAggregateFunctionError(aggExpr.name)
371
384
  }
@@ -204,7 +204,12 @@ function processJoin(
204
204
  lazyFrom.type === `queryRef` &&
205
205
  (lazyFrom.query.limit || lazyFrom.query.offset)
206
206
 
207
- if (!limitedSubquery) {
207
+ // If join expressions contain computed values (like concat functions)
208
+ // we don't optimize the join because we don't have an index over the computed values
209
+ const hasComputedJoinExpr =
210
+ mainExpr.type === `func` || joinedExpr.type === `func`
211
+
212
+ if (!limitedSubquery && !hasComputedJoinExpr) {
208
213
  // This join can be optimized by having the active collection
209
214
  // dynamically load keys into the lazy collection
210
215
  // based on the value of the joinKey and by looking up
@@ -142,6 +142,8 @@ export interface AnalyzedWhereClause {
142
142
  expression: BasicExpression<boolean>
143
143
  /** Set of table/source aliases that this WHERE clause touches */
144
144
  touchedSources: Set<string>
145
+ /** Whether this clause contains namespace-only references that prevent pushdown */
146
+ hasNamespaceOnlyRef: boolean
145
147
  }
146
148
 
147
149
  /**
@@ -486,19 +488,31 @@ function splitAndClausesRecursive(
486
488
  * This determines whether a clause can be pushed down to a specific table
487
489
  * or must remain in the main query (for multi-source clauses like join conditions).
488
490
  *
491
+ * Special handling for namespace-only references in outer joins:
492
+ * WHERE clauses that reference only a table namespace (e.g., isUndefined(special), eq(special, value))
493
+ * rather than specific properties (e.g., isUndefined(special.id), eq(special.id, value)) are treated as
494
+ * multi-source to prevent incorrect predicate pushdown that would change join semantics.
495
+ *
489
496
  * @param clause - The WHERE expression to analyze
490
497
  * @returns Analysis result with the expression and touched source aliases
491
498
  *
492
499
  * @example
493
500
  * ```typescript
494
- * // eq(users.department_id, 1) -> touches ['users']
495
- * // eq(users.id, posts.user_id) -> touches ['users', 'posts']
501
+ * // eq(users.department_id, 1) -> touches ['users'], hasNamespaceOnlyRef: false
502
+ * // eq(users.id, posts.user_id) -> touches ['users', 'posts'], hasNamespaceOnlyRef: false
503
+ * // isUndefined(special) -> touches ['special'], hasNamespaceOnlyRef: true (prevents pushdown)
504
+ * // eq(special, someValue) -> touches ['special'], hasNamespaceOnlyRef: true (prevents pushdown)
505
+ * // isUndefined(special.id) -> touches ['special'], hasNamespaceOnlyRef: false (allows pushdown)
506
+ * // eq(special.id, 5) -> touches ['special'], hasNamespaceOnlyRef: false (allows pushdown)
496
507
  * ```
497
508
  */
498
509
  function analyzeWhereClause(
499
510
  clause: BasicExpression<boolean>
500
511
  ): AnalyzedWhereClause {
512
+ // Track which table aliases this WHERE clause touches
501
513
  const touchedSources = new Set<string>()
514
+ // Track whether this clause contains namespace-only references that prevent pushdown
515
+ let hasNamespaceOnlyRef = false
502
516
 
503
517
  /**
504
518
  * Recursively collect all table aliases referenced in an expression
@@ -511,6 +525,13 @@ function analyzeWhereClause(
511
525
  const firstElement = expr.path[0]
512
526
  if (firstElement) {
513
527
  touchedSources.add(firstElement)
528
+
529
+ // If the path has only one element (just the namespace),
530
+ // this is a namespace-only reference that should not be pushed down
531
+ // This applies to ANY function, not just existence-checking functions
532
+ if (expr.path.length === 1) {
533
+ hasNamespaceOnlyRef = true
534
+ }
514
535
  }
515
536
  }
516
537
  break
@@ -537,6 +558,7 @@ function analyzeWhereClause(
537
558
  return {
538
559
  expression: clause,
539
560
  touchedSources,
561
+ hasNamespaceOnlyRef,
540
562
  }
541
563
  }
542
564
 
@@ -557,15 +579,15 @@ function groupWhereClauses(
557
579
 
558
580
  // Categorize each clause based on how many sources it touches
559
581
  for (const clause of analyzedClauses) {
560
- if (clause.touchedSources.size === 1) {
561
- // Single source clause - can be optimized
582
+ if (clause.touchedSources.size === 1 && !clause.hasNamespaceOnlyRef) {
583
+ // Single source clause without namespace-only references - can be optimized
562
584
  const source = Array.from(clause.touchedSources)[0]!
563
585
  if (!singleSource.has(source)) {
564
586
  singleSource.set(source, [])
565
587
  }
566
588
  singleSource.get(source)!.push(clause.expression)
567
- } else if (clause.touchedSources.size > 1) {
568
- // Multi-source clause - must stay in main query
589
+ } else if (clause.touchedSources.size > 1 || clause.hasNamespaceOnlyRef) {
590
+ // Multi-source clause or namespace-only reference - must stay in main query
569
591
  multiSource.push(clause.expression)
570
592
  }
571
593
  // Skip clauses that touch no sources (constants) - they don't need optimization
package/src/types.ts CHANGED
@@ -334,8 +334,14 @@ export interface BaseCollectionConfig<
334
334
  */
335
335
  gcTime?: number
336
336
  /**
337
- * Whether to start syncing immediately when the collection is created.
338
- * Defaults to false for lazy loading. Set to true to immediately sync.
337
+ * Whether to eagerly start syncing on collection creation.
338
+ * When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches.
339
+ *
340
+ * Note: Even with startSync=true, collections will pause syncing when there are no active
341
+ * subscribers (typically when components querying the collection unmount), resuming when new
342
+ * subscribers attach. This preserves normal staleTime/gcTime behavior.
343
+ *
344
+ * @default false
339
345
  */
340
346
  startSync?: boolean
341
347
  /**
@@ -0,0 +1,39 @@
1
+ // Type definitions for requestIdleCallback - compatible with existing browser types
2
+ export type IdleCallbackDeadline = {
3
+ didTimeout: boolean
4
+ timeRemaining: () => number
5
+ }
6
+
7
+ export type IdleCallbackFunction = (deadline: IdleCallbackDeadline) => void
8
+
9
+ const requestIdleCallbackPolyfill = (
10
+ callback: IdleCallbackFunction
11
+ ): number => {
12
+ // Use a very small timeout for the polyfill to simulate idle time
13
+ const timeout = 0
14
+ const timeoutId = setTimeout(() => {
15
+ callback({
16
+ didTimeout: true, // Always indicate timeout for the polyfill
17
+ timeRemaining: () => 50, // Return some time remaining for polyfill
18
+ })
19
+ }, timeout)
20
+ return timeoutId as unknown as number
21
+ }
22
+
23
+ const cancelIdleCallbackPolyfill = (id: number): void => {
24
+ clearTimeout(id as unknown as ReturnType<typeof setTimeout>)
25
+ }
26
+
27
+ export const safeRequestIdleCallback: (
28
+ callback: IdleCallbackFunction,
29
+ options?: { timeout?: number }
30
+ ) => number =
31
+ typeof window !== `undefined` && `requestIdleCallback` in window
32
+ ? (callback, options) =>
33
+ (window as any).requestIdleCallback(callback, options)
34
+ : (callback, _options) => requestIdleCallbackPolyfill(callback)
35
+
36
+ export const safeCancelIdleCallback: (id: number) => void =
37
+ typeof window !== `undefined` && `cancelIdleCallback` in window
38
+ ? (id) => (window as any).cancelIdleCallback(id)
39
+ : cancelIdleCallbackPolyfill
@@ -110,3 +110,13 @@ export const defaultComparator = makeComparator({
110
110
  nulls: `first`,
111
111
  stringSort: `locale`,
112
112
  })
113
+
114
+ /**
115
+ * Normalize a value for comparison
116
+ */
117
+ export function normalizeValue(value: any): any {
118
+ if (value instanceof Date) {
119
+ return value.getTime()
120
+ }
121
+ return value
122
+ }