mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
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:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user