Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ src/
├── features/
│ ├── router.ts list of feature APIs (server)
│ ├── nav.ts menu entries (client)
│ └── device-syncs/ the existing feature
│ └── device-syncs/ a feature, the one to copy
│ ├── api/queries.ts SQL with Kysely, plain functions taking `db`: tested
│ ├── api/queries.test.ts
│ ├── api/router.ts tRPC procedures
Expand Down
45 changes: 45 additions & 0 deletions docs/adr/0015-page-state-lives-in-the-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# 0015. Page state lives in the URL

**Status:** Proposed
**Date:** 2026-09-18

## Context

The stale devices page has a threshold the supervisor changes: the number of days without a sync.
It could have lived in React state inside the page, which is the shortest thing to write.

Nearly every feature still to come has the same shape — a filter by user (issue #17), a better sync
table (#19), a chart range (#5), a selected district on the map (#3). Whatever the first one does,
the others will copy, so the choice is worth making once rather than five times.

## Decision

A page's state lives in the URL.

- The route declares it: `validateSearch` with a zod schema, and `.catch(<default>)` so a
hand-edited or missing value falls back instead of throwing.
- The page reads it with `getRouteApi('/path').useSearch()` and writes it with
`navigate({ search, replace: true })`. `getRouteApi` rather than importing the route file, which
would be a circular import: the route already imports the page.
- No `useState` holding a copy, and no `useEffect` keeping the copy and the URL in step.

State nobody would want to share stays in the component. `NeverSyncedSection` keeps whether it is
folded in its own `useDisclosure`, because a folded section is not worth a URL.

## Consequences

A view can be bookmarked, reloaded and pasted into a chat. A junk URL cannot break the page, and the
fallback is written where the parameter is declared.

`replace: true` means a change of the control does not stack up in history: the back button leaves
the page rather than stepping back through every threshold typed. That is the intent — a half-typed
"1" on the way to "14" is not a view anyone wants to return to.

There is one source of truth, so the class of bug where a control and the data disagree cannot
happen.

Every change of the control costs a query: typing "14" queries for 1 and then 14. At 200 devices on
a local Postgres that is not worth a debounce. Revisit when a page fetches something expensive —
the fix is to debounce the value before it reaches the URL, and it is local to the page.

The state a page can hold is limited to what serialises into a query string.
40 changes: 40 additions & 0 deletions docs/adr/0016-lateral-join-for-the-latest-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 0016. A lateral join for the latest row per parent

**Status:** Proposed
**Date:** 2026-09-18

## Context

`device_sync` holds one row per sync and there is no last-sync column on `device`. Listing the
devices that have stopped syncing needs each device's newest sync — and needs to keep the devices
that have no sync at all, which every inner join written so far drops.

This is not one query's problem. A device detail page (issue #6), sync activity per user (#7) and
sync health per district (#3) all need the latest row of a group, and the seed deliberately contains
devices that never synced (ADR 0006), so the sync-less case is permanent.

## Decision

We take the latest row per parent with a `left join lateral` and `limit 1` — a subquery allowed to
refer to the row it is joined to. Kysely writes it as
`leftJoinLateral((eb) => eb.selectFrom(...).whereRef(...).orderBy(...).limit(1).as('last'), (join) => join.onTrue())`.

Two alternatives were rejected:

- `distinct on (device_id)` starts from the syncs, so keeping the devices that have none turns the
join round and reads backwards.
- A `group by` subquery of `max(synced_at)` joined back to `device_sync` needs two joins to recover
the columns of that row, and emits a parent twice when two of its rows share a timestamp.

## Consequences

The lateral reads the existing `device_sync (device_id, synced_at desc)` index, returns exactly one
row per parent, and carries the rest of that row — here the user who synced — with no second join.
Ordering it differently, or taking the two latest rows, is a one-line change.

It is Postgres and MySQL only. That costs nothing: the application is Postgres everywhere
(ADR 0013), and PGlite runs laterals, so the tests exercise the real thing.

A lateral runs its subquery once per outer row. At 200 devices that is free. If a query ever fans
out to thousands of parents and the index stops carrying it, the answer is a denormalised
`last_synced_at` column maintained on write — which is a schema decision, and would supersede this.
Loading
Loading