fix(db): refuse adding required no-default columns to populated tables (#10863)

This commit is contained in:
Gustavo Valverde
2026-08-18 13:47:15 -04:00
committed by GitHub
parent c3688ba88e
commit 845bbd1de6
12 changed files with 963 additions and 40 deletions
@@ -0,0 +1,14 @@
---
"better-auth": patch
"auth": patch
---
`auth migrate` no longer attempts to add a required column with no default value to a table that already has rows. It stops with an error naming the column and the backfill to run first. Previously the generated statement failed on SQLite, Postgres, and SQL Server; on MySQL it filled the new column with an empty string for every existing row and reported success. If `auth migrate` already ran against a MySQL database on 1.7, run the check in the upgrade guide's account identity section.
`getMigrations` throws the new `UnsafeMigrationError` (exported from `better-auth/db/migration`) for this refusal, so callers can distinguish it from other migration errors such as an index-definition conflict.
`auth generate` still emits the statements for external migration tooling, with a comment banner naming any column that needs a manual backfill first.
A required field whose database column is still nullable logs a warning instead of blocking the migration.
A CLI command that fails now prints its error and exits with a non-zero code instead of an unhandled promise rejection.
+57 -8
View File
@@ -70,7 +70,29 @@ This covers email and password, social login, the generic OAuth plugin, One Tap,
Better Auth now recognizes an external account by the unique pair of `issuer` and `accountId`. The `providerId` remains the local provider configuration, while `account.id` identifies the Better Auth account row and `account.accountId` remains the stable identifier assigned by the provider. The account schema adds the required `issuer` field and creates a unique compound index across both identity fields without renaming `accountId`.
<Callout type="warn">
Use a maintenance window and stop authentication writes before changing the account schema. The generated migration cannot choose trusted issuers or resolve identity collisions for you.
Use a maintenance window and stop authentication writes, including background jobs and admin APIs that insert into `account` directly, before changing the account schema. The generated migration cannot choose trusted issuers or resolve identity collisions for you.
</Callout>
<Callout type="warn">
If you already ran `auth migrate` against MySQL before this backfill, check for corruption first. Adding a required column with no default fails safely on SQLite, Postgres, and SQL Server, but MySQL's default `sql_mode` silently accepts it and backfills every existing row's `issuer` with an empty string instead of raising an error.
```sql title="MySQL corruption check"
SELECT COUNT(*) FROM account WHERE issuer = '';
```
A nonzero count means the database needs repair, not just backfill: drop the compound unique index before re-backfilling, since it was built over the corrupted empty-string values and throws duplicate-key errors as rows resolve to the same real issuer. MySQL has no `DROP INDEX IF EXISTS` for tables, and the index may not exist yet if index creation never ran, so check first.
```sql title="Drop the compound index if it exists (MySQL)"
SHOW INDEX FROM account WHERE Key_name = 'account_issuer_accountId_uidx';
-- Run the DROP INDEX below only if the query above returned a row.
DROP INDEX account_issuer_accountId_uidx ON account;
```
Recreate it once every row has a correct value, as part of step 5 below.
```sql title="Recreate the compound index (MySQL)"
CREATE UNIQUE INDEX account_issuer_accountId_uidx ON account (issuer, accountId);
```
</Callout>
**Backfill the account identity:**
@@ -79,13 +101,15 @@ Better Auth now recognizes an external account by the unique pair of `issuer` an
2. Add `issuer` as nullable during the backfill. Keep the existing physical `accountId` column.
3. Populate both fields according to the account type:
| Account type | `issuer` | `accountId` |
| -------------------------------- | ----------------------------------------- | -------------------------------------- |
| Credential | `local:credential` | Stable `id` from the linked `user` row |
| Provider with an issuer | Exact trusted issuer used by the provider | Existing provider account identifier |
| OAuth provider without an issuer | `local:oauth:<encoded providerId>` | Existing provider account identifier |
| Account type | `issuer` | `accountId` |
| -------------------------------------- | ------------------------------------------ | ---------------------------------------- |
| Credential | `local:credential` | Stable `id` from the linked `user` row |
| SIWE wallet, `providerId` `siwe` | `local:siwe` | Existing `<address>:<chainId>` value |
| Google One Tap, `providerId` `google` | `https://accounts.google.com` | Existing Google `sub` |
| Provider with an issuer | Exact trusted issuer used by the provider | Existing provider account identifier |
| OAuth provider without an issuer | `local:oauth:<encoded providerId>` | Existing provider account identifier |
The synthetic issuer percent-encodes its provider ID segment exactly as `encodeURIComponent(providerId)`; for example, `local:oauth:github` and `local:oauth:team%2Fgithub`. Build an explicit `providerId`-to-issuer map for your deployment. Multiple provider configurations that represent the same OpenID Connect authority must use the same issuer. Do not derive an issuer from email, display name, an unverified request value, or a mutable authorization endpoint.
The synthetic issuer percent-encodes its provider ID segment exactly as `encodeURIComponent(providerId)`; for example, `local:oauth:github` and `local:oauth:team%2Fgithub`. Build an explicit `providerId`-to-issuer map for your deployment. Multiple provider configurations that represent the same OpenID Connect authority must use the same issuer. Do not derive an issuer from email, display name, an unverified request value, or a mutable authorization endpoint. SIWE and Google One Tap are not OAuth providers, so the synthetic `local:oauth:` namespace never applies to them.
4. Find collisions before creating the unique index. Adapt this query to your physical table and field names:
```sql title="Identity collision check"
@@ -97,7 +121,32 @@ Better Auth now recognizes an external account by the unique pair of `issuer` an
```
If duplicate rows belong to one user, choose the account record to keep and reconcile its provider configuration, tokens, scopes, and timestamps before deleting the others. If a key belongs to multiple users, stop the migration and establish the owner from trusted provider data. Never merge users by matching email alone.
5. Confirm that every row has both identity fields, make `issuer` non-nullable, and add the unique compound index on `issuer` and `accountId`.
5. Confirm that every row has both identity fields, then make `issuer` non-nullable and add the unique compound index on `issuer` and `accountId`. `auth migrate` never emits a statement that makes an existing nullable column non-nullable; run this DDL yourself.
```sql title="Postgres"
ALTER TABLE account ALTER COLUMN issuer SET NOT NULL;
CREATE UNIQUE INDEX account_issuer_accountId_uidx ON account (issuer, accountId);
```
```sql title="MySQL"
ALTER TABLE account MODIFY COLUMN issuer VARCHAR(255) NOT NULL;
CREATE UNIQUE INDEX account_issuer_accountId_uidx ON account (issuer, accountId);
```
```sql title="SQL Server"
ALTER TABLE account ALTER COLUMN issuer VARCHAR(255) NOT NULL;
CREATE UNIQUE INDEX account_issuer_accountId_uidx ON account (issuer, accountId);
```
SQLite has no `ALTER COLUMN` that can add a `NOT NULL` constraint, so rebuild the table instead: create a replacement with the constraint, copy every row across, drop the original, rename the replacement, then recreate every index and foreign key the original table had.
```sql title="SQLite table rebuild"
CREATE TABLE account_new (/* same columns as account, with issuer TEXT NOT NULL */);
INSERT INTO account_new SELECT * FROM account;
DROP TABLE account;
ALTER TABLE account_new RENAME TO account;
CREATE UNIQUE INDEX account_issuer_accountId_uidx ON account (issuer, accountId);
```
6. Update custom adapters, database hooks, and generated schemas to include `issuer`. Credential accounts keep the linked user's stable `id` as `accountId`; email remains a mutable sign-in identifier and does not change the account key.
Account-specific APIs now use explicit, strongly typed selectors. Read `id` from `listAccounts`, then pass it as `accountId` to `unlinkAccount`. For `getAccessToken`, `refreshToken`, and `accountInfo`, choose exactly one of these request shapes:
@@ -0,0 +1,149 @@
import type { BetterAuthOptions } from "@better-auth/core";
import type { RowDataPacket } from "mysql2/promise";
import { createPool } from "mysql2/promise";
import { Pool } from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getMigrations, UnsafeMigrationError } from "./get-migration";
// The populated-table guard in `getMigrations` is dialect-independent by
// contract: MySQL's `sql_mode` silently accepts `ADD COLUMN ... NOT NULL`
// with no default and backfills an implicit value, which is exactly the
// corruption the guard exists to prevent. SQLite coverage lives in
// get-migration.test.ts; this file proves the refusal also holds against
// real Postgres and MySQL connections, and that no statement runs before it.
const POSTGRES_CONNECTION_STRING =
"postgres://user:password@localhost:5433/better_auth";
let isPostgresAvailable = false;
try {
const testPool = new Pool({
connectionString: POSTGRES_CONNECTION_STRING,
connectionTimeoutMillis: 2000,
});
await testPool.query("SELECT 1");
await testPool.end();
isPostgresAvailable = true;
} catch {
isPostgresAvailable = false;
}
const MYSQL_CONNECTION_STRING =
"mysql://user:password@localhost:3307/better_auth";
let isMysqlAvailable = false;
try {
const testPool = createPool({
uri: MYSQL_CONNECTION_STRING,
connectTimeout: 2000,
});
await testPool.query("SELECT 1");
await testPool.end();
isMysqlAvailable = true;
} catch {
isMysqlAvailable = false;
}
function unsafeChangeConfig(database: BetterAuthOptions["database"]) {
return {
database,
plugins: [
{
id: "unsafe-change",
schema: {
populatedNoDefault: {
fields: {
name: { type: "string" as const },
connectionIssuer: {
type: "string" as const,
required: true,
},
},
},
},
},
],
} satisfies BetterAuthOptions;
}
describe.runIf(isPostgresAvailable)(
"PostgreSQL unsafe migration guardrail",
() => {
const schema = "unsafe_change_test";
const pool = new Pool({ connectionString: POSTGRES_CONNECTION_STRING });
const schemaPool = new Pool({
connectionString: `${POSTGRES_CONNECTION_STRING}?options=-c search_path=${schema}`,
});
beforeAll(async () => {
await pool.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
await pool.query(`CREATE SCHEMA ${schema}`);
await schemaPool.query(
`CREATE TABLE "populatedNoDefault" ("id" text primary key not null, "name" text not null)`,
);
await schemaPool.query(
`INSERT INTO "populatedNoDefault" ("id", "name") VALUES ('p1', 'existing-row')`,
);
});
afterAll(async () => {
await pool.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
await pool.end();
await schemaPool.end();
});
it("refuses a required no-default column on a populated table before any statement executes", async () => {
const failure = await getMigrations(unsafeChangeConfig(schemaPool)).catch(
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(UnsafeMigrationError);
const columns = await schemaPool.query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = 'populatedNoDefault'`,
[schema],
);
expect(
columns.rows.some((row) => row.column_name === "connectionIssuer"),
).toBe(false);
});
},
);
describe.runIf(isMysqlAvailable)("MySQL unsafe migration guardrail", () => {
const pool = createPool({ uri: MYSQL_CONNECTION_STRING });
beforeAll(async () => {
await pool.query("DROP TABLE IF EXISTS `populatedNoDefault`");
await pool.query(
"CREATE TABLE `populatedNoDefault` (`id` varchar(36) primary key not null, `name` text not null)",
);
await pool.query(
"INSERT INTO `populatedNoDefault` (`id`, `name`) VALUES ('p1', 'existing-row')",
);
});
afterAll(async () => {
await pool.query("DROP TABLE IF EXISTS `populatedNoDefault`");
await pool.end();
});
/**
* This is the silent-corruption regression the guard exists for: without
* it, MySQL's default `sql_mode` accepts `ADD COLUMN ... NOT NULL` with
* no default and fills every existing row with an implicit empty string,
* reporting a successful migration over corrupted data.
*/
it("refuses a required no-default column on a populated table before any statement executes", async () => {
const failure = await getMigrations(unsafeChangeConfig(pool)).catch(
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(UnsafeMigrationError);
const [rows] = await pool.query<RowDataPacket[]>(
"SELECT COLUMN_NAME FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'populatedNoDefault'",
);
expect(rows.some((row) => row.COLUMN_NAME === "connectionIssuer")).toBe(
false,
);
});
});
@@ -1,8 +1,9 @@
import { DatabaseSync } from "node:sqlite";
import type { BetterAuthOptions } from "@better-auth/core";
import { BetterAuthError } from "@better-auth/core/error";
import { describe, expect, it } from "vitest";
import { organization } from "../plugins/organization";
import { getMigrations } from "./get-migration";
import { getMigrations, UnsafeMigrationError } from "./get-migration";
// A 1.6-shape team/teamMember schema: the 1.7 `memberCount` and `membershipKey`
// columns are missing, so getMigrations must ADD them to a populated table.
@@ -172,6 +173,34 @@ describe("get-migration: compound indexes on SQLite", () => {
);
});
/**
* An index-definition conflict is a plain `BetterAuthError`, never the
* `UnsafeMigrationError` the CLI narrows its catch on. Callers that only
* check `instanceof UnsafeMigrationError` must let this rethrow instead
* of treating it as the populated-table column refusal.
*/
it("does not classify an index-definition conflict as UnsafeMigrationError", async () => {
const error = await getMigrations({
database: new DatabaseSync(":memory:"),
plugins: [
{
id: "directory",
schema: {
directoryUser: {
fields: {
subject: { type: "string", index: true },
},
indexes: [{ fields: ["subject"] }],
},
},
},
],
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BetterAuthError);
expect(error).not.toBeInstanceOf(UnsafeMigrationError);
});
it("enforces a compound unique index with configured table and field names", async () => {
const db = new DatabaseSync(":memory:");
const config: BetterAuthOptions = {
@@ -410,3 +439,385 @@ describe("get-migration: compound indexes on SQLite", () => {
);
});
});
const backfillGuide =
"https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer";
// A 1.6-shape account table. `issuer` is either absent (the 1.7 column has not
// been added yet) or present as a nullable column (added by hand without the
// documented NOT NULL step).
function createAccountDb({
issuer,
issuerColumn = "issuer",
seeded = true,
}: {
issuer?: "nullable" | "notNull";
issuerColumn?: string;
seeded?: boolean;
}) {
const db = new DatabaseSync(":memory:");
db.exec(
`CREATE TABLE "user" (
"id" text primary key not null,
"name" text not null,
"email" text not null unique,
"emailVerified" integer not null,
"image" text,
"createdAt" date not null,
"updatedAt" date not null
)`,
);
const issuerDefinition =
issuer === "nullable"
? `"${issuerColumn}" text,`
: issuer === "notNull"
? `"${issuerColumn}" text not null,`
: "";
db.exec(
`CREATE TABLE "account" (
"id" text primary key not null,
${issuerDefinition}
"accountId" text not null,
"providerId" text not null,
"userId" text not null references "user" ("id"),
"accessToken" text,
"refreshToken" text,
"idToken" text,
"accessTokenExpiresAt" date,
"refreshTokenExpiresAt" date,
"scope" text,
"password" text,
"createdAt" date not null,
"updatedAt" date not null
)`,
);
if (!seeded) return db;
db.exec(
`INSERT INTO "user" ("id", "name", "email", "emailVerified", "createdAt", "updatedAt")
VALUES ('u1', 'Ada', 'ada@example.com', 1, '2020-01-01', '2020-01-01')`,
);
const issuerValue = issuer ? `, 'https://accounts.google.com'` : "";
const issuerTarget = issuer ? `, "${issuerColumn}"` : "";
db.exec(
`INSERT INTO "account" ("id", "accountId", "providerId", "userId", "createdAt", "updatedAt"${issuerTarget})
VALUES ('a1', '10769150350006150715113082367', 'google', 'u1', '2020-01-01', '2020-01-01'${issuerValue})`,
);
return db;
}
// A required additional field whose live column was left nullable, on a table
// that has nothing to do with account identity.
function addNullableTierColumn(db: DatabaseSync) {
db.exec(`ALTER TABLE "user" ADD COLUMN "tier" text`);
db.exec(`UPDATE "user" SET "tier" = 'free'`);
return db;
}
const tierField = {
user: { additionalFields: { tier: { type: "string", required: true } } },
} satisfies BetterAuthOptions;
function captureFailure(promise: Promise<unknown>) {
return promise.then(
() => null,
(error: unknown) => error,
);
}
function warnLogger(warnings: string[]) {
return {
level: "warn" as const,
log: (level: string, message: string) => {
if (level === "warn") warnings.push(message);
},
};
}
describe("get-migration: unsafe schema changes on populated tables", () => {
it("refuses to add a required column without a default to a populated table", async () => {
const db = new DatabaseSync(":memory:");
db.exec(
`CREATE TABLE "directoryUser" (
"id" text primary key not null,
"externalId" text not null
)`,
);
db.exec(
`INSERT INTO "directoryUser" ("id", "externalId") VALUES ('du1', 'employee-1')`,
);
const failure = await captureFailure(
getMigrations({
database: db,
plugins: [
{
id: "directory",
schema: {
directoryUser: {
fields: {
externalId: { type: "string" },
connectionIssuer: { type: "string", required: true },
},
},
},
},
],
}),
);
expect(failure).toBeInstanceOf(BetterAuthError);
expect(failure).toBeInstanceOf(UnsafeMigrationError);
expect(String(failure)).toContain(
'Cannot add required column "connectionIssuer" to populated table "directoryUser"',
);
expect(String(failure)).toContain("MySQL");
expect(String(failure)).toContain("empty string");
expect(String(failure)).not.toContain(backfillGuide);
});
/**
* @see https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer
*/
it("refuses to add the account issuer column to a populated account table, pointing at the upgrade guide", async () => {
const failure = await captureFailure(
getMigrations({ database: createAccountDb({}) }),
);
expect(failure).toBeInstanceOf(BetterAuthError);
expect(String(failure)).toContain(
'Cannot add required column "issuer" to populated table "account"',
);
expect(String(failure)).toContain(backfillGuide);
});
it("points at the upgrade guide through a renamed issuer column", async () => {
const failure = await captureFailure(
getMigrations({
database: createAccountDb({ issuerColumn: "identity_issuer" }),
account: { fields: { issuer: "identity_issuer" } },
}),
);
expect(String(failure)).toContain(
'Cannot add required column "identity_issuer" to populated table "account"',
);
expect(String(failure)).toContain(backfillGuide);
});
it("plans a required column without a default when the table is empty", async () => {
const { compileMigrations, toBeAdded } = await getMigrations({
database: createAccountDb({ seeded: false }),
});
expect(toBeAdded.find((t) => t.table === "account")?.fields).toHaveProperty(
"issuer",
);
expect((await compileMigrations()).toLowerCase()).toContain(
'add column "issuer" text not null',
);
});
it("still adds a required column with a static default to a populated table", async () => {
const db = new DatabaseSync(":memory:");
db.exec(
`CREATE TABLE "directoryUser" (
"id" text primary key not null,
"externalId" text not null
)`,
);
db.exec(
`INSERT INTO "directoryUser" ("id", "externalId") VALUES ('du1', 'employee-1')`,
);
const { compileMigrations, runMigrations } = await getMigrations({
database: db,
plugins: [
{
id: "directory",
schema: {
directoryUser: {
fields: {
externalId: { type: "string" },
connectionIssuer: {
type: "string",
required: true,
defaultValue: "local:directory",
},
},
},
},
},
],
});
expect((await compileMigrations()).toLowerCase()).toContain(
`add column "connectionissuer" text default 'local:directory' not null`,
);
await runMigrations();
const row = db
.prepare(`SELECT "connectionIssuer" FROM "directoryUser"`)
.get() as { connectionIssuer: string };
expect(row.connectionIssuer).toBe("local:directory");
});
});
describe("get-migration: nullable columns for required fields", () => {
it("warns and proceeds when a required field's live column is nullable", async () => {
const db = addNullableTierColumn(createAccountDb({ issuer: "notNull" }));
const warnings: string[] = [];
const { runMigrations, toBeCreated } = await getMigrations({
database: db,
...tierField,
logger: warnLogger(warnings),
plugins: [
{
id: "directory",
schema: {
directoryUser: {
fields: { externalId: { type: "string", required: true } },
},
},
},
],
});
expect(
warnings.some((warning) =>
warning.includes('Column "tier" on table "user"'),
),
).toBe(true);
expect(toBeCreated.map((table) => table.table)).toContain("directoryUser");
await runMigrations();
expect(
db
.prepare(
`SELECT "name" FROM sqlite_master WHERE "name" = 'directoryUser'`,
)
.get(),
).toBeDefined();
});
it("accepts a required field whose live column is not null", async () => {
const { compileMigrations, toBeAdded } = await getMigrations({
database: createAccountDb({ issuer: "notNull" }),
});
expect(toBeAdded.find((t) => t.table === "account")).toBeUndefined();
expect(await compileMigrations()).toContain(
'create unique index "account_issuer_accountId_uidx"',
);
});
it("warns with the real field and table name for the account issuer column", async () => {
const warnings: string[] = [];
await getMigrations({
database: createAccountDb({ issuer: "nullable" }),
logger: warnLogger(warnings),
});
expect(
warnings.some((warning) =>
warning.includes('Column "issuer" on table "account"'),
),
).toBe(true);
});
it("warns about both nullable drift and a type mismatch on the same column", async () => {
const db = new DatabaseSync(":memory:");
db.exec(
`CREATE TABLE "directoryUser" (
"id" text primary key not null,
"seatCount" text
)`,
);
db.exec(`INSERT INTO "directoryUser" ("id") VALUES ('du1')`);
const warnings: string[] = [];
await getMigrations({
database: db,
logger: warnLogger(warnings),
plugins: [
{
id: "directory",
schema: {
directoryUser: {
fields: { seatCount: { type: "number", required: true } },
},
},
},
],
});
expect(
warnings.some((warning) =>
warning.includes('Column "seatCount" on table "directoryUser"'),
),
).toBe(true);
expect(
warnings.some((warning) =>
warning.includes(
"Field seatCount in table directoryUser has a different type in the database",
),
),
).toBe(true);
});
});
describe("get-migration: inspecting a migration that cannot be applied", () => {
it("reports the unsafe column change and still compiles the statements", async () => {
const { compileMigrations, unsafeChanges } = await getMigrations(
{ database: createAccountDb({}) },
{ throwOnUnsafe: false },
);
expect(unsafeChanges).toHaveLength(1);
expect(unsafeChanges[0]).toContain(
'Cannot add required column "issuer" to populated table "account"',
);
expect((await compileMigrations()).toLowerCase()).toContain(
'alter table "account" add column "issuer" text not null',
);
});
it("keeps the empty-string detail out of a non-text column's message", async () => {
const db = new DatabaseSync(":memory:");
db.exec(
`CREATE TABLE "directoryUser" ("id" text primary key not null, "externalId" text not null)`,
);
db.exec(
`INSERT INTO "directoryUser" ("id", "externalId") VALUES ('du1', 'employee-1')`,
);
const { unsafeChanges } = await getMigrations(
{
database: db,
plugins: [
{
id: "directory",
schema: {
directoryUser: {
fields: {
externalId: { type: "string" },
seatCount: { type: "number", required: true },
},
},
},
},
],
},
{ throwOnUnsafe: false },
);
expect(unsafeChanges[0]).toContain(
'Cannot add required column "seatCount" to populated table "directoryUser"',
);
expect(unsafeChanges[0]).toContain("implicit default for the column type");
expect(unsafeChanges[0]).not.toContain("empty string");
});
});
+115 -23
View File
@@ -464,6 +464,58 @@ function assertExistingTableIndexFits({
}
}
const columnBackfillGuideUrl =
"https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer";
/**
* Thrown when {@link getMigrations} refuses to add a required column with no
* default value to a populated table. Distinct from the plain
* {@link BetterAuthError} thrown for index-definition conflicts, so callers
* can tell the two apart without matching on message text.
*/
export class UnsafeMigrationError extends BetterAuthError {}
function hasTimestampColumnDefault(
field: DBFieldAttribute,
dbType: KyselyDatabaseType,
) {
return (
field.type === "date" &&
typeof field.defaultValue === "function" &&
(dbType === "postgres" || dbType === "mysql" || dbType === "mssql")
);
}
// A required column added to a populated table needs a SQL default, or the NOT
// NULL add fails. Nullable unique columns are excluded: NULL is their only
// unique-safe backfill. A required unique column keeps its default; on a table
// with more than one row the unique index then rejects the shared backfill,
// which no generated migration can avoid.
function hasStaticColumnDefault(field: DBFieldAttribute) {
return (
!(field.unique && field.required === false) &&
(field.type === "string" ||
field.type === "number" ||
field.type === "boolean") &&
field.defaultValue !== undefined &&
field.defaultValue !== null &&
typeof field.defaultValue !== "function"
);
}
async function tableHasRows(
db: Kysely<Record<string, Record<string, unknown>>>,
dbType: KyselyDatabaseType,
table: string,
) {
const probe = db.selectFrom(table).select(sql`1`.as("present"));
const rows = await (dbType === "mssql"
? probe.top(1)
: probe.limit(1)
).execute();
return rows.length > 0;
}
export function matchType(
columnDataType: string,
fieldType: DBFieldType,
@@ -524,9 +576,38 @@ async function getMssqlSchema(db: Kysely<unknown>): Promise<string> {
}
}
export async function getMigrations(config: BetterAuthOptions) {
/**
* Build the migration plan that `auth migrate` executes and `auth generate`
* prints for the Kysely adapter.
*
* Adding a required column without a default to a populated table is refused:
* existing rows have no value to backfill. `throwOnUnsafe` picks how that
* refusal is delivered: executing callers get an {@link UnsafeMigrationError},
* read-only callers get the plan plus the same message in `unsafeChanges`.
*
* @throws {UnsafeMigrationError} when a required column cannot be migrated
* safely and `throwOnUnsafe` is left on.
* @throws {BetterAuthError} when an index definition conflicts with an
* existing or already-planned index.
*/
export async function getMigrations(
config: BetterAuthOptions,
{ throwOnUnsafe = true }: { throwOnUnsafe?: boolean } = {},
) {
const betterAuthSchema = getSchema(config);
const authTables = getAuthTables(config);
const accountIssuer = authTables.account && {
table: authTables.account.modelName,
column: authTables.account.fields.issuer?.fieldName || "issuer",
};
const isAccountIssuerColumn = (table: string, column: string) =>
table === accountIssuer?.table && column === accountIssuer.column;
const logger = createLogger(config.logger);
const unsafeChanges: string[] = [];
const reportUnsafeChange = (message: string) => {
if (throwOnUnsafe) throw new UnsafeMigrationError(message);
unsafeChanges.push(message);
};
let { kysely: db, databaseType: dbType } = await createKyselyAdapter(config);
@@ -739,6 +820,12 @@ export async function getMigrations(config: BetterAuthOptions) {
continue;
}
if (field.required !== false && column.isNullable) {
logger.warn(
`Column "${fieldName}" on table "${key}" stays nullable while the schema declares the field required, so existing rows can still hold null. Backfill every row for this column and enforce NOT NULL to remove the drift.`,
);
}
if (matchType(column.dataType, field.type, dbType)) {
continue;
} else {
@@ -888,11 +975,11 @@ export async function getMigrations(config: BetterAuthOptions) {
return typeMap[type][provider];
}
const getModelName = initGetModelName({
schema: getAuthTables(config),
schema: authTables,
usePlural: false,
});
const getFieldName = initGetFieldName({
schema: getAuthTables(config),
schema: authTables,
usePlural: false,
});
@@ -925,8 +1012,30 @@ export async function getMigrations(config: BetterAuthOptions) {
};
if (toBeAdded.length) {
const populatedTables = new Map<string, boolean>();
for (const table of toBeAdded) {
for (const [fieldName, field] of Object.entries(table.fields)) {
const timestampDefault = hasTimestampColumnDefault(field, dbType);
const staticDefault = hasStaticColumnDefault(field);
if (field.required !== false && !timestampDefault && !staticDefault) {
let populated = populatedTables.get(table.table);
if (populated === undefined) {
populated = await tableHasRows(db, dbType, table.table);
populatedTables.set(table.table, populated);
}
if (populated) {
const textDetail =
field.type === "string"
? " For a text column, every existing row ends up with the same empty string."
: "";
const guideLink = isAccountIssuerColumn(table.table, fieldName)
? ` See ${columnBackfillGuideUrl}`
: "";
reportUnsafeChange(
`Cannot add required column "${fieldName}" to populated table "${table.table}": the schema declares no default value, so existing rows have no value to backfill. MySQL accepts this statement instead of rejecting it and fills every existing row with an implicit default for the column type, reporting a successful migration over corrupted data.${textDetail} Add the column as nullable, backfill a correct value for every row, then make it NOT NULL.${guideLink}`,
);
}
}
const type = getType(
field,
fieldName,
@@ -980,31 +1089,13 @@ export async function getMigrations(config: BetterAuthOptions) {
)
.onDelete(field.references.onDelete || "cascade");
}
if (
field.type === "date" &&
typeof field.defaultValue === "function" &&
(dbType === "postgres" || dbType === "mysql" || dbType === "mssql")
) {
if (timestampDefault) {
if (dbType === "mysql") {
col = col.defaultTo(sql`CURRENT_TIMESTAMP(3)`);
} else {
col = col.defaultTo(sql`CURRENT_TIMESTAMP`);
}
} else if (
!(field.unique && field.required === false) &&
(field.type === "string" ||
field.type === "number" ||
field.type === "boolean") &&
field.defaultValue !== undefined &&
field.defaultValue !== null &&
typeof field.defaultValue !== "function"
) {
// A required column added to a populated table needs a SQL
// default, or the NOT NULL add fails. Nullable unique columns
// are excluded: NULL is their only unique-safe backfill. A
// required unique column keeps its default; on a table with
// more than one row the unique index then rejects the shared
// backfill, which no generated migration can avoid.
} else if (staticDefault) {
// Booleans map to 1/0 on engines without a native boolean type.
col = col.defaultTo(
typeof field.defaultValue === "boolean" &&
@@ -1129,6 +1220,7 @@ export async function getMigrations(config: BetterAuthOptions) {
toBeCreated,
toBeAdded,
toBeAddedIndexes,
unsafeChanges,
runMigrations,
compileMigrations,
};
+15
View File
@@ -200,6 +200,21 @@ async function generateAction(opts: any) {
});
spinner.stop();
if (schema.unsafeChanges?.length) {
console.warn(
chalk.red.bold(
`${schema.unsafeChanges.length} ${schema.unsafeChanges.length === 1 ? "change in this schema corrupts" : "changes in this schema corrupt"} a populated database.`,
),
);
for (const change of schema.unsafeChanges) {
console.warn(chalk.red(`-> ${change}`));
}
console.warn(
chalk.red.bold(
"The generated script carries the same warning. Fix the reported columns before you run it.",
),
);
}
if (!schema.code) {
await removeGeneratedStub();
console.log("Your schema is already up to date.");
+25 -3
View File
@@ -5,7 +5,7 @@ import {
getTelemetryAuthConfig,
} from "@better-auth/telemetry";
import { getAdapter } from "better-auth/db/adapter";
import { getMigrations } from "better-auth/db/migration";
import { getMigrations, UnsafeMigrationError } from "better-auth/db/migration";
import chalk from "chalk";
import { Command } from "commander";
import prompts from "prompts";
@@ -102,8 +102,30 @@ export async function migrateAction(opts: any) {
const spinner = yoctoSpinner({ text: "preparing migration..." }).start();
const { toBeAdded, toBeAddedIndexes, toBeCreated, runMigrations } =
await getMigrations(config);
let plan: Awaited<ReturnType<typeof getMigrations>>;
try {
plan = await getMigrations(config);
} catch (error) {
spinner.stop();
if (!(error instanceof UnsafeMigrationError)) throw error;
console.error(chalk.red("The migration was refused, and nothing ran."));
console.error(error.message);
console.error(
`Run ${chalk.yellow("npx auth@latest generate")} to read the statements without executing them.`,
);
try {
const telemetry = await createTelemetry(config);
await telemetry.publish({
type: "cli_migrate",
payload: {
outcome: "unsafe_change",
config: await getTelemetryAuthConfig(config),
},
});
} catch {}
process.exit(1);
}
const { toBeAdded, toBeAddedIndexes, toBeCreated, runMigrations } = plan;
if (!toBeAdded.length && !toBeAddedIndexes.length && !toBeCreated.length) {
spinner.stop();
+2 -1
View File
@@ -3,6 +3,7 @@ import type { DBAdapter } from "@better-auth/core/db/adapter";
import { generateDrizzleSchema } from "./drizzle";
import { generateKyselySchema } from "./kysely";
import { generatePrismaSchema } from "./prisma";
import type { SchemaGeneratorResult } from "./types";
export const adapters = {
prisma: generatePrismaSchema,
@@ -14,7 +15,7 @@ export const generateSchema = async (opts: {
adapter: DBAdapter;
file?: string;
options: BetterAuthOptions;
}) => {
}): Promise<SchemaGeneratorResult> => {
const adapter = opts.adapter;
// Adapter-provided createSchema takes priority over built-in generators.
+22 -2
View File
@@ -1,14 +1,34 @@
import { getMigrations } from "better-auth/db/migration";
import type { SchemaGenerator } from "./types";
function commentBanner(unsafeChanges: string[]): string {
const rule = `-- ${"-".repeat(77)}`;
const lines = [
rule,
"-- DO NOT RUN THIS SCRIPT AS IT IS.",
"-- Applying it to a populated database corrupts the rows it touches:",
];
for (const change of unsafeChanges) {
lines.push("--", `-- ${change}`);
}
lines.push(rule, "");
return lines.join("\n");
}
export const generateKyselySchema: SchemaGenerator = async ({
options,
file,
}) => {
const { compileMigrations } = await getMigrations(options);
const { compileMigrations, unsafeChanges } = await getMigrations(options, {
throwOnUnsafe: false,
});
const migrations = await compileMigrations();
const code = migrations.trim() === ";" ? "" : migrations;
return {
code: migrations.trim() === ";" ? "" : migrations,
code: unsafeChanges.length
? `${commentBanner(unsafeChanges)}${code}`
: code,
unsafeChanges,
fileName:
file ||
`./better-auth_migrations/${new Date()
+5
View File
@@ -6,6 +6,11 @@ export interface SchemaGeneratorResult {
fileName: string;
overwrite?: boolean;
append?: boolean;
/**
* Schema changes the generated code contains but no database can apply
* without corrupting the rows it already holds.
*/
unsafeChanges?: string[];
}
export interface SchemaGenerator {
+7 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { BetterAuthError } from "@better-auth/core/error";
import { Command } from "commander";
import { ai } from "./commands/ai";
import { createAdmin } from "./commands/create-admin";
@@ -37,10 +38,14 @@ async function main() {
.description("Better Auth CLI")
.action(() => program.help());
program.parse();
await program.parseAsync();
}
main().catch((error) => {
console.error("Error running Better Auth CLI:", error);
if (error instanceof BetterAuthError) {
console.error(error.message);
} else {
console.error("Error running Better Auth CLI:", error);
}
process.exit(1);
});
+140
View File
@@ -0,0 +1,140 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import Database from "better-sqlite3";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { cliPath } from "./utils";
const execFileAsync = promisify(execFile);
async function runCli(args: string[], cwd: string) {
try {
const { stdout, stderr } = await execFileAsync(
process.execPath,
[cliPath, ...args],
{
cwd,
env: { ...process.env, BETTER_AUTH_TELEMETRY_DISABLED: "true" },
},
);
return { exitCode: 0, output: `${stdout}${stderr}` };
} catch (error) {
const failure = error as {
code?: number;
stdout?: string;
stderr?: string;
};
return {
exitCode: failure.code ?? 1,
output: `${failure.stdout ?? ""}${failure.stderr ?? ""}`,
};
}
}
const projects: string[] = [];
// A pre-1.7 SQLite database: the account table predates the `issuer` column
// and already holds a row, so the guardrail must refuse to add it.
function createProject() {
const cacheDir = path.join(
process.cwd(),
"node_modules",
".cache",
"migrate-cli-",
);
fs.mkdirSync(path.dirname(cacheDir), { recursive: true });
const cwd = fs.mkdtempSync(cacheDir);
projects.push(cwd);
const databasePath = path.join(cwd, "app.db");
fs.writeFileSync(
path.join(cwd, "auth.ts"),
`import { betterAuth } from "better-auth";
import Database from "better-sqlite3";
export const auth = betterAuth({
database: new Database(${JSON.stringify(databasePath)}),
secret: "a-secret-long-enough-to-keep-the-cli-quiet",
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
});
`,
);
const database = new Database(databasePath);
database.exec(
`CREATE TABLE "user" ("id" text primary key not null, "name" text not null, "email" text not null unique, "emailVerified" integer not null, "image" text, "createdAt" date not null, "updatedAt" date not null)`,
);
database.exec(
`CREATE TABLE "account" (
"id" text primary key not null,
"accountId" text not null,
"providerId" text not null,
"userId" text not null references "user" ("id") on delete cascade,
"createdAt" date not null,
"updatedAt" date not null
)`,
);
database.exec(
`INSERT INTO "user" ("id", "name", "email", "emailVerified", "createdAt", "updatedAt") VALUES ('u1', 'Ada', 'ada@example.com', 1, '2020-01-01', '2020-01-01')`,
);
database.exec(
`INSERT INTO "account" ("id", "accountId", "providerId", "userId", "createdAt", "updatedAt") VALUES ('a1', 'g-1', 'google', 'u1', '2020-01-01', '2020-01-01')`,
);
database.close();
return { cwd };
}
beforeAll(() => {
if (!fs.existsSync(cliPath)) {
throw new Error(
`CLI binary not found at "${cliPath}". Run "pnpm --filter auth build" before running this test.`,
);
}
});
afterEach(() => {
for (const cwd of projects.splice(0)) {
fs.rmSync(cwd, { recursive: true, force: true });
}
});
describe("auth migrate: refusing a destructive column add", () => {
it("exits 1 with a clean refusal and no stack trace", async () => {
const { cwd } = createProject();
const { exitCode, output } = await runCli(
["migrate", "--config", "auth.ts", "--yes"],
cwd,
);
expect(exitCode).toBe(1);
expect(output).toContain(
'Cannot add required column "issuer" to populated table "account"',
);
expect(output).toContain(
"https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer",
);
expect(output).not.toContain("triggerUncaughtException");
expect(output).not.toContain("node:internal");
expect(output).not.toMatch(/at .*:\d+:\d+/);
});
});
describe("auth generate: emitting the refused migration with a warning", () => {
it("exits 0 and writes the warning banner alongside the computed statements", async () => {
const { cwd } = createProject();
const { exitCode, output } = await runCli(
["generate", "--config", "auth.ts", "--output", "migration.sql", "--yes"],
cwd,
);
expect(exitCode).toBe(0);
expect(output).toContain("corrupts a populated database");
const sql = fs.readFileSync(path.join(cwd, "migration.sql"), "utf-8");
expect(sql).toContain("DO NOT RUN THIS SCRIPT AS IT IS.");
expect(sql.toLowerCase()).toContain(
'alter table "account" add column "issuer" text not null',
);
});
});