Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions server/backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# cq-server backend

FastAPI service backing the cq remote store.

## Development

From the repository root:

```
make setup-server-backend # uv sync
make dev-api # run against a local SQLite DB
make test-server-backend # pytest
make lint-server-backend # pre-commit (ruff, ty, uv lock check)
```

## Database migrations (Alembic)

SQLAlchemy and Alembic are wired up but **currently unused at
runtime** — no migrations are defined, and `app.py` still creates
the SQLite schema directly. This is intentional; the framework is
staged so follow-up work in the [PostgreSQL-backend epic][epic] can
land the baseline migration, the async `Store` protocol, and the
Postgres backend incrementally.

Database URL resolution (used by `alembic/env.py` and, in a later
child issue, the runtime store factory) lives in
`cq_server.db_url.resolve_database_url`. Precedence:

1. `CQ_DATABASE_URL` — used verbatim (e.g. `postgresql+psycopg://…`).
2. `CQ_DB_PATH` — wrapped as `sqlite:///<path>` (back-compat with
the existing env var).
3. Default — `sqlite:////data/cq.db`.

To run Alembic commands against a local dev database (the path is
resolved relative to wherever `alembic` is invoked from — here,
`server/backend/`):

```
cd server/backend
CQ_DB_PATH=./dev.db uv run alembic current
```

Full environment-variable documentation will land alongside the
`CQ_DATABASE_URL` runtime wiring in a later phase-1 child issue.

[epic]: https://github.com/mozilla-ai/cq/issues/257
42 changes: 42 additions & 0 deletions server/backend/alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
[alembic]
script_location = %(here)s/alembic
prepend_sys_path = %(here)s/src
path_separator = os

# sqlalchemy.url is set at runtime in alembic/env.py via
# cq_server.db_url.resolve_database_url() — do not set it here.


[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARNING
handlers = console
qualname =

[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
75 changes: 75 additions & 0 deletions server/backend/alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Alembic runtime configuration for the cq server.

The database URL is resolved from the environment via
:func:`cq_server.db_url.resolve_database_url` so that ``alembic``
CLI invocations and future startup-time ``command.upgrade`` calls
share the same precedence rules.

``render_as_batch=True`` is set in both online and offline modes so
SQLite ALTER TABLE operations work via Alembic's batch recreate
dance. It is harmless on PostgreSQL.

No migrations exist yet — ``target_metadata`` is ``None``. The
baseline migration lands in issue #305.
"""

from __future__ import annotations

from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config, pool

from cq_server.db_url import resolve_database_url

config = context.config

if config.config_file_name is not None:
fileConfig(config.config_file_name)

# `set_main_option` routes through ConfigParser, which treats `%` as the
# start of an interpolation token. Fine for SQLite paths today, but a
# Postgres URL with a URL-encoded password (e.g. `p%40ss`) will need
# `%` doubled or a direct pass to `create_engine` when #309 wires this up.
config.set_main_option("sqlalchemy.url", resolve_database_url())

target_metadata = None


def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode — emit SQL without a connection."""
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)

with context.begin_transaction():
context.run_migrations()


def run_migrations_online() -> None:
"""Run migrations in 'online' mode — against a live connection."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)

with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
)

with context.begin_transaction():
context.run_migrations()


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
28 changes: 28 additions & 0 deletions server/backend/alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}


def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}


def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
Empty file.
2 changes: 2 additions & 0 deletions server/backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ dependencies = [
"pyjwt>=2.0",
"bcrypt>=4.0",
"cq-sdk~=0.9.0",
"sqlalchemy>=2.0.49,<2.1",
"alembic>=1.18.4,<2",
]

[project.scripts]
Expand Down
22 changes: 22 additions & 0 deletions server/backend/src/cq_server/db_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Resolve the database connection URL from environment variables."""

from __future__ import annotations

import os

_DEFAULT_SQLITE_PATH = "/data/cq.db"


def resolve_database_url() -> str:
"""Return the SQLAlchemy URL for the cq server database.

Precedence:
1. ``CQ_DATABASE_URL`` if set — returned verbatim.
2. ``CQ_DB_PATH`` — wrapped as ``sqlite:///<path>``.
3. Default — ``sqlite:///`` + ``_DEFAULT_SQLITE_PATH``.
"""
url = os.environ.get("CQ_DATABASE_URL")
if url:
return url
path = os.environ.get("CQ_DB_PATH", _DEFAULT_SQLITE_PATH)
return f"sqlite:///{path}"
31 changes: 31 additions & 0 deletions server/backend/tests/test_db_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Tests for resolve_database_url()."""

from cq_server.db_url import resolve_database_url


def test_explicit_database_url_wins(monkeypatch):
monkeypatch.setenv("CQ_DATABASE_URL", "postgresql://u:p@h/d")
monkeypatch.setenv("CQ_DB_PATH", "/tmp/ignored.db")
assert resolve_database_url() == "postgresql://u:p@h/d"


def test_db_path_becomes_sqlite_url(monkeypatch, tmp_path):
monkeypatch.delenv("CQ_DATABASE_URL", raising=False)
db = tmp_path / "cq.db"
monkeypatch.setenv("CQ_DB_PATH", str(db))
assert resolve_database_url() == f"sqlite:///{db}"


def test_default_when_nothing_set(monkeypatch):
monkeypatch.delenv("CQ_DATABASE_URL", raising=False)
monkeypatch.delenv("CQ_DB_PATH", raising=False)
assert resolve_database_url() == "sqlite:////data/cq.db"


def test_empty_database_url_falls_through(monkeypatch, tmp_path):
# Container orchestrators sometimes pass empty env vars; treat the same
# as unset so CQ_DB_PATH still wins.
db = tmp_path / "cq.db"
monkeypatch.setenv("CQ_DATABASE_URL", "")
monkeypatch.setenv("CQ_DB_PATH", str(db))
assert resolve_database_url() == f"sqlite:///{db}"
Loading