Skip to content

Commit f908307

Browse files
authored
feat(db): replace UUID with prefixed CUID2 IDs (#2162)
1 parent 6f4046e commit f908307

17 files changed

Lines changed: 201 additions & 88 deletions

File tree

apps/api/lib/auth.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { passkey } from "@better-auth/passkey";
22
import { stripe } from "@better-auth/stripe";
3-
import { schema as Db } from "@repo/db";
3+
import { schema as Db, generateAuthId, type AuthModel } from "@repo/db";
44
import { betterAuth } from "better-auth";
55
import type { DB } from "better-auth/adapters/drizzle";
66
import { drizzleAdapter } from "better-auth/adapters/drizzle";
@@ -101,7 +101,7 @@ function stripePlugin(db: DB, env: AuthEnv) {
101101
* Key behaviors:
102102
* - Uses custom 'identity' table instead of default 'account' model for OAuth accounts
103103
* - Allows users to create up to 5 organizations with 'owner' role as creator
104-
* - Delegates ID generation to database (schema defaults to gen_random_uuid)
104+
* - Generates prefixed CUID2 IDs at application level (e.g. usr_..., ses_...)
105105
* - Supports anonymous authentication alongside email/password and Google OAuth
106106
*
107107
* @param db Drizzle database instance - must include all required auth tables (user, session, identity, organization, member, invitation, verification)
@@ -201,7 +201,7 @@ export function createAuth(
201201

202202
advanced: {
203203
database: {
204-
generateId: false,
204+
generateId: ({ model }) => generateAuthId(model as AuthModel),
205205
},
206206
},
207207

bun.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

db/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
## Schema Conventions
22

33
- Drizzle `casing: "snake_case"` — use camelCase in TypeScript, columns map to snake_case in DB.
4-
- All primary keys: `text().primaryKey().default(sql`gen_random_uuid()`)`.
4+
- All primary keys: `text().primaryKey().$defaultFn(() => generateAuthId(...))` — application-generated prefixed CUID2 IDs (e.g. `usr_ght4k2jxm7pqbv01`). See `db/schema/id.ts` for prefix map.
55
- Timestamps: `timestamp({ withTimezone: true, mode: "date" })`. Every table has `createdAt` (`.defaultNow().notNull()`) and `updatedAt` (`.defaultNow().$onUpdate(() => new Date()).notNull()`).
66
- `identity` table = Better Auth's `account` table, renamed via `account.modelName: "identity"` in auth config.
77
- `member.role` and `invitation.status` are free `text`, not pgEnum — avoids fragile coupling with Better Auth's values.

db/README.md

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,6 @@ import { organization, member } from "@/db/schema/organization";
9090
- Wrong environment: confirm `ENVIRONMENT`/`NODE_ENV` matches the target and the corresponding `.env.*` file exists.
9191
- Drift/conflicts: run `bun --filter @repo/db check`; regenerate migrations if schema and migrations diverge.
9292

93-
## UUID v7
93+
## ID Generation
9494

95-
The schema uses `gen_random_uuid()` by default for maximum compatibility. For time-ordered UUIDs (better index locality), replace with:
96-
97-
- **PostgreSQL 18+**: `uuidv7()` (native)
98-
- **Earlier versions**: `uuid_generate_v7()` (requires [pg_uuidv7](https://github.com/fboulnois/pg_uuidv7) extension
99-
100-
```typescript
101-
// Example: enable UUID v7 in schema
102-
id: text()
103-
.primaryKey()
104-
.default(sql`uuidv7()`);
105-
```
95+
All primary keys use application-generated prefixed CUID2 IDs (e.g. `usr_ght4k2jxm7pqbv01`). IDs are generated at the application level via `$defaultFn()` — no database-level defaults. See `db/schema/id.ts` for the prefix map and `docs/specs/prefixed-ids.md` for design rationale.

db/migrations/0000_init.sql

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
CREATE TABLE "invitation" (
2-
"id" text PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
2+
"id" text PRIMARY KEY NOT NULL,
33
"email" text NOT NULL,
44
"inviter_id" text NOT NULL,
55
"organization_id" text NOT NULL,
@@ -14,7 +14,7 @@ CREATE TABLE "invitation" (
1414
);
1515
--> statement-breakpoint
1616
CREATE TABLE "passkey" (
17-
"id" text PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
17+
"id" text PRIMARY KEY NOT NULL,
1818
"name" text,
1919
"public_key" text NOT NULL,
2020
"user_id" text NOT NULL,
@@ -33,7 +33,7 @@ CREATE TABLE "passkey" (
3333
);
3434
--> statement-breakpoint
3535
CREATE TABLE "member" (
36-
"id" text PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
36+
"id" text PRIMARY KEY NOT NULL,
3737
"user_id" text NOT NULL,
3838
"organization_id" text NOT NULL,
3939
"role" text NOT NULL,
@@ -43,7 +43,7 @@ CREATE TABLE "member" (
4343
);
4444
--> statement-breakpoint
4545
CREATE TABLE "organization" (
46-
"id" text PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
46+
"id" text PRIMARY KEY NOT NULL,
4747
"name" text NOT NULL,
4848
"slug" text NOT NULL,
4949
"logo" text,
@@ -55,7 +55,7 @@ CREATE TABLE "organization" (
5555
);
5656
--> statement-breakpoint
5757
CREATE TABLE "identity" (
58-
"id" text PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
58+
"id" text PRIMARY KEY NOT NULL,
5959
"account_id" text NOT NULL,
6060
"provider_id" text NOT NULL,
6161
"user_id" text NOT NULL,
@@ -72,7 +72,7 @@ CREATE TABLE "identity" (
7272
);
7373
--> statement-breakpoint
7474
CREATE TABLE "session" (
75-
"id" text PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
75+
"id" text PRIMARY KEY NOT NULL,
7676
"expires_at" timestamp with time zone NOT NULL,
7777
"token" text NOT NULL,
7878
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
@@ -85,7 +85,7 @@ CREATE TABLE "session" (
8585
);
8686
--> statement-breakpoint
8787
CREATE TABLE "subscription" (
88-
"id" text PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
88+
"id" text PRIMARY KEY NOT NULL,
8989
"plan" text NOT NULL,
9090
"reference_id" text NOT NULL,
9191
"stripe_customer_id" text,
@@ -108,7 +108,7 @@ CREATE TABLE "subscription" (
108108
);
109109
--> statement-breakpoint
110110
CREATE TABLE "user" (
111-
"id" text PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
111+
"id" text PRIMARY KEY NOT NULL,
112112
"name" text NOT NULL,
113113
"email" text NOT NULL,
114114
"email_verified" boolean DEFAULT false NOT NULL,
@@ -121,7 +121,7 @@ CREATE TABLE "user" (
121121
);
122122
--> statement-breakpoint
123123
CREATE TABLE "verification" (
124-
"id" text PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
124+
"id" text PRIMARY KEY NOT NULL,
125125
"identifier" text NOT NULL,
126126
"value" text NOT NULL,
127127
"expires_at" timestamp with time zone NOT NULL,

db/migrations/meta/0000_snapshot.json

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
"name": "id",
1313
"type": "text",
1414
"primaryKey": true,
15-
"notNull": true,
16-
"default": "gen_random_uuid()"
15+
"notNull": true
1716
},
1817
"email": {
1918
"name": "email",
@@ -166,8 +165,7 @@
166165
"name": "id",
167166
"type": "text",
168167
"primaryKey": true,
169-
"notNull": true,
170-
"default": "gen_random_uuid()"
168+
"notNull": true
171169
},
172170
"name": {
173171
"name": "name",
@@ -305,8 +303,7 @@
305303
"name": "id",
306304
"type": "text",
307305
"primaryKey": true,
308-
"notNull": true,
309-
"default": "gen_random_uuid()"
306+
"notNull": true
310307
},
311308
"user_id": {
312309
"name": "user_id",
@@ -413,8 +410,7 @@
413410
"name": "id",
414411
"type": "text",
415412
"primaryKey": true,
416-
"notNull": true,
417-
"default": "gen_random_uuid()"
413+
"notNull": true
418414
},
419415
"name": {
420416
"name": "name",
@@ -483,8 +479,7 @@
483479
"name": "id",
484480
"type": "text",
485481
"primaryKey": true,
486-
"notNull": true,
487-
"default": "gen_random_uuid()"
482+
"notNull": true
488483
},
489484
"account_id": {
490485
"name": "account_id",
@@ -609,8 +604,7 @@
609604
"name": "id",
610605
"type": "text",
611606
"primaryKey": true,
612-
"notNull": true,
613-
"default": "gen_random_uuid()"
607+
"notNull": true
614608
},
615609
"expires_at": {
616610
"name": "expires_at",
@@ -726,8 +720,7 @@
726720
"name": "id",
727721
"type": "text",
728722
"primaryKey": true,
729-
"notNull": true,
730-
"default": "gen_random_uuid()"
723+
"notNull": true
731724
},
732725
"plan": {
733726
"name": "plan",
@@ -895,8 +888,7 @@
895888
"name": "id",
896889
"type": "text",
897890
"primaryKey": true,
898-
"notNull": true,
899-
"default": "gen_random_uuid()"
891+
"notNull": true
900892
},
901893
"name": {
902894
"name": "name",
@@ -973,8 +965,7 @@
973965
"name": "id",
974966
"type": "text",
975967
"primaryKey": true,
976-
"notNull": true,
977-
"default": "gen_random_uuid()"
968+
"notNull": true
978969
},
979970
"identifier": {
980971
"name": "identifier",

db/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,8 @@
4444
"drizzle-kit": "^0.31.9",
4545
"drizzle-orm": "^0.45.1",
4646
"typescript": "~5.9.3"
47+
},
48+
"dependencies": {
49+
"@paralleldrive/cuid2": "^3.3.0"
4750
}
4851
}

db/schema/id.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Prefixed CUID2 ID generation for all database entities.
2+
// Format: {prefix}_{body} e.g. "usr_ght4k2jxm7pqbv01" (20 chars total)
3+
// See docs/specs/prefixed-ids.md for design rationale.
4+
5+
import { init } from "@paralleldrive/cuid2";
6+
7+
// Keys are Better Auth's internal model names (not table names).
8+
// "account" maps to the "identity" table via account.modelName config.
9+
const AUTH_PREFIX = {
10+
user: "usr",
11+
session: "ses",
12+
account: "idn", // "identity" table — avoids confusion with user/billing account
13+
verification: "vfy",
14+
organization: "org",
15+
member: "mem",
16+
invitation: "inv",
17+
passkey: "pky",
18+
subscription: "sub",
19+
} as const;
20+
21+
export type AuthModel = keyof typeof AUTH_PREFIX;
22+
23+
const ID_LENGTH = 16;
24+
let _createId: (() => string) | null = null;
25+
26+
function createId(): string {
27+
if (!_createId) _createId = init({ length: ID_LENGTH });
28+
return _createId();
29+
}
30+
31+
/** Generate a prefixed ID for a Better Auth model (e.g. `"user"` → `"usr_..."`) */
32+
export function generateAuthId(model: AuthModel): string {
33+
const prefix = AUTH_PREFIX[model];
34+
if (!prefix) {
35+
throw new Error(
36+
`Unknown auth model "${String(model)}". Add it to AUTH_PREFIX in db/schema/id.ts`,
37+
);
38+
}
39+
return `${prefix}_${createId()}`;
40+
}
41+
42+
/** Generate a prefixed ID for non-auth tables (e.g. `generateId("upl")`) */
43+
export function generateId(prefix: string): string {
44+
if (!/^[a-z]{3}$/.test(prefix)) {
45+
throw new Error(
46+
`ID prefix must be exactly 3 lowercase letters, got "${prefix}"`,
47+
);
48+
}
49+
return `${prefix}_${createId()}`;
50+
}

db/schema/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from "./id";
12
export * from "./invitation";
23
export * from "./organization";
34
export * from "./passkey";

db/schema/invitation.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Better Auth invitation system for organization invites
22

3-
import { relations, sql } from "drizzle-orm";
3+
import { relations } from "drizzle-orm";
44
import { index, pgTable, text, timestamp, unique } from "drizzle-orm/pg-core";
5+
import { generateAuthId } from "./id";
56
import { organization } from "./organization";
67
import { user } from "./user";
78

@@ -18,7 +19,7 @@ export const invitation = pgTable(
1819
{
1920
id: text()
2021
.primaryKey()
21-
.default(sql`gen_random_uuid()`),
22+
.$defaultFn(() => generateAuthId("invitation")),
2223
email: text().notNull(),
2324
inviterId: text()
2425
.notNull()

0 commit comments

Comments
 (0)