mcpField
A
activerecord-tenanted

activerecord-tenanted

github.com/thinkoodleDevelopment7165 installsUpdated 8w ago
Repository

About activerecord-tenanted

A skill guide for using the Basecamp `activerecord-tenanted` gem to isolate Rails tenant data in separate SQLite databases while keeping identity and tenant records global. It focuses on shard selection, request scoping, jobs, migrations, and testing.

Key features

  1. 01Separate global and tenant records
  2. 02Provision one SQLite database per tenant
  3. 03Set tenant scope from request paths
  4. 04Re-enter tenant scope in jobs
  5. 05Migrate and test tenant databases safely

Use cases

  1. 01Designing a path-based Rails tenancy model
  2. 02Separating identity records from workspace data
  3. 03Running background jobs against tenant records
  4. 04Testing code that requires an active tenant

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/thinkoodle/rails-skills --skill activerecord-tenanted

After installing, type/activerecord-tenantedin Claude Code to confirm it loaded.

Bundled filesreference.md

Details

From the project README

activerecord-tenanted (SQLite per tenant)

One SQLite file per tenant via the Basecamp activerecord-tenanted gem. Identity, sessions, and the tenant row live in a global database. Product data lives in storage/tenants/{env}/{tenant_key}/main.sqlite3.

This is the Herald / Cortex / Innkeeper model. It is not:

  • Nebula acts_as_tenant — one database, workspace_id on every row
  • Fizzy path tenancy — one database, Current.account + SCRIPT_NAME URL prefix

If the app already uses acts_as_tenant or Fizzy-style account slugs in a shared schema, do not introduce this gem.

Philosophy

  1. Two base classes, two databases. GlobalRecord never sees tenant data. ApplicationRecord never sees identities.
  2. No tenanted query without a tenant. User.find outside with_tenant / Current.tenant= is a bug, even in jobs and rake tasks.
  3. The URL (or Current) chooses the shard. The gem's default subdomain resolver is wrong for path-based apps — disable it.
  4. Jobs re-enter the tenant. Active Job does not magically carry the shard. Pass a global id (account/tenant/project) and wrap perform in with_tenant.

What Goes Where

Global (GlobalRecord)Tenanted (ApplicationRecord)
Identity, Session, MagicLinkThe actual product records
Tenant / AccountUser (Cortex/Innkeeper — in-tenant profile)
Membership (Identity ↔ tenant)Everything else in that workspace
JoinCode, API tokensActive Storage blobs (when tenanted_rails_records is on)

Herald keeps Project global (so slugs/API keys exist before a shard is selected) and stores releases/subscribers in the account tenant. Cortex/Innkeeper put almost all product tables in the tenant DB. Both are fine — pick one and stay consistent.

Step 1: Gem + database.yml

# Gemfile
gem "activerecord-tenanted"
# config/database.yml — every environment
primary:
  adapter: sqlite3
  database: storage/tenants/<%= Rails.env %>/%{tenant}/main.sqlite3
  tenanted: true
  max_connection_pools: 50
global:
  adapter: sqlite3
  database: storage/<%= Rails.env %>.sqlite3
  migrations_paths: db/global_migrate
queue:
  adapter: sqlite3
  database: storage/<%= Rails.env %>_queue.sqlite3
  migrations_paths: db/queue_migrate
# cache + cable the same way

%{tenant} is the gem's placeholder. database_tasks: false on primary is optional (Herald sets it so Rails doesn't try to dump a single primary schema).

Migrations:

  • db/global_migrate/ — Identity, Tenant/Account, Membership, Session. Run once.
  • db/migrate/ — tenanted tables. The gem runs these per tenant.
bin/rails db:migrate          # tenanted
bin/rails db:migrate:global    # global — check the gem's task name in the app

Step 2: Two abstract classes

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
  tenanted
end

# app/models/global_record.rb
class GlobalRecord < ActiveRecord::Base
  self.abstract_class = true
  connects_to database: { writing: :global, reading: :global }
end

New model: if it is a person, session, or the tenant row itself → GlobalRecord. If it is data inside a workspace → ApplicationRecord. Getting this wrong is the #1 bug.

Step 3: Disable subdomain resolver + default tenant

Path-based apps (all of ours) must not use the gem's subdomain resolver:

