--- 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 `User` table is effectively dead code under `ProxyHeaderAuth` ### 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 1. Resolver calls `_swap_database(wiki_slug)` after `_swap_storage(repo_path)` 2. Updates `app.config["SQLALCHEMY_DATABASE_URI"]` to `sqlite:////srv/data/wikis/{slug}/wiki.db` 3. Calls `db.engine.dispose()` to close connections to previous DB 4. Lazy-creates `wiki.db` with schema if it doesn't exist (first request) 5. Calls `update_app_config()` to reload Preferences into `app.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: access `db._engines` internals. - **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.config` dict, so Preferences updates propagate. But `RENDERER_HTML_WHITELIST` parsed 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 own `current_app` - `db` stays importable for model definitions (`db.Model`, `db.Column`) - All `@app.route` decorators unchanged - `models.py` unchanged ### 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 `_serializer` in `helper.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_app` over direct `app` import - 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.
