page.tsx202 lines · main
| 1 | import Link from 'next/link'; |
| 2 | |
| 3 | import { DocsShell } from '../../../components/shell'; |
| 4 | |
| 5 | export const metadata = { title: 'migration · hasura → briven' }; |
| 6 | |
| 7 | export 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 "graphql in front of your postgres". briven is "reactive |
| 18 | functions in front of your postgres". 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'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'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'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'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 "table-as-graphql-root" surface your clients |
| 64 | actually use. don't port every autogenerated field — most of them aren'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'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 "merge another graphql api into |
| 90 | ours." 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 |
| 108 | import { query } from '@briven/cli/server'; |
| 109 | |
| 110 | export 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's auth is "decode a jwt, extract X-Hasura-User-Id, apply |
| 130 | permissions." briven'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'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'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's <code>subscription</code> → briven's reactive{' '} |
| 151 | <code>useQuery("getThing", args)</code>. the shapes look identical from |
| 152 | the client's side; the wire protocol is briven'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 | |
| 185 | function 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 | |
| 196 | function 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 | } |