@better-auth/core 1.7.0-rc.1 → 1.7.0-rc.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 (213) hide show
  1. package/dist/api/index.d.mts +28 -28
  2. package/dist/async_hooks/index.d.mts +0 -1
  3. package/dist/async_hooks/pure.index.d.mts +0 -1
  4. package/dist/context/endpoint-context.d.mts +1 -1
  5. package/dist/context/global.d.mts +0 -1
  6. package/dist/context/global.mjs +1 -1
  7. package/dist/context/request-state.d.mts +0 -1
  8. package/dist/context/request-state.mjs +8 -5
  9. package/dist/context/transaction.d.mts +8 -3
  10. package/dist/context/transaction.mjs +27 -10
  11. package/dist/db/adapter/factory.d.mts +3 -5
  12. package/dist/db/adapter/factory.mjs +7 -5
  13. package/dist/db/adapter/get-default-field-name.d.mts +2 -9
  14. package/dist/db/adapter/get-default-model-name.d.mts +1 -5
  15. package/dist/db/adapter/get-default-model-name.mjs +6 -5
  16. package/dist/db/adapter/get-field-attributes.d.mts +4 -11
  17. package/dist/db/adapter/get-field-name.d.mts +2 -9
  18. package/dist/db/adapter/get-id-field.d.mts +4 -12
  19. package/dist/db/adapter/get-model-name.d.mts +1 -5
  20. package/dist/db/adapter/index.d.mts +7 -32
  21. package/dist/db/adapter/types.d.mts +5 -17
  22. package/dist/db/adapter/utils.d.mts +0 -1
  23. package/dist/db/database-index.d.mts +44 -0
  24. package/dist/db/database-index.mjs +168 -0
  25. package/dist/db/get-tables.d.mts +7 -1
  26. package/dist/db/get-tables.mjs +47 -6
  27. package/dist/db/index.d.mts +3 -3
  28. package/dist/db/index.mjs +2 -2
  29. package/dist/db/internal.d.mts +3 -0
  30. package/dist/db/internal.mjs +3 -0
  31. package/dist/db/plugin.d.mts +4 -3
  32. package/dist/db/schema/account.d.mts +16 -3
  33. package/dist/db/schema/account.mjs +35 -2
  34. package/dist/db/schema/rate-limit.d.mts +1 -1
  35. package/dist/db/schema/rate-limit.mjs +9 -0
  36. package/dist/db/schema/session.d.mts +1 -1
  37. package/dist/db/schema/shared.d.mts +0 -1
  38. package/dist/db/schema/user.d.mts +1 -1
  39. package/dist/db/schema/verification.d.mts +1 -1
  40. package/dist/db/type.d.mts +17 -9
  41. package/dist/env/logger.d.mts +25 -25
  42. package/dist/error/codes.d.mts +0 -1
  43. package/dist/error/index.d.mts +0 -1
  44. package/dist/index.d.mts +1 -1
  45. package/dist/instrumentation/attributes.d.mts +0 -1
  46. package/dist/instrumentation/pure.index.d.mts +0 -1
  47. package/dist/instrumentation/tracer.mjs +1 -1
  48. package/dist/oauth2/authorization-params.d.mts +0 -1
  49. package/dist/oauth2/client-assertion.d.mts +3 -13
  50. package/dist/oauth2/client-credentials-token.d.mts +3 -17
  51. package/dist/oauth2/client-credentials-token.mjs +1 -4
  52. package/dist/oauth2/create-authorization-url.d.mts +3 -21
  53. package/dist/oauth2/dpop.d.mts +2 -23
  54. package/dist/oauth2/dpop.mjs +1 -1
  55. package/dist/oauth2/index.d.mts +3 -3
  56. package/dist/oauth2/index.mjs +2 -2
  57. package/dist/oauth2/oauth-provider.d.mts +94 -36
  58. package/dist/oauth2/refresh-access-token.d.mts +3 -19
  59. package/dist/oauth2/refresh-access-token.mjs +3 -9
  60. package/dist/oauth2/reject-redirects.mjs +1 -1
  61. package/dist/oauth2/token-endpoint-auth.d.mts +0 -1
  62. package/dist/oauth2/utils.d.mts +0 -1
  63. package/dist/oauth2/utils.mjs +2 -3
  64. package/dist/oauth2/validate-authorization-code.d.mts +5 -29
  65. package/dist/oauth2/verify-id-token.d.mts +3 -2
  66. package/dist/oauth2/verify-id-token.mjs +3 -3
  67. package/dist/oauth2/verify.d.mts +18 -9
  68. package/dist/oauth2/verify.mjs +68 -5
  69. package/dist/social-providers/apple.d.mts +9 -24
  70. package/dist/social-providers/apple.mjs +2 -1
  71. package/dist/social-providers/atlassian.d.mts +18 -19
  72. package/dist/social-providers/atlassian.mjs +1 -1
  73. package/dist/social-providers/cognito.d.mts +33 -22
  74. package/dist/social-providers/cognito.mjs +2 -2
  75. package/dist/social-providers/discord.d.mts +7 -20
  76. package/dist/social-providers/discord.mjs +1 -1
  77. package/dist/social-providers/dropbox.d.mts +7 -22
  78. package/dist/social-providers/dropbox.mjs +1 -1
  79. package/dist/social-providers/facebook.d.mts +19 -27
  80. package/dist/social-providers/facebook.mjs +6 -17
  81. package/dist/social-providers/figma.d.mts +7 -22
  82. package/dist/social-providers/figma.mjs +1 -1
  83. package/dist/social-providers/github.d.mts +7 -23
  84. package/dist/social-providers/github.mjs +1 -1
  85. package/dist/social-providers/gitlab.d.mts +6 -43
  86. package/dist/social-providers/gitlab.mjs +1 -1
  87. package/dist/social-providers/google.d.mts +10 -32
  88. package/dist/social-providers/google.mjs +2 -1
  89. package/dist/social-providers/huggingface.d.mts +7 -22
  90. package/dist/social-providers/huggingface.mjs +1 -1
  91. package/dist/social-providers/index.d.mts +229 -787
  92. package/dist/social-providers/kakao.d.mts +8 -33
  93. package/dist/social-providers/kakao.mjs +1 -1
  94. package/dist/social-providers/kick.d.mts +7 -22
  95. package/dist/social-providers/kick.mjs +1 -1
  96. package/dist/social-providers/line.d.mts +8 -44
  97. package/dist/social-providers/line.mjs +4 -6
  98. package/dist/social-providers/linear.d.mts +7 -21
  99. package/dist/social-providers/linear.mjs +1 -1
  100. package/dist/social-providers/linkedin.d.mts +7 -21
  101. package/dist/social-providers/linkedin.mjs +1 -1
  102. package/dist/social-providers/microsoft-entra-id.d.mts +8 -18
  103. package/dist/social-providers/microsoft-entra-id.mjs +24 -4
  104. package/dist/social-providers/naver.d.mts +27 -42
  105. package/dist/social-providers/naver.mjs +1 -1
  106. package/dist/social-providers/notion.d.mts +7 -21
  107. package/dist/social-providers/notion.mjs +1 -1
  108. package/dist/social-providers/paybin.d.mts +8 -23
  109. package/dist/social-providers/paybin.mjs +2 -1
  110. package/dist/social-providers/paypal.d.mts +8 -33
  111. package/dist/social-providers/paypal.mjs +1 -1
  112. package/dist/social-providers/polar.d.mts +7 -22
  113. package/dist/social-providers/polar.mjs +1 -1
  114. package/dist/social-providers/railway.d.mts +7 -22
  115. package/dist/social-providers/railway.mjs +1 -1
  116. package/dist/social-providers/reddit.d.mts +7 -20
  117. package/dist/social-providers/reddit.mjs +1 -1
  118. package/dist/social-providers/roblox.d.mts +7 -20
  119. package/dist/social-providers/roblox.mjs +1 -1
  120. package/dist/social-providers/salesforce.d.mts +7 -22
  121. package/dist/social-providers/salesforce.mjs +1 -1
  122. package/dist/social-providers/slack.d.mts +7 -20
  123. package/dist/social-providers/slack.mjs +1 -1
  124. package/dist/social-providers/spotify.d.mts +7 -22
  125. package/dist/social-providers/spotify.mjs +1 -1
  126. package/dist/social-providers/tiktok.d.mts +10 -22
  127. package/dist/social-providers/tiktok.mjs +1 -1
  128. package/dist/social-providers/twitch.d.mts +7 -20
  129. package/dist/social-providers/twitch.mjs +1 -1
  130. package/dist/social-providers/twitter.d.mts +29 -26
  131. package/dist/social-providers/twitter.mjs +1 -1
  132. package/dist/social-providers/vercel.d.mts +7 -22
  133. package/dist/social-providers/vercel.mjs +1 -1
  134. package/dist/social-providers/vk.d.mts +8 -24
  135. package/dist/social-providers/vk.mjs +5 -6
  136. package/dist/social-providers/wechat.d.mts +7 -19
  137. package/dist/social-providers/wechat.mjs +1 -1
  138. package/dist/social-providers/zoom.d.mts +9 -25
  139. package/dist/social-providers/zoom.mjs +1 -1
  140. package/dist/types/context.d.mts +16 -9
  141. package/dist/types/cookie.d.mts +0 -1
  142. package/dist/types/helper.d.mts +1 -1
  143. package/dist/types/index.d.mts +1 -1
  144. package/dist/types/init-options.d.mts +37 -26
  145. package/dist/types/plugin-client.d.mts +1 -3
  146. package/dist/types/plugin.d.mts +2 -2
  147. package/dist/utils/async.d.mts +0 -1
  148. package/dist/utils/db.d.mts +1 -0
  149. package/dist/utils/deprecate.d.mts +0 -1
  150. package/dist/utils/error-codes.d.mts +2 -2
  151. package/dist/utils/host.d.mts +27 -1
  152. package/dist/utils/host.mjs +1 -1
  153. package/dist/utils/ip.d.mts +1 -0
  154. package/dist/utils/ip.mjs +1 -1
  155. package/dist/utils/is-api-error.d.mts +0 -1
  156. package/dist/utils/redirect-uri.d.mts +6 -2
  157. package/dist/utils/redirect-uri.mjs +11 -1
  158. package/package.json +10 -2
  159. package/src/context/request-state.ts +26 -6
  160. package/src/context/transaction.ts +37 -18
  161. package/src/db/adapter/factory.ts +34 -28
  162. package/src/db/adapter/get-default-model-name.ts +23 -14
  163. package/src/db/database-index.ts +351 -0
  164. package/src/db/get-tables.ts +89 -9
  165. package/src/db/index.ts +4 -0
  166. package/src/db/internal.ts +12 -0
  167. package/src/db/plugin.ts +3 -1
  168. package/src/db/schema/account.ts +27 -1
  169. package/src/db/schema/user.ts +1 -1
  170. package/src/db/type.ts +12 -0
  171. package/src/error/codes.ts +1 -0
  172. package/src/oauth2/index.ts +5 -0
  173. package/src/oauth2/oauth-provider.ts +105 -36
  174. package/src/oauth2/verify-id-token.ts +4 -2
  175. package/src/oauth2/verify.ts +121 -12
  176. package/src/social-providers/apple.ts +2 -1
  177. package/src/social-providers/atlassian.ts +1 -1
  178. package/src/social-providers/cognito.ts +2 -2
  179. package/src/social-providers/discord.ts +1 -1
  180. package/src/social-providers/dropbox.ts +1 -1
  181. package/src/social-providers/facebook.ts +21 -28
  182. package/src/social-providers/figma.ts +1 -1
  183. package/src/social-providers/github.ts +1 -1
  184. package/src/social-providers/gitlab.ts +1 -1
  185. package/src/social-providers/google.ts +2 -1
  186. package/src/social-providers/huggingface.ts +1 -1
  187. package/src/social-providers/kakao.ts +1 -1
  188. package/src/social-providers/kick.ts +1 -1
  189. package/src/social-providers/line.ts +7 -12
  190. package/src/social-providers/linear.ts +1 -1
  191. package/src/social-providers/linkedin.ts +1 -1
  192. package/src/social-providers/microsoft-entra-id.ts +36 -27
  193. package/src/social-providers/naver.ts +1 -1
  194. package/src/social-providers/notion.ts +1 -1
  195. package/src/social-providers/paybin.ts +2 -1
  196. package/src/social-providers/paypal.ts +1 -1
  197. package/src/social-providers/polar.ts +1 -1
  198. package/src/social-providers/railway.ts +1 -1
  199. package/src/social-providers/reddit.ts +1 -1
  200. package/src/social-providers/roblox.ts +1 -1
  201. package/src/social-providers/salesforce.ts +1 -1
  202. package/src/social-providers/slack.ts +1 -1
  203. package/src/social-providers/spotify.ts +1 -1
  204. package/src/social-providers/tiktok.ts +2 -2
  205. package/src/social-providers/twitch.ts +1 -1
  206. package/src/social-providers/twitter.ts +1 -1
  207. package/src/social-providers/vercel.ts +1 -1
  208. package/src/social-providers/vk.ts +2 -2
  209. package/src/social-providers/wechat.ts +1 -1
  210. package/src/social-providers/zoom.ts +1 -1
  211. package/src/types/context.ts +21 -14
  212. package/src/types/init-options.ts +15 -14
  213. package/src/utils/redirect-uri.ts +20 -0
