@better-auth/core 1.7.1 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/api/index.d.mts +3 -0
  2. package/dist/context/endpoint-context.d.mts +19 -5
  3. package/dist/context/endpoint-context.mjs +35 -16
  4. package/dist/context/global.mjs +5 -2
  5. package/dist/context/index.d.mts +2 -2
  6. package/dist/context/index.mjs +2 -2
  7. package/dist/context/transaction.mjs +3 -0
  8. package/dist/db/adapter/atomic-fallback.mjs +134 -0
  9. package/dist/db/adapter/factory.mjs +22 -4
  10. package/dist/db/adapter/index.d.mts +15 -11
  11. package/dist/db/get-tables.mjs +1 -9
  12. package/dist/db/index.d.mts +2 -2
  13. package/dist/db/index.mjs +2 -2
  14. package/dist/db/internal.d.mts +3 -1
  15. package/dist/db/internal.mjs +3 -1
  16. package/dist/db/schema/account.d.mts +2 -13
  17. package/dist/db/schema/account.mjs +1 -19
  18. package/dist/db/schema-check.d.mts +48 -0
  19. package/dist/db/schema-check.mjs +80 -0
  20. package/dist/db/schema-diff.d.mts +104 -0
  21. package/dist/db/schema-diff.mjs +154 -0
  22. package/dist/env/logger.mjs +16 -1
  23. package/dist/instrumentation/tracer.mjs +1 -1
  24. package/dist/oauth2/index.d.mts +2 -2
  25. package/dist/oauth2/oauth-provider.d.mts +0 -10
  26. package/dist/oauth2/token-endpoint-auth.d.mts +26 -2
  27. package/dist/oauth2/token-endpoint-auth.mjs +11 -0
  28. package/dist/social-providers/apple.d.mts +0 -1
  29. package/dist/social-providers/apple.mjs +0 -1
  30. package/dist/social-providers/cloudflare.d.mts +132 -0
  31. package/dist/social-providers/cloudflare.mjs +85 -0
  32. package/dist/social-providers/cognito.d.mts +0 -1
  33. package/dist/social-providers/cognito.mjs +0 -1
  34. package/dist/social-providers/facebook.d.mts +0 -1
  35. package/dist/social-providers/facebook.mjs +0 -1
  36. package/dist/social-providers/google.d.mts +0 -1
  37. package/dist/social-providers/google.mjs +0 -1
  38. package/dist/social-providers/index.d.mts +53 -21
  39. package/dist/social-providers/index.mjs +3 -1
  40. package/dist/social-providers/line.d.mts +0 -1
  41. package/dist/social-providers/line.mjs +0 -1
  42. package/dist/social-providers/microsoft-entra-id.d.mts +0 -3
  43. package/dist/social-providers/microsoft-entra-id.mjs +0 -1
  44. package/dist/social-providers/paybin.d.mts +0 -1
  45. package/dist/social-providers/paybin.mjs +0 -1
  46. package/dist/social-providers/paypal.d.mts +3 -11
  47. package/dist/social-providers/paypal.mjs +20 -47
  48. package/dist/social-providers/reddit.mjs +22 -23
  49. package/dist/social-providers/roblox.mjs +5 -1
  50. package/dist/social-providers/tiktok.d.mts +1 -0
  51. package/dist/social-providers/tiktok.mjs +19 -10
  52. package/dist/social-providers/twitter.mjs +5 -1
  53. package/dist/social-providers/wechat.mjs +6 -1
  54. package/dist/types/context.d.mts +11 -0
  55. package/dist/types/init-options.d.mts +11 -0
  56. package/dist/utils/ip.mjs +11 -9
  57. package/dist/utils/url.d.mts +10 -1
  58. package/dist/utils/url.mjs +21 -1
  59. package/package.json +3 -3
  60. package/src/context/endpoint-context.ts +46 -21
  61. package/src/context/global.ts +7 -0
  62. package/src/context/index.ts +2 -0
  63. package/src/context/transaction.ts +5 -0
  64. package/src/db/adapter/atomic-fallback.ts +237 -0
  65. package/src/db/adapter/factory.ts +33 -17
  66. package/src/db/adapter/index.ts +15 -11
  67. package/src/db/get-tables.ts +1 -14
  68. package/src/db/index.ts +0 -2
  69. package/src/db/internal.ts +19 -0
  70. package/src/db/schema/account.ts +3 -22
  71. package/src/db/schema/user.ts +1 -1
  72. package/src/db/schema-check.ts +107 -0
  73. package/src/db/schema-diff.ts +270 -0
  74. package/src/env/logger.ts +22 -1
  75. package/src/oauth2/index.ts +2 -0
  76. package/src/oauth2/oauth-provider.ts +0 -10
  77. package/src/oauth2/token-endpoint-auth.ts +39 -6
  78. package/src/social-providers/apple.ts +0 -1
  79. package/src/social-providers/cloudflare.ts +221 -0
  80. package/src/social-providers/cognito.ts +0 -1
  81. package/src/social-providers/facebook.ts +0 -1
  82. package/src/social-providers/google.ts +0 -1
  83. package/src/social-providers/index.ts +3 -0
  84. package/src/social-providers/line.ts +0 -1
  85. package/src/social-providers/microsoft-entra-id.ts +0 -1
  86. package/src/social-providers/paybin.ts +0 -1
  87. package/src/social-providers/paypal.ts +30 -71
  88. package/src/social-providers/reddit.ts +34 -37
  89. package/src/social-providers/roblox.ts +5 -3
  90. package/src/social-providers/tiktok.ts +25 -14
  91. package/src/social-providers/twitter.ts +8 -2
  92. package/src/social-providers/wechat.ts +6 -6
  93. package/src/types/context.ts +11 -0
  94. package/src/types/init-options.ts +11 -0
  95. package/src/utils/ip.ts +13 -9
  96. package/src/utils/url.ts +43 -0
