@better-auth/core 1.7.2 → 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 (78) hide show
  1. package/dist/api/index.d.mts +3 -0
  2. package/dist/context/global.mjs +1 -1
  3. package/dist/context/transaction.mjs +3 -0
  4. package/dist/db/adapter/atomic-fallback.mjs +134 -0
  5. package/dist/db/adapter/factory.mjs +22 -4
  6. package/dist/db/adapter/index.d.mts +15 -11
  7. package/dist/db/get-tables.mjs +1 -9
  8. package/dist/db/index.d.mts +2 -2
  9. package/dist/db/index.mjs +2 -2
  10. package/dist/db/internal.d.mts +3 -1
  11. package/dist/db/internal.mjs +3 -1
  12. package/dist/db/schema/account.d.mts +2 -13
  13. package/dist/db/schema/account.mjs +1 -19
  14. package/dist/db/schema-check.d.mts +48 -0
  15. package/dist/db/schema-check.mjs +80 -0
  16. package/dist/db/schema-diff.d.mts +104 -0
  17. package/dist/db/schema-diff.mjs +154 -0
  18. package/dist/instrumentation/tracer.mjs +1 -1
  19. package/dist/oauth2/index.d.mts +2 -2
  20. package/dist/oauth2/oauth-provider.d.mts +0 -10
  21. package/dist/oauth2/token-endpoint-auth.d.mts +26 -2
  22. package/dist/oauth2/token-endpoint-auth.mjs +11 -0
  23. package/dist/social-providers/apple.d.mts +0 -1
  24. package/dist/social-providers/apple.mjs +0 -1
  25. package/dist/social-providers/cloudflare.d.mts +132 -0
  26. package/dist/social-providers/cloudflare.mjs +85 -0
  27. package/dist/social-providers/cognito.d.mts +0 -1
  28. package/dist/social-providers/cognito.mjs +0 -1
  29. package/dist/social-providers/facebook.d.mts +0 -1
  30. package/dist/social-providers/facebook.mjs +0 -1
  31. package/dist/social-providers/google.d.mts +0 -1
  32. package/dist/social-providers/google.mjs +0 -1
  33. package/dist/social-providers/index.d.mts +53 -21
  34. package/dist/social-providers/index.mjs +3 -1
  35. package/dist/social-providers/line.d.mts +0 -1
  36. package/dist/social-providers/line.mjs +0 -1
  37. package/dist/social-providers/microsoft-entra-id.d.mts +0 -3
  38. package/dist/social-providers/microsoft-entra-id.mjs +0 -1
  39. package/dist/social-providers/paybin.d.mts +0 -1
  40. package/dist/social-providers/paybin.mjs +0 -1
  41. package/dist/social-providers/paypal.d.mts +3 -11
  42. package/dist/social-providers/paypal.mjs +20 -47
  43. package/dist/social-providers/reddit.mjs +17 -22
  44. package/dist/social-providers/tiktok.d.mts +1 -0
  45. package/dist/social-providers/tiktok.mjs +14 -9
  46. package/dist/types/context.d.mts +11 -0
  47. package/dist/types/init-options.d.mts +11 -0
  48. package/dist/utils/ip.mjs +11 -9
  49. package/package.json +2 -2
  50. package/src/context/transaction.ts +5 -0
  51. package/src/db/adapter/atomic-fallback.ts +237 -0
  52. package/src/db/adapter/factory.ts +33 -17
  53. package/src/db/adapter/index.ts +15 -11
  54. package/src/db/get-tables.ts +1 -14
  55. package/src/db/index.ts +0 -2
  56. package/src/db/internal.ts +19 -0
  57. package/src/db/schema/account.ts +3 -22
  58. package/src/db/schema/user.ts +1 -1
  59. package/src/db/schema-check.ts +107 -0
  60. package/src/db/schema-diff.ts +270 -0
  61. package/src/oauth2/index.ts +2 -0
  62. package/src/oauth2/oauth-provider.ts +0 -10
  63. package/src/oauth2/token-endpoint-auth.ts +39 -6
  64. package/src/social-providers/apple.ts +0 -1
  65. package/src/social-providers/cloudflare.ts +221 -0
  66. package/src/social-providers/cognito.ts +0 -1
  67. package/src/social-providers/facebook.ts +0 -1
  68. package/src/social-providers/google.ts +0 -1
  69. package/src/social-providers/index.ts +3 -0
  70. package/src/social-providers/line.ts +0 -1
  71. package/src/social-providers/microsoft-entra-id.ts +0 -1
  72. package/src/social-providers/paybin.ts +0 -1
  73. package/src/social-providers/paypal.ts +30 -71
  74. package/src/social-providers/reddit.ts +27 -36
  75. package/src/social-providers/tiktok.ts +18 -13
  76. package/src/types/context.ts +11 -0
  77. package/src/types/init-options.ts +11 -0
  78. package/src/utils/ip.ts +13 -9
