Files
pad/web
xarmian a5b93c17c9 feat(api): add /items-changes?since=<seq> delta endpoint (TASK-1354) (#494)
* feat(api): add /items-changes?since=<seq> delta endpoint (TASK-1354)

Adds the delta-fetch sibling of /items-index for the local-first
read model (PLAN-1343 / DOC-1342 design decision #1). Clients track
the workspace-scoped monotonic seq cursor returned by /items-index
(TASK-1353) and poll /items-changes?since=<cursor> to apply just
the rows that have mutated — without re-downloading the entire
workspace.

## Endpoint

GET /api/v1/workspaces/{ws}/items-changes?since=<seq>&limit=<n>

  - `since`: exclusive seq lower bound (returns `seq > since`).
    Defaults to 0 → full delta == /items-index modulo ordering.
    Bad input → 400.
  - `limit`: cap on rows. Defaults to 5000, clamped to 50000. Bad
    input → 400.

## Response

  { "changes": [...skinny rows with `deleted: bool`...],
    "cursor": "<decimal MAX(seq) or unchanged since when empty>" }

Soft-deleted rows propagate (no `deleted_at IS NULL` filter on the
backing scan) so a delta consumer can remove them from its local
index without a second roundtrip. Parent metadata enrichment
matches /items-index: the underlying GetItem filters soft-deleted
parents so we never leak parent title/ref for an archived parent.

Cursor contract:
  - Sorted ASC by seq → re-passing the response's cursor as `since`
    on the next poll is no-overlap, no-gap (strictly monotonic seq
    invariant from TASK-1352).
  - Empty response preserves the caller's `since` so position isn't
    lost.
  - Truncated-by-limit responses set cursor to the last row's seq.

## Tests

  - FullDeltaFromZero — three creates, since=0, ascending seq, every
    row deleted=false, cursor=MAX(seq).
  - IncrementalUpdateAndDelete — typical resume flow: snapshot
    cursor, mutate, delta returns exactly the mutated + tombstoned
    rows with the right `deleted` flag.
  - CursorRoundtripsCleanly — empty-poll after consuming, cursor
    preserved.
  - LimitTruncatesAndCursorResumes — paging contract holds end to
    end with no overlap.
  - InvalidParams — bad since / limit values rejected with 400.
  - EmptyWorkspace — cursor round-trips caller's since unchanged.

## Web

TypeScript: `ItemChangeRow = ItemIndexRow & { deleted: boolean }`,
`ItemChangesResponse = { changes, cursor }`. API client gains
`api.items.changes(ws, sinceCursor, opts?)` with the same
defensive content-strip as listIndex so a stray `content: ""`
key from a Go zero-value can never clobber the canonical store.

Parent: PLAN-1343. Depends on TASK-1352 (seq column) and TASK-1353
(seq cursor on /items-index). Unblocks the future client-side
localIndex.applyDelta integration task.

* fix(api): surface tombstones for item-grant users in /items-changes per Codex review (round 1)

Codex round 1 caught that handleListItemsChanges was building its
ItemIDs filter from guestResourceFilter, which itself uses
GuestVisibleResources whose item-grant query filters out
soft-deleted items. The result: a guest or restricted member with
an item-level grant on a single item would see that ID disappear
from the lookup as soon as the item was soft-deleted — and
/items-changes would never emit a `deleted:true` tombstone, so the
client would keep the stale row in its local index forever.

Fix:
  - New Store.GuestVisibleResourcesIncludeDeleted that drops the
    `i.deleted_at IS NULL` / `c.deleted_at IS NULL` filters on
    both collection and item grants so tombstone IDs flow through.
  - New Server.guestResourceFilterIncludeDeletedItems delegate
    pointing at the new store helper. Implementation is shared with
    the live variant via guestResourceFilterCore so the
    member-collection-access + system-collection merge logic stays
    in one place.
  - handleListItemsChanges swaps to the include-deleted variant.

Test: TestGuestVisibleResourcesIncludeDeleted_SurfacesTombstones
covers both variants side-by-side — live drops the soft-deleted
grant, include-deleted preserves it.

* fix(store): assign per-row unique seqs in MigrateItemFieldValues per Codex review (round 2)

Codex round 2 caught that the bulk UPDATE inside
MigrateItemFieldValues gave every affected row the SAME
MAX(seq)+1. A /items-changes?limit=N poll that cut through that
equal-seq group would advance the cursor to the shared seq, and
the next `seq > cursor` poll would silently miss the rest of the
group — the cursor contract requires strict monotonicity.

Switched to a per-row loop inside the migration transaction so
every UPDATE re-reads MAX(seq) and each affected row ends up
with a strictly unique seq. The workspace advisory lock makes
the read-modify-write race-free on Postgres; SQLite's
single-writer rule handles it implicitly.

Trade-off: O(N) statements instead of O(1) for the bulk path.
Option-rename is an admin one-off so the cost is acceptable
(~1s/1000 rows on a warm SQLite connection). If future use cases
demand a larger row budget, a single-statement UPDATE..FROM with
ROW_NUMBER() CTE assigning per-row seqs would also work.

Test: TestMigrateItemFieldValues_PerRowUniqueSeq confirms 5 rows
in a single migration step all get unique seqs.
2026-05-11 13:42:11 -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
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
      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