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