Files
pad/web/package.json
T
xarmian 13fd9bbda7 feat(web): IndexedDB persistence for localIndex (TASK-1356) (#503)
* feat(web): IndexedDB persistence for localIndex (TASK-1356)

Adds `web/src/lib/stores/localIndexPersistence.ts` and wires it into
the existing localIndex store. Cold loads still hit /items-index;
warm loads paint from IDB before any network IO.

- New `idb` (8.0.3) dependency — small wrapper around IndexedDB.
- Per-workspace database `pad-local-index-{wsSlug}` with two object
  stores (`items` keyed by id, `meta` keyed by 'key' for cursor +
  schemaVersion).
- `LOCAL_INDEX_SCHEMA_VERSION = 1` — bump it on incompatible changes
  to `ItemIndexRow` or the IDB layout; mismatches drop the store and
  force a full /items-index resync. Same pattern as the Yjs
  schemaVersion in `web/src/lib/collab/schemaVersion.ts`.
- `bootstrap` now hydrates from IDB FIRST (paints from cache, flips
  state to 'ready'), then reconciles via /items-changes?since=cursor
  in the background. Cold cache falls through to /items-index and
  persists the result.
- Every mutation path (`upsert`, `applyDelta`, `remove`, `reset`)
  writes through to IDB. Persistence failures degrade silently to
  in-memory only — the read path is never blocked.
- SSR-safe (every IDB call gated on typeof indexedDB !== 'undefined').
- Best-effort: Safari private mode / quota / eviction all surface
  as "empty cache, re-bootstrap from network".

Phase 2 acceptance: warm paint of a populated workspace should now
appear before any /api/v1 request completes.

Parent: PLAN-1343. See DOC-1342 design decision #4.

* fix(web): localIndex reconcile loop + atomic delta persist (Codex round 1)

- [P1] 403 from the warm-load reconcile is no longer swallowed.
  When /items-changes returns `forbidden`, drop the cache and
  re-throw so the registered access-revoked handler (TASK-1360)
  sees it. Other network blips remain non-fatal — cache stands
  and the next reconnect retries.

- [P2] /items-changes is paged at DefaultItemChangesLimit (5000)
  per response. The previous one-shot reconcile would only catch
  up by a single page on a long-offline cache, and bootstrapState
  would pin at 'ready' forever with no later trigger to fetch the
  rest. Loop until the cursor stops advancing; defensively cap at
  50 iterations.

- [P2] New persistDelta() writes rows + meta cursor in a SINGLE
  IDB transaction. The previous separate persistUpserts + persistCursor
  could persist the cursor without the rows that produced it
  (tx interrupt, eviction), leaving the next warm hydrate with a
  cursor that skipped rows. applyDelta + the cold-path bootstrap
  snapshot now both use persistDelta. The standalone persistCursor
  helper is no longer used by localIndex but remains for callers
  that explicitly only need a cursor write.

Parent: PLAN-1343.

* fix(web): cold-path snapshot persists post-merge rows (Codex round 2)

[P1] When the cold-path `/items-index` request is in flight, an SSE
or `applyDelta` write can overlap and stamp a newer row into the
in-RAM index. `mergeRow` correctly skips the stale response row for
that id, but the previous IDB write used `resp.items.map(toSkinny)`
— the unfiltered server response — so the cache got the stale row
under the newer cursor. On the next warm boot, /items-changes?since
would skip that row forever.

Persist the POST-merge in-memory state (state.items.values()) so
the on-disk rows match the in-RAM rows that won the seq guard, and
the cursor stays consistent with them. Uses the same atomic
persistDelta path applyDelta does.

Parent: PLAN-1343.

* fix(web): generation guard on bootstrap + user-scoped IDB cache (round 3)

- [P1] WorkspaceState now carries a `generation` counter. Each
  `reset(ws)` bumps it on the prior state object before dropping
  the workspace from the map. Any in-flight bootstrap captures the
  generation at start and re-checks after every await — if the
  generation has advanced, the bootstrap silently bails out before
  reapplying rows or writing the snapshot to IDB. Without this, a
  sign-out / 403 purge during a slow /items-index could let the
  completed snapshot resurrect just-purged rows.

- [P1] IDB databases are now keyed by (userId, workspaceSlug)
  instead of workspaceSlug alone. The cache is "what THIS user could
  see last sync" — if a different user signs into the same browser,
  their bootstrap opens a fresh per-user namespace and the previous
  user's rows never surface. Anonymous callers (pre-auth) use the
  `anon` namespace. `localIndex.bootstrap` takes an `{ userId }`
  opt the caller passes in (the workspace state captures it on
  first bootstrap and threads it through every persistence call).

Parent: PLAN-1343.

* fix(web): durable applyDelta + required userId opt (Codex round 4)

- applyDelta now ALWAYS includes the existing in-RAM row in the
  persistDelta batch when it wins the seq guard. The previous version
  advanced the IDB cursor past those rows on the assumption their
  upsert()-fired persistUpserts had already landed — but that's a
  fire-and-forget background write that can lose the race or get
  aborted. Result: a row in RAM with seq S, no copy in IDB, and a
  persisted cursor of N > S — warm boot would skip it forever. One
  extra IDB put per redundant row trades cheaply against a missing-
  row class of bug.

- localIndex.bootstrap's `opts.userId` is now REQUIRED (not optional
  with `null` default). Authenticated callers that forget to pass
  it would have silently landed their cache in the shared `anon`
  namespace — a later account on the same browser could then read
  the previous account's rows. TypeScript now enforces an explicit
  choice; pre-auth callers pass null deliberately.

