History

Feed the cloud membership mirror from login and member writes (#2004)

Rhys Sullivan committed 1 month ago

Commit ddfe3f51 parent 89f424633 files · +5374 −606Browse files

Showing the changes to apps/cloud/scripts (2 of 33 files). Show all

Unified · Split

2 files
  1. apps/cloud/scripts/backfill-workos-mirror.ts +102 −0
  2. apps/cloud/scripts/test-globalsetup.ts +5 −1
apps/cloud/scripts/backfill-workos-mirror.ts+102−0
@@ -0,0 +1,102 @@
1+// ---------------------------------------------------------------------------
2+// One-off data backfill: fill the membership mirror (`accounts` profile
3+// columns + `memberships` rows, migration 0018) from WorkOS for every
4+// organization the mirror already knows.
5+//
6+// bun run db:backfill-workos-mirror:prod # op run --env-file=.env.production
7+// bun run db:backfill-workos-mirror:dev # against the local PGlite dev db
8+//
9+// For each live row in `organizations`: list EVERY membership WorkOS holds
10+// for it (active, pending, and inactive — a listing that skipped inactive
11+// ones would have the scan tombstone them, see `auth/workos-mirror-backfill.ts`),
12+// fetch each member's user (concurrency 5), and apply the listing in one
13+// transaction through the same guarded store the request path
14+// uses (`auth/workos-mirror-store.ts`): upsert user + membership, tombstone
15+// any mirrored membership of that org WorkOS no longer lists, and mark the
16+// org backfilled (`organizations.backfilled_at`) — the per-org mark the seat
17+// gates check before trusting a count from the mirror; an org left unmarked
18+// is scanned on demand the first time its seats are counted. Idempotent —
19+// the upserts refuse anything older than the stored WorkOS `updatedAt`, so
20+// re-running is safe, never rewinds a fresher row, and repairs a stale one;
21+// and a listing older than one already applied (two runs overlapping) is
22+// refused whole, so it cannot resurrect a membership the later listing found
23+// gone. Pass --dry-run to read and count without writing.
24+//
25+// DEPLOY ORDER: run this against production BEFORE deploying the builds that
26+// reconcile from the Events API and read seat counts from the mirror, so no
27+// request pays for an on-demand scan. The FIRST completed run records the
28+// events replay boundary (the reconciler's first run reads from it; without
29+// one it waits); later runs keep it, since only the events stream covers the
30+// org renames and user deletions between two runs. A run that fails part-way
31+// keeps the marks of the orgs it finished, records no boundary, and is safe
32+// to repeat. Verify the printed membership count against the WorkOS
33+// dashboard.
34+// ---------------------------------------------------------------------------
35+
36+import { asc, isNull } from "drizzle-orm";
37+import { drizzle } from "drizzle-orm/postgres-js";
38+import { Effect } from "effect";
39+import postgres from "postgres";
40+import { WorkOS } from "@workos-inc/node";
41+
42+import { backfillWorkOsMirror } from "../src/auth/workos-mirror-backfill";
43+import { makeWorkOsMirrorStore } from "../src/auth/workos-mirror-store";
44+import { organizations } from "../src/db/schema";
45+
46+const dryRun = process.argv.includes("--dry-run");
47+
48+const connectionString = process.env.DATABASE_URL;
49+if (!connectionString) {
50+ console.error("DATABASE_URL is not set");
51+ process.exit(1);
52+}
53+const apiKey = process.env.WORKOS_API_KEY;
54+if (!apiKey) {
55+ console.error("WORKOS_API_KEY is not set");
56+ process.exit(1);
57+}
58+
59+const usesLocalDatabase =
60+ connectionString.includes("127.0.0.1") || connectionString.includes("localhost");
61+
62+const sql = postgres(connectionString, {
63+ max: 1,
64+ prepare: false,
65+ ...(usesLocalDatabase ? {} : { ssl: "require" as const }),
66+});
67+const db = drizzle(sql);
68+const workos = new WorkOS(apiKey);
69+
70+// The script boundary: raw SDK / driver promises lifted once, here.
71+const fromPromise = <A>(fn: () => Promise<A>) =>
72+ Effect.tryPromise({ try: fn, catch: (cause) => cause });
73+
74+await Effect.runPromise(
75+ backfillWorkOsMirror(
76+ {
77+ listOrganizationIds: () =>
78+ fromPromise(async () => {
79+ // Never a deleted organization: its row is a tombstone (its
80+ // memberships are purged, WorkOS no longer has it) and the mirror
81+ // refuses a scan of it anyway.
82+ const rows = await db
83+ .select({ id: organizations.id })
84+ .from(organizations)
85+ .where(isNull(organizations.deletedAt))
86+ .orderBy(asc(organizations.createdAt));
87+ return rows.map((row) => row.id);
88+ }),
89+ listOrgMembers: (organizationId) =>
90+ fromPromise(async () => {
91+ const page = await workos.userManagement.listOrganizationMemberships({
92+ organizationId,
93+ statuses: ["active", "pending", "inactive"],
94+ });
95+ return page.listMetadata.after ? page.autoPagination() : page.data;
96+ }),
97+ getUser: (userId) => fromPromise(() => workos.userManagement.getUser(userId)),
98+ },
99+ makeWorkOsMirrorStore(db),
100+ { dryRun, log: (line) => console.log(line) },
101+ ).pipe(Effect.ensuring(Effect.promise(() => sql.end({ timeout: 5 })))),
102+);
apps/cloud/scripts/test-globalsetup.ts+5−1
@@ -42,7 +42,11 @@ export default async function setup() { · 41 unmodified lines
4242 db = await PGlite.create();
4343 await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER });
4444
45− server = new PGLiteSocketServer({ db, port: PORT, host: "127.0.0.1" });
45+ // PGlite is single-session; pglite-socket multiplexes connections onto it
46+ // by queueing whole transactions. Two connections let a test open two
47+ // transactions and interleave them (the mirror's scan-vs-feeder race in
48+ // auth/workos-mirror.node.test.ts); a third would be refused at once.
49+ server = new PGLiteSocketServer({ db, port: PORT, host: "127.0.0.1", maxConnections: 2 });
4650 await server.start();
4751
4852 // eslint-disable-next-line no-console