mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
e426f8c844
The durable cursor was the one value this cache wrote without ordering. Rows
already arbitrate on `seq` through `resolveRowWrite` and tombstones on
`Math.max` through `raiseTombstone`, but the two functions that write the cursor
field — `persistDelta` and `persistReplace` — each `put` a freshly built meta
row unconditionally, so it was last-writer-wins and a tab that was behind
lowered it.
A cursor that is too low only costs a replay, which is why this read as
harmless. It is not, because nothing keeps it low until the replay happens: a
behind tab lowers it to 400 over rows another tab had current to 500, and then
that tab's next ordinary delta writes 501 — leaving the cache claiming 501 while
the rows are missing every change in 401..500, with nothing left that will ever
replay them. The regression alone is recoverable; the regression followed by an
unrelated advance is not.
`persistReplace` is the same case, not an exception. The premise that a resync
legitimately PINS the cursor below the durable one does not hold: `/items-index`
returns `max(workspace MAX(seq) read before the list, MAX(returned rows.seq))`
and that floor is workspace-global, unfiltered by visibility and by soft-delete,
so every cursor the workspace has issued is at or below its max seq at issue
time. A replace whose cursor is behind is therefore a stale response that landed
late. The pin is about the resyncing tab's own RAM position, where a regression
is safe because the merge keeps the newer row per id and a replayed range is
idempotent.
Both writers now read the stored cursor before issuing any write in their
existing transaction and drop the WHOLE batch when the incoming cursor is
strictly behind — rows included, because a delta's rows and its cursor are one
statement and writing the rows while withholding the cursor breaks it in half.
Everything a behind batch carries by POSITION is already covered by the claim
the stored cursor makes. Equal is not behind — a restatement at the same
position may carry rows the other writer's cache lacks.
Position is all a behind cursor settles, though, and the review rounds were
right to keep pushing there: a revocation writes no item, so a snapshot can be
strictly newer in SCOPE while losing the position race to an unrelated mutation
in another tab. Refusing it drops a true statement, and what makes that
survivable is the refusal being whole — the durable epoch stays old, so the
disagreement that drives the repair is still on disk, and any hydrate resyncs on
it. That argument then has to survive contact with the two doors that can write
the epoch WITHOUT a snapshot behind them, which is why this touches `localIndex`
at all:
- `ensureAccessScope` may JOIN a resync rather than start one, and it stamps
the told epoch on that resync's behalf without being able to see whether its
`persistReplace` committed. Both of its stamping branches now skip when it
did not.
- the next ordinary delta would carry RAM's adopted epoch onto durable rows
the refused snapshot never replaced. A delta cannot be skipped — its rows and
cursor must land — so it writes NULL instead, which is the accurate claim
("cannot know what this was authorised for") and the one `ensureAccessScope`
already resyncs on. It clears itself: the next committing `persistReplace`
records the real epoch.
Both read one signal, `persistReplace`'s new boolean, through one helper. The
hazard predates this change — a storage failure or the generation guard reaches
it too — but a refusable write makes it routine. Unsupported IndexedDB reports
true, mirroring `HydrateResult.durableRead`; an unopenable database reports
false, because that failure cannot prove a cache is absent.
Cursors are ordered exactly rather than through `Number`, which ties any pair
differing only above 2^53 — and a tie reads as "not behind", the direction that
lets a stale batch through. Both sides are opaque decimal strings, unrounded
until something calls `Number` on them, so the compare is canonical-decimal by
length then lexicographically, falling back to the old reading for anything
non-canonical. The first draft justified `Number` by pointing at
`ItemIndexRow.seq`'s lossiness, which bounds a different pair of values;
`seqFromCursor` stays as it is because a tombstone stamp is persisted as a
number and has to be one.
Scope: this closes the `persistDelta` arm of the cross-tab resurrection, not
`persistReplace`'s documented residual, which names a stale `persistUpserts`
from another tab. `persistUpserts` carries no cursor, and gating it on the row's
own `seq` would also refuse the optimistic-reorder path that passes
`seq: undefined` deliberately. That arm stays with PLAN-2903.
Twelve new tests, ten of which fail against the unfixed source (measured by
running them against `main`'s copies of both files); the two that pass are
boundary guards, not regressions. Mutation matrix, eleven mutants: ten killed —
the gate removed from each writer, behind widened to at-or-behind, an absent meta
row treated as behind, the kinder refusal that keeps the cursor while adopting
the new epoch, the exact compare degraded back to `Number`, the commit signal
ignored in each of the two stamping branches and in the delta helper, and
`persistReplace` always reporting success. Two survived a first run and were
answered differently: the null-baseline branch was genuinely untested and now has
its own test, while the cold-path delta site is unreachable with the flag false
(`hasCache = reentry || cacheIsPopulated` gates that branch, only a post-resync
state can be false, and `reset()` restores the default) — an equivalent mutant,
and the call is kept uniform deliberately. A non-compiling negative control
scores BUILD-FAIL rather than SURVIVED.
Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
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