Files
pad/internal/store
xarmian 979537e4bb feat(web): localIndex in-RAM canonical store (TASK-1355) (#495)
* feat(web): localIndex in-RAM canonical store (TASK-1355)

New Svelte 5 module at web/src/lib/stores/localIndex.svelte.ts that
owns the in-RAM truth for the local-first read model. Per DOC-1342
decision #4: the store is canonical; IndexedDB persistence (next task)
is hydration + write-behind only.

Per-workspace state in a Map keyed by workspace slug:
- items: SvelteMap<itemId, ItemIndexRow> — keyed by item.id
- cursor: monotonic seq cursor as opaque decimal string
- bootstrapState: 'cold' | 'loading' | 'ready' | 'error'

Public API:
- bootstrap(ws): idempotent /items-index hydration (in-flight coalescing)
- getByCollection(ws, collSlug): synchronous filtered read
- applyDelta(ws, changes, cursor): batch upsert/remove + cursor advance
- upsert(ws, row): single-item write for SSE/optimistic paths
- remove(ws, id): single-item delete for SSE archive + 403 purge
- cursorFor(ws) / bootstrapStateFor(ws): reactive getters
- reset(ws): drop all state for a workspace

Defensively strips Item.content on every ingest so a caller passing
a full Item (e.g. from api.items.update) cannot leak the rich body
into the local index — matches the destructure-by-rest pattern in
api.items.listIndex / changes.

Parent: PLAN-1343.

* fix(web): localIndex reactivity + stale-batch guards per Codex review (round 1)

- [P1] WorkspaceState is now a class with `$state` class fields for
  `cursor` and `bootstrapState`. Svelte 5 only permits `$state()` at
  variable-initializer / class-field / constructor-first-assign
  sites — the previous `state = $state({...})` inside `ensureState`
  silently produced a non-reactive object on first hydration, so
  `bootstrapStateFor` getters could stay 'cold' through 'loading' /
  'ready' transitions. Class-field runes give us the same shape
  with reactivity intact.

- [P1] Documented that the store intentionally holds both live and
  archived rows. `applyDelta` only removes on the soft-delete
  `deleted: true` tombstone — status='archived' rows stay so a
  later `showArchived` toggle on the consumer doesn't need a
  refetch. Intended consumer pattern (TASK-1357) filters on
  `fields.status` at render time.

- [P2] `applyDelta` now drops the whole batch when `newCursor` does
  not strictly advance, AND skips individual rows whose `seq` is
  not greater than the cursor at the start of the call. In normal
  /items-changes flow the server filters to `seq > since`, but the
  per-row guard prevents test or future replay callers from
  overwriting newer state with older rows.

Parent: PLAN-1343.

* fix(web): localIndex archive filter + per-row seq guard (Codex round 2)

- [P1] `Item.deleted_at` was missing from the TS interface even though
  the server populates it (`Item.DeletedAt *time.Time` with omitempty).
  Added the field to `Item`, which flows into `ItemIndexRow` via the
  existing `Omit<Item, 'content'>` mapping.

- [P1] `getByCollection(ws, collSlug)` now filters soft-deleted rows
  out by default. The store still holds them (so a `showArchived`
  toggle doesn't need a refetch) but the default view is live-only,
  matching every other collection consumer in the codebase. Callers
  that want archived rows pass `{ includeArchived: true }`.

  Updated the module-level docstring: archived = `deleted_at` set
  (not `fields.status`), and clarified the upsert-vs-delete split on
  the change wire format (`deleted: true` = hard tombstone; soft
  deletes arrive as upserts with `deleted_at` populated).

- [P2] `applyDelta` per-row check now compares against BOTH the
  cursor floor at start AND the existing row's `seq`. Without the
  second check, a delta that legitimately advances the cursor could
  still carry a row whose `seq` is older than what we already hold
  for that id (since `upsert` / SSE paths can store newer rows
  without touching the cursor).

Parent: PLAN-1343.

* fix(web): preserve soft-deleted rows + upsert seq guard (Codex round 3)

- [P1] /items-changes sets `deleted: true` for soft-deleted rows
  (the server's derived view of `deleted_at != nil`), not for hard
  tombstones. The previous applyDelta removed those rows, defeating
  the store's stated invariant that archived items remain queryable
  via `getByCollection(..., { includeArchived: true })`. Now applyDelta
  always upserts on a change — the soft-deleted row keeps its skinny
  payload (with `deleted_at`) and falls out of the default filter
  but stays in the index. Hard deletes still flow through `remove()`.

- [P2] `upsert` now mirrors `applyDelta`'s per-row seq guard: skip
  the write if the incoming row's `seq` is not strictly greater than
  the existing row's `seq`. Without this, a late SSE / out-of-order
  optimistic response could regress a row after a fresher version
  had already landed.

Parent: PLAN-1343.

* fix: items-index returns deleted_at + bootstrap merges instead of clears (round 4)

- [P1] `/items-index` projection now selects `i.deleted_at` and
  `scanItemsIndex` populates `Item.DeletedAt`. Without this the
  local-first client could not distinguish archived rows from live
  ones, so `localIndex.getByCollection`'s default live-only filter
  would surface archived rows as live. Mirrors the projection of
  ListItemsChangesSince which has always carried this column.

- [P2] `localIndex.bootstrap` now merges into the existing state
  using the same per-row `seq` guard as `upsert`/`applyDelta`, and
  the cursor only advances forward. The previous clear-and-replace
  could regress rows that an in-flight `upsert()` or SSE-driven
  write had landed during the bootstrap request, and could reset
  the cursor below an SSE delta that advanced it concurrently.
  Explicit "drop everything" still flows through `reset()`.

Parent: PLAN-1343.

* fix(web): localIndex.getByCollection sorts updated_at DESC, id ASC (round 5)

[P2] SvelteMap iterates in insertion order; live upserts and applyDelta
writes appended rows / kept stale positions, so consumers reading from
getByCollection saw an order that drifted away from the server's
/items-index documented `updated_at DESC, id ASC`. Sort on read so the
collection page sees a stable, server-aligned order regardless of how
recently a row arrived through the in-RAM index. The cost is O(n log n)
per read; the consumer side is expected to memoize via $derived.

Parent: PLAN-1343.
2026-05-11 18:01:16 -04:00
..