@tanstack/db 0.1.3 → 0.1.4

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 (88) hide show
  1. package/dist/cjs/collection.cjs +112 -6
  2. package/dist/cjs/collection.cjs.map +1 -1
  3. package/dist/cjs/collection.d.cts +3 -2
  4. package/dist/cjs/errors.cjs +6 -0
  5. package/dist/cjs/errors.cjs.map +1 -1
  6. package/dist/cjs/errors.d.cts +3 -0
  7. package/dist/cjs/index.cjs +1 -0
  8. package/dist/cjs/index.cjs.map +1 -1
  9. package/dist/cjs/indexes/auto-index.cjs +30 -19
  10. package/dist/cjs/indexes/auto-index.cjs.map +1 -1
  11. package/dist/cjs/indexes/auto-index.d.cts +1 -0
  12. package/dist/cjs/indexes/base-index.cjs.map +1 -1
  13. package/dist/cjs/indexes/base-index.d.cts +2 -1
  14. package/dist/cjs/indexes/btree-index.cjs +26 -0
  15. package/dist/cjs/indexes/btree-index.cjs.map +1 -1
  16. package/dist/cjs/indexes/btree-index.d.cts +7 -0
  17. package/dist/cjs/indexes/index-options.d.cts +1 -1
  18. package/dist/cjs/query/compiler/evaluators.cjs +2 -2
  19. package/dist/cjs/query/compiler/evaluators.cjs.map +1 -1
  20. package/dist/cjs/query/compiler/evaluators.d.cts +1 -1
  21. package/dist/cjs/query/compiler/group-by.cjs +3 -1
  22. package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
  23. package/dist/cjs/query/compiler/index.cjs +72 -6
  24. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  25. package/dist/cjs/query/compiler/index.d.cts +16 -2
  26. package/dist/cjs/query/compiler/joins.cjs +111 -12
  27. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  28. package/dist/cjs/query/compiler/joins.d.cts +9 -2
  29. package/dist/cjs/query/compiler/order-by.cjs +62 -3
  30. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  31. package/dist/cjs/query/compiler/order-by.d.cts +12 -2
  32. package/dist/cjs/query/live-query-collection.cjs +196 -23
  33. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  34. package/dist/cjs/types.d.cts +1 -0
  35. package/dist/cjs/utils/btree.cjs +15 -0
  36. package/dist/cjs/utils/btree.cjs.map +1 -1
  37. package/dist/cjs/utils/btree.d.cts +8 -0
  38. package/dist/esm/collection.d.ts +3 -2
  39. package/dist/esm/collection.js +113 -7
  40. package/dist/esm/collection.js.map +1 -1
  41. package/dist/esm/errors.d.ts +3 -0
  42. package/dist/esm/errors.js +6 -0
  43. package/dist/esm/errors.js.map +1 -1
  44. package/dist/esm/index.js +2 -1
  45. package/dist/esm/indexes/auto-index.d.ts +1 -0
  46. package/dist/esm/indexes/auto-index.js +31 -20
  47. package/dist/esm/indexes/auto-index.js.map +1 -1
  48. package/dist/esm/indexes/base-index.d.ts +2 -1
  49. package/dist/esm/indexes/base-index.js.map +1 -1
  50. package/dist/esm/indexes/btree-index.d.ts +7 -0
  51. package/dist/esm/indexes/btree-index.js +26 -0
  52. package/dist/esm/indexes/btree-index.js.map +1 -1
  53. package/dist/esm/indexes/index-options.d.ts +1 -1
  54. package/dist/esm/query/compiler/evaluators.d.ts +1 -1
  55. package/dist/esm/query/compiler/evaluators.js +2 -2
  56. package/dist/esm/query/compiler/evaluators.js.map +1 -1
  57. package/dist/esm/query/compiler/group-by.js +3 -1
  58. package/dist/esm/query/compiler/group-by.js.map +1 -1
  59. package/dist/esm/query/compiler/index.d.ts +16 -2
  60. package/dist/esm/query/compiler/index.js +73 -7
  61. package/dist/esm/query/compiler/index.js.map +1 -1
  62. package/dist/esm/query/compiler/joins.d.ts +9 -2
  63. package/dist/esm/query/compiler/joins.js +114 -15
  64. package/dist/esm/query/compiler/joins.js.map +1 -1
  65. package/dist/esm/query/compiler/order-by.d.ts +12 -2
  66. package/dist/esm/query/compiler/order-by.js +62 -3
  67. package/dist/esm/query/compiler/order-by.js.map +1 -1
  68. package/dist/esm/query/live-query-collection.js +196 -23
  69. package/dist/esm/query/live-query-collection.js.map +1 -1
  70. package/dist/esm/types.d.ts +1 -0
  71. package/dist/esm/utils/btree.d.ts +8 -0
  72. package/dist/esm/utils/btree.js +15 -0
  73. package/dist/esm/utils/btree.js.map +1 -1
  74. package/package.json +2 -2
  75. package/src/collection.ts +163 -10
  76. package/src/errors.ts +6 -0
  77. package/src/indexes/auto-index.ts +53 -31
  78. package/src/indexes/base-index.ts +6 -1
  79. package/src/indexes/btree-index.ts +29 -0
  80. package/src/indexes/index-options.ts +2 -2
  81. package/src/query/compiler/evaluators.ts +6 -3
  82. package/src/query/compiler/group-by.ts +3 -1
  83. package/src/query/compiler/index.ts +112 -5
  84. package/src/query/compiler/joins.ts +216 -20
  85. package/src/query/compiler/order-by.ts +98 -3
  86. package/src/query/live-query-collection.ts +352 -24
  87. package/src/types.ts +1 -0
  88. package/src/utils/btree.ts +17 -0