@@ -0,0 +1,237 @@
1
+ import * as z from "zod";
2
+ import { BetterAuthError } from "../../error";
3
+ import type { CleanedWhere, CustomAdapter, Where } from "./index";
4
+
5
+ const MAX_ATTEMPTS = 5;
6
+
7
+ const scalar = z
8
+ .union([z.string(), z.number(), z.boolean(), z.date()])
9
+ .nullable();
10
+ const rowSchema = z.record(z.string(), z.unknown());
11
+ const readSchema = rowSchema.nullish();
12
+ const setSchema = z.record(z.string(), z.unknown()).transform((values) => {
13
+ const assignments: z.output<typeof rowSchema> = {};
14
+ for (const [field, value] of Object.entries(values)) {
15
+ if (value !== undefined) assignments[field] = value;
16
+ }
17
+ return assignments;
18
+ });
19
+ const mutationSchema = z.object({
20
+ increment: z.record(z.string(), z.number()),
21
+ set: setSchema.optional(),
22
+ });
23
+ const counterSchema = z.number().nullish();
24
+
25
+ type StoredRow = z.output<typeof rowSchema>;
26
+
27
+ type FallbackContext = {
28
+ adapter: CustomAdapter;
29
+ adapterId: string;
30
+ mapKeysTransformInput?: Record<string, string> | undefined;
31
+ mapKeysTransformOutput?: Record<string, string> | undefined;
32
+ getFieldName: (input: { model: string; field: string }) => string;
33
+ transformOutput: (
34
+ row: StoredRow,
35
+ model: string,
36
+ select: string[],
37
+ join: undefined,
38
+ ) => Promise<{ id?: unknown } | null>;
39
+ transformWhereClause: (input: {
40
+ model: string;
41
+ where: Where[];
42
+ action: "consumeOne" | "incrementOne";
43
+ }) => CleanedWhere[];
44
+ };
45
+
46
+ type FallbackRequest = {
47
+ model: string;
48
+ logicalModel: string;
49
+ where: CleanedWhere[];
50
+ };
51
+
52
+ // Read a snapshot, then conditionally mutate it. A write succeeds only when
53
+ // an adapter checks and mutates atomically and reports exactly one affected row.
54
+ export function createAtomicFallbacks(context: FallbackContext) {
55
+ const { adapter, adapterId } = context;
56
+ const outputId =
57
+ Object.entries(context.mapKeysTransformOutput ?? {}).find(
58
+ ([, field]) => field === "id",
59
+ )?.[0] ?? "id";
60
+ async function idWhere(
61
+ row: StoredRow,
62
+ model: string,
63
+ action: "consumeOne" | "incrementOne",
64
+ ): Promise<CleanedWhere> {
65
+ const mappedId =
66
+ context.mapKeysTransformInput?.id ||
67
+ context.getFieldName({ model, field: "id" });
68
+ if (row[mappedId] === undefined || row[mappedId] === null) {
69
+ throw new BetterAuthError(
70
+ `Adapter "${context.adapterId}" must return the row id for atomic fallbacks.`,
71
+ );
72
+ }
73
+ const output = await context.transformOutput(
74
+ row,
75
+ model,
76
+ [outputId],
77
+ undefined,
78
+ );
79
+ const id = output?.id;
80
+ if (typeof id !== "string" && typeof id !== "number") {
81
+ throw new BetterAuthError(
82
+ `Adapter "${context.adapterId}" must expose a logical string or number id through its output transform.`,
83
+ );
84
+ }
85
+ const [condition] = context.transformWhereClause({
86
+ model,
87
+ where: [{ field: "id", value: id }],
88
+ action,
89
+ });
90
+ if (!condition)
91
+ throw new BetterAuthError(
92
+ "The atomic fallback id condition was transformed away.",
93
+ );
94
+ return condition;
95
+ }
96
+ async function readRow({
97
+ model,
98
+ where,
99
+ }: FallbackRequest): Promise<StoredRow | null> {
100
+ const result = readSchema.safeParse(
101
+ await adapter.findOne<unknown>({ model, where }),
102
+ );
103
+ if (!result.success) {
104
+ throw new BetterAuthError(
105
+ `Adapter "${adapterId}" must return a row snapshot or null.`,
106
+ );
107
+ }
108
+ return result.data ?? null;
109
+ }
110
+
111
+ async function snapshotGuard(
112
+ row: StoredRow,
113
+ fields: readonly string[],
114
+ request: FallbackRequest,
115
+ action: "consumeOne" | "incrementOne",
116
+ ): Promise<CleanedWhere[]> {
117
+ const id = await idWhere(row, request.logicalModel, action);
118
+ const hasOr = request.where.some((clause) => clause.connector === "OR");
119
+ const guard: CleanedWhere[] = hasOr ? [id] : [...request.where, id];
120
+ const keys = new Set(fields);
121
+ for (const field of keys) {
122
+ if (field === id.field) continue;
123
+ const value = scalar.safeParse(row[field] ?? null);
124
+ if (!value.success) {
125
+ if (hasOr && request.where.some((clause) => clause.field === field)) {
126
+ throw new BetterAuthError(
127
+ `Adapter "${adapterId}" must implement native atomic methods for OR predicates on structured values.`,
128
+ );
129
+ }
130
+ continue;
131
+ }
132
+ guard.push({
133
+ field,
134
+ value: value.data,
135
+ operator: "eq",
136
+ connector: "AND",
137
+ mode: "sensitive",
138
+ });
139
+ }
140
+ return guard;
141
+ }
142
+
143
+ function changedOne(count: number): boolean {
144
+ if (count !== 0 && count !== 1) {
145
+ throw new BetterAuthError(
146
+ `Adapter "${adapterId}" must return an affected row count of 0 or 1 from an atomic fallback.`,
147
+ );
148
+ }
149
+ return count === 1;
150
+ }
151
+
152
+ async function consumeOne(
153
+ request: FallbackRequest,
154
+ ): Promise<StoredRow | null> {
155
+ const { model, where } = request;
156
+ const row = await readRow(request);
157
+ if (row === null) return null;
158
+ // Guard the selected snapshot with AND predicates, without widening an OR selector.
159
+ const guard = await snapshotGuard(
160
+ row,
161
+ [...Object.keys(row), ...where.map(({ field }) => field)],
162
+ request,
163
+ "consumeOne",
164
+ );
165
+ const count = await adapter.deleteMany({ model, where: guard });
166
+ return changedOne(count) ? row : null;
167
+ }
168
+
169
+ async function incrementOne(
170
+ request: FallbackRequest & {
171
+ increment: Record<string, number>;
172
+ set?: Record<string, unknown> | undefined;
173
+ },
174
+ ): Promise<StoredRow | null> {
175
+ const { model, where } = request;
176
+ const mutation = mutationSchema.safeParse(request);
177
+ if (!mutation.success) {
178
+ throw new BetterAuthError(
179
+ "incrementOne requires finite increments and a set object for the atomic fallback.",
180
+ );
181
+ }
182
+ const { increment, set } = mutation.data;
183
+ const deltas = Object.entries(increment);
184
+ const fields = [
185
+ ...where.map(({ field }) => field),
186
+ ...Object.keys(increment),
187
+ ...Object.keys(set ?? {}),
188
+ ];
189
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
190
+ const row = await readRow(request);
191
+ if (row === null) return null;
192
+ const update: z.output<typeof setSchema> = { ...set };
193
+ for (const [field, delta] of deltas) {
194
+ const previous = counterSchema.safeParse(row[field]);
195
+ if (!previous.success) {
196
+ throw new BetterAuthError(
197
+ `Adapter "${adapterId}" must return finite numeric counter values or null for atomic increments.`,
198
+ );
199
+ }
200
+ const current = previous.data ?? 0;
201
+ const next = current + delta;
202
+ if (!Number.isFinite(next) || (delta !== 0 && next === current)) {
203
+ throw new BetterAuthError(
204
+ `Adapter "${adapterId}" cannot represent the requested counter increment safely.`,
205
+ );
206
+ }
207
+ update[field] = next;
208
+ }
209
+ const guard = await snapshotGuard(row, fields, request, "incrementOne");
210
+ // A no-op takes effect at the read. Stores counting changed rows would report zero.
211
+ if (
212
+ Object.entries(update).every(([field, value]) => {
213
+ const previous = row[field];
214
+ if (previous instanceof Date && value instanceof Date) {
215
+ return previous.getTime() === value.getTime();
216
+ }
217
+ return Object.is(previous, value);
218
+ })
219
+ )
220
+ return row;
221
+ const count = await adapter.updateMany({
222
+ model,
223
+ where: guard,
224
+ update,
225
+ });
226
+ if (changedOne(count)) {
227
+ // A second read could observe another writer's result instead of ours.
228
+ return { ...row, ...update };
229
+ }
230
+ }
231
+ throw new BetterAuthError(
232
+ `Adapter "${adapterId}" could not complete an atomic increment due to contention. Retry the operation or implement incrementOne natively.`,
233
+ );
234
+ }
235
+
236
+ return { consumeOne, incrementOne };
237
+ }
@@ -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
+ }