# config/initializers/tenanted.rb
Rails.application.configure do
  config.active_record_tenanted.tenant_resolver = ->(request) { nil }
  config.active_record_tenanted.default_tenant =
    Rails.env.local? ? "#{Rails.env}-tenant" : nil
  config.active_record_tenanted.tenanted_rails_records = true
  config.active_record_tenanted.connection_class = "ApplicationRecord"
end

Production default_tenant must be nil. Otherwise boot can fail with ARTENANT not set, or worse, every request shares one leftover shard. Locally a named default tenant lets console/tests boot.

tenanted_rails_records = true stores Active Storage / Action Text in the tenant DB with prefixed keys. Pair with the active-storage-multitenant skill.

Step 4: Current swaps the shard

class Current < ActiveSupport::CurrentAttributes
  attribute :identity, :session
  attribute :tenant  # or :account — pick one name and use it everywhere
  attribute :user

  def tenant=(tenant)
    super
    return if ActiveRecord::Base.shard_swapping_prohibited?

    ApplicationRecord.current_tenant =
      tenant ? tenant.external_id : ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL
  end
end

Herald uses Current.account= and account.tenant_key ("account-#{id}"). Cortex/Innkeeper use Current.tenant= and tenant.external_id. Same mechanism.

Reset on request end (Cortex):

resets do
  next if ActiveRecord::Base.shard_swapping_prohibited?
  ApplicationRecord.current_tenant = ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL
end

Step 5: Resolve tenant from the URL

Do this in a controller concern, not the gem resolver:

module TenantScoping
  extend ActiveSupport::Concern

  included do
    before_action :set_current_tenant
    before_action :set_current_user
  end

  def set_current_tenant
    record = Tenant.find_by(external_id: params[:tenant_external_id])
    Current.tenant = record if record
  end

  def set_current_user
    return unless Current.identity && Current.tenant
    Current.user = User.find_by(identity_id: Current.identity.id)
  end
end

Routes nest under the tenant key (/:tenant_external_id/... or a project slug that loads Account). Unauthenticated pages (sign-in, webhooks from vendors) skip this and wrap only the tenanted work.

Create the tenant database when the Tenant/Account is created:

after_create :provision_tenant_database

def ensure_tenant_database!
  ApplicationRecord.create_tenant(tenant_key) unless database_exists?
rescue ActiveRecord::Tenanted::TenantExistsError
  # parallel tests / retries
end

Step 6: Jobs, rake, console

def perform(tenant_external_id, record_id)
  ApplicationRecord.with_tenant(tenant_external_id) do
    Current.tenant = Tenant.find_by(external_id: tenant_external_id)
    Record.find(record_id).do_work
  end
end

Never serialize a tenanted Active Record as a GID and expect perform to load it — the job runs untenanted. Pass the global tenant key plus the record id.

Rake / one-off:

Tenant.find_each do |tenant|
  ApplicationRecord.with_tenant(tenant.external_id) do
    puts Project.count
  end
end

Public inbound webhooks with no tenant in the URL: iterate tenants (Innkeeper) or look up a global token that points at a tenant, then with_tenant. Don't query User at the top of the job.

Tests

Every test that touches tenanted models must set a tenant. Use a helper:

def with_tenant(tenant = tenants(:acme), &block)
  ApplicationRecord.with_tenant(tenant.external_id, &block)
end

Integration tests: hit a URL that includes the tenant prefix so TenantScoping runs. Don't stub Current.tenant and then assert on HTTP — the shard won't match the request.

Anti-Patterns

  1. acts_as_tenant :workspace in an app that's already tenanted — two tenancy systems. Nebula is the other model; don't mix.
  2. Putting Identity or Session on ApplicationRecord — those tables must survive switching shards.
  3. User.find in a job without with_tenant — silent empty results or TenantNotSet.
  4. Leaving the subdomain resolver on — path-based apps resolve the wrong tenant or none.
  5. default_tenant in production — every process shares one database file.
  6. Assuming integer PKs in tenant DBs — our tenanted tables are UUIDs. See uuid-primary-keys.
  7. Creating blobs without a current tenant — "Cannot generate a Blob key without a tenant." Use with_tenant and the Active Storage skill.

Related

  • Identity vs User, memberships, join codes: identity-membership
  • Tenanted Active Storage / Lexxy: active-storage-multitenant
  • Outbound webhooks that run in a tenant: outgoing-webhooks
  • Passwordless sign-in (global Identity): magic-link-auth

See reference.md for database.yml, Herald vs Cortex naming, and shard-swap gotchas.

Comments

Markdown supported