Documentation: docs/, a changelog, and CLAUDE.md

PROGRESS.md becomes CHANGELOG.md with the finished step lists moved to an
appendix. The README is an overview pointing at docs/: configuration,
cli, users, sso (refreshed for accounts and admin-only settings), and
architecture. CLAUDE.md collects what working on this code actually
requires -- pkill -x not -f, the page being compiled in, the dead columns
on entries, the Playwright worker that deleted its own database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-11 03:00:25 +00:00
parent c47c224372
commit 6114add4a6
8 changed files with 684 additions and 269 deletions

119
CLAUDE.md Normal file
View File

@@ -0,0 +1,119 @@
# Working on ipodderx-rs
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
thing is built; this file is about working on it without repeating mistakes that have already been
made here.
## Where things are
The live install on this machine:
| | |
|---|---|
| Binary | `/config/.cargo/bin/ipx` |
| Config | `/config/.config/ipx/config.toml` |
| Database | `/config/.local/share/ipx/state.db` |
| Downloads | `/mnt/user/audio/ipx` |
| Web UI | `0.0.0.0:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel |
Deploying a change is: build, stop, copy, start.
```sh
cargo build --release
pkill -x ipx; sleep 2
cp target/release/ipx /config/.cargo/bin/ipx
setsid nohup /config/.cargo/bin/ipx --config /config/.config/ipx/config.toml daemon \
>/tmp/ipx.log 2>&1 </dev/null &
```
**`pkill -x ipx`, never `pkill -f ipx`.** `-f` matches the shell running the command and kills the
session (exit 144). This has happened more than once.
The daemon is not supervised: it will not survive a reboot. `contrib/` has a systemd unit nobody
has installed.
## Before you touch the page
`web/index.html` is `include_str!`d into the binary, so **every page change needs a rebuild** before
it is visible. It is one file: markup, CSS and script.
After any edit to it:
```sh
node tests/page-smoke.js
```
That loads the script against a stub DOM and checks every selector it wires at load actually
exists. It exists because a patch once anchored on a deleted function, `String.replace` silently
matched nothing, and the whole UI died with a `ReferenceError` while every server-side test passed.
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
## Tests
```sh
cargo test # ~51 tests: parsing, filters, retention, schedules, SQL, per-user state
node tests/page-smoke.js
npx playwright test # 16 browser tests against a real daemon on fixture feeds
```
Things about the browser suite that have cost time:
* It starts **its own daemon and database** under `/tmp/ipx-ui-test`, wiped once per run. Playwright
re-imports the config in every worker, so `prepare()` guards on `TEST_WORKER_INDEX` — without
that guard a worker deleted the database out from under the running daemon, which then kept
serving from the unlinked inode while everything else saw an empty file.
* Tests **share that daemon and run in order**. A test that opens an item marks it read and changes
what later tests see. Write assertions that do not depend on what ran before, or normalise the
state first.
* Fixture feeds must not share an enclosure URL, because `enclosures.url` is globally unique and
whichever feed is scanned first claims it.
* `webServer` starts **before** `globalSetup`, which is why the fixture config is written at
config-load time instead.
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
`matches_keywords`, `parse_interval`) are the easiest place to put it.
## Things that are true and easy to get wrong
* **`enclosures.url` is globally UNIQUE.** It is the dedupe key and the reason one file serves every
subscriber. Two feeds publishing the same URL means only the first one scanned shows it.
* **`entries.read`, `entries.flagged` and `entries.position` are dead columns.** Read state lives in
`entry_state` per user. Two bugs have already come from queries still reading the old ones
(retention, and the entry pruner) — grep before adding a third.
* **The catalogue is config.toml; the subscriptions are in the database.** A feed exists once;
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
derived and never written to config.
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
Anyone wanting an item is enough to fetch it.
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
handler and return `403`.
* **A `tokio::select!` only races its branches at the point of selection.** A long download has to
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
* Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons
fighting over the database, with the stale one still holding the port.
* `/api/settings` answering `200` does **not** mean the worker is alive — it is a different task.
Probe the control socket (`ipx status`) to check that.
## House style
Comments explain **why**, not what. If a line looks odd, the comment says what went wrong without
it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and
addressed to the person using it.
Every change gets an entry at the top of [CHANGELOG.md](CHANGELOG.md), dated, saying what landed and
what was wrong before. That record has been more useful than the git log more than once.
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
## Known gaps
* No user administration in the web UI; `ipx user` on the box only.
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
(documented in [docs/sso.md](docs/sso.md)).
* The Docker image predates multi-user; `docker compose build` before relying on it.
* Downloads land root-owned; Unraid shares want `99:100`.
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
Substack-style subtitles.