@@ -425,19 +425,19 @@ export const createAdapterFactory =
425
425
  joinConfig,
426
426
  } of requiredModels) {
427
427
  let joinedData = await (async () => {
428
- if (options.experimental?.joins) {
429
- const result = data[modelName];
430
- return result;
431
- } else {
432
- // doesn't support joins, so fallback to handleFallbackJoin
433
- const result = await handleFallbackJoin({
434
- baseModel: unsafe_model,
435
- baseData: transformedData,
436
- joinModel: modelName,
437
- specificJoinConfig: joinConfig,
438
- });
439
- return result;
428
+ if (options.advanced?.database?.joins) {
429
+ // Use native joined data when the adapter included the key;
430
+ // otherwise fall back to separate queries.
431
+ if (modelName in data) {
432
+ return data[modelName];
433
+ }
440
434
  }
435
+ return await handleFallbackJoin({
436
+ baseModel: unsafe_model,
437
+ baseData: transformedData,
438
+ joinModel: modelName,
439
+ specificJoinConfig: joinConfig,
440
+ });
441
441
  })();
442
442
 
443
443
  // If joinedData is undefined, initialize it based on relationship type
@@ -863,8 +863,8 @@ export const createAdapterFactory =
863
863
  }: {
864
864
  model: string;
865
865
  data: T;
866
- select?: string[];
867
- forceAllowId?: boolean;
866
+ select?: string[] | undefined;
867
+ forceAllowId?: boolean | undefined;
868
868
  }): Promise<R> => {
869
869
  transactionId++;
870
870
  const thisTransactionId = transactionId;
@@ -1098,8 +1098,8 @@ export const createAdapterFactory =
1098
1098
  }: {
1099
1099
  model: string;
1100
1100
  where: Where[];
1101
- select?: string[];
1102
- join?: JoinOption;
1101
+ select?: string[] | undefined;
1102
+ join?: JoinOption | undefined;
1103
1103
  }) => {
1104
1104
  transactionId++;
1105
1105
  const thisTransactionId = transactionId;
@@ -1118,9 +1118,12 @@ export const createAdapterFactory =
1118
1118
  join = result.join;
1119
1119
  select = result.select;
1120
1120
  }
