Phase 1: make ORM/migrations Postgres-compatible

Dialect-neutral fixes that keep SQLite behaviour unchanged:
- event_observer: choose INSERT construct by bind dialect so the
  on_conflict upsert works on both SQLite and Postgres
- database: map postgresql:// -> postgresql+asyncpg:// for async sessions
  (was sqlite-only), via a _to_async_url helper
- models: import JSON from sqlalchemy (generic) not the sqlite dialect
- alembic/env: render_as_batch only for SQLite (its ALTER TABLE workaround)

Full SQLite test suite green (1045 passed) and fresh db upgrade builds all
tables; black/flake8/mypy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Louis King
2026-06-13 21:48:49 +01:00
parent 57488239c9
commit 9eab07d244
7 changed files with 58 additions and 25 deletions
+3 -1
View File
@@ -58,7 +58,9 @@ def run_migrations_offline() -> None:
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True, # SQLite batch mode for ALTER TABLE
# Batch mode is a SQLite-only workaround for its limited ALTER TABLE;
# Postgres performs ALTERs directly.
render_as_batch=url.startswith("sqlite"),
)
with context.begin_transaction():
+17 -1
View File
@@ -11,6 +11,22 @@ from sqlalchemy.orm import Session, sessionmaker
from meshcore_hub.common.models.base import Base
def _to_async_url(database_url: str) -> str:
"""Map a sync database URL to its async-driver equivalent.
Leaves an already driver-qualified URL (``dialect+driver://``) untouched so an
explicit driver choice is respected.
"""
scheme = database_url.split("://", 1)[0]
if "+" in scheme:
return database_url
if scheme == "sqlite":
return database_url.replace("sqlite://", "sqlite+aiosqlite://", 1)
if scheme in ("postgresql", "postgres"):
return database_url.replace(f"{scheme}://", "postgresql+asyncpg://", 1)
return database_url
def create_database_engine(
database_url: str,
echo: bool = False,
@@ -142,7 +158,7 @@ class DatabaseManager:
from sqlalchemy.ext.asyncio import async_sessionmaker
async_url = self.database_url.replace("sqlite://", "sqlite+aiosqlite://")
async_url = _to_async_url(self.database_url)
self._async_engine = create_async_engine(async_url, echo=self._echo)
# Apply the same SQLite pragmas as the sync engine (see
+1 -2
View File
@@ -3,8 +3,7 @@
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import DateTime, ForeignKey, Index, String
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy import DateTime, ForeignKey, Index, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
@@ -8,13 +8,13 @@ from sqlalchemy import (
DateTime,
Float,
ForeignKey,
Insert,
Integer,
Index,
String,
UniqueConstraint,
update,
)
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
@@ -122,21 +122,40 @@ def add_event_observer(
now = observed_at or datetime.now(timezone.utc)
stmt = (
sqlite_insert(EventObserver)
.values(
id=str(uuid4()),
event_type=event_type,
event_hash=event_hash,
observer_node_id=observer_node_id,
snr=snr,
path_len=path_len,
observed_at=now,
created_at=now,
updated_at=now,
# 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,
"event_hash": event_hash,
"observer_node_id": observer_node_id,
"snr": snr,
"path_len": path_len,
"observed_at": now,
"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)
)
.on_conflict_do_nothing(index_elements=["event_hash", "observer_node_id"])
)
result = session.execute(stmt)
rowcount = getattr(result, "rowcount", 0)
+1 -2
View File
@@ -3,8 +3,7 @@
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
+1 -2
View File
@@ -3,8 +3,7 @@
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, String
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy import DateTime, ForeignKey, Index, JSON, LargeBinary, String
from sqlalchemy.orm import Mapped, mapped_column
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
+1 -2
View File
@@ -3,8 +3,7 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now