Moodboard: rebuild sync as gated, per-account replication

The first cut was a naive fetch-and-push that raced its own hydration:
an async server-wins pull could clobber edits made while it loaded, a
just-reloaded (empty) canvas could overwrite the server document, a
module one-shot flag let a second account in the same page load keep
writing to the first account's rows, failed saves silently fell out of
the dirty set, and canvas edits were attributed to whichever board was
active at flush time.

Sync is now replication between durable replicas:

- The canvas persists locally per board (localStorage, blob-stripped),
  ending the 'empty because it just loaded' state entirely - reloads
  restore the canvas even logged out, and there is no window where a
  hollow canvas can masquerade as user intent.
- Saves are gated behind a hydration state machine (idle/hydrating/
  ready); nothing pushes from a partially loaded session.
- Dirty state is per board and durable (Board.needsSync persists), so
  closed tabs resume their pending push next session; pagehide flushes
  flags and replica synchronously.
- Hydration is dirty-aware last-write-wins - clean boards take the
  server copy, dirty boards keep local content and push it - with
  per-board error isolation and bounded concurrency.
- Sync is account-scoped: hydration re-runs per user id, and boards
  stamped with another account's ownerId are never pushed (localStorage
  is machine-shared; server rows must not be). The adapter seam now
  exposes getUserId/subscribeAuthState instead of a boolean.
- Failed pushes stay dirty and retry with exponential backoff; deletes
  are tombstoned so offline deletes can't resurrect on next hydration;
  save status reflects real dirty state (never 'Saved' with edits
  pending).
- Media whose token no longer resolves renders a visible placeholder
  that is never persisted, so resolution self-heals on every load.
- Grid multi-image drops place all tiles immediately again (uploads
  moved to background write-back), and canvas edits are attributed to
  the board that owned the canvas at edit time.

