@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.
@@ -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
+ }