[GH-ISSUE #8849] Drizzle adapter with usePlural + joins: missing relationName on generated relations causes "multiple relations" error #19844

Open
opened 2026-04-15 19:11:49 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @thedevdavid on GitHub (Mar 30, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/8849

Originally assigned to: @ping-maxwell on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Configure Better Auth with usePlural: true + experimental: { joins: true }
  2. Define Drizzle relations with both singular and plural aliases to the same target (as the CLI historically generated)
  3. Attempt sign-in → 500 error

Current vs. Expected behavior

When using the Drizzle adapter with usePlural: true and experimental: { joins: true }, sign-in (and any endpoint that triggers a join query) fails with Error: There are multiple relations between "accounts" and "users". Please specify relation name.

Expected:

  • The CLI should generate relations with relationName when multiple keys reference the same table
  • The docs should document the required relation naming convention for usePlural + joins
  • Ideally, the adapter should validate relation availability at startup

What version of Better Auth are you using?

1.5.5

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.3.0: Wed Jan 28 20:54:55 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T6031",
    "release": "25.3.0",
    "cpuCount": 16,
    "cpuModel": "Apple M3 Max",
    "totalMemory": "64.00 GB",
    "freeMemory": "4.84 GB"
  },
  "node": {
    "version": "v24.14.0",
    "env": "development"
  },
  "packageManager": {
    "name": "bun",
    "version": "1.3.11"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "16.2.1"
    },
    {
      "name": "react",
      "version": "19.2.4"
    }
  ],
  "databases": [
    {
      "name": "drizzle",
      "version": "0.45.2"
    }
  ],
  "betterAuth": {
    "version": "1.5.5",
    "config": null
  }
}

Which area(s) are affected? (Select all that apply)

Package

Auth config (if applicable)

import { betterAuth } from "better-auth"
export const auth = betterAuth({
  emailAndPassword: {  
    enabled: true
  },
});

Additional context

  • better-auth: 1.5.5
  • @better-auth/drizzle-adapter: 1.5.5
  • drizzle-orm: 0.45.2
  • PostgreSQL via Supabase

Potentially related: #7773 , #6525 , #6547

Originally created by @thedevdavid on GitHub (Mar 30, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/8849 Originally assigned to: @ping-maxwell on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Configure Better Auth with usePlural: true + experimental: { joins: true } 2. Define Drizzle relations with both singular and plural aliases to the same target (as the CLI historically generated) 3. Attempt sign-in → 500 error ### Current vs. Expected behavior When using the Drizzle adapter with usePlural: true and experimental: { joins: true }, sign-in (and any endpoint that triggers a join query) fails with `Error: There are multiple relations between "accounts" and "users". Please specify relation name`. Expected: - The CLI should generate relations with relationName when multiple keys reference the same table - The docs should document the required relation naming convention for usePlural + joins - Ideally, the adapter should validate relation availability at startup ### What version of Better Auth are you using? 1.5.5 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.3.0: Wed Jan 28 20:54:55 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T6031", "release": "25.3.0", "cpuCount": 16, "cpuModel": "Apple M3 Max", "totalMemory": "64.00 GB", "freeMemory": "4.84 GB" }, "node": { "version": "v24.14.0", "env": "development" }, "packageManager": { "name": "bun", "version": "1.3.11" }, "frameworks": [ { "name": "next", "version": "16.2.1" }, { "name": "react", "version": "19.2.4" } ], "databases": [ { "name": "drizzle", "version": "0.45.2" } ], "betterAuth": { "version": "1.5.5", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Package ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" export const auth = betterAuth({ emailAndPassword: { enabled: true }, }); ``` ### Additional context - better-auth: 1.5.5 - @better-auth/drizzle-adapter: 1.5.5 - drizzle-orm: 0.45.2 - PostgreSQL via Supabase Potentially related: #7773 , #6525 , #6547
GiteaMirror added the databasebug labels 2026-04-15 19:11:49 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Mar 30, 2026):

This is a confirmed gap in the current implementation. The root cause is a mismatch between how the CLI generates relations and how the adapter consumes them at runtime:

  • CLI side: When multiple foreign keys reference the same table, the CLI generates separate relations() exports per field (e.g., testUserIdRelations, testManagerIdRelations) to avoid TypeScript duplicate-key errors [1]. However, it does not use Drizzle's relationName parameter, which is the standard way to disambiguate multiple relations to the same table [2].

  • Adapter side: When experimental: { joins: true } is enabled, the adapter builds a with clause using only the model name as the key (e.g., with: { user: true }) and never passes relationName [3]. So when Drizzle ORM sees two relations between the same tables, it throws "There are multiple relations between X and Y. Please specify relation name".

Workaround: You can manually define your relations using Drizzle's relationName parameter in a single relations() block instead of relying on the CLI-generated output. For example:

export const accountRelations = relations(accounts, ({ one }) => ({
  user: one(users, {
    fields: [accounts.userId],
    references: [users.id],
    relationName: "accountUser",
  }),
}));

export const userRelations = relations(users, ({ many }) => ({
  accounts: many(accounts, { relationName: "accountUser" }),
}));

However, this alone won't fully resolve the issue since the adapter doesn't pass relationName in its with clause construction either — so the fix really needs to happen at the adapter level.