Webapp adapter: session user token as the account id, and the raw
document/delete/resolve fetches wrapped so a network rejection degrades
to a per-board failure instead of aborting hydration.
This commit is contained in:
Tanish Manku
2026-07-20 18:02:10 -07:00
parent ab9a6dbc9b
commit 1abfc3457a
9 changed files with 758 additions and 191 deletions
@@ -107,8 +107,8 @@ const WebappLibraryPicker = ({
// mood_board project (multipart JSON upload; save-new returns the media file
// token that links the board to its server row).
const persistence: MoodboardPersistenceAdapter = {
isLoggedIn: () => Boolean(useSessionStore.getState().user),
subscribeLoginState: (onChange) => useSessionStore.subscribe(onChange),
getUserId: () => useSessionStore.getState().user?.user_token ?? null,
subscribeAuthState: (onChange) => useSessionStore.subscribe(onChange),
saveBoard: async ({ token, name, documentJson }) => {
const api = new ProjectsApi();
@@ -154,38 +154,56 @@ const persistence: MoodboardPersistenceAdapter = {
};
},
// The adapter contract is non-throwing; the raw fetch()es here reject on
// network failure, so wrap them — an escaped rejection would tear down
// the caller's whole hydration pass, not just this board.
loadBoard: async (token) => {
const response = await new MediaFilesApi().GetMediaFileByToken({
mediaFileToken: token,
});
const cdnUrl = response.success
? response.data?.media_links?.cdn_url
: undefined;
if (!cdnUrl) return { success: false };
const documentResponse = await fetch(cdnUrl);
if (!documentResponse.ok) return { success: false };
return { success: true, documentJson: await documentResponse.text() };
try {
const response = await new MediaFilesApi().GetMediaFileByToken({
mediaFileToken: token,
});
const cdnUrl = response.success
? response.data?.media_links?.cdn_url
: undefined;
if (!cdnUrl) return { success: false };
const documentResponse = await fetch(cdnUrl);
if (!documentResponse.ok) return { success: false };
return { success: true, documentJson: await documentResponse.text() };
} catch (error) {
console.error("[Moodboard] board document fetch failed:", error);
return { success: false };
}
},
deleteBoard: async (token) => {
const response = await new MediaFilesApi().DeleteMediaFileByToken({
mediaFileToken: token,
asMod: false,
});
return response.success;
try {
const response = await new MediaFilesApi().DeleteMediaFileByToken({
mediaFileToken: token,
asMod: false,
});
return response.success;
} catch (error) {
console.error("[Moodboard] board delete failed:", error);
return false;
}
},
resolveMediaUrls: async (tokens) => {
const response = await new MediaFilesApi().ListMediaFilesByTokens({
mediaTokens: tokens,
});
const urlByToken: Record<string, string> = {};
for (const media of response.data ?? []) {
if (media.media_links?.cdn_url) {
urlByToken[media.token] = media.media_links.cdn_url;
try {
const response = await new MediaFilesApi().ListMediaFilesByTokens({
mediaTokens: tokens,
});
const urlByToken: Record<string, string> = {};
for (const media of response.data ?? []) {
if (media.media_links?.cdn_url) {
urlByToken[media.token] = media.media_links.cdn_url;
}
}
return urlByToken;
} catch (error) {
console.error("[Moodboard] media URL resolution failed:", error);
return {};
}
return urlByToken;
},
};
@@ -32,14 +32,18 @@ export interface RemoteBoardMeta {
// Server persistence seam for boards. Each board maps to one mood_board
// project document on the backend; `saveBoard` creates the row when `token`
// is null and overwrites it otherwise. All members are promise-based and
// non-throwing (failures come back as { success: false }).
// MUST NOT throw (failures come back as { success: false } / empty results)
// — the sync layer treats a rejection as an implementation bug.
export interface MoodboardPersistenceAdapter {
/** Gate: remote sync only runs for signed-in users. */
isLoggedIn: () => boolean;
/** Notify when the login state may have changed (session fetch resolving,
* logout). Returns an unsubscribe. Without this, a session that resolves
* after the workspace mounts would never enable sync. */
subscribeLoginState?: (onChange: () => void) => () => void;
/** Stable id of the signed-in account, or null when logged out. Sync only
* runs for signed-in users, hydration is keyed per account, and boards
* are stamped with this id to block cross-account pushes. */
getUserId: () => string | null;
/** Notify when the auth state may have changed (session fetch resolving,
* logout, account switch). Returns an unsubscribe. Without this, a
* session that resolves after mount would never enable sync, and an
* account switch would keep syncing as the previous user. */
subscribeAuthState?: (onChange: () => void) => () => void;
saveBoard: (args: {
token: string | null;
name: string;
@@ -44,12 +44,18 @@ interface BoardLibraryState extends PersistedLibrary {
// Remote sync bookkeeping (used by the persistence layer, not views).
setBoardRemoteToken: (id: string, token: string) => void;
// Flag local edits pending a server push (no-op if already flagged, so
// repeat edits don't churn board identity).
markBoardNeedsSync: (id: string) => void;
// Record a completed push/pull: clears needsSync and stamps ownership.
markBoardSynced: (id: string, args: { ownerId: string }) => void;
// Insert a remotely loaded board, or replace the content of the local
// board already linked to the token. Does not change the active board.
// Returns the (existing or new) local board id.
upsertRemoteBoard: (args: {
token: string;
name: string;
ownerId: string;
itemOrder: string[];
items: Record<string, BoardItem>;
sections: Board["sections"];
@@ -205,7 +211,38 @@ export const useBoardLibraryStore = create<BoardLibraryState>()(
});
},
upsertRemoteBoard: ({ token, name, itemOrder, items, sections }) => {
markBoardNeedsSync: (id) => {
set((s) => {
const board = s.boards[id];
if (!board || board.needsSync === true) return s;
return {
boards: { ...s.boards, [id]: { ...board, needsSync: true } },
};
});
},
markBoardSynced: (id, { ownerId }) => {
set((s) => {
const board = s.boards[id];
if (!board) return s;
if (board.needsSync !== true && board.ownerId === ownerId) return s;
return {
boards: {
...s.boards,
[id]: { ...board, needsSync: false, ownerId },
},
};
});
},
upsertRemoteBoard: ({
token,
name,
ownerId,
itemOrder,
items,
sections,
}) => {
const existing = Object.values(get().boards).find(
(b) => b.remoteToken === token,
);
@@ -219,6 +256,8 @@ export const useBoardLibraryStore = create<BoardLibraryState>()(
itemOrder,
items,
sections,
ownerId,
needsSync: false,
updatedAt: nextSeq(),
},
},
@@ -237,6 +276,8 @@ export const useBoardLibraryStore = create<BoardLibraryState>()(
items,
sections,
remoteToken: token,
ownerId,
needsSync: false,
};
set((s) => ({
boards: { ...s.boards, [id]: board },
@@ -86,6 +86,15 @@ export interface Board {
// set by the first successful remote save. Absent/null for local-only
// boards and for library payloads persisted before remote sync existed.
remoteToken?: string | null;
// User id of the account this board syncs to, stamped at hydration/first
// push. localStorage is machine-shared, so the sync layer refuses to push
// a board owned by a different account (cross-account write protection).
// Absent/null = unowned local board, adoptable by whoever pushes it first.
ownerId?: string | null;
// Durable dirty flag: true when local edits haven't reached the server
// yet. Persisted so a closed tab resumes its pending push next session,
// and so hydration knows local content is newer than the server copy.
needsSync?: boolean;
}
// Nominal aspect ratios for non-media items, so masonry stays deterministic.
@@ -45,8 +45,10 @@ export const useBoardItemEntry = (
return id;
}, []);
// Place the image immediately from a blob, then (if the platform supports it)
// upload to capture a durable mediaToken so it becomes reference-capable.
// Place the image immediately from a blob; the upload that captures a
// durable mediaToken runs in the background with a write-back, so a
// multi-file add/drop places every tile at once instead of gating each
// placement behind the previous file's upload roundtrip.
const addImageFile = useCallback(
async (file: File) => {
const store = useBoardLibraryStore.getState();
@@ -60,15 +62,19 @@ export const useBoardItemEntry = (
naturalH: dims.h,
});
if (!adapter.uploadImage) return;
try {
const token = await adapter.uploadImage(file);
if (token) {
adapter.uploadImage(file).then(
(token) => {
if (!token) return;
const current = useBoardLibraryStore.getState();
// The item may have been deleted while the upload was in flight.
if (!current.boards[boardId]?.items[itemId]) return;
const patch: Partial<BoardImageItem> = { mediaId: token };
useBoardLibraryStore.getState().updateItem(boardId, itemId, patch);
}
} catch (err) {
console.error("[Moodboard] upload failed", err);
}
current.updateItem(boardId, itemId, patch);
},
(err) => {
console.error("[Moodboard] upload failed", err);
},
);
},
[adapter],
);
@@ -4,15 +4,29 @@ import { useBoardLibraryStore } from "../boards/BoardLibraryStore";
import { withDirtySuppressed } from "./dirtySuppression";
import {
EMPTY_CANVAS_DOCUMENT,
persistableCanvas,
type MoodboardCanvasDocument,
} from "./documents";
// The Konva canvas store is a single global surface with no board concept.
// This bridge keys canvas documents by board id (in memory) so switching
// boards swaps the canvas, and the sync layer can save/restore each board's
// canvas alongside its grid model.
// This bridge keys canvas documents by board id and mirrors them to
// localStorage, making the canvas a durable local replica just like the
// grid model (which the board store already persists):
// - switching boards swaps the live canvas through the map;
// - a reload restores each board's canvas instead of starting empty — so
// there is never a window where an "empty because it just loaded" canvas
// can masquerade as user intent and overwrite real content anywhere;
// - the sync layer serializes map entries, never a maybe-stale live view.
// Blob-URL nodes are stripped at the storage boundary (object URLs die with
// the page); the in-memory map keeps them so within-session switches are
// lossless.
const CANVAS_STORAGE_KEY = "artcraft_moodboard_canvas_v1";
const PERSIST_DEBOUNCE_MS = 500;
const canvasByBoard = new Map<string, MoodboardCanvasDocument>();
let loadedFromStorage = false;
let persistTimer: ReturnType<typeof setTimeout> | null = null;
export function captureCanvasDocument(): MoodboardCanvasDocument {
const state = useMoodboardStore.getState();
@@ -51,17 +65,37 @@ export function setCanvasForBoard(
boardId: string,
document: MoodboardCanvasDocument,
): void {
ensureLoaded();
canvasByBoard.set(boardId, document);
schedulePersist();
}
export function getCanvasForBoard(
boardId: string,
): MoodboardCanvasDocument | null {
ensureLoaded();
return canvasByBoard.get(boardId) ?? null;
}
export function dropCanvasForBoard(boardId: string): void {
ensureLoaded();
canvasByBoard.delete(boardId);
schedulePersist();
}
// Stash the live canvas under the given board id (the owner of what's on
// screen). Called by the sync layer whenever the canvas changes, and by the
// switch flow before swapping boards.
export function stashLiveCanvas(boardId: string): void {
setCanvasForBoard(boardId, captureCanvasDocument());
}
// Restore the live canvas from the replica on page load. Applied once by
// the workspace mount; a no-op when the board has no stored canvas.
export function restoreLiveCanvasForBoard(boardId: string | null): void {
if (!boardId) return;
const stored = getCanvasForBoard(boardId);
if (stored) applyCanvasDocument(stored);
}
// Board-switch handoff: stash the live canvas under the outgoing board, then
@@ -74,9 +108,9 @@ export function switchCanvasBetweenBoards({
toBoardId: string;
}): void {
if (fromBoardId && fromBoardId !== toBoardId) {
canvasByBoard.set(fromBoardId, captureCanvasDocument());
stashLiveCanvas(fromBoardId);
}
applyCanvasDocument(canvasByBoard.get(toBoardId) ?? null);
applyCanvasDocument(getCanvasForBoard(toBoardId));
}
// Activate another board, swapping the canvas along with it.
@@ -98,3 +132,52 @@ export function createBoardAndSwitch(): string {
switchCanvasBetweenBoards({ fromBoardId, toBoardId: boardId });
return boardId;
}
// Synchronous write for pagehide/teardown — the debounce must not be the
// reason a closing tab loses its last edit.
export function persistCanvasReplicaNow(): void {
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = null;
}
writeToStorage();
}
// ---------- helpers ----------
function ensureLoaded(): void {
if (loadedFromStorage) return;
loadedFromStorage = true;
try {
const raw = localStorage.getItem(CANVAS_STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, MoodboardCanvasDocument>;
for (const [boardId, doc] of Object.entries(parsed)) {
canvasByBoard.set(boardId, { ...EMPTY_CANVAS_DOCUMENT, ...doc });
}
} catch (error) {
console.error("[Moodboard] canvas replica load failed:", error);
}
}
function schedulePersist(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
persistTimer = null;
writeToStorage();
}, PERSIST_DEBOUNCE_MS);
}
function writeToStorage(): void {
try {
const serializable: Record<string, MoodboardCanvasDocument> = {};
for (const [boardId, doc] of canvasByBoard.entries()) {
serializable[boardId] = persistableCanvas(doc);
}
localStorage.setItem(CANVAS_STORAGE_KEY, JSON.stringify(serializable));
} catch (error) {
// Quota or serialization failure: the in-memory map still covers the
// session; the server replica covers durability for synced boards.
console.error("[Moodboard] canvas replica persist failed:", error);
}
}
@@ -7,6 +7,7 @@ import {
deserializeMoodboardDocument,
serializeMoodboardDocument,
EMPTY_CANVAS_DOCUMENT,
UNRESOLVED_MEDIA_SRC,
type MoodboardCanvasDocument,
} from "./documents";
@@ -78,6 +79,39 @@ describe("moodboard document serialization", () => {
expect(deserializeMoodboardDocument("42")).toBeNull();
expect(deserializeMoodboardDocument("{}")).toBeNull();
});
it("gives unresolved tokens a visible placeholder that stays self-healing", () => {
const document = serializeMoodboardDocument({
board: buildBoard(),
canvas: buildCanvas(),
});
// Backend returned nothing for these tokens (media deleted): the item
// stays visible with the placeholder instead of an invisible tile.
const patched = applyResolvedMediaUrls(document, {});
expect(
(patched.board.items["item-blob-tokened"] as { src: string }).src,
).toBe(UNRESOLVED_MEDIA_SRC);
expect((patched.canvas.nodes["node-blob-tokened"] as ImageNode).src).toBe(
UNRESOLVED_MEDIA_SRC,
);
// The placeholder is never persisted: a re-serialize clears it back to
// "" so every future load re-attempts resolution (self-healing if the
// media comes back), and the token stays collectable.
const reserialized = serializeMoodboardDocument({
board: {
...buildBoard(),
items: patched.board.items,
itemOrder: patched.board.itemOrder,
},
canvas: { ...buildCanvas(), nodes: patched.canvas.nodes },
});
expect(
(reserialized.board.items["item-blob-tokened"] as { src: string }).src,
).toBe("");
expect(collectUnresolvedMediaTokens(reserialized)).toContain("m_board1");
});
});
// ---------- fixtures ----------
@@ -1,10 +1,10 @@
import type { Board, BoardItem, BoardSection } from "../boards/boardTypes";
import type { Board, BoardItem, BoardSection } from "../boards/boardTypes";
import type { MoodboardNode } from "../canvas/types";
import type { CanvasSize, Viewport } from "../canvas/layout/geometry";
// The JSON document persisted per board (one mood_board project row on the
// backend). Bundles the durable grid model with the canvas state so a load
// restores the canvas exactly as it was left — nodes, pan, and zoom.
// restores the canvas exactly as it was left — nodes, pan, and zoom.
export interface MoodboardCanvasDocument {
nodes: Record<string, MoodboardNode>;
@@ -38,7 +38,7 @@ export const EMPTY_CANVAS_DOCUMENT: MoodboardCanvasDocument = {
};
// Blob object URLs die with the page, so they can't be persisted:
// - items/nodes with a mediaId keep their place with src cleared to "" —
// - items/nodes with a mediaId keep their place with src cleared to "" —
// the sync layer re-resolves the token to a CDN URL on load;
// - tokenless blob items/nodes are dropped entirely (nothing could ever
// display them again). Dropped canvas nodes are also pruned from
@@ -88,6 +88,26 @@ export function deserializeMoodboardDocument(
};
}
// Rendered in place of media whose token couldn't be resolved (deleted from
// the library, or the resolve call failed). Keeps the item visible instead
// of an invisible/broken tile. Serialization treats it like an empty src, so
// the token is re-resolved on every load — self-healing if the media comes
// back.
export const UNRESOLVED_MEDIA_SRC =
"data:image/svg+xml," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">' +
'<rect width="96" height="96" fill="#2a2a2e"/>' +
'<path d="M30 62l12-16 9 11 6-7 9 12H30z" fill="#55555c"/>' +
'<circle cx="38" cy="34" r="6" fill="#55555c"/>' +
"</svg>",
);
// True when a media src needs (re-)resolution from its token: never
// persisted ("" after a blob strip) or previously unresolved (placeholder).
const needsMediaResolution = (src: string): boolean =>
src === "" || src === UNRESOLVED_MEDIA_SRC;
// Media tokens whose display URL needs re-resolution after a load (src was a
// blob URL at save time and got cleared).
export function collectUnresolvedMediaTokens(
@@ -97,7 +117,7 @@ export function collectUnresolvedMediaTokens(
for (const item of Object.values(document.board.items)) {
if (
(item.kind === "image" || item.kind === "video") &&
!item.src &&
needsMediaResolution(item.src) &&
item.mediaId
) {
tokens.add(item.mediaId);
@@ -106,7 +126,7 @@ export function collectUnresolvedMediaTokens(
for (const node of Object.values(document.canvas.nodes)) {
if (
(node.kind === "image" || node.kind === "video") &&
!node.src &&
needsMediaResolution(node.src) &&
node.mediaId
) {
tokens.add(node.mediaId);
@@ -115,7 +135,8 @@ export function collectUnresolvedMediaTokens(
return Array.from(tokens);
}
// Patch resolved URLs back into a loaded document (in place on a copy).
// Patch resolved URLs back into a loaded document (on a copy). Tokens the
// backend didn't return get the placeholder src so the item stays visible.
export function applyResolvedMediaUrls(
document: MoodboardDocument,
urlByToken: Record<string, string>,
@@ -124,22 +145,26 @@ export function applyResolvedMediaUrls(
for (const [id, item] of Object.entries(items)) {
if (
(item.kind === "image" || item.kind === "video") &&
!item.src &&
item.mediaId &&
urlByToken[item.mediaId]
needsMediaResolution(item.src) &&
item.mediaId
) {
items[id] = { ...item, src: urlByToken[item.mediaId] };
items[id] = {
...item,
src: urlByToken[item.mediaId] ?? UNRESOLVED_MEDIA_SRC,
};
}
}
const nodes = { ...document.canvas.nodes };
for (const [id, node] of Object.entries(nodes)) {
if (
(node.kind === "image" || node.kind === "video") &&
!node.src &&
node.mediaId &&
urlByToken[node.mediaId]
needsMediaResolution(node.src) &&
node.mediaId
) {
nodes[id] = { ...node, src: urlByToken[node.mediaId] };
nodes[id] = {
...node,
src: urlByToken[node.mediaId] ?? UNRESOLVED_MEDIA_SRC,
};
}
}
return {
@@ -151,7 +176,11 @@ export function applyResolvedMediaUrls(
// ---------- helpers ----------
const isBlobUrl = (src: string): boolean => src.startsWith("blob:");
// Blob object URLs and the unresolved placeholder both die with the
// document — persist them as "" (token-only) so loads re-resolve.
const isEphemeralSrc = (src: string): boolean =>
src.startsWith("blob:") || src === UNRESOLVED_MEDIA_SRC;
function persistableBoardContent(board: Board): {
itemOrder: string[];
@@ -163,7 +192,7 @@ function persistableBoardContent(board: Board): {
items[id] = item;
continue;
}
if (!isBlobUrl(item.src)) {
if (!isEphemeralSrc(item.src)) {
items[id] = item;
continue;
}
@@ -178,7 +207,7 @@ function persistableBoardContent(board: Board): {
};
}
function persistableCanvas(
export function persistableCanvas(
canvas: MoodboardCanvasDocument,
): MoodboardCanvasDocument {
const nodes: Record<string, MoodboardNode> = {};
@@ -189,7 +218,7 @@ function persistableCanvas(
nodes[id] = node;
continue;
}
if (!isBlobUrl(node.src)) {
if (!isEphemeralSrc(node.src)) {
nodes[id] = node;
continue;
}
@@ -15,24 +15,48 @@ import {
} from "./documents";
import {
applyCanvasDocument,
captureCanvasDocument,
dropCanvasForBoard,
getCanvasForBoard,
persistCanvasReplicaNow,
restoreLiveCanvasForBoard,
setCanvasForBoard,
stashLiveCanvas,
} from "./canvasBridge";
import { isDirtySuppressed, withDirtySuppressed } from "./dirtySuppression";
// Remote sync controller. Mounted (via useMoodboardSync) by the workspace
// when the adapter provides persistence and the user is signed in:
// - on mount, hydrates boards from the server (server wins for boards
// already linked by token — the canvas half never persists locally, and
// linked boards autosave, so the server copy is the richer one);
// - subscribes to both stores and autosaves dirty boards (debounced);
// - exposes saveNow() for the manual Save button, plus a status signal.
// Remote sync controller. Both the grid model and the canvas are durable
// LOCAL replicas (board store → zustand persist; canvas → canvasBridge), so
// sync is a replication problem, not a fetch-and-push:
//
// - Saves are gated behind hydration ("ready" phase): nothing is ever
// pushed from a partially loaded session, so a just-loaded state can
// never overwrite real server content.
// - Dirty state is per board and durable (Board.needsSync persists), so a
// closed tab resumes its pending push next session and hydration knows
// when local content is newer than the server copy.
// - Hydration is dirty-aware last-write-wins: clean boards take the server
// copy; dirty boards keep local content and push it instead. It never
// clobbers unsaved work.
// - Sync is scoped per account: hydration re-runs on account change, and a
// board stamped with another account's ownerId is never pushed
// (localStorage is machine-shared; server rows are not).
// - Failed pushes stay dirty and retry with exponential backoff. Deletes
// are tombstoned so a board removed while offline can't resurrect from
// the server on the next hydration.
const AUTOSAVE_DEBOUNCE_MS = 2000;
const RETRY_BASE_MS = 5000;
const RETRY_MAX_MS = 60_000;
const HYDRATE_CONCURRENCY = 4;
const TOMBSTONE_STORAGE_KEY = "artcraft_moodboard_deleted_tokens_v1";
const DEFAULT_BOARD_NAME = "Untitled board";
export type MoodboardSaveStatus = "idle" | "dirty" | "saving" | "saved" | "error";
export type MoodboardSaveStatus =
| "idle"
| "dirty"
| "saving"
| "saved"
| "error";
interface MoodboardSyncState {
status: MoodboardSaveStatus;
@@ -46,13 +70,20 @@ export const useMoodboardSyncStore = create<MoodboardSyncState>((set) => ({
// ---------- module-level controller state ----------
type SyncPhase = "idle" | "hydrating" | "ready";
let persistenceRef: MoodboardPersistenceAdapter | null = null;
let syncPhase: SyncPhase = "idle";
// Account the current hydration/ready phase belongs to. A change of user
// resets the machine and re-hydrates.
let syncUserId: string | null = null;
const dirtyBoardIds = new Set<string>();
let canvasDirty = false;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let isSaving = false;
let hasPendingFlush = false;
let hydratedThisPageLoad = false;
let retryAttempt = 0;
let pageHideHooked = false;
let restoredLiveCanvas = false;
const setStatus = (status: MoodboardSaveStatus) =>
useMoodboardSyncStore.getState().setStatus(status);
@@ -67,27 +98,44 @@ export function useMoodboardSync(adapter: MoodboardAdapter): {
const status = useMoodboardSyncStore((s) => s.status);
const persistence = adapter.persistence;
// Login state resolves asynchronously (the session fetch may still be in
// flight when the workspace mounts), so track it reactively instead of
// sampling once.
const [loggedIn, setLoggedIn] = useState(() =>
Boolean(persistence?.isLoggedIn()),
// The session resolves asynchronously, so track the account reactively —
// a boolean sampled once would miss both late logins and account
// switches.
const [userId, setUserId] = useState<string | null>(
() => persistence?.getUserId() ?? null,
);
useEffect(() => {
if (!persistence) return undefined;
const update = () => setLoggedIn(persistence.isLoggedIn());
const update = () => setUserId(persistence.getUserId());
update();
return persistence.subscribeLoginState?.(update);
return persistence.subscribeAuthState?.(update);
}, [persistence]);
// Local-replica wiring, active regardless of login: restore the active
// board's canvas from storage on first mount, keep the replica stashed as
// the canvas changes, and flush it synchronously when the page hides.
useEffect(() => {
if (!persistence || !loggedIn) return undefined;
persistenceRef = persistence;
restoreLiveCanvasOnce();
hookPageHide();
if (!hydratedThisPageLoad) {
hydratedThisPageLoad = true;
void hydrateRemoteBoards(persistence);
}
const unsubscribeCanvas = useMoodboardStore.subscribe((state, prev) => {
if (isDirtySuppressed()) return;
if (
state.nodes === prev.nodes &&
state.rootOrder === prev.rootOrder &&
state.viewport === prev.viewport &&
state.snapEnabled === prev.snapEnabled
) {
return;
}
// Attribute the edit to the board that owns the canvas RIGHT NOW —
// resolving later (at flush time) would blame whichever board the
// user had switched to meanwhile.
const boardId = useBoardLibraryStore.getState().activeBoardId;
if (!boardId) return;
stashLiveCanvas(boardId);
markBoardDirty(boardId);
});
const unsubscribeBoards = useBoardLibraryStore.subscribe((state, prev) => {
if (isDirtySuppressed()) return;
@@ -97,142 +145,232 @@ export function useMoodboardSync(adapter: MoodboardAdapter): {
}
});
const unsubscribeCanvas = useMoodboardStore.subscribe((state, prev) => {
if (isDirtySuppressed()) return;
if (
state.nodes !== prev.nodes ||
state.rootOrder !== prev.rootOrder ||
state.viewport !== prev.viewport ||
state.snapEnabled !== prev.snapEnabled
) {
canvasDirty = true;
scheduleFlush();
setStatusIfIdle("dirty");
}
});
return () => {
unsubscribeBoards();
unsubscribeCanvas();
unsubscribeBoards();
};
}, [persistence, loggedIn]);
}, []);
return { enabled: Boolean(persistence) && loggedIn, status, saveNow: flushNow };
// Server sync, gated on a signed-in account.
useEffect(() => {
persistenceRef = persistence ?? null;
if (!persistence || !userId) {
// Logged out (or logged out mid-session): stop pushing. Local
// replicas and needsSync flags stay — pushes resume on login.
syncPhase = "idle";
clearSaveTimer();
return;
}
if (syncUserId !== userId) {
// First login of this page load, or an account switch: reset the
// machine and hydrate for this user. Dirty bookkeeping is rebuilt
// from the durable needsSync flags.
syncUserId = userId;
syncPhase = "hydrating";
clearSaveTimer();
dirtyBoardIds.clear();
retryAttempt = 0;
seedDirtyFromDurableFlags(userId);
void hydrateRemoteBoards(persistence, userId);
} else if (syncPhase === "ready") {
// Same user re-mounting the workspace: nothing to hydrate.
scheduleFlushIfDirty();
}
}, [persistence, userId]);
return {
enabled: Boolean(persistence) && userId !== null,
status,
saveNow: flushNow,
};
}
// Immediate save of everything dirty (manual Save button). Also saves the
// active board even when nothing is marked dirty, so the button always
// produces a fresh server copy.
// Immediate save of everything dirty (manual Save button). Also treats the
// active board as dirty so the button always produces a fresh server copy.
export function flushNow(): void {
const activeId = useBoardLibraryStore.getState().activeBoardId;
if (activeId) dirtyBoardIds.add(activeId);
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
if (activeId) {
stashLiveCanvas(activeId);
markBoardDirty(activeId);
}
clearSaveTimer();
void flush();
}
// Delete a board locally and (when linked) remotely. Used by the board
// picker so a deleted board doesn't resurrect on the next hydration.
// Delete a board locally and (when linked) remotely. The token is
// tombstoned first so a failed remote delete can't resurrect the board from
// the server on the next hydration — the delete is retried then instead.
export async function deleteBoardEverywhere(boardId: string): Promise<void> {
const store = useBoardLibraryStore.getState();
const token = store.boards[boardId]?.remoteToken;
store.deleteBoard(boardId);
dropCanvasForBoard(boardId);
dirtyBoardIds.delete(boardId);
if (token && persistenceRef?.deleteBoard) {
if (!token) return;
addTombstone(token);
if (persistenceRef?.deleteBoard) {
try {
await persistenceRef.deleteBoard(token);
const deleted = await persistenceRef.deleteBoard(token);
if (deleted) removeTombstone(token);
} catch (error) {
console.error("[Moodboard] remote board delete failed:", error);
}
}
}
// ---------- internals ----------
// ---------- dirty tracking + scheduling ----------
function markBoardDirty(boardId: string): void {
dirtyBoardIds.add(boardId);
scheduleFlush();
setStatusIfIdle("dirty");
// Durable flag: survives reload, and tells hydration that local wins.
withDirtySuppressed(() => {
useBoardLibraryStore.getState().markBoardNeedsSync(boardId);
});
if (canSaveNow()) {
scheduleFlush(AUTOSAVE_DEBOUNCE_MS);
}
if (useMoodboardSyncStore.getState().status !== "saving") {
setStatus("dirty");
}
}
function setStatusIfIdle(status: MoodboardSaveStatus): void {
const current = useMoodboardSyncStore.getState().status;
if (current !== "saving") setStatus(status);
function canSaveNow(): boolean {
return (
syncPhase === "ready" &&
persistenceRef !== null &&
syncUserId !== null &&
persistenceRef.getUserId() === syncUserId
);
}
function scheduleFlush(): void {
function scheduleFlush(delayMs: number): void {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
void flush();
}, AUTOSAVE_DEBOUNCE_MS);
}, delayMs);
}
function scheduleFlushIfDirty(): void {
if (dirtyBoardIds.size > 0) scheduleFlush(AUTOSAVE_DEBOUNCE_MS);
}
function clearSaveTimer(): void {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
}
function seedDirtyFromDurableFlags(userId: string): void {
const { boards } = useBoardLibraryStore.getState();
for (const board of Object.values(boards)) {
if (board.ownerId && board.ownerId !== userId) continue;
if (board.needsSync) dirtyBoardIds.add(board.id);
}
if (dirtyBoardIds.size > 0) setStatus("dirty");
}
// ---------- flush / save ----------
async function flush(): Promise<void> {
if (isSaving) {
hasPendingFlush = true;
return;
}
const persistence = persistenceRef;
if (!persistence || !persistence.isLoggedIn()) return;
// Canvas edits belong to the active board, resolved at save time.
if (canvasDirty) {
canvasDirty = false;
const activeId = useBoardLibraryStore.getState().activeBoardId;
if (activeId) dirtyBoardIds.add(activeId);
if (!canSaveNow()) {
// Not ready yet (still hydrating, or logged out). Dirty state is
// durable; hydration completion / login re-schedules.
return;
}
const persistence = persistenceRef as MoodboardPersistenceAdapter;
const userId = syncUserId as string;
if (dirtyBoardIds.size === 0) {
setStatus("saved");
return;
}
if (dirtyBoardIds.size === 0) return;
isSaving = true;
setStatus("saving");
let hadError = false;
const ids = Array.from(dirtyBoardIds);
dirtyBoardIds.clear();
const failed: string[] = [];
try {
const ids = Array.from(dirtyBoardIds);
dirtyBoardIds.clear();
for (const id of ids) {
const ok = await saveBoard(persistence, id);
if (!ok) hadError = true;
const ok = await saveBoard(persistence, userId, id);
if (!ok) failed.push(id);
}
} finally {
isSaving = false;
}
setStatus(hadError ? "error" : "saved");
// Failed boards stay dirty and retry with backoff — a transient network
// error must never silently drop edits from the push queue.
failed.forEach((id) => dirtyBoardIds.add(id));
if (hasPendingFlush || dirtyBoardIds.size > 0 || canvasDirty) {
hasPendingFlush = false;
scheduleFlush();
if (failed.length > 0) {
retryAttempt += 1;
setStatus("error");
scheduleFlush(
Math.min(RETRY_BASE_MS * 2 ** (retryAttempt - 1), RETRY_MAX_MS),
);
} else {
retryAttempt = 0;
if (dirtyBoardIds.size > 0 || hasPendingFlush) {
// Edits arrived while saving.
setStatus("dirty");
scheduleFlush(AUTOSAVE_DEBOUNCE_MS);
} else {
setStatus("saved");
}
}
hasPendingFlush = false;
}
async function saveBoard(
persistence: MoodboardPersistenceAdapter,
userId: string,
boardId: string,
): Promise<boolean> {
const state = useBoardLibraryStore.getState();
const board = state.boards[boardId];
if (!board) return true;
const canvas =
boardId === state.activeBoardId
? captureCanvasDocument()
: (getCanvasForBoard(boardId) ?? EMPTY_CANVAS_DOCUMENT);
// Cross-account write protection: a board stamped for another account is
// never pushed from this one, even though it is visible in the
// machine-shared local library.
if (board.ownerId && board.ownerId !== userId) {
console.warn(
"[Moodboard] skipping push of board owned by another account:",
boardId,
);
return true;
}
// Never-saved boards with no content and the default name are just the
// auto-created scratch board — creating a server row for every visit (and
// every new device) would litter the project list with empty untitleds.
// The replica map is the canvas authority; the live canvas is stashed
// into it on every edit and board switch.
if (boardId === state.activeBoardId) stashLiveCanvas(boardId);
const canvas = getCanvasForBoard(boardId) ?? EMPTY_CANVAS_DOCUMENT;
// Never-touched scratch boards (auto-created empty "Untitled board")
// don't get server rows — a row per visit would litter the project list.
const isUntouched =
!board.remoteToken &&
board.itemOrder.length === 0 &&
board.sections.length === 0 &&
board.name === "Untitled board" &&
board.name === DEFAULT_BOARD_NAME &&
canvas.rootOrder.length === 0;
if (isUntouched) return true;
if (isUntouched) {
withDirtySuppressed(() => {
useBoardLibraryStore.getState().markBoardSynced(boardId, {
ownerId: userId,
});
});
return true;
}
const documentJson = JSON.stringify(
serializeMoodboardDocument({ board, canvas }),
@@ -248,13 +386,22 @@ async function saveBoard(
console.error("[Moodboard] board save failed:", result.errorMessage);
return false;
}
if (result.token && !board.remoteToken) {
withDirtySuppressed(() => {
useBoardLibraryStore
.getState()
.setBoardRemoteToken(boardId, result.token as string);
});
}
withDirtySuppressed(() => {
const store = useBoardLibraryStore.getState();
if (result.token && !board.remoteToken) {
store.setBoardRemoteToken(boardId, result.token);
}
// Only clear the durable dirty flag if nothing changed while the
// save was in flight — a mid-save edit re-marked it and must
// survive to the next push. Board content fields are compared (not
// identity, which sync's own token write-back churns); the canvas
// is compared by replica-entry reference (every stash replaces it).
const canvasUnchanged =
(getCanvasForBoard(boardId) ?? EMPTY_CANVAS_DOCUMENT) === canvas;
if (!boardEditedSince(board, boardId) && canvasUnchanged) {
store.markBoardSynced(boardId, { ownerId: userId });
}
});
return true;
} catch (error) {
console.error("[Moodboard] board save failed:", error);
@@ -262,50 +409,246 @@ async function saveBoard(
}
}
// True when the board's content changed after `snapshot` was captured.
// Compares content fields, not identity — sync's own bookkeeping writes
// (token write-back) change identity without changing content.
function boardEditedSince(snapshot: Board, boardId: string): boolean {
const current = useBoardLibraryStore.getState().boards[boardId];
if (!current) return false;
return (
current.items !== snapshot.items ||
current.itemOrder !== snapshot.itemOrder ||
current.sections !== snapshot.sections ||
current.name !== snapshot.name
);
}
// ---------- hydration ----------
async function hydrateRemoteBoards(
persistence: MoodboardPersistenceAdapter,
userId: string,
): Promise<void> {
try {
const listed = await persistence.listBoards();
if (!listed.success || !listed.boards) return;
// Abandon silently if the account changed mid-flight.
if (syncUserId !== userId) return;
for (const remote of listed.boards) {
const loaded = await persistence.loadBoard(remote.token);
if (!loaded.success || !loaded.documentJson) continue;
if (listed.success && listed.boards) {
await retryTombstonedDeletes(persistence);
const tombstones = readTombstones();
const rows = listed.boards.filter((row) => !tombstones.has(row.token));
let boardDocument = deserializeMoodboardDocument(loaded.documentJson);
if (!boardDocument) continue;
const unresolved = collectUnresolvedMediaTokens(boardDocument);
if (unresolved.length > 0 && persistence.resolveMediaUrls) {
// Each board hydrates independently — one bad row/fetch must not
// strand every board after it.
await mapWithConcurrency(rows, HYDRATE_CONCURRENCY, async (row) => {
try {
const urls = await persistence.resolveMediaUrls(unresolved);
boardDocument = applyResolvedMediaUrls(boardDocument, urls);
await hydrateBoard(persistence, userId, row.token, row.name);
} catch (error) {
console.error("[Moodboard] media URL resolution failed:", error);
}
}
const doc = boardDocument;
withDirtySuppressed(() => {
const boardId = useBoardLibraryStore.getState().upsertRemoteBoard({
token: remote.token,
name: remote.name,
itemOrder: doc.board.itemOrder,
items: doc.board.items,
sections: doc.board.sections,
});
setCanvasForBoard(boardId, doc.canvas);
// The user is looking at this board: swap the live canvas in too.
// Fresh page loads start with an empty canvas (it never persists
// locally), so this can't clobber unsaved work.
if (boardId === useBoardLibraryStore.getState().activeBoardId) {
applyCanvasDocument(doc.canvas);
console.error(
"[Moodboard] board hydration failed:",
row.token,
error,
);
}
});
} else {
console.error("[Moodboard] board list failed; pushing dirty only");
}
} catch (error) {
console.error("[Moodboard] board hydration failed:", error);
console.error("[Moodboard] hydration failed:", error);
} finally {
if (syncUserId === userId) {
syncPhase = "ready";
// Adopt content-bearing local boards that never reached the server
// (created logged-out, or before sync existed) so signing in
// uploads them rather than leaving them stranded on this machine.
seedUnsyncedLocalBoards(userId);
if (dirtyBoardIds.size > 0) {
setStatus("dirty");
scheduleFlush(AUTOSAVE_DEBOUNCE_MS);
} else {
setStatus("saved");
}
}
}
}
async function hydrateBoard(
persistence: MoodboardPersistenceAdapter,
userId: string,
token: string,
name: string,
): Promise<void> {
const local = Object.values(useBoardLibraryStore.getState().boards).find(
(b) => b.remoteToken === token,
);
// Dirty-aware LWW: unsaved local edits win — they are strictly newer
// than the server copy from this device's perspective, and the pending
// push reconciles the server. Applying the server copy here would
// destroy them.
if (local?.needsSync) {
dirtyBoardIds.add(local.id);
return;
}
// Owned by a different account: hands off entirely.
if (local?.ownerId && local.ownerId !== userId) return;
const loaded = await persistence.loadBoard(token);
if (syncUserId !== userId) return;
if (!loaded.success || !loaded.documentJson) return;
let doc = deserializeMoodboardDocument(loaded.documentJson);
if (!doc) return;
const unresolved = collectUnresolvedMediaTokens(doc);
if (unresolved.length > 0 && persistence.resolveMediaUrls) {
try {
const urls = await persistence.resolveMediaUrls(unresolved);
doc = applyResolvedMediaUrls(doc, urls);
} catch (error) {
console.error("[Moodboard] media URL resolution failed:", error);
doc = applyResolvedMediaUrls(doc, {});
}
}
if (syncUserId !== userId) return;
const applied = doc;
withDirtySuppressed(() => {
const store = useBoardLibraryStore.getState();
// Re-check dirtiness at apply time: the user may have edited this
// board while its document was downloading.
const current = Object.values(store.boards).find(
(b) => b.remoteToken === token,
);
if (current?.needsSync) {
dirtyBoardIds.add(current.id);
return;
}
const boardId = store.upsertRemoteBoard({
token,
name: applied.board.name || name,
ownerId: userId,
itemOrder: applied.board.itemOrder,
items: applied.board.items,
sections: applied.board.sections,
});
setCanvasForBoard(boardId, applied.canvas);
if (boardId === store.activeBoardId) {
applyCanvasDocument(applied.canvas);
}
});
}
// Non-empty boards without a server row (and not owned by someone else)
// become part of this account's push backlog after hydration.
function seedUnsyncedLocalBoards(userId: string): void {
const { boards } = useBoardLibraryStore.getState();
for (const board of Object.values(boards)) {
if (board.remoteToken) continue;
if (board.ownerId && board.ownerId !== userId) continue;
const canvas = getCanvasForBoard(board.id);
const hasContent =
board.itemOrder.length > 0 ||
board.sections.length > 0 ||
board.name !== DEFAULT_BOARD_NAME ||
(canvas?.rootOrder.length ?? 0) > 0;
if (hasContent) dirtyBoardIds.add(board.id);
}
}
// ---------- delete tombstones ----------
function readTombstones(): Set<string> {
try {
const raw = localStorage.getItem(TOMBSTONE_STORAGE_KEY);
if (!raw) return new Set();
return new Set(JSON.parse(raw) as string[]);
} catch {
return new Set();
}
}
function writeTombstones(tokens: Set<string>): void {
try {
localStorage.setItem(
TOMBSTONE_STORAGE_KEY,
JSON.stringify(Array.from(tokens)),
);
} catch (error) {
console.error("[Moodboard] tombstone persist failed:", error);
}
}
function addTombstone(token: string): void {
const tokens = readTombstones();
tokens.add(token);
writeTombstones(tokens);
}
function removeTombstone(token: string): void {
const tokens = readTombstones();
if (!tokens.delete(token)) return;
writeTombstones(tokens);
}
async function retryTombstonedDeletes(
persistence: MoodboardPersistenceAdapter,
): Promise<void> {
if (!persistence.deleteBoard) return;
for (const token of readTombstones()) {
try {
const deleted = await persistence.deleteBoard(token);
if (deleted) removeTombstone(token);
} catch (error) {
console.error("[Moodboard] tombstoned delete retry failed:", error);
}
}
}
// ---------- page lifecycle ----------
function restoreLiveCanvasOnce(): void {
if (restoredLiveCanvas) return;
restoredLiveCanvas = true;
restoreLiveCanvasForBoard(useBoardLibraryStore.getState().activeBoardId);
}
function hookPageHide(): void {
if (pageHideHooked || typeof window === "undefined") return;
pageHideHooked = true;
window.addEventListener("pagehide", () => {
// Durability on exit is local-first: flush the canvas replica and the
// needsSync flags synchronously (multipart uploads can't be relied on
// during unload); the next session pushes the backlog.
const activeId = useBoardLibraryStore.getState().activeBoardId;
if (activeId && dirtyBoardIds.has(activeId)) stashLiveCanvas(activeId);
withDirtySuppressed(() => {
const store = useBoardLibraryStore.getState();
dirtyBoardIds.forEach((id) => store.markBoardNeedsSync(id));
});
persistCanvasReplicaNow();
});
}
// ---------- small utils ----------
async function mapWithConcurrency<T>(
items: T[],
limit: number,
run: (item: T) => Promise<void>,
): Promise<void> {
let nextIndex = 0;
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
while (nextIndex < items.length) {
const index = nextIndex++;
await run(items[index]);
}
},
);
await Promise.all(workers);
}