page.tsx247 lines · main
| 1 | import Link from 'next/link'; |
| 2 | |
| 3 | import { DocsShell } from '../../../components/shell'; |
| 4 | |
| 5 | export const metadata = { title: 'migration · prisma → briven' }; |
| 6 | |
| 7 | export default function PrismaMigrationPage() { |
| 8 | return ( |
| 9 | <DocsShell> |
| 10 | <p className="font-mono text-xs text-[var(--color-text-muted)]"> |
| 11 | <Link href="/migration" className="hover:text-[var(--color-text)]"> |
| 12 | ← migration |
| 13 | </Link> |
| 14 | </p> |
| 15 | <h1 className="mt-2 font-mono text-2xl tracking-tight">prisma → briven</h1> |
| 16 | <p className="mt-2 font-mono text-sm text-[var(--color-text-muted)]"> |
| 17 | port a prisma + postgres project onto briven. follow the ten-step playbook on{' '} |
| 18 | <Link href="/migration" className="underline underline-offset-2"> |
| 19 | /migration |
| 20 | </Link>{' '} |
| 21 | — this page covers only the prisma-specific parts. |
| 22 | </p> |
| 23 | |
| 24 | <div className="mt-4 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4 font-mono text-xs text-[var(--color-text-muted)]"> |
| 25 | prisma's strengths (typed clients, sql-agnostic schema files) and weaknesses (long |
| 26 | cold starts, awkward complex queries) are both reasons people move. on briven you keep |
| 27 | the typed-client feel — every <code>query()</code> / <code>mutation()</code> is typed via |
| 28 | its <code>Args</code> interface — and trade prisma client's ORM-level helpers for a |
| 29 | thin postgres query builder. the schema port is mechanical; the functions port is the |
| 30 | place to think. |
| 31 | </div> |
| 32 | |
| 33 | <Section title="schema port — prisma DSL → briven DSL"> |
| 34 | <p> |
| 35 | prisma uses its own schema language (<code>.prisma</code> files). map decorators to |
| 36 | briven column-builder calls: |
| 37 | </p> |
| 38 | <Snippet>{`// schema.prisma |
| 39 | model Post { |
| 40 | id String @id @default(cuid()) |
| 41 | authorId String |
| 42 | title String |
| 43 | body String |
| 44 | published Boolean @default(false) |
| 45 | views Int @default(0) |
| 46 | metadata Json? |
| 47 | createdAt DateTime @default(now()) |
| 48 | author User @relation(fields: [authorId], references: [id]) |
| 49 | @@index([authorId]) |
| 50 | @@index([published, authorId]) |
| 51 | } |
| 52 | |
| 53 | // briven/schema.ts |
| 54 | import { bigint, boolean, jsonb, schema, table, text, timestamp } from '@briven/cli/schema'; |
| 55 | |
| 56 | export default schema({ |
| 57 | posts: table({ |
| 58 | columns: { |
| 59 | id: text().primaryKey(), |
| 60 | authorId: text().notNull().references('users', 'id'), |
| 61 | title: text().notNull(), |
| 62 | body: text().notNull(), |
| 63 | published: boolean().notNull().default('false'), |
| 64 | views: bigint().notNull().default('0'), |
| 65 | metadata: jsonb<Record<string, unknown>>().nullable(), |
| 66 | createdAt: timestamp().notNull().default('now()'), |
| 67 | }, |
| 68 | indexes: [ |
| 69 | { columns: ['authorId'], unique: false }, |
| 70 | { columns: ['published', 'authorId'], unique: false }, |
| 71 | ], |
| 72 | }), |
| 73 | });`}</Snippet> |
| 74 | <ul className="list-disc pl-5"> |
| 75 | <li> |
| 76 | <code>@id</code> → <code>.primaryKey()</code>. prisma's <code>@default(cuid())</code>{' '} |
| 77 | / <code>@default(uuid())</code> don't carry over — briven mints ids in function |
| 78 | code via <code>ulid('<em>prefix</em>')</code> from{' '} |
| 79 | <code>@briven/shared</code>. ULIDs sort lexicographically by creation time, which is |
| 80 | usually what you want anyway. |
| 81 | </li> |
| 82 | <li> |
| 83 | <code>Int</code> → <code>bigint()</code>. prisma maps <code>Int</code> to int4 by |
| 84 | default; briven defaults numeric columns to int8 to head off overflow. if you need |
| 85 | int4 specifically, file an issue. |
| 86 | </li> |
| 87 | <li> |
| 88 | <code>Json?</code> → <code>jsonb<T>().nullable()</code>. give the column a type |
| 89 | arg so the function code gets typed reads. |
| 90 | </li> |
| 91 | <li> |
| 92 | <code>DateTime @default(now())</code> →{' '} |
| 93 | <code>timestamp().notNull().default('now()')</code>. |
| 94 | </li> |
| 95 | <li> |
| 96 | <code>@relation(fields: […], references: […])</code> →{' '} |
| 97 | <code>.references('table', 'column')</code> on the fk column. |
| 98 | briven doesn't generate the reverse-side accessor — query through{' '} |
| 99 | <code>ctx.db</code> on the related table directly. |
| 100 | </li> |
| 101 | <li> |
| 102 | <code>@@index([a, b])</code> → entry in the table's <code>indexes</code> array. |
| 103 | partial / expression indexes (e.g. <code>@@index([a], where: {'{ … }'})</code>) are a |
| 104 | known gap. |
| 105 | </li> |
| 106 | </ul> |
| 107 | </Section> |
| 108 | |
| 109 | <Section title="enums"> |
| 110 | <p> |
| 111 | prisma <code>enum</code> declarations have no first-class briven equivalent today. two |
| 112 | paths, depending on how strict you want the constraint: |
| 113 | </p> |
| 114 | <ul className="list-disc pl-5"> |
| 115 | <li> |
| 116 | <strong>application-side</strong> — column stays <code>text()</code>, the function |
| 117 | code validates against a TypeScript union literal. less strict but flexible; matches |
| 118 | how convex / nextauth migrations land. |
| 119 | </li> |
| 120 | <li> |
| 121 | <strong>database-side</strong> — apply the enum as a check-constraint via a raw-sql |
| 122 | migration after <code>briven deploy</code>. briven preserves untouched user objects on |
| 123 | re-deploy, so the constraint survives. |
| 124 | </li> |
| 125 | </ul> |
| 126 | </Section> |
| 127 | |
| 128 | <Section title="data export from prisma's postgres"> |
| 129 | <p> |
| 130 | prisma is one of several clients pointing at postgres — the dump/restore is the same as |
| 131 | the <Link href="/migration/postgres" className="underline underline-offset-2">raw-postgres playbook</Link>: |
| 132 | </p> |
| 133 | <Snippet>{`pg_dump --format=custom --no-owner --no-privileges \\ |
| 134 | "$PRISMA_DATABASE_URL" > prisma-dump-$(date +%Y%m%d).dump |
| 135 | |
| 136 | pg_restore --no-owner --no-privileges --data-only \\ |
| 137 | -d "$BRIVEN_PROJECT_DSN" prisma-dump-$(date +%Y%m%d).dump`}</Snippet> |
| 138 | <p> |
| 139 | run <code>briven deploy</code> first so the briven schema is in place, then restore{' '} |
| 140 | <code>--data-only</code>. that keeps briven's id naming + index naming in sync with |
| 141 | what the briven dsl declared, instead of inheriting prisma's names. |
| 142 | </p> |
| 143 | <p> |
| 144 | prisma's migration history table (<code>_prisma_migrations</code>) doesn't |
| 145 | carry — briven tracks its own migrations in <code>_briven_migrations</code>. drop the |
| 146 | prisma table after the cutover. |
| 147 | </p> |
| 148 | </Section> |
| 149 | |
| 150 | <Section title="functions port — PrismaClient calls → ctx.db chains"> |
| 151 | <p> |
| 152 | prisma client is generated; briven's <code>ctx.db</code> is a thin knex-style |
| 153 | builder. the port pattern is: replace <code>prisma.<em>model</em>.<em>op</em></code>{' '} |
| 154 | with the equivalent builder chain. |
| 155 | </p> |
| 156 | <Snippet>{`// before — prisma handler |
| 157 | import { prisma } from './db'; |
| 158 | |
| 159 | export async function recentPostsByAuthor(authorId: string, limit = 50) { |
| 160 | return prisma.post.findMany({ |
| 161 | where: { authorId, published: true }, |
| 162 | select: { id: true, title: true, createdAt: true }, |
| 163 | orderBy: { createdAt: 'desc' }, |
| 164 | take: Math.min(limit, 200), |
| 165 | }); |
| 166 | } |
| 167 | |
| 168 | // after — briven/functions/recentPostsByAuthor.ts |
| 169 | import { brivenError, query, type Ctx } from '@briven/cli/server'; |
| 170 | |
| 171 | interface Args { authorId: string; limit?: number } |
| 172 | |
| 173 | export default query(async (ctx: Ctx, args: Args) => { |
| 174 | if (!args.authorId) |
| 175 | throw new brivenError('validation_failed', 'authorId required', { status: 400 }); |
| 176 | return ctx |
| 177 | .db('posts') |
| 178 | .select(['id', 'title', 'createdAt']) |
| 179 | .where({ authorId: args.authorId, published: true }) |
| 180 | .orderBy('createdAt', 'desc') |
| 181 | .limit(Math.min(args.limit ?? 50, 200)); |
| 182 | });`}</Snippet> |
| 183 | <ul className="list-disc pl-5"> |
| 184 | <li> |
| 185 | <code>findMany</code> / <code>findFirst</code> / <code>findUnique</code> → builder |
| 186 | chain ending in <code>.first()</code> for unique lookups. |
| 187 | </li> |
| 188 | <li> |
| 189 | <code>create</code> / <code>update</code> / <code>delete</code> →{' '} |
| 190 | <code>insert / update / delete</code>. <code>upsert</code> needs an explicit{' '} |
| 191 | <code>onConflict</code> in raw sql today (known gap; an upsert helper is queued). |
| 192 | </li> |
| 193 | <li> |
| 194 | <code>include</code> / <code>select</code> with nested relations → no eager-join |
| 195 | shortcut yet. either run two queries inside the same function (the function executes |
| 196 | in one transaction, so the consistency is the same) or write a raw query for the |
| 197 | joined shape. |
| 198 | </li> |
| 199 | <li> |
| 200 | prisma <code>$transaction</code> → not needed; every <code>mutation()</code> body |
| 201 | runs in a single transaction by default. |
| 202 | </li> |
| 203 | </ul> |
| 204 | </Section> |
| 205 | |
| 206 | <Section title="auth port"> |
| 207 | <p> |
| 208 | prisma is unopinionated about auth — the user table is whatever you built. if it lines |
| 209 | up with better-auth's columns (<code>id, email, name, image, …</code>) the{' '} |
| 210 | <Link href="/migration/nextauth" className="underline underline-offset-2"> |
| 211 | nextauth → briven |
| 212 | </Link>{' '} |
| 213 | guide maps cleanly; if it's a custom shape, port the columns onto better-auth's |
| 214 | expected shape before flipping traffic. |
| 215 | </p> |
| 216 | </Section> |
| 217 | |
| 218 | <Section title="reactivity (new capability)"> |
| 219 | <p> |
| 220 | prisma queries are one-shot; the typical pattern is polling or websockets-on-the-side. |
| 221 | on briven the same <code>query()</code> used over http auto-becomes reactive when |
| 222 | consumed via <code>@briven/react</code>'s <code>useQuery</code>. table-level |
| 223 | NOTIFYs trigger re-runs — no code change needed. |
| 224 | </p> |
| 225 | </Section> |
| 226 | </DocsShell> |
| 227 | ); |
| 228 | } |
| 229 | |
| 230 | function Section({ title, children }: { title: string; children: React.ReactNode }) { |
| 231 | return ( |
| 232 | <section className="mt-10"> |
| 233 | <h2 className="font-mono text-lg">{title}</h2> |
| 234 | <div className="mt-2 space-y-3 font-mono text-sm text-[var(--color-text-muted)]"> |
| 235 | {children} |
| 236 | </div> |
| 237 | </section> |
| 238 | ); |
| 239 | } |
| 240 | |
| 241 | function Snippet({ children }: { children: string }) { |
| 242 | return ( |
| 243 | <pre className="overflow-x-auto rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4 font-mono text-xs"> |
| 244 | <code>{children}</code> |
| 245 | </pre> |
| 246 | ); |
| 247 | } |