@tanstack/db 0.5.1 → 0.5.3

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.
@@ -152,6 +152,43 @@ function generateUuid(): string {
152
152
  return crypto.randomUUID()
153
153
  }
154
154
 
155
+ /**
156
+ * Encodes a key (string or number) into a storage-safe string format.
157
+ * This prevents collisions between numeric and string keys by prefixing with type information.
158
+ *
159
+ * Examples:
160
+ * - number 1 → "n:1"
161
+ * - string "1" → "s:1"
162
+ * - string "n:1" → "s:n:1"
163
+ *
164
+ * @param key - The key to encode (string or number)
165
+ * @returns Type-prefixed string that is safe for storage
166
+ */
167
+ function encodeStorageKey(key: string | number): string {
168
+ if (typeof key === `number`) {
169
+ return `n:${key}`
170
+ }
171
+ return `s:${key}`
172
+ }
173
+
174
+ /**
175
+ * Decodes a storage key back to its original form.
176
+ * This is the inverse of encodeStorageKey.
177
+ *
178
+ * @param encodedKey - The encoded key from storage
179
+ * @returns The original key (string or number)
180
+ */
181
+ function decodeStorageKey(encodedKey: string): string | number {
182
+ if (encodedKey.startsWith(`n:`)) {
183
+ return Number(encodedKey.slice(2))
184
+ }
185
+ if (encodedKey.startsWith(`s:`)) {
186
+ return encodedKey.slice(2)
187
+ }
188
+ // Fallback for legacy data without encoding
189
+ return encodedKey
190
+ }
191
+
155
192
  /**
156
193
  * Creates an in-memory storage implementation that mimics the StorageApi interface
157
194
  * Used as a fallback when localStorage is not available (e.g., server-side rendering)
@@ -365,7 +402,7 @@ export function localStorageCollectionOptions(
365
402
  // Convert Map to object format for storage
366
403
  const objectData: Record<string, StoredItem<any>> = {}
367
404
  dataMap.forEach((storedItem, key) => {
368
- objectData[String(key)] = storedItem
405
+ objectData[encodeStorageKey(key)] = storedItem
369
406
  })
370
407
  const serialized = parser.stringify(objectData)
371
408
  storage.setItem(config.storageKey, serialized)
@@ -415,12 +452,11 @@ export function localStorageCollectionOptions(
415
452
  // Add new items with version keys
416
453
  params.transaction.mutations.forEach((mutation) => {
417
454
  // Use the engine's pre-computed key for consistency
418
- const key = mutation.key
419
455
  const storedItem: StoredItem<any> = {
420
456
  versionKey: generateUuid(),
421
457
  data: mutation.modified,
422
458
  }
423
- lastKnownData.set(key, storedItem)
459
+ lastKnownData.set(mutation.key, storedItem)
424
460
  })
425
461
 
426
462
  // Save to storage
@@ -450,12 +486,11 @@ export function localStorageCollectionOptions(
450
486
  // Update items with new version keys
451
487
  params.transaction.mutations.forEach((mutation) => {
452
488
  // Use the engine's pre-computed key for consistency
453
- const key = mutation.key
454
489
  const storedItem: StoredItem<any> = {
455
490
  versionKey: generateUuid(),
456
491
  data: mutation.modified,
457
492
  }
458
- lastKnownData.set(key, storedItem)
493
+ lastKnownData.set(mutation.key, storedItem)
459
494
  })
460
495
 
461
496
  // Save to storage
@@ -480,8 +515,7 @@ export function localStorageCollectionOptions(
480
515
  // Remove items
481
516
  params.transaction.mutations.forEach((mutation) => {
482
517
  // Use the engine's pre-computed key for consistency
483
- const key = mutation.key
484
- lastKnownData.delete(key)
518
+ lastKnownData.delete(mutation.key)
485
519
  })
486
520
 
487
521
  // Save to storage
@@ -547,8 +581,6 @@ export function localStorageCollectionOptions(
547
581
  // Apply each mutation
548
582
  for (const mutation of collectionMutations) {
549
583
  // Use the engine's pre-computed key to avoid key derivation issues
550
- const key = mutation.key
551
-
552
584
  switch (mutation.type) {
553
585
  case `insert`:
554
586
  case `update`: {
@@ -556,11 +588,11 @@ export function localStorageCollectionOptions(
556
588
  versionKey: generateUuid(),
557
589
  data: mutation.modified,
558
590
  }
559
- lastKnownData.set(key, storedItem)
591
+ lastKnownData.set(mutation.key, storedItem)
560
592
  break
561
593
  }
562
594
  case `delete`: {
563
- lastKnownData.delete(key)
595
+ lastKnownData.delete(mutation.key)
564
596
  break
565
597
  }
566
598
  }
@@ -616,7 +648,7 @@ function loadFromStorage<T extends object>(
616
648
  parsed !== null &&
617
649
  !Array.isArray(parsed)
618
650
  ) {
619
- Object.entries(parsed).forEach(([key, value]) => {
651
+ Object.entries(parsed).forEach(([encodedKey, value]) => {
620
652
  // Runtime check to ensure the value has the expected StoredItem structure
621
653
  if (
622
654
  value &&
@@ -625,9 +657,10 @@ function loadFromStorage<T extends object>(
625
657
  `data` in value
626
658
  ) {
627
659
  const storedItem = value as StoredItem<T>
628
- dataMap.set(key, storedItem)
660
+ const decodedKey = decodeStorageKey(encodedKey)
661
+ dataMap.set(decodedKey, storedItem)
629
662
  } else {
630
- throw new InvalidStorageDataFormatError(storageKey, key)
663
+ throw new InvalidStorageDataFormatError(storageKey, encodedKey)
631
664
  }
632
665
  })
633
666
  } else {
@@ -2,61 +2,26 @@ import { Func, PropRef, Value } from "../ir.js"
2
2
  import type { BasicExpression, OrderBy } from "../ir.js"
3
3
 
4
4
  /**
5
- * Functions supported by the collection index system.
6
- * These are the only functions that can be used in WHERE clauses
7
- * that are pushed down to collection subscriptions for index optimization.
8
- */
9
- export const SUPPORTED_COLLECTION_FUNCS = new Set([
10
- `eq`,
11
- `gt`,
12
- `lt`,
13
- `gte`,
14
- `lte`,
15
- `and`,
16
- `or`,
17
- `in`,
18
- `isNull`,
19
- `isUndefined`,
20
- `not`,
21
- ])
22
-
23
- /**
24
- * Determines if a WHERE clause can be converted to collection-compatible BasicExpression format.
25
- * This checks if the expression only uses functions supported by the collection index system.
5
+ * Normalizes a WHERE clause expression by removing table aliases from property references.
26
6
  *
27
- * @param whereClause - The WHERE clause to check
28
- * @returns True if the clause can be converted for collection index optimization
29
- */
30
- export function isConvertibleToCollectionFilter(
31
- whereClause: BasicExpression<boolean>
32
- ): boolean {
33
- const tpe = whereClause.type
34
- if (tpe === `func`) {
35
- // Check if this function is supported
36
- if (!SUPPORTED_COLLECTION_FUNCS.has(whereClause.name)) {
37
- return false
38
- }
39
- // Recursively check all arguments
40
- return whereClause.args.every((arg) =>
41
- isConvertibleToCollectionFilter(arg as BasicExpression<boolean>)
42
- )
43
- }
44
- return [`val`, `ref`].includes(tpe)
45
- }
46
-
47
- /**
48
- * Converts a WHERE clause to BasicExpression format compatible with collection indexes.
49
- * This function creates proper BasicExpression class instances that the collection
50
- * index system can understand.
7
+ * This function recursively traverses an expression tree and creates new BasicExpression
8
+ * instances with normalized paths. The main transformation is removing the collection alias
9
+ * from property reference paths (e.g., `['user', 'id']` becomes `['id']` when `collectionAlias`
10
+ * is `'user'`), which is needed when converting query-level expressions to collection-level
11
+ * expressions for subscriptions.
51
12
  *
52
- * @param whereClause - The WHERE clause to convert
53
- * @param collectionAlias - The alias of the collection being filtered
54
- * @returns The converted BasicExpression or null if conversion fails
13
+ * @param whereClause - The WHERE clause expression to normalize
14
+ * @param collectionAlias - The alias of the collection being filtered (to strip from paths)
15
+ * @returns A new BasicExpression with normalized paths
16
+ *
17
+ * @example
18
+ * // Input: ref with path ['user', 'id'] where collectionAlias is 'user'
19
+ * // Output: ref with path ['id']
55
20
  */
