@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.
Files changed (134) hide show
  1. package/dist/cjs/collection/events.cjs +9 -51
  2. package/dist/cjs/collection/events.cjs.map +1 -1
  3. package/dist/cjs/collection/events.d.cts +18 -7
  4. package/dist/cjs/collection/index.cjs +9 -12
  5. package/dist/cjs/collection/index.cjs.map +1 -1
  6. package/dist/cjs/collection/index.d.cts +13 -14
  7. package/dist/cjs/collection/subscription.cjs +62 -6
  8. package/dist/cjs/collection/subscription.cjs.map +1 -1
  9. package/dist/cjs/collection/subscription.d.cts +16 -3
  10. package/dist/cjs/collection/sync.cjs +58 -6
  11. package/dist/cjs/collection/sync.cjs.map +1 -1
  12. package/dist/cjs/collection/sync.d.cts +18 -4
  13. package/dist/cjs/errors.cjs +59 -17
  14. package/dist/cjs/errors.cjs.map +1 -1
  15. package/dist/cjs/errors.d.cts +44 -8
  16. package/dist/cjs/event-emitter.cjs +94 -0
  17. package/dist/cjs/event-emitter.cjs.map +1 -0
  18. package/dist/cjs/event-emitter.d.cts +45 -0
  19. package/dist/cjs/index.cjs +9 -4
  20. package/dist/cjs/index.cjs.map +1 -1
  21. package/dist/cjs/local-only.cjs.map +1 -1
  22. package/dist/cjs/local-only.d.cts +2 -5
  23. package/dist/cjs/query/builder/types.d.cts +1 -1
  24. package/dist/cjs/query/compiler/index.cjs +46 -19
  25. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  26. package/dist/cjs/query/compiler/index.d.cts +35 -9
  27. package/dist/cjs/query/compiler/joins.cjs +91 -66
  28. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  29. package/dist/cjs/query/compiler/joins.d.cts +6 -3
  30. package/dist/cjs/query/compiler/order-by.cjs +20 -4
  31. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  32. package/dist/cjs/query/compiler/order-by.d.cts +3 -1
  33. package/dist/cjs/query/compiler/select.cjs.map +1 -1
  34. package/dist/cjs/query/compiler/types.d.cts +4 -0
  35. package/dist/cjs/query/index.d.cts +1 -0
  36. package/dist/cjs/query/live/collection-config-builder.cjs +306 -46
  37. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  38. package/dist/cjs/query/live/collection-config-builder.d.cts +97 -9
  39. package/dist/cjs/query/live/collection-registry.cjs +16 -0
  40. package/dist/cjs/query/live/collection-registry.cjs.map +1 -0
  41. package/dist/cjs/query/live/collection-registry.d.cts +26 -0
  42. package/dist/cjs/query/live/collection-subscriber.cjs +86 -58
  43. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  44. package/dist/cjs/query/live/collection-subscriber.d.cts +5 -7
  45. package/dist/cjs/query/live-query-collection.cjs +11 -5
  46. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  47. package/dist/cjs/query/live-query-collection.d.cts +12 -5
  48. package/dist/cjs/query/optimizer.cjs +44 -7
  49. package/dist/cjs/query/optimizer.cjs.map +1 -1
  50. package/dist/cjs/query/optimizer.d.cts +4 -4
  51. package/dist/cjs/scheduler.cjs +137 -0
  52. package/dist/cjs/scheduler.cjs.map +1 -0
  53. package/dist/cjs/scheduler.d.cts +56 -0
  54. package/dist/cjs/transactions.cjs +7 -1
  55. package/dist/cjs/transactions.cjs.map +1 -1
  56. package/dist/cjs/types.d.cts +82 -11
  57. package/dist/esm/collection/events.d.ts +18 -7
  58. package/dist/esm/collection/events.js +9 -51
  59. package/dist/esm/collection/events.js.map +1 -1
  60. package/dist/esm/collection/index.d.ts +13 -14
  61. package/dist/esm/collection/index.js +9 -12
  62. package/dist/esm/collection/index.js.map +1 -1
  63. package/dist/esm/collection/subscription.d.ts +16 -3
  64. package/dist/esm/collection/subscription.js +62 -6
  65. package/dist/esm/collection/subscription.js.map +1 -1
  66. package/dist/esm/collection/sync.d.ts +18 -4
  67. package/dist/esm/collection/sync.js +59 -7
  68. package/dist/esm/collection/sync.js.map +1 -1
  69. package/dist/esm/errors.d.ts +44 -8
  70. package/dist/esm/errors.js +60 -18
  71. package/dist/esm/errors.js.map +1 -1
  72. package/dist/esm/event-emitter.d.ts +45 -0
  73. package/dist/esm/event-emitter.js +94 -0
  74. package/dist/esm/event-emitter.js.map +1 -0
  75. package/dist/esm/index.js +10 -5
  76. package/dist/esm/local-only.d.ts +2 -5
  77. package/dist/esm/local-only.js.map +1 -1
  78. package/dist/esm/query/builder/types.d.ts +1 -1
  79. package/dist/esm/query/compiler/index.d.ts +35 -9
  80. package/dist/esm/query/compiler/index.js +46 -19
  81. package/dist/esm/query/compiler/index.js.map +1 -1
  82. package/dist/esm/query/compiler/joins.d.ts +6 -3
  83. package/dist/esm/query/compiler/joins.js +93 -68
  84. package/dist/esm/query/compiler/joins.js.map +1 -1
  85. package/dist/esm/query/compiler/order-by.d.ts +3 -1
  86. package/dist/esm/query/compiler/order-by.js +20 -4
  87. package/dist/esm/query/compiler/order-by.js.map +1 -1
  88. package/dist/esm/query/compiler/select.js.map +1 -1
  89. package/dist/esm/query/compiler/types.d.ts +4 -0
  90. package/dist/esm/query/index.d.ts +1 -0
  91. package/dist/esm/query/live/collection-config-builder.d.ts +97 -9
  92. package/dist/esm/query/live/collection-config-builder.js +306 -46
  93. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  94. package/dist/esm/query/live/collection-registry.d.ts +26 -0
  95. package/dist/esm/query/live/collection-registry.js +16 -0
  96. package/dist/esm/query/live/collection-registry.js.map +1 -0
  97. package/dist/esm/query/live/collection-subscriber.d.ts +5 -7
  98. package/dist/esm/query/live/collection-subscriber.js +86 -58
  99. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  100. package/dist/esm/query/live-query-collection.d.ts +12 -5
  101. package/dist/esm/query/live-query-collection.js +11 -5
  102. package/dist/esm/query/live-query-collection.js.map +1 -1
  103. package/dist/esm/query/optimizer.d.ts +4 -4
  104. package/dist/esm/query/optimizer.js +44 -7
  105. package/dist/esm/query/optimizer.js.map +1 -1
  106. package/dist/esm/scheduler.d.ts +56 -0
  107. package/dist/esm/scheduler.js +137 -0
  108. package/dist/esm/scheduler.js.map +1 -0
  109. package/dist/esm/transactions.js +7 -1
  110. package/dist/esm/transactions.js.map +1 -1
  111. package/dist/esm/types.d.ts +82 -11
  112. package/package.json +2 -2
  113. package/src/collection/events.ts +25 -74
  114. package/src/collection/index.ts +15 -19
  115. package/src/collection/subscription.ts +88 -6
  116. package/src/collection/sync.ts +81 -9
  117. package/src/errors.ts +91 -13
  118. package/src/event-emitter.ts +118 -0
  119. package/src/local-only.ts +5 -12
  120. package/src/query/builder/types.ts +1 -1
  121. package/src/query/compiler/index.ts +124 -33
  122. package/src/query/compiler/joins.ts +187 -128
  123. package/src/query/compiler/order-by.ts +30 -2
  124. package/src/query/compiler/select.ts +2 -3
  125. package/src/query/compiler/types.ts +5 -0
  126. package/src/query/index.ts +1 -0
  127. package/src/query/live/collection-config-builder.ts +501 -60
  128. package/src/query/live/collection-registry.ts +47 -0
  129. package/src/query/live/collection-subscriber.ts +137 -105
  130. package/src/query/live-query-collection.ts +47 -18
  131. package/src/query/optimizer.ts +85 -15
  132. package/src/scheduler.ts +198 -0
  133. package/src/transactions.ts +12 -1
  134. package/src/types.ts +93 -11
