From ab4fde9e0f8958def9c3f12a40a9f3989a27b15e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 18:55:26 +0000 Subject: [PATCH 1/5] docs: add bug report for column mapping issues in on-demand sync Investigation of Discord-reported issues with snake_case to camelCase column mapping when using Electric collections with on-demand sync. Key findings: - columnMapper transformations not applied during SQL compilation - compileSQL uses TypeScript property names (camelCase) directly - Electric's structured expression support (whereExpr) not utilized - Results in PostgreSQL errors expecting snake_case column names Includes reproduction steps, root cause analysis, and proposed solutions. --- BUG_REPORT_COLUMN_MAPPING.md | 213 +++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 BUG_REPORT_COLUMN_MAPPING.md diff --git a/BUG_REPORT_COLUMN_MAPPING.md b/BUG_REPORT_COLUMN_MAPPING.md new file mode 100644 index 000000000..91fd7f0c5 --- /dev/null +++ b/BUG_REPORT_COLUMN_MAPPING.md @@ -0,0 +1,213 @@ +# Bug Report: Column Mapping Not Applied in On-Demand/Progressive Sync Modes + +## Summary + +When using `columnMapper: snakeCamelMapper()` with Electric collections in `on-demand` or `progressive` sync modes, camelCase column names in queries are not converted back to snake_case when sending subset requests to the Electric server. This causes reference errors because PostgreSQL expects the original snake_case column names. + +## Affected Versions +- `@tanstack/electric-db-collection`: All versions using on-demand sync +- `@electric-sql/client`: ^1.3.1+ + +## Bug 1: Column Mapper Not Applied to SQL Compilation + +### Reproduction + +```typescript +import { createCollection } from '@tanstack/db'; +import { electricCollectionOptions, snakeCamelMapper } from '@tanstack/electric-db-collection'; +import { CamelCasedPropertiesDeep } from 'type-fest'; + +// Database has snake_case columns: program_template_id, created_at, etc. +type Row = CamelCasedPropertiesDeep>; + +const collection = createCollection( + electricCollectionOptions({ + id: 'program-template-days', + getKey: (row) => row.id, + shapeOptions: { + columnMapper: snakeCamelMapper(), // Converts snake_case -> camelCase + url: `${electricUrl}/v1/shape`, + params: { table: 'program_template_days' }, + }, + syncMode: 'on-demand', // Bug appears in on-demand and progressive modes + }) +); + +// This query fails because "programTemplateId" is sent to Postgres instead of "program_template_id" +const { data } = useLiveQuery( + (q) => q + .from({ d: collection }) + .where(({ d }) => eq(d.programTemplateId, selectedProgramId)) // ❌ Fails + .select(({ d }) => d) +); +``` + +### Expected Behavior + +The query should work because `columnMapper: snakeCamelMapper()` should: +1. Convert `program_template_id` → `programTemplateId` when receiving data (this works) +2. Convert `programTemplateId` → `program_template_id` when sending WHERE clauses (this is broken) + +### Actual Behavior + +The SQL compiler in TanStack DB generates: +```sql +WHERE "programTemplateId" = $1 +``` + +But PostgreSQL expects: +```sql +WHERE "program_template_id" = $1 +``` + +### Root Cause + +The bug is in `/packages/electric-db-collection/src/sql-compiler.ts`: + +```typescript +function quoteIdentifier(name: string): string { + return `"${name}"` // Uses property name directly without transformation +} + +function compileBasicExpression(exp, params): string { + switch (exp.type) { + case `ref`: + return quoteIdentifier(exp.path[0]!) // ❌ No column mapping applied + // ... + } +} +``` + +The `compileSQL` function doesn't have access to the `columnMapper` from `shapeOptions`, so it cannot transform camelCase property names back to snake_case column names. + +### Why Eager Mode Works + +In `eager` mode, all data is synced without WHERE clause filtering, so no column names are sent to the server in subset queries. The column mapper only transforms incoming data, which works correctly. + +### Technical Details + +The Electric client supports two ways to receive subset params: +1. **Legacy string format** (`where`, `orderBy`): Pre-compiled SQL strings +2. **Structured expressions** (`whereExpr`, `orderByExpr`): IR that allows column mapping during compilation + +TanStack DB only sends the legacy string format. The Electric client's `encodeWhereClause` function attempts to transform column names in the string, but quoted identifiers like `"programTemplateId"` may not be properly handled. + +## Bug 2: Basic Collection Query Returns No Data in On-Demand Mode + +### Reproduction + +```typescript +// Collection configured with on-demand sync +const collection = createCollection( + electricCollectionOptions({ + syncMode: 'on-demand', + // ... + }) +); + +// This returns no data +const { data: rows } = useLiveQuery(myElectricCollection); + +// But this works: +const { data: rows } = useLiveQuery( + (q) => q.from({ collection: myElectricCollection }) +); +``` + +### Expected Behavior + +Both queries should return data when using an on-demand collection. + +### Actual Behavior + +Passing the collection directly to `useLiveQuery` (without a query builder function) returns no data. Using the query builder function works. + +### Note from Reporter + +The user noted this may be intended behavior based on documentation review, but the inconsistency is confusing. + +## Proposed Solutions + +### Solution 1: Pass Column Mapper to SQL Compiler + +Modify `createLoadSubsetDedupe` and `compileSQL` to accept a column mapper: + +```typescript +// electric.ts +const loadSubsetDedupe = createLoadSubsetDedupe({ + stream, + syncMode, + // ... + columnMapper: shapeOptions.columnMapper, // Pass mapper +}); + +// sql-compiler.ts +export function compileSQL( + options: LoadSubsetOptions, + columnMapper?: { encode?: (col: string) => string } +): SubsetParams { + // Use columnMapper.encode when quoting identifiers +} +``` + +### Solution 2: Send Structured Expressions (whereExpr) + +Modify TanStack DB to send structured IR expressions alongside or instead of compiled SQL strings: + +```typescript +// Instead of just { where: '"programTemplateId" = $1' } +// Send: +{ + where: '"programTemplateId" = $1', + whereExpr: { + type: 'func', + name: 'eq', + args: [{ type: 'ref', path: ['programTemplateId'] }, { type: 'val', value: 'uuid-123' }] + } +} +``` + +The Electric client can then apply column mapping to the structured expression. + +### Solution 3: Store Original Column Names + +Track the original database column names and use them during SQL compilation, rather than relying on TypeScript property names. + +## Workarounds + +### Workaround 1: Use snake_case in TypeScript Types + +Don't transform types with `CamelCasedPropertiesDeep`. Use snake_case property names throughout: + +```typescript +type Row = Tables<'program_template_days'>; // Keep snake_case +``` + +### Workaround 2: Use Eager Sync Mode + +If possible, use `syncMode: 'eager'` which syncs all data without WHERE clause filtering: + +```typescript +syncMode: 'eager', // Works but may not be suitable for large datasets +``` + +### Workaround 3: Client-Side Filtering + +Sync all data and filter client-side (not recommended for large datasets): + +```typescript +// Sync all, filter in JS +const { data } = useLiveQuery(collection); +const filtered = data.filter(d => d.programTemplateId === selectedProgramId); +``` + +## References + +- [Electric PR #3662: Document camelCase vs snake_case naming conventions](https://github.com/electric-sql/electric/pull/3662) +- [Electric TypeScript Client - Column Mapper API](https://electric-sql.com/docs/api/clients/typescript) + +## Files Involved + +- `/packages/electric-db-collection/src/sql-compiler.ts` - SQL compilation (missing column mapping) +- `/packages/electric-db-collection/src/electric.ts` - Electric sync configuration +- `/packages/db/src/types.ts` - Type definitions for LoadSubsetOptions From 570e4e87d7d64c8be6ee7a2dc8861dd9aa58d587 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 19:08:52 +0000 Subject: [PATCH 2/5] fix(electric-db-collection): apply columnMapper encoding in SQL compilation When using columnMapper (e.g., snakeCamelMapper()) with on-demand sync mode, camelCase column names in queries were not being converted back to snake_case before sending to PostgreSQL. Root cause: The SQL compiler quoted identifiers directly using TypeScript property names without applying the columnMapper's encode function. Changes: - Add encodeColumnName option to compileSQL() to transform column names - Pass columnMapper.encode from shapeOptions through createLoadSubsetDedupe - Apply encoding in quoteIdentifier() before quoting - Add comprehensive tests for column name encoding This fixes the issue where queries like eq(d.programTemplateId, value) would generate "programTemplateId" = $1 instead of "program_template_id" = $1. Fixes: Discord-reported issue with snakeCamelMapper in on-demand mode --- .../electric-db-collection/src/electric.ts | 22 +++- .../src/sql-compiler.ts | 69 +++++++++-- .../tests/sql-compiler.test.ts | 115 ++++++++++++++++++ 3 files changed, 188 insertions(+), 18 deletions(-) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 01484838e..c4dd5791c 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -13,7 +13,7 @@ import { TimeoutWaitingForMatchError, TimeoutWaitingForTxIdError, } from './errors' -import { compileSQL } from './sql-compiler' +import { compileSQL } from './sql-compiler' import { addTagToIndex, findRowsMatchingPattern, @@ -22,6 +22,7 @@ import { removeTagFromIndex, tagMatchesPattern, } from './tag-index' +import type {ColumnEncoder} from './sql-compiler'; import type { MoveOutPattern, MoveTag, @@ -347,6 +348,7 @@ function createLoadSubsetDedupe>({ write, commit, collectionId, + encodeColumnName, }: { stream: ShapeStream syncMode: ElectricSyncMode @@ -359,17 +361,24 @@ function createLoadSubsetDedupe>({ }) => void commit: () => void collectionId?: string + /** + * Optional function to encode column names (e.g., camelCase to snake_case). + * This is typically the `encode` function from shapeOptions.columnMapper. + */ + encodeColumnName?: ColumnEncoder }): DeduplicatedLoadSubset | null { // Eager mode doesn't need subset loading if (syncMode === `eager`) { return null } + const compileOptions = encodeColumnName ? { encodeColumnName } : undefined + const loadSubset = async (opts: LoadSubsetOptions) => { // In progressive mode, use fetchSnapshot during snapshot phase if (isBufferingInitialSync()) { // Progressive mode snapshot phase: fetch and apply immediately - const snapshotParams = compileSQL(opts) + const snapshotParams = compileSQL(opts, compileOptions) try { const { data: rows } = await stream.fetchSnapshot(snapshotParams) @@ -428,7 +437,7 @@ function createLoadSubsetDedupe>({ orderBy, // No limit - get all ties } - const whereCurrentParams = compileSQL(whereCurrentOpts) + const whereCurrentParams = compileSQL(whereCurrentOpts, compileOptions) promises.push(stream.requestSnapshot(whereCurrentParams)) debug( @@ -442,7 +451,7 @@ function createLoadSubsetDedupe>({ orderBy, limit, } - const whereFromParams = compileSQL(whereFromOpts) + const whereFromParams = compileSQL(whereFromOpts, compileOptions) promises.push(stream.requestSnapshot(whereFromParams)) debug( @@ -453,7 +462,7 @@ function createLoadSubsetDedupe>({ await Promise.all(promises) } else { // No cursor - standard single request - const snapshotParams = compileSQL(opts) + const snapshotParams = compileSQL(opts, compileOptions) await stream.requestSnapshot(snapshotParams) } } @@ -1296,6 +1305,9 @@ function createElectricSync>( write, commit, collectionId, + // Pass the columnMapper's encode function to transform column names + // (e.g., camelCase to snake_case) when compiling SQL for subset queries + encodeColumnName: shapeOptions.columnMapper?.encode, }) unsubscribeStream = stream.subscribe((messages: Array>) => { diff --git a/packages/electric-db-collection/src/sql-compiler.ts b/packages/electric-db-collection/src/sql-compiler.ts index d7d021c89..1020e079a 100644 --- a/packages/electric-db-collection/src/sql-compiler.ts +++ b/packages/electric-db-collection/src/sql-compiler.ts @@ -6,8 +6,30 @@ export type CompiledSqlRecord = Omit & { params?: Array } -export function compileSQL(options: LoadSubsetOptions): SubsetParams { +/** + * Optional function to encode column names (e.g., camelCase to snake_case) + * This is typically the `encode` function from a columnMapper + */ +export type ColumnEncoder = (columnName: string) => string + +/** + * Options for SQL compilation + */ +export interface CompileSQLOptions { + /** + * Optional function to encode column names before quoting. + * Used to transform property names (e.g., camelCase) to database column names (e.g., snake_case). + * This should be the `encode` function from shapeOptions.columnMapper. + */ + encodeColumnName?: ColumnEncoder +} + +export function compileSQL( + options: LoadSubsetOptions, + compileOptions?: CompileSQLOptions, +): SubsetParams { const { where, orderBy, limit } = options + const encodeColumnName = compileOptions?.encodeColumnName const params: Array = [] const compiledSQL: CompiledSqlRecord = { params } @@ -15,11 +37,11 @@ export function compileSQL(options: LoadSubsetOptions): SubsetParams { if (where) { // TODO: this only works when the where expression's PropRefs directly reference a column of the collection // doesn't work if it goes through aliases because then we need to know the entire query to be able to follow the reference until the base collection (cf. followRef function) - compiledSQL.where = compileBasicExpression(where, params) + compiledSQL.where = compileBasicExpression(where, params, encodeColumnName) } if (orderBy) { - compiledSQL.orderBy = compileOrderBy(orderBy, params) + compiledSQL.orderBy = compileOrderBy(orderBy, params, encodeColumnName) } if (limit) { @@ -58,21 +80,28 @@ export function compileSQL(options: LoadSubsetOptions): SubsetParams { * Quote PostgreSQL identifiers to handle mixed case column names correctly. * Electric/Postgres requires quotes for case-sensitive identifiers. * @param name - The identifier to quote + * @param encodeColumnName - Optional function to encode the column name before quoting (e.g., camelCase to snake_case) * @returns The quoted identifier */ -function quoteIdentifier(name: string): string { - return `"${name}"` +function quoteIdentifier( + name: string, + encodeColumnName?: ColumnEncoder, +): string { + const columnName = encodeColumnName ? encodeColumnName(name) : name + return `"${columnName}"` } /** * Compiles the expression to a SQL string and mutates the params array with the values. * @param exp - The expression to compile * @param params - The params array + * @param encodeColumnName - Optional function to encode column names (e.g., camelCase to snake_case) * @returns The compiled SQL string */ function compileBasicExpression( exp: IR.BasicExpression, params: Array, + encodeColumnName?: ColumnEncoder, ): string { switch (exp.type) { case `val`: @@ -85,17 +114,21 @@ function compileBasicExpression( `Compiler can't handle nested properties: ${exp.path.join(`.`)}`, ) } - return quoteIdentifier(exp.path[0]!) + return quoteIdentifier(exp.path[0]!, encodeColumnName) case `func`: - return compileFunction(exp, params) + return compileFunction(exp, params, encodeColumnName) default: throw new Error(`Unknown expression type`) } } -function compileOrderBy(orderBy: IR.OrderBy, params: Array): string { +function compileOrderBy( + orderBy: IR.OrderBy, + params: Array, + encodeColumnName?: ColumnEncoder, +): string { const compiledOrderByClauses = orderBy.map((clause: IR.OrderByClause) => - compileOrderByClause(clause, params), + compileOrderByClause(clause, params, encodeColumnName), ) return compiledOrderByClauses.join(`,`) } @@ -103,11 +136,12 @@ function compileOrderBy(orderBy: IR.OrderBy, params: Array): string { function compileOrderByClause( clause: IR.OrderByClause, params: Array, + encodeColumnName?: ColumnEncoder, ): string { // FIXME: We should handle stringSort and locale. // Correctly supporting them is tricky as it depends on Postgres' collation const { expression, compareOptions } = clause - let sql = compileBasicExpression(expression, params) + let sql = compileBasicExpression(expression, params, encodeColumnName) if (compareOptions.direction === `desc`) { sql = `${sql} DESC` @@ -134,6 +168,7 @@ function isNullValue(exp: IR.BasicExpression): boolean { function compileFunction( exp: IR.Func, params: Array = [], + encodeColumnName?: ColumnEncoder, ): string { const { name, args } = exp @@ -160,7 +195,7 @@ function compileFunction( } const compiledArgs = args.map((arg: IR.BasicExpression) => - compileBasicExpression(arg, params), + compileBasicExpression(arg, params, encodeColumnName), ) // Special case for IS NULL / IS NOT NULL - these are postfix operators @@ -181,7 +216,11 @@ function compileFunction( if (arg && arg.type === `func`) { const funcArg = arg if (funcArg.name === `isNull` || funcArg.name === `isUndefined`) { - const innerArg = compileBasicExpression(funcArg.args[0]!, params) + const innerArg = compileBasicExpression( + funcArg.args[0]!, + params, + encodeColumnName, + ) return `${innerArg} IS NOT NULL` } } @@ -270,7 +309,11 @@ function compileFunction( params.pop() // remove LHS (boolean) // Recompile RHS to get fresh param - const rhsCompiled = compileBasicExpression(rhsArg!, params) + const rhsCompiled = compileBasicExpression( + rhsArg!, + params, + encodeColumnName, + ) // Transform: flip the comparison (val op col → col flipped_op val) if (name === `lt`) { diff --git a/packages/electric-db-collection/tests/sql-compiler.test.ts b/packages/electric-db-collection/tests/sql-compiler.test.ts index 9989d47f9..04e2b0e79 100644 --- a/packages/electric-db-collection/tests/sql-compiler.test.ts +++ b/packages/electric-db-collection/tests/sql-compiler.test.ts @@ -308,5 +308,120 @@ describe(`sql-compiler`, () => { expect(result.limit).toBe(10) }) }) + + describe(`column name encoding (camelCase to snake_case)`, () => { + // Helper to simulate snakeCamelMapper's encode function + const camelToSnake = (str: string): string => + str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) + + it(`should encode column names in where clause when encoder is provided`, () => { + const result = compileSQL( + { + where: func(`eq`, [ref(`programTemplateId`), val(`uuid-123`)]), + }, + { encodeColumnName: camelToSnake }, + ) + expect(result.where).toBe(`"program_template_id" = $1`) + expect(result.params).toEqual({ '1': `uuid-123` }) + }) + + it(`should encode column names in compound where clauses`, () => { + const result = compileSQL( + { + where: func(`and`, [ + func(`eq`, [ref(`programTemplateId`), val(`uuid-123`)]), + func(`gt`, [ref(`createdAt`), val(`2024-01-01`)]), + ]), + }, + { encodeColumnName: camelToSnake }, + ) + expect(result.where).toBe( + `"program_template_id" = $1 AND "created_at" > $2`, + ) + expect(result.params).toEqual({ '1': `uuid-123`, '2': `2024-01-01` }) + }) + + it(`should encode column names in orderBy clause`, () => { + const result = compileSQL( + { + orderBy: [ + { + expression: ref(`createdAt`), + compareOptions: { direction: `desc`, nulls: `last` }, + }, + ], + }, + { encodeColumnName: camelToSnake }, + ) + expect(result.orderBy).toBe(`"created_at" DESC NULLS LAST`) + }) + + it(`should encode column names in isNull expressions`, () => { + const result = compileSQL( + { + where: func(`isNull`, [ref(`deletedAt`)]), + }, + { encodeColumnName: camelToSnake }, + ) + expect(result.where).toBe(`"deleted_at" IS NULL`) + }) + + it(`should encode column names in NOT isNull expressions`, () => { + const result = compileSQL( + { + where: func(`not`, [func(`isNull`, [ref(`archivedAt`)])]), + }, + { encodeColumnName: camelToSnake }, + ) + expect(result.where).toBe(`"archived_at" IS NOT NULL`) + }) + + it(`should not transform column names when no encoder is provided`, () => { + const result = compileSQL({ + where: func(`eq`, [ref(`programTemplateId`), val(`uuid-123`)]), + }) + // Without encoder, camelCase name is preserved + expect(result.where).toBe(`"programTemplateId" = $1`) + }) + + it(`should handle complex nested expressions with encoding`, () => { + const result = compileSQL( + { + where: func(`and`, [ + func(`eq`, [ref(`userId`), val(`user-1`)]), + func(`or`, [ + func(`eq`, [ref(`accountType`), val(`premium`)]), + func(`gte`, [ref(`totalSpend`), val(1000)]), + ]), + ]), + }, + { encodeColumnName: camelToSnake }, + ) + expect(result.where).toBe( + `"user_id" = $1 AND "account_type" = $2 OR "total_spend" >= $3`, + ) + }) + + it(`should encode column names in LIKE expressions`, () => { + const result = compileSQL( + { + where: func(`ilike`, [ref(`firstName`), val(`%john%`)]), + }, + { encodeColumnName: camelToSnake }, + ) + expect(result.where).toBe(`"first_name" ILIKE $1`) + }) + + it(`should work with already snake_case names (identity transform)`, () => { + const result = compileSQL( + { + where: func(`eq`, [ref(`user_id`), val(`123`)]), + }, + { encodeColumnName: camelToSnake }, + ) + // snake_case input remains snake_case + expect(result.where).toBe(`"user_id" = $1`) + }) + }) }) }) From 9bfe5c0ceed8ddaeddbb59a2291cc8952669130b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 19:12:19 +0000 Subject: [PATCH 3/5] fix(react-db,vue-db,svelte-db): warn when on-demand collection passed directly to useLiveQuery Add console warning when a collection with syncMode "on-demand" is passed directly to useLiveQuery. In on-demand mode, data is only loaded when queries with predicates request it. Passing the collection directly doesn't provide any predicates, so no data loads. The warning guides users to either: 1. Use a query builder function: useLiveQuery((q) => q.from({c: collection})) 2. Switch to syncMode "eager" for automatic sync This helps prevent confusion when users expect data to appear but nothing loads due to the on-demand sync behavior. --- packages/react-db/src/useLiveQuery.ts | 13 +++++++++++++ packages/svelte-db/src/useLiveQuery.svelte.ts | 13 +++++++++++++ packages/vue-db/src/useLiveQuery.ts | 13 +++++++++++++ 3 files changed, 39 insertions(+) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 14a56d647..7b7ead0b0 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -350,6 +350,19 @@ export function useLiveQuery( if (needsNewCollection) { if (isCollection) { + // Warn when passing a collection directly with on-demand sync mode + // In on-demand mode, data is only loaded when queries with predicates request it + // Passing the collection directly doesn't provide any predicates, so no data loads + const syncMode = (configOrQueryOrCollection as { config?: { syncMode?: string } }).config?.syncMode + if (syncMode === `on-demand`) { + console.warn( + `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` + + `will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` + + `Instead, use a query builder function:\n` + + ` const { data } = useLiveQuery((q) => q.from({ c: myCollection }).select(({ c }) => c))\n\n` + + `Or switch to syncMode "eager" if you want all data to sync automatically.`, + ) + } // It's already a collection, ensure sync is started for React hooks configOrQueryOrCollection.startSyncImmediate() collectionRef.current = configOrQueryOrCollection diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index cd82a59fa..a0e0fddeb 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -309,6 +309,19 @@ export function useLiveQuery( typeof unwrappedParam.id === `string` if (isCollection) { + // Warn when passing a collection directly with on-demand sync mode + // In on-demand mode, data is only loaded when queries with predicates request it + // Passing the collection directly doesn't provide any predicates, so no data loads + const syncMode = (unwrappedParam as { config?: { syncMode?: string } }).config?.syncMode + if (syncMode === `on-demand`) { + console.warn( + `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` + + `will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` + + `Instead, use a query builder function:\n` + + ` const { data } = useLiveQuery((q) => q.from({ c: myCollection }).select(({ c }) => c))\n\n` + + `Or switch to syncMode "eager" if you want all data to sync automatically.`, + ) + } // It's already a collection, ensure sync is started for Svelte helpers // Only start sync if the collection is in idle state if (unwrappedParam.status === `idle`) { diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 76e181664..487149bdb 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -274,6 +274,19 @@ export function useLiveQuery( typeof unwrappedParam.id === `string` if (isCollection) { + // Warn when passing a collection directly with on-demand sync mode + // In on-demand mode, data is only loaded when queries with predicates request it + // Passing the collection directly doesn't provide any predicates, so no data loads + const syncMode = (unwrappedParam as { config?: { syncMode?: string } }).config?.syncMode + if (syncMode === `on-demand`) { + console.warn( + `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` + + `will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` + + `Instead, use a query builder function:\n` + + ` const { data } = useLiveQuery((q) => q.from({ c: myCollection }).select(({ c }) => c))\n\n` + + `Or switch to syncMode "eager" if you want all data to sync automatically.`, + ) + } // It's already a collection, ensure sync is started for Vue hooks // Only start sync if the collection is in idle state if (unwrappedParam.status === `idle`) { From 37552b680f13c085928d88b34b8dc44713791653 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 19:13:00 +0000 Subject: [PATCH 4/5] chore: remove bug report - issues have been fixed --- BUG_REPORT_COLUMN_MAPPING.md | 213 ----------------------------------- 1 file changed, 213 deletions(-) delete mode 100644 BUG_REPORT_COLUMN_MAPPING.md diff --git a/BUG_REPORT_COLUMN_MAPPING.md b/BUG_REPORT_COLUMN_MAPPING.md deleted file mode 100644 index 91fd7f0c5..000000000 --- a/BUG_REPORT_COLUMN_MAPPING.md +++ /dev/null @@ -1,213 +0,0 @@ -# Bug Report: Column Mapping Not Applied in On-Demand/Progressive Sync Modes - -## Summary - -When using `columnMapper: snakeCamelMapper()` with Electric collections in `on-demand` or `progressive` sync modes, camelCase column names in queries are not converted back to snake_case when sending subset requests to the Electric server. This causes reference errors because PostgreSQL expects the original snake_case column names. - -## Affected Versions -- `@tanstack/electric-db-collection`: All versions using on-demand sync -- `@electric-sql/client`: ^1.3.1+ - -## Bug 1: Column Mapper Not Applied to SQL Compilation - -### Reproduction - -```typescript -import { createCollection } from '@tanstack/db'; -import { electricCollectionOptions, snakeCamelMapper } from '@tanstack/electric-db-collection'; -import { CamelCasedPropertiesDeep } from 'type-fest'; - -// Database has snake_case columns: program_template_id, created_at, etc. -type Row = CamelCasedPropertiesDeep>; - -const collection = createCollection( - electricCollectionOptions({ - id: 'program-template-days', - getKey: (row) => row.id, - shapeOptions: { - columnMapper: snakeCamelMapper(), // Converts snake_case -> camelCase - url: `${electricUrl}/v1/shape`, - params: { table: 'program_template_days' }, - }, - syncMode: 'on-demand', // Bug appears in on-demand and progressive modes - }) -); - -// This query fails because "programTemplateId" is sent to Postgres instead of "program_template_id" -const { data } = useLiveQuery( - (q) => q - .from({ d: collection }) - .where(({ d }) => eq(d.programTemplateId, selectedProgramId)) // ❌ Fails - .select(({ d }) => d) -); -``` - -### Expected Behavior - -The query should work because `columnMapper: snakeCamelMapper()` should: -1. Convert `program_template_id` → `programTemplateId` when receiving data (this works) -2. Convert `programTemplateId` → `program_template_id` when sending WHERE clauses (this is broken) - -### Actual Behavior - -The SQL compiler in TanStack DB generates: -```sql -WHERE "programTemplateId" = $1 -``` - -But PostgreSQL expects: -```sql -WHERE "program_template_id" = $1 -``` - -### Root Cause - -The bug is in `/packages/electric-db-collection/src/sql-compiler.ts`: - -```typescript -function quoteIdentifier(name: string): string { - return `"${name}"` // Uses property name directly without transformation -} - -function compileBasicExpression(exp, params): string { - switch (exp.type) { - case `ref`: - return quoteIdentifier(exp.path[0]!) // ❌ No column mapping applied - // ... - } -} -``` - -The `compileSQL` function doesn't have access to the `columnMapper` from `shapeOptions`, so it cannot transform camelCase property names back to snake_case column names. - -### Why Eager Mode Works - -In `eager` mode, all data is synced without WHERE clause filtering, so no column names are sent to the server in subset queries. The column mapper only transforms incoming data, which works correctly. - -### Technical Details - -The Electric client supports two ways to receive subset params: -1. **Legacy string format** (`where`, `orderBy`): Pre-compiled SQL strings -2. **Structured expressions** (`whereExpr`, `orderByExpr`): IR that allows column mapping during compilation - -TanStack DB only sends the legacy string format. The Electric client's `encodeWhereClause` function attempts to transform column names in the string, but quoted identifiers like `"programTemplateId"` may not be properly handled. - -## Bug 2: Basic Collection Query Returns No Data in On-Demand Mode - -### Reproduction - -```typescript -// Collection configured with on-demand sync -const collection = createCollection( - electricCollectionOptions({ - syncMode: 'on-demand', - // ... - }) -); - -// This returns no data -const { data: rows } = useLiveQuery(myElectricCollection); - -// But this works: -const { data: rows } = useLiveQuery( - (q) => q.from({ collection: myElectricCollection }) -); -``` - -### Expected Behavior - -Both queries should return data when using an on-demand collection. - -### Actual Behavior - -Passing the collection directly to `useLiveQuery` (without a query builder function) returns no data. Using the query builder function works. - -### Note from Reporter - -The user noted this may be intended behavior based on documentation review, but the inconsistency is confusing. - -## Proposed Solutions - -### Solution 1: Pass Column Mapper to SQL Compiler - -Modify `createLoadSubsetDedupe` and `compileSQL` to accept a column mapper: - -```typescript -// electric.ts -const loadSubsetDedupe = createLoadSubsetDedupe({ - stream, - syncMode, - // ... - columnMapper: shapeOptions.columnMapper, // Pass mapper -}); - -// sql-compiler.ts -export function compileSQL( - options: LoadSubsetOptions, - columnMapper?: { encode?: (col: string) => string } -): SubsetParams { - // Use columnMapper.encode when quoting identifiers -} -``` - -### Solution 2: Send Structured Expressions (whereExpr) - -Modify TanStack DB to send structured IR expressions alongside or instead of compiled SQL strings: - -```typescript -// Instead of just { where: '"programTemplateId" = $1' } -// Send: -{ - where: '"programTemplateId" = $1', - whereExpr: { - type: 'func', - name: 'eq', - args: [{ type: 'ref', path: ['programTemplateId'] }, { type: 'val', value: 'uuid-123' }] - } -} -``` - -The Electric client can then apply column mapping to the structured expression. - -### Solution 3: Store Original Column Names - -Track the original database column names and use them during SQL compilation, rather than relying on TypeScript property names. - -## Workarounds - -### Workaround 1: Use snake_case in TypeScript Types - -Don't transform types with `CamelCasedPropertiesDeep`. Use snake_case property names throughout: - -```typescript -type Row = Tables<'program_template_days'>; // Keep snake_case -``` - -### Workaround 2: Use Eager Sync Mode - -If possible, use `syncMode: 'eager'` which syncs all data without WHERE clause filtering: - -```typescript -syncMode: 'eager', // Works but may not be suitable for large datasets -``` - -### Workaround 3: Client-Side Filtering - -Sync all data and filter client-side (not recommended for large datasets): - -```typescript -// Sync all, filter in JS -const { data } = useLiveQuery(collection); -const filtered = data.filter(d => d.programTemplateId === selectedProgramId); -``` - -## References - -- [Electric PR #3662: Document camelCase vs snake_case naming conventions](https://github.com/electric-sql/electric/pull/3662) -- [Electric TypeScript Client - Column Mapper API](https://electric-sql.com/docs/api/clients/typescript) - -## Files Involved - -- `/packages/electric-db-collection/src/sql-compiler.ts` - SQL compilation (missing column mapping) -- `/packages/electric-db-collection/src/electric.ts` - Electric sync configuration -- `/packages/db/src/types.ts` - Type definitions for LoadSubsetOptions From 3ee5a1ef3da5eb7bfb7a4d1b947753825f7708f8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 19:15:48 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- packages/electric-db-collection/src/electric.ts | 9 ++++++--- packages/react-db/src/useLiveQuery.ts | 4 +++- packages/svelte-db/src/useLiveQuery.svelte.ts | 3 ++- packages/vue-db/src/useLiveQuery.ts | 3 ++- packages/vue-db/tests/useLiveQuery.test-d.ts | 4 +++- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index c4dd5791c..994889ded 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -13,7 +13,7 @@ import { TimeoutWaitingForMatchError, TimeoutWaitingForTxIdError, } from './errors' -import { compileSQL } from './sql-compiler' +import { compileSQL } from './sql-compiler' import { addTagToIndex, findRowsMatchingPattern, @@ -22,7 +22,7 @@ import { removeTagFromIndex, tagMatchesPattern, } from './tag-index' -import type {ColumnEncoder} from './sql-compiler'; +import type { ColumnEncoder } from './sql-compiler' import type { MoveOutPattern, MoveTag, @@ -437,7 +437,10 @@ function createLoadSubsetDedupe>({ orderBy, // No limit - get all ties } - const whereCurrentParams = compileSQL(whereCurrentOpts, compileOptions) + const whereCurrentParams = compileSQL( + whereCurrentOpts, + compileOptions, + ) promises.push(stream.requestSnapshot(whereCurrentParams)) debug( diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 7b7ead0b0..331ff3a27 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -353,7 +353,9 @@ export function useLiveQuery( // Warn when passing a collection directly with on-demand sync mode // In on-demand mode, data is only loaded when queries with predicates request it // Passing the collection directly doesn't provide any predicates, so no data loads - const syncMode = (configOrQueryOrCollection as { config?: { syncMode?: string } }).config?.syncMode + const syncMode = ( + configOrQueryOrCollection as { config?: { syncMode?: string } } + ).config?.syncMode if (syncMode === `on-demand`) { console.warn( `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` + diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index a0e0fddeb..76dedd3c7 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -312,7 +312,8 @@ export function useLiveQuery( // Warn when passing a collection directly with on-demand sync mode // In on-demand mode, data is only loaded when queries with predicates request it // Passing the collection directly doesn't provide any predicates, so no data loads - const syncMode = (unwrappedParam as { config?: { syncMode?: string } }).config?.syncMode + const syncMode = (unwrappedParam as { config?: { syncMode?: string } }) + .config?.syncMode if (syncMode === `on-demand`) { console.warn( `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` + diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 487149bdb..479c92b2b 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -277,7 +277,8 @@ export function useLiveQuery( // Warn when passing a collection directly with on-demand sync mode // In on-demand mode, data is only loaded when queries with predicates request it // Passing the collection directly doesn't provide any predicates, so no data loads - const syncMode = (unwrappedParam as { config?: { syncMode?: string } }).config?.syncMode + const syncMode = (unwrappedParam as { config?: { syncMode?: string } }) + .config?.syncMode if (syncMode === `on-demand`) { console.warn( `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` + diff --git a/packages/vue-db/tests/useLiveQuery.test-d.ts b/packages/vue-db/tests/useLiveQuery.test-d.ts index 514a2b913..df54ec2be 100644 --- a/packages/vue-db/tests/useLiveQuery.test-d.ts +++ b/packages/vue-db/tests/useLiveQuery.test-d.ts @@ -132,6 +132,8 @@ describe(`useLiveQuery type assertions`, () => { ) // Regular queries should return an array - expectTypeOf(data.value).toEqualTypeOf>() + expectTypeOf(data.value).toEqualTypeOf< + Array<{ id: string; name: string }> + >() }) })