// --------------------------------------------------------------------------- // One-off data backfill: fill the membership mirror (`accounts` profile // columns + `memberships` rows, migration 0018) from WorkOS for every // organization the mirror already knows. // // bun run db:backfill-workos-mirror:prod # op run --env-file=.env.production // bun run db:backfill-workos-mirror:dev # against the local PGlite dev db // // For each live row in `organizations`: list EVERY membership WorkOS holds // for it (active, pending, and inactive — a listing that skipped inactive // ones would have the scan tombstone them, see `auth/workos-mirror-backfill.ts`), // fetch each member's user (concurrency 5), and apply the listing in one // transaction through the same guarded store the request path // uses (`auth/workos-mirror-store.ts`): upsert user + membership, tombstone // any mirrored membership of that org WorkOS no longer lists, and mark the // org backfilled (`organizations.backfilled_at`) — the per-org mark the seat // gates check before trusting a count from the mirror; an org left unmarked // is scanned on demand the first time its seats are counted. Idempotent — // the upserts refuse anything older than the stored WorkOS `updatedAt`, so // re-running is safe, never rewinds a fresher row, and repairs a stale one; // and a listing older than one already applied (two runs overlapping) is // refused whole, so it cannot resurrect a membership the later listing found // gone. Pass --dry-run to read and count without writing. // // DEPLOY ORDER: run this against production BEFORE deploying the builds that // reconcile from the Events API and read seat counts from the mirror, so no // request pays for an on-demand scan. The FIRST run records the events // replay boundary BEFORE it lists anything (the reconciler's first run reads // from it; without one it waits); later runs — a retry included — keep it, // since only the events stream covers the org renames and user deletions // after that instant. A run that fails part-way keeps the marks of the orgs // it finished and the boundary it recorded, and is safe to repeat. Verify // the printed membership count against the WorkOS dashboard. // --------------------------------------------------------------------------- import { asc, isNull } from "drizzle-orm"; import { drizzle } from "drizzle-orm/postgres-js"; import { Effect } from "effect"; import postgres from "postgres"; import { WorkOS } from "@workos-inc/node"; import { backfillWorkOsMirror } from "../src/auth/workos-mirror-backfill"; import { makeWorkOsMirrorStore } from "../src/auth/workos-mirror-store"; import { organizations } from "../src/db/schema"; const dryRun = process.argv.includes("--dry-run"); const connectionString = process.env.DATABASE_URL; if (!connectionString) { console.error("DATABASE_URL is not set"); process.exit(1); } const apiKey = process.env.WORKOS_API_KEY; if (!apiKey) { console.error("WORKOS_API_KEY is not set"); process.exit(1); } const usesLocalDatabase = connectionString.includes("127.0.0.1") || connectionString.includes("localhost"); const sql = postgres(connectionString, { max: 1, prepare: false, ...(usesLocalDatabase ? {} : { ssl: "require" as const }), }); const db = drizzle(sql); const workos = new WorkOS(apiKey); // The script boundary: raw SDK / driver promises lifted once, here. const fromPromise = (fn: () => Promise) => Effect.tryPromise({ try: fn, catch: (cause) => cause }); await Effect.runPromise( backfillWorkOsMirror( { listOrganizationIds: () => fromPromise(async () => { // Never a deleted organization: its row is a tombstone (its // memberships are purged, WorkOS no longer has it) and the mirror // refuses a scan of it anyway. const rows = await db .select({ id: organizations.id }) .from(organizations) .where(isNull(organizations.deletedAt)) .orderBy(asc(organizations.createdAt)); return rows.map((row) => row.id); }), listOrgMembers: (organizationId) => fromPromise(async () => { const page = await workos.userManagement.listOrganizationMemberships({ organizationId, statuses: ["active", "pending", "inactive"], }); return page.listMetadata.after ? page.autoPagination() : page.data; }), getUser: (userId) => fromPromise(() => workos.userManagement.getUser(userId)), }, makeWorkOsMirrorStore(db), { dryRun, log: (line) => console.log(line) }, ).pipe(Effect.ensuring(Effect.promise(() => sql.end({ timeout: 5 })))), );