1121
- // If adapter doesn't support joins and we have joins, don't pass them to the adapter
1122
- const experimentalJoins = options.experimental?.joins;
1123
- if (!experimentalJoins && join && Object.keys(join).length > 0) {
1121
+ // If joins are disabled and we have joins, don't pass them to the adapter
1122
+ if (
1123
+ !options.advanced?.database?.joins &&
1124
+ join &&
1125
+ Object.keys(join).length > 0
1126
+ ) {
1124
1127
  passJoinToAdapter = false;
1125
1128
  }
1126
1129
  } else {
@@ -1178,12 +1181,12 @@ export const createAdapterFactory =
1178
1181
  join: unsafeJoin,
1179
1182
  }: {
1180
1183
  model: string;
1181
- where?: Where[];
1182
- limit?: number;
1184
+ where?: Where[] | undefined;
1185
+ limit?: number | undefined;
1183
1186
  select?: string[] | undefined;
1184
- sortBy?: { field: string; direction: "asc" | "desc" };
1185
- offset?: number;
1186
- join?: JoinOption;
1187
+ sortBy?: { field: string; direction: "asc" | "desc" } | undefined;
1188
+ offset?: number | undefined;
1189
+ join?: JoinOption | undefined;
1187
1190
  }) => {
1188
1191
  transactionId++;
1189
1192
  const thisTransactionId = transactionId;
@@ -1206,9 +1209,12 @@ export const createAdapterFactory =
1206
1209
  join = result.join;
1207
1210
  select = result.select;
1208
1211
  }
