mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
feat(db): check the database schema on initialization (#11178)
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"better-auth": patch
|
||||
"@better-auth/core": patch
|
||||
"@better-auth/kysely-adapter": patch
|
||||
"auth": patch
|
||||
---
|
||||
|
||||
Report missing tables, missing columns, and required columns Better Auth never writes during initialization, with guidance for fixing them. Kysely checks the live database schema. Authentication requests await the same check and are rejected if the schema does not match.
|
||||
|
||||
Validation is enabled by default, including in production. Set `advanced.database.validateSchema: false` to disable runtime validation. `auth migrate` refuses to apply changes when required unwritten columns need manual repair.
|
||||
@@ -123,6 +123,28 @@ await runMigrations();
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
## Schema Validation
|
||||
|
||||
During initialization, Better Auth compares the schema with the tables it writes and reports missing tables, missing columns, and required columns it never fills, together with their fixes. Errors appear through your configured logger without waiting for an authentication request. Requests await the same check and fail if the schema does not match; validation does not automatically stop your server or build.
|
||||
|
||||
Validation is enabled by default, including in production, and caches a clean result or mismatch per adapter instance. Programmatic migrations invalidate cached checks for the same database instance. Requests waiting on an invalidated check await the new result. Restart after applying schema changes with other tools. Kysely reads live database metadata and needs database access during initialization. Custom adapters without a registered check are not validated.
|
||||
|
||||
For adapters without schema validation, initialization logs a warning if you explicitly set `validateSchema: true`, or a debug message if you omit the option. Database operations proceed normally.
|
||||
|
||||
Set `advanced.database.validateSchema` to `false` to disable runtime validation and its skip message. `auth migrate` and `auth generate` retain their own schema diagnostics.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
export const auth = betterAuth({
|
||||
advanced: {
|
||||
database: {
|
||||
validateSchema: false, // [!code highlight]
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Secondary Storage
|
||||
|
||||
Secondary storage in Better Auth allows you to use key-value stores for managing session data, verification records, rate limiting counters, and other short-lived auth data. This can be useful when you want to offload the storage of intensive records to a high performance storage or even RAM.
|
||||
|
||||
@@ -648,6 +648,7 @@ export const auth = betterAuth({
|
||||
})) | false | "serial" | "uuid",
|
||||
defaultFindManyLimit: 100,
|
||||
joins: false,
|
||||
validateSchema: true,
|
||||
},
|
||||
backgroundTasks: {
|
||||
handler: (promise) => { /* e.g. waitUntil(promise) */ }
|
||||
@@ -680,6 +681,7 @@ Set custom strategies for ID generation, findMany queries, and database joins.
|
||||
* `generateId`: Controls how record IDs are generated. Accepts a custom function, `false`, `"serial"`, or `"uuid"` (default: [random base62 string](https://github.com/better-auth/better-auth/blob/main/packages/core/src/utils/id.ts)). See the [Database documentation](/docs/concepts/database#id-generation) for more info.
|
||||
* `defaultFindManyLimit`: The default maximum number of records returned by the `findMany` adapter method. (default: `100`)
|
||||
* `joins`: Enable database joins for adapters that support them. When disabled (default), related data is fetched via separate queries. See the [Database documentation](/docs/concepts/database#joins) for more info. (default: `false`)
|
||||
* `validateSchema`: Compare the schema with the tables Better Auth writes before the first request and report every difference with its fix. See the [Database documentation](/docs/concepts/database#schema-validation) for more info. (default: `true` outside production)
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
@@ -300,6 +300,9 @@ export const router = <Option extends BetterAuthOptions>(
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const pendingSchemaCheck = ctx.checkSchema?.();
|
||||
if (pendingSchemaCheck) await pendingSchemaCheck;
|
||||
|
||||
let currentRequest = req;
|
||||
|
||||
const rateLimitResponse = await onRequestRateLimit(currentRequest, ctx);
|
||||
|
||||
@@ -91,6 +91,8 @@ export function toAuthEndpoints<const E extends Record<string, Endpoint>>(
|
||||
|
||||
const run = async () => {
|
||||
const rawContext = await ctx;
|
||||
const pendingSchemaCheck = rawContext.checkSchema?.();
|
||||
if (pendingSchemaCheck) await pendingSchemaCheck;
|
||||
const authContext = isDynamicBaseURLConfig(rawContext.options.baseURL)
|
||||
? await resolveDynamicContext(rawContext, context)
|
||||
: rawContext;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AuthContext, BetterAuthOptions } from "@better-auth/core";
|
||||
import { runWithAdapter } from "@better-auth/core/context";
|
||||
import { SchemaMismatchError } from "@better-auth/core/db/internal";
|
||||
import { BASE_ERROR_CODES, BetterAuthError } from "@better-auth/core/error";
|
||||
import { getEndpoints, router } from "../api";
|
||||
import {
|
||||
@@ -15,7 +16,26 @@ export const createBetterAuth = <Options extends BetterAuthOptions>(
|
||||
options: Options,
|
||||
initFn: (options: Options) => Promise<AuthContext>,
|
||||
): Auth<Options> => {
|
||||
const authContext = initFn(options);
|
||||
const authContext = initFn(options).then((ctx) => {
|
||||
const validateSchema = ctx.options.advanced?.database?.validateSchema;
|
||||
if (!ctx.checkSchema && validateSchema !== false) {
|
||||
const level = validateSchema === true ? "warn" : "debug";
|
||||
ctx.logger[level](
|
||||
`Schema validation is not available for adapter "${ctx.adapter.id}". Skipping schema validation. Database operations will proceed normally.`,
|
||||
);
|
||||
}
|
||||
const pendingSchemaCheck = ctx.checkSchema?.();
|
||||
if (pendingSchemaCheck) {
|
||||
void pendingSchemaCheck.catch((error: unknown) => {
|
||||
ctx.logger.error(
|
||||
error instanceof SchemaMismatchError
|
||||
? error.message
|
||||
: "Could not validate the database schema. Check your database connection.",
|
||||
);
|
||||
});
|
||||
}
|
||||
return ctx;
|
||||
});
|
||||
const { api } = getEndpoints(authContext, options);
|
||||
const errorCodes = options.plugins?.reduce((acc, plugin) => {
|
||||
if (plugin.$ERROR_CODES) {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { BetterAuthOptions } from "@better-auth/core";
|
||||
import type { SchemaFinding } from "@better-auth/core/db/internal";
|
||||
import {
|
||||
createSchemaCheck,
|
||||
registerSchemaCheck,
|
||||
SchemaMismatchError,
|
||||
} from "@better-auth/core/db/internal";
|
||||
import { createLogger } from "@better-auth/core/env";
|
||||
import { memoryAdapter } from "@better-auth/memory-adapter";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { betterAuth } from "./full";
|
||||
import { betterAuth as betterAuthMinimal } from "./minimal";
|
||||
|
||||
const issuerDrift: SchemaFinding[] = [
|
||||
{ kind: "unexpected-required-column", table: "account", column: "issuer" },
|
||||
];
|
||||
|
||||
describe.each([
|
||||
["full", betterAuth],
|
||||
["minimal", betterAuthMinimal],
|
||||
] as const)("%s initialization schema validation", (_name, initialize) => {
|
||||
function createAuth(
|
||||
find: () => Promise<SchemaFinding[]>,
|
||||
options: Partial<BetterAuthOptions> = {},
|
||||
) {
|
||||
const log = vi.fn();
|
||||
const database = (options: BetterAuthOptions) => {
|
||||
const adapter = memoryAdapter({})(options);
|
||||
registerSchemaCheck(adapter, createSchemaCheck(find, "database"));
|
||||
return adapter;
|
||||
};
|
||||
const auth = initialize({
|
||||
baseURL: "https://auth.example.com",
|
||||
secret: "schema-validation-test-secret-at-least-32-characters",
|
||||
database,
|
||||
logger: { log },
|
||||
...options,
|
||||
});
|
||||
return { auth, log };
|
||||
}
|
||||
|
||||
it.for([
|
||||
{ validateSchema: true, disabled: false, level: "warn" },
|
||||
{ validateSchema: undefined, disabled: false, level: "debug" },
|
||||
{ validateSchema: false, disabled: false, level: undefined },
|
||||
{ validateSchema: true, disabled: true, level: undefined },
|
||||
] as const)("reports skipped validation: $validateSchema, logger disabled: $disabled", async ({
|
||||
validateSchema,
|
||||
disabled,
|
||||
level,
|
||||
}) => {
|
||||
const log = vi.fn();
|
||||
const auth = initialize({
|
||||
baseURL: "https://auth.example.com",
|
||||
secret: "schema-validation-test-secret-at-least-32-characters",
|
||||
database: (options) => ({ ...memoryAdapter({})(options), id: "custom" }),
|
||||
advanced: { database: { validateSchema } },
|
||||
logger: { log, level: "debug", disabled },
|
||||
});
|
||||
|
||||
await auth.$context;
|
||||
if (level) {
|
||||
expect(log).toHaveBeenCalledExactlyOnceWith(
|
||||
level,
|
||||
'Schema validation is not available for adapter "custom". Skipping schema validation. Database operations will proceed normally.',
|
||||
);
|
||||
} else {
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
}
|
||||
const initialLogCount = log.mock.calls.length;
|
||||
const response = await auth.handler(
|
||||
new Request("https://auth.example.com/api/auth/get-session"),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
await expect(
|
||||
auth.api.getSession({ headers: new Headers() }),
|
||||
).resolves.toBeNull();
|
||||
expect(log).toHaveBeenCalledTimes(initialLogCount);
|
||||
});
|
||||
|
||||
it("starts validation without adding a public readiness API", async () => {
|
||||
const find = vi.fn(async () => []);
|
||||
const log = vi.fn();
|
||||
const { auth } = createAuth(find, {
|
||||
advanced: { database: { validateSchema: true } },
|
||||
logger: { log, level: "debug" },
|
||||
});
|
||||
await auth.$context;
|
||||
|
||||
expect(find).toHaveBeenCalledTimes(1);
|
||||
expect(auth).not.toHaveProperty("$ready");
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shares the initialization verdict with concurrent requests", async () => {
|
||||
let finish = (_findings: SchemaFinding[]) => {};
|
||||
const pending = new Promise<SchemaFinding[]>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const find = vi.fn(() => pending);
|
||||
const { auth } = createAuth(find);
|
||||
await auth.$context;
|
||||
expect(find).toHaveBeenCalledTimes(1);
|
||||
|
||||
const session = auth.api.getSession({ headers: new Headers() });
|
||||
const request = auth.handler(
|
||||
new Request("https://auth.example.com/api/auth/get-session"),
|
||||
);
|
||||
const outcomes = Promise.allSettled([session, request]);
|
||||
finish(issuerDrift);
|
||||
|
||||
const results = await outcomes;
|
||||
expect(results).toEqual([
|
||||
{ status: "rejected", reason: expect.any(SchemaMismatchError) },
|
||||
{ status: "rejected", reason: expect.any(SchemaMismatchError) },
|
||||
]);
|
||||
await expect(
|
||||
auth.api.getSession({ headers: new Headers() }),
|
||||
).rejects.toThrow(SchemaMismatchError);
|
||||
expect(find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("validates before a plugin can handle an HTTP request", async () => {
|
||||
const onRequest = vi.fn(async () => ({
|
||||
response: new Response("handled by plugin"),
|
||||
}));
|
||||
const { auth } = createAuth(async () => issuerDrift, {
|
||||
plugins: [{ id: "request-handler", onRequest }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
auth.handler(
|
||||
new Request("https://auth.example.com/api/auth/get-session"),
|
||||
),
|
||||
).rejects.toThrow(SchemaMismatchError);
|
||||
expect(onRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries connectivity failures without logging driver secrets", async () => {
|
||||
const failure = new Error("postgres://user:secret@private-db/database");
|
||||
const find = vi
|
||||
.fn<() => Promise<SchemaFinding[]>>()
|
||||
.mockRejectedValueOnce(failure)
|
||||
.mockResolvedValueOnce([]);
|
||||
const { auth, log } = createAuth(find);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Could not validate the database schema"),
|
||||
);
|
||||
});
|
||||
expect(JSON.stringify(log.mock.calls)).not.toContain(failure.message);
|
||||
await expect(
|
||||
auth.api.getSession({ headers: new Headers() }),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
auth.api.getSession({ headers: new Headers() }),
|
||||
).resolves.toBeNull();
|
||||
expect(find).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("leaves configuration accessible for migration tooling after a mismatch", async () => {
|
||||
const { auth, log } = createAuth(async () => issuerDrift);
|
||||
await expect(
|
||||
auth.api.getSession({ headers: new Headers() }),
|
||||
).rejects.toThrow(SchemaMismatchError);
|
||||
|
||||
const context = await auth.$context;
|
||||
expect(context.adapter.id).toBe("memory");
|
||||
expect(auth.options.database).toBeTypeOf("function");
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("1.7.0 through 1.7.2"),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves context initialization failures", async () => {
|
||||
const find = vi.fn(async () => []);
|
||||
const { auth } = createAuth(find, { baseURL: { allowedHosts: [] } });
|
||||
|
||||
await expect(auth.$context).rejects.toThrow(
|
||||
"baseURL.allowedHosts cannot be empty",
|
||||
);
|
||||
expect(find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not report a plugin initialization failure as a schema error", async () => {
|
||||
const failure = new Error("plugin initialization failed");
|
||||
const find = vi.fn(async () => []);
|
||||
const { auth, log } = createAuth(find, {
|
||||
plugins: [
|
||||
{
|
||||
id: "failing-init",
|
||||
init: () => {
|
||||
throw failure;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(auth.$context).rejects.toBe(failure);
|
||||
await expect(auth.api.getSession({ headers: new Headers() })).rejects.toBe(
|
||||
failure,
|
||||
);
|
||||
expect(find).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports schema failures through the initialized context logger", async () => {
|
||||
const contextLog = vi.fn();
|
||||
const { auth, log } = createAuth(async () => issuerDrift, {
|
||||
plugins: [
|
||||
{
|
||||
id: "context-logger",
|
||||
init: () => ({
|
||||
context: { logger: createLogger({ log: contextLog }) },
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
auth.api.getSession({ headers: new Headers() }),
|
||||
).rejects.toThrow(SchemaMismatchError);
|
||||
await vi.waitFor(() => {
|
||||
expect(contextLog).toHaveBeenCalledWith(
|
||||
"error",
|
||||
new SchemaMismatchError(issuerDrift, "database").message,
|
||||
);
|
||||
});
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks the adapter selected during plugin initialization", async () => {
|
||||
const initialCheck = vi.fn(async () => []);
|
||||
const { auth } = createAuth(initialCheck, {
|
||||
plugins: [
|
||||
{
|
||||
id: "context-adapter",
|
||||
init: (context) => {
|
||||
const adapter = memoryAdapter({})(context.options);
|
||||
registerSchemaCheck(
|
||||
adapter,
|
||||
createSchemaCheck(async () => issuerDrift, "database"),
|
||||
);
|
||||
return { context: { adapter } };
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
auth.api.getSession({ headers: new Headers() }),
|
||||
).rejects.toThrow(SchemaMismatchError);
|
||||
expect(initialCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import { getBetterAuthVersion } from "@better-auth/core/context";
|
||||
import { getAuthTables } from "@better-auth/core/db";
|
||||
import type { DBAdapter } from "@better-auth/core/db/adapter";
|
||||
import { schemaCheckFor } from "@better-auth/core/db/internal";
|
||||
import { createLogger, env, isProduction, isTest } from "@better-auth/core/env";
|
||||
import { BetterAuthError } from "@better-auth/core/error";
|
||||
import type { OAuthProvider } from "@better-auth/core/oauth2";
|
||||
@@ -433,5 +434,6 @@ Most of the features of Better Auth will not work correctly.`,
|
||||
await initOrPromise;
|
||||
}
|
||||
|
||||
ctx.checkSchema = schemaCheckFor(ctx.adapter);
|
||||
return ctx as unknown as AuthContext<Options>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { BetterAuthOptions } from "@better-auth/core";
|
||||
import { registerSchemaCheck } from "@better-auth/core/db/internal";
|
||||
import { memoryAdapter } from "@better-auth/memory-adapter";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initMinimal } from "./init-minimal";
|
||||
@@ -41,4 +43,18 @@ describe("init-minimal (without Kysely)", () => {
|
||||
expect(res.adapter.id).toBe("memory");
|
||||
expect(res.adapter.options?.type).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes the schema check an adapter registers", async () => {
|
||||
const check = () => undefined;
|
||||
const database = (options: BetterAuthOptions) => {
|
||||
const adapter = memoryAdapter({})(options);
|
||||
registerSchemaCheck(adapter, check);
|
||||
return adapter;
|
||||
};
|
||||
const res = await initMinimal({
|
||||
baseURL: "http://localhost:3000",
|
||||
database,
|
||||
});
|
||||
expect(res.checkSchema).toBe(check);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { BetterAuthOptions } from "@better-auth/core";
|
||||
import { runWithTransaction } from "@better-auth/core/context";
|
||||
import { SchemaMismatchError } from "@better-auth/core/db/internal";
|
||||
import { kyselyAdapter } from "@better-auth/kysely-adapter";
|
||||
import { NodeSqliteDialect } from "@better-auth/kysely-adapter/node-sqlite-dialect";
|
||||
import { CamelCasePlugin, Kysely } from "kysely";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { betterAuth } from "../auth/full";
|
||||
import { getMigrations } from "./get-migration";
|
||||
|
||||
@@ -24,18 +30,38 @@ function createDatabase(accountTable: string) {
|
||||
return database;
|
||||
}
|
||||
|
||||
function createAuth(database: DatabaseSync) {
|
||||
return betterAuth({
|
||||
function seedAccountWrittenBy17(database: DatabaseSync) {
|
||||
const timestamp = new Date().toISOString();
|
||||
database.exec(
|
||||
`insert into "user" values ('u1', 'Employee', 'written-by-1-7@example.com', 0, null, '${timestamp}', '${timestamp}');
|
||||
insert into "account" values ('a1', 'https://idp.example.com', 'google-subject', 'google', 'u1', null, null, null, null, null, null, null, '${timestamp}', '${timestamp}');`,
|
||||
);
|
||||
}
|
||||
|
||||
function createAuth(
|
||||
database: DatabaseSync,
|
||||
extra: Partial<BetterAuthOptions> = {},
|
||||
) {
|
||||
const warnings: string[] = [];
|
||||
const auth = betterAuth({
|
||||
baseURL: "http://localhost:3000",
|
||||
secret: "better-auth-secret-that-is-long-enough-for-validation-test",
|
||||
database,
|
||||
emailAndPassword: { enabled: true },
|
||||
logger: {
|
||||
level: "warn",
|
||||
log: (_level, message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
return { auth, warnings };
|
||||
}
|
||||
|
||||
/** Signs a user up, links a social account, then reads it back by its key. */
|
||||
async function signUpAndLinkSocialAccount(
|
||||
auth: ReturnType<typeof createAuth>,
|
||||
auth: ReturnType<typeof createAuth>["auth"],
|
||||
email: string,
|
||||
) {
|
||||
const signUp = await auth.api.signUpEmail({
|
||||
@@ -63,11 +89,14 @@ describe("account table compatibility across v1 releases", () => {
|
||||
}) => {
|
||||
const database = createDatabase(ACCOUNT_TABLE);
|
||||
onTestFinished(() => database.close());
|
||||
const auth = createAuth(database);
|
||||
const { auth, warnings } = createAuth(database);
|
||||
|
||||
const { toBeCreated, toBeAdded } = await getMigrations(auth.options);
|
||||
const { toBeCreated, toBeAdded, schemaProblems } = await getMigrations(
|
||||
auth.options,
|
||||
);
|
||||
expect(toBeCreated).toEqual([]);
|
||||
expect(toBeAdded).toEqual([]);
|
||||
expect(schemaProblems).toEqual([]);
|
||||
|
||||
const { signUp, linked, found } = await signUpAndLinkSocialAccount(
|
||||
auth,
|
||||
@@ -75,6 +104,12 @@ describe("account table compatibility across v1 releases", () => {
|
||||
);
|
||||
expect(signUp.user.email).toBe("without-issuer-column@example.com");
|
||||
expect(found?.id).toBe(linked.id);
|
||||
|
||||
const columns = database
|
||||
.prepare(`select name from pragma_table_info('account')`)
|
||||
.all() as { name: string }[];
|
||||
expect(columns.map((column) => column.name)).not.toContain("issuer");
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps accounts written by 1.7 readable through their provider key", async ({
|
||||
@@ -82,12 +117,8 @@ describe("account table compatibility across v1 releases", () => {
|
||||
}) => {
|
||||
const database = createDatabase(ACCOUNT_TABLE_WITH_ISSUER);
|
||||
onTestFinished(() => database.close());
|
||||
const timestamp = new Date().toISOString();
|
||||
database.exec(
|
||||
`insert into "user" values ('u1', 'Employee', 'written-by-1-7@example.com', 0, null, '${timestamp}', '${timestamp}');
|
||||
insert into "account" values ('a1', 'https://idp.example.com', 'google-subject', 'google', 'u1', null, null, null, null, null, null, null, '${timestamp}', '${timestamp}');`,
|
||||
);
|
||||
const auth = createAuth(database);
|
||||
seedAccountWrittenBy17(database);
|
||||
const { auth } = createAuth(database);
|
||||
|
||||
const { internalAdapter } = await auth.$context;
|
||||
const found = await internalAdapter.findAccountByKey({
|
||||
@@ -98,20 +129,235 @@ describe("account table compatibility across v1 releases", () => {
|
||||
expect(found?.userId).toBe("u1");
|
||||
});
|
||||
|
||||
it("names the leftover issuer column instead of surfacing the driver error", async ({
|
||||
it("rejects every request until the required issuer column is relaxed", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const database = createDatabase(ACCOUNT_TABLE_WITH_ISSUER);
|
||||
onTestFinished(() => database.close());
|
||||
const auth = createAuth(database);
|
||||
seedAccountWrittenBy17(database);
|
||||
const { auth } = createAuth(database);
|
||||
|
||||
// Migrations never drop a column, so the CLI cannot resolve this state.
|
||||
const { toBeCreated, toBeAdded } = await getMigrations(auth.options);
|
||||
expect(toBeCreated).toEqual([]);
|
||||
// Migrations never drop a column, so the CLI reports it instead.
|
||||
const { toBeAdded, schemaProblems } = await getMigrations(auth.options);
|
||||
expect(toBeAdded).toEqual([]);
|
||||
expect(schemaProblems).toHaveLength(1);
|
||||
expect(schemaProblems[0]).toContain('Column "issuer" on table "account"');
|
||||
|
||||
const signUp = await signUpAndLinkSocialAccount(
|
||||
auth,
|
||||
"with-issuer-column@example.com",
|
||||
).catch((error: unknown) => error);
|
||||
expect(signUp).toBeInstanceOf(SchemaMismatchError);
|
||||
expect(signUp).toMatchObject({
|
||||
code: "SCHEMA_MISMATCH",
|
||||
findings: [
|
||||
{
|
||||
kind: "unexpected-required-column",
|
||||
table: "account",
|
||||
column: "issuer",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// A read meets the verdict kept from the first request.
|
||||
const read = await auth.api
|
||||
.getSession({ headers: new Headers() })
|
||||
.catch((error: unknown) => error);
|
||||
expect(read).toBe(signUp);
|
||||
});
|
||||
|
||||
it("surfaces the raw constraint error when the check is disabled", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const database = createDatabase(ACCOUNT_TABLE_WITH_ISSUER);
|
||||
onTestFinished(() => database.close());
|
||||
const { auth } = createAuth(database, {
|
||||
advanced: { database: { validateSchema: false } },
|
||||
});
|
||||
|
||||
const error = await signUpAndLinkSocialAccount(
|
||||
auth,
|
||||
"unchecked@example.com",
|
||||
).catch((error: unknown) => error);
|
||||
expect(error).not.toBeInstanceOf(SchemaMismatchError);
|
||||
expect((error as Error).message).toMatch(/NOT NULL constraint failed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema check with the adapter passed as a function", () => {
|
||||
it("accepts the plural table names the adapter uses", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const database = new DatabaseSync(":memory:");
|
||||
onTestFinished(() => database.close());
|
||||
let ddl = [
|
||||
USER_TABLE,
|
||||
SESSION_TABLE,
|
||||
ACCOUNT_TABLE,
|
||||
VERIFICATION_TABLE,
|
||||
].join("\n");
|
||||
for (const table of ["user", "session", "account", "verification"]) {
|
||||
ddl = ddl.replaceAll(`"${table}"`, `"${table}s"`);
|
||||
}
|
||||
database.exec(ddl);
|
||||
const db = new Kysely({ dialect: new NodeSqliteDialect({ database }) });
|
||||
const { auth } = createAuth(database, {
|
||||
database: kyselyAdapter(db, { type: "sqlite", usePlural: true }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
signUpAndLinkSocialAccount(auth, "with-issuer-column@example.com"),
|
||||
).rejects.toThrow(/account_issuer_accountId_uidx/);
|
||||
auth.api.getSession({ headers: new Headers() }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects every request until the required issuer column is relaxed", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const database = createDatabase(ACCOUNT_TABLE_WITH_ISSUER);
|
||||
onTestFinished(() => database.close());
|
||||
const db = new Kysely({ dialect: new NodeSqliteDialect({ database }) });
|
||||
const { auth } = createAuth(database, {
|
||||
database: kyselyAdapter(db, { type: "sqlite" }),
|
||||
});
|
||||
await expect(
|
||||
auth.api.getSession({ headers: new Headers() }),
|
||||
).rejects.toThrow(SchemaMismatchError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema check timing", () => {
|
||||
/**
|
||||
* @see https://www.better-auth.com/docs/concepts/database#programmatic-migrations
|
||||
*/
|
||||
it("rechecks the schema after programmatic migrations", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const database = new DatabaseSync(":memory:");
|
||||
onTestFinished(() => database.close());
|
||||
const { auth } = createAuth(database);
|
||||
await expect(
|
||||
auth.api.getSession({ headers: new Headers() }),
|
||||
).rejects.toThrow(SchemaMismatchError);
|
||||
|
||||
await (await getMigrations(auth.options)).runMigrations();
|
||||
|
||||
await expect(
|
||||
auth.api.signUpEmail({
|
||||
body: {
|
||||
email: "after-migration@example.com",
|
||||
password: "correct-horse-battery-staple",
|
||||
name: "Employee",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
user: { email: "after-migration@example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports drift during initialization without an auth request", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const database = createDatabase(ACCOUNT_TABLE_WITH_ISSUER);
|
||||
onTestFinished(() => database.close());
|
||||
const log = vi.fn();
|
||||
const { auth } = createAuth(database, { logger: { log } });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
"error",
|
||||
new SchemaMismatchError(
|
||||
[
|
||||
{
|
||||
kind: "unexpected-required-column",
|
||||
table: "account",
|
||||
column: "issuer",
|
||||
},
|
||||
],
|
||||
"database",
|
||||
).message,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
database.prepare('select count(*) as count from "user"').get(),
|
||||
).toMatchObject({ count: 0 });
|
||||
await expect(
|
||||
auth.api.getSession({ headers: new Headers() }),
|
||||
).rejects.toThrow(SchemaMismatchError);
|
||||
});
|
||||
|
||||
it("does not inspect the database when validation is disabled", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const database = new DatabaseSync(":memory:");
|
||||
onTestFinished(() => database.close());
|
||||
const { auth, warnings } = createAuth(database, {
|
||||
advanced: { database: { validateSchema: false } },
|
||||
});
|
||||
const context = await auth.$context;
|
||||
|
||||
expect(context.checkSchema).toBeUndefined();
|
||||
await expect(
|
||||
auth.api.getSession({ headers: new Headers() }),
|
||||
).resolves.toBeNull();
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not wait on a connection an ambient transaction holds", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const database = createDatabase(ACCOUNT_TABLE);
|
||||
onTestFinished(() => database.close());
|
||||
const { auth } = createAuth(database, {
|
||||
database: {
|
||||
dialect: new NodeSqliteDialect({ database }),
|
||||
type: "sqlite",
|
||||
transaction: true,
|
||||
},
|
||||
});
|
||||
const { adapter } = await auth.$context;
|
||||
await expect(
|
||||
runWithTransaction(adapter, () =>
|
||||
auth.api.signUpEmail({
|
||||
body: {
|
||||
email: "in-transaction@example.com",
|
||||
password: "correct-horse-battery-staple",
|
||||
name: "Employee",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({ user: { email: "in-transaction@example.com" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema check with Kysely's CamelCasePlugin", () => {
|
||||
const SNAKE_CASE_TABLES = `create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "email_verified" integer not null, "image" text, "created_at" date not null, "updated_at" date not null);
|
||||
create table "session" ("id" text not null primary key, "expires_at" date not null, "token" text not null unique, "created_at" date not null, "updated_at" date not null, "ip_address" text, "user_agent" text, "user_id" text not null references "user" ("id") on delete cascade);
|
||||
create table "account" ("id" text not null primary key, "account_id" text not null, "provider_id" text not null, "user_id" text not null references "user" ("id") on delete cascade, "access_token" text, "refresh_token" text, "id_token" text, "access_token_expires_at" date, "refresh_token_expires_at" date, "scope" text, "password" text, "created_at" date not null, "updated_at" date not null);
|
||||
create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expires_at" date not null, "created_at" date not null, "updated_at" date not null);`;
|
||||
|
||||
it("compares the names the plugin exposes, not the ones the database stores", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const database = new DatabaseSync(":memory:");
|
||||
onTestFinished(() => database.close());
|
||||
database.exec(SNAKE_CASE_TABLES);
|
||||
const { auth } = createAuth(database, {
|
||||
database: {
|
||||
db: new Kysely({
|
||||
dialect: new NodeSqliteDialect({ database }),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
}),
|
||||
type: "sqlite",
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
auth.api.signUpEmail({
|
||||
body: {
|
||||
email: "snake@example.com",
|
||||
password: "correct-horse-battery-staple",
|
||||
name: "Employee",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({ user: { email: "snake@example.com" } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,20 +7,21 @@ export async function getAdapter(
|
||||
options: BetterAuthOptions,
|
||||
): Promise<DBAdapter<BetterAuthOptions>> {
|
||||
return getBaseAdapter(options, async (opts) => {
|
||||
const { createKyselyAdapter } = await import("../adapters/kysely-adapter");
|
||||
const { createKyselyAdapter, kyselyAdapter } = await import(
|
||||
"../adapters/kysely-adapter"
|
||||
);
|
||||
const { kysely, databaseType, transaction } =
|
||||
await createKyselyAdapter(opts);
|
||||
if (!kysely) {
|
||||
throw new BetterAuthError("Failed to initialize database adapter");
|
||||
}
|
||||
const { kyselyAdapter } = await import("../adapters/kysely-adapter");
|
||||
return kyselyAdapter(kysely, {
|
||||
type: databaseType || "sqlite",
|
||||
debugLogs:
|
||||
opts.database && "debugLogs" in opts.database
|
||||
? opts.database.debugLogs
|
||||
: false,
|
||||
transaction: transaction,
|
||||
transaction,
|
||||
})(opts);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -823,3 +823,29 @@ describe("get-migration: inspecting a migration that cannot be applied", () => {
|
||||
expect(unsafeChanges[0]).not.toContain("empty string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("get-migration: schema problems the migration cannot fix", () => {
|
||||
it("reports a required column Better Auth never writes", async () => {
|
||||
const plan = await getMigrations({
|
||||
database: createAccountDb({ plan: "notNull" }),
|
||||
});
|
||||
expect(plan.schemaProblems).toHaveLength(1);
|
||||
expect(plan.schemaProblems[0]).toContain(
|
||||
'Column "plan" on table "account" is required but Better Auth never writes it',
|
||||
);
|
||||
expect(plan.toBeAdded).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports nothing for a nullable or a configured extra column", async () => {
|
||||
const nullable = await getMigrations({
|
||||
database: createAccountDb({ plan: "nullable" }),
|
||||
});
|
||||
expect(nullable.schemaProblems).toEqual([]);
|
||||
|
||||
const configured = await getMigrations({
|
||||
database: createAccountDb({ plan: "notNull" }),
|
||||
...accountPlanField,
|
||||
});
|
||||
expect(configured.schemaProblems).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
} from "@better-auth/core/db/adapter";
|
||||
import type { ResolvedDBTableIndex } from "@better-auth/core/db/internal";
|
||||
import {
|
||||
diffSchema,
|
||||
formatSchemaFinding,
|
||||
getDatabaseFieldIndexName,
|
||||
getDatabaseIndexStringLength,
|
||||
getPortableDatabaseIdentifierKey,
|
||||
invalidateSchemaChecks,
|
||||
} from "@better-auth/core/db/internal";
|
||||
import { createLogger } from "@better-auth/core/env";
|
||||
import { BetterAuthError } from "@better-auth/core/error";
|
||||
@@ -19,7 +22,13 @@ import type {
|
||||
DatabaseIndexMetadata,
|
||||
KyselyDatabaseType,
|
||||
} from "@better-auth/kysely-adapter";
|
||||
import { createKyselyAdapter } from "@better-auth/kysely-adapter";
|
||||
import {
|
||||
createKyselyAdapter,
|
||||
getMssqlSchema,
|
||||
getPostgresSchema,
|
||||
toIntrospectedTables,
|
||||
toPhysicalSchema,
|
||||
} from "@better-auth/kysely-adapter";
|
||||
import type {
|
||||
AlterTableColumnAlteringBuilder,
|
||||
ColumnDataType,
|
||||
@@ -558,48 +567,6 @@ export function matchType(
|
||||
return expected.includes(normalize(columnDataType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current PostgreSQL schema (search_path) for the database connection
|
||||
* Returns the first schema in the search_path, defaulting to 'public' if not found
|
||||
*/
|
||||
async function getPostgresSchema(db: Kysely<unknown>): Promise<string> {
|
||||
try {
|
||||
const result = await sql<{
|
||||
search_path?: string;
|
||||
searchPath?: string;
|
||||
}>`SHOW search_path`.execute(db);
|
||||
const searchPath =
|
||||
result.rows[0]?.search_path ?? result.rows[0]?.searchPath;
|
||||
if (searchPath) {
|
||||
// search_path can be a comma-separated list like "$user, public" or '"$user", public'
|
||||
// Supabase may return escaped format like '"\$user", public'
|
||||
// We want the first non-variable schema
|
||||
const schemas = searchPath
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
// Remove quotes and filter out variables like $user
|
||||
.map((s) => s.replace(/^["']|["']$/g, ""))
|
||||
// Filter out variable references like $user, \$user (escaped)
|
||||
.filter((s) => !s.startsWith("$") && !s.startsWith("\\$"));
|
||||
return schemas[0] || "public";
|
||||
}
|
||||
} catch {
|
||||
// If query fails, fall back to public schema
|
||||
}
|
||||
return "public";
|
||||
}
|
||||
|
||||
async function getMssqlSchema(db: Kysely<unknown>): Promise<string> {
|
||||
try {
|
||||
const result = await sql<{ schemaName?: string }>`
|
||||
SELECT SCHEMA_NAME() AS "schemaName"
|
||||
`.execute(db);
|
||||
return result.rows[0]?.schemaName || "dbo";
|
||||
} catch {
|
||||
return "dbo";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the migration plan that `auth migrate` executes and `auth generate`
|
||||
* prints for the Kysely adapter.
|
||||
@@ -736,6 +703,15 @@ export async function getMigrations(
|
||||
(table) => table.schema === currentSchema,
|
||||
);
|
||||
}
|
||||
// Columns the migration cannot fix: required, without a default, and never
|
||||
// written by Better Auth. Reported so the CLI stops before an insert fails.
|
||||
const schemaProblems = diffSchema(
|
||||
toPhysicalSchema(db, betterAuthSchema),
|
||||
toIntrospectedTables(tableMetadata),
|
||||
)
|
||||
.filter((finding) => finding.kind === "unexpected-required-column")
|
||||
.map((finding) => formatSchemaFinding(finding, "database"));
|
||||
|
||||
const toBeCreated: {
|
||||
table: string;
|
||||
fields: Record<string, DBFieldAttribute>;
|
||||
@@ -1233,8 +1209,14 @@ export async function getMigrations(
|
||||
}
|
||||
|
||||
async function runMigrations() {
|
||||
for (const migration of migrations) {
|
||||
await migration.execute();
|
||||
try {
|
||||
for (const migration of migrations) {
|
||||
await migration.execute();
|
||||
}
|
||||
} finally {
|
||||
if (migrations.length && config.database) {
|
||||
invalidateSchemaChecks(config.database);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function compileMigrations() {
|
||||
@@ -1246,6 +1228,7 @@ export async function getMigrations(
|
||||
toBeAdded,
|
||||
toBeAddedIndexes,
|
||||
unsafeChanges,
|
||||
schemaProblems,
|
||||
runMigrations,
|
||||
compileMigrations,
|
||||
};
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("internal adapter test", async () => {
|
||||
],
|
||||
} satisfies BetterAuthOptions;
|
||||
beforeAll(async () => {
|
||||
(await getMigrations(opts)).runMigrations();
|
||||
await (await getMigrations(opts)).runMigrations();
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
assertValidUserInfo,
|
||||
assertValidUserInfoSource,
|
||||
} from "../utils/validate-user-info";
|
||||
import { createLegacyAccountIssuerGuard } from "./legacy-account-issuer";
|
||||
import {
|
||||
getSessionDefaultFields,
|
||||
parseSessionOutput,
|
||||
@@ -64,7 +63,6 @@ export const createInternalAdapter = (
|
||||
const options = ctx.options;
|
||||
const secondaryStorage = options.secondaryStorage;
|
||||
const verificationConsumeLocks = new Map<string, Promise<void>>();
|
||||
const guardAccountInsert = createLegacyAccountIssuerGuard(logger);
|
||||
const sessionExpiration = options.session?.expiresIn || 60 * 60 * 24 * 7; // 7 days
|
||||
const {
|
||||
createWithHooks,
|
||||
@@ -250,18 +248,16 @@ export const createInternalAdapter = (
|
||||
message: "Failed to create user",
|
||||
});
|
||||
}
|
||||
const createdAccount = await guardAccountInsert(() =>
|
||||
createWithHooks(
|
||||
{
|
||||
...account,
|
||||
userId: createdUser.id,
|
||||
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
"account",
|
||||
undefined,
|
||||
),
|
||||
const createdAccount = await createWithHooks(
|
||||
{
|
||||
...account,
|
||||
userId: createdUser.id,
|
||||
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
"account",
|
||||
undefined,
|
||||
);
|
||||
return {
|
||||
user: createdUser,
|
||||
@@ -318,17 +314,15 @@ export const createInternalAdapter = (
|
||||
Partial<Account> &
|
||||
T,
|
||||
) => {
|
||||
const createdAccount = await guardAccountInsert(() =>
|
||||
createWithHooks(
|
||||
{
|
||||
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...account,
|
||||
},
|
||||
"account",
|
||||
undefined,
|
||||
),
|
||||
const createdAccount = await createWithHooks(
|
||||
{
|
||||
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...account,
|
||||
},
|
||||
"account",
|
||||
undefined,
|
||||
);
|
||||
return createdAccount as T & Account;
|
||||
},
|
||||
@@ -1080,17 +1074,15 @@ export const createInternalAdapter = (
|
||||
account: Omit<Account, "id" | "createdAt" | "updatedAt"> &
|
||||
Partial<Account>,
|
||||
) => {
|
||||
const _account = await guardAccountInsert(() =>
|
||||
createWithHooks(
|
||||
{
|
||||
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...account,
|
||||
},
|
||||
"account",
|
||||
undefined,
|
||||
),
|
||||
const _account = await createWithHooks(
|
||||
{
|
||||
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...account,
|
||||
},
|
||||
"account",
|
||||
undefined,
|
||||
);
|
||||
return _account;
|
||||
},
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { InternalLogger } from "@better-auth/core/env";
|
||||
import { BetterAuthError } from "@better-auth/core/error";
|
||||
import { deprecate } from "@better-auth/core/utils/deprecate";
|
||||
|
||||
/**
|
||||
* Bridge for databases that still carry the required `issuer` column Better
|
||||
* Auth 1.7.0 through 1.7.2 added to the `account` table.
|
||||
*
|
||||
* Accounts are recognized by `providerId` and `accountId` again, so no release
|
||||
* writes that column. A deployment that applied the 1.7 account schema and did
|
||||
* not relax the column fails every account insert with a driver error naming a
|
||||
* column the application never mentions. This module points that failure at the
|
||||
* upgrade guide. It is a signpost, not a fallback: the correction is DDL the
|
||||
* deployment owner runs, and a driver that fills an implicit default instead of
|
||||
* failing never reaches this code.
|
||||
*
|
||||
* Delete this file and its call sites in `internal-adapter.ts` to remove the
|
||||
* signpost. Nothing else depends on it.
|
||||
*/
|
||||
|
||||
const LEGACY_COLUMN = "issuer";
|
||||
const LEGACY_INDEX = "account_issuer_accountId_uidx";
|
||||
const UPGRADE_GUIDE =
|
||||
"https://www.better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-keeps-the-provider-key";
|
||||
|
||||
const REMEDIATION =
|
||||
`The "account" table still requires the "${LEGACY_COLUMN}" column added in Better Auth 1.7.0. ` +
|
||||
"Accounts are recognized by their provider ID and account ID again, so Better Auth never writes that column and its NOT NULL constraint rejects every sign-up and account link. " +
|
||||
`Drop the constraint, then the "${LEGACY_INDEX}" index. See ${UPGRADE_GUIDE}`;
|
||||
|
||||
/**
|
||||
* A driver reports the missing value by column name. The leftover unique index
|
||||
* carries the same word, so a duplicate key keeps its own error.
|
||||
*/
|
||||
function namesLegacyColumn(error: unknown): boolean {
|
||||
const message = (
|
||||
error instanceof Error ? error.message : String(error)
|
||||
).toLowerCase();
|
||||
return (
|
||||
message.includes(LEGACY_COLUMN) &&
|
||||
!message.includes(LEGACY_INDEX.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an account insert so a leftover 1.7 `issuer` column names itself.
|
||||
*/
|
||||
export function createLegacyAccountIssuerGuard(logger: InternalLogger) {
|
||||
const warnOnce = deprecate(() => {}, REMEDIATION, logger);
|
||||
|
||||
return async function guardAccountInsert<T>(
|
||||
insert: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await insert();
|
||||
} catch (error) {
|
||||
if (!namesLegacyColumn(error)) {
|
||||
throw error;
|
||||
}
|
||||
warnOnce();
|
||||
throw new BetterAuthError(REMEDIATION, { cause: error });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export async function getTestInstance<
|
||||
},
|
||||
} satisfies BetterAuthOptions;
|
||||
|
||||
const auth = betterAuth({
|
||||
const authOptions = {
|
||||
baseURL: "http://localhost:" + (config?.port || 3000),
|
||||
...opts,
|
||||
...options,
|
||||
@@ -183,7 +183,17 @@ export async function getTestInstance<
|
||||
password: options?.emailAndPassword?.password ?? testPassword,
|
||||
},
|
||||
plugins: [bearer(), ...(options?.plugins || [])],
|
||||
} as unknown as O);
|
||||
} as unknown as O;
|
||||
|
||||
if (testWith !== "mongodb") {
|
||||
const { runMigrations } = await getMigrations({
|
||||
...authOptions,
|
||||
database: opts.database,
|
||||
});
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
const auth = betterAuth(authOptions);
|
||||
|
||||
const testUser = {
|
||||
email: "test@test.com",
|
||||
@@ -218,14 +228,6 @@ export async function getTestInstance<
|
||||
});
|
||||
}
|
||||
|
||||
if (testWith !== "mongodb") {
|
||||
const { runMigrations } = await getMigrations({
|
||||
...auth.options,
|
||||
database: opts.database,
|
||||
});
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
await createTestUser();
|
||||
|
||||
const cleanup = async () => {
|
||||
|
||||
@@ -200,6 +200,16 @@ async function generateAction(opts: any) {
|
||||
});
|
||||
|
||||
spinner.stop();
|
||||
if (schema.schemaProblems?.length) {
|
||||
console.warn(
|
||||
chalk.red.bold(
|
||||
`⚠ ${schema.schemaProblems.length} ${schema.schemaProblems.length === 1 ? "column rejects" : "columns reject"} every insert Better Auth makes. No migration fixes this.`,
|
||||
),
|
||||
);
|
||||
for (const problem of schema.schemaProblems) {
|
||||
console.warn(chalk.red(`-> ${problem}`));
|
||||
}
|
||||
}
|
||||
if (schema.unsafeChanges?.length) {
|
||||
console.warn(
|
||||
chalk.red.bold(
|
||||
|
||||
@@ -125,6 +125,28 @@ export async function migrateAction(opts: any) {
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
}
|
||||
if (plan.schemaProblems.length) {
|
||||
spinner.stop();
|
||||
console.error(
|
||||
chalk.red.bold(
|
||||
`The database has ${plan.schemaProblems.length} ${plan.schemaProblems.length === 1 ? "column" : "columns"} Better Auth cannot write to. Nothing ran.`,
|
||||
),
|
||||
);
|
||||
for (const problem of plan.schemaProblems) {
|
||||
console.error(chalk.red(`-> ${problem}`));
|
||||
}
|
||||
try {
|
||||
const telemetry = await createTelemetry(config);
|
||||
await telemetry.publish({
|
||||
type: "cli_migrate",
|
||||
payload: {
|
||||
outcome: "schema_problem",
|
||||
config: await getTelemetryAuthConfig(config),
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
}
|
||||
const { toBeAdded, toBeAddedIndexes, toBeCreated, runMigrations } = plan;
|
||||
|
||||
if (!toBeAdded.length && !toBeAddedIndexes.length && !toBeCreated.length) {
|
||||
|
||||
@@ -19,9 +19,10 @@ export const generateKyselySchema: SchemaGenerator = async ({
|
||||
options,
|
||||
file,
|
||||
}) => {
|
||||
const { compileMigrations, unsafeChanges } = await getMigrations(options, {
|
||||
throwOnUnsafe: false,
|
||||
});
|
||||
const { compileMigrations, unsafeChanges, schemaProblems } =
|
||||
await getMigrations(options, {
|
||||
throwOnUnsafe: false,
|
||||
});
|
||||
const migrations = await compileMigrations();
|
||||
const code = migrations.trim() === ";" ? "" : migrations;
|
||||
return {
|
||||
@@ -29,6 +30,7 @@ export const generateKyselySchema: SchemaGenerator = async ({
|
||||
? `${commentBanner(unsafeChanges)}${code}`
|
||||
: code,
|
||||
unsafeChanges,
|
||||
schemaProblems,
|
||||
fileName:
|
||||
file ||
|
||||
`./better-auth_migrations/${new Date()
|
||||
|
||||
@@ -11,6 +11,11 @@ export interface SchemaGeneratorResult {
|
||||
* without corrupting the rows it already holds.
|
||||
*/
|
||||
unsafeChanges?: string[];
|
||||
/**
|
||||
* Columns the database requires that Better Auth never writes. No generated
|
||||
* migration removes them; each entry names the change that does.
|
||||
*/
|
||||
schemaProblems?: string[];
|
||||
}
|
||||
|
||||
export interface SchemaGenerator {
|
||||
|
||||
@@ -29,7 +29,8 @@ describe("migrate base auth instance", () => {
|
||||
config: "test/auth.ts",
|
||||
yes: true,
|
||||
});
|
||||
const signUpRes = await auth.api.signUpEmail({
|
||||
const servingAuth = betterAuth(auth.options);
|
||||
const signUpRes = await servingAuth.api.signUpEmail({
|
||||
body: {
|
||||
name: "test",
|
||||
email: "test@email.com",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AsyncLocalStorage } from "@better-auth/core/async_hooks";
|
||||
import { getAsyncLocalStorage } from "@better-auth/core/async_hooks";
|
||||
import type { DBAdapter, DBTransactionAdapter } from "../db/adapter";
|
||||
import { schemaCheckFor } from "../db/schema-check";
|
||||
import type { BetterAuthOptions } from "../types";
|
||||
import { __getBetterAuthGlobal } from "./global";
|
||||
|
||||
@@ -114,6 +115,10 @@ export const runWithTransaction = async <
|
||||
if (store?.isTransactionActive) {
|
||||
return fn();
|
||||
}
|
||||
// Settle the schema verdict before this transaction holds the
|
||||
// connection a single-connection store would need for the lookup.
|
||||
const pendingSchemaCheck = schemaCheckFor(adapter)?.();
|
||||
if (pendingSchemaCheck) await pendingSchemaCheck;
|
||||
const pendingHooks: Array<() => Promise<void>> = [];
|
||||
let result: Awaited<R>;
|
||||
let error: unknown;
|
||||
|
||||
@@ -10,3 +10,22 @@ export {
|
||||
resolveDatabaseTableIndexes,
|
||||
} from "./database-index";
|
||||
export { getAuthTablesWithResolvedIndexes } from "./get-tables";
|
||||
export {
|
||||
checksSchema,
|
||||
createSchemaCheck,
|
||||
invalidateSchemaChecks,
|
||||
registerSchemaCheck,
|
||||
type SchemaCheck,
|
||||
schemaCheckFor,
|
||||
} from "./schema-check";
|
||||
export {
|
||||
diffSchema,
|
||||
type ExpectedSchema,
|
||||
formatSchemaFinding,
|
||||
getExpectedSchema,
|
||||
type IntrospectedColumn,
|
||||
type IntrospectedTable,
|
||||
type SchemaFinding,
|
||||
SchemaMismatchError,
|
||||
type SchemaSource,
|
||||
} from "./schema-diff";
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
import type { BetterAuthOptions } from "../types";
|
||||
import {
|
||||
createSchemaCheck,
|
||||
invalidateSchemaChecks,
|
||||
registerSchemaCheck,
|
||||
schemaCheckFor,
|
||||
} from "./schema-check";
|
||||
import type { SchemaFinding } from "./schema-diff";
|
||||
import { SchemaMismatchError } from "./schema-diff";
|
||||
|
||||
const issuerDrift: SchemaFinding = {
|
||||
kind: "unexpected-required-column",
|
||||
table: "account",
|
||||
column: "issuer",
|
||||
};
|
||||
|
||||
/**
|
||||
* @see https://www.better-auth.com/docs/concepts/database#programmatic-migrations
|
||||
*/
|
||||
describe("createSchemaCheck", () => {
|
||||
it("invalidates only checks for the migrated database", async () => {
|
||||
const database = {};
|
||||
const find = vi.fn(async () => []);
|
||||
const otherFind = vi.fn(async () => []);
|
||||
const check = createSchemaCheck(find, "database", database);
|
||||
const otherCheck = createSchemaCheck(otherFind, "database", {});
|
||||
await Promise.all([check(), otherCheck()]);
|
||||
invalidateSchemaChecks(database);
|
||||
await Promise.all([check(), otherCheck()]);
|
||||
expect(find).toHaveBeenCalledTimes(2);
|
||||
expect(otherFind).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not let an old lookup overwrite a post-migration verdict", async () => {
|
||||
const database = {};
|
||||
let finish = (_findings: SchemaFinding[]) => {};
|
||||
const pending = new Promise<SchemaFinding[]>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const find = vi
|
||||
.fn<() => Promise<SchemaFinding[]>>()
|
||||
.mockReturnValueOnce(pending)
|
||||
.mockResolvedValueOnce([issuerDrift]);
|
||||
const check = createSchemaCheck(find, "database", database);
|
||||
const older = check();
|
||||
await Promise.resolve();
|
||||
invalidateSchemaChecks(database);
|
||||
await expect(check()).rejects.toThrow(SchemaMismatchError);
|
||||
finish([]);
|
||||
await expect(older).rejects.toThrow(SchemaMismatchError);
|
||||
await expect(check()).rejects.toThrow(SchemaMismatchError);
|
||||
expect(find).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each([
|
||||
{ findings: [] },
|
||||
{ findings: [issuerDrift] },
|
||||
])("rechecks a pending result after invalidation (%j)", async ({
|
||||
findings,
|
||||
}) => {
|
||||
const database = {};
|
||||
let finish = (_findings: SchemaFinding[]) => {};
|
||||
const pending = new Promise<SchemaFinding[]>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const find = vi
|
||||
.fn<() => Promise<SchemaFinding[]>>()
|
||||
.mockReturnValueOnce(pending)
|
||||
.mockResolvedValueOnce([]);
|
||||
const check = createSchemaCheck(find, "database", database);
|
||||
const older = check();
|
||||
await Promise.resolve();
|
||||
invalidateSchemaChecks(database);
|
||||
finish(findings);
|
||||
await expect(older).resolves.toBeUndefined();
|
||||
expect(find).toHaveBeenCalledTimes(2);
|
||||
expect(check()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("turns a synchronous lookup failure into a retryable rejection", async () => {
|
||||
const failure = new Error("connection unavailable");
|
||||
const find = vi
|
||||
.fn<() => Promise<SchemaFinding[]>>()
|
||||
.mockImplementationOnce(() => {
|
||||
throw failure;
|
||||
})
|
||||
.mockResolvedValueOnce([]);
|
||||
const check = createSchemaCheck(find, "database");
|
||||
|
||||
await expect(check()).rejects.toBe(failure);
|
||||
await expect(check()).resolves.toBeUndefined();
|
||||
expect(find).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("asks the store once and then answers without a promise", async () => {
|
||||
const find = vi.fn(async () => []);
|
||||
const check = createSchemaCheck(find, "database");
|
||||
await check();
|
||||
expect(check()).toBeUndefined();
|
||||
expect(find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shares one lookup between concurrent first calls", async () => {
|
||||
const find = vi.fn(async () => []);
|
||||
const check = createSchemaCheck(find, "database");
|
||||
await Promise.all([check(), check(), check()]);
|
||||
expect(find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps one mismatch and rethrows it without asking again", async () => {
|
||||
const find = vi.fn(async () => [issuerDrift]);
|
||||
const check = createSchemaCheck(find, "database");
|
||||
const first = await check()?.catch((error: unknown) => error);
|
||||
const second = await check()?.catch((error: unknown) => error);
|
||||
expect(first).toBeInstanceOf(SchemaMismatchError);
|
||||
expect(second).toBe(first);
|
||||
expect((first as SchemaMismatchError).findings).toEqual([issuerDrift]);
|
||||
expect(find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("asks again after the store could not be reached", async () => {
|
||||
const find = vi
|
||||
.fn<() => Promise<SchemaFinding[]>>()
|
||||
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
|
||||
.mockResolvedValueOnce([]);
|
||||
const check = createSchemaCheck(find, "database");
|
||||
await expect(check()).rejects.toThrow("ECONNREFUSED");
|
||||
await expect(check()).resolves.toBeUndefined();
|
||||
expect(find).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checksSchema", () => {
|
||||
it("accepts only a boolean validation option", () => {
|
||||
type DatabaseOptions = NonNullable<
|
||||
NonNullable<BetterAuthOptions["advanced"]>["database"]
|
||||
>;
|
||||
expectTypeOf<DatabaseOptions["validateSchema"]>().toEqualTypeOf<
|
||||
boolean | undefined
|
||||
>();
|
||||
});
|
||||
|
||||
it.for([
|
||||
"development",
|
||||
"production",
|
||||
"test",
|
||||
])("checks in %s unless disabled", async (environment, {
|
||||
onTestFinished,
|
||||
}) => {
|
||||
onTestFinished(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
vi.stubEnv("NODE_ENV", environment);
|
||||
vi.resetModules();
|
||||
const { checksSchema } = await import("./schema-check");
|
||||
expect(checksSchema({})).toBe(true);
|
||||
expect(
|
||||
checksSchema({ advanced: { database: { validateSchema: true } } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
checksSchema({ advanced: { database: { validateSchema: false } } }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema check registry", () => {
|
||||
it("finds the check registered for an adapter and nothing for others", () => {
|
||||
const adapter = {};
|
||||
const check = createSchemaCheck(async () => [], "database");
|
||||
registerSchemaCheck(adapter, check);
|
||||
expect(schemaCheckFor(adapter)).toBe(check);
|
||||
expect(schemaCheckFor({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Internal schema validation infrastructure for built-in adapters.
|
||||
*
|
||||
* Intended to become a public core extension point for community database
|
||||
* adapter authors once the registration and lifecycle contracts are stabilized.
|
||||
*/
|
||||
|
||||
import type { BetterAuthOptions } from "../types";
|
||||
import type { SchemaFinding, SchemaSource } from "./schema-diff";
|
||||
import { SchemaMismatchError } from "./schema-diff";
|
||||
|
||||
/**
|
||||
* Whether the adapter validates its schema. Enabled in every environment
|
||||
* unless explicitly disabled.
|
||||
*/
|
||||
export function checksSchema(options: BetterAuthOptions): boolean {
|
||||
return options.advanced?.database?.validateSchema !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves when the schema can hold what Better Auth writes. Returns nothing
|
||||
* once that is known and the database schema revision is unchanged.
|
||||
*/
|
||||
export type SchemaCheck = () => Promise<void> | undefined;
|
||||
|
||||
const schemaChecks = new WeakMap<object, SchemaCheck>();
|
||||
const schemaRevisions = new WeakMap<object, { value: number }>();
|
||||
|
||||
/** Invalidates cached checks after Better Auth changes this database's schema. */
|
||||
export function invalidateSchemaChecks(database: object): void {
|
||||
const revision = schemaRevisions.get(database);
|
||||
if (revision) revision.value++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a check to the adapter it verifies. The adapter object itself is
|
||||
* left untouched, so this works for adapters Better Auth does not own.
|
||||
*/
|
||||
export function registerSchemaCheck(adapter: object, check: SchemaCheck): void {
|
||||
schemaChecks.set(adapter, check);
|
||||
}
|
||||
|
||||
/**
|
||||
* The check registered for an adapter, if its store is checked at all.
|
||||
*/
|
||||
export function schemaCheckFor(adapter: object): SchemaCheck | undefined {
|
||||
return schemaChecks.get(adapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a schema comparison into a check shared by one adapter instance.
|
||||
*
|
||||
* The first call runs `find` and every concurrent call shares that promise. A
|
||||
* clean result is cached until invalidation. A mismatch is kept as one
|
||||
* {@link SchemaMismatchError} and rethrown on every later call without asking
|
||||
* the store again, until a migration invalidates it. When a database identity is supplied,
|
||||
* checks for that identity share its schema revision. Pending callers follow
|
||||
* the new check if their revision is invalidated. A failure to reach
|
||||
* the store is not kept, so the next call asks again.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const checkSchema = createSchemaCheck(
|
||||
* () => findSchemaProblems(db, "postgres", expected),
|
||||
* "database",
|
||||
* );
|
||||
* const pending = checkSchema();
|
||||
* if (pending) await pending;
|
||||
* ```
|
||||
*/
|
||||
export function createSchemaCheck(
|
||||
find: () => Promise<SchemaFinding[]>,
|
||||
source: SchemaSource,
|
||||
database?: object,
|
||||
): SchemaCheck {
|
||||
let revision = database ? schemaRevisions.get(database) : undefined;
|
||||
if (database && !revision) {
|
||||
revision = { value: 0 };
|
||||
schemaRevisions.set(database, revision);
|
||||
}
|
||||
let checkedRevision = revision?.value;
|
||||
let clean = false;
|
||||
let verdict: Promise<void> | undefined;
|
||||
return function checkSchema(): Promise<void> | undefined {
|
||||
const currentRevision = revision?.value;
|
||||
if (checkedRevision !== currentRevision) {
|
||||
checkedRevision = currentRevision;
|
||||
clean = false;
|
||||
verdict = undefined;
|
||||
}
|
||||
if (clean) return;
|
||||
return (verdict ??= Promise.resolve()
|
||||
.then(find)
|
||||
.then(
|
||||
(findings) => {
|
||||
if (revision?.value !== currentRevision) return checkSchema();
|
||||
if (findings.length) throw new SchemaMismatchError(findings, source);
|
||||
if (checkedRevision === currentRevision) clean = true;
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (revision?.value !== currentRevision) return checkSchema();
|
||||
if (checkedRevision === currentRevision) verdict = undefined;
|
||||
throw error;
|
||||
},
|
||||
));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SchemaFinding } from "./schema-diff";
|
||||
import {
|
||||
diffSchema,
|
||||
formatSchemaFinding,
|
||||
getExpectedSchema,
|
||||
SchemaMismatchError,
|
||||
} from "./schema-diff";
|
||||
|
||||
const expected = {
|
||||
account: {
|
||||
fields: {
|
||||
accountId: { type: "string" as const },
|
||||
providerId: { type: "string" as const },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const column = (name: string, nullable = false, hasDefault = false) => ({
|
||||
name,
|
||||
nullable,
|
||||
hasDefault,
|
||||
});
|
||||
|
||||
const issuerDrift = {
|
||||
kind: "unexpected-required-column",
|
||||
table: "account",
|
||||
column: "issuer",
|
||||
} as const;
|
||||
|
||||
describe("diffSchema", () => {
|
||||
it("reports a missing table", () => {
|
||||
expect(diffSchema(expected, [])).toEqual([
|
||||
{ kind: "missing-table", table: "account" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a missing column, including the implicit id", () => {
|
||||
const actual = [{ name: "account", columns: [column("accountId")] }];
|
||||
expect(diffSchema(expected, actual)).toEqual([
|
||||
{ kind: "missing-column", table: "account", column: "id" },
|
||||
{ kind: "missing-column", table: "account", column: "providerId" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a required column Better Auth never writes", () => {
|
||||
const actual = [
|
||||
{
|
||||
name: "account",
|
||||
columns: [
|
||||
column("id"),
|
||||
column("accountId"),
|
||||
column("providerId"),
|
||||
column("issuer"),
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(diffSchema(expected, actual)).toEqual([issuerDrift]);
|
||||
});
|
||||
|
||||
it("tolerates an extra column that is nullable or has a default", () => {
|
||||
const actual = [
|
||||
{
|
||||
name: "account",
|
||||
columns: [
|
||||
column("id"),
|
||||
column("accountId"),
|
||||
column("providerId"),
|
||||
column("issuer", true),
|
||||
column("tier", false, true),
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(diffSchema(expected, actual)).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches a schema-qualified table only in that schema", () => {
|
||||
const columns = [column("id"), column("accountId"), column("providerId")];
|
||||
const qualified = { account: { ...expected.account, schema: "auth" } };
|
||||
expect(
|
||||
diffSchema(qualified, [{ name: "account", schema: "public", columns }]),
|
||||
).toEqual([{ kind: "missing-table", table: "account" }]);
|
||||
expect(
|
||||
diffSchema(qualified, [{ name: "account", schema: "auth", columns }]),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
diffSchema(expected, [{ name: "account", schema: "auth", columns }]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a table that manages its own storage", () => {
|
||||
const own = { ...expected, jwks: { fields: {}, disableMigrations: true } };
|
||||
const actual = [
|
||||
{
|
||||
name: "account",
|
||||
columns: [column("id"), column("accountId"), column("providerId")],
|
||||
},
|
||||
];
|
||||
expect(diffSchema(own, actual)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SchemaMismatchError", () => {
|
||||
it("carries the findings as data and the fixes as text", () => {
|
||||
const error = new SchemaMismatchError([issuerDrift], "database");
|
||||
expect(error.code).toBe("SCHEMA_MISMATCH");
|
||||
expect(error.findings).toEqual([issuerDrift]);
|
||||
expect(error.source).toBe("database");
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://www.better-auth.com/docs/guides/1-7-upgrade-guide
|
||||
*/
|
||||
it.each([
|
||||
"account",
|
||||
"accounts",
|
||||
"plugin_tokens",
|
||||
])("does not assume the origin or index name of %s.issuer", (table) => {
|
||||
const message = formatSchemaFinding({ ...issuerDrift, table }, "database");
|
||||
expect(message).toContain(
|
||||
"If this column came from Better Auth 1.7.0 through 1.7.2",
|
||||
);
|
||||
expect(message).not.toContain(`${table}_issuer_accountId_uidx`);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://www.better-auth.com/docs/guide/create-a-plugin#schema
|
||||
*/
|
||||
describe("shared physical tables", () => {
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
])("checks shared tables regardless of model order (reversed=%s)", (reversed) => {
|
||||
const models = [
|
||||
[
|
||||
"managed",
|
||||
{ modelName: "shared", fields: { value: { type: "string" as const } } },
|
||||
],
|
||||
["external", { modelName: "shared", disableMigration: true, fields: {} }],
|
||||
] as const;
|
||||
const schema = getExpectedSchema({
|
||||
plugins: [
|
||||
{
|
||||
id: "shared",
|
||||
schema: Object.fromEntries(reversed ? [...models].reverse() : models),
|
||||
},
|
||||
],
|
||||
});
|
||||
const shared = { shared: schema.shared! };
|
||||
expect(diffSchema(shared, [])).toEqual([
|
||||
{ kind: "missing-table", table: "shared" },
|
||||
]);
|
||||
expect(
|
||||
diffSchema(shared, [{ name: "shared", columns: [column("id")] }]),
|
||||
).toEqual([{ kind: "missing-column", table: "shared", column: "value" }]);
|
||||
expect(
|
||||
diffSchema(shared, [
|
||||
{ name: "shared", columns: [column("id"), column("value")] },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema diagnostic output", () => {
|
||||
it("groups missing tables with one migration command", () => {
|
||||
const findings: SchemaFinding[] = [
|
||||
"user",
|
||||
"session",
|
||||
"account",
|
||||
"verification",
|
||||
].map((table) => ({ kind: "missing-table", table }));
|
||||
expect(
|
||||
new SchemaMismatchError(findings, "database").message,
|
||||
).toMatchInlineSnapshot(`
|
||||
"Database schema mismatch
|
||||
|
||||
Missing tables
|
||||
user, session, account, verification
|
||||
|
||||
help: Run \`npx auth migrate\` to add the missing tables and columns."
|
||||
`);
|
||||
});
|
||||
|
||||
it("groups mixed findings and presents repairs before migrations", () => {
|
||||
expect(
|
||||
new SchemaMismatchError(
|
||||
[
|
||||
{ kind: "missing-column", table: "user", column: "email" },
|
||||
issuerDrift,
|
||||
{ kind: "missing-table", table: "verification" },
|
||||
],
|
||||
"database",
|
||||
).message,
|
||||
).toMatchInlineSnapshot(`
|
||||
"Database schema mismatch
|
||||
|
||||
Missing tables
|
||||
verification
|
||||
|
||||
Missing columns
|
||||
user.email
|
||||
|
||||
Required columns Better Auth never writes
|
||||
account.issuer
|
||||
|
||||
Inserts into account will fail.
|
||||
|
||||
help: Make the listed columns nullable, give them defaults, or remove them.
|
||||
Run \`npx auth migrate\` to add the missing tables and columns.
|
||||
|
||||
note: If this column came from Better Auth 1.7.0 through 1.7.2,
|
||||
follow the upgrade guide before removing it:
|
||||
https://www.better-auth.com/docs/guides/1-7-upgrade-guide"
|
||||
`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
source: "drizzle" as const,
|
||||
repair: "nullable in your Drizzle schema",
|
||||
apply: "apply it with your migration tool",
|
||||
},
|
||||
{
|
||||
source: "prisma" as const,
|
||||
repair: "optional in your Prisma schema",
|
||||
apply: "prisma migrate",
|
||||
},
|
||||
])("uses the $source schema workflow", ({ source, repair, apply }) => {
|
||||
const message = new SchemaMismatchError(
|
||||
[
|
||||
{
|
||||
kind: "unexpected-required-column",
|
||||
table: "account",
|
||||
column: "legacyKey",
|
||||
},
|
||||
{
|
||||
kind: "unexpected-required-column",
|
||||
table: "user",
|
||||
column: "legacyRole",
|
||||
},
|
||||
],
|
||||
source,
|
||||
).message;
|
||||
expect(message).toContain(repair);
|
||||
expect(message).toContain(apply);
|
||||
expect(message.match(/npx auth generate/g)).toHaveLength(1);
|
||||
expect(message).not.toContain("npx auth migrate");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { BetterAuthError } from "../error";
|
||||
import type { BetterAuthOptions } from "../types";
|
||||
import { getAuthTables } from "./get-tables";
|
||||
import type { DBFieldAttribute } from "./type";
|
||||
|
||||
/**
|
||||
* A column as the database, or an ORM schema definition, reports it.
|
||||
*/
|
||||
export interface IntrospectedColumn {
|
||||
name: string;
|
||||
nullable: boolean;
|
||||
/**
|
||||
* The store fills the column when an insert omits it.
|
||||
*/
|
||||
hasDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A table as the database, or an ORM schema definition, reports it.
|
||||
*/
|
||||
export interface IntrospectedTable {
|
||||
name: string;
|
||||
/**
|
||||
* The schema the table lives in, when the store has schemas.
|
||||
*/
|
||||
schema?: string | undefined;
|
||||
columns: IntrospectedColumn[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tables Better Auth writes, keyed the way the store addresses them:
|
||||
* physical table name, then physical column name. A table that manages its
|
||||
* own storage is excluded from migrations and from this comparison.
|
||||
*/
|
||||
export type ExpectedSchema = Record<
|
||||
string,
|
||||
{
|
||||
fields: Record<string, DBFieldAttribute>;
|
||||
idColumn?: string | undefined;
|
||||
disableMigrations?: boolean | undefined;
|
||||
/**
|
||||
* The schema the table is addressed in. Unset when the store has no
|
||||
* schemas or the table is found by name alone.
|
||||
*/
|
||||
schema?: string | undefined;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* The tables this configuration writes, keyed the way the adapter addresses
|
||||
* them. Tables that share a physical name are merged into one entry.
|
||||
*/
|
||||
export function getExpectedSchema(
|
||||
options: BetterAuthOptions,
|
||||
{ usePlural = false }: { usePlural?: boolean | undefined } = {},
|
||||
): ExpectedSchema {
|
||||
const expected: ExpectedSchema = {};
|
||||
for (const table of Object.values(getAuthTables(options))) {
|
||||
const name = usePlural ? `${table.modelName}s` : table.modelName;
|
||||
const entry = (expected[name] ??= { fields: {}, disableMigrations: true });
|
||||
for (const [key, field] of Object.entries(table.fields)) {
|
||||
entry.fields[field.fieldName || key] = field;
|
||||
}
|
||||
entry.disableMigrations =
|
||||
entry.disableMigrations && !!table.disableMigrations;
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
export type SchemaFinding =
|
||||
| { kind: "missing-table"; table: string }
|
||||
| { kind: "missing-column"; table: string; column: string }
|
||||
| { kind: "unexpected-required-column"; table: string; column: string };
|
||||
|
||||
/**
|
||||
* How the schema reaches the store, which decides the fix each finding names.
|
||||
*/
|
||||
export type SchemaSource = "database" | "drizzle" | "prisma";
|
||||
|
||||
/**
|
||||
* Compares the tables Better Auth writes with what the store holds.
|
||||
*
|
||||
* A table or column Better Auth writes must exist. A column Better Auth does
|
||||
* not write must accept an insert that omits it, so it is nullable or carries
|
||||
* a default. Otherwise every insert into that table fails with a constraint
|
||||
* error that says nothing about why the schema drifted.
|
||||
*/
|
||||
export function diffSchema(
|
||||
expected: ExpectedSchema,
|
||||
actual: readonly IntrospectedTable[],
|
||||
): SchemaFinding[] {
|
||||
const findings: SchemaFinding[] = [];
|
||||
for (const [tableName, table] of Object.entries(expected)) {
|
||||
if (table.disableMigrations) continue;
|
||||
const actualTable = actual.find(
|
||||
(candidate) =>
|
||||
candidate.name === tableName &&
|
||||
(table.schema === undefined || candidate.schema === table.schema),
|
||||
);
|
||||
if (!actualTable) {
|
||||
findings.push({ kind: "missing-table", table: tableName });
|
||||
continue;
|
||||
}
|
||||
const written = new Set([
|
||||
table.idColumn ?? "id",
|
||||
...Object.keys(table.fields),
|
||||
]);
|
||||
for (const column of written) {
|
||||
if (!actualTable.columns.some((candidate) => candidate.name === column)) {
|
||||
findings.push({ kind: "missing-column", table: tableName, column });
|
||||
}
|
||||
}
|
||||
for (const column of actualTable.columns) {
|
||||
if (written.has(column.name) || column.nullable || column.hasDefault) {
|
||||
continue;
|
||||
}
|
||||
findings.push({
|
||||
kind: "unexpected-required-column",
|
||||
table: tableName,
|
||||
column: column.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
const applyHint: Record<SchemaSource, string> = {
|
||||
database: "Run `npx auth migrate` to add it.",
|
||||
drizzle:
|
||||
"Run `npx auth generate` to refresh the Drizzle schema, then apply it with your migration tool.",
|
||||
prisma:
|
||||
"Run `npx auth generate` to refresh the Prisma schema, then run `prisma migrate`.",
|
||||
};
|
||||
|
||||
const sourceLabel: Record<SchemaSource, string> = {
|
||||
database: "Database",
|
||||
drizzle: "Drizzle",
|
||||
prisma: "Prisma",
|
||||
};
|
||||
|
||||
/**
|
||||
* One finding as a sentence that names the change resolving it.
|
||||
*/
|
||||
export function formatSchemaFinding(
|
||||
finding: SchemaFinding,
|
||||
source: SchemaSource,
|
||||
): string {
|
||||
switch (finding.kind) {
|
||||
case "missing-table":
|
||||
return `Table "${finding.table}" is missing. ${applyHint[source]}`;
|
||||
case "missing-column":
|
||||
return `Column "${finding.column}" is missing from table "${finding.table}". ${applyHint[source]}`;
|
||||
case "unexpected-required-column": {
|
||||
const issuer =
|
||||
finding.column === "issuer"
|
||||
? " If this column came from Better Auth 1.7.0 through 1.7.2, follow the upgrade guide before removing it: https://www.better-auth.com/docs/guides/1-7-upgrade-guide"
|
||||
: "";
|
||||
return `Column "${finding.column}" on table "${finding.table}" is required but Better Auth never writes it, so every insert into "${finding.table}" fails. Drop the column, make it nullable, or give it a database default.${issuer}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const repairHint: Record<SchemaSource, string> = {
|
||||
database:
|
||||
"Make the listed columns nullable, give them defaults, or remove them.",
|
||||
drizzle:
|
||||
"Make the listed columns nullable in your Drizzle schema, give them defaults, or remove them.",
|
||||
prisma:
|
||||
"Make the listed fields optional in your Prisma schema, give them defaults, or remove them.",
|
||||
};
|
||||
|
||||
const migrationHint: Record<SchemaSource, string> = {
|
||||
...applyHint,
|
||||
database: "Run `npx auth migrate` to add the missing tables and columns.",
|
||||
};
|
||||
|
||||
function formatSchemaMismatch(
|
||||
findings: readonly SchemaFinding[],
|
||||
source: SchemaSource,
|
||||
): string {
|
||||
const tables: string[] = [];
|
||||
const columns: string[] = [];
|
||||
const required: string[] = [];
|
||||
const affectedTables = new Set<string>();
|
||||
let hasIssuer = false;
|
||||
for (const finding of findings) {
|
||||
switch (finding.kind) {
|
||||
case "missing-table":
|
||||
tables.push(finding.table);
|
||||
break;
|
||||
case "missing-column":
|
||||
columns.push(`${finding.table}.${finding.column}`);
|
||||
break;
|
||||
case "unexpected-required-column":
|
||||
required.push(`${finding.table}.${finding.column}`);
|
||||
affectedTables.add(finding.table);
|
||||
hasIssuer ||= finding.column === "issuer";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const sections = [`${sourceLabel[source]} schema mismatch`];
|
||||
if (tables.length)
|
||||
sections.push(` Missing tables\n ${tables.join(", ")}`);
|
||||
if (columns.length)
|
||||
sections.push(` Missing columns\n ${columns.join("\n ")}`);
|
||||
if (required.length) {
|
||||
sections.push(
|
||||
` Required columns Better Auth never writes\n ${required.join("\n ")}`,
|
||||
);
|
||||
sections.push(
|
||||
` Inserts into ${[...affectedTables].join(", ")} will fail.`,
|
||||
);
|
||||
}
|
||||
|
||||
const help: string[] = [];
|
||||
if (required.length) help.push(repairHint[source]);
|
||||
if (
|
||||
tables.length ||
|
||||
columns.length ||
|
||||
(required.length && source !== "database")
|
||||
) {
|
||||
help.push(migrationHint[source]);
|
||||
}
|
||||
if (help.length) sections.push(` help: ${help.join("\n ")}`);
|
||||
if (hasIssuer) {
|
||||
sections.push(
|
||||
" note: If this column came from Better Auth 1.7.0 through 1.7.2,\n" +
|
||||
" follow the upgrade guide before removing it:\n" +
|
||||
" https://www.better-auth.com/docs/guides/1-7-upgrade-guide",
|
||||
);
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The store cannot hold what this configuration writes.
|
||||
*
|
||||
* `findings` carries every problem as data; `message` lists each one with the
|
||||
* change that resolves it. Reported during initialization and thrown when
|
||||
* requests await validation, in every environment. Also thrown by
|
||||
* `auth migrate` before it changes anything.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* try {
|
||||
* await auth.api.getSession({ headers });
|
||||
* } catch (error) {
|
||||
* if (error instanceof SchemaMismatchError) console.error(error.findings);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SchemaMismatchError extends BetterAuthError {
|
||||
readonly code = "SCHEMA_MISMATCH";
|
||||
|
||||
constructor(
|
||||
readonly findings: readonly SchemaFinding[],
|
||||
readonly source: SchemaSource,
|
||||
) {
|
||||
super(formatSchemaMismatch(findings, source));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from "../db";
|
||||
import type { DBAdapter, Where } from "../db/adapter";
|
||||
import type { AccountKey } from "../db/schema/account";
|
||||
import type { SchemaCheck } from "../db/schema-check";
|
||||
import type { createLogger } from "../env";
|
||||
import type { OAuthProvider } from "../oauth2";
|
||||
import type {
|
||||
@@ -417,6 +418,16 @@ export type AuthContext<Options extends BetterAuthOptions = BetterAuthOptions> =
|
||||
"enabled" | "window" | "max" | "storage"
|
||||
>;
|
||||
adapter: DBAdapter<Options>;
|
||||
/**
|
||||
* Confirms the database can hold what this configuration writes.
|
||||
*
|
||||
* Shared by initialization and requests for this adapter instance;
|
||||
* returns nothing once the schema is known to be clean.
|
||||
* Context construction does not await the verdict, so migration
|
||||
* tooling can still use a context whose schema needs repair.
|
||||
* Absent when the check is disabled or the adapter registers none.
|
||||
*/
|
||||
checkSchema?: SchemaCheck | undefined;
|
||||
internalAdapter: InternalAdapter<Options>;
|
||||
createAuthCookie: CreateCookieGetterFn;
|
||||
secret: string;
|
||||
|
||||
@@ -471,6 +471,16 @@ export type BetterAuthAdvancedOptions = {
|
||||
* @default false
|
||||
*/
|
||||
joins?: boolean;
|
||||
/**
|
||||
* Validate the schema during initialization and report problems
|
||||
* through the configured logger. Authentication requests await
|
||||
* the same check and fail when the schema does not match.
|
||||
* Kysely introspects the database using its configured dialect.
|
||||
* Set `false` to disable runtime schema validation.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
validateSchema?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
/**
|
||||
|
||||
@@ -62,7 +62,9 @@
|
||||
"@better-auth/core": "workspace:*",
|
||||
"@better-auth/utils": "catalog:",
|
||||
"@cloudflare/workers-types": "^4.20250121.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"kysely": "catalog:",
|
||||
"pg": "^8.0.0",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export * from "./dialect";
|
||||
export * from "./kysely-adapter";
|
||||
export {
|
||||
getMssqlSchema,
|
||||
getPostgresSchema,
|
||||
toIntrospectedTables,
|
||||
toPhysicalSchema,
|
||||
} from "./schema-check";
|
||||
export * from "./types";
|
||||
// Don't export node:sqlite by default, as it is not production ready.
|
||||
// export * from "./node-sqlite";
|
||||
|
||||
@@ -8,6 +8,12 @@ import type {
|
||||
Where,
|
||||
} from "@better-auth/core/db/adapter";
|
||||
import { createAdapterFactory } from "@better-auth/core/db/adapter";
|
||||
import {
|
||||
checksSchema,
|
||||
createSchemaCheck,
|
||||
getExpectedSchema,
|
||||
registerSchemaCheck,
|
||||
} from "@better-auth/core/db/internal";
|
||||
import { logger } from "@better-auth/core/env";
|
||||
import { capitalizeFirstLetter } from "@better-auth/core/utils/string";
|
||||
import type {
|
||||
@@ -24,6 +30,7 @@ import {
|
||||
insensitiveNe,
|
||||
insensitiveNotIn,
|
||||
} from "./query-builders";
|
||||
import { findSchemaProblems } from "./schema-check";
|
||||
import type { KyselyDatabaseType } from "./types";
|
||||
|
||||
interface KyselyAdapterConfig {
|
||||
@@ -1013,6 +1020,22 @@ export const kyselyAdapter = (
|
||||
|
||||
return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
|
||||
lazyOptions = options;
|
||||
return adapter(options);
|
||||
const instance = adapter(options);
|
||||
if (checksSchema(options)) {
|
||||
registerSchemaCheck(
|
||||
instance,
|
||||
createSchemaCheck(
|
||||
() =>
|
||||
findSchemaProblems(
|
||||
db,
|
||||
config?.type,
|
||||
getExpectedSchema(options, { usePlural: config?.usePlural }),
|
||||
),
|
||||
"database",
|
||||
options.database,
|
||||
),
|
||||
);
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ExpectedSchema } from "@better-auth/core/db/internal";
|
||||
import { diffSchema } from "@better-auth/core/db/internal";
|
||||
import type { KyselyPlugin } from "kysely";
|
||||
import {
|
||||
CamelCasePlugin,
|
||||
DummyDriver,
|
||||
Kysely,
|
||||
PostgresDialect,
|
||||
SqliteAdapter,
|
||||
SqliteIntrospector,
|
||||
SqliteQueryCompiler,
|
||||
sql,
|
||||
} from "kysely";
|
||||
import { Pool } from "pg";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
findSchemaProblems,
|
||||
getPostgresSchema,
|
||||
toPhysicalSchema,
|
||||
} from "./schema-check";
|
||||
|
||||
/**
|
||||
* A connection that compiles queries and never sends them.
|
||||
*/
|
||||
function connection(plugins: KyselyPlugin[] = []) {
|
||||
return new Kysely<unknown>({
|
||||
dialect: {
|
||||
createAdapter: () => new SqliteAdapter(),
|
||||
createDriver: () => new DummyDriver(),
|
||||
createIntrospector: (db) => new SqliteIntrospector(db),
|
||||
createQueryCompiler: () => new SqliteQueryCompiler(),
|
||||
},
|
||||
plugins,
|
||||
});
|
||||
}
|
||||
|
||||
const expected: ExpectedSchema = {
|
||||
twoFactor: {
|
||||
fields: {
|
||||
userId: { type: "string" },
|
||||
backupCodes: { type: "string", required: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("toPhysicalSchema", () => {
|
||||
it("keeps the schema when no plugin renames identifiers", () => {
|
||||
expect(toPhysicalSchema(connection(), expected)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("uses the identifiers the plugins send", () => {
|
||||
expect(
|
||||
toPhysicalSchema(connection([new CamelCasePlugin()]), expected),
|
||||
).toEqual({
|
||||
two_factor: {
|
||||
fields: {
|
||||
user_id: { type: "string" },
|
||||
backup_codes: { type: "string", required: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the schema a plugin qualifies tables with", () => {
|
||||
const physical = toPhysicalSchema(
|
||||
connection().withSchema("auth"),
|
||||
expected,
|
||||
);
|
||||
expect(physical.twoFactor?.schema).toBe("auth");
|
||||
expect(
|
||||
toPhysicalSchema(connection(), expected).twoFactor?.schema,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("splits a schema-qualified model name into schema and table", () => {
|
||||
const physical = toPhysicalSchema(connection(), {
|
||||
"internal.users": { fields: { email: { type: "string" } } },
|
||||
});
|
||||
expect(physical).toEqual({
|
||||
users: { fields: { email: { type: "string" } }, schema: "internal" },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://kysely-org.github.io/kysely-apidoc/classes/CamelCasePlugin.html
|
||||
*/
|
||||
it("compares the implicit id using its physical identifier", () => {
|
||||
const physical = toPhysicalSchema(
|
||||
connection([new CamelCasePlugin({ upperCase: true })]),
|
||||
expected,
|
||||
);
|
||||
const columns = ["ID", "USER_ID", "BACKUP_CODES"].map((name) => ({
|
||||
name,
|
||||
nullable: false,
|
||||
hasDefault: false,
|
||||
}));
|
||||
expect(diffSchema(physical, [{ name: "TWO_FACTOR", columns }])).toEqual([]);
|
||||
expect(
|
||||
diffSchema(physical, [{ name: "TWO_FACTOR", columns: columns.slice(1) }]),
|
||||
).toEqual([{ kind: "missing-column", table: "TWO_FACTOR", column: "ID" }]);
|
||||
});
|
||||
|
||||
it("follows the plugin options rather than a fixed rule", () => {
|
||||
expect(
|
||||
toPhysicalSchema(
|
||||
connection([new CamelCasePlugin({ upperCase: true })]),
|
||||
expected,
|
||||
),
|
||||
).toHaveProperty("TWO_FACTOR.fields.USER_ID");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH
|
||||
*/
|
||||
describe("PostgreSQL schema validation", () => {
|
||||
const CONNECTION_STRING =
|
||||
"postgres://user:password@localhost:5433/better_auth";
|
||||
let postgresAvailable = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
const probe = new Pool({
|
||||
connectionString: CONNECTION_STRING,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
try {
|
||||
await probe.query("SELECT 1");
|
||||
postgresAvailable = true;
|
||||
} catch {
|
||||
// Integration tests require the local PostgreSQL test database.
|
||||
} finally {
|
||||
await probe.end();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(({ skip }) => {
|
||||
if (!postgresAvailable)
|
||||
skip("Local PostgreSQL test database is unavailable");
|
||||
});
|
||||
|
||||
it("keeps the migration fallback when search_path is empty", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const pool = new Pool({ connectionString: CONNECTION_STRING, max: 1 });
|
||||
const db = new Kysely({
|
||||
dialect: new PostgresDialect({ pool }),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
});
|
||||
onTestFinished(() => db.destroy());
|
||||
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await sql`SET TRANSACTION READ ONLY`.execute(trx);
|
||||
await sql`SELECT pg_catalog.set_config('search_path', '', true)`.execute(
|
||||
trx,
|
||||
);
|
||||
await expect(getPostgresSchema(trx)).resolves.toBe("public");
|
||||
});
|
||||
});
|
||||
|
||||
it("reads PostgreSQL metadata without changing the schema", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const pool = new Pool({ connectionString: CONNECTION_STRING, max: 1 });
|
||||
const db = new Kysely({ dialect: new PostgresDialect({ pool }) });
|
||||
onTestFinished(() => db.destroy());
|
||||
const table = `ba_missing_${randomUUID().replaceAll("-", "")}`;
|
||||
|
||||
await expect(
|
||||
findSchemaProblems(db, "postgres", { [table]: { fields: {} } }),
|
||||
).resolves.toEqual([{ kind: "missing-table", table }]);
|
||||
});
|
||||
|
||||
it("resolves role schemas and each table in search-path order", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const role = `ba_readiness_${randomUUID().replaceAll("-", "")}`;
|
||||
const fallback = `${role}_fallback`;
|
||||
const pool = new Pool({ connectionString: CONNECTION_STRING, max: 1 });
|
||||
const db = new Kysely({ dialect: new PostgresDialect({ pool }) });
|
||||
onTestFinished(() => db.destroy());
|
||||
await pool.query(`CREATE ROLE "${role}"`);
|
||||
onTestFinished(async () => {
|
||||
await pool.query("RESET ROLE");
|
||||
await pool.query(`DROP ROLE "${role}"`);
|
||||
});
|
||||
await pool.query(`CREATE SCHEMA "${role}" AUTHORIZATION "${role}"`);
|
||||
onTestFinished(() =>
|
||||
pool.query(`DROP SCHEMA "${role}" CASCADE`).then(() => {}),
|
||||
);
|
||||
await pool.query(`CREATE SCHEMA "${fallback}" AUTHORIZATION "${role}"`);
|
||||
onTestFinished(() =>
|
||||
pool.query(`DROP SCHEMA "${fallback}" CASCADE`).then(() => {}),
|
||||
);
|
||||
await pool.query(`SET ROLE "${role}"`);
|
||||
await pool.query(`SET search_path TO "$user", "${fallback}"`);
|
||||
await pool.query(
|
||||
'CREATE TABLE account (id text PRIMARY KEY, "providerId" text NOT NULL)',
|
||||
);
|
||||
await pool.query(
|
||||
`CREATE TABLE "${fallback}".account (id text PRIMARY KEY, issuer text NOT NULL)`,
|
||||
);
|
||||
await pool.query(
|
||||
`CREATE TABLE "${fallback}".session (id text PRIMARY KEY, token text NOT NULL)`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
findSchemaProblems(db, "postgres", {
|
||||
account: { fields: { providerId: { type: "string" } } },
|
||||
session: { fields: { token: { type: "string" } } },
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import type {
|
||||
ExpectedSchema,
|
||||
IntrospectedTable,
|
||||
SchemaFinding,
|
||||
} from "@better-auth/core/db/internal";
|
||||
import { diffSchema } from "@better-auth/core/db/internal";
|
||||
import type { Kysely, TableMetadata } from "kysely";
|
||||
import {
|
||||
ColumnNode,
|
||||
ReferenceNode,
|
||||
SelectQueryNode,
|
||||
sql,
|
||||
TableNode,
|
||||
} from "kysely";
|
||||
import type { KyselyDatabaseType } from "./types";
|
||||
|
||||
type AnyTables = Record<string, Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Converts Kysely table metadata into the shape `diffSchema` compares.
|
||||
*/
|
||||
export function toIntrospectedTables(
|
||||
tables: readonly TableMetadata[],
|
||||
): IntrospectedTable[] {
|
||||
return tables.map((table) => ({
|
||||
name: table.name,
|
||||
schema: table.schema,
|
||||
columns: table.columns.map((column) => ({
|
||||
name: column.name,
|
||||
nullable: column.isNullable,
|
||||
hasDefault: column.hasDefaultValue || column.isAutoIncrementing,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The expected schema in the identifiers the connection sends. A plugin that
|
||||
* renames identifiers, such as `CamelCasePlugin`, does so in `transformQuery`,
|
||||
* so compiling one select per table through the connection yields the names
|
||||
* the database is asked for. Without such a plugin the schema is unchanged.
|
||||
*/
|
||||
export function toPhysicalSchema(
|
||||
db: Kysely<unknown>,
|
||||
expected: ExpectedSchema,
|
||||
): ExpectedSchema {
|
||||
const physical: ExpectedSchema = {};
|
||||
for (const [table, definition] of Object.entries(expected)) {
|
||||
const entries = Object.entries(definition.fields);
|
||||
const sent = sentIdentifiers(db as Kysely<AnyTables>, table, [
|
||||
definition.idColumn ?? "id",
|
||||
...entries.map(([column]) => column),
|
||||
]);
|
||||
const fields: ExpectedSchema[string]["fields"] = {};
|
||||
entries.forEach(([column, attribute], index) => {
|
||||
fields[sent.columns[index + 1] ?? column] = attribute;
|
||||
});
|
||||
physical[sent.table] = {
|
||||
...definition,
|
||||
fields,
|
||||
schema: sent.schema ?? definition.schema,
|
||||
...(sent.columns[0] !== "id" && { idColumn: sent.columns[0] }),
|
||||
};
|
||||
}
|
||||
return physical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles a select of `columns` from `table` and reads the identifiers back
|
||||
* out of the transformed query, including the schema a plugin such as
|
||||
* `WithSchemaPlugin` qualifies the table with. A node the query does not
|
||||
* carry in the expected shape leaves its identifier undefined.
|
||||
*/
|
||||
function sentIdentifiers(
|
||||
db: Kysely<AnyTables>,
|
||||
table: string,
|
||||
columns: string[],
|
||||
): {
|
||||
schema?: string | undefined;
|
||||
table: string;
|
||||
columns: (string | undefined)[];
|
||||
} {
|
||||
const { query } = db.selectFrom(table).select(columns).compile();
|
||||
if (!SelectQueryNode.is(query)) return { table, columns: [] };
|
||||
// cspell:ignore froms
|
||||
const from = query.from?.froms[0];
|
||||
const sentTable = from && TableNode.is(from) ? from.table : undefined;
|
||||
return {
|
||||
schema: sentTable?.schema?.name,
|
||||
table: sentTable?.identifier.name ?? table,
|
||||
columns: (query.selections ?? []).map(({ selection }) =>
|
||||
ReferenceNode.is(selection) && ColumnNode.is(selection.column)
|
||||
? selection.column.column.name
|
||||
: undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The default schema for migration tooling. Let PostgreSQL resolve role
|
||||
* names and privileges, retaining the legacy public fallback when none exists.
|
||||
* Runtime validation uses the effective search path instead of this fallback.
|
||||
*/
|
||||
export async function getPostgresSchema(db: Kysely<unknown>): Promise<string> {
|
||||
const result = await sql<{ schema: string | null }>`
|
||||
SELECT pg_catalog.current_schema() AS schema
|
||||
`.execute(db.withoutPlugins());
|
||||
return result.rows[0]?.schema ?? "public";
|
||||
}
|
||||
|
||||
export async function getMssqlSchema(db: Kysely<unknown>): Promise<string> {
|
||||
const result = await sql<{ schemaName?: string }>`
|
||||
SELECT SCHEMA_NAME() AS "schemaName"
|
||||
`.execute(db.withoutPlugins());
|
||||
return result.rows[0]?.schemaName || "dbo";
|
||||
}
|
||||
|
||||
async function schemaSearchPath(
|
||||
db: Kysely<unknown>,
|
||||
dbType: KyselyDatabaseType | undefined,
|
||||
): Promise<string[] | undefined> {
|
||||
if (dbType === "postgres") {
|
||||
const result = await sql<{ schemas: string[] }>`
|
||||
SELECT pg_catalog.current_schemas(true)::text[] AS schemas
|
||||
`.execute(db.withoutPlugins());
|
||||
return result.rows[0]?.schemas ?? [];
|
||||
}
|
||||
if (dbType === "mssql") return [await getMssqlSchema(db), "dbo"];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the live database with the tables this configuration writes. Both
|
||||
* sides are read in the identifiers the connection sends: a plugin that
|
||||
* renames identifiers or qualifies them with a schema is applied to the
|
||||
* expected side, and introspection reports what the database stores.
|
||||
*/
|
||||
export async function findSchemaProblems(
|
||||
db: Kysely<unknown>,
|
||||
dbType: KyselyDatabaseType | undefined,
|
||||
expected: ExpectedSchema,
|
||||
): Promise<SchemaFinding[]> {
|
||||
const physical = toPhysicalSchema(db, expected);
|
||||
return db.connection().execute(async (connection) => {
|
||||
const searchPath = await schemaSearchPath(connection, dbType);
|
||||
const tables = toIntrospectedTables(
|
||||
await connection.introspection.getTables(),
|
||||
);
|
||||
for (const [name, table] of Object.entries(physical)) {
|
||||
if (table.schema !== undefined || !searchPath) continue;
|
||||
table.schema =
|
||||
searchPath.find((schema) =>
|
||||
tables.some(
|
||||
(candidate) =>
|
||||
candidate.name === name && candidate.schema === schema,
|
||||
),
|
||||
) ?? "";
|
||||
}
|
||||
return diffSchema(physical, tables);
|
||||
});
|
||||
}
|
||||
Generated
+6
@@ -1432,9 +1432,15 @@ importers:
|
||||
'@cloudflare/workers-types':
|
||||
specifier: ^4.20250121.0
|
||||
version: 4.20260226.1
|
||||
'@types/pg':
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
kysely:
|
||||
specifier: 0.28.17
|
||||
version: 0.28.17
|
||||
pg:
|
||||
specifier: ^8.0.0
|
||||
version: 8.19.0
|
||||
tsdown:
|
||||
specifier: 'catalog:'
|
||||
version: 0.21.10(@arethetypeswrong/core@0.18.3)(oxc-resolver@11.21.3)(publint@0.3.21)(typescript@6.0.3)
|
||||
|
||||
Reference in New Issue
Block a user