From 2b13e011b4a4f8be4e4c39573b9e962cbabb2094 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:31:18 +0000 Subject: [PATCH] fix(kysely-adapter): detect SQLite rowid aliases correctly (#11374) --- .changeset/clear-sqlite-generated-keys.md | 5 + .../cloudflare/test/migration.test.ts | 20 +++ .../kysely-adapter/src/d1-sqlite-dialect.ts | 52 +++--- packages/kysely-adapter/src/dialect.test.ts | 68 ++++++++ .../kysely-adapter/src/schema-check.test.ts | 37 +++++ .../src/sqlite-introspector.test.ts | 148 +++++++++++++++++- .../kysely-adapter/src/sqlite-introspector.ts | 51 +++--- 7 files changed, 336 insertions(+), 45 deletions(-) create mode 100644 .changeset/clear-sqlite-generated-keys.md diff --git a/.changeset/clear-sqlite-generated-keys.md b/.changeset/clear-sqlite-generated-keys.md new file mode 100644 index 0000000000..f7d3cfb3d8 --- /dev/null +++ b/.changeset/clear-sqlite-generated-keys.md @@ -0,0 +1,5 @@ +--- +"@better-auth/kysely-adapter": patch +--- + +Correctly detect database-generated SQLite primary keys during schema validation, including `INTEGER PRIMARY KEY` columns without `AUTOINCREMENT`. diff --git a/e2e/smoke/test/fixtures/cloudflare/test/migration.test.ts b/e2e/smoke/test/fixtures/cloudflare/test/migration.test.ts index 4fe507be25..2d9781b2ad 100644 --- a/e2e/smoke/test/fixtures/cloudflare/test/migration.test.ts +++ b/e2e/smoke/test/fixtures/cloudflare/test/migration.test.ts @@ -37,3 +37,23 @@ it("runs built-in migrations repeatedly on D1", async () => { ); expect(secondResponse.status).toBe(204); }); + +/** + * @see https://developers.cloudflare.com/d1/sql-api/sql-statements/#pragma-index_listtable_name + */ +it("distinguishes rowid keys from indexed primary keys on D1", async () => { + const { DB } = await server.getWorker().getEnv(); + await DB.exec("CREATE TABLE generated (id INTEGER PRIMARY KEY)"); + await DB.exec("CREATE TABLE descending (id INTEGER PRIMARY KEY DESC)"); + + const results = await DB.batch<{ origin: string }>([ + DB.prepare("PRAGMA index_list('generated')"), + DB.prepare("PRAGMA index_list('descending')"), + ]); + + expect( + results.map((result) => + result.results?.some((index) => index.origin === "pk"), + ), + ).toEqual([false, true]); +}); diff --git a/packages/kysely-adapter/src/d1-sqlite-dialect.ts b/packages/kysely-adapter/src/d1-sqlite-dialect.ts index 87b80fb0d6..3dc4250a4d 100644 --- a/packages/kysely-adapter/src/d1-sqlite-dialect.ts +++ b/packages/kysely-adapter/src/d1-sqlite-dialect.ts @@ -19,19 +19,16 @@ import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, } from "./kysely-migration-tables"; -import type { PragmaTableInfo } from "./sqlite-introspector"; +import type { + PragmaIndexListRow, + PragmaTableInfo, +} from "./sqlite-introspector"; import { sqliteIntegerPrimaryKeyColumn, toSqliteTableMetadata, } from "./sqlite-introspector"; import type { DatabaseIndexIntrospector } from "./types"; -interface D1IndexListRow { - name: string; - partial: number; - unique: number; -} - interface D1IndexInfoRow { name: string | null; seqno: number; @@ -46,7 +43,7 @@ export function createD1IndexIntrospector( ): DatabaseIndexIntrospector { return async (tableNames) => { if (tableNames.length === 0) return []; - const indexLists = await database.batch( + const indexLists = await database.batch( tableNames.map((tableName) => database.prepare( `PRAGMA index_list(${quoteSqliteStringLiteral(tableName)})`, @@ -204,8 +201,8 @@ class D1SqliteIntrospector implements DatabaseIntrospector { .where("name", "not like", "sqlite_%") // @ts-expect-error - D1 internal tables .where("name", "not like", "_cf_%") - .select(["name", "type", "sql"]) - .$castTo<{ name: string; type: string; sql: string | null }>(); + .select(["name", "type"]) + .$castTo<{ name: string; type: string }>(); if (!options.withInternalKyselyTables) { query = query @@ -225,20 +222,33 @@ class D1SqliteIntrospector implements DatabaseIntrospector { this.#d1.prepare("SELECT * FROM pragma_table_info(?)").bind(table.name), ); const batchResults = await this.#d1.batch(statements); + const rowidCandidates = tables.flatMap((table, index) => { + const column = sqliteIntegerPrimaryKeyColumn( + batchResults[index]?.results ?? [], + ); + return column ? [{ index, tableName: table.name, column }] : []; + }); + const indexResults = rowidCandidates.length + ? await this.#d1.batch( + rowidCandidates.map(({ tableName }) => + this.#d1.prepare( + `PRAGMA index_list(${quoteSqliteStringLiteral(tableName)})`, + ), + ), + ) + : []; + const generatedColumns = new Map( + rowidCandidates + .filter( + (_, index) => + !indexResults[index]?.results?.some((row) => row.origin === "pk"), + ) + .map(({ index, column }) => [index, column]), + ); return tables.map((table, index) => { const columns = batchResults[index]?.results ?? []; - const declared = table.sql - ?.split(/[(),]/) - .find((part) => part.toLowerCase().includes("autoincrement")) - ?.split(/\s+/) - .filter(Boolean)[0] - ?.replace(/["`]/g, ""); - return toSqliteTableMetadata( - table, - columns, - declared || sqliteIntegerPrimaryKeyColumn(columns), - ); + return toSqliteTableMetadata(table, columns, generatedColumns.get(index)); }); } diff --git a/packages/kysely-adapter/src/dialect.test.ts b/packages/kysely-adapter/src/dialect.test.ts index 5c45409611..144eec607c 100644 --- a/packages/kysely-adapter/src/dialect.test.ts +++ b/packages/kysely-adapter/src/dialect.test.ts @@ -231,6 +231,74 @@ describe("D1 table introspection", () => { "session", ]); }); + + /** + * @see https://github.com/better-auth/better-auth/issues/10551 + * @see https://www.sqlite.org/pragma.html#pragma_index_list + */ + it("checks primary-key indexes only for INTEGER PRIMARY KEY candidates", async ({ + onTestFinished, + }) => { + const all = vi.fn(async () => ({ + results: ["plain's", "descending", "text"].map((name) => ({ + name, + type: "table", + })), + meta: { changes: 0, last_row_id: 0 }, + })); + const prepare = vi.fn((query: string) => ({ + query, + bind: (tableName?: string) => ({ query, tableName, all }), + })); + const batch = vi + .fn() + .mockResolvedValueOnce( + ["plain's", "descending", "text"].map((name) => ({ + results: [ + { + cid: 0, + name: "id", + type: name === "text" ? "TEXT" : "INTEGER", + notnull: 0, + dflt_value: null, + pk: 1, + }, + ], + })), + ) + .mockResolvedValueOnce([ + { results: [] }, + { results: [{ origin: "pk" }] }, + ]); + const database = { + batch, + exec: vi.fn(), + prepare, + } as unknown as D1Database; + const db = new Kysely({ + dialect: new D1SqliteDialect({ database }), + }); + onTestFinished(() => db.destroy()); + + const tables = await db.introspection.getTables(); + + expect( + tables.map((table) => [table.name, table.columns[0]?.isAutoIncrementing]), + ).toEqual([ + ["plain's", true], + ["descending", false], + ["text", false], + ]); + expect(batch).toHaveBeenCalledTimes(2); + expect( + batch.mock.calls[1]?.[0].map( + (statement: { query: string }) => statement.query, + ), + ).toEqual([ + "PRAGMA index_list('plain''s')", + "PRAGMA index_list('descending')", + ]); + }); }); describe("createKyselyAdapter schema namespace", () => { diff --git a/packages/kysely-adapter/src/schema-check.test.ts b/packages/kysely-adapter/src/schema-check.test.ts index 6a03694919..75cdc7cc9a 100644 --- a/packages/kysely-adapter/src/schema-check.test.ts +++ b/packages/kysely-adapter/src/schema-check.test.ts @@ -149,6 +149,43 @@ it("validates SQLite without dialect introspection", async ({ expect(getTables).toHaveBeenCalledTimes(2); }); +/** + * @see https://www.sqlite.org/lang_createtable.html#rowids_and_the_integer_primary_key + */ +it("distinguishes generated and required SQLite primary keys", async ({ + onTestFinished, +}) => { + const sqlite = new DatabaseSync(":memory:"); + sqlite.exec( + "CREATE TABLE generated (seq INTEGER PRIMARY KEY NOT NULL, id TEXT NOT NULL UNIQUE)", + ); + sqlite.exec( + "CREATE TABLE descending (seq INTEGER PRIMARY KEY DESC NOT NULL, id TEXT NOT NULL UNIQUE)", + ); + sqlite.exec( + "CREATE TABLE without_rowid (seq INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE) WITHOUT ROWID", + ); + const db = new Kysely({ + dialect: new NodeSqliteDialect({ database: sqlite }), + }); + onTestFinished(() => db.destroy()); + + const findings = await findSchemaProblems(db, "sqlite", { + generated: { fields: {} }, + descending: { fields: {} }, + without_rowid: { fields: {} }, + }); + + expect(findings).toEqual([ + { kind: "unexpected-required-column", table: "descending", column: "seq" }, + { + kind: "unexpected-required-column", + table: "without_rowid", + column: "seq", + }, + ]); +}); + /** * @see https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH */ diff --git a/packages/kysely-adapter/src/sqlite-introspector.test.ts b/packages/kysely-adapter/src/sqlite-introspector.test.ts index fcaf2b61c7..a1f3aedfaa 100644 --- a/packages/kysely-adapter/src/sqlite-introspector.test.ts +++ b/packages/kysely-adapter/src/sqlite-introspector.test.ts @@ -1,13 +1,14 @@ import type { SQLInputValue } from "node:sqlite"; import { DatabaseSync } from "node:sqlite"; import { Kysely } from "kysely"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { BunSqliteDialect } from "./bun-sqlite-dialect"; import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, } from "./kysely-migration-tables"; import { NodeSqliteDialect } from "./node-sqlite-dialect"; +import { introspectSqliteTables } from "./sqlite-introspector"; function createSchema(db: DatabaseSync) { for (const sql of [ @@ -172,3 +173,148 @@ describe("sqlite introspector", () => { ); }); }); + +/** + * @see https://www.sqlite.org/autoinc.html + * @see https://www.sqlite.org/lang_createtable.html#rowids_and_the_integer_primary_key + */ +describe("SQLite generated primary keys", () => { + it.for([ + { + name: "an explicit AUTOINCREMENT declaration after whitespace", + definition: `CREATE TABLE entries ( + seq INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + id TEXT NOT NULL UNIQUE + )`, + generated: "seq", + }, + { + name: "an AUTOINCREMENT declaration separated by a comment", + definition: + "CREATE TABLE entries (seq INTEGER PRIMARY KEY/**/AUTOINCREMENT, id TEXT)", + generated: "seq", + }, + { + name: "an INTEGER PRIMARY KEY without AUTOINCREMENT", + definition: "CREATE TABLE entries (seq INTEGER PRIMARY KEY, id TEXT)", + generated: "seq", + }, + { + name: "a quoted primary key identifier", + definition: + 'CREATE TABLE entries ("sequence number" INTEGER PRIMARY KEY, id TEXT)', + generated: "sequence number", + }, + { + name: "AUTOINCREMENT inside a default string", + definition: + "CREATE TABLE entries (ref INTEGER PRIMARY KEY,note TEXT DEFAULT 'use AUTOINCREMENT')", + generated: "ref", + }, + { + name: "AUTOINCREMENT inside a default string on a WITHOUT ROWID table", + definition: + "CREATE TABLE entries (ref INTEGER PRIMARY KEY, note TEXT DEFAULT 'use AUTOINCREMENT') WITHOUT ROWID", + generated: undefined, + }, + { + name: "an INTEGER PRIMARY KEY DESC column", + definition: + "CREATE TABLE entries (seq INTEGER PRIMARY KEY DESC, id TEXT)", + generated: undefined, + }, + { + name: "a table-level PRIMARY KEY with DESC ordering", + definition: + "CREATE TABLE entries (seq INTEGER, id TEXT, PRIMARY KEY (seq DESC))", + generated: "seq", + }, + { + name: "an INTEGER PRIMARY KEY on a WITHOUT ROWID table", + definition: + "CREATE TABLE entries (seq INTEGER PRIMARY KEY, id TEXT) WITHOUT ROWID", + generated: undefined, + }, + { + name: "an INT PRIMARY KEY column", + definition: "CREATE TABLE entries (seq INT PRIMARY KEY, id TEXT)", + generated: undefined, + }, + ])("recognizes $name", async ({ definition, generated }, { + onTestFinished, + }) => { + const sqlite = new DatabaseSync(":memory:"); + sqlite.exec(definition); + const db = new Kysely({ + dialect: new NodeSqliteDialect({ database: sqlite }), + }); + onTestFinished(() => db.destroy()); + + const tables = await db.introspection.getTables(); + const entries = tables.find((table) => table.name === "entries"); + expect( + entries?.columns + .filter((column) => column.isAutoIncrementing) + .map((column) => column.name), + ).toEqual(generated ? [generated] : []); + }); + + it("BunSqliteDialect recognizes an INTEGER PRIMARY KEY", async ({ + onTestFinished, + }) => { + const sqlite = new DatabaseSync(":memory:"); + sqlite.exec("CREATE TABLE entries (id INTEGER PRIMARY KEY, name TEXT)"); + const db = new Kysely({ + dialect: new BunSqliteDialect({ database: asBunLikeDatabase(sqlite) }), + }); + onTestFinished(() => db.destroy()); + + const tables = await db.introspection.getTables(); + const id = tables.find((table) => table.name === "entries")?.columns[0]; + expect(id?.isAutoIncrementing).toBe(true); + }); + + /** @see https://github.com/better-auth/better-auth/issues/10551 */ + it("uses statement-form PRAGMA when dialect introspection is unavailable", async ({ + onTestFinished, + }) => { + const sqlite = new DatabaseSync(":memory:"); + const generatedName = 'generated"name'; + sqlite.exec('CREATE TABLE "generated""name" (id INTEGER PRIMARY KEY)'); + sqlite.exec("CREATE TABLE descending (id INTEGER PRIMARY KEY DESC)"); + const prepare = vi.spyOn(sqlite, "prepare"); + const dialect = new NodeSqliteDialect({ database: sqlite }); + vi.spyOn(dialect, "createIntrospector").mockReturnValue({ + getMetadata: async () => { + throw new Error("introspection unavailable"); + }, + getSchemas: async () => [], + getTables: async () => { + throw new Error("introspection unavailable"); + }, + }); + const db = new Kysely({ dialect }); + onTestFinished(() => db.destroy()); + + const tables = await introspectSqliteTables(db, [ + generatedName, + "descending", + ]); + expect( + tables.map((table) => [table.name, table.columns[0]?.isAutoIncrementing]), + ).toEqual([ + [generatedName, true], + ["descending", false], + ]); + const indexQueries = prepare.mock.calls + .map(([query]) => query.trim()) + .filter((query) => query.includes("index_list")); + expect(indexQueries).toHaveLength(2); + expect(indexQueries).toEqual( + expect.arrayContaining([ + 'PRAGMA index_list("generated""name")', + 'PRAGMA index_list("descending")', + ]), + ); + }); +}); diff --git a/packages/kysely-adapter/src/sqlite-introspector.ts b/packages/kysely-adapter/src/sqlite-introspector.ts index 43b983dfcd..b6584afad1 100644 --- a/packages/kysely-adapter/src/sqlite-introspector.ts +++ b/packages/kysely-adapter/src/sqlite-introspector.ts @@ -22,30 +22,25 @@ export interface PragmaTableInfo { type: string; } +export interface PragmaIndexListRow { + name: string; + origin: "c" | "u" | "pk"; + partial: number; + unique: number; +} + interface SqliteTable { name: string; - sql?: string | null | undefined; type?: string | undefined; } interface SqliteSystemDatabase { sqlite_schema: { name: string; - sql: string | null; type: "index" | "table" | "trigger" | "view"; }; } -function declaredAutoIncrementColumn( - createSql: string | null | undefined, -): string | undefined { - return createSql - ?.split(/[(),]/) - .find((part) => part.toLowerCase().includes("autoincrement")) - ?.split(/\s+/)[0] - ?.replace(/["`]/g, ""); -} - export function sqliteIntegerPrimaryKeyColumn( columns: readonly PragmaTableInfo[], ): string | undefined { @@ -56,6 +51,19 @@ export function sqliteIntegerPrimaryKeyColumn( : undefined; } +async function sqliteGeneratedPrimaryKeyColumn( + db: Kysely, + name: string, + columns: readonly PragmaTableInfo[], +): Promise { + const column = sqliteIntegerPrimaryKeyColumn(columns); + if (!column) return; + const { rows } = await sql` + PRAGMA index_list(${sql.id(name)}) + `.execute(db.withoutPlugins()); + return rows.some((index) => index.origin === "pk") ? undefined : column; +} + export function toSqliteTableMetadata( table: SqliteTable, columns: readonly PragmaTableInfo[], @@ -82,11 +90,12 @@ async function readTableMetadata( PRAGMA table_info(${sql.id(name)}) `.execute(db.withoutPlugins()); if (columns.length === 0) return; - return toSqliteTableMetadata( - { name }, + const generatedColumn = await sqliteGeneratedPrimaryKeyColumn( + db, + name, columns, - sqliteIntegerPrimaryKeyColumn(columns), ); + return toSqliteTableMetadata({ name }, columns, generatedColumn); } export async function introspectSqliteTables( @@ -132,22 +141,18 @@ export function createSqliteIntrospector( const tables = await query.execute(); return Promise.all( tables.map(async ({ name }) => { - const createTable = await systemDb - .selectFrom("sqlite_schema") - .where("name", "=", name) - .select("sql") - .executeTakeFirst(); const columns = await db .selectFrom( sql`pragma_table_info(${name})`.as("table_info"), ) .select(["cid", "name", "type", "notnull", "dflt_value", "pk"]) .execute(); - return toSqliteTableMetadata( - { name, sql: createTable?.sql }, + const generatedColumn = await sqliteGeneratedPrimaryKeyColumn( + db, + name, columns, - declaredAutoIncrementColumn(createTable?.sql), ); + return toSqliteTableMetadata({ name }, columns, generatedColumn); }), ); }