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
36import { asc, isNull } from "drizzle-orm";
37import { drizzle } from "drizzle-orm/postgres-js";
38import { Effect } from "effect";
39import postgres from "postgres";
40import { WorkOS } from "@workos-inc/node";
41
42import { backfillWorkOsMirror } from "../src/auth/workos-mirror-backfill";
43import { makeWorkOsMirrorStore } from "../src/auth/workos-mirror-store";
44import { organizations } from "../src/db/schema";
45
46const dryRun = process.argv.includes("--dry-run");
47
48const connectionString = process.env.DATABASE_URL;
49if (!connectionString) {
50 console.error("DATABASE_URL is not set");
51 process.exit(1);
52}
53const apiKey = process.env.WORKOS_API_KEY;
54if (!apiKey) {
55 console.error("WORKOS_API_KEY is not set");
56 process.exit(1);
57}
58
59const usesLocalDatabase =
60 connectionString.includes("127.0.0.1") || connectionString.includes("localhost");
61
62const sql = postgres(connectionString, {
63 max: 1,
64 prepare: false,
65 ...(usesLocalDatabase ? {} : { ssl: "require" as const }),
66});
67const db = drizzle(sql);
68const workos = new WorkOS(apiKey);
69
70// The script boundary: raw SDK / driver promises lifted once, here.
71const fromPromise = <A>(fn: () => Promise<A>) =>
72 Effect.tryPromise({ try: fn, catch: (cause) => cause });
73
74await 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
42 db = await PGlite.create();42 db = await PGlite.create();
43 await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER });43 await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER });
44 44
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 });
46 await server.start();50 await server.start();
47 51
48 // eslint-disable-next-line no-console52 // eslint-disable-next-line no-console