Scaling Beyond Per-User: Workspaces and Multi-Session

Scaling Beyond Per-User: Workspaces and Multi-Session

Warpkit ships per-user SQLite as the default. This covers most simple SaaS apps. Two distinct problems arise when you outgrow it.

Problem A: Multiple workspaces per identity

One email, N isolated projects. Example: a user has a "Personal" workspace and a "Acme Corp" workspace, each with separate data.

Migration path:

  1. Add a workspaces table to the shared Drizzle DB (id, name, owner)
  2. Add a workspace_members table (workspaceId, userId, role)
  3. Give each workspace its own SQLite DB: getWorkspaceDb(workspaceId, dbPath)
  4. Replace requireUser() with requireWorkspaceMembership(workspaceId) -- queries shared DB for membership, returns { db, role }
  5. Move billing to workspace level (subscription tied to workspaceId, not userId)
  6. Every server function receives workspaceId as an explicit input param

Problem B: Multiple simultaneous identities

Multiple email logins in one browser session, with fast switching. Example: a freelancer logged in as both alice@personal.com and alice@client.com.

Solution already built in:

Better Auth's multiSession plugin is configured in src/server/auth.ts. Users can be logged in as N emails simultaneously and switch without re-auth.

Combining both

With Problem A (workspaces) and Problem B (multi-session), each identity has its own workspace list. The session switcher UI spans both dimensions: switch identity, then switch workspace within that identity.

What NOT to do

org_members alone solves neither problem. It handles team access within one identity but does not provide:

  • Workspace isolation (separate SQLite DBs)
  • Identity switching (multiple email sessions)

When to evolve

Stay on per-user until you have a concrete user need for one of these patterns. Both add significant complexity. The per-user model covers the majority of B2C SaaS apps.

A third tier: Organization (decided 2026-10-18)

Problem A above turns the per-user model into a two-tier one (User -> Workspace). A third tier, an Organization above the Workspace, was decided for the RunMist family once it became clear that a company needs one invoice, one member list, and one SSO config across several workspaces. The decision record and phased plan live in the RunMist repo, docs/runmist/plans/org-layer-multi-tenancy.md.

Workspaces come first. An organization over per-user SQLite files gives its members nothing to share, because each member's data is still in their own file. That is the "What NOT to do" above: org_members alone solves neither problem. Do Problem A, then add the organization.

Use the better-auth organization plugin (better-auth/plugins) rather than hand-rolled tables. It provides the organization, member, and invitation models, invitation email via sendInvitationEmail, expiry, acceptance bound to the invitee's email, last-owner protection, limits (membershipLimit, invitationLimit), lifecycle hooks, and a permission map via createAccessControl. Keep teams off, and keep your own gate: authorize from the workspace in the URL, never from the plugin's session.activeOrganizationId.

The shape:

  • Organization (plugin model plus server-owned billing fields via additionalFields with input: false) owns the people, the invoice, and SSO.
  • Members and invitations are separate plugin tables, so a pending invite needs no user row.
  • A workspace belongs to exactly one organization.

The boundary that keeps this cheap on SQLite: the organization is metadata only, it never owns a database file. Workspaces keep their own SQLite file and their own project/resource meters, so resource limits stay per-workspace and nothing aggregates across files. Two limits move to the organization, both plain meta-DB counts: members (active plus pending invitations) and workspace count. The workspace-count limit is required: once the plan is billed on the organization, an uncapped user creates a new workspace to get a fresh set of per-workspace limits.

Porting checklist, once the repo has workspaces:

  1. Shared permission map with createAccessControl, and a requirePermission check after requireWorkspaceMembership() in place of ad-hoc role arrays.
  2. Add the plugin and its three tables to the shared Drizzle schema. Add workspaces.organization_id as a nullable column with no REFERENCES (enforced in app code and a trigger): SQLite cannot ADD COLUMN ... NOT NULL REFERENCES, and drizzle-kit's fallback rebuild of workspaces cascade-deletes its children (docs/warpkit/patterns/sqlite-migrations.md).
  3. Backfill one organization per existing workspace, copying every workspace_members row with its role, not just the owner.
  4. Resolve access through organization membership inside requireWorkspaceMembership(), same return shape.
  5. Invitations through the plugin; members limit and workspace-count limit on the organization.
  6. Move billing to the organization: checkout metadata, the Stripe webhook lookup, the portal URL, and the plan read. Leave old UNIQUE billing columns in place unread rather than rebuilding the table.
  7. A "move workspace to organization" action, so 1:1 backfilled orgs can be consolidated onto one invoice.

The shared-database fork cannot use a file per tenant and needs a row-level tenant column instead. See the warpkit-postgres fork's docs/warpkit/architecture/postgres-port.md, "Organization scoping".