@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,255 @@
1
+ import type { LoadSubsetOptions } from '@tanstack/db'
2
+ import type { ExtractedFilters, FilterDimension } from './types.js'
3
+ import { toKey } from './serialization.js'
4
+
5
+ /**
6
+ * TanStack DB expression types (simplified for our needs)
7
+ * These mirror the IR types from @tanstack/db
8
+ */
9
+ interface PropRef {
10
+ type: `ref`
11
+ path: Array<string>
12
+ }
13
+
14
+ interface Value {
15
+ type: `val`
16
+ value: unknown
17
+ }
18
+
19
+ interface Func {
20
+ type: `func`
21
+ name: string
22
+ args: Array<BasicExpression>
23
+ }
24
+
25
+ type BasicExpression = PropRef | Value | Func
26
+
27
+ /**
28
+ * Check if a value is a PropRef expression
29
+ */
30
+ function isPropRef(expr: unknown): expr is PropRef {
31
+ return (
32
+ typeof expr === `object` &&
33
+ expr !== null &&
34
+ `type` in expr &&
35
+ expr.type === `ref` &&
36
+ `path` in expr &&
37
+ Array.isArray((expr as PropRef).path)
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Check if a value is a Value expression
43
+ */
44
+ function isValue(expr: unknown): expr is Value {
45
+ return (
46
+ typeof expr === `object` &&
47
+ expr !== null &&
48
+ `type` in expr &&
49
+ expr.type === `val` &&
50
+ `value` in expr
51
+ )
52
+ }
53
+
54
+ /**
55
+ * Check if a value is a Func expression
56
+ */
57
+ function isFunc(expr: unknown): expr is Func {
58
+ return (
59
+ typeof expr === `object` &&
60
+ expr !== null &&
61
+ `type` in expr &&
62
+ expr.type === `func` &&
63
+ `name` in expr &&
64
+ `args` in expr &&
65
+ Array.isArray((expr as Func).args)
66
+ )
67
+ }
68
+
69
+ /**
70
+ * Check if a PropRef matches our target field.
71
+ * Handles both aliased (e.g., ['msg', 'pageId']) and direct (e.g., ['pageId']) paths.
72
+ */
73
+ function propRefMatchesField(propRef: PropRef, fieldName: string): boolean {
74
+ const { path } = propRef
75
+ // Direct field reference: ['pageId']
76
+ if (path.length === 1 && path[0] === fieldName) {
77
+ return true
78
+ }
79
+ // Aliased field reference: ['msg', 'pageId'] or ['m', 'pageId']
80
+ if (path.length === 2 && path[1] === fieldName) {
81
+ return true
82
+ }
83
+ return false
84
+ }
85
+
86
+ /**
87
+ * Extract filter values from an 'eq' function call.
88
+ * Pattern: eq(ref(filterField), val(x)) or eq(val(x), ref(filterField))
89
+ */
90
+ function extractFromEq(func: Func, filterField: string): unknown[] {
91
+ if (func.args.length !== 2) return []
92
+
93
+ const [left, right] = func.args
94
+
95
+ // eq(ref, val)
96
+ if (isPropRef(left) && propRefMatchesField(left, filterField) && isValue(right)) {
97
+ return [right.value]
98
+ }
99
+
100
+ // eq(val, ref) - reversed order
101
+ if (isValue(left) && isPropRef(right) && propRefMatchesField(right, filterField)) {
102
+ return [left.value]
103
+ }
104
+
105
+ return []
106
+ }
107
+
108
+ /**
109
+ * Extract filter values from an 'in' function call.
110
+ * Pattern: in(ref(filterField), val([a, b, c]))
111
+ */
112
+ function extractFromIn(func: Func, filterField: string): unknown[] {
113
+ if (func.args.length !== 2) return []
114
+
115
+ const [left, right] = func.args
116
+
117
+ // in(ref, val)
118
+ if (isPropRef(left) && propRefMatchesField(left, filterField) && isValue(right)) {
119
+ const val = right.value
120
+ if (Array.isArray(val)) {
121
+ return val
122
+ }
123
+ }
124
+
125
+ return []
126
+ }
127
+
128
+ /**
129
+ * Recursively walk an expression tree to find all filter values for the given field.
130
+ * Handles 'eq', 'in', 'and', and 'or' expressions.
131
+ */
132
+ function walkExpression(expr: unknown, filterField: string): unknown[] {
133
+ if (!isFunc(expr)) return []
134
+
135
+ const { name, args } = expr
136
+ const results: unknown[] = []
137
+
138
+ switch (name) {
139
+ case `eq`:
140
+ results.push(...extractFromEq(expr, filterField))
141
+ break
142
+
143
+ case `in`:
144
+ results.push(...extractFromIn(expr, filterField))
145
+ break
146
+
147
+ case `and`:
148
+ case `or`:
149
+ // Recursively process all arguments
150
+ for (const arg of args) {
151
+ results.push(...walkExpression(arg, filterField))
152
+ }
153
+ break
154
+
155
+ // For other functions, recursively check their arguments
156
+ // (in case of nested expressions)
157
+ default:
158
+ for (const arg of args) {
159
+ results.push(...walkExpression(arg, filterField))
160
+ }
161
+ break
162
+ }
163
+
164
+ return results
165
+ }
166
+
167
+ /**
168
+ * Extract filter values from LoadSubsetOptions.
169
+ *
170
+ * Parses the `where` expression to find equality (`.eq()`) and set membership (`.in()`)
171
+ * comparisons for the specified filter field.
172
+ *
173
+ * @param options - The LoadSubsetOptions from a live query
174
+ * @param filterField - The field name to extract values for (e.g., 'pageId')
175
+ * @returns Array of unique filter values found in the expression
176
+ *
177
+ * @example
178
+ * // For query: where(m => m.pageId.eq('page-1'))
179
+ * extractFilterValues(options, 'pageId') // returns ['page-1']
180
+ *
181
+ * @example
182
+ * // For query: where(m => inArray(m.pageId, ['page-1', 'page-2']))
183
+ * extractFilterValues(options, 'pageId') // returns ['page-1', 'page-2']
184
+ *
185
+ * @example
186
+ * // For query: where(m => and(m.pageId.eq('page-1'), m.status.eq('active')))
187
+ * extractFilterValues(options, 'pageId') // returns ['page-1']
188
+ */
189
+ export function extractFilterValues(
190
+ options: LoadSubsetOptions,
191
+ filterField: string
192
+ ): unknown[] {
193
+ const { where } = options
194
+
195
+ if (!where) {
196
+ return []
197
+ }
198
+
199
+ // Extract from the where expression
200
+ const values = walkExpression(where, filterField)
201
+
202
+ // Return unique values using toKey for stable identity comparison
203
+ const seen = new Set<string>()
204
+ const unique: unknown[] = []
205
+ for (const value of values) {
206
+ const key = toKey(value)
207
+ if (!seen.has(key)) {
208
+ seen.add(key)
209
+ unique.push(value)
210
+ }
211
+ }
212
+
213
+ return unique
214
+ }
215
+
216
+ /**
217
+ * Check if LoadSubsetOptions contains a filter for the specified field.
218
+ */
219
+ export function hasFilterField(options: LoadSubsetOptions, filterField: string): boolean {
220
+ return extractFilterValues(options, filterField).length > 0
221
+ }
222
+
223
+ /**
224
+ * Extract filter values for multiple filter dimensions from LoadSubsetOptions.
225
+ *
226
+ * Parses the `where` expression to find values for each configured filter dimension.
227
+ * Results are keyed by convexArg for direct use in Convex query args.
228
+ *
229
+ * @param options - The LoadSubsetOptions from a live query
230
+ * @param filterDimensions - Array of filter dimensions to extract
231
+ * @returns Object mapping convexArg -> values for dimensions with matches
232
+ *
233
+ * @example
234
+ * // For query: where(m => m.pageId.eq('page-1') && m.authorId.eq('user-1'))
235
+ * extractMultipleFilterValues(options, [
236
+ * { filterField: 'pageId', convexArg: 'pageIds' },
237
+ * { filterField: 'authorId', convexArg: 'authorIds' },
238
+ * ])
239
+ * // returns { pageIds: ['page-1'], authorIds: ['user-1'] }
240
+ */
241
+ export function extractMultipleFilterValues(
242
+ options: LoadSubsetOptions,
243
+ filterDimensions: FilterDimension[]
244
+ ): ExtractedFilters {
245
+ const result: ExtractedFilters = {}
246
+
247
+ for (const dim of filterDimensions) {
248
+ const values = extractFilterValues(options, dim.filterField)
249
+ if (values.length > 0) {
250
+ result[dim.convexArg] = values
251
+ }
252
+ }
253
+
254
+ return result
255
+ }
package/src/index.ts ADDED
@@ -0,0 +1,247 @@
1
+ import type {
2
+ CollectionConfig,
3
+ LoadSubsetOptions,
4
+ SyncConfig,
5
+ UtilsRecord,
6
+ } from '@tanstack/db'
7
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
8
+ import type { FunctionReference, FunctionReturnType } from 'convex/server'
9
+ import { ConvexSyncManager } from './ConvexSyncManager.js'
10
+ import { extractMultipleFilterValues } from './expression-parser.js'
11
+ import type { ConvexCollectionConfig, FilterConfig, FilterDimension } from './types.js'
12
+
13
+ // Re-export types
14
+ export type {
15
+ ConvexCollectionConfig,
16
+ ConvexUnsubscribe,
17
+ ExtractedFilters,
18
+ FilterConfig,
19
+ FilterDimension,
20
+ } from './types.js'
21
+ export { extractFilterValues, extractMultipleFilterValues, hasFilterField } from './expression-parser.js'
22
+ export { ConvexSyncManager } from './ConvexSyncManager.js'
23
+ export { serializeValue, deserializeValue, toKey, fromKey } from './serialization.js'
24
+
25
+ // Default configuration values
26
+ const DEFAULT_UPDATED_AT_FIELD = `updatedAt`
27
+ const DEFAULT_DEBOUNCE_MS = 50
28
+ const DEFAULT_TAIL_OVERLAP_MS = 10000
29
+ const DEFAULT_RESUBSCRIBE_THRESHOLD = 10
30
+
31
+ /**
32
+ * Normalize filter configuration to an array of FilterDimension.
33
+ * - undefined = [] (0 filters, global sync)
34
+ * - single object = [object] (1 filter)
35
+ * - array = array as-is (N filters)
36
+ */
37
+ function normalizeFilterConfig(filters: FilterConfig): FilterDimension[] {
38
+ if (filters === undefined) return []
39
+ return Array.isArray(filters) ? filters : [filters]
40
+ }
41
+
42
+ /**
43
+ * Schema output type inference helper
44
+ */
45
+ type InferSchemaOutput<T> = T extends StandardSchemaV1
46
+ ? StandardSchemaV1.InferOutput<T> extends object
47
+ ? StandardSchemaV1.InferOutput<T>
48
+ : Record<string, unknown>
49
+ : Record<string, unknown>
50
+
51
+ /**
52
+ * Infer the item type from a Convex query's return type.
53
+ * Expects the query to return an array of items.
54
+ */
55
+ type InferQueryItemType<TQuery extends FunctionReference<'query'>> =
56
+ FunctionReturnType<TQuery> extends Array<infer T>
57
+ ? T extends object
58
+ ? T
59
+ : Record<string, unknown>
60
+ : Record<string, unknown>
61
+
62
+ /**
63
+ * Creates collection options for use with TanStack DB's createCollection.
64
+ * This integrates Convex real-time subscriptions with TanStack DB collections
65
+ * using the "backfill + tail" synchronization pattern.
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * import { createCollection } from '@tanstack/react-db'
70
+ * import { convexCollectionOptions } from '@michaelstewart/convex-tanstack-db-collection'
71
+ * import { api } from '@convex/_generated/api'
72
+ *
73
+ * // Single filter dimension
74
+ * const messagesCollection = createCollection(
75
+ * convexCollectionOptions({
76
+ * client: convexClient,
77
+ * query: api.messages.sync,
78
+ * filters: { filterField: 'pageId', convexArg: 'pageIds' },
79
+ * getKey: (msg) => msg._id,
80
+ *
81
+ * onInsert: async ({ transaction }) => {
82
+ * const newMsg = transaction.mutations[0].modified
83
+ * await convexClient.mutation(api.messages.create, newMsg)
84
+ * },
85
+ * })
86
+ * )
87
+ *
88
+ * // Multiple filter dimensions
89
+ * const filteredCollection = createCollection(
90
+ * convexCollectionOptions({
91
+ * client: convexClient,
92
+ * query: api.items.syncFiltered,
93
+ * filters: [
94
+ * { filterField: 'pageId', convexArg: 'pageIds' },
95
+ * { filterField: 'authorId', convexArg: 'authorIds' },
96
+ * ],
97
+ * getKey: (item) => item._id,
98
+ * })
99
+ * )
100
+ *
101
+ * // No filters (global sync)
102
+ * const allItemsCollection = createCollection(
103
+ * convexCollectionOptions({
104
+ * client: convexClient,
105
+ * query: api.items.syncAll, // Query takes only { after }
106
+ * getKey: (item) => item._id,
107
+ * })
108
+ * )
109
+ *
110
+ * // In UI:
111
+ * const { data: messages } = useLiveQuery(q =>
112
+ * q.from({ msg: messagesCollection })
113
+ * .where(({ msg }) => msg.pageId.eq('page-123'))
114
+ * )
115
+ * ```
116
+ */
117
+
118
+ // Overload for when schema is provided
119
+ export function convexCollectionOptions<
120
+ TSchema extends StandardSchemaV1,
121
+ TKey extends string | number = string | number,
122
+ TUtils extends UtilsRecord = UtilsRecord,
123
+ >(
124
+ config: ConvexCollectionConfig<InferSchemaOutput<TSchema>, TKey, TSchema, TUtils> & {
125
+ schema: TSchema
126
+ }
127
+ ): CollectionConfig<InferSchemaOutput<TSchema>, TKey, TSchema, TUtils>
128
+
129
+ // Overload for when no schema is provided - T is inferred from query's return type
130
+ export function convexCollectionOptions<
131
+ TQuery extends FunctionReference<'query'>,
132
+ T extends InferQueryItemType<TQuery> = InferQueryItemType<TQuery>,
133
+ TKey extends string | number = string | number,
134
+ TUtils extends UtilsRecord = UtilsRecord,
135
+ >(
136
+ config: Omit<ConvexCollectionConfig<T, TKey, never, TUtils>, 'query'> & {
137
+ schema?: never
138
+ query: TQuery
139
+ getKey: (item: T) => TKey
140
+ }
141
+ ): CollectionConfig<T, TKey, never, TUtils>
142
+
143
+ // Implementation - uses concrete types; overloads provide proper type inference
144
+ export function convexCollectionOptions(
145
+ config: ConvexCollectionConfig<Record<string, unknown>, string | number, never, UtilsRecord>
146
+ ): CollectionConfig<Record<string, unknown>, string | number, never, UtilsRecord> {
147
+ const {
148
+ client,
149
+ query,
150
+ filters,
151
+ updatedAtFieldName = DEFAULT_UPDATED_AT_FIELD,
152
+ debounceMs = DEFAULT_DEBOUNCE_MS,
153
+ tailOverlapMs = DEFAULT_TAIL_OVERLAP_MS,
154
+ resubscribeThreshold = DEFAULT_RESUBSCRIBE_THRESHOLD,
155
+ getKey,
156
+ onInsert,
157
+ onUpdate,
158
+ ...baseConfig
159
+ } = config
160
+
161
+ // Normalize filter configuration
162
+ const filterDimensions = normalizeFilterConfig(filters)
163
+
164
+ // Create the sync manager
165
+ const syncManager = new ConvexSyncManager<any, any>({
166
+ client,
167
+ query,
168
+ filterDimensions,
169
+ updatedAtFieldName,
170
+ debounceMs,
171
+ tailOverlapMs,
172
+ resubscribeThreshold,
173
+ getKey: getKey as (item: any) => string | number,
174
+ })
175
+
176
+ // Create the sync configuration
177
+ const syncConfig: SyncConfig<any, any> = {
178
+ sync: (params) => {
179
+ const { collection, begin, write, commit, markReady } = params
180
+
181
+ // Initialize sync manager with callbacks
182
+ syncManager.setCallbacks({
183
+ collection: {
184
+ get: (key) => collection.get(key),
185
+ has: (key) => collection.has(key),
186
+ },
187
+ begin,
188
+ write,
189
+ commit,
190
+ markReady,
191
+ })
192
+
193
+ // Return loadSubset, unloadSubset, and cleanup handlers
194
+ return {
195
+ loadSubset: (options: LoadSubsetOptions): Promise<void> => {
196
+ // 0-filter case: global sync
197
+ if (filterDimensions.length === 0) {
198
+ return syncManager.requestFilters({})
199
+ }
200
+
201
+ // Extract filter values from the where expression
202
+ const extracted = extractMultipleFilterValues(options, filterDimensions)
203
+
204
+ // Sync if ANY dimension has values (any-filter matching)
205
+ // Convex query arg validators enforce which combinations are valid
206
+ if (Object.keys(extracted).length === 0) {
207
+ // No filter values found - this is expected for queries that filter
208
+ // by other fields (e.g., clientId, parentId). These queries read from
209
+ // the already-synced collection and don't need to trigger a sync.
210
+ return Promise.resolve()
211
+ }
212
+
213
+ return syncManager.requestFilters(extracted)
214
+ },
215
+
216
+ unloadSubset: (options: LoadSubsetOptions): void => {
217
+ // 0-filter case: global sync
218
+ if (filterDimensions.length === 0) {
219
+ syncManager.releaseFilters({})
220
+ return
221
+ }
222
+
223
+ // Extract filter values from the where expression
224
+ const extracted = extractMultipleFilterValues(options, filterDimensions)
225
+
226
+ if (Object.keys(extracted).length > 0) {
227
+ syncManager.releaseFilters(extracted)
228
+ }
229
+ },
230
+
231
+ cleanup: () => {
232
+ syncManager.cleanup()
233
+ },
234
+ }
235
+ },
236
+ }
237
+
238
+ // Return the complete collection config
239
+ return {
240
+ ...baseConfig,
241
+ getKey,
242
+ syncMode: `on-demand`, // Always on-demand since we sync based on query predicates
243
+ sync: syncConfig,
244
+ onInsert,
245
+ onUpdate,
246
+ }
247
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Value serialization utilities for stable hashing and round-tripping.
3
+ *
4
+ * Adapted from TanStack DB's query-db-collection:
5
+ * https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/serialization.ts
6
+ */
7
+
8
+ interface TypeMarker {
9
+ __type: string
10
+ value?: unknown
11
+ sign?: number
12
+ }
13
+
14
+ function isTypeMarker(value: unknown): value is TypeMarker {
15
+ return (
16
+ typeof value === `object` &&
17
+ value !== null &&
18
+ `__type` in value &&
19
+ typeof (value as TypeMarker).__type === `string`
20
+ )
21
+ }
22
+
23
+ /**
24
+ * Serializes a value into a JSON-safe format that preserves special JS types.
25
+ * Handles: undefined, NaN, Infinity, -Infinity, Date, arrays, and objects.
26
+ */
27
+ export function serializeValue(value: unknown): unknown {
28
+ if (value === undefined) {
29
+ return { __type: `undefined` }
30
+ }
31
+
32
+ if (typeof value === `number`) {
33
+ if (Number.isNaN(value)) {
34
+ return { __type: `nan` }
35
+ }
36
+ if (value === Number.POSITIVE_INFINITY) {
37
+ return { __type: `infinity`, sign: 1 }
38
+ }
39
+ if (value === Number.NEGATIVE_INFINITY) {
40
+ return { __type: `infinity`, sign: -1 }
41
+ }
42
+ }
43
+
44
+ if (
45
+ value === null ||
46
+ typeof value === `string` ||
47
+ typeof value === `number` ||
48
+ typeof value === `boolean`
49
+ ) {
50
+ return value
51
+ }
52
+
53
+ if (value instanceof Date) {
54
+ return { __type: `date`, value: value.toJSON() }
55
+ }
56
+
57
+ if (Array.isArray(value)) {
58
+ return value.map((item) => serializeValue(item))
59
+ }
60
+
61
+ if (typeof value === `object`) {
62
+ return Object.fromEntries(
63
+ Object.entries(value as Record<string, unknown>).map(([key, val]) => [
64
+ key,
65
+ serializeValue(val),
66
+ ])
67
+ )
68
+ }
69
+
70
+ return value
71
+ }
72
+
73
+ /**
74
+ * Deserializes a value back from its JSON-safe format.
75
+ * Restores: undefined, NaN, Infinity, -Infinity, Date, arrays, and objects.
76
+ */
77
+ export function deserializeValue(value: unknown): unknown {
78
+ if (isTypeMarker(value)) {
79
+ switch (value.__type) {
80
+ case `undefined`:
81
+ return undefined
82
+ case `nan`:
83
+ return NaN
84
+ case `infinity`:
85
+ return value.sign === 1 ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY
86
+ case `date`:
87
+ return new Date(value.value as string)
88
+ default:
89
+ return value
90
+ }
91
+ }
92
+
93
+ if (value === null || typeof value === `string` || typeof value === `number` || typeof value === `boolean`) {
94
+ return value
95
+ }
96
+
97
+ if (Array.isArray(value)) {
98
+ return value.map((item) => deserializeValue(item))
99
+ }
100
+
101
+ if (typeof value === `object`) {
102
+ return Object.fromEntries(
103
+ Object.entries(value as Record<string, unknown>).map(([key, val]) => [
104
+ key,
105
+ deserializeValue(val),
106
+ ])
107
+ )
108
+ }
109
+
110
+ return value
111
+ }
112
+
113
+ /**
114
+ * Converts a value to a stable string key for use in Sets/Maps.
115
+ */
116
+ export function toKey(value: unknown): string {
117
+ return JSON.stringify(serializeValue(value))
118
+ }
119
+
120
+ /**
121
+ * Restores a value from its string key representation.
122
+ */
123
+ export function fromKey(key: string): unknown {
124
+ return deserializeValue(JSON.parse(key))
125
+ }