page.tsx221 lines · main
1import Link from 'next/link';
2
3import { DocsShell } from '../../../components/shell';
4
5export const metadata = { title: 'migration · firebase → briven' };
6
7export default function FirebaseMigrationPage() {
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">firebase → briven</h1>
16 <p className="mt-2 font-mono text-sm text-[var(--color-text-muted)]">
17 the hardest path. firebase is a document database, firebase auth is its own world,
18 firebase storage is GCS. briven is postgres + Better Auth + S3-compatible. plan a
19 2+ week parallel-run window.
20 </p>
21
22 <div className="mt-6 rounded-md border border-[var(--color-text-error)] bg-[var(--color-surface)] p-4 font-mono text-xs text-[var(--color-text-muted)]">
23 <strong>read this first:</strong> firebase migrations expose document-shape mismatches
24 that don&apos;t show up in unit tests. if you have a firestore field that&apos;s
25 sometimes a string and sometimes a number, it lives across two columns on briven —
26 catch this in step 1 of the playbook (inventory) by sampling 1k rows per collection
27 and noting every field&apos;s observed types.
28 </div>
29
30 <Section title="document → relational remap">
31 <p>three patterns cover most firestore collections:</p>
32 <ul className="list-disc pl-5">
33 <li>
34 <strong>flat collection</strong> — every document has the same shape. trivial: one
35 briven table, one column per field. denormalised <code>map</code> fields can become
36 either <code>jsonb()</code> (when you don&apos;t query inside) or sibling columns
37 (when you do).
38 </li>
39 <li>
40 <strong>subcollection</strong> — <code>users/&lt;uid&gt;/notes/&lt;noteId&gt;</code>{' '}
41 becomes a child table with a foreign key to the parent.{' '}
42 <code>userId text().references(&apos;users&apos;, &apos;id&apos;)</code>. queries change shape
43 (from <code>collection(db, &apos;users&apos;, uid, &apos;notes&apos;)</code> to{' '}
44 <code>ctx.db(&apos;notes&apos;).where({`{ userId }`})</code>) but the security model
45 is clearer.
46 </li>
47 <li>
48 <strong>polymorphic union</strong> — a single collection where{' '}
49 <code>type</code> drives which fields are populated. either:
50 (a) one table with all union-shaped columns nullable, or
51 (b) a base table + per-type child tables joined by{' '}
52 <code>id</code>. (a) is faster to migrate; (b) is tighter to maintain. pick (a) for
53 year-one and revisit.
54 </li>
55 </ul>
56 </Section>
57
58 <Section title="schema sketch">
59 <p>
60 firestore <code>users/&lt;uid&gt;</code> + <code>users/&lt;uid&gt;/notes/&lt;noteId&gt;</code>:
61 </p>
62 <Snippet>{`// firestore (informal)
63users/<uid> = {
64 email: string,
65 displayName?: string,
66 preferences: { theme: 'light' | 'dark', density: 'compact' | 'comfy' },
67 createdAt: Timestamp,
68}
69
70users/<uid>/notes/<noteId> = {
71 body: string,
72 archived?: boolean,
73 authorId: ref('users/<uid>'), // implicit in firestore
74 createdAt: Timestamp,
75}
76
77// briven
78import { bigint, boolean, jsonb, schema, table, text } from '@briven/cli/schema';
79
80interface Preferences {
81 theme: 'light' | 'dark';
82 density: 'compact' | 'comfy';
83}
84
85export default schema({
86 users: table({
87 columns: {
88 id: text().primaryKey(),
89 email: text().notNull(),
90 displayName: text(),
91 preferences: jsonb<Preferences>().notNull().default("'{}'"),
92 createdAt: bigint().notNull(),
93 },
94 indexes: [{ columns: ['email'], unique: true }],
95 }),
96 notes: table({
97 columns: {
98 id: text().primaryKey(),
99 userId: text().notNull().references('users', 'id'),
100 body: text().notNull(),
101 archived: boolean().notNull().default('false'),
102 createdAt: bigint().notNull(),
103 },
104 indexes: [{ columns: ['userId', 'createdAt'] }],
105 }),
106});`}</Snippet>
107 </Section>
108
109 <Section title="data export — firestore → briven">
110 <p>
111 firebase&apos;s admin SDK can stream a collection as ndjson. write a one-shot node
112 script that walks every collection and pushes into briven via the cli&apos;s
113 <code> briven db shell-token</code> dsn:
114 </p>
115 <Snippet>{`// migrate.ts
116import admin from 'firebase-admin';
117import postgres from 'postgres';
118import { execSync } from 'node:child_process';
119
120admin.initializeApp({ credential: admin.credential.applicationDefault() });
121const dsn = execSync('briven db shell-token').toString().trim();
122const sql = postgres(dsn);
123
124const usersSnap = await admin.firestore().collection('users').get();
125for (const doc of usersSnap.docs) {
126 const data = doc.data();
127 await sql\`
128 INSERT INTO users (id, email, display_name, preferences, created_at)
129 VALUES (\${doc.id}, \${data.email}, \${data.displayName ?? null},
130 \${JSON.stringify(data.preferences ?? {})},
131 \${data.createdAt.toMillis()})
132 ON CONFLICT (id) DO UPDATE SET
133 email = EXCLUDED.email,
134 display_name = EXCLUDED.display_name,
135 preferences = EXCLUDED.preferences
136 \`;
137 // Stream subcollections.
138 const notesSnap = await doc.ref.collection('notes').get();
139 for (const note of notesSnap.docs) {
140 const n = note.data();
141 await sql\`
142 INSERT INTO notes (id, user_id, body, archived, created_at)
143 VALUES (\${note.id}, \${doc.id}, \${n.body}, \${n.archived ?? false},
144 \${n.createdAt.toMillis()})
145 ON CONFLICT (id) DO UPDATE SET body = EXCLUDED.body, archived = EXCLUDED.archived
146 \`;
147 }
148}
149await sql.end();
150console.log('done');`}</Snippet>
151 <p>
152 run it twice during the parallel-run window — first to seed, then again right
153 before cutover to pick up writes that landed on firestore in the meantime.{' '}
154 <code>ON CONFLICT DO UPDATE</code> makes both runs idempotent.
155 </p>
156 </Section>
157
158 <Section title="auth port">
159 <p>
160 firebase auth → Better Auth. preserve <code>users.id</code> by passing the
161 firebase <code>uid</code> as the briven user id during the export above. the cutover
162 is a forced sign-in: users keep their email, get a fresh session.
163 </p>
164 <p>
165 if you used firebase&apos;s phone auth, briven doesn&apos;t have a first-class phone
166 provider yet. plan to migrate phone-only users to email-or-magic-link before the
167 cutover.
168 </p>
169 </Section>
170
171 <Section title="storage port">
172 <p>
173 firebase storage is GCS. briven.tech uses MinIO; self-host is whatever
174 S3-compatible bucket you point it at. <code>gsutil rsync</code> from your firestore
175 bucket into a fresh briven bucket; the path layout is a free choice — keep your
176 existing prefix structure and update your function code to read from{' '}
177 <code>${`{projectId}/${`oldPrefix`}/${`...`}`}</code>.
178 </p>
179 </Section>
180
181 <Section title="reactivity port">
182 <p>
183 firestore&apos;s <code>onSnapshot</code> → briven&apos;s{' '}
184 <code>useQuery(&quot;getThing&quot;, args)</code>. shapes are similar; the
185 differences:
186 </p>
187 <ul className="list-disc pl-5">
188 <li>
189 firestore subscribes to a query path; briven subscribes to a function. write the
190 function once, every client uses it.
191 </li>
192 <li>
193 firestore returns <code>QuerySnapshot</code> with per-document change events;
194 briven returns the function&apos;s full return value on every NOTIFY. for
195 high-fanout collections, this means more bytes over the wire — diff client-side
196 if it matters, or paginate the function.
197 </li>
198 </ul>
199 </Section>
200 </DocsShell>
201 );
202}
203
204function Section({ title, children }: { title: string; children: React.ReactNode }) {
205 return (
206 <section className="mt-10">
207 <h2 className="font-mono text-lg">{title}</h2>
208 <div className="mt-2 space-y-3 font-mono text-sm text-[var(--color-text-muted)]">
209 {children}
210 </div>
211 </section>
212 );
213}
214
215function Snippet({ children }: { children: string }) {
216 return (
217 <pre className="overflow-x-auto rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4 font-mono text-xs">
218 <code>{children}</code>
219 </pre>
220 );
221}