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 | 42 | db = await PGlite.create(); |
| 43 | 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 | 50 | await server.start(); |
| 47 | 51 | |
| 48 | 52 | // eslint-disable-next-line no-console |