{"version":3,"file":"dialect-helpers-BSbfaO3F.mjs","names":[],"sources":["../src/database/dialect-helpers.ts"],"sourcesContent":["/**\n * Dialect-specific SQL helpers\n *\n * Every function takes a Kysely `db` instance and detects the dialect from\n * the adapter class. No module-level state, no globals, no heuristics —\n * the adapter is the source of truth.\n *\n * This is NOT an ORM abstraction — just targeted helpers for the ~15 places\n * that use raw dialect-specific SQL. Most Kysely schema builder code already\n * works cross-dialect.\n */\n\nimport type { ColumnDataType, Kysely, RawBuilder } from \"kysely\";\nimport { PostgresAdapter, sql } from \"kysely\";\n\nimport type { DatabaseDialectType } from \"../db/adapters.js\";\nimport { validateIdentifier, validateJsonFieldName } from \"./validate.js\";\n\nexport type { DatabaseDialectType };\n\n/**\n * Detect dialect type from a Kysely instance via the adapter class.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport function detectDialect(db: Kysely<any>): DatabaseDialectType {\n\tif (db.getExecutor().adapter instanceof PostgresAdapter) return \"postgres\";\n\treturn \"sqlite\";\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport function isSqlite(db: Kysely<any>): boolean {\n\treturn detectDialect(db) === \"sqlite\";\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport function isPostgres(db: Kysely<any>): boolean {\n\treturn detectDialect(db) === \"postgres\";\n}\n\n/**\n * Declared by an adapter whose backend caps the number of terms in a compound\n * SELECT (`UNION ALL`, `INTERSECT`, `EXCEPT`). SQLite's own\n * SQLITE_LIMIT_COMPOUND_SELECT default is 500 — high enough that no query\n * EmDash builds approaches it — but Cloudflare D1 sets it to 5 and rejects\n * anything larger with \"too many terms in compound SELECT\".\n */\nexport interface CompoundSelectLimitedAdapter {\n\t/** Maximum terms per compound SELECT. Must be a positive integer. */\n\treadonly compoundSelectLimit: number;\n}\n\n/**\n * The backend's compound-SELECT ceiling, or null when it has none worth\n * splitting statements for. Only the adapter knows: the limit is a property of\n * the SQLite build behind the dialect, not of the SQL flavour, so two \"sqlite\"\n * dialects can answer differently.\n *\n * A declared ceiling must be a positive integer — callers batch by it, and\n * every other value silently misbehaves rather than failing: 0 and negatives\n * never advance the batch cursor, fractions overlap batches and double-count,\n * NaN yields an empty batch. A malformed declaration throws here, where the\n * message can name the adapter.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport function compoundSelectLimit(db: Kysely<any>): number | null {\n\tconst adapter: object = db.getExecutor().adapter;\n\tif (!(\"compoundSelectLimit\" in adapter)) return null;\n\n\tconst limit: unknown = adapter.compoundSelectLimit;\n\tif (typeof limit !== \"number\" || !Number.isInteger(limit) || limit < 1) {\n\t\tthrow new Error(\n\t\t\t`${adapter.constructor.name} declares compoundSelectLimit ${String(limit)}; it must be a positive integer.`,\n\t\t);\n\t}\n\treturn limit;\n}\n\n/**\n * Default timestamp expression for column defaults.\n * Wrapped in parens for use in CREATE TABLE ... DEFAULT (...).\n *\n * sqlite:   (datetime('now'))\n * postgres: CURRENT_TIMESTAMP\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport function currentTimestamp(db: Kysely<any>): RawBuilder<string> {\n\tif (isPostgres(db)) {\n\t\treturn sql`CURRENT_TIMESTAMP`;\n\t}\n\treturn sql`(datetime('now'))`;\n}\n\n/**\n * Timestamp expression for use in WHERE clauses and SET expressions.\n * No wrapping parens.\n *\n * sqlite:   datetime('now')\n * postgres: CURRENT_TIMESTAMP\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport function currentTimestampValue(db: Kysely<any>): RawBuilder<string> {\n\tif (isPostgres(db)) {\n\t\treturn sql`CURRENT_TIMESTAMP`;\n\t}\n\treturn sql`datetime('now')`;\n}\n\n/**\n * Build WHERE clause for status filtering on a content table.\n * Scheduled content becomes public only after the publication sweep commits\n * the row with a literal `published` status.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport function buildStatusCondition(\n\t_db: Kysely<any>,\n\tstatus: string,\n\ttablePrefix?: string,\n): ReturnType<typeof sql> {\n\tconst statusField = tablePrefix ? `${tablePrefix}.status` : \"status\";\n\treturn sql`${sql.ref(statusField)} = ${status}`;\n}\n\n/**\n * Check if a table exists in the database.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport async function tableExists(db: Kysely<any>, tableName: string): Promise<boolean> {\n\tif (isPostgres(db)) {\n\t\t// Scope to the active schema (matches indexExists/columnExists below).\n\t\t// Hardcoding 'public' breaks non-public-schema Postgres deployments.\n\t\tconst result = await sql<{ exists: boolean }>`\n\t\t\tSELECT EXISTS(\n\t\t\t\tSELECT 1 FROM information_schema.tables\n\t\t\t\tWHERE table_schema = current_schema() AND table_name = ${tableName}\n\t\t\t) as exists\n\t\t`.execute(db);\n\t\treturn result.rows[0]?.exists === true;\n\t}\n\n\tconst result = await sql<{ name: string }>`\n\t\tSELECT name FROM sqlite_master\n\t\tWHERE type = 'table' AND name = ${tableName}\n\t`.execute(db);\n\treturn result.rows.length > 0;\n}\n\n/**\n * Check if an index exists in the database.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport async function indexExists(db: Kysely<any>, indexName: string): Promise<boolean> {\n\tif (isPostgres(db)) {\n\t\tconst result = await sql<{ exists: boolean }>`\n\t\t\tSELECT EXISTS(\n\t\t\t\tSELECT 1 FROM pg_indexes\n\t\t\t\tWHERE schemaname = current_schema() AND indexname = ${indexName}\n\t\t\t) as exists\n\t\t`.execute(db);\n\t\treturn result.rows[0]?.exists === true;\n\t}\n\n\tconst result = await sql<{ name: string }>`\n\t\tSELECT name FROM sqlite_master\n\t\tWHERE type = 'index' AND name = ${indexName}\n\t`.execute(db);\n\treturn result.rows.length > 0;\n}\n\n/**\n * Check if a column exists in the database.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport async function columnExists(\n\tdb: Kysely<any>,\n\ttableName: string,\n\tcolumnName: string,\n): Promise<boolean> {\n\tif (isPostgres(db)) {\n\t\tconst result = await sql<{ exists: boolean }>`\n\t\t\tSELECT EXISTS(\n\t\t\t\tSELECT 1 FROM information_schema.columns\n\t\t\t\tWHERE table_schema = current_schema()\n\t\t\t\t\tAND table_name = ${tableName}\n\t\t\t\t\tAND column_name = ${columnName}\n\t\t\t) as exists\n\t\t`.execute(db);\n\t\treturn result.rows[0]?.exists === true;\n\t}\n\n\tconst result = await sql<{ name: string }>`\n\t\tSELECT name FROM pragma_table_info(${tableName})\n\t\tWHERE name = ${columnName}\n\t`.execute(db);\n\treturn result.rows.length > 0;\n}\n\n/**\n * List tables matching a LIKE pattern.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport async function listTablesLike(db: Kysely<any>, pattern: string): Promise<string[]> {\n\tif (isPostgres(db)) {\n\t\t// Scope to the connection's active schema rather than hardcoding\n\t\t// 'public'. A Postgres deployment using a non-public schema (per-tenant\n\t\t// or shared-cluster setups), or per-test schemas, otherwise sees tables\n\t\t// from the wrong schema — or none at all. Mirrors migration 038.\n\t\tconst result = await sql<{ table_name: string }>`\n\t\t\tSELECT table_name FROM information_schema.tables\n\t\t\tWHERE table_schema = current_schema() AND table_name LIKE ${pattern}\n\t\t`.execute(db);\n\t\treturn result.rows.map((r) => r.table_name);\n\t}\n\n\tconst result = await sql<{ name: string }>`\n\t\tSELECT name FROM sqlite_master\n\t\tWHERE type = 'table' AND name LIKE ${pattern}\n\t`.execute(db);\n\treturn result.rows.map((r) => r.name);\n}\n\n/**\n * Column type for binary data.\n *\n * sqlite:   blob\n * postgres: bytea\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport function binaryType(db: Kysely<any>): ColumnDataType {\n\tif (isPostgres(db)) {\n\t\treturn \"bytea\";\n\t}\n\treturn \"blob\";\n}\n\n/**\n * SQL expression for extracting a field from a JSON column stored as text.\n *\n * sqlite:   json_extract(column, '$.path')\n * postgres: (column)::jsonb->>'path'\n *\n * The Postgres cast is required because JSON columns (e.g.\n * `_plugin_storage.data`) are `text`, and `text ->> unknown` is not an\n * operator. The cast is immutable, so the same expression works in\n * expression indexes — queries and indexes must build it through this\n * helper so the planner can match them.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport function jsonExtractExpr(db: Kysely<any>, column: string, path: string): string {\n\tvalidateIdentifier(column, \"JSON column name\");\n\tvalidateJsonFieldName(path, \"JSON path\");\n\tif (isPostgres(db)) {\n\t\treturn `(${column})::jsonb->>'${path}'`;\n\t}\n\treturn `json_extract(${column}, '$.${path}')`;\n}\n\n/**\n * SQL expression for extracting a queryable field from the plugin-storage\n * `data` column.\n *\n * `_plugin_storage.data` is `text`, so Postgres extraction goes through the\n * `(data)::jsonb->>'field'` cast (#1898) — otherwise `text ->> unknown` is not\n * an operator. But the extracted value is still `text`, so a numeric comparison\n * (`stock >= 10`) compares lexically (`'9' >= '10'` is TRUE) and silently\n * over-counts / oversells; pass `{ numeric: true }` for a numeric comparison.\n *\n * The numeric form is a **type-guarded** cast, not a bare `::numeric`. A bare\n * cast throws `invalid input syntax for type numeric` the moment a single\n * scanned row stores a non-number in that field (documents are schemaless),\n * aborting the whole query — and it would diverge from SQLite, which silently\n * coerces. Guarding with `jsonb_typeof`/`json_type` makes the comparison total\n * and parity-correct on both dialects: a non-number stored value yields `NULL`\n * (no match) instead of an error.\n *\n * The field name is validated before interpolation, so the casts wrap only a\n * safe identifier and add no injection surface.\n *\n * sqlite text:      json_extract(data, '$.field')\n * sqlite numeric:   CASE WHEN json_type(data, '$.field') IN ('integer', 'real')\n *                     THEN json_extract(data, '$.field') END\n * postgres text:    (data)::jsonb->>'field'\n * postgres numeric: CASE WHEN jsonb_typeof((data)::jsonb->'field') = 'number'\n *                     THEN ((data)::jsonb->>'field')::numeric END\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport function pluginDataExtractExpr(\n\tdb: Kysely<any>,\n\tfield: string,\n\toptions?: { numeric?: boolean },\n): string {\n\tvalidateJsonFieldName(field, \"plugin storage field name\");\n\tif (isPostgres(db)) {\n\t\tconst text = `(data)::jsonb->>'${field}'`;\n\t\tif (!options?.numeric) return text;\n\t\treturn `CASE WHEN jsonb_typeof((data)::jsonb->'${field}') = 'number' THEN (${text})::numeric END`;\n\t}\n\tconst extract = `json_extract(data, '$.${field}')`;\n\tif (!options?.numeric) return extract;\n\treturn `CASE WHEN json_type(data, '$.${field}') IN ('integer', 'real') THEN ${extract} END`;\n}\n\n/**\n * SQL expression for ordering plugin-storage rows by a `data` field.\n *\n * `ORDER BY` has no bound operand to infer numeric-vs-text from, so extracting\n * as text (`->>'field'`) would sort a numeric field lexically on Postgres\n * (`[10, 100, 9]`) while SQLite's `json_extract` sorts it numerically — a\n * cross-dialect divergence. Ordering over the jsonb-native value (`->'field'`,\n * single arrow) fixes this: jsonb btree ordering is numeric among numbers,\n * lexical among strings, and total across heterogeneous values (never throws).\n * SQLite's `json_extract` already orders numerically, so it is unchanged.\n *\n * sqlite:   json_extract(data, '$.field')\n * postgres: (data)::jsonb->'field'\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance\nexport function pluginDataOrderExpr(db: Kysely<any>, field: string): string {\n\tvalidateJsonFieldName(field, \"plugin storage order field name\");\n\tif (isPostgres(db)) {\n\t\treturn `(data)::jsonb->'${field}'`;\n\t}\n\treturn `json_extract(data, '$.${field}')`;\n}\n"],"mappings":"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"}