// --------------------------------------------------------------------------- // Cloud-specific identity & multi-tenancy tables // --------------------------------------------------------------------------- // // AuthKit owns the canonical user/membership data. We mirror it locally: // // - `accounts` — login identity + profile (foreign key anchor for // created_by, etc.; email/name/avatar for member lists) // - `organizations` — billing entity, scoping root for all domain data // - `memberships` — which accounts belong to which organizations, with // the WorkOS role and status // - `workos_sync` — the WorkOS Events API cursor the reconciler resumes // from, and the replay boundary the one-off backfill // records // // The mirror is fed by login (the callback has the user + memberships in // hand), write-through on every Executor-initiated change, and the WorkOS // Events API (dashboard-side changes). It is the read path for membership and // member lists — WorkOS is a write target and an event source, never a // per-request read. Invitations are NOT mirrored; they stay live in WorkOS. // // `workos_updated_at` on `accounts` and `memberships` is the WorkOS // `updatedAt` of the payload that last wrote the row. Every upsert is guarded // on it, so feeders can be replayed and reordered without an older payload // clobbering a newer one. import { sql } from "drizzle-orm"; import { index, pgTable, primaryKey, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; /** * Login identity + mirrored WorkOS profile. The `id` is the WorkOS user ID. * Profile columns are nullable because a row can be minted by `ensureAccount` * (an api-key path, a membership arriving before its user event) with nothing * but the id; the next user payload fills them in. */ export const accounts = pgTable( "accounts", { id: text("id").primaryKey(), email: text("email"), firstName: text("first_name"), lastName: text("last_name"), avatarUrl: text("avatar_url"), /** WorkOS `updatedAt` of the user payload that last wrote this row. */ workosUpdatedAt: timestamp("workos_updated_at", { withTimezone: true }), lastSignInAt: timestamp("last_sign_in_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ // `findByEmail` and the search filter compare lower-cased; the index // matches that expression so the lookup stays indexed. emailLowerIdx: index("accounts_email_lower_idx").on(sql`lower(${t.email})`), }), ); /** * Organization (billing entity, scoping root). The `id` is the WorkOS * organization ID. The `slug` is OURS, not WorkOS's (their org object has no * slug): minted at the moment a row is inserted (the single mint point is * `upsertOrganization`) and stable across renames so org URLs don't break. * NOT NULL — there is no nullable window: legacy rows were backfilled once and * every insert since carries a slug. */ export const organizations = pgTable( "organizations", { id: text("id").primaryKey(), name: text("name").notNull(), slug: text("slug").notNull(), /** * When this organization's membership list was last FULLY scanned from * WorkOS (the one-off backfill, or the on-demand scan a seat count * triggers), or null if it never was. Until then the mirror may hold only * the members login and write-through happened to record, so a count read * from it is partial; every seat gate checks this mark first. Per * organization, never database-wide: an org mirrored lazily after a * backfill ran starts unmarked and is scanned on its first count. The * mark also orders membership writes: a payload stamped before it is * refused, since the scan was the full listing at that instant and a * membership it did not contain was revoked before it — before the * events replay boundary, so nothing would tombstone it again. */ backfilledAt: timestamp("backfilled_at", { withTimezone: true }), /** * When this organization was deleted, or null while it is live. Set FIRST * by cloud's own deletion flow (`auth/handlers.ts` deleteOrganization), * before the WorkOS delete and the local purge, and by the * `organization.deleted` event for an org deleted in the WorkOS dashboard * — which MINTS the row as a tombstone when the mirror has never seen the * organization: membership is authorized from the local mirror, so a * marked organization refuses every session at once, whether or not the * later steps land. Membership rows are left as they are until the purge * (so the admin who started the deletion can retry it after a step * failed), and the purge (`db/org-deletion.ts`) removes them but KEEPS * this row as a tombstone: a feeder that fetched a membership before the * deletion and writes it after (a login that stalled across the deletion) * finds the tombstone and does not mint the organization live. A marked * organization is never renamed. */ deletedAt: timestamp("deleted_at", { withTimezone: true }), /** * The instant the stored `name` is known to have been the organization's * name in WorkOS: the WorkOS `updatedAt` of the organization payload that * wrote it, or — for a name learned from a membership list at sign-in, * which carries no organization timestamp — the instant that list was * fetched. A name write stamped earlier than this is refused * (`upsertOrganization`), so a sign-in whose list predates a rename cannot * revert it. Null only on rows written before the stamp existed. */ workosUpdatedAt: timestamp("workos_updated_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ slugUnique: uniqueIndex("organizations_slug_unique").on(t.slug), }), ); /** * Account ↔ organization link, mirroring the WorkOS organization membership. * Answers "which workspaces does this account belong to?" and "is this caller * an active member with which role?" without a WorkOS round-trip, and gives * per-(account, organization) data a foreign key to point at. * * `membershipId` is the WorkOS `om_…` id — nullable only because rows written * before the mirror existed carry none; every feeder sets it. `role` is the * WorkOS role slug as issued (`admin` / `member`); `status` is the WorkOS * membership status (`active` / `pending` / `inactive`). A membership deleted * in WorkOS is never dropped here: it is tombstoned as `inactive` with * `deleted_at` set, so a feeder replaying an older payload cannot resurrect it. * * `deleted_at` means "the membership under `membership_id` was DELETED in * WorkOS" — an identity fact, not a timestamp to order payloads by. WorkOS * never reuses a deleted `om_…` id, so any payload naming that id is stale * however it is stamped, and a payload naming a DIFFERENT id for the same * (account, organization) is the member re-added: a replacement, ordered by * `workos_updated_at` like every other write. Distinct from `status = * 'inactive'` with `deleted_at` null, which is a membership WorkOS * deactivated but still holds and can reactivate under the same id. */ export const memberships = pgTable( "memberships", { accountId: text("account_id") .notNull() .references(() => accounts.id, { onDelete: "cascade" }), organizationId: text("organization_id") .notNull() .references(() => organizations.id, { onDelete: "cascade" }), membershipId: text("membership_id"), role: text("role").notNull().default("member"), status: text("status", { enum: ["active", "pending", "inactive"] }) .notNull() .default("active"), /** WorkOS `updatedAt` of the membership payload that last wrote this row. */ workosUpdatedAt: timestamp("workos_updated_at", { withTimezone: true }), /** * When the membership under `membership_id` was deleted in WorkOS, or null * while WorkOS still holds it (whatever its `status`). Set once; cleared * only when a replacement membership (another id) takes the row over. */ deletedAt: timestamp("deleted_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ pk: primaryKey({ columns: [t.accountId, t.organizationId] }), membershipIdUnique: uniqueIndex("memberships_membership_id_unique").on(t.membershipId), organizationIdx: index("memberships_organization_id_idx").on(t.organizationId), }), ); /** * Every WorkOS membership id (`om_…`) a DELETE has named, keyed by that id * alone. The `memberships` row is keyed by (account, organization) and holds * ONE membership id, so a row-level tombstone can only record the deletion of * the id the row happens to carry: a delete of membership B arriving while * the row still holds an older membership A of the same pair (A replaced by * B in WorkOS before the mirror saw either, B then deleted) has no row to * tombstone, and a later payload of B — stamped after A, under another id — * would take the row over live. This ledger is what the mirror consults * instead: a delete always records the id here, whatever the row holds, and * no membership write ever names a recorded id again, however it is stamped * — WorkOS never reuses a deleted `om_…` id. `deleted_at` records when; it * orders nothing. Rows cascade with their account and organization. */ export const membershipTombstones = pgTable( "membership_tombstones", { membershipId: text("membership_id").primaryKey(), accountId: text("account_id") .notNull() .references(() => accounts.id, { onDelete: "cascade" }), organizationId: text("organization_id") .notNull() .references(() => organizations.id, { onDelete: "cascade" }), deletedAt: timestamp("deleted_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ organizationIdx: index("membership_tombstones_organization_id_idx").on(t.organizationId), }), ); /** * The WorkOS Events API sync state. One row per stream (`id` names the * stream; the reconciler uses `"events"`), holding the id of the last event * applied. Advanced only by compare-and-set, so two concurrent reconciler * runs cannot both believe they own the stream: the loser's CAS fails and it * stops. * * `range_start` on the `"events"` row is the REPLAY BOUNDARY: the instant the * FIRST one-off backfill run (`scripts/backfill-workos-mirror.ts`) began * reading WorkOS, recorded BEFORE its first listing. Everything before it is * covered by that backfill; the reconciler's first run (no cursor yet) reads * the events stream from here, so a revocation between the backfill and the * first run is never skipped. Written once: a run that fails part-way leaves * it standing and its retry keeps it, and a later run keeps it too, because * the backfill does not refresh everything the events stream carries * (organization renames, deleted users' profiles) — those after the first * boundary are replayed from it. Without a cursor or a boundary the * reconciler does not guess; it waits for the backfill. * * `backfill_completed_at` is when a backfill run first wrote EVERY live * organization (`scripts/backfill-workos-mirror.ts` completing, or refusing * an organization only because a later listing was already applied). Until * it is set, the mirror may lack members who have not signed in since it * shipped, so a membership check read from it would deny them: it is the * first half of the mirror-readiness mark the authorization path consults * before trusting the mirror over WorkOS. Write-once — a later completed run * keeps the first instant, so readiness never flips back. Per-organization * completeness for the seat gates is tracked separately * (`organizations.backfilled_at`). * * `drained_at` is when a reconciler run last read the events stream to its * END (an empty page, or a page with nothing after it) — the second half of * the readiness mark: a mirror whose reconciler has not caught up recently * may still grant a member WorkOS already revoked, so the authorization * path trusts the mirror only while this is within its lag budget. Moved * forward by every draining run; never cleared. A run that stops at its * page budget or yields to another run leaves it as it was. * * Migration 0019 seeds the boundary and the completion mark on a database * with no organizations, where there is nothing to backfill; `drained_at` * is left for the reconciler's first run to set (migration 0020). */ export const workosSync = pgTable("workos_sync", { id: text("id").primaryKey(), cursor: text("cursor"), rangeStart: timestamp("range_start", { withTimezone: true }), backfillCompletedAt: timestamp("backfill_completed_at", { withTimezone: true }), drainedAt: timestamp("drained_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), });