Phase 2: DATABASE_BACKEND switch, component config, schema scoping

- config: add DatabaseBackend enum + DATABASE_* component vars on
  CommonSettings (host/port/name/schema/user/password, defaults
  'meshcorehub'); single effective_database_url resolver with precedence
  DATABASE_URL > postgres (assembled, fail-fast on missing vars) > SQLite
  default. effective_database_schema returns the schema only for Postgres.
  Collapses the duplicated resolver/field out of Collector/API settings.
- database: create_database_engine/DatabaseManager accept a schema arg and
  scope Postgres connections via search_path (psycopg2 -c options; asyncpg
  server_settings). No-op for SQLite.
- alembic/env: migrate into the instance schema (version_table_schema +
  CREATE SCHEMA + search_path) for Postgres; fix online render_as_batch to
  be SQLite-only (matching the offline path).
- .env.example: document the DATABASE_* block.

SQLite behaviour unchanged: default no-env path resolves to the same URL
(new regression tests), full suite green (1051 passed), fresh db upgrade OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Louis KingandClaude Opus 4.8 committed 2026-06-13 22:01:00 +01:00
1 parent 9eab07d244
commit f342f5bc70
5 files changed
+240 -52

No files matched your search

+30 -3
View File
@@ -4,7 +4,7 @@ import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from sqlalchemy import engine_from_config, pool, text
from meshcore_hub.common.models import Base
@@ -41,6 +41,18 @@ def get_database_url() -> str:
return config.get_main_option("sqlalchemy.url", "sqlite:///./meshcore.db")
def get_schema(url: str) -> str | None:
"""Postgres schema to migrate into, or None for SQLite.
Each Hub instance keeps its tables and alembic_version in its own schema so
multiple instances (prod, stg, ...) can share one Postgres database with
independent migration state.
"""
if url.startswith(("postgresql", "postgres")):
return os.environ.get("DATABASE_SCHEMA", "meshcorehub")
return None
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
@@ -53,6 +65,7 @@ def run_migrations_offline() -> None:
script output.
"""
url = get_database_url()
schema = get_schema(url)
context.configure(
url=url,
target_metadata=target_metadata,
@@ -61,6 +74,8 @@ def run_migrations_offline() -> None:
# Batch mode is a SQLite-only workaround for its limited ALTER TABLE;
# Postgres performs ALTERs directly.
render_as_batch=url.startswith("sqlite"),
version_table_schema=schema,
include_schemas=schema is not None,
)
with context.begin_transaction():
@@ -74,7 +89,9 @@ def run_migrations_online() -> None:
and associate a connection with the context.
"""
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = get_database_url()
url = get_database_url()
configuration["sqlalchemy.url"] = url
schema = get_schema(url)
connectable = engine_from_config(
configuration,
@@ -83,10 +100,20 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
# Ensure the instance's schema exists and scope this connection to it so
# tables (and alembic_version) are created there. No-op for SQLite.
if schema is not None:
connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema}"'))
connection.execute(text(f'SET search_path TO "{schema}"'))
connection.commit()
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True, # SQLite batch mode for ALTER TABLE
# Batch mode is a SQLite-only workaround for its limited ALTER TABLE.
render_as_batch=url.startswith("sqlite"),
version_table_schema=schema,
include_schemas=schema is not None,
)
with context.begin_transaction():