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
36 changes: 36 additions & 0 deletions .agents/rules/backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
paths:
- "backend/**"
---

# Backend

FastAPI, pydantic and SQLAlchemy. Copy the shape of the messages example for a new
resource: `models/message.py`, `schemas/message.py`, `routes/messages.py`.

## Where code goes

- `main.py`: creates the app and wires in middleware and routers. Run it with
`python -m app.main`.
- `routes/`: HTTP handlers, one file per resource, added to `api_router` in
`routes/__init__.py`. Every route lives under `/api/v1`.
- `models/`: SQLAlchemy tables, one file per table, imported in `models/__init__.py`.
- `schemas/`: pydantic request and response models, one file per resource.
- `middleware/`: request middleware.
- `config/`: settings, the database session and logging.

## Rules

- Routes are `async def` and use `SessionDep` for the database. Call other services
with `httpx.AsyncClient`, never `requests`.
- The handler's name becomes the operationId in camelCase, so `list_messages` becomes
`listMessages()` in the frontend. Name handlers after what they do.
- Every route declares a pydantic response model as its return type. Never return a
SQLAlchemy model or a bare `dict`. Schemas use camelCase aliases in JSON.
- Query with `select()` / `insert()` / `update()` / `delete()` or `text()` only. See
"Queries" in `AGENTS.md`.
- Change tables only through `models/` and `mise run db:dev:migrate:generate <name>`.
- End every `ORDER BY` with a unique column, usually `id`.
- Log with `logging.getLogger(__name__)`. Never `print`.
- New environment variables go in `config/settings.py` and `.env.example`.
- Code must pass `mypy --strict`. Avoid `Any` and `# type: ignore`.
14 changes: 14 additions & 0 deletions .agents/rules/frontend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
paths:
- "frontend/**"
---

# Frontend

- Call the backend only through the generated SDK in `src/api/generated`. No raw
`fetch` to `/api`.
- If the SDK is missing a route or a field, change the backend and run
`mise run contract`. Do not hand-write types for API responses.
- Use relative `/api/...` URLs. Vite proxies them in dev and nginx proxies them when
deployed.
- Use arrow functions for components and handlers: `export const App = () => {}`.
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
**/node_modules
**/dist
**/.env
**/.venv
**/__pycache__
frontend/src/api/generated
.git
12 changes: 12 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.py]
indent_size = 4
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
SERVICE_USER_POSTGRES=app
SERVICE_PASSWORD_POSTGRES=app
POSTGRES_DB=app
POSTGRES_PORT=5432
DATABASE_URL=postgresql://app:app@localhost:5432/app?sslmode=disable
PORT=3000
LOG_LEVEL=info
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Force LF everywhere. Files are executed inside Linux containers;
# CRLF line endings from Windows checkouts break shell scripts and config.
* text=auto eol=lf
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# Every step runs the same mise task you run locally, so `mise run check`
# passing on your machine means these pass too.
jobs:
frontend_lint:
name: frontend / lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: jdx/mise-action@v5
- run: pnpm install --frozen-lockfile --dir frontend
- run: mise run lint:frontend

frontend_format:
name: frontend / format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: jdx/mise-action@v5
- run: pnpm install --frozen-lockfile --dir frontend
- run: mise run fmt:frontend

backend_lint:
name: backend / lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: jdx/mise-action@v5
- run: uv sync --locked --directory backend
- run: mise run lint:backend

backend_contract:
name: backend / contract
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: jdx/mise-action@v5
- run: uv sync --locked --directory backend
- run: mise run contract:check

backend_format:
name: backend / format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: jdx/mise-action@v5
- run: mise run fmt:backend
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
node_modules/
dist/
*.tsbuildinfo
.DS_Store

.venv/
__pycache__/
.mypy_cache/
.ruff_cache/

.env
.env.*
!.env.example

frontend/src/api/generated/
63 changes: 63 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Working in this repo

Rules for anyone changing code here, human or agent. Before editing `backend/` or
`frontend/`, also read the matching file in `.agents/rules/`.

## Before you start

- Read `README.md` and the code you are about to change.
- Make the smallest change that does the job. No extra abstractions, dependencies or
config the task does not need.
- Follow the patterns already in the repo before inventing new ones.

