History

Org slugs: mint at insert, drop lazy minting, make the column NOT NULL

The org-slug column was nullable and the runtime self-healed it: every resolveOrganization (and the SSR gate) lazily minted a slug for any row that lacked one. That left a permanent nullable state and a heal-on-read cost. Move slug generation to the single moment a row is inserted — upsertOrganization mints the slug at INSERT (ON CONFLICT DO NOTHING absorbs the id/slug races, retrying with a fresh candidate) and keeps the existing slug on conflict. The three insert sites (createOrganization, acceptInvitation, the WorkOS-fallback upsert in resolveOrganization) all get a slug for free; resolveOrganization and the SSR gate's organizationDisplay no longer heal. The schema column is now NOT NULL (migration 0004), backfilled once for legacy rows. Deploy order matters and is documented in the backfill script: backfill → deploy this code → apply 0004. The old code inserts a NULL slug then heals, so NOT NULL cannot land while it still serves. Also adds e2e/cloud/auth-routing-flow.test.ts: one browser session walking the whole arc — signed out → /login → hosted-AuthKit sign-in → org-less onboarding → create org → canonical /<slug> dashboard — through the real dev-server SSR gate. The hops are covered piecewise elsewhere; this is the narrative regression guard and the only test pinning the bare-/ → /<slug>/ dashboard canonicalization a fresh login lands on.

Rhys Sullivan committed 5 months ago

Commit da0a4cc1 parent 1f9bfe013 files · +1429 −88Browse files

Showing the changes to apps/cloud/scripts (1 of 13 files). Show all

Unified · Split

apps/cloud/scripts/backfill-org-slugs.ts+11−8
@@ -1,16 +1,19 @@
11 /* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */
22 // ---------------------------------------------------------------------------
33 // One-off data backfill: mint URL slugs for organizations that predate the
4−// slug column. Run OUT-OF-BAND against the database after applying the
5−// schema migration that adds `organizations.slug` (0002):
4+// slug column (added by migration 0003, nullable).
65 //
76 // bun run db:backfill-org-slugs:prod # op run --env-file=.env.production
87 // bun run db:backfill-org-slugs:dev # against the local PGlite dev db
98 //
10−// Idempotent — already-slugged rows are skipped, so re-running is safe. The
11−// runtime also lazily mints slugs on first resolution (see
12−// auth/organization.ts), so this backfill is belt-and-braces for bulk
13−// pre-warming rather than a deploy gate.
9+// Idempotent — already-slugged rows are skipped, so re-running is safe.
10+//
11+// Ordering matters: the runtime now mints a slug at the moment a row is
12+// inserted and NO LONGER self-heals null slugs, and migration 0004 makes the
13+// column NOT NULL. So this backfill is the SOLE way pre-existing null-slug rows
14+// get a slug, and it MUST run BEFORE 0004 is applied (a null row would fail the
15+// NOT NULL alter). The correct sequence is: backfill (this script) → deploy the
16+// mint-at-insert code → apply 0004.
1417 // Pass --dry-run to print the plan without writing.
1518 // ---------------------------------------------------------------------------
1619
@@ -47,8 +50,8 @@ for (const row of rows) { · 30 unmodified lines
4750 planned.add(slug);
4851 console.log(`${row.id} ${JSON.stringify(row.name)} -> ${slug}`);
4952 if (dryRun) continue;
50− // Guard WHERE slug IS NULL: a concurrently-deployed runtime may have lazily
51− // minted this org's slug between our read and this write.
53+ // Guard WHERE slug IS NULL keeps re-runs idempotent (and tolerates a row that
54+ // gained a slug between our read and this write).
5255 await sql`UPDATE organizations SET slug = ${slug} WHERE id = ${row.id} AND slug IS NULL`;
5356 updated += 1;
5457 }