56
- export function convertToBasicExpression(
21
+ export function normalizeExpressionPaths(
57
22
  whereClause: BasicExpression<boolean>,
58
23
  collectionAlias: string
59
- ): BasicExpression<boolean> | null {
24
+ ): BasicExpression<boolean> {
60
25
  const tpe = whereClause.type
61
26
  if (tpe === `val`) {
62
27
  return new Value(whereClause.value)
@@ -74,42 +39,29 @@ export function convertToBasicExpression(
74
39
  // Fallback for non-array paths
75
40
  return new PropRef(Array.isArray(path) ? path : [String(path)])
76
41
  } else {
77
- // Check if this function is supported
78
- if (!SUPPORTED_COLLECTION_FUNCS.has(whereClause.name)) {
79
- return null
80
- }
81
42
  // Recursively convert all arguments
82
43
  const args: Array<BasicExpression> = []
83
44
  for (const arg of whereClause.args) {
84
- const convertedArg = convertToBasicExpression(
45
+ const convertedArg = normalizeExpressionPaths(
85
46
  arg as BasicExpression<boolean>,
86
47
  collectionAlias
87
48
  )
88
- if (convertedArg == null) {
89
- return null
90
- }
91
49
  args.push(convertedArg)
92
50
  }
93
51
  return new Func(whereClause.name, args)
94
52
  }
95
53
  }
96
54
 
97
- export function convertOrderByToBasicExpression(
55
+ export function normalizeOrderByPaths(
98
56
  orderBy: OrderBy,
99
57
  collectionAlias: string
100
58
  ): OrderBy {
101
59
  const normalizedOrderBy = orderBy.map((clause) => {
102
- const basicExp = convertToBasicExpression(
60
+ const basicExp = normalizeExpressionPaths(
103
61
  clause.expression,
104
62
  collectionAlias
105
63
  )
106
64
 
107
- if (!basicExp) {
108
- throw new Error(
109
- `Failed to convert orderBy expression to a basic expression: ${clause.expression}`
110
- )
111
- }
112
-
113
65
  return {
114
66
  ...clause,
115
67
  expression: basicExp,
@@ -1,9 +1,8 @@
1
1
  import { MultiSet } from "@tanstack/db-ivm"
2
2
  import {
3
- convertOrderByToBasicExpression,
4
- convertToBasicExpression,
3
+ normalizeExpressionPaths,
4
+ normalizeOrderByPaths,
5
5
  } from "../compiler/expressions.js"
6
- import { WhereClauseConversionError } from "../../errors.js"
7
6
  import type { MultiSetArray, RootStreamBuilder } from "@tanstack/db-ivm"
8
7
  import type { Collection } from "../../collection/index.js"
9
8
  import type { ChangeMessage } from "../../types.js"
@@ -41,13 +40,8 @@ export class CollectionSubscriber<
41
40
  const whereClause = this.getWhereClauseForAlias()
42
41
 
43
42
  if (whereClause) {
44
- const whereExpression = convertToBasicExpression(whereClause, this.alias)
45
-
46
- if (whereExpression) {
47
- return this.subscribeToChanges(whereExpression)
48
- }
49
-
50
- throw new WhereClauseConversionError(this.collectionId, this.alias)
43
+ const whereExpression = normalizeExpressionPaths(whereClause, this.alias)
44
+ return this.subscribeToChanges(whereExpression)
51
45
  }
52
46
 
53
47
  return this.subscribeToChanges()
@@ -199,10 +193,7 @@ export class CollectionSubscriber<
199
193
  subscription.setOrderByIndex(index)
200
194
 
201
195
  // Normalize the orderBy clauses such that the references are relative to the collection
202
- const normalizedOrderBy = convertOrderByToBasicExpression(
203
- orderBy,
204
- this.alias
205
- )
196
+ const normalizedOrderBy = normalizeOrderByPaths(orderBy, this.alias)
206
197
 
207
198
  // Load the first `offset + limit` values from the index
208
199
  // i.e. the K items from the collection that fall into the requested range: [offset, offset + limit[
@@ -289,10 +280,7 @@ export class CollectionSubscriber<
289
280
  : biggestSentRow
290
281
 
291
282
  // Normalize the orderBy clauses such that the references are relative to the collection
292
- const normalizedOrderBy = convertOrderByToBasicExpression(
293
- orderBy,
294
- this.alias
295
- )
283
+ const normalizedOrderBy = normalizeOrderByPaths(orderBy, this.alias)
296
284
 
297
285
  // Take the `n` items after the biggest sent value
298
286
  subscription.requestLimitedSnapshot({
@@ -131,7 +131,6 @@ import {
131
131
  getWhereExpression,
132
132
  isResidualWhere,
133
133
  } from "./ir.js"
134
- import { isConvertibleToCollectionFilter } from "./compiler/expressions.js"
135
134
  import type { BasicExpression, From, QueryIR, Select, Where } from "./ir.js"
136
135
 
137
136
  /**
@@ -248,14 +247,10 @@ function extractSourceWhereClauses(
248
247
  const groupedClauses = groupWhereClauses(analyzedClauses)
249
248
 
250
249
  // Only include single-source clauses that reference collections directly
251
- // and can be converted to BasicExpression format for collection indexes
252
250
  for (const [sourceAlias, whereClause] of groupedClauses.singleSource) {
253
251
  // Check if this source alias corresponds to a collection reference
254
252
  if (isCollectionReference(query, sourceAlias)) {
255
- // Check if the WHERE clause can be converted to collection-compatible format
256
- if (isConvertibleToCollectionFilter(whereClause)) {
257
- sourceWhereClauses.set(sourceAlias, whereClause)
258
- }
253
+ sourceWhereClauses.set(sourceAlias, whereClause)
259
254
  }
260
255
  }
261
256
 
package/src/types.ts CHANGED
@@ -139,7 +139,26 @@ export type NonEmptyArray<T> = [T, ...Array<T>]
139
139
  export type TransactionWithMutations<
140
140
  T extends object = Record<string, unknown>,
141
141
  TOperation extends OperationType = OperationType,
142
- > = Transaction<T> & {
142
+ > = Omit<Transaction<T>, `mutations`> & {
143
+ /**
144
+ * We must omit the `mutations` property from `Transaction<T>` before intersecting
145
+ * because TypeScript intersects property types when the same property appears on
146
+ * both sides of an intersection.
147
+ *
148
+ * Without `Omit`:
149
+ * - `Transaction<T>` has `mutations: Array<PendingMutation<T>>`
150
+ * - The intersection would create: `Array<PendingMutation<T>> & NonEmptyArray<PendingMutation<T, TOperation>>`
151
+ * - When mapping over this array, TypeScript widens `TOperation` from the specific literal
152
+ * (e.g., `"delete"`) to the union `OperationType` (`"insert" | "update" | "delete"`)
153
+ * - This causes `PendingMutation<T, OperationType>` to evaluate the conditional type
154
+ * `original: TOperation extends 'insert' ? {} : T` as `{} | T` instead of just `T`
155
+ *
156
+ * With `Omit`:
157
+ * - We remove `mutations` from `Transaction<T>` first
158
+ * - Then add back `mutations: NonEmptyArray<PendingMutation<T, TOperation>>`
159
+ * - TypeScript can properly narrow `TOperation` to the specific literal type
160
+ * - This ensures `mutation.original` is correctly typed as `T` (not `{} | T`) when mapping
161
+ */
143
162
  mutations: NonEmptyArray<PendingMutation<T, TOperation>>
144
163
  }
145
164