@tanstack/db 0.0.27 → 0.0.29

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 (164) hide show
  1. package/dist/cjs/change-events.cjs +141 -0
  2. package/dist/cjs/change-events.cjs.map +1 -0
  3. package/dist/cjs/change-events.d.cts +49 -0
  4. package/dist/cjs/collection.cjs +234 -86
  5. package/dist/cjs/collection.cjs.map +1 -1
  6. package/dist/cjs/collection.d.cts +95 -20
  7. package/dist/cjs/errors.cjs +509 -1
  8. package/dist/cjs/errors.cjs.map +1 -1
  9. package/dist/cjs/errors.d.cts +225 -1
  10. package/dist/cjs/index.cjs +82 -3
  11. package/dist/cjs/index.cjs.map +1 -1
  12. package/dist/cjs/index.d.cts +5 -1
  13. package/dist/cjs/indexes/auto-index.cjs +64 -0
  14. package/dist/cjs/indexes/auto-index.cjs.map +1 -0
  15. package/dist/cjs/indexes/auto-index.d.cts +9 -0
  16. package/dist/cjs/indexes/base-index.cjs +46 -0
  17. package/dist/cjs/indexes/base-index.cjs.map +1 -0
  18. package/dist/cjs/indexes/base-index.d.cts +54 -0
  19. package/dist/cjs/indexes/index-options.d.cts +13 -0
  20. package/dist/cjs/indexes/lazy-index.cjs +193 -0
  21. package/dist/cjs/indexes/lazy-index.cjs.map +1 -0
  22. package/dist/cjs/indexes/lazy-index.d.cts +96 -0
  23. package/dist/cjs/indexes/ordered-index.cjs +227 -0
  24. package/dist/cjs/indexes/ordered-index.cjs.map +1 -0
  25. package/dist/cjs/indexes/ordered-index.d.cts +72 -0
  26. package/dist/cjs/local-storage.cjs +9 -15
  27. package/dist/cjs/local-storage.cjs.map +1 -1
  28. package/dist/cjs/query/builder/functions.cjs +11 -0
  29. package/dist/cjs/query/builder/functions.cjs.map +1 -1
  30. package/dist/cjs/query/builder/functions.d.cts +4 -0
  31. package/dist/cjs/query/builder/index.cjs +6 -7
  32. package/dist/cjs/query/builder/index.cjs.map +1 -1
  33. package/dist/cjs/query/builder/ref-proxy.cjs +37 -0
  34. package/dist/cjs/query/builder/ref-proxy.cjs.map +1 -1
  35. package/dist/cjs/query/builder/ref-proxy.d.cts +12 -0
  36. package/dist/cjs/query/compiler/evaluators.cjs +83 -58
  37. package/dist/cjs/query/compiler/evaluators.cjs.map +1 -1
  38. package/dist/cjs/query/compiler/evaluators.d.cts +8 -0
  39. package/dist/cjs/query/compiler/expressions.cjs +61 -0
  40. package/dist/cjs/query/compiler/expressions.cjs.map +1 -0
  41. package/dist/cjs/query/compiler/expressions.d.cts +25 -0
  42. package/dist/cjs/query/compiler/group-by.cjs +5 -10
  43. package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
  44. package/dist/cjs/query/compiler/index.cjs +23 -17
  45. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  46. package/dist/cjs/query/compiler/index.d.cts +12 -3
  47. package/dist/cjs/query/compiler/joins.cjs +61 -12
  48. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  49. package/dist/cjs/query/compiler/order-by.cjs +4 -34
  50. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  51. package/dist/cjs/query/compiler/types.d.cts +2 -2
  52. package/dist/cjs/query/live-query-collection.cjs +54 -12
  53. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  54. package/dist/cjs/query/optimizer.cjs +45 -7
  55. package/dist/cjs/query/optimizer.cjs.map +1 -1
  56. package/dist/cjs/query/optimizer.d.cts +13 -3
  57. package/dist/cjs/transactions.cjs +5 -4
  58. package/dist/cjs/transactions.cjs.map +1 -1
  59. package/dist/cjs/types.d.cts +31 -0
  60. package/dist/cjs/utils/array-utils.cjs +18 -0
  61. package/dist/cjs/utils/array-utils.cjs.map +1 -0
  62. package/dist/cjs/utils/array-utils.d.cts +8 -0
  63. package/dist/cjs/utils/comparison.cjs +52 -0
  64. package/dist/cjs/utils/comparison.cjs.map +1 -0
  65. package/dist/cjs/utils/comparison.d.cts +11 -0
  66. package/dist/cjs/utils/index-optimization.cjs +270 -0
  67. package/dist/cjs/utils/index-optimization.cjs.map +1 -0
  68. package/dist/cjs/utils/index-optimization.d.cts +29 -0
  69. package/dist/esm/change-events.d.ts +49 -0
  70. package/dist/esm/change-events.js +141 -0
  71. package/dist/esm/change-events.js.map +1 -0
  72. package/dist/esm/collection.d.ts +95 -20
  73. package/dist/esm/collection.js +232 -84
  74. package/dist/esm/collection.js.map +1 -1
  75. package/dist/esm/errors.d.ts +225 -1
  76. package/dist/esm/errors.js +510 -2
  77. package/dist/esm/errors.js.map +1 -1
  78. package/dist/esm/index.d.ts +5 -1
  79. package/dist/esm/index.js +81 -2
  80. package/dist/esm/index.js.map +1 -1
  81. package/dist/esm/indexes/auto-index.d.ts +9 -0
  82. package/dist/esm/indexes/auto-index.js +64 -0
  83. package/dist/esm/indexes/auto-index.js.map +1 -0
  84. package/dist/esm/indexes/base-index.d.ts +54 -0
  85. package/dist/esm/indexes/base-index.js +46 -0
  86. package/dist/esm/indexes/base-index.js.map +1 -0
  87. package/dist/esm/indexes/index-options.d.ts +13 -0
  88. package/dist/esm/indexes/lazy-index.d.ts +96 -0
  89. package/dist/esm/indexes/lazy-index.js +193 -0
  90. package/dist/esm/indexes/lazy-index.js.map +1 -0
  91. package/dist/esm/indexes/ordered-index.d.ts +72 -0
  92. package/dist/esm/indexes/ordered-index.js +227 -0
  93. package/dist/esm/indexes/ordered-index.js.map +1 -0
  94. package/dist/esm/local-storage.js +9 -15
  95. package/dist/esm/local-storage.js.map +1 -1
  96. package/dist/esm/query/builder/functions.d.ts +4 -0
  97. package/dist/esm/query/builder/functions.js +11 -0
  98. package/dist/esm/query/builder/functions.js.map +1 -1
  99. package/dist/esm/query/builder/index.js +6 -7
  100. package/dist/esm/query/builder/index.js.map +1 -1
  101. package/dist/esm/query/builder/ref-proxy.d.ts +12 -0
  102. package/dist/esm/query/builder/ref-proxy.js +37 -0
  103. package/dist/esm/query/builder/ref-proxy.js.map +1 -1
  104. package/dist/esm/query/compiler/evaluators.d.ts +8 -0
  105. package/dist/esm/query/compiler/evaluators.js +84 -59
  106. package/dist/esm/query/compiler/evaluators.js.map +1 -1
  107. package/dist/esm/query/compiler/expressions.d.ts +25 -0
  108. package/dist/esm/query/compiler/expressions.js +61 -0
  109. package/dist/esm/query/compiler/expressions.js.map +1 -0
  110. package/dist/esm/query/compiler/group-by.js +5 -10
  111. package/dist/esm/query/compiler/group-by.js.map +1 -1
  112. package/dist/esm/query/compiler/index.d.ts +12 -3
  113. package/dist/esm/query/compiler/index.js +23 -17
  114. package/dist/esm/query/compiler/index.js.map +1 -1
  115. package/dist/esm/query/compiler/joins.js +61 -12
  116. package/dist/esm/query/compiler/joins.js.map +1 -1
  117. package/dist/esm/query/compiler/order-by.js +1 -31
  118. package/dist/esm/query/compiler/order-by.js.map +1 -1
  119. package/dist/esm/query/compiler/types.d.ts +2 -2
  120. package/dist/esm/query/live-query-collection.js +54 -12
  121. package/dist/esm/query/live-query-collection.js.map +1 -1
  122. package/dist/esm/query/optimizer.d.ts +13 -3
  123. package/dist/esm/query/optimizer.js +40 -2
  124. package/dist/esm/query/optimizer.js.map +1 -1
  125. package/dist/esm/transactions.js +5 -4
  126. package/dist/esm/transactions.js.map +1 -1
  127. package/dist/esm/types.d.ts +31 -0
  128. package/dist/esm/utils/array-utils.d.ts +8 -0
  129. package/dist/esm/utils/array-utils.js +18 -0
  130. package/dist/esm/utils/array-utils.js.map +1 -0
  131. package/dist/esm/utils/comparison.d.ts +11 -0
  132. package/dist/esm/utils/comparison.js +52 -0
  133. package/dist/esm/utils/comparison.js.map +1 -0
  134. package/dist/esm/utils/index-optimization.d.ts +29 -0
  135. package/dist/esm/utils/index-optimization.js +270 -0
  136. package/dist/esm/utils/index-optimization.js.map +1 -0
  137. package/package.json +1 -1
  138. package/src/change-events.ts +257 -0
  139. package/src/collection.ts +318 -105
  140. package/src/errors.ts +545 -1
  141. package/src/index.ts +7 -1
  142. package/src/indexes/auto-index.ts +108 -0
  143. package/src/indexes/base-index.ts +119 -0
  144. package/src/indexes/index-options.ts +42 -0
  145. package/src/indexes/lazy-index.ts +251 -0
  146. package/src/indexes/ordered-index.ts +305 -0
  147. package/src/local-storage.ts +16 -17
  148. package/src/query/builder/functions.ts +14 -0
  149. package/src/query/builder/index.ts +12 -7
  150. package/src/query/builder/ref-proxy.ts +65 -0
  151. package/src/query/compiler/evaluators.ts +114 -62
  152. package/src/query/compiler/expressions.ts +92 -0
  153. package/src/query/compiler/group-by.ts +10 -10
  154. package/src/query/compiler/index.ts +52 -22
  155. package/src/query/compiler/joins.ts +114 -15
  156. package/src/query/compiler/order-by.ts +1 -45
  157. package/src/query/compiler/types.ts +2 -2
  158. package/src/query/live-query-collection.ts +95 -15
  159. package/src/query/optimizer.ts +94 -5
  160. package/src/transactions.ts +10 -4
  161. package/src/types.ts +38 -0
  162. package/src/utils/array-utils.ts +28 -0
  163. package/src/utils/comparison.ts +79 -0
  164. package/src/utils/index-optimization.ts +546 -0
