mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 23:53:17 -05:00
fix(cli): select account identity migration strategy
This commit is contained in:
@@ -43,7 +43,7 @@ npx auth@latest migrate plan
|
||||
npx auth@latest migrate plan --json > better-auth-migration-plan.json
|
||||
```
|
||||
|
||||
The JSON plan includes the adapter, dialect, sorted schema changes, blockers, and any release migration actions. A blocked plan exits with status `1`.
|
||||
The JSON plan includes the adapter, dialect, selected and detected account identity strategies, account counts, issuer-resolution coverage, projected collisions, sorted schema changes, blockers, and any release migration actions. A blocked plan exits with status `1`.
|
||||
|
||||
### Apply a migration
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ it("migrates published 1.6.30 accounts and OAuth records through the Drizzle ada
|
||||
});
|
||||
|
||||
const currentOptions = {
|
||||
account: { identityStrategy: "issuer" as const },
|
||||
baseURL: "http://localhost:3000",
|
||||
emailAndPassword: { enabled: true },
|
||||
plugins: [
|
||||
@@ -94,7 +95,7 @@ it("migrates published 1.6.30 accounts and OAuth records through the Drizzle ada
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates Drizzle snake_case account columns without duplicating logical fields", async () => {
|
||||
it("plans a default provider-scoped upgrade against Drizzle snake_case columns", async () => {
|
||||
const sqlite = new Database(":memory:");
|
||||
try {
|
||||
sqlite.exec(`
|
||||
@@ -136,38 +137,27 @@ it("migrates Drizzle snake_case account columns without duplicating logical fiel
|
||||
schema,
|
||||
transaction: true,
|
||||
});
|
||||
const auth17 = betterAuth({ ...currentOptions, database });
|
||||
const inspection = await getMigrations(auth17.options);
|
||||
const inspection = await getMigrations(
|
||||
betterAuth({ ...currentOptions, database }).options,
|
||||
);
|
||||
|
||||
expect(inspection.accountIdentitySchema).toEqual({
|
||||
accountIdColumn: "account_id",
|
||||
issuerColumn: "issuer",
|
||||
table: "account",
|
||||
});
|
||||
expect(inspection.migrationBlockers).toContainEqual({
|
||||
code: "required-column-backfill",
|
||||
columns: ["issuer"],
|
||||
table: "account",
|
||||
expect(inspection.accountIdentity).toMatchObject({
|
||||
selectedStrategy: "provider-id",
|
||||
detectedStrategy: "provider-id",
|
||||
totalAccounts: 1,
|
||||
});
|
||||
expect(inspection.migrationBlockers).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "account-identity-strategy-mismatch",
|
||||
}),
|
||||
);
|
||||
const accountFields = Object.keys(
|
||||
inspection.toBeAdded.find(({ table }) => table === "account")?.fields ??
|
||||
{},
|
||||
);
|
||||
expect(accountFields).toContain("issuer");
|
||||
expect(accountFields).not.toContain("issuer");
|
||||
expect(accountFields).not.toContain("account_id");
|
||||
expect(accountFields).not.toContain("provider_id");
|
||||
|
||||
await expect(migrateFrom16(auth17.options, {})).resolves.toMatchObject({
|
||||
accounts: {
|
||||
migrated: 1,
|
||||
providers: { credential: 1 },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT issuer FROM account WHERE id = ?")
|
||||
.get("account-1"),
|
||||
).toEqual({ issuer: "local:credential" });
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ async function exerciseAccountAndOrganizationMigration({
|
||||
await beforeMigrate?.();
|
||||
|
||||
const auth17 = betterAuth({
|
||||
account: { identityStrategy: "issuer" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database,
|
||||
emailAndPassword: {
|
||||
@@ -566,6 +567,80 @@ it("migrates users created by published 1.6.30 and authenticates them through 1.
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a published 1.6.30 provider-scoped account through the 1.7 cutover", {
|
||||
timeout: 60_000,
|
||||
}, async () => {
|
||||
const database = new DatabaseSync(":memory:");
|
||||
try {
|
||||
const auth1630 = betterAuth1630({
|
||||
baseURL: "http://localhost:3000",
|
||||
database,
|
||||
emailAndPassword: { enabled: true },
|
||||
});
|
||||
await (await getMigrations1630(auth1630.options)).runMigrations();
|
||||
const source = await auth1630.api.signUpEmail({
|
||||
body: {
|
||||
email: "provider-scoped@sqlite.example.com",
|
||||
name: "Provider Scoped User",
|
||||
password: "correct-horse-battery-staple",
|
||||
},
|
||||
});
|
||||
const sourceContext = await auth1630.$context;
|
||||
await sourceContext.internalAdapter.linkAccount({
|
||||
accountId: "published-google-subject",
|
||||
providerId: "google",
|
||||
userId: source.user.id,
|
||||
});
|
||||
|
||||
const auth17 = betterAuth({
|
||||
account: { identityStrategy: "provider-id" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database,
|
||||
emailAndPassword: { enabled: true },
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: "google-client",
|
||||
clientSecret: "google-secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
const migration = await migrateFrom16(auth17.options, {});
|
||||
expect(migration.accounts).toEqual({
|
||||
migrated: 0,
|
||||
providers: {},
|
||||
});
|
||||
|
||||
const signIn = await auth17.api.signInEmail({
|
||||
body: {
|
||||
email: "provider-scoped@sqlite.example.com",
|
||||
password: "correct-horse-battery-staple",
|
||||
},
|
||||
returnHeaders: true,
|
||||
});
|
||||
const sessionCookie = signIn.headers.getSetCookie()[0];
|
||||
if (!sessionCookie) throw new Error("Expected 1.7 to create a session");
|
||||
const accounts = await auth17.api.listUserAccounts({
|
||||
headers: { cookie: sessionCookie },
|
||||
});
|
||||
expect(accounts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
providerId: "credential",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
accountId: "published-google-subject",
|
||||
providerId: "google",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(accounts.every((account) => account.issuer === undefined)).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid OAuth data before changing the 1.6 database", async () => {
|
||||
const database = new DatabaseSync(":memory:");
|
||||
try {
|
||||
@@ -599,6 +674,7 @@ it("rejects invalid OAuth data before changing the 1.6 database", async () => {
|
||||
database.exec(`UPDATE oauthApplication SET redirectUrls = ''`);
|
||||
|
||||
const auth17 = betterAuth({
|
||||
account: { identityStrategy: "issuer" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database,
|
||||
emailAndPassword: { enabled: true },
|
||||
@@ -705,6 +781,7 @@ it("rolls back the SQLite release migration when schema application is blocked",
|
||||
`);
|
||||
|
||||
const auth17 = betterAuth({
|
||||
account: { identityStrategy: "issuer" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database,
|
||||
emailAndPassword: { enabled: true },
|
||||
@@ -903,6 +980,7 @@ it("repairs a MySQL account table an earlier migration filled with empty issuers
|
||||
const auth17 = betterAuth({
|
||||
baseURL: "http://localhost:3000",
|
||||
database: pool,
|
||||
account: { identityStrategy: "issuer" },
|
||||
emailAndPassword: { enabled: true },
|
||||
});
|
||||
await expect(migrateFrom16(auth17.options, {})).resolves.toMatchObject({
|
||||
@@ -981,6 +1059,7 @@ it("ignores a matching account index outside the active PostgreSQL schema", {
|
||||
const auth17 = betterAuth({
|
||||
baseURL: "http://localhost:3000",
|
||||
database: pool,
|
||||
account: { identityStrategy: "issuer" },
|
||||
emailAndPassword: { enabled: true },
|
||||
});
|
||||
await expect(migrateFrom16(auth17.options, {})).resolves.toMatchObject({
|
||||
@@ -1253,6 +1332,7 @@ it("retires published 1.6.30 SCIM credentials with a custom account ID column an
|
||||
fields: {
|
||||
accountId: "externalAccountId",
|
||||
},
|
||||
identityStrategy: "issuer",
|
||||
},
|
||||
baseURL: "http://localhost:3000",
|
||||
database: {
|
||||
@@ -1430,6 +1510,7 @@ it("checkpoints MySQL legacy tables and rolls back interrupted SCIM retirement",
|
||||
},
|
||||
};
|
||||
const auth17 = betterAuth({
|
||||
account: { identityStrategy: "issuer" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database: { db: currentDatabase, transaction: true, type: "mysql" },
|
||||
emailAndPassword: { enabled: true },
|
||||
|
||||
@@ -49,6 +49,7 @@ it("migrates published 1.6.30 accounts and OAuth records through the Prisma adap
|
||||
adapter: new PrismaBetterSqlite3({ url: `file:${databasePath}` }),
|
||||
});
|
||||
const auth17 = betterAuth({
|
||||
account: { identityStrategy: "issuer" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "sqlite",
|
||||
|
||||
@@ -32,12 +32,14 @@ import { getSchemaFromAuthTables } from "./get-schema";
|
||||
import type { MigrationDatabase } from "./migration-database";
|
||||
import { getMigrationDatabase } from "./migration-database";
|
||||
import type {
|
||||
AccountIdentityMigrationAssessment,
|
||||
MigrateFrom16Options,
|
||||
ReleaseMigrationBlocker,
|
||||
} from "./release-migration";
|
||||
import {
|
||||
describeMigrationDecisionBlocker,
|
||||
findReleaseMigrationBlockers,
|
||||
inspectAccountIdentityMigration,
|
||||
inspectLegacyReleaseDataFrom16,
|
||||
inspectScimAccountsFrom16,
|
||||
migrateAccountIdentityFrom16,
|
||||
@@ -206,13 +208,15 @@ export interface RequiredColumnConstraintBlocker {
|
||||
table: string;
|
||||
}
|
||||
|
||||
export interface AccountIdentityMigrationSchema {
|
||||
accountIdColumn: string;
|
||||
issuerColumn: string;
|
||||
export interface AccountIdentityStrategyMismatchBlocker {
|
||||
code: "account-identity-strategy-mismatch";
|
||||
configuredStrategy: "provider-id";
|
||||
detectedStrategy: "issuer" | "mixed";
|
||||
table: string;
|
||||
}
|
||||
|
||||
export type MigrationBlocker =
|
||||
| AccountIdentityStrategyMismatchBlocker
|
||||
| IndexColumnBoundsBlocker
|
||||
| RequiredColumnBackfillBlocker
|
||||
| RequiredColumnConstraintBlocker
|
||||
@@ -233,6 +237,9 @@ function isAccountIdentitySchemaBlocker(
|
||||
blocker.index.columns[1] === accountIdColumn
|
||||
);
|
||||
}
|
||||
if (blocker.code === "account-identity-strategy-mismatch") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(blocker.code === "required-column-backfill" ||
|
||||
blocker.code === "required-column-constraint") &&
|
||||
@@ -246,7 +253,7 @@ function isAccountIdentitySchemaBlocker(
|
||||
export function isHandledByMigrationFrom16(
|
||||
config: BetterAuthOptions,
|
||||
blocker: MigrationBlocker,
|
||||
physicalSchema?: AccountIdentityMigrationSchema | undefined,
|
||||
accountIdentity?: AccountIdentityMigrationAssessment | undefined,
|
||||
) {
|
||||
if (
|
||||
blocker.code === "reprovision-data" ||
|
||||
@@ -259,11 +266,13 @@ export function isHandledByMigrationFrom16(
|
||||
const accountSchema = getAuthTables(config).account;
|
||||
return isAccountIdentitySchemaBlocker(
|
||||
blocker,
|
||||
physicalSchema?.table || accountSchema?.modelName || "account",
|
||||
physicalSchema?.issuerColumn ||
|
||||
accountIdentity?.physicalSchema?.table ||
|
||||
accountSchema?.modelName ||
|
||||
"account",
|
||||
accountIdentity?.physicalSchema?.issuerColumn ||
|
||||
accountSchema?.fields.issuer?.fieldName ||
|
||||
"issuer",
|
||||
physicalSchema?.accountIdColumn ||
|
||||
accountIdentity?.physicalSchema?.accountIdColumn ||
|
||||
accountSchema?.fields.accountId?.fieldName ||
|
||||
"accountId",
|
||||
);
|
||||
@@ -273,6 +282,11 @@ function createMigrationBlockerError(blocker: MigrationBlocker) {
|
||||
if (blocker.code === "index-column-bounds") {
|
||||
return new BetterAuthError(blocker.message);
|
||||
}
|
||||
if (blocker.code === "account-identity-strategy-mismatch") {
|
||||
return new BetterAuthError(
|
||||
`Migration blocked: table "${blocker.table}" already uses ${blocker.detectedStrategy} account identity. Set account.identityStrategy to "issuer" to match the database.`,
|
||||
);
|
||||
}
|
||||
if (blocker.code === "table-data-move") {
|
||||
return new BetterAuthError(
|
||||
`Migration blocked: move rows from retired table "${blocker.sourceTable}" to "${blocker.targetTable}" before applying the schema migration.`,
|
||||
@@ -725,7 +739,7 @@ function describeExistingTableIndexMisfit({
|
||||
}
|
||||
|
||||
const columnBackfillGuideUrl =
|
||||
"https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer";
|
||||
"https://better-auth.com/docs/guides/1-7-upgrade-guide#choose-account-identity-strategy";
|
||||
|
||||
/**
|
||||
* Thrown when a migration plan refuses to run or compile because it would
|
||||
@@ -875,18 +889,14 @@ async function getMigrationsWithDatabase(
|
||||
const throwOnUnsafe = inspectionOptions.throwOnUnsafe !== false;
|
||||
const authTables = migrationDatabase.authTables;
|
||||
const betterAuthSchema = getSchemaFromAuthTables(authTables);
|
||||
const accountIssuer = authTables.account && {
|
||||
table: authTables.account.modelName,
|
||||
column: authTables.account.fields.issuer?.fieldName || "issuer",
|
||||
};
|
||||
const accountIdentitySchema = authTables.account
|
||||
? {
|
||||
accountIdColumn:
|
||||
authTables.account.fields.accountId?.fieldName || "accountId",
|
||||
issuerColumn: authTables.account.fields.issuer?.fieldName || "issuer",
|
||||
table: authTables.account.modelName,
|
||||
}
|
||||
: undefined;
|
||||
const accountIssuerField = authTables.account?.fields.issuer;
|
||||
const accountIssuer =
|
||||
authTables.account && accountIssuerField
|
||||
? {
|
||||
table: authTables.account.modelName,
|
||||
column: accountIssuerField.fieldName || "issuer",
|
||||
}
|
||||
: undefined;
|
||||
const isAccountIssuerColumn = (table: string, column: string) =>
|
||||
table === accountIssuer?.table && column === accountIssuer.column;
|
||||
const logger = createLogger(config.logger);
|
||||
@@ -1002,6 +1012,11 @@ async function getMigrationsWithDatabase(
|
||||
(table) => table.schema === currentSchema,
|
||||
);
|
||||
}
|
||||
const accountIdentity = await inspectAccountIdentityMigration(
|
||||
config,
|
||||
migrationDatabase,
|
||||
tableMetadata,
|
||||
);
|
||||
const toBeCreated: {
|
||||
table: string;
|
||||
fields: Record<string, DBFieldAttribute>;
|
||||
@@ -1018,6 +1033,19 @@ async function getMigrationsWithDatabase(
|
||||
name: string;
|
||||
}[] = [];
|
||||
const migrationBlockers: MigrationBlocker[] = [];
|
||||
if (
|
||||
accountIdentity.selectedStrategy === "provider-id" &&
|
||||
(accountIdentity.detectedStrategy === "issuer" ||
|
||||
accountIdentity.detectedStrategy === "mixed")
|
||||
) {
|
||||
migrationBlockers.push({
|
||||
code: "account-identity-strategy-mismatch",
|
||||
configuredStrategy: "provider-id",
|
||||
detectedStrategy: accountIdentity.detectedStrategy,
|
||||
table:
|
||||
migrationDatabase.inspectionAuthTables.account?.modelName || "account",
|
||||
});
|
||||
}
|
||||
const plannedIndexes = new Map<string, ResolvedDBTableIndex>();
|
||||
migrationBlockers.push(
|
||||
...(await findReleaseMigrationBlockers({
|
||||
@@ -1620,7 +1648,7 @@ async function getMigrationsWithDatabase(
|
||||
return compiled.join(";\n\n") + ";";
|
||||
}
|
||||
return {
|
||||
accountIdentitySchema,
|
||||
accountIdentity,
|
||||
migrationTarget: {
|
||||
adapter: adapterId,
|
||||
dialect: dbType,
|
||||
@@ -1680,7 +1708,7 @@ export async function migrateFrom16(
|
||||
!isHandledByMigrationFrom16(
|
||||
config,
|
||||
blocker,
|
||||
initialMigration.accountIdentitySchema,
|
||||
initialMigration.accountIdentity,
|
||||
),
|
||||
);
|
||||
if (unhandledBlocker) throw createMigrationBlockerError(unhandledBlocker);
|
||||
|
||||
@@ -44,6 +44,7 @@ import { getAdapter } from "./adapter-kysely";
|
||||
export interface MigrationDatabase {
|
||||
adapterId: string;
|
||||
authTables: BetterAuthDBSchema;
|
||||
inspectionAuthTables: BetterAuthDBSchema;
|
||||
databaseType: KyselyDatabaseType;
|
||||
inTransaction: boolean;
|
||||
introspectIndexes?: DatabaseIndexIntrospector | undefined;
|
||||
@@ -56,6 +57,51 @@ export interface MigrationDatabase {
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function getInspectionAuthTables(config: BetterAuthOptions) {
|
||||
return getAuthTables({
|
||||
...config,
|
||||
account: {
|
||||
...config.account,
|
||||
identityStrategy: "issuer",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function selectConfiguredPhysicalTables(
|
||||
configuredTables: BetterAuthDBSchema,
|
||||
physicalTables: BetterAuthDBSchema,
|
||||
): BetterAuthDBSchema {
|
||||
return Object.fromEntries(
|
||||
Object.entries(configuredTables).map(([schemaKey, configuredTable]) => {
|
||||
const physicalTable = physicalTables[schemaKey];
|
||||
if (!physicalTable) {
|
||||
throw new BetterAuthError(
|
||||
`Migration schema could not resolve model "${configuredTable.modelName}".`,
|
||||
);
|
||||
}
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(configuredTable.fields).map(([fieldKey, field]) => {
|
||||
const physicalField = physicalTable.fields[fieldKey];
|
||||
if (!physicalField) {
|
||||
throw new BetterAuthError(
|
||||
`Migration schema could not resolve field "${configuredTable.modelName}.${field.fieldName || fieldKey}".`,
|
||||
);
|
||||
}
|
||||
return [fieldKey, physicalField];
|
||||
}),
|
||||
);
|
||||
return [
|
||||
schemaKey,
|
||||
{
|
||||
...configuredTable,
|
||||
modelName: physicalTable.modelName,
|
||||
fields,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createMigrationRecordWriter(
|
||||
config: BetterAuthOptions,
|
||||
kysely: Kysely<unknown>,
|
||||
@@ -178,11 +224,13 @@ function createMigrationDialect(
|
||||
|
||||
export async function getMigrationDatabase(config: BetterAuthOptions) {
|
||||
const configuredAuthTables = getAuthTables(config);
|
||||
const inspectionAuthTables = getInspectionAuthTables(config);
|
||||
const directDatabase = await createKyselyAdapter(config);
|
||||
if (directDatabase.kysely && directDatabase.databaseType) {
|
||||
const database: MigrationDatabase = {
|
||||
adapterId: "kysely",
|
||||
authTables: configuredAuthTables,
|
||||
inspectionAuthTables,
|
||||
databaseType: directDatabase.databaseType,
|
||||
inTransaction: false,
|
||||
introspectIndexes: directDatabase.introspectIndexes,
|
||||
@@ -199,6 +247,7 @@ export async function getMigrationDatabase(config: BetterAuthOptions) {
|
||||
callback({
|
||||
adapterId: database.adapterId,
|
||||
authTables: database.authTables,
|
||||
inspectionAuthTables: database.inspectionAuthTables,
|
||||
databaseType: database.databaseType,
|
||||
inTransaction: true,
|
||||
introspectIndexes: database.introspectIndexes,
|
||||
@@ -228,9 +277,13 @@ export async function getMigrationDatabase(config: BetterAuthOptions) {
|
||||
`The ${adapter.id} adapter does not expose a SQL migration connection. Use \`auth generate\` and your database tooling instead.`,
|
||||
);
|
||||
}
|
||||
const authTables = migrationConnection.resolvePhysicalSchema
|
||||
? await migrationConnection.resolvePhysicalSchema(configuredAuthTables)
|
||||
: configuredAuthTables;
|
||||
const physicalInspectionAuthTables = migrationConnection.resolvePhysicalSchema
|
||||
? await migrationConnection.resolvePhysicalSchema(inspectionAuthTables)
|
||||
: inspectionAuthTables;
|
||||
const authTables = selectConfiguredPhysicalTables(
|
||||
configuredAuthTables,
|
||||
physicalInspectionAuthTables,
|
||||
);
|
||||
const createDatabase = (
|
||||
connection: MigrationDatabaseConnection,
|
||||
inTransaction: boolean,
|
||||
@@ -241,6 +294,7 @@ export async function getMigrationDatabase(config: BetterAuthOptions) {
|
||||
const database: MigrationDatabase = {
|
||||
adapterId: adapter.id,
|
||||
authTables,
|
||||
inspectionAuthTables: physicalInspectionAuthTables,
|
||||
databaseType: connection.dialect,
|
||||
inTransaction,
|
||||
kysely,
|
||||
|
||||
@@ -291,9 +291,14 @@ function getMigrationDecisionBlockerKey(blocker: MigrationDecisionBlocker) {
|
||||
/** Why a provider's issuer cannot be derived from configuration alone. */
|
||||
export type UnresolvedIssuerReason = "discovery-issuer" | "dynamic-issuer";
|
||||
|
||||
type ProviderIdentityKind = "external" | "local";
|
||||
|
||||
type ProviderIssuerResolution =
|
||||
| { issuer: string }
|
||||
| { unresolved: UnresolvedIssuerReason };
|
||||
| { identityKind: ProviderIdentityKind; issuer: string }
|
||||
| {
|
||||
identityKind: "external";
|
||||
unresolved: UnresolvedIssuerReason;
|
||||
};
|
||||
|
||||
type ProviderIssuerEntry = readonly [string, ProviderIssuerResolution];
|
||||
|
||||
@@ -308,7 +313,9 @@ function resolveSocialProviderIssuer(
|
||||
provider: OAuthProvider,
|
||||
): ProviderIssuerResolution {
|
||||
const issuer = resolveStaticOAuthAccountIssuer(provider);
|
||||
return issuer ? { issuer } : { unresolved: "dynamic-issuer" };
|
||||
return issuer
|
||||
? { identityKind: "external", issuer }
|
||||
: { identityKind: "external", unresolved: "dynamic-issuer" };
|
||||
}
|
||||
|
||||
function resolveGenericOAuthIssuers(
|
||||
@@ -323,16 +330,26 @@ function resolveGenericOAuthIssuers(
|
||||
entry as Partial<GenericOAuthConfig>;
|
||||
if (!providerId) return [];
|
||||
if (typeof accountIssuer === "function") {
|
||||
return [[providerId, { unresolved: "dynamic-issuer" }]];
|
||||
return [
|
||||
[
|
||||
providerId,
|
||||
{ identityKind: "external", unresolved: "dynamic-issuer" },
|
||||
],
|
||||
];
|
||||
}
|
||||
if (accountIssuer === undefined && discoveryUrl) {
|
||||
return [[providerId, { unresolved: "discovery-issuer" }]];
|
||||
return [
|
||||
[
|
||||
providerId,
|
||||
{ identityKind: "external", unresolved: "discovery-issuer" },
|
||||
],
|
||||
];
|
||||
}
|
||||
const issuer = resolveStaticOAuthAccountIssuer({
|
||||
accountIssuer,
|
||||
id: providerId,
|
||||
});
|
||||
return issuer ? [[providerId, { issuer }]] : [];
|
||||
return issuer ? [[providerId, { identityKind: "external", issuer }]] : [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -345,24 +362,28 @@ function resolvePluginIssuers(
|
||||
case "generic-oauth":
|
||||
return resolveGenericOAuthIssuers(pluginOptions);
|
||||
case "siwe":
|
||||
return [["siwe", { issuer: createLocalAccountIssuer("siwe") }]];
|
||||
return [
|
||||
[
|
||||
"siwe",
|
||||
{
|
||||
identityKind: "local",
|
||||
issuer: createLocalAccountIssuer("siwe"),
|
||||
},
|
||||
],
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the issuer every configured provider establishes at runtime, so a
|
||||
* 1.6 migration only asks for the issuers the configuration cannot produce.
|
||||
*
|
||||
* Resolution reads configuration only: no plugin is initialized and no
|
||||
* discovery document is fetched, so a provider whose issuer arrives with a
|
||||
* discovery response or a provider response stays unresolved.
|
||||
*/
|
||||
export async function resolveConfiguredIssuers(
|
||||
interface ConfiguredIssuerState extends ConfiguredAccountIssuers {
|
||||
providerKinds: Record<string, ProviderIdentityKind>;
|
||||
}
|
||||
|
||||
async function resolveConfiguredIssuerState(
|
||||
config: BetterAuthOptions,
|
||||
): Promise<ConfiguredAccountIssuers> {
|
||||
): Promise<ConfiguredIssuerState> {
|
||||
const resolutions = new Map<string, ProviderIssuerResolution>();
|
||||
for (const [providerId, providerConfig] of Object.entries(
|
||||
config.socialProviders || {},
|
||||
@@ -386,14 +407,34 @@ export async function resolveConfiguredIssuers(
|
||||
const issuers: Record<string, string> = {
|
||||
credential: createLocalAccountIssuer("credential"),
|
||||
};
|
||||
const providerKinds: Record<string, ProviderIdentityKind> = {
|
||||
credential: "local",
|
||||
};
|
||||
const unresolvedProviders: Record<string, UnresolvedIssuerReason> = {};
|
||||
for (const [providerId, resolution] of resolutions) {
|
||||
providerKinds[providerId] = resolution.identityKind;
|
||||
if ("issuer" in resolution) {
|
||||
issuers[providerId] = resolution.issuer;
|
||||
continue;
|
||||
}
|
||||
unresolvedProviders[providerId] = resolution.unresolved;
|
||||
}
|
||||
return { issuers, providerKinds, unresolvedProviders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the issuer every configured provider establishes at runtime, so a
|
||||
* 1.6 migration only asks for the issuers the configuration cannot produce.
|
||||
*
|
||||
* Resolution reads configuration only: no plugin is initialized and no
|
||||
* discovery document is fetched, so a provider whose issuer arrives with a
|
||||
* discovery response or a provider response stays unresolved.
|
||||
*/
|
||||
export async function resolveConfiguredIssuers(
|
||||
config: BetterAuthOptions,
|
||||
): Promise<ConfiguredAccountIssuers> {
|
||||
const { issuers, unresolvedProviders } =
|
||||
await resolveConfiguredIssuerState(config);
|
||||
return { issuers, unresolvedProviders };
|
||||
}
|
||||
|
||||
@@ -402,8 +443,8 @@ async function resolveMigrationAccountIssuers(
|
||||
options: MigrateFrom16Options,
|
||||
accountTable: string,
|
||||
blockers: MigrationDecisionBlocker[] | undefined,
|
||||
configured: ConfiguredIssuerState,
|
||||
) {
|
||||
const configured = await resolveConfiguredIssuers(config);
|
||||
const accountIssuers = { ...configured.issuers };
|
||||
for (const [providerId, requested] of Object.entries(
|
||||
options.accountIssuers || {},
|
||||
@@ -531,6 +572,153 @@ interface LegacyAccountIdentityRow {
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
export interface AccountIdentityMigrationAssessment {
|
||||
selectedStrategy: "provider-id" | "issuer";
|
||||
detectedStrategy: "empty" | "provider-id" | "issuer" | "mixed";
|
||||
physicalSchema?:
|
||||
| {
|
||||
accountIdColumn: string;
|
||||
issuerColumn: string;
|
||||
table: string;
|
||||
}
|
||||
| undefined;
|
||||
migrationRequired: boolean;
|
||||
totalAccounts: number;
|
||||
externalAccounts: number;
|
||||
automaticIssuerResolution?: { resolved: number; total: number } | undefined;
|
||||
projectedCollisions: number;
|
||||
manualReviewProviders: string[];
|
||||
}
|
||||
|
||||
/** Inspects the configured account-identity path without changing the database. */
|
||||
export async function inspectAccountIdentityMigration(
|
||||
config: BetterAuthOptions,
|
||||
database: MigrationDatabase,
|
||||
existingTables: readonly TableMetadata[],
|
||||
): Promise<AccountIdentityMigrationAssessment> {
|
||||
const accountSchema = database.inspectionAuthTables.account;
|
||||
const selectedStrategy: AccountIdentityMigrationAssessment["selectedStrategy"] =
|
||||
config.account?.identityStrategy === "issuer" ? "issuer" : "provider-id";
|
||||
const createEmptyAssessment = (
|
||||
physicalSchema?: AccountIdentityMigrationAssessment["physicalSchema"],
|
||||
): AccountIdentityMigrationAssessment => ({
|
||||
selectedStrategy,
|
||||
detectedStrategy: "empty",
|
||||
physicalSchema,
|
||||
migrationRequired: false,
|
||||
totalAccounts: 0,
|
||||
externalAccounts: 0,
|
||||
projectedCollisions: 0,
|
||||
manualReviewProviders: [],
|
||||
});
|
||||
if (!accountSchema) return createEmptyAssessment();
|
||||
const physicalSchema = {
|
||||
accountIdColumn: accountSchema.fields.accountId?.fieldName || "accountId",
|
||||
issuerColumn: accountSchema.fields.issuer?.fieldName || "issuer",
|
||||
table: accountSchema.modelName || "account",
|
||||
};
|
||||
const emptyAssessment = createEmptyAssessment(physicalSchema);
|
||||
const accountTable = physicalSchema.table;
|
||||
const tableMetadata = existingTables.find(
|
||||
(table) => table.name === accountTable,
|
||||
);
|
||||
if (!tableMetadata) return emptyAssessment;
|
||||
const columns = new Set(tableMetadata.columns.map((column) => column.name));
|
||||
const accountIdColumn = physicalSchema.accountIdColumn;
|
||||
const providerIdColumn =
|
||||
accountSchema.fields.providerId?.fieldName || "providerId";
|
||||
const issuerColumn = physicalSchema.issuerColumn;
|
||||
if (!columns.has(accountIdColumn) || !columns.has(providerIdColumn)) {
|
||||
throw new BetterAuthError(
|
||||
`Account identity inspection could not resolve the physical provider columns on table "${accountTable}".`,
|
||||
);
|
||||
}
|
||||
const rows = await sql<
|
||||
Pick<
|
||||
LegacyAccountIdentityRow,
|
||||
"issuer" | "providerAccountId" | "providerId"
|
||||
>
|
||||
>`
|
||||
SELECT
|
||||
${sql.ref(accountIdColumn)} AS "providerAccountId",
|
||||
${sql.ref(providerIdColumn)} AS "providerId",
|
||||
${columns.has(issuerColumn) ? sql.ref(issuerColumn) : sql`NULL`} AS "issuer"
|
||||
FROM ${sql.table(accountTable)}
|
||||
`.execute(database.kysely);
|
||||
const accounts = rows.rows;
|
||||
if (accounts.length === 0) return emptyAssessment;
|
||||
const accountsWithIssuer = accounts.filter((account) =>
|
||||
Boolean(readStoredIssuer(account.issuer)),
|
||||
).length;
|
||||
const detectedStrategy =
|
||||
!columns.has(issuerColumn) || accountsWithIssuer === 0
|
||||
? "provider-id"
|
||||
: accountsWithIssuer === accounts.length
|
||||
? "issuer"
|
||||
: "mixed";
|
||||
const configuredIssuers = await resolveConfiguredIssuerState(config);
|
||||
const isExternal = (providerId: string) =>
|
||||
(configuredIssuers.providerKinds[providerId] ??
|
||||
(providerId === "credential" || providerId === "siwe"
|
||||
? "local"
|
||||
: "external")) === "external";
|
||||
const externalAccounts = accounts.filter((account) =>
|
||||
isExternal(account.providerId),
|
||||
);
|
||||
const manualReviewProviders =
|
||||
selectedStrategy === "issuer"
|
||||
? [
|
||||
...new Set(
|
||||
externalAccounts
|
||||
.filter(
|
||||
(account) =>
|
||||
!readStoredIssuer(account.issuer) &&
|
||||
!configuredIssuers.issuers[account.providerId],
|
||||
)
|
||||
.map((account) => account.providerId),
|
||||
),
|
||||
].sort()
|
||||
: [];
|
||||
const projectedIdentities = new Set<string>();
|
||||
let projectedCollisions = 0;
|
||||
for (const account of accounts) {
|
||||
const namespace =
|
||||
selectedStrategy === "provider-id"
|
||||
? account.providerId
|
||||
: readStoredIssuer(account.issuer) ||
|
||||
configuredIssuers.issuers[account.providerId];
|
||||
if (!namespace) continue;
|
||||
const identity = JSON.stringify([namespace, account.providerAccountId]);
|
||||
if (projectedIdentities.has(identity)) projectedCollisions += 1;
|
||||
else projectedIdentities.add(identity);
|
||||
}
|
||||
return {
|
||||
selectedStrategy,
|
||||
detectedStrategy,
|
||||
physicalSchema,
|
||||
migrationRequired:
|
||||
selectedStrategy === "issuer"
|
||||
? detectedStrategy !== "issuer"
|
||||
: detectedStrategy === "issuer" || detectedStrategy === "mixed",
|
||||
totalAccounts: accounts.length,
|
||||
externalAccounts: externalAccounts.length,
|
||||
...(selectedStrategy === "issuer"
|
||||
? {
|
||||
automaticIssuerResolution: {
|
||||
resolved: externalAccounts.filter(
|
||||
(account) =>
|
||||
Boolean(readStoredIssuer(account.issuer)) ||
|
||||
Boolean(configuredIssuers.issuers[account.providerId]),
|
||||
).length,
|
||||
total: externalAccounts.length,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
projectedCollisions,
|
||||
manualReviewProviders,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDynamicAccountIssuers({
|
||||
accounts,
|
||||
blockers,
|
||||
@@ -1681,6 +1869,7 @@ async function inspectAccountIdentityFrom16(
|
||||
migrationDatabase?: MigrationDatabase,
|
||||
blockers?: MigrationDecisionBlocker[],
|
||||
) {
|
||||
if (config.account?.identityStrategy !== "issuer") return undefined;
|
||||
const database = migrationDatabase ?? (await getMigrationDatabase(config));
|
||||
const { authTables, kysely } = database;
|
||||
const accountSchema = authTables.account;
|
||||
@@ -1707,13 +1896,22 @@ async function inspectAccountIdentityFrom16(
|
||||
accountTableMetadata.columns.map((column) => column.name),
|
||||
);
|
||||
if (!existingColumns.has(accountIdColumn)) return undefined;
|
||||
|
||||
const accountIdentities = await sql<LegacyAccountIdentityRow>`
|
||||
SELECT
|
||||
${sql.ref(idColumn)} AS "id",
|
||||
${sql.ref(accountIdColumn)} AS "providerAccountId",
|
||||
${existingColumns.has(issuerColumn) ? sql.ref(issuerColumn) : sql`NULL`} AS "issuer",
|
||||
${sql.ref(providerIdColumn)} AS "providerId"
|
||||
FROM ${sql.table(accountTable)}
|
||||
`.execute(kysely);
|
||||
const configuredIssuers = await resolveConfiguredIssuerState(config);
|
||||
const { accountIssuers, unresolvedProviders } =
|
||||
await resolveMigrationAccountIssuers(
|
||||
config,
|
||||
options,
|
||||
accountTable,
|
||||
blockers,
|
||||
configuredIssuers,
|
||||
);
|
||||
|
||||
const providerInventory = await sql<AccountProviderCount>`
|
||||
@@ -1744,14 +1942,6 @@ async function inspectAccountIdentityFrom16(
|
||||
});
|
||||
}
|
||||
|
||||
const accountIdentities = await sql<LegacyAccountIdentityRow>`
|
||||
SELECT
|
||||
${sql.ref(idColumn)} AS "id",
|
||||
${sql.ref(accountIdColumn)} AS "providerAccountId",
|
||||
${existingColumns.has(issuerColumn) ? sql.ref(issuerColumn) : sql`NULL`} AS "issuer",
|
||||
${sql.ref(providerIdColumn)} AS "providerId"
|
||||
FROM ${sql.table(accountTable)}
|
||||
`.execute(kysely);
|
||||
const accountIssuerByAccountId = resolveDynamicAccountIssuers({
|
||||
accounts: accountIdentities.rows,
|
||||
blockers,
|
||||
@@ -1762,11 +1952,15 @@ async function inspectAccountIdentityFrom16(
|
||||
const projectedIdentities = new Set<string>();
|
||||
const reportedCollisions = new Set<string>();
|
||||
for (const account of accountIdentities.rows) {
|
||||
const issuer =
|
||||
readStoredIssuer(account.issuer) ??
|
||||
(unresolvedProviders[account.providerId] === "dynamic-issuer"
|
||||
? accountIssuerByAccountId[account.id]
|
||||
: accountIssuers[account.providerId]);
|
||||
const storedIssuer = readStoredIssuer(account.issuer);
|
||||
const configuredIssuer = accountIssuers[account.providerId];
|
||||
let issuer = storedIssuer;
|
||||
if (!issuer) {
|
||||
issuer =
|
||||
unresolvedProviders[account.providerId] === "dynamic-issuer"
|
||||
? accountIssuerByAccountId[account.id]
|
||||
: configuredIssuer;
|
||||
}
|
||||
if (issuer === null || issuer === undefined) continue;
|
||||
const identityKey = JSON.stringify([issuer, account.providerAccountId]);
|
||||
if (projectedIdentities.has(identityKey)) {
|
||||
@@ -1875,6 +2069,9 @@ export async function migrateAccountIdentityFrom16(
|
||||
options: MigrateFrom16Options,
|
||||
migrationDatabase?: MigrationDatabase,
|
||||
): Promise<MigratedAccountSummary> {
|
||||
if (config.account?.identityStrategy !== "issuer") {
|
||||
return { migrated: 0, providers: {} };
|
||||
}
|
||||
const { databaseType, inTransaction, kysely } =
|
||||
migrationDatabase ?? (await getMigrationDatabase(config));
|
||||
if (
|
||||
|
||||
@@ -195,6 +195,23 @@ export function printHumanMigrationPlan(
|
||||
`Target: ${migrationPlan.target.adapter}/${migrationPlan.target.dialect}`,
|
||||
);
|
||||
console.log(`Status: ${migrationPlan.status}`);
|
||||
console.log(
|
||||
`Account identity: ${migrationPlan.accountIdentity.selectedStrategy} (database: ${migrationPlan.accountIdentity.detectedStrategy})`,
|
||||
);
|
||||
console.log(
|
||||
`Accounts: ${migrationPlan.accountIdentity.totalAccounts} total, ${migrationPlan.accountIdentity.externalAccounts} external`,
|
||||
);
|
||||
if (migrationPlan.accountIdentity.automaticIssuerResolution) {
|
||||
console.log(
|
||||
`Automatic issuer resolution: ${migrationPlan.accountIdentity.automaticIssuerResolution.resolved}/${migrationPlan.accountIdentity.automaticIssuerResolution.total}`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Projected collisions: ${migrationPlan.accountIdentity.projectedCollisions}`,
|
||||
);
|
||||
console.log(
|
||||
`Providers requiring manual issuer review: ${migrationPlan.accountIdentity.manualReviewProviders.join(", ") || "none"}`,
|
||||
);
|
||||
console.log(
|
||||
`Blockers: ${migrationPlan.blockers.map(({ code }) => code).join(", ") || "none"}`,
|
||||
);
|
||||
@@ -285,7 +302,7 @@ export async function migrateAction(opts: unknown) {
|
||||
: yoctoSpinner({ text: "preparing migration..." }).start();
|
||||
|
||||
const {
|
||||
accountIdentitySchema,
|
||||
accountIdentity,
|
||||
toBeAdded,
|
||||
toBeAddedIndexes,
|
||||
toBeCreated,
|
||||
@@ -303,7 +320,7 @@ export async function migrateAction(opts: unknown) {
|
||||
toBeAddedIndexes.length > 0 ||
|
||||
toBeCreated.length > 0;
|
||||
const isReleaseMigrationBlocker = (blocker: MigrationBlocker) =>
|
||||
isHandledByMigrationFrom16(config, blocker, accountIdentitySchema);
|
||||
isHandledByMigrationFrom16(config, blocker, accountIdentity);
|
||||
let releaseMigrationBlockers = await collectReleaseMigrationBlockers(
|
||||
config,
|
||||
releaseMigrationOptions,
|
||||
@@ -327,6 +344,7 @@ export async function migrateAction(opts: unknown) {
|
||||
}
|
||||
const buildMigrationPlan = () =>
|
||||
createMigrationPlan({
|
||||
accountIdentity,
|
||||
hasChanges,
|
||||
migrationBlockers: effectiveMigrationBlockers,
|
||||
migrationTarget,
|
||||
@@ -335,6 +353,7 @@ export async function migrateAction(opts: unknown) {
|
||||
actions: describeIrreversibleReleaseActions(
|
||||
legacyReleaseState,
|
||||
decisions,
|
||||
accountIdentity,
|
||||
),
|
||||
id: RELEASE_MIGRATION_ID,
|
||||
}
|
||||
@@ -475,6 +494,7 @@ export async function migrateAction(opts: unknown) {
|
||||
for (const action of describeIrreversibleReleaseActions(
|
||||
legacyReleaseState,
|
||||
decisions,
|
||||
accountIdentity,
|
||||
)) {
|
||||
console.log("->", action);
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ async function inventoryScimAccountsToRetire(
|
||||
export function describeIrreversibleReleaseActions(
|
||||
state: LegacyReleaseDataState,
|
||||
decisions: MigrationDecisions | undefined,
|
||||
accountIdentity: MigrationPlan["accountIdentity"],
|
||||
): string[] {
|
||||
const actions: string[] = [];
|
||||
if (state.oauthApplication?.rowCount) {
|
||||
@@ -179,9 +180,15 @@ export function describeIrreversibleReleaseActions(
|
||||
if (renamed.length > 0) {
|
||||
actions.push(`rename ${renamed.join(", ")}`);
|
||||
}
|
||||
actions.push(
|
||||
"write the 1.7 account identity onto every existing account row",
|
||||
);
|
||||
if (
|
||||
accountIdentity.selectedStrategy === "issuer" &&
|
||||
accountIdentity.migrationRequired &&
|
||||
accountIdentity.totalAccounts > 0
|
||||
) {
|
||||
actions.push(
|
||||
"write the 1.7 account identity onto every existing account row",
|
||||
);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { printHumanMigrationPlan } from "./migrate";
|
||||
import { createMigrationPlan } from "./migration-plan";
|
||||
|
||||
describe("createMigrationPlan", () => {
|
||||
it("reports the configured account identity path and mismatch remediation", () => {
|
||||
const plan = createMigrationPlan({
|
||||
accountIdentity: {
|
||||
selectedStrategy: "provider-id",
|
||||
detectedStrategy: "issuer",
|
||||
migrationRequired: true,
|
||||
totalAccounts: 4,
|
||||
externalAccounts: 3,
|
||||
projectedCollisions: 0,
|
||||
manualReviewProviders: [],
|
||||
},
|
||||
hasChanges: false,
|
||||
migrationBlockers: [
|
||||
{
|
||||
code: "account-identity-strategy-mismatch",
|
||||
configuredStrategy: "provider-id",
|
||||
detectedStrategy: "issuer",
|
||||
table: "account",
|
||||
},
|
||||
],
|
||||
migrationTarget: { adapter: "drizzle", dialect: "postgres" },
|
||||
toBeAdded: [],
|
||||
toBeAddedIndexes: [],
|
||||
toBeCreated: [],
|
||||
});
|
||||
|
||||
expect(plan.status).toBe("blocked");
|
||||
expect(plan.accountIdentity.selectedStrategy).toBe("provider-id");
|
||||
expect(plan.blockers[0]).toMatchObject({
|
||||
code: "account-identity-strategy-mismatch",
|
||||
remediation: {
|
||||
summary:
|
||||
'Set account.identityStrategy to "issuer" so runtime behavior matches the migrated database, then run the plan again.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prints issuer readiness and manual-review providers before applying", () => {
|
||||
const plan = createMigrationPlan({
|
||||
accountIdentity: {
|
||||
selectedStrategy: "issuer",
|
||||
detectedStrategy: "provider-id",
|
||||
migrationRequired: true,
|
||||
totalAccounts: 17,
|
||||
externalAccounts: 3,
|
||||
automaticIssuerResolution: { resolved: 2, total: 3 },
|
||||
projectedCollisions: 1,
|
||||
manualReviewProviders: ["custom-oidc"],
|
||||
},
|
||||
hasChanges: true,
|
||||
migrationBlockers: [],
|
||||
migrationTarget: { adapter: "prisma", dialect: "postgres" },
|
||||
toBeAdded: [],
|
||||
toBeAddedIndexes: [],
|
||||
toBeCreated: [],
|
||||
});
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
printHumanMigrationPlan(plan, [], [], []);
|
||||
const output = log.mock.calls
|
||||
.map((parts) => parts.map(String).join(" "))
|
||||
.join("\n");
|
||||
expect(output).toContain(
|
||||
"Account identity: issuer (database: provider-id)",
|
||||
);
|
||||
expect(output).toContain("Automatic issuer resolution: 2/3");
|
||||
expect(output).toContain("Projected collisions: 1");
|
||||
expect(output).toContain(
|
||||
"Providers requiring manual issuer review: custom-oidc",
|
||||
);
|
||||
expect(output).toContain("No database changes were applied.");
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ interface CreateMigrationPlanInput
|
||||
MigrationInspection,
|
||||
| "migrationBlockers"
|
||||
| "migrationTarget"
|
||||
| "accountIdentity"
|
||||
| "toBeAdded"
|
||||
| "toBeAddedIndexes"
|
||||
| "toBeCreated"
|
||||
@@ -49,6 +50,7 @@ export type MigrationPlanBlocker = MigrationBlockerDetail & {
|
||||
};
|
||||
|
||||
export interface MigrationPlan {
|
||||
accountIdentity: MigrationInspection["accountIdentity"];
|
||||
blockers: MigrationPlanBlocker[];
|
||||
changes: {
|
||||
addColumns: Array<{ columns: string[]; table: string }>;
|
||||
@@ -77,6 +79,8 @@ export interface ReleaseMigrationPlan {
|
||||
/** One actionable sentence naming the blocked table and the work it needs. */
|
||||
export function describeMigrationBlocker(blocker: MigrationBlockerDetail) {
|
||||
switch (blocker.code) {
|
||||
case "account-identity-strategy-mismatch":
|
||||
return `${blocker.table}: the database uses ${blocker.detectedStrategy} account identity, but the configured strategy is ${blocker.configuredStrategy}.`;
|
||||
case "index-column-bounds":
|
||||
case "release-migration-error":
|
||||
return blocker.message;
|
||||
@@ -99,6 +103,8 @@ export function describeMigrationBlocker(blocker: MigrationBlockerDetail) {
|
||||
|
||||
function summarizeMigrationRemediation(blocker: MigrationBlockerDetail) {
|
||||
switch (blocker.code) {
|
||||
case "account-identity-strategy-mismatch":
|
||||
return 'Set account.identityStrategy to "issuer" so runtime behavior matches the migrated database, then run the plan again.';
|
||||
case "account-identity-collision":
|
||||
return `Merge or remove the duplicate rows in "${blocker.table}" so issuer "${blocker.issuer}" holds provider account id "${blocker.providerAccountId}" once, then migrate again.`;
|
||||
case "account-issuer-conflict":
|
||||
@@ -153,12 +159,14 @@ function summarizeMigrationRemediation(blocker: MigrationBlockerDetail) {
|
||||
|
||||
function resolveMigrationGuideAnchor(blocker: MigrationBlockerDetail) {
|
||||
switch (blocker.code) {
|
||||
case "account-identity-strategy-mismatch":
|
||||
return "choose-account-identity-strategy";
|
||||
case "account-identity-collision":
|
||||
case "account-issuer-conflict":
|
||||
case "account-issuer-decision-required":
|
||||
case "issuer-conflict":
|
||||
case "issuer-required":
|
||||
return "account-identity-is-scoped-by-issuer";
|
||||
return "choose-account-identity-strategy";
|
||||
case "reprovision-data":
|
||||
case "scim-decision-required":
|
||||
case "scim-inventory-mismatch":
|
||||
@@ -192,6 +200,7 @@ function getBlockerKey(blocker: MigrationBlockerDetail) {
|
||||
}
|
||||
|
||||
export function createMigrationPlan({
|
||||
accountIdentity,
|
||||
hasChanges,
|
||||
migrationBlockers,
|
||||
migrationTarget,
|
||||
@@ -207,6 +216,7 @@ export function createMigrationPlan({
|
||||
];
|
||||
return {
|
||||
formatVersion: 1,
|
||||
accountIdentity,
|
||||
target: migrationTarget,
|
||||
status:
|
||||
blockers.length > 0
|
||||
|
||||
@@ -37,8 +37,8 @@ async function runCli(args: string[], cwd: string) {
|
||||
|
||||
const projects: string[] = [];
|
||||
|
||||
// A pre-1.7 SQLite database: the account table predates the `issuer` column
|
||||
// and already holds a row, so the guardrail must refuse to add it.
|
||||
// A pre-1.7 SQLite database whose populated account table must remain
|
||||
// provider-scoped when the 1.7 strategy is omitted.
|
||||
function createProject() {
|
||||
const cacheDir = path.join(
|
||||
process.cwd(),
|
||||
@@ -85,7 +85,7 @@ export const auth = betterAuth({
|
||||
`INSERT INTO "account" ("id", "accountId", "providerId", "userId", "createdAt", "updatedAt") VALUES ('a1', 'g-1', 'google', 'u1', '2020-01-01', '2020-01-01')`,
|
||||
);
|
||||
database.close();
|
||||
return { cwd };
|
||||
return { cwd, databasePath };
|
||||
}
|
||||
|
||||
async function createPublished16Project() {
|
||||
@@ -148,22 +148,30 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("auth migrate: refusing a destructive column add", () => {
|
||||
it("exits 1 with a clean refusal and no stack trace", async () => {
|
||||
const { cwd } = createProject();
|
||||
describe("auth migrate: preserving the default account identity", () => {
|
||||
it("plans a populated 1.6 account table without adding issuer", async () => {
|
||||
const { cwd, databasePath } = createProject();
|
||||
const { exitCode, output } = await runCli(
|
||||
["migrate", "--config", "auth.ts", "--yes"],
|
||||
["migrate", "plan", "--config", "auth.ts", "--json"],
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(output).toContain(
|
||||
"Migration blocked. No database changes were applied.",
|
||||
);
|
||||
expect(output).toContain("[issuer-required]");
|
||||
expect(output).toContain(
|
||||
"https://better-auth.com/docs/guides/1-7-upgrade-guide",
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(output)).toMatchObject({
|
||||
accountIdentity: {
|
||||
selectedStrategy: "provider-id",
|
||||
detectedStrategy: "provider-id",
|
||||
},
|
||||
blockers: [],
|
||||
});
|
||||
const database = new Database(databasePath);
|
||||
expect(
|
||||
database
|
||||
.prepare("PRAGMA table_info(account)")
|
||||
.all()
|
||||
.map((column) => (column as { name: string }).name),
|
||||
).not.toContain("issuer");
|
||||
database.close();
|
||||
expect(output).not.toContain("triggerUncaughtException");
|
||||
expect(output).not.toContain("node:internal");
|
||||
expect(output).not.toMatch(/at .*:\d+:\d+/);
|
||||
@@ -171,7 +179,7 @@ describe("auth migrate: refusing a destructive column add", () => {
|
||||
});
|
||||
|
||||
describe("auth migrate: upgrading a published 1.6.30 database", () => {
|
||||
it("plans and applies the adjacent release migration through the built CLI", async () => {
|
||||
it("keeps the published account schema and sign-in behavior unchanged", async () => {
|
||||
const { baseURL, credentials, cwd, databasePath, secret } =
|
||||
await createPublished16Project();
|
||||
const sourceDatabase = new Database(databasePath);
|
||||
@@ -189,14 +197,12 @@ describe("auth migrate: upgrading a published 1.6.30 database", () => {
|
||||
);
|
||||
expect(planned.exitCode).toBe(0);
|
||||
expect(JSON.parse(planned.output)).toMatchObject({
|
||||
blockers: [],
|
||||
releaseMigration: {
|
||||
actions: [
|
||||
"write the 1.7 account identity onto every existing account row",
|
||||
],
|
||||
id: "1.6-to-1.7",
|
||||
accountIdentity: {
|
||||
selectedStrategy: "provider-id",
|
||||
detectedStrategy: "provider-id",
|
||||
},
|
||||
status: "ready",
|
||||
blockers: [],
|
||||
status: "up-to-date",
|
||||
});
|
||||
|
||||
const plannedDatabase = new Database(databasePath);
|
||||
@@ -216,16 +222,18 @@ describe("auth migrate: upgrading a published 1.6.30 database", () => {
|
||||
expect(JSON.parse(applied.output)).toMatchObject({
|
||||
mode: "apply",
|
||||
plan: {
|
||||
releaseMigration: { id: "1.6-to-1.7" },
|
||||
status: "ready",
|
||||
status: "up-to-date",
|
||||
},
|
||||
status: "applied",
|
||||
status: "up-to-date",
|
||||
});
|
||||
|
||||
const migratedDatabase = new Database(databasePath);
|
||||
expect(
|
||||
migratedDatabase.prepare("SELECT issuer, providerId FROM account").get(),
|
||||
).toEqual({ issuer: "local:credential", providerId: "credential" });
|
||||
migratedDatabase
|
||||
.prepare("PRAGMA table_info(account)")
|
||||
.all()
|
||||
.map((column) => (column as { name: string }).name),
|
||||
).not.toContain("issuer");
|
||||
const auth17 = betterAuth({
|
||||
baseURL,
|
||||
database: migratedDatabase,
|
||||
@@ -243,8 +251,8 @@ describe("auth migrate: upgrading a published 1.6.30 database", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth generate: emitting the refused migration with a warning", () => {
|
||||
it("exits 0 and writes the warning banner alongside the computed statements", async () => {
|
||||
describe("auth generate: preserving the default account identity", () => {
|
||||
it("emits no issuer column or destructive warning", async () => {
|
||||
const { cwd } = createProject();
|
||||
const { exitCode, output } = await runCli(
|
||||
["generate", "--config", "auth.ts", "--output", "migration.sql", "--yes"],
|
||||
@@ -252,12 +260,10 @@ describe("auth generate: emitting the refused migration with a warning", () => {
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output).toContain("corrupts a populated database");
|
||||
expect(output).not.toContain("corrupts a populated database");
|
||||
|
||||
const sql = fs.readFileSync(path.join(cwd, "migration.sql"), "utf-8");
|
||||
expect(sql).toContain("DO NOT RUN THIS SCRIPT AS IT IS.");
|
||||
expect(sql.toLowerCase()).toContain(
|
||||
'alter table "account" add column "issuer" text not null',
|
||||
);
|
||||
expect(sql).not.toContain("DO NOT RUN THIS SCRIPT AS IT IS.");
|
||||
expect(sql.toLowerCase()).not.toContain('column "issuer"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,6 +317,15 @@ describe("migration plan status", () => {
|
||||
*/
|
||||
it("reports pending release work when the schema is already current", () => {
|
||||
const plan = createMigrationPlan({
|
||||
accountIdentity: {
|
||||
selectedStrategy: "provider-id",
|
||||
detectedStrategy: "provider-id",
|
||||
migrationRequired: false,
|
||||
totalAccounts: 1,
|
||||
externalAccounts: 0,
|
||||
projectedCollisions: 0,
|
||||
manualReviewProviders: [],
|
||||
},
|
||||
hasChanges: false,
|
||||
migrationBlockers: [],
|
||||
migrationTarget: { adapter: "kysely", dialect: "sqlite" },
|
||||
@@ -334,6 +343,15 @@ describe("migration plan status", () => {
|
||||
|
||||
it("prints blocker details and remediation in the human plan", () => {
|
||||
const plan = createMigrationPlan({
|
||||
accountIdentity: {
|
||||
selectedStrategy: "issuer",
|
||||
detectedStrategy: "provider-id",
|
||||
migrationRequired: true,
|
||||
totalAccounts: 1,
|
||||
externalAccounts: 0,
|
||||
projectedCollisions: 0,
|
||||
manualReviewProviders: [],
|
||||
},
|
||||
hasChanges: true,
|
||||
migrationBlockers: [
|
||||
{
|
||||
@@ -383,6 +401,7 @@ describe("migrate published 1.6.30 account data", () => {
|
||||
});
|
||||
|
||||
const auth17 = betterAuth({
|
||||
account: { identityStrategy: "issuer" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database: db,
|
||||
plugins: [
|
||||
@@ -471,6 +490,7 @@ describe("migrate published 1.6.30 account data", () => {
|
||||
expect(sourceAccounts).toHaveLength(2);
|
||||
|
||||
const auth17 = betterAuth({
|
||||
account: { identityStrategy: "issuer" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database: db,
|
||||
emailAndPassword: {
|
||||
@@ -601,6 +621,7 @@ describe("migrate published 1.6.30 account data", () => {
|
||||
},
|
||||
});
|
||||
const auth17 = betterAuth({
|
||||
account: { identityStrategy: "issuer" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database: db,
|
||||
emailAndPassword: {
|
||||
@@ -659,9 +680,11 @@ describe("migrate published 1.6.30 account data", () => {
|
||||
async function createReleaseDecisionFixture(
|
||||
db: Database.Database,
|
||||
{
|
||||
identityStrategy = "issuer",
|
||||
sourceClientSecretStorage = "plain",
|
||||
targetClientSecretStorage = "hashed",
|
||||
}: {
|
||||
identityStrategy?: "issuer" | "provider-id" | "unset";
|
||||
sourceClientSecretStorage?: "encrypted" | "hashed" | "plain";
|
||||
targetClientSecretStorage?: "encrypted" | "hashed";
|
||||
} = {},
|
||||
@@ -740,6 +763,7 @@ async function createReleaseDecisionFixture(
|
||||
});
|
||||
|
||||
const options17: BetterAuthOptions = {
|
||||
...(identityStrategy !== "unset" && { account: { identityStrategy } }),
|
||||
baseURL: "http://localhost:3000",
|
||||
database: db,
|
||||
emailAndPassword: {
|
||||
@@ -1027,9 +1051,11 @@ describe("migrate command modes", () => {
|
||||
});
|
||||
|
||||
describe("plan every unresolved 1.6.30 release decision", () => {
|
||||
it("reports the missing issuer, consent strategy, and SCIM inventory in one run", async () => {
|
||||
it("reports the consent strategy and SCIM inventory in one run", async () => {
|
||||
const db = new Database(":memory:");
|
||||
const { scimAccountId } = await createReleaseDecisionFixture(db);
|
||||
const { scimAccountId } = await createReleaseDecisionFixture(db, {
|
||||
identityStrategy: "unset",
|
||||
});
|
||||
const plan = await writeMigrationDecisions({
|
||||
formatVersion: 1,
|
||||
migration: "1.6-to-1.7",
|
||||
@@ -1052,22 +1078,14 @@ describe("plan every unresolved 1.6.30 release decision", () => {
|
||||
expect(process.exitCode).toBe(1);
|
||||
const jsonPlan = JSON.parse(String(consoleLog.mock.calls[0]?.[0])) as {
|
||||
blockers: Array<Record<string, unknown>>;
|
||||
releaseMigration?: { actions: string[] };
|
||||
status: string;
|
||||
};
|
||||
expect(jsonPlan.status).toBe("blocked");
|
||||
expect(jsonPlan.releaseMigration?.actions).not.toContain(
|
||||
"write the 1.7 account identity onto every existing account row",
|
||||
);
|
||||
expect(jsonPlan.blockers).toEqual([
|
||||
{
|
||||
accountCount: 1,
|
||||
code: "issuer-required",
|
||||
providerId: "workforce-fixture",
|
||||
reason: "unconfigured-provider",
|
||||
remediation: {
|
||||
docs: "https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer",
|
||||
summary:
|
||||
'Record the issuer for "workforce-fixture" under issuers in better-auth-migration.json, or run `auth migrate apply` in a terminal to answer it there.',
|
||||
},
|
||||
table: "account",
|
||||
},
|
||||
{
|
||||
code: "scim-inventory-mismatch",
|
||||
missingAccountIds: [scimAccountId],
|
||||
@@ -1327,7 +1345,7 @@ describe("plan every unresolved 1.6.30 release decision", () => {
|
||||
' Fix: Remove "credential" from the issuers in better-auth-migration.json to migrate these accounts as "local:credential", or configure the provider to establish "https://credential.example".',
|
||||
);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
" Docs: https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer",
|
||||
" Docs: https://better-auth.com/docs/guides/1-7-upgrade-guide#choose-account-identity-strategy",
|
||||
);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
`Resolve every blocker above in your 1.6 data, then run \`auth migrate apply ${plan}\` again. Upgrade guide: https://better-auth.com/docs/guides/1-7-upgrade-guide`,
|
||||
@@ -1388,6 +1406,7 @@ async function createResolvableAccountFixture(db: Database.Database) {
|
||||
};
|
||||
await auth1630.api.signUpEmail({ body: credentials });
|
||||
const options17: BetterAuthOptions = {
|
||||
account: { identityStrategy: "issuer" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database: db,
|
||||
emailAndPassword: {
|
||||
@@ -1687,6 +1706,7 @@ describe("interview the unresolved 1.6.30 release decisions", () => {
|
||||
issuer: "local:credential",
|
||||
});
|
||||
const auth17 = betterAuth({
|
||||
account: { identityStrategy: "issuer" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database: db,
|
||||
emailAndPassword: { enabled: true },
|
||||
@@ -1953,6 +1973,7 @@ async function createRenamedLegacyClientFixture(db: Database.Database) {
|
||||
},
|
||||
});
|
||||
const options17: BetterAuthOptions = {
|
||||
account: { identityStrategy: "issuer" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database: db,
|
||||
emailAndPassword: {
|
||||
@@ -2299,6 +2320,7 @@ describe("migrate published 1.6.30 organization team data", () => {
|
||||
},
|
||||
};
|
||||
const auth17 = betterAuth({
|
||||
account: { identityStrategy: "issuer" },
|
||||
baseURL: "http://localhost:3000",
|
||||
database: db,
|
||||
emailAndPassword: {
|
||||
|
||||
Reference in New Issue
Block a user