better-auth 1.7.0 → 1.7.1

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.
@@ -1,11 +1,37 @@
1
1
  import { BetterAuthOptions } from "@better-auth/core";
2
2
  import { DBFieldAttribute, DBFieldType } from "@better-auth/core/db";
3
+ import { BetterAuthError } from "@better-auth/core/error";
3
4
  import { KyselyDatabaseType } from "@better-auth/kysely-adapter";
4
5
  import { ResolvedDBTableIndex } from "@better-auth/core/db/internal";
5
6
 
6
7
  //#region src/db/get-migration.d.ts
8
+ /**
9
+ * Thrown when {@link getMigrations} refuses to add a required column with no
10
+ * default value to a populated table. Distinct from the plain
11
+ * {@link BetterAuthError} thrown for index-definition conflicts, so callers
12
+ * can tell the two apart without matching on message text.
13
+ */
14
+ declare class UnsafeMigrationError extends BetterAuthError {}
7
15
  declare function matchType(columnDataType: string, fieldType: DBFieldType, dbType: KyselyDatabaseType): boolean;
8
- declare function getMigrations(config: BetterAuthOptions): Promise<{
16
+ /**
17
+ * Build the migration plan that `auth migrate` executes and `auth generate`
18
+ * prints for the Kysely adapter.
19
+ *
20
+ * Adding a required column without a default to a populated table is refused:
21
+ * existing rows have no value to backfill. `throwOnUnsafe` picks how that
22
+ * refusal is delivered: executing callers get an {@link UnsafeMigrationError},
23
+ * read-only callers get the plan plus the same message in `unsafeChanges`.
24
+ *
25
+ * @throws {UnsafeMigrationError} when a required column cannot be migrated
26
+ * safely and `throwOnUnsafe` is left on.
27
+ * @throws {BetterAuthError} when an index definition conflicts with an
28
+ * existing or already-planned index.
29
+ */
30
+ declare function getMigrations(config: BetterAuthOptions, {
31
+ throwOnUnsafe
32
+ }?: {
33
+ throwOnUnsafe?: boolean;
34
+ }): Promise<{
9
35
  toBeCreated: {
10
36
  table: string;
11
37
  fields: Record<string, DBFieldAttribute>;
@@ -21,8 +47,9 @@ declare function getMigrations(config: BetterAuthOptions): Promise<{
21
47
  index: ResolvedDBTableIndex;
22
48
  name: string;
23
49
  }[];
50
+ unsafeChanges: string[];
24
51
  runMigrations: () => Promise<void>;
25
52
  compileMigrations: () => Promise<string>;
26
53
  }>;
27
54
  //#endregion
28
- export { getMigrations, matchType };
55
+ export { UnsafeMigrationError, getMigrations, matchType };
@@ -278,6 +278,24 @@ function assertExistingTableIndexFits({ columnBounds, dbType, existingColumns, f
278
278
  }
279
279
  if (requiredBytes > byteBudget) throw new BetterAuthError(`Cannot create database index "${index.name}" on existing table "${table}" because its columns can exceed ${dbType === "mysql" ? "MySQL" : "SQL Server"}'s ${byteBudget}-byte index-key limit. Bound the indexed string columns to the generated schema lengths, resolve oversized values, then run the migration again.`);
280
280
  }
281
+ const columnBackfillGuideUrl = "https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer";
282
+ /**
283
+ * Thrown when {@link getMigrations} refuses to add a required column with no
284
+ * default value to a populated table. Distinct from the plain
285
+ * {@link BetterAuthError} thrown for index-definition conflicts, so callers
286
+ * can tell the two apart without matching on message text.
287
+ */
288
+ var UnsafeMigrationError = class extends BetterAuthError {};
289
+ function hasTimestampColumnDefault(field, dbType) {
290
+ return field.type === "date" && typeof field.defaultValue === "function" && (dbType === "postgres" || dbType === "mysql" || dbType === "mssql");
291
+ }
292
+ function hasStaticColumnDefault(field) {
293
+ return !(field.unique && field.required === false) && (field.type === "string" || field.type === "number" || field.type === "boolean") && field.defaultValue !== void 0 && field.defaultValue !== null && typeof field.defaultValue !== "function";
294
+ }
295
+ async function tableHasRows(db, dbType, table) {
296
+ const probe = db.selectFrom(table).select(sql`1`.as("present"));
297
+ return (await (dbType === "mssql" ? probe.top(1) : probe.limit(1)).execute()).length > 0;
298
+ }
281
299
  function matchType(columnDataType, fieldType, dbType) {
282
300
  function normalize(type) {
283
301
  return type.toLowerCase().split("(")[0].trim();
@@ -307,9 +325,34 @@ async function getMssqlSchema(db) {
307
325
  return "dbo";
308
326
  }
309
327
  }
310
- async function getMigrations(config) {
328
+ /**
329
+ * Build the migration plan that `auth migrate` executes and `auth generate`
330
+ * prints for the Kysely adapter.
331
+ *
332
+ * Adding a required column without a default to a populated table is refused:
333
+ * existing rows have no value to backfill. `throwOnUnsafe` picks how that
334
+ * refusal is delivered: executing callers get an {@link UnsafeMigrationError},
335
+ * read-only callers get the plan plus the same message in `unsafeChanges`.
336
+ *
337
+ * @throws {UnsafeMigrationError} when a required column cannot be migrated
338
+ * safely and `throwOnUnsafe` is left on.
339
+ * @throws {BetterAuthError} when an index definition conflicts with an
340
+ * existing or already-planned index.
341
+ */
342
+ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
311
343
  const betterAuthSchema = getSchema(config);
344
+ const authTables = getAuthTables(config);
345
+ const accountIssuer = authTables.account && {
346
+ table: authTables.account.modelName,
347
+ column: authTables.account.fields.issuer?.fieldName || "issuer"
348
+ };
349
+ const isAccountIssuerColumn = (table, column) => table === accountIssuer?.table && column === accountIssuer.column;
312
350
  const logger = createLogger(config.logger);
351
+ const unsafeChanges = [];
352
+ const reportUnsafeChange = (message) => {
353
+ if (throwOnUnsafe) throw new UnsafeMigrationError(message);
354
+ unsafeChanges.push(message);
355
+ };
313
356
  let { kysely: db, databaseType: dbType } = await createKyselyAdapter(config);
314
357
  if (!dbType) {
315
358
  logger.warn("Could not determine database type, defaulting to sqlite. Please provide a type in the database options to avoid this.");
@@ -421,6 +464,7 @@ async function getMigrations(config) {
421
464
  toBeAddedFields[fieldName] = field;
422
465
  continue;
423
466
  }
467
+ if (field.required !== false && column.isNullable) logger.warn(`Column "${fieldName}" on table "${key}" stays nullable while the schema declares the field required, so existing rows can still hold null. Backfill every row for this column and enforce NOT NULL to remove the drift.`);
424
468
  if (matchType(column.dataType, field.type, dbType)) continue;
425
469
  else logger.warn(`Field ${fieldName} in table ${key} has a different type in the database. Expected ${field.type} but got ${column.dataType}.`);
426
470
  }
@@ -501,11 +545,11 @@ async function getMigrations(config) {
501
545
  return typeMap[type][provider];
502
546
  }
503
547
  const getModelName = initGetModelName({
504
- schema: getAuthTables(config),
548
+ schema: authTables,
505
549
  usePlural: false
506
550
  });
507
551
  const getFieldName = initGetFieldName({
508
- schema: getAuthTables(config),
552
+ schema: authTables,
509
553
  usePlural: false
510
554
  });
511
555
  function getReferencePath(model, field) {
@@ -530,28 +574,45 @@ async function getMigrations(config) {
530
574
  indexes: table.indexes ?? []
531
575
  });
532
576
  };
533
- if (toBeAdded.length) for (const table of toBeAdded) for (const [fieldName, field] of Object.entries(table.fields)) {
534
- const type = getType(field, fieldName, getTableIndexStringLength(table.table, fieldName));
535
- const builder = db.schema.alterTable(table.table);
536
- if (field.index || field.unique) {
537
- const indexName = getDatabaseFieldIndexName(table.table, fieldName, field.unique ?? false);
538
- let indexBuilder = db.schema.createIndex(indexName).on(table.table).columns([fieldName]);
539
- if (field.unique) {
540
- indexBuilder = indexBuilder.unique();
541
- if (field.required === false && dbType === "mssql") indexBuilder = indexBuilder.where(fieldName, "is not", null);
542
- if (field.required !== false && field.defaultValue !== void 0 && field.defaultValue !== null && typeof field.defaultValue !== "function") logger.warn(`Adding unique column "${fieldName}" to existing table "${table.table}" backfills every existing row with its default value. If the table has more than one row, creating the unique index "${indexName}" will fail; backfill distinct values manually, then re-run the migration or create the index yourself.`);
577
+ if (toBeAdded.length) {
578
+ const populatedTables = /* @__PURE__ */ new Map();
579
+ for (const table of toBeAdded) for (const [fieldName, field] of Object.entries(table.fields)) {
580
+ const timestampDefault = hasTimestampColumnDefault(field, dbType);
581
+ const staticDefault = hasStaticColumnDefault(field);
582
+ if (field.required !== false && !timestampDefault && !staticDefault) {
583
+ let populated = populatedTables.get(table.table);
584
+ if (populated === void 0) {
585
+ populated = await tableHasRows(db, dbType, table.table);
586
+ populatedTables.set(table.table, populated);
587
+ }
588
+ if (populated) {
589
+ const textDetail = field.type === "string" ? " For a text column, every existing row ends up with the same empty string." : "";
590
+ const guideLink = isAccountIssuerColumn(table.table, fieldName) ? ` See ${columnBackfillGuideUrl}` : "";
591
+ reportUnsafeChange(`Cannot add required column "${fieldName}" to populated table "${table.table}": the schema declares no default value, so existing rows have no value to backfill. MySQL accepts this statement instead of rejecting it and fills every existing row with an implicit default for the column type, reporting a successful migration over corrupted data.${textDetail} Add the column as nullable, backfill a correct value for every row, then make it NOT NULL.${guideLink}`);
592
+ }
543
593
  }
544
- deferredIndexes.push(indexBuilder);
594
+ const type = getType(field, fieldName, getTableIndexStringLength(table.table, fieldName));
595
+ const builder = db.schema.alterTable(table.table);
596
+ if (field.index || field.unique) {
597
+ const indexName = getDatabaseFieldIndexName(table.table, fieldName, field.unique ?? false);
598
+ let indexBuilder = db.schema.createIndex(indexName).on(table.table).columns([fieldName]);
599
+ if (field.unique) {
600
+ indexBuilder = indexBuilder.unique();
601
+ if (field.required === false && dbType === "mssql") indexBuilder = indexBuilder.where(fieldName, "is not", null);
602
+ if (field.required !== false && field.defaultValue !== void 0 && field.defaultValue !== null && typeof field.defaultValue !== "function") logger.warn(`Adding unique column "${fieldName}" to existing table "${table.table}" backfills every existing row with its default value. If the table has more than one row, creating the unique index "${indexName}" will fail; backfill distinct values manually, then re-run the migration or create the index yourself.`);
603
+ }
604
+ deferredIndexes.push(indexBuilder);
605
+ }
606
+ const built = builder.addColumn(fieldName, type, (col) => {
607
+ col = field.required !== false ? col.notNull() : col;
608
+ if (field.references) col = col.references(getReferencePath(field.references.model, field.references.field)).onDelete(field.references.onDelete || "cascade");
609
+ if (timestampDefault) if (dbType === "mysql") col = col.defaultTo(sql`CURRENT_TIMESTAMP(3)`);
610
+ else col = col.defaultTo(sql`CURRENT_TIMESTAMP`);
611
+ else if (staticDefault) col = col.defaultTo(typeof field.defaultValue === "boolean" && (dbType === "sqlite" || dbType === "mssql") ? field.defaultValue ? 1 : 0 : field.defaultValue);
612
+ return col;
613
+ });
614
+ migrations.push(built);
545
615
  }
546
- const built = builder.addColumn(fieldName, type, (col) => {
547
- col = field.required !== false ? col.notNull() : col;
548
- if (field.references) col = col.references(getReferencePath(field.references.model, field.references.field)).onDelete(field.references.onDelete || "cascade");
549
- if (field.type === "date" && typeof field.defaultValue === "function" && (dbType === "postgres" || dbType === "mysql" || dbType === "mssql")) if (dbType === "mysql") col = col.defaultTo(sql`CURRENT_TIMESTAMP(3)`);
550
- else col = col.defaultTo(sql`CURRENT_TIMESTAMP`);
551
- else if (!(field.unique && field.required === false) && (field.type === "string" || field.type === "number" || field.type === "boolean") && field.defaultValue !== void 0 && field.defaultValue !== null && typeof field.defaultValue !== "function") col = col.defaultTo(typeof field.defaultValue === "boolean" && (dbType === "sqlite" || dbType === "mssql") ? field.defaultValue ? 1 : 0 : field.defaultValue);
552
- return col;
553
- });
554
- migrations.push(built);
555
616
  }
556
617
  if (toBeCreated.length) for (const table of toBeCreated) {
557
618
  const idType = getType({ type: useNumberId ? "number" : "string" }, "id");
@@ -601,9 +662,10 @@ async function getMigrations(config) {
601
662
  toBeCreated,
602
663
  toBeAdded,
603
664
  toBeAddedIndexes,
665
+ unsafeChanges,
604
666
  runMigrations,
605
667
  compileMigrations
606
668
  };
607
669
  }
608
670
  //#endregion
609
- export { getMigrations, matchType };
671
+ export { UnsafeMigrationError, getMigrations, matchType };
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.7.0";
2
+ var version = "1.7.1";
3
3
  //#endregion
4
4
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-auth",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -473,13 +473,13 @@
473
473
  "kysely": "^0.28.17 || ^0.29.0",
474
474
  "nanostores": "^1.3.0",
475
475
  "zod": "^4.3.6",
476
- "@better-auth/core": "1.7.0",
477
- "@better-auth/drizzle-adapter": "1.7.0",
478
- "@better-auth/kysely-adapter": "1.7.0",
479
- "@better-auth/memory-adapter": "1.7.0",
480
- "@better-auth/mongo-adapter": "1.7.0",
481
- "@better-auth/prisma-adapter": "1.7.0",
482
- "@better-auth/telemetry": "1.7.0"
476
+ "@better-auth/core": "1.7.1",
477
+ "@better-auth/drizzle-adapter": "1.7.1",
478
+ "@better-auth/kysely-adapter": "1.7.1",
479
+ "@better-auth/memory-adapter": "1.7.1",
480
+ "@better-auth/mongo-adapter": "1.7.1",
481
+ "@better-auth/prisma-adapter": "1.7.1",
482
+ "@better-auth/telemetry": "1.7.1"
483
483
  },
484
484
  "devDependencies": {
485
485
  "@lynx-js/react": "^0.121.2",