Files
pad/web
xarmian c017ad359d fix(events): give each in-memory bus incarnation its own ID space (BUG-2736)
Both in-process buses assigned Last-Event-ID values from a counter that
restarted at 1 on every process start. A client holding cursor 2 from a
previous incarnation could reconnect to a restarted server, pass every
coverage check BUG-2731 added, and be replayed the NEW space's 3, 4, 5 as
though they followed the OLD space's 2 -- silently missing everything the
dead space held above 2.

Nothing local could tell the two 2s apart. The cursor carries no epoch, and
in internal/events per-workspace IDs are non-consecutive by construction, so
"did we issue this ID?" was numerically undecidable. The four adjacent levers
were checked rather than assumed: comparing in memory has nothing to compare
against; persisting the counter makes single-process Pad carry durable
event-bus state and still resets on data loss; refusing cursors we did not
issue is the undecidable one; and a nonce on a second channel is unavailable
because EventSource echoes Last-Event-ID and nothing else, and cannot rewrite
its URL on an automatic reconnect.

So the ID space's identity goes in the ID's VALUE while its FORMAT is
unchanged: still a bare int64, still ParseInt on the way back. internal/idspace
mints a base of processStartUnixMilli<<20 and each bus counts up from it. Two
incarnations can only collide if the earlier process published more than 2^20
events per millisecond of its own lifetime -- a deterministic bound, not the
probabilistic one BUG-2736's body rules out. A CAS makes bases strictly
increasing within a process too, which the clock alone does not do for two
buses constructed in the same millisecond.

A backwards clock step degrades in the SAFE direction: a lower base puts old
cursors ABOVE the new buffer's newest ID, so they are refused rather than
answered wrongly. The overflow bound is computed, not estimated: the last
start instant that fits is 2248-09-26T15:10:22Z.

Each bus then answers the resume question exactly instead of inferring it: a
non-zero cursor at or below this incarnation's base was issued by a dead
space. That is strictly stronger than the coverage check alone, which serves
the ADJACENT cursor on reasoning that only holds within one ID space.

In internal/watchevents the check lives in one helper both entry points call.
Written inline in EventsSince it was absent from SubscribeAndReplaySince --
the path the SSE handler actually uses -- so the component was fixed and its
wiring was not (team CONVE-19). A test now drives both.

web's ItemEvent no longer declares `id?: number`. Nothing read it, which is
the only reason it was harmless; a base of ~1.8e18 is past JavaScript's
MAX_SAFE_INTEGER, so the first reader would have silently got a rounded
number. Defused while still unread.

Tests that spelled out IDs now read back what the bus assigned -- a literal 1
is a cursor from a dead space, which turned two negative controls into their
own opposite. The two watchevents guards (cold buffer, dead incarnation) are
tested separately, because a single test covering both would keep passing
with either deleted.

The Redis half is not here. Its counter is shared across processes, so
identifying its ID space needs an epoch travelling with each message; that is
the next commit on this branch.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-22 18:14:45 +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
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