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:
119
CLAUDE.md
Normal file
119
CLAUDE.md
Normal 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.
|
||||
Reference in New Issue
Block a user