@@ -116,11 +116,13 @@
116
116
  */
117
117
 
118
118
  import { deepEquals } from "../utils.js"
119
+ import { CannotCombineEmptyExpressionListError } from "../errors.js"
119
120
  import {
120
121
  CollectionRef as CollectionRefClass,
121
122
  Func,
122
123
  QueryRef as QueryRefClass,
123
124
  } from "./ir.js"
125
+ import { isConvertibleToCollectionFilter } from "./compiler/expressions.js"
124
126
  import type { BasicExpression, From, QueryIR } from "./ir.js"
125
127
 
126
128
  /**
@@ -143,6 +145,16 @@ export interface GroupedWhereClauses {
143
145
  multiSource?: BasicExpression<boolean>
144
146
  }
145
147
 
148
+ /**
149
+ * Result of query optimization including both the optimized query and collection-specific WHERE clauses
150
+ */
151
+ export interface OptimizationResult {
152
+ /** The optimized query with WHERE clauses potentially moved to subqueries */
153
+ optimizedQuery: QueryIR
154
+ /** Map of collection aliases to their extracted WHERE clauses for index optimization */
155
+ collectionWhereClauses: Map<string, BasicExpression<boolean>>
156
+ }
157
+
146
158
  /**
147
159
  * Main query optimizer entry point that lifts WHERE clauses into subqueries.
148
160
  *
@@ -151,7 +163,7 @@ export interface GroupedWhereClauses {
151
163
  * sources as possible, then removing redundant subqueries.
152
164
  *
153
165
  * @param query - The QueryIR to optimize
154
- * @returns A new QueryIR with optimizations applied (or original if no optimization possible)
166
+ * @returns An OptimizationResult with the optimized query and collection WHERE clause mapping
155
167
  *
156
168
  * @example
157
169
  * ```typescript
@@ -161,11 +173,15 @@ export interface GroupedWhereClauses {
161
173
  * where: [eq(u.dept_id, 1), gt(p.views, 100)]
162
174
  * }
163
175
  *
164
- * const optimized = optimizeQuery(originalQuery)
176
+ * const { optimizedQuery, collectionWhereClauses } = optimizeQuery(originalQuery)
165
177
  * // Result: Single-source clauses moved to deepest possible subqueries
178
+ * // collectionWhereClauses: Map { 'u' => eq(u.dept_id, 1), 'p' => gt(p.views, 100) }
166
179
  * ```
167
180
  */