Parent: PLAN-1343.

* fix(web): user-mismatch reset + transient resync retry (Codex round 5)

- [P1] bootstrap() now resets the workspace state BEFORE the
  early-return for 'ready' / pending-promise when the caller's
  opts.userId doesn't match the cached state.userId. Without this,
  a user switch in the same tab could inherit the previous user's
  in-memory map and in-flight promise.

- [P2] WorkspaceState.pendingResync tracks transient delta-sync
  failures on the warm path. When /items-changes fails (non-403)
  after warm cache hydrate, bootstrapState stays 'ready' so the
  UI keeps working off the cache, but pendingResync stays true and
  the next bootstrap() call retries the reconcile instead of
  no-opping. Cold path always finishes with pendingResync=false.

- Documented the permission-revocation-without-row-change limitation
  inline: the cache can't see grants removed without a mutation,
  per DOC-1342 design decision #3 — that's the 403-on-click purge
  flow (TASK-1360), not this layer's job.

Parent: PLAN-1343.

* fix(web): only clear pendingResync when reconcile catches up (round 6)

[P2] The /items-changes reconcile loop has a 50-page safety cap to
prevent pathological tight loops. The previous code unconditionally
cleared `pendingResync` after the loop exited, even on cap-hit, so
a cache that's 50+ pages behind (250k+ rows) would record itself as
fresh and skip retries on future bootstraps. Now `pendingResync` is
only cleared when the loop exited because the server returned no new
rows AND no cursor advance — the genuine "caught up" signal. Cap-hit
leaves `pendingResync = true` so the next bootstrap call resumes.

The permission-revocation-without-row-change concern Codex re-raised
is the explicit DOC-1342 design decision #3 (best-effort cache; 403
purge handles stale-by-permission). The server emits grant-revocation
tombstones through /items-changes per internal/store/grants.go, so the
cache reconciles to-server-truth at the next reconnect. Anything that
slips past that is the 403-on-click purge path (TASK-1360). The
limitation is now explicitly noted inline.

Parent: PLAN-1343.

* fix(web): reentry order + identity-checked inflight cleanup (round 7)

- [P2] Capture `reentry` BEFORE flipping bootstrapState to 'loading'.
  The previous version set state='loading' first, then checked
  `state.bootstrapState === 'ready'` to decide if we're in a
  pendingResync retry — that check always read false, so retries
  re-read IDB instead of just rerunning the reconcile. With
  fire-and-forget IDB writes, re-reading rows whose RAM copy was
  just removed but whose IDB delete hadn't landed would resurrect
  them. Reentry now also skips the 'loading' flip so the UI never
  blanks during a retry.

- [P2] Inflight cleanup is now identity-checked. A `reset()` during
  an in-flight bootstrap can let a fresh bootstrap call re-occupy
  the inflight slot before the stale promise's `finally` runs;
  deleting unconditionally would remove the new entry and let a
  duplicate bootstrap start. We hold the promise in `slot.p` (a
  shared object so the closure can see assignment without TDZ
  issues), and only clear `inflight.delete(ws)` if `slot.p` is
  still the registered promise.

Parent: PLAN-1343.

* fix(web): 401 + empty-cache warm-load (Codex round 8)

- [P1] 401 (unauthorized) from /items-changes reconcile is now
  treated like 403 (forbidden): drop the cache, mark state=error,
  re-throw. The api.items.changes path throws PadApiError with
  code='unauthorized' on 401 (after the redirect-to-login is fired),
  so the cache shouldn't keep showing private rows while the
  redirect is in flight. Other network failures remain transient.

- [P2] A populated IDB cache is now defined as "has rows OR cursor
  > 0", not "has rows". An empty workspace (or a guest with
  item-level grants but no items granted yet) legitimately has zero
  rows but a real meta cursor from the prior sync. The previous
  check forced those workspaces through the cold /items-index path
  on every page load, defeating the warm-load fast path.

Parent: PLAN-1343.
2026-05-11 19:30:07 -04:00

61 lines
1.7 KiB
JSON

{
"name": "web",
"private": true,
"version": "0.0.1",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install --with-deps chromium"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.59.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/qrcode": "^1.5.6",
"marked": "^18.0.3",
"svelte": "^5.55.5",
"svelte-check": "^4.4.7",
"typescript": "^6.0.3",
"vite": "^8.0.11"
},
"overrides": {
"cookie": "^0.7.2"
},
"dependencies": {
"@tiptap/core": "^3.22.5",
"@tiptap/extension-bubble-menu": "^3.22.5",
"@tiptap/extension-code-block-lowlight": "^3.22.5",
"@tiptap/extension-collaboration": "3.22.5",
"@tiptap/extension-collaboration-caret": "3.22.5",
"@tiptap/extension-link": "^3.22.5",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-table": "^3.22.5",
"@tiptap/extension-task-item": "^3.22.5",
"@tiptap/extension-task-list": "^3.22.5",
"@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.22.5",
"@tiptap/suggestion": "^3.22.5",
"@tiptap/y-tiptap": "^3.0.3",
"diff": "^9.0.0",
"dompurify": "^3.4.2",
"idb": "^8.0.3",
"lowlight": "^3.3.0",
"mermaid": "^11.14.0",
"qrcode": "^1.5.4",
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0",
"y-protocols": "^1.0.7",
"yjs": "^13.6.30"
}
}