Files
pad/internal
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