@@ -8,6 +8,7 @@ import { BetterAuthError } from "../../error";
8
8
  import type { BetterAuthOptions } from "../../types";
9
9
  import { safeJSONParse } from "../../utils/json";
10
10
  import { getAuthTables } from "../get-tables";
11
+ import { createAtomicFallbacks } from "./atomic-fallback";
11
12
  import { initGetDefaultFieldName } from "./get-default-field-name";
12
13
  import { initGetDefaultModelName } from "./get-default-model-name";
13
14
  import { initGetFieldAttributes } from "./get-field-attributes";
@@ -841,6 +842,16 @@ export const createAdapterFactory =
841
842
  });
842
843
 
843
844
  let lazyLoadTransaction: DBAdapter<Options>["transaction"] | null = null;
845
+ const atomicFallbacks = createAtomicFallbacks({
846
+ adapter: adapterInstance,
847
+ adapterId: config.adapterId,
848
+ mapKeysTransformInput: config.mapKeysTransformInput,
849
+ mapKeysTransformOutput: config.mapKeysTransformOutput,
850
+ getFieldName,
851
+ transformOutput,
852
+ transformWhereClause,
853
+ });
854
+
844
855
  const adapter: DBAdapter<Options> = {
845
856
  transaction: async (cb) => {
846
857
  if (!lazyLoadTransaction) {
@@ -1366,18 +1377,20 @@ export const createAdapterFactory =
1366
1377
  { model, where },
1367
1378
  );
1368
1379
 
1369
- if (typeof adapterInstance.consumeOne !== "function") {
1370
- throw new BetterAuthError(
1371
- `Adapter "${config.adapterId}" must implement consumeOne for atomic single-use credential consumption.`,
1372
- );
1373
- }
1374
1380
  const res = await withSpan(
1375
1381
  `db consumeOne ${model}`,
1376
1382
  {
1377
1383
  [ATTR_DB_OPERATION_NAME]: "consumeOne",
1378
1384
  [ATTR_DB_COLLECTION_NAME]: model,
1379
1385
  },
1380
- () => adapterInstance.consumeOne<T>({ model, where }),
1386
+ () =>
1387
+ adapterInstance.consumeOne
1388
+ ? adapterInstance.consumeOne<T>({ model, where })
1389
+ : atomicFallbacks.consumeOne({
1390
+ model,
1391
+ logicalModel: unsafeModel,
1392
+ where,
1393
+ }),
1381
1394
  );
1382
1395
 
1383
1396
  debugLog(
@@ -1440,11 +1453,6 @@ export const createAdapterFactory =
1440
1453
  { model, where, increment: unsafeIncrement, set: unsafeSet },
1441
1454
  );
1442
1455
 
1443
- if (typeof adapterInstance.incrementOne !== "function") {
1444
- throw new BetterAuthError(
1445
- `Adapter "${config.adapterId}" must implement incrementOne for atomic guarded counter updates.`,
1446
- );
1447
- }
1448
1456
  const mappedKeys = config.mapKeysTransformInput ?? {};
1449
1457
  const increment: Record<string, number> = {};
1450
1458
  for (const [field, delta] of Object.entries(unsafeIncrement)) {
@@ -1473,12 +1481,20 @@ export const createAdapterFactory =
1473
1481
  [ATTR_DB_COLLECTION_NAME]: model,
1474
1482
  },
1475
1483
  () =>
1476
- adapterInstance.incrementOne<T>({
1477
- model,
1478
- where,
1479
- increment,
1480
- set,
1481
- }),
1484
+ adapterInstance.incrementOne
1485
+ ? adapterInstance.incrementOne<T>({
1486
+ model,
1487
+ where,
1488
+ increment,
1489
+ set,
1490
+ })
1491
+ : atomicFallbacks.incrementOne({
1492
+ model,
1493
+ logicalModel: unsafeModel,
1494
+ where,
1495
+ increment,
1496
+ set,
1497
+ }),
1482
1498
  );
1483
1499
 
1484
1500
  debugLog(
@@ -466,9 +466,9 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
466
466
  * race-safe primitive for consuming single-use credentials
467
467
  * (verification tokens, authorization codes, one-time tokens).
468
468
  *
469
- * Always defined on the factory-wrapped adapter. The underlying
470
- * `CustomAdapter` must implement this natively; there is no portable
471
- * fallback that can guarantee cross-process single-use semantics.
469
+ * Always defined on the factory-wrapped adapter. Without a native method,
470
+ * the factory uses a snapshot-guarded delete and requires an exact affected
471
+ * row count. The adapter must evaluate the condition and deletion atomically.
472
472
  */
473
473
  consumeOne: <T>(data: { model: string; where: Where[] }) => Promise<T | null>;
474
474
  /**
@@ -490,9 +490,11 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
490
490
  * primitive for guarded counter updates (e.g. decrementing a remaining-uses
491
491
  * counter only while it is still positive).
492
492
  *
493
- * Always defined on the factory-wrapped adapter. The underlying
494
- * `CustomAdapter` must implement this natively; there is no portable
495
- * fallback that can guarantee guarded counter semantics across runtimes.
493
+ * Always defined on the factory-wrapped adapter. Without a native method,
494
+ * the factory uses bounded compare-and-swap retries. Contention exhaustion
495
+ * throws rather than returning null. Conditional writes must be atomic.
496
+ * A no-op may return the read snapshot without writing. A non-null result
497
+ * alone does not establish exclusive ownership of the row.
496
498
  */
497
499
  incrementOne: <T>(data: {
498
500
  model: string;
@@ -587,19 +589,21 @@ export interface CustomAdapter {
587
589
  where: CleanedWhere[];
588
590
  }) => Promise<number>;
589
591
  /**
590
- * Native atomic single-row consume.
592
+ * Optional native atomic single-row consume.
593
+ *
591
594
  * Implementing this method natively (e.g. `DELETE ... RETURNING *`,
592
595
  * `findOneAndDelete`, `OUTPUT deleted.*`) gives one round trip and the
593
596
  * strongest race-safety guarantee. Implementations must delete at most
594
597
  * one matching row.
595
598
  */
596
- consumeOne: <T>(data: {
599
+ consumeOne?: <T>(data: {
597
600
  model: string;
598
601
  where: CleanedWhere[];
599
602
  }) => Promise<T | null>;
600
603
  /**
601
- * Native atomic guarded counter mutation. Applies
602
- * `field = field + delta` for each entry in `increment` (negative deltas
604
+ * Optional native atomic guarded counter mutation.
605
+ *
606
+ * Applies `field = field + delta` for each entry in `increment` (negative deltas
603
607
  * decrement), with `where` acting as both selector and guard and `set`
604
608
  * assigning absolute values in the same operation. Returns the updated row,
605
609
  * or `null` when the guard matched no row.
@@ -608,7 +612,7 @@ export interface CustomAdapter {
608
612
  * RETURNING *`) gives one round trip and the strongest race-safety
609
613
  * guarantee.
610
614
  */
611
- incrementOne: <T>(data: {
615
+ incrementOne?: <T>(data: {
612
616
  model: string;
613
617
  where: CleanedWhere[];
614
618
  increment: Record<string, number>;
@@ -250,21 +250,8 @@ const buildAuthTables = (options: BetterAuthOptions): BetterAuthDBSchema => {
250
250
  : {}),
251
251
  account: {
252
252
  modelName: options.account?.modelName || "account",
253
- indexes: mergeTableIndexes(
254
- [
255
- {
256
- fields: ["issuer", "accountId"],
257
- unique: true,
258
- },
259
- ],
260
- account?.indexes,
261
- ),
253
+ indexes: account?.indexes,
262
254
  fields: {
263
- issuer: {
264
- type: "string",
265
- required: true,
266
- fieldName: options.account?.fields?.issuer || "issuer",
267
- },
268
255
  accountId: {
269
256
  type: "string",
270
257
  required: true,
package/src/db/index.ts CHANGED
@@ -5,8 +5,6 @@ export {
5
5
  type AccountKey,
6
6
  accountSchema,
7
7
  type BaseAccount,
8
- createLocalAccountIssuer,
9
- createOAuthAccountIssuer,
10
8
  } from "./schema/account";
11
9
  export {
12
10
  type BaseRateLimit,
@@ -10,3 +10,22 @@ export {
10
10
  resolveDatabaseTableIndexes,
11
11
  } from "./database-index";
12
12
  export { getAuthTablesWithResolvedIndexes } from "./get-tables";
13
+ export {
14
+ checksSchema,
15
+ createSchemaCheck,
16
+ invalidateSchemaChecks,
17
+ registerSchemaCheck,
18
+ type SchemaCheck,
19
+ schemaCheckFor,
20
+ } from "./schema-check";
21
+ export {
22
+ diffSchema,
23
+ type ExpectedSchema,
24
+ formatSchemaFinding,
25
+ getExpectedSchema,
26
+ type IntrospectedColumn,
27
+ type IntrospectedTable,
28
+ type SchemaFinding,
29
+ SchemaMismatchError,
30
+ type SchemaSource,
31
+ } from "./schema-diff";
@@ -9,7 +9,6 @@ import { coreSchema } from "./shared";
9
9
 
10
10
  export const accountSchema = coreSchema.extend({
11
11
  providerId: z.string(),
12
- issuer: z.string(),
13
12
  accountId: z.string(),
14
13
  userId: z.coerce.string(),
15
14
  accessToken: z.string().nullish(),
@@ -39,27 +38,9 @@ export const accountSchema = coreSchema.extend({
39
38
  export type BaseAccount = z.infer<typeof accountSchema>;
40
39
 
41
40
  /** The stable provider-side key used to recognize an account. */
42
- export type AccountKey = Readonly<Pick<BaseAccount, "issuer" | "accountId">>;
43
-
44
- function encodeAccountIssuerProviderId(providerId: string): string {
45
- return encodeURIComponent(providerId);
46
- }
47
-
48
- /**
49
- * Creates the synthetic issuer used by providers without an issuer of their own.
50
- */
51
- export function createLocalAccountIssuer(providerId: string): string {
52
- return `local:${encodeAccountIssuerProviderId(providerId)}`;
53
- }
54
-
55
- /**
56
- * Creates the synthetic issuer used by OAuth providers without an issuer of
57
- * their own. OAuth identities use a distinct namespace so a provider ID
58
- * cannot collide with an internal local authentication method.
59
- */
60
- export function createOAuthAccountIssuer(providerId: string): string {
61
- return `local:oauth:${encodeAccountIssuerProviderId(providerId)}`;
62
- }
41
+ export type AccountKey = Readonly<
42
+ Pick<BaseAccount, "providerId" | "accountId">
43
+ >;
63
44
 
64
45
  /**
65
46
  * Account schema type used by better-auth, note that it's possible that account could have additional fields
@@ -9,7 +9,7 @@ import { coreSchema } from "./shared";
9
9
  export const userSchema = coreSchema.extend({
10
10
  // TODO(#9124): widen to nullish in v2. OAuth providers (Discord phone-only,
11
11
  // Apple subsequent sign-ins, etc.) can legitimately omit email; identity
12
- // must key on (issuer, accountId) per OpenID Connect Core §5.7.
12
+ // must key on (providerId, accountId) per OpenID Connect Core §5.7.
13
13
  email: z.string().transform((val) => val.toLowerCase()),
14
14
  emailVerified: z.boolean().default(false),
15
15
  name: z.string(),
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Internal schema validation infrastructure for built-in adapters.
3
+ *
4
+ * Intended to become a public core extension point for community database
5
+ * adapter authors once the registration and lifecycle contracts are stabilized.
6
+ */
7
+
8
+ import type { BetterAuthOptions } from "../types";
9
+ import type { SchemaFinding, SchemaSource } from "./schema-diff";
10
+ import { SchemaMismatchError } from "./schema-diff";
11
+
12
+ /**
13
+ * Whether the adapter validates its schema. Enabled in every environment
14
+ * unless explicitly disabled.
15
+ */
16
+ export function checksSchema(options: BetterAuthOptions): boolean {
17
+ return options.advanced?.database?.validateSchema !== false;
18
+ }
19
+
20
+ /**
21
+ * Resolves when the schema can hold what Better Auth writes. Returns nothing
22
+ * once that is known and the database schema revision is unchanged.
23
+ */
24
+ export type SchemaCheck = () => Promise<void> | undefined;
25
+
26
+ const schemaChecks = new WeakMap<object, SchemaCheck>();
27
+ const schemaRevisions = new WeakMap<object, { value: number }>();
28
+
29
+ /** Invalidates cached checks after Better Auth changes this database's schema. */
30
+ export function invalidateSchemaChecks(database: object): void {
31
+ const revision = schemaRevisions.get(database);
32
+ if (revision) revision.value++;
33
+ }
34
+
35
+ /**
36
+ * Attaches a check to the adapter it verifies. The adapter object itself is
37
+ * left untouched, so this works for adapters Better Auth does not own.
38
+ */
39
+ export function registerSchemaCheck(adapter: object, check: SchemaCheck): void {
40
+ schemaChecks.set(adapter, check);
41
+ }
42
+
43
+ /**
44
+ * The check registered for an adapter, if its store is checked at all.
45
+ */
46
+ export function schemaCheckFor(adapter: object): SchemaCheck | undefined {
47
+ return schemaChecks.get(adapter);
48
+ }
49
+
50
+ /**
51
+ * Turns a schema comparison into a check shared by one adapter instance.
52
+ *
53
+ * The first call runs `find` and every concurrent call shares that promise. A
54
+ * clean result is cached until invalidation. A mismatch is kept as one
55
+ * {@link SchemaMismatchError} and rethrown on every later call without asking
56
+ * the store again, until a migration invalidates it. When a database identity is supplied,
57
+ * checks for that identity share its schema revision. Pending callers follow
58
+ * the new check if their revision is invalidated. A failure to reach
59
+ * the store is not kept, so the next call asks again.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * const checkSchema = createSchemaCheck(
64
+ * () => findSchemaProblems(db, "postgres", expected),
65
+ * "database",
66
+ * );
67
+ * const pending = checkSchema();
68
+ * if (pending) await pending;
69
+ * ```
70
+ */
71
+ export function createSchemaCheck(
72
+ find: () => Promise<SchemaFinding[]>,
73
+ source: SchemaSource,
74
+ database?: object,
75
+ ): SchemaCheck {
76
+ let revision = database ? schemaRevisions.get(database) : undefined;
77
+ if (database && !revision) {
78
+ revision = { value: 0 };
79
+ schemaRevisions.set(database, revision);
80
+ }
81
+ let checkedRevision = revision?.value;
82
+ let clean = false;
83
+ let verdict: Promise<void> | undefined;
84
+ return function checkSchema(): Promise<void> | undefined {
85
+ const currentRevision = revision?.value;
86
+ if (checkedRevision !== currentRevision) {
87
+ checkedRevision = currentRevision;
88
+ clean = false;
89
+ verdict = undefined;
90
+ }
91
+ if (clean) return;
92
+ return (verdict ??= Promise.resolve()
93
+ .then(find)
94
+ .then(
95
+ (findings) => {
96
+ if (revision?.value !== currentRevision) return checkSchema();
97
+ if (findings.length) throw new SchemaMismatchError(findings, source);
98
+ if (checkedRevision === currentRevision) clean = true;
99
+ },
100
+ (error: unknown) => {
101
+ if (revision?.value !== currentRevision) return checkSchema();
102
+ if (checkedRevision === currentRevision) verdict = undefined;
103
+ throw error;
104
+ },
105
+ ));
106
+ };
107
+ }
@@ -0,0 +1,270 @@
1
+ import { BetterAuthError } from "../error";
2
+ import type { BetterAuthOptions } from "../types";
3
+ import { getAuthTables } from "./get-tables";
4
+ import type { DBFieldAttribute } from "./type";
5
+
6
+ /**
7
+ * A column as the database, or an ORM schema definition, reports it.
8
+ */
9
+ export interface IntrospectedColumn {
10
+ name: string;
11
+ nullable: boolean;
12
+ /**
13
+ * The store fills the column when an insert omits it.
14
+ */
15
+ hasDefault: boolean;
16
+ }
17
+
18
+ /**
19
+ * A table as the database, or an ORM schema definition, reports it.
20
+ */
21
+ export interface IntrospectedTable {
22
+ name: string;
23
+ /**
24
+ * The schema the table lives in, when the store has schemas.
25
+ */
26
+ schema?: string | undefined;
27
+ columns: IntrospectedColumn[];
28
+ }
29
+
30
+ /**
31
+ * The tables Better Auth writes, keyed the way the store addresses them:
32
+ * physical table name, then physical column name. A table that manages its
33
+ * own storage is excluded from migrations and from this comparison.
34
+ */
35
+ export type ExpectedSchema = Record<
36
+ string,
37
+ {
38
+ fields: Record<string, DBFieldAttribute>;
39
+ idColumn?: string | undefined;
40
+ disableMigrations?: boolean | undefined;
41
+ /**
42
+ * The schema the table is addressed in. Unset when the store has no
43
+ * schemas or the table is found by name alone.
44
+ */
45
+ schema?: string | undefined;
46
+ }
47
+ >;
48
+
49
+ /**
50
+ * The tables this configuration writes, keyed the way the adapter addresses
51
+ * them. Tables that share a physical name are merged into one entry.
52
+ */
53
+ export function getExpectedSchema(
54
+ options: BetterAuthOptions,
55
+ { usePlural = false }: { usePlural?: boolean | undefined } = {},
56
+ ): ExpectedSchema {
57
+ const expected: ExpectedSchema = {};
58
+ for (const table of Object.values(getAuthTables(options))) {
59
+ const name = usePlural ? `${table.modelName}s` : table.modelName;
60
+ const entry = (expected[name] ??= { fields: {}, disableMigrations: true });
61
+ for (const [key, field] of Object.entries(table.fields)) {
62
+ entry.fields[field.fieldName || key] = field;
63
+ }
64
+ entry.disableMigrations =
65
+ entry.disableMigrations && !!table.disableMigrations;
66
+ }
67
+ return expected;
68
+ }
69
+
70
+ export type SchemaFinding =
71
+ | { kind: "missing-table"; table: string }
72
+ | { kind: "missing-column"; table: string; column: string }
73
+ | { kind: "unexpected-required-column"; table: string; column: string };
74
+
75
+ /**
76
+ * How the schema reaches the store, which decides the fix each finding names.
77
+ */
78
+ export type SchemaSource = "database" | "drizzle" | "prisma";
79
+
80
+ /**
81
+ * Compares the tables Better Auth writes with what the store holds.
82
+ *
83
+ * A table or column Better Auth writes must exist. A column Better Auth does
84
+ * not write must accept an insert that omits it, so it is nullable or carries
85
+ * a default. Otherwise every insert into that table fails with a constraint
86
+ * error that says nothing about why the schema drifted.
87
+ */
88
+ export function diffSchema(
89
+ expected: ExpectedSchema,
90
+ actual: readonly IntrospectedTable[],
91
+ ): SchemaFinding[] {
92
+ const findings: SchemaFinding[] = [];
93
+ for (const [tableName, table] of Object.entries(expected)) {
94
+ if (table.disableMigrations) continue;
95
+ const actualTable = actual.find(
96
+ (candidate) =>
97
+ candidate.name === tableName &&
98
+ (table.schema === undefined || candidate.schema === table.schema),
99
+ );
100
+ if (!actualTable) {
101
+ findings.push({ kind: "missing-table", table: tableName });
102
+ continue;
103
+ }
104
+ const written = new Set([
105
+ table.idColumn ?? "id",
106
+ ...Object.keys(table.fields),
107
+ ]);
108
+ for (const column of written) {
109
+ if (!actualTable.columns.some((candidate) => candidate.name === column)) {
110
+ findings.push({ kind: "missing-column", table: tableName, column });
111
+ }
112
+ }
113
+ for (const column of actualTable.columns) {
114
+ if (written.has(column.name) || column.nullable || column.hasDefault) {
115
+ continue;
116
+ }
117
+ findings.push({
118
+ kind: "unexpected-required-column",
119
+ table: tableName,
120
+ column: column.name,
121
+ });
122
+ }
123
+ }
124
+ return findings;
125
+ }
126
+
127
+ const applyHint: Record<SchemaSource, string> = {
128
+ database: "Run `npx auth migrate` to add it.",
129
+ drizzle:
130
+ "Run `npx auth generate` to refresh the Drizzle schema, then apply it with your migration tool.",
131
+ prisma:
132
+ "Run `npx auth generate` to refresh the Prisma schema, then run `prisma migrate`.",
133
+ };
134
+
135
+ const relaxHint: Record<SchemaSource, string> = {
136
+ database: "Drop the column, make it nullable, or give it a database default.",
137
+ drizzle:
138
+ "Remove it from the Drizzle schema, make it nullable, or give it a default, then apply the change with your migration tool.",
139
+ prisma:
140
+ "Remove it from the Prisma schema, make it optional, or give it a default, then run `prisma migrate`.",
141
+ };
142
+
143
+ const sourceLabel: Record<SchemaSource, string> = {
144
+ database: "Database",
145
+ drizzle: "Drizzle",
146
+ prisma: "Prisma",
147
+ };
148
+
149
+ /**
150
+ * One finding as a sentence that names the change resolving it.
151
+ */
152
+ export function formatSchemaFinding(
153
+ finding: SchemaFinding,
154
+ source: SchemaSource,
155
+ ): string {
156
+ switch (finding.kind) {
157
+ case "missing-table":
158
+ return `Table "${finding.table}" is missing. ${applyHint[source]}`;
159
+ case "missing-column":
160
+ return `Column "${finding.column}" is missing from table "${finding.table}". ${applyHint[source]}`;
161
+ case "unexpected-required-column": {
162
+ const issuer =
163
+ finding.column === "issuer"
164
+ ? " 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"
165
+ : "";
166
+ return `Column "${finding.column}" on table "${finding.table}" is required but Better Auth never writes it, so every insert into "${finding.table}" fails. ${relaxHint[source]}${issuer}`;
167
+ }
168
+ }
169
+ }
170
+
171
+ const repairHint: Record<SchemaSource, string> = {
172
+ database:
173
+ "Make the listed columns nullable, give them defaults, or remove them.",
174
+ drizzle:
175
+ "Make the listed columns nullable in your Drizzle schema, give them defaults, or remove them.",
176
+ prisma:
177
+ "Make the listed fields optional in your Prisma schema, give them defaults, or remove them.",
178
+ };
179
+
180
+ const migrationHint: Record<SchemaSource, string> = {
181
+ ...applyHint,
182
+ database: "Run `npx auth migrate` to add the missing tables and columns.",
183
+ };
184
+
185
+ function formatSchemaMismatch(
186
+ findings: readonly SchemaFinding[],
187
+ source: SchemaSource,
188
+ ): string {
189
+ const tables: string[] = [];
190
+ const columns: string[] = [];
191
+ const required: string[] = [];
192
+ const affectedTables = new Set<string>();
193
+ let hasIssuer = false;
194
+ for (const finding of findings) {
195
+ switch (finding.kind) {
196
+ case "missing-table":
197
+ tables.push(finding.table);
198
+ break;
199
+ case "missing-column":
200
+ columns.push(`${finding.table}.${finding.column}`);
201
+ break;
202
+ case "unexpected-required-column":
203
+ required.push(`${finding.table}.${finding.column}`);
204
+ affectedTables.add(finding.table);
205
+ hasIssuer ||= finding.column === "issuer";
206
+ break;
207
+ }
208
+ }
209
+
210
+ const sections = [`${sourceLabel[source]} schema mismatch`];
211
+ if (tables.length)
212
+ sections.push(` Missing tables\n ${tables.join(", ")}`);
213
+ if (columns.length)
214
+ sections.push(` Missing columns\n ${columns.join("\n ")}`);
215
+ if (required.length) {
216
+ sections.push(
217
+ ` Required columns Better Auth never writes\n ${required.join("\n ")}`,
218
+ );
219
+ sections.push(
220
+ ` Inserts into ${[...affectedTables].join(", ")} will fail.`,
221
+ );
222
+ }
223
+
224
+ const help: string[] = [];
225
+ if (required.length) help.push(repairHint[source]);
226
+ if (
227
+ tables.length ||
228
+ columns.length ||
229
+ (required.length && source !== "database")
230
+ ) {
231
+ help.push(migrationHint[source]);
232
+ }
233
+ if (help.length) sections.push(` help: ${help.join("\n ")}`);
234
+ if (hasIssuer) {
235
+ sections.push(
236
+ " note: If this column came from Better Auth 1.7.0 through 1.7.2,\n" +
237
+ " follow the upgrade guide before removing it:\n" +
238
+ " https://www.better-auth.com/docs/guides/1-7-upgrade-guide",
239
+ );
240
+ }
241
+ return sections.join("\n\n");
242
+ }
243
+
244
+ /**
245
+ * The store cannot hold what this configuration writes.
246
+ *
247
+ * `findings` carries every problem as data; `message` lists each one with the
248
+ * change that resolves it. Reported during initialization and thrown when
249
+ * requests await validation, in every environment. Also thrown by
250
+ * `auth migrate` before it changes anything.
251
+ *
252
+ * @example
253
+ * ```ts
254
+ * try {
255
+ * await auth.api.getSession({ headers });
256
+ * } catch (error) {
257
+ * if (error instanceof SchemaMismatchError) console.error(error.findings);
258
+ * }
259
+ * ```
260
+ */
261
+ export class SchemaMismatchError extends BetterAuthError {
262
+ readonly code = "SCHEMA_MISMATCH";
263
+
264
+ constructor(
265
+ readonly findings: readonly SchemaFinding[],
266
+ readonly source: SchemaSource,
267
+ ) {
268
+ super(formatSchemaMismatch(findings, source));
269
+ }
270
+ }
package/src/env/logger.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { AuthEndpointContext } from "../context/endpoint-context";
2
+ import { __getCurrentEndpointContext } from "../context/global";
1
3
  import { getColorDepth } from "./color-depth";
2
4
 
3
5
  export const TTY_COLORS = {
@@ -142,4 +144,23 @@ export const createLogger = (options?: Logger | undefined): InternalLogger => {
142
144
  };
143
145
  };
144
146
 
145
- export const logger = createLogger();
147
+ const defaultLogger = createLogger();
148
+
149
+ const getCurrentLogger = (): InternalLogger => {
150
+ const currentLogger =
151
+ __getCurrentEndpointContext<AuthEndpointContext>()?.context.logger;
152
+ return currentLogger && currentLogger !== logger
153
+ ? currentLogger
154
+ : defaultLogger;
155
+ };
156
+
157
+ export const logger: InternalLogger = {
158
+ debug: (...params) => getCurrentLogger().debug(...params),
159
+ info: (...params) => getCurrentLogger().info(...params),
160
+ success: (...params) => getCurrentLogger().success(...params),
161
+ warn: (...params) => getCurrentLogger().warn(...params),
162
+ error: (...params) => getCurrentLogger().error(...params),
163
+ get level() {
164
+ return getCurrentLogger().level;
165
+ },
166
+ };
@@ -80,6 +80,8 @@ export {
80
80
  export type {
81
81
  TokenEndpointAuth,
82
82
  TokenEndpointAuthMethod,
83
+ TokenEndpointRequestContext,
84
+ TokenEndpointRequestHook,
83
85
  TokenEndpointSecretAuthentication,
84
86
  } from "./token-endpoint-auth";
85
87
  export {