## Writing code

- Clear names over comments. Only comment what a reader would otherwise get wrong.
- Guard clauses and early returns instead of nested `if`s.
- Keep functions short and focused on one thing.
- Delete dead code and commented-out code. Git remembers it.
- Validate untrusted input once, with pydantic, where it enters the system. Trust it
after that.
- In the frontend, write modern TypeScript: arrow functions, `type` instead of
`interface`, no `enum` or `namespace`.

## The database

The SQLAlchemy models in `backend/src/app/models/` are the source of truth for every
table. Change the database only this way:

1. Edit a model, or add one and import it in `models/__init__.py`.
2. Run `mise run db:dev:migrate:generate <name>`. It compares the models with the
database and writes the SQL to a new file in `backend/migrations/`.
3. Read the generated SQL, then run `mise run db:dev:migrate:up`.
4. `mise run db:dev:migrate:status` shows whether the database is missing migrations.

Never write schema SQL by hand and never edit a migration that is already on `main`.
The only SQL you add to a migration is seed data.

## Queries

Every query must read as SQL. Use SQLAlchemy's `select()`, `insert()`, `update()` and
`delete()`, or `text()` with bound parameters, and run them on the session:

```python
rows = await session.scalars(
select(models.Message).where(models.Message.id == message_id)
)
```

- Never use the ORM's object style: `session.query()`, `session.get()`,
`session.add()`, `session.delete()` or lazy-loaded relationships.
- Never build SQL strings with f-strings or `%`. Pass values as parameters.

## The API contract

The routes and pydantic schemas are the source of truth for the API.
`backend/openapi.json` is generated from them and committed.
`frontend/src/api/generated/` is generated from that file and gitignored. Never edit
either by hand. After changing a route, run `mise run contract` and commit
`backend/openapi.json`.

## Finishing

- Run `mise run check` and fix everything it reports.
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Python React starter

React, FastAPI and Postgres. `mise tasks` lists every task.

## Install mise

```sh
curl https://mise.run | sh
```

Then follow the printed step to activate it in your shell. See
<https://mise.jdx.dev/getting-started.html> for other installers. You also need
Docker.

## Set up

```sh
mise install
cp .env.example .env
mise run setup
```

## Run

```sh
mise run dev
```

Open <http://localhost:5173>. The API is on <http://localhost:3000/api/v1>.

## Migrations

Tables are defined in `backend/src/app/models/`. Change them there, then generate a migration.

```sh
mise run db:dev:migrate:generate add_something # write SQL from your model changes
mise run db:dev:migrate:up # apply pending migrations
mise run db:dev:migrate:status # is the database behind?
mise run db:dev:migrate:down # undo the latest migration
```

Read the generated SQL before committing it.

`mise run db:dev:studio` opens Prisma Studio on <http://localhost:5555>.
4 changes: 4 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.venv
**/__pycache__
.mypy_cache
.ruff_cache
25 changes: 25 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Python and uv versions are pinned here and in mise.toml. Keep them in sync.
FROM python:3.14.8-slim AS base
COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /usr/local/bin/uv
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never \
UV_PROJECT_ENVIRONMENT=/opt/venv \
PATH=/opt/venv/bin:$PATH \
PYTHONUNBUFFERED=1

FROM base AS build
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
COPY src src
RUN uv sync --locked --no-dev --no-editable

FROM base AS deploy
COPY --from=ghcr.io/amacneil/dbmate:2.37.0 /usr/local/bin/dbmate /usr/local/bin/dbmate
RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin app
COPY --from=build /opt/venv /opt/venv
COPY migrations migrations
USER app
EXPOSE 3000
CMD ["sh", "-c", "dbmate --migrations-dir migrations --no-dump-schema migrate && exec python -m app.main"]
14 changes: 14 additions & 0 deletions backend/migrations/20261019232056_init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- migrate:up
CREATE TABLE messages (
id SERIAL NOT NULL,
text TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
PRIMARY KEY (id)
);

INSERT INTO messages (text) VALUES
('Hello from the database'),
('These rows come from the first migration');

-- migrate:down
DROP TABLE messages;
Loading
Loading