mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
e91c4fc261
* fix(store): include the cursor's own second in /changes deltas (BUG-2539)
items.updated_at / items.deleted_at are RFC3339 whole-second strings
(store.now()), while the /changes cursor is a unix-millisecond value —
normally the previous response's server_time. ItemsModifiedSince
formatted that cursor with the same second precision, truncating it
DOWN, then compared with a strict `>`. Every change landing in the
cursor's own second compared equal and was dropped, permanently: the
caller advances its cursor past that second and nothing reaches back.
User-visible symptom: a bulk archive ~450ms after a page seeded its
cursor left the item rendering as LIVE indefinitely — no banner, no
redirect — while the server had deleted_at set. It was never
archive-specific (updates were dropped identically); a missed update is
usually re-delivered by the next event, a missed deletion never is.
Compare inclusively against the truncated second instead. The boundary
second may be re-delivered, which every consumer of this endpoint
applies idempotently, and it is bounded to one second of changes per
sync. Sub-second storage is the other fix and is a migration, not a
one-liner: these comparisons are lexicographic on TEXT columns and
mixing precisions inverts them ("…20.451Z" sorts BEFORE "…20Z").
Verified against a live instance with four cursors all strictly earlier
than the archive in real time: two inside its second MISS, two in
earlier seconds HIT.
Tests:
- TestItemsModifiedSince_SameSecondCursor — same-second leg plus a
previous-second control. Fails 3/3 unfixed, passes 3/3 fixed; the
control passes on both.
- e2e bug-2539-sync-window — the banner must appear in the already-open
page AND follow a /changes delta that carried the deletion, so a
reload cannot satisfy it. The 450ms leg fails unfixed; 1200ms control
passes on both.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
* test(store,e2e): close the review gaps in the BUG-2539 counterfactuals
Codex review of 7905ed06 found no P1s and three P2s, all on whether the
tests actually measure the fix. Each was right.
Store test:
- It derived "same second" from a wall-clock reading taken BEFORE the
writes, so a leg whose writes drifted into the next second would pass
under the unfixed query and still be counted as evidence. It now reads
the timestamps the writes actually STORED and compares those against
the cursor's second.
- Misalignment retried instead of skipped, so the leg cannot silently
stop testing anything.
- It never asserted the archived row comes back in `updated`, leaving
the `(deleted_at IS NULL OR deleted_at >= ?)` arm free to regress to
`>` unnoticed. Now asserted.
E2E:
- It accepted ANY /changes response carrying the deletion, including the
cursor-seeding request setWorkspace fires during load, and never
established that the page had loaded a live row. Both holes let it
pass without exercising the incremental path. It now requires the
page's own item GET to have seen deleted_at null AND the deletion to
arrive on a /changes that resolved after the archive POST completed.
- Assert check.ok() before reading deleted_at; delete the scratch
workspace at the end.
Also softened the comment's claim about re-delivery: `>=` makes the
endpoint at-least-once at the boundary and rows can repeat across
several rapid syncs, not just one. What makes that safe is that the
payload is server state rather than an increment, so the comment now
says that instead of "bounded to one second".
Counterfactuals re-run against a genuinely reverted query (the earlier
stash-based attempt was a no-op once the fix was committed, and passed
for that reason): store test fails 3/3 with all three assertions firing,
e2e 450ms leg fails, both controls pass.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
* test(e2e): decide BUG-2539's oracle by request order, not response timing
Codex's second pass kept one P2 on the e2e, and it was right on both
halves. The oracle compared response arrival against the moment the
archive POST completed, which is racy in both directions: the server
publishes the SSE event BEFORE the bulk handler finishes writing its
response, so the incremental /changes can resolve first and be scored as
"not after the archive" (false failure); and a slow cursor-seeding
response can resolve after it and be scored as incremental (false pass).
The live-row check was existential — any live read of the row counted,
including one issued after the archive.
Both are now decided on the REQUEST side, where ordering is not racy:
- `/changes` requests are numbered as they are issued. The seed that
setWorkspace fires on mount is number 0; only a LATER one carrying the
deletion satisfies the assertion.
- Item GETs record their issue time, and `archiveSentAt` is stamped
immediately BEFORE the POST goes out, so "the page read a live row
before the archive" is decidable without waiting on anything.
- The async response handlers are collected and awaited before the
assertions read their flags, instead of racing them.
Cleanup moved into a finally so a failing assertion no longer leaks the
scratch workspace, and its response is checked.
Counterfactual re-run at repeat-each=3 against a genuinely reverted
query: the 450ms leg fails 3/3, the 1200ms control passes 3/3, and both
legs pass 3/3 with the fix in.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
* test(e2e): anchor BUG-2539's legs to preconditions, not a fixed delay
The fixed 450ms/1200ms offsets measured from navigation start were a
proxy for the real condition, and a machine-speed-dependent one. Under
parallel workers a slow load put the page's own item read AFTER the
archive, so the page rendered an already-archived row and the sync path
was never exercised — the live-row assertion then failed, correctly
reporting that the leg had not reproduced the scenario.
The legs now wait for the two things that actually have to be true —
the page has READ a live row (server-attested deleted_at null) and its
EventSource is subscribed (the /events response headers have arrived;
the handler subscribes before writing them) — and are named after the
mechanism: archive inside the cursor's own second vs after crossing the
next second boundary.
Also from Codex's third pass:
- the /changes URL match is anchored so it cannot also match
/items-changes, whose requests would otherwise consume ordinals and
let the cursor seed pass as the incremental sync;
- the incremental check now requires ordinal > 0 AND issue time at/after
the archive, so a retried seed cannot pass on ordinal alone;
- the live-row check no longer compares clocks at all — a response
carrying deleted_at null cannot come back after the archive applied,
so the server attests it;
- the response-handler drain loops until no new handler was queued while
awaiting, instead of snapshotting the array once;
- cleanup is recorded in `finally` and asserted after it, so a cleanup
failure cannot replace the real one, and setup now runs inside the try
so a failed setup cannot leak the scratch workspace.
Counterfactual against a genuinely reverted query: the same-second leg
now fails 6/6 (the delay-based version managed 7/8), control passes 6/6.
130 runs green with the fix in.
Both assertions carry the recorded state in their message: this leg
flaked twice in ~70 runs of an earlier revision and the artifacts were
cleared by the next run before they could be read, so a recurrence has
to explain itself from the failure text.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
* test(e2e): make BUG-2539's leg PROVE it hit the second it is named after
Codex's fourth pass: the same-second leg never checked that it actually
landed in the cursor's second. It archived immediately after the
preconditions and assumed. On a slow machine that drifts across the
boundary, at which point the reproducing leg quietly becomes a second
control and passes against the very query it exists to convict — the
same "a test that silently stops testing anything" shape the store test
was already hardened against, which is what makes the point land.
Each attempt now compares two SERVER values: the `server_time` of the
page's first /changes (the seed — that value IS the client's
lastSyncTime, the cursor the failing sync used) against the `deleted_at`
the server stored. Same-second leg requires equality, boundary leg
requires difference, and a misaligned attempt is retried on a fresh
workspace (up to 6) instead of asserted on. Exhausting the attempts
fails with both seconds in the message.
Also from that pass:
- cleanup swallows a rejected delete rather than replacing the real
failure with a transport error;
- `createdSlug` is set from the requested slug BEFORE parsing the
response, so a malformed success cannot leak a workspace;
- request/response listeners are removed per attempt, so retries do not
stack handlers;
- the live-row comment now says "carries no deleted_at" — a live item
omits the field (omitempty) rather than sending null. The previous
commit message said `deleted_at: null`; the check was always a falsy
one, so only the wording was wrong.
Counterfactual against a genuinely reverted query: same-second leg fails
4/4, control passes 4/4. 12/12 green with the fix.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
* test(e2e): scope BUG-2539's matchers per attempt; don't accept an unknown seed
Codex's fifth pass, both findings real:
- Retry ordinals were not scoped to the attempt's workspace. A retry
reuses the page, which is still showing the previous attempt's
workspace when the listeners go on, so an in-flight /changes from that
one could take ordinal 0 and be mistaken for the new seed. The
/changes, item-GET, and /events matchers are now built per attempt
against that attempt's slug.
- A seed response that was never observed left seedServerTime null,
which the alignment check folded into "different second". For the
same-second leg that already meant a retry, but the CONTROL leg would
proceed on an unknown and claim it had proven a difference it never
saw. An unknown seed is now its own retry.
Counterfactual re-run after the change: same-second leg fails 4/4
against a reverted query, control passes 4/4; 10/10 green with the fix.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
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
activity/ ActivityFeed
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