For context, there's an open PR #6913 for Drizzle Relations v2 using drizzle-orm's defineRelations API which may provide a cleaner path forward [4]. The related issues you linked (#6525, #6547) addressed the CLI-side TypeScript errors [5] but not the runtime relationName disambiguation needed by the adapter.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:4156984196 --> @dosubot[bot] commented on GitHub (Mar 30, 2026): <!-- Answer --> This is a confirmed gap in the current implementation. The root cause is a mismatch between how the CLI generates relations and how the adapter consumes them at runtime: - **CLI side**: When multiple foreign keys reference the same table, the CLI generates **separate `relations()` exports** per field (e.g., `testUserIdRelations`, `testManagerIdRelations`) to avoid TypeScript duplicate-key errors [[1]](https://github.com/better-auth/better-auth/blob/3860c09d9f6af9afb964ab7801f45326c67a4242/packages/cli/src/generators/drizzle.ts#L410-L453). However, it does **not** use Drizzle's `relationName` parameter, which is the standard way to disambiguate multiple relations to the same table [[2]](https://github.com/better-auth/better-auth/issues/6525). - **Adapter side**: When `experimental: { joins: true }` is enabled, the adapter builds a `with` clause using only the model name as the key (e.g., `with: { user: true }`) and **never passes `relationName`** [[3]](https://github.com/better-auth/better-auth/blob/3860c09d9f6af9afb964ab7801f45326c67a4242/packages/drizzle-adapter/src/drizzle-adapter.ts#L598-L619). So when Drizzle ORM sees two relations between the same tables, it throws `&quot;There are multiple relations between X and Y. Please specify relation name&quot;`. **Workaround**: You can manually define your relations using Drizzle's `relationName` parameter in a single `relations()` block instead of relying on the CLI-generated output. For example: ```typescript export const accountRelations = relations(accounts, ({ one }) => ({ user: one(users, { fields: [accounts.userId], references: [users.id], relationName: "accountUser", }), })); export const userRelations = relations(users, ({ many }) => ({ accounts: many(accounts, { relationName: "accountUser" }), })); ``` However, **this alone won't fully resolve the issue** since the adapter doesn't pass `relationName` in its `with` clause construction either — so the fix really needs to happen at the adapter level. For context, there's an open [PR #6913](https://github.com/better-auth/better-auth/pull/6913) for Drizzle Relations v2 using drizzle-orm's `defineRelations` API which may provide a cleaner path forward [[4]](https://github.com/better-auth/better-auth/pull/6913). The related issues you linked (#6525, #6547) addressed the CLI-side TypeScript errors [[5]](https://github.com/better-auth/better-auth/pull/6547) but not the runtime `relationName` disambiguation needed by the adapter. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=brand-link).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/848d70c3-e992-4727-be9d-5b55ebc2206e?feedback_type=great_response&utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/848d70c3-e992-4727-be9d-5b55ebc2206e?feedback_type=irrelevant_answer&utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/848d70c3-e992-4727-be9d-5b55ebc2206e?feedback_type=incorrect_sources&utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/848d70c3-e992-4727-be9d-5b55ebc2206e?feedback_type=too_verbose&utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/848d70c3-e992-4727-be9d-5b55ebc2206e?feedback_type=hallucination&utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/848d70c3-e992-4727-be9d-5b55ebc2206e?feedback_type=bug_report&utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-bug_report) | [Other](https://app.dosu.dev/response-feedback/848d70c3-e992-4727-be9d-5b55ebc2206e?feedback_type=other&utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=chat-badge)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=This%20is%20a%20confirmed%20gap%20in%20the%20current%20implementation.%20The%20root%20cause%20is%20a%20mismatch%20between%20how%20the%20CLI%20generates%20relations%20and%20how%20the%20adapter%20consumes%20them%20at%20runtime%3A%0A%0A-%20%2A%2ACLI%20side%2A%2A%3A%20When%20multiple%20foreign%20keys%20reference%20the%20same%20table%2C%20the%20CLI%20generates%20%2A%2Aseparate%20%60relations%28%29%60%20exports%2A%2A%20per%20field%20%28e.g.%2C%20%60testUserIdRelations%60%2C%20%60testManagerIdRelations%60%29%20to%20avoid%20TypeScript%20duplicate-key%20errors%20%5B%5B1%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/3860c09d9f6af9afb964ab7801f45326c67a4242/packages/cli/src/generators/drizzle.ts%23L410-L453%29.%20However%2C%20it%20does%20%2A%2Anot%2A%2A%20use%20Drizzle%27s%20%60relationName%60%20parameter%2C%20which%20is%20the%20standard%20way%20to%20disambiguate%20multiple%20relations%20to%20the%20same%20table%20%5B%5B2%5D%5D%28https%3A//github.com/better-auth/better-auth/issues/6525%29.%0A%0A-%20%2A%2AAdapter%20side%2A%2A%3A%20When%20%60experimental%3A%20%7B%20joins%3A%20true%20%7D%60%20is%20enabled%2C%20the%20adapter%20builds%20a%20%60with%60%20clause%20using%20only%20the%20model%20name%20as%20the%20key%20%28e.g.%2C%20%60with%3A%20%7B%20user%3A%20true%20%7D%60%29%20and%20%2A%2Anever%20passes%20%60relationName%60%2A%2A%20%5B%5B3%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/3860c09d9f6af9afb964ab7801f45326c67a4242/packages/drizzle-adapter/src/drizzle-adapter.ts%23L598-L619%29.%20So%20when%20Drizzle%20ORM%20sees%20two%20relations%20between%20the%20same%20tables%2C%20it%20throws%20%60%26quot%3BThere%20are%20multiple%20relations%20between%20X%20and%20Y.%20Please%20specify%20relation%20name%26quot%3B%60.%0A%0A%2A%2AWorkaround%2A%2A%3A%20You%20can%20manually%20define%20your%20relations%20using%20Drizzle%27s%20%60relationName%60%20parameter%20in%20a%20single%20%60relations%28%29%60%20block%20instead%20of%20relying%20on%20the%20CLI-generated%20output.%20For%20example%3A%0A%0A%60%60%60typescript%0Aexport%20const%20accountRelations%20%3D%20relations%28accounts%2C%20%28%7B%20one%20%7D%29%20%3D%3E%20%28%7B%0A%20%20user%3A%20one%28users%2C%20%7B%0A%20%20%20%20fields%3A%20%5Baccounts.userId%5D%2C%0A%20%20%20%20references%3A%20%5Busers.id%5D%2C%0A%20%20%20%20relationName%3A%20%22accountUser%22%2C%0A%20%20%7D%29%2C%0A%7D%29%29%3B%0A%0Aexport%20const%20userRelations%20%3D%20relations%28users%2C%20%28%7B%20many%20%7D%29%20%3D%3E%20%28%7B%0A%20%20accounts%3A%20many%28accounts%2C%20%7B%20relationName%3A%20%22accountUser%22%20%7D%29%2C%0A%7D%29%29%3B%0A%60%60%60%0A%0AHowever%2C%20%2A%2Athis%20alone%20won%27t%20fully%20resolve%20the%20issue%2A%2A%20since%20the%20adapter%20doesn%27t%20pass%20%60relationName%60%20in%20its%20%60with%60%20clause%20construction%20either%20%E2%80%94%20so%20the%20fix%20really%20needs%20to%20happen%20at%20the%20adapter%20level.%0A%0AFor%20context%2C%20there%27s%20an%20open%20%5BPR%20%236913%5D%28https%3A//github.com/better-auth/better-auth/pull/6913%29%20for%20Drizzle%20Relations%20v2%20using%20drizzle-orm%27s%20%60defineRelations%60%20API%20which%20may%20provide%20a%20cleaner%20path%20forward%20%5B%5B4%5D%5D%28https%3A//github.com/better-auth/better-auth/pull/6913%29.%20The%20related%20issues%20you%20linked%20%28%236525%2C%20%236547%29%20addressed%20the%20CLI-side%20TypeScript%20errors%20%5B%5B5%5D%5D%28https%3A//github.com/better-auth/better-auth/pull/6547%29%20but%20not%20the%20runtime%20%60relationName%60%20disambiguation%20needed%20by%20the%20adapter.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=join-discord)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/8849)
Author
Owner

@ping-maxwell commented on GitHub (Mar 31, 2026):

@thedevdavid can you share your schema?
Also are you using Drizzle relations v2?

<!-- gh-comment-id:4163160612 --> @ping-maxwell commented on GitHub (Mar 31, 2026): @thedevdavid can you share your schema? Also are you using Drizzle relations v2?
Author
Owner

@thedevdavid commented on GitHub (Apr 6, 2026):

Sorry, I missed this @ping-maxwell . I'm using relations v1.

Better auth config:

logger: {
      level: process.env.NODE_ENV === "production" ? "error" : "debug",
    },
    database: drizzleAdapter(db, {
      schema: drizzleSchema,
      usePlural: true,
      provider: "pg",
      debugLogs: process.env.NODE_ENV !== "production",
    }),
    appName: "App",
    secret: env.betterAuthSecret,
    user: {
      additionalFields: {
        tosAccepted: {
          type: "string",
          input: true,
          required: true,
          defaultValue: "0",
        },
        privacyPolicyAccepted: {
          type: "string",
          input: true,
          required: true,
          defaultValue: "0",
        },
        marketingConsentAccepted: {
          type: "string",
          input: true,
          required: true,
          defaultValue: "0",
        },
        onboardingCompleted: {
          type: "boolean",
          required: false,
          defaultValue: false,
          input: false,
        },
        signupReviewStatus: {
          type: "string",
          required: true,
          defaultValue: "pending",
          input: false,
        },
      },
      changeEmail: {
        enabled: false,
        sendChangeEmailConfirmation: async ({ user, newEmail, url }) => {
          const changeLink = url.startsWith("http")
            ? url
            : `${emailBaseUrl}${url.startsWith("/") ? "" : "/"}${url}`;
          try {
            await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, {
              type: "email_change",
              user: { id: user.id, email: user.email, name: user.name },
              newEmail,
              changeLink,
            });
          } catch (error) {
            Sentry.captureException(error, {
              tags: { auth_email_type: "email_change" },
            });
          }
        },
      },
      deleteUser: {
        enabled: true,
        sendDeleteAccountVerification: async ({ user, url }) => {
          const deletionLink = url.startsWith("http")
            ? url
            : `${emailBaseUrl}${url.startsWith("/") ? "" : "/"}${url}`;
          try {
            await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, {
              type: "account_deletion",
              user: { id: user.id, email: user.email, name: user.name },
              deletionLink,
            });
          } catch (error) {
            Sentry.captureException(error, {
              tags: { auth_email_type: "account_deletion" },
            });
          }
        },
        beforeDelete: async (user) => {
          try {
            const personalOrg = await getPersonalAccount(db, user.id);
            const orgIds = personalOrg ? [personalOrg.id] : [];

            for (const orgId of orgIds) {
              await markOrganizationForDeletion(db, orgId, {
                reason: "Owner account deleted",
              });
            }

            Sentry.captureMessage("User account deletion initiated", {
              level: "info",
              tags: { component: "auth", hook: "user.deleteUser.before" },
              extra: {
                userId: user.id,
                email: user.email,
                personalOrgIds: orgIds,
              },
            });
          } catch (error) {
            Sentry.captureException(error, {
              tags: { component: "auth", hook: "user.deleteUser.before" },
              extra: { userId: user.id },
            });
            // Re-throw to abort deletion
            throw error;
          }
        },
        afterDelete: async () => {
          Sentry.captureMessage("User account deletion completed", {
            level: "info",
            tags: { component: "auth", hook: "user.deleteUser.after" },
          });
        },
      },
    },
    session: {
      storeSessionInDatabase: true,
      expiresIn: 60 * 60 * 24 * 30, // 30 days
      updateAge: 60 * 60 * 24, // Update session daily (24 hours)
      preserveSessionInDatabase: true,
      cookieCache: {
        enabled: true,
        maxAge: 60 * 30, // 30 mins
        strategy: "jwe",
        version: COOKIE_CACHE_VERSION,
      },
      freshAge: 60 * 60 * 2, // 2h - sensitive operations
    },
    verification: {
      storeInDatabase: true,
      storeIdentifier: {
        default: "plain",
        overrides: {
          "email-verification": "hashed",
          "password-reset": "hashed",
        },
      },
    },
    secondaryStorage,
    advanced: {
      ipAddressHeaders: [
        "cf-connecting-ip",
        "x-vercel-forwarded-for",
        "x-forwarded-for",
      ],
      disableOriginCheck: false,
      cookiePrefix: "app",
      useSecureCookies: isHttps,
      crossSubDomainCookies: shouldUseDomainCookies
        ? { enabled: true, domain: cookieDomain as string }
        : { enabled: false },
      // SameSite=None + Secure is required when cross-subdomain cookies are enabled,
      // because the app (Next.js) and API (Cloudflare Worker) are on different subdomains.
      // Cross-subdomain requests require SameSite=None; Secure=true is mandatory with it.
      defaultCookieAttributes: {
        httpOnly: true,
        sameSite: shouldUseDomainCookies ? "none" : "lax",
        secure: isHttps,
        ...(shouldUseDomainCookies ? { domain: cookieDomain } : {}),
      },
      database: {
        generateId: false,
      },
    },
    experimental: {
      joins: true,
    },
    baseURL: {
      allowedHosts: [
        "localhost:*",
        "appname.com",
        "*.appname.com",
      ],
      fallback: env.appUrl || "https://appname.com",
      protocol: "auto",
    },
    emailVerification: {
      sendVerificationEmail: async ({ user, url }) => {
        const verificationLink = url.startsWith("http")
          ? url
          : `${emailBaseUrl}${url.startsWith("/") ? "" : "/"}${url}`;
        try {
          await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, {
            type: "verification",
            user: { id: user.id, email: user.email, name: user.name },
            verificationLink,
          });
        } catch (error) {
          Sentry.captureException(error, {
            tags: { auth_email_type: "verification" },
          });
        }
      },
      afterEmailVerification: async (user) => {
        sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, {
          type: "welcome",
          user: { id: user.id, email: user.email, name: user.name },
          dashboardUrl: `${emailBaseUrl}/dashboard`,
          catalogUrl: `${emailBaseUrl}/catalog`,
        }).catch((error) => {
          Sentry.captureException(error, {
            tags: { auth_email_type: "welcome" },
          });
        });

        try {
          const reviewBaseUrl = deriveAdminAppUrl(env.appUrl);
          const reviewUrl = `${reviewBaseUrl}/users/${user.id}`;

          const recipients = await getAdminEmailRecipients(db);
          await Promise.all(
            recipients
              .filter((recipient) => recipient.email !== user.email)
              .map((recipient) =>
                sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, {
                  type: "new_user_review",
                  recipient: {
                    email: recipient.email,
                    name: recipient.name,
                  },
                  newUser: {
                    id: user.id,
                    email: user.email,
                    name: user.name,
                    verifiedAt: new Date().toISOString(),
                  },
                  reviewUrl,
                }).catch((error) => {
                  Sentry.captureException(error, {
                    tags: {
                      auth_email_type: "new_user_review",
                      recipient_email: recipient.email,
                    },
                  });
                })
              )
          );
        } catch (error) {
          Sentry.captureException(error, {
            tags: { auth_email_type: "new_user_review" },
          });
        }
      },
      sendOnSignUp: true,
      sendOnSignIn: true,
      autoSignInAfterVerification: true,
      expiresIn: 600, // 10 minutes
    },
    databaseHooks: createDatabaseHooks(db),
    rateLimit: {
      storage: "secondary-storage",
      window: 60,
      max: 100,
      customRules: {
        "/auth/signin": {
          window: 60,
          max: 5,
        },
        "/auth/signup": {
          window: 300,
          max: 3,
        },
        "/auth/forget-password": {
          window: 300,
          max: 3,
        },
        "/auth/reset-password": {
          window: 60,
          max: 3,
        },
        "/auth/update-password": {
          window: 300,
          max: 5,
        },
        "/auth/verify-email": {
          window: 60,
          max: 3,
        },
        "/auth/magic-link": {
          window: 300,
          max: 3,
        },
        "/organization/invite": {
          window: 300,
          max: 10,
        },
        "/organization/accept-invitation": {
          window: 60,
          max: 5,
        },
      },
    },
    // Auth Settings
    emailAndPassword: {
      enabled: true,
      requireEmailVerification: true,
      minPasswordLength: 8,
      maxPasswordLength: 72,
      autoSignIn: true,
      sendResetPassword: async ({ user, url }) => {
        const resetLink = url.startsWith("http")
          ? url
          : `${emailBaseUrl}${url.startsWith("/") ? "" : "/"}${url}`;
        try {
          await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, {
            type: "password_reset",
            user: { id: user.id, email: user.email, name: user.name },
            resetLink,
          });
        } catch (error) {
          Sentry.captureException(error, {
            tags: { auth_email_type: "password_reset" },
          });
        }
      },
      onPasswordReset: async ({ user }) => {
        sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, {
          type: "password_reset_success",
          user: { id: user.id, email: user.email, name: user.name },
          resetDate: new Date().toISOString(),
          dashboardUrl: `${emailBaseUrl}/dashboard`,
        }).catch((error) => {
          Sentry.captureException(error, {
            tags: { auth_email_type: "password_reset_success" },
          });
        });
      },
      resetPasswordTokenExpiresIn: 600, // 10 minutes
    },
    plugins: [
      // Authentication
      phoneNumber({
        requireVerification: true,
        sendOTP: () => {
          throw new Error("Phone number verification is not yet available");
        },
        callbackOnVerification: async (_data, _request) => {
          // TODO: post-verification actions
        },
        signUpOnVerification: {
          getTempEmail: (phoneNumber) =>
            `${phoneNumber}@temp.appname.com`,
        },
      }),
      username({
        minUsernameLength: 3,
        maxUsernameLength: 64,
        usernameValidator: (username) => !isInvalidUsername(username),
        displayUsernameValidator: (username) => !isInvalidUsername(username),
        displayUsernameNormalization: (displayUsername) =>
          displayUsername.toLowerCase(),
      }),
      oneTimeToken({
        storeToken: "hashed",
        expiresIn: 10, // 10 minutes
      }),
      magicLink({
        sendMagicLink: async (data) => {
          try {
            await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, {
              type: "magic_link",
              email: data.email,
              magicLink: data.url,
            });
          } catch (error) {
            Sentry.captureException(error, {
              tags: { auth_email_type: "magic_link" },
            });
          }
        },
        storeToken: "hashed",
        expiresIn: 10 * 60, // 10 minutes
        allowedAttempts: 3,
        disableSignUp: true,
        rateLimit: {
          max: 10,
          window: 10 * 60,
        },
      }),
      emailOTP({
        storeOTP: "hashed",
        expiresIn: 10 * 60, // 10 minutes
        disableSignUp: true,
        sendVerificationOTP: async (data) => {
          try {
            await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, {
              type: "otp",
              email: data.email,
              otp: data.otp,
            });
          } catch (error) {
            Sentry.captureException(error, {
              tags: { auth_email_type: "otp" },
            });
          }
        },
      }),
      twoFactor({
        issuer: "App",
        twoFactorCookieMaxAge: 60 * 60 * 24 * 30, // 30 days trusted device
      }),
      passkey({
        rpID:
          process.env.PASSKEY_RP_ID ||
          (isHttps
            ?
              (env.rootDomain?.replace(/^\./, "") ?? "localhost")
            : "localhost"),
        rpName: "App",
        origin:
          process.env.PASSKEY_ORIGIN || (env.appUrl ?? "http://localhost"),
      }),
      // Extensions
      organization({
        ac: orgAc,
        roles: {
          [ORG_ROLES.OWNER]: orgOwnerRole,
          [ORG_ROLES.ADMIN]: orgAdminRole,
          [ORG_ROLES.MEMBER]: orgMemberRole,
          [ORG_ROLES.VIEWER]: orgViewerRole,
        },
        allowUserToCreateOrganization: false,
        organizationLimit: 16,
        membershipLimit: 64,
        invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days
        schema: {
          organization: {
            additionalFields: {
              type: {
                type: "string",
                input: true,
                required: true,
              },
              status: {
                type: "string",
                input: true,
                required: true,
              },
              company: {
                type: "string",
                input: true,
                required: false,
              },
              artist: {
                type: "string",
                input: true,
                required: false,
              },
              isPersonal: {
                type: "boolean",
                input: true,
                required: true,
                defaultValue: true,
              },
              downloadAllowed: {
                type: "boolean",
                input: true,
                required: true,
                defaultValue: false,
              },
            },
          },
          member: {
            additionalFields: {
              membershipStatusId: {
                type: "string",
                input: true,
                required: false,
              },
              settings: {
                type: "json",
                input: true,
                required: false,
                defaultValue: "{}",
              },
              createdBy: {
                type: "string",
                input: true,
                required: false,
                references: {
                  field: "id",
                  model: "users",
                  onDelete: "no action",
                },
              },
              updatedBy: {
                type: "string",
                input: true,
                required: false,
                references: {
                  field: "id",
                  model: "users",
                  onDelete: "no action",
                },
              },
            },
          },
        },
        sendInvitationEmail: async (data) => {
          const inviteLink = `${emailBaseUrl}/auth/invitation?id=${data.id}`;
          try {
            await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, {
              type: "org_invitation",
              email: data.email,
              organization: {
                id: data.organization.id,
                name: data.organization.name,
              },
              inviter: {
                name: data.inviter.user.name,
                email: data.inviter.user.email,
              },
              inviteLink,
              invitationId: data.id,
            });
          } catch (error) {
            Sentry.captureException(error, {
              tags: { auth_email_type: "org_invitation" },
            });
          }
        },
        organizationHooks: createOrganizationHooks(db),
      }),
      admin({
        defaultRole: USER_ROLES.USER,
        adminRoles: [USER_ROLES.ADMIN],
        impersonationSessionDuration: 60 * 60, // 1 hour
        ac: adminAc,
        roles: {
          [USER_ROLES.ADMIN]: adminRole,
          [USER_ROLES.USER]: userRole,
        },
      }),
      apiKey({
        storage: "secondary-storage",
        references: "user",
        defaultPrefix: "app.",
        requireName: true,
        minimumNameLength: 3,
        maximumNameLength: 64,
        permissions: {
          /**
           * Default permissions for new API keys.
           * Better Auth's defaultPermissions callback receives no user context,
           * so per-user permissions cannot be derived here. Users can adjust
           * permissions via API key management after creation.
           */
          defaultPermissions(_referenceId, _ctx) {
            return {
              files: ["read", "download"],
              catalog: ["view"],
              track: ["view"],
            };
          },
        },
      }),
      // Utilities
      captcha({
        provider: "cloudflare-turnstile",
        secretKey: env.cloudflareTurnstileSecretKey,
        endpoints: [
          "/auth/signin",
          "/auth/signup",
          "/auth/forget-password",
          "/auth/update-password",
          // "/auth/verify-email",
          "/organization/invite",
          // new?
          "/sign-up/email",
          "/sign-in/email",
          "/sign-in/username",
          "/sign-in/magic-link",
          "/is-username-available",
          "/request-password-reset",
          "/change-password",
          "/forget-password",
          "/api-key/create",
          "/api-key/update",
          "/organization/create",
          "/organization/check-slug",
          "/organization/invite-member",
          "/organization/add-member",
          "/organization/create-team",
          "/organization/update-team",
          "/organization/add-team-member",
        ],
      }),
      multiSession(),
      emailHarmony({
        allowNormalizedSignin: true,
        normalizer: (email: string) => {
          const [local, domain] = email.split("@");
          if (!(local && domain)) {
            return false;
          }
          const lowerDomain = domain.toLowerCase();
          let normalizedLocal = local.toLowerCase();

          const gmailDomains = new Set(["gmail.com", "googlemail.com"]);
          if (gmailDomains.has(lowerDomain)) {
            normalizedLocal = normalizedLocal.replace(/\./g, "");
            return `${normalizedLocal}@gmail.com`;
          }

          return `${normalizedLocal}@${lowerDomain}`;
        },
      }),
      phoneHarmony({
        defaultCountry: "US",
      }),
      haveIBeenPwned(),
      dash(dashConfig),
    ]

