feat(db): Base/SQLite storage (VFS→SeaweedFS), drop Postgres coupling (#13)

bootnode stores its catalog in Base/SQLite — the canonical store, durable via
VFS→SeaweedFS — not Postgres. Make the models dialect-portable and the engine
sqlite-aware:
- models.py: postgres JSONB/UUID → portable sqlalchemy JSON/Uuid
- config.py: database_url is str (not PostgresDsn) so sqlite+aiosqlite:/// DSNs
  validate; default = sqlite+aiosqlite:////data/bootnode/bootnode.db
- session.py: normalize sqlite:// → sqlite+aiosqlite://, and skip the sized
  pool (pool_size/max_overflow are invalid on sqlite) + check_same_thread off

Postgres DSNs still accepted (legacy/multi-instance) but no longer required.
This commit is contained in:
2026-06-23 09:19:59 -07:00
committed by GitHub
parent aff0952998
commit 56503248af
3 changed files with 72 additions and 56 deletions
+6 -4
View File
@@ -3,7 +3,7 @@
from functools import lru_cache
from typing import Literal
from pydantic import AliasChoices, Field, PostgresDsn, RedisDsn
from pydantic import AliasChoices, Field, RedisDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -23,9 +23,11 @@ class Settings(BaseSettings):
debug: bool = False
api_prefix: str = "/v1"
# Database
database_url: PostgresDsn = Field(
default="postgresql+asyncpg://bootnode:bootnode@localhost:5432/bootnode"
# Database — Base/SQLite is canonical (VFS→SeaweedFS for durability);
# PostgreSQL DSNs are still accepted for legacy/multi-instance. Plain str
# (not PostgresDsn) so a `sqlite+aiosqlite:///…` URL validates.
database_url: str = Field(
default="sqlite+aiosqlite:////data/bootnode/bootnode.db"
)
db_pool_size: int = 20
db_max_overflow: int = 10
+46 -40
View File
@@ -5,23 +5,29 @@ from datetime import datetime
from typing import Any
from sqlalchemy import (
JSON,
Boolean,
DateTime,
ForeignKey,
Integer,
String,
Text,
Uuid,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
"""Base class for all models."""
"""Base class for all models.
Uses portable SQLAlchemy types (JSON, Uuid) rather than the postgres
dialect (JSON, UUID) so the same models run on Base/SQLite — the canonical
store (VFS→SeaweedFS for durability) — as well as PostgreSQL.
"""
type_annotation_map = {
dict[str, Any]: JSONB,
dict[str, Any]: JSON,
}
@@ -31,14 +37,14 @@ class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
avatar: Mapped[str | None] = mapped_column(String(500), nullable=True)
roles: Mapped[list[str] | None] = mapped_column(JSONB, default=["user"])
permissions: Mapped[list[str] | None] = mapped_column(JSONB, default=["read", "write"])
roles: Mapped[list[str] | None] = mapped_column(JSON, default=["user"])
permissions: Mapped[list[str] | None] = mapped_column(JSON, default=["read", "write"])
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
@@ -59,14 +65,14 @@ class Project(Base):
__tablename__ = "projects"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
owner_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), nullable=False)
org_id: Mapped[str] = mapped_column(String(100), nullable=False, default="hanzo")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
settings: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
allowed_chains: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
allowed_chains: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
@@ -87,18 +93,18 @@ class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
key_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
key_prefix: Mapped[str] = mapped_column(String(12), nullable=False) # First chars for display
rate_limit: Mapped[int] = mapped_column(Integer, default=100) # requests per minute
compute_units_limit: Mapped[int] = mapped_column(Integer, default=1000) # CU per minute
allowed_origins: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
allowed_chains: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
allowed_origins: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
allowed_chains: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
@@ -115,17 +121,17 @@ class Webhook(Base):
__tablename__ = "webhooks"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
url: Mapped[str] = mapped_column(Text, nullable=False)
chain: Mapped[str] = mapped_column(String(50), nullable=False)
network: Mapped[str] = mapped_column(String(50), nullable=False, default="mainnet")
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
filters: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
filters: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
secret: Mapped[str] = mapped_column(String(255), nullable=False) # For HMAC signing
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
failure_count: Mapped[int] = mapped_column(Integer, default=0)
@@ -147,12 +153,12 @@ class WebhookDelivery(Base):
__tablename__ = "webhook_deliveries"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
webhook_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False
)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
response_body: Mapped[str | None] = mapped_column(Text, nullable=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=1)
@@ -173,10 +179,10 @@ class Usage(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
api_key_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True
Uuid(as_uuid=True), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True
)
chain: Mapped[str] = mapped_column(String(50), nullable=False)
network: Mapped[str] = mapped_column(String(50), nullable=False)
@@ -195,10 +201,10 @@ class SmartWallet(Base):
__tablename__ = "smart_wallets"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
address: Mapped[str] = mapped_column(String(42), nullable=False, unique=True)
owner_address: Mapped[str] = mapped_column(String(42), nullable=False)
@@ -207,7 +213,7 @@ class SmartWallet(Base):
network: Mapped[str] = mapped_column(String(50), nullable=False, default="mainnet")
salt: Mapped[str] = mapped_column(String(66), nullable=False) # bytes32 hex
is_deployed: Mapped[bool] = mapped_column(Boolean, default=False)
wallet_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
wallet_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
@@ -219,20 +225,20 @@ class TeamMember(Base):
__tablename__ = "team_members"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
invite_token: Mapped[str | None] = mapped_column(String(255), nullable=True)
invited_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
invited_by: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True)
joined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
@@ -245,19 +251,19 @@ class GasPolicy(Base):
__tablename__ = "gas_policies"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
chain: Mapped[str] = mapped_column(String(50), nullable=False)
network: Mapped[str] = mapped_column(String(50), nullable=False, default="mainnet")
rules: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
rules: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
max_gas_per_op: Mapped[int] = mapped_column(Integer, default=1000000) # gas units
max_spend_per_day_usd: Mapped[int] = mapped_column(Integer, default=100) # in cents
allowed_contracts: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
allowed_methods: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
allowed_contracts: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
allowed_methods: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
@@ -270,10 +276,10 @@ class Subscription(Base):
__tablename__ = "subscriptions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, unique=True
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, unique=True
)
tier: Mapped[str] = mapped_column(String(50), nullable=False, default="free")
hanzo_subscription_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
@@ -322,10 +328,10 @@ class OrgCluster(Base):
__tablename__ = "org_clusters"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
Uuid(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
cluster_id: Mapped[str] = mapped_column(
String(255), nullable=False,
@@ -335,8 +341,8 @@ class OrgCluster(Base):
String(50), nullable=False, default="digitalocean",
)
region: Mapped[str] = mapped_column(String(50), nullable=False, default="sfo3")
allowed_chains: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
allowed_namespaces: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
allowed_chains: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
allowed_namespaces: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
+20 -12
View File
@@ -10,27 +10,35 @@ settings = get_settings()
def _ensure_async_dsn(dsn: str) -> str:
"""Ensure the database DSN uses the asyncpg driver.
Converts ``postgresql://`` or ``postgres://`` to
``postgresql+asyncpg://`` so that SQLAlchemy's async engine
uses the correct driver regardless of how DATABASE_URL is set.
"""
"""Normalize the DSN to an async driver: asyncpg for PostgreSQL, aiosqlite
for Base/SQLite (the canonical store). Idempotent."""
if dsn.startswith("postgresql+asyncpg://"):
return dsn
if dsn.startswith("postgresql://"):
return dsn.replace("postgresql://", "postgresql+asyncpg://", 1)
if dsn.startswith("postgres://"):
return dsn.replace("postgres://", "postgresql+asyncpg://", 1)
if dsn.startswith("sqlite+aiosqlite://"):
return dsn
if dsn.startswith("sqlite://"):
return dsn.replace("sqlite://", "sqlite+aiosqlite://", 1)
return dsn
engine = create_async_engine(
_ensure_async_dsn(str(settings.database_url)),
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
echo=settings.debug,
)
_dsn = _ensure_async_dsn(str(settings.database_url))
# Base/SQLite doesn't use a sized connection pool — the default pool rejects
# pool_size/max_overflow, and async cross-task access needs check_same_thread
# off. PostgreSQL keeps the sized async pool.
if _dsn.startswith("sqlite"):
_engine_kwargs: dict = {"connect_args": {"check_same_thread": False}}
else:
_engine_kwargs = {
"pool_size": settings.db_pool_size,
"max_overflow": settings.db_max_overflow,
}
engine = create_async_engine(_dsn, echo=settings.debug, **_engine_kwargs)
async_session = async_sessionmaker(
engine,