);
}
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;
}