diff --git a/dashboards/admin-web/package.json b/dashboards/admin-web/package.json index 1e0eed30..02f583ad 100644 --- a/dashboards/admin-web/package.json +++ b/dashboards/admin-web/package.json @@ -29,6 +29,7 @@ "@bytelyst/api-client": "workspace:*", "@bytelyst/auth": "workspace:*", "@bytelyst/config": "workspace:*", + "@bytelyst/dashboard-components": "workspace:*", "@bytelyst/cosmos": "workspace:*", "@bytelyst/datastore": "workspace:*", "@bytelyst/design-tokens": "workspace:*", diff --git a/dashboards/admin-web/src/app/(dashboard)/ops/ab-testing/page.tsx b/dashboards/admin-web/src/app/(dashboard)/ops/ab-testing/page.tsx index aa119e4b..ae81e57f 100644 --- a/dashboards/admin-web/src/app/(dashboard)/ops/ab-testing/page.tsx +++ b/dashboards/admin-web/src/app/(dashboard)/ops/ab-testing/page.tsx @@ -2,13 +2,12 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { PageHeader } from '@/components/PageHeader'; +import { PageHeader } from '@bytelyst/dashboard-components'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { LoadingSpinner } from '@/components/LoadingSpinner'; -import { EmptyState } from '@/components/EmptyState'; +import { LoadingSpinner, EmptyState } from '@bytelyst/dashboard-components'; import { Beaker, Plus, diff --git a/dashboards/admin-web/src/app/not-found.tsx b/dashboards/admin-web/src/app/not-found.tsx index dbd19ad5..088b4c06 100644 --- a/dashboards/admin-web/src/app/not-found.tsx +++ b/dashboards/admin-web/src/app/not-found.tsx @@ -1,21 +1,5 @@ -import Link from 'next/link'; +import { NotFoundPage } from '@bytelyst/dashboard-components'; export default function NotFound() { - return ( -
-
-
404
-

Page not found

-

- The page you're looking for doesn't exist or has been moved. -

- - Go to Dashboard - -
-
- ); + return ; } diff --git a/dashboards/tracker-web/src/app/not-found.tsx b/dashboards/tracker-web/src/app/not-found.tsx index 9fb3e72b..54c989cd 100644 --- a/dashboards/tracker-web/src/app/not-found.tsx +++ b/dashboards/tracker-web/src/app/not-found.tsx @@ -1,21 +1,5 @@ -import Link from 'next/link'; +import { NotFoundPage } from '@bytelyst/dashboard-components'; export default function NotFound() { - return ( -
-
-
404
-

Page not found

-

- The page you're looking for doesn't exist or has been moved. -

- - Go Home - -
-
- ); + return ; } diff --git a/package.json b/package.json index 00aefda1..33b7b11c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "audit": "pnpm -r audit --audit-level moderate", "clean": "pnpm -r exec rm -rf dist", "prototype:self-test": "./scripts/prototype-self-test.sh", - "prepare": "husky install" + "prepare": "husky" }, "devDependencies": { "@changesets/cli": "^2.28.1", diff --git a/packages/dashboard-components/package.json b/packages/dashboard-components/package.json index ea3d37fa..943eabef 100644 --- a/packages/dashboard-components/package.json +++ b/packages/dashboard-components/package.json @@ -4,25 +4,33 @@ "description": "Shared React components for ByteLyst dashboards", "type": "module", "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, + "files": [ + "dist" + ], "scripts": { "build": "tsc", + "test": "vitest run", "typecheck": "tsc --noEmit" }, "peerDependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0" + "react": ">=18.0.0", + "react-dom": ">=18.0.0" }, "devDependencies": { - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "typescript": "^5.7.3" + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "happy-dom": "^18.0.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "typescript": "^5.7.3", + "vitest": "^4.0.18" } } diff --git a/packages/dashboard-components/src/EmptyState.tsx b/packages/dashboard-components/src/EmptyState.tsx index 5474edc9..f3aba1ef 100644 --- a/packages/dashboard-components/src/EmptyState.tsx +++ b/packages/dashboard-components/src/EmptyState.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; -interface EmptyStateProps { +export interface EmptyStateProps { icon?: ReactNode; title: string; description: string; @@ -8,22 +8,42 @@ interface EmptyStateProps { label: string; onClick: () => void; }; + className?: string; } -export function EmptyState({ icon, title, description, action }: EmptyStateProps): ReactNode { +export function EmptyState({ + icon, + title, + description, + action, + className = '', +}: EmptyStateProps): ReactNode { return ( -
+
{icon && ( -
+
{icon}
)} -

{title}

-

{description}

+

+ {title} +

+

+ {description} +

{action && ( diff --git a/packages/dashboard-components/src/ErrorPage.tsx b/packages/dashboard-components/src/ErrorPage.tsx index 4fb82507..ad12ef27 100644 --- a/packages/dashboard-components/src/ErrorPage.tsx +++ b/packages/dashboard-components/src/ErrorPage.tsx @@ -1,20 +1,31 @@ import type { ReactNode } from 'react'; -interface ErrorPageProps { +export interface ErrorPageProps { title?: string; message?: string; onRetry?: () => void; + className?: string; } export function ErrorPage({ title = 'Something went wrong', message = 'An unexpected error occurred. Please try again.', onRetry, + className = '', }: ErrorPageProps): ReactNode { return ( -
-
- +
+
+
-

{title}

-

{message}

+

+ {title} +

+

+ {message} +

{onRetry && ( diff --git a/packages/dashboard-components/src/LoadingSkeleton.tsx b/packages/dashboard-components/src/LoadingSkeleton.tsx index 43eed133..f7e2e91f 100644 --- a/packages/dashboard-components/src/LoadingSkeleton.tsx +++ b/packages/dashboard-components/src/LoadingSkeleton.tsx @@ -1,15 +1,19 @@ import type { ReactNode } from 'react'; -interface LoadingSkeletonProps { +export interface LoadingSkeletonProps { rows?: number; className?: string; } export function LoadingSkeleton({ rows = 3, className = '' }: LoadingSkeletonProps): ReactNode { return ( -
+
{Array.from({ length: rows }).map((_, i) => ( -
+
))}
); diff --git a/packages/dashboard-components/src/LoadingSpinner.tsx b/packages/dashboard-components/src/LoadingSpinner.tsx index 062ae463..9109b2e6 100644 --- a/packages/dashboard-components/src/LoadingSpinner.tsx +++ b/packages/dashboard-components/src/LoadingSpinner.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; -interface LoadingSpinnerProps { +export interface LoadingSpinnerProps { size?: 'sm' | 'md' | 'lg'; className?: string; } @@ -13,9 +13,10 @@ export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerPr }; return ( -
+
void; + className?: string; } export function NotFoundPage({ - title = 'Page Not Found', - message = 'The page you are looking for does not exist.', + title = 'Page not found', + message = "The page you're looking for doesn't exist or has been moved.", + statusCode = '404', + backLabel = 'Go Back', + backHref, onBack, + className = '', }: NotFoundPageProps): ReactNode { return ( -
-
- +
+
- - + {statusCode} +
+

+ {title} +

+

+ {message} +

+ {(onBack || backHref) && + (backHref ? ( + + {backLabel} + + ) : ( + + ))}
-

{title}

-

{message}

- {onBack && ( - - )}
); } diff --git a/packages/dashboard-components/src/PageHeader.tsx b/packages/dashboard-components/src/PageHeader.tsx index 4d7240c4..c8e5d1b5 100644 --- a/packages/dashboard-components/src/PageHeader.tsx +++ b/packages/dashboard-components/src/PageHeader.tsx @@ -1,22 +1,41 @@ import type { ReactNode } from 'react'; -interface PageHeaderProps { - title: string; - breadcrumbs?: Array<{ label: string; href?: string }>; - actions?: ReactNode; +export interface Breadcrumb { + label: string; + href?: string; } -export function PageHeader({ title, breadcrumbs, actions }: PageHeaderProps): ReactNode { +export interface PageHeaderProps { + title: string; + breadcrumbs?: Breadcrumb[]; + actions?: ReactNode; + className?: string; +} + +export function PageHeader({ + title, + breadcrumbs, + actions, + className = '', +}: PageHeaderProps): ReactNode { return ( -
+
{breadcrumbs && breadcrumbs.length > 0 && ( -
{actions &&
{actions}
}
diff --git a/packages/dashboard-components/src/components.test.tsx b/packages/dashboard-components/src/components.test.tsx new file mode 100644 index 00000000..e3735a38 --- /dev/null +++ b/packages/dashboard-components/src/components.test.tsx @@ -0,0 +1,253 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { LoadingSpinner } from './LoadingSpinner.js'; +import { LoadingSkeleton } from './LoadingSkeleton.js'; +import { EmptyState } from './EmptyState.js'; +import { PageHeader } from './PageHeader.js'; +import { ErrorPage } from './ErrorPage.js'; +import { NotFoundPage } from './NotFoundPage.js'; + +describe('LoadingSpinner', () => { + it('renders with default size', () => { + render(); + const status = screen.getByRole('status'); + expect(status).toBeDefined(); + expect(status.className).toContain('w-8 h-8'); + }); + + it('renders with small size', () => { + render(); + const status = screen.getByRole('status'); + expect(status.className).toContain('w-4 h-4'); + }); + + it('renders with large size', () => { + render(); + const status = screen.getByRole('status'); + expect(status.className).toContain('w-12 h-12'); + }); + + it('applies custom className', () => { + render(); + const status = screen.getByRole('status'); + expect(status.className).toContain('mt-4'); + }); + + it('renders SVG spinner element', () => { + render(); + const svg = screen.getByRole('status').querySelector('svg'); + expect(svg).toBeDefined(); + expect(svg!.classList.contains('animate-spin')).toBe(true); + }); +}); + +describe('LoadingSkeleton', () => { + it('renders default 3 rows', () => { + render(); + const container = screen.getByRole('status'); + const rows = container.querySelectorAll('.animate-pulse'); + expect(rows.length).toBe(3); + }); + + it('renders custom number of rows', () => { + render(); + const container = screen.getByRole('status'); + const rows = container.querySelectorAll('.animate-pulse'); + expect(rows.length).toBe(5); + }); + + it('applies custom className', () => { + render(); + const container = screen.getByRole('status'); + expect(container.className).toContain('my-8'); + }); + + it('renders pulse-animated skeleton rows', () => { + render(); + const row = screen.getByRole('status').querySelector('.animate-pulse'); + expect(row).toBeDefined(); + expect(row!.classList.contains('rounded')).toBe(true); + }); +}); + +describe('EmptyState', () => { + it('renders title and description', () => { + render(); + expect(screen.getByText('No items')).toBeDefined(); + expect(screen.getByText('Create your first item.')).toBeDefined(); + }); + + it('renders icon when provided', () => { + render( + X} + /> + ); + expect(screen.getByTestId('icon')).toBeDefined(); + }); + + it('does not render icon container when not provided', () => { + const { container } = render(); + const iconWrapper = container.querySelector('.w-16.h-16'); + expect(iconWrapper).toBeNull(); + }); + + it('renders action button and handles click', () => { + const onClick = vi.fn(); + render( + + ); + const button = screen.getByText('Create'); + expect(button).toBeDefined(); + fireEvent.click(button); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('does not render action button when not provided', () => { + const { container } = render(); + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBe(0); + }); + + it('renders with theme-aware structure', () => { + const { container } = render(); + const heading = container.querySelector('h3'); + expect(heading).toBeDefined(); + expect(heading!.textContent).toBe('Test'); + const desc = container.querySelector('p'); + expect(desc).toBeDefined(); + expect(desc!.textContent).toBe('Desc'); + }); +}); + +describe('PageHeader', () => { + it('renders title', () => { + render(); + expect(screen.getByText('Dashboard')).toBeDefined(); + }); + + it('renders breadcrumbs', () => { + render( + + ); + expect(screen.getByText('Home')).toBeDefined(); + expect(screen.getByLabelText('Breadcrumb')).toBeDefined(); + }); + + it('renders breadcrumb links with href', () => { + render( + + ); + const link = screen.getByText('Home'); + expect(link.tagName).toBe('A'); + expect(link.getAttribute('href')).toBe('/'); + }); + + it('renders breadcrumb text without href', () => { + render(); + const text = screen.getByText('Current'); + expect(text.tagName).toBe('SPAN'); + }); + + it('renders actions', () => { + render(Action} />); + expect(screen.getByTestId('action-btn')).toBeDefined(); + }); + + it('does not render breadcrumb nav when empty', () => { + const { container } = render(); + const nav = container.querySelector('nav'); + expect(nav).toBeNull(); + }); +}); + +describe('ErrorPage', () => { + it('renders with default props', () => { + render(); + expect(screen.getByText('Something went wrong')).toBeDefined(); + expect(screen.getByText('An unexpected error occurred. Please try again.')).toBeDefined(); + }); + + it('renders custom title and message', () => { + render(); + expect(screen.getByText('Server Error')).toBeDefined(); + expect(screen.getByText('The server is down.')).toBeDefined(); + }); + + it('renders retry button and handles click', () => { + const onRetry = vi.fn(); + render(); + const button = screen.getByText('Try Again'); + expect(button).toBeDefined(); + fireEvent.click(button); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it('does not render retry button when not provided', () => { + const { container } = render(); + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBe(0); + }); + + it('renders error icon and semantic structure', () => { + const { container } = render(); + const iconContainer = container.querySelector('.w-16'); + expect(iconContainer).toBeDefined(); + const svg = iconContainer!.querySelector('svg'); + expect(svg).toBeDefined(); + const heading = container.querySelector('h2'); + expect(heading).toBeDefined(); + expect(heading!.textContent).toBe('Something went wrong'); + }); +}); + +describe('NotFoundPage', () => { + it('renders with default props', () => { + render(); + expect(screen.getByText('404')).toBeDefined(); + expect(screen.getByText('Page not found')).toBeDefined(); + }); + + it('renders custom status code', () => { + render(); + expect(screen.getByText('403')).toBeDefined(); + expect(screen.getByText('Forbidden')).toBeDefined(); + }); + + it('renders back button with onClick', () => { + const onBack = vi.fn(); + render(); + const button = screen.getByText('Go Back'); + fireEvent.click(button); + expect(onBack).toHaveBeenCalledOnce(); + }); + + it('renders back link with href', () => { + render(); + const link = screen.getByText('Go Home'); + expect(link.tagName).toBe('A'); + expect(link.getAttribute('href')).toBe('/'); + }); + + it('does not render button when neither onBack nor backHref provided', () => { + const { container } = render(); + const buttons = container.querySelectorAll('button'); + const links = container.querySelectorAll('a'); + expect(buttons.length).toBe(0); + expect(links.length).toBe(0); + }); + + it('custom backLabel is used', () => { + render( {}} backLabel="Return" />); + expect(screen.getByText('Return')).toBeDefined(); + }); +}); diff --git a/packages/dashboard-components/src/index.ts b/packages/dashboard-components/src/index.ts index bbd09d97..ca49e775 100644 --- a/packages/dashboard-components/src/index.ts +++ b/packages/dashboard-components/src/index.ts @@ -1,12 +1,15 @@ /** * @bytelyst/dashboard-components * - * Shared React components for ByteLyst dashboards + * Shared React components for ByteLyst dashboards. + * All components are theme-aware — they read CSS custom properties + * (--color-primary, --color-foreground, --color-muted, etc.) + * with sensible fallback defaults. */ -export { ErrorPage } from './ErrorPage.js'; -export { NotFoundPage } from './NotFoundPage.js'; -export { LoadingSpinner } from './LoadingSpinner.js'; -export { LoadingSkeleton } from './LoadingSkeleton.js'; -export { EmptyState } from './EmptyState.js'; -export { PageHeader } from './PageHeader.js'; +export { ErrorPage, type ErrorPageProps } from './ErrorPage.js'; +export { NotFoundPage, type NotFoundPageProps } from './NotFoundPage.js'; +export { LoadingSpinner, type LoadingSpinnerProps } from './LoadingSpinner.js'; +export { LoadingSkeleton, type LoadingSkeletonProps } from './LoadingSkeleton.js'; +export { EmptyState, type EmptyStateProps } from './EmptyState.js'; +export { PageHeader, type PageHeaderProps, type Breadcrumb } from './PageHeader.js'; diff --git a/packages/dashboard-components/tsconfig.json b/packages/dashboard-components/tsconfig.json index 0c1049b3..b15ef2ec 100644 --- a/packages/dashboard-components/tsconfig.json +++ b/packages/dashboard-components/tsconfig.json @@ -8,5 +8,6 @@ "declarationMap": true, "lib": ["ES2022", "DOM", "DOM.Iterable"] }, - "include": ["src/**/*"] + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] } diff --git a/packages/dashboard-components/vitest.config.ts b/packages/dashboard-components/vitest.config.ts new file mode 100644 index 00000000..7b00b489 --- /dev/null +++ b/packages/dashboard-components/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'happy-dom', + passWithNoTests: true, + }, +}); diff --git a/packages/sync/src/engine.ts b/packages/sync/src/engine.ts index d3f6f4e9..277e2e00 100644 --- a/packages/sync/src/engine.ts +++ b/packages/sync/src/engine.ts @@ -1,6 +1,15 @@ /** * Sync Engine — Core implementation * + * Offline-first sync with: + * - Queue persistence via pluggable StorageAdapter + * - Deduplication (collapse updates to same entity + id) + * - Exponential backoff retry (configurable base/max delay) + * - Conflict detection via HTTP 409 + configurable resolution strategies + * - Connectivity detection with auto-flush on reconnect + * - Telemetry integration for sync success/failure/conflict tracking + * - onPull callback so consumers merge pulled data into local store + * * @module @bytelyst/sync/engine */ @@ -15,6 +24,7 @@ import type { EntityName, SyncOperation, ConflictStrategy, + Conflict, } from './types.js'; // ───────────────────────────────────────────────────────────────────────────── @@ -22,26 +32,60 @@ import type { // ───────────────────────────────────────────────────────────────────────────── const DEFAULT_MAX_RETRIES = 5; -const DEFAULT_RETRY_DELAY_MS = 1000; +const DEFAULT_RETRY_BASE_DELAY_MS = 1000; +const DEFAULT_RETRY_MAX_DELAY_MS = 30_000; const QUEUE_KEY = 'queue'; const LAST_SYNC_KEY = 'lastSync'; +// ───────────────────────────────────────────────────────────────────────────── +// HTTP 409 Conflict Error +// ───────────────────────────────────────────────────────────────────────────── + +export class SyncConflictError extends Error { + constructor(public remoteData: unknown) { + super('Sync conflict: server has newer version'); + this.name = 'SyncConflictError'; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** Compute exponential backoff with jitter: base * 2^attempt + random jitter */ +export function computeBackoff(attempt: number, baseMs: number, maxMs: number): number { + const delay = Math.min(baseMs * Math.pow(2, attempt), maxMs); + const jitter = delay * 0.1 * Math.random(); + return delay + jitter; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + // ───────────────────────────────────────────────────────────────────────────── // Sync Engine Implementation // ───────────────────────────────────────────────────────────────────────────── export class SyncEngineImpl implements SyncEngine { - private config: SyncEngineConfig; + private config: Required< + Pick + > & + SyncEngineConfig; private status: SyncStatus = 'idle'; private queueLength = 0; private lastSyncAt?: string; + private lastError?: string; private statusListeners: Set = new Set(); - private connectivityListeners: (() => void)[] = []; + private onlineHandler: (() => void) | null = null; + private offlineHandler: (() => void) | null = null; + private destroyed = false; constructor(config: SyncEngineConfig) { this.config = { maxRetries: DEFAULT_MAX_RETRIES, - retryDelayMs: DEFAULT_RETRY_DELAY_MS, + retryBaseDelayMs: DEFAULT_RETRY_BASE_DELAY_MS, + retryMaxDelayMs: DEFAULT_RETRY_MAX_DELAY_MS, ...config, }; this.setupConnectivityDetection(); @@ -65,79 +109,95 @@ export class SyncEngineImpl implements SyncEngine { retryCount: 0, }; - // Deduplication: Check if there's already a pending item for same entity/data const existingQueue = await this.getQueue(); const dedupKey = this.getDedupKey(entity, data); const existingIndex = existingQueue.findIndex( - i => this.getDedupKey(i.entity, i.data) === dedupKey + i => this.getDedupKey(i.entity, i.data) === dedupKey && i.operation === operation ); if (existingIndex >= 0) { - // Replace existing item with newer data existingQueue[existingIndex] = item; } else { existingQueue.push(item); } await this.saveQueue(existingQueue); - this.queueLength = existingQueue.length; - this.updateStatus('idle'); + this.notifyStatus(); } async delete(entity: EntityName, id: string): Promise { - await this.push(entity, { id }, 'delete'); + // Also remove any pending create/update for this entity+id + const queue = await this.getQueue(); + const dedupKey = `${entity}:${id}`; + const filtered = queue.filter(i => this.getDedupKey(i.entity, i.data) !== dedupKey); + + const item: SyncItem = { + id: this.generateId(), + entity, + operation: 'delete', + data: { id }, + timestamp: new Date().toISOString(), + retryCount: 0, + }; + filtered.push(item); + + await this.saveQueue(filtered); + this.notifyStatus(); } async pull(): Promise { - const result: SyncResult = { - success: true, - pushed: 0, - pulled: 0, - conflicts: 0, - errors: 0, - timestamp: new Date().toISOString(), - }; - - this.updateStatus('syncing'); + const result = this.emptyResult(); + this.setStatus('syncing'); try { - // Pull changes from server for each entity for (const [entityName, entityConfig] of Object.entries(this.config.entities)) { try { - const pulled = await this.pullEntity(entityName, entityConfig.endpoint); - result.pulled += pulled; + const count = await this.pullEntity(entityName, entityConfig.endpoint); + result.pulled += count; } catch (error) { result.errors++; - this.trackError('pull', entityName, error); + this.trackTelemetry('sync_pull_error', entityName, error); } } + result.timestamp = new Date().toISOString(); await this.setLastSyncTime(result.timestamp); this.lastSyncAt = result.timestamp; + this.setStatus(result.errors > 0 ? 'error' : 'idle'); } catch (error) { result.success = false; - this.updateStatus('error', error instanceof Error ? error.message : 'Unknown error'); - } - - if (result.success && result.errors === 0) { - this.updateStatus('idle'); + this.setStatus('error', error instanceof Error ? error.message : 'Unknown error'); } return result; } async fullSync(): Promise { - const result = await this.pushQueue(); + if (!this.isOnline()) { + this.setStatus('offline'); + return { ...this.emptyResult(), success: false }; + } + + const pushResult = await this.pushQueue(); const pullResult = await this.pull(); - return { - success: result.success && pullResult.success, - pushed: result.pushed, + const combined: SyncResult = { + success: pushResult.success && pullResult.success, + pushed: pushResult.pushed, pulled: pullResult.pulled, - conflicts: result.conflicts + pullResult.conflicts, - errors: result.errors + pullResult.errors, + conflicts: pushResult.conflicts + pullResult.conflicts, + errors: pushResult.errors + pullResult.errors, timestamp: new Date().toISOString(), }; + + this.trackTelemetry('sync_complete', '*', undefined, { + pushed: combined.pushed, + pulled: combined.pulled, + conflicts: combined.conflicts, + errors: combined.errors, + }); + + return combined; } // ─────────────────────────────────────────────────────────────────────────── @@ -156,39 +216,33 @@ export class SyncEngineImpl implements SyncEngine { private async pushQueue(): Promise { const queue = await this.getQueue(); - const result: SyncResult = { - success: true, - pushed: 0, - pulled: 0, - conflicts: 0, - errors: 0, - timestamp: new Date().toISOString(), - }; + const result = this.emptyResult(); - if (queue.length === 0) { - return result; - } - - this.updateStatus('syncing'); + if (queue.length === 0) return result; + this.setStatus('syncing'); const remaining: SyncItem[] = []; for (const item of queue) { try { - const success = await this.pushItem(item); - if (success) { - result.pushed++; - } else { - remaining.push(item); - } + await this.pushItemWithRetry(item); + result.pushed++; } catch (error) { - if (item.retryCount < (this.config.maxRetries || DEFAULT_MAX_RETRIES)) { + if (error instanceof SyncConflictError) { + result.conflicts++; + const resolved = await this.handleConflict(item, error.remoteData); + if (resolved) { + result.pushed++; + } else { + result.errors++; + } + } else if (item.retryCount < this.config.maxRetries) { item.retryCount++; item.lastError = error instanceof Error ? error.message : String(error); remaining.push(item); } else { result.errors++; - this.trackError('push', item.entity, error); + this.trackTelemetry('sync_push_dropped', item.entity, error); } } } @@ -202,49 +256,127 @@ export class SyncEngineImpl implements SyncEngine { return result; } - private async pushItem(item: SyncItem): Promise { + private async pushItemWithRetry(item: SyncItem): Promise { const entityConfig = this.config.entities[item.entity]; if (!entityConfig) { throw new Error(`Unknown entity: ${item.entity}`); } + const dataId = (item.data as { id?: string })?.id; const path = - item.operation === 'delete' || item.operation === 'update' - ? `${entityConfig.endpoint}/${(item.data as { id: string }).id}` + (item.operation === 'delete' || item.operation === 'update') && dataId + ? `${entityConfig.endpoint}/${dataId}` : entityConfig.endpoint; const method = item.operation === 'delete' ? 'DELETE' : item.operation === 'update' ? 'PATCH' : 'POST'; - try { - await this.config.apiClient.fetch(path, { - method, - body: method !== 'DELETE' ? JSON.stringify(item.data) : undefined, - }); - return true; - } catch { - return false; + const headers: Record = {}; + if (method !== 'DELETE') { + headers['Content-Type'] = 'application/json'; } + + // Attempt with exponential backoff on transient failures + let lastError: unknown; + const maxAttempts = Math.max(1, this.config.maxRetries - item.retryCount); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + await this.config.apiClient.fetch(path, { + method, + headers, + body: method !== 'DELETE' ? JSON.stringify(item.data) : undefined, + }); + // Success — track and return + this.trackTelemetry('sync_push_success', item.entity); + return; + } catch (error) { + lastError = error; + + // Check for conflict (409) — don't retry, let caller handle + if (error instanceof SyncConflictError) { + throw error; + } + if (this.isConflictError(error)) { + throw new SyncConflictError(undefined); + } + + // Non-retriable errors — throw immediately + if (this.isNonRetriable(error)) { + throw error; + } + + // Transient error — backoff and retry + if (attempt < maxAttempts - 1) { + const delay = computeBackoff( + attempt, + this.config.retryBaseDelayMs, + this.config.retryMaxDelayMs + ); + await sleep(delay); + } + } + } + + throw lastError; } private async pullEntity(entityName: string, endpoint: string): Promise { const lastSync = await this.getLastSyncTime(); const path = lastSync ? `${endpoint}?since=${encodeURIComponent(lastSync)}` : endpoint; - const result = await this.config.apiClient.safeFetch<{ items: unknown[] }>(path); + const response = await this.config.apiClient.safeFetch<{ items: unknown[] }>(path); - if (result.error || !result.data) { + if (response.error || !response.data) { return 0; } - // Store pulled items locally (consumer handles storage) - return result.data.items?.length || 0; + const items = response.data.items ?? []; + + if (items.length > 0 && this.config.onPull) { + await this.config.onPull(entityName, items); + } + + return items.length; } // ─────────────────────────────────────────────────────────────────────────── // Conflict Resolution // ─────────────────────────────────────────────────────────────────────────── + private async handleConflict(item: SyncItem, remoteData: unknown): Promise { + const entityConfig = this.config.entities[item.entity]; + if (!entityConfig) return false; + + const strategy = entityConfig.conflictStrategy; + this.trackTelemetry('sync_conflict', item.entity, undefined, { strategy }); + + try { + const winner = await this.resolveConflict(item, remoteData, strategy); + + if (winner === remoteData) { + // Server wins — nothing to push, consumer gets remote via onPull + if (this.config.onPull) { + await this.config.onPull(item.entity, [remoteData]); + } + return true; + } + + // Client data wins — re-push with force + const dataId = (winner as { id?: string })?.id; + const endpoint = entityConfig.endpoint; + const path = dataId ? `${endpoint}/${dataId}` : endpoint; + await this.config.apiClient.fetch(path, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(winner), + }); + return true; + } catch { + return false; + } + } + private async resolveConflict( item: SyncItem, remoteData: unknown, @@ -253,55 +385,82 @@ export class SyncEngineImpl implements SyncEngine { switch (strategy) { case 'server-wins': return remoteData; + case 'client-wins': return item.data; + case 'last-write-wins': { const localTime = new Date(item.timestamp).getTime(); const remoteTime = new Date( - (remoteData as { updatedAt?: string })?.updatedAt || 0 + (remoteData as { updatedAt?: string })?.updatedAt ?? '1970-01-01' ).getTime(); return localTime > remoteTime ? item.data : remoteData; } - case 'manual': + + case 'manual': { if (this.config.onConflict) { - return await this.config.onConflict(item, remoteData); + const conflict: Conflict = { + entity: item.entity, + localItem: item, + remoteData, + }; + return await this.config.onConflict(conflict); } + // No handler — fall back to server-wins return remoteData; + } + default: return remoteData; } } // ─────────────────────────────────────────────────────────────────────────── - // Connectivity + // Connectivity Detection // ─────────────────────────────────────────────────────────────────────────── private setupConnectivityDetection(): void { - if (typeof window !== 'undefined' && window.addEventListener) { - const handleOnline = () => { - void this.flush(); - this.connectivityListeners.forEach(cb => cb()); - }; + if (typeof globalThis === 'undefined') return; + const win = typeof window !== 'undefined' ? window : undefined; + if (!win?.addEventListener) return; - window.addEventListener('online', handleOnline); - } + this.onlineHandler = () => { + this.setStatus('idle'); + void this.flush(); + }; + this.offlineHandler = () => { + this.setStatus('offline'); + }; + + win.addEventListener('online', this.onlineHandler); + win.addEventListener('offline', this.offlineHandler); } private isOnline(): boolean { - if (typeof navigator !== 'undefined') { + if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') { return navigator.onLine; } - return true; + return true; // Assume online in non-browser environments (Node.js, SSR) } async flush(): Promise { - if (this.status === 'syncing') return; + if (this.destroyed || this.status === 'syncing') return; const result = await this.pushQueue(); if (result.success && result.errors === 0) { - this.updateStatus('idle'); + this.setStatus('idle'); } } + destroy(): void { + this.destroyed = true; + const win = typeof window !== 'undefined' ? window : undefined; + if (win) { + if (this.onlineHandler) win.removeEventListener('online', this.onlineHandler); + if (this.offlineHandler) win.removeEventListener('offline', this.offlineHandler); + } + this.statusListeners.clear(); + } + // ─────────────────────────────────────────────────────────────────────────── // Status & Monitoring // ─────────────────────────────────────────────────────────────────────────── @@ -315,6 +474,7 @@ export class SyncEngineImpl implements SyncEngine { status: this.status, queueLength: this.queueLength, lastSyncAt: this.lastSyncAt, + lastError: this.lastError, }; } @@ -323,13 +483,18 @@ export class SyncEngineImpl implements SyncEngine { return () => this.statusListeners.delete(callback); } - private updateStatus(status: SyncStatus, error?: string): void { + private setStatus(status: SyncStatus, error?: string): void { this.status = status; + if (error) this.lastError = error; + this.notifyStatus(); + } + + private notifyStatus(): void { const info: SyncStatusInfo = { - status, + status: this.status, queueLength: this.queueLength, lastSyncAt: this.lastSyncAt, - lastError: error, + lastError: this.lastError, }; this.statusListeners.forEach(cb => cb(info)); } @@ -340,6 +505,7 @@ export class SyncEngineImpl implements SyncEngine { async clearQueue(): Promise { await this.saveQueue([]); + this.notifyStatus(); } async reprocessFailed(): Promise { @@ -362,7 +528,7 @@ export class SyncEngineImpl implements SyncEngine { } private generateId(): string { - return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; } private getDedupKey(entity: string, data: unknown): string { @@ -370,9 +536,60 @@ export class SyncEngineImpl implements SyncEngine { return id ? `${entity}:${id}` : `${entity}:${JSON.stringify(data)}`; } - private trackError(_operation: string, _entity: string, _error: unknown): void { - if (this.config.telemetryClient) { - // Telemetry tracking would go here + private emptyResult(): SyncResult { + return { + success: true, + pushed: 0, + pulled: 0, + conflicts: 0, + errors: 0, + timestamp: new Date().toISOString(), + }; + } + + // ─────────────────────────────────────────────────────────────────────────── + // Error Classification + // ─────────────────────────────────────────────────────────────────────────── + + private isConflictError(error: unknown): boolean { + if (error instanceof SyncConflictError) return true; + const msg = error instanceof Error ? error.message : String(error); + return msg.includes('409') || msg.includes('conflict'); + } + + private isNonRetriable(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + // 4xx errors (except 408, 429) are non-retriable + return /\b(400|401|403|404|405|406|410|422)\b/.test(msg); + } + + private extractRemoteData(error: unknown): unknown { + if (error instanceof SyncConflictError) return error.remoteData; + return undefined; + } + + // ─────────────────────────────────────────────────────────────────────────── + // Telemetry + // ─────────────────────────────────────────────────────────────────────────── + + private trackTelemetry( + eventName: string, + entity: string, + error?: unknown, + extra?: Record + ): void { + if (!this.config.telemetryClient) return; + try { + this.config.telemetryClient.trackEvent('sync', 'sync-engine', eventName, { + tags: { + productId: this.config.productId, + entity, + ...(error ? { error: error instanceof Error ? error.message : String(error) } : {}), + }, + metrics: extra as Record | undefined, + }); + } catch { + // Telemetry should never break sync } } } diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 6004075c..e49e9616 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -30,7 +30,7 @@ * ``` */ -export { createSyncEngine, SyncEngineImpl } from './engine.js'; +export { createSyncEngine, SyncEngineImpl, SyncConflictError, computeBackoff } from './engine.js'; export { LocalStorageAdapter, InMemoryAdapter, MMKVAdapter, type MMKVInstance } from './storage.js'; @@ -46,6 +46,7 @@ export type { SyncResult, SyncStatusInfo, SyncStatusCallback, + PullHandler, StorageAdapter, Conflict, } from './types.js'; diff --git a/packages/sync/src/sync.test.ts b/packages/sync/src/sync.test.ts index 5f72404b..ce876b02 100644 --- a/packages/sync/src/sync.test.ts +++ b/packages/sync/src/sync.test.ts @@ -1,287 +1,608 @@ /** - * Sync Engine Tests + * Sync Engine Tests — 25+ tests * - * @module @bytelyst/sync/engine.test + * Covers: queue persistence, retry with backoff, conflict resolution (all 4 + * strategies), deduplication, connectivity, onPull callback, telemetry, + * delete consolidation, multiple entities, status monitoring, destroy. + * + * @module @bytelyst/sync/sync.test */ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createSyncEngine, InMemoryAdapter } from './index.js'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createSyncEngine, InMemoryAdapter, computeBackoff, SyncConflictError } from './index.js'; +import type { SyncStatusInfo, SyncEngineConfig, EntityConfig } from './types.js'; import type { ApiClient, ApiResult } from '@bytelyst/api-client'; +import type { TelemetryClient } from '@bytelyst/telemetry-client'; // ───────────────────────────────────────────────────────────────────────────── -// Mock API Client +// Helpers // ───────────────────────────────────────────────────────────────────────────── -function createMockApiClient(): ApiClient & { - getRequests: () => { path: string; options?: RequestInit }[]; -} { +interface MockApiClient extends ApiClient { + getRequests(): { path: string; options?: RequestInit }[]; + setFetchBehavior(fn: (path: string, options?: RequestInit) => unknown): void; + setSafeFetchBehavior(fn: (path: string) => unknown): void; +} + +function createMockApiClient(): MockApiClient { const requests: { path: string; options?: RequestInit }[] = []; + let fetchBehavior: ((path: string, options?: RequestInit) => unknown) | null = null; + let safeFetchBehavior: ((path: string) => unknown) | null = null; return { fetch: async (path: string, options?: RequestInit): Promise => { requests.push({ path, options }); - return { items: [] } as unknown as T; + if (fetchBehavior) return fetchBehavior(path, options) as T; + return {} as T; }, safeFetch: async (path: string, options?: RequestInit): Promise> => { requests.push({ path, options }); + if (safeFetchBehavior) return safeFetchBehavior(path) as ApiResult; return { data: { items: [] } as unknown as T, error: null }; }, getRequests: () => requests, + setFetchBehavior: fn => { + fetchBehavior = fn; + }, + setSafeFetchBehavior: fn => { + safeFetchBehavior = fn; + }, + }; +} + +function createMockTelemetry(): TelemetryClient & { events: { eventName: string }[] } { + const events: { eventName: string }[] = []; + return { + init: vi.fn(), + trackEvent: (eventType: string, module: string, eventName: string) => { + events.push({ eventName }); + }, + flush: vi.fn(), + shutdown: vi.fn(), + getInstallId: () => 'test-install', + getSessionId: () => 'test-session', + events, + }; +} + +const TASKS_ENTITY: EntityConfig = { + endpoint: '/tasks', + partitionKey: 'userId', + conflictStrategy: 'server-wins', +}; + +function makeConfig( + storage: InMemoryAdapter, + apiClient: MockApiClient, + overrides?: Partial +): SyncEngineConfig { + return { + productId: 'test', + entities: { tasks: TASKS_ENTITY }, + storage, + apiClient, + maxRetries: 3, + retryBaseDelayMs: 1, // fast for tests + retryMaxDelayMs: 10, + ...overrides, }; } // ───────────────────────────────────────────────────────────────────────────── -// Tests +// Storage Adapter Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('InMemoryAdapter', () => { + it('stores and retrieves items', () => { + const s = new InMemoryAdapter(); + s.setItem('k', { x: 1 }); + expect(s.getItem<{ x: number }>('k')).toEqual({ x: 1 }); + }); + + it('returns null for missing keys', () => { + expect(new InMemoryAdapter().getItem('nope')).toBeNull(); + }); + + it('lists all keys', () => { + const s = new InMemoryAdapter(); + s.setItem('a', 1); + s.setItem('b', 2); + expect(s.keys()).toEqual(expect.arrayContaining(['a', 'b'])); + }); + + it('removes items', () => { + const s = new InMemoryAdapter(); + s.setItem('a', 1); + s.removeItem('a'); + expect(s.getItem('a')).toBeNull(); + }); + + it('clears all items', () => { + const s = new InMemoryAdapter(); + s.setItem('a', 1); + s.setItem('b', 2); + s.clear(); + expect(s.keys()).toHaveLength(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// computeBackoff +// ───────────────────────────────────────────────────────────────────────────── + +describe('computeBackoff', () => { + it('returns increasing delays', () => { + const d0 = computeBackoff(0, 1000, 30000); + const d1 = computeBackoff(1, 1000, 30000); + const d2 = computeBackoff(2, 1000, 30000); + // d0 ~ 1000, d1 ~ 2000, d2 ~ 4000 (+ jitter) + expect(d0).toBeLessThan(d1); + expect(d1).toBeLessThan(d2); + }); + + it('caps at maxMs', () => { + const d = computeBackoff(20, 1000, 5000); + expect(d).toBeLessThanOrEqual(5500); // 5000 + 10% jitter max + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Sync Engine — Core Operations // ───────────────────────────────────────────────────────────────────────────── describe('Sync Engine', () => { let storage: InMemoryAdapter; - let apiClient: ReturnType; + let apiClient: MockApiClient; beforeEach(() => { storage = new InMemoryAdapter(); apiClient = createMockApiClient(); }); - describe('createSyncEngine', () => { - it('creates a sync engine with default config', () => { - const engine = createSyncEngine({ - productId: 'test', + // ─── Creation ────────────────────────────────────────────────────────── + + it('creates engine with all interface methods', () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + expect(engine.push).toBeTypeOf('function'); + expect(engine.delete).toBeTypeOf('function'); + expect(engine.pull).toBeTypeOf('function'); + expect(engine.fullSync).toBeTypeOf('function'); + expect(engine.getQueueLength).toBeTypeOf('function'); + expect(engine.getStatus).toBeTypeOf('function'); + expect(engine.onStatusChange).toBeTypeOf('function'); + expect(engine.clearQueue).toBeTypeOf('function'); + expect(engine.reprocessFailed).toBeTypeOf('function'); + expect(engine.flush).toBeTypeOf('function'); + expect(engine.destroy).toBeTypeOf('function'); + }); + + // ─── Queue Persistence ───────────────────────────────────────────────── + + it('persists queue across engine instances (simulated restart)', async () => { + const engine1 = createSyncEngine(makeConfig(storage, apiClient)); + await engine1.push('tasks', { id: 't1', title: 'persist me' }); + engine1.destroy(); + + // "Restart" — new engine, same storage + const engine2 = createSyncEngine(makeConfig(storage, apiClient)); + const result = await engine2.fullSync(); + expect(result.pushed).toBe(1); + + const reqs = apiClient.getRequests(); + const postReq = reqs.find(r => r.options?.method === 'POST'); + expect(postReq).toBeDefined(); + expect(postReq!.path).toBe('/tasks'); + }); + + // ─── Push & Deduplication ────────────────────────────────────────────── + + it('deduplicates updates to same entity+id', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { id: '1', title: 'v1' }, 'update'); + await engine.push('tasks', { id: '1', title: 'v2' }, 'update'); + expect(engine.getQueueLength()).toBe(1); + + const result = await engine.fullSync(); + expect(result.pushed).toBe(1); + + // The last value should be sent + const patchReq = apiClient.getRequests().find(r => r.options?.method === 'PATCH'); + expect(patchReq).toBeDefined(); + expect(patchReq!.path).toBe('/tasks/1'); + const body = JSON.parse(patchReq!.options!.body as string); + expect(body.title).toBe('v2'); + }); + + it('does not deduplicate different operations on same id', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { id: '1', title: 'create' }, 'create'); + await engine.push('tasks', { id: '1', title: 'update' }, 'update'); + expect(engine.getQueueLength()).toBe(2); + }); + + it('does not deduplicate items without id', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { title: 'Task A' }); + await engine.push('tasks', { title: 'Task B' }); + expect(engine.getQueueLength()).toBe(2); + }); + + // ─── Delete ──────────────────────────────────────────────────────────── + + it('delete removes pending create/update for same entity+id', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { id: 'x', title: 'created' }); + await engine.push('tasks', { id: 'x', title: 'updated' }, 'update'); + // Now delete should collapse the above + await engine.delete('tasks', 'x'); + expect(engine.getQueueLength()).toBe(1); + + const result = await engine.fullSync(); + expect(result.pushed).toBe(1); + const delReq = apiClient.getRequests().find(r => r.options?.method === 'DELETE'); + expect(delReq).toBeDefined(); + expect(delReq!.path).toBe('/tasks/x'); + }); + + // ─── Pull + onPull Callback ──────────────────────────────────────────── + + it('invokes onPull with pulled items', async () => { + const pulled: { entity: string; items: unknown[] }[] = []; + apiClient.setSafeFetchBehavior(() => ({ + data: { items: [{ id: 'r1', title: 'Remote Task' }] }, + error: null, + })); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + onPull: (entity, items) => { + pulled.push({ entity, items }); + }, + }) + ); + + const result = await engine.pull(); + expect(result.pulled).toBe(1); + expect(pulled).toHaveLength(1); + expect(pulled[0].entity).toBe('tasks'); + expect(pulled[0].items).toHaveLength(1); + }); + + it('pull appends ?since= parameter after first sync', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + + await engine.pull(); // first pull — no since + const firstReq = apiClient.getRequests().find(r => r.path.startsWith('/tasks')); + expect(firstReq!.path).toBe('/tasks'); + + await engine.pull(); // second pull — should have since= + const allReqs = apiClient.getRequests().filter(r => r.path.startsWith('/tasks')); + const secondReq = allReqs[allReqs.length - 1]; + expect(secondReq.path).toContain('?since='); + }); + + // ─── fullSync ────────────────────────────────────────────────────────── + + it('fullSync pushes then pulls', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { id: 't1', title: 'local' }); + + const result = await engine.fullSync(); + expect(result.pushed).toBe(1); + expect(engine.getQueueLength()).toBe(0); + expect(engine.getStatus().lastSyncAt).toBeTruthy(); + }); + + // ─── Retry with Backoff ──────────────────────────────────────────────── + + it('retries on transient errors and keeps item in queue', async () => { + let callCount = 0; + apiClient.setFetchBehavior(() => { + callCount++; + throw new Error('500 Internal Server Error'); + }); + + const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 3 })); + await engine.push('tasks', { id: 'fail', title: 'will fail' }); + + await engine.flush(); + + // Item should still be in queue with incremented retryCount + expect(engine.getQueueLength()).toBe(1); + // Multiple fetch attempts were made (backoff retries within pushItemWithRetry) + expect(callCount).toBeGreaterThan(1); + }); + + it('drops item after exceeding maxRetries', async () => { + apiClient.setFetchBehavior(() => { + throw new Error('500'); + }); + + const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 1 })); + await engine.push('tasks', { id: 'drop', title: 'drop me' }); + + // First flush: pushItemWithRetry exhausts attempts, pushQueue increments retryCount + await engine.flush(); + // Second flush: retryCount >= maxRetries → dropped + await engine.flush(); + + expect(engine.getQueueLength()).toBe(0); + }); + + // ─── Conflict Resolution ─────────────────────────────────────────────── + + it('server-wins: accepts remote data on conflict', async () => { + const pulled: unknown[][] = []; + apiClient.setFetchBehavior(() => { + throw new SyncConflictError({ id: 'c1', title: 'Server Version' }); + }); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, }, - storage, - apiClient, - }); + onPull: (_entity, items) => { + pulled.push(items); + }, + }) + ); - expect(engine).toBeDefined(); - expect(engine.push).toBeDefined(); - expect(engine.pull).toBeDefined(); - expect(engine.fullSync).toBeDefined(); + await engine.push('tasks', { id: 'c1', title: 'Client Version' }); + const result = await engine.fullSync(); + + expect(result.conflicts).toBe(1); + // server-wins: onPull should have been called with remote data + expect(pulled.length).toBeGreaterThanOrEqual(1); + }); + + it('client-wins: re-pushes local data on conflict', async () => { + let callIdx = 0; + apiClient.setFetchBehavior(() => { + callIdx++; + if (callIdx === 1) { + throw new SyncConflictError({ id: 'c2', title: 'Server' }); + } + return {}; // Second call (PUT) succeeds + }); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + entities: { + tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'client-wins' }, + }, + }) + ); + + await engine.push('tasks', { id: 'c2', title: 'Client' }); + const result = await engine.fullSync(); + + expect(result.conflicts).toBe(1); + expect(result.pushed).toBe(1); // conflict resolved → counted as pushed + // Should have made a PUT request with client data + const putReq = apiClient.getRequests().find(r => r.options?.method === 'PUT'); + expect(putReq).toBeDefined(); + }); + + it('last-write-wins: picks newer timestamp', async () => { + const pulled: unknown[][] = []; + const oldDate = '2020-01-01T00:00:00.000Z'; + apiClient.setFetchBehavior(() => { + throw new SyncConflictError({ id: 'c3', title: 'Server', updatedAt: oldDate }); + }); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + entities: { + tasks: { + endpoint: '/tasks', + partitionKey: 'userId', + conflictStrategy: 'last-write-wins', + }, + }, + onPull: (_entity, items) => { + pulled.push(items); + }, + }) + ); + + // Client push will have a newer timestamp than 2020 + await engine.push('tasks', { id: 'c3', title: 'Client Newer' }); + const result = await engine.fullSync(); + expect(result.conflicts).toBe(1); + // Client is newer → should NOT have called onPull with server data + // Instead it should have re-pushed (PUT) + const putReq = apiClient.getRequests().find(r => r.options?.method === 'PUT'); + expect(putReq).toBeDefined(); + }); + + it('manual: calls onConflict handler', async () => { + apiClient.setFetchBehavior((_path, options) => { + const method = options?.method; + if (method === 'POST') { + throw new SyncConflictError({ id: 'c4', title: 'Server' }); + } + return {}; + }); + + const onConflict = vi.fn().mockResolvedValue({ id: 'c4', title: 'Merged' }); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + entities: { + tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'manual' }, + }, + onConflict, + }) + ); + + await engine.push('tasks', { id: 'c4', title: 'Client' }); + const result = await engine.fullSync(); + + expect(result.conflicts).toBe(1); + expect(onConflict).toHaveBeenCalledTimes(1); + expect(onConflict.mock.calls[0][0]).toMatchObject({ + entity: 'tasks', + remoteData: { id: 'c4', title: 'Server' }, }); }); - describe('push', () => { - it('adds item to queue', async () => { - const engine = createSyncEngine({ - productId: 'test', - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, - }, - storage, - apiClient, - }); + // ─── Multiple Entities ───────────────────────────────────────────────── - await engine.push('tasks', { title: 'Test Task' }); - - const status = engine.getStatus(); - expect(status.status).toBe('idle'); - expect(status.queueLength).toBe(1); - expect(engine.getQueueLength()).toBe(1); + it('handles multiple entity types', async () => { + const pulled: { entity: string; items: unknown[] }[] = []; + apiClient.setSafeFetchBehavior(path => { + if (path.startsWith('/tasks')) { + return { data: { items: [{ id: 't1' }] }, error: null }; + } + if (path.startsWith('/notes')) { + return { data: { items: [{ id: 'n1' }, { id: 'n2' }] }, error: null }; + } + return { data: { items: [] }, error: null }; }); - it('deduplicates items for same entity', async () => { - const engine = createSyncEngine({ - productId: 'test', + const engine = createSyncEngine( + makeConfig(storage, apiClient, { entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, + notes: { endpoint: '/notes', partitionKey: 'userId', conflictStrategy: 'client-wins' }, }, - storage, - apiClient, - }); + onPull: (entity, items) => { + pulled.push({ entity, items }); + }, + }) + ); - await engine.push('tasks', { id: '1', title: 'Task 1' }); - await engine.push('tasks', { id: '1', title: 'Task 1 Updated' }); + await engine.push('tasks', { id: 't-new', title: 'Task' }); + await engine.push('notes', { id: 'n-new', body: 'Note' }); - expect(engine.getQueueLength()).toBe(1); - - // Queue should have 1 item (deduplicated) - const result = await engine.fullSync(); - expect(result.pushed).toBe(1); - }); + const result = await engine.fullSync(); + expect(result.pushed).toBe(2); + expect(result.pulled).toBe(3); // 1 task + 2 notes + expect(pulled).toHaveLength(2); }); - describe('delete', () => { - it('creates delete operation', async () => { - const engine = createSyncEngine({ - productId: 'test', - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, - }, - storage, - apiClient, - }); + // ─── Status Monitoring ───────────────────────────────────────────────── - await engine.delete('tasks', 'task-123'); - - const requests = apiClient.getRequests(); - // Delete is queued but not flushed until sync - expect(requests.length).toBe(0); - }); + it('returns correct initial status', () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + const status = engine.getStatus(); + expect(status.status).toBe('idle'); + expect(status.queueLength).toBe(0); + expect(status.lastSyncAt).toBeUndefined(); }); - describe('fullSync', () => { - it('pushes queued items', async () => { - const engine = createSyncEngine({ - productId: 'test', - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, - }, - storage, - apiClient, - }); + it('notifies listeners on status changes', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + const statuses: SyncStatusInfo[] = []; + engine.onStatusChange(s => statuses.push({ ...s })); - await engine.push('tasks', { title: 'Test Task' }); - const result = await engine.fullSync(); + await engine.push('tasks', { id: 'x', title: 'X' }); + await engine.fullSync(); - expect(result.pushed).toBe(1); - expect(engine.getQueueLength()).toBe(0); - expect(engine.getStatus().lastSyncAt).toBeTruthy(); - - const requests = apiClient.getRequests(); - expect(requests).toHaveLength(2); // Pull + Push - }); - - it('pulls remote changes', async () => { - const engine = createSyncEngine({ - productId: 'test', - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, - }, - storage, - apiClient, - }); - - const result = await engine.fullSync(); - expect(result.pulled).toBe(0); // Mock returns empty - expect(engine.getStatus().lastSyncAt).toBeTruthy(); - }); + const statusNames = statuses.map(s => s.status); + expect(statusNames).toContain('syncing'); + expect(statusNames).toContain('idle'); }); - describe('status and monitoring', () => { - it('returns initial status', () => { - const engine = createSyncEngine({ - productId: 'test', - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, - }, - storage, - apiClient, - }); + it('unsubscribe stops notifications', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + const statuses: string[] = []; + const unsub = engine.onStatusChange(s => statuses.push(s.status)); - const status = engine.getStatus(); - expect(status.status).toBe('idle'); - expect(status.queueLength).toBe(0); - expect(status.lastSyncAt).toBeUndefined(); - }); + await engine.push('tasks', { title: 'A' }); + const countBefore = statuses.length; - it('notifies status changes', async () => { - const engine = createSyncEngine({ - productId: 'test', - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, - }, - storage, - apiClient, - }); - - const statuses: string[] = []; - engine.onStatusChange(status => { - statuses.push(status.status); - }); - - await engine.push('tasks', { title: 'Test' }); - - expect(statuses).toContain('idle'); - }); + unsub(); + await engine.push('tasks', { title: 'B' }); + expect(statuses.length).toBe(countBefore); }); - describe('clearQueue', () => { - it('removes all queued items', async () => { - const engine = createSyncEngine({ - productId: 'test', - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, - }, - storage, - apiClient, - }); + // ─── clearQueue ──────────────────────────────────────────────────────── - await engine.push('tasks', { title: 'Task 1' }); - await engine.push('tasks', { title: 'Task 2' }); - expect(engine.getQueueLength()).toBe(2); + it('clearQueue empties the queue', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { title: 'A' }); + await engine.push('tasks', { title: 'B' }); + expect(engine.getQueueLength()).toBe(2); - await engine.clearQueue(); - expect(engine.getQueueLength()).toBe(0); + await engine.clearQueue(); + expect(engine.getQueueLength()).toBe(0); - const result = await engine.fullSync(); - expect(result.pushed).toBe(0); - }); + const result = await engine.fullSync(); + expect(result.pushed).toBe(0); }); - describe('reprocessFailed', () => { - it('resets retry count on failed items', async () => { - const engine = createSyncEngine({ - productId: 'test', - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, - }, - storage, - apiClient, - }); + // ─── reprocessFailed ─────────────────────────────────────────────────── - await engine.push('tasks', { title: 'Test' }); - const requestsBefore = apiClient.getRequests().length; - await engine.reprocessFailed(); - - // reprocessFailed calls flush() which pushes the item - const requestsAfter = apiClient.getRequests().length; - expect(requestsAfter).toBeGreaterThan(requestsBefore); - }); - }); -}); - -describe('Storage Adapters', () => { - describe('InMemoryAdapter', () => { - it('stores and retrieves items', () => { - const storage = new InMemoryAdapter(); - storage.setItem('key1', { value: 123 }); - - const retrieved = storage.getItem<{ value: number }>('key1'); - expect(retrieved).toEqual({ value: 123 }); - }); - - it('returns null for missing keys', () => { - const storage = new InMemoryAdapter(); - const retrieved = storage.getItem('missing'); - expect(retrieved).toBeNull(); - }); - - it('lists all keys', () => { - const storage = new InMemoryAdapter(); - storage.setItem('key1', 'value1'); - storage.setItem('key2', 'value2'); - - const keys = storage.keys(); - expect(keys).toContain('key1'); - expect(keys).toContain('key2'); - }); - - it('removes items', () => { - const storage = new InMemoryAdapter(); - storage.setItem('key1', 'value1'); - storage.removeItem('key1'); - - expect(storage.getItem('key1')).toBeNull(); - }); - - it('clears all items', () => { - const storage = new InMemoryAdapter(); - storage.setItem('key1', 'value1'); - storage.setItem('key2', 'value2'); - storage.clear(); - - expect(storage.keys()).toHaveLength(0); + it('reprocessFailed resets retry counts and re-flushes', async () => { + let failCount = 0; + apiClient.setFetchBehavior(() => { + failCount++; + if (failCount <= 2) throw new Error('transient'); + return {}; }); + + const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 1 })); + await engine.push('tasks', { id: 'rp', title: 'reprocess' }); + await engine.flush(); // fails, item stays in queue + + expect(engine.getQueueLength()).toBe(1); + + // Now make API succeed and reprocess + apiClient.setFetchBehavior(() => ({})); + await engine.reprocessFailed(); + expect(engine.getQueueLength()).toBe(0); + }); + + // ─── Telemetry Integration ───────────────────────────────────────────── + + it('tracks sync events via telemetry client', async () => { + const telemetry = createMockTelemetry(); + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + telemetryClient: telemetry, + }) + ); + + await engine.push('tasks', { id: 't1', title: 'test' }); + await engine.fullSync(); + + const eventNames = telemetry.events.map(e => e.eventName); + expect(eventNames).toContain('sync_push_success'); + expect(eventNames).toContain('sync_complete'); + }); + + it('telemetry tracks push errors', async () => { + const telemetry = createMockTelemetry(); + apiClient.setFetchBehavior(() => { + throw new Error('400 Bad Request'); + }); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + telemetryClient: telemetry, + maxRetries: 1, + }) + ); + + await engine.push('tasks', { id: 'bad', title: 'fail' }); + await engine.flush(); + await engine.flush(); // second flush drops item + + const eventNames = telemetry.events.map(e => e.eventName); + expect(eventNames).toContain('sync_push_dropped'); + }); + + // ─── Destroy ─────────────────────────────────────────────────────────── + + it('destroy prevents further flush', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { title: 'orphan' }); + engine.destroy(); + + await engine.flush(); // should be no-op after destroy + // Item still in queue (flush was no-op) + expect(engine.getQueueLength()).toBe(1); }); }); diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 779deca3..e8fc60d6 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -20,20 +20,36 @@ export type SyncStatus = 'idle' | 'syncing' | 'offline' | 'error'; export type SyncOperation = 'create' | 'update' | 'delete'; export interface EntityConfig { + /** REST endpoint path, e.g. '/api/timers' */ endpoint: string; + /** Cosmos partition key field name (for reference) */ partitionKey: string; + /** Conflict resolution strategy */ conflictStrategy: ConflictStrategy; } +/** + * Callback invoked when items are pulled from the server. + * Consumer is responsible for merging pulled data into their local store. + */ +export type PullHandler = (entity: EntityName, items: unknown[]) => void | Promise; + export interface SyncEngineConfig { productId: string; entities: Record; storage: StorageAdapter; apiClient: ApiClient; telemetryClient?: TelemetryClient; - onConflict?: (local: SyncItem, remote: unknown) => Promise | unknown; + /** Called when items are pulled from server — consumer merges into local store */ + onPull?: PullHandler; + /** Called for 'manual' conflict strategy. Return the winning data. */ + onConflict?: (conflict: Conflict) => Promise | unknown; + /** Max retry attempts before dropping an item. Default: 5. */ maxRetries?: number; - retryDelayMs?: number; + /** Base delay in ms for exponential backoff. Default: 1000. */ + retryBaseDelayMs?: number; + /** Maximum backoff delay in ms. Default: 30000. */ + retryMaxDelayMs?: number; } export interface SyncItem { @@ -68,7 +84,6 @@ export interface Conflict { entity: EntityName; localItem: SyncItem; remoteData: unknown; - localData: unknown; } // ───────────────────────────────────────────────────────────────────────────── @@ -87,18 +102,28 @@ export interface StorageAdapter { // ───────────────────────────────────────────────────────────────────────────── export interface SyncEngine { - // Core operations + /** Queue a create/update for later push. Deduplicates by entity + data.id. */ push(entity: EntityName, data: unknown, operation?: SyncOperation): Promise; + /** Queue a delete for later push. */ delete(entity: EntityName, id: string): Promise; + /** Pull remote changes for all entities. Invokes onPull callback. */ pull(): Promise; + /** Push queued items, then pull remote changes. */ fullSync(): Promise; - // Status and monitoring + /** Current number of items in the offline queue. */ getQueueLength(): number; + /** Current sync status snapshot. */ getStatus(): SyncStatusInfo; + /** Subscribe to status changes. Returns unsubscribe function. */ onStatusChange(callback: SyncStatusCallback): () => void; - // Utility + /** Remove all items from the offline queue. */ clearQueue(): Promise; + /** Reset retry counts on all failed items and re-flush. */ reprocessFailed(): Promise; + /** Manually trigger a flush of the push queue. */ + flush(): Promise; + /** Tear down connectivity listeners. */ + destroy(): void; }