@tanstack/electric-db-collection 0.4.8 → 0.5.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.
@@ -1,4 +1,4 @@
1
- import { BaseCollectionConfig, CollectionConfig, DeleteMutationFnParams, InsertMutationFnParams, SyncMode, UpdateMutationFnParams, UtilsRecord } from '@tanstack/db';
1
+ import { BaseCollectionConfig, CollectionConfig, DeleteMutationFnParams, InsertMutationFnParams, SyncMode, UpdateMutationFnParams } from '@tanstack/db';
2
2
  import { StandardSchemaV1 } from '@standard-schema/spec';
3
3
  import { GetExtensions, Message, Row, ShapeStreamOptions } from '@electric-sql/client';
4
4
  export { isChangeMessage, isControlMessage } from '@electric-sql/client';
@@ -28,11 +28,11 @@ export type MatchFunction<T extends Row<unknown>> = (message: Message<T>) => boo
28
28
  /**
29
29
  * Matching strategies for Electric synchronization
30
30
  * Handlers can return:
31
- * - Txid strategy: { txid: number | number[], timeout?: number } (recommended)
31
+ * - Txid strategy: { txid: number | number[], timeout?: number } (deprecated compatibility behavior)
32
32
  * - Void (no return value) - mutation completes without waiting
33
33
  *
34
34
  * The optional timeout property specifies how long to wait for the txid(s) in milliseconds.
35
- * If not specified, defaults to 5000ms.
35
+ * If not specified, defaults to 15000ms.
36
36
  */
