Records the DATABASE_BACKEND explicit switch, schema-per-instance isolation via search_path for shared-cluster prod/stg, the no-admin-credentials provisioning decision, the migrate-to-postgres command internals, the operator runbook, and the phased implementation order with SQLite gates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
24 KiB
Plan: Add PostgreSQL support and migrate existing SQLite databases
Context
meshcore-hub currently runs on SQLite (sqlite:///{DATA_HOME}/collector/meshcore.db).
SQLite WAL does not work over network filesystems and limits concurrent writers, so it
caps the project at a single host — the README already flags switching to Postgres for
multi-host scaling. The goal is to (1) make the codebase genuinely Postgres-compatible,
(2) add a Postgres container and component-based connection config, and (3) give existing
community operators a one-command path to migrate their live SQLite data into Postgres
(downtime is acceptable).
The stack is already mostly ready: SQLAlchemy 2.0 + Alembic, asyncpg/psycopg2-binary
declared as the [postgres] optional dependency in pyproject.toml, and DATABASE_URL
threaded through config.py and alembic/env.py. The work is closing the SQLite-specific
gaps and adding the container + migration tooling.
Decisions made: data migration uses a SQLAlchemy ORM copy script (type-safe, no
extra system dependency for operators); connection config uses component env vars
assembled into a URL; and the backend is selected by an explicit DATABASE_BACKEND
switch (sqlite default | postgres) that fails fast when set to postgres without the
required component vars — rather than silently falling back to SQLite. An explicit
DATABASE_URL, if set, still overrides everything (managed/external PG, tests). pgloader is
not used — see "Why not pgloader" below.
SQLite remains the zero-config default: a deployment with no DB env vars behaves exactly
as today. Postgres is opt-in and requires agreement across two layers — the app
(DATABASE_BACKEND=postgres + component vars) and the infra (the compose postgres
profile must be activated). The explicit switch makes a half-configured state fail loudly at
startup instead of silently using the wrong database.
Two further decisions (detailed in Parts B and C):
- Multi-instance isolation via Postgres schemas. Production runs several Hub instances
(prod, stg, …) against one shared Postgres; each is scoped to its own schema
(
DATABASE_SCHEMA, defaultmeshcorehub) viasearch_path, with its ownalembic_versionfor independent migration state. Defaults for database/schema/role are allmeshcorehub. - No admin/bootstrap credentials. The app never holds cluster-admin creds; provisioning
is automatic on the bundled container (image entrypoint) and out-of-band for managed
Postgres (IaC/console/DBA). No
POSTGRES_ADMIN_*.
Why not pgloader
pgloader would infer the target schema from SQLite's dynamic typing and produce wrong
Postgres types: is_observer (stored 0/1) → bigint not boolean; decoded JSON
(stored as TEXT) → text not json; DateTime(timezone=True) values (stored as text)
→ no timestamptz; String(64) length constraints lost; and no alembic_version
consistent with our migration history. The ORM copy script reuses the existing models, so
SQLAlchemy performs every type conversion correctly and the schema is created by
alembic upgrade head.
Implementation order
Parts map to phases. The throughline: only Phases 1–2 touch shared code and can regress SQLite, so each ends in a SQLite gate; Phases 3–5 are additive and Postgres-only. Reach "Postgres-ready code + verified SQLite" at the end of Phase 2 before committing to the heavier container/migration work.
- Phase 1 — Code compatibility (Part A). The four dialect-neutral fixes (upsert, async
URL mapping, generic
JSON, conditionalrender_as_batch).- Gate 1 (SQLite): full existing test suite green on SQLite + fresh
db upgradefrom scratch on a new SQLite file. These changes are behaviour-neutral, so any breakage is isolated to these four edits — easy to bisect.
- Gate 1 (SQLite): full existing test suite green on SQLite + fresh
- Phase 2 — Backend switch + config (Part B).
DATABASE_BACKEND, theDATABASE_*vars,search_pathwiring,version_table_schema.- Gate 2 (SQLite — the key checkpoint): re-run full suite; add a test asserting the
default no-env path resolves to the exact same SQLite URL/behaviour as before (no
schema/search_path logic engaged when
DATABASE_BACKENDunset). Milestone: code is Postgres-compatible and SQLite is proven untouched.
- Gate 2 (SQLite — the key checkpoint): re-run full suite; add a test asserting the
default no-env path resolves to the exact same SQLite URL/behaviour as before (no
schema/search_path logic engaged when
- Phase 3 — Postgres container (Part C). Service,
DATABASE_* → POSTGRES_*derivation,initdb.dschema script, profile, healthcheck. Pure infra; cannot regress SQLite.- Gate 3 (Postgres): bring container up,
db upgradeagainst it (tables land in the schema,alembic_versionstamped), run the existing suite against Postgres. Wire a SQLite + Postgres test matrix here so both run going forward.
- Gate 3 (Postgres): bring container up,
- Phase 4 — Data migration command (Part D). New Postgres-only code path; zero SQLite risk.
- Gate 4: round-trip the real dev DB (
data/collector/meshcore.db) → Postgres, reconcile per-table row counts, spot-check a JSON/bool/timestamp value.
- Gate 4: round-trip the real dev DB (
- Phase 5 — Verification + docs (Part E). End-to-end stack run on the
postgresprofile;docs/upgrading.mdrunbook +search_path/provisioning docs.
Part A — Make the code Postgres-compatible (required regardless of migration tool)
These are real runtime bugs on Postgres, not cosmetics.
-
Dialect-aware upsert —
src/meshcore_hub/common/models/event_observer.py:17,125-139add_event_observer()is live collector code and currently usesfrom sqlalchemy.dialects.sqlite import insert as sqlite_insert+.on_conflict_do_nothing(...). On Postgres this emits invalid SQL. Fix: pick the insert construct by bind dialect, e.g.if session.bind.dialect.name == "postgresql": from sqlalchemy.dialects.postgresql import insert as pg_insert stmt = pg_insert(EventObserver).values(...).on_conflict_do_nothing( index_elements=["event_hash", "observer_node_id"]) else: from sqlalchemy.dialects.sqlite import insert as sqlite_insert stmt = sqlite_insert(EventObserver).values(...).on_conflict_do_nothing(...)Both dialects expose the same
.on_conflict_do_nothing(index_elements=...)API, so only the import/constructor differs. Grep for otherdialects.sqlite import insertusages. -
Async driver mapping —
src/meshcore_hub/common/database.py:145_ensure_async_engine()only rewritessqlite://→sqlite+aiosqlite://. Apostgresql://URL keeps the syncpsycopg2driver and async API sessions fail. Fix: mappostgresql:///postgres://→postgresql+asyncpg://(leave an already+driver-qualified URL untouched). Add a small helper (e.g._to_async_url(url)) used here. -
Generic JSON type — 4 models import
from sqlalchemy.dialects.sqlite import JSON:models/raw_packet.py:7,models/telemetry.py,models/trace_path.py,models/event_log.py. Switch to genericfrom sqlalchemy import JSON. GenericJSONmaps to SQLite JSON and PostgresJSONautomatically. (Optional: usepostgresql.JSONBvia.with_variant()for indexability — not required for parity.) -
Conditional batch migrations —
alembic/env.py:61,87render_as_batch=Trueis unconditional (it's a SQLite ALTER-TABLE workaround). Make itrender_as_batch = get_database_url().startswith("sqlite")in bothrun_migrations_offline()andrun_migrations_online(). Existing migrations that callop.batch_alter_table(...)still run correctly on Postgres (Alembic emits directALTERthere), and a fresh Postgres DB runs the whole history from scratch.
The SQLite
PRAGMAblock indatabase.py:52-65,150-161is already guarded bystartswith("sqlite")— no change needed.
Verification for Part A: run the existing test suite against Postgres (see Part E).
Part B — Component-based connection config
Centralize config in src/meshcore_hub/common/config.py. CollectorSettings and
APISettings currently each carry database_url + a duplicated effective_database_url
property (config.py:72-75,174-182 and the matching block in APISettings).
- Add a
database_backendfield toCommonSettings(envDATABASE_BACKEND, default"sqlite", validated choicesqlite | postgres). - Add component fields to
CommonSettings(so both inherit), all defaulting to"meshcorehub"where named:database_host,database_port(default5432),database_name(the database, default"meshcorehub"),database_schema(the Postgres schema/namespace, default"meshcorehub"),database_user(default"meshcorehub"),database_password.DATABASE_NAMEandDATABASE_SCHEMAare distinct: the former is the database, the latter is the namespace within it. They differ in the shared-cluster case (see "Multi-instance isolation" below); for a single instance both aremeshcorehub.
- Add a shared resolution helper (method on
CommonSettings, or module function) used by botheffective_database_urlproperties, with this precedence:- explicit
database_urlif set → use verbatim (escape hatch: managed/external PG, tests); - else if
database_backend == "postgres"→ requiredatabase_host,database_name,database_user,database_password(raise a clear startup error naming any missing var — do not fall back to SQLite), then assemblepostgresql+psycopg2://{user}:{password}@{host}:{port}/{name}(URL-encode the password); - else (
database_backend == "sqlite") → existing SQLite default underDATA_HOME.
- explicit
- Collapse the duplicated
effective_database_urlinto the shared helper.
Multi-instance isolation via Postgres schemas
Production will run multiple Hub instances (prod, stg, …) against one shared Postgres.
Isolate them with schema-per-instance, not database-per-instance: a shared database with
each instance scoped to its own schema (DATABASE_SCHEMA). This reuses the schema var and is
lighter to provision on a shared/managed cluster than many databases. (Database-per-instance
is the heavier alternative — stronger isolation, but more provisioning; not chosen.)
Mechanics (Postgres only — SQLite ignores all of this):
- Scope every connection to the schema via
search_pathrather than hardcodingschema=on the models/metadata (hardcoding would pin the ORM to one schema and break SQLite). Set it increate_database_enginefor Postgres, e.g. psycopg2connect_args={"options": f"-csearch_path={schema}"}(and the asyncpg equivalent for the async engine — aSET search_pathon connect via an event listener). Models stay schema-agnostic; the active schema is chosen entirely by config. - Per-instance migration state: Alembic must place its bookkeeping in the instance's
schema — pass
version_table_schema=<schema>inalembic/env.py(Postgres only) so each instance has its ownalembic_versionand prod/stg can sit on different revisions independently.include_schemas=Truefor autogenerate. Thesearch_pathset on the connection meansupgradecreates the tables in the right schema automatically. - Schema must exist first: the app role needs its schema present. Either pre-provisioned
out-of-band (managed; see Part C) or
CREATE SCHEMA IF NOT EXISTSon the bundled container. Thedb migrate-to-postgrescommand inherits the samesearch_path, so it loads into the instance's schema with no extra flags.
Update .env.example with a DATABASE_BACKEND line (default sqlite) plus the
DATABASE_HOST / DATABASE_PORT / DATABASE_NAME / DATABASE_SCHEMA / DATABASE_USER /
DATABASE_PASSWORD block (defaults meshcorehub), documented as "set
DATABASE_BACKEND=postgres and fill these in to use Postgres; override DATABASE_SCHEMA
per instance (e.g. prod, stg) when sharing one cluster; leave defaults for SQLite."
Part C — Postgres container
In docker-compose.yml (mirror the existing redis service style, named volume pattern
at lines ~411-419):
- Add a
postgresservice (postgres:17-alpine), a namedpostgres_data:/var/lib/postgresql/datavolume, and apg_isreadyhealthcheck. Put it behind apostgrescompose profile (SQLite stays the zero-config default; the container only runs when the profile is activated, e.g.docker compose --profile postgres up). - Single source of truth for credentials — derive the container's init vars from the
app's
DATABASE_*rather than maintaining a duplicate set:The image entrypoint auto-creates the role + database from these on first init (empty volume only). Add a tinyenvironment: POSTGRES_USER: ${DATABASE_USER:-meshcorehub} POSTGRES_PASSWORD: ${DATABASE_PASSWORD} POSTGRES_DB: ${DATABASE_NAME:-meshcorehub}/docker-entrypoint-initdb.d/script to alsoCREATE SCHEMA IF NOT EXISTS "${DATABASE_SCHEMA}" AUTHORIZATION "${DATABASE_USER}"so the bundled path needs no manual SQL. - Add
DATABASE_*(includingDATABASE_BACKEND) to the env passed tomigrate,collector, andapiservices. - Document the two-layer requirement explicitly: enabling Postgres = set
DATABASE_BACKEND=postgres+ component vars and activate thepostgresprofile. Compose can't read the app env var to auto-activate its profile, so these stay two switches — but the app's fail-fast validation (Part B) turns a half-configured state into a clear startup error rather than a silent SQLite fallback. - Make
migrate(and thereforecollector/api)depends_onpostgresservice_healthywhen the Postgres profile is active. - Add the
postgres_datanamed volume to thevolumes:block. - Mirror into
docker-compose.prod.ymlnetworking if Postgres should sit onproxy-net(usually it should stay internal-only — confirm during implementation).
Provisioning decision: no admin/bootstrap credentials
The app and tooling never hold cluster-admin credentials. The only privileged
operations are the one-time CREATE ROLE / CREATE DATABASE / CREATE SCHEMA / GRANT;
everything afterwards (Alembic db upgrade DDL, runtime DML, the data-migration command)
runs as the least-privileged app role, which only needs to own its schema/database, not
superuser. Provisioning is therefore split by environment, and we do not add
POSTGRES_ADMIN_USER/PASSWORD (handing the app permanent admin creds for a one-time task is
a security regression and not the production pattern):
| Environment | Provisioning of role/db/schema/grants | App holds |
|---|---|---|
| Bundled container (dev / small prod) | Automatic: image entrypoint creates role+db from POSTGRES_* (= DATABASE_*), init script creates the schema. No admin creds exist or are needed. |
DATABASE_* |
| Managed / shared Postgres (prod, stg) | Out-of-band — Terraform/IaC, cloud console, or a DBA runs the SQL once. The platform's master user stays in the infra layer, never in the app. | DATABASE_* only |
Managed/shared-cluster provisioning SQL to document in docs/ (one schema per instance in a
shared database; role must own its schema so db upgrade can create tables; need not be
superuser — which is why the migration command's session_replication_role step has a
documented fallback, Part D):
-- once per cluster
CREATE ROLE meshcorehub LOGIN PASSWORD '…';
CREATE DATABASE meshcorehub OWNER meshcorehub;
-- connected to the meshcorehub database, once per Hub instance:
CREATE SCHEMA IF NOT EXISTS prod AUTHORIZATION meshcorehub; -- and stg, etc.
GRANT ALL ON SCHEMA prod TO meshcorehub;
Each instance then sets DATABASE_SCHEMA=prod / stg. A Terraform example is a nice-to-have
follow-up, not required for v1. (Optional future convenience: a meshcore-hub db bootstrap
command that accepts admin creds only as one-shot CLI flags, never persisted env — out of
scope for v1.)
Part D — Data migration command (meshcore-hub db migrate-to-postgres)
Add a new Click command in the db group in src/meshcore_hub/__main__.py (after
db_upgrade, ~line 86), backed by a helper module (e.g.
src/meshcore_hub/common/db_migrate.py).
Command behaviour
- Opens a source
DatabaseManager(SQLite) and targetDatabaseManager(Postgres) using the existingcreate_database_engine(database.py:14). - Sensible defaults make the common case zero-flag:
--sourcedefaults to the legacy SQLite path underDATA_HOME(sqlite:////data/collector/meshcore.db) regardless of the configured backend;--targetdefaults to the configuredeffective_database_url(Postgres). Both can be overridden explicitly. - Verifies the target schema is at
headand tables are empty (refuse otherwise unless--truncate) — guards against accidental double-runs. Non-destructive to the source. - Copies at the SQLAlchemy Core level, table-by-table — not through ORM objects.
Iterate
Base.metadata.sorted_tablesand for eachTable,select(table)from SQLite andinsert(table)into Postgres reusing the sameTableobject. Type conversion is a free side-effect of the dialects' type processors: reading applies the SQLite result processor per column (0/1→bool, JSONTEXT→dict, ISO string→datetime), writing applies the Postgres bind processor (bool→boolean,dict→json,datetime→timestamptz, UUID-string→varchar). No per-model code, works generically for every table. (Core, not ORM, because instances can't be shared across two sessions.) Base.metadata.sorted_tablesgives a parent-first FK order (nodes→node_tags,user_profiles→user_profile_nodes,event_observers, event tables,channels,events_log) and naturally excludesalembic_version(not a mapped model — it was already created/stamped bydb upgrade, so we must not touch it).- Streams large tables:
select(...).execution_options(stream_results=True)+result.partitions(batch_size)(~2k) soraw_packetsdoesn't materialize in memory; inserts go out via the executemany path. - Timezone normalization (the one explicit conversion): SQLite doesn't persist tz, so
DateTime(timezone=True)values read back as naive datetimes; inserting naive values into Postgrestimestamptzwould assume the session tz. Since the app always writes UTC (utc_now()), a small normalize step attachestzinfo=UTCto naive datetimes for tz-aware columns before insert. - Single target transaction wrapping the whole copy → all-or-nothing; any failure leaves Postgres empty and the operator re-runs. Acceptable given the downtime window.
- FK enforcement during load —
sorted_tablesorder is normally sufficient (the schema has no FK cycles). For robustness the load issuesSET session_replication_role = replicato disable FK triggers, then restoresDEFAULT. Caveat: this requires DB-superuser privilege, which the bundled composepostgrescontainer has but a managed Postgres (RDS / Cloud SQL) may not grant. Fallback for managed targets: skip thesession_replication_roletoggle and rely solely onsorted_tablesorder (safe here as there are no FK cycles). The command should detect the missing privilege and fall back gracefully (or expose a--no-replication-roleflag), and the docs should call this out. - After load, reconcile — no sequences to fix (all PKs are app-generated UUID strings), but log a per-table source-vs-target row-count comparison as a built-in check.
--dry-runprints the per-table row counts without writing.
Reuse
Base.metadata.sorted_tablesand the existing models insrc/meshcore_hub/common/models/— do not redefine schema in the script.
Why run it inside the
migrateservice: the command needs both databases reachable at once — themigrateservice mounts thedatavolume (SQLite source) and sits on the compose network withpostgres(target). Hencedocker compose run --rm migrate ...rather than a bare host invocation.
Operator runbook (docker-compose deployment, downtime acceptable)
Starting state: running on SQLite, data in the data volume at /data/collector/meshcore.db.
- Back up the SQLite database — this is the rollback path:
docker compose cp collector:/data/collector/meshcore.db ./meshcore-backup-$(date +%F).db - Stop the writers (downtime starts; quiesces the SQLite file):
docker compose stop collector api web - Configure Postgres in
.env— only theDATABASE_*block (the bundled container'sPOSTGRES_*init vars derive from these, see Part C):DATABASE_BACKEND=postgres DATABASE_HOST=postgres DATABASE_PORT=5432 DATABASE_NAME=meshcorehub DATABASE_SCHEMA=meshcorehub # override per instance (prod/stg) on a shared cluster DATABASE_USER=meshcorehub DATABASE_PASSWORD=<strong-password> # e.g. openssl rand -base64 32 - Start the Postgres container and wait for its
pg_isreadyhealthcheck:docker compose --profile postgres up -d postgres - Create the schema in Postgres (builds correctly-typed tables + stamps
alembic_version):docker compose --profile postgres run --rm migrate meshcore-hub db upgrade - Copy the data across (zero-flag common case; optionally
--dry-runfirst):Confirm the per-table reconciliation counts all match.docker compose --profile postgres run --rm migrate \ meshcore-hub db migrate-to-postgres - Bring the stack up against Postgres and verify:
Spot-check the web dashboard shows nodes/events. Downtime ends here.
docker compose --profile postgres up -d docker compose run --rm api meshcore-hub api health - Decommission SQLite (later) — once confident (a few days), remove the old
meshcore.dbfrom thedatavolume; keep the step-1 backup archived.
Rollback: stop the stack, set DATABASE_BACKEND=sqlite (or remove it) in .env, and
docker compose up -d without the postgres profile. You're back on the untouched SQLite
file — the migration never mutates the source.
The implementation should mirror this runbook into
docs/upgrading.md(Part D deliverable).
Part E — Verification
- Unit/integration tests on Postgres. Spin up a throwaway Postgres
(
docker run --rm -e POSTGRES_PASSWORD=test -p 55432:5432 postgres:17-alpine), export the matchingDATABASE_*, runmeshcore-hub db upgrade, then the existing test suite pointed at Postgres. Confirms Part A (esp. theevent_observerupsert and async API sessions) and the migration chain build cleanly on Postgres. - Round-trip data migration. Use the real dev DB at
data/collector/meshcore.db(orbackup/meshcore.db) as source. Rundb upgrade+db migrate-to-postgres, then assert per-table row counts match (the command's built-in reconciliation), and spot-check araw_packets.decodedJSON value, anis_observerboolean, and areceived_attimestamp survived with correct types. - End-to-end app run. Bring up the stack with the
postgresprofile +DATABASE_*set, confirmmigratecompletes,collectoringests an event (exercisesadd_event_observerupsert on Postgres), and theapi/webserve data (meshcore-hub api health).
Critical files
| File | Change |
|---|---|
src/meshcore_hub/common/models/event_observer.py |
Dialect-aware upsert (A1) |
src/meshcore_hub/common/database.py |
Async driver mapping for Postgres (A2) |
src/meshcore_hub/common/models/{raw_packet,telemetry,trace_path,event_log}.py |
Generic JSON import (A3) |
alembic/env.py |
Conditional render_as_batch (A4) |
src/meshcore_hub/common/config.py |
Component DATABASE_* vars + shared URL assembly (B) |
.env.example |
Document new DATABASE_* vars (B) |
docker-compose.yml (+ .prod.yml) |
postgres service, volume, depends_on, env (C) |
src/meshcore_hub/__main__.py + new common/db_migrate.py |
db migrate-to-postgres command (D) |
README.md / docs/upgrading.md |
Document Postgres setup + migration procedure |
Out of scope / notes
- Keep SQLite as the zero-config default; Postgres is opt-in. No forced migration.
- No schema redesign — all column types already map cleanly once A1–A4 land.
- Consider gating CI to run the suite against both SQLite and Postgres (follow-up).