@@ -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 collection aliases to their extracted WHERE clauses for index optimization */
166
- collectionWhereClauses: Map<string, BasicExpression<boolean>>
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, collectionWhereClauses } = optimizeQuery(originalQuery)
187
+ * const { optimizedQuery, sourceWhereClauses } = optimizeQuery(originalQuery)
188
188
  * // Result: Single-source clauses moved to deepest possible subqueries
189
- * // collectionWhereClauses: Map { 'u' => eq(u.dept_id, 1), 'p' => gt(p.views, 100) }
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 collection WHERE clauses before optimization
194
- const collectionWhereClauses = extractCollectionWhereClauses(query)
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
- collectionWhereClauses,
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 collection aliases to their WHERE clauses
227
+ * @returns Map of source aliases to their WHERE clauses
228
228
  */
229
- function extractCollectionWhereClauses(
229
+ function extractSourceWhereClauses(
230
230
  query: QueryIR
231
231
  ): Map<string, BasicExpression<boolean>> {
232
- const collectionWhereClauses = new Map<string, BasicExpression<boolean>>()
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 collectionWhereClauses
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
- collectionWhereClauses.set(sourceAlias, whereClause)
257
+ sourceWhereClauses.set(sourceAlias, whereClause)
258
258
  }
259
259
  }
260
260
  }
261
261
 
262
- return collectionWhereClauses
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
  *
@@ -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()
@@ -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
- transactionStack = transactionStack.filter((t) => t.id !== tx.id)
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
- export type OnLoadMoreOptions = {
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
- onLoadMore?: (options: OnLoadMoreOptions) => void | Promise<void>
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 = Record<string, Fn>,
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 = Record<string, Fn>,
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 = Record<string, Fn>,
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 = Record<string, Fn>,
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 = Record<string, Fn>,
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 = Record<string, Fn>,
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 = Record<string, Fn>,
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
- > extends BaseCollectionConfig<T, TKey, TSchema> {
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
- > = CollectionConfig<T, TKey, TSchema> & MaybeSingleResult
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>