Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions db/migrations/0015_relax_account_issuer.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS "account_issuer_accountId_uidx";
ALTER TABLE "account" DROP COLUMN "issuer";
3 changes: 3 additions & 0 deletions doc/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Run the SQL migrations in filename order against the Turso database:
12. `db/migrations/0012_opportunity_workflow.sql`
13. `db/migrations/0013_contribution_readiness.sql`
14. `db/migrations/0014_user_moderation.sql`
15. `db/migrations/0015_relax_account_issuer.sql`

The first migration creates Better Auth's user, session, account, and verification tables. The second creates user-owned saved searches. Migration files intentionally contain structure only—never credentials or production data.

Expand Down Expand Up @@ -84,6 +85,8 @@ The thirteenth adds the contribution-readiness preference to cloud saved searche
existing records continue to include every readiness status.
The fourteenth adds role, moderation, and impersonation fields to users and sessions
for the Better Auth admin plugin, and promotes existing administrator records.
The fifteenth drops the legacy `account.issuer` column and unique index to align with
Better Auth 1.7.3+ runtime schema expectations while preserving provider credentials.

## GitHub OAuth

Expand Down
106 changes: 106 additions & 0 deletions scripts/migrate-account-issuer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createClient } from "@libsql/client";
import * as fs from "fs";

function parseEnv(filePath) {
if (!fs.existsSync(filePath)) return {};
const content = fs.readFileSync(filePath, "utf-8");
const env = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx !== -1) {
const key = trimmed.slice(0, eqIdx).trim();
let val = trimmed.slice(eqIdx + 1).trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
env[key] = val;
}
}
return env;
}

async function applyMigration(name, envFile) {
console.log(`\n========================================`);
console.log(`Applying migration 0015 to: ${name} (${envFile})`);
console.log(`========================================`);

const env = parseEnv(envFile);
const url = env.TURSO_DATABASE_URL;
const authToken = env.TURSO_AUTH_TOKEN;

if (!url || !authToken) {
console.error(`[SKIP] Missing credentials in ${envFile}`);
return false;
}

const client = createClient({ url, authToken });

try {
const tableInfo = await client.execute("PRAGMA table_info(account)");
const colNames = new Set(tableInfo.rows.map((r) => r.name));

const indexInfo = await client.execute("PRAGMA index_list(account)");
const indexNames = new Set(indexInfo.rows.map((r) => r.name));

if (indexNames.has("account_issuer_accountId_uidx")) {
console.log(`Dropping index "account_issuer_accountId_uidx"...`);
await client.execute('DROP INDEX IF EXISTS "account_issuer_accountId_uidx";');
console.log(` -> Index dropped`);
} else {
console.log(`Index "account_issuer_accountId_uidx" does not exist, skipping drop.`);
}

if (colNames.has("issuer")) {
console.log(`Dropping column "issuer" from "account"...`);
await client.execute('ALTER TABLE "account" DROP COLUMN "issuer";');
console.log(` -> Column dropped`);
} else {
console.log(`Column "issuer" does not exist on "account", skipping drop.`);
}

// Verification
const updatedTableInfo = await client.execute("PRAGMA table_info(account)");
const remainingCols = updatedTableInfo.rows.map((r) => r.name);
console.log(`Updated account columns:`, remainingCols.join(", "));

const updatedIndexInfo = await client.execute("PRAGMA index_list(account)");
console.log(`Updated account indexes:`, updatedIndexInfo.rows.map((r) => r.name).join(", "));

const countResult = await client.execute("SELECT count(*) as count FROM account");
console.log(`Account records preserved: ${countResult.rows[0].count}`);

if (remainingCols.includes("issuer")) {
throw new Error(`Column "issuer" is still present on table "account"!`);
}

return true;
} catch (err) {
console.error(`[ERROR] Failed migrating ${name}:`, err);
return false;
}
}

async function main() {
const previewSuccess = await applyMigration("Preview Database", ".env.preview.local");
const prodSuccess = await applyMigration("Production/Local Database", ".env.local");

console.log(`\n========================================`);
console.log(`Migration 0015 Summary:`);
console.log(`Preview: ${previewSuccess ? "SUCCESS" : "FAILED"}`);
console.log(`Production: ${prodSuccess ? "SUCCESS" : "FAILED"}`);
console.log(`========================================\n`);

if (!previewSuccess || !prodSuccess) {
process.exit(1);
}
}

main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});
9 changes: 1 addition & 8 deletions src/lib/auth-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ export const account = sqliteTable(
"account",
{
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
Expand All @@ -85,13 +84,7 @@ export const account = sqliteTable(
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [
uniqueIndex("account_issuer_accountId_uidx").on(
table.issuer,
table.accountId,
),
index("account_userId_idx").on(table.userId),
],
(table) => [index("account_userId_idx").on(table.userId)],
);

export const verification = sqliteTable(
Expand Down
50 changes: 50 additions & 0 deletions tests/lib/auth-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { diffSchema, getExpectedSchema } from "@better-auth/core/db/internal";
import { getTableColumns, is, Table } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import * as schema from "@/lib/auth-schema";

function introspectDrizzleSchema(drizzleSchema: Record<string, unknown>) {
const tables: Array<{
name: string;
columns: Array<{ name: string; nullable: boolean; hasDefault: boolean }>;
}> = [];

for (const [name, table] of Object.entries(drizzleSchema)) {
if (!is(table, Table)) continue;
const columns = Object.entries(getTableColumns(table)).map(
([key, column]) => ({
name: key,
nullable: !column.notNull,
hasDefault:
column.hasDefault ||
(column as { generated?: unknown }).generated !== undefined,
}),
);
tables.push({ name, columns });
}

return tables;
}

describe("auth schema compatibility", () => {
it("does not have schema mismatch findings against Better Auth expected schema", () => {
const introspected = introspectDrizzleSchema(
schema as unknown as Record<string, unknown>,
);
const expected = getExpectedSchema({});
const findings = diffSchema(expected, introspected);

expect(findings).toEqual([]);
});

it("account table definition matches Better Auth without legacy issuer column", () => {
const columns = getTableColumns(schema.account);
const columnKeys = Object.keys(columns);

expect(columnKeys).not.toContain("issuer");
expect(columnKeys).toContain("id");
expect(columnKeys).toContain("accountId");
expect(columnKeys).toContain("providerId");
expect(columnKeys).toContain("userId");
});
});