fix(kysely-adapter): detect SQLite rowid aliases correctly (#11374)

This commit is contained in:
Taesu
2026-09-24 01:31:18 +00:00
committed by GitHub
parent f95d5f1c2c
commit 2b13e011b4
7 changed files with 336 additions and 45 deletions
@@ -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`.
@@ -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<CloudflareBindings>().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]);
});
@@ -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<D1IndexListRow>(
const indexLists = await database.batch<PragmaIndexListRow>(
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<PragmaTableInfo>(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<PragmaIndexListRow>(
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));
});
}
@@ -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<unknown>({
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", () => {
@@ -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<unknown>({
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
*/
@@ -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")',
]),
);
});
});
@@ -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<unknown>,
name: string,
columns: readonly PragmaTableInfo[],
): Promise<string | undefined> {
const column = sqliteIntegerPrimaryKeyColumn(columns);
if (!column) return;
const { rows } = await sql<PragmaIndexListRow>`
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<PragmaTableInfo>`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);
}),
);
}