Merge pull request #371 from ipnet-mesh/docs/remove-sqlite-support-plan

Remove SQLite support; PostgreSQL is now the only database backend
This commit is contained in:
JingleManSweep authored and GitHub committed 2026-08-30 10:49:13 +01:00
commit 562e54a2ff
50 files changed
+1287 -817

No files matched your search

+13 -16
View File
@@ -56,41 +56,38 @@ IMAGE_VERSION=latest
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
LOG_LEVEL=INFO
# Base directory for runtime data (database, etc.)
# Base directory for runtime data (seed/health/content files, etc.)
# Default: ./data (relative to docker-compose.yml location)
# Inside containers this is mapped to /data
#
# Structure:
# ${DATA_HOME}/
# └── collector/
# └── meshcore.db # SQLite database
# ├── collector/ # collector health/state files
# └── web/ # web tier data
DATA_HOME=./data
# -----------------------------------------------------------------------------
# Database
# Database (PostgreSQL — the only backend since v0.19)
# -----------------------------------------------------------------------------
# SQLite is the zero-config default and needs nothing here — it lives at
# ${DATA_HOME}/collector/meshcore.db.
# The bundled postgres container is part of the default `core` profile and
# derives its POSTGRES_USER/PASSWORD/DB from DATABASE_USER/PASSWORD/NAME, so
# a default `docker compose up` works with zero configuration. The default
# password below is dev-only — override DATABASE_PASSWORD in production.
#
# To use PostgreSQL instead, set DATABASE_BACKEND=postgres and fill in the
# DATABASE_* values below (the bundled postgres container derives its
# POSTGRES_USER/PASSWORD/DB from DATABASE_USER/PASSWORD/NAME). You must also
# activate the compose 'postgres' profile, e.g. `docker compose --profile postgres up`.
#
# See docs/database.md for the full backend reference: production role/database
# See docs/database.md for the full reference: production role/database
# provisioning, managed/external Postgres, and schema-per-instance (search_path)
# isolation for multiple instances sharing one cluster.
# isolation for multiple instances sharing one cluster. Upgrading from an old
# SQLite deployment? See docs/upgrading.md (v0.19 section).
#
# DATABASE_BACKEND=postgres
# DATABASE_HOST=postgres
# DATABASE_PORT=5432
# DATABASE_NAME=meshcorehub
# DATABASE_SCHEMA=meshcorehub # override per instance (e.g. prod, stg) on a shared cluster
# DATABASE_USER=meshcorehub
# DATABASE_PASSWORD= # required for postgres; e.g. `openssl rand -base64 32`
# DATABASE_PASSWORD= # e.g. `openssl rand -base64 32` (required in production)
#
# Advanced: set DATABASE_URL to a full SQLAlchemy URL to override all of the above
# (e.g. a managed/external Postgres). Takes precedence over DATABASE_BACKEND.
# (e.g. a managed/external Postgres).
# DATABASE_URL=postgresql+psycopg2://user:pass@host:5432/dbname
# Directory containing seed data files for import
+17
View File
@@ -74,6 +74,21 @@ jobs:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
# Pinned to match the bundled postgres:17-alpine in docker-compose.yml
image: postgres:17
env:
POSTGRES_USER: meshcorehub
POSTGRES_PASSWORD: meshcorehub-test
POSTGRES_DB: meshcorehub_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U meshcorehub -d meshcorehub_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v7
@@ -89,6 +104,8 @@ jobs:
pip install -e ".[dev]"
- name: Run tests with pytest
env:
TEST_POSTGRES_URL: postgresql+psycopg2://meshcorehub:meshcorehub-test@localhost:5432/meshcorehub_test
run: |
pytest -nauto --cov=meshcore_hub --cov-report=xml --cov-report=term-missing --junitxml=junit.xml -o junit_family=legacy
+15 -16
View File
@@ -78,7 +78,7 @@ npm run test:frontend # host: vitest unit + component tests
## Tests & Quality
Coverage is **opt-in**; add `--cov=meshcore_hub` (or `make test-cov`) when you want it. The dev loop defaults to no coverage and parallel across CPU cores.
Coverage is **opt-in**; add `--cov=meshcore_hub` (or `make test-cov`) when you want it. The dev loop defaults to no coverage and parallel across CPU cores. Backend tests require PostgreSQL: `make test` / `make test-cov` / `make test-unit` start and stop the throwaway test Postgres automatically (a `postgres:17-alpine` on `127.0.0.1:55432`; the management is skipped when `TEST_POSTGRES_URL` points at your own instance). For direct `pytest` runs, `make test-db-up` first — pytest fails fast with a hint when the database is unreachable.
```bash
# Canonical: run tests in parallel, no coverage, surface the pass/fail summary
@@ -138,16 +138,17 @@ Design notes when extending the suite:
## Database & Ops
The default backend is **SQLite** (zero-config, file at `${DATA_HOME}/collector/meshcore.db`). **PostgreSQL** is also supported via `DATABASE_BACKEND=postgres` — see `docs/database.md` for the full backend reference, production provisioning, and schema-per-instance setup. Migrations are backend-agnostic; the commands below work for both.
The database backend is **PostgreSQL** (the only backend since v0.19). The bundled container starts with the `core` compose profile — see `docs/database.md` for the full reference, production provisioning, and schema-per-instance setup. Backend tests also require Postgres: `make test-db-up` starts a throwaway instance on `127.0.0.1:55432` (or point `TEST_POSTGRES_URL` at your own).
```bash
# --- LOCAL (venv): sync the volume DB to ./meshcore.db, then author a migration
# Volume name is ${COMPOSE_PROJECT_NAME:-hub}_data (default: hub_data)
# (SQLite only — for Postgres, point the migration env at the cluster directly)
docker run -it --rm -v hub_data:/data -v "$PWD":/pwd ubuntu cp /data/collector/meshcore.db /pwd/meshcore.db
meshcore-hub db revision --autogenerate -m "description"
# --- LOCAL (venv): author a migration against a local schema (NOT the dev DB)
# Use a scratch schema on the test DB so autogenerate diffs against a clean
# schema; never point revision tooling at the running dev database.
TEST_POSTGRES_URL=postgresql+psycopg2://meshcorehub:meshcorehub-test@localhost:55432/meshcorehub_test \
DATABASE_URL=postgresql+psycopg2://meshcorehub:meshcorehub-test@localhost:55432/meshcorehub_test \
DATABASE_SCHEMA=scratch meshcore-hub db revision --autogenerate -m "description"
# --- CONTAINER: apply migrations (the DB lives in the data volume)
# --- CONTAINER: apply migrations (the DB lives in the postgres_data volume)
# Migrations auto-apply on `up` via the migrate service. Manual one-off:
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core run --rm migrate db upgrade
@@ -358,16 +359,14 @@ async def test_collector_handles_advertisement():
# Integration test
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.ext.asyncio import AsyncSession
@pytest.fixture
async def db_session():
"""Create in-memory SQLite database for testing."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with AsyncSession(engine) as session:
async def db_session(db_url, db_schema):
"""Async session on the per-worker Postgres schema (see tests/conftest.py)."""
manager = DatabaseManager(db_url, schema=db_schema)
manager.create_tables()
async with manager.async_session() as session:
yield session
@pytest.fixture
-2
View File
@@ -86,8 +86,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
RUN apt-get update && apt-get install -y --no-install-recommends \
# For serial port access
udev \
# For database debugging and maintenance
sqlite3 \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /data
+35 -3
View File
@@ -5,7 +5,7 @@ VOLUMES = $(COMPOSE_PROJECT_NAME)_data $(COMPOSE_PROJECT_NAME)_mqtt_data \
$(COMPOSE_PROJECT_NAME)_observer_data
.PHONY: build up down logs backup restore test test-cov test-unit test-frontend \
e2e-build e2e-up e2e-down e2e-seed e2e-test
test-db-up test-db-down e2e-build e2e-up e2e-down e2e-seed e2e-test
build:
docker compose $(COMPOSE_FILES) --profile all build --no-cache
@@ -36,21 +36,53 @@ restore:
alpine sh -c "cd / && tar xzf /backup/$$(basename $(FILE))"
# --- Tests ---------------------------------------------------------------
# Backend tests run against PostgreSQL only: `test`, `test-cov`, and
# `test-unit` start the throwaway test DB automatically and stop it again
# afterwards — even when pytest fails (EXIT trap). Set TEST_POSTGRES_URL to
# point at your own instance and the automatic up/down is skipped.
# For direct pytest runs, start it manually with `make test-db-up`.
# Coverage is opt-in (use test-cov). Dev loop runs in parallel across cores.
# `test` runs the backend suite then the frontend (vitest) suite.
test:
pytest -nauto --no-cov
$(MAKE) test-frontend
set -e; \
if [ -z "$$TEST_POSTGRES_URL" ]; then \
$(MAKE) --no-print-directory test-db-up; \
trap '$(MAKE) --no-print-directory test-db-down' EXIT; \
fi; \
pytest -nauto --no-cov; \
$(MAKE) --no-print-directory test-frontend
test-cov:
set -e; \
if [ -z "$$TEST_POSTGRES_URL" ]; then \
$(MAKE) --no-print-directory test-db-up; \
trap '$(MAKE) --no-print-directory test-db-down' EXIT; \
fi; \
pytest --cov=meshcore_hub --cov-report=term-missing
test-unit:
set -e; \
if [ -z "$$TEST_POSTGRES_URL" ]; then \
$(MAKE) --no-print-directory test-db-up; \
trap '$(MAKE) --no-print-directory test-db-down' EXIT; \
fi; \
pytest -nauto --no-cov tests/test_common/ tests/test_api/ tests/test_collector/ tests/test_web/
test-frontend:
npm run test:frontend
# Throwaway PostgreSQL for the backend suite (127.0.0.1:55432, dev-only creds,
# ephemeral tmpfs storage). pytest fails fast with a hint if it is unreachable.
# The project name is pinned so a COMPOSE_PROJECT_NAME set in .env (the dev
# stack) can never make `down` target the dev services.
TEST_DB_COMPOSE = docker compose -f docker-compose.test-db.yml -p meshcore-hub-test-db
test-db-up:
$(TEST_DB_COMPOSE) up -d --wait
test-db-down:
$(TEST_DB_COMPOSE) down -v --remove-orphans
# --- E2E (Playwright) ---------------------------------------------------
# Self-contained throwaway stack (own ephemeral Postgres, isolated volumes).
# make e2e-build && make e2e-up # start the stack (build first time)
+3 -3
View File
@@ -8,7 +8,7 @@
Python 3.13+ platform for managing and orchestrating MeshCore mesh networks.
> [!WARNING]
> **DEPRECATION NOTICE** — v0.14 adds PostgreSQL support (`DATABASE_BACKEND=postgres`); SQLite remains the zero-config default. SQLite support will be maintained for at least the next few releases (~3 months), then removed in favour of PostgreSQL-only. See [docs/database.md](docs/database.md) to switch backends and [docs/upgrading.md](docs/upgrading.md) to migrate.
> **PostgreSQL-only since v0.19** — MeshCore Hub runs on PostgreSQL (bundled container by default; `docker compose up` is still zero-effort). SQLite support was removed in v0.19; existing deployments can migrate in place with `meshcore-hub db migrate-to-postgres` — see [docs/upgrading.md](docs/upgrading.md) and [docs/database.md](docs/database.md).
![MeshCore Hub Web Dashboard](docs/images/web.png)
@@ -350,7 +350,7 @@ meshcore-hub/
│ ├── hosting/ # Reverse proxy hosting guides
│ ├── configuration.md # Single source of truth for environment variables
│ ├── content.md # Custom content setup guide
│ ├── database.md # Database backends (SQLite/PostgreSQL) reference
│ ├── database.md # Database (PostgreSQL) reference
│ ├── deployment.md # Production setup, scaling, Redis, multi-instance
│ ├── i18n.md # Translation reference guide
│ ├── letsmesh.md # LetsMesh packet decoding details
@@ -372,7 +372,7 @@ meshcore-hub/
- [docs/observer.md](docs/observer.md) - Remote packet-capture observers and `PACKETCAPTURE_*` reference
- [docs/routes.md](docs/routes.md) - Route health monitoring and link status
- [docs/maintenance.md](docs/maintenance.md) - Backup and restore procedures
- [docs/database.md](docs/database.md) - Database backends (SQLite/PostgreSQL) and migration
- [docs/database.md](docs/database.md) - Database (PostgreSQL) reference and upgrade path
- [docs/upgrading.md](docs/upgrading.md) - Upgrade guide for breaking changes
- [docs/letsmesh.md](docs/letsmesh.md) - LetsMesh packet decoding details
- [docs/seeding.md](docs/seeding.md) - Seed data format and import guide
+3 -2
View File
@@ -36,8 +36,9 @@ recursive_version_locations = false
# the output encoding used when revision files are written from script.py.mako
output_encoding = utf-8
# Database URL - can be overridden by environment variable
sqlalchemy.url = sqlite:///./meshcore.db
# Database URL is resolved from the environment by alembic/env.py via
# CommonSettings.effective_database_url (explicit DATABASE_URL or the
# DATABASE_* components); no URL is set here.
[post_write_hooks]
+14 -29
View File
@@ -20,34 +20,25 @@ target_metadata = Base.metadata
def get_database_url() -> str:
"""Get the database URL using the same resolution as the app.
"""Get the PostgreSQL URL using the same resolution as the app.
Delegates to CommonSettings.effective_database_url so DATABASE_BACKEND=postgres
(+ DATABASE_* components) and an explicit DATABASE_URL are honoured identically to
the running services — otherwise migrations would silently target SQLite.
Delegates to CommonSettings.effective_database_url so the DATABASE_*
components and an explicit DATABASE_URL are honoured identically to the
running services.
"""
from pathlib import Path
from meshcore_hub.common.config import CommonSettings
url = CommonSettings().effective_database_url
# Ensure the parent directory exists for SQLite file URLs.
if url.startswith("sqlite:///"):
db_path = Path(url.replace("sqlite:///", ""))
db_path.parent.mkdir(parents=True, exist_ok=True)
return url
return CommonSettings().effective_database_url
def get_schema(url: str) -> str | None:
def get_schema(url: str) -> str:
"""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
return os.environ.get("DATABASE_SCHEMA", "meshcorehub")
def run_migrations_offline() -> None:
@@ -68,11 +59,8 @@ def run_migrations_offline() -> None:
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
# 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,
include_schemas=True,
)
with context.begin_transaction():
@@ -97,20 +85,17 @@ 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()
# Ensure the instance's schema exists and scope this connection to it
# so tables (and alembic_version) are created there.
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,
# 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,
include_schemas=True,
)
with context.begin_transaction():
+38
View File
@@ -0,0 +1,38 @@
# ==========================================================================
# Throwaway PostgreSQL instance for the backend test suite (pytest).
#
# NOT part of the application stack. Start/stop with:
# make test-db-up
# make test-db-down
#
# The Makefile pins -p meshcore-hub-test-db so a COMPOSE_PROJECT_NAME set in
# the repo .env (the dev stack) can never make `down` target dev services.
#
# The meshcorehub role owns the meshcorehub_test database (image entrypoint),
# which is exactly the privilege the per-xdist-worker test schemas need
# (CREATE SCHEMA) — no superuser and no CREATEDB required. The data directory
# is a tmpfs mount, so nothing survives `down`.
# ==========================================================================
name: meshcore-hub-test-db
services:
test-postgres:
image: postgres:17-alpine
container_name: meshcore-hub-test-postgres
ports:
- "127.0.0.1:55432:5432"
environment:
- POSTGRES_USER=meshcorehub
- POSTGRES_PASSWORD=meshcorehub-test
- POSTGRES_DB=meshcorehub_test
# Every pytest-xdist worker holds its own pooled engine (pool_size=20 +
# max_overflow=30) against this one instance, so raise max_connections
# above the Postgres default of 100.
command: ["postgres", "-c", "max_connections=200"]
tmpfs:
- /var/lib/postgresql/data:size=512m
healthcheck:
test: ["CMD-SHELL", "pg_isready -U meshcorehub -d meshcorehub_test"]
interval: 5s
timeout: 5s
retries: 10
+14 -15
View File
@@ -148,21 +148,25 @@ services:
retries: 3
# ==========================================================================
# PostgreSQL - optional database backend (alternative to default SQLite)
# Enable with: DATABASE_BACKEND=postgres (+ DATABASE_* vars) AND the
# 'postgres' profile, e.g. `docker compose --profile core --profile postgres up`.
# PostgreSQL - the database backend (the only one since v0.19)
# Part of the core/all profiles so a default `compose up` is zero-effort;
# the explicit 'postgres' profile also starts it standalone.
# POSTGRES_* init vars derive from the same DATABASE_* values (single source
# of truth); the schema itself is created by `db upgrade` (alembic).
# The default password is dev-only — override DATABASE_PASSWORD in
# production (the container is not published outside the compose network).
# ==========================================================================
postgres:
image: postgres:17-alpine
container_name: ${COMPOSE_PROJECT_NAME:-hub}-postgres
profiles:
- all
- core
- postgres
restart: unless-stopped
environment:
- POSTGRES_USER=${DATABASE_USER:-meshcorehub}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:-}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:-meshcorehub}
- POSTGRES_DB=${DATABASE_NAME:-meshcorehub}
volumes:
- postgres_data:/var/lib/postgresql/data
@@ -209,14 +213,13 @@ services:
- CHANNEL_REFRESH_INTERVAL_SECONDS=${CHANNEL_REFRESH_INTERVAL_SECONDS:-300}
- DATA_HOME=/data
- SEED_HOME=/seed
# Database backend (SQLite default; set DATABASE_BACKEND=postgres to switch)
- DATABASE_BACKEND=${DATABASE_BACKEND:-sqlite}
# PostgreSQL connection (bundled postgres service or external)
- DATABASE_HOST=${DATABASE_HOST:-postgres}
- DATABASE_PORT=${DATABASE_PORT:-5432}
- DATABASE_NAME=${DATABASE_NAME:-meshcorehub}
- DATABASE_SCHEMA=${DATABASE_SCHEMA:-meshcorehub}
- DATABASE_USER=${DATABASE_USER:-meshcorehub}
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-}
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-meshcorehub}
# Webhook configuration
- WEBHOOK_ADVERTISEMENT_URL=${WEBHOOK_ADVERTISEMENT_URL:-}
- WEBHOOK_ADVERTISEMENT_SECRET=${WEBHOOK_ADVERTISEMENT_SECRET:-}
@@ -298,14 +301,13 @@ services:
- MQTT_TRANSPORT=${MQTT_TRANSPORT:-websockets}
- MQTT_WS_PATH=${MQTT_WS_PATH:-/}
- DATA_HOME=/data
# Database backend (SQLite default; set DATABASE_BACKEND=postgres to switch)
- DATABASE_BACKEND=${DATABASE_BACKEND:-sqlite}
# PostgreSQL connection (bundled postgres service or external)
- DATABASE_HOST=${DATABASE_HOST:-postgres}
- DATABASE_PORT=${DATABASE_PORT:-5432}
- DATABASE_NAME=${DATABASE_NAME:-meshcorehub}
- DATABASE_SCHEMA=${DATABASE_SCHEMA:-meshcorehub}
- DATABASE_USER=${DATABASE_USER:-meshcorehub}
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-}
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-meshcorehub}
- API_HOST=0.0.0.0
- API_PORT=8000
# Worker processes for multi-core concurrency (1 = single process).
@@ -455,23 +457,20 @@ services:
- core
- migrate
restart: "no"
# Wait for postgres only when it's running (postgres profile active);
# required:false means SQLite deployments don't pull it in or block on it.
depends_on:
postgres:
condition: service_healthy
required: false
required: true
volumes:
- data:/data
environment:
- DATA_HOME=/data
- DATABASE_BACKEND=${DATABASE_BACKEND:-sqlite}
- DATABASE_HOST=${DATABASE_HOST:-postgres}
- DATABASE_PORT=${DATABASE_PORT:-5432}
- DATABASE_NAME=${DATABASE_NAME:-meshcorehub}
- DATABASE_SCHEMA=${DATABASE_SCHEMA:-meshcorehub}
- DATABASE_USER=${DATABASE_USER:-meshcorehub}
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-}
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-meshcorehub}
command: ["db", "upgrade"]
# ==========================================================================
+2 -3
View File
@@ -17,7 +17,7 @@ Process-wide and MQTT-broker settings used by every service. For multi-instance
| `COMPOSE_PROJECT_NAME` | `hub` | Docker Compose project name; prefixes container and volume names. Change per instance when running multiple deployments on the same host |
| `IMAGE_VERSION` | `latest` | Docker image tag to use (`latest`, `main`, `v1.0.0`, etc.) |
| `LOG_LEVEL` | `INFO` | Logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) |
| `DATA_HOME` | `./data` | Base directory for runtime data (the SQLite file lives under `${DATA_HOME}/collector/meshcore.db`) |
| `DATA_HOME` | `./data` | Base directory for runtime data (seed/health/content files under `${DATA_HOME}/collector` and `${DATA_HOME}/web`) |
| `SEED_HOME` | `./seed` | Directory containing seed data files |
| `TZ` | `UTC` | IANA timezone for displaying dates/times (e.g. `America/New_York`, `Europe/London`) |
| `MQTT_HOST` | `localhost` (`mqtt` in Docker) | MQTT broker hostname |
@@ -34,11 +34,10 @@ Process-wide and MQTT-broker settings used by every service. For multi-instance
## Database
MeshCore Hub defaults to **SQLite** (zero-config, single host). Set `DATABASE_BACKEND=postgres` to switch to **PostgreSQL** for write scaling, multi-host deployments, and multiple instances sharing one cluster via schema-per-instance. Postgres is opt-in — leave the `DATABASE_*` variables unset to keep using SQLite. See [database.md](database.md) for the full backend reference: bundled container, production role/database provisioning, managed/external Postgres, schema-per-instance isolation, and the SQLite → PostgreSQL migration runbook.
MeshCore Hub runs on **PostgreSQL** — the only supported database backend since v0.19. The bundled container is part of the default `core` compose profile (zero-config), and the same `DATABASE_*` variables point the services at a managed/external instance or a shared cluster with schema-per-instance isolation. See [database.md](database.md) for the full reference: bundled container, production role/database provisioning, managed/external Postgres, and schema-per-instance isolation. Upgrading an old SQLite deployment? See [upgrading.md](upgrading.md) (v0.19 section).
| Variable | Default | Description |
| --- | --- | --- |
| `DATABASE_BACKEND` | `sqlite` | `sqlite` or `postgres`. Explicit switch — Postgres is never selected implicitly |
| `DATABASE_HOST` | `postgres` | Postgres hostname (`postgres` = bundled container service name) |
| `DATABASE_PORT` | `5432` | Postgres port |
| `DATABASE_NAME` | `meshcorehub` | Database name |
+17 -34
View File
@@ -1,40 +1,24 @@
# Database
MeshCore Hub supports two database backends: **SQLite** (the zero-config default) and **PostgreSQL** (optional, for write scaling and multi-host deployments). Postgres is opt-in — leave the `DATABASE_*` variables unset to keep using SQLite.
MeshCore Hub runs on **PostgreSQL** — the only supported database backend since v0.19. The bundled container is part of the default `core` compose profile, so a standard `docker compose up` works with zero configuration; managed/external Postgres and multiple schema-isolated instances sharing one cluster are equally supported.
> [!NOTE]
> As of v0.14, SQLite is **deprecated** in favour of PostgreSQL. SQLite remains the default and continues to work unchanged; support will be removed in a future release (at least 3 months out). New deployments that need to scale across hosts should pick Postgres. Existing SQLite deployments can move to Postgres with a one-command migration — see [Migrating from SQLite to PostgreSQL](#migrating-from-sqlite-to-postgresql) and [upgrading.md](upgrading.md).
> SQLite support (the pre-v0.14 default) was **removed in v0.19**. Existing SQLite deployments can migrate in place with the built-in `meshcore-hub db migrate-to-postgres` command — see [Upgrading from SQLite](#upgrading-from-sqlite) below and the [v0.19 upgrade guide](upgrading.md).
## SQLite (default)
## Docker (bundled container)
SQLite needs no configuration. The database file is created automatically on first run and lives under `DATA_HOME` (see [configuration.md → Common](configuration.md#common)):
```
${DATA_HOME}/collector/meshcore.db
```
In Docker Compose this is the `hub_data` volume (`${COMPOSE_PROJECT_NAME:-hub}_data`). WAL mode is enabled automatically, allowing concurrent readers alongside the collector's single writer.
**Limitations:** writes are serialised to one process, and SQLite's file locking does **not** work over network filesystems — it caps you at a single host. To scale writes or run the stack across multiple hosts, switch to PostgreSQL.
## PostgreSQL
Set `DATABASE_BACKEND=postgres` and fill in the `DATABASE_*` connection variables. Postgres is never selected implicitly — the explicit switch avoids a silent backend change. For the full variable reference, see [configuration.md → Database](configuration.md#database).
### Docker (bundled container)
Postgres is bundled behind the `postgres` compose profile:
Postgres ships with the stack and starts with the `core` profile — no extra flags:
```bash
# Start the stack on Postgres (bundled container)
docker compose -f docker-compose.yml -f docker-compose.dev.yml \
--profile postgres --profile core up -d
# Start on SQLite (default — no postgres profile)
# Default stack: bundled Postgres starts alongside the app services
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core up -d
```
### Production provisioning (role and database)
The container derives `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` from `DATABASE_USER` / `DATABASE_PASSWORD` / `DATABASE_NAME`, and ships a **dev-only default password** (`meshcorehub`) so a default `up` initializes out of the box. **Production deployments must override `DATABASE_PASSWORD`** — the container is not published outside the compose network, but the default password is public knowledge (it is in the repo).
The `migrate` service waits for Postgres to be healthy and runs `db upgrade` before `collector` and `api` start.
## Production provisioning (role and database)
The bundled container provisions the role and database for you on first start from the `DATABASE_*` values. For a **managed or external** Postgres, create them once before pointing Hub at it. This mirrors the init script used in the [ipnet-mesh/infrastructure](https://github.com/ipnet-mesh/infrastructure/blob/main/etc/postgres/init/02_meshcorehub_db.sh) cluster:
@@ -47,12 +31,11 @@ GRANT ALL PRIVILEGES ON DATABASE meshcorehub TO meshcorehub;
The application **schema** and tables are created automatically by `db upgrade` (run by the `migrate` service on startup); the role just needs `CREATE` privilege on the database. Hub only ever connects as `DATABASE_USER` — no admin or bootstrap credentials are needed at runtime.
### Managed or external Postgres
## Managed or external Postgres
To point Hub at an already-running Postgres (e.g. a managed cloud instance), set `DATABASE_HOST` at it and **do not** activate the `postgres` profile:
To point Hub at an already-running Postgres (e.g. a managed cloud instance), set the `DATABASE_*` connection variables. The bundled container still starts with the `core` profile (it simply runs idle if the services point elsewhere); to skip it entirely, start a narrower profile set, e.g. `--profile collector --profile api`:
```bash
DATABASE_BACKEND=postgres
DATABASE_HOST=your-managed-postgres.example.com
DATABASE_PORT=5432
DATABASE_NAME=meshcorehub
@@ -66,6 +49,8 @@ For advanced cases (custom driver, extra query params), set a full SQLAlchemy UR
DATABASE_URL=postgresql+psycopg2://meshcorehub:your-password@host:5432/meshcorehub
```
Missing connection configuration (no `DATABASE_URL` and no `DATABASE_*` components) fails fast at startup with an error naming the missing variables — Hub never silently falls back to a default.
## Schema-per-instance (`search_path`)
Each Hub instance is isolated to its own Postgres **schema** via the connection's `search_path`, rather than its own database. This lets several instances (e.g. `prod`, `stg`) share **one** Postgres cluster without colliding — each gets its own tables and its own `alembic_version`.
@@ -75,12 +60,10 @@ Give every instance a distinct `DATABASE_SCHEMA`:
```bash
# Production (.env)
COMPOSE_PROJECT_NAME=hub
DATABASE_BACKEND=postgres
DATABASE_SCHEMA=meshcorehub_prod
# Staging (.env, separate directory)
COMPOSE_PROJECT_NAME=hub-beta
DATABASE_BACKEND=postgres
DATABASE_SCHEMA=meshcorehub_stg
```
@@ -88,8 +71,8 @@ The schema is created automatically on `db upgrade` if it does not exist, so no
> **Note:** This is the database-level isolation for instances sharing a Postgres cluster. For running multiple instances on the same Docker host (separate volumes, Traefik routing), see [Multi-Instance Deployments](deployment.md#multi-instance-deployments).
## Migrating from SQLite to PostgreSQL
## Upgrading from SQLite
Existing SQLite deployments can be moved to Postgres with a single built-in command (`meshcore-hub db migrate-to-postgres`), which copies every table in foreign-key order through the ORM and prints a per-table row-count reconciliation. Downtime is required while writers are stopped; the source SQLite file is never modified.
Deployments still on SQLite (the pre-v0.14 default, deprecated in v0.14 and removed in v0.19) can be moved to Postgres in place with a single built-in command (`meshcore-hub db migrate-to-postgres`), which copies every table in foreign-key order through the ORM and prints a per-table row-count reconciliation. Downtime is required while writers are stopped; the source SQLite file is never modified. The command is retained through v0.19 and scheduled for removal in v0.20.
See the **v0.14 upgrade guide** in [upgrading.md](upgrading.md#migrating-an-existing-sqlite-database-to-postgres) for the full step-by-step runbook (backup, stop writers, bring up Postgres, run the migration, restart on Postgres).
See the **v0.19 upgrade guide** in [upgrading.md](upgrading.md) for the full step-by-step runbook (backup, stop writers, bring up Postgres, run the migration, restart).
+2 -4
View File
@@ -80,7 +80,7 @@ TRAEFIK_PRIORITY=20
This ensures `beta.example.com` (priority 20) is matched before the production wildcard `*.example.com` (priority 10). For other services on the same network (e.g., an MQTT broker at `mqtt.example.com`), use an even higher priority (e.g., 30).
> **Shared Postgres cluster:** the setup above runs each instance in its own directory with its own volumes (the default SQLite path). To instead run several instances (e.g. `prod` + `stg`) against **one** PostgreSQL cluster — isolated via a per-instance schema (`search_path`) — see [database.md](database.md#schema-per-instance-search_path).
> **Shared Postgres cluster:** the setup above runs each instance in its own directory with its own volumes (bundled Postgres container per instance). To instead run several instances (e.g. `prod` + `stg`) against **one** PostgreSQL cluster — isolated via a per-instance schema (`search_path`) — see [database.md](database.md#schema-per-instance-search_path).
## Scaling the API
@@ -93,9 +93,7 @@ API_WORKERS=4
Each worker is an independent process sharing one listening socket, so the kernel balances connections across them and CPU-bound work (JSON serialisation, validation) spreads over multiple cores. Workers read their configuration from **environment variables** (CLI flags are not propagated to forked workers), which is how Docker Compose already supplies config. Enabling Redis (`REDIS_ENABLED=true`) is recommended so all workers share one cache.
Pick a worker count around the number of CPU cores available to the container; start with `2`–`4` and measure under realistic load.
**SQLite caveat:** all workers share one SQLite file on the same host (WAL mode lets concurrent readers coexist with the single writer), but writes do not scale and this does not extend across hosts. To scale the API across hosts, switch to PostgreSQL (`DATABASE_BACKEND=postgres`) — the API requires no code changes. See [database.md](database.md) for backend setup and the SQLite → Postgres migration runbook.
Pick a worker count around the number of CPU cores available to the container; start with `2`–`4` and measure under realistic load. Workers all talk to the same PostgreSQL database (the bundled container or an external instance — see [database.md](database.md)), so scaling extends across hosts unchanged.
> Prefer `API_WORKERS` over running multiple `api` containers (`--scale api=N`): the `api` service uses a fixed `container_name`, and one process-managed container per stack keeps logs, health checks, and monitoring simple.
@@ -0,0 +1,491 @@
# Remove SQLite Support — PostgreSQL-Only from v0.19
## Summary
v0.19 drops SQLite entirely and makes PostgreSQL the only supported database
backend. The dual-backend machinery introduced in v0.14 (`DATABASE_BACKEND`
switch, SQLite defaults, dialect branches, WAL pragmas, the SQLite test path)
is removed from application code, config, Docker packaging, tests, and docs.
`DATA_HOME` stays (it still holds seed files, health files, and web data), but
the `${DATA_HOME}/collector/meshcore.db` concept disappears.
The test suite is rebuilt on Postgres: backend tests run against a **dedicated
test database within a provided Postgres instance**, and each pytest-xdist
worker isolates itself with its own **schema via `search_path`** (instead of a
temporary SQLite file or per-worker databases). This keeps tests runnable with
a least-privileged role (no `CREATE DATABASE`/superuser needed) and exercises
exactly the same code path as production.
Two items are deliberately **kept for one release** (removed in v0.20): the
read-only `db migrate-to-postgres` escape-hatch command, and the
`database_backend` settings field (which now rejects `sqlite` with a targeted
upgrade error instead of silently selecting anything).
## Background & Motivation
- **v0.14 added Postgres and deprecated SQLite** (plan
`docs/plans/20260613-2111-postgres-migration/plan.md`; README deprecation
notice; `docs/database.md`: "SQLite is deprecated in favour of PostgreSQL...
support will be removed in a future release"). v0.19 is that removal.
- **Dual-backend support is an ongoing tax.** SQLite-specific logic is spread
across the codebase: WAL/pragma event listeners and in-memory pool
special-casing in `common/database.py`, the dialect-aware upsert in
`common/models/event_observer.py`, `render_as_batch` conditionals in
`alembic/env.py`, the `DatabaseBackend` enum + SQLite default URL in
`common/config.py`, the `db migrate-to-postgres` tooling, a two-mode test
harness (`TEST_DATABASE_BACKEND`), and dual-backend documentation in six
docs files.
- **Recent feature work is already Postgres-first.** The v0.18 route-perf
migration (`a59611449e2a`) uses a covering index with `INCLUDE` — "SQLite
does not support INCLUDE; its existing plain index is left untouched (SQLite
is dev/test-scale only)" (`alembic/versions/20260725_1200_*`). The e2e
stack has always been Postgres-only (`e2e/docker-compose.test.yml`).
- **SQLite constrains the deployment model** it was meant to simplify: no
multi-host scaling, file locking unusable on network filesystems, single
writer. Postgres (bundled container by default) gives the same
"compose up and it works" experience without those limits.
- Recent git history (v0.16–v0.18 hardening: `e87a002`, `3f9669f`, `b23e7bc`,
`dca3dcb`) has been correctness/ops work on the Postgres path — the backend
is mature enough to stand alone.
## Goals
- Remove every SQLite runtime code path, dependency, config knob, and doc
reference; Postgres is the only backend after v0.19.
- A default `docker compose up` remains zero-effort: the bundled `postgres`
container becomes part of the `core` profile (no extra profile flag),
including a working default password so the stack initializes out of the
box.
- Missing/invalid database configuration fails fast at startup with a clear
error (no silent fallback, no half-configured state).
- Backend tests (unit + integration, `tests/`) run **only** against Postgres:
a dedicated test database in a provided Postgres instance, per-worker schema
isolation via `search_path`.
- CI runs the suite against a Postgres service container.
- Existing SQLite deployments migrate in place on v0.19 via the retained
`meshcore-hub db migrate-to-postgres` command (read-only SQLite access via
Python's stdlib `sqlite3` driver — not a runtime backend), with removal of
that command scheduled for v0.20.
## Non-Goals
- **No schema redesign** and no new Alembic migrations beyond what the removal
itself requires (none expected).
- **No rewriting of shipped Alembic revisions.** Historical migrations already
replay cleanly from scratch on Postgres (fresh installs prove this today);
their SQLite mentions are comments or already-guarded dialect branches.
Editing them risks needless divergence from deployed revision history.
- **No admin/bootstrap credentials anywhere** (consistent with the
postgres-migration plan's provisioning decision). Tests must work with a
least-privileged role that owns its test database — hence schema-per-worker
instead of database-per-worker.
- No driver migration (keep `psycopg2-binary` + `asyncpg`; psycopg v3 is a
follow-up at best).
- No frontend changes (the SPA never touches the DB).
- No changes to Redis/MQTT/observer/traefik packaging.
- No removal (yet) of `db migrate-to-postgres`, the `database_backend`
settings field, or the `[postgres]` extra alias — those get one release of
deprecation grace and are removed in v0.20 (tracked as follow-ups).
## Requirements
### Functional Requirements
1. The collector, API, web, migrate, and seed components start only against a
configured PostgreSQL database; startup fails fast with an actionable error
naming the missing variables when connection config is absent.
2. The `database_backend` settings field is kept for one release as a rejected
legacy switch: `DATABASE_BACKEND=sqlite` raises a targeted error ("SQLite
support was removed in v0.19 — migrate with `db migrate-to-postgres`, see
docs/upgrading.md") instead of being silently discarded by
`extra="ignore"`; `DATABASE_BACKEND=postgres` is accepted as a no-op. The
field is deleted in v0.20.
3. An explicit `DATABASE_URL` still overrides the component vars (managed /
external Postgres escape hatch, tests).
4. Default compose deployment: `postgres` service starts with the `core`
profile; `migrate` waits for it to be healthy; `collector`/`api` come up
against it with no extra flags. The bundled container ships a **default
password** (`meshcorehub`, overridable via `DATABASE_PASSWORD`) so the
postgres image initializes and the app services can connect with zero
config; production overrides are documented.
5. `meshcore-hub db migrate-to-postgres` remains available in v0.19 (sync
SQLAlchemy copy, stdlib sqlite3 driver only) and is documented as the
upgrade path; its v0.20 removal is announced in `docs/upgrading.md`.
6. `make test` and CI produce a green backend suite with no SQLite anywhere in
the pipeline.
### Technical Requirements
1. **Dependencies:** drop `aiosqlite`; move `asyncpg` and `psycopg2-binary`
from the `[postgres]` optional extra into core `dependencies`; keep
`[postgres]` as an empty alias extra for one release (external install
instructions may reference it) and remove it in v0.20.
2. **Config (`common/config.py`):** keep `database_backend` as a
validated-rejection field (Functional Requirement 2); remove the SQLite
default URL branch so `effective_database_url` is "explicit `DATABASE_URL`
or assembled Postgres URL", failing fast on missing
host/name/user/password; `effective_database_schema` always returns
`database_schema`. `DATA_HOME` remains for seed/health/content paths
(`collector_data_dir`, `web_data_dir`, `node_tags_file`, ...).
3. **Engine layer (`common/database.py`):** delete the SQLite branches —
`check_same_thread`, PRAGMA event listeners (sync + async), in-memory pool
special-casing, the sqlite arm of `_to_async_url`, the parent-dir mkdir.
`search_path`/timezone wiring becomes unconditional for all connections.
The existing `schema=` parameter on `create_database_engine` /
`DatabaseManager` is what the test harness uses for worker isolation.
4. **Models:** `add_event_observer()` in `common/models/event_observer.py`
becomes a plain `sqlalchemy.dialects.postgresql.insert` upsert.
5. **Alembic:** `alembic/env.py` drops the `render_as_batch` conditional and
the `None`-schema branch (`version_table_schema`/`include_schemas` always
set); `alembic.ini` loses its `sqlite:///./meshcore.db` placeholder URL
(env.py resolves the real URL).
6. **CLI/app defaults:** replace `sqlite:///./meshcore.db` default arguments
and help text in `api/app.py:34,75`, `api/cli.py:33`,
`collector/cli.py:90`, `collector/subscriber.py:919,1005`; the app factory
should require an explicit URL (or resolve it from settings) rather than
defaulting to a file DB. `__main__.py` keeps only the
migrate-to-postgres source-URL default (retained command).
7. **Docker:** `docker-compose.yml` moves `postgres` into the `core` (and
`all`) profile, makes `migrate` `depends_on: postgres: condition:
service_healthy` (required), drops the `DATABASE_BACKEND` env lines from
`collector`/`api`/`migrate`, and gives both the `postgres` container and
the app services a shared default password
(`${DATABASE_PASSWORD:-meshcorehub}`). `Dockerfile` drops the `sqlite3`
apt package (the retained migration command only needs Python's stdlib
driver). The `data` volume stays (seed/health).
8. **Test harness:** Postgres-only fixtures (detail in Phase 1). No temporary
SQLite files, no `TEST_DATABASE_BACKEND` switch.
9. **Docs:** README (remove deprecation notice → removal notice),
`docs/database.md` (Postgres-only reference), `docs/configuration.md`,
`docs/deployment.md`, `docs/seeding.md`, `docs/upgrading.md` (new v0.19
section with the in-place SQLite→Postgres runbook), `.env.example`, and
the "Database & Ops" + test sections of `AGENTS.md`.
## Implementation Plan
The ordering is deliberate: **convert the test suite to Postgres first while
SQLite code still exists** (the suite already passes on Postgres via
`TEST_DATABASE_BACKEND=postgres`, so this is low-risk and keeps a green gate),
**then** delete SQLite from the application, then infra/deps/docs. Each phase
ends with the full suite green.
### Phase 1: Postgres-only test harness (suite converts first)
- Add a throwaway test-database stack: `docker-compose.test-db.yml` (new file)
running `postgres:17-alpine` published on `127.0.0.1:55432`, with
`POSTGRES_USER=meshcorehub`, a fixed dev-only
`POSTGRES_PASSWORD=meshcorehub-test`, `POSTGRES_DB=meshcorehub_test`,
ephemeral volume, and `command: ["postgres", "-c", "max_connections=200"]`
(see Risks: connection-pool pressure). The app role **owns** the database
(image entrypoint), which is exactly the privilege schema creation needs —
no superuser, no `CREATEDB`.
- Makefile: add `test-db-up` / `test-db-down` targets and document them in the
`test` / `test-unit` comments. Reachability is enforced by pytest itself:
the session fixture fails fast with an actionable "run `make test-db-up`"
message (Makefile shell probing of Postgres is not worth the complexity).
- `tests/conftest.py`:
- Delete `db_engine` (in-memory SQLite), `test_db_path` (temp file), the
`db_backend` fixture, and the SQLite branch of `db_url`.
- `db_url` becomes Postgres-only, driven by `TEST_POSTGRES_URL` (default
`postgresql+psycopg2://meshcorehub:meshcorehub-test@localhost:55432/meshcorehub_test`).
Missing/unreachable DB → `pytest.exit()` with an actionable message
(not a skip — Postgres is now mandatory).
- **Worker isolation via schema, not database:** derive
`schema = f"hub_test_{worker_id}"` (`hub_test_master`, `hub_test_gw0`, ...)
from the xdist worker id. The role owns the database, so the fixture
creates its schema with a plain connection
(`CREATE SCHEMA IF NOT EXISTS "hub_test_gw0"`) — no admin connection to
the `postgres` maintenance DB like today's `CREATE DATABASE` dance — then
builds the engine through the production factory:
`create_database_engine(db_url, schema=<worker_schema>)`, so tests
exercise the same `search_path`/`-ctimezone=UTC` wiring as production.
Teardown: `DROP SCHEMA "<schema>" CASCADE`.
- Replace the shared `db_engine`/`db_session` fixtures with Postgres-backed
equivalents bound to the worker schema; keep the truncate-between-tests
pattern from `tests/test_api/conftest.py::_truncate_all`.
- Convert the **local SQLite fixtures the shared-fixture rewrite doesn't
reach** (audit, verified during review):
- `tests/test_api/test_channel_visibility.py` (own in-memory
`sqlite:///:memory:` `db_session` fixture, line 23) → use the shared
Postgres-backed fixture.
- `tests/test_common/test_models.py` (local `db_session`, line 26) and
`tests/test_common/test_channel_model.py` (line 19) → same.
- `tests/test_collector/conftest.py::async_db_session` (in-memory
`sqlite+aiosqlite:///:memory:` + pragma listener) → async session from the
worker-schema `DatabaseManager` (production `async_session()` path).
- `tests/test_collector/test_cli.py` — `TestChannelCommands` has its own
`cli_db_url` fixture (line 128) creating real temp-file SQLite databases
(usages at 287, 406, 439, 699), plus `sqlite:///` mock-settings URL
strings (lines 48, 66, 93, 113) → point `cli_db_url` at the worker-schema
Postgres engine (truncate between tests) and sweep the mock URLs.
- `tests/test_api/test_metrics.py` (lines 89, 381) — builds isolated app
instances from `sqlite:///{test_db_path}`, consuming the `test_db_path`
fixture Phase 1 deletes → create them against the worker-schema Postgres
URL (truncate to isolate from the shared client).
- `tests/test_collector/test_tag_import.py::db_manager` (line 180, in-memory
`DatabaseManager`) → worker-schema `DatabaseManager`.
- `tests/test_collector/test_subscriber.py:1376` —
`database_url="sqlite:///:memory:"` factory arg (MQTT is mocked, the URL
is opaque) → sweep to the Postgres test URL.
- Reword stale SQLite comments/docstrings the Gate 2 grep would flag:
`tests/test_api/test_dashboard.py:30,1366` (the `_date_bucket_key` tests
themselves stay — the helper is dialect-neutral),
`tests/test_collector/test_handlers/test_contacts.py:76`,
`tests/test_api/test_user_profiles.py:103`,
`tests/test_api/test_routes.py:521` ("the shared SQLite file" — stale once
fixtures convert), `tests/test_collector/test_spam.py:4`,
`tests/test_collector/test_cli.py:126`.
- `tests/test_api/conftest.py` and `tests/test_collector/conftest.py`
proper: delete the `db_backend == "postgres"` branches and the SQLite
pragma listeners; `api_db_engine` / `db_manager` use the schema-scoped
engine unconditionally.
- Rewrite the SQLite-specific test files:
- `tests/test_common/test_database.py` — drop in-memory/SQLite cases
(`test_sqlite_never_has_schema`, `test_async_engine_skips_pool_args_for_memory_sqlite`,
pragma assertions); add/keep Postgres `search_path` + timezone assertions.
- `tests/test_common/test_config.py` — replace
`test_default_backend_is_sqlite_unchanged` etc. with the new
resolution contract (components → URL, missing-var error, `DATABASE_URL`
override, `DATABASE_BACKEND=sqlite` rejection, `DATABASE_BACKEND=postgres`
no-op).
- `tests/test_common/test_db_migrate.py`, `tests/test_main.py` — **kept**
for the retained migration command (stdlib sqlite3 only; verify no
aiosqlite import creeps in).
- CI (`.github/workflows/ci.yml`): add a `postgres:17` service container
(version pinned to match the bundled `postgres:17-alpine`) to the test job
and export `TEST_POSTGRES_URL` pointing at it. Remove any SQLite assumption
from the test step.
- **Gate 1:** full backend suite green on Postgres with SQLite still present
in the code (this is effectively today's `TEST_DATABASE_BACKEND=postgres`
mode, promoted to the only mode).
### Phase 2: Remove SQLite from application code
- `common/config.py`: keep `database_backend` for one release as the
rejected-legacy switch (Functional Requirement 2); collapse
`effective_database_url` to "explicit URL or assembled Postgres URL";
`effective_database_schema` always returns `database_schema`.
- `common/database.py`: remove all SQLite branches listed in Technical
Requirement 3; simplify `_to_async_url` to the Postgres mapping; update
comments that contrast with SQLite behaviour (timezone pinning rationale
stays, reworded).
- `common/models/event_observer.py`: Postgres-only upsert.
- `alembic/env.py` + `alembic.ini`: Postgres-only (Technical Requirement 5).
- `api/app.py`, `api/cli.py`, `collector/cli.py`, `collector/subscriber.py`:
remove `sqlite:///...` defaults/help text; require configured connection.
- `api/routes/dashboard.py`: update the SQLite-contrast comments (lines 104,
433, 451); the `func.date()` str-vs-date coercion stays (it is the
dialect-neutral fix from plan `20260616-2023-fix-postgres-charts-flatline`).
`collector/spam.py` needs only a docstring reword — its module docstring
says "Postgres/SQLite" (line 7); no code changes there.
- `common/db_migrate.py` + the `db migrate-to-postgres` command in
`__main__.py`: **kept** in v0.19 (decided); annotate the module docstring
with its v0.20 removal.
- Rewrite `tests/test_api/test_app_factory.py` — it currently asserts the
SQLite default URL resolution (`sqlite:////srv/hubdata/collector/meshcore.db`,
lines 52–79); switch those assertions to the Postgres resolution contract.
Sweep `tests/test_api/test_cache.py` mock URLs (`sqlite:///...` strings,
lines 1011–1271) to Postgres URLs for cleanliness (they are opaque mock
values — cosmetic, but the audit grep must come out clean).
- **Gate 2:** full suite green; grep audit: no `sqlite` remains in `src/`
(case-insensitive) except the intentionally-kept `db_migrate.py` /
`__main__.py` migration tooling, and none in `tests/` except that tooling's
tests.
### Phase 3: Infra & packaging
- `docker-compose.yml`: `postgres` gains `core`/`all` profiles (currently
only `postgres`); `migrate.depends_on.postgres.required: true`; drop
`DATABASE_BACKEND=${DATABASE_BACKEND:-sqlite}` from `collector`/`api`/
`migrate` env; keep the `DATABASE_*` block; change
`POSTGRES_PASSWORD=${DATABASE_PASSWORD:-}` →
`${DATABASE_PASSWORD:-meshcorehub}` and mirror the same default in the app
services' `DATABASE_PASSWORD` so the zero-config stack connects (the
current empty default would make the postgres image refuse to initialize).
Update the service header comments.
- `Dockerfile`: remove the `sqlite3` apt package (line 90; added by plan
`20260517-1623-sqlite3-docker-image`). Safe even with the retained
migration command — it only needs Python's stdlib sqlite3 driver.
- `docker-compose.dev.yml` / `.prod.yml` / `.traefik.yml`: verify no
SQLite/backend assumptions (current audit: none).
- `e2e/docker-compose.test.yml`: drop the now-meaningless
`DATABASE_BACKEND=postgres` lines (3 places: lines 53, 129, 172);
everything else already Postgres-only.
- **Gate 3:** user builds and brings up the stack (assistant does not build
images per AGENTS.md): `core` profile comes up with bundled Postgres,
`migrate` completes, collector ingests, API/web serve.
### Phase 4: Dependencies
- `pyproject.toml`: remove `aiosqlite` from `dependencies`; move
`asyncpg>=0.28.0` and `psycopg2-binary>=2.9.0` into `dependencies`; keep
`[postgres]` as an empty alias extra for one release (commented
"deprecated, no-op — drivers are core dependencies since v0.19; removed in
v0.20"); update the `[dev]` comment about the "dual-backend test suite".
- Refresh `.venv` (`pip install -e ".[dev]"`) and confirm no import references
`aiosqlite`.
- **Gate 4:** full suite + `pre-commit run --all-files` green.
### Phase 5: Documentation & upgrade story
- `docs/upgrading.md`: new **v0.19** section — breaking-change notice
("SQLite support removed"), the in-place runbook using the retained
`db migrate-to-postgres` command (backup → stop writers → bring up Postgres
→ migrate → restart), the `DATABASE_BACKEND=sqlite` rejection behaviour,
the new bundled default password (and that production must override it),
and the v0.20 removal schedule for the migration command, the
`database_backend` field, and the `[postgres]` extra alias.
- `docs/database.md`: Postgres-only reference — remove the "SQLite (default)"
and backend-choice sections, keep bundled-container setup, managed/external
Postgres, schema-per-instance provisioning, and the migration section
(repositioned as upgrade guidance).
- `docs/configuration.md` (`DATA_HOME` description, `DATABASE_BACKEND` row),
`docs/deployment.md` (SQLite caveats → removed), `docs/seeding.md`
(directory tree without `meshcore.db`), `.env.example` (Database section
rewrite: `DATABASE_*` defaults, no SQLite commentary, `DATA_HOME` comment
without the db file).
- `README.md`: replace the deprecation notice with a short "PostgreSQL-only"
statement; update doc-tree descriptions.
- `AGENTS.md`: rewrite "Database & Ops" (default backend is now the bundled
Postgres; migration authoring runs against a local PG schema instead of a
copied `./meshcore.db` file) and the test commands (tests require
`make test-db-up` / `TEST_POSTGRES_URL`).
- **Gate 5:** docs cross-reference sweep (grep for `sqlite`, `DATA_HOME` +
`meshcore.db`, `DATABASE_BACKEND`) returns only intentional mentions
(historical plan files, upgrading.md history, retained migration tooling).
### Phase 6: Final verification
- `pytest -nauto --no-cov` (full backend suite on Postgres),
`npm run test:frontend`, `npx tsc --noEmit`, `pre-commit run --all-files`,
`npm run typecheck:e2e` + `npx playwright test --list` (collection only).
- Fresh-install proof: against a clean test database, `meshcore-hub db upgrade`
from revision zero completes on Postgres (exercises the full migration
history without SQLite).
- User-run: `make build && make up` on the `core` profile; e2e via
`make e2e-build && make e2e-up && make e2e-test`.
## Risks & Mitigations
- **Connection-pool pressure in tests.** The production factory hardcodes
`pool_size=20` / `max_overflow=30`; with `pytest -nauto` every xdist worker
holds its own engine against one shared test database, so worst-case
connection counts can exceed Postgres' default `max_connections=100`.
Mitigated by running the test container with `-c max_connections=200`
(Phase 1) and by the existing per-test engine disposal; revisit pool sizing
if CI ever shows connection exhaustion.
- **Parallel plan conflict: mesh-link-monitoring.**
`docs/plans/20260705-2306-mesh-link-monitoring/plan.md` is Approved but not
yet implemented, and deliberately designs dual-backend code (`sqlite_insert`
/ `pg_insert` branches, Alembic batch-mode DDL, `SQLITE_MAX_VARIABLE_NUMBER`
avoidance). If it lands before or alongside this plan, those SQLite branches
become dead weight or conflicts. Coordination rule: **this plan lands
first**; the link-monitoring implementation then targets Postgres-only
(drop the `sqlite_insert` arm, keep plain `INSERT ... ON CONFLICT`-style
upserts). Flag this in the link-monitoring plan when it is taskified.
- **Breaking change for `DATABASE_BACKEND=sqlite` deployments.** Mitigated by
the retained migration command, the targeted startup error (not a silent
ignore), and a prominent `docs/upgrading.md` v0.19 section — the same
fail-loud philosophy the v0.14 plan used for half-configured Postgres.
- **Default password in the bundled container.** `meshcorehub` as the dev
default is visible in the repo; the postgres container is not published
outside the compose network, and the upgrade docs require production
overrides. Accept the risk for zero-config parity with the old SQLite
default.
## References
- `docs/plans/20260613-2111-postgres-migration/plan.md` — the plan that added
Postgres, the `DATABASE_*`/schema-per-instance design, the no-admin-creds
provisioning decision, and the `db migrate-to-postgres` command. This plan
is its logical conclusion.
- `docs/plans/20260517-1623-sqlite3-docker-image/plan.md` — added the
`sqlite3` CLI to the runtime image (Dockerfile line 90); removed here.
- `docs/plans/20260616-2023-fix-postgres-charts-flatline/plan.md` — early
Postgres-semantics fix (timezone/day-boundary); its dialect-neutral
`func.date()` coercion in `api/routes/dashboard.py` survives this plan.
- `docs/plans/20260705-2306-mesh-link-monitoring/plan.md` — approved,
unimplemented dual-backend design; must land after this plan (see Risks).
- `docs/upgrading.md` — v0.14 "Optional PostgreSQL Backend" + "Migrating an
existing SQLite database to Postgres" runbooks (basis for the v0.19 section).
- `README.md:11-12` — current SQLite deprecation notice (to be replaced).
- Key code sites: `src/meshcore_hub/common/config.py:27-129` (backend switch),
`src/meshcore_hub/common/database.py:19-253` (SQLite branches),
`src/meshcore_hub/common/models/event_observer.py:143-160` (dialect upsert),
`alembic/env.py:41-114`, `src/meshcore_hub/__main__.py:88-180`
(migrate-to-postgres), `tests/conftest.py:108-221` (current dual-backend
fixtures), `docker-compose.yml:150-178,447-475` (postgres profile, migrate).
## Review
**Status**: Approved
**Reviewed**: 2026-08-30 (second pass; first review 2026-08-29 resolved the
original open questions — those resolutions are retained below)
### Resolutions
- **Fate of `db migrate-to-postgres`** (was Open Question 1): user chose
**keep in v0.19, remove in v0.20**. Reflected in Goals, FR5, Phase 2
("kept"), Phase 4 (no aiosqlite needed — sync stdlib sqlite3 driver), and
the v0.19 upgrading.md runbook. Verified `db_migrate.py` uses only sync
SQLAlchemy, so the aiosqlite dependency and Dockerfile `sqlite3` CLI are
still removed.
- **`DATABASE_BACKEND` handling** (was Open Question 2): user chose **keep the
settings field for one release; `sqlite` raises a targeted removal error,
`postgres` is a no-op**. Reflected in FR2 and Phase 2.
- **Test Postgres source** (was Open Question 3): resolved to the recommended
dedicated `docker-compose.test-db.yml` on `127.0.0.1:55432` +
`make test-db-up/down` (isolates tests from dev data; no assistant-driven
compose against the dev stack).
- **`[postgres]` extra** (was Open Question 4): resolved to the recommended
empty alias extra for one release, removed in v0.20.
- **Schema naming/teardown** (was Open Question 5): resolved to the
recommended `hub_test_<worker_id>` schemas with `DROP SCHEMA ... CASCADE`
teardown; schema creation uses a plain connection (role owns the database),
no admin/maintenance-DB connection.
- **CI Postgres pin** (was Open Question 6): resolved to `postgres:17`,
matching the bundled `postgres:17-alpine`.
- **Zero-config password gap (new, found in review):** `docker-compose.yml`
currently defaults `POSTGRES_PASSWORD`/`DATABASE_PASSWORD` to empty, which
would break a default `compose up` once postgres joins `core`. Added
FR4/TR7/Phase 3 resolution: shared default password `meshcorehub`
(overridable), documented as dev-only; production must override.
- **Missed SQLite test fixtures (new, found in review):** added to Phase 1 —
`tests/test_api/test_channel_visibility.py:23`,
`tests/test_common/test_models.py:26`,
`tests/test_common/test_channel_model.py:19`, and
`tests/test_collector/conftest.py::async_db_session` (aiosqlite in-memory).
- **Missed SQLite-asserting tests (new, found in review):** added to Phase 2 —
`tests/test_api/test_app_factory.py:52-79` (asserts SQLite default URL
resolution) and cosmetic mock-URL sweep in `tests/test_api/test_cache.py`.
- **Factual correction:** Phase 2 originally cited SQLite comments in
`collector/spam.py`; the first review found no *code* references there and
`api/routes/dashboard.py:104,433,451` were confirmed as the real sites.
Second pass correction: `collector/spam.py:7` (module docstring) does say
"Postgres/SQLite" — a wording-only fix is now listed in Phase 2 (Gate 2's
case-insensitive grep would otherwise fail).
- **Connection-pool pressure (new, found in review):** added Risk + mitigation
(`max_connections=200` on the test container).
- **mesh-link-monitoring coordination (new, found in review):** approved but
unimplemented plan designs dual-backend code; added Risk with ordering rule
(this plan lands first; link-monitoring implementation drops SQLite arms).
- **Missed real-SQLite test files (second pass, 2026-08-30):** Phase 1 now
also enumerates `tests/test_collector/test_cli.py` (`TestChannelCommands`
real temp-file SQLite DBs + mock URLs), `tests/test_api/test_metrics.py:89,381`
(isolated app instances via the `test_db_path` fixture being deleted),
`tests/test_collector/test_tag_import.py:180` (in-memory `DatabaseManager`),
and `tests/test_collector/test_subscriber.py:1376` (`:memory:` factory arg),
plus wording sweeps in six comment-only test locations. All previously would
have surfaced only as Gate 2 grep failures or broken tests after fixture
deletion.
### Remaining Action Items
- Track for **v0.20**: removal of `db migrate-to-postgres` +
`common/db_migrate.py` + its tests, the `database_backend` settings field,
and the `[postgres]` extra alias (announce in the v0.19 upgrading.md).
- When taskifying `docs/plans/20260705-2306-mesh-link-monitoring`, add a note
that its SQLite branches/batch-DDL design items are superseded by this plan.
- After Phase 3, user verifies the zero-config `core` profile boot (bundled
Postgres + default password) per AGENTS.md build rules.
@@ -0,0 +1,117 @@
# Tasks: Remove SQLite Support — PostgreSQL-Only from v0.19
> Generated from `plan.md` on 2026-08-30
## Phase 1: Postgres-only test harness (convert suite first)
- [x] Add throwaway test-database stack
- [x] Create `docker-compose.test-db.yml`: `postgres:17-alpine` published on `127.0.0.1:55432`, `POSTGRES_USER=meshcorehub`, `POSTGRES_PASSWORD=meshcorehub-test`, `POSTGRES_DB=meshcorehub_test`, ephemeral volume, `command: ["postgres", "-c", "max_connections=200"]` (role owns the DB — no superuser/CREATEDB)
- [x] Add Makefile targets for the test DB
- [x] Add `test-db-up` / `test-db-down`
- [x] Reference them in the `test` / `test-unit` target comments (reachability enforced by pytest, not shell probing)
- [x] Rewrite `tests/conftest.py` core fixtures
- [x] Delete `db_engine` (in-memory SQLite), `test_db_path` (temp file), `db_backend`, and the SQLite branch of `db_url`
- [x] Make `db_url` Postgres-only via `TEST_POSTGRES_URL` (default `postgresql+psycopg2://meshcorehub:meshcorehub-test@localhost:55432/meshcorehub_test`); missing/unreachable DB → `pytest.exit()` with an actionable "run `make test-db-up`" message
- [x] Worker isolation via schema: derive `schema = f"hub_test_{worker_id}"`, create it with a plain `CREATE SCHEMA IF NOT EXISTS` connection, build the engine via production `create_database_engine(db_url, schema=...)` (search_path + UTC wiring exercised); teardown `DROP SCHEMA "<schema>" CASCADE`
- [x] Provide Postgres-backed `db_engine`/`db_session` equivalents bound to the worker schema, with the truncate-between-tests pattern
- [x] Clean the component conftests
- [x] `tests/test_api/conftest.py`: delete `db_backend == "postgres"` branches and SQLite pragma listeners; `api_db_engine` uses the schema-scoped engine unconditionally
- [x] `tests/test_collector/conftest.py`: same cleanup; rebuild `async_db_session` (in-memory aiosqlite + pragma listener) as an async session from the worker-schema `DatabaseManager` (production `async_session()` path)
- [x] Convert local SQLite fixtures not reached by the shared rewrite
- [x] `tests/test_api/test_channel_visibility.py:23` — own in-memory `db_session` → shared Postgres fixture
- [x] `tests/test_common/test_models.py:26` and `tests/test_common/test_channel_model.py:19` — local `db_session` → shared fixture
- [x] `tests/test_collector/test_cli.py` — `TestChannelCommands::cli_db_url` (line 128, real temp-file SQLite; usages 287, 406, 439, 699) → worker-schema Postgres engine with truncate between tests
- [x] `tests/test_api/test_metrics.py:89,381` — isolated app instances built from `sqlite:///{test_db_path}` (fixture being deleted) → worker-schema Postgres URL with truncate isolation
- [x] `tests/test_collector/test_tag_import.py:180` — in-memory `DatabaseManager` → worker-schema `DatabaseManager`
- [x] `tests/test_collector/test_subscriber.py:1376` — `database_url="sqlite:///:memory:"` factory arg → Postgres test URL
- [x] Reword stale SQLite comments/docstrings in tests (wording only, tests stay)
- [x] `tests/test_api/test_dashboard.py:30,1366` (`_date_bucket_key` tests stay — helper is dialect-neutral)
- [x] `tests/test_collector/test_handlers/test_contacts.py:76`
- [x] `tests/test_api/test_user_profiles.py:103`
- [x] `tests/test_api/test_routes.py:521` ("the shared SQLite file")
- [x] `tests/test_collector/test_spam.py:4`
- [x] `tests/test_collector/test_cli.py:126` (class docstring)
- [x] Rewrite SQLite-specific test files
- [x] `tests/test_common/test_database.py` — drop `test_sqlite_never_has_schema`, `test_async_engine_skips_pool_args_for_memory_sqlite`, pragma assertions; keep/add Postgres `search_path` + timezone assertions
- [x] `tests/test_common/test_config.py` — replace SQLite-default tests with the new resolution contract (components → URL, missing-var error, `DATABASE_URL` override, `DATABASE_BACKEND=sqlite` rejection, `DATABASE_BACKEND=postgres` no-op)
- [x] Verify kept tests `tests/test_common/test_db_migrate.py` + `tests/test_main.py` still pass (stdlib sqlite3 only — no aiosqlite import creeps in)
- [x] CI (`.github/workflows/ci.yml`)
- [x] Add a `postgres:17` service container (pinned to match bundled `postgres:17-alpine`) to the test job
- [x] Export `TEST_POSTGRES_URL` pointing at it; remove SQLite assumptions from the test step
- [x] **Gate 1**: full backend suite green on Postgres with SQLite still present in the code
## Phase 2: Remove SQLite from application code
- [x] `src/meshcore_hub/common/config.py`
- [x] Keep `database_backend` as validated-rejection field: `sqlite` raises targeted error ("SQLite support was removed in v0.19 — migrate with `db migrate-to-postgres`, see docs/upgrading.md"); `postgres` accepted as no-op
- [x] Collapse `effective_database_url` to "explicit `DATABASE_URL` or assembled Postgres URL", failing fast on missing host/name/user/password
- [x] `effective_database_schema` always returns `database_schema`
- [x] `src/meshcore_hub/common/database.py`
- [x] Delete SQLite branches: `check_same_thread`, PRAGMA event listeners (sync + async), in-memory pool special-casing, sqlite arm of `_to_async_url`, parent-dir mkdir
- [x] Make `search_path`/timezone wiring unconditional; reword SQLite-contrast comments (timezone rationale stays)
- [x] `src/meshcore_hub/common/models/event_observer.py` — `add_event_observer()` becomes a plain `sqlalchemy.dialects.postgresql.insert` upsert
- [x] Alembic
- [x] `alembic/env.py`: drop `render_as_batch` conditional and `None`-schema branch (`version_table_schema`/`include_schemas` always set)
- [x] `alembic.ini`: remove the `sqlite:///./meshcore.db` placeholder URL (line 40)
- [x] Remove SQLite defaults/help text from CLI/app entry points
- [x] `src/meshcore_hub/api/app.py:34,75` — app factory requires explicit URL or settings resolution (no file-DB default)
- [x] `src/meshcore_hub/api/cli.py:33` — default arg + help text
- [x] `src/meshcore_hub/collector/cli.py:90` — default arg + help text
- [x] `src/meshcore_hub/collector/subscriber.py:919,1005` — `database_url` defaults
- [x] `src/meshcore_hub/__main__.py` — keep only the migrate-to-postgres source-URL default (retained command)
- [x] Comment/docstring sweeps in app code
- [x] `src/meshcore_hub/api/routes/dashboard.py:104,433,451` — update SQLite-contrast comments (keep `func.date()` coercion)
- [x] `src/meshcore_hub/collector/spam.py:7` — module docstring "Postgres/SQLite" reword
- [x] `src/meshcore_hub/common/db_migrate.py` — annotate module docstring with v0.20 removal
- [x] Update SQLite-asserting tests
- [x] Rewrite `tests/test_api/test_app_factory.py:52-79` — Postgres resolution contract instead of `sqlite:////srv/hubdata/...` defaults
- [x] Sweep `tests/test_api/test_cache.py:1011-1271` mock `sqlite:///` URLs → Postgres URLs
- [x] **Gate 2**: full suite green; grep audit — no `sqlite` (case-insensitive) in `src/` except `db_migrate.py`/`__main__.py` tooling, none in `tests/` except that tooling's tests
## Phase 3: Infra & packaging
- [x] `docker-compose.yml`
- [x] Move `postgres` into the `core` (and `all`) profile
- [x] `migrate.depends_on.postgres`: `condition: service_healthy`, `required: true`
- [x] Drop `DATABASE_BACKEND=${DATABASE_BACKEND:-sqlite}` env lines from `collector`/`api`/`migrate`
- [x] `POSTGRES_PASSWORD=${DATABASE_PASSWORD:-meshcorehub}` and mirror the same default in app services' `DATABASE_PASSWORD` (zero-config boot); update service header comments
- [x] `Dockerfile` — remove the `sqlite3` apt package (line 90; stdlib driver suffices for the retained migration command)
- [x] Verify `docker-compose.dev.yml` / `.prod.yml` / `.traefik.yml` have no SQLite/backend assumptions
- [x] `e2e/docker-compose.test.yml` — drop the three `DATABASE_BACKEND=postgres` lines (53, 129, 172)
- [ ] **Gate 3** (user-run per AGENTS.md): user builds and brings up the `core` profile — bundled Postgres healthy, `migrate` completes, collector ingests, API/web serve
## Phase 4: Dependencies
- [x] `pyproject.toml`
- [x] Remove `aiosqlite` from `dependencies`
- [x] Move `asyncpg>=0.28.0` and `psycopg2-binary>=2.9.0` into `dependencies`
- [x] Keep `[postgres]` as an empty alias extra with deprecation comment ("no-op — drivers are core since v0.19; removed in v0.20")
- [x] Update the `[dev]` comment about the "dual-backend test suite"
- [x] Refresh `.venv` (`pip install -e ".[dev]"`) and confirm nothing imports `aiosqlite`
- [x] **Gate 4**: full suite + `pre-commit run --all-files` green
## Phase 5: Documentation & upgrade story
- [x] `docs/upgrading.md` — new v0.19 section: breaking-change notice, in-place runbook (backup → stop writers → bring up Postgres → `db migrate-to-postgres` → restart), `DATABASE_BACKEND=sqlite` rejection behaviour, bundled default password (production must override), v0.20 removal schedule (migration command, `database_backend` field, `[postgres]` extra)
- [x] `docs/database.md` — Postgres-only reference: remove "SQLite (default)" and backend-choice sections; keep bundled-container setup, managed/external Postgres, schema-per-instance provisioning, migration section repositioned as upgrade guidance
- [x] `docs/configuration.md` — `DATA_HOME` description, `DATABASE_BACKEND` row
- [x] `docs/deployment.md` — remove SQLite caveats
- [x] `docs/seeding.md` — directory tree without `meshcore.db`
- [x] `.env.example` — rewrite Database section: `DATABASE_*` defaults, no SQLite commentary, `DATA_HOME` comment without the db file
- [x] `README.md` — replace deprecation notice with "PostgreSQL-only" statement; update doc-tree descriptions
- [x] `AGENTS.md` — rewrite "Database & Ops" (bundled Postgres default; migration authoring against a local PG schema instead of `./meshcore.db`) and test commands (`make test-db-up` / `TEST_POSTGRES_URL` required)
- [x] **Gate 5**: docs sweep — grep `sqlite`, `DATA_HOME` + `meshcore.db`, `DATABASE_BACKEND` returns only intentional mentions (historical plans, upgrading.md history, retained tooling)
## Phase 6: Final Verification
- [x] Backend: `pytest -nauto --no-cov` (full suite on Postgres)
- [x] Frontend: `npm run test:frontend`, `npx tsc --noEmit`
- [x] `pre-commit run --all-files`
- [x] E2E toolchain: `npm run typecheck:e2e` + `npx playwright test --config=e2e/playwright.config.ts --list` (collection only)
- [x] Fresh-install proof: against a clean test database, `meshcore-hub db upgrade` from revision zero completes on Postgres
- [ ] User-run: `make build && make up` on the `core` profile; e2e via `make e2e-build && make e2e-up && make e2e-test`
## Follow-ups & Coordination (not part of this implementation)
- [ ] Track for v0.20: remove `db migrate-to-postgres` + `common/db_migrate.py` + its tests, the `database_backend` settings field, and the `[postgres]` extra alias (announced in the v0.19 `docs/upgrading.md`)
- [ ] When taskifying `docs/plans/20260705-2306-mesh-link-monitoring` (future session): note its SQLite branches (`sqlite_insert` arm, batch-mode DDL, `SQLITE_MAX_VARIABLE_NUMBER` avoidance) are superseded by this plan — implementation targets Postgres-only
+2 -2
View File
@@ -25,8 +25,8 @@ seed/ # SEED_HOME (seed data files)
└── routes.yaml # Route health definitions for import
data/ # DATA_HOME (runtime data)
└── collector/
└── meshcore.db # SQLite database
├── collector/ # collector health/state files
└── web/ # web tier data
```
Example seed files are provided in `example/seed/`.
+50
View File
@@ -2,6 +2,56 @@
This guide covers upgrading from a previous MeshCore Hub release to the current version. Check the relevant version section below before upgrading.
## v0.19.0
### PostgreSQL-only (SQLite support removed)
**⚠️ Breaking:** SQLite is no longer a supported database backend. PostgreSQL is the only backend — announced in v0.14 and removed here. Existing SQLite deployments must migrate their data (runbook below) before upgrading.
What changed:
- **The bundled `postgres` container is part of the default `core` compose profile.** A plain `docker compose up` now starts PostgreSQL alongside the app services — still zero-config, including a working default password (`meshcorehub`, overridable via `DATABASE_PASSWORD`). **Production deployments must override `DATABASE_PASSWORD`** — the default is dev-only (the container is not published outside the compose network, but don't rely on that).
- **`DATABASE_BACKEND` is rejected, not ignored.** A leftover `DATABASE_BACKEND=sqlite` in your environment now fails at startup with a targeted error pointing at the migration command; `DATABASE_BACKEND=postgres` is accepted as a no-op. Remove the variable — the field itself is removed in v0.20.
- **Missing database configuration fails fast.** With neither `DATABASE_URL` nor the `DATABASE_*` components (`DATABASE_HOST`, `DATABASE_NAME`, `DATABASE_USER`, `DATABASE_PASSWORD`) set, services refuse to start with an error naming the missing variables — no silent fallback.
- **Tests run against PostgreSQL.** The backend suite requires a Postgres instance (`make test-db-up` starts a throwaway one; `TEST_POSTGRES_URL` points at your own). CI uses a `postgres:17` service container.
- The `sqlite3` CLI was removed from the Docker image, and `aiosqlite` is no longer a dependency (the retained migration command reads SQLite via Python's stdlib driver only).
#### Migrating an existing SQLite deployment in place
Downtime is required while writers are stopped; the source SQLite file is never modified.
1. **Back up first.** Copy your `meshcore.db` (or back up the `hub_data` volume — see [Backup & Restore](maintenance.md)).
2. **Stop the writers** (collector and api):
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml stop collector api
```
3. **Bring up Postgres and create the schema** (on the v0.19 image; the bundled container starts with the `core` profile):
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgres
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm migrate
```
`migrate` runs `db upgrade`, creating the schema, all tables (with correct native types — `boolean`, `json`, `timestamptz`), and stamping `alembic_version`.
4. **Copy the data** with the built-in command:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml \
run --rm migrate meshcore-hub db migrate-to-postgres
```
It defaults the source to `sqlite:///{DATA_HOME}/collector/meshcore.db` and the target to your configured `DATABASE_*` connection. It copies every table in foreign-key order through the ORM (so SQLite's dynamically typed values are converted correctly — `0/1` → `boolean`, JSON text → `json`, naive datetimes → UTC `timestamptz`), then prints a per-table source-vs-target row-count reconciliation and fails on any mismatch. Use `--dry-run` to preview counts first, and `--truncate` to overwrite a non-empty target.
5. **Remove `DATABASE_BACKEND` from your `.env`** (any value) and bring the stack back up:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core up -d
```
> **Managed Postgres / non-superuser roles:** the migration disables foreign-key triggers during the copy via `session_replication_role = replica`, which requires a superuser. When the target role is not a superuser (typical for managed Postgres), the command automatically falls back to copying in parent-first order instead. Pass `--no-replication-role` to force the fallback explicitly.
#### Scheduled for removal in v0.20
The following are retained through v0.19 for upgrade compatibility and removed in v0.20:
- the `meshcore-hub db migrate-to-postgres` command and its `common/db_migrate.py` module,
- the `database_backend` settings field (the `DATABASE_BACKEND` rejection),
- the `[postgres]` pip extra alias (the drivers are core dependencies since v0.19).
## v0.18.0
### Security hardening (fail-closed defaults)
-3
View File
@@ -50,7 +50,6 @@ services:
- test_data:/data
environment:
- DATA_HOME=/data
- DATABASE_BACKEND=postgres
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
- DATABASE_NAME=meshcorehub
@@ -126,7 +125,6 @@ services:
- MQTT_USERNAME=test-admin
- MQTT_PASSWORD=test-password
- DATA_HOME=/data
- DATABASE_BACKEND=postgres
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
- DATABASE_NAME=meshcorehub
@@ -169,7 +167,6 @@ services:
- MQTT_USERNAME=test-admin
- MQTT_PASSWORD=test-password
- DATA_HOME=/data
- DATABASE_BACKEND=postgres
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
- DATABASE_NAME=meshcorehub
+6 -9
View File
@@ -39,7 +39,8 @@ dependencies = [
"httpx>=0.25.0",
"authlib>=1.3.0",
"itsdangerous>=2.0.0",
"aiosqlite>=0.19.0",
"asyncpg>=0.28.0",
"psycopg2-binary>=2.9.0",
"pyyaml>=6.0.0",
"python-frontmatter>=1.0.0",
"prometheus-client>=0.20.0",
@@ -60,15 +61,11 @@ dev = [
"beautifulsoup4>=4.12.0",
"types-paho-mqtt>=1.6.0",
"types-PyYAML>=6.0.0",
# Postgres drivers needed to run the dual-backend test suite
# (TEST_DATABASE_BACKEND=postgres) locally.
"asyncpg>=0.28.0",
"psycopg2-binary>=2.9.0",
]
postgres = [
"asyncpg>=0.28.0",
"psycopg2-binary>=2.9.0",
]
# Deprecated no-op alias: the Postgres drivers are core dependencies since
# v0.19. Kept one release for external install instructions that reference
# `.[postgres]`; removed in v0.20.
postgres = []
[project.scripts]
meshcore-hub = "meshcore_hub.__main__:main"
+7 -2
View File
@@ -31,7 +31,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
global _db_manager
# Get database URL from app state
database_url = getattr(app.state, "database_url", "sqlite:///./meshcore.db")
database_url = getattr(app.state, "database_url", None)
if not database_url:
raise RuntimeError(
"No database URL configured — pass database_url to create_app "
"or configure DATABASE_URL / DATABASE_* for the app factory"
)
# Initialize database (schema managed by Alembic migrations)
logger.info(f"Initializing database: {database_url}")
@@ -72,7 +77,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
def create_app(
database_url: str = "sqlite:///./meshcore.db",
database_url: str | None = None,
read_key: str | None = None,
admin_key: str | None = None,
mqtt_host: str = "localhost",
+1 -1
View File
@@ -30,7 +30,7 @@ import click
type=str,
default=None,
envvar="DATABASE_URL",
help="Database connection URL (default: sqlite:///{data_home}/collector/meshcore.db)",
help="Database connection URL (default: the configured PostgreSQL connection from DATABASE_URL / DATABASE_* vars)",
)
@click.option(
"--read-key",
+8 -7
View File
@@ -101,10 +101,10 @@ def _flood_only_filter(
def _date_bucket_key(value: str | date | None) -> str | None:
"""Coerce a DB-returned date bucket key to a canonical ``%Y-%m-%d`` string.
SQLite's ``func.date()`` returns a ``str``; Postgres returns a
``datetime.date``. This normalizes both to the same string key so dict
lookups by ``"%Y-%m-%d"`` succeed on either backend. ``None`` passes
through unchanged.
Postgres's ``func.date()`` returns a ``datetime.date``; this normalizes it
to the same string key the frontend/serializers expect so dict lookups by
``"%Y-%m-%d"`` succeed. String values pass through unchanged; ``None``
passes through unchanged.
"""
if isinstance(value, str):
return value
@@ -430,7 +430,8 @@ def get_activity(
start_date = end_date - timedelta(days=days)
# Query advertisement counts grouped by date
# Use SQLite's date() function for grouping (returns string 'YYYY-MM-DD')
# (func.date() truncates to the UTC day boundary; the session timezone
# is pinned to UTC by the engine layer)
date_expr = func.date(Advertisement.received_at)
query = (
@@ -447,8 +448,8 @@ def get_activity(
results = session.execute(query).all()
# Build a dict of date -> count, normalizing the key to a string so it
# works on both SQLite (func.date() returns str) and Postgres (returns date).
# Build a dict of date -> count, normalizing the key to a string
# (func.date() returns date objects on Postgres).
counts_by_date = {_date_bucket_key(row.date): row.count for row in results}
# Generate all dates in the range, filling in zeros for missing days
+1 -1
View File
@@ -87,7 +87,7 @@ if TYPE_CHECKING:
type=str,
default=None,
envvar="DATABASE_URL",
help="Database connection URL (default: sqlite:///{data_home}/collector/meshcore.db)",
help="Database connection URL (default: the configured PostgreSQL connection from DATABASE_URL / DATABASE_* vars)",
)
@click.option(
"--log-level",
+2 -2
View File
@@ -4,10 +4,10 @@ Pure helpers (`normalize_sender`, `compute_path_prefix`) plus two DB-querying
scorers (`score_message`, `rescore_recent`) and a config object (`SpamConfig`).
The design is described in ``docs/plans/20260622-2243-spam-detection/plan.md``.
Counts are computed directly from Postgres/SQLite over the
Counts are computed directly from PostgreSQL over the
``(path_prefix, received_at)`` and ``(sender_normalized, received_at)`` indexes;
the window cutoff is always a Python-computed datetime passed as a bound
parameter, so identical code runs on both backends (no SQL ``NOW()``/``INTERVAL``).
parameter (no SQL ``NOW()``/``INTERVAL``).
"""
from __future__ import annotations
+8 -3
View File
@@ -916,7 +916,7 @@ def create_subscriber(
mqtt_tls: bool = False,
mqtt_transport: str = "websockets",
mqtt_ws_path: str = "/",
database_url: str = "sqlite:///./meshcore.db",
database_url: Optional[str] = None,
webhook_dispatcher: Optional["WebhookDispatcher"] = None,
cleanup_enabled: bool = False,
cleanup_retention_days: int = 30,
@@ -939,7 +939,7 @@ def create_subscriber(
mqtt_tls: Enable TLS/SSL for MQTT connection
mqtt_transport: MQTT transport protocol (tcp or websockets)
mqtt_ws_path: WebSocket path (used when transport=websockets)
database_url: Database connection URL
database_url: PostgreSQL connection URL (required)
webhook_dispatcher: Optional webhook dispatcher for event forwarding
cleanup_enabled: Enable automatic event data cleanup
cleanup_retention_days: Number of days to retain event data
@@ -951,6 +951,11 @@ def create_subscriber(
Returns:
Configured Subscriber instance
"""
if not database_url:
raise ValueError(
"database_url is required (PostgreSQL) — configure DATABASE_URL "
"or the DATABASE_* component vars"
)
# Create MQTT client with unique client ID to allow multiple collectors
unique_id = uuid.uuid4().hex[:8]
mqtt_config = MQTTConfig(
@@ -1002,7 +1007,7 @@ def run_collector(
mqtt_tls: bool = False,
mqtt_transport: str = "websockets",
mqtt_ws_path: str = "/",
database_url: str = "sqlite:///./meshcore.db",
database_url: Optional[str] = None,
webhook_dispatcher: Optional["WebhookDispatcher"] = None,
cleanup_enabled: bool = False,
cleanup_retention_days: int = 30,
+68 -61
View File
@@ -3,7 +3,7 @@
from enum import Enum
from typing import Optional
from pydantic import Field
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -24,13 +24,6 @@ class MQTTTransport(str, Enum):
WEBSOCKETS = "websockets"
class DatabaseBackend(str, Enum):
"""Database backend selector."""
SQLITE = "sqlite"
POSTGRES = "postgres"
class CommonSettings(BaseSettings):
"""Common settings shared by all components."""
@@ -46,87 +39,101 @@ class CommonSettings(BaseSettings):
description="Base directory for service data (e.g., ./data or /data)",
)
# Database backend selection and connection components.
# SQLite is the zero-config default; set DATABASE_BACKEND=postgres (plus the
# DATABASE_* component vars) to use Postgres. An explicit DATABASE_URL overrides
# everything (managed/external Postgres, tests).
database_backend: DatabaseBackend = Field(
default=DatabaseBackend.SQLITE,
description="Database backend: 'sqlite' (default) or 'postgres'",
# Database connection. PostgreSQL is the only backend since v0.19: either
# an explicit DATABASE_URL or the DATABASE_* components below. The legacy
# DATABASE_BACKEND selector is kept for one release purely so a leftover
# `sqlite` setting fails with a targeted upgrade error instead of being
# silently ignored; the field is removed in v0.20.
database_backend: str = Field(
default="postgres",
description=(
"Legacy backend selector retained for one release; 'postgres' is "
"accepted (no-op) and 'sqlite' is rejected (support removed in v0.19)"
),
)
database_url: Optional[str] = Field(
default=None,
description=(
"Explicit SQLAlchemy database URL; overrides DATABASE_BACKEND/component vars. "
"Default: sqlite:///{data_home}/collector/meshcore.db"
"Explicit SQLAlchemy PostgreSQL URL; overrides the DATABASE_* "
"component vars (managed/external Postgres, tests)"
),
)
database_host: Optional[str] = Field(
default=None,
description="Postgres host (required when DATABASE_BACKEND=postgres)",
description="PostgreSQL host (required unless DATABASE_URL is set)",
)
database_port: int = Field(default=5432, description="Postgres port")
database_port: int = Field(default=5432, description="PostgreSQL port")
database_name: str = Field(
default="meshcorehub", description="Postgres database name"
default="meshcorehub", description="PostgreSQL database name"
)
database_schema: str = Field(
default="meshcorehub",
description="Postgres schema (namespace); override per instance on a shared cluster",
description="PostgreSQL schema (namespace); override per instance on a shared cluster",
)
database_user: str = Field(
default="meshcorehub", description="PostgreSQL role/user"
)
database_user: str = Field(default="meshcorehub", description="Postgres role/user")
database_password: Optional[str] = Field(
default=None,
description="Postgres password (required when DATABASE_BACKEND=postgres)",
description="PostgreSQL password (required unless DATABASE_URL is set)",
)
@field_validator("database_backend", mode="before")
@classmethod
def _validate_legacy_backend(cls, value: object) -> str:
"""Reject the removed SQLite backend with a targeted upgrade error."""
raw = "postgres" if value is None else str(value).strip().lower()
if raw == "sqlite":
raise ValueError(
"SQLite support was removed in v0.19 — migrate with "
"`meshcore-hub db migrate-to-postgres`, see docs/upgrading.md"
)
if raw != "postgres":
raise ValueError(
f"DATABASE_BACKEND must be 'postgres' (the only supported "
f"backend), got {value!r}"
)
return raw
@property
def effective_database_url(self) -> str:
"""Resolve the SQLAlchemy database URL.
"""Resolve the SQLAlchemy PostgreSQL URL.
Precedence: explicit DATABASE_URL > postgres (assembled from components) >
SQLite default under DATA_HOME. Fails fast for a misconfigured postgres backend
rather than silently falling back to SQLite.
Precedence: explicit DATABASE_URL > URL assembled from the DATABASE_*
components. Fails fast when components are missing rather than
silently falling back to anything.
"""
if self.database_url:
return self.database_url
if self.database_backend == DatabaseBackend.POSTGRES:
missing = [
name
for name, value in (
("DATABASE_HOST", self.database_host),
("DATABASE_NAME", self.database_name),
("DATABASE_USER", self.database_user),
("DATABASE_PASSWORD", self.database_password),
)
if not value
]
if missing:
raise ValueError(
"DATABASE_BACKEND=postgres requires: " + ", ".join(missing)
)
from urllib.parse import quote_plus
user = quote_plus(self.database_user)
password = quote_plus(self.database_password or "")
return (
f"postgresql+psycopg2://{user}:{password}"
f"@{self.database_host}:{self.database_port}/{self.database_name}"
missing = [
name
for name, value in (
("DATABASE_HOST", self.database_host),
("DATABASE_NAME", self.database_name),
("DATABASE_USER", self.database_user),
("DATABASE_PASSWORD", self.database_password),
)
from pathlib import Path
if not value
]
if missing:
raise ValueError(
"PostgreSQL connection is not configured: missing "
+ ", ".join(missing)
+ " (or set DATABASE_URL)"
)
from urllib.parse import quote_plus
db_path = Path(self.data_home) / "collector" / "meshcore.db"
return f"sqlite:///{db_path}"
user = quote_plus(self.database_user)
password = quote_plus(self.database_password or "")
return (
f"postgresql+psycopg2://{user}:{password}"
f"@{self.database_host}:{self.database_port}/{self.database_name}"
)
@property
def effective_database_schema(self) -> Optional[str]:
"""Postgres schema to scope connections to, or None for SQLite.
Returns the schema only when the effective URL is Postgres; SQLite has no
schema concept, so callers leave search_path untouched.
"""
if self.effective_database_url.startswith(("postgresql", "postgres")):
return self.database_schema
return None
def effective_database_schema(self) -> str:
"""PostgreSQL schema to scope connections to (search_path)."""
return self.database_schema
# Logging
log_level: LogLevel = Field(default=LogLevel.INFO, description="Logging level")
+50 -117
View File
@@ -6,7 +6,7 @@ import os
from contextlib import asynccontextmanager, contextmanager
from typing import Any, AsyncGenerator, Generator
from sqlalchemy import create_engine, event
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from sqlalchemy.orm import Session, sessionmaker
@@ -16,38 +16,27 @@ from meshcore_hub.common.models.base import Base
logger = logging.getLogger(__name__)
def _resolve_pg_schema(database_url: str, schema: str | None) -> str | None:
def _resolve_pg_schema(schema: str | None) -> str | None:
"""Resolve the Postgres schema to scope a connection to (search_path).
Returns None for SQLite (no schema concept). For Postgres, an explicit ``schema``
wins; otherwise it falls back to the ``DATABASE_SCHEMA`` env var. The CLI's
``load_dotenv()`` and Docker both populate that var, so runtime entrypoints don't
need to thread the schema through every constructor.
An explicit ``schema`` wins; otherwise it falls back to the
``DATABASE_SCHEMA`` env var. The CLI's ``load_dotenv()`` and Docker both
populate that var, so runtime entrypoints don't need to thread the schema
through every constructor.
"""
if not database_url.startswith(("postgresql", "postgres")):
return None
if schema:
return schema
return os.environ.get("DATABASE_SCHEMA")
return schema or os.environ.get("DATABASE_SCHEMA")
def _to_async_url(database_url: str) -> str:
"""Map a sync database URL to its async-driver equivalent.
"""Map a sync database URL to its asyncpg equivalent.
Postgres always maps to asyncpg for the async engine, even when the sync URL
names a sync driver (e.g. ``postgresql+psycopg2://``, which is what the config
assembler produces) — otherwise async sessions would try to use the sync driver.
SQLite maps to aiosqlite unless a driver is already specified.
The async engine always uses asyncpg, even when the sync URL names a sync
driver (e.g. ``postgresql+psycopg2://``, which is what the config
assembler produces) — otherwise async sessions would try to use the sync
driver.
"""
scheme = database_url.split("://", 1)[0]
dialect = scheme.split("+", 1)[0]
if dialect in ("postgresql", "postgres"):
return database_url.replace(f"{scheme}://", "postgresql+asyncpg://", 1)
if dialect == "sqlite":
if "+" in scheme:
return database_url
return database_url.replace("sqlite://", "sqlite+aiosqlite://", 1)
return database_url
return database_url.replace(f"{scheme}://", "postgresql+asyncpg://", 1)
def create_database_engine(
@@ -60,43 +49,35 @@ def create_database_engine(
Args:
database_url: SQLAlchemy database URL
echo: Enable SQL query logging
schema: Postgres schema to scope connections to via search_path. Defaults to
the DATABASE_SCHEMA env var when not given. Ignored for SQLite.
schema: Postgres schema to scope connections to via search_path.
Defaults to the DATABASE_SCHEMA env var when not given.
Returns:
SQLAlchemy Engine instance
"""
connect_args: dict[str, Any] = {}
engine_kwargs: dict[str, Any] = {}
# SQLite-specific configuration
if database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
# Scope Postgres connections to the configured schema via search_path and pin
# the session timezone to UTC so func.date(<timestamptz>) truncates on the UTC
# day boundary — matching SQLite, which stores UTC text. This keeps the models
# schema-agnostic (no hardcoded schema=) so the same code serves SQLite,
# single-instance Postgres, and multiple schema-isolated instances on one cluster.
resolved_schema = _resolve_pg_schema(database_url, schema)
is_postgres = database_url.startswith(("postgresql", "postgres"))
if is_postgres:
options_parts: list[str] = []
if resolved_schema:
options_parts.append(f"-csearch_path={resolved_schema}")
options_parts.append("-ctimezone=UTC")
connect_args["options"] = " ".join(options_parts)
# Scope connections to the configured schema via search_path and pin the
# session timezone to UTC so func.date(<timestamptz>) truncates on the UTC
# day boundary — the collector writes UTC, so day buckets must line up on
# it. This keeps the models schema-agnostic (no hardcoded schema=) so the
# same code serves single-instance Postgres and multiple schema-isolated
# instances on one cluster.
resolved_schema = _resolve_pg_schema(schema)
options_parts: list[str] = []
if resolved_schema:
options_parts.append(f"-csearch_path={resolved_schema}")
options_parts.append("-ctimezone=UTC")
connect_args["options"] = " ".join(options_parts)
# Size the pool above the default Starlette threadpool (~40 threads) so
# concurrent request handlers don't block waiting for a connection. Applies
# to file-based SQLite and networked backends (e.g. a future Postgres).
# In-memory SQLite uses a non-overflow pool, so skip these args there.
is_memory_sqlite = database_url in ("sqlite://", "sqlite:///:memory:")
if not is_memory_sqlite:
engine_kwargs["pool_size"] = 20
engine_kwargs["max_overflow"] = 30
# concurrent request handlers don't block waiting for a connection.
engine_kwargs: dict[str, Any] = {
"pool_size": 20,
"max_overflow": 30,
}
engine = create_engine(
return create_engine(
database_url,
echo=echo,
connect_args=connect_args,
@@ -104,24 +85,6 @@ def create_database_engine(
**engine_kwargs,
)
# Apply SQLite pragmas on every new connection
if database_url.startswith("sqlite"):
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record): # type: ignore
cursor = dbapi_connection.cursor()
# WAL lets readers run concurrently with a single writer (the
# collector), and busy_timeout waits instead of immediately raising
# "database is locked" under contention. synchronous=NORMAL is safe
# under WAL and faster.
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
return engine
def create_session_factory(engine: Engine) -> sessionmaker[Session]:
"""Create a session factory for the given engine.
@@ -175,19 +138,11 @@ class DatabaseManager:
database_url: SQLAlchemy database URL
echo: Enable SQL query logging
schema: Postgres schema to scope connections to (search_path). Defaults to
the DATABASE_SCHEMA env var when not given; ignored for SQLite.
the DATABASE_SCHEMA env var when not given.
"""
self.database_url = database_url
self._echo = echo
self._schema = _resolve_pg_schema(database_url, schema)
# Ensure parent directory exists for SQLite databases
if database_url.startswith("sqlite:///"):
from pathlib import Path
# Extract path from sqlite:///path/to/db.sqlite
db_path = Path(database_url.replace("sqlite:///", ""))
db_path.parent.mkdir(parents=True, exist_ok=True)
self._schema = _resolve_pg_schema(schema)
self.engine = create_database_engine(database_url, echo=echo, schema=schema)
self.session_factory = create_session_factory(self.engine)
@@ -204,31 +159,24 @@ class DatabaseManager:
from sqlalchemy.ext.asyncio import async_sessionmaker
async_url = _to_async_url(self.database_url)
async_connect_args: dict[str, Any] = {}
# asyncpg sets search_path and timezone via server_settings (not the libpq
# -c options string the sync psycopg2 engine uses). self._schema is already
# resolved (explicit arg or DATABASE_SCHEMA env) and None for SQLite.
# Timezone is pinned to UTC unconditionally for Postgres so day boundaries
# match SQLite's UTC-text truncation.
is_postgres = self.database_url.startswith(("postgresql", "postgres"))
if is_postgres:
server_settings: dict[str, str] = {"timezone": "UTC"}
if self._schema:
server_settings["search_path"] = self._schema
async_connect_args["server_settings"] = server_settings
# asyncpg sets search_path and timezone via server_settings (not the
# libpq -c options string the sync psycopg2 engine uses). self._schema
# is already resolved (explicit arg or DATABASE_SCHEMA env). Timezone
# is pinned to UTC so day boundaries line up with the collector's
# UTC writes.
server_settings: dict[str, str] = {"timezone": "UTC"}
if self._schema:
server_settings["search_path"] = self._schema
async_connect_args: dict[str, Any] = {"server_settings": server_settings}
# Mirror the sync engine's pool configuration (see
# create_database_engine): without pool_pre_ping a Postgres restart
# leaves stale asyncpg connections in the pool that error on next
# use. In-memory SQLite uses a non-overflow pool, so skip there.
is_memory_sqlite = self.database_url in (
"sqlite+aiosqlite://",
"sqlite+aiosqlite:///:memory:",
)
async_engine_kwargs: dict[str, Any] = {"pool_pre_ping": True}
if not is_memory_sqlite:
async_engine_kwargs["pool_size"] = 20
async_engine_kwargs["max_overflow"] = 30
# leaves stale asyncpg connections in the pool that error on next use.
async_engine_kwargs: dict[str, Any] = {
"pool_pre_ping": True,
"pool_size": 20,
"max_overflow": 30,
}
self._async_engine = create_async_engine(
async_url,
@@ -237,21 +185,6 @@ class DatabaseManager:
**async_engine_kwargs,
)
# Apply the same SQLite pragmas as the sync engine (see
# create_database_engine) for the async engine's connections.
if self.database_url.startswith("sqlite"):
@event.listens_for(self._async_engine.sync_engine, "connect")
def set_sqlite_pragma_async(
dbapi_connection: object, connection_record: object
) -> None:
cursor = dbapi_connection.cursor() # type: ignore[attr-defined]
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
self._async_session_factory = async_sessionmaker(
self._async_engine,
class_=AsyncSession,
+8 -5
View File
@@ -1,10 +1,13 @@
"""Copy all data from a source (SQLite) database into a target (Postgres) database.
This powers ``meshcore-hub db migrate-to-postgres``. It operates at the SQLAlchemy
Core level, iterating ``Base.metadata.sorted_tables`` and copying each table through
the ORM's typed columns. The round-trip ``SQLite value -> Python object -> Postgres
value`` is what makes the conversion correct (e.g. integer ``0/1`` -> ``bool``, JSON
``TEXT`` -> ``dict``, datetime string -> ``timestamptz``) without any per-model code.
This powers ``meshcore-hub db migrate-to-postgres`` — the v0.19 upgrade path for
existing SQLite deployments. It is scheduled for removal in v0.20. It operates at
the SQLAlchemy Core level, iterating ``Base.metadata.sorted_tables`` and copying
each table through the ORM's typed columns. The round-trip ``SQLite value ->
Python object -> Postgres value`` is what makes the conversion correct (e.g.
integer ``0/1`` -> ``bool``, JSON ``TEXT`` -> ``dict``, datetime string ->
``timestamptz``) without any per-model code. SQLite is read via Python's stdlib
``sqlite3`` driver — it is not a supported runtime backend.
The schema must already exist in the target (created by ``db upgrade``); this module
only moves data and never mutates the source.
@@ -8,13 +8,13 @@ from sqlalchemy import (
DateTime,
Float,
ForeignKey,
Insert,
Integer,
Index,
String,
UniqueConstraint,
update,
)
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
@@ -104,7 +104,8 @@ def add_event_observer(
) -> bool:
"""Add an observer to an event, handling duplicates gracefully.
Uses INSERT OR IGNORE to handle the unique constraint on (event_hash, observer_node_id).
Uses a Postgres ``INSERT ... ON CONFLICT DO NOTHING`` against the unique
constraint on (event_hash, observer_node_id).
Args:
session: SQLAlchemy session
@@ -122,10 +123,6 @@ def add_event_observer(
now = observed_at or datetime.now(timezone.utc)
# Both SQLite and Postgres expose on_conflict_do_nothing() with the same signature,
# but the INSERT construct must come from the matching dialect or it emits SQL for
# the wrong backend. Build the statement in each branch (rather than aliasing the
# insert() function) so the two dialect-specific Insert types stay distinct.
values = {
"id": str(uuid4()),
"event_type": event_type,
@@ -137,25 +134,11 @@ def add_event_observer(
"created_at": now,
"updated_at": now,
}
conflict_cols = ["event_hash", "observer_node_id"]
stmt: Insert
if session.get_bind().dialect.name == "postgresql":
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = (
pg_insert(EventObserver)
.values(**values)
.on_conflict_do_nothing(index_elements=conflict_cols)
)
else:
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = (
sqlite_insert(EventObserver)
.values(**values)
.on_conflict_do_nothing(index_elements=conflict_cols)
)
stmt = (
pg_insert(EventObserver)
.values(**values)
.on_conflict_do_nothing(index_elements=["event_hash", "observer_node_id"])
)
result = session.execute(stmt)
rowcount = getattr(result, "rowcount", 0)
+71 -104
View File
@@ -1,16 +1,15 @@
"""Shared pytest fixtures for all tests."""
import os
import tempfile
from typing import Generator
import dotenv
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.engine.url import make_url
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
from meshcore_hub.common import config as config_module
from meshcore_hub.common.database import create_database_engine
from meshcore_hub.common.models import Base
# The CLI entrypoint (meshcore_hub.__main__) calls load_dotenv() at import time so
@@ -105,16 +104,78 @@ def _ignore_dotenv(monkeypatch):
monkeypatch.delenv(ev, raising=False)
def _truncate_all(engine) -> None:
"""Delete rows from every table in child-first order (FK-safe)."""
with engine.begin() as conn:
for table in reversed(Base.metadata.sorted_tables):
conn.execute(table.delete())
# Default points at the throwaway stack from `make test-db-up`. Override with
# TEST_POSTGRES_URL to use any other Postgres instance.
DEFAULT_TEST_POSTGRES_URL = (
"postgresql+psycopg2://meshcorehub:meshcorehub-test"
"@localhost:55432/meshcorehub_test"
)
@pytest.fixture(scope="session")
def db_url(worker_id: str) -> str:
"""PostgreSQL URL for this pytest session (Postgres is the only backend).
Missing or unreachable database is a hard exit — Postgres is mandatory,
not skippable — with an actionable message pointing at ``make test-db-up``.
"""
url = os.environ.get("TEST_POSTGRES_URL") or DEFAULT_TEST_POSTGRES_URL
probe = create_engine(url, poolclass=NullPool)
try:
with probe.connect() as conn:
conn.execute(text("SELECT 1"))
except Exception as exc:
pytest.exit(
f"PostgreSQL test database unreachable at {url!r}: {exc}\n"
"Backend tests require PostgreSQL. Start the throwaway stack "
"with: make test-db-up\n"
"(or point TEST_POSTGRES_URL at your own instance)",
returncode=4,
)
finally:
probe.dispose()
return url
@pytest.fixture(scope="session")
def db_schema(worker_id: str, db_url: str):
"""Per-xdist-worker schema isolating parallel test runs.
``hub_test_master`` / ``hub_test_gw0`` / ... The test role owns the
database (no superuser or CREATEDB needed), so the schema is created with
a plain connection — no admin connection to a maintenance database.
Engines built through the production ``create_database_engine`` factory
scope themselves to it via ``search_path``. Torn down with DROP SCHEMA
CASCADE.
"""
schema = f"hub_test_{worker_id}"
admin = create_engine(db_url, poolclass=NullPool)
with admin.begin() as conn:
conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema}"'))
yield schema
with admin.begin() as conn:
conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
admin.dispose()
@pytest.fixture
def db_engine():
"""Create an in-memory SQLite database engine for testing."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
)
def db_engine(db_url: str, db_schema: str):
"""Worker-schema Postgres engine built by the production factory.
Tables are created idempotently; rows are truncated after each test so
sibling tests start clean without paying schema-build costs.
"""
engine = create_database_engine(db_url, schema=db_schema)
Base.metadata.create_all(engine)
yield engine
Base.metadata.drop_all(engine)
_truncate_all(engine)
engine.dispose()
@@ -125,97 +186,3 @@ def db_session(db_engine):
session = Session()
yield session
session.close()
@pytest.fixture(scope="session")
def test_db_path():
"""Session-scoped temporary SQLite database file path.
One file per pytest session; engines below build schema on it once.
"""
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
yield path
if os.path.exists(path):
os.unlink(path)
@pytest.fixture(scope="session")
def db_backend() -> str:
"""Active test database backend (``sqlite`` or ``postgres``).
Controlled by ``TEST_DATABASE_BACKEND`` env var (default: ``sqlite``).
When ``postgres``, ``TEST_POSTGRES_URL`` must also be set.
"""
backend = os.environ.get("TEST_DATABASE_BACKEND", "sqlite").lower()
if backend not in ("sqlite", "postgres"):
raise ValueError(
f"TEST_DATABASE_BACKEND must be 'sqlite' or 'postgres', got: {backend}"
)
return backend
@pytest.fixture(scope="session")
def db_url(db_backend: str, test_db_path: str, request) -> Generator[str, None, None]:
"""Database URL for the active backend.
For Postgres, each pytest-xdist worker gets its own database (e.g.
``test_gw0``) to avoid truncation races between parallel workers. Shared by
the API and collector suites so both exercise the same backend.
"""
if db_backend == "postgres":
env_url = os.environ.get("TEST_POSTGRES_URL")
if not env_url:
pytest.skip(
"TEST_DATABASE_BACKEND=postgres but TEST_POSTGRES_URL is not set; "
"e.g. TEST_POSTGRES_URL=postgresql+psycopg2://postgres:postgres@localhost:55432/test"
)
assert env_url is not None
worker_id = "master"
if hasattr(request.config, "workerinput"):
worker_id = request.config.workerinput["workerid"]
base_url = make_url(env_url)
worker_db = f"{base_url.database}_{worker_id}"
worker_url = base_url.set(database=worker_db).render_as_string(
hide_password=False
)
admin_url = base_url.set(database="postgres")
admin_engine = create_engine(
admin_url.render_as_string(hide_password=False),
isolation_level="AUTOCOMMIT",
)
try:
with admin_engine.connect() as conn:
exists = conn.execute(
text("SELECT 1 FROM pg_database WHERE datname = :name"),
{"name": worker_db},
).scalar()
if not exists:
conn.execute(text(f'CREATE DATABASE "{worker_db}"'))
finally:
admin_engine.dispose()
yield worker_url
admin_engine = create_engine(
admin_url.render_as_string(hide_password=False),
isolation_level="AUTOCOMMIT",
)
try:
with admin_engine.connect() as conn:
conn.execute(
text(
"SELECT pg_terminate_backend(pid) "
"FROM pg_stat_activity "
"WHERE datname = :name AND pid <> pg_backend_pid()"
),
{"name": worker_db},
)
conn.execute(text(f'DROP DATABASE IF EXISTS "{worker_db}"'))
finally:
admin_engine.dispose()
else:
yield f"sqlite:///{test_db_path}"
+11 -27
View File
@@ -1,8 +1,8 @@
"""API test fixtures.
The ``db_backend`` / ``db_url`` / ``test_db_path`` fixtures that drive the
SQLite-vs-Postgres switch live in the shared ``tests/conftest.py`` so the
collector suite can reuse the same backend; they are inherited here.
The ``db_url`` / ``db_schema`` fixtures that provide the per-xdist-worker
Postgres schema live in the shared ``tests/conftest.py`` so the collector
suite can reuse the same database; they are inherited here.
"""
from contextlib import contextmanager
@@ -10,7 +10,6 @@ from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event as sa_event
from sqlalchemy.orm import sessionmaker
from meshcore_hub.api.app import create_app
@@ -36,31 +35,16 @@ from meshcore_hub.common.models import (
@pytest.fixture(scope="session")
def api_db_engine(db_url: str, db_backend: str):
"""Session-scoped database engine. Schema is built once per pytest session.
def api_db_engine(db_url: str, db_schema: str):
"""Session-scoped worker-schema Postgres engine. Schema built once.
For Postgres, uses the production ``create_database_engine`` factory so the
test exercises the same ``connect_args`` (including ``-ctimezone=UTC``).
Each xdist worker has its own database (see ``db_url``), so no locking
is needed.
Uses the production ``create_database_engine`` factory so tests exercise
the same ``connect_args`` (``search_path`` + ``-ctimezone=UTC``) as the
running services. Each xdist worker has its own schema (see
``db_schema``), so no locking is needed.
"""
if db_backend == "postgres":
engine = create_database_engine(db_url)
Base.metadata.drop_all(engine)
else:
engine = create_engine(
db_url,
connect_args={"check_same_thread": False},
)
@sa_event.listens_for(engine, "connect")
def set_sqlite_pragma(
dbapi_connection: object, connection_record: object
) -> None:
cursor = dbapi_connection.cursor() # type: ignore[attr-defined]
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
engine = create_database_engine(db_url, schema=db_schema)
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
yield engine
Base.metadata.drop_all(engine)
+29 -8
View File
@@ -10,6 +10,12 @@ from meshcore_hub.api.app import create_app_from_env
_FACTORY_ENV = [
"DATABASE_URL",
"DATA_HOME",
"DATABASE_HOST",
"DATABASE_PORT",
"DATABASE_NAME",
"DATABASE_SCHEMA",
"DATABASE_USER",
"DATABASE_PASSWORD",
"REDIS_ENABLED",
"REDIS_HOST",
"REDIS_PORT",
@@ -31,6 +37,11 @@ _FACTORY_ENV = [
def clean_env(monkeypatch):
for var in _FACTORY_ENV:
monkeypatch.delenv(var, raising=False)
# The factory requires a database connection (PostgreSQL-only); default
# to an explicit URL so tests not about DB resolution still build an app.
monkeypatch.setenv(
"DATABASE_URL", "postgresql+psycopg2://u:p@factory-test:5432/hub"
)
return monkeypatch
@@ -49,7 +60,7 @@ def _served_paths(app):
def test_factory_reads_database_and_redis_from_env(clean_env):
"""Workers must pick up the real DB/Redis config from env, not the
hardcoded create_app defaults."""
clean_env.setenv("DATABASE_URL", "sqlite:////tmp/workers-test.db")
clean_env.setenv("DATABASE_URL", "postgresql+psycopg2://u:p@workers-test:5432/hub")
clean_env.setenv("REDIS_ENABLED", "true")
clean_env.setenv("REDIS_HOST", "redis-test")
clean_env.setenv("REDIS_PORT", "6390")
@@ -58,7 +69,7 @@ def test_factory_reads_database_and_redis_from_env(clean_env):
app = create_app_from_env()
assert app.state.database_url == "sqlite:////tmp/workers-test.db"
assert app.state.database_url == "postgresql+psycopg2://u:p@workers-test:5432/hub"
assert app.state.redis_enabled is True
assert app.state.redis_host == "redis-test"
assert app.state.redis_port == 6390
@@ -66,17 +77,27 @@ def test_factory_reads_database_and_redis_from_env(clean_env):
assert app.state.metrics_cache_ttl == 99
def test_factory_honours_explicit_disable_and_data_home(clean_env):
"""Env values override anything else, and a data-home (no explicit
DATABASE_URL) resolves to the collector DB path — never the bare
create_app default that would point workers at ./meshcore.db."""
def test_factory_assembles_url_from_component_vars(clean_env):
"""Without an explicit DATABASE_URL, the DATABASE_* components assemble
the connection — the app factory never falls back to a default file DB."""
clean_env.delenv("DATABASE_URL")
clean_env.setenv("REDIS_ENABLED", "false")
clean_env.setenv("DATA_HOME", "/srv/hubdata")
clean_env.setenv("DATABASE_HOST", "pg-test")
clean_env.setenv("DATABASE_PASSWORD", "pw")
app = create_app_from_env()
assert app.state.redis_enabled is False
assert app.state.database_url == "sqlite:////srv/hubdata/collector/meshcore.db"
assert app.state.database_url == (
"postgresql+psycopg2://meshcorehub:pw@pg-test:5432/meshcorehub"
)
def test_factory_without_database_config_raises(clean_env):
"""Missing connection config fails fast instead of guessing a default."""
clean_env.delenv("DATABASE_URL")
with pytest.raises(ValueError, match="PostgreSQL connection is not configured"):
_ = create_app_from_env()
def test_factory_redis_enabled_accepts_truthy_values(clean_env):
+8 -8
View File
@@ -1008,7 +1008,7 @@ class TestLifespanRedis:
from meshcore_hub.api.app import lifespan
app = FastAPI()
app.state.database_url = "sqlite:///./test.db"
app.state.database_url = "postgresql+psycopg2://u:p@testhost:5432/testdb"
app.state.redis_enabled = False
app_module._db_manager = None
@@ -1023,7 +1023,7 @@ class TestLifespanRedis:
from meshcore_hub.api.app import lifespan
app = FastAPI()
app.state.database_url = "sqlite:///./test.db"
app.state.database_url = "postgresql+psycopg2://u:p@testhost:5432/testdb"
app.state.redis_enabled = True
app.state.redis_host = "redis-host"
app.state.redis_port = 6380
@@ -1053,7 +1053,7 @@ class TestLifespanRedis:
from meshcore_hub.api.app import lifespan
app = FastAPI()
app.state.database_url = "sqlite:///./test.db"
app.state.database_url = "postgresql+psycopg2://u:p@testhost:5432/testdb"
app.state.redis_enabled = True
mock_cache = MagicMock()
@@ -1157,7 +1157,7 @@ class TestCliRedis:
with patch("meshcore_hub.common.config.get_api_settings") as mock_settings:
mock_settings.return_value = MagicMock(
data_home="/tmp/test",
effective_database_url="sqlite:///test.db",
effective_database_url="postgresql+psycopg2://u:p@testhost:5432/testdb",
)
result = runner.invoke(
api,
@@ -1178,7 +1178,7 @@ class TestCliRedis:
with patch("meshcore_hub.common.config.get_api_settings") as mock_settings:
mock_settings.return_value = MagicMock(
data_home="/tmp/test",
effective_database_url="sqlite:///test.db",
effective_database_url="postgresql+psycopg2://u:p@testhost:5432/testdb",
)
result = runner.invoke(api, catch_exceptions=False)
assert "Redis enabled: False" in result.output
@@ -1194,7 +1194,7 @@ class TestCliRedis:
with patch("meshcore_hub.common.config.get_api_settings") as mock_settings:
mock_settings.return_value = MagicMock(
data_home="/tmp/test",
effective_database_url="sqlite:///test.db",
effective_database_url="postgresql+psycopg2://u:p@testhost:5432/testdb",
)
with patch("meshcore_hub.api.app.create_app") as mock_create_app:
mock_create_app.return_value = MagicMock()
@@ -1242,7 +1242,7 @@ class TestCliRedis:
with patch("meshcore_hub.common.config.get_api_settings") as mock_settings:
mock_settings.return_value = MagicMock(
data_home="/tmp/test",
effective_database_url="sqlite:///test.db",
effective_database_url="postgresql+psycopg2://u:p@testhost:5432/testdb",
)
with patch("meshcore_hub.api.app.create_app") as mock_create_app:
mock_create_app.return_value = MagicMock()
@@ -1268,7 +1268,7 @@ class TestCliRedis:
with patch("meshcore_hub.common.config.get_api_settings") as mock_settings:
mock_settings.return_value = MagicMock(
data_home="/tmp/test",
effective_database_url="sqlite:///test.db",
effective_database_url="postgresql+psycopg2://u:p@testhost:5432/testdb",
)
with patch("meshcore_hub.api.app.create_app") as mock_create_app:
mock_create_app.return_value = MagicMock()
-21
View File
@@ -2,36 +2,15 @@
from unittest.mock import MagicMock
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from meshcore_hub.api.channel_visibility import (
get_all_known_channel_indices,
get_max_visibility_level,
get_visible_channel_indices,
resolve_user_role,
)
from meshcore_hub.common.models import Base
from meshcore_hub.common.models.channel import Channel
@pytest.fixture
def db_session():
"""Create an in-memory SQLite database session."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
Base.metadata.drop_all(engine)
engine.dispose()
def _make_request(
headers: dict | None = None, app_state: dict | None = None
) -> MagicMock:
+17 -4
View File
@@ -6,13 +6,19 @@ from click.testing import CliRunner
from meshcore_hub.api.cli import api
# Opaque URL: satisfies the fail-fast database check; nothing connects
# (uvicorn.run is mocked, so the lifespan never starts).
_TEST_DB_URL = "postgresql+psycopg2://meshcorehub:pw@localhost:5432/meshcorehub"
def test_api_default_runs_single_process():
"""With the default worker count, the app object is passed directly and no
worker/factory options are used."""
runner = CliRunner()
with patch("uvicorn.run") as mock_run:
result = runner.invoke(api, [], catch_exceptions=False)
result = runner.invoke(
api, ["--database-url", _TEST_DB_URL], catch_exceptions=False
)
assert result.exit_code == 0
assert mock_run.call_count == 1
@@ -27,7 +33,11 @@ def test_api_workers_uses_env_factory_import_string():
string with the requested worker count."""
runner = CliRunner()
with patch("uvicorn.run") as mock_run:
result = runner.invoke(api, ["--workers", "3"], catch_exceptions=False)
result = runner.invoke(
api,
["--database-url", _TEST_DB_URL, "--workers", "3"],
catch_exceptions=False,
)
assert result.exit_code == 0
args, kwargs = mock_run.call_args
@@ -41,7 +51,10 @@ def test_api_workers_from_env_var():
runner = CliRunner()
with patch("uvicorn.run") as mock_run:
result = runner.invoke(
api, [], env={"API_WORKERS": "2"}, catch_exceptions=False
api,
["--database-url", _TEST_DB_URL],
env={"API_WORKERS": "2"},
catch_exceptions=False,
)
assert result.exit_code == 0
@@ -57,7 +70,7 @@ def test_api_single_process_passes_spam_and_role_settings():
with patch("uvicorn.run") as mock_run:
result = runner.invoke(
api,
[],
["--database-url", _TEST_DB_URL],
env={
"SPAM_DETECTION_ENABLED": "true",
"SPAM_SCORE_THRESHOLD": "0.8",
+5 -8
View File
@@ -27,7 +27,7 @@ class TestDateBucketKey:
"""Unit tests for the _date_bucket_key dialect-neutral normalization helper."""
def test_str_passthrough(self) -> None:
"""SQLite returns str — pass through unchanged."""
"""String keys (as stored/serialized) pass through unchanged."""
assert _date_bucket_key("2026-06-15") == "2026-06-15"
def test_date_object_normalized(self) -> None:
@@ -1363,16 +1363,13 @@ class TestDashboardDateBucketRegression:
"""Regression tests for the Postgres date-bucket flatline bug.
These tests seed data at deterministic UTC timestamps and assert the
specific date buckets have non-zero counts. On SQLite (where
func.date() returns str) these always passed. On Postgres (where
func.date() returns date) these would have failed before the
_date_bucket_key fix, because the dict lookup by string key would
miss the date-object key.
specific date buckets have non-zero counts. Postgres's ``func.date()``
returns ``date`` objects, so without the ``_date_bucket_key`` fix the
dict lookup by string key would miss the date-object key.
Run against Postgres with::
TEST_DATABASE_BACKEND=postgres \\
TEST_POSTGRES_URL=postgresql+psycopg2://postgres:postgres@localhost:55432/test \\
make test-db-up
pytest tests/test_api/test_dashboard.py -k Regression
"""
+2 -6
View File
@@ -83,11 +83,9 @@ class TestMetricsAuth:
assert response.status_code == 200
def test_deny_by_default_when_no_read_key(
self, test_db_path, api_db_engine, mock_mqtt, mock_db_manager
self, db_url, api_db_engine, mock_mqtt, mock_db_manager
):
"""Unauthenticated /metrics is denied when no key is configured."""
db_url = f"sqlite:///{test_db_path}"
with patch("meshcore_hub.api.app._db_manager", mock_db_manager):
app = create_app(
database_url=db_url,
@@ -375,11 +373,9 @@ class TestMetricsDisabled:
"""Tests for when metrics are disabled."""
def test_metrics_404_when_disabled(
self, test_db_path, api_db_engine, mock_mqtt, mock_db_manager
self, db_url, api_db_engine, mock_mqtt, mock_db_manager
):
"""Test that /metrics returns 404 when disabled."""
db_url = f"sqlite:///{test_db_path}"
with patch("meshcore_hub.api.app._db_manager", mock_db_manager):
app = create_app(
database_url=db_url,
+1 -1
View File
@@ -518,7 +518,7 @@ class TestRouteQualityAvg:
# Use uuid-derived public_keys so concurrent tests using _sample_nodes
# (which always inserts "a"*64 / "b"*64) can't trip the unique
# constraint on Node.public_key during parallel xdist runs against
# the shared SQLite file.
# the shared worker schema.
suffix = uuid4().hex
keys = [(suffix[:32]).rjust(64, "0"), (suffix[32:64]).rjust(64, "0")]
nodes = [_make_node(session, k) for k in keys]
+3 -11
View File
@@ -100,22 +100,14 @@ class TestListProfiles:
api_db_session.commit()
# Temporarily disable FK enforcement to simulate an orphaned adoption
# (node deleted while the adoption record persists). SQLite uses
# PRAGMA; Postgres uses session_replication_role = replica.
dialect = api_db_session.bind.dialect.name # type: ignore[union-attr]
if dialect == "postgresql":
api_db_session.execute(text("SET session_replication_role = replica"))
else:
api_db_session.execute(text("PRAGMA foreign_keys=OFF"))
# (node deleted while the adoption record persists).
api_db_session.execute(text("SET session_replication_role = replica"))
api_db_session.execute(
text("DELETE FROM nodes WHERE id = :id"),
{"id": sample_node.id},
)
api_db_session.commit()
if dialect == "postgresql":
api_db_session.execute(text("SET session_replication_role = DEFAULT"))
else:
api_db_session.execute(text("PRAGMA foreign_keys=ON"))
api_db_session.execute(text("SET session_replication_role = DEFAULT"))
response = client_no_auth.get(
"/api/v1/user/profiles",
+20 -58
View File
@@ -1,18 +1,14 @@
"""Fixtures for collector component tests.
The ``db_backend`` / ``db_url`` switch lives in the shared ``tests/conftest.py``.
The synchronous ``db_manager`` / ``db_session`` fixtures honour it so the spam
scorer, the re-scoring sweep, and the message handler are exercised on both
SQLite (default) and Postgres (``TEST_DATABASE_BACKEND=postgres``). On Postgres
they share the per-xdist-worker database with the API suite, so tables are
created idempotently and *truncated* (never dropped) at teardown to coexist with
the API's session-scoped engine.
All fixtures run against the shared per-xdist-worker Postgres schema from
``tests/conftest.py`` (``db_url`` / ``db_schema``). The synchronous
``db_manager`` / ``db_session`` fixtures and the async ``async_db_session``
fixture use the production ``DatabaseManager`` paths (including
``async_session()``) and truncate between tests to coexist with the API
suite's session-scoped engine.
"""
import pytest
from sqlalchemy import event as sa_event
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.ext.asyncio import async_sessionmaker
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models.base import Base
@@ -26,24 +22,13 @@ def _truncate_all(engine) -> None:
@pytest.fixture
def db_manager(db_backend, db_url):
"""Create a database manager for testing, honouring the active backend.
Default (SQLite) uses an isolated in-memory database per test, exactly as
before. Postgres reuses the shared worker database with idempotent schema +
truncate-on-teardown.
"""
if db_backend == "postgres":
manager = DatabaseManager(db_url)
manager.create_tables()
yield manager
_truncate_all(manager.engine)
manager.dispose()
else:
manager = DatabaseManager("sqlite:///:memory:")
manager.create_tables()
yield manager
manager.dispose()
def db_manager(db_url, db_schema):
"""Database manager bound to the worker schema (production engine path)."""
manager = DatabaseManager(db_url, schema=db_schema)
manager.create_tables()
yield manager
_truncate_all(manager.engine)
manager.dispose()
@pytest.fixture
@@ -55,34 +40,11 @@ def db_session(db_manager):
@pytest.fixture
async def async_db_session():
"""Create an async database session for testing.
Uses a separate in-memory database with tables created inline.
"""
# Create async engine with in-memory database
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
@sa_event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma_async(
dbapi_connection: object, connection_record: object
) -> None:
cursor = dbapi_connection.cursor() # type: ignore[attr-defined]
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
# Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Create session factory
async_session_maker = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
# Provide session
async with async_session_maker() as session:
async def async_db_session(db_url, db_schema):
"""Async session from the production ``DatabaseManager.async_session()``."""
manager = DatabaseManager(db_url, schema=db_schema)
manager.create_tables()
async with manager.async_session() as session:
yield session
# Cleanup
await engine.dispose()
_truncate_all(manager.engine)
await manager.adispose()
+5 -5
View File
@@ -118,7 +118,7 @@ async def test_cleanup_old_data_live(async_db_session: AsyncSession) -> None:
old_trace = TracePath(
observer_node_id=node.id,
initiator_tag="test",
initiator_tag=12345,
created_at=old_date,
updated_at=old_date,
)
@@ -354,12 +354,12 @@ async def test_cleanup_orphaned_node_relations(
await async_db_session.commit()
await async_db_session.execute(text("PRAGMA foreign_keys=OFF"))
await async_db_session.execute(text("SET session_replication_role = replica"))
await async_db_session.execute(
text("DELETE FROM nodes WHERE id = :id"), {"id": node.id}
)
await async_db_session.commit()
await async_db_session.execute(text("PRAGMA foreign_keys=ON"))
await async_db_session.execute(text("SET session_replication_role = DEFAULT"))
counts = await cleanup_orphaned_node_relations(async_db_session, dry_run=False)
@@ -403,12 +403,12 @@ async def test_cleanup_orphaned_node_relations_dry_run(
async_db_session.add(adoption)
await async_db_session.commit()
await async_db_session.execute(text("PRAGMA foreign_keys=OFF"))
await async_db_session.execute(text("SET session_replication_role = replica"))
await async_db_session.execute(
text("DELETE FROM nodes WHERE id = :id"), {"id": node.id}
)
await async_db_session.commit()
await async_db_session.execute(text("PRAGMA foreign_keys=ON"))
await async_db_session.execute(text("SET session_replication_role = DEFAULT"))
counts = await cleanup_orphaned_node_relations(async_db_session, dry_run=True)
+50 -30
View File
@@ -7,13 +7,24 @@ from click.testing import CliRunner
from meshcore_hub.collector.cli import _import_channels, _import_routes, collector
from meshcore_hub.collector.routes import derive_expected_hash
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.database import DatabaseManager, create_database_engine
from meshcore_hub.common.models import Base
from meshcore_hub.common.models.channel import Channel
from meshcore_hub.common.models.node import Node
from meshcore_hub.common.models.route import Route
from meshcore_hub.common.models.route_node import RouteNode
from meshcore_hub.common.models.route_observer import RouteObserver
# Opaque URL handed to mocked settings (nothing connects to it).
_MOCK_PG_URL = "postgresql+psycopg2://meshcorehub:pw@localhost:5432/meshcorehub"
def _truncate_all(engine) -> None:
"""Delete rows from every table in child-first order (FK-safe)."""
with engine.begin() as conn:
for table in reversed(Base.metadata.sorted_tables):
conn.execute(table.delete())
def _make_mock_settings(db_url: str, seed_home: str = "/tmp/seed") -> MagicMock:
mock_settings = MagicMock(
@@ -45,7 +56,7 @@ class TestCollectorGroup:
def test_collector_without_subcommand_calls_run_service(self):
runner = CliRunner()
mock_settings = _make_mock_settings("sqlite:///tmp/test.db")
mock_settings = _make_mock_settings(_MOCK_PG_URL)
with (
patch(
@@ -63,7 +74,7 @@ class TestCollectorGroup:
def test_collector_with_data_home_override(self):
runner = CliRunner()
mock_settings = _make_mock_settings("sqlite:///default/db")
mock_settings = _make_mock_settings(_MOCK_PG_URL)
with (
patch(
@@ -90,7 +101,7 @@ class TestCollectorRunSubcommand:
def test_run_subcommand_calls_run_service(self):
runner = CliRunner()
mock_settings = _make_mock_settings("sqlite:///tmp/test.db")
mock_settings = _make_mock_settings(_MOCK_PG_URL)
with (
patch(
@@ -110,7 +121,7 @@ class TestCollectorSeedSubcommand:
def test_seed_command_help(self):
runner = CliRunner()
mock_settings = _make_mock_settings("sqlite:///tmp/test.db")
mock_settings = _make_mock_settings(_MOCK_PG_URL)
with patch(
"meshcore_hub.common.config.get_collector_settings",
@@ -123,16 +134,23 @@ class TestCollectorSeedSubcommand:
class TestChannelCommands:
"""Integration tests for channel CLI commands using real SQLite."""
"""Integration tests for channel CLI commands against worker-schema Postgres."""
@pytest.fixture
def cli_db_url(self, tmp_path):
db_path = tmp_path / "test.db"
db_url = f"sqlite:///{db_path}"
db = DatabaseManager(db_url)
db.create_tables()
db.dispose()
return db_url
def cli_db_url(self, db_url, db_schema, monkeypatch):
"""Worker-schema Postgres URL for the CLI.
``DATABASE_SCHEMA`` is exported so the CLI's own
``DatabaseManager(ctx.obj["database_url"])`` resolves the same schema
via the production env fallback.
"""
monkeypatch.setenv("DATABASE_SCHEMA", db_schema)
engine = create_database_engine(db_url, schema=db_schema)
Base.metadata.create_all(engine)
_truncate_all(engine)
yield db_url
_truncate_all(engine)
engine.dispose()
def test_channel_list_empty(self, cli_db_url):
runner = CliRunner()
@@ -282,11 +300,12 @@ class TestImportChannels:
"""Unit tests for _import_channels YAML import function."""
@pytest.fixture
def import_db(self, tmp_path):
db_path = tmp_path / "import.db"
db = DatabaseManager(f"sqlite:///{db_path}")
def import_db(self, db_url, db_schema):
db = DatabaseManager(db_url, schema=db_schema)
db.create_tables()
_truncate_all(db.engine)
yield db
_truncate_all(db.engine)
db.dispose()
def _write_yaml(self, tmp_path, content: str) -> str:
@@ -394,7 +413,7 @@ class TestImportChannels:
class TestChannelSeedImport:
"""Integration tests for seed command with channels.yaml."""
def test_seed_imports_channels_yaml(self, tmp_path):
def test_seed_imports_channels_yaml(self, tmp_path, db_url, db_schema, monkeypatch):
runner = CliRunner()
seed_dir = tmp_path / "seed"
seed_dir.mkdir()
@@ -402,10 +421,11 @@ class TestChannelSeedImport:
"SeededCh: AABBCCDDEEFF00112233445566778899\n"
)
db_path = tmp_path / "seed_test.db"
db_url = f"sqlite:///{db_path}"
db = DatabaseManager(db_url)
# DATABASE_SCHEMA routes the CLI's own DatabaseManager to the worker schema.
monkeypatch.setenv("DATABASE_SCHEMA", db_schema)
db = DatabaseManager(db_url, schema=db_schema)
db.create_tables()
_truncate_all(db.engine)
db.dispose()
mock_settings = _make_mock_settings(db_url, seed_home=str(seed_dir))
@@ -423,22 +443,22 @@ class TestChannelSeedImport:
assert result.exit_code == 0
assert "Channels: 1 created" in result.output
db = DatabaseManager(db_url)
db = DatabaseManager(db_url, schema=db_schema)
with db.session_scope() as session:
ch = session.query(Channel).filter(Channel.name == "SeededCh").first()
assert ch is not None
assert ch.visibility == "community"
db.dispose()
def test_seed_no_seed_files(self, tmp_path):
def test_seed_no_seed_files(self, tmp_path, db_url, db_schema, monkeypatch):
runner = CliRunner()
empty_seed = tmp_path / "empty_seed"
empty_seed.mkdir()
db_path = tmp_path / "noseed.db"
db_url = f"sqlite:///{db_path}"
db = DatabaseManager(db_url)
monkeypatch.setenv("DATABASE_SCHEMA", db_schema)
db = DatabaseManager(db_url, schema=db_schema)
db.create_tables()
_truncate_all(db.engine)
db.dispose()
mock_settings = _make_mock_settings(db_url, seed_home=str(empty_seed))
@@ -681,7 +701,7 @@ class TestImportRoutes:
class TestRouteSeedImport:
"""Integration tests for the ``seed`` command with routes.yaml."""
def test_seed_imports_routes_yaml(self, tmp_path):
def test_seed_imports_routes_yaml(self, tmp_path, db_url, db_schema, monkeypatch):
runner = CliRunner()
seed_dir = tmp_path / "seed"
seed_dir.mkdir()
@@ -695,10 +715,10 @@ class TestRouteSeedImport:
f" path:\n - '{pk_a}'\n - '{pk_b}'\n"
)
db_path = tmp_path / "seed_route.db"
db_url = f"sqlite:///{db_path}"
db = DatabaseManager(db_url)
monkeypatch.setenv("DATABASE_SCHEMA", db_schema)
db = DatabaseManager(db_url, schema=db_schema)
db.create_tables()
_truncate_all(db.engine)
with db.session_scope() as session:
session.add(Node(public_key=pk_a, name="NodeA"))
session.add(Node(public_key=pk_b, name="NodeB"))
@@ -718,7 +738,7 @@ class TestRouteSeedImport:
assert result.exit_code == 0
assert "Routes: 1 created" in result.output
db = DatabaseManager(db_url)
db = DatabaseManager(db_url, schema=db_schema)
with db.session_scope() as session:
route = session.query(Route).filter(Route.from_label == "NodeA").first()
assert route is not None
@@ -73,7 +73,7 @@ def test_handle_contact_updates_existing_node_name(db_session, mock_db_manager):
db_session.expire_all()
node = db_session.query(Node).filter_by(public_key="b" * 64).first()
assert node.name == "NewName"
# Compare timestamps without timezone (SQLite strips timezone info)
# Compare timestamps without timezone (naive datetimes from the handler)
assert node.last_seen is not None
assert node.last_seen.replace(tzinfo=None) == last_seen_time.replace(tzinfo=None)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Tests for the spam scoring module (collector/spam.py).
The DB-touching tests run against whichever backend the shared ``db_manager``
fixture is configured for (SQLite by default, Postgres when
``TEST_DATABASE_BACKEND=postgres``).
The DB-touching tests run against the per-worker Postgres schema via the
shared ``db_manager`` fixture.
"""
from dataclasses import replace
+2 -2
View File
@@ -1367,13 +1367,13 @@ class TestSubscriberDispatch:
class TestCreateSubscriber:
"""Tests for create_subscriber factory function."""
def test_creates_subscriber(self):
def test_creates_subscriber(self, db_url):
"""Test creating a subscriber."""
with patch("meshcore_hub.collector.subscriber.MQTTClient") as MockMQTT:
subscriber = create_subscriber(
mqtt_host="localhost",
mqtt_port=1883,
database_url="sqlite:///:memory:",
database_url=db_url,
)
assert subscriber is not None
-9
View File
@@ -12,7 +12,6 @@ from meshcore_hub.collector.tag_import import (
load_tags_file,
validate_public_key,
)
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models import Node, NodeTag
@@ -174,14 +173,6 @@ class TestLoadTagsFile:
class TestImportTags:
"""Tests for import_tags function."""
@pytest.fixture
def db_manager(self):
"""Create an in-memory database manager for testing."""
manager = DatabaseManager("sqlite:///:memory:")
manager.create_tables()
yield manager
manager.dispose()
@pytest.fixture
def sample_tags_file(self):
"""Create a sample tags file with new format."""
+1 -19
View File
@@ -4,30 +4,12 @@ import hashlib
import pytest
from pydantic import ValidationError
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from meshcore_hub.common.models import Base, Channel, ChannelVisibility
from meshcore_hub.common.models import Channel, ChannelVisibility
from meshcore_hub.common.schemas.channels import ChannelCreate, ChannelUpdate
@pytest.fixture
def db_session():
"""Create an in-memory SQLite database session."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
Base.metadata.drop_all(engine)
engine.dispose()
class TestChannelModel:
"""Tests for Channel SQLAlchemy model."""
+43 -37
View File
@@ -1,6 +1,7 @@
"""Tests for configuration settings."""
import pytest
from pydantic import ValidationError
from meshcore_hub.common.config import (
CommonSettings,
@@ -68,14 +69,10 @@ class TestCollectorSettings:
"""Test that custom data_home affects effective paths."""
settings = CollectorSettings(_env_file=None, data_home="/custom/data")
assert (
settings.effective_database_url
== "sqlite:////custom/data/collector/meshcore.db"
)
assert settings.collector_data_dir == "/custom/data/collector"
def test_explicit_database_url_overrides(self) -> None:
"""Test that explicit database_url overrides the default."""
"""Test that explicit database_url overrides the components."""
settings = CollectorSettings(
_env_file=None, database_url="postgresql://user@host/db"
)
@@ -181,16 +178,13 @@ class TestAPISettings:
"""Tests for APISettings."""
def test_custom_data_home(self) -> None:
"""Test that custom data_home affects effective database path."""
"""Test that custom data_home is stored (seed/health/content paths)."""
settings = APISettings(_env_file=None, data_home="/custom/data")
assert (
settings.effective_database_url
== "sqlite:////custom/data/collector/meshcore.db"
)
assert settings.data_home == "/custom/data"
def test_explicit_database_url_overrides(self) -> None:
"""Test that explicit database_url overrides the default."""
"""Test that explicit database_url overrides the components."""
settings = APISettings(_env_file=None, database_url="postgresql://user@host/db")
assert settings.database_url == "postgresql://user@host/db"
@@ -328,24 +322,13 @@ class TestWebSettings:
assert settings.features["radio_config"] is False
class TestDatabaseBackendResolution:
"""Tests for DATABASE_BACKEND selection and URL/schema resolution."""
class TestDatabaseUrlResolution:
"""Tests for the PostgreSQL-only URL/schema resolution contract."""
def test_default_backend_is_sqlite_unchanged(self) -> None:
"""No DB env vars -> the same SQLite path and no schema as before."""
settings = CollectorSettings(_env_file=None, data_home="/data")
assert settings.database_backend.value == "sqlite"
assert (
settings.effective_database_url == "sqlite:////data/collector/meshcore.db"
)
assert settings.effective_database_schema is None
def test_postgres_backend_assembles_url_and_schema(self) -> None:
"""Postgres backend assembles a URL from components and exposes the schema."""
def test_components_assemble_url_and_schema(self) -> None:
"""DATABASE_* components assemble a URL; the schema is always exposed."""
settings = APISettings(
_env_file=None,
database_backend="postgres",
database_host="pg",
database_password="pw",
)
@@ -355,22 +338,20 @@ class TestDatabaseBackendResolution:
)
assert settings.effective_database_schema == "meshcorehub"
def test_postgres_password_is_url_encoded(self) -> None:
def test_password_is_url_encoded(self) -> None:
"""Special characters in the password are percent-encoded."""
settings = APISettings(
_env_file=None,
database_backend="postgres",
database_host="pg",
database_password="s3cr3t/p@ss",
)
assert "s3cr3t%2Fp%40ss" in settings.effective_database_url
def test_postgres_schema_override_per_instance(self) -> None:
def test_schema_override_per_instance(self) -> None:
"""DATABASE_SCHEMA isolates instances sharing one database."""
settings = APISettings(
_env_file=None,
database_backend="postgres",
database_host="pg",
database_password="pw",
database_schema="stg",
@@ -378,19 +359,44 @@ class TestDatabaseBackendResolution:
assert settings.effective_database_schema == "stg"
def test_postgres_missing_required_vars_fails_fast(self) -> None:
"""Misconfigured postgres backend raises rather than silently using SQLite."""
settings = APISettings(_env_file=None, database_backend="postgres")
def test_missing_required_vars_fails_fast(self) -> None:
"""No DATABASE_URL and no components raises rather than guessing."""
settings = APISettings(_env_file=None)
with pytest.raises(ValueError, match="DATABASE_BACKEND=postgres requires"):
with pytest.raises(ValueError, match="PostgreSQL connection is not configured"):
_ = settings.effective_database_url
def test_explicit_url_overrides_backend(self) -> None:
"""An explicit DATABASE_URL wins even when a backend is selected."""
def test_explicit_url_overrides_components(self) -> None:
"""An explicit DATABASE_URL wins over the component vars."""
settings = CollectorSettings(
_env_file=None,
database_backend="postgres",
database_host="pg",
database_password="pw",
database_url="postgresql+psycopg2://u:p@h/db",
)
assert settings.effective_database_url == "postgresql+psycopg2://u:p@h/db"
def test_backend_defaults_to_postgres_noop(self) -> None:
"""DATABASE_BACKEND=postgres (the default) is accepted as a no-op."""
settings = CommonSettings(
_env_file=None,
database_backend="postgres",
database_host="pg",
database_password="pw",
)
assert settings.database_backend == "postgres"
assert settings.effective_database_url.startswith("postgresql+psycopg2://")
def test_backend_sqlite_is_rejected_with_upgrade_hint(self) -> None:
"""DATABASE_BACKEND=sqlite fails with the targeted v0.19 upgrade error."""
with pytest.raises(
ValidationError, match="SQLite support was removed in v0.19"
):
_ = CommonSettings(_env_file=None, database_backend="sqlite")
def test_backend_unknown_value_is_rejected(self) -> None:
"""Anything other than 'postgres' is rejected."""
with pytest.raises(ValidationError, match="must be 'postgres'"):
_ = CommonSettings(_env_file=None, database_backend="mysql")
+15 -70
View File
@@ -1,10 +1,8 @@
"""Tests for database engine configuration."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import text
from meshcore_hub.common.database import (
DatabaseManager,
@@ -14,43 +12,12 @@ from meshcore_hub.common.database import (
)
class TestSqlitePragmas:
"""Verify concurrency-related SQLite pragmas are applied on connect."""
def test_wal_and_busy_timeout_enabled(self, tmp_path: Path) -> None:
"""File-based SQLite engines should run in WAL mode with a busy timeout."""
db_path = tmp_path / "pragma.db"
engine = create_database_engine(f"sqlite:///{db_path}")
try:
with engine.connect() as conn:
journal_mode = conn.execute(text("PRAGMA journal_mode")).scalar()
busy_timeout = conn.execute(text("PRAGMA busy_timeout")).scalar()
foreign_keys = conn.execute(text("PRAGMA foreign_keys")).scalar()
assert str(journal_mode).lower() == "wal"
assert busy_timeout is not None and int(busy_timeout) >= 5000
assert foreign_keys is not None and int(foreign_keys) == 1
finally:
engine.dispose()
def test_in_memory_engine_builds(self) -> None:
"""In-memory SQLite must still build (no overflow-pool kwargs)."""
engine = create_database_engine("sqlite:///:memory:")
try:
with engine.connect() as conn:
assert conn.execute(text("SELECT 1")).scalar() == 1
finally:
engine.dispose()
class TestAsyncUrlMapping:
"""Map sync URLs to their async-driver equivalents for the async engine."""
"""Map sync URLs to their asyncpg equivalents for the async engine."""
@pytest.mark.parametrize(
"sync_url,expected",
[
("sqlite:///x.db", "sqlite+aiosqlite:///x.db"),
("sqlite+aiosqlite:///x.db", "sqlite+aiosqlite:///x.db"),
("postgresql://u:p@h/db", "postgresql+asyncpg://u:p@h/db"),
("postgres://u:p@h/db", "postgresql+asyncpg://u:p@h/db"),
# config assembles +psycopg2; the async engine must still use asyncpg
@@ -65,21 +32,16 @@ class TestAsyncUrlMapping:
class TestSchemaResolution:
"""search_path schema resolution (explicit arg vs DATABASE_SCHEMA env)."""
def test_sqlite_never_has_schema(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DATABASE_SCHEMA", "ignored")
assert _resolve_pg_schema("sqlite:///x.db", None) is None
assert _resolve_pg_schema("sqlite:///x.db", "explicit") is None
def test_explicit_schema_wins(self) -> None:
assert _resolve_pg_schema("postgresql://u@h/db", "prod") == "prod"
assert _resolve_pg_schema("prod") == "prod"
def test_falls_back_to_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DATABASE_SCHEMA", "stg")
assert _resolve_pg_schema("postgresql://u@h/db", None) == "stg"
assert _resolve_pg_schema(None) == "stg"
def test_none_when_no_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("DATABASE_SCHEMA", raising=False)
assert _resolve_pg_schema("postgresql://u@h/db", None) is None
assert _resolve_pg_schema(None) is None
class TestPostgresSessionTimezone:
@@ -87,7 +49,7 @@ class TestPostgresSessionTimezone:
func.date(<timestamptz>) truncates on the session timezone's day boundary.
The collector writes UTC, so the session must be UTC for day buckets to
match SQLite's UTC-text truncation.
line up with those writes.
"""
def test_sync_engine_sets_timezone_utc_without_schema(
@@ -109,13 +71,14 @@ class TestPostgresSessionTimezone:
assert "-csearch_path=meshcorehub" in options
assert "-ctimezone=UTC" in options
def test_sqlite_engine_has_no_timezone_options(self, tmp_path: Path) -> None:
"""SQLite engines must not set timezone options."""
engine = create_database_engine(f"sqlite:///{tmp_path / 'x.db'}")
try:
assert "options" not in engine.url.query
finally:
engine.dispose()
def test_sync_engine_always_sizes_the_pool(self) -> None:
"""Pool sizing is unconditional (sized above the Starlette threadpool)."""
with patch("meshcore_hub.common.database.create_engine") as mock_create:
create_database_engine("postgresql://u:p@h/db")
_, kwargs = mock_create.call_args
assert kwargs["pool_size"] == 20
assert kwargs["max_overflow"] == 30
assert kwargs["pool_pre_ping"] is True
def test_async_engine_sets_server_settings_timezone_utc(
self, monkeypatch: pytest.MonkeyPatch
@@ -166,28 +129,10 @@ class TestPostgresSessionTimezone:
assert kwargs["pool_size"] == 20
assert kwargs["max_overflow"] == 30
def test_async_engine_skips_pool_args_for_memory_sqlite(self) -> None:
"""In-memory SQLite uses a non-overflow pool; sizing args must be omitted."""
manager = DatabaseManager.__new__(DatabaseManager)
manager.database_url = "sqlite+aiosqlite:///:memory:"
manager._echo = False
manager._schema = None
manager._async_engine = None
manager._async_session_factory = None
with (
patch("meshcore_hub.common.database.create_async_engine") as mock_async,
patch("meshcore_hub.common.database.event.listens_for"),
):
manager._ensure_async_engine()
_, kwargs = mock_async.call_args
assert "pool_size" not in kwargs
assert "max_overflow" not in kwargs
async def test_adispose_closes_async_engine(self) -> None:
"""adispose awaits the async engine dispose and clears it."""
manager = DatabaseManager.__new__(DatabaseManager)
manager.database_url = "sqlite+aiosqlite:///:memory:"
manager.database_url = "postgresql://u:p@h/db"
manager._echo = False
manager._schema = None
mock_async_engine = AsyncMock()
@@ -205,7 +150,7 @@ class TestPostgresSessionTimezone:
def test_dispose_sync_context_closes_async_engine_via_temp_loop(self) -> None:
"""Outside a running loop, dispose() drains the async engine too."""
manager = DatabaseManager.__new__(DatabaseManager)
manager.database_url = "sqlite+aiosqlite:///:memory:"
manager.database_url = "postgresql://u:p@h/db"
manager._echo = False
manager._schema = None
disposed = []
+1 -20
View File
@@ -1,11 +1,8 @@
"""Tests for database models."""
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
from meshcore_hub.common.models import (
Base,
Node,
NodeTag,
Message,
@@ -19,22 +16,6 @@ from meshcore_hub.common.models import (
)
@pytest.fixture
def db_session():
"""Create an in-memory SQLite database session."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
Base.metadata.drop_all(engine)
engine.dispose()
class TestNodeModel:
"""Tests for Node model."""