@michaelstewart/convex-tanstack-db-collection 0.0.1
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/LICENSE +21 -0
- package/README.md +385 -0
- package/dist/cjs/index.cjs +679 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/esm/index.d.ts +461 -0
- package/dist/esm/index.js +679 -0
- package/dist/esm/index.js.map +1 -0
- package/package.json +63 -0
- package/src/ConvexSyncManager.ts +611 -0
- package/src/expression-parser.ts +255 -0
- package/src/index.ts +247 -0
- package/src/serialization.ts +125 -0
- package/src/types.ts +260 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import type { FunctionReference } from 'convex/server'
|
|
2
|
+
import type { ChangeMessageOrDeleteKeyMessage } from '@tanstack/db'
|
|
3
|
+
import type {
|
|
4
|
+
ConvexClientLike,
|
|
5
|
+
ConvexSyncManagerOptions,
|
|
6
|
+
ExtractedFilters,
|
|
7
|
+
FilterDimension,
|
|
8
|
+
} from './types.js'
|
|
9
|
+
import { toKey, fromKey } from './serialization.js'
|
|
10
|
+
|
|
11
|
+
export type { ConvexSyncManagerOptions }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sync callbacks passed from TanStack DB's sync function
|
|
15
|
+
*/
|
|
16
|
+
export interface SyncCallbacks<
|
|
17
|
+
T extends object = Record<string, unknown>,
|
|
18
|
+
TKey extends string | number = string | number,
|
|
19
|
+
> {
|
|
20
|
+
collection: {
|
|
21
|
+
get: (key: TKey) => T | undefined
|
|
22
|
+
has: (key: TKey) => boolean
|
|
23
|
+
}
|
|
24
|
+
begin: () => void
|
|
25
|
+
write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => void
|
|
26
|
+
commit: () => void
|
|
27
|
+
markReady: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* ConvexSyncManager - Manages real-time synchronization with Convex backend
|
|
32
|
+
*
|
|
33
|
+
* Implements the "backfill + tail" pattern:
|
|
34
|
+
* 1. When new filters are requested, backfill with `after: 0` to get full history
|
|
35
|
+
* 2. Maintain a single live subscription for all active filter values with `after: globalCursor - tailOverlapMs`
|
|
36
|
+
* 3. Use LWW (Last-Write-Wins) to handle overlapping data from backfill and subscription
|
|
37
|
+
*
|
|
38
|
+
* Supports 0, 1, or N filter dimensions:
|
|
39
|
+
* - 0 filters: Global sync with just { after }
|
|
40
|
+
* - 1+ filters: Filter-based sync with values extracted from where clauses
|
|
41
|
+
*/
|
|
42
|
+
export class ConvexSyncManager<
|
|
43
|
+
T extends object = Record<string, unknown>,
|
|
44
|
+
TKey extends string | number = string | number,
|
|
45
|
+
> {
|
|
46
|
+
// Configuration
|
|
47
|
+
private client: ConvexClientLike
|
|
48
|
+
private query: FunctionReference<`query`>
|
|
49
|
+
private filterDimensions: FilterDimension[]
|
|
50
|
+
private updatedAtFieldName: string
|
|
51
|
+
private debounceMs: number
|
|
52
|
+
private tailOverlapMs: number
|
|
53
|
+
private resubscribeThreshold: number
|
|
54
|
+
private getKey: (item: T) => TKey
|
|
55
|
+
|
|
56
|
+
// State - per-dimension tracking (keyed by convexArg)
|
|
57
|
+
private activeDimensions = new Map<string, Set<string>>()
|
|
58
|
+
private refCounts = new Map<string, number>() // composite key -> count
|
|
59
|
+
private pendingFilters: ExtractedFilters = {}
|
|
60
|
+
private globalCursor = 0
|
|
61
|
+
private currentSubscription: (() => void) | null = null
|
|
62
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
63
|
+
private isProcessing = false
|
|
64
|
+
private markedReady = false
|
|
65
|
+
|
|
66
|
+
// For 0-filter case (global sync)
|
|
67
|
+
private hasRequestedGlobal = false
|
|
68
|
+
private globalRefCount = 0
|
|
69
|
+
|
|
70
|
+
// Track messages received since last subscription to batch cursor updates
|
|
71
|
+
private messagesSinceSubscription = 0
|
|
72
|
+
|
|
73
|
+
// Sync callbacks (set when sync() is called)
|
|
74
|
+
private callbacks: SyncCallbacks<T, TKey> | null = null
|
|
75
|
+
|
|
76
|
+
constructor(options: ConvexSyncManagerOptions<T>) {
|
|
77
|
+
this.client = options.client
|
|
78
|
+
this.query = options.query
|
|
79
|
+
this.filterDimensions = options.filterDimensions
|
|
80
|
+
this.updatedAtFieldName = options.updatedAtFieldName
|
|
81
|
+
this.debounceMs = options.debounceMs
|
|
82
|
+
this.tailOverlapMs = options.tailOverlapMs
|
|
83
|
+
this.resubscribeThreshold = options.resubscribeThreshold
|
|
84
|
+
this.getKey = options.getKey as (item: T) => TKey
|
|
85
|
+
|
|
86
|
+
// Initialize activeDimensions for each filter dimension
|
|
87
|
+
for (const dim of this.filterDimensions) {
|
|
88
|
+
this.activeDimensions.set(dim.convexArg, new Set())
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Initialize the sync manager with callbacks from TanStack DB
|
|
94
|
+
*/
|
|
95
|
+
setCallbacks(callbacks: SyncCallbacks<T, TKey>): void {
|
|
96
|
+
this.callbacks = callbacks
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create a composite key for ref counting multi-filter combinations.
|
|
101
|
+
* Uses serialized values for deterministic keys.
|
|
102
|
+
*/
|
|
103
|
+
private createCompositeKey(filters: ExtractedFilters): string {
|
|
104
|
+
// Sort by convexArg for deterministic ordering
|
|
105
|
+
const sorted = Object.keys(filters)
|
|
106
|
+
.sort()
|
|
107
|
+
.reduce(
|
|
108
|
+
(acc, key) => {
|
|
109
|
+
// Serialize values and sort for deterministic ordering
|
|
110
|
+
const values = filters[key]
|
|
111
|
+
if (values) {
|
|
112
|
+
acc[key] = values.map((v) => toKey(v)).sort()
|
|
113
|
+
}
|
|
114
|
+
return acc
|
|
115
|
+
},
|
|
116
|
+
{} as Record<string, string[]>
|
|
117
|
+
)
|
|
118
|
+
return JSON.stringify(sorted)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Request filters to be synced (called by loadSubset)
|
|
123
|
+
* Filters are batched via debouncing for efficiency
|
|
124
|
+
*/
|
|
125
|
+
requestFilters(filters: ExtractedFilters): Promise<void> {
|
|
126
|
+
// Handle 0-filter case (global sync)
|
|
127
|
+
if (this.filterDimensions.length === 0) {
|
|
128
|
+
this.globalRefCount++
|
|
129
|
+
if (!this.hasRequestedGlobal) {
|
|
130
|
+
this.hasRequestedGlobal = true
|
|
131
|
+
return this.scheduleProcessing()
|
|
132
|
+
}
|
|
133
|
+
return Promise.resolve()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Increment ref count for this filter combination
|
|
137
|
+
const compositeKey = this.createCompositeKey(filters)
|
|
138
|
+
const count = this.refCounts.get(compositeKey) || 0
|
|
139
|
+
this.refCounts.set(compositeKey, count + 1)
|
|
140
|
+
|
|
141
|
+
// Track which values are new (not yet active)
|
|
142
|
+
let hasNewValues = false
|
|
143
|
+
for (const [convexArg, values] of Object.entries(filters)) {
|
|
144
|
+
const activeSet = this.activeDimensions.get(convexArg)
|
|
145
|
+
if (!activeSet) continue
|
|
146
|
+
|
|
147
|
+
// Find the dimension config for single validation
|
|
148
|
+
const dim = this.filterDimensions.find((d) => d.convexArg === convexArg)
|
|
149
|
+
|
|
150
|
+
// Validate single constraint before adding values
|
|
151
|
+
if (dim?.single) {
|
|
152
|
+
const existingCount = activeSet.size
|
|
153
|
+
const pendingCount = this.pendingFilters[convexArg]?.length ?? 0
|
|
154
|
+
const newValues = values.filter((v) => {
|
|
155
|
+
const serialized = toKey(v)
|
|
156
|
+
const alreadyActive = activeSet.has(serialized)
|
|
157
|
+
const alreadyPending = this.pendingFilters[convexArg]?.some(
|
|
158
|
+
(pv) => toKey(pv) === serialized
|
|
159
|
+
)
|
|
160
|
+
return !alreadyActive && !alreadyPending
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
if (existingCount + pendingCount + newValues.length > 1) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Filter '${dim.filterField}' is configured as single but multiple values were requested. ` +
|
|
166
|
+
`Active: ${existingCount}, Pending: ${pendingCount}, New: ${newValues.length}. ` +
|
|
167
|
+
`Use single: false if you need to sync multiple values.`
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const value of values) {
|
|
173
|
+
const serialized = toKey(value)
|
|
174
|
+
if (!activeSet.has(serialized)) {
|
|
175
|
+
// Add to pending filters
|
|
176
|
+
if (!this.pendingFilters[convexArg]) {
|
|
177
|
+
this.pendingFilters[convexArg] = []
|
|
178
|
+
}
|
|
179
|
+
// Check if already pending (by serialized key)
|
|
180
|
+
const alreadyPending = this.pendingFilters[convexArg].some(
|
|
181
|
+
(v) => toKey(v) === serialized
|
|
182
|
+
)
|
|
183
|
+
if (!alreadyPending) {
|
|
184
|
+
this.pendingFilters[convexArg].push(value)
|
|
185
|
+
hasNewValues = true
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// If there are new values, schedule processing
|
|
192
|
+
if (hasNewValues) {
|
|
193
|
+
return this.scheduleProcessing()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return Promise.resolve()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Release filters when no longer needed (called by unloadSubset)
|
|
201
|
+
*/
|
|
202
|
+
releaseFilters(filters: ExtractedFilters): void {
|
|
203
|
+
// Handle 0-filter case
|
|
204
|
+
if (this.filterDimensions.length === 0) {
|
|
205
|
+
this.globalRefCount = Math.max(0, this.globalRefCount - 1)
|
|
206
|
+
if (this.globalRefCount === 0 && this.hasRequestedGlobal) {
|
|
207
|
+
this.hasRequestedGlobal = false
|
|
208
|
+
this.updateSubscription()
|
|
209
|
+
}
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Decrement ref count for this filter combination
|
|
214
|
+
const compositeKey = this.createCompositeKey(filters)
|
|
215
|
+
const count = (this.refCounts.get(compositeKey) || 0) - 1
|
|
216
|
+
|
|
217
|
+
if (count <= 0) {
|
|
218
|
+
this.refCounts.delete(compositeKey)
|
|
219
|
+
} else {
|
|
220
|
+
this.refCounts.set(compositeKey, count)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check if any values are now unreferenced
|
|
224
|
+
this.cleanupUnreferencedValues()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Remove values from activeDimensions that are no longer referenced
|
|
229
|
+
* by any composite key in refCounts
|
|
230
|
+
*/
|
|
231
|
+
private cleanupUnreferencedValues(): void {
|
|
232
|
+
// Collect all referenced serialized values per dimension
|
|
233
|
+
const referencedValues = new Map<string, Set<string>>()
|
|
234
|
+
for (const dim of this.filterDimensions) {
|
|
235
|
+
referencedValues.set(dim.convexArg, new Set())
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Walk through all composite keys and collect their serialized values
|
|
239
|
+
for (const compositeKey of this.refCounts.keys()) {
|
|
240
|
+
try {
|
|
241
|
+
// Composite keys store values as already-serialized strings
|
|
242
|
+
const filters = JSON.parse(compositeKey) as Record<string, string[]>
|
|
243
|
+
for (const [convexArg, serializedValues] of Object.entries(filters)) {
|
|
244
|
+
const refSet = referencedValues.get(convexArg)
|
|
245
|
+
if (refSet) {
|
|
246
|
+
for (const serialized of serializedValues) {
|
|
247
|
+
refSet.add(serialized)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
// Skip invalid keys
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Remove unreferenced values from activeDimensions
|
|
257
|
+
// (activeDimensions stores serialized keys)
|
|
258
|
+
let needsSubscriptionUpdate = false
|
|
259
|
+
for (const [convexArg, activeSet] of this.activeDimensions) {
|
|
260
|
+
const refSet = referencedValues.get(convexArg)!
|
|
261
|
+
for (const serialized of activeSet) {
|
|
262
|
+
if (!refSet.has(serialized)) {
|
|
263
|
+
activeSet.delete(serialized)
|
|
264
|
+
needsSubscriptionUpdate = true
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (needsSubscriptionUpdate) {
|
|
270
|
+
this.updateSubscription()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Schedule debounced processing of pending filters
|
|
276
|
+
*/
|
|
277
|
+
private scheduleProcessing(): Promise<void> {
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
// Clear existing timer
|
|
280
|
+
if (this.debounceTimer) {
|
|
281
|
+
clearTimeout(this.debounceTimer)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Schedule processing
|
|
285
|
+
this.debounceTimer = setTimeout(async () => {
|
|
286
|
+
try {
|
|
287
|
+
await this.processFilterBatch()
|
|
288
|
+
resolve()
|
|
289
|
+
} catch (error) {
|
|
290
|
+
reject(error)
|
|
291
|
+
}
|
|
292
|
+
}, this.debounceMs)
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Process the current batch of pending filters
|
|
298
|
+
*/
|
|
299
|
+
private async processFilterBatch(): Promise<void> {
|
|
300
|
+
if (this.isProcessing) {
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Check if there's anything to process
|
|
305
|
+
const hasPendingFilters = Object.keys(this.pendingFilters).length > 0
|
|
306
|
+
const needsGlobalSync = this.filterDimensions.length === 0 && this.hasRequestedGlobal
|
|
307
|
+
|
|
308
|
+
if (!hasPendingFilters && !needsGlobalSync) {
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.isProcessing = true
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
if (this.filterDimensions.length === 0) {
|
|
316
|
+
// 0-filter case: global sync
|
|
317
|
+
await this.runGlobalBackfill()
|
|
318
|
+
} else {
|
|
319
|
+
// Collect new filter values that need backfill
|
|
320
|
+
const newFilters = { ...this.pendingFilters }
|
|
321
|
+
this.pendingFilters = {}
|
|
322
|
+
|
|
323
|
+
// Add to active dimensions (store serialized keys)
|
|
324
|
+
for (const [convexArg, values] of Object.entries(newFilters)) {
|
|
325
|
+
const activeSet = this.activeDimensions.get(convexArg)
|
|
326
|
+
if (activeSet) {
|
|
327
|
+
for (const value of values) {
|
|
328
|
+
activeSet.add(toKey(value))
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Run backfill for new filter values (fetch full history)
|
|
334
|
+
await this.runBackfill(newFilters)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Update the live subscription to include all active values
|
|
338
|
+
this.updateSubscription()
|
|
339
|
+
|
|
340
|
+
// Mark ready after first successful sync
|
|
341
|
+
if (!this.markedReady && this.callbacks) {
|
|
342
|
+
this.callbacks.markReady()
|
|
343
|
+
this.markedReady = true
|
|
344
|
+
}
|
|
345
|
+
} finally {
|
|
346
|
+
this.isProcessing = false
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Run global backfill for 0-filter case
|
|
352
|
+
*/
|
|
353
|
+
private async runGlobalBackfill(): Promise<void> {
|
|
354
|
+
try {
|
|
355
|
+
const args: Record<string, unknown> = { after: 0 }
|
|
356
|
+
|
|
357
|
+
const items = await this.client.query(this.query, args as any)
|
|
358
|
+
|
|
359
|
+
if (Array.isArray(items)) {
|
|
360
|
+
this.handleIncomingData(items as T[])
|
|
361
|
+
}
|
|
362
|
+
} catch (error) {
|
|
363
|
+
console.error('[ConvexSyncManager] Global backfill error:', error)
|
|
364
|
+
throw error
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Run backfill query for new filter values to get their full history
|
|
370
|
+
*/
|
|
371
|
+
private async runBackfill(newFilters: ExtractedFilters): Promise<void> {
|
|
372
|
+
if (Object.keys(newFilters).length === 0) return
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
// Query with after: 0 to get full history for new filter values
|
|
376
|
+
const args: Record<string, unknown> = {
|
|
377
|
+
...newFilters,
|
|
378
|
+
after: 0,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const items = await this.client.query(this.query, args as any)
|
|
382
|
+
|
|
383
|
+
if (Array.isArray(items)) {
|
|
384
|
+
this.handleIncomingData(items as T[])
|
|
385
|
+
}
|
|
386
|
+
} catch (error) {
|
|
387
|
+
console.error('[ConvexSyncManager] Backfill error:', error)
|
|
388
|
+
throw error
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Build query args from all active dimensions
|
|
394
|
+
*/
|
|
395
|
+
private buildQueryArgs(after: number): Record<string, unknown> {
|
|
396
|
+
const args: Record<string, unknown> = { after }
|
|
397
|
+
|
|
398
|
+
// Deserialize values back to original types for the Convex query
|
|
399
|
+
for (const [convexArg, serializedValues] of this.activeDimensions) {
|
|
400
|
+
const values = [...serializedValues].map((s) => fromKey(s))
|
|
401
|
+
|
|
402
|
+
// Check if this dimension is configured as single
|
|
403
|
+
const dim = this.filterDimensions.find((d) => d.convexArg === convexArg)
|
|
404
|
+
args[convexArg] = dim?.single ? values[0] : values
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return args
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Update the live subscription to cover all active filter values
|
|
412
|
+
*/
|
|
413
|
+
private updateSubscription(): void {
|
|
414
|
+
// Unsubscribe from current subscription
|
|
415
|
+
if (this.currentSubscription) {
|
|
416
|
+
this.currentSubscription()
|
|
417
|
+
this.currentSubscription = null
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Reset message counter for new subscription
|
|
421
|
+
this.messagesSinceSubscription = 0
|
|
422
|
+
|
|
423
|
+
// Check if we should subscribe
|
|
424
|
+
if (this.filterDimensions.length === 0) {
|
|
425
|
+
// 0-filter case: subscribe if global sync is active
|
|
426
|
+
if (!this.hasRequestedGlobal) {
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
// Check if any dimension has active values
|
|
431
|
+
let hasActiveValues = false
|
|
432
|
+
for (const activeSet of this.activeDimensions.values()) {
|
|
433
|
+
if (activeSet.size > 0) {
|
|
434
|
+
hasActiveValues = true
|
|
435
|
+
break
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!hasActiveValues) {
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Calculate cursor with overlap to avoid missing updates
|
|
444
|
+
const cursor = Math.max(0, this.globalCursor - this.tailOverlapMs)
|
|
445
|
+
|
|
446
|
+
// Build subscription args
|
|
447
|
+
const args = this.buildQueryArgs(cursor)
|
|
448
|
+
|
|
449
|
+
// Runtime detection: ConvexClient has onUpdate, ConvexReactClient has watchQuery
|
|
450
|
+
if ('onUpdate' in this.client) {
|
|
451
|
+
// ConvexClient pattern: client.onUpdate(query, args, callback)
|
|
452
|
+
const subscription = this.client.onUpdate(
|
|
453
|
+
this.query,
|
|
454
|
+
args as any,
|
|
455
|
+
(result: unknown) => {
|
|
456
|
+
if (result !== undefined) {
|
|
457
|
+
const items = result as T[]
|
|
458
|
+
if (Array.isArray(items)) {
|
|
459
|
+
this.handleIncomingData(items)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
(error: unknown) => {
|
|
464
|
+
console.error(`[ConvexSyncManager] Subscription error:`, error)
|
|
465
|
+
}
|
|
466
|
+
)
|
|
467
|
+
this.currentSubscription = () => subscription.unsubscribe()
|
|
468
|
+
} else {
|
|
469
|
+
// ConvexReactClient pattern: client.watchQuery(query, args).onUpdate(callback)
|
|
470
|
+
const watch = this.client.watchQuery(this.query, args as any)
|
|
471
|
+
this.currentSubscription = watch.onUpdate(() => {
|
|
472
|
+
// Get current value from the watch
|
|
473
|
+
const result = watch.localQueryResult()
|
|
474
|
+
if (result !== undefined) {
|
|
475
|
+
const items = result as T[]
|
|
476
|
+
if (Array.isArray(items)) {
|
|
477
|
+
this.handleIncomingData(items)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Handle incoming data from backfill or subscription
|
|
486
|
+
* Uses LWW (Last-Write-Wins) to resolve conflicts
|
|
487
|
+
*/
|
|
488
|
+
private handleIncomingData(items: T[]): void {
|
|
489
|
+
if (!this.callbacks || items.length === 0) return
|
|
490
|
+
|
|
491
|
+
const { collection, begin, write, commit } = this.callbacks
|
|
492
|
+
|
|
493
|
+
// Track if we see new items that advance the cursor
|
|
494
|
+
const previousCursor = this.globalCursor
|
|
495
|
+
let newItemCount = 0
|
|
496
|
+
|
|
497
|
+
begin()
|
|
498
|
+
|
|
499
|
+
for (const item of items) {
|
|
500
|
+
const key = this.getKey(item)
|
|
501
|
+
const incomingTs = (item as any)[this.updatedAtFieldName] as number | undefined
|
|
502
|
+
|
|
503
|
+
// Update global cursor to track the latest timestamp we've seen
|
|
504
|
+
if (incomingTs !== undefined && incomingTs > this.globalCursor) {
|
|
505
|
+
this.globalCursor = incomingTs
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const existing = collection.get(key)
|
|
509
|
+
|
|
510
|
+
if (!existing) {
|
|
511
|
+
// New item - insert
|
|
512
|
+
write({ type: `insert`, value: item })
|
|
513
|
+
// Count as new if it's beyond the previous cursor (not from overlap)
|
|
514
|
+
if (incomingTs !== undefined && incomingTs > previousCursor) {
|
|
515
|
+
newItemCount++
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
// Existing item - check if incoming is fresher (LWW)
|
|
519
|
+
const existingTs = (existing as any)[this.updatedAtFieldName] as number | undefined
|
|
520
|
+
|
|
521
|
+
if (incomingTs !== undefined && existingTs !== undefined) {
|
|
522
|
+
if (incomingTs > existingTs) {
|
|
523
|
+
// Incoming is fresher - update
|
|
524
|
+
write({ type: `update`, value: item })
|
|
525
|
+
// Count as new if it's beyond the previous cursor
|
|
526
|
+
if (incomingTs > previousCursor) {
|
|
527
|
+
newItemCount++
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// Otherwise skip (stale data from overlap)
|
|
531
|
+
} else if (incomingTs !== undefined) {
|
|
532
|
+
// Existing has no timestamp, incoming does - update
|
|
533
|
+
write({ type: `update`, value: item })
|
|
534
|
+
}
|
|
535
|
+
// If incoming has no timestamp, skip (can't determine freshness)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
commit()
|
|
540
|
+
|
|
541
|
+
// Track only new messages (beyond previous cursor) for cursor advancement
|
|
542
|
+
this.messagesSinceSubscription += newItemCount
|
|
543
|
+
|
|
544
|
+
// Re-subscribe with advanced cursor after threshold is reached
|
|
545
|
+
if (
|
|
546
|
+
this.resubscribeThreshold > 0 &&
|
|
547
|
+
this.messagesSinceSubscription >= this.resubscribeThreshold &&
|
|
548
|
+
this.currentSubscription !== null
|
|
549
|
+
) {
|
|
550
|
+
this.updateSubscription()
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Clean up all resources
|
|
556
|
+
*/
|
|
557
|
+
cleanup(): void {
|
|
558
|
+
// Clear debounce timer
|
|
559
|
+
if (this.debounceTimer) {
|
|
560
|
+
clearTimeout(this.debounceTimer)
|
|
561
|
+
this.debounceTimer = null
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Unsubscribe from current subscription
|
|
565
|
+
if (this.currentSubscription) {
|
|
566
|
+
this.currentSubscription()
|
|
567
|
+
this.currentSubscription = null
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Clear state
|
|
571
|
+
for (const activeSet of this.activeDimensions.values()) {
|
|
572
|
+
activeSet.clear()
|
|
573
|
+
}
|
|
574
|
+
this.refCounts.clear()
|
|
575
|
+
this.pendingFilters = {}
|
|
576
|
+
this.globalCursor = 0
|
|
577
|
+
this.markedReady = false
|
|
578
|
+
this.hasRequestedGlobal = false
|
|
579
|
+
this.globalRefCount = 0
|
|
580
|
+
this.messagesSinceSubscription = 0
|
|
581
|
+
this.callbacks = null
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Get debug info about current state
|
|
586
|
+
*/
|
|
587
|
+
getDebugInfo(): {
|
|
588
|
+
activeDimensions: Record<string, unknown[]>
|
|
589
|
+
globalCursor: number
|
|
590
|
+
pendingFilters: ExtractedFilters
|
|
591
|
+
hasSubscription: boolean
|
|
592
|
+
markedReady: boolean
|
|
593
|
+
hasRequestedGlobal: boolean
|
|
594
|
+
messagesSinceSubscription: number
|
|
595
|
+
} {
|
|
596
|
+
const activeDimensions: Record<string, unknown[]> = {}
|
|
597
|
+
for (const [convexArg, serializedValues] of this.activeDimensions) {
|
|
598
|
+
activeDimensions[convexArg] = [...serializedValues].map((s) => fromKey(s))
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return {
|
|
602
|
+
activeDimensions,
|
|
603
|
+
globalCursor: this.globalCursor,
|
|
604
|
+
pendingFilters: { ...this.pendingFilters },
|
|
605
|
+
hasSubscription: this.currentSubscription !== null,
|
|
606
|
+
markedReady: this.markedReady,
|
|
607
|
+
hasRequestedGlobal: this.hasRequestedGlobal,
|
|
608
|
+
messagesSinceSubscription: this.messagesSinceSubscription,
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|