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:
- Add a
workspacestable to the shared Drizzle DB (id, name, owner) - Add a
workspace_memberstable (workspaceId, userId, role) - Give each workspace its own SQLite DB:
getWorkspaceDb(workspaceId, dbPath) - Replace
requireUser()withrequireWorkspaceMembership(workspaceId)-- queries shared DB for membership, returns{ db, role } - Move billing to workspace level (subscription tied to workspaceId, not userId)
- Every server function receives
workspaceIdas 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
additionalFieldswithinput: 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:
- Shared permission map with
createAccessControl, and arequirePermissioncheck afterrequireWorkspaceMembership()in place of ad-hoc role arrays. - Add the plugin and its three tables to the shared Drizzle schema. Add
workspaces.organization_idas a nullable column with noREFERENCES(enforced in app code and a trigger): SQLite cannotADD COLUMN ... NOT NULL REFERENCES, and drizzle-kit's fallback rebuild ofworkspacescascade-deletes its children (docs/warpkit/patterns/sqlite-migrations.md). - Backfill one organization per existing workspace, copying every
workspace_membersrow with its role, not just the owner. - Resolve access through organization membership inside
requireWorkspaceMembership(), same return shape. - Invitations through the plugin; members limit and workspace-count limit on the organization.
- Move billing to the organization: checkout metadata, the Stripe webhook
lookup, the portal URL, and the plan read. Leave old
UNIQUEbilling columns in place unread rather than rebuilding the table. - 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".