page.tsx202 lines · main
1import Link from 'next/link';
2
3import { DocsShell } from '../../../components/shell';
4
5export const metadata = { title: 'migration · hasura → briven' };
6
7export default function HasuraMigrationPage() {
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">hasura → briven</h1>
16 <p className="mt-2 font-mono text-sm text-[var(--color-text-muted)]">
17 hasura is &quot;graphql in front of your postgres&quot;. briven is &quot;reactive
18 functions in front of your postgres&quot;. the postgres half ports cleanly; the
19 graphql + permissions half becomes function code. plan a 3-5 day window for a
20 mid-size project (50-150 tables, a dozen actions, a handful of remote schemas).
21 </p>
22
23 <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)]">
24 <strong>read this first:</strong> the trap with hasura migrations is that the
25 schema port looks trivial (it&apos;s the same postgres!) and then the permissions
26 port doubles your timeline. <em>permissions are not optional</em> — every public
27 graphql query you exposed had a permission rule, and skipping any of them in the
28 port turns a private query into an open endpoint. step 4 of the playbook is where
29 90% of the work lives.
30 </div>
31
32 <Section title="what carries over for free">
33 <ul className="list-disc pl-5">
34 <li>
35 <strong>tables, columns, indexes, foreign keys, constraints</strong> —{' '}
36 <code>pg_dump --schema-only</code> from the hasura postgres and load it into a
37 scratch postgres; that file is 95% of <code>briven/schema.ts</code>. translate
38 with the cli&apos;s <code>briven import --schema-sql ./schema.sql</code>
39 generator (best-effort; eyeball before committing).
40 </li>
41 <li>
42 <strong>postgres functions + triggers + extensions</strong> —{' '}
43 <code>pg_dump</code> includes them; briven&apos;s data plane runs the same
44 pg17+pgvector image. enable extensions explicitly via the schema dsl so the
45 migration runner knows to load them on a fresh project.
46 </li>
47 <li>
48 <strong>row data</strong> — <code>pg_dump --data-only</code> →{' '}
49 <code>pg_restore</code> through <code>briven db shell-token</code>. row-count
50 both sides; numbers must match.
51 </li>
52 </ul>
53 </Section>
54
55 <Section title="what becomes function code">
56 <p>
57 everything graphql-shaped becomes a briven query/mutation. hasura&apos;s metadata
58 maps as follows:
59 </p>
60 <ul className="list-disc pl-5">
61 <li>
62 <strong>tracked tables / autogenerated queries</strong> — write one{' '}
63 <code>query()</code> per &quot;table-as-graphql-root&quot; surface your clients
64 actually use. don&apos;t port every autogenerated field — most of them aren&apos;t
65 called. grep your frontend for the queries you actually send, port only those.
66 </li>
67 <li>
68 <strong>relationships</strong> — hasura denormalises related rows inline (
69 <code>{`users(... { posts { ... } })`}</code>). in briven this is one or two joins
70 inside the function, or a sibling <code>useQuery</code> on the client. for nested
71 shapes, prefer the join in the function — fewer roundtrips, easier auth.
72 </li>
73 <li>
74 <strong>actions (HTTP webhooks)</strong> — direct port to <code>mutation()</code>{' '}
75 functions. the action&apos;s url + handler becomes the body of the function.
76 </li>
77 <li>
78 <strong>event triggers</strong> — port to either (a) a briven function called from
79 an outbox table that postgres triggers write into, or (b) a postgres trigger that
80 calls <code>pg_notify</code> and a briven listener function picks up. (a) is
81 simpler; (b) is lower latency.
82 </li>
83 <li>
84 <strong>scheduled triggers (cron)</strong> — briven has a cron primitive coming
85 in v1; until then, ship a github actions / external scheduler that hits a briven
86 function on a schedule.
87 </li>
88 <li>
89 <strong>remote schemas</strong> — these were &quot;merge another graphql api into
90 ours.&quot; in briven, write a function that fetches from the remote api in
91 its handler and returns the data. you lose graphql stitching; you gain typed
92 request/response shapes.
93 </li>
94 </ul>
95 </Section>
96
97 <Section title="permissions port">
98 <p>
99 hasura permissions live in metadata: per-table, per-role, with select/insert/update/
100 delete columns + a row filter expression. <em>every one of them</em> needs an
101 equivalent in your function code. there is no shortcut.
102 </p>
103 <Snippet>{`// hasura: a select permission on "notes" for role "user"
104// filter: { user_id: { _eq: "X-Hasura-User-Id" } }
105// columns: [id, body, created_at]
106
107// briven equivalent — inside briven/functions/notes.ts
108import { query } from '@briven/cli/server';
109
110export const list = query({
111 args: { /* none — the user is identified by the session */ },
112 handler: async (ctx) => {
113 const userId = ctx.user?.id;
114 if (!userId) throw new Error('unauthorized');
115 const rows = await ctx.db('notes')
116 .select(['id', 'body', 'created_at']) // mirror the column allowlist
117 .where({ user_id: userId }); // mirror the filter
118 return rows;
119 },
120});`}</Snippet>
121 <p>
122 the pattern is mechanical but tedious. inventory every (role, table, action) triple
123 from your hasura metadata before writing any function code — that list is the work.
124 </p>
125 </Section>
126
127 <Section title="auth port">
128 <p>
129 hasura&apos;s auth is &quot;decode a jwt, extract X-Hasura-User-Id, apply
130 permissions.&quot; briven&apos;s auth is Better Auth — sessions over cookies,
131 token-based for headless clients. two cutover options:
132 </p>
133 <ul className="list-disc pl-5">
134 <li>
135 <strong>preserve user ids</strong> — easiest. your hasura users table has stable
136 ids; export them as-is into briven&apos;s <code>users</code> table via the data
137 copy. users sign in fresh (forced re-auth) but keep their data and links.
138 </li>
139 <li>
140 <strong>preserve jwts</strong> — if you can&apos;t force a re-auth (consumer app
141 with many active sessions), keep your existing auth issuer and validate its jwts
142 in a briven middleware. file a support ticket — this path is supported but not
143 self-service yet.
144 </li>
145 </ul>
146 </Section>
147
148 <Section title="subscriptions port">
149 <p>
150 hasura&apos;s <code>subscription</code> → briven&apos;s reactive{' '}
151 <code>useQuery(&quot;getThing&quot;, args)</code>. the shapes look identical from
152 the client&apos;s side; the wire protocol is briven&apos;s. one difference worth
153 calling out:
154 </p>
155 <ul className="list-disc pl-5">
156 <li>
157 hasura sends incremental updates over a long-lived websocket; briven re-runs the
158 function on every relevant LISTEN/NOTIFY and pushes the full new result. for very
159 large result sets this is more bytes per update — paginate the function or split
160 into smaller subscriptions if it matters.
161 </li>
162 <li>
163 hasura aggregations (<code>_aggregate</code>) over a live query are heavy; the
164 same logic in a briven function lets you pre-compute or cache. mostly an upgrade.
165 </li>
166 </ul>
167 </Section>
168
169 <Section title="cutover checklist">
170 <ul className="list-disc pl-5">
171 <li>schema + indexes + extensions match (pg_dump diff)</li>
172 <li>every (role, table, action) permission has a function equivalent</li>
173 <li>every active client query in your frontend has a briven function</li>
174 <li>actions ported + tested with real upstream</li>
175 <li>event triggers wired</li>
176 <li>auth strategy decided (preserve ids vs preserve jwts)</li>
177 <li>48-hour parallel-run window planned + observed</li>
178 <li>hasura console set to read-only after cutover for 7 days</li>
179 </ul>
180 </Section>
181 </DocsShell>
182 );
183}
184
185function Section({ title, children }: { title: string; children: React.ReactNode }) {
186 return (
187 <section className="mt-10">
188 <h2 className="font-mono text-lg">{title}</h2>
189 <div className="mt-2 space-y-3 font-mono text-sm text-[var(--color-text-muted)]">
190 {children}
191 </div>
192 </section>
193 );
194}
195
196function Snippet({ children }: { children: string }) {
197 return (
198 <pre className="overflow-x-auto rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4 font-mono text-xs">
199 <code>{children}</code>
200 </pre>
201 );
202}