Skip to content
Merged
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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,20 @@ jobs:
name: Backend tests (Python 3.11)
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: calorieapp_ci
POSTGRES_PASSWORD: synthetic_ci_only
POSTGRES_DB: calorieapp_ci_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U calorieapp_ci -d calorieapp_ci_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10

steps:
- name: Check out repository
Expand Down Expand Up @@ -153,6 +167,21 @@ jobs:
- name: Compile backend Python
run: python -m compileall -q backend/app

- name: Verify forward-only schema migration and readiness
working-directory: backend
env:
CALORIEAPP_ENV: test
DATABASE_URL: sqlite:///${{ runner.temp }}/calorie-schema-smoke.sqlite
run: |
python -m app.schema_cli upgrade
python -m app.schema_cli check

- name: Verify PostgreSQL migration compatibility
working-directory: backend
env:
CALORIEAPP_POSTGRES_TEST_DATABASE_URL: postgresql+psycopg://calorieapp_ci:synthetic_ci_only@127.0.0.1:5432/calorieapp_ci_test
run: python -m pytest tests/test_postgresql_integration.py -q -W error::DeprecationWarning

frontend-checks:
name: Frontend lint and build (Node.js 20)
runs-on: ubuntu-latest
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/wordpress-plugin-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ jobs:
- name: Build deterministic archive and checksum
run: |
python tools/build_wordpress_plugin_release.py \
--expected-version "${{ steps.version.outputs.value }}"
--expected-version "${{ steps.version.outputs.value }}" \
--require-cleared-provenance