37
37
  export type MatchingStrategy = {
38
38
  txid: Txid | Array<Txid>;
@@ -61,7 +61,7 @@ export type ElectricSyncMode = SyncMode | `progressive`;
61
61
  * @template T - The type of items in the collection
62
62
  * @template TSchema - The schema type for validation
63
63
  */
64
- export interface ElectricCollectionConfig<T extends Row<unknown> = Row<unknown>, TSchema extends StandardSchemaV1 = never> extends Omit<BaseCollectionConfig<T, string | number, TSchema, ElectricCollectionUtils<T>, any>, `onInsert` | `onUpdate` | `onDelete` | `syncMode`> {
64
+ export interface ElectricCollectionConfig<T extends Row<unknown> = Row<unknown>, TSchema extends StandardSchemaV1 = never> extends Omit<BaseCollectionConfig<T, string | number, TSchema, ElectricCollectionUtils<T>>, `onInsert` | `onUpdate` | `onDelete` | `syncMode`> {
65
65
  /**
66
66
  * Configuration options for the ElectricSQL ShapeStream
67
67
  */
@@ -74,43 +74,82 @@ export interface ElectricCollectionConfig<T extends Row<unknown> = Row<unknown>,
74
74
  [ELECTRIC_TEST_HOOKS]?: ElectricTestHooks;
75
75
  /**
76
76
  * Optional asynchronous handler function called before an insert operation
77
+ *
78
+ * **IMPORTANT - Electric Synchronization:**
79
+ * This handler **must not resolve** until synchronization is confirmed.
80
+ * Await one of these synchronization utilities before the handler completes:
81
+ * 1. `await collection.utils.awaitTxId(txid)` (recommended for most cases)
82
+ * 2. `await collection.utils.awaitMatch(fn)` for custom matching logic
83
+ *
84
+ * Simply returning without waiting for sync will drop optimistic state too early, causing UI glitches.
85
+ *
77
86
  * @param params Object containing transaction and collection information
78
- * @returns Promise resolving to { txid, timeout? } or void
87
+ * @returns Promise that should resolve after synchronization is complete
88
+ *
89
+ * **Deprecation notice:** Returning `{ txid }` from handlers is deprecated and will be removed in v1.0.
90
+ * Use `await collection.utils.awaitTxId(txid)` within the handler instead.
91
+ *
79
92
  * @example
80
- * // Basic Electric insert handler with txid (recommended)
81
- * onInsert: async ({ transaction }) => {
93
+ * // Recommended: Wait for txid to sync
94
+ * onInsert: async ({ transaction, collection }) => {
82
95
  * const newItem = transaction.mutations[0].modified
83
96
  * const result = await api.todos.create({
84
97
  * data: newItem
85
98
  * })
86
- * return { txid: result.txid }
99
+ * // Wait for txid to sync before handler completes
100
+ * await collection.utils.awaitTxId(result.txid)
87
101
  * }
88
102
  *
89
103
  * @example
90
104
  * // Insert handler with custom timeout
91
- * onInsert: async ({ transaction }) => {
105
+ * onInsert: async ({ transaction, collection }) => {
106
+ * const newItem = transaction.mutations[0].modified
107
+ * const result = await api.todos.create({
108
+ * data: newItem
109
+ * })
110
+ * // Wait up to 10 seconds for txid
111
+ * await collection.utils.awaitTxId(result.txid, 10000)
112
+ * }
113
+ *
114
+ * @example
115
+ * // Insert handler with timeout error handling
116
+ * onInsert: async ({ transaction, collection }) => {
92
117
  * const newItem = transaction.mutations[0].modified
93
118
  * const result = await api.todos.create({
94
119
  * data: newItem
95
120
  * })
96
- * return { txid: result.txid, timeout: 10000 } // Wait up to 10 seconds
121
+ *
122
+ * try {
123
+ * await collection.utils.awaitTxId(result.txid, 5000)
124
+ * } catch (error) {
125
+ * // Decide sync timeout policy:
126
+ * // - Throw to rollback optimistic state
127
+ * // - Catch to keep optimistic state (eventual consistency)
128
+ * // - Schedule background retry
129
+ * console.warn('Sync timeout, keeping optimistic state:', error)
130
+ * // Don't throw - allow optimistic state to persist
131
+ * }
97
132
  * }
98
133
  *
99
134
  * @example
100
- * // Insert handler with multiple items - return array of txids
101
- * onInsert: async ({ transaction }) => {
135
+ * // Insert handler with multiple items
136
+ * onInsert: async ({ transaction, collection }) => {
102
137
  * const items = transaction.mutations.map(m => m.modified)
103
138
  * const results = await Promise.all(
104
139
  * items.map(item => api.todos.create({ data: item }))
105
140
  * )
106
- * return { txid: results.map(r => r.txid) }
141
+ * // Wait for all txids to sync
142
+ * await Promise.all(
143
+ * results.map(r => collection.utils.awaitTxId(r.txid))
144
+ * )
107
145
  * }
108
146
  *
109
147
  * @example
110
- * // Use awaitMatch utility for custom matching
148
+ * // Alternative: Use awaitMatch utility for custom matching logic
111
149
  * onInsert: async ({ transaction, collection }) => {
112
150
  * const newItem = transaction.mutations[0].modified
113
151
  * await api.todos.create({ data: newItem })
152
+ * // Wait for specific change to appear in sync stream
114
153
  * await collection.utils.awaitMatch(
115
154
  * (message) => isChangeMessage(message) &&
116
155
  * message.headers.operation === 'insert' &&
@@ -121,24 +160,39 @@ export interface ElectricCollectionConfig<T extends Row<unknown> = Row<unknown>,
121
160
  onInsert?: (params: InsertMutationFnParams<T, string | number, ElectricCollectionUtils<T>>) => Promise<MatchingStrategy>;
122
161
  /**
123
162
  * Optional asynchronous handler function called before an update operation
163
+ *
164
+ * **IMPORTANT - Electric Synchronization:**
165
+ * This handler **must not resolve** until synchronization is confirmed.
166
+ * Await one of these synchronization utilities before the handler completes:
167
+ * 1. `await collection.utils.awaitTxId(txid)` (recommended for most cases)
168
+ * 2. `await collection.utils.awaitMatch(fn)` for custom matching logic
169
+ *
170
+ * Simply returning without waiting for sync will drop optimistic state too early, causing UI glitches.
171
+ *
124
172
  * @param params Object containing transaction and collection information
125
- * @returns Promise resolving to { txid, timeout? } or void
173
+ * @returns Promise that should resolve after synchronization is complete
174
+ *
175
+ * **Deprecation notice:** Returning `{ txid }` from handlers is deprecated and will be removed in v1.0.
176
+ * Use `await collection.utils.awaitTxId(txid)` within the handler instead.
177
+ *
126
178
  * @example
127
- * // Basic Electric update handler with txid (recommended)
128
- * onUpdate: async ({ transaction }) => {
179
+ * // Recommended: Wait for txid to sync
180
+ * onUpdate: async ({ transaction, collection }) => {
129
181
  * const { original, changes } = transaction.mutations[0]
130
182
  * const result = await api.todos.update({
131
183
  * where: { id: original.id },
132
184
  * data: changes
133
185
  * })
134
- * return { txid: result.txid }
186
+ * // Wait for txid to sync before handler completes
187
+ * await collection.utils.awaitTxId(result.txid)
135
188
  * }
136
189
  *
137
190
  * @example
138
- * // Use awaitMatch utility for custom matching
191
+ * // Alternative: Use awaitMatch utility for custom matching logic
139
192
  * onUpdate: async ({ transaction, collection }) => {
140
193
  * const { original, changes } = transaction.mutations[0]
141
194
  * await api.todos.update({ where: { id: original.id }, data: changes })
195
+ * // Wait for specific change to appear in sync stream
142
196
  * await collection.utils.awaitMatch(
143
197
  * (message) => isChangeMessage(message) &&
144
198
  * message.headers.operation === 'update' &&
@@ -149,23 +203,38 @@ export interface ElectricCollectionConfig<T extends Row<unknown> = Row<unknown>,
149
203
  onUpdate?: (params: UpdateMutationFnParams<T, string | number, ElectricCollectionUtils<T>>) => Promise<MatchingStrategy>;
150
204
  /**
151
205
  * Optional asynchronous handler function called before a delete operation
206
+ *
207
+ * **IMPORTANT - Electric Synchronization:**
208
+ * This handler **must not resolve** until synchronization is confirmed.
209
+ * Await one of these synchronization utilities before the handler completes:
210
+ * 1. `await collection.utils.awaitTxId(txid)` (recommended for most cases)
211
+ * 2. `await collection.utils.awaitMatch(fn)` for custom matching logic
212
+ *
213
+ * Simply returning without waiting for sync will drop optimistic state too early, causing UI glitches.
214
+ *
152
215
  * @param params Object containing transaction and collection information
153
- * @returns Promise resolving to { txid, timeout? } or void
216
+ * @returns Promise that should resolve after synchronization is complete
217
+ *
218
+ * **Deprecation notice:** Returning `{ txid }` from handlers is deprecated and will be removed in v1.0.
219
+ * Use `await collection.utils.awaitTxId(txid)` within the handler instead.
220
+ *
154
221
  * @example
155
- * // Basic Electric delete handler with txid (recommended)
156
- * onDelete: async ({ transaction }) => {
222
+ * // Recommended: Wait for txid to sync
223
+ * onDelete: async ({ transaction, collection }) => {
157
224
  * const mutation = transaction.mutations[0]
158
225
  * const result = await api.todos.delete({
159
226
  * id: mutation.original.id
160
227
  * })
161
- * return { txid: result.txid }
228
+ * // Wait for txid to sync before handler completes
229
+ * await collection.utils.awaitTxId(result.txid)
162
230
  * }
163
231
  *
164
232
  * @example
165
- * // Use awaitMatch utility for custom matching
233
+ * // Alternative: Use awaitMatch utility for custom matching logic
166
234
  * onDelete: async ({ transaction, collection }) => {
167
235
  * const mutation = transaction.mutations[0]
168
236
  * await api.todos.delete({ id: mutation.original.id })
237
+ * // Wait for specific change to appear in sync stream
169
238
  * await collection.utils.awaitMatch(
170
239
  * (message) => isChangeMessage(message) &&
171
240
  * message.headers.operation === 'delete' &&
@@ -186,7 +255,7 @@ export type AwaitMatchFn<T extends Row<unknown>> = (matchFn: MatchFunction<T>, t
186
255
  /**
187
256
  * Electric collection utilities type
188
257
  */
189
- export interface ElectricCollectionUtils<T extends Row<unknown> = Row<unknown>> extends UtilsRecord {
258
+ export interface ElectricCollectionUtils<T extends Row<unknown> = Row<unknown>> {
190
259
  awaitTxId: AwaitTxIdFn;
191
260
  awaitMatch: AwaitMatchFn<T>;
192
261
  }
@@ -35,7 +35,7 @@ function compileSQL(options, compileOptions) {
35
35
  }
36
36
  function quoteIdentifier(name, encodeColumnName) {
37
37
  const columnName = encodeColumnName ? encodeColumnName(name) : name;
38
- return `"${columnName}"`;
38
+ return `"${columnName.replace(/"/g, `""`)}"`;
39
39
  }
40
40
  function compileBasicExpression(exp, params, encodeColumnName) {
41
41
  switch (exp.type) {
@@ -91,9 +91,44 @@ function compileFunction(exp, params = [], encodeColumnName) {
91
91
  );
92
92
  }
93
93
  }
94
- const compiledArgs = args.map(
95
- (arg) => compileBasicExpression(arg, params, encodeColumnName)
94
+ if (name === `not`) {
95
+ if (args.length !== 1) throw new Error(`NOT expects 1 argument`);
96
+ const arg = args[0];
97
+ if (arg?.type === `func` && (arg.name === `isNull` || arg.name === `isUndefined`)) {
98
+ if (arg.args.length !== 1) {
99
+ throw new Error(`${arg.name} expects 1 argument`);
100
+ }
101
+ const innerArg = arg.args[0];
102
+ const compiled = compileBasicExpression(
103
+ innerArg,
104
+ params,
105
+ encodeColumnName
106
+ );
107
+ return `${innerArg.type === `func` ? `(${compiled})` : compiled} IS NOT NULL`;
108
+ }
109
+ }
110
+ const booleanLiteralIndex = args.findIndex(
111
+ (arg) => arg.type === `val` && typeof arg.value === `boolean`
96
112
  );
113
+ if (args.length === 2 && isBooleanComparisonOp(name) && booleanLiteralIndex !== -1) {
114
+ const literalValue = args[booleanLiteralIndex].value;
115
+ const valueArg = args[booleanLiteralIndex === 0 ? 1 : 0];
116
+ const compiled = compileBasicExpression(valueArg, params, encodeColumnName);
117
+ const value = `(${compiled})`;
118
+ const lessThan = (name === `lt` || name === `lte`) === (booleanLiteralIndex === 1);
119
+ const inclusive = name === `lte` || name === `gte`;
120
+ if (inclusive && literalValue === lessThan) {
121
+ return `${value} = ${value}`;
122
+ }
123
+ if (!inclusive && literalValue !== lessThan) {
124
+ return `${value} <> ${value}`;
125
+ }
126
+ return `${value} = ${lessThan ? `FALSE` : `TRUE`}`;
127
+ }
128
+ const compiledArgs = args.map((arg) => {
129
+ const compiled = compileBasicExpression(arg, params, encodeColumnName);
130
+ return arg.type === `func` && (arg.name === `and` || arg.name === `or` || name !== `and` && name !== `or` && name !== `not` && (isBinaryOp(arg.name) || arg.name === `not` || arg.name === `isNull` || arg.name === `isUndefined`)) ? `(${compiled})` : compiled;
131
+ });
97
132
  if (name === `isNull` || name === `isUndefined`) {
98
133
  if (compiledArgs.length !== 1) {
99
134
  throw new Error(`${name} expects 1 argument`);
@@ -101,108 +136,25 @@ function compileFunction(exp, params = [], encodeColumnName) {
101
136
  return `${compiledArgs[0]} ${opName}`;
102
137
  }
103
138
  if (name === `not`) {
104
- if (compiledArgs.length !== 1) {
105
- throw new Error(`NOT expects 1 argument`);
106
- }
107
- const arg = args[0];
108
- if (arg && arg.type === `func`) {
109
- const funcArg = arg;
110
- if (funcArg.name === `isNull` || funcArg.name === `isUndefined`) {
111
- const innerArg = compileBasicExpression(
112
- funcArg.args[0],
113
- params,
114
- encodeColumnName
115
- );
116
- return `${innerArg} IS NOT NULL`;
117
- }
118
- }
119
139
  return `${opName} (${compiledArgs[0]})`;
120
140
  }
121
141
  if (isBinaryOp(name)) {
122
142
  if ((name === `and` || name === `or`) && compiledArgs.length > 2) {
123
- return compiledArgs.map((arg) => `(${arg})`).join(` ${opName} `);
143
+ return compiledArgs.join(` ${opName} `);
124
144
  }
125
145
  if (compiledArgs.length !== 2) {
126
146
  throw new Error(`Binary operator ${name} expects 2 arguments`);
127
147
  }
128
148
  const [lhs, rhs] = compiledArgs;
129
- if (isBooleanComparisonOp(name)) {
130
- const lhsArg = args[0];
131
- const rhsArg = args[1];
132
- if (rhsArg && rhsArg.type === `val` && typeof rhsArg.value === `boolean`) {
133
- const boolValue = rhsArg.value;
134
- params.pop();
135
- if (name === `lt`) {
136
- if (boolValue === true) {
137
- params.push(false);
138
- return `${lhs} = $${params.length}`;
139
- } else {
140
- return `false`;
141
- }
142
- } else if (name === `gt`) {
143
- if (boolValue === false) {
144
- params.push(true);
145
- return `${lhs} = $${params.length}`;
146
- } else {
147
- return `false`;
148
- }
149
- } else if (name === `lte`) {
150
- if (boolValue === true) {
151
- return `true`;
152
- } else {
153
- params.push(false);
154
- return `${lhs} = $${params.length}`;
155
- }
156
- } else if (name === `gte`) {
157
- if (boolValue === false) {
158
- return `true`;
159
- } else {
160
- params.push(true);
161
- return `${lhs} = $${params.length}`;
162
- }
163
- }
149
+ if (name === `in`) {
150
+ const valueArg = args[0];
151
+ const arrayArg = args[1];
152
+ if (valueArg.type === `val` && Array.isArray(valueArg.value)) {
153
+ throw new Error(`Array-valued 'in' left operand; expected a scalar`);
164
154
  }
165
- if (lhsArg && lhsArg.type === `val` && typeof lhsArg.value === `boolean`) {
166
- const boolValue = lhsArg.value;
167
- params.pop();
168
- params.pop();
169
- const rhsCompiled = compileBasicExpression(
170
- rhsArg,
171
- params,
172
- encodeColumnName
173
- );
174
- if (name === `lt`) {
175
- if (boolValue === true) {
176
- return `false`;
177
- } else {
178
- params.push(true);
179
- return `${rhsCompiled} = $${params.length}`;
180
- }
181
- } else if (name === `gt`) {
182
- if (boolValue === true) {
183
- params.push(false);
184
- return `${rhsCompiled} = $${params.length}`;
185
- } else {
186
- return `false`;
187
- }
188
- } else if (name === `lte`) {
189
- if (boolValue === false) {
190
- return `true`;
191
- } else {
192
- params.push(true);
193
- return `${rhsCompiled} = $${params.length}`;
194
- }
195
- } else if (name === `gte`) {
196
- if (boolValue === true) {
197
- return `true`;
198
- } else {
199
- params.push(false);
200
- return `${rhsCompiled} = $${params.length}`;
201
- }
202
- }
155
+ if (arrayArg.type === `ref` && valueArg.type === `val`) {
156
+ return `${lhs} = ANY(${rhs}) AND ${rhs} @> ARRAY[${lhs}] AND ${rhs} IS NOT NULL`;
203
157
  }
204
- }
205
- if (name === `in`) {
206
158
  return `${lhs} ${opName}(${rhs})`;
207
159
  }
208
160
  return `${lhs} ${opName} ${rhs}`;
@@ -225,7 +177,7 @@ function isBinaryOp(name) {
225
177
  return binaryOps.includes(name);
226
178
  }
227
179
  function isComparisonOp(name) {
228
- const comparisonOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`];
180
+ const comparisonOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `in`];
229
181
  return comparisonOps.includes(name);
230
182
  }
231
183
  function isBooleanComparisonOp(name) {
@@ -1 +1 @@
1
- {"version":3,"file":"sql-compiler.cjs","sources":["../../src/sql-compiler.ts"],"sourcesContent":["import { serialize } from './pg-serializer'\nimport type { SubsetParams } from '@electric-sql/client'\nimport type { IR, LoadSubsetOptions } from '@tanstack/db'\n\nexport type CompiledSqlRecord = Omit<SubsetParams, `params`> & {\n params?: Array<unknown>\n}\n\n/**\n * Optional function to encode column names (e.g., camelCase to snake_case)\n * This is typically the `encode` function from a columnMapper\n */\nexport type ColumnEncoder = (columnName: string) => string\n\n/**\n * Options for SQL compilation\n */\nexport interface CompileSQLOptions {\n /**\n * Optional function to encode column names before quoting.\n * Used to transform property names (e.g., camelCase) to database column names (e.g., snake_case).\n * This should be the `encode` function from shapeOptions.columnMapper.\n */\n encodeColumnName?: ColumnEncoder\n}\n\nexport function compileSQL<T>(\n options: LoadSubsetOptions,\n compileOptions?: CompileSQLOptions,\n): SubsetParams {\n const { where, orderBy, limit } = options\n const encodeColumnName = compileOptions?.encodeColumnName\n\n const params: Array<T> = []\n const compiledSQL: CompiledSqlRecord = { params }\n\n if (where) {\n // TODO: this only works when the where expression's PropRefs directly reference a column of the collection\n // 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)\n compiledSQL.where = compileBasicExpression(where, params, encodeColumnName)\n }\n\n if (orderBy) {\n compiledSQL.orderBy = compileOrderBy(orderBy, params, encodeColumnName)\n }\n\n if (limit) {\n compiledSQL.limit = limit\n }\n\n // WORKAROUND for Electric bug: Empty subset requests don't load data\n // Add dummy \"true = true\" predicate when there's no where clause\n // This is always true so doesn't filter data, just tricks Electric into loading\n if (!where) {\n compiledSQL.where = `true = true`\n }\n\n // Serialize the values in the params array into PG formatted strings\n // and transform the array into a Record<string, string>\n const paramsRecord = params.reduce(\n (acc, param, index) => {\n const serialized = serialize(param)\n // Empty strings are valid query values (e.g., WHERE column = '')\n // Only omit null/undefined values from params\n if (param != null) {\n acc[`${index + 1}`] = serialized\n }\n return acc\n },\n {} as Record<string, string>,\n )\n\n return {\n ...compiledSQL,\n params: paramsRecord,\n }\n}\n\n/**\n * Quote PostgreSQL identifiers to handle mixed case column names correctly.\n * Electric/Postgres requires quotes for case-sensitive identifiers.\n * @param name - The identifier to quote\n * @param encodeColumnName - Optional function to encode the column name before quoting (e.g., camelCase to snake_case)\n * @returns The quoted identifier\n */\nfunction quoteIdentifier(\n name: string,\n encodeColumnName?: ColumnEncoder,\n): string {\n const columnName = encodeColumnName ? encodeColumnName(name) : name\n return `\"${columnName}\"`\n}\n\n/**\n * Compiles the expression to a SQL string and mutates the params array with the values.\n * @param exp - The expression to compile\n * @param params - The params array\n * @param encodeColumnName - Optional function to encode column names (e.g., camelCase to snake_case)\n * @returns The compiled SQL string\n */\nfunction compileBasicExpression(\n exp: IR.BasicExpression<unknown>,\n params: Array<unknown>,\n encodeColumnName?: ColumnEncoder,\n): string {\n switch (exp.type) {\n case `val`:\n params.push(exp.value)\n return `$${params.length}`\n case `ref`:\n // TODO: doesn't yet support JSON(B) values which could be accessed with nested props\n if (exp.path.length !== 1) {\n throw new Error(\n `Compiler can't handle nested properties: ${exp.path.join(`.`)}`,\n )\n }\n return quoteIdentifier(exp.path[0]!, encodeColumnName)\n case `func`:\n return compileFunction(exp, params, encodeColumnName)\n default:\n throw new Error(`Unknown expression type`)\n }\n}\n\nfunction compileOrderBy(\n orderBy: IR.OrderBy,\n params: Array<unknown>,\n encodeColumnName?: ColumnEncoder,\n): string {\n const compiledOrderByClauses = orderBy.map((clause: IR.OrderByClause) =>\n compileOrderByClause(clause, params, encodeColumnName),\n )\n return compiledOrderByClauses.join(`,`)\n}\n\nfunction compileOrderByClause(\n clause: IR.OrderByClause,\n params: Array<unknown>,\n encodeColumnName?: ColumnEncoder,\n): string {\n // FIXME: We should handle stringSort and locale.\n // Correctly supporting them is tricky as it depends on Postgres' collation\n const { expression, compareOptions } = clause\n let sql = compileBasicExpression(expression, params, encodeColumnName)\n\n if (compareOptions.direction === `desc`) {\n sql = `${sql} DESC`\n }\n\n if (compareOptions.nulls === `first`) {\n sql = `${sql} NULLS FIRST`\n }\n\n if (compareOptions.nulls === `last`) {\n sql = `${sql} NULLS LAST`\n }\n\n return sql\n}\n\n/**\n * Check if a BasicExpression represents a null/undefined value\n */\nfunction isNullValue(exp: IR.BasicExpression<unknown>): boolean {\n return exp.type === `val` && (exp.value === null || exp.value === undefined)\n}\n\nfunction compileFunction(\n exp: IR.Func<unknown>,\n params: Array<unknown> = [],\n encodeColumnName?: ColumnEncoder,\n): string {\n const { name, args } = exp\n\n const opName = getOpName(name)\n\n // Handle comparison operators with null/undefined values\n // These would create invalid queries with missing params (e.g., \"col = $1\" with empty params)\n // In SQL, all comparisons with NULL return UNKNOWN, so these are almost always mistakes\n if (isComparisonOp(name)) {\n const nullArgIndex = args.findIndex((arg: IR.BasicExpression) =>\n isNullValue(arg),\n )\n\n if (nullArgIndex !== -1) {\n // All comparison operators (including eq) throw an error for null values\n // Users should use isNull() or isUndefined() to check for null values\n throw new Error(\n `Cannot use null/undefined value with '${name}' operator. ` +\n `Comparisons with null always evaluate to UNKNOWN in SQL. ` +\n `Use isNull() or isUndefined() to check for null values, ` +\n `or filter out null values before building the query.`,\n )\n }\n }\n\n const compiledArgs = args.map((arg: IR.BasicExpression) =>\n compileBasicExpression(arg, params, encodeColumnName),\n )\n\n // Special case for IS NULL / IS NOT NULL - these are postfix operators\n if (name === `isNull` || name === `isUndefined`) {\n if (compiledArgs.length !== 1) {\n throw new Error(`${name} expects 1 argument`)\n }\n return `${compiledArgs[0]} ${opName}`\n }\n\n // Special case for NOT - unary prefix operator\n if (name === `not`) {\n if (compiledArgs.length !== 1) {\n throw new Error(`NOT expects 1 argument`)\n }\n // Check if the argument is IS NULL to generate IS NOT NULL\n const arg = args[0]\n if (arg && arg.type === `func`) {\n const funcArg = arg\n if (funcArg.name === `isNull` || funcArg.name === `isUndefined`) {\n const innerArg = compileBasicExpression(\n funcArg.args[0]!,\n params,\n encodeColumnName,\n )\n return `${innerArg} IS NOT NULL`\n }\n }\n return `${opName} (${compiledArgs[0]})`\n }\n\n if (isBinaryOp(name)) {\n // Special handling for AND/OR which can be variadic\n if ((name === `and` || name === `or`) && compiledArgs.length > 2) {\n // Chain multiple arguments: (a AND b AND c) or (a OR b OR c)\n return compiledArgs.map((arg) => `(${arg})`).join(` ${opName} `)\n }\n\n if (compiledArgs.length !== 2) {\n throw new Error(`Binary operator ${name} expects 2 arguments`)\n }\n const [lhs, rhs] = compiledArgs\n\n // Special case for comparison operators with boolean values\n // PostgreSQL doesn't support < > <= >= on booleans\n // Transform to equivalent equality checks or constant expressions\n if (isBooleanComparisonOp(name)) {\n const lhsArg = args[0]\n const rhsArg = args[1]\n\n // Check if RHS is a boolean literal value\n if (\n rhsArg &&\n rhsArg.type === `val` &&\n typeof rhsArg.value === `boolean`\n ) {\n const boolValue = rhsArg.value\n // Remove the boolean param we just added since we'll transform the expression\n params.pop()\n\n // Transform based on operator and boolean value\n // Boolean ordering: false < true\n if (name === `lt`) {\n if (boolValue === true) {\n // lt(col, true) → col = false (only false is less than true)\n params.push(false)\n return `${lhs} = $${params.length}`\n } else {\n // lt(col, false) → nothing is less than false\n return `false`\n }\n } else if (name === `gt`) {\n if (boolValue === false) {\n // gt(col, false) → col = true (only true is greater than false)\n params.push(true)\n return `${lhs} = $${params.length}`\n } else {\n // gt(col, true) → nothing is greater than true\n return `false`\n }\n } else if (name === `lte`) {\n if (boolValue === true) {\n // lte(col, true) → everything is ≤ true\n return `true`\n } else {\n // lte(col, false) → col = false\n params.push(false)\n return `${lhs} = $${params.length}`\n }\n } else if (name === `gte`) {\n if (boolValue === false) {\n // gte(col, false) → everything is ≥ false\n return `true`\n } else {\n // gte(col, true) → col = true\n params.push(true)\n return `${lhs} = $${params.length}`\n }\n }\n }\n\n // Check if LHS is a boolean literal value (less common but handle it)\n if (\n lhsArg &&\n lhsArg.type === `val` &&\n typeof lhsArg.value === `boolean`\n ) {\n const boolValue = lhsArg.value\n // Remove params for this expression and rebuild\n params.pop() // remove RHS\n params.pop() // remove LHS (boolean)\n\n // Recompile RHS to get fresh param\n const rhsCompiled = compileBasicExpression(\n rhsArg!,\n params,\n encodeColumnName,\n )\n\n // Transform: flip the comparison (val op col → col flipped_op val)\n if (name === `lt`) {\n // lt(true, col) → gt(col, true) → col > true → nothing is greater than true\n if (boolValue === true) {\n return `false`\n } else {\n // lt(false, col) → gt(col, false) → col = true\n params.push(true)\n return `${rhsCompiled} = $${params.length}`\n }\n } else if (name === `gt`) {\n // gt(true, col) → lt(col, true) → col = false\n if (boolValue === true) {\n params.push(false)\n return `${rhsCompiled} = $${params.length}`\n } else {\n // gt(false, col) → lt(col, false) → nothing is less than false\n return `false`\n }\n } else if (name === `lte`) {\n if (boolValue === false) {\n // lte(false, col) → gte(col, false) → everything\n return `true`\n } else {\n // lte(true, col) → gte(col, true) → col = true\n params.push(true)\n return `${rhsCompiled} = $${params.length}`\n }\n } else if (name === `gte`) {\n if (boolValue === true) {\n // gte(true, col) → lte(col, true) → everything\n return `true`\n } else {\n // gte(false, col) → lte(col, false) → col = false\n params.push(false)\n return `${rhsCompiled} = $${params.length}`\n }\n }\n }\n }\n\n // Special case for = ANY operator which needs parentheses around the array parameter\n if (name === `in`) {\n return `${lhs} ${opName}(${rhs})`\n }\n return `${lhs} ${opName} ${rhs}`\n }\n\n return `${opName}(${compiledArgs.join(`,`)})`\n}\n\nfunction isBinaryOp(name: string): boolean {\n const binaryOps = [\n `eq`,\n `gt`,\n `gte`,\n `lt`,\n `lte`,\n `and`,\n `or`,\n `in`,\n `like`,\n `ilike`,\n ]\n return binaryOps.includes(name)\n}\n\n/**\n * Check if operator is a comparison operator that takes two values\n * These operators cannot accept null/undefined as values\n * (null comparisons in SQL always evaluate to UNKNOWN)\n */\nfunction isComparisonOp(name: string): boolean {\n const comparisonOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`]\n return comparisonOps.includes(name)\n}\n\n/**\n * Checks if the operator is a comparison operator (excluding eq)\n * These operators don't work on booleans in PostgreSQL without casting\n */\nfunction isBooleanComparisonOp(name: string): boolean {\n return [`gt`, `gte`, `lt`, `lte`].includes(name)\n}\n\nfunction getOpName(name: string): string {\n const opNames = {\n eq: `=`,\n gt: `>`,\n gte: `>=`,\n lt: `<`,\n lte: `<=`,\n add: `+`,\n and: `AND`,\n or: `OR`,\n not: `NOT`,\n isUndefined: `IS NULL`,\n isNull: `IS NULL`,\n in: `= ANY`, // Use = ANY syntax for array parameters\n like: `LIKE`,\n ilike: `ILIKE`,\n upper: `UPPER`,\n lower: `LOWER`,\n length: `LENGTH`,\n concat: `CONCAT`,\n coalesce: `COALESCE`,\n }\n\n const opName = opNames[name as keyof typeof opNames]\n\n if (!opName) {\n throw new Error(`Unknown operator/function: ${name}`)\n }\n\n return opName\n}\n"],"names":["serialize"],"mappings":"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"}
1
+ {"version":3,"file":"sql-compiler.cjs","sources":["../../src/sql-compiler.ts"],"sourcesContent":["import { serialize } from './pg-serializer'\nimport type { SubsetParams } from '@electric-sql/client'\nimport type { IR, LoadSubsetOptions } from '@tanstack/db'\n\nexport type CompiledSqlRecord = Omit<SubsetParams, `params`> & {\n params?: Array<unknown>\n}\n\n/**\n * Optional function to encode column names (e.g., camelCase to snake_case)\n * This is typically the `encode` function from a columnMapper\n */\nexport type ColumnEncoder = (columnName: string) => string\n\n/**\n * Options for SQL compilation\n */\nexport interface CompileSQLOptions {\n /**\n * Optional function to encode column names before quoting.\n * Used to transform property names (e.g., camelCase) to database column names (e.g., snake_case).\n * This should be the `encode` function from shapeOptions.columnMapper.\n */\n encodeColumnName?: ColumnEncoder\n}\n\nexport function compileSQL<T>(\n options: LoadSubsetOptions,\n compileOptions?: CompileSQLOptions,\n): SubsetParams {\n const { where, orderBy, limit } = options\n const encodeColumnName = compileOptions?.encodeColumnName\n\n const params: Array<T> = []\n const compiledSQL: CompiledSqlRecord = { params }\n\n if (where) {\n // TODO: this only works when the where expression's PropRefs directly reference a column of the collection\n // 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)\n compiledSQL.where = compileBasicExpression(where, params, encodeColumnName)\n }\n\n if (orderBy) {\n compiledSQL.orderBy = compileOrderBy(orderBy, params, encodeColumnName)\n }\n\n if (limit) {\n compiledSQL.limit = limit\n }\n\n // WORKAROUND for Electric bug: Empty subset requests don't load data\n // Add dummy \"true = true\" predicate when there's no where clause\n // This is always true so doesn't filter data, just tricks Electric into loading\n if (!where) {\n compiledSQL.where = `true = true`\n }\n\n // Serialize the values in the params array into PG formatted strings\n // and transform the array into a Record<string, string>\n const paramsRecord = params.reduce(\n (acc, param, index) => {\n const serialized = serialize(param)\n // Empty strings are valid query values (e.g., WHERE column = '')\n // Only omit null/undefined values from params\n if (param != null) {\n acc[`${index + 1}`] = serialized\n }\n return acc\n },\n {} as Record<string, string>,\n )\n\n return {\n ...compiledSQL,\n params: paramsRecord,\n }\n}\n\n/**\n * Quote PostgreSQL identifiers to handle mixed case column names correctly.\n * Electric/Postgres requires quotes for case-sensitive identifiers.\n * @param name - The identifier to quote\n * @param encodeColumnName - Optional function to encode the column name before quoting (e.g., camelCase to snake_case)\n * @returns The quoted identifier\n */\nfunction quoteIdentifier(\n name: string,\n encodeColumnName?: ColumnEncoder,\n): string {\n const columnName = encodeColumnName ? encodeColumnName(name) : name\n return `\"${columnName.replace(/\"/g, `\"\"`)}\"`\n}\n\n/**\n * Compiles the expression to a SQL string and mutates the params array with the values.\n * @param exp - The expression to compile\n * @param params - The params array\n * @param encodeColumnName - Optional function to encode column names (e.g., camelCase to snake_case)\n * @returns The compiled SQL string\n */\nfunction compileBasicExpression(\n exp: IR.BasicExpression<unknown>,\n params: Array<unknown>,\n encodeColumnName?: ColumnEncoder,\n): string {\n switch (exp.type) {\n case `val`:\n params.push(exp.value)\n return `$${params.length}`\n case `ref`:\n if (exp.path.length !== 1) {\n throw new Error(\n `Compiler can't handle nested properties: ${exp.path.join(`.`)}`,\n )\n }\n return quoteIdentifier(exp.path[0]!, encodeColumnName)\n case `func`:\n return compileFunction(exp, params, encodeColumnName)\n default:\n throw new Error(`Unknown expression type`)\n }\n}\n\nfunction compileOrderBy(\n orderBy: IR.OrderBy,\n params: Array<unknown>,\n encodeColumnName?: ColumnEncoder,\n): string {\n const compiledOrderByClauses = orderBy.map((clause: IR.OrderByClause) =>\n compileOrderByClause(clause, params, encodeColumnName),\n )\n return compiledOrderByClauses.join(`,`)\n}\n\nfunction compileOrderByClause(\n clause: IR.OrderByClause,\n params: Array<unknown>,\n encodeColumnName?: ColumnEncoder,\n): string {\n // FIXME: We should handle stringSort and locale.\n // Correctly supporting them is tricky as it depends on Postgres' collation\n const { expression, compareOptions } = clause\n let sql = compileBasicExpression(expression, params, encodeColumnName)\n\n if (compareOptions.direction === `desc`) {\n sql = `${sql} DESC`\n }\n\n if (compareOptions.nulls === `first`) {\n sql = `${sql} NULLS FIRST`\n }\n\n if (compareOptions.nulls === `last`) {\n sql = `${sql} NULLS LAST`\n }\n\n return sql\n}\n\n/**\n * Check if a BasicExpression represents a null/undefined value\n */\nfunction isNullValue(exp: IR.BasicExpression<unknown>): boolean {\n return exp.type === `val` && (exp.value === null || exp.value === undefined)\n}\n\nfunction compileFunction(\n exp: IR.Func<unknown>,\n params: Array<unknown> = [],\n encodeColumnName?: ColumnEncoder,\n): string {\n const { name, args } = exp\n\n const opName = getOpName(name)\n\n // Handle comparison operators with null/undefined values\n // These would create invalid queries with missing params (e.g., \"col = $1\" with empty params)\n // In SQL, all comparisons with NULL return UNKNOWN, so these are almost always mistakes\n if (isComparisonOp(name)) {\n const nullArgIndex = args.findIndex((arg: IR.BasicExpression) =>\n isNullValue(arg),\n )\n\n if (nullArgIndex !== -1) {\n // All comparison operators (including eq) throw an error for null values\n // Users should use isNull() or isUndefined() to check for null values\n throw new Error(\n `Cannot use null/undefined value with '${name}' operator. ` +\n `Comparisons with null always evaluate to UNKNOWN in SQL. ` +\n `Use isNull() or isUndefined() to check for null values, ` +\n `or filter out null values before building the query.`,\n )\n }\n }\n\n if (name === `not`) {\n if (args.length !== 1) throw new Error(`NOT expects 1 argument`)\n const arg = args[0]\n if (\n arg?.type === `func` &&\n (arg.name === `isNull` || arg.name === `isUndefined`)\n ) {\n if (arg.args.length !== 1) {\n throw new Error(`${arg.name} expects 1 argument`)\n }\n const innerArg = arg.args[0]!\n const compiled = compileBasicExpression(\n innerArg,\n params,\n encodeColumnName,\n )\n return `${innerArg.type === `func` ? `(${compiled})` : compiled} IS NOT NULL`\n }\n }\n\n const booleanLiteralIndex = args.findIndex(\n (arg) => arg.type === `val` && typeof arg.value === `boolean`,\n )\n if (\n args.length === 2 &&\n isBooleanComparisonOp(name) &&\n booleanLiteralIndex !== -1\n ) {\n const literalValue = (args[booleanLiteralIndex] as IR.Value<boolean>).value\n const valueArg = args[booleanLiteralIndex === 0 ? 1 : 0]!\n const compiled = compileBasicExpression(valueArg, params, encodeColumnName)\n const value = `(${compiled})`\n const lessThan =\n (name === `lt` || name === `lte`) === (booleanLiteralIndex === 1)\n const inclusive = name === `lte` || name === `gte`\n\n if (inclusive && literalValue === lessThan) {\n return `${value} = ${value}`\n }\n if (!inclusive && literalValue !== lessThan) {\n return `${value} <> ${value}`\n }\n return `${value} = ${lessThan ? `FALSE` : `TRUE`}`\n }\n\n const compiledArgs = args.map((arg: IR.BasicExpression) => {\n const compiled = compileBasicExpression(arg, params, encodeColumnName)\n // AND/OR group their children by precedence; NOT already wraps its operand.\n // In value positions, preserve any nested operator as a single expression.\n return arg.type === `func` &&\n (arg.name === `and` ||\n arg.name === `or` ||\n (name !== `and` &&\n name !== `or` &&\n name !== `not` &&\n (isBinaryOp(arg.name) ||\n arg.name === `not` ||\n arg.name === `isNull` ||\n arg.name === `isUndefined`)))\n ? `(${compiled})`\n : compiled\n })\n\n // Special case for IS NULL / IS NOT NULL - these are postfix operators\n if (name === `isNull` || name === `isUndefined`) {\n if (compiledArgs.length !== 1) {\n throw new Error(`${name} expects 1 argument`)\n }\n return `${compiledArgs[0]} ${opName}`\n }\n\n // Special case for NOT - unary prefix operator\n if (name === `not`) {\n return `${opName} (${compiledArgs[0]})`\n }\n\n if (isBinaryOp(name)) {\n // Special handling for AND/OR which can be variadic\n if ((name === `and` || name === `or`) && compiledArgs.length > 2) {\n // Chain multiple arguments: (a AND b AND c) or (a OR b OR c)\n return compiledArgs.join(` ${opName} `)\n }\n\n if (compiledArgs.length !== 2) {\n throw new Error(`Binary operator ${name} expects 2 arguments`)\n }\n const [lhs, rhs] = compiledArgs\n\n if (name === `in`) {\n const valueArg = args[0]!\n const arrayArg = args[1]!\n\n if (valueArg.type === `val` && Array.isArray(valueArg.value)) {\n throw new Error(`Array-valued 'in' left operand; expected a scalar`)\n }\n\n if (arrayArg.type === `ref` && valueArg.type === `val`) {\n // Resolve literal parameters from the array element type before containment.\n return `${lhs} = ANY(${rhs}) AND ${rhs} @> ARRAY[${lhs}] AND ${rhs} IS NOT NULL`\n }\n\n // Literal value lists and ref/ref membership retain the original = ANY form.\n return `${lhs} ${opName}(${rhs})`\n }\n return `${lhs} ${opName} ${rhs}`\n }\n\n return `${opName}(${compiledArgs.join(`,`)})`\n}\n\nfunction isBinaryOp(name: string): boolean {\n const binaryOps = [\n `eq`,\n `gt`,\n `gte`,\n `lt`,\n `lte`,\n `and`,\n `or`,\n `in`,\n `like`,\n `ilike`,\n ]\n return binaryOps.includes(name)\n}\n\n/**\n * Check if operator is a comparison operator that takes two values\n * These operators cannot accept null/undefined as values\n * (null comparisons in SQL always evaluate to UNKNOWN)\n */\nfunction isComparisonOp(name: string): boolean {\n const comparisonOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `in`]\n return comparisonOps.includes(name)\n}\n\n/**\n * Checks if the operator is a comparison operator (excluding eq)\n * These operators don't work on booleans in PostgreSQL without casting\n */\nfunction isBooleanComparisonOp(name: string): boolean {\n return [`gt`, `gte`, `lt`, `lte`].includes(name)\n}\n\nfunction getOpName(name: string): string {\n const opNames = {\n eq: `=`,\n gt: `>`,\n gte: `>=`,\n lt: `<`,\n lte: `<=`,\n add: `+`,\n and: `AND`,\n or: `OR`,\n not: `NOT`,\n isUndefined: `IS NULL`,\n isNull: `IS NULL`,\n in: `= ANY`, // Use = ANY syntax for array parameters\n like: `LIKE`,\n ilike: `ILIKE`,\n upper: `UPPER`,\n lower: `LOWER`,\n length: `LENGTH`,\n concat: `CONCAT`,\n coalesce: `COALESCE`,\n }\n\n const opName = opNames[name as keyof typeof opNames]\n\n if (!opName) {\n throw new Error(`Unknown operator/function: ${name}`)\n }\n\n return opName\n}\n"],"names":["serialize"],"mappings":"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"}