Files
pad/web
xarmian e426f8c844 fix(web): the durable item cache's cursor never moves backward, and a refused batch stamps no epoch (TASK-2906) (#1264)
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
2026-09-06 18:22:36 -04:00
..
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00

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