@electric-sql/client
Version:
Postgres everywhere - your data, in sync, wherever you need it.
1 lines • 242 kB
Source Map (JSON)
{"version":3,"sources":["../src/error.ts","../src/parser.ts","../src/column-mapper.ts","../src/helpers.ts","../src/constants.ts","../src/fetch.ts","../src/expression-compiler.ts","../../../node_modules/.pnpm/@microsoft+fetch-event-source@2.0.1_patch_hash=46f4e76dd960e002a542732bb4323817a24fce1673cb71e2f458fe09776fa188/node_modules/@microsoft/fetch-event-source/src/parse.ts","../../../node_modules/.pnpm/@microsoft+fetch-event-source@2.0.1_patch_hash=46f4e76dd960e002a542732bb4323817a24fce1673cb71e2f458fe09776fa188/node_modules/@microsoft/fetch-event-source/src/fetch.ts","../src/expired-shapes-cache.ts","../src/up-to-date-tracker.ts","../src/snapshot-tracker.ts","../src/shape-stream-state.ts","../src/pause-lock.ts","../src/client.ts","../src/shape.ts"],"sourcesContent":["export class FetchError extends Error {\n status: number\n text?: string\n json?: object\n headers: Record<string, string>\n\n constructor(\n status: number,\n text: string | undefined,\n json: object | undefined,\n headers: Record<string, string>,\n public url: string,\n message?: string\n ) {\n super(\n message ||\n `HTTP Error ${status} at ${url}: ${text ?? JSON.stringify(json)}`\n )\n this.name = `FetchError`\n this.status = status\n this.text = text\n this.json = json\n this.headers = headers\n }\n\n static async fromResponse(\n response: Response,\n url: string\n ): Promise<FetchError> {\n const status = response.status\n const headers = Object.fromEntries([...response.headers.entries()])\n let text: string | undefined = undefined\n let json: object | undefined = undefined\n\n const contentType = response.headers.get(`content-type`)\n if (!response.bodyUsed) {\n if (contentType && contentType.includes(`application/json`)) {\n json = (await response.json()) as object\n } else {\n text = await response.text()\n }\n }\n\n return new FetchError(status, text, json, headers, url)\n }\n}\n\nexport class FetchBackoffAbortError extends Error {\n constructor() {\n super(`Fetch with backoff aborted`)\n this.name = `FetchBackoffAbortError`\n }\n}\n\nexport class InvalidShapeOptionsError extends Error {\n constructor(message: string) {\n super(message)\n this.name = `InvalidShapeOptionsError`\n }\n}\n\nexport class MissingShapeUrlError extends Error {\n constructor() {\n super(`Invalid shape options: missing required url parameter`)\n this.name = `MissingShapeUrlError`\n }\n}\n\nexport class InvalidSignalError extends Error {\n constructor() {\n super(`Invalid signal option. It must be an instance of AbortSignal.`)\n this.name = `InvalidSignalError`\n }\n}\n\nexport class MissingShapeHandleError extends Error {\n constructor() {\n super(\n `shapeHandle is required if this isn't an initial fetch (i.e. offset > -1)`\n )\n this.name = `MissingShapeHandleError`\n }\n}\n\nexport class ReservedParamError extends Error {\n constructor(reservedParams: string[]) {\n super(\n `Cannot use reserved Electric parameter names in custom params: ${reservedParams.join(`, `)}`\n )\n this.name = `ReservedParamError`\n }\n}\n\nexport class ParserNullValueError extends Error {\n constructor(columnName: string) {\n super(`Column \"${columnName ?? `unknown`}\" does not allow NULL values`)\n this.name = `ParserNullValueError`\n }\n}\n\nexport class ShapeStreamAlreadyRunningError extends Error {\n constructor() {\n super(`ShapeStream is already running`)\n this.name = `ShapeStreamAlreadyRunningError`\n }\n}\n\nexport class MissingHeadersError extends Error {\n constructor(url: string, missingHeaders: Array<string>) {\n let msg = `The response for the shape request to ${url} didn't include the following required headers:\\n`\n missingHeaders.forEach((h) => {\n msg += `- ${h}\\n`\n })\n msg += `\\nThis is often due to a proxy not setting CORS correctly so that all Electric headers can be read by the client.`\n msg += `\\nFor more information visit the troubleshooting guide: /docs/guides/troubleshooting/missing-headers`\n super(msg)\n }\n}\n\nexport class StaleCacheError extends Error {\n constructor(message: string) {\n super(message)\n this.name = `StaleCacheError`\n }\n}\n","import { ColumnInfo, GetExtensions, Row, Schema, Value } from './types'\nimport { ParserNullValueError } from './error'\n\ntype Token = string\ntype NullableToken = Token | null\nexport type ParseFunction<Extensions = never> = (\n value: Token,\n additionalInfo?: Omit<ColumnInfo, `type` | `dims`>\n) => Value<Extensions>\ntype NullableParseFunction<Extensions = never> = (\n value: NullableToken,\n additionalInfo?: Omit<ColumnInfo, `type` | `dims`>\n) => Value<Extensions>\n/**\n * @typeParam Extensions - Additional types that can be parsed by this parser beyond the standard SQL types.\n * Defaults to no additional types.\n */\nexport type Parser<Extensions = never> = {\n [key: string]: ParseFunction<Extensions>\n}\n\nexport type TransformFunction<Extensions = never> = (\n message: Row<Extensions>\n) => Row<Extensions>\n\nconst parseNumber = (value: string) => Number(value)\nconst parseBool = (value: string) => value === `true` || value === `t`\nconst parseBigInt = (value: string) => BigInt(value)\nconst parseJson = (value: string) => JSON.parse(value)\nconst identityParser: ParseFunction = (v: string) => v\n\nexport const defaultParser: Parser = {\n int2: parseNumber,\n int4: parseNumber,\n int8: parseBigInt,\n bool: parseBool,\n float4: parseNumber,\n float8: parseNumber,\n json: parseJson,\n jsonb: parseJson,\n}\n\n// Taken from: https://github.com/electric-sql/pglite/blob/main/packages/pglite/src/types.ts#L233-L279\nexport function pgArrayParser<Extensions>(\n value: Token,\n parser?: NullableParseFunction<Extensions>\n): Value<Extensions> {\n let i = 0\n let char = null\n let str = ``\n let quoted = false\n let last = 0\n let p: string | undefined = undefined\n\n function extractValue(x: Token, start: number, end: number) {\n let val: Token | null = x.slice(start, end)\n val = val === `NULL` ? null : val\n return parser ? parser(val) : val\n }\n\n function loop(x: string): Array<Value<Extensions>> {\n const xs = []\n for (; i < x.length; i++) {\n char = x[i]\n if (quoted) {\n if (char === `\\\\`) {\n str += x[++i]\n } else if (char === `\"`) {\n xs.push(parser ? parser(str) : str)\n str = ``\n quoted = x[i + 1] === `\"`\n last = i + 2\n } else {\n str += char\n }\n } else if (char === `\"`) {\n quoted = true\n } else if (char === `{`) {\n last = ++i\n xs.push(loop(x))\n } else if (char === `}`) {\n quoted = false\n last < i && xs.push(extractValue(x, last, i))\n last = i + 1\n break\n } else if (char === `,` && p !== `}` && p !== `\"`) {\n xs.push(extractValue(x, last, i))\n last = i + 1\n }\n p = char\n }\n last < i && xs.push(xs.push(extractValue(x, last, i + 1)))\n return xs\n }\n\n return loop(value)[0]\n}\n\nexport class MessageParser<T extends Row<unknown>> {\n private parser: Parser<GetExtensions<T>>\n private transformer?: TransformFunction<GetExtensions<T>>\n constructor(\n parser?: Parser<GetExtensions<T>>,\n transformer?: TransformFunction<GetExtensions<T>>\n ) {\n // Merge the provided parser with the default parser\n // to use the provided parser whenever defined\n // and otherwise fall back to the default parser\n this.parser = { ...defaultParser, ...parser }\n this.transformer = transformer\n }\n\n parse<Result>(messages: string, schema: Schema): Result {\n return JSON.parse(messages, (key, value) => {\n // typeof value === `object` && value !== null\n // is needed because there could be a column named `value`\n // and the value associated to that column will be a string or null.\n // But `typeof null === 'object'` so we need to make an explicit check.\n // We also parse the `old_value`, which appears on updates when `replica=full`.\n if (\n (key === `value` || key === `old_value`) &&\n typeof value === `object` &&\n value !== null\n ) {\n return this.transformMessageValue(value, schema)\n }\n return value\n }) as Result\n }\n\n /**\n * Parse an array of ChangeMessages from a snapshot response.\n * Applies type parsing and transformations to the value and old_value properties.\n */\n parseSnapshotData<Result>(\n messages: Array<unknown>,\n schema: Schema\n ): Array<Result> {\n return messages.map((message) => {\n const msg = message as Record<string, unknown>\n\n // Transform the value property if it exists\n if (msg.value && typeof msg.value === `object` && msg.value !== null) {\n msg.value = this.transformMessageValue(msg.value, schema)\n }\n\n // Transform the old_value property if it exists\n if (\n msg.old_value &&\n typeof msg.old_value === `object` &&\n msg.old_value !== null\n ) {\n msg.old_value = this.transformMessageValue(msg.old_value, schema)\n }\n\n return msg as Result\n })\n }\n\n /**\n * Transform a message value or old_value object by parsing its columns.\n */\n private transformMessageValue(\n value: unknown,\n schema: Schema\n ): Row<GetExtensions<T>> {\n const row = value as Record<string, Value<GetExtensions<T>>>\n Object.keys(row).forEach((key) => {\n row[key] = this.parseRow(key, row[key] as NullableToken, schema)\n })\n\n return this.transformer ? this.transformer(row) : row\n }\n\n // Parses the message values using the provided parser based on the schema information\n private parseRow(\n key: string,\n value: NullableToken,\n schema: Schema\n ): Value<GetExtensions<T>> {\n const columnInfo = schema[key]\n if (!columnInfo) {\n // We don't have information about the value\n // so we just return it\n return value\n }\n\n // Copy the object but don't include `dimensions` and `type`\n const { type: typ, dims: dimensions, ...additionalInfo } = columnInfo\n\n // Pick the right parser for the type\n // and support parsing null values if needed\n // if no parser is provided for the given type, just return the value as is\n const typeParser = this.parser[typ] ?? identityParser\n const parser = makeNullableParser(typeParser, columnInfo, key)\n\n if (dimensions && dimensions > 0) {\n // It's an array\n const nullablePgArrayParser = makeNullableParser(\n (value, _) => pgArrayParser(value, parser),\n columnInfo,\n key\n )\n return nullablePgArrayParser(value)\n }\n\n return parser(value, additionalInfo)\n }\n}\n\nfunction makeNullableParser<Extensions>(\n parser: ParseFunction<Extensions>,\n columnInfo: ColumnInfo,\n columnName?: string\n): NullableParseFunction<Extensions> {\n const isNullable = !(columnInfo.not_null ?? false)\n // The sync service contains `null` value for a column whose value is NULL\n // but if the column value is an array that contains a NULL value\n // then it will be included in the array string as `NULL`, e.g.: `\"{1,NULL,3}\"`\n return (value: NullableToken) => {\n if (value === null) {\n if (!isNullable) {\n throw new ParserNullValueError(columnName ?? `unknown`)\n }\n return null\n }\n return parser(value, columnInfo)\n }\n}\n","import { Schema } from './types'\n\ntype DbColumnName = string\ntype AppColumnName = string\n\n/**\n * Quote a PostgreSQL identifier for safe use in query parameters.\n *\n * Wraps the identifier in double quotes and escapes any internal\n * double quotes by doubling them. This ensures identifiers with\n * special characters (commas, spaces, etc.) are handled correctly.\n *\n * @param identifier - The identifier to quote\n * @returns The quoted identifier\n *\n * @example\n * ```typescript\n * quoteIdentifier('user_id') // '\"user_id\"'\n * quoteIdentifier('foo,bar') // '\"foo,bar\"'\n * quoteIdentifier('has\"quote') // '\"has\"\"quote\"'\n * ```\n *\n * @internal\n */\nexport function quoteIdentifier(identifier: string): string {\n // Escape internal double quotes by doubling them\n const escaped = identifier.replace(/\"/g, `\"\"`)\n return `\"${escaped}\"`\n}\n\n/**\n * A bidirectional column mapper that handles transforming column **names**\n * between database format (e.g., snake_case) and application format (e.g., camelCase).\n *\n * **Important**: ColumnMapper only transforms column names, not column values or types.\n * For type conversions (e.g., string → Date), use the `parser` option.\n * For value transformations (e.g., encryption), use the `transformer` option.\n *\n * @example\n * ```typescript\n * const mapper = snakeCamelMapper()\n * mapper.decode('user_id') // 'userId'\n * mapper.encode('userId') // 'user_id'\n * ```\n */\nexport interface ColumnMapper {\n /**\n * Transform a column name from database format to application format.\n * Applied to column names in query results.\n */\n decode: (dbColumnName: DbColumnName) => AppColumnName\n\n /**\n * Transform a column name from application format to database format.\n * Applied to column names in WHERE clauses and other query parameters.\n */\n encode: (appColumnName: AppColumnName) => DbColumnName\n}\n\n/**\n * Converts a snake_case string to camelCase.\n *\n * Handles edge cases:\n * - Preserves leading underscores: `_user_id` → `_userId`\n * - Preserves trailing underscores: `user_id_` → `userId_`\n * - Collapses multiple underscores: `user__id` → `userId`\n * - Normalizes to lowercase first: `user_Column` → `userColumn`\n *\n * @example\n * snakeToCamel('user_id') // 'userId'\n * snakeToCamel('project_id') // 'projectId'\n * snakeToCamel('created_at') // 'createdAt'\n * snakeToCamel('_private') // '_private'\n * snakeToCamel('user__id') // 'userId'\n * snakeToCamel('user_id_') // 'userId_'\n */\nexport function snakeToCamel(str: string): string {\n // Preserve leading underscores\n const leadingUnderscores = str.match(/^_+/)?.[0] ?? ``\n const withoutLeading = str.slice(leadingUnderscores.length)\n\n // Preserve trailing underscores for round-trip safety\n const trailingUnderscores = withoutLeading.match(/_+$/)?.[0] ?? ``\n const core = trailingUnderscores\n ? withoutLeading.slice(\n 0,\n withoutLeading.length - trailingUnderscores.length\n )\n : withoutLeading\n\n // Convert to lowercase\n const normalized = core.toLowerCase()\n\n // Convert snake_case to camelCase (handling multiple underscores)\n const camelCased = normalized.replace(/_+([a-z])/g, (_, letter) =>\n letter.toUpperCase()\n )\n\n return leadingUnderscores + camelCased + trailingUnderscores\n}\n\n/**\n * Converts a camelCase string to snake_case.\n *\n * Handles consecutive capitals (acronyms) properly:\n * - `userID` → `user_id`\n * - `userHTTPSURL` → `user_https_url`\n *\n * @example\n * camelToSnake('userId') // 'user_id'\n * camelToSnake('projectId') // 'project_id'\n * camelToSnake('createdAt') // 'created_at'\n * camelToSnake('userID') // 'user_id'\n * camelToSnake('parseHTMLString') // 'parse_html_string'\n */\nexport function camelToSnake(str: string): string {\n return (\n str\n // Insert underscore before uppercase letters that follow lowercase letters\n // e.g., userId -> user_Id\n .replace(/([a-z])([A-Z])/g, `$1_$2`)\n // Insert underscore before uppercase letters that are followed by lowercase letters\n // This handles acronyms: userID -> user_ID, but parseHTMLString -> parse_HTML_String\n .replace(/([A-Z]+)([A-Z][a-z])/g, `$1_$2`)\n .toLowerCase()\n )\n}\n\n/**\n * Creates a column mapper from an explicit mapping of database columns to application columns.\n *\n * @param mapping - Object mapping database column names (keys) to application column names (values)\n * @returns A ColumnMapper that can encode and decode column names bidirectionally\n *\n * @example\n * const mapper = createColumnMapper({\n * user_id: 'userId',\n * project_id: 'projectId',\n * created_at: 'createdAt'\n * })\n *\n * // Use with ShapeStream\n * const stream = new ShapeStream({\n * url: 'http://localhost:3000/v1/shape',\n * params: { table: 'todos' },\n * columnMapper: mapper\n * })\n */\nexport function createColumnMapper(\n mapping: Record<string, string>\n): ColumnMapper {\n // Build reverse mapping: app name -> db name\n const reverseMapping: Record<string, string> = {}\n for (const [dbName, appName] of Object.entries(mapping)) {\n reverseMapping[appName] = dbName\n }\n\n return {\n decode: (dbColumnName: string) => {\n return mapping[dbColumnName] ?? dbColumnName\n },\n\n encode: (appColumnName: string) => {\n return reverseMapping[appColumnName] ?? appColumnName\n },\n }\n}\n\n/**\n * Encodes column names in a WHERE clause using the provided encoder function.\n * Uses regex to identify column references and replace them.\n *\n * Handles common SQL patterns:\n * - Simple comparisons: columnName = $1\n * - Function calls: LOWER(columnName)\n * - Qualified names: table.columnName\n * - Operators: columnName IS NULL, columnName IN (...)\n * - Quoted strings: Preserves string literals unchanged\n *\n * Note: This uses regex-based replacement which works for most common cases\n * but may not handle all complex SQL expressions perfectly. For complex queries,\n * test thoroughly or use database column names directly in WHERE clauses.\n *\n * @param whereClause - The WHERE clause string to encode\n * @param encode - Optional encoder function. If undefined, returns whereClause unchanged.\n * @returns The encoded WHERE clause\n *\n * @internal\n */\nexport function encodeWhereClause(\n whereClause: string | undefined,\n encode?: (columnName: string) => string\n): string {\n if (!whereClause || !encode) return whereClause ?? ``\n\n // SQL keywords that should not be transformed (common ones)\n const sqlKeywords = new Set([\n `SELECT`,\n `FROM`,\n `WHERE`,\n `AND`,\n `OR`,\n `NOT`,\n `IN`,\n `IS`,\n `NULL`,\n `NULLS`,\n `FIRST`,\n `LAST`,\n `TRUE`,\n `FALSE`,\n `LIKE`,\n `ILIKE`,\n `BETWEEN`,\n `ASC`,\n `DESC`,\n `LIMIT`,\n `OFFSET`,\n `ORDER`,\n `BY`,\n `GROUP`,\n `HAVING`,\n `DISTINCT`,\n `AS`,\n `ON`,\n `JOIN`,\n `LEFT`,\n `RIGHT`,\n `INNER`,\n `OUTER`,\n `CROSS`,\n `CASE`,\n `WHEN`,\n `THEN`,\n `ELSE`,\n `END`,\n `CAST`,\n `LOWER`,\n `UPPER`,\n `COALESCE`,\n `NULLIF`,\n ])\n\n // Track positions of quoted strings and double-quoted identifiers to skip them\n const quotedRanges: Array<{ start: number; end: number }> = []\n\n // Find all single-quoted strings and double-quoted identifiers\n let pos = 0\n while (pos < whereClause.length) {\n const ch = whereClause[pos]\n if (ch === `'` || ch === `\"`) {\n const start = pos\n const quoteChar = ch\n pos++ // Skip opening quote\n // Find closing quote, handling escaped quotes ('' or \"\")\n while (pos < whereClause.length) {\n if (whereClause[pos] === quoteChar) {\n if (whereClause[pos + 1] === quoteChar) {\n pos += 2 // Skip escaped quote\n } else {\n pos++ // Skip closing quote\n break\n }\n } else {\n pos++\n }\n }\n quotedRanges.push({ start, end: pos })\n } else {\n pos++\n }\n }\n\n // Helper to check if position is within a quoted string or double-quoted identifier\n const isInQuotedString = (pos: number): boolean => {\n return quotedRanges.some((range) => pos >= range.start && pos < range.end)\n }\n\n // Pattern explanation:\n // (?<![a-zA-Z0-9_]) - negative lookbehind: not preceded by identifier char\n // ([a-zA-Z_][a-zA-Z0-9_]*) - capture: valid SQL identifier\n // (?![a-zA-Z0-9_]) - negative lookahead: not followed by identifier char\n //\n // This avoids matching:\n // - Parts of longer identifiers\n // - SQL keywords (handled by checking if result differs from input)\n const identifierPattern =\n /(?<![a-zA-Z0-9_])([a-zA-Z_][a-zA-Z0-9_]*)(?![a-zA-Z0-9_])/g\n\n return whereClause.replace(identifierPattern, (match, _p1, offset) => {\n // Don't transform if inside quoted string\n if (isInQuotedString(offset)) {\n return match\n }\n\n // Don't transform SQL keywords\n if (sqlKeywords.has(match.toUpperCase())) {\n return match\n }\n\n // Don't transform parameter placeholders ($1, $2, etc.)\n // This regex won't match them anyway, but being explicit\n if (match.startsWith(`$`)) {\n return match\n }\n\n // Apply encoding\n const encoded = encode(match)\n return encoded\n })\n}\n\n/**\n * Creates a column mapper that automatically converts between snake_case and camelCase.\n * This is the most common use case for column mapping.\n *\n * When a schema is provided, it will only map columns that exist in the schema.\n * Otherwise, it will map any column name it encounters.\n *\n * **⚠️ Limitations and Edge Cases:**\n * - **WHERE clause encoding**: Uses regex-based parsing which may not handle all complex\n * SQL expressions. Test thoroughly with your queries, especially those with:\n * - Complex nested expressions\n * - Custom operators or functions\n * - Column names that conflict with SQL keywords\n * - Quoted identifiers (e.g., `\"$price\"`, `\"user-id\"`) - not supported\n * - Column names with special characters (non-alphanumeric except underscore)\n * - **Acronym ambiguity**: `userID` → `user_id` → `userId` (ID becomes Id after roundtrip)\n * Use `createColumnMapper()` with explicit mapping if you need exact control\n * - **Type conversion**: This only renames columns, not values. Use `parser` for type conversion\n *\n * **When to use explicit mapping instead:**\n * - You have column names that don't follow snake_case/camelCase patterns\n * - You need exact control over mappings (e.g., `id` → `identifier`)\n * - Your WHERE clauses are complex and automatic encoding fails\n * - You have quoted identifiers or column names with special characters\n *\n * @param schema - Optional database schema to constrain mapping to known columns\n * @returns A ColumnMapper for snake_case ↔ camelCase conversion\n *\n * @example\n * // Basic usage\n * const mapper = snakeCamelMapper()\n *\n * // With schema - only maps columns in schema (recommended)\n * const mapper = snakeCamelMapper(schema)\n *\n * // Use with ShapeStream\n * const stream = new ShapeStream({\n * url: 'http://localhost:3000/v1/shape',\n * params: { table: 'todos' },\n * columnMapper: snakeCamelMapper()\n * })\n *\n * @example\n * // If automatic encoding fails, fall back to manual column names in WHERE clauses:\n * stream.requestSnapshot({\n * where: \"user_id = $1\", // Use database column names directly if needed\n * params: { \"1\": \"123\" }\n * })\n */\nexport function snakeCamelMapper(schema?: Schema): ColumnMapper {\n // If schema provided, build explicit mapping\n if (schema) {\n const mapping: Record<string, string> = {}\n for (const dbColumn of Object.keys(schema)) {\n mapping[dbColumn] = snakeToCamel(dbColumn)\n }\n return createColumnMapper(mapping)\n }\n\n // Otherwise, map dynamically\n return {\n decode: (dbColumnName: string) => {\n return snakeToCamel(dbColumnName)\n },\n\n encode: (appColumnName: string) => {\n return camelToSnake(appColumnName)\n },\n }\n}\n","import {\n ChangeMessage,\n ControlMessage,\n Message,\n NormalizedPgSnapshot,\n Offset,\n PostgresSnapshot,\n Row,\n} from './types'\n\n/**\n * Type guard for checking {@link Message} is {@link ChangeMessage}.\n *\n * See [TS docs](https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards)\n * for information on how to use type guards.\n *\n * @param message - the message to check\n * @returns true if the message is a {@link ChangeMessage}\n *\n * @example\n * ```ts\n * if (isChangeMessage(message)) {\n * const msgChng: ChangeMessage = message // Ok\n * const msgCtrl: ControlMessage = message // Err, type mismatch\n * }\n * ```\n */\nexport function isChangeMessage<T extends Row<unknown> = Row>(\n message: Message<T>\n): message is ChangeMessage<T> {\n return message != null && `key` in message\n}\n\n/**\n * Type guard for checking {@link Message} is {@link ControlMessage}.\n *\n * See [TS docs](https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards)\n * for information on how to use type guards.\n *\n * @param message - the message to check\n * @returns true if the message is a {@link ControlMessage}\n *\n * * @example\n * ```ts\n * if (isControlMessage(message)) {\n * const msgChng: ChangeMessage = message // Err, type mismatch\n * const msgCtrl: ControlMessage = message // Ok\n * }\n * ```\n */\nexport function isControlMessage<T extends Row<unknown> = Row>(\n message: Message<T>\n): message is ControlMessage {\n return message != null && `headers` in message && `control` in message.headers\n}\n\nexport function isUpToDateMessage<T extends Row<unknown> = Row>(\n message: Message<T>\n): message is ControlMessage & { up_to_date: true } {\n return isControlMessage(message) && message.headers.control === `up-to-date`\n}\n\n/**\n * Parses the LSN from the up-to-date message and turns it into an offset.\n * The LSN is only present in the up-to-date control message when in SSE mode.\n * If we are not in SSE mode this function will return undefined.\n */\nexport function getOffset(message: ControlMessage): Offset | undefined {\n if (message.headers.control != `up-to-date`) return\n const lsn = message.headers.global_last_seen_lsn\n return lsn ? (`${lsn}_0` as Offset) : undefined\n}\n\nfunction bigintReplacer(_key: string, value: unknown): unknown {\n return typeof value === `bigint` ? value.toString() : value\n}\n\n/**\n * BigInt-safe version of JSON.stringify.\n * Converts BigInt values to their string representation (as JSON strings,\n * e.g. `{ id: 42n }` becomes `{\"id\":\"42\"}`) instead of throwing.\n * Assumes input is a JSON-serializable value — passing `undefined` at the\n * top level will return `undefined` (matching `JSON.stringify` behavior).\n */\nexport function bigintSafeStringify(value: unknown): string {\n return JSON.stringify(value, bigintReplacer)\n}\n\n/**\n * Checks if a transaction is visible in a snapshot.\n *\n * @param txid - the transaction id to check\n * @param snapshot - the information about the snapshot\n * @returns true if the transaction is visible in the snapshot\n */\nexport function isVisibleInSnapshot(\n txid: number | bigint | `${bigint}`,\n snapshot: PostgresSnapshot | NormalizedPgSnapshot\n): boolean {\n const xid = BigInt(txid)\n const xmin = BigInt(snapshot.xmin)\n const xmax = BigInt(snapshot.xmax)\n const xip = snapshot.xip_list.map(BigInt)\n\n // If the transaction id is less than the minimum transaction id, it is visible in the snapshot.\n // If the transaction id is less than the maximum transaction id and not in the list of active\n // transactions at the time of the snapshot, it has been committed before the snapshot was taken\n // and is therefore visible in the snapshot.\n // Otherwise, it is not visible in the snapshot.\n\n return xid < xmin || (xid < xmax && !xip.includes(xid))\n}\n","export const LIVE_CACHE_BUSTER_HEADER = `electric-cursor`\nexport const SHAPE_HANDLE_HEADER = `electric-handle`\nexport const CHUNK_LAST_OFFSET_HEADER = `electric-offset`\nexport const SHAPE_SCHEMA_HEADER = `electric-schema`\nexport const CHUNK_UP_TO_DATE_HEADER = `electric-up-to-date`\nexport const COLUMNS_QUERY_PARAM = `columns`\nexport const LIVE_CACHE_BUSTER_QUERY_PARAM = `cursor`\nexport const EXPIRED_HANDLE_QUERY_PARAM = `expired_handle`\nexport const SHAPE_HANDLE_QUERY_PARAM = `handle`\nexport const LIVE_QUERY_PARAM = `live`\nexport const OFFSET_QUERY_PARAM = `offset`\nexport const TABLE_QUERY_PARAM = `table`\nexport const WHERE_QUERY_PARAM = `where`\nexport const REPLICA_PARAM = `replica`\nexport const WHERE_PARAMS_PARAM = `params`\n/**\n * @deprecated Use {@link LIVE_SSE_QUERY_PARAM} instead.\n */\nexport const EXPERIMENTAL_LIVE_SSE_QUERY_PARAM = `experimental_live_sse`\nexport const LIVE_SSE_QUERY_PARAM = `live_sse`\nexport const FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh`\nexport const PAUSE_STREAM = `pause-stream`\nexport const SYSTEM_WAKE = `system-wake`\nexport const LOG_MODE_QUERY_PARAM = `log`\nexport const SUBSET_PARAM_WHERE = `subset__where`\nexport const SUBSET_PARAM_LIMIT = `subset__limit`\nexport const SUBSET_PARAM_OFFSET = `subset__offset`\nexport const SUBSET_PARAM_ORDER_BY = `subset__order_by`\nexport const SUBSET_PARAM_WHERE_PARAMS = `subset__params`\nexport const SUBSET_PARAM_WHERE_EXPR = `subset__where_expr`\nexport const SUBSET_PARAM_ORDER_BY_EXPR = `subset__order_by_expr`\nexport const CACHE_BUSTER_QUERY_PARAM = `cache-buster` // Random cache buster to bypass stale CDN responses\n\n// Query parameters that should be passed through when proxying Electric requests\nexport const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [\n LIVE_QUERY_PARAM,\n LIVE_SSE_QUERY_PARAM,\n SHAPE_HANDLE_QUERY_PARAM,\n OFFSET_QUERY_PARAM,\n LIVE_CACHE_BUSTER_QUERY_PARAM,\n EXPIRED_HANDLE_QUERY_PARAM,\n LOG_MODE_QUERY_PARAM,\n SUBSET_PARAM_WHERE,\n SUBSET_PARAM_LIMIT,\n SUBSET_PARAM_OFFSET,\n SUBSET_PARAM_ORDER_BY,\n SUBSET_PARAM_WHERE_PARAMS,\n SUBSET_PARAM_WHERE_EXPR,\n SUBSET_PARAM_ORDER_BY_EXPR,\n CACHE_BUSTER_QUERY_PARAM,\n]\n","import {\n CHUNK_LAST_OFFSET_HEADER,\n CHUNK_UP_TO_DATE_HEADER,\n EXPIRED_HANDLE_QUERY_PARAM,\n LIVE_CACHE_BUSTER_HEADER,\n LIVE_QUERY_PARAM,\n OFFSET_QUERY_PARAM,\n SHAPE_SCHEMA_HEADER,\n SHAPE_HANDLE_HEADER,\n SHAPE_HANDLE_QUERY_PARAM,\n SUBSET_PARAM_LIMIT,\n SUBSET_PARAM_OFFSET,\n SUBSET_PARAM_ORDER_BY,\n SUBSET_PARAM_WHERE,\n SUBSET_PARAM_WHERE_PARAMS,\n} from './constants'\nimport {\n FetchError,\n FetchBackoffAbortError,\n MissingHeadersError,\n} from './error'\n\n// Some specific 4xx and 5xx HTTP status codes that we definitely\n// want to retry\nconst HTTP_RETRY_STATUS_CODES = [429]\n\nexport interface BackoffOptions {\n /**\n * Initial delay before retrying in milliseconds\n */\n initialDelay: number\n /**\n * Maximum retry delay in milliseconds\n * After reaching this, delay stays constant (e.g., retry every 60s)\n */\n maxDelay: number\n multiplier: number\n onFailedAttempt?: () => void\n debug?: boolean\n /**\n * Maximum number of retry attempts before giving up.\n * Set to Infinity (default) for indefinite retries - needed for offline scenarios\n * where clients may go offline and come back later.\n */\n maxRetries?: number\n}\n\nexport const BackoffDefaults = {\n initialDelay: 1_000,\n maxDelay: 32_000,\n multiplier: 2,\n maxRetries: Infinity, // Retry forever - clients may go offline and come back\n}\n\n/**\n * Parse Retry-After header value and return delay in milliseconds\n * Supports both delta-seconds format and HTTP-date format\n * Returns 0 if header is not present or invalid\n */\nexport function parseRetryAfterHeader(retryAfter: string | undefined): number {\n if (!retryAfter) return 0\n\n // Try parsing as seconds (delta-seconds format)\n const retryAfterSec = Number(retryAfter)\n if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {\n return retryAfterSec * 1000\n }\n\n // Try parsing as HTTP-date\n const retryDate = Date.parse(retryAfter)\n if (!isNaN(retryDate)) {\n // Handle clock skew: clamp to non-negative, cap at reasonable max\n const deltaMs = retryDate - Date.now()\n return Math.max(0, Math.min(deltaMs, 3600_000)) // Cap at 1 hour\n }\n\n return 0\n}\n\nexport function createFetchWithBackoff(\n fetchClient: typeof fetch,\n backoffOptions: BackoffOptions = BackoffDefaults\n): typeof fetch {\n const {\n initialDelay,\n maxDelay,\n multiplier,\n debug = false,\n onFailedAttempt,\n maxRetries = Infinity,\n } = backoffOptions\n return async (...args: Parameters<typeof fetch>): Promise<Response> => {\n const url = args[0]\n const options = args[1]\n\n let delay = initialDelay\n let attempt = 0\n\n while (true) {\n try {\n const result = await fetchClient(...args)\n if (result.ok) {\n return result\n }\n\n const err = await FetchError.fromResponse(result, url.toString())\n\n throw err\n } catch (e) {\n onFailedAttempt?.()\n if (options?.signal?.aborted) {\n throw new FetchBackoffAbortError()\n } else if (\n e instanceof FetchError &&\n !HTTP_RETRY_STATUS_CODES.includes(e.status) &&\n e.status >= 400 &&\n e.status < 500\n ) {\n // Any client errors cannot be backed off on, leave it to the caller to handle.\n throw e\n } else {\n // Check max retries\n attempt++\n if (attempt > maxRetries) {\n if (debug) {\n console.log(\n `Max retries reached (${attempt}/${maxRetries}), giving up`\n )\n }\n throw e\n }\n\n // Calculate wait time honoring server-driven backoff as a floor\n // Precedence: max(serverMinimum, min(clientMaxDelay, backoffWithJitter))\n\n // 1. Parse server-provided Retry-After (if present)\n const serverMinimumMs =\n e instanceof FetchError && e.headers\n ? parseRetryAfterHeader(e.headers[`retry-after`])\n : 0\n\n // 2. Calculate client backoff with full jitter strategy\n // Full jitter: random_between(0, min(cap, exponential_backoff))\n // See: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/\n const jitter = Math.random() * delay // random value between 0 and current delay\n const clientBackoffMs = Math.min(jitter, maxDelay) // cap at maxDelay\n\n // 3. Server minimum is the floor, client cap is the ceiling\n const waitMs = Math.max(serverMinimumMs, clientBackoffMs)\n\n if (debug) {\n const source = serverMinimumMs > 0 ? `server+client` : `client`\n console.log(\n `Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`\n )\n }\n\n // Wait for the calculated duration\n await new Promise((resolve) => setTimeout(resolve, waitMs))\n\n // Increase the delay for the next attempt (capped at maxDelay)\n delay = Math.min(delay * multiplier, maxDelay)\n }\n }\n }\n }\n}\n\nconst NO_BODY_STATUS_CODES = [201, 204, 205]\n\n// Ensure body can actually be read in its entirety\nexport function createFetchWithConsumedMessages(fetchClient: typeof fetch) {\n return async (...args: Parameters<typeof fetch>): Promise<Response> => {\n const url = args[0]\n const res = await fetchClient(...args)\n try {\n if (res.status < 200 || NO_BODY_STATUS_CODES.includes(res.status)) {\n return res\n }\n\n const text = await res.text()\n return new Response(text, res)\n } catch (err) {\n if (args[1]?.signal?.aborted) {\n throw new FetchBackoffAbortError()\n }\n\n throw new FetchError(\n res.status,\n undefined,\n undefined,\n Object.fromEntries([...res.headers.entries()]),\n url.toString(),\n err instanceof Error\n ? err.message\n : typeof err === `string`\n ? err\n : `failed to read body`\n )\n }\n }\n}\n\ninterface ChunkPrefetchOptions {\n maxChunksToPrefetch: number\n}\n\nconst ChunkPrefetchDefaults = {\n maxChunksToPrefetch: 2,\n}\n\n/**\n * Creates a fetch client that prefetches subsequent log chunks for\n * consumption by the shape stream without waiting for the chunk bodies\n * themselves to be loaded.\n *\n * @param fetchClient the client to wrap\n * @param prefetchOptions options to configure prefetching\n * @returns wrapped client with prefetch capabilities\n */\nexport function createFetchWithChunkBuffer(\n fetchClient: typeof fetch,\n prefetchOptions: ChunkPrefetchOptions = ChunkPrefetchDefaults\n): typeof fetch {\n const { maxChunksToPrefetch } = prefetchOptions\n\n let prefetchQueue: PrefetchQueue | undefined\n\n const prefetchClient = async (...args: Parameters<typeof fetchClient>) => {\n const url = args[0].toString()\n const method = getRequestMethod(args[0], args[1])\n\n // Prefetch is only valid for GET requests. The prefetch queue matches\n // requests by URL alone and ignores HTTP method/body, so a POST request\n // with the same URL would incorrectly consume the prefetched stream\n // response instead of making its own request.\n if (method !== `GET`) {\n prefetchQueue?.abort()\n prefetchQueue = undefined\n return fetchClient(...args)\n }\n\n // try to consume from the prefetch queue first, and if request is\n // not present abort the prefetch queue as it must no longer be valid\n const prefetchedRequest = prefetchQueue?.consume(...args)\n if (prefetchedRequest) {\n return prefetchedRequest\n }\n\n // Clear the prefetch queue after aborting to prevent returning\n // stale/aborted requests on future calls with the same URL\n prefetchQueue?.abort()\n prefetchQueue = undefined\n\n // perform request and fire off prefetch queue if request is eligible\n const response = await fetchClient(...args)\n const nextUrl = getNextChunkUrl(url, response)\n if (nextUrl) {\n prefetchQueue = new PrefetchQueue({\n fetchClient,\n maxPrefetchedRequests: maxChunksToPrefetch,\n url: nextUrl,\n requestInit: args[1],\n })\n }\n\n return response\n }\n\n return prefetchClient\n}\n\nexport const requiredElectricResponseHeaders = [\n CHUNK_LAST_OFFSET_HEADER,\n SHAPE_HANDLE_HEADER,\n]\n\nexport const requiredLiveResponseHeaders = [LIVE_CACHE_BUSTER_HEADER]\n\nexport const requiredNonLiveResponseHeaders = [SHAPE_SCHEMA_HEADER]\n\nexport function createFetchWithResponseHeadersCheck(\n fetchClient: typeof fetch\n): typeof fetch {\n return async (...args: Parameters<typeof fetchClient>) => {\n const response = await fetchClient(...args)\n\n if (response.ok) {\n // Check that the necessary Electric headers are present on the response\n const headers = response.headers\n const missingHeaders: Array<string> = []\n\n const addMissingHeaders = (requiredHeaders: Array<string>) =>\n missingHeaders.push(...requiredHeaders.filter((h) => !headers.has(h)))\n\n const input = args[0]\n const urlString = input.toString()\n const url = new URL(urlString)\n\n // Snapshot responses (subset params) return a JSON object and do not include Electric chunk headers\n const isSnapshotRequest = [\n SUBSET_PARAM_WHERE,\n SUBSET_PARAM_WHERE_PARAMS,\n SUBSET_PARAM_LIMIT,\n SUBSET_PARAM_OFFSET,\n SUBSET_PARAM_ORDER_BY,\n ].some((p) => url.searchParams.has(p))\n if (isSnapshotRequest) {\n return response\n }\n\n addMissingHeaders(requiredElectricResponseHeaders)\n if (url.searchParams.get(LIVE_QUERY_PARAM) === `true`) {\n addMissingHeaders(requiredLiveResponseHeaders)\n }\n\n if (\n !url.searchParams.has(LIVE_QUERY_PARAM) ||\n url.searchParams.get(LIVE_QUERY_PARAM) === `false`\n ) {\n addMissingHeaders(requiredNonLiveResponseHeaders)\n }\n\n if (missingHeaders.length > 0) {\n throw new MissingHeadersError(urlString, missingHeaders)\n }\n }\n\n return response\n }\n}\n\nclass PrefetchQueue {\n readonly #fetchClient: typeof fetch\n readonly #maxPrefetchedRequests: number\n readonly #prefetchQueue = new Map<\n string,\n [Promise<Response>, AbortController]\n >()\n #queueHeadUrl: string | void\n #queueTailUrl: string | void\n\n constructor(options: {\n url: Parameters<typeof fetch>[0]\n requestInit: Parameters<typeof fetch>[1]\n maxPrefetchedRequests: number\n fetchClient?: typeof fetch\n }) {\n this.#fetchClient =\n options.fetchClient ??\n ((...args: Parameters<typeof fetch>) => fetch(...args))\n this.#maxPrefetchedRequests = options.maxPrefetchedRequests\n this.#queueHeadUrl = options.url.toString()\n this.#queueTailUrl = this.#queueHeadUrl\n this.#prefetch(options.url, options.requestInit)\n }\n\n abort(): void {\n this.#prefetchQueue.forEach(([_, aborter]) => aborter.abort())\n this.#prefetchQueue.clear()\n }\n\n consume(...args: Parameters<typeof fetch>): Promise<Response> | void {\n const url = args[0].toString()\n\n const entry = this.#prefetchQueue.get(url)\n // only consume if request is in queue and is the queue \"head\"\n // if request is in the queue but not the head, the queue is being\n // consumed out of order and should be restarted\n if (!entry || url !== this.#queueHeadUrl) return\n\n const [request, aborter] = entry\n // Don't return aborted requests - they will reject with AbortError\n if (aborter.signal.aborted) {\n this.#prefetchQueue.delete(url)\n return\n }\n this.#prefetchQueue.delete(url)\n\n // fire off new prefetch since request has been consumed\n request\n .then((response) => {\n const nextUrl = getNextChunkUrl(url, response)\n this.#queueHeadUrl = nextUrl\n if (\n this.#queueTailUrl &&\n !this.#prefetchQueue.has(this.#queueTailUrl)\n ) {\n this.#prefetch(this.#queueTailUrl, args[1])\n }\n })\n .catch(() => {})\n\n return request\n }\n\n #prefetch(...args: Parameters<typeof fetch>): void {\n const url = args[0].toString()\n\n // only prefetch when queue is not full\n if (this.#prefetchQueue.size >= this.#maxPrefetchedRequests) return\n\n // initialize aborter per request, to avoid aborting consumed requests that\n // are still streaming their bodies to the consumer\n const aborter = new AbortController()\n\n try {\n const { signal, cleanup } = chainAborter(aborter, args[1]?.signal)\n const request = this.#fetchClient(url, { ...(args[1] ?? {}), signal })\n this.#prefetchQueue.set(url, [request, aborter])\n request\n .then((response) => {\n // only keep prefetching if response chain is uninterrupted\n if (!response.ok || aborter.signal.aborted) return\n\n const nextUrl = getNextChunkUrl(url, response)\n\n // only prefetch when there is a next URL\n if (!nextUrl || nextUrl === url) {\n this.#queueTailUrl = undefined\n return\n }\n\n this.#queueTailUrl = nextUrl\n return this.#prefetch(nextUrl, args[1])\n })\n .catch(() => {})\n .finally(cleanup)\n } catch (_) {\n // ignore prefetch errors\n }\n }\n}\n\n/**\n * Generate the next chunk's URL if the url and response are valid\n */\nfunction getNextChunkUrl(url: string, res: Response): string | void {\n const shapeHandle = res.headers.get(SHAPE_HANDLE_HEADER)\n const lastOffset = res.headers.get(CHUNK_LAST_OFFSET_HEADER)\n const isUpToDate = res.headers.has(CHUNK_UP_TO_DATE_HEADER)\n\n // only prefetch if shape handle and offset for next chunk are available, and\n // response is not already up-to-date\n if (!shapeHandle || !lastOffset || isUpToDate) return\n\n const nextUrl = new URL(url)\n\n // don't prefetch live requests, rushing them will only\n // potentially miss more recent data\n if (nextUrl.searchParams.has(LIVE_QUERY_PARAM)) return\n\n // don't prefetch if the response handle is the expired handle from the request\n // this can happen when a proxy serves a stale cached response despite the\n // expired_handle cache buster parameter\n const expiredHandle = nextUrl.searchParams.get(EXPIRED_HANDLE_QUERY_PARAM)\n if (expiredHandle && shapeHandle === expiredHandle) {\n console.warn(\n `[Electric] Received stale cached response with expired shape handle. ` +\n `This should not happen and indicates a proxy/CDN caching misconfiguration. ` +\n `The response contained handle \"${shapeHandle}\" which was previously marked as expired. ` +\n `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +\n `Skipping prefetch to prevent infinite 409 loop.`\n )\n return\n }\n\n nextUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, shapeHandle)\n nextUrl.searchParams.set(OFFSET_QUERY_PARAM, lastOffset)\n nextUrl.searchParams.sort()\n return nextUrl.toString()\n}\n\n/**\n * Chains an abort controller on an optional source signal's\n * aborted state - if the source signal is aborted, the provided abort\n * controller will also abort\n */\nfunction chainAborter(\n aborter: AbortController,\n sourceSignal?: AbortSignal | null\n): {\n signal: AbortSignal\n cleanup: () => void\n} {\n let cleanup = noop\n if (!sourceSignal) {\n // no-op, nothing to chain to\n } else if (sourceSignal.aborted) {\n // source signal is already aborted, abort immediately\n aborter.abort()\n } else {\n // chain to source signal abort event, and add callback to unlink\n // the aborter to avoid memory leaks\n const abortParent = () => aborter.abort()\n sourceSignal.addEventListener(`abort`, abortParent, {\n once: true,\n signal: aborter.signal,\n })\n cleanup = () => sourceSignal.removeEventListener(`abort`, abortParent)\n }\n\n return {\n signal: aborter.signal,\n cleanup,\n }\n}\n\nfunction noop() {}\n\nfunction getRequestMethod(\n input: Parameters<typeof fetch>[0],\n init?: Parameters<typeof fetch>[1]\n): string {\n if (init?.method) {\n return init.method.toUpperCase()\n }\n\n if (typeof Request !== `undefined` && input instanceof Request) {\n return input.method.toUpperCase()\n }\n\n return `GET`\n}\n","import { SerializedExpression, SerializedOrderByClause } from './types'\nimport { quoteIdentifier } from './column-mapper'\n\n/**\n * Compiles a serialized expression into a SQL string.\n * Applies columnMapper transformations to column references.\n *\n * @param expr - The serialized expression to compile\n * @param columnMapper - Optional function to transform column names (e.g., camelCase to snake_case)\n * @returns The compiled SQL string\n *\n * @example\n * ```typescript\n * const expr = { type: 'ref', column: 'userId' }\n * compileExpression(expr, camelToSnake) // '\"user_id\"'\n * ```\n */\nexport function compileExpression(\n expr: SerializedExpression,\n columnMapper?: (col: string) => string\n): string {\n switch (expr.type) {\n case `ref`: {\n // Apply columnMapper, then quote\n const mappedColumn = columnMapper\n ? columnMapper(expr.column)\n : expr.column\n return quoteIdentifier(mappedColumn)\n }\n case `val`:\n return `$${expr.paramIndex}`\n case `func`:\n return compileFunction(expr, columnMapper)\n default: {\n // TypeScript exhaustiveness check\n const _exhaustive: never = expr\n throw new Error(`Unknown expression type: ${JSON.stringify(_exhaustive)}`)\n }\n }\n}\n\n/**\n * Compiles a function expression into SQL.\n */\nfunction compileFunction(\n expr: { type: `func`; name: string; args: SerializedExpression[] },\n columnMapper?: (col: string) => string\n): string {\n const args = expr.args.map((arg) => compileExpression(arg, columnMapper))\n\n switch (expr.name) {\n // Binary comparison operators\n case `eq`:\n return `${args[0]} = ${args[1]}`\n case `gt`:\n return `${args[0]} > ${args[1]}`\n case `gte`:\n return `${args[0]} >= ${args[1]}`\n case `lt`:\n return `${args[0]} < ${args[1]}`\n case `lte`:\n return `${args[0]} <= ${args[1]}`\n\n // Logical operators\n case `and`:\n return args.map((a) => `(${a})`).join(` AND `)\n case `or`:\n return args.map((a) => `(${a})`).join(` OR `)\n case `not`:\n return `NOT (${args[0]})`\n\n // Special operators\n case `in`:\n return `${args[0]} = ANY(${args[1]})`\n case `like`:\n return `${args[0]} LIKE ${args[1]}`\n case `ilike`:\n return `${args[0]} ILIKE ${args[1]}`\n case `isNull`:\n case `isUndefined`:\n return `${args[0]} IS NULL`\n\n // String functions\n case `upper`:\n return `UPPER(${args[0]})`\n case `lower`:\n return `LOWER(${args[0]})`\n case `length`:\n return `LENGTH(${args[0]})`\n case `concat`:\n return `CONCAT(${args.join(`, `)})`\n\n // Other functions\n case `coalesce`:\n return `COALESCE(${args.join(`, `)})`\n\n default:\n throw new Error(`Unknown function: ${expr.name}`)\n }\n}\n\n/**\n * Compiles serialized ORDER BY clauses into a SQL string.\n * Applies columnMapper transformations to column references.\n *\n * @param clauses - The serialized ORDER BY clauses to compile\n * @param columnMapper - Optional function to transform column names\n * @returns The compiled SQL ORDER BY string\n *\n * @example\n * ```typescript\n * const clauses = [{ column: 'createdAt', direction: 'desc', nulls: 'first' }]\n * compileOrderBy(clauses, camelToSnake) // '\"created_at\" DESC