page.tsx221 lines · main
| 1 | import Link from 'next/link'; |
| 2 | |
| 3 | import { DocsShell } from '../../../components/shell'; |
| 4 | |
| 5 | export const metadata = { title: 'migration · firebase → briven' }; |
| 6 | |
| 7 | export 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't show up in unit tests. if you have a firestore field that'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'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't query inside) or sibling columns |
| 37 | (when you do). |
| 38 | </li> |
| 39 | <li> |
| 40 | <strong>subcollection</strong> — <code>users/<uid>/notes/<noteId></code>{' '} |
| 41 | becomes a child table with a foreign key to the parent.{' '} |
| 42 | <code>userId text().references('users', 'id')</code>. queries change shape |
| 43 | (from <code>collection(db, 'users', uid, 'notes')</code> to{' '} |
| 44 | <code>ctx.db('notes').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/<uid></code> + <code>users/<uid>/notes/<noteId></code>: |
| 61 | </p> |
| 62 | <Snippet>{`// firestore (informal) |
| 63 | users/<uid> = { |
| 64 | email: string, |
| 65 | displayName?: string, |
| 66 | preferences: { theme: 'light' | 'dark', density: 'compact' | 'comfy' }, |
| 67 | createdAt: Timestamp, |
| 68 | } |
| 69 | |
| 70 | users/<uid>/notes/<noteId> = { |
| 71 | body: string, |
| 72 | archived?: boolean, |
| 73 | authorId: ref('users/<uid>'), // implicit in firestore |
| 74 | createdAt: Timestamp, |
| 75 | } |
| 76 | |
| 77 | // briven |
| 78 | import { bigint, boolean, jsonb, schema, table, text } from '@briven/cli/schema'; |
| 79 | |
| 80 | interface Preferences { |
| 81 | theme: 'light' | 'dark'; |
| 82 | density: 'compact' | 'comfy'; |
| 83 | } |
| 84 | |
| 85 | export 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'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's |
| 113 | <code> briven db shell-token</code> dsn: |
| 114 | </p> |
| 115 | <Snippet>{`// migrate.ts |
| 116 | import admin from 'firebase-admin'; |
| 117 | import postgres from 'postgres'; |
| 118 | import { execSync } from 'node:child_process'; |
| 119 | |
| 120 | admin.initializeApp({ credential: admin.credential.applicationDefault() }); |
| 121 | const dsn = execSync('briven db shell-token').toString().trim(); |
| 122 | const sql = postgres(dsn); |
| 123 | |
| 124 | const usersSnap = await admin.firestore().collection('users').get(); |
| 125 | for (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 | } |
| 149 | await sql.end(); |
| 150 | console.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's phone auth, briven doesn'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's <code>onSnapshot</code> → briven's{' '} |
| 184 | <code>useQuery("getThing", 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'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 | |
| 204 | function 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 | |
| 215 | function 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 | } |