Skip to content

feat(board): whole-board offline materialization for synced boards (PR-1: phases A+B) - #191

Merged
winlp4ever merged 7 commits into
mainfrom
feat/synced-offline-base
Aug 6, 2026
Merged

feat(board): whole-board offline materialization for synced boards (PR-1: phases A+B)#191
winlp4ever merged 7 commits into
mainfrom
feat/synced-offline-base

Conversation

@winlp4ever

@winlp4ever winlp4ever commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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=trueall_layers=True, ignoring root_id.

Phase B — frontend whole-board materialize

  • getWholeBoard(boardId) → all layers in one call.
  • materializeBoardOffline(boardId): whole-board fetch → graphToContentwriteInitialBase. Fast-path skips the fetch if a base already exists; load()s first so writeInitialBase's pristine guard is accurate. Reused by first-open and (PR-2) the on-demand download.
  • use-board-sync-v2: seed via materializeBoardOffline on first open; drop the per-layer onSnapshot seed; keep the local-replica paint.

writeInitialBase / graphToContent primitives + their guards (empty-welcome, drift, TOCTOU) carry over.

Tests

  • Backend: ?whole=trueget_graph(all_layers=True, root_id ignored); default stays scoped.
  • Frontend: materializeBoardOffline persists all layers + is idempotent (skips when a base exists); the existing writeInitialBase cases.
  • Full suites green: frontend 1110, backend 686, both builds.

Docs

offline-first-data-model.md, ADR-SYNC-001, roadmap.md updated to the whole-board model.

…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.
@winlp4ever winlp4ever changed the title feat(board): persist synced-board base locally for offline reads (phase 1) feat(board): whole-board offline materialization for synced boards (PR-1: phases A+B) Aug 5, 2026
- 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.
…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
@winlp4ever
winlp4ever merged commit 96af632 into main Aug 6, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant