chore: update dependencies

This commit is contained in:
saravanakumardb1 2026-03-19 21:25:30 -07:00
parent 0f299231cc
commit f56672508a
20 changed files with 1319 additions and 427 deletions

View File

@ -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:*",

View File

@ -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,

View File

@ -1,21 +1,5 @@
import Link from 'next/link';
import { NotFoundPage } from '@bytelyst/dashboard-components';
export default function NotFound() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="mx-auto max-w-md text-center">
<div className="mb-4 text-6xl font-bold text-muted-foreground">404</div>
<h2 className="mb-2 text-xl font-semibold">Page not found</h2>
<p className="mb-6 text-sm text-muted-foreground">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Link
href="/"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Go to Dashboard
</Link>
</div>
</div>
);
return <NotFoundPage backHref="/" backLabel="Go to Dashboard" />;
}

View File

@ -1,21 +1,5 @@
import Link from 'next/link';
import { NotFoundPage } from '@bytelyst/dashboard-components';
export default function NotFound() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="mx-auto max-w-md text-center">
<div className="mb-4 text-6xl font-bold text-muted-foreground">404</div>
<h2 className="mb-2 text-xl font-semibold">Page not found</h2>
<p className="mb-6 text-sm text-muted-foreground">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Link
href="/"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Go Home
</Link>
</div>
</div>
);
return <NotFoundPage backHref="/" backLabel="Go Home" />;
}

View File

@ -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",

View File

@ -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"
}
}

View File

