page.tsx249 lines · main
1import Link from 'next/link';
2
3import { DocsShell } from '../../../components/shell';
4
5export const metadata = { title: 'migration · mongodb → briven' };
6
7export 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&apos;t the dump/restore — it&apos;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&lt;T&gt;()</code> is a first-class column type, so
35 you don&apos;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&apos;t decide, flatten. it&apos;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)
62const 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
78import { boolean, bigint, jsonb, schema, table, text, timestamp } from '@briven/cli/schema';
79
80export 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(&apos;<em>prefix</em>&apos;)</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&apos;s default 64-bit double works
114 for counters; briven&apos;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&apos;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)
133mongoexport \\
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
148psql "$BRIVEN_PROJECT_DSN" -c "\\copy posts FROM 'posts.csv' CSV HEADER"
149psql "$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
159const 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
165import { brivenError, query, type Ctx } from '@briven/cli/server';
166interface Args { authorId: string; limit?: number }
167export 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&apos;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 &apos;s mongo adapter, lucia&apos;s mongo adapter, or a hand-rolled session collection).
206 the schema maps to better-auth&apos;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>&apos;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
232function 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
243function 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}