package/src/collection.ts CHANGED
@@ -65,6 +65,7 @@ import type { BaseIndex, IndexResolver } from "./indexes/base-index.js"
65
65
  interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
66
66
  committed: boolean
67
67
  operations: Array<OptimisticChangeMessage<T>>
68
+ truncate?: boolean
68
69
  }
69
70
 
70
71
  /**
@@ -392,9 +393,8 @@ export class CollectionImpl<
392
393
  this.onFirstReadyCallbacks = []
393
394
  callbacks.forEach((callback) => callback())
394
395
 
395
- // If the collection is empty when it becomes ready, emit an empty change event
396
396
  // to notify subscribers (like LiveQueryCollection) that the collection is ready
397
- if (this.size === 0 && this.changeListeners.size > 0) {
397
+ if (this.changeListeners.size > 0) {
398
398
  this.emitEmptyReadyEvent()
399
399
  }
400
400
  }
@@ -559,11 +559,16 @@ export class CollectionImpl<
559
559
 
560
560
  // Check if an item with this key already exists when inserting
561
561
  if (messageWithoutKey.type === `insert`) {
562
+ const insertingIntoExistingSynced = this.syncedData.has(key)
563
+ const hasPendingDeleteForKey = pendingTransaction.operations.some(
564
+ (op) => op.key === key && op.type === `delete`
565
+ )
566
+ const isTruncateTransaction = pendingTransaction.truncate === true
567
+ // Allow insert after truncate in the same transaction even if it existed in syncedData
562
568
  if (
563
- this.syncedData.has(key) &&
564
- !pendingTransaction.operations.some(
565
- (op) => op.key === key && op.type === `delete`
566
- )
569
+ insertingIntoExistingSynced &&
570
+ !hasPendingDeleteForKey &&
571
+ !isTruncateTransaction
567
572
  ) {
568
573
  throw new DuplicateKeySyncError(key, this.id)
569
574
  }
@@ -600,6 +605,28 @@ export class CollectionImpl<
600
605
  markReady: () => {
601
606
  this.markReady()
602
607
  },
608
+ truncate: () => {
609
+ const pendingTransaction =
610
+ this.pendingSyncedTransactions[
611
+ this.pendingSyncedTransactions.length - 1
612
+ ]
613
+ if (!pendingTransaction) {
614
+ throw new NoPendingSyncTransactionWriteError()
615
+ }
616
+ if (pendingTransaction.committed) {
617
+ throw new SyncTransactionAlreadyCommittedWriteError()
618
+ }
619
+
620
+ // Clear all operations from the current transaction
621
+ pendingTransaction.operations = []
622
+
623
+ // Mark the transaction as a truncate operation. During commit, this triggers:
624
+ // - Delete events for all previously synced keys (excluding optimistic-deleted keys)
625
+ // - Clearing of syncedData/syncedMetadata
626
+ // - Subsequent synced ops applied on the fresh base
627
+ // - Finally, optimistic mutations re-applied on top (single batch)
628
+ pendingTransaction.truncate = true
629
+ },
603
630
  })
604
631
 
605
632
  // Store cleanup function if provided
@@ -957,6 +984,12 @@ export class CollectionImpl<
957
984
  for (const listener of this.changeListeners) {
958
985
  listener([])
959
986
  }
987
+ // Emit to key-specific listeners
988
+ for (const [_key, keyListeners] of this.changeKeyListeners) {
989
+ for (const listener of keyListeners) {
990
+ listener([])
991
+ }
992
+ }
960
993
  }
961
994
 
962
995
  /**
@@ -1149,7 +1182,11 @@ export class CollectionImpl<
1149
1182
  }
1150
1183
  }
1151
1184
 
1152
- if (!hasPersistingTransaction) {
1185
+ const hasTruncateSync = this.pendingSyncedTransactions.some(
1186
+ (t) => t.truncate === true
1187
+ )
1188
+
1189
+ if (!hasPersistingTransaction || hasTruncateSync) {
1153
1190
  // Set flag to prevent redundant optimistic state recalculations
1154
1191
  this.isCommittingSyncTransactions = true
1155
1192
 
@@ -1179,6 +1216,28 @@ export class CollectionImpl<
1179
1216
  const rowUpdateMode = this.config.sync.rowUpdateMode || `partial`
1180
1217
 
1181
1218
  for (const transaction of this.pendingSyncedTransactions) {
1219
+ // Handle truncate operations first
1220
+ if (transaction.truncate) {
1221
+ // TRUNCATE PHASE
1222
+ // 1) Emit a delete for every currently-synced key so downstream listeners/indexes
1223
+ // observe a clear-before-rebuild. We intentionally skip keys already in
1224
+ // optimisticDeletes because their delete was previously emitted by the user.
1225
+ for (const key of this.syncedData.keys()) {
1226
+ if (this.optimisticDeletes.has(key)) continue
1227
+ const previousValue =
1228
+ this.optimisticUpserts.get(key) || this.syncedData.get(key)
1229
+ if (previousValue !== undefined) {
1230
+ events.push({ type: `delete`, key, value: previousValue })
1231
+ }
1232
+ }
1233
+
1234
+ // 2) Clear the authoritative synced base. Subsequent server ops in this
1235
+ // same commit will rebuild the base atomically.
1236
+ this.syncedData.clear()
1237
+ this.syncedMetadata.clear()
1238
+ this.syncedKeys.clear()
1239
+ }
1240
+
1182
1241
  for (const operation of transaction.operations) {
1183
1242
  const key = operation.key as TKey
1184
1243
  this.syncedKeys.add(key)
@@ -1228,7 +1287,101 @@ export class CollectionImpl<
1228
1287
  }
1229
1288
  }
1230
1289
 
1231
- // Clear optimistic state since sync operations will now provide the authoritative data
1290
+ // After applying synced operations, if this commit included a truncate,
1291
+ // re-apply optimistic mutations on top of the fresh synced base. This ensures
1292
+ // the UI preserves local intent while respecting server rebuild semantics.
1293
+ // Ordering: deletes (above) -> server ops (just applied) -> optimistic upserts.
1294
+ const hadTruncate = this.pendingSyncedTransactions.some(
1295
+ (t) => t.truncate === true
1296
+ )
1297
+ if (hadTruncate) {
1298
+ // Avoid duplicating keys that were inserted/updated by synced operations in this commit
1299
+ const syncedInsertedOrUpdatedKeys = new Set<TKey>()
1300
+ for (const t of this.pendingSyncedTransactions) {
1301
+ for (const op of t.operations) {
1302
+ if (op.type === `insert` || op.type === `update`) {
1303
+ syncedInsertedOrUpdatedKeys.add(op.key as TKey)
1304
+ }
1305
+ }
1306
+ }
1307
+
1308
+ // Build re-apply sets from ACTIVE optimistic transactions against the new synced base
1309
+ // We do not copy maps; we compute intent directly from transactions to avoid drift.
1310
+ const reapplyUpserts = new Map<TKey, T>()
1311
+ const reapplyDeletes = new Set<TKey>()
1312
+
1313
+ for (const tx of this.transactions.values()) {
1314
+ if ([`completed`, `failed`].includes(tx.state)) continue
1315
+ for (const mutation of tx.mutations) {
1316
+ if (mutation.collection !== this || !mutation.optimistic) continue
1317
+ const key = mutation.key as TKey
1318
+ switch (mutation.type) {
1319
+ case `insert`:
1320
+ reapplyUpserts.set(key, mutation.modified as T)
1321
+ reapplyDeletes.delete(key)
1322
+ break
1323
+ case `update`: {
1324
+ const base = this.syncedData.get(key)
1325
+ const next = base
1326
+ ? (Object.assign({}, base, mutation.changes) as T)
1327
+ : (mutation.modified as T)
1328
+ reapplyUpserts.set(key, next)
1329
+ reapplyDeletes.delete(key)
1330
+ break
1331
+ }
1332
+ case `delete`:
1333
+ reapplyUpserts.delete(key)
1334
+ reapplyDeletes.add(key)
1335
+ break
1336
+ }
1337
+ }
1338
+ }
1339
+
1340
+ // Emit inserts for re-applied upserts, skipping any keys that have an optimistic delete.
1341
+ // If the server also inserted/updated the same key in this batch, override that value
1342
+ // with the optimistic value to preserve local intent.
1343
+ for (const [key, value] of reapplyUpserts) {
1344
+ if (reapplyDeletes.has(key)) continue
1345
+ if (syncedInsertedOrUpdatedKeys.has(key)) {
1346
+ let foundInsert = false
1347
+ for (let i = events.length - 1; i >= 0; i--) {
1348
+ const evt = events[i]!
1349
+ if (evt.key === key && evt.type === `insert`) {
1350
+ evt.value = value
1351
+ foundInsert = true
1352
+ break
1353
+ }
1354
+ }
1355
+ if (!foundInsert) {
1356
+ events.push({ type: `insert`, key, value })
1357
+ }
1358
+ } else {
1359
+ events.push({ type: `insert`, key, value })
1360
+ }
1361
+ }
1362
+
1363
+ // Finally, ensure we do NOT insert keys that have an outstanding optimistic delete.
1364
+ if (events.length > 0 && reapplyDeletes.size > 0) {
1365
+ const filtered: Array<ChangeMessage<T, TKey>> = []
1366
+ for (const evt of events) {
1367
+ if (evt.type === `insert` && reapplyDeletes.has(evt.key)) {
1368
+ continue
1369
+ }
1370
+ filtered.push(evt)
1371
+ }
1372
+ events.length = 0
1373
+ events.push(...filtered)
1374
+ }
1375
+
1376
+ // Ensure listeners are active before emitting this critical batch
1377
+ if (!this.isReady()) {
1378
+ this.setStatus(`ready`)
1379
+ }
1380
+ }
1381
+
1382
+ // Maintain optimistic state appropriately
1383
+ // Clear optimistic state since sync operations will now provide the authoritative data.
1384
+ // Any still-active user transactions will be re-applied below in recompute.
1232
1385
  this.optimisticUpserts.clear()
1233
1386
  this.optimisticDeletes.clear()
1234
1387
 
@@ -1397,8 +1550,8 @@ export class CollectionImpl<
1397
1550
 
1398
1551
  /**
1399
1552
  * Creates an index on a collection for faster queries.
1400
- * Indexes significantly improve query performance by allowing binary search
1401
- * and range queries instead of full scans.
1553
+ * Indexes significantly improve query performance by allowing constant time lookups
1554
+ * and logarithmic time range queries instead of full scans.
1402
1555
  *
1403
1556
  * @template TResolver - The type of the index resolver (constructor or async loader)
1404
1557
  * @param indexCallback - Function that extracts the indexed value from each item
package/src/errors.ts CHANGED
@@ -377,6 +377,12 @@ export class UnknownFunctionError extends QueryCompilationError {
377
377
  }
378
378
  }
379
379
 
380
+ export class JoinCollectionNotFoundError extends QueryCompilationError {
381
+ constructor(collectionId: string) {
382
+ super(`Collection "${collectionId}" not found during compilation of join`)
383
+ }
384
+ }
385
+
380
386
  // JOIN Operation Errors
381
387
  export class JoinError extends TanStackDBError {
382
388
  constructor(message: string) {
@@ -6,6 +6,57 @@ export interface AutoIndexConfig {
6
6
  autoIndex?: `off` | `eager`
7
7
  }
8
8
 
9
+ function shouldAutoIndex(collection: CollectionImpl<any, any, any, any, any>) {
10
+ // Only proceed if auto-indexing is enabled
11
+ if (collection.config.autoIndex !== `eager`) {
12
+ return false
13
+ }
14
+
15
+ // Don't auto-index during sync operations
16
+ if (
17
+ collection.status === `loading` ||
18
+ collection.status === `initialCommit`
19
+ ) {
20
+ return false
21
+ }
22
+
23
+ return true
24
+ }
25
+
26
+ export function ensureIndexForField<
27
+ T extends Record<string, any>,
28
+ TKey extends string | number,
29
+ >(
30
+ fieldName: string,
31
+ fieldPath: Array<string>,
32
+ collection: CollectionImpl<T, TKey, any, any, any>,
33
+ compareFn?: (a: any, b: any) => number
34
+ ) {
35
+ if (!shouldAutoIndex(collection)) {
36
+ return
37
+ }
38
+
39
+ // Check if we already have an index for this field
40
+ const existingIndex = Array.from(collection.indexes.values()).find((index) =>
41
+ index.matchesField(fieldPath)
42
+ )
43
+
44
+ if (existingIndex) {
45
+ return // Index already exists
46
+ }
47
+
48
+ // Create a new index for this field using the collection's createIndex method
49
+ try {
50
+ collection.createIndex((row) => (row as any)[fieldName], {
51
+ name: `auto_${fieldName}`,
52
+ indexType: BTreeIndex,
53
+ options: compareFn ? { compareFn } : {},
54
+ })
55
+ } catch (error) {
56
+ console.warn(`Failed to create auto-index for field "${fieldName}":`, error)
57
+ }
58
+ }
59
+
9
60
  /**
10
61
  * Analyzes a where expression and creates indexes for all simple operations on single fields
11
62
  */
