From 6daabbe7122658a1491f7ca8925925483489e616 Mon Sep 17 00:00:00 2001 From: Hoang Pham Date: Wed, 1 Apr 2026 17:43:19 +0700 Subject: [PATCH] Implement durable revision-aware Nextcloud sync Signed-off-by: Hoang Pham --- lib/Controller/WhiteboardController.php | 35 +- lib/Exception/WhiteboardConflictException.php | 30 ++ lib/Service/WhiteboardContentService.php | 358 ++++++++++-------- src/components/ReadOnlyViewer.tsx | 23 +- src/database/db.ts | 40 +- src/hooks/useBoardDataManager.ts | 214 ++++------- src/hooks/useCollaboration.ts | 5 + src/hooks/useSync.ts | 83 +++- src/hooks/useVersionPreview.ts | 158 ++++++-- src/stores/useSyncStore.ts | 63 ++- src/types/protocol.ts | 31 +- src/utils/persistedBoardData.ts | 256 +++++++++++++ src/utils/sanitizeAppState.ts | 7 +- src/workers/syncWorker.ts | 221 +---------- src/workers/syncWorkerCore.ts | 335 ++++++++++++++++ .../Service/WhiteboardContentServiceTest.php | 285 ++++++++++++++ tests/integration/persistedBoardData.spec.ts | 90 +++++ tests/integration/syncWorker.spec.ts | 342 +++++++++++++++++ 18 files changed, 2005 insertions(+), 571 deletions(-) create mode 100644 lib/Exception/WhiteboardConflictException.php create mode 100644 src/utils/persistedBoardData.ts create mode 100644 src/workers/syncWorkerCore.ts create mode 100644 tests/Unit/Service/WhiteboardContentServiceTest.php create mode 100644 tests/integration/persistedBoardData.spec.ts create mode 100644 tests/integration/syncWorker.spec.ts diff --git a/lib/Controller/WhiteboardController.php b/lib/Controller/WhiteboardController.php index 9f720c09..c559800f 100644 --- a/lib/Controller/WhiteboardController.php +++ b/lib/Controller/WhiteboardController.php @@ -12,6 +12,7 @@ use Exception; use OCA\Whiteboard\Exception\InvalidUserException; use OCA\Whiteboard\Exception\UnauthorizedException; +use OCA\Whiteboard\Exception\WhiteboardConflictException; use OCA\Whiteboard\Service\Authentication\GetUserFromIdServiceFactory; use OCA\Whiteboard\Service\ConfigService; use OCA\Whiteboard\Service\ExceptionService; @@ -20,6 +21,7 @@ use OCA\Whiteboard\Service\WhiteboardContentService; use OCA\Whiteboard\Service\WhiteboardLibraryService; use OCP\AppFramework\ApiController; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; @@ -75,23 +77,40 @@ public function show(int $fileId): DataResponse { #[PublicPage] public function update(int $fileId, array $data): DataResponse { $lockKey = "sync_lock_{$fileId}"; - $lockValue = uniqid(); + $lockValue = uniqid('', true); $lockTTL = 5; // 5 seconds - // Simple distributed lock - if (!$this->cache->add($lockKey, $lockValue, $lockTTL)) { - return new DataResponse(['status' => 'conflict'], 409); - } - try { + $maxLockAttempts = 5; + for ($attempt = 0; $attempt < $maxLockAttempts; $attempt++) { + if ($this->cache->add($lockKey, $lockValue, $lockTTL)) { + break; + } + + if ($attempt === $maxLockAttempts - 1) { + throw new Exception('Whiteboard sync is temporarily busy', Http::STATUS_SERVICE_UNAVAILABLE); + } + + usleep(100000); + } + $jwt = $this->getJwtFromRequest(); $userId = $this->jwtService->getUserIdFromJWT($jwt); $user = $this->getUserFromIdServiceFactory->create($userId)->getUser(); $file = $this->getFileServiceFactory->create($user, $fileId)->getFile(); - $this->contentService->updateContent($file, $data); + $meta = $this->contentService->updateContent($file, $data, $userId); - return new DataResponse(['status' => 'success']); + return new DataResponse([ + 'status' => 'success', + 'meta' => $meta, + ]); + + } catch (WhiteboardConflictException $e) { + return new DataResponse([ + 'status' => 'conflict', + 'data' => $e->getCurrentDocument(), + ], Http::STATUS_CONFLICT); } catch (Exception $e) { $this->logger->error('Error syncing whiteboard data: ' . $e->getMessage()); diff --git a/lib/Exception/WhiteboardConflictException.php b/lib/Exception/WhiteboardConflictException.php new file mode 100644 index 00000000..40116552 --- /dev/null +++ b/lib/Exception/WhiteboardConflictException.php @@ -0,0 +1,30 @@ + $currentDocument + */ + public function __construct( + private array $currentDocument, + ) { + parent::__construct('Whiteboard content conflict', 409); + } + + /** + * @return array + */ + public function getCurrentDocument(): array { + return $this->currentDocument; + } +} diff --git a/lib/Service/WhiteboardContentService.php b/lib/Service/WhiteboardContentService.php index 5ba88d87..1ac008ef 100644 --- a/lib/Service/WhiteboardContentService.php +++ b/lib/Service/WhiteboardContentService.php @@ -9,7 +9,10 @@ namespace OCA\Whiteboard\Service; +use InvalidArgumentException; use JsonException; +use OCA\Whiteboard\Exception\WhiteboardConflictException; +use OCP\AppFramework\Http; use OCP\Files\File; use OCP\Files\GenericFileException; use OCP\Files\NotPermittedException; @@ -23,6 +26,8 @@ public function __construct( } /** + * @return array + * * @throws NotPermittedException * @throws GenericFileException * @throws LockedException @@ -31,102 +36,109 @@ public function __construct( public function getContent(File $file): array { $fileContent = $file->getContent(); if ($fileContent === '') { - $fileContent = '{"elements":[],"scrollToContent":true}'; + return $this->getEmptyDocument(); } - return json_decode($fileContent, true, 512, JSON_THROW_ON_ERROR); + $decoded = json_decode($fileContent, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decoded)) { + return $this->getEmptyDocument(); + } + + return $this->normalizeStoredDocument($decoded); } /** + * @return array + * * @throws NotPermittedException * @throws GenericFileException * @throws LockedException * @throws JsonException + * @throws WhiteboardConflictException */ - public function updateContent(File $file, array $data): void { + public function updateContent(File $file, array $data, string $updatedBy): array { $fileId = $file->getId(); - $incoming = $this->normalizeIncomingData($data); - - if ($this->isEffectivelyEmptyPayload($incoming)) { - $this->logger->debug('Skipping whiteboard save because payload is empty', [ - 'app' => 'whiteboard', - 'fileId' => $fileId, - ]); - return; - } + $hadPersistedMeta = false; try { - $current = $this->normalizeStoredData($this->getContent($file)); + $fileContent = $file->getContent(); + if ($fileContent === '') { + $currentDocument = $this->getEmptyDocument(); + } else { + $decoded = json_decode($fileContent, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decoded)) { + $currentDocument = $this->getEmptyDocument(); + } else { + $unwrapped = $this->unwrapData($decoded); + $hadPersistedMeta = array_key_exists('meta', $unwrapped) && is_array($unwrapped['meta']); + $currentDocument = $this->normalizeStoredDocument($decoded); + } + } } catch (JsonException $e) { $this->logger->warning('Existing whiteboard content is invalid JSON, resetting to defaults', [ 'app' => 'whiteboard', 'fileId' => $fileId, 'error' => $e->getMessage(), ]); - $current = $this->getEmptyState(); + $currentDocument = $this->getEmptyDocument(); } - $merged = $this->mergeData($current, $incoming); + $incoming = $this->normalizeIncomingPayload($data); + $currentSnapshot = $this->canonicalize($this->extractSnapshot($currentDocument)); + $incomingSnapshot = $this->canonicalize($this->extractSnapshot($incoming['document'])); + + if ($currentSnapshot === $incomingSnapshot) { + if (!$hadPersistedMeta && $incoming['baseRev'] === $currentDocument['meta']['persistedRev']) { + $updatedDocument = $incoming['document']; + $updatedDocument['meta'] = [ + 'persistedRev' => 1, + 'updatedAt' => $this->currentTimeMs(), + 'updatedBy' => $updatedBy, + ]; - $canonicalCurrent = $this->canonicalize($current); - $canonicalMerged = $this->canonicalize($merged); + $this->writeDocument($file, $updatedDocument); + + return $updatedDocument['meta']; + } - if ($canonicalCurrent === $canonicalMerged) { $this->logger->debug('Skipping whiteboard save because payload matches stored content', [ 'app' => 'whiteboard', 'fileId' => $fileId, + 'persistedRev' => $currentDocument['meta']['persistedRev'], ]); - return; + return $currentDocument['meta']; } - try { - $encodedPayload = json_encode($canonicalMerged, JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - $this->logger->error('Failed to encode whiteboard content before saving', [ - 'app' => 'whiteboard', - 'fileId' => $fileId, - 'error' => $e->getMessage(), - ]); - throw $e; + $currentRev = $currentDocument['meta']['persistedRev']; + if ($incoming['baseRev'] !== $currentRev) { + throw new WhiteboardConflictException($currentDocument); } - $maxRetries = 3; - $baseDelay = 1000000; // 1 second - - for ($attempt = 0; $attempt < $maxRetries; $attempt++) { - try { - $file->putContent($encodedPayload); - return; - - } catch (LockedException $e) { - if ($attempt === $maxRetries - 1) { - $this->logger->error('Whiteboard file write failed after retries', [ - 'app' => 'whiteboard', - 'fileId' => $fileId, - 'error' => $e->getMessage(), - ]); - throw $e; - } + $updatedDocument = $incoming['document']; + $updatedDocument['meta'] = [ + 'persistedRev' => $currentRev + 1, + 'updatedAt' => $this->currentTimeMs(), + 'updatedBy' => $updatedBy, + ]; - $delay = (int)($baseDelay * ((int)(2 ** $attempt))); - $this->logger->warning('Whiteboard file locked, retrying', [ - 'app' => 'whiteboard', - 'fileId' => $fileId, - 'attempt' => $attempt + 1, - ]); + $this->writeDocument($file, $updatedDocument); - usleep($delay); - } - } + return $updatedDocument['meta']; } /** * @return array */ - private function getEmptyState(): array { + private function getEmptyDocument(): array { return [ + 'meta' => [ + 'persistedRev' => 0, + 'updatedAt' => null, + 'updatedBy' => null, + ], 'elements' => [], 'files' => [], + 'appState' => [], 'scrollToContent' => true, ]; } @@ -145,148 +157,188 @@ private function unwrapData(array $payload): array { } /** - * @param array $incoming + * @param array $payload * - * @return array + * @return array{baseRev:int,document:array} */ - private function normalizeIncomingData(array $incoming): array { - $incoming = $this->unwrapData($incoming); - - if (empty($incoming)) { - return $this->getEmptyState(); - } - - $normalized = []; + private function normalizeIncomingPayload(array $payload): array { + $payload = $this->unwrapData($payload); + $baseRev = $this->parseBaseRev($payload); - if (array_key_exists('elements', $incoming) && is_array($incoming['elements'])) { - $normalized['elements'] = $this->sanitizeElements($incoming['elements']); - } + return [ + 'baseRev' => $baseRev, + 'document' => $this->normalizeSnapshot($payload, true), + ]; + } - if (array_key_exists('files', $incoming)) { - $normalized['files'] = is_array($incoming['files']) - ? $this->sanitizeFiles($incoming['files']) - : []; - } + /** + * @param array $stored + * + * @return array + */ + private function normalizeStoredDocument(array $stored): array { + $stored = $this->unwrapData($stored); - if (array_key_exists('appState', $incoming) && is_array($incoming['appState'])) { - $normalized['appState'] = $this->sanitizeAppState($incoming['appState']); + if (empty($stored)) { + return $this->getEmptyDocument(); } - if (array_key_exists('scrollToContent', $incoming)) { - $normalized['scrollToContent'] = (bool)$incoming['scrollToContent']; - } + $document = $this->normalizeSnapshot($stored, false); + $document['meta'] = $this->normalizeMeta($stored['meta'] ?? null); - return $normalized; + return [ + 'meta' => $document['meta'], + 'elements' => $document['elements'], + 'files' => $document['files'], + 'appState' => $document['appState'], + 'scrollToContent' => $document['scrollToContent'], + ]; } /** * @param array $payload + * + * @return array */ - private function isEffectivelyEmptyPayload(array $payload): bool { - $hasFiles = array_key_exists('files', $payload) - && is_array($payload['files']) - && !empty($payload['files']); - - if ($hasFiles) { - return false; + private function normalizeSnapshot(array $payload, bool $requireElements): array { + if ($requireElements && (!array_key_exists('elements', $payload) || !is_array($payload['elements']))) { + throw new InvalidArgumentException('Invalid whiteboard payload: elements must be an array', Http::STATUS_BAD_REQUEST); } - $hasAppState = array_key_exists('appState', $payload) - && is_array($payload['appState']) - && !empty($payload['appState']); - - if ($hasAppState) { - return false; - } - - if (array_key_exists('scrollToContent', $payload) && $payload['scrollToContent'] !== true) { - return false; + if (array_key_exists('files', $payload) && !is_array($payload['files'])) { + throw new InvalidArgumentException('Invalid whiteboard payload: files must be an object', Http::STATUS_BAD_REQUEST); } - if (!array_key_exists('elements', $payload) || !is_array($payload['elements'])) { - return false; + if (array_key_exists('appState', $payload) && !is_array($payload['appState'])) { + throw new InvalidArgumentException('Invalid whiteboard payload: appState must be an object', Http::STATUS_BAD_REQUEST); } - if (!empty($payload['elements'])) { - return false; - } - - foreach ($payload as $key => $_value) { - if (!in_array($key, ['elements', 'files', 'appState', 'scrollToContent'], true)) { - return false; - } - } - - return true; + return [ + 'elements' => (array_key_exists('elements', $payload) && is_array($payload['elements'])) + ? $this->sanitizeElements($payload['elements']) + : [], + 'files' => (array_key_exists('files', $payload) && is_array($payload['files'])) + ? $this->sanitizeFiles($payload['files']) + : [], + 'appState' => (array_key_exists('appState', $payload) && is_array($payload['appState'])) + ? $this->sanitizeAppState($payload['appState']) + : [], + 'scrollToContent' => $this->resolveScrollToContent($payload), + ]; } /** - * @param array $stored + * @param mixed $value * * @return array - * - * @throws JsonException */ - private function normalizeStoredData(array $stored): array { - $stored = $this->unwrapData($stored); - - if (empty($stored)) { - return $this->getEmptyState(); + private function normalizeMeta($value): array { + if (!is_array($value)) { + return $this->getEmptyDocument()['meta']; } - $normalized = $this->getEmptyState(); + return [ + 'persistedRev' => (is_int($value['persistedRev'] ?? null) && $value['persistedRev'] >= 0) + ? $value['persistedRev'] + : 0, + 'updatedAt' => (is_int($value['updatedAt'] ?? null) || is_float($value['updatedAt'] ?? null)) + ? (int)$value['updatedAt'] + : null, + 'updatedBy' => is_string($value['updatedBy'] ?? null) + ? $value['updatedBy'] + : null, + ]; + } - if (array_key_exists('elements', $stored) && is_array($stored['elements'])) { - $normalized['elements'] = $this->sanitizeElements($stored['elements']); + /** + * @param array $payload + */ + private function parseBaseRev(array $payload): int { + if (!array_key_exists('baseRev', $payload) || !is_int($payload['baseRev']) || $payload['baseRev'] < 0) { + throw new InvalidArgumentException('Invalid whiteboard payload: baseRev must be a non-negative integer', Http::STATUS_BAD_REQUEST); } - if (array_key_exists('files', $stored) && is_array($stored['files'])) { - $normalized['files'] = $this->sanitizeFiles($stored['files']); - } + return $payload['baseRev']; + } - if (array_key_exists('appState', $stored) && is_array($stored['appState'])) { - $normalized['appState'] = $this->sanitizeAppState($stored['appState']); - } elseif (array_key_exists('appState', $stored) && $stored['appState'] === null) { - unset($normalized['appState']); + /** + * @param array $payload + */ + private function resolveScrollToContent(array $payload): bool { + if (array_key_exists('scrollToContent', $payload)) { + return (bool)$payload['scrollToContent']; } - if (array_key_exists('scrollToContent', $stored)) { - $normalized['scrollToContent'] = (bool)$stored['scrollToContent']; + if (array_key_exists('appState', $payload) && is_array($payload['appState']) && array_key_exists('scrollToContent', $payload['appState'])) { + return (bool)$payload['appState']['scrollToContent']; } - return $normalized; + return true; } /** - * @param array $current - * @param array $incoming + * @param array $document * * @return array */ - private function mergeData(array $current, array $incoming): array { - $merged = $current; + private function extractSnapshot(array $document): array { + return [ + 'elements' => $document['elements'], + 'files' => $document['files'], + 'appState' => $document['appState'], + 'scrollToContent' => $document['scrollToContent'], + ]; + } - if (array_key_exists('elements', $incoming)) { - $merged['elements'] = $incoming['elements']; - } + /** + * @param array $document + * + * @throws JsonException + * @throws LockedException + * @throws GenericFileException + * @throws NotPermittedException + */ + private function writeDocument(File $file, array $document): void { + $fileId = $file->getId(); - if (array_key_exists('files', $incoming)) { - $merged['files'] = $incoming['files']; + try { + $encodedPayload = json_encode($this->canonicalize($document), JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->logger->error('Failed to encode whiteboard content before saving', [ + 'app' => 'whiteboard', + 'fileId' => $fileId, + 'error' => $e->getMessage(), + ]); + throw $e; } - if (array_key_exists('appState', $incoming)) { - if ($incoming['appState'] === null) { - unset($merged['appState']); - } else { - $merged['appState'] = $incoming['appState']; - } - } + $maxRetries = 3; + $baseDelay = 1000000; - if (array_key_exists('scrollToContent', $incoming)) { - $merged['scrollToContent'] = (bool)$incoming['scrollToContent']; - } + for ($attempt = 0; $attempt < $maxRetries; $attempt++) { + try { + $file->putContent($encodedPayload); + return; + } catch (LockedException $e) { + if ($attempt === $maxRetries - 1) { + $this->logger->error('Whiteboard file write failed after retries', [ + 'app' => 'whiteboard', + 'fileId' => $fileId, + 'error' => $e->getMessage(), + ]); + throw $e; + } - return $merged; + $delay = (int)($baseDelay * ((int)(2 ** $attempt))); + $this->logger->warning('Whiteboard file locked, retrying', [ + 'app' => 'whiteboard', + 'fileId' => $fileId, + 'attempt' => $attempt + 1, + ]); + + usleep($delay); + } + } } /** @@ -337,7 +389,7 @@ private function sanitizeFiles(array $files): array { * @return array */ private function sanitizeAppState(array $appState): array { - unset($appState['collaborators'], $appState['selectedElementIds']); + unset($appState['collaborators'], $appState['selectedElementIds'], $appState['scrollToContent']); if (!empty($appState)) { ksort($appState); @@ -365,6 +417,10 @@ private function canonicalize($value) { return $value; } + private function currentTimeMs(): int { + return (int)round(microtime(true) * 1000); + } + private function isList(array $array): bool { if (function_exists('array_is_list')) { return array_is_list($array); diff --git a/src/components/ReadOnlyViewer.tsx b/src/components/ReadOnlyViewer.tsx index e3c1bfcc..68bd7cb1 100644 --- a/src/components/ReadOnlyViewer.tsx +++ b/src/components/ReadOnlyViewer.tsx @@ -19,17 +19,11 @@ import logger from '../utils/logger' import type { WhiteboardAppProps } from '../App' import { initialDataState } from '../constants/excalidraw' import { useThemeHandling } from '../hooks/useThemeHandling' +import { extractSnapshotFromPersistedBoard } from '../utils/persistedBoardData' import { sanitizeAppStateForSync } from '../utils/sanitizeAppState' const ReadOnlyExcalidraw = memo(ExcalidrawComponent) -type ParsedVersionContent = { - elements?: unknown - files?: unknown - appState?: unknown - scrollToContent?: boolean -} - type SceneState = ExcalidrawInitialDataState & { scrollToContent?: boolean } @@ -112,24 +106,25 @@ export default function ReadOnlyViewer({ return } - let parsed: ParsedVersionContent = {} + let parsed: unknown = {} if (rawContent.trim().length > 0) { try { - parsed = JSON.parse(rawContent) as ParsedVersionContent - } catch (parseError) { + parsed = JSON.parse(rawContent) + } catch { throw new Error('Failed to parse version JSON') } } - const elements = sanitizeElements(parsed.elements) - const files = sanitizeFiles(parsed.files) - const appState = sanitizeAppState(parsed.appState) + const snapshot = extractSnapshotFromPersistedBoard(parsed) + const elements = sanitizeElements(snapshot.elements) + const files = sanitizeFiles(snapshot.files) + const appState = sanitizeAppState(snapshot.appState) setScene({ elements, files, appState, - scrollToContent: parsed.scrollToContent ?? true, + scrollToContent: snapshot.scrollToContent, }) } catch (fetchError) { if (abortController.signal.aborted) { diff --git a/src/database/db.ts b/src/database/db.ts index 38832744..b080587b 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -12,10 +12,14 @@ export interface WhiteboardData { id: number elements: ExcalidrawElement[] files: BinaryFiles - appState?: AppState + appState?: Partial + scrollToContent?: boolean savedAt?: number hasPendingLocalChanges?: boolean lastSyncedHash?: number + persistedRev?: number + lastServerUpdatedAt?: number | null + lastServerUpdatedBy?: string | null } export class WhiteboardDatabase extends Dexie.Dexie { @@ -28,22 +32,48 @@ export class WhiteboardDatabase extends Dexie.Dexie { this.version(1).stores({ whiteboards: '++id, savedAt', }) + + this.version(2).stores({ + whiteboards: '++id, savedAt', + }).upgrade(async (tx) => { + await tx.table('whiteboards').toCollection().modify((whiteboard: WhiteboardData) => { + whiteboard.persistedRev = whiteboard.persistedRev ?? 0 + whiteboard.lastServerUpdatedAt = whiteboard.lastServerUpdatedAt ?? null + whiteboard.lastServerUpdatedBy = whiteboard.lastServerUpdatedBy ?? null + whiteboard.scrollToContent = whiteboard.scrollToContent ?? true + }) + }) } async get( fileId: number, ): Promise { - return this.whiteboards.get(fileId) + const whiteboard = await this.whiteboards.get(fileId) + if (!whiteboard) { + return undefined + } + + return { + ...whiteboard, + persistedRev: whiteboard.persistedRev ?? 0, + lastServerUpdatedAt: whiteboard.lastServerUpdatedAt ?? null, + lastServerUpdatedBy: whiteboard.lastServerUpdatedBy ?? null, + scrollToContent: whiteboard.scrollToContent ?? true, + } } async put( fileId: number, elements: ExcalidrawElement[], files: BinaryFiles, - appState?: AppState, + appState?: Partial, options: { + scrollToContent?: boolean hasPendingLocalChanges?: boolean lastSyncedHash?: number + persistedRev?: number + lastServerUpdatedAt?: number | null + lastServerUpdatedBy?: string | null } = {}, ): Promise { const existing = await this.whiteboards.get(fileId) @@ -53,9 +83,13 @@ export class WhiteboardDatabase extends Dexie.Dexie { elements, files, appState, + scrollToContent: options.scrollToContent ?? existing?.scrollToContent ?? true, savedAt: Date.now(), hasPendingLocalChanges: options.hasPendingLocalChanges ?? existing?.hasPendingLocalChanges ?? false, lastSyncedHash: options.lastSyncedHash ?? existing?.lastSyncedHash, + persistedRev: options.persistedRev ?? existing?.persistedRev ?? 0, + lastServerUpdatedAt: options.lastServerUpdatedAt ?? existing?.lastServerUpdatedAt ?? null, + lastServerUpdatedBy: options.lastServerUpdatedBy ?? existing?.lastServerUpdatedBy ?? null, } return this.whiteboards.put(data) diff --git a/src/hooks/useBoardDataManager.ts b/src/hooks/useBoardDataManager.ts index 699c11b6..e2e51b97 100644 --- a/src/hooks/useBoardDataManager.ts +++ b/src/hooks/useBoardDataManager.ts @@ -5,17 +5,20 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useCallback, useEffect, useState, useRef } from 'react' -import { useWhiteboardConfigStore } from '../stores/useWhiteboardConfigStore' -import { useExcalidrawStore } from '../stores/useExcalidrawStore' -import { useJWTStore } from '../stores/useJwtStore' -import { useSyncStore } from '../stores/useSyncStore' -import { db } from '../database/db' +import { useCallback, useEffect, useRef, useState } from 'react' import { generateUrl } from '@nextcloud/router' import { useShallow } from 'zustand/react/shallow' +import { db } from '../database/db' import { initialDataState } from '../constants/excalidraw' +import { useExcalidrawStore } from '../stores/useExcalidrawStore' +import { useJWTStore } from '../stores/useJwtStore' +import { useSyncStore } from '../stores/useSyncStore' +import { useWhiteboardConfigStore } from '../stores/useWhiteboardConfigStore' import logger from '../utils/logger' -import { computeElementVersionHash, mergeSceneElements } from '../utils/syncSceneData' +import { + extractSnapshotFromPersistedBoard, + resolveBoardLoadState, +} from '../utils/persistedBoardData' import { sanitizeAppStateForSync } from '../utils/sanitizeAppState' export function useBoardDataManager() { @@ -39,7 +42,12 @@ export function useBoardDataManager() { fileVersion: state.fileVersion, }))) - const fetchDataFromServer = useCallback(async (fileId: number) => { + const { setPersistedMetadata, resetPersistedMetadata } = useSyncStore(useShallow(state => ({ + setPersistedMetadata: state.setPersistedMetadata, + resetPersistedMetadata: state.resetPersistedMetadata, + }))) + + const fetchDataFromServer = useCallback(async (currentFileId: number) => { try { const jwt = await useJWTStore.getState().getJWT() if (!jwt) { @@ -47,7 +55,7 @@ export function useBoardDataManager() { return null } - const url = generateUrl(`apps/whiteboard/${fileId}`) + const url = generateUrl(`apps/whiteboard/${currentFileId}`) const response = await fetch(url, { method: 'GET', headers: { @@ -75,7 +83,6 @@ export function useBoardDataManager() { } }, []) - // Cleanup function to cancel all pending timeouts const cancelPendingTimeouts = useCallback(() => { loadingTimeoutsRef.current.forEach(timeout => clearTimeout(timeout)) loadingTimeoutsRef.current.clear() @@ -124,7 +131,7 @@ export function useBoardDataManager() { } } - if (!parsedContent || !Array.isArray(parsedContent.elements)) { + if (!parsedContent) { logger.warn('[BoardDataManager] Version content missing elements array, falling back to defaults', { versionSource, }) @@ -133,17 +140,18 @@ export function useBoardDataManager() { return } - const sanitizedAppState = sanitizeAppStateForSync(parsedContent.appState) + const versionSnapshot = extractSnapshotFromPersistedBoard(parsedContent) + const sanitizedAppState = sanitizeAppStateForSync(versionSnapshot.appState) const finalAppState = { ...initialDataState.appState, ...sanitizedAppState, } resolveInitialData({ - elements: parsedContent.elements, - files: parsedContent.files || {}, + elements: versionSnapshot.elements, + files: versionSnapshot.files || {}, appState: finalAppState, - scrollToContent: parsedContent.scrollToContent ?? true, + scrollToContent: versionSnapshot.scrollToContent, }) setIsLoading(false) } catch (error) { @@ -156,12 +164,12 @@ export function useBoardDataManager() { if (!fileId) { logger.warn('[BoardDataManager] No fileId provided, cannot load data') + resetPersistedMetadata() resolveInitialData(initialDataState) setIsLoading(false) return } - // Store the current fileId to validate later currentFileIdRef.current = fileId try { @@ -172,121 +180,23 @@ export function useBoardDataManager() { } const localData = await db.get(fileId) - const hasPendingLocalChanges = localData?.hasPendingLocalChanges ?? false - - // Validate that we're still loading the same file if (currentFileIdRef.current !== fileId) { return } - // ALWAYS fetch from server to get latest data const serverData = await fetchDataFromServer(fileId) - - // Validate that we're still loading the same file - if (currentFileIdRef.current !== fileId) { - return - } - - let dataToUse = null - - if (serverData && serverData.elements && Array.isArray(serverData.elements)) { - // Server has data - const { restoreElements } = await import('@nextcloud/excalidraw') - - const restoredServerElements = restoreElements(serverData.elements, null) - const serverHash = computeElementVersionHash(restoredServerElements) - const serverScrollToContent = serverData.scrollToContent ?? true - const sanitizedServerAppState = sanitizeAppStateForSync(serverData.appState) - const sanitizedLocalAppState = sanitizeAppStateForSync(localData?.appState) - - if (localData && localData.elements && Array.isArray(localData.elements) && hasPendingLocalChanges) { - // Local has pending changes – reconcile to avoid losing unsynced work - const restoredLocalElements = restoreElements(localData.elements, null) - const reconciledElements = mergeSceneElements(restoredLocalElements, restoredServerElements, {}) - - const mergedFiles = { ...localData.files, ...serverData.files } - const mergedAppState = { ...sanitizedLocalAppState, ...sanitizedServerAppState } - - dataToUse = { - elements: reconciledElements, - files: mergedFiles, - appState: mergedAppState, - scrollToContent: serverScrollToContent, - } - - await db.put( - fileId, - reconciledElements, - mergedFiles || {}, - mergedAppState, - { - hasPendingLocalChanges: true, - lastSyncedHash: serverHash, - }, - ) - } else { - // Use server content as source of truth (restores, clean loads, etc.) - const mergedAppState = { ...sanitizedLocalAppState, ...sanitizedServerAppState } - const files = serverData.files || {} - - dataToUse = { - ...serverData, - files, - appState: mergedAppState, - scrollToContent: serverScrollToContent, - } - - await db.put( - fileId, - serverData.elements, - files, - mergedAppState, - { - hasPendingLocalChanges: false, - lastSyncedHash: serverHash, - }, - ) - } - } else if (localData && localData.elements) { - // Only local has data - dataToUse = localData - } else { - // No data from either source - dataToUse = null - } - - // Final validation before resolving data if (currentFileIdRef.current !== fileId) { return } - // Use the reconciled/fetched data - if (dataToUse && dataToUse.elements) { - const elements = dataToUse.elements - const sanitizedAppState = sanitizeAppStateForSync(dataToUse.appState) - const finalAppState = { ...defaultSettings, ...sanitizedAppState } - const files = dataToUse.files || {} + const boardState = resolveBoardLoadState({ + localBoard: localData, + serverBoard: serverData, + }) - // Force a small delay to ensure the component is ready to receive the data - const timeout = setTimeout(() => { - // Validate one more time before resolving - if (currentFileIdRef.current === fileId) { - resolveInitialData({ - elements, - appState: finalAppState, - files, - scrollToContent: dataToUse.scrollToContent ?? true, - }) - setIsLoading(false) - } - loadingTimeoutsRef.current.delete(timeout) - }, 50) - loadingTimeoutsRef.current.add(timeout) - } else { - // No valid data from either source, use defaults - // Force a small delay to ensure the component is ready to receive the data + if (!boardState) { + resetPersistedMetadata() const timeout = setTimeout(() => { - // Validate one more time before resolving if (currentFileIdRef.current === fileId) { resolveInitialData(initialDataState) setIsLoading(false) @@ -294,13 +204,46 @@ export function useBoardDataManager() { loadingTimeoutsRef.current.delete(timeout) }, 50) loadingTimeoutsRef.current.add(timeout) + return } + + await db.put( + fileId, + boardState.snapshot.elements, + boardState.snapshot.files || {}, + boardState.snapshot.appState, + { + scrollToContent: boardState.snapshot.scrollToContent, + hasPendingLocalChanges: boardState.hasPendingLocalChanges, + lastSyncedHash: boardState.lastSyncedHash, + persistedRev: boardState.meta.persistedRev, + lastServerUpdatedAt: boardState.meta.updatedAt, + lastServerUpdatedBy: boardState.meta.updatedBy, + }, + ) + + setPersistedMetadata(boardState.meta) + + const sanitizedAppState = sanitizeAppStateForSync(boardState.snapshot.appState) + const finalAppState = { ...defaultSettings, ...sanitizedAppState } + const timeout = setTimeout(() => { + if (currentFileIdRef.current === fileId) { + resolveInitialData({ + elements: boardState.snapshot.elements, + appState: finalAppState, + files: boardState.snapshot.files || {}, + scrollToContent: boardState.snapshot.scrollToContent, + }) + setIsLoading(false) + } + loadingTimeoutsRef.current.delete(timeout) + }, 50) + loadingTimeoutsRef.current.add(timeout) } catch (error) { logger.error('[BoardDataManager] Error loading data:', error) - // Force a small delay to ensure the component is ready to receive the data const timeout = setTimeout(() => { - // Validate one more time before resolving if (currentFileIdRef.current === fileId) { + resetPersistedMetadata() resolveInitialData(initialDataState) setIsLoading(false) } @@ -308,7 +251,17 @@ export function useBoardDataManager() { }, 50) loadingTimeoutsRef.current.add(timeout) } - }, [fileId, resolveInitialData, fetchDataFromServer, isVersionPreview, versionSource, fileVersion]) + }, [ + fileId, + fileVersion, + fetchDataFromServer, + isVersionPreview, + resetPersistedMetadata, + resetInitialDataPromise, + resolveInitialData, + setPersistedMetadata, + versionSource, + ]) const saveOnUnmount = useCallback(() => { if (useWhiteboardConfigStore.getState().isVersionPreview) { @@ -319,7 +272,6 @@ export function useBoardDataManager() { const currentIsReadOnly = useWhiteboardConfigStore.getState().isReadOnly if (api && !currentIsReadOnly) { - const currentFileId = useWhiteboardConfigStore.getState().fileId const currentWorker = useSyncStore.getState().worker const currentIsWorkerReady = useSyncStore.getState().isWorkerReady @@ -331,7 +283,6 @@ export function useBoardDataManager() { const files = api.getFiles() const filteredAppState = sanitizeAppStateForSync(appState) - // Set up a one-time message handler to detect when sync is complete const messageHandler = (event: MessageEvent) => { if (event.data.type === 'LOCAL_SYNC_COMPLETE') { currentWorker.removeEventListener('message', messageHandler) @@ -341,19 +292,18 @@ export function useBoardDataManager() { } } - // Add the message handler currentWorker.addEventListener('message', messageHandler) - - // Send the sync message currentWorker.postMessage({ type: 'SYNC_TO_LOCAL', fileId: currentFileId, elements, files, appState: filteredAppState, + scrollToContent: typeof appState.scrollToContent === 'boolean' + ? appState.scrollToContent + : true, }) - // Set a timeout to remove the handler after 500ms in case we don't get a response setTimeout(() => { currentWorker.removeEventListener('message', messageHandler) }, 500) @@ -364,7 +314,6 @@ export function useBoardDataManager() { } }, []) - // Load data when fileId changes useEffect(() => { const shouldLoad = ( (isVersionPreview && !!versionSource) @@ -372,13 +321,9 @@ export function useBoardDataManager() { ) if (shouldLoad) { - // Cancel any pending timeouts from previous loads cancelPendingTimeouts() - - // Reset the initialDataPromise to ensure clean state resetInitialDataPromise() - // Clear any existing Excalidraw data const api = useExcalidrawStore.getState().excalidrawAPI if (api) { api.resetScene() @@ -389,7 +334,6 @@ export function useBoardDataManager() { } }, [fileId, fileVersion, isVersionPreview, versionSource, loadBoard, cancelPendingTimeouts, resetInitialDataPromise]) - // Cleanup on unmount useEffect(() => { return () => { cancelPendingTimeouts() diff --git a/src/hooks/useCollaboration.ts b/src/hooks/useCollaboration.ts index 054c6504..7b076077 100644 --- a/src/hooks/useCollaboration.ts +++ b/src/hooks/useCollaboration.ts @@ -571,14 +571,19 @@ export function useCollaboration() { // Persist authoritative snapshot locally to avoid stale IndexedDB data if (fileId) { try { + const existing = await db.get(fileId) await db.put( fileId, restoredElements, files || {}, appStatePatch, { + scrollToContent, hasPendingLocalChanges: false, lastSyncedHash: computeElementVersionHash(restoredElements), + persistedRev: existing?.persistedRev ?? 0, + lastServerUpdatedAt: existing?.lastServerUpdatedAt ?? null, + lastServerUpdatedBy: existing?.lastServerUpdatedBy ?? null, }, ) } catch (persistError) { diff --git a/src/hooks/useSync.ts b/src/hooks/useSync.ts index 68265add..6d10cf88 100644 --- a/src/hooks/useSync.ts +++ b/src/hooks/useSync.ts @@ -13,6 +13,10 @@ import { useCollaborationStore } from '../stores/useCollaborationStore' import { generateUrl } from '@nextcloud/router' import { useShallow } from 'zustand/react/shallow' import logger from '../utils/logger' +import { + normalizePersistedBoardDocument, + normalizePersistedBoardMeta, +} from '../utils/persistedBoardData' import { sanitizeAppStateForSync } from '../utils/sanitizeAppState' import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types' import type { BinaryFiles } from '@excalidraw/excalidraw/types/types' @@ -46,12 +50,14 @@ export function useSync() { terminateWorker, isWorkerReady, worker, + resetPersistedMetadata, } = useSyncStore( useShallow(state => ({ initializeWorker: state.initializeWorker, terminateWorker: state.terminateWorker, isWorkerReady: state.isWorkerReady, worker: state.worker, + resetPersistedMetadata: state.resetPersistedMetadata, })), ) @@ -89,7 +95,8 @@ export function useSync() { // Reset prevSyncedFilesRef when fileId changes to prevent leakage across files useEffect(() => { prevSyncedFilesRef.current = {} - }, [fileId]) // Depends on fileId from the hook scope + resetPersistedMetadata() + }, [fileId, resetPersistedMetadata]) // Depends on fileId from the hook scope // --- Sync Logic --- @@ -105,7 +112,16 @@ export function useSync() { const files = excalidrawAPI.getFiles() as BinaryFiles const filteredAppState = sanitizeAppStateForSync(appState) - const message: WorkerInboundMessage = { type: 'SYNC_TO_LOCAL', fileId, elements, files, appState: filteredAppState } + const message: WorkerInboundMessage = { + type: 'SYNC_TO_LOCAL', + fileId, + elements, + files, + appState: filteredAppState, + scrollToContent: typeof appState.scrollToContent === 'boolean' + ? appState.scrollToContent + : true, + } worker.postMessage(message) logSyncResult('local', { status: 'syncing' }) } catch (error) { @@ -146,6 +162,7 @@ export function useSync() { const elements = excalidrawAPI.getSceneElementsIncludingDeleted() as readonly ExcalidrawElement[] const files = excalidrawAPI.getFiles() as BinaryFiles + const appState = excalidrawAPI.getAppState() const message: WorkerInboundMessage = { type: 'SYNC_TO_SERVER', @@ -154,6 +171,10 @@ export function useSync() { jwt, elements, files, + appState: sanitizeAppStateForSync(appState), + scrollToContent: typeof appState.scrollToContent === 'boolean' + ? appState.scrollToContent + : true, } worker.postMessage(message) @@ -323,7 +344,15 @@ export function useSync() { if (excalidrawAPI) { const elements = excalidrawAPI.getSceneElementsIncludingDeleted() const files = excalidrawAPI.getFiles() - cachedStateRef.current = { elements, files } + const appState = excalidrawAPI.getAppState() + cachedStateRef.current = { + elements, + files, + appState: sanitizeAppStateForSync(appState), + scrollToContent: typeof appState.scrollToContent === 'boolean' + ? appState.scrollToContent + : true, + } } throttledSyncToLocal() @@ -363,7 +392,17 @@ export function useSync() { }, [isDedicatedSyncer]) // Cache the latest state for final sync - update on EVERY change - const cachedStateRef = useRef<{ elements: readonly ExcalidrawElement[]; files: BinaryFiles }>({ elements: [], files: {} as BinaryFiles }) + const cachedStateRef = useRef<{ + elements: readonly ExcalidrawElement[] + files: BinaryFiles + appState: ReturnType + scrollToContent: boolean + }>({ + elements: [], + files: {} as BinaryFiles, + appState: {}, + scrollToContent: true, + }) // Direct sync when leaving - synchronous to ensure it completes const doFinalServerSync = useCallback(() => { @@ -383,15 +422,29 @@ export function useSync() { return } - // Use CACHED state instead of trying to get it now (might be cleared already) - const { elements, files } = cachedStateRef.current + const api = useExcalidrawStore.getState().excalidrawAPI + const currentRuntimeMeta = useSyncStore.getState() + const snapshot = api ? { + elements: api.getSceneElementsIncludingDeleted(), + files: api.getFiles() as BinaryFiles, + appState: sanitizeAppStateForSync(api.getAppState()), + scrollToContent: typeof api.getAppState().scrollToContent === 'boolean' + ? api.getAppState().scrollToContent + : true, + } : cachedStateRef.current // eslint-disable-next-line no-console - console.log('[Sync] Using cached state with', elements.length, 'elements') + console.log('[Sync] Using cached state with', snapshot.elements.length, 'elements') const url = generateUrl(`apps/whiteboard/${fileId}`) const data = JSON.stringify({ - data: { elements, files: files || {} }, + data: { + baseRev: currentRuntimeMeta.persistedRev, + elements: snapshot.elements, + files: snapshot.files || {}, + appState: snapshot.appState, + scrollToContent: snapshot.scrollToContent, + }, }) // Use synchronous XMLHttpRequest (works in beforeunload) @@ -402,6 +455,20 @@ export function useSync() { xhr.setRequestHeader('Authorization', `Bearer ${jwt}`) xhr.send(data) + if (xhr.responseText) { + try { + const responseData = JSON.parse(xhr.responseText) + if (xhr.status >= 200 && xhr.status < 300) { + const responseMeta = normalizePersistedBoardMeta(responseData?.meta) + useSyncStore.getState().setPersistedMetadata(responseMeta) + } else if (xhr.status === 409) { + const conflictDocument = normalizePersistedBoardDocument(responseData?.data) + useSyncStore.getState().setPersistedMetadata(conflictDocument.meta) + } + } catch (parseError) { + logger.warn('[Sync] Failed to parse final sync response', parseError) + } + } // eslint-disable-next-line no-console console.log('[Sync] Final sync done, status:', xhr.status) } catch (error) { diff --git a/src/hooks/useVersionPreview.ts b/src/hooks/useVersionPreview.ts index 3395dfd8..19d1138d 100644 --- a/src/hooks/useVersionPreview.ts +++ b/src/hooks/useVersionPreview.ts @@ -13,10 +13,16 @@ import { showError, showSuccess } from '@nextcloud/dialogs' import { useShallow } from 'zustand/react/shallow' import { useWhiteboardConfigStore } from '../stores/useWhiteboardConfigStore' import { useJWTStore } from '../stores/useJwtStore' +import { useSyncStore } from '../stores/useSyncStore' import { db } from '../database/db' import { computeElementVersionHash } from '../utils/syncSceneData' import { useCollaborationStore } from '../stores/useCollaborationStore' import logger from '../utils/logger' +import { + areSnapshotsEquivalent, + normalizePersistedBoardDocument, + type PersistedBoardMeta, +} from '../utils/persistedBoardData' import { sanitizeAppStateForSync } from '../utils/sanitizeAppState' import { generateUrl } from '@nextcloud/router' @@ -26,13 +32,7 @@ type RestoredSnapshot = { files: BinaryFiles appState: Partial scrollToContent: boolean -} - -type ParsedVersionContent = { - elements?: unknown - files?: unknown - appState?: unknown - scrollToContent?: boolean + meta?: PersistedBoardMeta } interface UseVersionPreviewOptions { @@ -154,26 +154,6 @@ export function useVersionPreview({ const captureRestoredSnapshot = useCallback(async (sourceOverride?: string | null): Promise => { try { - if (!sourceOverride && excalidrawAPI) { - const rawElements = excalidrawAPI.getSceneElementsIncludingDeleted?.() || [] - const sanitizedElements = restoreElements(rawElements, null) as ExcalidrawElement[] - const rawFiles = excalidrawAPI.getFiles?.() || {} - const filesCopy: BinaryFiles = { ...rawFiles } - const rawAppState = excalidrawAPI.getAppState?.() || {} - const appStateCopy = sanitizeAppStateForSync(rawAppState) - appStateCopy.viewModeEnabled = false - const scrollToContent = typeof rawAppState.scrollToContent === 'boolean' - ? rawAppState.scrollToContent - : true - - return { - elements: sanitizedElements, - files: filesCopy, - appState: appStateCopy, - scrollToContent, - } - } - const effectiveSource = sourceOverride ?? currentVersionSource if (effectiveSource) { const response = await fetch(effectiveSource, { @@ -195,37 +175,59 @@ export function useVersionPreview({ files: {}, appState: {}, scrollToContent: true, + meta: undefined, } } - let parsedContent: ParsedVersionContent | null = null + let parsedContent: unknown = null try { - parsedContent = JSON.parse(rawContent) as ParsedVersionContent - } catch (error) { + parsedContent = JSON.parse(rawContent) + } catch { throw new Error('Failed to parse version content JSON') } - if (!parsedContent || !Array.isArray(parsedContent.elements)) { + if (!parsedContent) { throw new Error('Version content is missing elements array') } - const sanitizedElements = restoreElements(parsedContent.elements as ExcalidrawElement[], null) as ExcalidrawElement[] - const rawFiles = (parsedContent.files && typeof parsedContent.files === 'object') - ? parsedContent.files - : {} - const files = rawFiles as BinaryFiles - const rawAppState = (parsedContent.appState && typeof parsedContent.appState === 'object') - ? parsedContent.appState - : {} - const parsedAppState = sanitizeAppStateForSync(rawAppState) + const persistedDocument = normalizePersistedBoardDocument(parsedContent) + const sanitizedElements = restoreElements(persistedDocument.elements, null) as ExcalidrawElement[] + const parsedAppState = sanitizeAppStateForSync(persistedDocument.appState) const appStateCopy: Partial = { ...parsedAppState } appStateCopy.viewModeEnabled = false return { elements: sanitizedElements, - files, + files: persistedDocument.files, + appState: appStateCopy, + scrollToContent: persistedDocument.scrollToContent, + meta: persistedDocument.meta, + } + } + + if (excalidrawAPI) { + const rawElements = excalidrawAPI.getSceneElementsIncludingDeleted?.() || [] + const sanitizedElements = restoreElements(rawElements, null) as ExcalidrawElement[] + const rawFiles = excalidrawAPI.getFiles?.() || {} + const filesCopy: BinaryFiles = { ...rawFiles } + const rawAppState = excalidrawAPI.getAppState?.() || {} + const appStateCopy = sanitizeAppStateForSync(rawAppState) + appStateCopy.viewModeEnabled = false + const scrollToContent = typeof rawAppState.scrollToContent === 'boolean' + ? rawAppState.scrollToContent + : true + const runtimeSyncState = useSyncStore.getState() + + return { + elements: sanitizedElements, + files: filesCopy, appState: appStateCopy, - scrollToContent: parsedContent.scrollToContent ?? true, + scrollToContent, + meta: { + persistedRev: runtimeSyncState.persistedRev, + updatedAt: runtimeSyncState.lastServerUpdatedAt, + updatedBy: runtimeSyncState.lastServerUpdatedBy, + }, } } } catch (error) { @@ -310,6 +312,12 @@ export function useVersionPreview({ viewModeEnabled: false, } const filesToStore: BinaryFiles = files || {} + const existing = await db.get(fileId) + const localMeta = snapshot.meta ?? { + persistedRev: existing?.persistedRev ?? 0, + updatedAt: existing?.lastServerUpdatedAt ?? null, + updatedBy: existing?.lastServerUpdatedBy ?? null, + } await db.put( fileId, @@ -317,10 +325,15 @@ export function useVersionPreview({ filesToStore, sanitizedAppState, { + scrollToContent, hasPendingLocalChanges: false, lastSyncedHash: computeElementVersionHash(elements), + persistedRev: localMeta.persistedRev, + lastServerUpdatedAt: localMeta.updatedAt, + lastServerUpdatedBy: localMeta.updatedBy, }, ) + useSyncStore.getState().setPersistedMetadata(localMeta) try { const jwt = await getJWT() @@ -334,6 +347,7 @@ export function useVersionPreview({ }, body: JSON.stringify({ data: { + baseRev: useSyncStore.getState().persistedRev, elements, files: filesToStore, appState: sanitizedAppState, @@ -345,6 +359,66 @@ export function useVersionPreview({ if (!response.ok && response.status !== 409) { throw new Error(`Unexpected status ${response.status}`) } + + if (response.status === 409) { + const conflictResponse = await response.json() + const conflictDocument = normalizePersistedBoardDocument(conflictResponse?.data) + + if (areSnapshotsEquivalent(snapshot, conflictDocument)) { + await db.put( + fileId, + elements, + filesToStore, + sanitizedAppState, + { + scrollToContent, + hasPendingLocalChanges: false, + lastSyncedHash: computeElementVersionHash(conflictDocument.elements), + persistedRev: conflictDocument.meta.persistedRev, + lastServerUpdatedAt: conflictDocument.meta.updatedAt, + lastServerUpdatedBy: conflictDocument.meta.updatedBy, + }, + ) + useSyncStore.getState().setPersistedMetadata(conflictDocument.meta) + } else { + await db.put( + fileId, + conflictDocument.elements, + conflictDocument.files, + conflictDocument.appState, + { + scrollToContent: conflictDocument.scrollToContent, + hasPendingLocalChanges: false, + lastSyncedHash: computeElementVersionHash(conflictDocument.elements), + persistedRev: conflictDocument.meta.persistedRev, + lastServerUpdatedAt: conflictDocument.meta.updatedAt, + lastServerUpdatedBy: conflictDocument.meta.updatedBy, + }, + ) + useSyncStore.getState().setPersistedMetadata(conflictDocument.meta) + logger.warn('[useVersionPreview] Restored snapshot diverged from durable server state, using server document') + } + } else { + const responseData = await response.json() + const responseMeta = normalizePersistedBoardDocument({ + meta: responseData?.meta, + }).meta + await db.put( + fileId, + elements, + filesToStore, + sanitizedAppState, + { + scrollToContent, + hasPendingLocalChanges: false, + lastSyncedHash: computeElementVersionHash(elements), + persistedRev: responseMeta.persistedRev, + lastServerUpdatedAt: responseMeta.updatedAt, + lastServerUpdatedBy: responseMeta.updatedBy, + }, + ) + useSyncStore.getState().setPersistedMetadata(responseMeta) + } } else { logger.warn('[useVersionPreview] Skipping server sync for restored version due to missing JWT') } diff --git a/src/stores/useSyncStore.ts b/src/stores/useSyncStore.ts index 77a206dd..66fe880e 100644 --- a/src/stores/useSyncStore.ts +++ b/src/stores/useSyncStore.ts @@ -5,6 +5,7 @@ import { create } from 'zustand' import logger from '../utils/logger' +import { useWhiteboardConfigStore } from './useWhiteboardConfigStore' export type SyncOperation = 'local' | 'server' | 'websocket' | 'cursor' @@ -21,10 +22,19 @@ interface SyncStore { // Core state worker: Worker | null isWorkerReady: boolean + persistedRev: number + lastServerUpdatedAt: number | null + lastServerUpdatedBy: string | null // Actions setWorker: (worker: Worker | null) => void setIsWorkerReady: (ready: boolean) => void + setPersistedMetadata: (meta: { + persistedRev?: number + updatedAt?: number | null + updatedBy?: string | null + }) => void + resetPersistedMetadata: () => void // Worker functions initializeWorker: () => Worker | null @@ -35,6 +45,9 @@ export const useSyncStore = create((set, get) => ({ // State worker: null, isWorkerReady: false, + persistedRev: 0, + lastServerUpdatedAt: null, + lastServerUpdatedBy: null, // Actions setWorker: (worker) => set({ worker }), @@ -43,6 +56,26 @@ export const useSyncStore = create((set, get) => ({ set({ isWorkerReady: ready }) }, + setPersistedMetadata: (meta) => { + set((state) => ({ + persistedRev: meta.persistedRev ?? state.persistedRev, + lastServerUpdatedAt: Object.prototype.hasOwnProperty.call(meta, 'updatedAt') + ? meta.updatedAt ?? null + : state.lastServerUpdatedAt, + lastServerUpdatedBy: Object.prototype.hasOwnProperty.call(meta, 'updatedBy') + ? meta.updatedBy ?? null + : state.lastServerUpdatedBy, + })) + }, + + resetPersistedMetadata: () => { + set({ + persistedRev: 0, + lastServerUpdatedAt: null, + lastServerUpdatedBy: null, + }) + }, + // Worker functions initializeWorker: () => { @@ -66,6 +99,9 @@ export const useSyncStore = create((set, get) => ({ if (syncWorker) { syncWorker.onmessage = (event) => { const { type, ...data } = event.data + const currentFileId = useWhiteboardConfigStore.getState().fileId + const messageFileId = typeof data.fileId === 'number' ? data.fileId : null + const isActiveFileMessage = messageFileId === null || messageFileId === currentFileId switch (type) { case 'INIT_COMPLETE': @@ -91,14 +127,36 @@ export const useSyncStore = create((set, get) => ({ break case 'SERVER_SYNC_COMPLETE': + if (isActiveFileMessage) { + get().setPersistedMetadata({ + persistedRev: data.persistedRev, + updatedAt: data.updatedAt, + updatedBy: data.updatedBy, + }) + } // Use the imported logSyncResult function logSyncResult('server', { - status: 'success', + status: data.conflict ? 'success after conflict' : 'success', elementsCount: data.elementsCount, error: null, }) break + case 'SERVER_SYNC_CONFLICT': + if (isActiveFileMessage) { + get().setPersistedMetadata({ + persistedRev: data.persistedRev, + updatedAt: data.updatedAt, + updatedBy: data.updatedBy, + }) + } + logger.warn('[SyncStore] Worker server sync conflict:', data.error) + logSyncResult('server', { + status: 'conflict', + error: data.error, + }) + break + case 'SERVER_SYNC_ERROR': logger.error('[SyncStore] Worker server sync error:', data.error) // Use the imported logSyncResult function @@ -135,6 +193,9 @@ export const useSyncStore = create((set, get) => ({ set({ worker: null, isWorkerReady: false, + persistedRev: 0, + lastServerUpdatedAt: null, + lastServerUpdatedBy: null, }) } }, diff --git a/src/types/protocol.ts b/src/types/protocol.ts index 1223837e..e8e2e8ac 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -14,6 +14,7 @@ export type WorkerInboundMessage = elements: readonly ExcalidrawElement[] files: BinaryFiles appState?: Partial + scrollToContent?: boolean } | { type: 'SYNC_TO_SERVER' @@ -22,14 +23,36 @@ export type WorkerInboundMessage = jwt: string elements: readonly ExcalidrawElement[] files: BinaryFiles + appState?: Partial + scrollToContent?: boolean } export type WorkerOutboundMessage = | { type: 'INIT_COMPLETE' } | { type: 'INIT_ERROR'; error: string } - | { type: 'LOCAL_SYNC_COMPLETE'; duration: number; elementsCount: number } - | { type: 'LOCAL_SYNC_ERROR'; error: string } - | { type: 'SERVER_SYNC_COMPLETE'; duration: number; elementsCount: number; success: boolean; response?: unknown; skipped?: boolean } - | { type: 'SERVER_SYNC_ERROR'; error: string } + | { type: 'LOCAL_SYNC_COMPLETE'; fileId: number; duration: number; elementsCount: number } + | { type: 'LOCAL_SYNC_ERROR'; fileId?: number; error: string } + | { + type: 'SERVER_SYNC_COMPLETE' + fileId: number + duration: number + elementsCount: number + success: boolean + response?: unknown + skipped?: boolean + conflict?: boolean + persistedRev?: number + updatedAt?: number | null + updatedBy?: string | null + } + | { + type: 'SERVER_SYNC_CONFLICT' + fileId: number + error?: string + persistedRev?: number + updatedAt?: number | null + updatedBy?: string | null + } + | { type: 'SERVER_SYNC_ERROR'; fileId?: number; error: string } export type WorkerMessage = WorkerInboundMessage | WorkerOutboundMessage diff --git a/src/utils/persistedBoardData.ts b/src/utils/persistedBoardData.ts new file mode 100644 index 00000000..d6ac7e0c --- /dev/null +++ b/src/utils/persistedBoardData.ts @@ -0,0 +1,256 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types' +import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types/types' +import type { WhiteboardData } from '../database/db' +import { sanitizeAppStateForSync } from './sanitizeAppState' +import { computeElementVersionHash, mergeSceneElements } from './syncSceneData' + +export interface PersistedBoardMeta { + persistedRev: number + updatedAt: number | null + updatedBy: string | null +} + +export interface PersistedBoardSnapshot { + elements: ExcalidrawElement[] + files: BinaryFiles + appState: Partial + scrollToContent: boolean +} + +export interface PersistedBoardDocument extends PersistedBoardSnapshot { + meta: PersistedBoardMeta +} + +type PersistedBoardInput = Partial & { + data?: unknown +} + +type BoardLoadResolution = { + snapshot: PersistedBoardSnapshot + meta: PersistedBoardMeta + hasPendingLocalChanges: boolean + lastSyncedHash: number +} + +const DEFAULT_META: PersistedBoardMeta = { + persistedRev: 0, + updatedAt: null, + updatedBy: null, +} + +const isRecord = (value: unknown): value is Record => ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) +) + +const coerceNonNegativeInteger = (value: unknown, fallback: number): number => ( + Number.isInteger(value) && Number(value) >= 0 + ? Number(value) + : fallback +) + +const coerceNullableNumber = (value: unknown): number | null => ( + typeof value === 'number' && Number.isFinite(value) + ? value + : null +) + +const coerceNullableString = (value: unknown): string | null => ( + typeof value === 'string' && value.length > 0 + ? value + : null +) + +const normalizeElements = (value: unknown): ExcalidrawElement[] => ( + Array.isArray(value) + ? value.filter(isRecord) as ExcalidrawElement[] + : [] +) + +const normalizeFiles = (value: unknown): BinaryFiles => { + if (!isRecord(value)) { + return {} + } + + const files: BinaryFiles = {} + for (const [key, file] of Object.entries(value)) { + if (file && isRecord(file)) { + files[key] = file as BinaryFiles[string] + } + } + + return Object.fromEntries( + Object.entries(files).sort(([left], [right]) => left.localeCompare(right)), + ) as BinaryFiles +} + +const resolveOptionalScrollToContent = (value: unknown): boolean | undefined => { + if (!isRecord(value)) { + return undefined + } + + if (typeof value.scrollToContent === 'boolean') { + return value.scrollToContent + } + + if (isRecord(value.appState) && typeof value.appState.scrollToContent === 'boolean') { + return value.appState.scrollToContent + } + + return undefined +} + +const normalizeAppState = (value: unknown): Partial => ( + isRecord(value) + ? sanitizeAppStateForSync(value as Partial) + : {} +) + +const unwrapPersistedBoardValue = (value: unknown): unknown => { + if (!isRecord(value) || !isRecord(value.data)) { + return value + } + + return value.data +} + +const canonicalizeValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(item => canonicalizeValue(item)) + } + + if (!isRecord(value)) { + return value + } + + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nestedValue]) => [key, canonicalizeValue(nestedValue)]), + ) +} + +export const normalizePersistedBoardMeta = (value: unknown): PersistedBoardMeta => { + if (!isRecord(value)) { + return { ...DEFAULT_META } + } + + return { + persistedRev: coerceNonNegativeInteger(value.persistedRev, DEFAULT_META.persistedRev), + updatedAt: coerceNullableNumber(value.updatedAt), + updatedBy: coerceNullableString(value.updatedBy), + } +} + +export const normalizePersistedBoardDocument = (value: unknown): PersistedBoardDocument => { + const rawValue = unwrapPersistedBoardValue(value) + const document = isRecord(rawValue) ? rawValue as PersistedBoardInput : {} + const scrollToContent = resolveOptionalScrollToContent(document) ?? true + + return { + meta: normalizePersistedBoardMeta(document.meta), + elements: normalizeElements(document.elements), + files: normalizeFiles(document.files), + appState: normalizeAppState(document.appState), + scrollToContent, + } +} + +export const extractSnapshotFromPersistedBoard = (value: unknown): PersistedBoardSnapshot => { + const document = normalizePersistedBoardDocument(value) + + return { + elements: document.elements, + files: document.files, + appState: document.appState, + scrollToContent: document.scrollToContent, + } +} + +export const mergeLocalPendingWithServerSnapshot = ( + localValue: unknown, + serverValue: unknown, +): PersistedBoardSnapshot => { + const localSnapshot = extractSnapshotFromPersistedBoard(localValue) + const serverSnapshot = extractSnapshotFromPersistedBoard(serverValue) + const localScrollToContent = resolveOptionalScrollToContent(unwrapPersistedBoardValue(localValue)) + + return { + elements: mergeSceneElements( + localSnapshot.elements, + serverSnapshot.elements, + localSnapshot.appState as AppState, + ), + files: { + ...serverSnapshot.files, + ...localSnapshot.files, + }, + appState: { + ...serverSnapshot.appState, + ...localSnapshot.appState, + }, + scrollToContent: localScrollToContent ?? serverSnapshot.scrollToContent, + } +} + +export const areSnapshotsEquivalent = (leftValue: unknown, rightValue: unknown): boolean => { + const leftSnapshot = extractSnapshotFromPersistedBoard(leftValue) + const rightSnapshot = extractSnapshotFromPersistedBoard(rightValue) + + if (computeElementVersionHash(leftSnapshot.elements) !== computeElementVersionHash(rightSnapshot.elements)) { + return false + } + + return JSON.stringify(canonicalizeValue(leftSnapshot)) === JSON.stringify(canonicalizeValue(rightSnapshot)) +} + +export const resolveBoardLoadState = ({ + localBoard, + serverBoard, +}: { + localBoard?: WhiteboardData | null + serverBoard?: unknown | null +}): BoardLoadResolution | null => { + if (serverBoard) { + const serverDocument = normalizePersistedBoardDocument(serverBoard) + const hasPendingLocalChanges = Boolean(localBoard?.hasPendingLocalChanges) + const hasLocalScene = Array.isArray(localBoard?.elements) + + if (hasPendingLocalChanges && hasLocalScene) { + return { + snapshot: mergeLocalPendingWithServerSnapshot(localBoard, serverDocument), + meta: serverDocument.meta, + hasPendingLocalChanges: true, + lastSyncedHash: computeElementVersionHash(serverDocument.elements), + } + } + + return { + snapshot: extractSnapshotFromPersistedBoard(serverDocument), + meta: serverDocument.meta, + hasPendingLocalChanges: false, + lastSyncedHash: computeElementVersionHash(serverDocument.elements), + } + } + + if (!localBoard || !Array.isArray(localBoard.elements)) { + return null + } + + return { + snapshot: extractSnapshotFromPersistedBoard(localBoard), + meta: normalizePersistedBoardMeta({ + persistedRev: localBoard.persistedRev, + updatedAt: localBoard.lastServerUpdatedAt, + updatedBy: localBoard.lastServerUpdatedBy, + }), + hasPendingLocalChanges: localBoard.hasPendingLocalChanges ?? false, + lastSyncedHash: localBoard.lastSyncedHash ?? computeElementVersionHash(localBoard.elements), + } +} diff --git a/src/utils/sanitizeAppState.ts b/src/utils/sanitizeAppState.ts index a05f8572..4fe23a18 100644 --- a/src/utils/sanitizeAppState.ts +++ b/src/utils/sanitizeAppState.ts @@ -5,25 +5,26 @@ import type { AppState } from '@excalidraw/excalidraw/types/types' -const NON_TRANSFERRED_KEYS: Array = [ +const NON_TRANSFERRED_KEYS = [ 'collaborators', 'selectedElementIds', 'width', 'height', 'offsetTop', 'offsetLeft', -] +] as const export function sanitizeAppStateForSync(state: Partial | AppState | null | undefined): Partial { if (!state || typeof state !== 'object') { return {} } - const cleaned: Partial = { ...state } + const cleaned = { ...state } as Partial & { scrollToContent?: boolean } NON_TRANSFERRED_KEYS.forEach((key) => { delete (cleaned as Record)[key] }) + delete cleaned.scrollToContent return cleaned } diff --git a/src/workers/syncWorker.ts b/src/workers/syncWorker.ts index df2dfeb9..fd13ff17 100644 --- a/src/workers/syncWorker.ts +++ b/src/workers/syncWorker.ts @@ -4,219 +4,36 @@ */ import { db } from '../database/db' -import { computeElementVersionHash } from '../utils/syncSceneData' -import type { WorkerInboundMessage, WorkerOutboundMessage } from '../types/protocol' +import type { WorkerInboundMessage } from '../types/protocol' +import { createSyncWorkerHandlers } from './syncWorkerCore' const ctx: Worker = self as unknown as Worker -let performance: Performance -try { - performance = self.performance -} catch { - performance = { - now: () => Date.now(), - } as Performance -} - -// Logging disabled in production to reduce noise -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const log = () => { - // No-op -} - -const error = (message: string, ...args: unknown[]) => { +const reportError = (message: string, ...args: unknown[]) => { try { globalThis.console.error(`[SyncWorker] ${message}`, ...args) } catch { - // Ignore logging errors inside worker - } -} - -const sendMessage = (message: WorkerOutboundMessage) => { - try { - ctx.postMessage(message) - } catch (e) { - error(`Failed to send message: ${message.type}`, e) - } -} - -type SyncToLocalMessage = Extract -type SyncToServerMessage = Extract - -const handleSyncToLocal = async (data: SyncToLocalMessage) => { - const { fileId, elements, files, appState } = data - - if (!fileId) { - error('Missing fileId for local sync') - sendMessage({ - type: 'LOCAL_SYNC_ERROR', - error: 'Missing fileId for local sync', - }) - return - } - - const startTime = performance.now() - - try { - const filteredAppState = appState ? { ...appState } : appState - - if (filteredAppState && filteredAppState.collaborators) { - delete filteredAppState.collaborators - } - - await db.put(fileId, elements, files || {}, filteredAppState, { - hasPendingLocalChanges: true, - }) - - const duration = performance.now() - startTime - - sendMessage({ - type: 'LOCAL_SYNC_COMPLETE', - duration, - elementsCount: elements.length, - }) - } catch (e) { - error('Error syncing to local storage:', e) - sendMessage({ - type: 'LOCAL_SYNC_ERROR', - error: e instanceof Error ? e.message : String(e), - }) + // Ignore logging failures inside worker runtime. } } -const handleSyncToServer = async (data: SyncToServerMessage) => { - const { fileId, url, jwt, elements, files } = data - - if (!fileId || !url || !jwt) { - error('Missing required data for server sync', { fileId, url: !!url, jwt: !!jwt }) - sendMessage({ - type: 'SERVER_SYNC_ERROR', - error: 'Missing required data for server sync', - }) - return - } - - const startTime = performance.now() - +const now = () => { try { - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - Authorization: `Bearer ${jwt}`, - } - - const response = await globalThis.fetch(url, { - method: 'PUT', - headers, - body: JSON.stringify({ - data: { elements, files: files || {} }, - }), - }) - - if (response.status === 409) { - sendMessage({ - type: 'SERVER_SYNC_COMPLETE', - success: true, - skipped: true, - duration: 0, - elementsCount: elements?.length ?? 0, - }) - return - } - - if (!response.ok) { - let errorMessage = `Server responded with status: ${response.status}` - try { - const responseText = await response.text() - errorMessage += ` - ${responseText}` - } catch { - // Ignore parse errors - } - throw new Error(errorMessage) - } - - let responseData: unknown - try { - responseData = await response.json() - } catch { - // Non-JSON response still counts as success - } - - try { - const existing = await db.get(fileId) - await db.put( - fileId, - elements, - files || existing?.files || {}, - existing?.appState, - { - hasPendingLocalChanges: false, - lastSyncedHash: computeElementVersionHash(elements || []), - }, - ) - } catch (dbError) { - error('Error updating local metadata after server sync:', dbError) - } - - const duration = performance.now() - startTime - - sendMessage({ - type: 'SERVER_SYNC_COMPLETE', - success: true, - duration, - elementsCount: elements.length, - response: responseData, - }) - } catch (e) { - error('Error syncing to server:', e) - sendMessage({ - type: 'SERVER_SYNC_ERROR', - error: e instanceof Error ? e.message : String(e), - }) - } -} - -const initWorker = () => { - try { - sendMessage({ type: 'INIT_COMPLETE' }) - } catch (e) { - error('Failed to initialize worker:', e) - sendMessage({ - type: 'INIT_ERROR', - error: e instanceof Error ? e.message : String(e), - }) + return self.performance.now() + } catch { + return Date.now() } } -const handleMessage = async (event: MessageEvent) => { - const message = event.data - - try { - switch (message.type) { - case 'INIT': - initWorker() - break - case 'SYNC_TO_LOCAL': - await handleSyncToLocal(message) - break - case 'SYNC_TO_SERVER': - await handleSyncToServer(message) - break - default: - // Unknown message type - ignore - } - } catch (e) { - error(`Error handling message ${message.type}:`, e) - const errorMessage = e instanceof Error ? e.message : String(e) - - if (message.type === 'SYNC_TO_LOCAL') { - sendMessage({ type: 'LOCAL_SYNC_ERROR', error: errorMessage }) - } else if (message.type === 'SYNC_TO_SERVER') { - sendMessage({ type: 'SERVER_SYNC_ERROR', error: errorMessage }) - } else { - sendMessage({ type: 'INIT_ERROR', error: errorMessage }) - } - } -} +const handlers = createSyncWorkerHandlers({ + database: db, + postMessage: (message) => ctx.postMessage(message), + now, + reportError, +}) -ctx.addEventListener('message', handleMessage) +ctx.addEventListener('message', (event: MessageEvent) => { + void handlers.handleMessage(event.data).catch((error) => { + reportError('Unhandled worker message error:', error) + }) +}) diff --git a/src/workers/syncWorkerCore.ts b/src/workers/syncWorkerCore.ts new file mode 100644 index 00000000..a2a72249 --- /dev/null +++ b/src/workers/syncWorkerCore.ts @@ -0,0 +1,335 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { WorkerInboundMessage, WorkerOutboundMessage } from '../types/protocol' +import { + areSnapshotsEquivalent, + extractSnapshotFromPersistedBoard, + mergeLocalPendingWithServerSnapshot, + normalizePersistedBoardDocument, + normalizePersistedBoardMeta, +} from '../utils/persistedBoardData' +import { computeElementVersionHash } from '../utils/syncSceneData' + +type SyncToLocalMessage = Extract +type SyncToServerMessage = Extract + +type WorkerDatabase = { + get: (fileId: number) => Promise | undefined> + put: (...args: any[]) => Promise +} + +export type SyncWorkerDependencies = { + database?: WorkerDatabase + fetchFn?: typeof globalThis.fetch + postMessage: (message: WorkerOutboundMessage) => void + now?: () => number + reportError?: (message: string, ...args: unknown[]) => void +} + +const MAX_CONFLICT_RETRIES = 2 + +const isRecord = (value: unknown): value is Record => ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) +) + +const defaultNow = () => Date.now() + +const getErrorMessage = (error: unknown): string => ( + error instanceof Error + ? error.message + : String(error) +) + +const parseResponseJson = async (response: Response): Promise => { + try { + return await response.json() + } catch { + return undefined + } +} + +const resolveSuccessMeta = ( + responseData: unknown, + fallbackRev: number, + fallbackUpdatedAt: number | null, + fallbackUpdatedBy: string | null, +) => { + if (!isRecord(responseData) || !isRecord(responseData.meta)) { + return { + persistedRev: fallbackRev, + updatedAt: fallbackUpdatedAt, + updatedBy: fallbackUpdatedBy, + } + } + + return normalizePersistedBoardMeta(responseData.meta) +} + +export const createSyncWorkerHandlers = ({ + database, + fetchFn = globalThis.fetch.bind(globalThis), + postMessage, + now = defaultNow, + reportError = () => undefined, +}: SyncWorkerDependencies) => { + if (!database) { + throw new Error('Sync worker database dependency is required') + } + + const sendMessage = (message: WorkerOutboundMessage) => { + try { + postMessage(message) + } catch (error) { + reportError(`Failed to send message: ${message.type}`, error) + } + } + + const handleSyncToLocal = async (data: SyncToLocalMessage) => { + const { fileId, elements, files, appState, scrollToContent } = data + + if (!fileId) { + sendMessage({ + type: 'LOCAL_SYNC_ERROR', + fileId, + error: 'Missing fileId for local sync', + }) + return + } + + const startedAt = now() + + try { + await database.put(fileId, [...elements], files || {}, appState, { + scrollToContent: scrollToContent ?? true, + hasPendingLocalChanges: true, + }) + + sendMessage({ + type: 'LOCAL_SYNC_COMPLETE', + fileId, + duration: now() - startedAt, + elementsCount: elements.length, + }) + } catch (error) { + reportError('Error syncing to local storage:', error) + sendMessage({ + type: 'LOCAL_SYNC_ERROR', + fileId, + error: getErrorMessage(error), + }) + } + } + + const handleSyncToServer = async (data: SyncToServerMessage) => { + const { fileId, url, jwt, elements, files, appState, scrollToContent } = data + + if (!fileId || !url || !jwt) { + sendMessage({ + type: 'SERVER_SYNC_ERROR', + fileId, + error: 'Missing required data for server sync', + }) + return + } + + const startedAt = now() + const existing = await database.get(fileId) + let currentSnapshot = extractSnapshotFromPersistedBoard({ + elements, + files: files || {}, + appState, + scrollToContent, + }) + let currentBaseRev = existing?.persistedRev ?? 0 + let conflictCount = 0 + + try { + while (true) { + const response = await fetchFn(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ + data: { + baseRev: currentBaseRev, + elements: currentSnapshot.elements, + files: currentSnapshot.files, + appState: currentSnapshot.appState, + scrollToContent: currentSnapshot.scrollToContent, + }, + }), + }) + + if (response.status === 409) { + const responseData = await parseResponseJson(response) + const serverDocument = normalizePersistedBoardDocument( + isRecord(responseData) ? responseData.data : undefined, + ) + const serverMeta = serverDocument.meta + + if (areSnapshotsEquivalent(currentSnapshot, serverDocument)) { + await database.put( + fileId, + currentSnapshot.elements, + currentSnapshot.files, + currentSnapshot.appState, + { + scrollToContent: currentSnapshot.scrollToContent, + hasPendingLocalChanges: false, + lastSyncedHash: computeElementVersionHash(serverDocument.elements), + persistedRev: serverMeta.persistedRev, + lastServerUpdatedAt: serverMeta.updatedAt, + lastServerUpdatedBy: serverMeta.updatedBy, + }, + ) + + sendMessage({ + type: 'SERVER_SYNC_COMPLETE', + fileId, + success: true, + conflict: true, + duration: now() - startedAt, + elementsCount: currentSnapshot.elements.length, + response: responseData, + persistedRev: serverMeta.persistedRev, + updatedAt: serverMeta.updatedAt, + updatedBy: serverMeta.updatedBy, + }) + return + } + + const mergedSnapshot = mergeLocalPendingWithServerSnapshot(currentSnapshot, serverDocument) + await database.put( + fileId, + mergedSnapshot.elements, + mergedSnapshot.files, + mergedSnapshot.appState, + { + scrollToContent: mergedSnapshot.scrollToContent, + hasPendingLocalChanges: true, + lastSyncedHash: computeElementVersionHash(serverDocument.elements), + persistedRev: serverMeta.persistedRev, + lastServerUpdatedAt: serverMeta.updatedAt, + lastServerUpdatedBy: serverMeta.updatedBy, + }, + ) + + if (conflictCount >= MAX_CONFLICT_RETRIES) { + sendMessage({ + type: 'SERVER_SYNC_CONFLICT', + fileId, + error: 'Durable sync conflict after retrying rebased snapshot', + persistedRev: serverMeta.persistedRev, + updatedAt: serverMeta.updatedAt, + updatedBy: serverMeta.updatedBy, + }) + return + } + + currentSnapshot = mergedSnapshot + currentBaseRev = serverMeta.persistedRev + conflictCount++ + continue + } + + if (!response.ok) { + let errorMessage = `Server responded with status: ${response.status}` + try { + const responseText = await response.text() + errorMessage += ` - ${responseText}` + } catch { + // Ignore parse failures while constructing the error message. + } + throw new Error(errorMessage) + } + + const responseData = await parseResponseJson(response) + const responseMeta = resolveSuccessMeta( + responseData, + currentBaseRev, + existing?.lastServerUpdatedAt ?? null, + existing?.lastServerUpdatedBy ?? null, + ) + + await database.put( + fileId, + currentSnapshot.elements, + currentSnapshot.files, + currentSnapshot.appState, + { + scrollToContent: currentSnapshot.scrollToContent, + hasPendingLocalChanges: false, + lastSyncedHash: computeElementVersionHash(currentSnapshot.elements), + persistedRev: responseMeta.persistedRev, + lastServerUpdatedAt: responseMeta.updatedAt, + lastServerUpdatedBy: responseMeta.updatedBy, + }, + ) + + sendMessage({ + type: 'SERVER_SYNC_COMPLETE', + fileId, + success: true, + conflict: conflictCount > 0, + duration: now() - startedAt, + elementsCount: currentSnapshot.elements.length, + response: responseData, + persistedRev: responseMeta.persistedRev, + updatedAt: responseMeta.updatedAt, + updatedBy: responseMeta.updatedBy, + }) + return + } + } catch (error) { + reportError('Error syncing to server:', error) + sendMessage({ + type: 'SERVER_SYNC_ERROR', + fileId, + error: getErrorMessage(error), + }) + } + } + + const initWorker = () => { + try { + sendMessage({ type: 'INIT_COMPLETE' }) + } catch (error) { + reportError('Failed to initialize worker:', error) + sendMessage({ + type: 'INIT_ERROR', + error: getErrorMessage(error), + }) + } + } + + const handleMessage = async (message: WorkerInboundMessage) => { + switch (message.type) { + case 'INIT': + initWorker() + break + case 'SYNC_TO_LOCAL': + await handleSyncToLocal(message) + break + case 'SYNC_TO_SERVER': + await handleSyncToServer(message) + break + default: + break + } + } + + return { + handleMessage, + handleSyncToLocal, + handleSyncToServer, + initWorker, + } +} diff --git a/tests/Unit/Service/WhiteboardContentServiceTest.php b/tests/Unit/Service/WhiteboardContentServiceTest.php new file mode 100644 index 00000000..d6e6a20a --- /dev/null +++ b/tests/Unit/Service/WhiteboardContentServiceTest.php @@ -0,0 +1,285 @@ +service = new WhiteboardContentService( + $this->createMock(LoggerInterface::class), + ); + } + + public function testEmptyFileNormalizesToPersistedRevZero(): void { + $file = $this->createFileMock(''); + + $content = $this->service->getContent($file); + + self::assertSame([ + 'meta' => [ + 'persistedRev' => 0, + 'updatedAt' => null, + 'updatedBy' => null, + ], + 'elements' => [], + 'files' => [], + 'appState' => [], + 'scrollToContent' => true, + ], $content); + } + + public function testLegacyFileNormalizesCorrectly(): void { + $file = $this->createFileMock(json_encode([ + 'elements' => [ + ['id' => 'shape-1'], + ], + 'files' => [ + 'file-a' => ['id' => 'file-a'], + ], + 'appState' => [ + 'collaborators' => ['alice' => true], + 'viewBackgroundColor' => '#fff', + ], + 'scrollToContent' => false, + ], JSON_THROW_ON_ERROR)); + + $content = $this->service->getContent($file); + + self::assertSame(0, $content['meta']['persistedRev']); + self::assertSame(null, $content['meta']['updatedAt']); + self::assertSame(null, $content['meta']['updatedBy']); + self::assertSame([['id' => 'shape-1']], $content['elements']); + self::assertSame(['file-a' => ['id' => 'file-a']], $content['files']); + self::assertSame(['viewBackgroundColor' => '#fff'], $content['appState']); + self::assertFalse($content['scrollToContent']); + } + + public function testMatchingBaseRevIncrementsRevisionAndWritesUpdatedBy(): void { + $writtenContent = null; + $file = $this->createFileMock( + json_encode([ + 'elements' => [], + 'files' => [], + 'appState' => [], + 'scrollToContent' => true, + ], JSON_THROW_ON_ERROR), + 42, + function (string $payload) use (&$writtenContent): int { + $writtenContent = $payload; + return 0; + }, + ); + + $meta = $this->service->updateContent($file, [ + 'data' => [ + 'baseRev' => 0, + 'elements' => [ + ['id' => 'shape-1'], + ], + 'files' => [], + 'appState' => [ + 'viewBackgroundColor' => '#fff', + ], + 'scrollToContent' => false, + ], + ], 'alice'); + + self::assertSame(1, $meta['persistedRev']); + self::assertSame('alice', $meta['updatedBy']); + self::assertIsInt($meta['updatedAt']); + self::assertNotNull($writtenContent); + + $stored = json_decode((string)$writtenContent, true, 512, JSON_THROW_ON_ERROR); + self::assertSame(1, $stored['meta']['persistedRev']); + self::assertSame('alice', $stored['meta']['updatedBy']); + self::assertSame([['id' => 'shape-1']], $stored['elements']); + self::assertFalse($stored['scrollToContent']); + } + + public function testFirstSaveOfLegacyBoardWritesMetaEvenWhenSnapshotIsOtherwiseIdentical(): void { + $writtenContent = null; + $file = $this->createFileMock( + json_encode([ + 'elements' => [ + ['id' => 'shape-1'], + ], + 'files' => [], + 'appState' => [], + 'scrollToContent' => true, + ], JSON_THROW_ON_ERROR), + 42, + function (string $payload) use (&$writtenContent): int { + $writtenContent = $payload; + return 0; + }, + ); + + $meta = $this->service->updateContent($file, [ + 'data' => [ + 'baseRev' => 0, + 'elements' => [ + ['id' => 'shape-1'], + ], + 'files' => [], + 'appState' => [], + 'scrollToContent' => true, + ], + ], 'alice'); + + self::assertSame(1, $meta['persistedRev']); + self::assertSame('alice', $meta['updatedBy']); + self::assertNotNull($writtenContent); + + $stored = json_decode((string)$writtenContent, true, 512, JSON_THROW_ON_ERROR); + self::assertSame(1, $stored['meta']['persistedRev']); + } + + public function testStaleBaseRevWithDifferentContentReturnsConflictPayload(): void { + $file = $this->createFileMock(json_encode([ + 'meta' => [ + 'persistedRev' => 8, + 'updatedAt' => 1743494412345, + 'updatedBy' => 'bob', + ], + 'elements' => [ + ['id' => 'server-shape'], + ], + 'files' => [], + 'appState' => [], + 'scrollToContent' => true, + ], JSON_THROW_ON_ERROR)); + + try { + $this->service->updateContent($file, [ + 'data' => [ + 'baseRev' => 7, + 'elements' => [ + ['id' => 'local-shape'], + ], + 'files' => [], + 'appState' => [], + 'scrollToContent' => true, + ], + ], 'alice'); + self::fail('Expected WhiteboardConflictException to be thrown'); + } catch (WhiteboardConflictException $e) { + self::assertSame(8, $e->getCurrentDocument()['meta']['persistedRev']); + self::assertSame('bob', $e->getCurrentDocument()['meta']['updatedBy']); + self::assertSame([['id' => 'server-shape']], $e->getCurrentDocument()['elements']); + } + } + + public function testStaleBaseRevWithIdenticalContentIsIdempotentSuccess(): void { + $file = $this->createFileMock( + json_encode([ + 'meta' => [ + 'persistedRev' => 8, + 'updatedAt' => 1743494412345, + 'updatedBy' => 'bob', + ], + 'elements' => [ + ['id' => 'shape-1'], + ], + 'files' => [ + 'file-b' => ['id' => 'file-b'], + 'file-a' => ['id' => 'file-a'], + ], + 'appState' => [ + 'viewBackgroundColor' => '#fff', + ], + 'scrollToContent' => false, + ], JSON_THROW_ON_ERROR), + 42, + function (string $_payload): int { + self::fail('putContent should not be called for idempotent saves'); + return 0; + }, + ); + + $meta = $this->service->updateContent($file, [ + 'data' => [ + 'baseRev' => 2, + 'elements' => [ + ['id' => 'shape-1'], + ], + 'files' => [ + 'file-a' => ['id' => 'file-a'], + 'file-b' => ['id' => 'file-b'], + ], + 'appState' => [ + 'collaborators' => ['alice' => true], + 'viewBackgroundColor' => '#fff', + ], + 'scrollToContent' => false, + ], + ], 'alice'); + + self::assertSame([ + 'persistedRev' => 8, + 'updatedAt' => 1743494412345, + 'updatedBy' => 'bob', + ], $meta); + } + + public function testInvalidBaseRevReturnsBadRequest(): void { + $file = $this->createFileMock(''); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('baseRev'); + + $this->service->updateContent($file, [ + 'data' => [ + 'baseRev' => 'seven', + 'elements' => [], + 'files' => [], + 'appState' => [], + 'scrollToContent' => true, + ], + ], 'alice'); + } + + public function testMalformedPayloadReturnsBadRequest(): void { + $file = $this->createFileMock(''); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('elements'); + + $this->service->updateContent($file, [ + 'data' => [ + 'baseRev' => 0, + 'files' => [], + 'appState' => [], + 'scrollToContent' => true, + ], + ], 'alice'); + } + + /** + * @param callable(string):int|null $putContentHandler + */ + private function createFileMock(string $content, int $fileId = 42, ?callable $putContentHandler = null): File { + $file = $this->createMock(File::class); + $file->method('getId')->willReturn($fileId); + $file->method('getContent')->willReturn($content); + + if ($putContentHandler !== null) { + $file->method('putContent')->willReturnCallback($putContentHandler); + } + + return $file; + } +} diff --git a/tests/integration/persistedBoardData.spec.ts b/tests/integration/persistedBoardData.spec.ts new file mode 100644 index 00000000..7988359e --- /dev/null +++ b/tests/integration/persistedBoardData.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { + extractSnapshotFromPersistedBoard, + resolveBoardLoadState, +} from '../../src/utils/persistedBoardData' + +describe('persistedBoardData helpers', () => { + it('keeps local pending data while rebasing onto server durable revision', () => { + const result = resolveBoardLoadState({ + localBoard: { + id: 42, + elements: [ + { id: 'local-1', version: 2, versionNonce: 20, isDeleted: false, type: 'rectangle' }, + ] as never, + files: { + localFile: { id: 'localFile', dataURL: 'local' }, + } as never, + appState: { + viewBackgroundColor: '#fff', + name: 'local', + }, + scrollToContent: false, + hasPendingLocalChanges: true, + persistedRev: 2, + }, + serverBoard: { + meta: { + persistedRev: 7, + updatedAt: 1743494412345, + updatedBy: 'bob', + }, + elements: [ + { id: 'server-1', version: 1, versionNonce: 10, isDeleted: false, type: 'ellipse' }, + ], + files: { + serverFile: { id: 'serverFile', dataURL: 'server' }, + }, + appState: { + viewBackgroundColor: '#000', + gridSize: 10, + }, + scrollToContent: true, + }, + }) + + expect(result).not.toBeNull() + expect(result?.hasPendingLocalChanges).toBe(true) + expect(result?.meta.persistedRev).toBe(7) + expect(result?.meta.updatedBy).toBe('bob') + expect(result?.snapshot.scrollToContent).toBe(false) + expect(result?.snapshot.files).toMatchObject({ + serverFile: { id: 'serverFile', dataURL: 'server' }, + localFile: { id: 'localFile', dataURL: 'local' }, + }) + expect(result?.snapshot.appState).toMatchObject({ + viewBackgroundColor: '#fff', + gridSize: 10, + name: 'local', + }) + }) + + it('accepts raw revisioned board JSON with top-level meta for read-only consumers', () => { + const snapshot = extractSnapshotFromPersistedBoard({ + meta: { + persistedRev: 9, + updatedAt: 1743494412345, + updatedBy: 'alice', + }, + elements: [ + { id: 'shape-1', version: 1, versionNonce: 10, isDeleted: false, type: 'diamond' }, + ], + files: { + fileA: { id: 'fileA', dataURL: 'data:image/png;base64,aaaa' }, + }, + appState: { + viewBackgroundColor: '#fafafa', + }, + scrollToContent: false, + }) + + expect(snapshot.elements).toHaveLength(1) + expect(snapshot.files).toMatchObject({ + fileA: { id: 'fileA', dataURL: 'data:image/png;base64,aaaa' }, + }) + expect(snapshot.appState).toMatchObject({ + viewBackgroundColor: '#fafafa', + }) + expect(snapshot.scrollToContent).toBe(false) + }) +}) diff --git a/tests/integration/syncWorker.spec.ts b/tests/integration/syncWorker.spec.ts new file mode 100644 index 00000000..067d6789 --- /dev/null +++ b/tests/integration/syncWorker.spec.ts @@ -0,0 +1,342 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WhiteboardData } from '../../src/database/db' +import { createSyncWorkerHandlers } from '../../src/workers/syncWorkerCore' + +const createElement = (id: string, version = 1, versionNonce = 10) => ({ + id, + type: 'rectangle', + version, + versionNonce, + isDeleted: false, +}) + +const createDatabase = (initial?: Partial) => { + let stored: WhiteboardData | undefined = initial + ? { + id: 42, + elements: [], + files: {}, + appState: {}, + scrollToContent: true, + hasPendingLocalChanges: false, + persistedRev: 0, + lastServerUpdatedAt: null, + lastServerUpdatedBy: null, + ...initial, + } + : undefined + + const put = vi.fn(async ( + fileId: number, + elements: WhiteboardData['elements'], + files: WhiteboardData['files'], + appState?: WhiteboardData['appState'], + options: { + scrollToContent?: boolean + hasPendingLocalChanges?: boolean + lastSyncedHash?: number + persistedRev?: number + lastServerUpdatedAt?: number | null + lastServerUpdatedBy?: string | null + } = {}, + ) => { + stored = { + id: fileId, + elements: [...elements], + files, + appState, + scrollToContent: options.scrollToContent ?? stored?.scrollToContent ?? true, + hasPendingLocalChanges: options.hasPendingLocalChanges ?? stored?.hasPendingLocalChanges ?? false, + lastSyncedHash: options.lastSyncedHash ?? stored?.lastSyncedHash, + persistedRev: options.persistedRev ?? stored?.persistedRev ?? 0, + lastServerUpdatedAt: options.lastServerUpdatedAt ?? stored?.lastServerUpdatedAt ?? null, + lastServerUpdatedBy: options.lastServerUpdatedBy ?? stored?.lastServerUpdatedBy ?? null, + savedAt: Date.now(), + } + return fileId + }) + + return { + get: vi.fn(async () => stored), + put, + getStored: () => stored, + } +} + +describe('syncWorker durable revision handling', () => { + beforeEach(() => { + vi.useRealTimers() + }) + + it('sends baseRev from IndexedDB metadata and clears pending on success', async () => { + const database = createDatabase({ + persistedRev: 7, + appState: { viewBackgroundColor: '#fff' }, + }) + const postMessage = vi.fn() + const fetchFn = vi.fn(async (_url: string, init?: RequestInit) => { + const payload = JSON.parse(String(init?.body)) + expect(payload.data.baseRev).toBe(7) + expect(payload.data.appState).toMatchObject({ viewBackgroundColor: '#fafafa' }) + expect(payload.data.scrollToContent).toBe(false) + + return new Response(JSON.stringify({ + status: 'success', + meta: { + persistedRev: 8, + updatedAt: 1743494412345, + updatedBy: 'alice', + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) + + const handlers = createSyncWorkerHandlers({ + database: database as never, + fetchFn: fetchFn as never, + postMessage, + }) + + await handlers.handleSyncToServer({ + type: 'SYNC_TO_SERVER', + fileId: 42, + url: 'https://example.invalid/whiteboard', + jwt: 'jwt-token', + elements: [createElement('shape-1')] as never, + files: {} as never, + appState: { viewBackgroundColor: '#fafafa' }, + scrollToContent: false, + }) + + expect(database.put).toHaveBeenCalled() + expect(database.getStored()).toMatchObject({ + hasPendingLocalChanges: false, + persistedRev: 8, + lastServerUpdatedAt: 1743494412345, + lastServerUpdatedBy: 'alice', + scrollToContent: false, + }) + expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'SERVER_SYNC_COMPLETE', + fileId: 42, + persistedRev: 8, + })) + }) + + it('treats identical 409 payloads as idempotent success and updates durable revision', async () => { + const database = createDatabase({ + persistedRev: 3, + }) + const postMessage = vi.fn() + const fetchFn = vi.fn(async () => new Response(JSON.stringify({ + status: 'conflict', + data: { + meta: { + persistedRev: 4, + updatedAt: 1743494412345, + updatedBy: 'bob', + }, + elements: [createElement('shape-1')], + files: {}, + appState: { + viewBackgroundColor: '#fff', + }, + scrollToContent: true, + }, + }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + })) + + const handlers = createSyncWorkerHandlers({ + database: database as never, + fetchFn: fetchFn as never, + postMessage, + }) + + await handlers.handleSyncToServer({ + type: 'SYNC_TO_SERVER', + fileId: 42, + url: 'https://example.invalid/whiteboard', + jwt: 'jwt-token', + elements: [createElement('shape-1')] as never, + files: {} as never, + appState: { viewBackgroundColor: '#fff' }, + scrollToContent: true, + }) + + expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'SERVER_SYNC_COMPLETE', + conflict: true, + persistedRev: 4, + })) + expect(database.getStored()).toMatchObject({ + hasPendingLocalChanges: false, + persistedRev: 4, + lastServerUpdatedBy: 'bob', + }) + }) + + it('merges divergent 409 payloads, keeps pending state, and retries with the new server revision', async () => { + const database = createDatabase({ + persistedRev: 1, + }) + const postMessage = vi.fn() + const fetchFn = vi.fn(async (_url: string, init?: RequestInit) => { + const payload = JSON.parse(String(init?.body)) + if (fetchFn.mock.calls.length === 1) { + expect(payload.data.baseRev).toBe(1) + return new Response(JSON.stringify({ + status: 'conflict', + data: { + meta: { + persistedRev: 2, + updatedAt: 1743494412345, + updatedBy: 'bob', + }, + elements: [createElement('server-shape', 4, 40)], + files: { + serverFile: { id: 'serverFile', dataURL: 'server' }, + }, + appState: { + viewBackgroundColor: '#000', + }, + scrollToContent: true, + }, + }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }) + } + + expect(payload.data.baseRev).toBe(2) + expect(payload.data.files).toMatchObject({ + serverFile: { id: 'serverFile', dataURL: 'server' }, + localFile: { id: 'localFile', dataURL: 'local' }, + }) + expect(payload.data.appState).toMatchObject({ + viewBackgroundColor: '#fff', + gridSize: 10, + }) + + return new Response(JSON.stringify({ + status: 'success', + meta: { + persistedRev: 3, + updatedAt: 1743494412999, + updatedBy: 'alice', + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) + + const handlers = createSyncWorkerHandlers({ + database: database as never, + fetchFn: fetchFn as never, + postMessage, + }) + + await handlers.handleSyncToServer({ + type: 'SYNC_TO_SERVER', + fileId: 42, + url: 'https://example.invalid/whiteboard', + jwt: 'jwt-token', + elements: [createElement('local-shape', 5, 50)] as never, + files: { + localFile: { id: 'localFile', dataURL: 'local' }, + } as never, + appState: { + viewBackgroundColor: '#fff', + gridSize: 10, + }, + scrollToContent: false, + }) + + expect(fetchFn).toHaveBeenCalledTimes(2) + expect(database.put).toHaveBeenCalledWith( + 42, + expect.any(Array), + expect.objectContaining({ + serverFile: { id: 'serverFile', dataURL: 'server' }, + localFile: { id: 'localFile', dataURL: 'local' }, + }), + expect.objectContaining({ + viewBackgroundColor: '#fff', + gridSize: 10, + }), + expect.objectContaining({ + hasPendingLocalChanges: true, + persistedRev: 2, + }), + ) + expect(database.getStored()).toMatchObject({ + hasPendingLocalChanges: false, + persistedRev: 3, + lastServerUpdatedBy: 'alice', + }) + expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'SERVER_SYNC_COMPLETE', + conflict: true, + persistedRev: 3, + })) + }) + + it('does not report conflict responses as success-by-skip after retry exhaustion', async () => { + const database = createDatabase({ + persistedRev: 1, + }) + const postMessage = vi.fn() + const fetchFn = vi.fn(async () => new Response(JSON.stringify({ + status: 'conflict', + data: { + meta: { + persistedRev: 2, + updatedAt: 1743494412345, + updatedBy: 'bob', + }, + elements: [createElement('server-shape', 4, 40)], + files: {}, + appState: {}, + scrollToContent: true, + }, + }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + })) + + const handlers = createSyncWorkerHandlers({ + database: database as never, + fetchFn: fetchFn as never, + postMessage, + }) + + await handlers.handleSyncToServer({ + type: 'SYNC_TO_SERVER', + fileId: 42, + url: 'https://example.invalid/whiteboard', + jwt: 'jwt-token', + elements: [createElement('local-shape', 5, 50)] as never, + files: {} as never, + appState: {}, + scrollToContent: true, + }) + + expect(fetchFn).toHaveBeenCalledTimes(3) + expect(postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ + type: 'SERVER_SYNC_CONFLICT', + fileId: 42, + })) + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'SERVER_SYNC_COMPLETE', + skipped: true, + })) + expect(database.getStored()).toMatchObject({ + hasPendingLocalChanges: true, + persistedRev: 2, + }) + }) +})