Showing the changes to apps/cloud/scripts (2 of 33 files). Show all
Unified · Split
| @@ -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 | ); | ||
| @@ -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-console | 52 | // eslint-disable-next-line no-console |