1209
- // If adapter doesn't support joins and we have joins, don't pass them to the adapter
1210
- const experimentalJoins = options.experimental?.joins;
1211
- if (!experimentalJoins && join && Object.keys(join).length > 0) {
1212
+ // If joins are disabled and we have joins, don't pass them to the adapter
1213
+ if (
1214
+ !options.advanced?.database?.joins &&
1215
+ join &&
1216
+ Object.keys(join).length > 0
1217
+ ) {
1212
1218
  passJoinToAdapter = false;
1213
1219
  }
1214
1220
  } else {
@@ -1503,7 +1509,7 @@ export const createAdapterFactory =
1503
1509
  where: unsafeWhere,
1504
1510
  }: {
1505
1511
  model: string;
1506
- where?: Where[];
1512
+ where?: Where[] | undefined;
1507
1513
  }) => {
1508
1514
  transactionId++;
1509
1515
  const thisTransactionId = transactionId;
@@ -20,26 +20,35 @@ export const initGetDefaultModelName = ({
20
20
  * 3. Using this function helps us get the actual model name based on the user's defined custom modelName.
21
21
  */
22
22
  const getDefaultModelName = (model: string) => {
23
+ // Resolve a model string (either a schema key or a user-defined
24
+ // `modelName`) back to its canonical schema key.
25
+ //
26
+ // An exact schema-key match must win over a `modelName` match.
27
+ // Better-auth internals (and `references.model`, see get-tables.ts)
28
+ // always pass canonical schema keys, so when a user remaps a
29
+ // built-in table onto another table's schema key (e.g.
30
+ // `user.modelName = "account"`), preferring the modelName alias
31
+ // would silently reroute every internal "account" query to the
32
+ // user table. The modelName lookup is only a fallback for
33
+ // externally supplied physical table names.
34
+ // @see https://github.com/better-auth/better-auth/issues/8111
35
+ // @see https://github.com/better-auth/better-auth/issues/10136
36
+ const resolve = (candidate: string): string | undefined => {
37
+ if (schema[candidate]) return candidate;
38
+ return Object.entries(schema).find(
39
+ ([_, f]) => f.modelName === candidate,
40
+ )?.[0];
41
+ };
42
+
23
43
  // It's possible this `model` could had applied `usePlural`.
24
44
  // Thus we'll try the search but without the trailing `s`.
25
45
  if (usePlural && model.charAt(model.length - 1) === "s") {
26
46
  const pluralessModel = model.slice(0, -1);
27
- let m = schema[pluralessModel] ? pluralessModel : undefined;
28
- if (!m) {
29
- m = Object.entries(schema).find(
30
- ([_, f]) => f.modelName === pluralessModel,
31
- )?.[0];
32
- }
33
-
34
- if (m) {
35
- return m;
36
- }
47
+ const m = resolve(pluralessModel);
48
+ if (m) return m;
37
49
  }
38
50
 
39
- let m = schema[model] ? model : undefined;
40
- if (!m) {
41
- m = Object.entries(schema).find(([_, f]) => f.modelName === model)?.[0];
42
- }
51
+ const m = resolve(model);
43
52
 
44
53
  if (!m) {
45
54
  throw new BetterAuthError(`Model "${model}" not found in schema`);
@@ -0,0 +1,351 @@
1
+ import { BetterAuthError } from "../error";
2
+ import type { DBFieldAttribute, DBTableIndex } from "./type";
3
+
4
+ const MAX_DATABASE_INDEX_NAME_BYTES = 63;
5
+ const MAX_DATABASE_INDEX_FIELDS = 16;
6
+
7
+ export function getPortableDatabaseIdentifierKey(identifier: string) {
8
+ return identifier.toLowerCase();
9
+ }
10
+
11
+ function getUtf8ByteLength(value: string) {
12
+ return new TextEncoder().encode(value).length;
13
+ }
14
+
15
+ function truncateUtf8(value: string, maxBytes: number) {
16
+ let result = "";
17
+ let byteLength = 0;
18
+ for (const character of value) {
19
+ const characterByteLength = getUtf8ByteLength(character);
20
+ if (byteLength + characterByteLength > maxBytes) break;
21
+ result += character;
22
+ byteLength += characterByteLength;
23
+ }
24
+ return result;
25
+ }
26
+
27
+ function getStableIndexNameHash(value: string) {
28
+ let hash = 0x811c9dc5;
29
+ for (let index = 0; index < value.length; index++) {
30
+ hash ^= value.charCodeAt(index);
31
+ hash = Math.imul(hash, 0x01000193);
32
+ }
33
+ return (hash >>> 0).toString(16).padStart(8, "0");
34
+ }
35
+
36
+ /** A table-level index resolved to physical database columns. */
37
+ export interface ResolvedDBTableIndex extends Omit<DBTableIndex, "fields"> {
38
+ /** Physical database column names, in index order. */
39
+ columns: readonly [string, ...string[]];
40
+ name: string;
41
+ }
42
+
43
+ export interface DBTableIndexSource {
44
+ fields: Readonly<Record<string, DBFieldAttribute>>;
45
+ indexes: readonly DBTableIndex[] | undefined;
46
+ tableName: string;
47
+ }
48
+
49
+ export type BoundedDatabaseIndexDialect = "mssql" | "mysql";
50
+
51
+ /** Returns the stable database name for a table-level index. */
52
+ export function getDatabaseIndexName(
53
+ tableName: string,
54
+ index: DBTableIndex,
55
+ ): string {
56
+ if (index.name !== undefined) {
57
+ if (index.name.trim().length === 0) {
58
+ throw new BetterAuthError(
59
+ "Database index names must contain at least one visible character.",
60
+ );
61
+ }
62
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(index.name)) {
63
+ throw new BetterAuthError(
64
+ "Database index names must start with a letter or underscore and contain only letters, numbers, and underscores.",
65
+ );
66
+ }
67
+ if (getUtf8ByteLength(index.name) > MAX_DATABASE_INDEX_NAME_BYTES) {
68
+ throw new BetterAuthError(
69
+ `Database index names must be at most ${MAX_DATABASE_INDEX_NAME_BYTES} UTF-8 bytes.`,
70
+ );
71
+ }
72
+ return index.name;
73
+ }
74
+
75
+ const indexKind = index.unique ? "uidx" : "idx";
76
+ const generatedName = `${tableName}_${index.fields.join("_")}_${indexKind}`;
77
+ if (getUtf8ByteLength(generatedName) <= MAX_DATABASE_INDEX_NAME_BYTES) {
78
+ return generatedName;
79
+ }
80
+
81
+ const suffix = `_${getStableIndexNameHash(generatedName)}_${indexKind}`;
82
+ return `${truncateUtf8(
83
+ generatedName.slice(0, -indexKind.length - 1),
84
+ MAX_DATABASE_INDEX_NAME_BYTES - getUtf8ByteLength(suffix),
85
+ )}${suffix}`;
86
+ }
87
+
88
+ /** Returns the database name used by legacy field-level index metadata. */
89
+ export function getDatabaseFieldIndexName(
90
+ tableName: string,
91
+ columnName: string,
92
+ unique: boolean,
93
+ ) {
94
+ return getDatabaseIndexName(tableName, {
95
+ fields: [columnName],
96
+ unique,
97
+ });
98
+ }
99
+
100
+ /** Resolves logical index fields to their configured database column names. */
101
+ export function resolveDatabaseTableIndexes({
102
+ fields,
103
+ indexes,
104
+ tableName,
105
+ }: {
106
+ fields: Readonly<Record<string, DBFieldAttribute>>;
107
+ indexes: readonly DBTableIndex[] | undefined;
108
+ tableName: string;
109
+ }): readonly ResolvedDBTableIndex[] {
110
+ const resolvedIndexes = (indexes ?? []).map((index) => {
111
+ if (index.fields.length === 0) {
112
+ throw new BetterAuthError(
113
+ `Index on table "${tableName}" must include at least one field.`,
114
+ );
115
+ }
116
+ if (index.fields.length > MAX_DATABASE_INDEX_FIELDS) {
117
+ throw new BetterAuthError(
118
+ `Index on table "${tableName}" can include at most ${MAX_DATABASE_INDEX_FIELDS} fields so it works across supported databases.`,
119
+ );
120
+ }
121
+ if (new Set(index.fields).size !== index.fields.length) {
122
+ throw new BetterAuthError(
123
+ `Index on table "${tableName}" contains the same field more than once.`,
124
+ );
125
+ }
126
+ if (
127
+ index.unique &&
128
+ index.fields.some((fieldName) => fields[fieldName]?.required === false)
129
+ ) {
130
+ throw new BetterAuthError(
131
+ `Unique index on table "${tableName}" can only include required fields so its behavior is consistent across databases.`,
132
+ );
133
+ }
134
+ const unsupportedField = index.fields.find((fieldName) => {
135
+ const fieldType = fields[fieldName]?.type;
136
+ return (
137
+ fieldType === "json" ||
138
+ fieldType === "string[]" ||
139
+ fieldType === "number[]"
140
+ );
141
+ });
142
+ if (unsupportedField) {
143
+ throw new BetterAuthError(
144
+ `Index on table "${tableName}" references field "${unsupportedField}", whose type is not portably indexable.`,
145
+ );
146
+ }
147
+ const resolveFieldName = (fieldName: string) => {
148
+ const field = fields[fieldName];
149
+ if (!field) {
150
+ throw new BetterAuthError(
151
+ `Index on table "${tableName}" references unknown field "${fieldName}".`,
152
+ );
153
+ }
154
+ return field.fieldName || fieldName;
155
+ };
156
+ const [firstField, ...remainingFields] = index.fields;
157
+ const columns = [
158
+ resolveFieldName(firstField),
159
+ ...remainingFields.map(resolveFieldName),
160
+ ] as ResolvedDBTableIndex["columns"];
161
+ if (
162
+ new Set(columns.map(getPortableDatabaseIdentifierKey)).size !==
163
+ columns.length
164
+ ) {
165
+ throw new BetterAuthError(
166
+ `Index on table "${tableName}" resolves more than one field to the same database column.`,
167
+ );
168
+ }
169
+
170
+ return {
171
+ columns,
172
+ name: getDatabaseIndexName(tableName, {
173
+ ...index,
174
+ fields: columns,
175
+ }),
176
+ unique: index.unique,
177
+ } satisfies ResolvedDBTableIndex;
178
+ });
179
+
180
+ const definitionsByName = new Map<string, string>();
181
+ const deduplicatedIndexes: ResolvedDBTableIndex[] = [];
182
+ for (const index of resolvedIndexes) {
183
+ const definition = JSON.stringify([index.columns, index.unique ?? false]);
184
+ const identifierKey = getPortableDatabaseIdentifierKey(index.name);
185
+ const existingDefinition = definitionsByName.get(identifierKey);
186
+ if (existingDefinition && existingDefinition !== definition) {
187
+ throw new BetterAuthError(
188
+ `Database index name "${index.name}" identifies more than one index on table "${tableName}".`,
189
+ );
190
+ }
191
+ if (existingDefinition) continue;
192
+ definitionsByName.set(identifierKey, definition);
193
+ deduplicatedIndexes.push(index);
194
+ }
195
+ return deduplicatedIndexes;
196
+ }
197
+
198
+ /**
199
+ * Returns a safe generated string length for a column across all of its table
200
+ * indexes in byte-limited SQL dialects.
201
+ */
202
+ export function getDatabaseIndexStringLength({
203
+ columnName,
204
+ dialect,
205
+ fields,
206
+ indexes,
207
+ }: {
208
+ columnName: string;
209
+ dialect: BoundedDatabaseIndexDialect;
210
+ fields: Readonly<Record<string, DBFieldAttribute>>;
211
+ indexes: readonly ResolvedDBTableIndex[];
212
+ }): number | undefined {
213
+ const fieldsByColumn = new Map(
214
+ Object.entries(fields).map(([fieldName, field]) => [
215
+ field.fieldName || fieldName,
216
+ field,
217
+ ]),
218
+ );
219
+ const containingIndexes = indexes.filter((index) =>
220
+ index.columns.includes(columnName),
221
+ );
222
+ if (containingIndexes.length === 0) return undefined;
223
+
224
+ const byteBudget = dialect === "mysql" ? 3072 : 1700;
225
+ const bytesPerCharacter = dialect === "mysql" ? 4 : 1;
226
+ const defaultLength = dialect === "mysql" ? 191 : 255;
227
+ return containingIndexes.reduce((length, index) => {
228
+ const stringColumnCount = index.columns.filter((column) => {
229
+ const type = fieldsByColumn.get(column)?.type;
230
+ return type === "string" || Array.isArray(type);
231
+ }).length;
232
+ if (stringColumnCount === 0) return length;
233
+ const nonStringColumnBytes =
234
+ (index.columns.length - stringColumnCount) * 16;
235
+ const safeLength = Math.floor(
236
+ Math.max(1, byteBudget - nonStringColumnBytes) /
237
+ bytesPerCharacter /
238
+ stringColumnCount,
239
+ );
240
+ return Math.min(length, safeLength);
241
+ }, defaultLength);
242
+ }
243
+
244
+ /**
245
+ * Resolves and validates every table index as one portable database schema.
246
+ *
247
+ * Index names are schema-wide because SQLite and PostgreSQL do not scope them
248
+ * to an individual table.
249
+ */
250
+ export function resolveDatabaseSchemaIndexes(
251
+ sources: readonly DBTableIndexSource[],
252
+ ): ReadonlyMap<string, readonly ResolvedDBTableIndex[]> {
253
+ const mergedSourcesByTable = new Map<
254
+ string,
255
+ {
256
+ fields: Record<string, DBFieldAttribute>;
257
+ indexes: DBTableIndex[];
258
+ tableName: string;
259
+ }
260
+ >();
261
+ for (const source of sources) {
262
+ const existingSource = mergedSourcesByTable.get(source.tableName);
263
+ if (existingSource) {
264
+ if (
265
+ existingSource.indexes.length > 0 ||
266
+ (source.indexes?.length ?? 0) > 0
267
+ ) {
268
+ throw new BetterAuthError(
269
+ `Database schema resolves more than one indexed logical table to "${source.tableName}". Define table-level indexes through one logical schema key instead of aliasing multiple keys to the same database table.`,
270
+ );
271
+ }
272
+ Object.assign(existingSource.fields, source.fields);
273
+ continue;
274
+ }
275
+ const mergedSource: {
276
+ fields: Record<string, DBFieldAttribute>;
277
+ indexes: DBTableIndex[];
278
+ tableName: string;
279
+ } = {
280
+ fields: {},
281
+ indexes: [],
282
+ tableName: source.tableName,
283
+ };
284
+ Object.assign(mergedSource.fields, source.fields);
285
+ mergedSource.indexes.push(...(source.indexes ?? []));
286
+ mergedSourcesByTable.set(source.tableName, mergedSource);
287
+ }
288
+
289
+ const indexesByTable = new Map<string, ResolvedDBTableIndex[]>();
290
+ const indexOwnerByName = new Map<string, string>();
291
+ const tableNamesByIdentifier = new Map(
292
+ [...mergedSourcesByTable.keys()].map((tableName) => [
293
+ getPortableDatabaseIdentifierKey(tableName),
294
+ tableName,
295
+ ]),
296
+ );
297
+ const fieldIndexOwnerByName = new Map<string, string>();
298
+
299
+ for (const source of mergedSourcesByTable.values()) {
300
+ for (const [fieldName, field] of Object.entries(source.fields)) {
301
+ if (!field.index && !field.unique) continue;
302
+ const indexName = getDatabaseFieldIndexName(
303
+ source.tableName,
304
+ field.fieldName || fieldName,
305
+ field.unique ?? false,
306
+ );
307
+ const identifierKey = getPortableDatabaseIdentifierKey(indexName);
308
+ if (tableNamesByIdentifier.has(identifierKey)) {
309
+ throw new BetterAuthError(
310
+ `Database index name "${indexName}" conflicts with a table name. Index and table names must be unique across the schema.`,
311
+ );
312
+ }
313
+ const existingOwner = fieldIndexOwnerByName.get(identifierKey);
314
+ if (existingOwner) {
315
+ throw new BetterAuthError(
316
+ `Database field-level index name "${indexName}" is used by both table "${existingOwner}" and table "${source.tableName}".`,
317
+ );
318
+ }
319
+ fieldIndexOwnerByName.set(identifierKey, source.tableName);
320
+ }
321
+ }
322
+
323
+ for (const source of mergedSourcesByTable.values()) {
324
+ const resolvedIndexes = resolveDatabaseTableIndexes(source);
325
+ indexesByTable.set(source.tableName, [...resolvedIndexes]);
326
+
327
+ for (const index of resolvedIndexes) {
328
+ const identifierKey = getPortableDatabaseIdentifierKey(index.name);
329
+ if (tableNamesByIdentifier.has(identifierKey)) {
330
+ throw new BetterAuthError(
331
+ `Database index name "${index.name}" conflicts with a table name. Index and table names must be unique across the schema.`,
332
+ );
333
+ }
334
+ const fieldIndexOwner = fieldIndexOwnerByName.get(identifierKey);
335
+ if (fieldIndexOwner) {
336
+ throw new BetterAuthError(
337
+ `Database index name "${index.name}" is already reserved by field-level index metadata on table "${fieldIndexOwner}". Remove the duplicate table-level index or give it a distinct name.`,
338
+ );
339
+ }
340
+ const indexOwner = indexOwnerByName.get(identifierKey);
341
+ if (indexOwner && indexOwner !== source.tableName) {
342
+ throw new BetterAuthError(
343
+ `Database index name "${index.name}" is used by both table "${indexOwner}" and table "${source.tableName}". Index names must be unique across the schema.`,
344
+ );
345
+ }
346
+ indexOwnerByName.set(identifierKey, source.tableName);
347
+ }
348
+ }
349
+
350
+ return indexesByTable;
351
+ }
@@ -1,9 +1,32 @@
1
1
  import type { BetterAuthOptions } from "../types";
2
- import type { BetterAuthDBSchema, DBFieldAttribute } from "./type";
2
+ import { resolveDatabaseSchemaIndexes } from "./database-index";
3
+ import type {
4
+ BetterAuthDBSchema,
5
+ DBFieldAttribute,
6
+ DBTableIndex,
7
+ } from "./type";
3
8
 
4
- export const getAuthTables = (
5
- options: BetterAuthOptions,
6
- ): BetterAuthDBSchema => {
9
+ function mergeTableIndexes(
10
+ ...indexCollections: ReadonlyArray<readonly DBTableIndex[] | undefined>
11
+ ) {
12
+ const indexes: DBTableIndex[] = [];
13
+ const seenIndexDefinitions = new Set<string>();
14
+ for (const index of indexCollections.flatMap(
15
+ (collection) => collection ?? [],
16
+ )) {
17
+ const definition = JSON.stringify([
18
+ index.name ?? null,
19
+ index.fields,
20
+ index.unique ?? false,
21
+ ]);
22
+ if (seenIndexDefinitions.has(definition)) continue;
23
+ seenIndexDefinitions.add(definition);
24
+ indexes.push(index);
25
+ }
26
+ return indexes;
27
+ }
28
+
29
+ const buildAuthTables = (options: BetterAuthOptions): BetterAuthDBSchema => {
7
30
  const pluginSchema = (options.plugins ?? []).reduce(
8
31
  (acc, plugin) => {
9
32
  const schema = plugin.schema;
@@ -14,6 +37,7 @@ export const getAuthTables = (
14
37
  ...acc[key]?.fields,
15
38
  ...value.fields,
16
39
  },
40
+ indexes: mergeTableIndexes(acc[key]?.indexes, value.indexes),
17
41
  modelName: value.modelName || key,
18
42
  disableMigrations:
19
43
  value.disableMigration ?? acc[key]?.disableMigrations,
@@ -25,6 +49,7 @@ export const getAuthTables = (
25
49
  string,
26
50
  {
27
51
  fields: Record<string, DBFieldAttribute>;
52
+ indexes?: readonly DBTableIndex[] | undefined;
28
53
  modelName: string;
29
54
  disableMigrations?: boolean | undefined;
30
55
  }
@@ -64,6 +89,7 @@ export const getAuthTables = (
64
89
  const verificationTable = {
65
90
  verification: {
66
91
  modelName: options.verification?.modelName || "verification",
92
+ indexes: verification?.indexes,
67
93
  fields: {
68
94
  identifier: {
69
95
  type: "string",
@@ -104,6 +130,7 @@ export const getAuthTables = (
104
130
  const sessionTable = {
105
131
  session: {
106
132
  modelName: options.session?.modelName || "session",
133
+ indexes: session?.indexes,
107
134
  fields: {
108
135
  expiresAt: {
109
136
  type: "date",
@@ -142,7 +169,18 @@ export const getAuthTables = (
142
169
  type: "string",
143
170
  fieldName: options.session?.fields?.userId || "userId",
144
171
  references: {
145
- model: options.user?.modelName || "user",
172
+ // Use the canonical user schema key here rather than
173
+ // `options.user.modelName`. Downstream consumers (e.g.
174
+ // `getSchema`, `getMigrations`, and the runtime adapter
175
+ // resolvers) treat `references.model` as a schema key
176
+ // and look it up via `tables[references.model]` /
177
+ // `getDefaultModelName`. Writing the modelName alias
178
+ // here would collide when a user picks a modelName that
179
+ // matches another schema key (for example
180
+ // `user.modelName = "account"`), causing the FK to
181
+ // resolve to the wrong table.
182
+ // @see https://github.com/better-auth/better-auth/issues/8111
183
+ model: "user",
146
184
  field: "id",
147
185
  onDelete: "cascade",
148
186
  },
@@ -156,9 +194,10 @@ export const getAuthTables = (
156
194
  },
157
195
  } satisfies BetterAuthDBSchema;
158
196
 
159
- return {
197
+ const authTables = {
160
198
  user: {
161
199
  modelName: options.user?.modelName || "user",
200
+ indexes: user?.indexes,
162
201
  fields: {
163
202
  name: {
164
203
  type: "string",
@@ -211,11 +250,26 @@ export const getAuthTables = (
211
250
  : {}),
212
251
  account: {
213
252
  modelName: options.account?.modelName || "account",
253
+ indexes: mergeTableIndexes(
254
+ [
255
+ {
256
+ fields: ["issuer", "providerAccountId"],
257
+ unique: true,
258
+ },
259
+ ],
260
+ account?.indexes,
261
+ ),
214
262
  fields: {
215
- accountId: {
263
+ issuer: {
264
+ type: "string",
265
+ required: true,
266
+ fieldName: options.account?.fields?.issuer || "issuer",
267
+ },
268
+ providerAccountId: {
216
269
  type: "string",
217
270
  required: true,
218
- fieldName: options.account?.fields?.accountId || "accountId",
271
+ fieldName:
272
+ options.account?.fields?.providerAccountId || "providerAccountId",
219
273
  },
220
274
  providerId: {
221
275
  type: "string",
@@ -225,7 +279,11 @@ export const getAuthTables = (
225
279
  userId: {
226
280
  type: "string",
227
281
  references: {
228
- model: options.user?.modelName || "user",
282
+ // See note on `session.userId.references.model` above:
283
+ // always use the canonical user schema key so the FK
284
+ // target survives `user.modelName` aliasing.
285
+ // @see https://github.com/better-auth/better-auth/issues/8111
286
+ model: "user",
229
287
  field: "id",
230
288
  onDelete: "cascade",
231
289
  },
@@ -301,4 +359,26 @@ export const getAuthTables = (
301
359
  ...pluginTables,
302
360
  ...(shouldAddRateLimitTable ? rateLimitTable : {}),
303
361
  } satisfies BetterAuthDBSchema;
362
+
363
+ return authTables;
304
364
  };
365
+
366
+ export function getAuthTablesWithResolvedIndexes(options: BetterAuthOptions) {
367
+ const tables = buildAuthTables(options);
368
+ const indexesByTable = resolveDatabaseSchemaIndexes(
369
+ Object.values(tables)
370
+ .filter(
371
+ (table) => !("disableMigrations" in table) || !table.disableMigrations,
372
+ )
373
+ .map((table) => ({
374
+ fields: table.fields,
375
+ indexes: "indexes" in table ? table.indexes : undefined,
376
+ tableName: table.modelName,
377
+ })),
378
+ );
379
+
380
+ return { indexesByTable, tables };
381
+ }
382
+
383
+ export const getAuthTables = (options: BetterAuthOptions): BetterAuthDBSchema =>
384
+ getAuthTablesWithResolvedIndexes(options).tables;
package/src/db/index.ts CHANGED
@@ -2,8 +2,11 @@ export { getAuthTables } from "./get-tables";
2
2
  export type { BetterAuthPluginDBSchema } from "./plugin";
3
3
  export {
4
4
  type Account,
5
+ type AccountKey,
5
6
  accountSchema,
6
7
  type BaseAccount,
8
+ createLocalAccountIssuer,
9
+ createOAuthAccountIssuer,
7
10
  } from "./schema/account";
8
11
  export {
9
12
  type BaseRateLimit,
@@ -29,6 +32,7 @@ export type {
29
32
  DBFieldAttributeConfig,
30
33
  DBFieldType,
31
34
  DBPrimitive,
35
+ DBTableIndex,
32
36
  InferDBFieldInput,
33
37
  InferDBFieldOutput,
34
38
  InferDBFieldsFromOptions,