Auth schema:

export const users = pgTable(
  "users",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    name: text().notNull(),
    email: text().notNull(),
    emailVerified: boolean("email_verified").default(false).notNull(),
    image: text(),
    username: text(),
    displayUsername: text("display_username"),
    onboardingCompleted: boolean("onboarding_completed")
      .default(false)
      .notNull(),
    twoFactorEnabled: boolean("two_factor_enabled").default(false),
    phoneNumber: text("phone_number"),
    phoneNumberVerified: boolean("phone_number_verified"),
    role: text(),
    signupReviewStatus: text("signup_review_status")
      .default("pending")
      .notNull(),
    signupReviewedAt: timestamp("signup_reviewed_at", {
      withTimezone: true,
    }),
    signupReviewedBy: uuid("signup_reviewed_by"),
    banned: boolean().default(false),
    banReason: text("ban_reason"),
    banExpires: timestamp("ban_expires", {
      withTimezone: true,
    }),
    normalizedEmail: text("normalized_email"),
    tosAccepted: text("tos_accepted").default("0").notNull(),
    privacyPolicyAccepted: text("privacy_policy_accepted")
      .default("0")
      .notNull(),
    marketingConsentAccepted: text("marketing_consent_accepted")
      .default("0")
      .notNull(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (table) => [
    index("idx_users_banned")
      .using("btree", table.banned.asc().nullsLast().op("bool_ops"))
      .where(sql`(banned = true)`),
    index("idx_users_email").using(
      "btree",
      table.email.asc().nullsLast().op("text_ops")
    ),
    index("idx_users_email_lower").using("btree", sql`lower(email)`),
    index("idx_users_role").using(
      "btree",
      table.role.asc().nullsLast().op("text_ops")
    ),
    index("idx_users_signup_review_status").using(
      "btree",
      table.signupReviewStatus.asc().nullsLast().op("text_ops")
    ),
    foreignKey({
      columns: [table.signupReviewedBy],
      foreignColumns: [table.id],
      name: "users_signup_reviewed_by_fkey",
    }).onDelete("set null"),
    unique("users_email_key").on(table.email),
    unique("users_username_key").on(table.username),
    unique("users_phone_number_key").on(table.phoneNumber),
    unique("users_normalized_email_key").on(table.normalizedEmail),
  ]
);

export const organizations = pgTable(
  "organizations",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    name: text().notNull(),
    slug: text().notNull(),
    logo: text().default("#EA9D20").notNull(),
    owner: uuid(),
    type: text().notNull(),
    status: uuid().notNull(),
    company: uuid(),
    artist: uuid(),
    isPersonal: boolean("is_personal").default(true).notNull(),
    metadata: jsonb(),
    downloadAllowed: boolean("download_allowed").default(false).notNull(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (table) => [
    index("idx_organizations_artist")
      .using("btree", table.artist.asc().nullsLast().op("uuid_ops"))
      .where(sql`(artist IS NOT NULL)`),
    index("idx_organizations_company")
      .using("btree", table.company.asc().nullsLast().op("uuid_ops"))
      .where(sql`(company IS NOT NULL)`),
    index("idx_organizations_is_personal")
      .using("btree", table.isPersonal.asc().nullsLast().op("bool_ops"))
      .where(sql`(is_personal = true)`),
    index("idx_organizations_metadata_gin")
      .using("gin", table.metadata.asc().nullsLast().op("jsonb_ops"))
      .where(sql`(metadata IS NOT NULL)`),
    // Expression index idx_organizations_metadata_is_personal ON ((metadata->>'isPersonal'))
    // is SQL-migration-only. See: 20260205000200_high_indexes.sql
    index("idx_organizations_owner").using(
      "btree",
      table.owner.asc().nullsLast().op("uuid_ops")
    ),
    index("idx_organizations_owner_personal")
      .using(
        "btree",
        table.owner.asc().nullsLast().op("bool_ops"),
        table.isPersonal.asc().nullsLast().op("uuid_ops")
      )
      .where(sql`(is_personal = true)`),
    uniqueIndex("idx_organizations_owner_personal_unique")
      .using("btree", table.owner.asc().nullsLast().op("uuid_ops"))
      .where(sql`(is_personal = true AND owner IS NOT NULL)`),
    index("idx_organizations_owner_status").using(
      "btree",
      table.owner.asc().nullsLast().op("uuid_ops"),
      table.status.asc().nullsLast().op("uuid_ops")
    ),
    index("idx_organizations_slug").using(
      "btree",
      table.slug.asc().nullsLast().op("text_ops")
    ),
    index("idx_organizations_status").using(
      "btree",
      table.status.asc().nullsLast().op("uuid_ops")
    ),
    index("idx_organizations_type").using(
      "btree",
      table.type.asc().nullsLast().op("text_ops")
    ),
    check(
      "organizations_type_allowed_slug",
      sql`${table.type} IN ('client', 'another-type', 'admin')`
    ),
    foreignKey({
      columns: [table.artist],
      foreignColumns: [artists.id],
      name: "organizations_artist_artists_id_fk",
    }).onDelete("set null"),
    foreignKey({
      columns: [table.company],
      foreignColumns: [companies.id],
      name: "organizations_company_companies_id_fk",
    }).onDelete("set null"),
    foreignKey({
      columns: [table.owner],
      foreignColumns: [users.id],
      name: "organizations_owner_fkey",
    }).onDelete("set null"),
    foreignKey({
      columns: [table.status],
      foreignColumns: [organizationStatuses.id],
      name: "organizations_status_fkey",
    }),
    unique("organizations_slug_key").on(table.slug),
  ]
);

export const usersToDelete = pgTable(
  "users_to_delete",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    userId: uuid("user_id"),
    reason: text(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
    createdBy: uuid("created_by"),
  },
  (table) => [
    index("idx_users_to_delete_user_id").using(
      "btree",
      table.userId.asc().nullsLast().op("uuid_ops")
    ),
    foreignKey({
      columns: [table.userId],
      foreignColumns: [users.id],
      name: "users_to_delete_user_id_fkey",
    }).onDelete("cascade"),
    foreignKey({
      columns: [table.createdBy],
      foreignColumns: [users.id],
      name: "users_to_delete_created_by_fkey",
    }),
    unique("users_to_delete_user_id_key").on(table.userId),
  ]
);

export const organizationsToDelete = pgTable(
  "organizations_to_delete",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    organizationId: uuid("organization_id"),
    reason: text(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
    createdBy: uuid("created_by"),
  },
  (table) => [
    index("idx_organizations_to_delete_organization_id").using(
      "btree",
      table.organizationId.asc().nullsLast().op("uuid_ops")
    ),
    foreignKey({
      columns: [table.organizationId],
      foreignColumns: [organizations.id],
      name: "organizations_to_delete_organization_id_fkey",
    }).onDelete("cascade"),
    foreignKey({
      columns: [table.createdBy],
      foreignColumns: [users.id],
      name: "organizations_to_delete_created_by_fkey",
    }),
    unique("organizations_to_delete_organization_id_key").on(
      table.organizationId
    ),
  ]
);

export const organizationTypes = pgTable(
  "organization_types",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    name: text().notNull(),
    slug: text().notNull(),
    description: text(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (table) => [unique("organization_types_slug_key").on(table.slug)]
);

export const organizationStatuses = pgTable(
  "organization_statuses",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    name: text().notNull(),
    slug: text().notNull(),
    description: text(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (table) => [unique("organization_statuses_slug_key").on(table.slug)]
);

export const accounts = pgTable(
  "accounts",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    accountId: text("account_id").notNull(),
    providerId: text("provider_id").notNull(),
    userId: uuid("user_id").notNull(),
    accessToken: text("access_token"),
    refreshToken: text("refresh_token"),
    idToken: text("id_token"),
    accessTokenExpiresAt: timestamp("access_token_expires_at", {
      withTimezone: true,
      mode: "string",
    }),
    refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
      withTimezone: true,
      mode: "string",
    }),
    scope: text(),
    password: text(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (table) => [
    index("idx_accounts_account_id").using(
      "btree",
      table.accountId.asc().nullsLast().op("text_ops")
    ),
    index("idx_accounts_provider_account").using(
      "btree",
      table.providerId.asc().nullsLast().op("text_ops"),
      table.accountId.asc().nullsLast().op("text_ops")
    ),
    index("idx_accounts_provider_id").using(
      "btree",
      table.providerId.asc().nullsLast().op("text_ops")
    ),
    index("idx_accounts_provider_user").using(
      "btree",
      table.providerId.asc().nullsLast().op("text_ops"),
      table.userId.asc().nullsLast().op("text_ops")
    ),
    index("idx_accounts_user_id").using(
      "btree",
      table.userId.asc().nullsLast().op("uuid_ops")
    ),
    foreignKey({
      columns: [table.userId],
      foreignColumns: [users.id],
      name: "accounts_user_id_fkey",
    }).onDelete("cascade"),
  ]
);

export const apikeys = pgTable(
  "apikeys",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    configId: text("config_id").notNull().default("default"),
    name: text(),
    start: text(),
    prefix: text(),
    key: text().notNull(),
    referenceId: uuid("reference_id").notNull(),
    refillInterval: integer("refill_interval"),
    refillAmount: integer("refill_amount"),
    lastRefillAt: timestamp("last_refill_at", {
      withTimezone: true,
    }),
    enabled: boolean().default(true),
    rateLimitEnabled: boolean("rate_limit_enabled").default(true),
    rateLimitTimeWindow: integer("rate_limit_time_window").default(86_400_000),
    rateLimitMax: integer("rate_limit_max").default(10),
    requestCount: integer("request_count").default(0),
    remaining: integer(),
    lastRequest: timestamp("last_request", {
      withTimezone: true,
    }),
    expiresAt: timestamp("expires_at", { withTimezone: true }),
    permissions: text(),
    metadata: jsonb(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (table) => [
    index("idx_apikeys_enabled")
      .using("btree", table.enabled.asc().nullsLast().op("bool_ops"))
      .where(sql`(enabled = true)`),
    index("idx_apikeys_expires_at")
      .using("btree", table.expiresAt.asc().nullsLast().op("timestamptz_ops"))
      .where(sql`(expires_at IS NOT NULL)`),
    index("idx_apikeys_key").using(
      "btree",
      table.key.asc().nullsLast().op("text_ops")
    ),
    index("idx_apikeys_metadata_gin")
      .using("gin", table.metadata.asc().nullsLast().op("jsonb_ops"))
      .where(sql`(metadata IS NOT NULL)`),
    index("idx_apikeys_reference_id").using(
      "btree",
      table.referenceId.asc().nullsLast().op("uuid_ops")
    ),
    index("idx_apikeys_config_id").using(
      "btree",
      table.configId.asc().nullsLast().op("text_ops")
    ),
    foreignKey({
      columns: [table.referenceId],
      foreignColumns: [users.id],
      name: "apikeys_reference_id_fkey",
    }).onDelete("cascade"),
  ]
);

export const invitations = pgTable(
  "invitations",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    organizationId: uuid("organization_id").notNull(),
    email: text().notNull(),
    role: text().default("member").notNull(),
    status: text().default("pending").notNull(),
    expiresAt: timestamp("expires_at", {
      withTimezone: true,
    }).notNull(),
    inviterId: uuid("inviter_id").notNull(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (table) => [
    index("idx_invitations_email").using(
      "btree",
      table.email.asc().nullsLast().op("text_ops")
    ),
    index("idx_invitations_expires_at").using(
      "btree",
      table.expiresAt.asc().nullsLast().op("timestamptz_ops")
    ),
    index("idx_invitations_inviter_id").using(
      "btree",
      table.inviterId.asc().nullsLast().op("uuid_ops")
    ),
    index("idx_invitations_org_email").using(
      "btree",
      table.organizationId.asc().nullsLast().op("uuid_ops"),
      table.email.asc().nullsLast().op("text_ops")
    ),
    uniqueIndex("idx_invitations_org_email_pending_unique")
      .using(
        "btree",
        table.organizationId.asc().nullsLast().op("uuid_ops"),
        table.email.asc().nullsLast().op("text_ops")
      )
      .where(sql`(status = 'pending'::text)`),
    index("idx_invitations_organization_id").using(
      "btree",
      table.organizationId.asc().nullsLast().op("uuid_ops")
    ),
    index("idx_invitations_status").using(
      "btree",
      table.status.asc().nullsLast().op("text_ops")
    ),
    index("idx_invitations_status_expires")
      .using(
        "btree",
        table.status.asc().nullsLast().op("text_ops"),
        table.expiresAt.asc().nullsLast().op("timestamptz_ops")
      )
      .where(sql`(status = 'pending'::text)`),
    index("idx_invitations_email_status_expires")
      .using(
        "btree",
        table.email.asc().nullsLast().op("text_ops"),
        table.status.asc().nullsLast().op("text_ops"),
        table.expiresAt.asc().nullsLast().op("timestamptz_ops")
      )
      .where(sql`(status = 'pending'::text)`),
    foreignKey({
      columns: [table.inviterId],
      foreignColumns: [users.id],
      name: "invitations_inviter_id_fkey",
    }).onDelete("cascade"),
    foreignKey({
      columns: [table.organizationId],
      foreignColumns: [organizations.id],
      name: "invitations_organization_id_fkey",
    }).onDelete("cascade"),
  ]
);

export const members = pgTable(
  "members",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    organizationId: uuid("organization_id").notNull(),
    userId: uuid("user_id").notNull(),
    role: text().default("member").notNull(),
    membershipStatusId: uuid("membership_status_id"),
    settings: jsonb().default({}),
    createdBy: uuid("created_by"),
    updatedBy: uuid("updated_by"),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true }),
  },
  (table) => [
    index("idx_members_created_by")
      .using("btree", table.createdBy.asc().nullsLast().op("uuid_ops"))
      .where(sql`(created_by IS NOT NULL)`),
    index("idx_members_membership_status_id").using(
      "btree",
      table.membershipStatusId.asc().nullsLast().op("uuid_ops")
    ),
    index("idx_members_organization_user").using(
      "btree",
      table.organizationId.asc().nullsLast().op("uuid_ops"),
      table.userId.asc().nullsLast().op("uuid_ops")
    ),
    index("idx_members_role").using(
      "btree",
      table.role.asc().nullsLast().op("text_ops")
    ),
    index("idx_members_settings_gin")
      .using("gin", table.settings.asc().nullsLast().op("jsonb_ops"))
      .where(sql`(settings IS NOT NULL)`),
    index("idx_members_updated_by")
      .using("btree", table.updatedBy.asc().nullsLast().op("uuid_ops"))
      .where(sql`(updated_by IS NOT NULL)`),
    index("idx_members_user_id").using(
      "btree",
      table.userId.asc().nullsLast().op("uuid_ops")
    ),
    index("idx_members_org_membership_status").using(
      "btree",
      table.organizationId.asc().nullsLast().op("uuid_ops"),
      table.membershipStatusId.asc().nullsLast().op("uuid_ops")
    ),
    foreignKey({
      columns: [table.createdBy],
      foreignColumns: [users.id],
      name: "members_created_by_fkey",
    }),
    foreignKey({
      columns: [table.membershipStatusId],
      foreignColumns: [organizationStatuses.id],
      name: "members_membership_status_id_fkey",
    }).onDelete("set null"),
    foreignKey({
      columns: [table.organizationId],
      foreignColumns: [organizations.id],
      name: "members_organization_id_fkey",
    }).onDelete("cascade"),
    foreignKey({
      columns: [table.updatedBy],
      foreignColumns: [users.id],
      name: "members_updated_by_fkey",
    }),
    foreignKey({
      columns: [table.userId],
      foreignColumns: [users.id],
      name: "members_user_id_fkey",
    }).onDelete("cascade"),
    unique("members_organization_user_unique").on(
      table.organizationId,
      table.userId
    ),
  ]
);

export const passkeys = pgTable(
  "passkeys",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    name: text(),
    publicKey: text("public_key").notNull(),
    userId: uuid("user_id").notNull(),
    credentialId: text("credential_id").notNull(),
    counter: integer().notNull(),
    deviceType: text("device_type").notNull(),
    backedUp: boolean("backed_up").notNull(),
    transports: text(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
    aaguid: text(),
  },
  (table) => [
    index("idx_passkeys_user_id").using(
      "btree",
      table.userId.asc().nullsLast().op("uuid_ops")
    ),
    foreignKey({
      columns: [table.userId],
      foreignColumns: [users.id],
      name: "passkeys_user_id_fkey",
    }).onDelete("cascade"),
    unique("passkeys_credential_id_key").on(table.credentialId),
  ]
);

export const sessions = pgTable(
  "sessions",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    expiresAt: timestamp("expires_at", {
      withTimezone: true,
    }).notNull(),
    token: text().notNull(),
    ipAddress: text("ip_address"),
    userAgent: text("user_agent"),
    userId: uuid("user_id").notNull(),
    activeOrganizationId: uuid("active_organization_id"),
    impersonatedBy: uuid("impersonated_by"),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (table) => [
    index("idx_sessions_active_organization_id")
      .using(
        "btree",
        table.activeOrganizationId.asc().nullsLast().op("uuid_ops")
      )
      .where(sql`(active_organization_id IS NOT NULL)`),
    index("idx_sessions_expires_at").using(
      "btree",
      table.expiresAt.asc().nullsLast().op("timestamptz_ops")
    ),
    index("idx_sessions_impersonated_by")
      .using("btree", table.impersonatedBy.asc().nullsLast().op("uuid_ops"))
      .where(sql`(impersonated_by IS NOT NULL)`),
    index("idx_sessions_token").using(
      "btree",
      table.token.asc().nullsLast().op("text_ops")
    ),
    index("idx_sessions_user_id").using(
      "btree",
      table.userId.asc().nullsLast().op("uuid_ops")
    ),
    foreignKey({
      columns: [table.activeOrganizationId],
      foreignColumns: [organizations.id],
      name: "sessions_active_organization_id_fkey",
    }).onDelete("set null"),
    foreignKey({
      columns: [table.impersonatedBy],
      foreignColumns: [users.id],
      name: "sessions_impersonated_by_fkey",
    }).onDelete("set null"),
    foreignKey({
      columns: [table.userId],
      foreignColumns: [users.id],
      name: "sessions_user_id_fkey",
    }).onDelete("cascade"),
    unique("sessions_token_key").on(table.token),
  ]
);

export const twoFactors = pgTable(
  "two_factors",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    secret: text().notNull(),
    backupCodes: text("backup_codes").notNull(),
    userId: uuid("user_id").notNull(),
  },
  (table) => [
    index("idx_two_factors_secret").using(
      "btree",
      table.secret.asc().nullsLast().op("text_ops")
    ),
    index("idx_two_factors_user_id").using(
      "btree",
      table.userId.asc().nullsLast().op("uuid_ops")
    ),
    foreignKey({
      columns: [table.userId],
      foreignColumns: [users.id],
      name: "two_factors_user_id_fkey",
    }).onDelete("cascade"),
  ]
);

export const verifications = pgTable(
  "verifications",
  {
    id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(),
    identifier: text().notNull(),
    value: text().notNull(),
    expiresAt: timestamp("expires_at", {
      withTimezone: true,
    }).notNull(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (table) => [
    index("idx_verifications_expires_at").using(
      "btree",
      table.expiresAt.asc().nullsLast().op("timestamptz_ops")
    ),
    index("idx_verifications_identifier").using(
      "btree",
      table.identifier.asc().nullsLast().op("text_ops")
    ),
    index("idx_verifications_identifier_value").using(
      "btree",
      table.identifier.asc().nullsLast().op("text_ops"),
      table.value.asc().nullsLast().op("text_ops")
    ),
  ]
);
<!-- gh-comment-id:4193162016 --> @thedevdavid commented on GitHub (Apr 6, 2026): Sorry, I missed this @ping-maxwell . I'm using relations v1. Better auth config: ```typescript logger: { level: process.env.NODE_ENV === "production" ? "error" : "debug", }, database: drizzleAdapter(db, { schema: drizzleSchema, usePlural: true, provider: "pg", debugLogs: process.env.NODE_ENV !== "production", }), appName: "App", secret: env.betterAuthSecret, user: { additionalFields: { tosAccepted: { type: "string", input: true, required: true, defaultValue: "0", }, privacyPolicyAccepted: { type: "string", input: true, required: true, defaultValue: "0", }, marketingConsentAccepted: { type: "string", input: true, required: true, defaultValue: "0", }, onboardingCompleted: { type: "boolean", required: false, defaultValue: false, input: false, }, signupReviewStatus: { type: "string", required: true, defaultValue: "pending", input: false, }, }, changeEmail: { enabled: false, sendChangeEmailConfirmation: async ({ user, newEmail, url }) => { const changeLink = url.startsWith("http") ? url : `${emailBaseUrl}${url.startsWith("/") ? "" : "/"}${url}`; try { await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, { type: "email_change", user: { id: user.id, email: user.email, name: user.name }, newEmail, changeLink, }); } catch (error) { Sentry.captureException(error, { tags: { auth_email_type: "email_change" }, }); } }, }, deleteUser: { enabled: true, sendDeleteAccountVerification: async ({ user, url }) => { const deletionLink = url.startsWith("http") ? url : `${emailBaseUrl}${url.startsWith("/") ? "" : "/"}${url}`; try { await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, { type: "account_deletion", user: { id: user.id, email: user.email, name: user.name }, deletionLink, }); } catch (error) { Sentry.captureException(error, { tags: { auth_email_type: "account_deletion" }, }); } }, beforeDelete: async (user) => { try { const personalOrg = await getPersonalAccount(db, user.id); const orgIds = personalOrg ? [personalOrg.id] : []; for (const orgId of orgIds) { await markOrganizationForDeletion(db, orgId, { reason: "Owner account deleted", }); } Sentry.captureMessage("User account deletion initiated", { level: "info", tags: { component: "auth", hook: "user.deleteUser.before" }, extra: { userId: user.id, email: user.email, personalOrgIds: orgIds, }, }); } catch (error) { Sentry.captureException(error, { tags: { component: "auth", hook: "user.deleteUser.before" }, extra: { userId: user.id }, }); // Re-throw to abort deletion throw error; } }, afterDelete: async () => { Sentry.captureMessage("User account deletion completed", { level: "info", tags: { component: "auth", hook: "user.deleteUser.after" }, }); }, }, }, session: { storeSessionInDatabase: true, expiresIn: 60 * 60 * 24 * 30, // 30 days updateAge: 60 * 60 * 24, // Update session daily (24 hours) preserveSessionInDatabase: true, cookieCache: { enabled: true, maxAge: 60 * 30, // 30 mins strategy: "jwe", version: COOKIE_CACHE_VERSION, }, freshAge: 60 * 60 * 2, // 2h - sensitive operations }, verification: { storeInDatabase: true, storeIdentifier: { default: "plain", overrides: { "email-verification": "hashed", "password-reset": "hashed", }, }, }, secondaryStorage, advanced: { ipAddressHeaders: [ "cf-connecting-ip", "x-vercel-forwarded-for", "x-forwarded-for", ], disableOriginCheck: false, cookiePrefix: "app", useSecureCookies: isHttps, crossSubDomainCookies: shouldUseDomainCookies ? { enabled: true, domain: cookieDomain as string } : { enabled: false }, // SameSite=None + Secure is required when cross-subdomain cookies are enabled, // because the app (Next.js) and API (Cloudflare Worker) are on different subdomains. // Cross-subdomain requests require SameSite=None; Secure=true is mandatory with it. defaultCookieAttributes: { httpOnly: true, sameSite: shouldUseDomainCookies ? "none" : "lax", secure: isHttps, ...(shouldUseDomainCookies ? { domain: cookieDomain } : {}), }, database: { generateId: false, }, }, experimental: { joins: true, }, baseURL: { allowedHosts: [ "localhost:*", "appname.com", "*.appname.com", ], fallback: env.appUrl || "https://appname.com", protocol: "auto", }, emailVerification: { sendVerificationEmail: async ({ user, url }) => { const verificationLink = url.startsWith("http") ? url : `${emailBaseUrl}${url.startsWith("/") ? "" : "/"}${url}`; try { await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, { type: "verification", user: { id: user.id, email: user.email, name: user.name }, verificationLink, }); } catch (error) { Sentry.captureException(error, { tags: { auth_email_type: "verification" }, }); } }, afterEmailVerification: async (user) => { sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, { type: "welcome", user: { id: user.id, email: user.email, name: user.name }, dashboardUrl: `${emailBaseUrl}/dashboard`, catalogUrl: `${emailBaseUrl}/catalog`, }).catch((error) => { Sentry.captureException(error, { tags: { auth_email_type: "welcome" }, }); }); try { const reviewBaseUrl = deriveAdminAppUrl(env.appUrl); const reviewUrl = `${reviewBaseUrl}/users/${user.id}`; const recipients = await getAdminEmailRecipients(db); await Promise.all( recipients .filter((recipient) => recipient.email !== user.email) .map((recipient) => sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, { type: "new_user_review", recipient: { email: recipient.email, name: recipient.name, }, newUser: { id: user.id, email: user.email, name: user.name, verifiedAt: new Date().toISOString(), }, reviewUrl, }).catch((error) => { Sentry.captureException(error, { tags: { auth_email_type: "new_user_review", recipient_email: recipient.email, }, }); }) ) ); } catch (error) { Sentry.captureException(error, { tags: { auth_email_type: "new_user_review" }, }); } }, sendOnSignUp: true, sendOnSignIn: true, autoSignInAfterVerification: true, expiresIn: 600, // 10 minutes }, databaseHooks: createDatabaseHooks(db), rateLimit: { storage: "secondary-storage", window: 60, max: 100, customRules: { "/auth/signin": { window: 60, max: 5, }, "/auth/signup": { window: 300, max: 3, }, "/auth/forget-password": { window: 300, max: 3, }, "/auth/reset-password": { window: 60, max: 3, }, "/auth/update-password": { window: 300, max: 5, }, "/auth/verify-email": { window: 60, max: 3, }, "/auth/magic-link": { window: 300, max: 3, }, "/organization/invite": { window: 300, max: 10, }, "/organization/accept-invitation": { window: 60, max: 5, }, }, }, // Auth Settings emailAndPassword: { enabled: true, requireEmailVerification: true, minPasswordLength: 8, maxPasswordLength: 72, autoSignIn: true, sendResetPassword: async ({ user, url }) => { const resetLink = url.startsWith("http") ? url : `${emailBaseUrl}${url.startsWith("/") ? "" : "/"}${url}`; try { await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, { type: "password_reset", user: { id: user.id, email: user.email, name: user.name }, resetLink, }); } catch (error) { Sentry.captureException(error, { tags: { auth_email_type: "password_reset" }, }); } }, onPasswordReset: async ({ user }) => { sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, { type: "password_reset_success", user: { id: user.id, email: user.email, name: user.name }, resetDate: new Date().toISOString(), dashboardUrl: `${emailBaseUrl}/dashboard`, }).catch((error) => { Sentry.captureException(error, { tags: { auth_email_type: "password_reset_success" }, }); }); }, resetPasswordTokenExpiresIn: 600, // 10 minutes }, plugins: [ // Authentication phoneNumber({ requireVerification: true, sendOTP: () => { throw new Error("Phone number verification is not yet available"); }, callbackOnVerification: async (_data, _request) => { // TODO: post-verification actions }, signUpOnVerification: { getTempEmail: (phoneNumber) => `${phoneNumber}@temp.appname.com`, }, }), username({ minUsernameLength: 3, maxUsernameLength: 64, usernameValidator: (username) => !isInvalidUsername(username), displayUsernameValidator: (username) => !isInvalidUsername(username), displayUsernameNormalization: (displayUsername) => displayUsername.toLowerCase(), }), oneTimeToken({ storeToken: "hashed", expiresIn: 10, // 10 minutes }), magicLink({ sendMagicLink: async (data) => { try { await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, { type: "magic_link", email: data.email, magicLink: data.url, }); } catch (error) { Sentry.captureException(error, { tags: { auth_email_type: "magic_link" }, }); } }, storeToken: "hashed", expiresIn: 10 * 60, // 10 minutes allowedAttempts: 3, disableSignUp: true, rateLimit: { max: 10, window: 10 * 60, }, }), emailOTP({ storeOTP: "hashed", expiresIn: 10 * 60, // 10 minutes disableSignUp: true, sendVerificationOTP: async (data) => { try { await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, { type: "otp", email: data.email, otp: data.otp, }); } catch (error) { Sentry.captureException(error, { tags: { auth_email_type: "otp" }, }); } }, }), twoFactor({ issuer: "App", twoFactorCookieMaxAge: 60 * 60 * 24 * 30, // 30 days trusted device }), passkey({ rpID: process.env.PASSKEY_RP_ID || (isHttps ? (env.rootDomain?.replace(/^\./, "") ?? "localhost") : "localhost"), rpName: "App", origin: process.env.PASSKEY_ORIGIN || (env.appUrl ?? "http://localhost"), }), // Extensions organization({ ac: orgAc, roles: { [ORG_ROLES.OWNER]: orgOwnerRole, [ORG_ROLES.ADMIN]: orgAdminRole, [ORG_ROLES.MEMBER]: orgMemberRole, [ORG_ROLES.VIEWER]: orgViewerRole, }, allowUserToCreateOrganization: false, organizationLimit: 16, membershipLimit: 64, invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days schema: { organization: { additionalFields: { type: { type: "string", input: true, required: true, }, status: { type: "string", input: true, required: true, }, company: { type: "string", input: true, required: false, }, artist: { type: "string", input: true, required: false, }, isPersonal: { type: "boolean", input: true, required: true, defaultValue: true, }, downloadAllowed: { type: "boolean", input: true, required: true, defaultValue: false, }, }, }, member: { additionalFields: { membershipStatusId: { type: "string", input: true, required: false, }, settings: { type: "json", input: true, required: false, defaultValue: "{}", }, createdBy: { type: "string", input: true, required: false, references: { field: "id", model: "users", onDelete: "no action", }, }, updatedBy: { type: "string", input: true, required: false, references: { field: "id", model: "users", onDelete: "no action", }, }, }, }, }, sendInvitationEmail: async (data) => { const inviteLink = `${emailBaseUrl}/auth/invitation?id=${data.id}`; try { await sendOrEnqueueEmail(env.emailQueue, resend, fromEmail, { type: "org_invitation", email: data.email, organization: { id: data.organization.id, name: data.organization.name, }, inviter: { name: data.inviter.user.name, email: data.inviter.user.email, }, inviteLink, invitationId: data.id, }); } catch (error) { Sentry.captureException(error, { tags: { auth_email_type: "org_invitation" }, }); } }, organizationHooks: createOrganizationHooks(db), }), admin({ defaultRole: USER_ROLES.USER, adminRoles: [USER_ROLES.ADMIN], impersonationSessionDuration: 60 * 60, // 1 hour ac: adminAc, roles: { [USER_ROLES.ADMIN]: adminRole, [USER_ROLES.USER]: userRole, }, }), apiKey({ storage: "secondary-storage", references: "user", defaultPrefix: "app.", requireName: true, minimumNameLength: 3, maximumNameLength: 64, permissions: { /** * Default permissions for new API keys. * Better Auth's defaultPermissions callback receives no user context, * so per-user permissions cannot be derived here. Users can adjust * permissions via API key management after creation. */ defaultPermissions(_referenceId, _ctx) { return { files: ["read", "download"], catalog: ["view"], track: ["view"], }; }, }, }), // Utilities captcha({ provider: "cloudflare-turnstile", secretKey: env.cloudflareTurnstileSecretKey, endpoints: [ "/auth/signin", "/auth/signup", "/auth/forget-password", "/auth/update-password", // "/auth/verify-email", "/organization/invite", // new? "/sign-up/email", "/sign-in/email", "/sign-in/username", "/sign-in/magic-link", "/is-username-available", "/request-password-reset", "/change-password", "/forget-password", "/api-key/create", "/api-key/update", "/organization/create", "/organization/check-slug", "/organization/invite-member", "/organization/add-member", "/organization/create-team", "/organization/update-team", "/organization/add-team-member", ], }), multiSession(), emailHarmony({ allowNormalizedSignin: true, normalizer: (email: string) => { const [local, domain] = email.split("@"); if (!(local && domain)) { return false; } const lowerDomain = domain.toLowerCase(); let normalizedLocal = local.toLowerCase(); const gmailDomains = new Set(["gmail.com", "googlemail.com"]); if (gmailDomains.has(lowerDomain)) { normalizedLocal = normalizedLocal.replace(/\./g, ""); return `${normalizedLocal}@gmail.com`; } return `${normalizedLocal}@${lowerDomain}`; }, }), phoneHarmony({ defaultCountry: "US", }), haveIBeenPwned(), dash(dashConfig), ] ``` Auth schema: ```typescript export const users = pgTable( "users", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), name: text().notNull(), email: text().notNull(), emailVerified: boolean("email_verified").default(false).notNull(), image: text(), username: text(), displayUsername: text("display_username"), onboardingCompleted: boolean("onboarding_completed") .default(false) .notNull(), twoFactorEnabled: boolean("two_factor_enabled").default(false), phoneNumber: text("phone_number"), phoneNumberVerified: boolean("phone_number_verified"), role: text(), signupReviewStatus: text("signup_review_status") .default("pending") .notNull(), signupReviewedAt: timestamp("signup_reviewed_at", { withTimezone: true, }), signupReviewedBy: uuid("signup_reviewed_by"), banned: boolean().default(false), banReason: text("ban_reason"), banExpires: timestamp("ban_expires", { withTimezone: true, }), normalizedEmail: text("normalized_email"), tosAccepted: text("tos_accepted").default("0").notNull(), privacyPolicyAccepted: text("privacy_policy_accepted") .default("0") .notNull(), marketingConsentAccepted: text("marketing_consent_accepted") .default("0") .notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ index("idx_users_banned") .using("btree", table.banned.asc().nullsLast().op("bool_ops")) .where(sql`(banned = true)`), index("idx_users_email").using( "btree", table.email.asc().nullsLast().op("text_ops") ), index("idx_users_email_lower").using("btree", sql`lower(email)`), index("idx_users_role").using( "btree", table.role.asc().nullsLast().op("text_ops") ), index("idx_users_signup_review_status").using( "btree", table.signupReviewStatus.asc().nullsLast().op("text_ops") ), foreignKey({ columns: [table.signupReviewedBy], foreignColumns: [table.id], name: "users_signup_reviewed_by_fkey", }).onDelete("set null"), unique("users_email_key").on(table.email), unique("users_username_key").on(table.username), unique("users_phone_number_key").on(table.phoneNumber), unique("users_normalized_email_key").on(table.normalizedEmail), ] ); export const organizations = pgTable( "organizations", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), name: text().notNull(), slug: text().notNull(), logo: text().default("#EA9D20").notNull(), owner: uuid(), type: text().notNull(), status: uuid().notNull(), company: uuid(), artist: uuid(), isPersonal: boolean("is_personal").default(true).notNull(), metadata: jsonb(), downloadAllowed: boolean("download_allowed").default(false).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ index("idx_organizations_artist") .using("btree", table.artist.asc().nullsLast().op("uuid_ops")) .where(sql`(artist IS NOT NULL)`), index("idx_organizations_company") .using("btree", table.company.asc().nullsLast().op("uuid_ops")) .where(sql`(company IS NOT NULL)`), index("idx_organizations_is_personal") .using("btree", table.isPersonal.asc().nullsLast().op("bool_ops")) .where(sql`(is_personal = true)`), index("idx_organizations_metadata_gin") .using("gin", table.metadata.asc().nullsLast().op("jsonb_ops")) .where(sql`(metadata IS NOT NULL)`), // Expression index idx_organizations_metadata_is_personal ON ((metadata->>'isPersonal')) // is SQL-migration-only. See: 20260205000200_high_indexes.sql index("idx_organizations_owner").using( "btree", table.owner.asc().nullsLast().op("uuid_ops") ), index("idx_organizations_owner_personal") .using( "btree", table.owner.asc().nullsLast().op("bool_ops"), table.isPersonal.asc().nullsLast().op("uuid_ops") ) .where(sql`(is_personal = true)`), uniqueIndex("idx_organizations_owner_personal_unique") .using("btree", table.owner.asc().nullsLast().op("uuid_ops")) .where(sql`(is_personal = true AND owner IS NOT NULL)`), index("idx_organizations_owner_status").using( "btree", table.owner.asc().nullsLast().op("uuid_ops"), table.status.asc().nullsLast().op("uuid_ops") ), index("idx_organizations_slug").using( "btree", table.slug.asc().nullsLast().op("text_ops") ), index("idx_organizations_status").using( "btree", table.status.asc().nullsLast().op("uuid_ops") ), index("idx_organizations_type").using( "btree", table.type.asc().nullsLast().op("text_ops") ), check( "organizations_type_allowed_slug", sql`${table.type} IN ('client', 'another-type', 'admin')` ), foreignKey({ columns: [table.artist], foreignColumns: [artists.id], name: "organizations_artist_artists_id_fk", }).onDelete("set null"), foreignKey({ columns: [table.company], foreignColumns: [companies.id], name: "organizations_company_companies_id_fk", }).onDelete("set null"), foreignKey({ columns: [table.owner], foreignColumns: [users.id], name: "organizations_owner_fkey", }).onDelete("set null"), foreignKey({ columns: [table.status], foreignColumns: [organizationStatuses.id], name: "organizations_status_fkey", }), unique("organizations_slug_key").on(table.slug), ] ); export const usersToDelete = pgTable( "users_to_delete", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), userId: uuid("user_id"), reason: text(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), createdBy: uuid("created_by"), }, (table) => [ index("idx_users_to_delete_user_id").using( "btree", table.userId.asc().nullsLast().op("uuid_ops") ), foreignKey({ columns: [table.userId], foreignColumns: [users.id], name: "users_to_delete_user_id_fkey", }).onDelete("cascade"), foreignKey({ columns: [table.createdBy], foreignColumns: [users.id], name: "users_to_delete_created_by_fkey", }), unique("users_to_delete_user_id_key").on(table.userId), ] ); export const organizationsToDelete = pgTable( "organizations_to_delete", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), organizationId: uuid("organization_id"), reason: text(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), createdBy: uuid("created_by"), }, (table) => [ index("idx_organizations_to_delete_organization_id").using( "btree", table.organizationId.asc().nullsLast().op("uuid_ops") ), foreignKey({ columns: [table.organizationId], foreignColumns: [organizations.id], name: "organizations_to_delete_organization_id_fkey", }).onDelete("cascade"), foreignKey({ columns: [table.createdBy], foreignColumns: [users.id], name: "organizations_to_delete_created_by_fkey", }), unique("organizations_to_delete_organization_id_key").on( table.organizationId ), ] ); export const organizationTypes = pgTable( "organization_types", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), name: text().notNull(), slug: text().notNull(), description: text(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [unique("organization_types_slug_key").on(table.slug)] ); export const organizationStatuses = pgTable( "organization_statuses", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), name: text().notNull(), slug: text().notNull(), description: text(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [unique("organization_statuses_slug_key").on(table.slug)] ); export const accounts = pgTable( "accounts", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: uuid("user_id").notNull(), accessToken: text("access_token"), refreshToken: text("refresh_token"), idToken: text("id_token"), accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true, mode: "string", }), refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true, mode: "string", }), scope: text(), password: text(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ index("idx_accounts_account_id").using( "btree", table.accountId.asc().nullsLast().op("text_ops") ), index("idx_accounts_provider_account").using( "btree", table.providerId.asc().nullsLast().op("text_ops"), table.accountId.asc().nullsLast().op("text_ops") ), index("idx_accounts_provider_id").using( "btree", table.providerId.asc().nullsLast().op("text_ops") ), index("idx_accounts_provider_user").using( "btree", table.providerId.asc().nullsLast().op("text_ops"), table.userId.asc().nullsLast().op("text_ops") ), index("idx_accounts_user_id").using( "btree", table.userId.asc().nullsLast().op("uuid_ops") ), foreignKey({ columns: [table.userId], foreignColumns: [users.id], name: "accounts_user_id_fkey", }).onDelete("cascade"), ] ); export const apikeys = pgTable( "apikeys", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), configId: text("config_id").notNull().default("default"), name: text(), start: text(), prefix: text(), key: text().notNull(), referenceId: uuid("reference_id").notNull(), refillInterval: integer("refill_interval"), refillAmount: integer("refill_amount"), lastRefillAt: timestamp("last_refill_at", { withTimezone: true, }), enabled: boolean().default(true), rateLimitEnabled: boolean("rate_limit_enabled").default(true), rateLimitTimeWindow: integer("rate_limit_time_window").default(86_400_000), rateLimitMax: integer("rate_limit_max").default(10), requestCount: integer("request_count").default(0), remaining: integer(), lastRequest: timestamp("last_request", { withTimezone: true, }), expiresAt: timestamp("expires_at", { withTimezone: true }), permissions: text(), metadata: jsonb(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ index("idx_apikeys_enabled") .using("btree", table.enabled.asc().nullsLast().op("bool_ops")) .where(sql`(enabled = true)`), index("idx_apikeys_expires_at") .using("btree", table.expiresAt.asc().nullsLast().op("timestamptz_ops")) .where(sql`(expires_at IS NOT NULL)`), index("idx_apikeys_key").using( "btree", table.key.asc().nullsLast().op("text_ops") ), index("idx_apikeys_metadata_gin") .using("gin", table.metadata.asc().nullsLast().op("jsonb_ops")) .where(sql`(metadata IS NOT NULL)`), index("idx_apikeys_reference_id").using( "btree", table.referenceId.asc().nullsLast().op("uuid_ops") ), index("idx_apikeys_config_id").using( "btree", table.configId.asc().nullsLast().op("text_ops") ), foreignKey({ columns: [table.referenceId], foreignColumns: [users.id], name: "apikeys_reference_id_fkey", }).onDelete("cascade"), ] ); export const invitations = pgTable( "invitations", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), organizationId: uuid("organization_id").notNull(), email: text().notNull(), role: text().default("member").notNull(), status: text().default("pending").notNull(), expiresAt: timestamp("expires_at", { withTimezone: true, }).notNull(), inviterId: uuid("inviter_id").notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ index("idx_invitations_email").using( "btree", table.email.asc().nullsLast().op("text_ops") ), index("idx_invitations_expires_at").using( "btree", table.expiresAt.asc().nullsLast().op("timestamptz_ops") ), index("idx_invitations_inviter_id").using( "btree", table.inviterId.asc().nullsLast().op("uuid_ops") ), index("idx_invitations_org_email").using( "btree", table.organizationId.asc().nullsLast().op("uuid_ops"), table.email.asc().nullsLast().op("text_ops") ), uniqueIndex("idx_invitations_org_email_pending_unique") .using( "btree", table.organizationId.asc().nullsLast().op("uuid_ops"), table.email.asc().nullsLast().op("text_ops") ) .where(sql`(status = 'pending'::text)`), index("idx_invitations_organization_id").using( "btree", table.organizationId.asc().nullsLast().op("uuid_ops") ), index("idx_invitations_status").using( "btree", table.status.asc().nullsLast().op("text_ops") ), index("idx_invitations_status_expires") .using( "btree", table.status.asc().nullsLast().op("text_ops"), table.expiresAt.asc().nullsLast().op("timestamptz_ops") ) .where(sql`(status = 'pending'::text)`), index("idx_invitations_email_status_expires") .using( "btree", table.email.asc().nullsLast().op("text_ops"), table.status.asc().nullsLast().op("text_ops"), table.expiresAt.asc().nullsLast().op("timestamptz_ops") ) .where(sql`(status = 'pending'::text)`), foreignKey({ columns: [table.inviterId], foreignColumns: [users.id], name: "invitations_inviter_id_fkey", }).onDelete("cascade"), foreignKey({ columns: [table.organizationId], foreignColumns: [organizations.id], name: "invitations_organization_id_fkey", }).onDelete("cascade"), ] ); export const members = pgTable( "members", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), organizationId: uuid("organization_id").notNull(), userId: uuid("user_id").notNull(), role: text().default("member").notNull(), membershipStatusId: uuid("membership_status_id"), settings: jsonb().default({}), createdBy: uuid("created_by"), updatedBy: uuid("updated_by"), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }), }, (table) => [ index("idx_members_created_by") .using("btree", table.createdBy.asc().nullsLast().op("uuid_ops")) .where(sql`(created_by IS NOT NULL)`), index("idx_members_membership_status_id").using( "btree", table.membershipStatusId.asc().nullsLast().op("uuid_ops") ), index("idx_members_organization_user").using( "btree", table.organizationId.asc().nullsLast().op("uuid_ops"), table.userId.asc().nullsLast().op("uuid_ops") ), index("idx_members_role").using( "btree", table.role.asc().nullsLast().op("text_ops") ), index("idx_members_settings_gin") .using("gin", table.settings.asc().nullsLast().op("jsonb_ops")) .where(sql`(settings IS NOT NULL)`), index("idx_members_updated_by") .using("btree", table.updatedBy.asc().nullsLast().op("uuid_ops")) .where(sql`(updated_by IS NOT NULL)`), index("idx_members_user_id").using( "btree", table.userId.asc().nullsLast().op("uuid_ops") ), index("idx_members_org_membership_status").using( "btree", table.organizationId.asc().nullsLast().op("uuid_ops"), table.membershipStatusId.asc().nullsLast().op("uuid_ops") ), foreignKey({ columns: [table.createdBy], foreignColumns: [users.id], name: "members_created_by_fkey", }), foreignKey({ columns: [table.membershipStatusId], foreignColumns: [organizationStatuses.id], name: "members_membership_status_id_fkey", }).onDelete("set null"), foreignKey({ columns: [table.organizationId], foreignColumns: [organizations.id], name: "members_organization_id_fkey", }).onDelete("cascade"), foreignKey({ columns: [table.updatedBy], foreignColumns: [users.id], name: "members_updated_by_fkey", }), foreignKey({ columns: [table.userId], foreignColumns: [users.id], name: "members_user_id_fkey", }).onDelete("cascade"), unique("members_organization_user_unique").on( table.organizationId, table.userId ), ] ); export const passkeys = pgTable( "passkeys", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), name: text(), publicKey: text("public_key").notNull(), userId: uuid("user_id").notNull(), credentialId: text("credential_id").notNull(), counter: integer().notNull(), deviceType: text("device_type").notNull(), backedUp: boolean("backed_up").notNull(), transports: text(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), aaguid: text(), }, (table) => [ index("idx_passkeys_user_id").using( "btree", table.userId.asc().nullsLast().op("uuid_ops") ), foreignKey({ columns: [table.userId], foreignColumns: [users.id], name: "passkeys_user_id_fkey", }).onDelete("cascade"), unique("passkeys_credential_id_key").on(table.credentialId), ] ); export const sessions = pgTable( "sessions", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), expiresAt: timestamp("expires_at", { withTimezone: true, }).notNull(), token: text().notNull(), ipAddress: text("ip_address"), userAgent: text("user_agent"), userId: uuid("user_id").notNull(), activeOrganizationId: uuid("active_organization_id"), impersonatedBy: uuid("impersonated_by"), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ index("idx_sessions_active_organization_id") .using( "btree", table.activeOrganizationId.asc().nullsLast().op("uuid_ops") ) .where(sql`(active_organization_id IS NOT NULL)`), index("idx_sessions_expires_at").using( "btree", table.expiresAt.asc().nullsLast().op("timestamptz_ops") ), index("idx_sessions_impersonated_by") .using("btree", table.impersonatedBy.asc().nullsLast().op("uuid_ops")) .where(sql`(impersonated_by IS NOT NULL)`), index("idx_sessions_token").using( "btree", table.token.asc().nullsLast().op("text_ops") ), index("idx_sessions_user_id").using( "btree", table.userId.asc().nullsLast().op("uuid_ops") ), foreignKey({ columns: [table.activeOrganizationId], foreignColumns: [organizations.id], name: "sessions_active_organization_id_fkey", }).onDelete("set null"), foreignKey({ columns: [table.impersonatedBy], foreignColumns: [users.id], name: "sessions_impersonated_by_fkey", }).onDelete("set null"), foreignKey({ columns: [table.userId], foreignColumns: [users.id], name: "sessions_user_id_fkey", }).onDelete("cascade"), unique("sessions_token_key").on(table.token), ] ); export const twoFactors = pgTable( "two_factors", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), secret: text().notNull(), backupCodes: text("backup_codes").notNull(), userId: uuid("user_id").notNull(), }, (table) => [ index("idx_two_factors_secret").using( "btree", table.secret.asc().nullsLast().op("text_ops") ), index("idx_two_factors_user_id").using( "btree", table.userId.asc().nullsLast().op("uuid_ops") ), foreignKey({ columns: [table.userId], foreignColumns: [users.id], name: "two_factors_user_id_fkey", }).onDelete("cascade"), ] ); export const verifications = pgTable( "verifications", { id: uuid().default(sql`uuid_generate_v4()`).primaryKey().notNull(), identifier: text().notNull(), value: text().notNull(), expiresAt: timestamp("expires_at", { withTimezone: true, }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ index("idx_verifications_expires_at").using( "btree", table.expiresAt.asc().nullsLast().op("timestamptz_ops") ), index("idx_verifications_identifier").using( "btree", table.identifier.asc().nullsLast().op("text_ops") ), index("idx_verifications_identifier_value").using( "btree", table.identifier.asc().nullsLast().op("text_ops"), table.value.asc().nullsLast().op("text_ops") ), ] ); ```
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19844