- name: Upload workflow artifact
uses: actions/upload-artifact@v6
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```

Backend health endpoint: http://127.0.0.1:8000/health
Backend database readiness endpoint: http://127.0.0.1:8000/ready

Optional backend startup helper (PowerShell):

Expand Down Expand Up @@ -201,6 +202,10 @@ gate can additionally run the local developer health check.
- Public roadmap: docs/public/roadmap.md
- Public deployment guide: docs/public/deployment.md
- Durable data and privacy foundation: docs/DURABLE_DATA_FOUNDATION.md
- BigchainDB decision record: docs/BIGCHAINDB_ASSESSMENT.md
- Ecosystem continuity foundation: docs/ECOSYSTEM_CONTINUITY.md
- Official product and separate ecosystem boundary: docs/PRODUCT_ECOSYSTEM_BOUNDARY.md
- Identity Bridge code-provenance review: docs/IDENTITY_BRIDGE_CODE_PROVENANCE.md
- Voluntary XRPL transaction-linking architecture: docs/XRPL_TRANSACTION_LINKING.md
- Public data-safety direction: docs/public/data-safety.md
- Public XRPL reference direction: docs/public/xrpl-linking.md
Expand Down
8 changes: 6 additions & 2 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,9 @@ sponsorship. No dependency establishes that the project's product concept is
novel, exclusive, or patent-clear.

The WordPress Identity Bridge is a separately licensed GPL-2.0-or-later
component. See its `LICENSE` file. Open Food Facts data is addressed separately
in `DATA_LICENSING.md`.
component. Its release-file inventory, known external interfaces and unresolved
source-clearance work are recorded in
`contracts/identity-bridge/v1/code-provenance.json` and
`docs/IDENTITY_BRIDGE_CODE_PROVENANCE.md`. See its `LICENSE` and bundled
`THIRD_PARTY_NOTICES.md` files. Open Food Facts data is addressed separately in
`DATA_LICENSING.md`.
11 changes: 5 additions & 6 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@
# Non-localhost origins must use HTTPS because API requests include session cookies.
CORS_ORIGINS=http://localhost:3000

# DATABASE_URL: SQLite database connection string
# Local development: Not needed (defaults to backend/calorieapp.db)
# Cloud examples: sqlite:///tmp/calorieapp.db or other persistent path
# For production, consider a managed database.
# DATABASE_URL=sqlite:////tmp/calorieapp.db
# DATABASE_URL: provider-neutral database connection string
# Local/test: not needed (defaults to backend/calorieapp.db)
# Staging/production: PostgreSQL is mandatory; SQLite fails closed.
# DATABASE_URL=postgresql://USER:PASSWORD@HOST/DATABASE

# PORT: Server port (read by deployment wrapper, not directly by FastAPI)
# Local development: 8000
Expand Down Expand Up @@ -51,7 +50,7 @@ WORDPRESS_BRIDGE_SECRET=CHANGE_ME_TO_A_RANDOM_SECRET
CALORIEAPP_CLIENT_ID=calorieapp-backend

# Deployment environment selector used for startup guardrails.
# Allowed local-only value: local
# Allowed values: local, test, staging, production
CALORIEAPP_ENV=local

# Where the frontend should land after successful callback finalization.
Expand Down
27 changes: 25 additions & 2 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This service is the CalorieApp V1 data layer only.
## Endpoints

- GET /health
- GET /ready
- GET /search-food?q=
- POST /log-food
- GET /logs
Expand All @@ -20,7 +21,8 @@ This service is the CalorieApp V1 data layer only.
1. python -m venv .venv
2. Activate your environment
3. pip install -r requirements.txt
4. python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
4. python -m app.schema_cli upgrade
5. python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000

Canonical backend command:

Expand Down Expand Up @@ -52,11 +54,32 @@ If you see inconsistent API responses:
1. Stop old backend processes.
2. Start backend using start-backend.ps1.
3. Confirm http://127.0.0.1:8000/health returns status ok.
4. Retry requests from frontend on localhost:3000.
4. Confirm http://127.0.0.1:8000/ready reports the expected database revision.
5. Retry requests from frontend on localhost:3000.

## Schema migrations

Schema changes are forward-only, versioned and provider-neutral:

```bash
python -m app.schema_cli current
python -m app.schema_cli upgrade
python -m app.schema_cli check
```

Local and test startup may apply known migrations automatically. Staging and
production never migrate on application startup. Their approved pipeline must
run `upgrade --approval-reference <change-id>` before starting the new app
version. Downgrades are deliberately unsupported; use a tested corrective
migration or verified restore.

## Notes

- Data storage uses local SQLite via SQLModel for development and tests only.
- Public user onboarding remains blocked until the durable PostgreSQL,
migration, persistence, export, erasure and recovery gates pass.
- The core ecosystem remains free to users. Database and Web3 schema functions
rely on open application code and standard PostgreSQL capabilities, not paid
add-ons. Separately reviewed value-added developer services may be offered
later without paywalling identity or personal-data rights.
- Open Food Facts is consumed only by backend service endpoints.
120 changes: 81 additions & 39 deletions backend/app/database.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
"""
Database setup for CalorieApp backend.
Uses SQLModel with local SQLite by default and PostgreSQL in hosted environments.
"""
"""Database configuration, startup guards and readiness for CalorieApp."""
import os
from pathlib import Path

from sqlalchemy import inspect, text
from sqlmodel import Session, SQLModel, create_engine
from sqlalchemy import text
from sqlalchemy.engine import Engine, make_url
from sqlmodel import Session, create_engine

from .schema_migrations import (
SCHEMA_HEAD,
assert_database_at_head,
current_revision,
upgrade_database,
)


def _normalize_database_url(database_url: str) -> str:
Expand All @@ -18,53 +23,90 @@ def _normalize_database_url(database_url: str) -> str:
return database_url


# Read DATABASE_URL from environment; default to local SQLite file.
# Format: sqlite:///path/to/db.sqlite or sqlite+pysqlite:///path
if DATABASE_URL := os.getenv("DATABASE_URL"):
pass # Use environment configuration
_DATABASE_URL_FROM_ENV = os.getenv("DATABASE_URL")
if _DATABASE_URL_FROM_ENV:
DATABASE_URL = _DATABASE_URL_FROM_ENV
_DATABASE_URL_WAS_EXPLICIT = True
else:
# Local development default: SQLite file one directory above this file (backend/calorieapp.db).
_DB_PATH = Path(__file__).parent.parent / "calorieapp.db"
DATABASE_URL = f"sqlite:///{_DB_PATH}"
_DATABASE_URL_WAS_EXPLICIT = False

DATABASE_URL = _normalize_database_url(DATABASE_URL)

_IS_SQLITE = DATABASE_URL.startswith("sqlite:")
_ENGINE_OPTIONS = {"connect_args": {"check_same_thread": False}} if _IS_SQLITE else {"pool_pre_ping": True}
engine = create_engine(DATABASE_URL, **_ENGINE_OPTIONS)

_OPTIONAL_LOG_COLUMNS: dict[str, tuple[str, str]] = {
"owner_id": ("TEXT", "TEXT"),
"portion_percentage": ("REAL", "DOUBLE PRECISION"),
"barcode": ("TEXT", "TEXT"),
"image_url": ("TEXT", "TEXT"),
"brand": ("TEXT", "TEXT"),
"serving_size": ("TEXT", "TEXT"),
"nutri_score": ("TEXT", "TEXT"),
}


def _ensure_food_log_optional_columns() -> None:
"""Add known nullable columns without resetting existing SQLite/PostgreSQL data."""
with engine.begin() as connection:
inspector = inspect(connection)
if not inspector.has_table("food_log"):
return
existing = {str(column["name"]) for column in inspector.get_columns("food_log")}
dialect_index = 0 if connection.dialect.name == "sqlite" else 1
quote = connection.dialect.identifier_preparer.quote

for column_name, column_types in _OPTIONAL_LOG_COLUMNS.items():
if column_name in existing:
continue
column_type = column_types[dialect_index]
connection.execute(text(f"ALTER TABLE {quote('food_log')} ADD COLUMN {quote(column_name)} {column_type}"))
_ALLOWED_ENVIRONMENTS = {"local", "test", "staging", "production"}


def validate_database_environment(
database_url: str,
environment: str | None,
*,
database_url_was_explicit: bool = True,
) -> str:
"""Validate the environment/database pairing and return the resolved environment."""
normalized_environment = environment.strip().lower() if environment and environment.strip() else None
if normalized_environment is None:
if database_url_was_explicit:
raise RuntimeError(
"CALORIEAPP_ENV must be set when DATABASE_URL is explicitly configured"
)
normalized_environment = "local"

if normalized_environment not in _ALLOWED_ENVIRONMENTS:
allowed = ", ".join(sorted(_ALLOWED_ENVIRONMENTS))
raise RuntimeError(f"CALORIEAPP_ENV must be one of: {allowed}")

backend_name = make_url(_normalize_database_url(database_url)).get_backend_name()
if backend_name not in {"sqlite", "postgresql"}:
raise RuntimeError("DATABASE_URL must use SQLite or PostgreSQL")
if backend_name == "sqlite" and normalized_environment not in {"local", "test"}:
raise RuntimeError(
"SQLite is only allowed when CALORIEAPP_ENV is local or test; "
f"current environment is {normalized_environment}"
)
if normalized_environment in {"staging", "production"} and backend_name != "postgresql":
raise RuntimeError(
f"{normalized_environment} requires a PostgreSQL DATABASE_URL"
)
return normalized_environment


def _configured_environment() -> str:
return validate_database_environment(
str(engine.url),
os.getenv("CALORIEAPP_ENV"),
database_url_was_explicit=_DATABASE_URL_WAS_EXPLICIT,
)


def init_db() -> None:
"""Create all SQLModel tables if they do not already exist."""
SQLModel.metadata.create_all(engine)
_ensure_food_log_optional_columns()
"""Upgrade local/test databases and require pre-approved migrations elsewhere."""
environment = _configured_environment()
if environment in {"local", "test"}:
upgrade_database(engine)
else:
assert_database_at_head(engine)


def database_readiness(target_engine: Engine | None = None) -> dict[str, str]:
"""Perform a read-only connectivity and migration-head check."""
selected_engine = target_engine or engine
validate_database_environment(
str(selected_engine.url),
os.getenv("CALORIEAPP_ENV"),
database_url_was_explicit=(
_DATABASE_URL_WAS_EXPLICIT if target_engine is None else False
),
)
with selected_engine.connect() as connection:
connection.execute(text("SELECT 1")).scalar_one()
assert_database_at_head(selected_engine)
return {"status": "ready", "database_revision": current_revision(selected_engine) or SCHEMA_HEAD}


def get_session():
Expand Down
11 changes: 10 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select

from .database import get_session, init_db
from .database import database_readiness, get_session, init_db
from .locales import resolve_locale
from .models import AuthSessionDB, BridgeAuthNonceDB, CalorieAppUserDB, FoodLogDB
from .schemas import (
Expand Down Expand Up @@ -677,6 +677,15 @@ def health() -> dict[str, str]:
return {"status": "ok", "service": "calorieapp-backend"}


@app.get("/ready")
def ready() -> dict[str, str]:
"""Confirm that the database is reachable and exactly at schema head."""
return {
**database_readiness(),
"service": "calorieapp-backend",
}


# =========================================================================
# Identity Endpoints
# =========================================================================
Expand Down
4 changes: 2 additions & 2 deletions backend/app/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""
SQLModel table definitions for CalorieApp backend.
FoodLogDB maps to the food_log table in calorieapp.db.
FoodLogDB maps to the provider-neutral food_log table.
Also includes identity tables: CalorieAppUser, ExternalIdentity, AuthorizationCode.
"""
from datetime import UTC, datetime
Expand All @@ -17,7 +17,7 @@ def utc_now() -> datetime:


class FoodLogDB(SQLModel, table=True):
"""Persistent food log entry stored in SQLite."""
"""Persistent food log entry owned by one internal CalorieApp user."""

__tablename__ = "food_log"

Expand Down
Loading