@tanstack/db 0.4.8 → 0.4.10
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/events.cjs +9 -51
- package/dist/cjs/collection/events.cjs.map +1 -1
- package/dist/cjs/collection/events.d.cts +18 -7
- package/dist/cjs/collection/index.cjs +9 -12
- package/dist/cjs/collection/index.cjs.map +1 -1
- package/dist/cjs/collection/index.d.cts +13 -14
- package/dist/cjs/collection/subscription.cjs +62 -6
- package/dist/cjs/collection/subscription.cjs.map +1 -1
- package/dist/cjs/collection/subscription.d.cts +16 -3
- package/dist/cjs/collection/sync.cjs +58 -6
- package/dist/cjs/collection/sync.cjs.map +1 -1
- package/dist/cjs/collection/sync.d.cts +18 -4
- package/dist/cjs/errors.cjs +59 -17
- package/dist/cjs/errors.cjs.map +1 -1
- package/dist/cjs/errors.d.cts +44 -8
- package/dist/cjs/event-emitter.cjs +94 -0
- package/dist/cjs/event-emitter.cjs.map +1 -0
- package/dist/cjs/event-emitter.d.cts +45 -0
- package/dist/cjs/index.cjs +9 -4
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/local-only.cjs.map +1 -1
- package/dist/cjs/local-only.d.cts +2 -5
- package/dist/cjs/query/builder/types.d.cts +1 -1
- package/dist/cjs/query/compiler/index.cjs +46 -19
- package/dist/cjs/query/compiler/index.cjs.map +1 -1
- package/dist/cjs/query/compiler/index.d.cts +35 -9
- package/dist/cjs/query/compiler/joins.cjs +91 -66
- package/dist/cjs/query/compiler/joins.cjs.map +1 -1
- package/dist/cjs/query/compiler/joins.d.cts +6 -3
- package/dist/cjs/query/compiler/order-by.cjs +20 -4
- package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
- package/dist/cjs/query/compiler/order-by.d.cts +3 -1
- package/dist/cjs/query/compiler/select.cjs.map +1 -1
- package/dist/cjs/query/compiler/types.d.cts +4 -0
- package/dist/cjs/query/index.d.cts +1 -0
- package/dist/cjs/query/live/collection-config-builder.cjs +306 -46
- package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
- package/dist/cjs/query/live/collection-config-builder.d.cts +97 -9
- package/dist/cjs/query/live/collection-registry.cjs +16 -0
- package/dist/cjs/query/live/collection-registry.cjs.map +1 -0
- package/dist/cjs/query/live/collection-registry.d.cts +26 -0
- package/dist/cjs/query/live/collection-subscriber.cjs +86 -58
- package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
- package/dist/cjs/query/live/collection-subscriber.d.cts +5 -7
- package/dist/cjs/query/live-query-collection.cjs +11 -5
- package/dist/cjs/query/live-query-collection.cjs.map +1 -1
- package/dist/cjs/query/live-query-collection.d.cts +12 -5
- package/dist/cjs/query/optimizer.cjs +44 -7
- package/dist/cjs/query/optimizer.cjs.map +1 -1
- package/dist/cjs/query/optimizer.d.cts +4 -4
- package/dist/cjs/scheduler.cjs +137 -0
- package/dist/cjs/scheduler.cjs.map +1 -0
- package/dist/cjs/scheduler.d.cts +56 -0
- package/dist/cjs/transactions.cjs +7 -1
- package/dist/cjs/transactions.cjs.map +1 -1
- package/dist/cjs/types.d.cts +82 -11
- package/dist/esm/collection/events.d.ts +18 -7
- package/dist/esm/collection/events.js +9 -51
- package/dist/esm/collection/events.js.map +1 -1
- package/dist/esm/collection/index.d.ts +13 -14
- package/dist/esm/collection/index.js +9 -12
- package/dist/esm/collection/index.js.map +1 -1
- package/dist/esm/collection/subscription.d.ts +16 -3
- package/dist/esm/collection/subscription.js +62 -6
- package/dist/esm/collection/subscription.js.map +1 -1
- package/dist/esm/collection/sync.d.ts +18 -4
- package/dist/esm/collection/sync.js +59 -7
- package/dist/esm/collection/sync.js.map +1 -1
- package/dist/esm/errors.d.ts +44 -8
- package/dist/esm/errors.js +60 -18
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/event-emitter.d.ts +45 -0
- package/dist/esm/event-emitter.js +94 -0
- package/dist/esm/event-emitter.js.map +1 -0
- package/dist/esm/index.js +10 -5
- package/dist/esm/local-only.d.ts +2 -5
- package/dist/esm/local-only.js.map +1 -1
- package/dist/esm/query/builder/types.d.ts +1 -1
- package/dist/esm/query/compiler/index.d.ts +35 -9
- package/dist/esm/query/compiler/index.js +46 -19
- package/dist/esm/query/compiler/index.js.map +1 -1
- package/dist/esm/query/compiler/joins.d.ts +6 -3
- package/dist/esm/query/compiler/joins.js +93 -68
- package/dist/esm/query/compiler/joins.js.map +1 -1
- package/dist/esm/query/compiler/order-by.d.ts +3 -1
- package/dist/esm/query/compiler/order-by.js +20 -4
- package/dist/esm/query/compiler/order-by.js.map +1 -1
- package/dist/esm/query/compiler/select.js.map +1 -1
- package/dist/esm/query/compiler/types.d.ts +4 -0
- package/dist/esm/query/index.d.ts +1 -0
- package/dist/esm/query/live/collection-config-builder.d.ts +97 -9
- package/dist/esm/query/live/collection-config-builder.js +306 -46
- package/dist/esm/query/live/collection-config-builder.js.map +1 -1
- package/dist/esm/query/live/collection-registry.d.ts +26 -0
- package/dist/esm/query/live/collection-registry.js +16 -0
- package/dist/esm/query/live/collection-registry.js.map +1 -0
- package/dist/esm/query/live/collection-subscriber.d.ts +5 -7
- package/dist/esm/query/live/collection-subscriber.js +86 -58
- package/dist/esm/query/live/collection-subscriber.js.map +1 -1
- package/dist/esm/query/live-query-collection.d.ts +12 -5
- package/dist/esm/query/live-query-collection.js +11 -5
- package/dist/esm/query/live-query-collection.js.map +1 -1
- package/dist/esm/query/optimizer.d.ts +4 -4
- package/dist/esm/query/optimizer.js +44 -7
- package/dist/esm/query/optimizer.js.map +1 -1
- package/dist/esm/scheduler.d.ts +56 -0
- package/dist/esm/scheduler.js +137 -0
- package/dist/esm/scheduler.js.map +1 -0
- package/dist/esm/transactions.js +7 -1
- package/dist/esm/transactions.js.map +1 -1
- package/dist/esm/types.d.ts +82 -11
- package/package.json +2 -2
- package/src/collection/events.ts +25 -74
- package/src/collection/index.ts +15 -19
- package/src/collection/subscription.ts +88 -6
- package/src/collection/sync.ts +81 -9
- package/src/errors.ts +91 -13
- package/src/event-emitter.ts +118 -0
- package/src/local-only.ts +5 -12
- package/src/query/builder/types.ts +1 -1
- package/src/query/compiler/index.ts +124 -33
- package/src/query/compiler/joins.ts +187 -128
- package/src/query/compiler/order-by.ts +30 -2
- package/src/query/compiler/select.ts +2 -3
- package/src/query/compiler/types.ts +5 -0
- package/src/query/index.ts +1 -0
- package/src/query/live/collection-config-builder.ts +501 -60
- package/src/query/live/collection-registry.ts +47 -0
- package/src/query/live/collection-subscriber.ts +137 -105
- package/src/query/live-query-collection.ts +47 -18
- package/src/query/optimizer.ts +85 -15
- package/src/scheduler.ts +198 -0
- package/src/transactions.ts +12 -1
- package/src/types.ts +93 -11
package/src/query/optimizer.ts
CHANGED
|
@@ -162,8 +162,8 @@ export interface GroupedWhereClauses {
|
|
|
162
162
|
export interface OptimizationResult {
|
|
163
163
|
/** The optimized query with WHERE clauses potentially moved to subqueries */
|
|
164
164
|
optimizedQuery: QueryIR
|
|
165
|
-
/** Map of
|
|
166
|
-
|
|
165
|
+
/** Map of source aliases to their extracted WHERE clauses for index optimization */
|
|
166
|
+
sourceWhereClauses: Map<string, BasicExpression<boolean>>
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
/**
|
|
@@ -184,14 +184,14 @@ export interface OptimizationResult {
|
|
|
184
184
|
* where: [eq(u.dept_id, 1), gt(p.views, 100)]
|
|
185
185
|
* }
|
|
186
186
|
*
|
|
187
|
-
* const { optimizedQuery,
|
|
187
|
+
* const { optimizedQuery, sourceWhereClauses } = optimizeQuery(originalQuery)
|
|
188
188
|
* // Result: Single-source clauses moved to deepest possible subqueries
|
|
189
|
-
* //
|
|
189
|
+
* // sourceWhereClauses: Map { 'u' => eq(u.dept_id, 1), 'p' => gt(p.views, 100) }
|
|
190
190
|
* ```
|
|
191
191
|
*/
|
|
192
192
|
export function optimizeQuery(query: QueryIR): OptimizationResult {
|
|
193
|
-
// First, extract
|
|
194
|
-
const
|
|
193
|
+
// First, extract source WHERE clauses before optimization
|
|
194
|
+
const sourceWhereClauses = extractSourceWhereClauses(query)
|
|
195
195
|
|
|
196
196
|
// Apply multi-level predicate pushdown with iterative convergence
|
|
197
197
|
let optimized = query
|
|
@@ -214,7 +214,7 @@ export function optimizeQuery(query: QueryIR): OptimizationResult {
|
|
|
214
214
|
|
|
215
215
|
return {
|
|
216
216
|
optimizedQuery: cleaned,
|
|
217
|
-
|
|
217
|
+
sourceWhereClauses,
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
|
|
@@ -224,16 +224,16 @@ export function optimizeQuery(query: QueryIR): OptimizationResult {
|
|
|
224
224
|
* to specific collections, but only for simple queries without joins.
|
|
225
225
|
*
|
|
226
226
|
* @param query - The original QueryIR to analyze
|
|
227
|
-
* @returns Map of
|
|
227
|
+
* @returns Map of source aliases to their WHERE clauses
|
|
228
228
|
*/
|
|
229
|
-
function
|
|
229
|
+
function extractSourceWhereClauses(
|
|
230
230
|
query: QueryIR
|
|
231
231
|
): Map<string, BasicExpression<boolean>> {
|
|
232
|
-
const
|
|
232
|
+
const sourceWhereClauses = new Map<string, BasicExpression<boolean>>()
|
|
233
233
|
|
|
234
234
|
// Only analyze queries that have WHERE clauses
|
|
235
235
|
if (!query.where || query.where.length === 0) {
|
|
236
|
-
return
|
|
236
|
+
return sourceWhereClauses
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
// Split all AND clauses at the root level for granular analysis
|
|
@@ -254,12 +254,12 @@ function extractCollectionWhereClauses(
|
|
|
254
254
|
if (isCollectionReference(query, sourceAlias)) {
|
|
255
255
|
// Check if the WHERE clause can be converted to collection-compatible format
|
|
256
256
|
if (isConvertibleToCollectionFilter(whereClause)) {
|
|
257
|
-
|
|
257
|
+
sourceWhereClauses.set(sourceAlias, whereClause)
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
return
|
|
262
|
+
return sourceWhereClauses
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
/**
|
|
@@ -782,8 +782,6 @@ function optimizeFromWithTracking(
|
|
|
782
782
|
return new QueryRefClass(subQuery, from.alias)
|
|
783
783
|
}
|
|
784
784
|
|
|
785
|
-
// Must be queryRef due to type system
|
|
786
|
-
|
|
787
785
|
// SAFETY CHECK: Only check safety when pushing WHERE clauses into existing subqueries
|
|
788
786
|
// We need to be careful about pushing WHERE clauses into subqueries that already have
|
|
789
787
|
// aggregates, HAVING, or ORDER BY + LIMIT since that could change their semantics
|
|
@@ -793,6 +791,12 @@ function optimizeFromWithTracking(
|
|
|
793
791
|
return new QueryRefClass(deepCopyQuery(from.query), from.alias)
|
|
794
792
|
}
|
|
795
793
|
|
|
794
|
+
// Skip pushdown when a clause references a field that only exists via a renamed
|
|
795
|
+
// projection inside the subquery; leaving it outside preserves the alias mapping.
|
|
796
|
+
if (referencesAliasWithRemappedSelect(from.query, whereClause, from.alias)) {
|
|
797
|
+
return new QueryRefClass(deepCopyQuery(from.query), from.alias)
|
|
798
|
+
}
|
|
799
|
+
|
|
796
800
|
// Add the WHERE clause to the existing subquery
|
|
797
801
|
// Create a deep copy to ensure immutability
|
|
798
802
|
const existingWhere = from.query.where || []
|
|
@@ -943,6 +947,72 @@ function whereReferencesComputedSelectFields(
|
|
|
943
947
|
return false
|
|
944
948
|
}
|
|
945
949
|
|
|
950
|
+
/**
|
|
951
|
+
* Detects whether a WHERE clause references the subquery alias through fields that
|
|
952
|
+
* are re-exposed under different names (renamed SELECT projections or fnSelect output).
|
|
953
|
+
* In those cases we keep the clause at the outer level to avoid alias remapping bugs.
|
|
954
|
+
* TODO: in future we should handle this by rewriting the clause to use the subquery's
|
|
955
|
+
* internal field references, but it likely needs a wider refactor to do cleanly.
|
|
956
|
+
*/
|
|
957
|
+
function referencesAliasWithRemappedSelect(
|
|
958
|
+
subquery: QueryIR,
|
|
959
|
+
whereClause: BasicExpression<boolean>,
|
|
960
|
+
outerAlias: string
|
|
961
|
+
): boolean {
|
|
962
|
+
const refs = collectRefs(whereClause)
|
|
963
|
+
// Only care about clauses that actually reference the outer alias.
|
|
964
|
+
if (refs.every((ref) => ref.path[0] !== outerAlias)) {
|
|
965
|
+
return false
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// fnSelect always rewrites the row shape, so alias-safe pushdown is impossible.
|
|
969
|
+
if (subquery.fnSelect) {
|
|
970
|
+
return true
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const select = subquery.select
|
|
974
|
+
// Without an explicit SELECT the clause still refers to the original collection.
|
|
975
|
+
if (!select) {
|
|
976
|
+
return false
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
for (const ref of refs) {
|
|
980
|
+
const path = ref.path
|
|
981
|
+
// Need at least alias + field to matter.
|
|
982
|
+
if (path.length < 2) continue
|
|
983
|
+
if (path[0] !== outerAlias) continue
|
|
984
|
+
|
|
985
|
+
const projected = select[path[1]!]
|
|
986
|
+
// Unselected fields can't be remapped, so skip - only care about fields in the SELECT.
|
|
987
|
+
if (!projected) continue
|
|
988
|
+
|
|
989
|
+
// Non-PropRef projections are computed values; cannot push down.
|
|
990
|
+
if (!(projected instanceof PropRef)) {
|
|
991
|
+
return true
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// If the projection is just the alias (whole row) without a specific field,
|
|
995
|
+
// we can't verify whether the field we're referencing is being preserved or remapped.
|
|
996
|
+
if (projected.path.length < 2) {
|
|
997
|
+
return true
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const [innerAlias, innerField] = projected.path
|
|
1001
|
+
|
|
1002
|
+
// Safe only when the projection points straight back to the same alias or the
|
|
1003
|
+
// underlying source alias and preserves the field name.
|
|
1004
|
+
if (innerAlias !== outerAlias && innerAlias !== subquery.from.alias) {
|
|
1005
|
+
return true
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (innerField !== path[1]) {
|
|
1009
|
+
return true
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return false
|
|
1014
|
+
}
|
|
1015
|
+
|
|
946
1016
|
/**
|
|
947
1017
|
* Helper function to combine multiple expressions with AND.
|
|
948
1018
|
*
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identifier used to scope scheduled work. Maps to a transaction id for live queries.
|
|
3
|
+
*/
|
|
4
|
+
export type SchedulerContextId = string | symbol
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options for {@link Scheduler.schedule}. Jobs are identified by `jobId` within a context
|
|
8
|
+
* and may declare dependencies.
|
|
9
|
+
*/
|
|
10
|
+
interface ScheduleOptions {
|
|
11
|
+
contextId?: SchedulerContextId
|
|
12
|
+
jobId: unknown
|
|
13
|
+
dependencies?: Iterable<unknown>
|
|
14
|
+
run: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* State per context. Queue preserves order, jobs hold run functions, dependencies track
|
|
19
|
+
* prerequisites, and completed records which jobs have run during the current flush.
|
|
20
|
+
*/
|
|
21
|
+
interface SchedulerContextState {
|
|
22
|
+
queue: Array<unknown>
|
|
23
|
+
jobs: Map<unknown, () => void>
|
|
24
|
+
dependencies: Map<unknown, Set<unknown>>
|
|
25
|
+
completed: Set<unknown>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Scoped scheduler that coalesces work by context and job.
|
|
30
|
+
*
|
|
31
|
+
* - **context** (e.g. transaction id) defines the batching boundary; work is queued until flushed.
|
|
32
|
+
* - **job id** deduplicates work within a context; scheduling the same job replaces the previous run function.
|
|
33
|
+
* - Without a context id, work executes immediately.
|
|
34
|
+
*
|
|
35
|
+
* Callers manage their own state; the scheduler only orchestrates execution order.
|
|
36
|
+
*/
|
|
37
|
+
export class Scheduler {
|
|
38
|
+
private contexts = new Map<SchedulerContextId, SchedulerContextState>()
|
|
39
|
+
private clearListeners = new Set<(contextId: SchedulerContextId) => void>()
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get or create the state bucket for a context.
|
|
43
|
+
*/
|
|
44
|
+
private getOrCreateContext(
|
|
45
|
+
contextId: SchedulerContextId
|
|
46
|
+
): SchedulerContextState {
|
|
47
|
+
let context = this.contexts.get(contextId)
|
|
48
|
+
if (!context) {
|
|
49
|
+
context = {
|
|
50
|
+
queue: [],
|
|
51
|
+
jobs: new Map(),
|
|
52
|
+
dependencies: new Map(),
|
|
53
|
+
completed: new Set(),
|
|
54
|
+
}
|
|
55
|
+
this.contexts.set(contextId, context)
|
|
56
|
+
}
|
|
57
|
+
return context
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Schedule work. Without a context id, executes immediately.
|
|
62
|
+
* Otherwise queues the job to be flushed once dependencies are satisfied.
|
|
63
|
+
* Scheduling the same jobId again replaces the previous run function.
|
|
64
|
+
*/
|
|
65
|
+
schedule({ contextId, jobId, dependencies, run }: ScheduleOptions): void {
|
|
66
|
+
if (typeof contextId === `undefined`) {
|
|
67
|
+
run()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const context = this.getOrCreateContext(contextId)
|
|
72
|
+
|
|
73
|
+
// If this is a new job, add it to the queue
|
|
74
|
+
if (!context.jobs.has(jobId)) {
|
|
75
|
+
context.queue.push(jobId)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Store or replace the run function
|
|
79
|
+
context.jobs.set(jobId, run)
|
|
80
|
+
|
|
81
|
+
// Update dependencies
|
|
82
|
+
if (dependencies) {
|
|
83
|
+
const depSet = new Set<unknown>(dependencies)
|
|
84
|
+
depSet.delete(jobId)
|
|
85
|
+
context.dependencies.set(jobId, depSet)
|
|
86
|
+
} else if (!context.dependencies.has(jobId)) {
|
|
87
|
+
context.dependencies.set(jobId, new Set())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Clear completion status since we're rescheduling
|
|
91
|
+
context.completed.delete(jobId)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Flush all queued work for a context. Jobs with unmet dependencies are retried.
|
|
96
|
+
* Throws if a pass completes without running any job (dependency cycle).
|
|
97
|
+
*/
|
|
98
|
+
flush(contextId: SchedulerContextId): void {
|
|
99
|
+
const context = this.contexts.get(contextId)
|
|
100
|
+
if (!context) return
|
|
101
|
+
|
|
102
|
+
const { queue, jobs, dependencies, completed } = context
|
|
103
|
+
|
|
104
|
+
while (queue.length > 0) {
|
|
105
|
+
let ranThisPass = false
|
|
106
|
+
const jobsThisPass = queue.length
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < jobsThisPass; i++) {
|
|
109
|
+
const jobId = queue.shift()!
|
|
110
|
+
const run = jobs.get(jobId)
|
|
111
|
+
if (!run) {
|
|
112
|
+
dependencies.delete(jobId)
|
|
113
|
+
completed.delete(jobId)
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const deps = dependencies.get(jobId)
|
|
118
|
+
let ready = !deps
|
|
119
|
+
if (deps) {
|
|
120
|
+
ready = true
|
|
121
|
+
for (const dep of deps) {
|
|
122
|
+
if (dep !== jobId && !completed.has(dep)) {
|
|
123
|
+
ready = false
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (ready) {
|
|
130
|
+
jobs.delete(jobId)
|
|
131
|
+
dependencies.delete(jobId)
|
|
132
|
+
// Run the job. If it throws, we don't mark it complete, allowing the
|
|
133
|
+
// error to propagate while maintaining scheduler state consistency.
|
|
134
|
+
run()
|
|
135
|
+
completed.add(jobId)
|
|
136
|
+
ranThisPass = true
|
|
137
|
+
} else {
|
|
138
|
+
queue.push(jobId)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!ranThisPass) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Scheduler detected unresolved dependencies for context ${String(
|
|
145
|
+
contextId
|
|
146
|
+
)}.`
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.contexts.delete(contextId)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Flush all contexts with pending work. Useful during tear-down.
|
|
156
|
+
*/
|
|
157
|
+
flushAll(): void {
|
|
158
|
+
for (const contextId of Array.from(this.contexts.keys())) {
|
|
159
|
+
this.flush(contextId)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Clear all scheduled jobs for a context. */
|
|
164
|
+
clear(contextId: SchedulerContextId): void {
|
|
165
|
+
this.contexts.delete(contextId)
|
|
166
|
+
// Notify listeners that this context was cleared
|
|
167
|
+
this.clearListeners.forEach((listener) => listener(contextId))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Register a listener to be notified when a context is cleared. */
|
|
171
|
+
onClear(listener: (contextId: SchedulerContextId) => void): () => void {
|
|
172
|
+
this.clearListeners.add(listener)
|
|
173
|
+
return () => this.clearListeners.delete(listener)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Check if a context has pending jobs. */
|
|
177
|
+
hasPendingJobs(contextId: SchedulerContextId): boolean {
|
|
178
|
+
const context = this.contexts.get(contextId)
|
|
179
|
+
return !!context && context.jobs.size > 0
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Remove a single job from a context and clean up its dependencies. */
|
|
183
|
+
clearJob(contextId: SchedulerContextId, jobId: unknown): void {
|
|
184
|
+
const context = this.contexts.get(contextId)
|
|
185
|
+
if (!context) return
|
|
186
|
+
|
|
187
|
+
context.jobs.delete(jobId)
|
|
188
|
+
context.dependencies.delete(jobId)
|
|
189
|
+
context.completed.delete(jobId)
|
|
190
|
+
context.queue = context.queue.filter((id) => id !== jobId)
|
|
191
|
+
|
|
192
|
+
if (context.jobs.size === 0) {
|
|
193
|
+
this.contexts.delete(contextId)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const transactionScopedScheduler = new Scheduler()
|
package/src/transactions.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
TransactionNotPendingCommitError,
|
|
6
6
|
TransactionNotPendingMutateError,
|
|
7
7
|
} from "./errors"
|
|
8
|
+
import { transactionScopedScheduler } from "./scheduler.js"
|
|
8
9
|
import type { Deferred } from "./deferred"
|
|
9
10
|
import type {
|
|
10
11
|
MutationFn,
|
|
@@ -179,11 +180,21 @@ export function getActiveTransaction(): Transaction | undefined {
|
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
function registerTransaction(tx: Transaction<any>) {
|
|
183
|
+
// Clear any stale work that may have been left behind if a previous mutate
|
|
184
|
+
// scope aborted before we could flush.
|
|
185
|
+
transactionScopedScheduler.clear(tx.id)
|
|
182
186
|
transactionStack.push(tx)
|
|
183
187
|
}
|
|
184
188
|
|
|
185
189
|
function unregisterTransaction(tx: Transaction<any>) {
|
|
186
|
-
|
|
190
|
+
// Always flush pending work for this transaction before removing it from
|
|
191
|
+
// the ambient stack – this runs even if the mutate callback throws.
|
|
192
|
+
// If flush throws (e.g., due to a job error), we still clean up the stack.
|
|
193
|
+
try {
|
|
194
|
+
transactionScopedScheduler.flush(tx.id)
|
|
195
|
+
} finally {
|
|
196
|
+
transactionStack = transactionStack.filter((t) => t.id !== tx.id)
|
|
197
|
+
}
|
|
187
198
|
}
|
|
188
199
|
|
|
189
200
|
function removeFromPendingList(tx: Transaction<any>) {
|
package/src/types.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Collection } from "./collection/index.js"
|
|
|
3
3
|
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
|
4
4
|
import type { Transaction } from "./transactions"
|
|
5
5
|
import type { BasicExpression, OrderBy } from "./query/ir.js"
|
|
6
|
+
import type { EventEmitter } from "./event-emitter.js"
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Helper type to extract the output type from a standard schema
|
|
@@ -150,17 +151,83 @@ export type Row<TExtensions = never> = Record<string, Value<TExtensions>>
|
|
|
150
151
|
|
|
151
152
|
export type OperationType = `insert` | `update` | `delete`
|
|
152
153
|
|
|
153
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Subscription status values
|
|
156
|
+
*/
|
|
157
|
+
export type SubscriptionStatus = `ready` | `loadingSubset`
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Event emitted when subscription status changes
|
|
161
|
+
*/
|
|
162
|
+
export interface SubscriptionStatusChangeEvent {
|
|
163
|
+
type: `status:change`
|
|
164
|
+
subscription: Subscription
|
|
165
|
+
previousStatus: SubscriptionStatus
|
|
166
|
+
status: SubscriptionStatus
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Event emitted when subscription status changes to a specific status
|
|
171
|
+
*/
|
|
172
|
+
export interface SubscriptionStatusEvent<T extends SubscriptionStatus> {
|
|
173
|
+
type: `status:${T}`
|
|
174
|
+
subscription: Subscription
|
|
175
|
+
previousStatus: SubscriptionStatus
|
|
176
|
+
status: T
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Event emitted when subscription is unsubscribed
|
|
181
|
+
*/
|
|
182
|
+
export interface SubscriptionUnsubscribedEvent {
|
|
183
|
+
type: `unsubscribed`
|
|
184
|
+
subscription: Subscription
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* All subscription events
|
|
189
|
+
*/
|
|
190
|
+
export type SubscriptionEvents = {
|
|
191
|
+
"status:change": SubscriptionStatusChangeEvent
|
|
192
|
+
"status:ready": SubscriptionStatusEvent<`ready`>
|
|
193
|
+
"status:loadingSubset": SubscriptionStatusEvent<`loadingSubset`>
|
|
194
|
+
unsubscribed: SubscriptionUnsubscribedEvent
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Public interface for a collection subscription
|
|
199
|
+
* Used by sync implementations to track subscription lifecycle
|
|
200
|
+
*/
|
|
201
|
+
export interface Subscription extends EventEmitter<SubscriptionEvents> {
|
|
202
|
+
/** Current status of the subscription */
|
|
203
|
+
readonly status: SubscriptionStatus
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type LoadSubsetOptions = {
|
|
207
|
+
/** The where expression to filter the data */
|
|
154
208
|
where?: BasicExpression<boolean>
|
|
209
|
+
/** The order by clause to sort the data */
|
|
155
210
|
orderBy?: OrderBy
|
|
211
|
+
/** The limit of the data to load */
|
|
156
212
|
limit?: number
|
|
213
|
+
/**
|
|
214
|
+
* The subscription that triggered the load.
|
|
215
|
+
* Advanced sync implementations can use this for:
|
|
216
|
+
* - LRU caching keyed by subscription
|
|
217
|
+
* - Reference counting to track active subscriptions
|
|
218
|
+
* - Subscribing to subscription events (e.g., finalization/unsubscribe)
|
|
219
|
+
* @optional Available when called from CollectionSubscription, may be undefined for direct calls
|
|
220
|
+
*/
|
|
221
|
+
subscription?: Subscription
|
|
157
222
|
}
|
|
158
223
|
|
|
224
|
+
export type LoadSubsetFn = (options: LoadSubsetOptions) => true | Promise<void>
|
|
225
|
+
|
|
159
226
|
export type CleanupFn = () => void
|
|
160
227
|
|
|
161
228
|
export type SyncConfigRes = {
|
|
162
229
|
cleanup?: CleanupFn
|
|
163
|
-
|
|
230
|
+
loadSubset?: LoadSubsetFn
|
|
164
231
|
}
|
|
165
232
|
export interface SyncConfig<
|
|
166
233
|
T extends object = Record<string, unknown>,
|
|
@@ -242,7 +309,7 @@ export interface InsertConfig {
|
|
|
242
309
|
export type UpdateMutationFnParams<
|
|
243
310
|
T extends object = Record<string, unknown>,
|
|
244
311
|
TKey extends string | number = string | number,
|
|
245
|
-
TUtils extends UtilsRecord =
|
|
312
|
+
TUtils extends UtilsRecord = UtilsRecord,
|
|
246
313
|
> = {
|
|
247
314
|
transaction: TransactionWithMutations<T, `update`>
|
|
248
315
|
collection: Collection<T, TKey, TUtils>
|
|
@@ -251,7 +318,7 @@ export type UpdateMutationFnParams<
|
|
|
251
318
|
export type InsertMutationFnParams<
|
|
252
319
|
T extends object = Record<string, unknown>,
|
|
253
320
|
TKey extends string | number = string | number,
|
|
254
|
-
TUtils extends UtilsRecord =
|
|
321
|
+
TUtils extends UtilsRecord = UtilsRecord,
|
|
255
322
|
> = {
|
|
256
323
|
transaction: TransactionWithMutations<T, `insert`>
|
|
257
324
|
collection: Collection<T, TKey, TUtils>
|
|
@@ -259,7 +326,7 @@ export type InsertMutationFnParams<
|
|
|
259
326
|
export type DeleteMutationFnParams<
|
|
260
327
|
T extends object = Record<string, unknown>,
|
|
261
328
|
TKey extends string | number = string | number,
|
|
262
|
-
TUtils extends UtilsRecord =
|
|
329
|
+
TUtils extends UtilsRecord = UtilsRecord,
|
|
263
330
|
> = {
|
|
264
331
|
transaction: TransactionWithMutations<T, `delete`>
|
|
265
332
|
collection: Collection<T, TKey, TUtils>
|
|
@@ -268,21 +335,21 @@ export type DeleteMutationFnParams<
|
|
|
268
335
|
export type InsertMutationFn<
|
|
269
336
|
T extends object = Record<string, unknown>,
|
|
270
337
|
TKey extends string | number = string | number,
|
|
271
|
-
TUtils extends UtilsRecord =
|
|
338
|
+
TUtils extends UtilsRecord = UtilsRecord,
|
|
272
339
|
TReturn = any,
|
|
273
340
|
> = (params: InsertMutationFnParams<T, TKey, TUtils>) => Promise<TReturn>
|
|
274
341
|
|
|
275
342
|
export type UpdateMutationFn<
|
|
276
343
|
T extends object = Record<string, unknown>,
|
|
277
344
|
TKey extends string | number = string | number,
|
|
278
|
-
TUtils extends UtilsRecord =
|
|
345
|
+
TUtils extends UtilsRecord = UtilsRecord,
|
|
279
346
|
TReturn = any,
|
|
280
347
|
> = (params: UpdateMutationFnParams<T, TKey, TUtils>) => Promise<TReturn>
|
|
281
348
|
|
|
282
349
|
export type DeleteMutationFn<
|
|
283
350
|
T extends object = Record<string, unknown>,
|
|
284
351
|
TKey extends string | number = string | number,
|
|
285
|
-
TUtils extends UtilsRecord =
|
|
352
|
+
TUtils extends UtilsRecord = UtilsRecord,
|
|
286
353
|
TReturn = any,
|
|
287
354
|
> = (params: DeleteMutationFnParams<T, TKey, TUtils>) => Promise<TReturn>
|
|
288
355
|
|
|
@@ -313,6 +380,8 @@ export type CollectionStatus =
|
|
|
313
380
|
/** Collection has been cleaned up and resources freed */
|
|
314
381
|
| `cleaned-up`
|
|
315
382
|
|
|
383
|
+
export type SyncMode = `eager` | `on-demand`
|
|
384
|
+
|
|
316
385
|
export interface BaseCollectionConfig<
|
|
317
386
|
T extends object = Record<string, unknown>,
|
|
318
387
|
TKey extends string | number = string | number,
|
|
@@ -321,7 +390,7 @@ export interface BaseCollectionConfig<
|
|
|
321
390
|
// then it would conflict with the overloads of createCollection which
|
|
322
391
|
// requires either T to be provided or a schema to be provided but not both!
|
|
323
392
|
TSchema extends StandardSchemaV1 = never,
|
|
324
|
-
TUtils extends UtilsRecord =
|
|
393
|
+
TUtils extends UtilsRecord = UtilsRecord,
|
|
325
394
|
TReturn = any,
|
|
326
395
|
> {
|
|
327
396
|
// If an id isn't passed in, a UUID will be
|
|
@@ -374,6 +443,15 @@ export interface BaseCollectionConfig<
|
|
|
374
443
|
* compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime()
|
|
375
444
|
*/
|
|
376
445
|
compare?: (x: T, y: T) => number
|
|
446
|
+
/**
|
|
447
|
+
* The mode of sync to use for the collection.
|
|
448
|
+
* @default `eager`
|
|
449
|
+
* @description
|
|
450
|
+
* - `eager`: syncs all data immediately on preload
|
|
451
|
+
* - `on-demand`: syncs data in incremental snapshots when the collection is queried
|
|
452
|
+
* The exact implementation of the sync mode is up to the sync implementation.
|
|
453
|
+
*/
|
|
454
|
+
syncMode?: SyncMode
|
|
377
455
|
/**
|
|
378
456
|
* Optional asynchronous handler function called before an insert operation
|
|
379
457
|
* @param params Object containing transaction and collection information
|
|
@@ -503,13 +581,16 @@ export interface BaseCollectionConfig<
|
|
|
503
581
|
* }
|
|
504
582
|
*/
|
|
505
583
|
onDelete?: DeleteMutationFn<T, TKey, TUtils, TReturn>
|
|
584
|
+
|
|
585
|
+
utils?: TUtils
|
|
506
586
|
}
|
|
507
587
|
|
|
508
588
|
export interface CollectionConfig<
|
|
509
589
|
T extends object = Record<string, unknown>,
|
|
510
590
|
TKey extends string | number = string | number,
|
|
511
591
|
TSchema extends StandardSchemaV1 = never,
|
|
512
|
-
|
|
592
|
+
TUtils extends UtilsRecord = UtilsRecord,
|
|
593
|
+
> extends BaseCollectionConfig<T, TKey, TSchema, TUtils> {
|
|
513
594
|
sync: SyncConfig<T, TKey>
|
|
514
595
|
}
|
|
515
596
|
|
|
@@ -533,7 +614,8 @@ export type CollectionConfigSingleRowOption<
|
|
|
533
614
|
T extends object = Record<string, unknown>,
|
|
534
615
|
TKey extends string | number = string | number,
|
|
535
616
|
TSchema extends StandardSchemaV1 = never,
|
|
536
|
-
|
|
617
|
+
TUtils extends UtilsRecord = {},
|
|
618
|
+
> = CollectionConfig<T, TKey, TSchema, TUtils> & MaybeSingleResult
|
|
537
619
|
|
|
538
620
|
export type ChangesPayload<T extends object = Record<string, unknown>> = Array<
|
|
539
621
|
ChangeMessage<T>
|