mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 18:13:26 +00:00
13fd9bbda7
* 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.
61 lines
1.7 KiB
JSON
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"
|
|
}
|
|
}
|