page.tsx188 lines · main
| 1 | import Link from 'next/link'; |
| 2 | |
| 3 | import { DocsShell } from '../../../components/shell'; |
| 4 | |
| 5 | export const metadata = { title: 'migration · postgres → briven' }; |
| 6 | |
| 7 | export default function PostgresMigrationPage() { |
| 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">raw postgres / drizzle / prisma → briven</h1> |
| 16 | <p className="mt-2 font-mono text-sm text-[var(--color-text-muted)]"> |
| 17 | the straightest path. you already have a postgres schema; briven gives you reactive |
| 18 | queries, function hosting, and a managed deploy story on top of it. follow the |
| 19 | ten-step playbook on{' '} |
| 20 | <Link href="/migration" className="underline underline-offset-2"> |
| 21 | /migration |
| 22 | </Link>{' '} |
| 23 | — this page documents the postgres-specific parts. |
| 24 | </p> |
| 25 | |
| 26 | <Section title="schema port — drizzle / prisma → briven dsl"> |
| 27 | <p>your existing column types map directly. drizzle:</p> |
| 28 | <Snippet>{`// drizzle |
| 29 | export const notes = pgTable('notes', { |
| 30 | id: text('id').primaryKey(), |
| 31 | body: text('body').notNull(), |
| 32 | authorId: text('author_id').references(() => users.id), |
| 33 | createdAt: timestamp('created_at').defaultNow().notNull(), |
| 34 | }); |
| 35 | |
| 36 | // briven |
| 37 | import { schema, table, text, timestamp } from '@briven/cli/schema'; |
| 38 | export default schema({ |
| 39 | notes: table({ |
| 40 | columns: { |
| 41 | id: text().primaryKey(), |
| 42 | body: text().notNull(), |
| 43 | authorId: text().references('users', 'id'), |
| 44 | createdAt: timestamp().notNull().default('now()'), |
| 45 | }, |
| 46 | }), |
| 47 | });`}</Snippet> |
| 48 | <p>prisma:</p> |
| 49 | <Snippet>{`// prisma |
| 50 | model Note { |
| 51 | id String @id @default(cuid()) |
| 52 | body String |
| 53 | authorId String? |
| 54 | author User? @relation(fields: [authorId], references: [id]) |
| 55 | createdAt DateTime @default(now()) |
| 56 | } |
| 57 | |
| 58 | // briven |
| 59 | notes: table({ |
| 60 | columns: { |
| 61 | id: text().primaryKey(), |
| 62 | body: text().notNull(), |
| 63 | authorId: text().references('users', 'id'), // FK relation flattens to a column ref |
| 64 | createdAt: timestamp().notNull().default('now()'), |
| 65 | }, |
| 66 | }),`}</Snippet> |
| 67 | <p>conventions to know:</p> |
| 68 | <ul className="list-disc pl-5"> |
| 69 | <li> |
| 70 | <strong>column casing.</strong> briven dsl is camelCase in TS; the generated SQL is |
| 71 | snake_case-by-convention. if your existing tables already use snake_case, the |
| 72 | migration is zero-diff at the SQL layer. |
| 73 | </li> |
| 74 | <li> |
| 75 | <strong>indexes</strong> live on the table's <code>indexes: [...]</code> array, |
| 76 | not chained on the column. compound + unique indexes go here. |
| 77 | </li> |
| 78 | <li> |
| 79 | <strong>generated columns</strong> (drizzle <code>$generated()</code>, postgres{' '} |
| 80 | <code>GENERATED ALWAYS AS</code>) aren't modelled in the dsl yet — declare the |
| 81 | column as <code>text()</code>/<code>integer()</code>, then add the GENERATED clause |
| 82 | via a custom migration step. |
| 83 | </li> |
| 84 | </ul> |
| 85 | </Section> |
| 86 | |
| 87 | <Section title="query layer — drizzle/prisma → ctx.db"> |
| 88 | <p> |
| 89 | briven's <code>ctx.db</code> is a focused query builder, not a full ORM. the |
| 90 | 90% of select / insert / update / delete patterns translate directly: |
| 91 | </p> |
| 92 | <Snippet>{`// drizzle |
| 93 | const rows = await db.select().from(notes).where(eq(notes.authorId, id)).orderBy(desc(notes.createdAt)).limit(50); |
| 94 | |
| 95 | // briven |
| 96 | const rows = await ctx.db('notes') |
| 97 | .select() |
| 98 | .where({ authorId: id }) |
| 99 | .orderBy('createdAt', 'desc') |
| 100 | .limit(50);`}</Snippet> |
| 101 | <p> |
| 102 | for the remaining 10% — joins, CTEs, window functions, full-text — drop to{' '} |
| 103 | <code>ctx.db.execute(sql, params)</code> with a parameterised SQL string. see{' '} |
| 104 | <Link href="/functions" className="underline underline-offset-2">/functions</Link> for |
| 105 | the full <code>Ctx</code> shape. |
| 106 | </p> |
| 107 | </Section> |
| 108 | |
| 109 | <Section title="data port — pg_dump | pg_restore"> |
| 110 | <p> |
| 111 | since briven's data plane is also postgres, the data move is a pg_dump pipe. |
| 112 | briven creates a per-project schema (<code>proj_<projectId></code>); your |
| 113 | existing <code>public</code> schema lands inside it. |
| 114 | </p> |
| 115 | <Snippet>{`# 1. open a short-lived dsn into the briven project's schema |
| 116 | briven db shell-token > /tmp/briven-dsn # writes a single-line dsn |
| 117 | |
| 118 | # 2. dump source, restore into briven, scoped to public |
| 119 | pg_dump --schema=public --no-owner --no-privileges \\ |
| 120 | --format=custom \\ |
| 121 | "$SOURCE_DATABASE_URL" \\ |
| 122 | | pg_restore --no-owner --no-privileges \\ |
| 123 | --schema=public \\ |
| 124 | --dbname="$(cat /tmp/briven-dsn)" |
| 125 | |
| 126 | # 3. verify row counts match |
| 127 | psql "$SOURCE_DATABASE_URL" -tAc 'select count(*) from notes' |
| 128 | psql "$(cat /tmp/briven-dsn)" -tAc 'select count(*) from public.notes'`}</Snippet> |
| 129 | <p> |
| 130 | the briven dsn is short-lived (15 minutes per issuance) — issue a fresh one if your |
| 131 | dump runs longer. |
| 132 | </p> |
| 133 | </Section> |
| 134 | |
| 135 | <Section title="functions port — drizzle/prisma handlers → briven functions"> |
| 136 | <p> |
| 137 | your existing API handlers (express, fastify, hono, next.js api routes) become files |
| 138 | under <code>briven/functions/</code>. one file per endpoint: |
| 139 | </p> |
| 140 | <Snippet>{`// before: express + drizzle |
| 141 | app.get('/api/notes', async (req, res) => { |
| 142 | const rows = await db.select().from(notes).where(eq(notes.authorId, req.user.id)); |
| 143 | res.json({ notes: rows }); |
| 144 | }); |
| 145 | |
| 146 | // after: briven/functions/getNotes.ts |
| 147 | import { query, type Ctx } from '@briven/cli/server'; |
| 148 | export default query(async (ctx: Ctx) => { |
| 149 | if (!ctx.auth) throw new Error('unauthorized'); |
| 150 | return await ctx.db('notes').select().where({ authorId: ctx.auth.userId }); |
| 151 | });`}</Snippet> |
| 152 | <p> |
| 153 | the wrapping framework goes away — briven owns the http surface. invoke from the |
| 154 | client via <code>briven invoke getNotes</code>, or via the SDK's reactive{' '} |
| 155 | <code>useQuery</code>. |
| 156 | </p> |
| 157 | </Section> |
| 158 | |
| 159 | <Section title="auth port"> |
| 160 | <p> |
| 161 | if you were rolling your own auth on top of postgres (sessions table + cookie + bcrypt), |
| 162 | Better Auth gives you the same primitives without the maintenance burden. magic-link |
| 163 | + email/password + GitHub OAuth ship out of the box; bring your{' '} |
| 164 | <code>users.id</code> column over and Better Auth keeps using it. |
| 165 | </p> |
| 166 | </Section> |
| 167 | </DocsShell> |
| 168 | ); |
| 169 | } |
| 170 | |
| 171 | function Section({ title, children }: { title: string; children: React.ReactNode }) { |
| 172 | return ( |
| 173 | <section className="mt-10"> |
| 174 | <h2 className="font-mono text-lg">{title}</h2> |
| 175 | <div className="mt-2 space-y-3 font-mono text-sm text-[var(--color-text-muted)]"> |
| 176 | {children} |
| 177 | </div> |
| 178 | </section> |
| 179 | ); |
| 180 | } |
| 181 | |
| 182 | function Snippet({ children }: { children: string }) { |
| 183 | return ( |
| 184 | <pre className="overflow-x-auto rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4 font-mono text-xs"> |
| 185 | <code>{children}</code> |
| 186 | </pre> |
| 187 | ); |
| 188 | } |