Properties
category: reference tags: [architecture, database, multi-tenancy] last_updated: 2026-03-17 confidence: high
Per-Wiki Database Design
Decision (2026-03-17)
Implementing Option A (per-request DB swap via monkey-patching) now. A cleaner upstream refactor (contextvars-based dependency injection) is sketched below and will be proposed to redimp as a separate PR. Option A is throwaway work if the upstream refactor lands, but unblocks per-wiki permissions immediately.
Current State
Otterwiki uses a single shared SQLite DB (/tmp/otterwiki_default.db) for all tenants. The Preferences, User, Drafts, and Cache tables are global. The TenantResolver swaps GitStorage (the git repo path) per-request but never swaps the SQLAlchemy DB binding.
This hasn't caused problems because:
ProxyHeaderAuth.has_permission()ignores the Preferences table entirely — permissions come from proxy headers- User Management and Permissions admin panels are disabled via
PLATFORM_MODE - The
Usertable is effectively dead code underProxyHeaderAuth
What the resolver swaps today
| Component | Swapped? | How |
|---|---|---|
| GitStorage | Yes | Monkey-patches storage across 7 modules |
| GitHttpServer | Yes | Recreated per-request |
app.config["REPOSITORY"] |
Yes | Direct assignment |
SQLAlchemy db |
No | All wikis share one DB |
app_renderer |
No | Created once at import time |
| Flask-Mail | No | Never swapped |
app.config (other keys) |
No | SITE_NAME, permissions, etc. are global |
Option A: Per-Request DB Swap (implementing now)
Storage layout
/srv/data/
├── robot.db (platform: users, acls, wikis, oauth)
└── wikis/{slug}/
├── repo/ (git repo — already exists)
└── wiki.db (otterwiki: preferences, drafts, user, cache)
Mechanism
- Resolver calls
_swap_database(wiki_slug)after_swap_storage(repo_path) - Updates
app.config["SQLALCHEMY_DATABASE_URI"]tosqlite:////srv/data/wikis/{slug}/wiki.db - Calls
db.engine.dispose()to close connections to previous DB - Lazy-creates
wiki.dbwith schema if it doesn't exist (first request) - Calls
update_app_config()to reload Preferences intoapp.config
Schema initialization
Raw SQL in management API (avoids importing Otterwiki's server.py). Creates 4 tables matching otterwiki/models.py: preferences, drafts, user, cache. Seeds SITE_NAME.
Permission model
Platform ACLs remain authoritative. ProxyHeaderAuth trusts proxy headers, ignores READ_ACCESS/WRITE_ACCESS. No change to auth flow. Per-wiki Preferences used only for wiki appearance/behavior settings.
DID-as-email
Already handled — resolver injects @handle in the email header field. No User table rows needed under ProxyHeaderAuth.
Migration
Lazy: if wiki.db doesn't exist when resolver hits it, create and seed on the spot. No data migration from shared default DB.
Risks
- Flask-SQLAlchemy engine caching:
dispose()+ config update should work with FSA 3.x lazy engine creation. Fallback: accessdb._enginesinternals. - Worker model: Assumes gunicorn sync workers. Async/threaded workers would race on global state.
update_app_config()side effects: Recreates Flask-Mail per-call. Harmless.- Renderer: Holds reference to
app.configdict, so Preferences updates propagate. ButRENDERER_HTML_WHITELISTparsed at init time won't update per-wiki.
Option B: Upstream Contextvars Refactor (future PR to redimp)
Core idea
New otterwiki/context.py provides getter functions backed by contextvars:
get_storage(),get_db(),get_renderer(),get_mail(),get_githttpserver()- Each falls back to the current module-level singleton when no context override is set
wiki_context()context manager sets overrides for a block
What stays the same
app(Flask) stays as module-level singleton — Flask has its owncurrent_appdbstays importable for model definitions (db.Model,db.Column)- All
@app.routedecorators unchanged models.pyunchanged
Migration path
~280 mechanical substitutions across 14 files:
storage→get_storage()app.config[...]→current_app.config[...]db.session.X()→get_db().session.X()- One lazy-init fix for
_serializerinhelper.py
Phasing
PR 1 (small): Add context.py, init_defaults() call, migrate 2-3 leaf modules. Purely additive.
PR 2 (wide, mechanical): Migrate remaining modules.
Upstream pitch
- Testability: inject mocks via
wiki_context()without real git repos or SQLite - Flask best practice:
current_appover directappimport - Plugin flexibility: temporary renderer/storage overrides
- Backward compatible: getters fall back to singletons
Constraint
models.py uses db.Model at class definition time — db can never fully move behind a getter. The contextvar is for session operations only.
SQLite Multi-Tenant: Why It's Fine
Per-tenant SQLite is a well-established pattern (37signals, Turso, Laravel Tenancy, Rails Shardines). For our use case:
- Small per-tenant data (prefs, drafts, accounts)
- Read-heavy workload
- Single VPS, tens-to-hundreds of tenants
- Already using SQLite with per-wiki git repos
Postgres would add a daemon, configuration, and maintenance for no benefit. The pattern breaks down at tens of thousands of tenants — not a concern here.
Operational concern is schema migrations — lazy migration on connection open (PRAGMA user_version) is the simplest approach.
