@tanstack/db 0.4.16 → 0.4.18
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.
- package/dist/cjs/collection/changes.cjs +1 -1
- package/dist/cjs/collection/changes.cjs.map +1 -1
- package/dist/cjs/collection/subscription.cjs +55 -5
- package/dist/cjs/collection/subscription.cjs.map +1 -1
- package/dist/cjs/collection/subscription.d.cts +5 -2
- package/dist/cjs/duplicate-instance-check.d.cts +1 -0
- package/dist/cjs/errors.cjs +46 -0
- package/dist/cjs/errors.cjs.map +1 -1
- package/dist/cjs/errors.d.cts +14 -0
- package/dist/cjs/index.cjs +3 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/optimistic-action.cjs +6 -1
- package/dist/cjs/optimistic-action.cjs.map +1 -1
- package/dist/cjs/paced-mutations.cjs.map +1 -1
- package/dist/cjs/paced-mutations.d.cts +2 -2
- package/dist/cjs/query/compiler/index.cjs +40 -0
- package/dist/cjs/query/compiler/index.cjs.map +1 -1
- package/dist/cjs/query/compiler/order-by.cjs +3 -6
- package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
- package/dist/cjs/query/live/collection-subscriber.cjs +20 -34
- package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
- package/dist/cjs/strategies/debounceStrategy.cjs.map +1 -1
- package/dist/cjs/strategies/debounceStrategy.d.cts +4 -1
- package/dist/cjs/strategies/throttleStrategy.cjs.map +1 -1
- package/dist/cjs/strategies/throttleStrategy.d.cts +8 -2
- package/dist/cjs/transactions.cjs +3 -1
- package/dist/cjs/transactions.cjs.map +1 -1
- package/dist/cjs/transactions.d.cts +3 -1
- package/dist/cjs/utils/type-guards.cjs +7 -0
- package/dist/cjs/utils/type-guards.cjs.map +1 -0
- package/dist/cjs/utils/type-guards.d.cts +6 -0
- package/dist/esm/collection/changes.js +1 -1
- package/dist/esm/collection/changes.js.map +1 -1
- package/dist/esm/collection/subscription.d.ts +5 -2
- package/dist/esm/collection/subscription.js +56 -6
- package/dist/esm/collection/subscription.js.map +1 -1
- package/dist/esm/duplicate-instance-check.d.ts +1 -0
- package/dist/esm/errors.d.ts +14 -0
- package/dist/esm/errors.js +46 -0
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.js +4 -1
- package/dist/esm/optimistic-action.js +6 -1
- package/dist/esm/optimistic-action.js.map +1 -1
- package/dist/esm/paced-mutations.d.ts +2 -2
- package/dist/esm/paced-mutations.js.map +1 -1
- package/dist/esm/query/compiler/index.js +41 -1
- package/dist/esm/query/compiler/index.js.map +1 -1
- package/dist/esm/query/compiler/order-by.js +3 -6
- package/dist/esm/query/compiler/order-by.js.map +1 -1
- package/dist/esm/query/live/collection-subscriber.js +20 -34
- package/dist/esm/query/live/collection-subscriber.js.map +1 -1
- package/dist/esm/strategies/debounceStrategy.d.ts +4 -1
- package/dist/esm/strategies/debounceStrategy.js.map +1 -1
- package/dist/esm/strategies/throttleStrategy.d.ts +8 -2
- package/dist/esm/strategies/throttleStrategy.js.map +1 -1
- package/dist/esm/transactions.d.ts +3 -1
- package/dist/esm/transactions.js +3 -1
- package/dist/esm/transactions.js.map +1 -1
- package/dist/esm/utils/type-guards.d.ts +6 -0
- package/dist/esm/utils/type-guards.js +7 -0
- package/dist/esm/utils/type-guards.js.map +1 -0
- package/package.json +1 -1
- package/src/collection/changes.ts +1 -1
- package/src/collection/subscription.ts +82 -6
- package/src/duplicate-instance-check.ts +32 -0
- package/src/errors.ts +51 -0
- package/src/optimistic-action.ts +7 -2
- package/src/paced-mutations.ts +2 -2
- package/src/query/compiler/index.ts +74 -0
- package/src/query/compiler/order-by.ts +3 -5
- package/src/query/live/collection-subscriber.ts +26 -72
- package/src/strategies/debounceStrategy.ts +4 -1
- package/src/strategies/throttleStrategy.ts +8 -2
- package/src/transactions.ts +5 -1
- package/src/utils/type-guards.ts +12 -0
|
@@ -3,6 +3,7 @@ import { optimizeQuery } from "../optimizer.js"
|
|
|
3
3
|
import {
|
|
4
4
|
CollectionInputNotFoundError,
|
|
5
5
|
DistinctRequiresSelectError,
|
|
6
|
+
DuplicateAliasInSubqueryError,
|
|
6
7
|
HavingRequiresGroupByError,
|
|
7
8
|
LimitOffsetRequireOrderByError,
|
|
8
9
|
UnsupportedFromTypeError,
|
|
@@ -99,6 +100,11 @@ export function compileQuery(
|
|
|
99
100
|
return cachedResult
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
// Validate the raw query BEFORE optimization to check user's original structure.
|
|
104
|
+
// This must happen before optimization because the optimizer may create internal
|
|
105
|
+
// subqueries (e.g., for predicate pushdown) that reuse aliases, which is fine.
|
|
106
|
+
validateQueryStructure(rawQuery)
|
|
107
|
+
|
|
102
108
|
// Optimize the query before compilation
|
|
103
109
|
const { optimizedQuery: query, sourceWhereClauses } = optimizeQuery(rawQuery)
|
|
104
110
|
|
|
@@ -375,6 +381,74 @@ export function compileQuery(
|
|
|
375
381
|
return compilationResult
|
|
376
382
|
}
|
|
377
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Collects aliases used for DIRECT collection references (not subqueries).
|
|
386
|
+
* Used to validate that subqueries don't reuse parent query collection aliases.
|
|
387
|
+
* Only direct CollectionRef aliases matter - QueryRef aliases don't cause conflicts.
|
|
388
|
+
*/
|
|
389
|
+
function collectDirectCollectionAliases(query: QueryIR): Set<string> {
|
|
390
|
+
const aliases = new Set<string>()
|
|
391
|
+
|
|
392
|
+
// Collect FROM alias only if it's a direct collection reference
|
|
393
|
+
if (query.from.type === `collectionRef`) {
|
|
394
|
+
aliases.add(query.from.alias)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Collect JOIN aliases only for direct collection references
|
|
398
|
+
if (query.join) {
|
|
399
|
+
for (const joinClause of query.join) {
|
|
400
|
+
if (joinClause.from.type === `collectionRef`) {
|
|
401
|
+
aliases.add(joinClause.from.alias)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return aliases
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Validates the structure of a query and its subqueries.
|
|
411
|
+
* Checks that subqueries don't reuse collection aliases from parent queries.
|
|
412
|
+
* This must be called on the RAW query before optimization.
|
|
413
|
+
*/
|
|
414
|
+
function validateQueryStructure(
|
|
415
|
+
query: QueryIR,
|
|
416
|
+
parentCollectionAliases: Set<string> = new Set()
|
|
417
|
+
): void {
|
|
418
|
+
// Collect direct collection aliases from this query level
|
|
419
|
+
const currentLevelAliases = collectDirectCollectionAliases(query)
|
|
420
|
+
|
|
421
|
+
// Check if any current alias conflicts with parent aliases
|
|
422
|
+
for (const alias of currentLevelAliases) {
|
|
423
|
+
if (parentCollectionAliases.has(alias)) {
|
|
424
|
+
throw new DuplicateAliasInSubqueryError(
|
|
425
|
+
alias,
|
|
426
|
+
Array.from(parentCollectionAliases)
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Combine parent and current aliases for checking nested subqueries
|
|
432
|
+
const combinedAliases = new Set([
|
|
433
|
+
...parentCollectionAliases,
|
|
434
|
+
...currentLevelAliases,
|
|
435
|
+
])
|
|
436
|
+
|
|
437
|
+
// Recursively validate FROM subquery
|
|
438
|
+
if (query.from.type === `queryRef`) {
|
|
439
|
+
validateQueryStructure(query.from.query, combinedAliases)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Recursively validate JOIN subqueries
|
|
443
|
+
if (query.join) {
|
|
444
|
+
for (const joinClause of query.join) {
|
|
445
|
+
if (joinClause.from.type === `queryRef`) {
|
|
446
|
+
validateQueryStructure(joinClause.from.query, combinedAliases)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
378
452
|
/**
|
|
379
453
|
* Processes the FROM clause, handling direct collection references and subqueries.
|
|
380
454
|
* Populates `aliasToCollectionId` and `aliasRemapping` for per-alias subscription tracking.
|
|
@@ -179,13 +179,11 @@ export function processOrderBy(
|
|
|
179
179
|
orderByOptimizationInfo
|
|
180
180
|
|
|
181
181
|
setSizeCallback = (getSize: () => number) => {
|
|
182
|
-
optimizableOrderByCollections[followRefCollection.id] =
|
|
183
|
-
|
|
184
|
-
dataNeeded: () => {
|
|
182
|
+
optimizableOrderByCollections[followRefCollection.id]![`dataNeeded`] =
|
|
183
|
+
() => {
|
|
185
184
|
const size = getSize()
|
|
186
185
|
return Math.max(0, orderByOptimizationInfo!.limit - size)
|
|
187
|
-
}
|
|
188
|
-
}
|
|
186
|
+
}
|
|
189
187
|
}
|
|
190
188
|
}
|
|
191
189
|
}
|
|
@@ -73,29 +73,33 @@ export class CollectionSubscriber<
|
|
|
73
73
|
)
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
const trackLoadPromise = () => {
|
|
77
|
+
// Guard against duplicate transitions
|
|
78
|
+
if (!this.subscriptionLoadingPromises.has(subscription)) {
|
|
79
|
+
let resolve: () => void
|
|
80
|
+
const promise = new Promise<void>((res) => {
|
|
81
|
+
resolve = res
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
this.subscriptionLoadingPromises.set(subscription, {
|
|
85
|
+
resolve: resolve!,
|
|
86
|
+
})
|
|
87
|
+
this.collectionConfigBuilder.liveQueryCollection!._sync.trackLoadPromise(
|
|
88
|
+
promise
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// It can be that we are not yet subscribed when the first `loadSubset` call happens (i.e. the initial query).
|
|
94
|
+
// So we also check the status here and if it's `loadingSubset` then we track the load promise
|
|
95
|
+
if (subscription.status === `loadingSubset`) {
|
|
96
|
+
trackLoadPromise()
|
|
97
|
+
}
|
|
98
|
+
|
|
76
99
|
// Subscribe to subscription status changes to propagate loading state
|
|
77
100
|
const statusUnsubscribe = subscription.on(`status:change`, (event) => {
|
|
78
|
-
// TODO: For now we are setting this loading state whenever the subscription
|
|
79
|
-
// status changes to 'loadingSubset'. But we have discussed it only happening
|
|
80
|
-
// when the the live query has it's offset/limit changed, and that triggers the
|
|
81
|
-
// subscription to request a snapshot. This will require more work to implement,
|
|
82
|
-
// and builds on https://github.com/TanStack/db/pull/663 which this PR
|
|
83
|
-
// does not yet depend on.
|
|
84
101
|
if (event.status === `loadingSubset`) {
|
|
85
|
-
|
|
86
|
-
if (!this.subscriptionLoadingPromises.has(subscription)) {
|
|
87
|
-
let resolve: () => void
|
|
88
|
-
const promise = new Promise<void>((res) => {
|
|
89
|
-
resolve = res
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
this.subscriptionLoadingPromises.set(subscription, {
|
|
93
|
-
resolve: resolve!,
|
|
94
|
-
})
|
|
95
|
-
this.collectionConfigBuilder.liveQueryCollection!._sync.trackLoadPromise(
|
|
96
|
-
promise
|
|
97
|
-
)
|
|
98
|
-
}
|
|
102
|
+
trackLoadPromise()
|
|
99
103
|
} else {
|
|
100
104
|
// status is 'ready'
|
|
101
105
|
const deferred = this.subscriptionLoadingPromises.get(subscription)
|
|
@@ -176,30 +180,14 @@ export class CollectionSubscriber<
|
|
|
176
180
|
whereExpression: BasicExpression<boolean> | undefined,
|
|
177
181
|
orderByInfo: OrderByOptimizationInfo
|
|
178
182
|
) {
|
|
179
|
-
const { orderBy, offset, limit,
|
|
180
|
-
orderByInfo
|
|
183
|
+
const { orderBy, offset, limit, index } = orderByInfo
|
|
181
184
|
|
|
182
185
|
const sendChangesInRange = (
|
|
183
186
|
changes: Iterable<ChangeMessage<any, string | number>>
|
|
184
187
|
) => {
|
|
185
188
|
// Split live updates into a delete of the old value and an insert of the new value
|
|
186
|
-
// and filter out changes that are bigger than the biggest value we've sent so far
|
|
187
|
-
// because they can't affect the topK (and if later we need more data, we will dynamically load more data)
|
|
188
189
|
const splittedChanges = splitUpdates(changes)
|
|
189
|
-
|
|
190
|
-
if (dataNeeded && dataNeeded() === 0) {
|
|
191
|
-
// If the topK is full [..., maxSentValue] then we do not need to send changes > maxSentValue
|
|
192
|
-
// because they can never make it into the topK.
|
|
193
|
-
// However, if the topK isn't full yet, we need to also send changes > maxSentValue
|
|
194
|
-
// because they will make it into the topK
|
|
195
|
-
filteredChanges = filterChangesSmallerOrEqualToMax(
|
|
196
|
-
splittedChanges,
|
|
197
|
-
comparator,
|
|
198
|
-
this.biggest
|
|
199
|
-
)
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
this.sendChangesToPipelineWithTracking(filteredChanges, subscription)
|
|
190
|
+
this.sendChangesToPipelineWithTracking(splittedChanges, subscription)
|
|
203
191
|
}
|
|
204
192
|
|
|
205
193
|
// Subscribe to changes and only send changes that are smaller than the biggest value we've sent so far
|
|
@@ -395,37 +383,3 @@ function* splitUpdates<
|
|
|
395
383
|
}
|
|
396
384
|
}
|
|
397
385
|
}
|
|
398
|
-
|
|
399
|
-
function* filterChanges<
|
|
400
|
-
T extends object = Record<string, unknown>,
|
|
401
|
-
TKey extends string | number = string | number,
|
|
402
|
-
>(
|
|
403
|
-
changes: Iterable<ChangeMessage<T, TKey>>,
|
|
404
|
-
f: (change: ChangeMessage<T, TKey>) => boolean
|
|
405
|
-
): Generator<ChangeMessage<T, TKey>> {
|
|
406
|
-
for (const change of changes) {
|
|
407
|
-
if (f(change)) {
|
|
408
|
-
yield change
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Filters changes to only include those that are smaller or equal to the provided max value
|
|
415
|
-
* @param changes - Iterable of changes to filter
|
|
416
|
-
* @param comparator - Comparator function to use for filtering
|
|
417
|
-
* @param maxValue - Range to filter changes within (range boundaries are exclusive)
|
|
418
|
-
* @returns Iterable of changes that fall within the range
|
|
419
|
-
*/
|
|
420
|
-
function* filterChangesSmallerOrEqualToMax<
|
|
421
|
-
T extends object = Record<string, unknown>,
|
|
422
|
-
TKey extends string | number = string | number,
|
|
423
|
-
>(
|
|
424
|
-
changes: Iterable<ChangeMessage<T, TKey>>,
|
|
425
|
-
comparator: (a: any, b: any) => number,
|
|
426
|
-
maxValue: any
|
|
427
|
-
): Generator<ChangeMessage<T, TKey>> {
|
|
428
|
-
yield* filterChanges(changes, (change) => {
|
|
429
|
-
return !maxValue || comparator(change.value, maxValue) <= 0
|
|
430
|
-
})
|
|
431
|
-
}
|
|
@@ -14,7 +14,10 @@ import type { Transaction } from "../transactions"
|
|
|
14
14
|
*
|
|
15
15
|
* @example
|
|
16
16
|
* ```ts
|
|
17
|
-
* const mutate =
|
|
17
|
+
* const mutate = usePacedMutations({
|
|
18
|
+
* onMutate: (value) => {
|
|
19
|
+
* collection.update(id, draft => { draft.value = value })
|
|
20
|
+
* },
|
|
18
21
|
* mutationFn: async ({ transaction }) => {
|
|
19
22
|
* await api.save(transaction.mutations)
|
|
20
23
|
* },
|
|
@@ -16,7 +16,10 @@ import type { Transaction } from "../transactions"
|
|
|
16
16
|
* @example
|
|
17
17
|
* ```ts
|
|
18
18
|
* // Throttle slider updates to every 200ms
|
|
19
|
-
* const mutate =
|
|
19
|
+
* const mutate = usePacedMutations({
|
|
20
|
+
* onMutate: (volume) => {
|
|
21
|
+
* settingsCollection.update('volume', draft => { draft.value = volume })
|
|
22
|
+
* },
|
|
20
23
|
* mutationFn: async ({ transaction }) => {
|
|
21
24
|
* await api.updateVolume(transaction.mutations)
|
|
22
25
|
* },
|
|
@@ -27,7 +30,10 @@ import type { Transaction } from "../transactions"
|
|
|
27
30
|
* @example
|
|
28
31
|
* ```ts
|
|
29
32
|
* // Throttle with leading and trailing execution
|
|
30
|
-
* const mutate =
|
|
33
|
+
* const mutate = usePacedMutations({
|
|
34
|
+
* onMutate: (data) => {
|
|
35
|
+
* collection.update(id, draft => { Object.assign(draft, data) })
|
|
36
|
+
* },
|
|
31
37
|
* mutationFn: async ({ transaction }) => {
|
|
32
38
|
* await api.save(transaction.mutations)
|
|
33
39
|
* },
|
package/src/transactions.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createDeferred } from "./deferred"
|
|
2
|
+
import "./duplicate-instance-check"
|
|
2
3
|
import {
|
|
3
4
|
MissingMutationFunctionError,
|
|
4
5
|
TransactionAlreadyCompletedRollbackError,
|
|
@@ -244,7 +245,9 @@ class Transaction<T extends object = Record<string, unknown>> {
|
|
|
244
245
|
|
|
245
246
|
/**
|
|
246
247
|
* Execute collection operations within this transaction
|
|
247
|
-
* @param callback - Function containing collection operations to group together
|
|
248
|
+
* @param callback - Function containing collection operations to group together. If the
|
|
249
|
+
* callback returns a Promise, the transaction context will remain active until the promise
|
|
250
|
+
* settles, allowing optimistic writes after `await` boundaries.
|
|
248
251
|
* @returns This transaction for chaining
|
|
249
252
|
* @example
|
|
250
253
|
* // Group multiple operations
|
|
@@ -287,6 +290,7 @@ class Transaction<T extends object = Record<string, unknown>> {
|
|
|
287
290
|
}
|
|
288
291
|
|
|
289
292
|
registerTransaction(this)
|
|
293
|
+
|
|
290
294
|
try {
|
|
291
295
|
callback()
|
|
292
296
|
} finally {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type guard to check if a value is promise-like (has a `.then` method)
|
|
3
|
+
* @param value - The value to check
|
|
4
|
+
* @returns True if the value is promise-like, false otherwise
|
|
5
|
+
*/
|
|
6
|
+
export function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
7
|
+
return (
|
|
8
|
+
!!value &&
|
|
9
|
+
(typeof value === `object` || typeof value === `function`) &&
|
|
10
|
+
typeof (value as { then?: unknown }).then === `function`
|
|
11
|
+
)
|
|
12
|
+
}
|