From 9c103965de7e0971449a94461caf5b43ebd86aab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 05:45:50 +0000 Subject: [PATCH] M10: durable Yjs autosave persistence + re-grounded roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boards previously lived only in each client's IndexedDB and the ephemeral Realtime broadcast, so a fresh client or a late-joiner arriving after every peer had left loaded an empty board. The snapshots table already supports a single self-compacting `autosave` row per board (unique partial index + RLS insert/update on public boards), so this wires the missing client loop with no migration and no compaction function: - packages/sync/src/autosave.ts: loadAutosave (Y.applyUpdate merge — not the destructive restoreSnapshot) and startAutosave (debounced writer with a pagehide/visibilitychange flush, update-or-insert), plus shared bytea hex helpers. - shapeStore.initBoard: load then start autosave after IndexedDB + realtime attach, only when a Supabase client is configured (local-only unchanged). - snapshotsApi: reuse the hex helpers from @notux/sync (drop the duplicates). Adds docs/ROADMAP.md with the re-grounded M10–M16 + Living Cosmos design track. Verified: pnpm typecheck + pnpm build pass; encode -> hex bytea -> applyUpdate round-trip reproduces pages/shapes/pageList and is idempotent on re-merge. https://claude.ai/code/session_01QFK6Z1fYzo7XSogUETXnwe --- apps/web/src/features/board/snapshotsApi.ts | 19 +-- docs/ROADMAP.md | 79 +++++++++++++ packages/canvas/src/store/shapeStore.ts | 15 +++ packages/sync/src/autosave.ts | 125 ++++++++++++++++++++ packages/sync/src/index.ts | 6 + 5 files changed, 226 insertions(+), 18 deletions(-) create mode 100644 docs/ROADMAP.md create mode 100644 packages/sync/src/autosave.ts diff --git a/apps/web/src/features/board/snapshotsApi.ts b/apps/web/src/features/board/snapshotsApi.ts index fbf9244..5e730db 100644 --- a/apps/web/src/features/board/snapshotsApi.ts +++ b/apps/web/src/features/board/snapshotsApi.ts @@ -1,4 +1,5 @@ import type { SupabaseClient } from "@supabase/supabase-js"; +import { bytesToHexBytea, hexByteaToBytes } from "@notux/sync"; export interface NamedSnapshot { id: string; @@ -6,24 +7,6 @@ export interface NamedSnapshot { createdAt: string; } -// PostgREST reads/writes a `bytea` column as a hex string in the "\x.." format. -// Encoding the Yjs update as hex avoids needing a server-side base64 decode RPC -// and works against the stock schema. -function bytesToHexBytea(bytes: Uint8Array): string { - let hex = "\\x"; - for (const b of bytes) hex += b.toString(16).padStart(2, "0"); - return hex; -} - -function hexByteaToBytes(s: string): Uint8Array { - const hex = s.startsWith("\\x") ? s.slice(2) : s; - const out = new Uint8Array(hex.length >> 1); - for (let i = 0; i < out.length; i++) { - out[i] = parseInt(hex.substr(i * 2, 2), 16); - } - return out; -} - export async function saveNamedSnapshot( client: SupabaseClient, boardId: string, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..f465b99 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,79 @@ +# NotUX Enhancement Roadmap + +Re-grounded against the live repo (currently at **M9**). The earlier strategy doc assumed +the repo sat at M4; in fact `perfect-freehand`, Yjs + a custom Supabase-broadcast provider, +`y-indexeddb`, PDF/image import, audio + YouTube/gdrive embeds, named snapshots, and +selection/transform/z-order/lock are all already shipped. This roadmap keeps the recommended +direction but re-prioritizes around the genuine, verified gaps. + +Strategic posture is unchanged: keep the Konva + Yjs + Supabase Realtime + Liquid Glass +stack; treat Excalidraw/tldraw/OpenBoard as reference implementations, not dependencies. + +## M10 — Durable Yjs autosave persistence ✅ (this PR) + +**The critical architectural fix.** Board state previously lived only in each client's +IndexedDB and the ephemeral Realtime broadcast — a fresh client (no IndexedDB) or a +late-joiner arriving after every peer had left got an **empty board**. + +The `snapshots` table already supports a single self-compacting `autosave` row per board +(unique partial index + RLS insert/update for public boards), so **no migration and no +compaction Edge Function are needed** — we diverge from the original append-log proposal in +favor of the simpler model the schema already encodes. + +- `packages/sync/src/autosave.ts`: `loadAutosave` (fetch + `Y.applyUpdate` **merge**, not the + destructive `restoreSnapshot`), `startAutosave` (debounced ~2s / max-wait ~10s writer with a + best-effort `pagehide`/`visibilitychange` flush, update-or-insert the autosave row), and the + shared `bytea` hex helpers. +- `packages/canvas/src/store/shapeStore.ts` (`initBoard`): after IndexedDB hydrate + realtime + attach, `loadAutosave` then `startAutosave` — only when a Supabase client is configured + (local-only mode is unchanged). +- `apps/web/src/features/board/snapshotsApi.ts`: reuses the hex helpers from `@notux/sync`. + +*Exit criterion:* a board survives all users leaving; a fresh client and an offline late-joiner +both load the last state and converge. + +## M11 — Native ink feel + +Feature-detect `getCoalescedEvents()` (fall back to plain `pointermove`); add a +`{ desynchronized: true }` context on a **dedicated wet-ink Konva layer** split out of the +shared `OverlayLayer` so only it redraws per move; `pointerType`-based palm rejection in +`PenTool`. Raw points + pressure are already stored — keep that. Tune `strokeGeometry.ts` +`streamline`/`smoothing` for the "hot elbows" artifact. *Exit:* smooth Apple-Pencil strokes on +iPad Safari, crisp at any zoom; only the wet-ink layer redraws during a stroke. + +## M12 — Scale the canvas + +Add `rbush` as a spatial index in `shapeStore` for viewport culling + hit-testing (replaces the +linear scans in `CanvasStage.hitTestWorld` / `rectIntersect`); cull off-screen shapes in +`ShapesLayer`; set Konva `perfectDrawEnabled(false)` + `shadowForStrokeEnabled(false)` on shape +nodes. *Exit:* 60 fps panning a 5,000+ shape board. + +## M13 — Editing polish + +Refine the area eraser toward a scribble-overlay + geometry hit-test feel; auto-sizing stickies; +color picker palette + custom + recents (in Living Cosmos). + +## M14 — Content breadth + +URL bookmarks via a cached `unfurl` Supabase Edge Function (genuine gap). Verify PDF import is +lazily rasterized per page. + +## M15 — Embeds registry + +Generalize the existing YouTube/gdrive embeds into a tldraw-style `EmbedDefinition` registry; +export embeds as placeholders (iframes can't be captured by canvas export). + +## M16 — Cross-platform packaging + +Add `vite-plugin-pwa` + full manifest + icons (genuine gap — `index.html` has only a +`theme-color`); wrap with Capacitor gated by `Capacitor.isNativePlatform()`; macOS as an +installed PWA. Do not introduce React Native/Flutter. + +## Design track — Living Cosmos re-skin + +Replace the Apple-system tokens in `packages/ui/src/styles.css` (`--accent:#5ac8fa`/`#0a84ff`) +with the Living Cosmos palette (midnight `#0a0e1a`, nebula `#3d4fd6`, rain `#4db8c8`, moss, +sandstone), the Inter + IBM Plex Mono fonts, the organic radius grammar (12/16/999/8), and the +`--ease-organic`/`--ease-spring` motion curves; set `index.html` `theme-color` to `#0a0e1a`. +Keep accessibility guards (`prefers-reduced-motion`, focus rings using rain). Only touches the +token layer the components already consume, so it can land early. diff --git a/packages/canvas/src/store/shapeStore.ts b/packages/canvas/src/store/shapeStore.ts index 4b52fbd..e770944 100644 --- a/packages/canvas/src/store/shapeStore.ts +++ b/packages/canvas/src/store/shapeStore.ts @@ -9,6 +9,8 @@ import { getAwareness, findPageMap, getPageMap, + loadAutosave, + startAutosave, LOCAL_ORIGIN, type Awareness, } from "@notux/sync"; @@ -25,6 +27,10 @@ export interface RealtimeConfig { // same init sequence (handles React StrictMode double-invoke). const _initPromises = new Map>(); +// Detaches the durable-autosave listeners for the previously initialised board. +// Stops the old loop before a new board's loop starts when switching boards. +let _stopAutosave: (() => void) | null = null; + interface ShapeStoreState { // Bumps on every Yjs mutation so React selectors re-render. revision: number; @@ -178,6 +184,15 @@ export const useShapeStore = create((set, get) => ({ }); getSupabaseProvider({ client: cfg.client, boardId, doc, awareness }); set({ _awareness: awareness }); + + // Durable persistence: merge the server-side autosave snapshot so a + // fresh client (no IndexedDB) or a late-joiner arriving after every peer + // has left still loads the board, then keep that row up to date. CRDT + // merge means this is safe to run after IndexedDB + realtime attach. + _stopAutosave?.(); + _stopAutosave = null; + await loadAutosave(cfg.client, boardId, doc); + _stopAutosave = startAutosave(cfg.client, boardId, doc); } })(); diff --git a/packages/sync/src/autosave.ts b/packages/sync/src/autosave.ts new file mode 100644 index 0000000..62bf2d6 --- /dev/null +++ b/packages/sync/src/autosave.ts @@ -0,0 +1,125 @@ +import * as Y from "yjs"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { encodeSnapshot } from "./snapshots"; + +// PostgREST reads/writes a `bytea` column as a hex string in the "\x.." format. +// Encoding the Yjs update as hex avoids needing a server-side base64 decode RPC +// and works against the stock schema. Shared with the named-snapshot API in the +// web app so there's a single source for the bytea wire format. +export function bytesToHexBytea(bytes: Uint8Array): string { + let hex = "\\x"; + for (const b of bytes) hex += b.toString(16).padStart(2, "0"); + return hex; +} + +export function hexByteaToBytes(s: string): Uint8Array { + const hex = s.startsWith("\\x") ? s.slice(2) : s; + const out = new Uint8Array(hex.length >> 1); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return out; +} + +// Fetch the board's durable `autosave` snapshot from Postgres and MERGE it into +// the live doc. Uses Y.applyUpdate (a CRDT merge), NOT restoreSnapshot — the +// latter is a destructive time-travel replace used by the named-snapshot panel. +// Merging is order-independent and convergent, so it's safe to call after the +// IndexedDB hydrate and after the realtime provider has attached. This is what +// lets a fresh client (no IndexedDB) or a late-joiner arriving after every peer +// has left still load the last board state. +export async function loadAutosave( + client: SupabaseClient, + boardId: string, + doc: Y.Doc, +): Promise { + const { data, error } = await client + .from("snapshots") + .select("ydoc") + .eq("board_id", boardId) + .eq("kind", "autosave") + .maybeSingle(); + if (error || !data?.ydoc) return; + Y.applyUpdate(doc, hexByteaToBytes(data.ydoc as string)); +} + +// Write the full board state to the single per-board `autosave` row. The schema +// keeps one self-compacting row (Y.encodeStateAsUpdate already compacts), so +// there's no append-log to compact — we update-or-insert in place. PostgREST +// can't upsert onto the partial unique index (board_id where kind='autosave'), +// so we branch: try update first, insert only if no row existed. +async function writeAutosave( + client: SupabaseClient, + boardId: string, + doc: Y.Doc, +): Promise { + const ydoc = bytesToHexBytea(encodeSnapshot(doc)); + const { data, error } = await client + .from("snapshots") + .update({ ydoc }) + .eq("board_id", boardId) + .eq("kind", "autosave") + .select("id"); + if (error) return; + if (!data || data.length === 0) { + await client.from("snapshots").insert({ board_id: boardId, kind: "autosave", ydoc }); + } +} + +const DEBOUNCE_MS = 2_000; +const MAX_WAIT_MS = 10_000; + +// Persist the doc to Postgres on every change, debounced (~2s) with a max-wait +// (~10s) so a long uninterrupted editing burst still flushes periodically. +// Best-effort flush on pagehide / tab-hide so the last edits aren't lost on +// close. Returns a stop() that detaches all listeners and timers. +export function startAutosave( + client: SupabaseClient, + boardId: string, + doc: Y.Doc, +): () => void { + let debounceTimer: ReturnType | null = null; + let firstChangeAt: number | null = null; + let stopped = false; + + const flush = () => { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + firstChangeAt = null; + if (!stopped) void writeAutosave(client, boardId, doc); + }; + + const onUpdate = () => { + const now = Date.now(); + if (firstChangeAt === null) firstChangeAt = now; + if (now - firstChangeAt >= MAX_WAIT_MS) { + flush(); + return; + } + if (debounceTimer !== null) clearTimeout(debounceTimer); + debounceTimer = setTimeout(flush, DEBOUNCE_MS); + }; + + const onHide = () => { + if (document.visibilityState === "hidden") flush(); + }; + + doc.on("update", onUpdate); + const hasWindow = typeof window !== "undefined"; + if (hasWindow) { + window.addEventListener("pagehide", flush); + document.addEventListener("visibilitychange", onHide); + } + + return () => { + stopped = true; + if (debounceTimer !== null) clearTimeout(debounceTimer); + doc.off("update", onUpdate); + if (hasWindow) { + window.removeEventListener("pagehide", flush); + document.removeEventListener("visibilitychange", onHide); + } + }; +} diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 2170373..466d65c 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -20,4 +20,10 @@ export type { SupabaseProviderOptions } from "./supabaseProvider"; export { colorForSeed } from "./identity"; export { LOCAL_ORIGIN } from "./origin"; export { encodeSnapshot, restoreSnapshot } from "./snapshots"; +export { + loadAutosave, + startAutosave, + bytesToHexBytea, + hexByteaToBytes, +} from "./autosave"; export { Awareness } from "y-protocols/awareness";