@ -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 (
<div className="flex flex-col items-center justify-center min-h-[300px] p-8 text-center">
<div
className={`flex flex-col items-center justify-center min-h-[300px] p-8 text-center ${className}`}
>
{icon && (
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
<div
className="w-16 h-16 rounded-full flex items-center justify-center mb-4"
style={{ backgroundColor: 'var(--color-muted, #f3f4f6)' }}
>
{icon}
</div>
)}
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">{title}</h3>
<p className="text-gray-500 dark:text-gray-400 max-w-sm mb-6">{description}</p>
<h3
className="text-lg font-medium mb-2"
style={{ color: 'var(--color-foreground, #111827)' }}
>
{title}
</h3>
<p className="max-w-sm mb-6" style={{ color: 'var(--color-muted-foreground, #6b7280)' }}>
{description}
</p>
{action && (
<button
onClick={action.onClick}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
className="px-4 py-2 rounded-lg transition-colors text-white"
style={{ backgroundColor: 'var(--color-primary, #2563eb)' }}
>
{action.label}
</button>

View File

@ -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 (
<div className="flex flex-col items-center justify-center min-h-[400px] p-8">
<div className="w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className={`flex flex-col items-center justify-center min-h-[400px] p-8 ${className}`}>
<div
className="w-16 h-16 rounded-full flex items-center justify-center mb-4"
style={{ backgroundColor: 'var(--color-destructive-muted, #fef2f2)' }}
>
<svg
className="w-8 h-8"
style={{ color: 'var(--color-destructive, #ef4444)' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
@ -23,12 +34,23 @@ export function ErrorPage({
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{title}</h2>
<p className="text-gray-600 dark:text-gray-400 text-center max-w-md mb-6">{message}</p>
<h2
className="text-xl font-semibold mb-2"
style={{ color: 'var(--color-foreground, #111827)' }}
>
{title}
</h2>
<p
className="text-center max-w-md mb-6"
style={{ color: 'var(--color-muted-foreground, #6b7280)' }}
>
{message}
</p>
{onRetry && (
<button
onClick={onRetry}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
className="px-4 py-2 rounded-lg transition-colors text-white"
style={{ backgroundColor: 'var(--color-primary, #2563eb)' }}
>
Try Again
</button>

View File

@ -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 (
<div className={`space-y-3 ${className}`}>
<div className={`space-y-3 ${className}`} role="status" aria-label="Loading content">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="h-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
<div
key={i}
className="h-12 rounded animate-pulse"
style={{ backgroundColor: 'var(--color-muted, #e5e7eb)' }}
/>
))}
</div>
);

View File

@ -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 (
<div className={`${sizeClasses[size]} ${className}`}>
<div className={`${sizeClasses[size]} ${className}`} role="status" aria-label="Loading">
<svg
className="animate-spin text-blue-600"
className="animate-spin"
style={{ color: 'var(--color-primary, currentColor)' }}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"

View File

@ -1,43 +1,61 @@
import type { ReactNode } from 'react';
interface NotFoundPageProps {
export interface NotFoundPageProps {
title?: string;
message?: string;
statusCode?: string;
backLabel?: string;
backHref?: string;
onBack?: () => 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 (
<div className="flex flex-col items-center justify-center min-h-[400px] p-8">
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
<svg
className="w-8 h-8 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<div className={`flex min-h-screen items-center justify-center p-4 ${className}`}>
<div className="mx-auto max-w-md text-center">
<div
className="mb-4 text-6xl font-bold"
style={{ color: 'var(--color-muted-foreground, #9ca3af)' }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{statusCode}
</div>
<h2
className="mb-2 text-xl font-semibold"
style={{ color: 'var(--color-foreground, #111827)' }}
>
{title}
</h2>
<p className="mb-6 text-sm" style={{ color: 'var(--color-muted-foreground, #6b7280)' }}>
{message}
</p>
{(onBack || backHref) &&
(backHref ? (
<a
href={backHref}
className="inline-block rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
style={{ backgroundColor: 'var(--color-primary, #2563eb)' }}
>
{backLabel}
</a>
) : (
<button
onClick={onBack}
className="rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
style={{ backgroundColor: 'var(--color-primary, #2563eb)' }}
>
{backLabel}
</button>
))}
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{title}</h2>
<p className="text-gray-600 dark:text-gray-400 text-center max-w-md mb-6">{message}</p>
{onBack && (
<button
onClick={onBack}
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors"
>
Go Back
</button>
)}
</div>
);
}

View File

@ -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 (
<div className="flex items-center justify-between mb-6">
<div className={`flex items-center justify-between mb-6 ${className}`}>
<div>
{breadcrumbs && breadcrumbs.length > 0 && (
<nav className="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-2">
<nav
className="flex items-center space-x-2 text-sm mb-2"
style={{ color: 'var(--color-muted-foreground, #6b7280)' }}
aria-label="Breadcrumb"
>
{breadcrumbs.map((crumb, index) => (
<span key={index} className="flex items-center">
{index > 0 && <span className="mx-2">/</span>}
{crumb.href ? (
<a href={crumb.href} className="hover:text-gray-700 dark:hover:text-gray-300">
<a
href={crumb.href}
className="hover:opacity-80 transition-opacity"
style={{ color: 'var(--color-muted-foreground, #6b7280)' }}
>
{crumb.label}
</a>
) : (
@ -26,7 +45,9 @@ export function PageHeader({ title, breadcrumbs, actions }: PageHeaderProps): Re
))}
</nav>
)}
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{title}</h1>
<h1 className="text-2xl font-bold" style={{ color: 'var(--color-foreground, #111827)' }}>
{title}
</h1>
</div>
{actions && <div className="flex items-center space-x-3">{actions}</div>}
</div>

View File

@ -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(<LoadingSpinner />);
const status = screen.getByRole('status');
expect(status).toBeDefined();
expect(status.className).toContain('w-8 h-8');
});
it('renders with small size', () => {
render(<LoadingSpinner size="sm" />);
const status = screen.getByRole('status');
expect(status.className).toContain('w-4 h-4');
});
it('renders with large size', () => {
render(<LoadingSpinner size="lg" />);
const status = screen.getByRole('status');
expect(status.className).toContain('w-12 h-12');
});
it('applies custom className', () => {
render(<LoadingSpinner className="mt-4" />);
const status = screen.getByRole('status');
expect(status.className).toContain('mt-4');
});
it('renders SVG spinner element', () => {
render(<LoadingSpinner />);
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(<LoadingSkeleton />);
const container = screen.getByRole('status');
const rows = container.querySelectorAll('.animate-pulse');
expect(rows.length).toBe(3);
});
it('renders custom number of rows', () => {
render(<LoadingSkeleton rows={5} />);
const container = screen.getByRole('status');
const rows = container.querySelectorAll('.animate-pulse');
expect(rows.length).toBe(5);
});
it('applies custom className', () => {
render(<LoadingSkeleton className="my-8" />);
const container = screen.getByRole('status');
expect(container.className).toContain('my-8');
});
it('renders pulse-animated skeleton rows', () => {
render(<LoadingSkeleton rows={1} />);
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(<EmptyState title="No items" description="Create your first item." />);
expect(screen.getByText('No items')).toBeDefined();
expect(screen.getByText('Create your first item.')).toBeDefined();
});
it('renders icon when provided', () => {
render(
<EmptyState
title="No items"
description="Create one."
icon={<span data-testid="icon">X</span>}
/>
);
expect(screen.getByTestId('icon')).toBeDefined();
});
it('does not render icon container when not provided', () => {
const { container } = render(<EmptyState title="No items" description="Create one." />);
const iconWrapper = container.querySelector('.w-16.h-16');
expect(iconWrapper).toBeNull();
});
it('renders action button and handles click', () => {
const onClick = vi.fn();
render(
<EmptyState
title="No items"
description="Create one."
action={{ label: 'Create', onClick }}
/>
);
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(<EmptyState title="No items" description="Create one." />);
const buttons = container.querySelectorAll('button');
expect(buttons.length).toBe(0);
});
it('renders with theme-aware structure', () => {
const { container } = render(<EmptyState title="Test" description="Desc" />);
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(<PageHeader title="Dashboard" />);
expect(screen.getByText('Dashboard')).toBeDefined();
});
it('renders breadcrumbs', () => {
render(
<PageHeader title="Users" breadcrumbs={[{ label: 'Home', href: '/' }, { label: 'Users' }]} />
);
expect(screen.getByText('Home')).toBeDefined();
expect(screen.getByLabelText('Breadcrumb')).toBeDefined();
});
it('renders breadcrumb links with href', () => {
render(
<PageHeader
title="Detail"
breadcrumbs={[{ label: 'Home', href: '/' }, { label: 'Detail' }]}
/>
);
const link = screen.getByText('Home');
expect(link.tagName).toBe('A');
expect(link.getAttribute('href')).toBe('/');
});
it('renders breadcrumb text without href', () => {
render(<PageHeader title="Detail" breadcrumbs={[{ label: 'Current' }]} />);
const text = screen.getByText('Current');
expect(text.tagName).toBe('SPAN');
});
it('renders actions', () => {
render(<PageHeader title="Test" actions={<button data-testid="action-btn">Action</button>} />);
expect(screen.getByTestId('action-btn')).toBeDefined();
});
it('does not render breadcrumb nav when empty', () => {
const { container } = render(<PageHeader title="Simple" />);
const nav = container.querySelector('nav');
expect(nav).toBeNull();
});
});
describe('ErrorPage', () => {
it('renders with default props', () => {
render(<ErrorPage />);
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(<ErrorPage title="Server Error" message="The server is down." />);
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(<ErrorPage onRetry={onRetry} />);
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(<ErrorPage />);
const buttons = container.querySelectorAll('button');
expect(buttons.length).toBe(0);
});
it('renders error icon and semantic structure', () => {
const { container } = render(<ErrorPage />);
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(<NotFoundPage />);
expect(screen.getByText('404')).toBeDefined();
expect(screen.getByText('Page not found')).toBeDefined();
});
it('renders custom status code', () => {
render(<NotFoundPage statusCode="403" title="Forbidden" />);
expect(screen.getByText('403')).toBeDefined();
expect(screen.getByText('Forbidden')).toBeDefined();
});
it('renders back button with onClick', () => {
const onBack = vi.fn();
render(<NotFoundPage onBack={onBack} />);
const button = screen.getByText('Go Back');
fireEvent.click(button);
expect(onBack).toHaveBeenCalledOnce();
});
it('renders back link with href', () => {
render(<NotFoundPage backHref="/" backLabel="Go Home" />);
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(<NotFoundPage />);
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(<NotFoundPage onBack={() => {}} backLabel="Return" />);
expect(screen.getByText('Return')).toBeDefined();
});
});

View File

@ -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';

View File

@ -8,5 +8,6 @@
"declarationMap": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*"]
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'happy-dom',
passWithNoTests: true,
},
});

View File

@ -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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ─────────────────────────────────────────────────────────────────────────────
// Sync Engine Implementation
// ─────────────────────────────────────────────────────────────────────────────
export class SyncEngineImpl implements SyncEngine {
private config: SyncEngineConfig;
private config: Required<
Pick<SyncEngineConfig, 'maxRetries' | 'retryBaseDelayMs' | 'retryMaxDelayMs'>
> &
SyncEngineConfig;
private status: SyncStatus = 'idle';
private queueLength = 0;
private lastSyncAt?: string;
private lastError?: string;
private statusListeners: Set<SyncStatusCallback> = 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<void> {
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<SyncResult> {
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<SyncResult> {
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<SyncResult> {
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<boolean> {
private async pushItemWithRetry(item: SyncItem): Promise<void> {
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<string, string> = {};
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<unknown>(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<number> {
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<boolean> {
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<void> {
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<void> {
await this.saveQueue([]);
this.notifyStatus();
}
async reprocessFailed(): Promise<void> {
@ -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<string, unknown>
): 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<string, number> | undefined,
});
} catch {
// Telemetry should never break sync
}
}
}

View File

@ -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';

View File

@ -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 <T>(path: string, options?: RequestInit): Promise<T> => {
requests.push({ path, options });
return { items: [] } as unknown as T;
if (fetchBehavior) return fetchBehavior(path, options) as T;
return {} as T;
},
safeFetch: async <T>(path: string, options?: RequestInit): Promise<ApiResult<T>> => {
requests.push({ path, options });
if (safeFetchBehavior) return safeFetchBehavior(path) as ApiResult<T>;
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>
): 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<typeof createMockApiClient>;
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);
});
});

View File

@ -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<void>;
export interface SyncEngineConfig {
productId: string;
entities: Record<EntityName, EntityConfig>;
storage: StorageAdapter;
apiClient: ApiClient;
telemetryClient?: TelemetryClient;
onConflict?: (local: SyncItem, remote: unknown) => Promise<unknown> | 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> | 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<void>;
/** Queue a delete for later push. */
delete(entity: EntityName, id: string): Promise<void>;
/** Pull remote changes for all entities. Invokes onPull callback. */
pull(): Promise<SyncResult>;
/** Push queued items, then pull remote changes. */
fullSync(): Promise<SyncResult>;
// 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<void>;
/** Reset retry counts on all failed items and re-flush. */
reprocessFailed(): Promise<void>;
/** Manually trigger a flush of the push queue. */
flush(): Promise<void>;
/** Tear down connectivity listeners. */
destroy(): void;
}