page.tsx249 lines · main
| 1 | import Link from 'next/link'; |
| 2 | |
| 3 | import { DocsShell } from '../../../components/shell'; |
| 4 | |
| 5 | export const metadata = { title: 'migration · mongodb → briven' }; |
| 6 | |
| 7 | export default function MongoMigrationPage() { |
| 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">mongodb → briven</h1> |
| 16 | <p className="mt-2 font-mono text-sm text-[var(--color-text-muted)]"> |
| 17 | port a mongodb 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 mongodb-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 | mongodb → postgres is the second-hardest migration shape (only firebase is harder). the |
| 26 | work isn't the dump/restore — it's deciding which embedded documents stay |
| 27 | embedded as <code>jsonb</code> and which get flattened into separate tables. plan for |
| 28 | the schema port to take 60–80% of the project time, and run a 2+ week parallel-run |
| 29 | window because shape mismatches surface late. |
| 30 | </div> |
| 31 | |
| 32 | <Section title="when to keep documents embedded vs flatten"> |
| 33 | <p> |
| 34 | briven is postgres-first. <code>jsonb<T>()</code> is a first-class column type, so |
| 35 | you don't have to flatten every embedded doc. the decision matrix: |
| 36 | </p> |
| 37 | <ul className="list-disc pl-5"> |
| 38 | <li> |
| 39 | <strong>embedded</strong>: the sub-doc is always read with the parent, never queried |
| 40 | independently, never updated in isolation. example: a <code>profile</code>{' '} |
| 41 | object on a <code>user</code> doc with name/avatar/bio fields. |
| 42 | </li> |
| 43 | <li> |
| 44 | <strong>flatten to a row</strong>: the sub-doc is independently queryable (find by an |
| 45 | inner field), independently updated (atomic write to one nested item), or unbounded |
| 46 | in count (notifications, audit entries). these become real tables with a foreign key. |
| 47 | </li> |
| 48 | <li> |
| 49 | <strong>flatten to a row, eagerly fetched</strong>: when you almost always want the |
| 50 | parent + child together, the rows-with-fk shape still wins. briven query functions |
| 51 | return whatever shape you build — fetch both, return them as one object. |
| 52 | </li> |
| 53 | </ul> |
| 54 | <p> |
| 55 | if you can't decide, flatten. it's easier to inline two relational reads than |
| 56 | to undo a too-large <code>jsonb</code> column later. |
| 57 | </p> |
| 58 | </Section> |
| 59 | |
| 60 | <Section title="schema port — collection → table"> |
| 61 | <Snippet>{`// before — mongoose-ish shape (representative) |
| 62 | const PostSchema = new Schema({ |
| 63 | _id: ObjectId, |
| 64 | authorId: ObjectId, |
| 65 | title: { type: String, required: true }, |
| 66 | body: { type: String, required: true }, |
| 67 | published: { type: Boolean, default: false }, |
| 68 | views: { type: Number, default: 0 }, |
| 69 | tags: [String], // unbounded → think hard |
| 70 | author: { // embedded, always read together → keep |
| 71 | name: String, |
| 72 | avatarUrl: String, |
| 73 | }, |
| 74 | createdAt: { type: Date, default: Date.now }, |
| 75 | }); |
| 76 | |
| 77 | // after — briven/schema.ts |
| 78 | import { boolean, bigint, jsonb, schema, table, text, timestamp } from '@briven/cli/schema'; |
| 79 | |
| 80 | export default schema({ |
| 81 | posts: table({ |
| 82 | columns: { |
| 83 | id: text().primaryKey(), |
| 84 | authorId: text().notNull().references('users', 'id'), |
| 85 | title: text().notNull(), |
| 86 | body: text().notNull(), |
| 87 | published: boolean().notNull().default('false'), |
| 88 | views: bigint().notNull().default('0'), |
| 89 | // tags: unbounded array — flatten to a separate post_tags table. |
| 90 | // embedded author profile stays as jsonb (typed, read-with-parent). |
| 91 | author: jsonb<{ name: string; avatarUrl: string }>().notNull(), |
| 92 | createdAt: timestamp().notNull().default('now()'), |
| 93 | }, |
| 94 | }), |
| 95 | post_tags: table({ |
| 96 | columns: { |
| 97 | id: text().primaryKey(), |
| 98 | postId: text().notNull().references('posts', 'id'), |
| 99 | tag: text().notNull(), |
| 100 | }, |
| 101 | indexes: [{ columns: ['postId'], unique: false }, { columns: ['tag'], unique: false }], |
| 102 | }), |
| 103 | });`}</Snippet> |
| 104 | <ul className="list-disc pl-5"> |
| 105 | <li> |
| 106 | <code>ObjectId</code> → <code>text()</code>. mint new ids via{' '} |
| 107 | <code>ulid('<em>prefix</em>')</code> in function code (sorts lexicographically |
| 108 | by creation, which most callers want). if you need to preserve existing object ids |
| 109 | for backwards compatibility with clients, keep them as text — the dump step below |
| 110 | stringifies ObjectIds. |
| 111 | </li> |
| 112 | <li> |
| 113 | <code>Number</code> → <code>bigint()</code>. mongo's default 64-bit double works |
| 114 | for counters; briven's bigint avoids overflow on long-running tallies. |
| 115 | </li> |
| 116 | <li> |
| 117 | <code>Date</code> → <code>timestamp()</code>. unix-ms <code>Date</code> values |
| 118 | round-trip via <code>--date_oid_dates</code> in <code>mongoexport</code>. |
| 119 | </li> |
| 120 | <li> |
| 121 | <code>[String]</code> / <code>[ObjectId]</code> arrays — flatten to a join table. |
| 122 | postgres arrays exist but cost you index-able query support; the join table is |
| 123 | cheaper and more obvious. |
| 124 | </li> |
| 125 | </ul> |
| 126 | </Section> |
| 127 | |
| 128 | <Section title="data export from mongodb"> |
| 129 | <p> |
| 130 | mongo doesn't natively output rows — you go through json. the canonical sequence: |
| 131 | </p> |
| 132 | <Snippet>{`# 1. export each collection to JSON (one doc per line) |
| 133 | mongoexport \\ |
| 134 | --uri="$MONGO_URI" \\ |
| 135 | --collection=posts \\ |
| 136 | --out=posts.json \\ |
| 137 | --jsonArray |
| 138 | |
| 139 | # 2. transform the JSON into postgres COPY-compatible CSV. |
| 140 | # this step is custom per collection — a small node script that: |
| 141 | # - flattens embedded docs into jsonb columns |
| 142 | # - emits join-table rows for unbounded arrays |
| 143 | # - stringifies ObjectIds |
| 144 | # - converts mongo's $date / $oid extended-json into postgres values |
| 145 | # see: docs/migration/scripts/mongo-to-csv.ts (template) |
| 146 | |
| 147 | # 3. load with COPY against the briven project dsn |
| 148 | psql "$BRIVEN_PROJECT_DSN" -c "\\copy posts FROM 'posts.csv' CSV HEADER" |
| 149 | psql "$BRIVEN_PROJECT_DSN" -c "\\copy post_tags FROM 'post_tags.csv' CSV HEADER"`}</Snippet> |
| 150 | <p> |
| 151 | the transform script is the migration. budget a day per non-trivial collection — the |
| 152 | rules for which fields fold into <code>jsonb</code> vs flatten are decisions you make |
| 153 | once and codify in the script. |
| 154 | </p> |
| 155 | </Section> |
| 156 | |
| 157 | <Section title="functions port — find/aggregate → ctx.db chains"> |
| 158 | <Snippet>{`// before — mongoose |
| 159 | const recent = await Post.find({ authorId, published: true }) |
| 160 | .select({ title: 1, body: 1, createdAt: 1 }) |
| 161 | .sort({ createdAt: -1 }) |
| 162 | .limit(50); |
| 163 | |
| 164 | // after — briven/functions/recentPostsByAuthor.ts |
| 165 | import { brivenError, query, type Ctx } from '@briven/cli/server'; |
| 166 | interface Args { authorId: string; limit?: number } |
| 167 | export default query(async (ctx: Ctx, args: Args) => { |
| 168 | if (!args.authorId) |
| 169 | throw new brivenError('validation_failed', 'authorId required', { status: 400 }); |
| 170 | return ctx |
| 171 | .db('posts') |
| 172 | .select(['id', 'title', 'body', 'createdAt']) |
| 173 | .where({ authorId: args.authorId, published: true }) |
| 174 | .orderBy('createdAt', 'desc') |
| 175 | .limit(Math.min(args.limit ?? 50, 200)); |
| 176 | });`}</Snippet> |
| 177 | <ul className="list-disc pl-5"> |
| 178 | <li> |
| 179 | <strong>aggregate pipelines</strong> → either chain in the query-builder (group-by / |
| 180 | having lives there) or drop into raw sql via <code>ctx.db.raw(...)</code>. complex |
| 181 | pipelines often read better as a sql CTE; the migration is the right time to rewrite. |
| 182 | </li> |
| 183 | <li> |
| 184 | <strong>$lookup joins</strong> → either run two queries inside the same function |
| 185 | (single transaction; identical consistency) or use a raw join. mongo apps that |
| 186 | relied on $lookup heavily tend to over-flatten in mongo — the briven port is a |
| 187 | chance to fix the model. |
| 188 | </li> |
| 189 | <li> |
| 190 | <strong>upserts</strong> → no first-class helper today; raw sql{' '} |
| 191 | <code>INSERT ... ON CONFLICT</code>. an upsert helper on <code>ctx.db</code> is queued. |
| 192 | </li> |
| 193 | <li> |
| 194 | <strong>transactions</strong> → every <code>mutation()</code> body is a single |
| 195 | transaction by default. mongo's <code>session.withTransaction</code> blocks map |
| 196 | 1:1 — drop the session arg. |
| 197 | </li> |
| 198 | </ul> |
| 199 | </Section> |
| 200 | |
| 201 | <Section title="auth port"> |
| 202 | <p> |
| 203 | mongo apps usually run auth on top of the same database ( |
| 204 | <Link href="/migration/nextauth" className="underline underline-offset-2">nextauth</Link> |
| 205 | 's mongo adapter, lucia's mongo adapter, or a hand-rolled session collection). |
| 206 | the schema maps to better-auth's shape one collection at a time — same drill as the |
| 207 | drizzle/prisma ports. |
| 208 | </p> |
| 209 | </Section> |
| 210 | |
| 211 | <Section title="reactivity (new capability)"> |
| 212 | <p> |
| 213 | mongo apps use change streams for reactive reads. on briven, wrap a read as{' '} |
| 214 | <code>query()</code> and the same call from <code>@briven/react</code>'s{' '} |
| 215 | <code>useQuery</code> auto-refetches on table-level NOTIFYs — no change-stream |
| 216 | subscription, no oplog reader, no per-document filter handling. |
| 217 | </p> |
| 218 | </Section> |
| 219 | |
| 220 | <Section title="parallel-run window — non-negotiable"> |
| 221 | <p> |
| 222 | mongo → relational migrations almost always surface a shape mismatch in week 2 (the |
| 223 | field you thought was optional is required for some old document set, or vice versa). |
| 224 | plan a minimum 14-day parallel run before traffic flips. budget time to fix the |
| 225 | transform script and re-import; the briven schema rarely needs to change. |
| 226 | </p> |
| 227 | </Section> |
| 228 | </DocsShell> |
| 229 | ); |
| 230 | } |
| 231 | |
| 232 | function Section({ title, children }: { title: string; children: React.ReactNode }) { |
| 233 | return ( |
| 234 | <section className="mt-10"> |
| 235 | <h2 className="font-mono text-lg">{title}</h2> |
| 236 | <div className="mt-2 space-y-3 font-mono text-sm text-[var(--color-text-muted)]"> |
| 237 | {children} |
| 238 | </div> |
| 239 | </section> |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | function Snippet({ children }: { children: string }) { |
| 244 | return ( |
| 245 | <pre className="overflow-x-auto rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4 font-mono text-xs"> |
| 246 | <code>{children}</code> |
| 247 | </pre> |
| 248 | ); |
| 249 | } |