8000 feat(storage): give every campaign its own database by Mnehmos · Pull Request #83 · Mnehmos/mnehmos.rpg.mcp · GitHub
[go: up one dir, main page]

Skip to content

feat(storage): give every campaign its own database - #83

Merged
Mnehmos merged 1 commit into
mainfrom
feat/per-campaign-databases
Aug 13, 2026
Merged

Mnehmos merged 1 commit into
mainfrom
feat/per-campaign-databases

Conversation

@Mnehmos
@Mnehmos Mnehmos commented Aug 13, 2026 •
Copy link
Copy Markdown
Owner

Phase 2 of the multi-user plan. Phase 1 (#82) established a verified tenant; this consumes it.

What

getDb() resolves a SQLite file per campaign under RPG_DATA_DIR/campaigns/<shard>/<campaignId>.db, keyed on the campaign id from the signed tenant context.

Isolation becomes physical rather than a discipline. A campaign's rows are the only rows in the file, so a query that forgets to scope cannot reach another tenant's data. The 30 repositories and 217 call sites are unchanged — they simply address a database containing exactly one campaign.

The audit found zero repositories enforcing a tenant predicate and 34 of 48 tables with no scope column at all. Adding predicates everywhere would have meant ~292 edit points and a rule every future query must remember. This is the alternative: one resolution point, and the data isn't there to leak.

  • Handles pooled with an LRU cap (64). A Map preserves insertion order, so re-inserting on read is all the LRU needs.
  • Campaign ids become a path segment, so they are validated against a UUID pattern rather than sanitized. A rejected id is a bug or an attack; neither should be repaired into something that opens a file.
  • Migrations run lazily per database on first open — adding a campaign never requires a migration pass over every existing file.
  • The 18 private ensureDb() helpers no longer compute a path.

withSession now derives SessionContext from the verified tenant instead of reading sessionId out of tool arguments, which are assembled from model output. The 'default' fallback is gone; unscoped requests get a sentinel that doesn't read like a real session id.

Two boot-time bindings had to move

Both captured a single database for the process lifetime, which cannot work per campaign:

  • AuditLogger resolved its repository in the constructor, at boot, where no tenant exists. It now resolves per write — which also puts each audit row in its own campaign's database.
  • The agent runtime bound eight repositories at startup. agent_manage and combat_manage already build it lazily from the request's database, so the startup wiring is removed. 8000 Provider initialization stays at boot since it is genuinely process-wide.

Risk

This is the cutover. Existing campaign data becomes unreachable.

  • After deploy, requests resolve to new, empty per-campaign databases. The current /app/data/rpg-mcp.db is not migrated and becomes orphaned. This is the planned behaviour — production holds only test campaigns — but it is not reversible by rollback alone once new campaigns have been written.
  • The host's routing rows outlive the data. reference_engine_sessions still maps each account/campaign to reference-engine world/party/character ids that no longer exist in the fresh databases. Existing test campaigns will therefore fail with "not found" rather than cleanly disappearing. Cutover should clear those rows, or simply create new campaigns and abandon the old ones.
  • RPG_MCP_DB_PATH becomes dead configuration and should be unset to avoid implying the engine still opens one shared file.
  • getDb(path) now throws outside tests. Verified no production caller passes a path after this change.

Rollback: reverting restores the single-database behaviour, but any campaign created after deploy lives only in a per-campaign file and would not be visible again.

Acceptance criteria

  • Two campaigns resolve to two different files on disk.
  • A character created in campaign A is invisible to campaign B, including through an unscoped SELECT * FROM characters.
  • Campaigns are isolated even when owned by the same account.
  • Repeated access to one campaign reuses a single handle.
  • getDb() with no verified tenant throws rather than defaulting.
  • Malformed campaign ids — path traversal, separators, non-UUID, empty — are rejected before touching the filesystem.
  • Concurrent campaigns stay on their own database across await points.
  • All 68 existing test files continue to drive handlers unchanged.

Verification

  • npm test — 2,343 passed / 7 skipped, no failures.
  • npx tsc --noEmit — exit 0.
  • npm run build — clean.

11 new tests in tests/storage/per-campaign-db.test.ts. The isolation test deliberately calls findAll(), which issues an unscoped SELECT * FROM characters — that is the point: the query cannot leak because the row is not in the file.

Not covered: a live end-to-end turn against the deployed engine. That is the deploy-time check.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added isolated storage for each campaign, preventing data from crossing campaign boundaries.
    • Improved tenant-aware request and session handling for more reliable context separation.
    • Added safeguards for invalid or missing campaign identity information.
    • Improved database lifecycle management, including reuse, cleanup, and recovery support.
  • Bug Fixes

    • Ensured audit records and server operations use the correct campaign context.
    • Updated event notifications to use the correct unscoped session identifier.
  • Tests

    • Added coverage for campaign isolation, concurrent operations, tenant separation, and database reuse.

Phase 2 of the multi-user plan. Phase 1 established a verified tenant; this
consumes it.

getDb() now resolves a SQLite file per campaign under
RPG_DATA_DIR/campaigns/<shard>/<campaignId>.db, keyed on the campaign id from
the signed tenant context. Isolation becomes physical rather than a predicate
every query has to remember: a campaign own rows are the only rows in the
file, so a query that forgets to scope cannot reach another tenant data. The
30 repositories and 217 call sites are unchanged — they simply address a
database containing exactly one campaign.

- Handles are pooled with an LRU cap (64). A Map preserves insertion order,
  so re-inserting on read is all the LRU needs.
- Campaign ids become a path segment, so they are validated against a UUID
  pattern rather than sanitized. A rejected id is a bug or an attack and
  neither should be repaired into something that opens a file.
- Migrations run lazily per database on first open, so adding a campaign
  never requires a migration pass over every existing file.
- The 18 private ensureDb() helpers no longer compute a path.

withSession now derives SessionContext from the verified tenant instead of
reading sessionId out of tool arguments, which are assembled from model
output. The 'default' fallback is gone; unscoped requests get a sentinel that
does not read like a real session id.

Two boot-time bindings had to move, both of which captured one database for
the process lifetime:

- AuditLogger resolved its repository in the constructor. It now resolves per
  write, which also puts each audit row in its own campaign database.
- The agent runtime bound eight repositories at startup. agent_manage and
  combat_manage already build it lazily from the request database, so the
  startup wiring is removed; provider initialization stays at boot since it
  is genuinely process-wide.

getDb(path) remains as a test-only escape hatch and throws outside tests.
That is what lets all 68 existing test files keep driving handlers directly
without a tenant scope, unchanged.
@coderabbitai
coderabbitai Bot commented Aug 13, 2026 •
Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The server now resolves SQLite storage per verified campaign tenant. Database handles are pooled and isolated by campaign. Request sessions and audit writes use tenant-derived context. Repository callers delegate database selection to getDb(), and startup defers agent runtime construction.

Changes

Campaign-scoped storage

Layer / File(s) Summary
Campaign database resolution and pooling
src/storage/index.ts
Adds sharded campaign paths, UUID validation, tenant resolution, pooled database handles, test overrides, migrations, eviction, and shutdown cleanup.
Tenant-derived request and audit context
src/server/types.ts, src/server/audit.ts
Builds session context from verified tenant data and resolves the audit repository for each write.
Server and repository integration
src/server/index.ts, src/server/consolidated/*
Removes environment-based path selection, uses parameterless getDb(), reports the campaign path, and defers agent runtime construction.
Campaign isolation validation
tests/storage/per-campaign-db.test.ts, tests/server/events.test.ts
Tests campaign isolation, validation, handle reuse, concurrent access, and the unscoped session identifier.

Estimated code review effort: 4 (Complex) | ~45 minutes

Mergeability Score: 🟠 High · up to 44e62

The change moves campaigns to separate databases, but currently makes existing data unreachable without a migration path, can close a database still in use during high concurrency, and can cause one unverified subscription to remove another. Merge should wait until these data, availability, and isolation risks are addressed.

Sequence Diagram(s)

sequenceDiagram
  participant Request
  participant withSession
  participant getTenant
  participant getDb
  participant CampaignDatabasePool
  Request->>withSession: invoke handler with arguments
  withSession->>getTenant: resolve verified tenant
  getTenant-->>withSession: accountId, campaignId, worldId
  withSession->>getDb: resolve tenant database
  getDb->>CampaignDatabasePool: open or reuse campaign handle
  CampaignDatabasePool-->>getDb: return database handle
  getDb-->>withSession: return campaign database
  withSession-->>Request: execute handler with SessionContext
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: per-campaign database isolation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/per-campaign-databases

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 44e6255024

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/storage/index.ts
Comment on lines +207 to +212
if (path !== undefined) {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
'getDb(path) is test-only. Production callers must use getDb(), which resolves ' +
'the database from the verified tenant context.'
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update production callers before rejecting path arguments

When NODE_ENV !== 'test', numerous registered handlers still pass a path, including character_manage, world_manage, npc_manage, combat_manage, and their underlying combat handlers. Those calls now throw getDb(path) is test-only before the verified tenant can be resolved, so major tool families fail on every production invocation; convert all remaining production call sites to getDb() before enforcing this guard.

Useful? React with 👍 / 👎.

Comment thread src/storage/index.ts
Comment on lines +221 to +223
if (overrideDb) return overrideDb;

return openCampaignDb(requireTenant().campaignId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Provide tenant context for non-HTTP transports

The default stdio transport—and the TCP, Unix-socket, and WebSocket transports—never establish runInTenant; only the HTTP transport verifies x-rpg-tenant. Consequently every storage-backed tool using the new no-argument path reaches this requireTenant() and fails, including the advertised Claude Desktop configuration that launches the server over stdio. These transports need a trusted tenant-binding mechanism, or they must be explicitly retired rather than left accepting unusable tool calls.

Useful? React with 👍 / 👎.

Comment thread src/storage/index.ts
Comment on lines +183 to +187
const db = pool.get(oldest);
pool.delete(oldest);
try {
db?.pragma('wal_checkpoint(TRUNCATE)');
db?.close();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not evict database handles still in use

With more than 64 campaigns active concurrently, this closes the least-recently-acquired handle even when an in-flight request still owns repositories backed by it. For example, agent_manage invoke acquires its campaign runtime, awaits the LLM provider, and then writes through the same repositories; if 64 other campaigns open databases during that await, this code evicts and closes the first handle, causing its post-provider audit/state writes to fail. Track active leases/reference counts or otherwise avoid closing handles until their requests finish.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/storage/index.ts`:
- Around line 221-223: Update the database selection flow around requireTenant()
and openCampaignDb() to add a migration gate for legacy shared-database rows
before routing requests to campaign files. Import existing shared data into the
relevant campaign file, or prevent campaign-file access until an external
migration has completed; preserve the overrideDb path and ensure no existing
rows become inaccessible during rollout.
- Around line 174-175: Update the database-handle lifecycle around pool.set,
evictBeyondCap, and the related lines 179-187 to track active request leases or
reference counts; only evict and close handles with no active owner, including
requests retaining a handle across await. Add a concurrency test that holds one
handle across an await while opening more than 64 other campaigns and verifies
the retained handle remains usable.

In `@tests/server/events.test.ts`:
- Around line 38-41: Update withSession and the event-subscription flow so
unverified subscriptions cannot share the sentinel sessionId "unscoped": either
reject requests without a verified tenant or assign each connection a unique
identifier. Preserve verified-tenant behavior, and add a regression test
covering two simultaneous unverified subscriptions to ensure both remain active.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b4fb2b42-4831-4e08-bed5-abeeeb58881e

📥 Commits

Reviewing files that changed from the base of the PR and between 4cbdc01 and 44e6255.

📒 Files selected for processing (24)
  • src/server/audit.ts
  • src/server/consolidated/agent-manage.ts
  • src/server/consolidated/batch-manage.ts
  • src/server/consolidated/corpse-manage.ts
  • src/server/consolidated/improvisation-manage.ts
  • src/server/consolidated/inventory-manage.ts
  • src/server/consolidated/item-manage.ts
  • src/server/consolidated/math-manage.ts
  • src/server/consolidated/narrative-manage.ts
  • src/server/consolidated/party-manage.ts
  • src/server/consolidated/perception-manage.ts
  • src/server/consolidated/quest-manage.ts
  • src/server/consolidated/scene-manage.ts
  • src/server/consolidated/secret-manage.ts
  • src/server/consolidated/session-manage.ts
  • src/server/consolidated/spawn-manage.ts
  • src/server/consolidated/strategy-manage.ts
  • src/server/consolidated/travel-manage.ts
  • src/server/consolidated/turn-manage.ts
  • src/server/index.ts
  • src/server/types.ts
  • src/storage/index.ts
  • tests/server/events.test.ts
  • tests/storage/per-campaign-db.test.ts

Comment thread src/storage/index.ts
Comment on lines +174 to +175
pool.set(campaignId, db);
evictBeyondCap();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Do not close database handles that an active request retains.

Line 175 can evict and close a database handle after another request retained it across an await. When more than 64 campaigns interleave, the resumed request can use a closed better-sqlite3 handle and fail. Track request leases or reference counts. Evict only handles with no active owner. Add a concurrency test that holds a handle across an await while opening more than 64 other campaigns.

Also applies to: 179-187

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/storage/index.ts` around lines 174 - 175, Update the database-handle
lifecycle around pool.set, evictBeyondCap, and the related lines 179-187 to
track active request leases or reference counts; only evict and close handles
with no active owner, including requests retaining a handle across await. Add a
concurrency test that holds one handle across an await while opening more than
64 other campaigns and verifies the retained handle remains usable.

Comment thread src/storage/index.ts
Comment on lines +221 to +223
if (overrideDb) return overrideDb;

return openCampaignDb(requireTenant().campa 8000 ignId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Add a migration gate for existing shared database data.

Line 223 routes every request to a new campaign file. Existing rows in the shared database become unavailable immediately after deployment because this code has no import or fallback path. Migrate legacy data into campaign files before serving requests, or block the rollout until an external migration completes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/storage/index.ts` around lines 221 - 223, Update the database selection
flow around requireTenant() and openCampaignDb() to add a migration gate for
legacy shared-database rows before routing requests to campaign files. Import
existing shared data into the relevant campaign file, or prevent campaign-file
access until an external migration has completed; preserve the overrideDb path
and ensure no existing rows become inaccessible during rollout.

Comment on lines +38 to +41
// 'unscoped' rather than the old 'default': this subscription was
// made outside any verified tenant, and the sentinel should not
// read like a real session id that callers could be pooled into.
params: { topic: 'world', payload: { data: 'test' }, sessionId: 'unscoped' }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 'activeSubscriptions|sessionId|unscoped' src/server/events.ts tests/server/events.test.ts

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 5737


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- session helper and related call sites ---'
rg -n -C 12 'function withSession|const withSession|withSession\(|unscoped|verified tenant|sessionId' src tests -g '*.{ts,tsx}' | head -n 400

printf '%s\n' '--- events test setup and subscription cases ---'
sed -n '1,180p' tests/server/events.test.ts

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 32511


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- events.ts imports and implementation ---'
sed -n '1,110p' src/server/events.ts

printf '%s\n' '--- all withSession definitions and imports ---'
rg -n -C 8 'withSession' src tests -g '*.{ts,tsx}' | head -n 500

printf '%s\n' '--- session context types and transport/tool dispatch ---'
rg -n -C 10 'sessionId|CallToolResult|RequestHandlerExtra|extra' src/server src -g '*.{ts,tsx}' | head -n 500

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- src/server/types.ts ---'
sed -n '1,90p' src/server/types.ts

printf '%s\n' '--- src/engine/pubsub.ts ---'
sed -n '1,180p' src/engine/pubsub.ts

printf '%s\n' '--- event registration lifecycle ---'
sed -n '100,145p' src/server/index.ts
sed -n '150,205p' src/server/transport/http.ts

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 7182


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

types = Path("src/server/types.ts").read_text()
events = Path("src/server/events.ts").read_text()
http = Path("src/server/transport/http.ts").read_text()

assert "const UNSCOPED = 'unscoped';" in types
assert "sessionId: tenant ? `${tenant.accountId}:${tenant.campaignId}` : UNSCOPED" in types
assert "const activeSubscriptions: Map<string, Array<() => void>> = new Map();" in events
assert "activeSubscriptions.get(sessionId)" in events
assert "activeSubscriptions.set(sessionId, newSubs)" in events
assert "new PubSub" not in http
assert "sessionIdGenerator: undefined" in http

# Behavioral model of the relevant subscription/cleanup logic.
subscriptions = {"world": set()}
active = {}

def subscribe(key, callback):
    subscriptions["world"].add(callback)
    active[key] = [lambda: subscriptions["world"].discard(callback)]

first = lambda payload: "first"
second = lambda payload: "second"

key = "unscoped"
active.get(key, [])
subscribe(key, first)
for unsubscribe in active.get(key, []):
    unsubscribe()
subscribe(key, second)

assert first not in subscriptions["world"]
assert second in subscriptions["world"]
print("unscoped requests resolve to one key; the second subscription removes the first")
print("HTTP uses stateless per-request transports while the event PubSub is shared by the server factory")
PY

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 339


Do not use one shared key for unverified subscriptions.

withSession maps every request without a verified tenant to sessionId: 'unscoped'. A second unverified subscription therefore removes the first subscription from activeSubscriptions. Reject unverified event subscriptions or assign each connection a unique identifier. Add a regression test with two unverified subscriptions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/server/events.test.ts` around lines 38 - 41, Update withSession and the
event-subscription flow so unverified subscriptions cannot share the sentinel
sessionId "unscoped": either reject requests without a verified tenant or assign
each connection a unique identifier. Preserve verified-tenant behavior, and add
a regression test covering two simultaneous unverified subscriptions to ensure
both remain active.

@Mnehmos
Mnehmos merged commit 7300708 into main Aug 13, 2026
4 checks passed
zxrfire pushed a commit to zxrfire/mnehmos.rpg.mcp that referenced this pull request Sep 8, 2026
Phase 2 of the multi-user plan. Phase 1 established a verified tenant; this
consumes it.

getDb() now resolves a SQLite file per campaign under
RPG_DATA_DIR/campaigns/<shard>/<campaignId>.db, keyed on the campaign id from
the signed tenant context. Isolation becomes physical rather than a predicate
every query has to remember: a campaign own rows are the only rows in the
file, so a query that forgets to scope cannot reach another tenant data. The
30 repositories and 217 call sites are unchanged — they simply address a
database containing exactly one campaign.

- Handles are pooled with an LRU cap (64). A Map preserves insertion order,
  so re-inserting on read is all the LRU needs.
- Campaign ids become a path segment, so they are validated against a UUID
  pattern rather than sanitized. A rejected id is a bug or an attack and
  neither should be repaired into something that opens a file.
- Migrations run lazily per database on first open, so adding a campaign
  never requires a migration pass over every existing file.
- The 18 private ensureDb() helpers no longer compute a path.

withSession now derives SessionContext from the verified tenant instead of
reading sessionId out of tool arguments, which are assembled from model
output. The 'default' fallback is gone; unscoped requests get a sentinel that
does not read like a real session id.

Two boot-time bindings had to move, both of which captured one database for
the process lifetime:

- AuditLogger resolved its repository in the constructor. It now resolves per
  write, which also puts each audit row in its own campaign database.
- The agent runtime bound eight repositories at startup. agent_manage and
  combat_manage already build it lazily from the request database, so the
  startup wiring is removed; provider initialization stays at boot since it
  is genuinely process-wide.

getDb(path) remains as a test-only escape hatch and throws outside tests.
That is what lets all 68 existing test files keep driving handlers directly
without a tenant scope, unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0