@@ -16,16 +67,7 @@ export function ensureIndexForExpression<
16
67
  expression: BasicExpression,
17
68
  collection: CollectionImpl<T, TKey, any, any, any>
18
69
  ): void {
19
- // Only proceed if auto-indexing is enabled
20
- if (collection.config.autoIndex !== `eager`) {
21
- return
22
- }
23
-
24
- // Don't auto-index during sync operations
25
- if (
26
- collection.status === `loading` ||
27
- collection.status === `initialCommit`
28
- ) {
70
+ if (!shouldAutoIndex(collection)) {
29
71
  return
30
72
  }
31
73
 
@@ -33,27 +75,7 @@ export function ensureIndexForExpression<
33
75
  const indexableExpressions = extractIndexableExpressions(expression)
34
76
 
35
77
  for (const { fieldName, fieldPath } of indexableExpressions) {
36
- // Check if we already have an index for this field
37
- const existingIndex = Array.from(collection.indexes.values()).find(
38
- (index) => index.matchesField(fieldPath)
39
- )
40
-
41
- if (existingIndex) {
42
- continue // Index already exists
43
- }
44
-
45
- // Create a new index for this field using the collection's createIndex method
46
- try {
47
- collection.createIndex((row) => (row as any)[fieldName], {
48
- name: `auto_${fieldName}`,
49
- indexType: BTreeIndex,
50
- })
51
- } catch (error) {
52
- console.warn(
53
- `Failed to create auto-index for field "${fieldName}":`,
54
- error
55
- )
56
- }
78
+ ensureIndexForField(fieldName, fieldPath, collection)
57
79
  }
58
80
  }
59
81
 
@@ -1,6 +1,6 @@
1
1
  import { compileSingleRowExpression } from "../query/compiler/evaluators.js"
2
2
  import { comparisonFunctions } from "../query/builder/functions.js"
3
- import type { BasicExpression } from "../query/ir.js"
3
+ import type { BasicExpression, OrderByDirection } from "../query/ir.js"
4
4
 
5
5
  /**
6
6
  * Operations that indexes can support, imported from available comparison functions
@@ -56,6 +56,11 @@ export abstract class BaseIndex<
56
56
  abstract build(entries: Iterable<[TKey, any]>): void
57
57
  abstract clear(): void
58
58
  abstract lookup(operation: IndexOperation, value: any): Set<TKey>
59
+ abstract take(
60
+ n: number,
61
+ direction?: OrderByDirection,
62
+ from?: TKey
63
+ ): Array<TKey>
59
64
  abstract get keyCount(): number
60
65
 
61
66
  // Common methods
@@ -230,6 +230,35 @@ export class BTreeIndex<
230
230
  return result
231
231
  }
232
232
 
233
+ /**
234
+ * Returns the next n items after the provided item or the first n items if no from item is provided.
235
+ * @param n - The number of items to return
236
+ * @param from - The item to start from (exclusive). Starts from the smallest item (inclusive) if not provided.
237
+ * @returns The next n items after the provided key. Returns the first n items if no from item is provided.
238
+ */
239
+ take(n: number, from?: any): Array<TKey> {
240
+ const keysInResult: Set<TKey> = new Set()
241
+ const result: Array<TKey> = []
242
+ const nextKey = (k?: any) => this.orderedEntries.nextHigherKey(k)
243
+ let key = from
244
+
245
+ while ((key = nextKey(key)) && result.length < n) {
246
+ const keys = this.valueMap.get(key)
247
+ if (keys) {
248
+ const it = keys.values()
249
+ let ks: TKey | undefined
250
+ while (result.length < n && (ks = it.next().value)) {
251
+ if (!keysInResult.has(ks)) {
252
+ result.push(ks)
253
+ keysInResult.add(ks)
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ return result
260
+ }
261
+
233
262
  /**
234
263
  * Performs an IN array lookup
235
264
  */
@@ -8,7 +8,7 @@ export interface IndexOptions<TResolver extends IndexResolver = IndexResolver> {
8
8
  indexType?: TResolver
9
9
  options?: TResolver extends IndexConstructor<any>
10
10
  ? TResolver extends new (
11
- id: string,
11
+ id: number,
12
12
  expr: any,
13
13
  name?: string,
14
14
  options?: infer O
@@ -17,7 +17,7 @@ export interface IndexOptions<TResolver extends IndexResolver = IndexResolver> {
17
17
  : never
18
18
  : TResolver extends () => Promise<infer TCtor>
19
19
  ? TCtor extends new (
20
- id: string,
20
+ id: number,
21
21
  expr: any,
22
22
  name?: string,
23
23
  options?: infer O
@@ -20,9 +20,12 @@ export type CompiledSingleRowExpression = (item: Record<string, unknown>) => any
20
20
  * Compiles an expression into an optimized evaluator function.
21
21
  * This eliminates branching during evaluation by pre-compiling the expression structure.
22
22
  */
23
- export function compileExpression(expr: BasicExpression): CompiledExpression {
24
- const compiledFn = compileExpressionInternal(expr, false)
25
- return compiledFn as CompiledExpression
23
+ export function compileExpression(
24
+ expr: BasicExpression,
25
+ isSingleRow: boolean = false
26
+ ): CompiledExpression | CompiledSingleRowExpression {
27
+ const compiledFn = compileExpressionInternal(expr, isSingleRow)
28
+ return compiledFn
26
29
  }
27
30
 
28
31
  /**
@@ -166,7 +166,9 @@ export function processGroupBy(
166
166
  const mapping = validateAndCreateMapping(groupByClause, selectClause)
167
167
 
168
168
  // Pre-compile groupBy expressions
169
- const compiledGroupByExpressions = groupByClause.map(compileExpression)
169
+ const compiledGroupByExpressions = groupByClause.map((e) =>
170
+ compileExpression(e)
171
+ )
170
172
 
171
173
  // Create a key extractor function using simple __key_X format
172
174
  const keyExtractor = ([, row]: [
@@ -7,17 +7,21 @@ import {
7
7
  LimitOffsetRequireOrderByError,
8
8
  UnsupportedFromTypeError,
9
9
  } from "../../errors.js"
10
+ import { PropRef } from "../ir.js"
10
11
  import { compileExpression } from "./evaluators.js"
11
12
  import { processJoins } from "./joins.js"
12
13
  import { processGroupBy } from "./group-by.js"
13
14
  import { processOrderBy } from "./order-by.js"
14
15
  import { processSelectToResults } from "./select.js"
16
+ import type { OrderByOptimizationInfo } from "./order-by.js"
15
17
  import type {
16
18
  BasicExpression,
17
19
  CollectionRef,
18
20
  QueryIR,
19
21
  QueryRef,
20
22
  } from "../ir.js"
23
+ import type { LazyCollectionCallbacks } from "./joins.js"
24
+ import type { Collection } from "../../collection.js"
21
25
  import type {
22
26
  KeyedStream,
23
27
  NamespacedAndKeyedStream,
@@ -29,6 +33,8 @@ import type { QueryCache, QueryMapping } from "./types.js"
29
33
  * Result of query compilation including both the pipeline and collection-specific WHERE clauses
30
34
  */
31
35
  export interface CompilationResult {
36
+ /** The ID of the main collection */
37
+ collectionId: string
32
38
  /** The compiled query pipeline */
33
39
  pipeline: ResultStream
34
40
  /** Map of collection aliases to their WHERE clauses for index optimization */
@@ -46,6 +52,10 @@ export interface CompilationResult {
46
52
  export function compileQuery(
47
53
  rawQuery: QueryIR,
48
54
  inputs: Record<string, KeyedStream>,
55
+ collections: Record<string, Collection<any, any, any, any, any>>,
56
+ callbacks: Record<string, LazyCollectionCallbacks>,
57
+ lazyCollections: Set<string>,
58
+ optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
49
59
  cache: QueryCache = new WeakMap(),
50
60
  queryMapping: QueryMapping = new WeakMap()
51
61
  ): CompilationResult {
@@ -70,9 +80,17 @@ export function compileQuery(
70
80
  const tables: Record<string, KeyedStream> = {}
71
81
 
72
82
  // Process the FROM clause to get the main table
73
- const { alias: mainTableAlias, input: mainInput } = processFrom(
83
+ const {
84
+ alias: mainTableAlias,
85
+ input: mainInput,
86
+ collectionId: mainCollectionId,
87
+ } = processFrom(
74
88
  query.from,
75
89
  allInputs,
90
+ collections,
91
+ callbacks,
92
+ lazyCollections,
93
+ optimizableOrderByCollections,
76
94
  cache,
77
95
  queryMapping
78
96
  )
@@ -96,10 +114,16 @@ export function compileQuery(
96
114
  pipeline,
97
115
  query.join,
98
116
  tables,
117
+ mainCollectionId,
99
118
  mainTableAlias,
100
119
  allInputs,
101
120
  cache,
102
- queryMapping
121
+ queryMapping,
122
+ collections,
123
+ callbacks,
124
+ lazyCollections,
125
+ optimizableOrderByCollections,
126
+ rawQuery
103
127
  )
104
128
  }
105
129
 
@@ -231,8 +255,11 @@ export function compileQuery(
231
255
  // Process orderBy parameter if it exists
232
256
  if (query.orderBy && query.orderBy.length > 0) {
233
257
  const orderedPipeline = processOrderBy(
258
+ rawQuery,
234
259
  pipeline,
235
260
  query.orderBy,
261
+ collections[mainCollectionId]!,
262
+ optimizableOrderByCollections,
236
263
  query.limit,
237
264
  query.offset
238
265
  )
@@ -249,6 +276,7 @@ export function compileQuery(
249
276
  const result = resultPipeline
250
277
  // Cache the result before returning (use original query as key)
251
278
  const compilationResult = {
279
+ collectionId: mainCollectionId,
252
280
  pipeline: result,
253
281
  collectionWhereClauses,
254
282
  }
@@ -275,6 +303,7 @@ export function compileQuery(
275
303
  const result = resultPipeline
276
304
  // Cache the result before returning (use original query as key)
277
305
  const compilationResult = {
306
+ collectionId: mainCollectionId,
278
307
  pipeline: result,
279
308
  collectionWhereClauses,
280
309
  }
@@ -289,16 +318,20 @@ export function compileQuery(
289
318
  function processFrom(
290
319
  from: CollectionRef | QueryRef,
291
320
  allInputs: Record<string, KeyedStream>,
321
+ collections: Record<string, Collection>,
322
+ callbacks: Record<string, LazyCollectionCallbacks>,
323
+ lazyCollections: Set<string>,
324
+ optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
292
325
  cache: QueryCache,
293
326
  queryMapping: QueryMapping
294
- ): { alias: string; input: KeyedStream } {
327
+ ): { alias: string; input: KeyedStream; collectionId: string } {
295
328
  switch (from.type) {
296
329
  case `collectionRef`: {
297
330
  const input = allInputs[from.collection.id]
298
331
  if (!input) {
299
332
  throw new CollectionInputNotFoundError(from.collection.id)
300
333
  }
301
- return { alias: from.alias, input }
334
+ return { alias: from.alias, input, collectionId: from.collection.id }
302
335
  }
303
336
  case `queryRef`: {
304
337
  // Find the original query for caching purposes
@@ -308,6 +341,10 @@ function processFrom(
308
341
  const subQueryResult = compileQuery(
309
342
  originalQuery,
310
343
  allInputs,
344
+ collections,
345
+ callbacks,
346
+ lazyCollections,
347
+ optimizableOrderByCollections,
311
348
  cache,
312
349
  queryMapping
313
350
  )
@@ -324,7 +361,11 @@ function processFrom(
324
361
  })
325
362
  )
326
363
 
327
- return { alias: from.alias, input: extractedInput }
364
+ return {
365
+ alias: from.alias,
366
+ input: extractedInput,
367
+ collectionId: subQueryResult.collectionId,
368
+ }
328
369
  }
329
370
  default:
330
371
  throw new UnsupportedFromTypeError((from as any).type)
@@ -380,3 +421,69 @@ function mapNestedQueries(
380
421
  }
381
422
  }
382
423
  }
424
+
425
+ function getRefFromAlias(
426
+ query: QueryIR,
427
+ alias: string
428
+ ): CollectionRef | QueryRef | void {
429
+ if (query.from.alias === alias) {
430
+ return query.from
431
+ }
432
+
433
+ for (const join of query.join || []) {
434
+ if (join.from.alias === alias) {
435
+ return join.from
436
+ }
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Follows the given reference in a query
442
+ * until its finds the root field the reference points to.
443
+ * @returns The collection, its alias, and the path to the root field in this collection
444
+ */
445
+ export function followRef(
446
+ query: QueryIR,
447
+ ref: PropRef<any>,
448
+ collection: Collection
449
+ ): { collection: Collection; path: Array<string> } | void {
450
+ if (ref.path.length === 0) {
451
+ return
452
+ }
453
+
454
+ if (ref.path.length === 1) {
455
+ // This field should be part of this collection
456
+ const field = ref.path[0]!
457
+ // is it part of the select clause?
458
+ if (query.select) {
459
+ const selectedField = query.select[field]
460
+ if (selectedField && selectedField.type === `ref`) {
461
+ return followRef(query, selectedField, collection)
462
+ }
463
+ }
464
+
465
+ // Either this field is not part of the select clause
466
+ // and thus it must be part of the collection itself
467
+ // or it is part of the select but is not a reference
468
+ // so we can stop here and don't have to follow it
469
+ return { collection, path: [field] }
470
+ }
471
+
472
+ if (ref.path.length > 1) {
473
+ // This is a nested field
474
+ const [alias, ...rest] = ref.path
475
+ const aliasRef = getRefFromAlias(query, alias!)
476
+ if (!aliasRef) {
477
+ return
478
+ }
479
+
480
+ if (aliasRef.type === `queryRef`) {
481
+ return followRef(aliasRef.query, new PropRef(rest), collection)
482
+ } else {
483
+ // This is a reference to a collection
484
+ // we can't follow it further
485
+ // so the field must be on the collection itself
486
+ return { collection: aliasRef.collection, path: rest }
487
+ }
488
+ }
489
+ }