A shared todo app, built contract-first: one Protobuf schema, a Python ConnectRPC backend on PostgreSQL, a Next.js web app, a native iOS app, and a CLI — all speaking the same API.
It is a real application rather than a demo: four roles enforced in SQL, thirteen enums carried end to end into a real PostgreSQL type, Danish and English as equals, and an activity feed that still reads correctly after a rename.
flowchart LR
proto["proto/todo/v1<br/>52 RPCs · 13 enums"]
proto -->|buf generate| pyGen["backend/gen<br/>connect-python"]
proto -->|buf generate| tsGen["web/src/gen<br/>protobuf-es"]
proto -->|buf generate| swGen["apple/gen<br/>connect-swift"]
tsGen --> web["Web<br/>Next.js 16"]
swGen --> ios["iOS<br/>SwiftUI"]
pyGen --> cli["CLI<br/>todoapp"]
pyGen --> api["Backend<br/>connect-python ASGI"]
web -->|Connect · bearer| api
ios -->|Connect · bearer| api
cli -->|Connect · bearer| api
api --> pg[("PostgreSQL 16<br/>13 enum types · 14 tables")]
classDef contract fill:#18181b,color:#fafafa,stroke:#18181b
classDef gen fill:#f4f4f5,color:#3f3f46,stroke:#d4d4d8,stroke-dasharray:4 3
classDef client fill:#fff,color:#18181b,stroke:#a1a1aa
classDef store fill:#eef2ff,color:#3730a3,stroke:#a5b4fc
class proto contract
class pyGen,tsGen,swGen gen
class web,ios,cli,api client
class pg store
proto/todo/v1/ |
the contract: 13 enums, 4 services, 52 RPCs |
backend/ |
Python 3.12+ · connect-python (ASGI) · psycopg3 · raw SQL |
web/ |
Next.js 16 · React 19 · Tailwind 4 · next-intl (da/en) |
apple/ |
SwiftUI · connect-swift · Swift 6 strict concurrency |
backend/src/todoapp/cli/ |
the todoapp command — 60 subcommands |
The dashed boxes are generated by make generate and are not committed. Change the
proto, regenerate, and follow the type errors — that is the whole workflow.
You need PostgreSQL 15+, Python 3.12+ with uv, Node 22+ with pnpm, and buf.
make setup # install deps, generate code, create databases, migrate, seed
make dev # backend on :8081, web on :3000make setup asks which email and password you want for the demo admin account. No
credentials are stored in this repository; the pair you choose is written to
.demo-account.env, which is gitignored and which the CLI and iOS end-to-end targets
read. Two collaborator accounts come with it — partner@example.com and
colleague@example.com, same password — so sharing, assignment and comment threads have
somebody else in them.
Open http://localhost:3000 and sign in with the account you chose.
make help lists every target. If you would rather not install PostgreSQL, make docker-up starts one on port 5433.
- The web app needs a Font Awesome Pro licence.
web/.npmrcreads${FONTAWESOME_NPM_TOKEN}; without it,pnpm installinweb/fails. The backend, the CLI and the iOS app are unaffected. - The iOS app needs macOS and Xcode 26. Simulator builds need nothing else; a device
build needs your own Apple Developer team —
make ios-signing TEAM=ABCDE12345.
- Lists with a colour, a visibility (private / shared / public), archiving, manual ordering, and per-list labels.
- Sharing by email, with four roles — owner, editor, commenter, viewer — enforced in SQL rather than in the UI.
- Tasks with status, priority, assignee, labels, a due date that may or may not carry a time of day, a start date, an estimate, a checklist, comments, and repeat rules that spawn the next occurrence when you tick one off.
- Filtering by status, priority, label, assignee, due range, overdue, and free text, with per-status counts and cursor pagination.
A task's status is a state machine the server owns — completing one stamps the metadata and, if it repeats, creates the next occurrence in the same transaction:
stateDiagram-v2
[*] --> todo
todo --> in_progress
in_progress --> blocked
blocked --> in_progress
todo --> done
in_progress --> done
blocked --> done
todo --> cancelled
in_progress --> cancelled
blocked --> cancelled
done --> todo : reopen, clears completed_at
cancelled --> todo : reopen
done --> [*] : recurring? next occurrence created
note right of done
terminal: completed_at and
completed_by are stamped here
end note
- An activity feed that survives renames and deletions, because it stores what the target was called at the time.
- Email and password auth: Argon2id, opaque server-side sessions, per-address rate limiting, email verification, password reset, and a device list you can revoke from.
- Danish and English throughout, including enum labels, error messages, dates, and the two transactional emails.
- An admin area for accounts: roles, suspension with a reason, creation.
proto/todo/v1/ defines everything. make generate writes Python into backend/gen/
and TypeScript into web/src/gen/; neither is committed. Change the proto, regenerate,
and follow the type errors.
Every closed set of values is a real enum in four places, and the mapping between them is derived, never hand-written:
flowchart TD
A["TASK_STATUS_DONE<br/><i>proto enum value</i>"]
B["'done'<br/><i>PostgreSQL enum label — a real CREATE TYPE</i>"]
C["Færdig / Done<br/><i>localized display name</i>"]
A -->|"EnumCodec — read off the generated descriptor"| B
B -->|"enumToJson · cli.display · DisplayableEnum"| C
A -.->|"a value with no PG label fails at the boundary"| X["ERROR_REASON_INVALID_ENUM_VALUE"]
classDef step fill:#fff,color:#18181b,stroke:#a1a1aa
classDef err fill:#fef2f2,color:#991b1b,stroke:#fca5a5
class A,B,C step
class X err
A proto value with no PostgreSQL label fails at the boundary rather than silently
storing a default, and tests/test_enum_parity.py asserts the label sets — and their
declaration order — match pg_enum exactly. Order matters: task_priority is declared
least to most urgent so ORDER BY priority DESC is meaningful without a lookup table.
No ORM. Queries live as text in backend/src/todoapp/repositories/, rows come back as
dicts, and the schema is the contract. That is a deliberate trade: it costs a mapper
layer (services/mappers.py) and buys queries you can read, EXPLAIN, and paste into
psql.
Authorization is part of the query, not a check after it:
EXISTS (SELECT 1 FROM list_members m WHERE m.list_id = l.id AND m.user_id = %(viewer_id)s)
OR l.visibility = 'public'
OR %(viewer_is_admin)sThere is no request shape that returns a list the caller may not see. A list they cannot
reach is reported as NOT_FOUND, never PERMISSION_DENIED, so the API does not disclose
that someone else's data exists.
The database also holds the invariants the application would otherwise have to remember:
a CHECK ties completed_at to the terminal statuses, a partial unique index enforces
exactly one owner per list, and updated_at is maintained by a trigger.
erDiagram
users ||--o{ sessions : "opaque, revocable"
users ||--o{ list_members : "membership"
lists ||--o{ list_members : "exactly one owner"
lists ||--o{ labels : "scoped to the list"
lists ||--o{ tasks : ""
tasks ||--o{ subtasks : "one level deep"
tasks ||--o{ comments : ""
tasks }o--o{ labels : "task_labels"
users ||--o{ tasks : "creator · assignee · completer"
lists ||--o{ activities : "audit trail"
users {
uuid id PK
citext email UK
user_role role "enum"
user_status status "enum"
locale locale "enum"
}
lists {
uuid id PK
list_color color "enum"
list_visibility visibility "enum"
int position "manual order"
}
list_members {
member_role role "enum"
}
tasks {
task_status status "enum"
task_priority priority "least to most urgent"
timestamptz due_at "nullable"
bool due_has_time "all-day or not"
}
activities {
activity_action action "enum"
text target_label "the name at the time"
}
Every column marked enum is a real PostgreSQL type, not a TEXT with a CHECK.
A failure carries a machine-readable ErrorReason as a Protobuf detail. The
ConnectError message is English developer text for logs; clients translate the reason,
so the same failure reads natively in Danish and English and adding a locale needs no
server change. errors.proto is the whole vocabulary.
Sessions are opaque and server-side — revocation is one UPDATE, and only the SHA-256 of
a token is stored. The web app keeps that token in an HttpOnly cookie on the Next.js
origin and attaches it as a bearer header when the server calls the API. So:
- no token in
document.cookie, and an XSS bug cannot read it; - no cross-origin cookie, so no CORS between
:3000and:8081; - Server Components fetch directly, with no client-side round trip.
sequenceDiagram
autonumber
participant B as Browser
participant N as Next.js server
participant A as Connect API
participant I as Auth interceptor
participant S as Service
participant P as PostgreSQL
B->>N: GET /lists (HttpOnly cookie)
N->>N: read token from the cookie
N->>A: ListLists + Authorization: Bearer …
A->>I: intercept
I->>P: SELECT … FROM sessions WHERE token_hash = sha256($1)
P-->>I: principal (or nothing)
I->>S: bind principal, or leave the call anonymous
S->>S: require_principal — rejects anonymous here
S->>P: SELECT … WHERE EXISTS (list_members …)
P-->>S: only rows this caller may see
S-->>N: ListListsResponse
N-->>B: rendered HTML — the token never leaves the server
Writes go through Server Actions. web/src/lib/api.ts is server-only, which makes
importing it into a Client Component a build error rather than a leak.
The locale lives in a cookie, so there is one canonical path per screen and switching
language does not change the URL you would share. The server reads it before the first
byte — no flash of the wrong language. Enum display names live in
web/messages/{da,en}.json and are mirrored in backend/src/todoapp/cli/display.py, so
the browser and the terminal call the same thing by the same name.
Both languages are written natively. "Haster" is what a Dane would say; it is not a translation of "Urgent".
flowchart TB
subgraph one["One contract, four ways in"]
direction LR
W["Web<br/>13 routes<br/>Server Components + Actions"]
I["iOS<br/>12 screens<br/>48 of 52 RPCs"]
C["CLI<br/>60 commands<br/>every RPC"]
A["API<br/>4 services<br/>52 RPCs"]
end
W --> A
I --> A
C --> A
classDef c fill:#fff,color:#18181b,stroke:#a1a1aa
class W,I,C,A c
Each one is a first-class client, not a thin wrapper: the CLI reaches every RPC, the iOS
app reaches all but four (two need a universal-link handler, two are redundant), and the
web app covers every screen. make ios-screenshots renders the iOS app's ten screens to
apple/screenshots/.
Installed with the backend as todoapp. A pure API client — it holds no database
credentials and cannot bypass an authorization check.
todoapp auth login --email you@example.com
todoapp lists ls
todoapp tasks list --open --overdue
todoapp tasks add "Køb mælk" --list ind --due tomorrow --priority high --subtask "Havremælk"
todoapp tasks get 4f2c # any unique id prefix works
todoapp tasks done 4f2c # rolls a repeat forward and tells you
todoapp lists share 7a1b --email kollega@eksempel.dk --role editor
todoapp tasks list --json | jq '.tasks[].title'Every command takes --json, --locale da|en, and --server.
The CLI is meant to be usable by scripts and coding agents, not only by people, so
--json is machine-readable on every path — including the failures. The contract:
| stdout | stderr | exit | |
|---|---|---|---|
| success | the payload | empty | 0 |
| bad usage | empty | one JSON error | 2 |
| not signed in / not allowed | empty | one JSON error | 3 |
| anything else failed | empty | one JSON error | 1 |
$ todoapp lists get 00000000-0000-0000-0000-000000000000 --json
{
"error": {
"message": "list not found",
"exit_code": 1,
"reason": "ERROR_REASON_LIST_NOT_FOUND",
"hint": "Run: todoapp lists list"
}
}Branch on reason, not on message. The reason is a todo.v1.ErrorReason name —
stable, and the same value the API sent. The message is localized prose and may be
reworded. field names the input at fault, and metadata carries the numbers you need to
correct yourself and retry ({"max_length": "200"}).
Errors go to stderr rather than stdout on purpose: a command that has already written part of a payload and then fails would otherwise leave two concatenated objects on stdout, which is not parseable JSON at all. So stdout is always either one valid document or empty.
Enum values are emitted as their proto names (TASK_STATUS_DONE), and field names keep
their proto spelling (due_at, not dueAt), so the output is self-describing and matches
the contract rather than a language's conventions.
make test # 173 backend tests, against a real PostgreSQL
make ios-test # 29 Swift tests + a launch smoke test
make cli-coverage # all 60 CLI commands against a running server
make check # what CI runs: lint, backend tests, web buildThe suite runs against a real PostgreSQL (todoapp_test, dropped and recreated per
session) and calls the app over the wire through httpx's ASGI transport. Nothing below
the RPC boundary is mocked, which is the only way a raw-SQL repository layer is actually
covered — the tests exercise the same queries, constraints, and enum types production
does.
What it checks, beyond the happy paths: that a wrong password and an unknown address are indistinguishable; that sign-in is rate limited; that suspending an account invalidates the session already in someone's hand; that a viewer cannot write and a commenter cannot edit; that an editor cannot change visibility or share; that completing a repeating task twice does not create two follow-ups; that 31 January plus a month is 28 February; and that the proto and PostgreSQL enums agree.
proto/todo/v1/
enums.proto every closed set of values
errors.proto ErrorReason — the client's translation keys
common.proto pagination, denormalised refs
user.proto auth.proto list.proto task.proto
backend/src/todoapp/
main.py ASGI router: four Connect apps, CORS, health
config.py pydantic-settings
errors.py typed ConnectError constructors
mail.py the two transactional emails, da/en
auth/ passwords, tokens, principal, interceptors, permissions
db/ pool, migration runner, migrations/, seed
domain/ enum codecs, validation, recurrence
repositories/ raw SQL, one module per aggregate
services/ the four RPC services, plus mappers
cli/ the `todoapp` command
backend/tests/ 173 tests
web/src/
app/ routes: (auth), (app), verify-email
app/actions/ Server Actions — the only write path
components/ UI, including ui/ from ui.happenings.social
i18n/ lib/ locale resolution, API clients, formatting
web/messages/ da.json, en.json
apple/
project.yml XcodeGen source of truth — the .xcodeproj is generated
Core/ config, keychain, Connect client, session token, enums
DesignSystem/ Theme, Components, FlowLayout — app-agnostic
Apps/Todo/iOS/ App/, Features/<area>/, Components/
Apps/Todo/Resources/ Localizable.xcstrings — 353 keys × da/en
Apps/Todo/Tests/ Swift Testing + a fake Connect transport
- Python: Google style. Ruff for lint and format, Google-convention docstrings,
annotations everywhere.
make format. - SQL: lower case keywords in identifiers, explicit casts on every placeholder, forward-only checksummed migrations.
- TypeScript:
strictplusnoUncheckedIndexedAccess. Server Components by default;'use client'only where interaction demands it. - UI: the Happenings design system — borders over shadows,
rounded-xlcards,cursor-pointerand a scale-down press on everything clickable, Font Awesome Pro, light and dark for every view.
- The backend listens on 8081. On macOS, 8080 is usually WhatsApp Desktop.
buf generateruns withclean: true, so its output directories are wiped each time. They must contain only generated code — that is why Python lands inbackend/gen/rather than next to the hand-written package..claude/skills/todoapp/holds a skill describing the invariants and the traps, for agent-assisted work in this repo.apple/AGENTS.mddoes the same for the iOS app.- No credential is hardcoded anywhere. If you find one, that is a bug — see SECURITY.md.
Pull requests are welcome. CONTRIBUTING.md covers the setup, the five rules that keep the four clients honest with each other, and what a review looks for. Please also read the Code of Conduct.
For a security issue, do not open a public issue — see SECURITY.md.
MIT © Rasmus Rou Bach Jensing and Happenings Group A/S.