@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 @@
1
+ {"version":3,"file":"index.cjs","sources":["../../src/serialization.ts","../../src/ConvexSyncManager.ts","../../src/expression-parser.ts","../../src/index.ts"],"sourcesContent":["/**\n * Value serialization utilities for stable hashing and round-tripping.\n *\n * Adapted from TanStack DB's query-db-collection:\n * https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/serialization.ts\n */\n\ninterface TypeMarker {\n __type: string\n value?: unknown\n sign?: number\n}\n\nfunction isTypeMarker(value: unknown): value is TypeMarker {\n return (\n typeof value === `object` &&\n value !== null &&\n `__type` in value &&\n typeof (value as TypeMarker).__type === `string`\n )\n}\n\n/**\n * Serializes a value into a JSON-safe format that preserves special JS types.\n * Handles: undefined, NaN, Infinity, -Infinity, Date, arrays, and objects.\n */\nexport function serializeValue(value: unknown): unknown {\n if (value === undefined) {\n return { __type: `undefined` }\n }\n\n if (typeof value === `number`) {\n if (Number.isNaN(value)) {\n return { __type: `nan` }\n }\n if (value === Number.POSITIVE_INFINITY) {\n return { __type: `infinity`, sign: 1 }\n }\n if (value === Number.NEGATIVE_INFINITY) {\n return { __type: `infinity`, sign: -1 }\n }\n }\n\n if (\n value === null ||\n typeof value === `string` ||\n typeof value === `number` ||\n typeof value === `boolean`\n ) {\n return value\n }\n\n if (value instanceof Date) {\n return { __type: `date`, value: value.toJSON() }\n }\n\n if (Array.isArray(value)) {\n return value.map((item) => serializeValue(item))\n }\n\n if (typeof value === `object`) {\n return Object.fromEntries(\n Object.entries(value as Record<string, unknown>).map(([key, val]) => [\n key,\n serializeValue(val),\n ])\n )\n }\n\n return value\n}\n\n/**\n * Deserializes a value back from its JSON-safe format.\n * Restores: undefined, NaN, Infinity, -Infinity, Date, arrays, and objects.\n */\nexport function deserializeValue(value: unknown): unknown {\n if (isTypeMarker(value)) {\n switch (value.__type) {\n case `undefined`:\n return undefined\n case `nan`:\n return NaN\n case `infinity`:\n return value.sign === 1 ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY\n case `date`:\n return new Date(value.value as string)\n default:\n return value\n }\n }\n\n if (value === null || typeof value === `string` || typeof value === `number` || typeof value === `boolean`) {\n return value\n }\n\n if (Array.isArray(value)) {\n return value.map((item) => deserializeValue(item))\n }\n\n if (typeof value === `object`) {\n return Object.fromEntries(\n Object.entries(value as Record<string, unknown>).map(([key, val]) => [\n key,\n deserializeValue(val),\n ])\n )\n }\n\n return value\n}\n\n/**\n * Converts a value to a stable string key for use in Sets/Maps.\n */\nexport function toKey(value: unknown): string {\n return JSON.stringify(serializeValue(value))\n}\n\n/**\n * Restores a value from its string key representation.\n */\nexport function fromKey(key: string): unknown {\n return deserializeValue(JSON.parse(key))\n}\n","import type { FunctionReference } from 'convex/server'\nimport type { ChangeMessageOrDeleteKeyMessage } from '@tanstack/db'\nimport type {\n ConvexClientLike,\n ConvexSyncManagerOptions,\n ExtractedFilters,\n FilterDimension,\n} from './types.js'\nimport { toKey, fromKey } from './serialization.js'\n\nexport type { ConvexSyncManagerOptions }\n\n/**\n * Sync callbacks passed from TanStack DB's sync function\n */\nexport interface SyncCallbacks<\n T extends object = Record<string, unknown>,\n TKey extends string | number = string | number,\n> {\n collection: {\n get: (key: TKey) => T | undefined\n has: (key: TKey) => boolean\n }\n begin: () => void\n write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => void\n commit: () => void\n markReady: () => void\n}\n\n/**\n * ConvexSyncManager - Manages real-time synchronization with Convex backend\n *\n * Implements the \"backfill + tail\" pattern:\n * 1. When new filters are requested, backfill with `after: 0` to get full history\n * 2. Maintain a single live subscription for all active filter values with `after: globalCursor - tailOverlapMs`\n * 3. Use LWW (Last-Write-Wins) to handle overlapping data from backfill and subscription\n *\n * Supports 0, 1, or N filter dimensions:\n * - 0 filters: Global sync with just { after }\n * - 1+ filters: Filter-based sync with values extracted from where clauses\n */\nexport class ConvexSyncManager<\n T extends object = Record<string, unknown>,\n TKey extends string | number = string | number,\n> {\n // Configuration\n private client: ConvexClientLike\n private query: FunctionReference<`query`>\n private filterDimensions: FilterDimension[]\n private updatedAtFieldName: string\n private debounceMs: number\n private tailOverlapMs: number\n private resubscribeThreshold: number\n private getKey: (item: T) => TKey\n\n // State - per-dimension tracking (keyed by convexArg)\n private activeDimensions = new Map<string, Set<string>>()\n private refCounts = new Map<string, number>() // composite key -> count\n private pendingFilters: ExtractedFilters = {}\n private globalCursor = 0\n private currentSubscription: (() => void) | null = null\n private debounceTimer: ReturnType<typeof setTimeout> | null = null\n private isProcessing = false\n private markedReady = false\n\n // For 0-filter case (global sync)\n private hasRequestedGlobal = false\n private globalRefCount = 0\n\n // Track messages received since last subscription to batch cursor updates\n private messagesSinceSubscription = 0\n\n // Sync callbacks (set when sync() is called)\n private callbacks: SyncCallbacks<T, TKey> | null = null\n\n constructor(options: ConvexSyncManagerOptions<T>) {\n this.client = options.client\n this.query = options.query\n this.filterDimensions = options.filterDimensions\n this.updatedAtFieldName = options.updatedAtFieldName\n this.debounceMs = options.debounceMs\n this.tailOverlapMs = options.tailOverlapMs\n this.resubscribeThreshold = options.resubscribeThreshold\n this.getKey = options.getKey as (item: T) => TKey\n\n // Initialize activeDimensions for each filter dimension\n for (const dim of this.filterDimensions) {\n this.activeDimensions.set(dim.convexArg, new Set())\n }\n }\n\n /**\n * Initialize the sync manager with callbacks from TanStack DB\n */\n setCallbacks(callbacks: SyncCallbacks<T, TKey>): void {\n this.callbacks = callbacks\n }\n\n /**\n * Create a composite key for ref counting multi-filter combinations.\n * Uses serialized values for deterministic keys.\n */\n private createCompositeKey(filters: ExtractedFilters): string {\n // Sort by convexArg for deterministic ordering\n const sorted = Object.keys(filters)\n .sort()\n .reduce(\n (acc, key) => {\n // Serialize values and sort for deterministic ordering\n const values = filters[key]\n if (values) {\n acc[key] = values.map((v) => toKey(v)).sort()\n }\n return acc\n },\n {} as Record<string, string[]>\n )\n return JSON.stringify(sorted)\n }\n\n /**\n * Request filters to be synced (called by loadSubset)\n * Filters are batched via debouncing for efficiency\n */\n requestFilters(filters: ExtractedFilters): Promise<void> {\n // Handle 0-filter case (global sync)\n if (this.filterDimensions.length === 0) {\n this.globalRefCount++\n if (!this.hasRequestedGlobal) {\n this.hasRequestedGlobal = true\n return this.scheduleProcessing()\n }\n return Promise.resolve()\n }\n\n // Increment ref count for this filter combination\n const compositeKey = this.createCompositeKey(filters)\n const count = this.refCounts.get(compositeKey) || 0\n this.refCounts.set(compositeKey, count + 1)\n\n // Track which values are new (not yet active)\n let hasNewValues = false\n for (const [convexArg, values] of Object.entries(filters)) {\n const activeSet = this.activeDimensions.get(convexArg)\n if (!activeSet) continue\n\n // Find the dimension config for single validation\n const dim = this.filterDimensions.find((d) => d.convexArg === convexArg)\n\n // Validate single constraint before adding values\n if (dim?.single) {\n const existingCount = activeSet.size\n const pendingCount = this.pendingFilters[convexArg]?.length ?? 0\n const newValues = values.filter((v) => {\n const serialized = toKey(v)\n const alreadyActive = activeSet.has(serialized)\n const alreadyPending = this.pendingFilters[convexArg]?.some(\n (pv) => toKey(pv) === serialized\n )\n return !alreadyActive && !alreadyPending\n })\n\n if (existingCount + pendingCount + newValues.length > 1) {\n throw new Error(\n `Filter '${dim.filterField}' is configured as single but multiple values were requested. ` +\n `Active: ${existingCount}, Pending: ${pendingCount}, New: ${newValues.length}. ` +\n `Use single: false if you need to sync multiple values.`\n )\n }\n }\n\n for (const value of values) {\n const serialized = toKey(value)\n if (!activeSet.has(serialized)) {\n // Add to pending filters\n if (!this.pendingFilters[convexArg]) {\n this.pendingFilters[convexArg] = []\n }\n // Check if already pending (by serialized key)\n const alreadyPending = this.pendingFilters[convexArg].some(\n (v) => toKey(v) === serialized\n )\n if (!alreadyPending) {\n this.pendingFilters[convexArg].push(value)\n hasNewValues = true\n }\n }\n }\n }\n\n // If there are new values, schedule processing\n if (hasNewValues) {\n return this.scheduleProcessing()\n }\n\n return Promise.resolve()\n }\n\n /**\n * Release filters when no longer needed (called by unloadSubset)\n */\n releaseFilters(filters: ExtractedFilters): void {\n // Handle 0-filter case\n if (this.filterDimensions.length === 0) {\n this.globalRefCount = Math.max(0, this.globalRefCount - 1)\n if (this.globalRefCount === 0 && this.hasRequestedGlobal) {\n this.hasRequestedGlobal = false\n this.updateSubscription()\n }\n return\n }\n\n // Decrement ref count for this filter combination\n const compositeKey = this.createCompositeKey(filters)\n const count = (this.refCounts.get(compositeKey) || 0) - 1\n\n if (count <= 0) {\n this.refCounts.delete(compositeKey)\n } else {\n this.refCounts.set(compositeKey, count)\n }\n\n // Check if any values are now unreferenced\n this.cleanupUnreferencedValues()\n }\n\n /**\n * Remove values from activeDimensions that are no longer referenced\n * by any composite key in refCounts\n */\n private cleanupUnreferencedValues(): void {\n // Collect all referenced serialized values per dimension\n const referencedValues = new Map<string, Set<string>>()\n for (const dim of this.filterDimensions) {\n referencedValues.set(dim.convexArg, new Set())\n }\n\n // Walk through all composite keys and collect their serialized values\n for (const compositeKey of this.refCounts.keys()) {\n try {\n // Composite keys store values as already-serialized strings\n const filters = JSON.parse(compositeKey) as Record<string, string[]>\n for (const [convexArg, serializedValues] of Object.entries(filters)) {\n const refSet = referencedValues.get(convexArg)\n if (refSet) {\n for (const serialized of serializedValues) {\n refSet.add(serialized)\n }\n }\n }\n } catch {\n // Skip invalid keys\n }\n }\n\n // Remove unreferenced values from activeDimensions\n // (activeDimensions stores serialized keys)\n let needsSubscriptionUpdate = false\n for (const [convexArg, activeSet] of this.activeDimensions) {\n const refSet = referencedValues.get(convexArg)!\n for (const serialized of activeSet) {\n if (!refSet.has(serialized)) {\n activeSet.delete(serialized)\n needsSubscriptionUpdate = true\n }\n }\n }\n\n if (needsSubscriptionUpdate) {\n this.updateSubscription()\n }\n }\n\n /**\n * Schedule debounced processing of pending filters\n */\n private scheduleProcessing(): Promise<void> {\n return new Promise((resolve, reject) => {\n // Clear existing timer\n if (this.debounceTimer) {\n clearTimeout(this.debounceTimer)\n }\n\n // Schedule processing\n this.debounceTimer = setTimeout(async () => {\n try {\n await this.processFilterBatch()\n resolve()\n } catch (error) {\n reject(error)\n }\n }, this.debounceMs)\n })\n }\n\n /**\n * Process the current batch of pending filters\n */\n private async processFilterBatch(): Promise<void> {\n if (this.isProcessing) {\n return\n }\n\n // Check if there's anything to process\n const hasPendingFilters = Object.keys(this.pendingFilters).length > 0\n const needsGlobalSync = this.filterDimensions.length === 0 && this.hasRequestedGlobal\n\n if (!hasPendingFilters && !needsGlobalSync) {\n return\n }\n\n this.isProcessing = true\n\n try {\n if (this.filterDimensions.length === 0) {\n // 0-filter case: global sync\n await this.runGlobalBackfill()\n } else {\n // Collect new filter values that need backfill\n const newFilters = { ...this.pendingFilters }\n this.pendingFilters = {}\n\n // Add to active dimensions (store serialized keys)\n for (const [convexArg, values] of Object.entries(newFilters)) {\n const activeSet = this.activeDimensions.get(convexArg)\n if (activeSet) {\n for (const value of values) {\n activeSet.add(toKey(value))\n }\n }\n }\n\n // Run backfill for new filter values (fetch full history)\n await this.runBackfill(newFilters)\n }\n\n // Update the live subscription to include all active values\n this.updateSubscription()\n\n // Mark ready after first successful sync\n if (!this.markedReady && this.callbacks) {\n this.callbacks.markReady()\n this.markedReady = true\n }\n } finally {\n this.isProcessing = false\n }\n }\n\n /**\n * Run global backfill for 0-filter case\n */\n private async runGlobalBackfill(): Promise<void> {\n try {\n const args: Record<string, unknown> = { after: 0 }\n\n const items = await this.client.query(this.query, args as any)\n\n if (Array.isArray(items)) {\n this.handleIncomingData(items as T[])\n }\n } catch (error) {\n console.error('[ConvexSyncManager] Global backfill error:', error)\n throw error\n }\n }\n\n /**\n * Run backfill query for new filter values to get their full history\n */\n private async runBackfill(newFilters: ExtractedFilters): Promise<void> {\n if (Object.keys(newFilters).length === 0) return\n\n try {\n // Query with after: 0 to get full history for new filter values\n const args: Record<string, unknown> = {\n ...newFilters,\n after: 0,\n }\n\n const items = await this.client.query(this.query, args as any)\n\n if (Array.isArray(items)) {\n this.handleIncomingData(items as T[])\n }\n } catch (error) {\n console.error('[ConvexSyncManager] Backfill error:', error)\n throw error\n }\n }\n\n /**\n * Build query args from all active dimensions\n */\n private buildQueryArgs(after: number): Record<string, unknown> {\n const args: Record<string, unknown> = { after }\n\n // Deserialize values back to original types for the Convex query\n for (const [convexArg, serializedValues] of this.activeDimensions) {\n const values = [...serializedValues].map((s) => fromKey(s))\n\n // Check if this dimension is configured as single\n const dim = this.filterDimensions.find((d) => d.convexArg === convexArg)\n args[convexArg] = dim?.single ? values[0] : values\n }\n\n return args\n }\n\n /**\n * Update the live subscription to cover all active filter values\n */\n private updateSubscription(): void {\n // Unsubscribe from current subscription\n if (this.currentSubscription) {\n this.currentSubscription()\n this.currentSubscription = null\n }\n\n // Reset message counter for new subscription\n this.messagesSinceSubscription = 0\n\n // Check if we should subscribe\n if (this.filterDimensions.length === 0) {\n // 0-filter case: subscribe if global sync is active\n if (!this.hasRequestedGlobal) {\n return\n }\n } else {\n // Check if any dimension has active values\n let hasActiveValues = false\n for (const activeSet of this.activeDimensions.values()) {\n if (activeSet.size > 0) {\n hasActiveValues = true\n break\n }\n }\n if (!hasActiveValues) {\n return\n }\n }\n\n // Calculate cursor with overlap to avoid missing updates\n const cursor = Math.max(0, this.globalCursor - this.tailOverlapMs)\n\n // Build subscription args\n const args = this.buildQueryArgs(cursor)\n\n // Runtime detection: ConvexClient has onUpdate, ConvexReactClient has watchQuery\n if ('onUpdate' in this.client) {\n // ConvexClient pattern: client.onUpdate(query, args, callback)\n const subscription = this.client.onUpdate(\n this.query,\n args as any,\n (result: unknown) => {\n if (result !== undefined) {\n const items = result as T[]\n if (Array.isArray(items)) {\n this.handleIncomingData(items)\n }\n }\n },\n (error: unknown) => {\n console.error(`[ConvexSyncManager] Subscription error:`, error)\n }\n )\n this.currentSubscription = () => subscription.unsubscribe()\n } else {\n // ConvexReactClient pattern: client.watchQuery(query, args).onUpdate(callback)\n const watch = this.client.watchQuery(this.query, args as any)\n this.currentSubscription = watch.onUpdate(() => {\n // Get current value from the watch\n const result = watch.localQueryResult()\n if (result !== undefined) {\n const items = result as T[]\n if (Array.isArray(items)) {\n this.handleIncomingData(items)\n }\n }\n })\n }\n }\n\n /**\n * Handle incoming data from backfill or subscription\n * Uses LWW (Last-Write-Wins) to resolve conflicts\n */\n private handleIncomingData(items: T[]): void {\n if (!this.callbacks || items.length === 0) return\n\n const { collection, begin, write, commit } = this.callbacks\n\n // Track if we see new items that advance the cursor\n const previousCursor = this.globalCursor\n let newItemCount = 0\n\n begin()\n\n for (const item of items) {\n const key = this.getKey(item)\n const incomingTs = (item as any)[this.updatedAtFieldName] as number | undefined\n\n // Update global cursor to track the latest timestamp we've seen\n if (incomingTs !== undefined && incomingTs > this.globalCursor) {\n this.globalCursor = incomingTs\n }\n\n const existing = collection.get(key)\n\n if (!existing) {\n // New item - insert\n write({ type: `insert`, value: item })\n // Count as new if it's beyond the previous cursor (not from overlap)\n if (incomingTs !== undefined && incomingTs > previousCursor) {\n newItemCount++\n }\n } else {\n // Existing item - check if incoming is fresher (LWW)\n const existingTs = (existing as any)[this.updatedAtFieldName] as number | undefined\n\n if (incomingTs !== undefined && existingTs !== undefined) {\n if (incomingTs > existingTs) {\n // Incoming is fresher - update\n write({ type: `update`, value: item })\n // Count as new if it's beyond the previous cursor\n if (incomingTs > previousCursor) {\n newItemCount++\n }\n }\n // Otherwise skip (stale data from overlap)\n } else if (incomingTs !== undefined) {\n // Existing has no timestamp, incoming does - update\n write({ type: `update`, value: item })\n }\n // If incoming has no timestamp, skip (can't determine freshness)\n }\n }\n\n commit()\n\n // Track only new messages (beyond previous cursor) for cursor advancement\n this.messagesSinceSubscription += newItemCount\n\n // Re-subscribe with advanced cursor after threshold is reached\n if (\n this.resubscribeThreshold > 0 &&\n this.messagesSinceSubscription >= this.resubscribeThreshold &&\n this.currentSubscription !== null\n ) {\n this.updateSubscription()\n }\n }\n\n /**\n * Clean up all resources\n */\n cleanup(): void {\n // Clear debounce timer\n if (this.debounceTimer) {\n clearTimeout(this.debounceTimer)\n this.debounceTimer = null\n }\n\n // Unsubscribe from current subscription\n if (this.currentSubscription) {\n this.currentSubscription()\n this.currentSubscription = null\n }\n\n // Clear state\n for (const activeSet of this.activeDimensions.values()) {\n activeSet.clear()\n }\n this.refCounts.clear()\n this.pendingFilters = {}\n this.globalCursor = 0\n this.markedReady = false\n this.hasRequestedGlobal = false\n this.globalRefCount = 0\n this.messagesSinceSubscription = 0\n this.callbacks = null\n }\n\n /**\n * Get debug info about current state\n */\n getDebugInfo(): {\n activeDimensions: Record<string, unknown[]>\n globalCursor: number\n pendingFilters: ExtractedFilters\n hasSubscription: boolean\n markedReady: boolean\n hasRequestedGlobal: boolean\n messagesSinceSubscription: number\n } {\n const activeDimensions: Record<string, unknown[]> = {}\n for (const [convexArg, serializedValues] of this.activeDimensions) {\n activeDimensions[convexArg] = [...serializedValues].map((s) => fromKey(s))\n }\n\n return {\n activeDimensions,\n globalCursor: this.globalCursor,\n pendingFilters: { ...this.pendingFilters },\n hasSubscription: this.currentSubscription !== null,\n markedReady: this.markedReady,\n hasRequestedGlobal: this.hasRequestedGlobal,\n messagesSinceSubscription: this.messagesSinceSubscription,\n }\n }\n}\n","import type { LoadSubsetOptions } from '@tanstack/db'\nimport type { ExtractedFilters, FilterDimension } from './types.js'\nimport { toKey } from './serialization.js'\n\n/**\n * TanStack DB expression types (simplified for our needs)\n * These mirror the IR types from @tanstack/db\n */\ninterface PropRef {\n type: `ref`\n path: Array<string>\n}\n\ninterface Value {\n type: `val`\n value: unknown\n}\n\ninterface Func {\n type: `func`\n name: string\n args: Array<BasicExpression>\n}\n\ntype BasicExpression = PropRef | Value | Func\n\n/**\n * Check if a value is a PropRef expression\n */\nfunction isPropRef(expr: unknown): expr is PropRef {\n return (\n typeof expr === `object` &&\n expr !== null &&\n `type` in expr &&\n expr.type === `ref` &&\n `path` in expr &&\n Array.isArray((expr as PropRef).path)\n )\n}\n\n/**\n * Check if a value is a Value expression\n */\nfunction isValue(expr: unknown): expr is Value {\n return (\n typeof expr === `object` &&\n expr !== null &&\n `type` in expr &&\n expr.type === `val` &&\n `value` in expr\n )\n}\n\n/**\n * Check if a value is a Func expression\n */\nfunction isFunc(expr: unknown): expr is Func {\n return (\n typeof expr === `object` &&\n expr !== null &&\n `type` in expr &&\n expr.type === `func` &&\n `name` in expr &&\n `args` in expr &&\n Array.isArray((expr as Func).args)\n )\n}\n\n/**\n * Check if a PropRef matches our target field.\n * Handles both aliased (e.g., ['msg', 'pageId']) and direct (e.g., ['pageId']) paths.\n */\nfunction propRefMatchesField(propRef: PropRef, fieldName: string): boolean {\n const { path } = propRef\n // Direct field reference: ['pageId']\n if (path.length === 1 && path[0] === fieldName) {\n return true\n }\n // Aliased field reference: ['msg', 'pageId'] or ['m', 'pageId']\n if (path.length === 2 && path[1] === fieldName) {\n return true\n }\n return false\n}\n\n/**\n * Extract filter values from an 'eq' function call.\n * Pattern: eq(ref(filterField), val(x)) or eq(val(x), ref(filterField))\n */\nfunction extractFromEq(func: Func, filterField: string): unknown[] {\n if (func.args.length !== 2) return []\n\n const [left, right] = func.args\n\n // eq(ref, val)\n if (isPropRef(left) && propRefMatchesField(left, filterField) && isValue(right)) {\n return [right.value]\n }\n\n // eq(val, ref) - reversed order\n if (isValue(left) && isPropRef(right) && propRefMatchesField(right, filterField)) {\n return [left.value]\n }\n\n return []\n}\n\n/**\n * Extract filter values from an 'in' function call.\n * Pattern: in(ref(filterField), val([a, b, c]))\n */\nfunction extractFromIn(func: Func, filterField: string): unknown[] {\n if (func.args.length !== 2) return []\n\n const [left, right] = func.args\n\n // in(ref, val)\n if (isPropRef(left) && propRefMatchesField(left, filterField) && isValue(right)) {\n const val = right.value\n if (Array.isArray(val)) {\n return val\n }\n }\n\n return []\n}\n\n/**\n * Recursively walk an expression tree to find all filter values for the given field.\n * Handles 'eq', 'in', 'and', and 'or' expressions.\n */\nfunction walkExpression(expr: unknown, filterField: string): unknown[] {\n if (!isFunc(expr)) return []\n\n const { name, args } = expr\n const results: unknown[] = []\n\n switch (name) {\n case `eq`:\n results.push(...extractFromEq(expr, filterField))\n break\n\n case `in`:\n results.push(...extractFromIn(expr, filterField))\n break\n\n case `and`:\n case `or`:\n // Recursively process all arguments\n for (const arg of args) {\n results.push(...walkExpression(arg, filterField))\n }\n break\n\n // For other functions, recursively check their arguments\n // (in case of nested expressions)\n default:\n for (const arg of args) {\n results.push(...walkExpression(arg, filterField))\n }\n break\n }\n\n return results\n}\n\n/**\n * Extract filter values from LoadSubsetOptions.\n *\n * Parses the `where` expression to find equality (`.eq()`) and set membership (`.in()`)\n * comparisons for the specified filter field.\n *\n * @param options - The LoadSubsetOptions from a live query\n * @param filterField - The field name to extract values for (e.g., 'pageId')\n * @returns Array of unique filter values found in the expression\n *\n * @example\n * // For query: where(m => m.pageId.eq('page-1'))\n * extractFilterValues(options, 'pageId') // returns ['page-1']\n *\n * @example\n * // For query: where(m => inArray(m.pageId, ['page-1', 'page-2']))\n * extractFilterValues(options, 'pageId') // returns ['page-1', 'page-2']\n *\n * @example\n * // For query: where(m => and(m.pageId.eq('page-1'), m.status.eq('active')))\n * extractFilterValues(options, 'pageId') // returns ['page-1']\n */\nexport function extractFilterValues(\n options: LoadSubsetOptions,\n filterField: string\n): unknown[] {\n const { where } = options\n\n if (!where) {\n return []\n }\n\n // Extract from the where expression\n const values = walkExpression(where, filterField)\n\n // Return unique values using toKey for stable identity comparison\n const seen = new Set<string>()\n const unique: unknown[] = []\n for (const value of values) {\n const key = toKey(value)\n if (!seen.has(key)) {\n seen.add(key)\n unique.push(value)\n }\n }\n\n return unique\n}\n\n/**\n * Check if LoadSubsetOptions contains a filter for the specified field.\n */\nexport function hasFilterField(options: LoadSubsetOptions, filterField: string): boolean {\n return extractFilterValues(options, filterField).length > 0\n}\n\n/**\n * Extract filter values for multiple filter dimensions from LoadSubsetOptions.\n *\n * Parses the `where` expression to find values for each configured filter dimension.\n * Results are keyed by convexArg for direct use in Convex query args.\n *\n * @param options - The LoadSubsetOptions from a live query\n * @param filterDimensions - Array of filter dimensions to extract\n * @returns Object mapping convexArg -> values for dimensions with matches\n *\n * @example\n * // For query: where(m => m.pageId.eq('page-1') && m.authorId.eq('user-1'))\n * extractMultipleFilterValues(options, [\n * { filterField: 'pageId', convexArg: 'pageIds' },\n * { filterField: 'authorId', convexArg: 'authorIds' },\n * ])\n * // returns { pageIds: ['page-1'], authorIds: ['user-1'] }\n */\nexport function extractMultipleFilterValues(\n options: LoadSubsetOptions,\n filterDimensions: FilterDimension[]\n): ExtractedFilters {\n const result: ExtractedFilters = {}\n\n for (const dim of filterDimensions) {\n const values = extractFilterValues(options, dim.filterField)\n if (values.length > 0) {\n result[dim.convexArg] = values\n }\n }\n\n return result\n}\n","import type {\n CollectionConfig,\n LoadSubsetOptions,\n SyncConfig,\n UtilsRecord,\n} from '@tanstack/db'\nimport type { StandardSchemaV1 } from '@standard-schema/spec'\nimport type { FunctionReference, FunctionReturnType } from 'convex/server'\nimport { ConvexSyncManager } from './ConvexSyncManager.js'\nimport { extractMultipleFilterValues } from './expression-parser.js'\nimport type { ConvexCollectionConfig, FilterConfig, FilterDimension } from './types.js'\n\n// Re-export types\nexport type {\n ConvexCollectionConfig,\n ConvexUnsubscribe,\n ExtractedFilters,\n FilterConfig,\n FilterDimension,\n} from './types.js'\nexport { extractFilterValues, extractMultipleFilterValues, hasFilterField } from './expression-parser.js'\nexport { ConvexSyncManager } from './ConvexSyncManager.js'\nexport { serializeValue, deserializeValue, toKey, fromKey } from './serialization.js'\n\n// Default configuration values\nconst DEFAULT_UPDATED_AT_FIELD = `updatedAt`\nconst DEFAULT_DEBOUNCE_MS = 50\nconst DEFAULT_TAIL_OVERLAP_MS = 10000\nconst DEFAULT_RESUBSCRIBE_THRESHOLD = 10\n\n/**\n * Normalize filter configuration to an array of FilterDimension.\n * - undefined = [] (0 filters, global sync)\n * - single object = [object] (1 filter)\n * - array = array as-is (N filters)\n */\nfunction normalizeFilterConfig(filters: FilterConfig): FilterDimension[] {\n if (filters === undefined) return []\n return Array.isArray(filters) ? filters : [filters]\n}\n\n/**\n * Schema output type inference helper\n */\ntype InferSchemaOutput<T> = T extends StandardSchemaV1\n ? StandardSchemaV1.InferOutput<T> extends object\n ? StandardSchemaV1.InferOutput<T>\n : Record<string, unknown>\n : Record<string, unknown>\n\n/**\n * Infer the item type from a Convex query's return type.\n * Expects the query to return an array of items.\n */\ntype InferQueryItemType<TQuery extends FunctionReference<'query'>> =\n FunctionReturnType<TQuery> extends Array<infer T>\n ? T extends object\n ? T\n : Record<string, unknown>\n : Record<string, unknown>\n\n/**\n * Creates collection options for use with TanStack DB's createCollection.\n * This integrates Convex real-time subscriptions with TanStack DB collections\n * using the \"backfill + tail\" synchronization pattern.\n *\n * @example\n * ```typescript\n * import { createCollection } from '@tanstack/react-db'\n * import { convexCollectionOptions } from '@michaelstewart/convex-tanstack-db-collection'\n * import { api } from '@convex/_generated/api'\n *\n * // Single filter dimension\n * const messagesCollection = createCollection(\n * convexCollectionOptions({\n * client: convexClient,\n * query: api.messages.sync,\n * filters: { filterField: 'pageId', convexArg: 'pageIds' },\n * getKey: (msg) => msg._id,\n *\n * onInsert: async ({ transaction }) => {\n * const newMsg = transaction.mutations[0].modified\n * await convexClient.mutation(api.messages.create, newMsg)\n * },\n * })\n * )\n *\n * // Multiple filter dimensions\n * const filteredCollection = createCollection(\n * convexCollectionOptions({\n * client: convexClient,\n * query: api.items.syncFiltered,\n * filters: [\n * { filterField: 'pageId', convexArg: 'pageIds' },\n * { filterField: 'authorId', convexArg: 'authorIds' },\n * ],\n * getKey: (item) => item._id,\n * })\n * )\n *\n * // No filters (global sync)\n * const allItemsCollection = createCollection(\n * convexCollectionOptions({\n * client: convexClient,\n * query: api.items.syncAll, // Query takes only { after }\n * getKey: (item) => item._id,\n * })\n * )\n *\n * // In UI:\n * const { data: messages } = useLiveQuery(q =>\n * q.from({ msg: messagesCollection })\n * .where(({ msg }) => msg.pageId.eq('page-123'))\n * )\n * ```\n */\n\n// Overload for when schema is provided\nexport function convexCollectionOptions<\n TSchema extends StandardSchemaV1,\n TKey extends string | number = string | number,\n TUtils extends UtilsRecord = UtilsRecord,\n>(\n config: ConvexCollectionConfig<InferSchemaOutput<TSchema>, TKey, TSchema, TUtils> & {\n schema: TSchema\n }\n): CollectionConfig<InferSchemaOutput<TSchema>, TKey, TSchema, TUtils>\n\n// Overload for when no schema is provided - T is inferred from query's return type\nexport function convexCollectionOptions<\n TQuery extends FunctionReference<'query'>,\n T extends InferQueryItemType<TQuery> = InferQueryItemType<TQuery>,\n TKey extends string | number = string | number,\n TUtils extends UtilsRecord = UtilsRecord,\n>(\n config: Omit<ConvexCollectionConfig<T, TKey, never, TUtils>, 'query'> & {\n schema?: never\n query: TQuery\n getKey: (item: T) => TKey\n }\n): CollectionConfig<T, TKey, never, TUtils>\n\n// Implementation - uses concrete types; overloads provide proper type inference\nexport function convexCollectionOptions(\n config: ConvexCollectionConfig<Record<string, unknown>, string | number, never, UtilsRecord>\n): CollectionConfig<Record<string, unknown>, string | number, never, UtilsRecord> {\n const {\n client,\n query,\n filters,\n updatedAtFieldName = DEFAULT_UPDATED_AT_FIELD,\n debounceMs = DEFAULT_DEBOUNCE_MS,\n tailOverlapMs = DEFAULT_TAIL_OVERLAP_MS,\n resubscribeThreshold = DEFAULT_RESUBSCRIBE_THRESHOLD,\n getKey,\n onInsert,\n onUpdate,\n ...baseConfig\n } = config\n\n // Normalize filter configuration\n const filterDimensions = normalizeFilterConfig(filters)\n\n // Create the sync manager\n const syncManager = new ConvexSyncManager<any, any>({\n client,\n query,\n filterDimensions,\n updatedAtFieldName,\n debounceMs,\n tailOverlapMs,\n resubscribeThreshold,\n getKey: getKey as (item: any) => string | number,\n })\n\n // Create the sync configuration\n const syncConfig: SyncConfig<any, any> = {\n sync: (params) => {\n const { collection, begin, write, commit, markReady } = params\n\n // Initialize sync manager with callbacks\n syncManager.setCallbacks({\n collection: {\n get: (key) => collection.get(key),\n has: (key) => collection.has(key),\n },\n begin,\n write,\n commit,\n markReady,\n })\n\n // Return loadSubset, unloadSubset, and cleanup handlers\n return {\n loadSubset: (options: LoadSubsetOptions): Promise<void> => {\n // 0-filter case: global sync\n if (filterDimensions.length === 0) {\n return syncManager.requestFilters({})\n }\n\n // Extract filter values from the where expression\n const extracted = extractMultipleFilterValues(options, filterDimensions)\n\n // Sync if ANY dimension has values (any-filter matching)\n // Convex query arg validators enforce which combinations are valid\n if (Object.keys(extracted).length === 0) {\n // No filter values found - this is expected for queries that filter\n // by other fields (e.g., clientId, parentId). These queries read from\n // the already-synced collection and don't need to trigger a sync.\n return Promise.resolve()\n }\n\n return syncManager.requestFilters(extracted)\n },\n\n unloadSubset: (options: LoadSubsetOptions): void => {\n // 0-filter case: global sync\n if (filterDimensions.length === 0) {\n syncManager.releaseFilters({})\n return\n }\n\n // Extract filter values from the where expression\n const extracted = extractMultipleFilterValues(options, filterDimensions)\n\n if (Object.keys(extracted).length > 0) {\n syncManager.releaseFilters(extracted)\n }\n },\n\n cleanup: () => {\n syncManager.cleanup()\n },\n }\n },\n }\n\n // Return the complete collection config\n return {\n ...baseConfig,\n getKey,\n syncMode: `on-demand`, // Always on-demand since we sync based on query predicates\n sync: syncConfig,\n onInsert,\n onUpdate,\n }\n}\n"],"names":["_a"],"mappings":"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"}
@@ -0,0 +1,461 @@
1
+ import { BaseCollectionConfig } from '@tanstack/db';
2
+ import { ChangeMessageOrDeleteKeyMessage } from '@tanstack/db';
3
+ import { CollectionConfig } from '@tanstack/db';
4
+ import { FunctionReference } from 'convex/server';
5
+ import { FunctionReturnType } from 'convex/server';
6
+ import { LoadSubsetOptions } from '@tanstack/db';
7
+ import { StandardSchemaV1 } from '@standard-schema/spec';
8
+ import { UtilsRecord } from '@tanstack/db';
9
+
10
+ /**
11
+ * ConvexClient pattern: uses onUpdate for subscriptions
12
+ */
13
+ declare interface ConvexBrowserClientLike extends ConvexClientBase {
14
+ onUpdate(query: FunctionReference<'query'>, args: any, callback: (result: any) => void, onError?: (error: any) => void): {
15
+ unsubscribe(): void;
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Base client interface with query method
21
+ */
22
+ declare interface ConvexClientBase {
23
+ query(query: FunctionReference<'query'>, args: any): Promise<any>;
24
+ }
25
+
26
+ /**
27
+ * A Convex client that supports query and subscription methods.
28
+ * Compatible with both ConvexClient (browser) and ConvexReactClient (react).
29
+ *
30
+ * ConvexClient uses: client.onUpdate(query, args, callback)
31
+ * ConvexReactClient uses: client.watchQuery(query, args).onUpdate(callback)
32
+ */
33
+ declare type ConvexClientLike = ConvexReactClientLike | ConvexBrowserClientLike;
34
+
35
+ /**
36
+ * Configuration for the Convex collection adapter
37
+ */
38
+ export declare interface ConvexCollectionConfig<T extends object = Record<string, unknown>, TKey extends string | number = string | number, TSchema extends StandardSchemaV1 = never, TUtils extends UtilsRecord = UtilsRecord> extends Omit<BaseCollectionConfig<T, TKey, TSchema, TUtils>, 'syncMode'> {
39
+ /**
40
+ * The Convex client instance used for queries and subscriptions.
41
+ * Compatible with both ConvexClient and ConvexReactClient.
42
+ */
43
+ client: ConvexClientLike;
44
+ /**
45
+ * The Convex query function reference for syncing data.
46
+ * This query must accept the configured filter args (arrays of filter values)
47
+ * and an optional `after` timestamp for incremental sync.
48
+ *
49
+ * @example
50
+ * // convex/messages.ts
51
+ * export const sync = userQuery({
52
+ * args: {
53
+ * pageIds: v.array(v.string()),
54
+ * after: v.optional(v.number()),
55
+ * },
56
+ * handler: async (ctx, { pageIds, after = 0 }) => {
57
+ * return await ctx.db
58
+ * .query('messages')
59
+ * .filter(q => pageIds.includes(q.field('pageId')))
60
+ * .filter(q => q.gt(q.field('updatedAt'), after))
61
+ * .collect()
62
+ * },
63
+ * })
64
+ */
65
+ query: FunctionReference<'query'>;
66
+ /**
67
+ * Filter configuration for syncing data based on query predicates.
68
+ * - undefined or [] = sync everything (0 filters, query takes only { after })
69
+ * - single object = one filter dimension
70
+ * - array = multiple filter dimensions
71
+ *
72
+ * @example
73
+ * // Single filter
74
+ * filters: { filterField: 'pageId', convexArg: 'pageIds' }
75
+ *
76
+ * // Multiple filters
77
+ * filters: [
78
+ * { filterField: 'pageId', convexArg: 'pageIds' },
79
+ * { filterField: 'authorId', convexArg: 'authorIds' },
80
+ * ]
81
+ */
82
+ filters?: FilterConfig;
83
+ /**
84
+ * The field name on items that contains the timestamp for LWW conflict resolution.
85
+ * Used to determine which version of an item is newer.
86
+ * @default 'updatedAt'
87
+ */
88
+ updatedAtFieldName?: string;
89
+ /**
90
+ * Debounce time in milliseconds for batching loadSubset calls.
91
+ * Multiple calls within this window will be batched together.
92
+ * @default 50
93
+ */
94
+ debounceMs?: number;
95
+ /**
96
+ * Overlap window in milliseconds when rewinding the subscription cursor.
97
+ * This ensures we don't miss updates from transactions that committed out-of-order
98
+ * (commit order doesn't match timestamp generation order across different keys).
99
+ * @default 10000
100
+ */
101
+ tailOverlapMs?: number;
102
+ /**
103
+ * Number of messages to receive before re-subscribing with an advanced cursor.
104
+ * This reduces Convex function invocations by batching cursor updates.
105
+ * Set to 0 to disable automatic cursor advancement.
106
+ * @default 10
107
+ */
108
+ resubscribeThreshold?: number;
109
+ }
110
+
111
+ /**
112
+ * Creates collection options for use with TanStack DB's createCollection.
113
+ * This integrates Convex real-time subscriptions with TanStack DB collections
114
+ * using the "backfill + tail" synchronization pattern.
115
+ *
116
+ * @example
117
+ * ```typescript
118
+ * import { createCollection } from '@tanstack/react-db'
119
+ * import { convexCollectionOptions } from '@michaelstewart/convex-tanstack-db-collection'
120
+ * import { api } from '@convex/_generated/api'
121
+ *
122
+ * // Single filter dimension
123
+ * const messagesCollection = createCollection(
124
+ * convexCollectionOptions({
125
+ * client: convexClient,
126
+ * query: api.messages.sync,
127
+ * filters: { filterField: 'pageId', convexArg: 'pageIds' },
128
+ * getKey: (msg) => msg._id,
129
+ *
130
+ * onInsert: async ({ transaction }) => {
131
+ * const newMsg = transaction.mutations[0].modified
132
+ * await convexClient.mutation(api.messages.create, newMsg)
133
+ * },
134
+ * })
135
+ * )
136
+ *
137
+ * // Multiple filter dimensions
138
+ * const filteredCollection = createCollection(
139
+ * convexCollectionOptions({
140
+ * client: convexClient,
141
+ * query: api.items.syncFiltered,
142
+ * filters: [
143
+ * { filterField: 'pageId', convexArg: 'pageIds' },
144
+ * { filterField: 'authorId', convexArg: 'authorIds' },
145
+ * ],
146
+ * getKey: (item) => item._id,
147
+ * })
148
+ * )
149
+ *
150
+ * // No filters (global sync)
151
+ * const allItemsCollection = createCollection(
152
+ * convexCollectionOptions({
153
+ * client: convexClient,
154
+ * query: api.items.syncAll, // Query takes only { after }
155
+ * getKey: (item) => item._id,
156
+ * })
157
+ * )
158
+ *
159
+ * // In UI:
160
+ * const { data: messages } = useLiveQuery(q =>
161
+ * q.from({ msg: messagesCollection })
162
+ * .where(({ msg }) => msg.pageId.eq('page-123'))
163
+ * )
164
+ * ```
165
+ */
166
+ export declare function convexCollectionOptions<TSchema extends StandardSchemaV1, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord>(config: ConvexCollectionConfig<InferSchemaOutput<TSchema>, TKey, TSchema, TUtils> & {
167
+ schema: TSchema;
168
+ }): CollectionConfig<InferSchemaOutput<TSchema>, TKey, TSchema, TUtils>;
169
+
170
+ export declare function convexCollectionOptions<TQuery extends FunctionReference<'query'>, T extends InferQueryItemType<TQuery> = InferQueryItemType<TQuery>, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord>(config: Omit<ConvexCollectionConfig<T, TKey, never, TUtils>, 'query'> & {
171
+ schema?: never;
172
+ query: TQuery;
173
+ getKey: (item: T) => TKey;
174
+ }): CollectionConfig<T, TKey, never, TUtils>;
175
+
176
+ /**
177
+ * ConvexReactClient pattern: uses watchQuery for subscriptions
178
+ */
179
+ declare interface ConvexReactClientLike extends ConvexClientBase {
180
+ watchQuery(query: FunctionReference<'query'>, args: any): {
181
+ onUpdate(callback: () => void): () => void;
182
+ localQueryResult(): any;
183
+ };
184
+ }
185
+
186
+ /**
187
+ * ConvexSyncManager - Manages real-time synchronization with Convex backend
188
+ *
189
+ * Implements the "backfill + tail" pattern:
190
+ * 1. When new filters are requested, backfill with `after: 0` to get full history
191
+ * 2. Maintain a single live subscription for all active filter values with `after: globalCursor - tailOverlapMs`
192
+ * 3. Use LWW (Last-Write-Wins) to handle overlapping data from backfill and subscription
193
+ *
194
+ * Supports 0, 1, or N filter dimensions:
195
+ * - 0 filters: Global sync with just { after }
196
+ * - 1+ filters: Filter-based sync with values extracted from where clauses
197
+ */
198
+ export declare class ConvexSyncManager<T extends object = Record<string, unknown>, TKey extends string | number = string | number> {
199
+ private client;
200
+ private query;
201
+ private filterDimensions;
202
+ private updatedAtFieldName;
203
+ private debounceMs;
204
+ private tailOverlapMs;
205
+ private resubscribeThreshold;
206
+ private getKey;
207
+ private activeDimensions;
208
+ private refCounts;
209
+ private pendingFilters;
210
+ private globalCursor;
211
+ private currentSubscription;
212
+ private debounceTimer;
213
+ private isProcessing;
214
+ private markedReady;
215
+ private hasRequestedGlobal;
216
+ private globalRefCount;
217
+ private messagesSinceSubscription;
218
+ private callbacks;
219
+ constructor(options: ConvexSyncManagerOptions<T>);
220
+ /**
221
+ * Initialize the sync manager with callbacks from TanStack DB
222
+ */
223
+ setCallbacks(callbacks: SyncCallbacks<T, TKey>): void;
224
+ /**
225
+ * Create a composite key for ref counting multi-filter combinations.
226
+ * Uses serialized values for deterministic keys.
227
+ */
228
+ private createCompositeKey;
229
+ /**
230
+ * Request filters to be synced (called by loadSubset)
231
+ * Filters are batched via debouncing for efficiency
232
+ */
233
+ requestFilters(filters: ExtractedFilters): Promise<void>;
234
+ /**
235
+ * Release filters when no longer needed (called by unloadSubset)
236
+ */
237
+ releaseFilters(filters: ExtractedFilters): void;
238
+ /**
239
+ * Remove values from activeDimensions that are no longer referenced
240
+ * by any composite key in refCounts
241
+ */
242
+ private cleanupUnreferencedValues;
243
+ /**
244
+ * Schedule debounced processing of pending filters
245
+ */
246
+ private scheduleProcessing;
247
+ /**
248
+ * Process the current batch of pending filters
249
+ */
250
+ private processFilterBatch;
251
+ /**
252
+ * Run global backfill for 0-filter case
253
+ */
254
+ private runGlobalBackfill;
255
+ /**
256
+ * Run backfill query for new filter values to get their full history
257
+ */
258
+ private runBackfill;
259
+ /**
260
+ * Build query args from all active dimensions
261
+ */
262
+ private buildQueryArgs;
263
+ /**
264
+ * Update the live subscription to cover all active filter values
265
+ */
266
+ private updateSubscription;
267
+ /**
268
+ * Handle incoming data from backfill or subscription
269
+ * Uses LWW (Last-Write-Wins) to resolve conflicts
270
+ */
271
+ private handleIncomingData;
272
+ /**
273
+ * Clean up all resources
274
+ */
275
+ cleanup(): void;
276
+ /**
277
+ * Get debug info about current state
278
+ */
279
+ getDebugInfo(): {
280
+ activeDimensions: Record<string, unknown[]>;
281
+ globalCursor: number;
282
+ pendingFilters: ExtractedFilters;
283
+ hasSubscription: boolean;
284
+ markedReady: boolean;
285
+ hasRequestedGlobal: boolean;
286
+ messagesSinceSubscription: number;
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Options for the ConvexSyncManager
292
+ */
293
+ declare interface ConvexSyncManagerOptions<T extends object = Record<string, unknown>> {
294
+ client: ConvexClientLike;
295
+ query: FunctionReference<'query'>;
296
+ filterDimensions: FilterDimension[];
297
+ updatedAtFieldName: string;
298
+ debounceMs: number;
299
+ tailOverlapMs: number;
300
+ resubscribeThreshold: number;
301
+ getKey: (item: T) => string | number;
302
+ }
303
+
304
+ /**
305
+ * Unsubscribe function returned by Convex client.onUpdate
306
+ */
307
+ export declare type ConvexUnsubscribe<T> = {
308
+ (): void;
309
+ unsubscribe(): void;
310
+ getCurrentValue(): T | undefined;
311
+ };
312
+
313
+ /**
314
+ * Deserializes a value back from its JSON-safe format.
315
+ * Restores: undefined, NaN, Infinity, -Infinity, Date, arrays, and objects.
316
+ */
317
+ export declare function deserializeValue(value: unknown): unknown;
318
+
319
+ /**
320
+ * Extracted filter values keyed by convexArg for direct use in query args.
321
+ * Values preserve their original types (strings, numbers, etc.)
322
+ * @example { pageIds: ['p1', 'p2'], authorIds: ['u1'] }
323
+ */
324
+ export declare interface ExtractedFilters {
325
+ [convexArg: string]: unknown[];
326
+ }
327
+
328
+ /**
329
+ * Extract filter values from LoadSubsetOptions.
330
+ *
331
+ * Parses the `where` expression to find equality (`.eq()`) and set membership (`.in()`)
332
+ * comparisons for the specified filter field.
333
+ *
334
+ * @param options - The LoadSubsetOptions from a live query
335
+ * @param filterField - The field name to extract values for (e.g., 'pageId')
336
+ * @returns Array of unique filter values found in the expression
337
+ *
338
+ * @example
339
+ * // For query: where(m => m.pageId.eq('page-1'))
340
+ * extractFilterValues(options, 'pageId') // returns ['page-1']
341
+ *
342
+ * @example
343
+ * // For query: where(m => inArray(m.pageId, ['page-1', 'page-2']))
344
+ * extractFilterValues(options, 'pageId') // returns ['page-1', 'page-2']
345
+ *
346
+ * @example
347
+ * // For query: where(m => and(m.pageId.eq('page-1'), m.status.eq('active')))
348
+ * extractFilterValues(options, 'pageId') // returns ['page-1']
349
+ */
350
+ export declare function extractFilterValues(options: LoadSubsetOptions, filterField: string): unknown[];
351
+
352
+ /**
353
+ * Extract filter values for multiple filter dimensions from LoadSubsetOptions.
354
+ *
355
+ * Parses the `where` expression to find values for each configured filter dimension.
356
+ * Results are keyed by convexArg for direct use in Convex query args.
357
+ *
358
+ * @param options - The LoadSubsetOptions from a live query
359
+ * @param filterDimensions - Array of filter dimensions to extract
360
+ * @returns Object mapping convexArg -> values for dimensions with matches
361
+ *
362
+ * @example
363
+ * // For query: where(m => m.pageId.eq('page-1') && m.authorId.eq('user-1'))
364
+ * extractMultipleFilterValues(options, [
365
+ * { filterField: 'pageId', convexArg: 'pageIds' },
366
+ * { filterField: 'authorId', convexArg: 'authorIds' },
367
+ * ])
368
+ * // returns { pageIds: ['page-1'], authorIds: ['user-1'] }
369
+ */
370
+ export declare function extractMultipleFilterValues(options: LoadSubsetOptions, filterDimensions: FilterDimension[]): ExtractedFilters;
371
+
372
+ /**
373
+ * Filter configuration supporting 0, 1, or N dimensions.
374
+ * - undefined or [] = sync everything (0 filters)
375
+ * - single object = one filter
376
+ * - array = multiple filters
377
+ */
378
+ export declare type FilterConfig = FilterDimension | FilterDimension[] | undefined;
379
+
380
+ /**
381
+ * Single filter dimension configuration.
382
+ * Maps a TanStack DB query field to a Convex query argument.
383
+ */
384
+ export declare interface FilterDimension {
385
+ /**
386
+ * Field name in TanStack DB queries to extract filter values from.
387
+ * This is the field used in `where` expressions like `m.pageId.eq('p1')`.
388
+ * @example 'pageId'
389
+ */
390
+ filterField: string;
391
+ /**
392
+ * Argument name to pass to the Convex query for filter values.
393
+ * This should match the array argument in your Convex query.
394
+ * @example 'pageIds' for a Convex query with `args: { pageIds: v.array(v.string()) }`
395
+ */
396
+ convexArg: string;
397
+ /**
398
+ * If true, assert that only one value is ever requested for this filter.
399
+ * An error will be thrown if multiple values are requested.
400
+ * When single is true, the value is passed directly (not as an array).
401
+ * @default false
402
+ * @example
403
+ * // Convex query expects: { pageId: v.string() }
404
+ * filters: { filterField: 'pageId', convexArg: 'pageId', single: true }
405
+ */
406
+ single?: boolean;
407
+ }
408
+
409
+ /**
410
+ * Restores a value from its string key representation.
411
+ */
412
+ export declare function fromKey(key: string): unknown;
413
+
414
+ /**
415
+ * Check if LoadSubsetOptions contains a filter for the specified field.
416
+ */
417
+ export declare function hasFilterField(options: LoadSubsetOptions, filterField: string): boolean;
418
+
419
+ /**
420
+ * Infer the item type from a Convex query's return type.
421
+ * Expects the query to return an array of items.
422
+ */
423
+ declare type InferQueryItemType<TQuery extends FunctionReference<'query'>> = FunctionReturnType<TQuery> extends Array<infer T> ? T extends object ? T : Record<string, unknown> : Record<string, unknown>;
424
+
425
+ /**
426
+ * Schema output type inference helper
427
+ */
428
+ declare type InferSchemaOutput<T> = T extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<T> extends object ? StandardSchemaV1.InferOutput<T> : Record<string, unknown> : Record<string, unknown>;
429
+
430
+ /**
431
+ * Value serialization utilities for stable hashing and round-tripping.
432
+ *
433
+ * Adapted from TanStack DB's query-db-collection:
434
+ * https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/serialization.ts
435
+ */
436
+ /**
437
+ * Serializes a value into a JSON-safe format that preserves special JS types.
438
+ * Handles: undefined, NaN, Infinity, -Infinity, Date, arrays, and objects.
439
+ */
440
+ export declare function serializeValue(value: unknown): unknown;
441
+
442
+ /**
443
+ * Sync callbacks passed from TanStack DB's sync function
444
+ */
445
+ declare interface SyncCallbacks<T extends object = Record<string, unknown>, TKey extends string | number = string | number> {
446
+ collection: {
447
+ get: (key: TKey) => T | undefined;
448
+ has: (key: TKey) => boolean;
449
+ };
450
+ begin: () => void;
451
+ write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => void;
452
+ commit: () => void;
453
+ markReady: () => void;
454
+ }
455
+
456
+ /**
457
+ * Converts a value to a stable string key for use in Sets/Maps.
458
+ */
459
+ export declare function toKey(value: unknown): string;
460
+
461
+ export { }