168
- export function optimizeQuery(query: QueryIR): QueryIR {
181
+ export function optimizeQuery(query: QueryIR): OptimizationResult {
182
+ // First, extract collection WHERE clauses before optimization
183
+ const collectionWhereClauses = extractCollectionWhereClauses(query)
184
+
169
185
  // Apply multi-level predicate pushdown with iterative convergence
170
186
  let optimized = query
171
187
  let previousOptimized: QueryIR | undefined
@@ -185,7 +201,80 @@ export function optimizeQuery(query: QueryIR): QueryIR {
185
201
  // Remove redundant subqueries
186
202
  const cleaned = removeRedundantSubqueries(optimized)
187
203
 
188
- return cleaned
204
+ return {
205
+ optimizedQuery: cleaned,
206
+ collectionWhereClauses,
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Extracts collection-specific WHERE clauses from a query for index optimization.
212
+ * This analyzes the original query to identify WHERE clauses that can be pushed down
213
+ * to specific collections, but only for simple queries without joins.
214
+ *
215
+ * @param query - The original QueryIR to analyze
216
+ * @returns Map of collection aliases to their WHERE clauses
217
+ */
218
+ function extractCollectionWhereClauses(
219
+ query: QueryIR
220
+ ): Map<string, BasicExpression<boolean>> {
221
+ const collectionWhereClauses = new Map<string, BasicExpression<boolean>>()
222
+
223
+ // Only analyze queries that have WHERE clauses
224
+ if (!query.where || query.where.length === 0) {
225
+ return collectionWhereClauses
226
+ }
227
+
228
+ // Split all AND clauses at the root level for granular analysis
229
+ const splitWhereClauses = splitAndClauses(query.where)
230
+
231
+ // Analyze each WHERE clause to determine which sources it touches
232
+ const analyzedClauses = splitWhereClauses.map((clause) =>
233
+ analyzeWhereClause(clause)
234
+ )
235
+
236
+ // Group clauses by single-source vs multi-source
237
+ const groupedClauses = groupWhereClauses(analyzedClauses)
238
+
239
+ // Only include single-source clauses that reference collections directly
240
+ // and can be converted to BasicExpression format for collection indexes
241
+ for (const [sourceAlias, whereClause] of groupedClauses.singleSource) {
242
+ // Check if this source alias corresponds to a collection reference
243
+ if (isCollectionReference(query, sourceAlias)) {
244
+ // Check if the WHERE clause can be converted to collection-compatible format
245
+ if (isConvertibleToCollectionFilter(whereClause)) {
246
+ collectionWhereClauses.set(sourceAlias, whereClause)
247
+ }
248
+ }
249
+ }
250
+
251
+ return collectionWhereClauses
252
+ }
253
+
254
+ /**
255
+ * Determines if a source alias refers to a collection reference (not a subquery).
256
+ * This is used to identify WHERE clauses that can be pushed down to collection subscriptions.
257
+ *
258
+ * @param query - The query to analyze
259
+ * @param sourceAlias - The source alias to check
260
+ * @returns True if the alias refers to a collection reference
261
+ */
262
+ function isCollectionReference(query: QueryIR, sourceAlias: string): boolean {
263
+ // Check the FROM clause
264
+ if (query.from.alias === sourceAlias) {
265
+ return query.from.type === `collectionRef`
266
+ }
267
+
268
+ // Check JOIN clauses
269
+ if (query.join) {
270
+ for (const joinClause of query.join) {
271
+ if (joinClause.from.alias === sourceAlias) {
272
+ return joinClause.from.type === `collectionRef`
273
+ }
274
+ }
275
+ }
276
+
277
+ return false
189
278
  }
190
279
 
191
280
  /**
@@ -726,7 +815,7 @@ function combineWithAnd(
726
815
  expressions: Array<BasicExpression<boolean>>
727
816
  ): BasicExpression<boolean> {
728
817
  if (expressions.length === 0) {
729
- throw new Error(`Cannot combine empty expression list`)
818
+ throw new CannotCombineEmptyExpressionListError()
730
819
  }
731
820
 
732
821
  if (expressions.length === 1) {
@@ -1,4 +1,10 @@
1
1
  import { createDeferred } from "./deferred"
2
+ import {
3
+ MissingMutationFunctionError,
4
+ TransactionAlreadyCompletedRollbackError,
5
+ TransactionNotPendingCommitError,
6
+ TransactionNotPendingMutateError,
7
+ } from "./errors"
2
8
  import type { Deferred } from "./deferred"
3
9
  import type {
4
10
  MutationFn,
@@ -124,7 +130,7 @@ class Transaction<T extends object = Record<string, unknown>> {
124
130
 
125
131
  constructor(config: TransactionConfig<T>) {
126
132
  if (typeof config.mutationFn === `undefined`) {
127
- throw `mutationFn is required when creating a transaction`
133
+ throw new MissingMutationFunctionError()
128
134
  }
129
135
  this.id = config.id ?? crypto.randomUUID()
130
136
  this.mutationFn = config.mutationFn
@@ -186,7 +192,7 @@ class Transaction<T extends object = Record<string, unknown>> {
186
192
  */
187
193
  mutate(callback: () => void): Transaction<T> {
188
194
  if (this.state !== `pending`) {
189
- throw `You can no longer call .mutate() as the transaction is no longer pending`
195
+ throw new TransactionNotPendingMutateError()
190
196
  }
191
197
 
192
198
  registerTransaction(this)
@@ -260,7 +266,7 @@ class Transaction<T extends object = Record<string, unknown>> {
260
266
  rollback(config?: { isSecondaryRollback?: boolean }): Transaction<T> {
261
267
  const isSecondaryRollback = config?.isSecondaryRollback ?? false
262
268
  if (this.state === `completed`) {
263
- throw `You can no longer call .rollback() as the transaction is already completed`
269
+ throw new TransactionAlreadyCompletedRollbackError()
264
270
  }
265
271
 
266
272
  this.setState(`failed`)
@@ -342,7 +348,7 @@ class Transaction<T extends object = Record<string, unknown>> {
342
348
  */
343
349
  async commit(): Promise<Transaction<T>> {
344
350
  if (this.state !== `pending`) {
345
- throw `You can no longer call .commit() as the transaction is no longer pending`
351
+ throw new TransactionNotPendingCommitError()
346
352
  }
347
353
 
348
354
  this.setState(`persisting`)
package/src/types.ts CHANGED
@@ -3,6 +3,9 @@ import type { Collection } from "./collection"
3
3
  import type { StandardSchemaV1 } from "@standard-schema/spec"
4
4
  import type { Transaction } from "./transactions"
5
5
 
6
+ import type { SingleRowRefProxy } from "./query/builder/ref-proxy"
7
+ import type { BasicExpression } from "./query/ir.js"
8
+
6
9
  /**
7
10
  * Helper type to extract the output type from a standard schema
8
11
  *
@@ -374,6 +377,15 @@ export interface CollectionConfig<
374
377
  * Defaults to false for lazy loading. Set to true to immediately sync.
375
378
  */
376
379
  startSync?: boolean
380
+ /**
381
+ * Auto-indexing mode for the collection.
382
+ * When enabled, indexes will be automatically created for simple where expressions.
383
+ * @default "eager"
384
+ * @description
385
+ * - "off": No automatic indexing
386
+ * - "eager": Automatically create indexes for simple where expressions in subscribeChanges (default)
387
+ */
388
+ autoIndex?: `off` | `eager`
377
389
  /**
378
390
  * Optional function to compare two items.
379
391
  * This is used to order the items in the collection.
@@ -555,6 +567,32 @@ export type KeyedNamespacedRow = [unknown, NamespacedRow]
555
567
  */
556
568
  export type NamespacedAndKeyedStream = IStreamBuilder<KeyedNamespacedRow>
557
569
 
570
+ /**
571
+ * Options for subscribing to collection changes
572
+ */
573
+ export interface SubscribeChangesOptions<
574
+ T extends object = Record<string, unknown>,
575
+ > {
576
+ /** Whether to include the current state as initial changes */
577
+ includeInitialState?: boolean
578
+ /** Filter changes using a where expression */
579
+ where?: (row: SingleRowRefProxy<T>) => any
580
+ /** Pre-compiled expression for filtering changes */
581
+ whereExpression?: BasicExpression<boolean>
582
+ }
583
+
584
+ /**
585
+ * Options for getting current state as changes
586
+ */
587
+ export interface CurrentStateAsChangesOptions<
588
+ T extends object = Record<string, unknown>,
589
+ > {
590
+ /** Filter the current state using a where expression */
591
+ where?: (row: SingleRowRefProxy<T>) => any
592
+ /** Pre-compiled expression for filtering the current state */
593
+ whereExpression?: BasicExpression<boolean>
594
+ }
595
+
558
596
  /**
559
597
  * Function type for listening to collection changes
560
598
  * @param changes - Array of change messages describing what happened
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Finds the correct insert position for a value in a sorted array using binary search
3
+ * @param sortedArray The sorted array to search in
4
+ * @param value The value to find the position for
5
+ * @param compareFn Comparison function to use for ordering
6
+ * @returns The index where the value should be inserted to maintain order
7
+ */
8
+ export function findInsertPosition<T>(
9
+ sortedArray: Array<[T, any]>,
10
+ value: T,
11
+ compareFn: (a: T, b: T) => number
12
+ ): number {
13
+ let left = 0
14
+ let right = sortedArray.length
15
+
16
+ while (left < right) {
17
+ const mid = Math.floor((left + right) / 2)
18
+ const comparison = compareFn(sortedArray[mid]![0], value)
19
+
20
+ if (comparison < 0) {
21
+ left = mid + 1
22
+ } else {
23
+ right = mid
24
+ }
25
+ }
26
+
27
+ return left
28
+ }
@@ -0,0 +1,79 @@
1
+ // WeakMap to store stable IDs for objects
2
+ const objectIds = new WeakMap<object, number>()
3
+ let nextObjectId = 1
4
+
5
+ /**
6
+ * Get or create a stable ID for an object
7
+ */
8
+ function getObjectId(obj: object): number {
9
+ if (objectIds.has(obj)) {
10
+ return objectIds.get(obj)!
11
+ }
12
+ const id = nextObjectId++
13
+ objectIds.set(obj, id)
14
+ return id
15
+ }
16
+
17
+ /**
18
+ * Universal comparison function for all data types
19
+ * Handles null/undefined, strings, arrays, dates, objects, and primitives
20
+ * Always sorts null/undefined values first
21
+ */
22
+ export const ascComparator = (a: any, b: any): number => {
23
+ // Handle null/undefined
24
+ if (a == null && b == null) return 0
25
+ if (a == null) return -1
26
+ if (b == null) return 1
27
+
28
+ // if a and b are both strings, compare them based on locale
29
+ if (typeof a === `string` && typeof b === `string`) {
30
+ return a.localeCompare(b)
31
+ }
32
+
33
+ // if a and b are both arrays, compare them element by element
34
+ if (Array.isArray(a) && Array.isArray(b)) {
35
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
36
+ const result = ascComparator(a[i], b[i])
37
+ if (result !== 0) {
38
+ return result
39
+ }
40
+ }
41
+ // All elements are equal up to the minimum length
42
+ return a.length - b.length
43
+ }
44
+
45
+ // If both are dates, compare them
46
+ if (a instanceof Date && b instanceof Date) {
47
+ return a.getTime() - b.getTime()
48
+ }
49
+
50
+ // If at least one of the values is an object, use stable IDs for comparison
51
+ const aIsObject = typeof a === `object`
52
+ const bIsObject = typeof b === `object`
53
+
54
+ if (aIsObject || bIsObject) {
55
+ // If both are objects, compare their stable IDs
56
+ if (aIsObject && bIsObject) {
57
+ const aId = getObjectId(a)
58
+ const bId = getObjectId(b)
59
+ return aId - bId
60
+ }
61
+
62
+ // If only one is an object, objects come after primitives
63
+ if (aIsObject) return 1
64
+ if (bIsObject) return -1
65
+ }
66
+
67
+ // For primitive values, use direct comparison
68
+ if (a < b) return -1
69
+ if (a > b) return 1
70
+ return 0
71
+ }
72
+
73
+ /**
74
+ * Descending comparator function for ordering values
75
+ * Handles null/undefined as largest values (opposite of ascending)
76
+ */
77
+ export const descComparator = (a: unknown, b: unknown): number => {
78
+ return ascComparator(b, a)
79
+ }