chore: update dependencies
This commit is contained in:
parent
0f299231cc
commit
f56672508a
@ -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:*",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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're looking for doesn'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" />;
|
||||
}
|
||||
|
||||
@ -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're looking for doesn'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" />;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
253
packages/dashboard-components/src/components.test.tsx
Normal file
253
packages/dashboard-components/src/components.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
|
||||
@ -8,5 +8,6 @@
|
||||
"declarationMap": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
|
||||
9
packages/dashboard-components/vitest.config.ts
Normal file
9
packages/dashboard-components/vitest.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user