mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 10:03:29 +00:00
f465d4c51e
* fix(server): a malformed before_id is a 400, not a 500 (BUG-2774) before_id went from the query string into the cursor predicate unchecked. Postgres refuses a text parameter that is not valid UTF-8 or that carries a NUL (SQLSTATE 22021/22P05), so the store call errored and the handler answered 500 — the server announcing its own failure for a client's bad input, and SQLSTATE noise in the logs for an input problem. SQLite accepts the same bytes and matches nothing, so the identical request was a 200 there; the failure mode diverged by dialect from one line of unvalidated input. validCursorID rejects exactly what the DATABASE rejects rather than what an id should look like, and deliberately carries NO length or format bound: the structured kinds' ids come from the item's own fields blob, nothing validates them on write, and an imported artifact may carry any string — so a cap could only ever fire on a legitimate cursor, while the cost of an over-long one is a single indexed comparison against a parameter the URL length limit already bounds. Its comment says so, because the obvious review question is why there isn't one. Tests: three malformed shapes reachable from a plain URL (%FF, an embedded NUL, invalid bytes mid-string) plus three controls — a UUID, a structured note id, and a long non-ASCII id — without which "reject every before_id" would pass and paging would be dead rather than honest. A store-level test pins the PREMISE where it is real: on Postgres the query itself fails, on SQLite it succeeds and returns nothing, and both halves are asserted in the one place that sees both, each with a message saying what it means if the backend's behaviour has moved. Verified on Postgres 17 (private container, not the shared port). The handler legs fail with the validation removed. Refs: BUG-2774 Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * fix(server): never emit a cursor the handler would refuse (codex round 1) The validation had a second direction I had not closed. A structured entry's id comes from the item's fields blob, which nothing validates on write, so a JSON \u0000 escape arrives as a real NUL on SQLite — Postgres's jsonb refuses it at the door, which is why this is a one-backend hazard. That id became the entry's id, the entry's id became next_before_id, and the client sending it back got a 400 from the validation this unit just added: the server handing out a cursor it then refuses, wedging paging on that item. Such an id now takes the positional fallback that empty and duplicate ids already take — the id has to be usable as a CURSOR, not merely unique. One condition on an existing branch rather than new machinery. Its test uses two notes, one NUL-bearing and one clean, so it distinguishes 'replaced the unusable id' from 'stopped using raw ids at all'. Verified against the unmutated branch: the NUL id is emitted verbatim and the leg fails. Refs: BUG-2774 Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * test: assert the envelope, the emitted cursor, and split the dialect premise (codex round 3) Three test weaknesses, all mine. Status-only assertions: a bare 400 or the wrong code would have passed, and clients branch on the code rather than the number. The legs now decode the envelope and require invalid_cursor with a non-empty message. The emitted-cursor test claimed to cover next_before_id and did not: at limit=50 the fixture emits no cursor at all, so it only checked entry ids. A third note and a truncating limit make the NUL-bearing one the LAST kept entry, which is what puts it in the cursor — and the test now asserts the cursor is valid AND that paging with it returns 200. The mutant (emitted ids unchecked) still dies, now on the thing the test is named for. The dialect test is CHARACTERIZATION and now says so: it passes with or without this change, because it describes the backends rather than the handler, and that is its job — it is the premise the 400 rests on. Its 'both halves in one place' claim was also false in an unconfigured run, where NewPostgres skipped the whole test and the SQLite half never ran either. Split into subtests: the SQLite half runs everywhere, the Postgres half skips alone. Refs: BUG-2774 Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * fix(server): a one-sided cursor is a 400, not a silent no-op (codex round 4) before_id alone could never match anything: the id is the tie-break AT the cursor instant, and before defaults to now+1m, which no row shares. So it was accepted, ignored, and the caller paged from the beginning believing they had a cursor — the accepted-and-does-nothing shape, which is the worst answer of the three available. The other direction stays supported deliberately, and has a control leg saying so: before alone is the external-client case the "g" sentinel exists for, and rejecting the pair symmetrically would break something the handler goes out of its way to serve. In scope for this unit rather than a separate filing because it is the same parameter and the same class of answer — and because BUG-2765's PR body documented "both fields or neither" as the contract, which until now nothing enforced. Codex's other round-4 finding — a structured entry id can collide with a comment/activity/version id, because the dedupe map holds only structured ids — is real, pre-existing, and a cross-source id decision rather than a validation. Filed as BUG-2783. Refs: BUG-2774, BUG-2783 Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * docs: the cursor contract is asymmetric, and now says so (codex round 6) BUG-2765's prose said "both fields, or neither" in the handler doc comment and the TS client. This unit then enforced it in one direction only — deliberately, because `before` alone is the external-client shape the id sentinel exists to serve, while `before_id` alone matches nothing and silently pages from the beginning. The slogan was mine and the asymmetry is mine; leaving both in place would have left a reader to discover the difference from a 400. Both sites now state which one-sided form is accepted, which is refused, and why each. Refs: BUG-2774 Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
Pad Web UI
SvelteKit 2 + Svelte 5 frontend for Pad, compiled to static files and embedded into the Go binary.
Development
npm install
npm run dev # Dev server at localhost:5173 (proxies API to localhost:7777)
npm run build # Production build to build/
npm run check # Type checking with svelte-check
When developing, run the Go backend separately with make dev from the project root.
Building for Production
Do not build in isolation. Always use make build from the project root — this builds the web frontend, then compiles the Go binary with the build output embedded via //go:embed.
Stack
- Svelte 5 with runes (
$state,$derived,$effect) - SvelteKit 2 with
adapter-static(SPA mode) - Tiptap block editor with markdown round-trip
- svelte-dnd-action for drag-and-drop in board/list views
- SSE for real-time updates
- TypeScript throughout
Structure
src/
routes/ SvelteKit pages
+layout.svelte App shell (sidebar + main)
+page.svelte Landing/redirect
[workspace]/
+page.svelte Dashboard (collections, phases, activity)
+layout.svelte SSE connection per workspace
[collection]/
+page.svelte Collection view (board/list)
[collection]/[item]/
+page.svelte Item detail + editor
conventions/ Purpose-built conventions page
playbooks/ Purpose-built playbooks page
settings/ Workspace settings
lib/
api/client.ts HTTP API client
components/
layout/ Sidebar, navigation
editor/ Tiptap editor, raw markdown editor
fields/ FieldEditor, relation picker
items/ ItemCard, ItemDetail
collections/ BoardView, ListView
common/ StatusBadge, badges, modals
search/ CommandPalette
stores/ Svelte 5 reactive stores
workspace.svelte.ts Workspace state
collections.svelte.ts Collection + item state
ui.svelte.ts Sidebar, mobile state
types/index.ts TypeScript types and constants
app.css Global styles and design tokens