feat(board): whole-board offline materialization for synced boards (PR-1: phases A+B) - #191
Merged
Conversation
…se 1) Synced (v2) boards applied the server welcome snapshot in-memory only and discarded it, so offline (or before the welcome) the board rendered empty and the local store held only deltas, not the base. Phase 1 — the first-connect case: - BoardPersistence.writeInitialBase(content): seed the base from the welcome, but ONLY on a pristine replica (no snapshot row, no oplog, nothing pending) so writeSnapshot(content, 0) truncates nothing and later local/remote ops replay on top (unacked locals stay in the outbox). No-ops on reconnect drift or once edits exist — replacing a base mid-session needs serverSeq-based truncation (a follow-up). - snapshot-load.graphToContent(graph): server Graph -> BoardContent via a throwaway store (server-only base, no local edits) — what writeInitialBase persists. - use-board-sync-v2: paint the store from the local replica on load (before attach, as a remote batch) so offline boards aren't blank; and in onSnapshot, after the merge, writeInitialBase(graphToContent(graph)). Legacy (non-v2) synced boards keep no local replica (out of scope; v2 retires them). Tests cover the seed+offline-load, both guards (drift, existing edits), and base+delta convergence.
Document the new behavior where the durable fact lives (DRY): - offline-first-data-model.md: new persistence invariant — v2 boards seed the base from the welcome via writeInitialBase, pristine-replica only, drift no-op. - ADR-SYNC-001: one-line consequence + pointer to the data-model doc. - roadmap.md: log Phase 2 (drift-replace, serverSeq-based truncation) + that it unblocks local-sourced synced reads. - use-board-sync-v2 header: correct the now-stale 'base re-hydrates each load / offline-first is a follow-up' comment.
- Empty welcome no longer locks the offline base empty: a {} welcome
(graphToContent → emptyContent, the DB-hiccup case) is skipped, so a real
later welcome still seeds the base. (Was: wrote an empty snapshot, then every
real welcome no-op'd forever.)
- writeInitialBase now takes a thunk, so the graph→content replay runs only when
it actually writes — not on every reconnect welcome (it no-ops after the first).
- Drain the append queue before the pristine check so this.seq is current (closes
a benign TOCTOU vs an in-flight append).
- Caller .catch()es the fire-and-forget call so a teardown race (engine closed
mid-flight) can't raise an unhandled rejection.
- Paint from the local replica unconditionally (like the local branch): a no-op
on empty content, and restores groups/frame layout, not just nodes/edges.
Added a test for the empty-welcome guard.
Supersedes the per-layer welcome-seed: the welcome snapshot is a single folder
layer, so seeding it left subboards blank offline. Now a synced board's ENTIRE
graph is persisted on first open.
Phase A (backend):
- GraphStore.get_graph(all_layers=False): skip the per-layer node/edge filter and
return the whole board when set (the Qdrant query already fetches whole-board).
- GET /boards/{id}?whole=true → all_layers, ignoring root_id.
Phase B (frontend):
- getWholeBoard(boardId): fetch all layers in one call.
- materializeBoardOffline(boardId): whole-board fetch → graphToContent →
writeInitialBase; snapshot-exists fast path skips the fetch; load() first so
the pristine guard is accurate. Reused by first-open + (later) on-demand download.
- use-board-sync-v2: seed via materializeBoardOffline on first open; drop the
per-layer onSnapshot seed; keep the load()->paint.
writeInitialBase / graphToContent primitives + tests carry over from the earlier
work. Tests: whole-board endpoint wiring (?whole -> all_layers, root_id ignored);
materialize persists all layers + is idempotent. Docs updated to whole-board.
- writeInitialBase returns whether it wrote; materializeBoardOffline bails (no network fetch) if a snapshot exists OR the oplog is non-empty, and returns writeInitialBase's bool — so 'download for offline' can't report success on a no-op (rare oplog-but-no-snapshot case) or waste a whole-board fetch. - Reuse the coordinator's mounted persistence instead of opening a second BoardPersistence on the same board/engine — one writer, its append-queue serializes with the seed. Transient instance only for the headless download. Tests: materialize skips (no fetch) on a non-pristine replica + returns false on an empty whole-board graph.
This was referenced Aug 5, 2026
…acked on PR-1 (#192) * feat(board): offline-availability marker + on-demand download (PR-2, phase C) Synced boards now show whether they're available offline and let you download them on demand — the visible half of the whole-board offline base (PR-1). - useBoardOfflineStatus(boardId): cheap snapshots-row check, keyed per board. - useDownloadBoard(): materializeBoardOffline (whole board) + invalidate the status so the marker flips. isBoardAvailableOffline exported for testing. - Sidebar BoardItem: BoardOfflineAction trailing icon — CloudCheck when offline-ready, CloudArrowDown to download (pulses while downloading). Label padding widened for the two trailing actions. - Dashboard synced cards: same marker/download next to the kind badge. - Coordinator invalidates the status after the auto-seed on open, so opening a board flips its marker to ready immediately. - New CloudArrowDownIcon. Test: isBoardAvailableOffline flips false→true when a snapshot exists. Note: sidebar two-action positioning (right-8 / pr-14) is a reasonable default — tune visually on-device. * fix(board): address offline-marker review — empty boards, flicker, dedup, feedback - writeInitialBase: drop the empty-content guard (its hiccup-welcome rationale is obsolete now seeding goes through the authoritative ?whole=true fetch), so a genuinely empty synced board writes an empty base and its offline marker can flip instead of the download button being permanently dead - offline marker: render nothing while the status query is still resolving, so an already-downloaded board doesn't flash the download icon on cold cache - materializeBoardOffline: share one in-flight run per board, so the coordinator auto-seed and an on-demand download can't double-fetch the whole board - useDownloadBoard: toast on error so a failed (offline) download isn't silent - use-board-sync-v2: hold queryClient in a ref so its identity isn't in the mount effect deps (a provider remount no longer tears down the coordinator) * feat(connection): board-aware offline overlay + top-right connection indicator Completes the offline UX on top of the per-board marker: - ConnectionIndicator: a non-blocking top-right 'Offline' badge, shown whenever the connection detector reports offline (self-gating; online by default until the authed-only detector says otherwise) - OfflineOverlay is now board-aware: on a synced board already available offline it renders nothing (the local replica keeps the app usable — the indicator carries the signal); it only keeps the blocking modal when the view needs the server — an undownloaded board (adapted 'isn't available offline' copy) or a non-board route (generic 'can't reach the server') - root-layout parses the open board id from the path and feeds the overlay - adds WifiSlashIcon Signal stays HTTP-failure + browser-offline (v2 WS-close is intentionally not wired into the app-wide detector). * fix(backend): fetch the whole board without the 1000-node filt cap get_graph's node + link fetches used filt's default limit=1000 (with order_by set, that caps at a single 1000-row page). For all_layers=True this feeds the offline base, so a board with >1000 nodes/links would seed a silently truncated base and still be marked 'available offline'. Size each fetch to count() and drop the ordering (irrelevant for materialization) so the scroll pages to exhaustion. Also fixes the pre-existing per-layer path, which capped total board nodes at 1000 before filtering to the layer. Documents two serverSeq-model gaps from the review (peer-op-race auto-seed, snapshot-row-vs-complete-base marker) as known follow-ups in the roadmap. * feat(sidebar): live surface-tree from local store (local + offline synced) (#193) * feat(sidebar): live surface-tree from local store for local + offline synced boards The sidebar hierarchy didn't reflect canvas edits: local boards had no invalidation at all, and synced boards read the REST /contents endpoint (per- level, 5-min cache) with only partial rename patching and nothing for create — so a new sheet never appeared and rename/icon lagged until re-expand. Unify the source: both local and offline-available synced boards project the tree from the on-device store (`useLocalBoardContents`), and `useSidebarContentsSync` invalidates `[localBoardContents, boardId]` (debounced past the persistence flush) on a surface-relevant op — create / delete / rename / re-icon / move. Reading the local store means no server round-trip that could race persistence. - BoardItem: gate the tree on offline-availability — a synced board with no local base yet shows 'Download for offline to view contents' rather than a server-backed tree (aligns with the offline marker) - BoardTreeNode: `local` now controls navigation routing only; data always comes from the flat `treeContents` (drops the per-level REST fetch + loading state) * fix(sidebar): address tree-live review — surface-kind deletes, flush race, stale docs - affectsSurfaceTree: node.remove carries the full node (not just an id), so filter it by kind like node.add — deleting a sticky/shape/image no longer triggers a whole-board snapshot+oplog re-read; fix the wrong-shaped test fixture that masked this - useSidebarContentsSync: chain the re-read to the persistence flush() instead of a fixed 250ms margin, so the fresh load always reflects the committed edit even under a slow/contended IndexedDB write - refresh the useLocalBoardContents docstring (invalidation is now wired) and note useBoardContents + the boardContents REST path as dead/removable; log the cleanup + open-board live-store read as roadmap follow-ups
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Evolves #191 (the per-layer welcome-seed) into the correct whole-board offline base. First step of the offline-availability work (per-board · on-demand · smart overlay). Follow-ups: PR-2 (offline marker + on-demand download), PR-3 (connection indicator + smart overlay).
Problem
The server welcome snapshot is a single folder layer (even
root_id=null= root layer only; there was no whole-board endpoint). So the earlier "seed the welcome" approach only made the opened layer offline — navigate into a subboard offline → blank.Phase A — backend whole-board fetch
GraphStore.get_graph(…, all_layers=False)— skip the per-layer node/edge Python filter and return the entire board when set (the Qdrant query already fetches whole-board; it was just being filtered out).GET /boards/{id}?whole=true→all_layers=True, ignoringroot_id.Phase B — frontend whole-board materialize
getWholeBoard(boardId)→ all layers in one call.materializeBoardOffline(boardId): whole-board fetch →graphToContent→writeInitialBase. Fast-path skips the fetch if a base already exists;load()s first sowriteInitialBase's pristine guard is accurate. Reused by first-open and (PR-2) the on-demand download.use-board-sync-v2: seed viamaterializeBoardOfflineon first open; drop the per-layeronSnapshotseed; keep the local-replica paint.writeInitialBase/graphToContentprimitives + their guards (empty-welcome, drift, TOCTOU) carry over.Tests
?whole=true→get_graph(all_layers=True, root_id ignored); default stays scoped.materializeBoardOfflinepersists all layers + is idempotent (skips when a base exists); the existingwriteInitialBasecases.Docs
offline-first-data-model.md,ADR-SYNC-001,roadmap.mdupdated to the whole-board model.