chore: update dependencies
This commit is contained in:
parent
0f299231cc
commit
f56672508a
@ -29,6 +29,7 @@
|
|||||||
"@bytelyst/api-client": "workspace:*",
|
"@bytelyst/api-client": "workspace:*",
|
||||||
"@bytelyst/auth": "workspace:*",
|
"@bytelyst/auth": "workspace:*",
|
||||||
"@bytelyst/config": "workspace:*",
|
"@bytelyst/config": "workspace:*",
|
||||||
|
"@bytelyst/dashboard-components": "workspace:*",
|
||||||
"@bytelyst/cosmos": "workspace:*",
|
"@bytelyst/cosmos": "workspace:*",
|
||||||
"@bytelyst/datastore": "workspace:*",
|
"@bytelyst/datastore": "workspace:*",
|
||||||
"@bytelyst/design-tokens": "workspace:*",
|
"@bytelyst/design-tokens": "workspace:*",
|
||||||
|
|||||||
@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
import { LoadingSpinner, EmptyState } from '@bytelyst/dashboard-components';
|
||||||
import { EmptyState } from '@/components/EmptyState';
|
|
||||||
import {
|
import {
|
||||||
Beaker,
|
Beaker,
|
||||||
Plus,
|
Plus,
|
||||||
|
|||||||
@ -1,21 +1,5 @@
|
|||||||
import Link from 'next/link';
|
import { NotFoundPage } from '@bytelyst/dashboard-components';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return <NotFoundPage backHref="/" backLabel="Go to Dashboard" />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,5 @@
|
|||||||
import Link from 'next/link';
|
import { NotFoundPage } from '@bytelyst/dashboard-components';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return <NotFoundPage backHref="/" backLabel="Go Home" />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
"audit": "pnpm -r audit --audit-level moderate",
|
"audit": "pnpm -r audit --audit-level moderate",
|
||||||
"clean": "pnpm -r exec rm -rf dist",
|
"clean": "pnpm -r exec rm -rf dist",
|
||||||
"prototype:self-test": "./scripts/prototype-self-test.sh",
|
"prototype:self-test": "./scripts/prototype-self-test.sh",
|
||||||
"prepare": "husky install"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.28.1",
|
"@changesets/cli": "^2.28.1",
|
||||||
|
|||||||
@ -4,25 +4,33 @@
|
|||||||
"description": "Shared React components for ByteLyst dashboards",
|
"description": "Shared React components for ByteLyst dashboards",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts"
|
"types": "./dist/index.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.0.0",
|
"react": ">=18.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react": "^19.2.14",
|
||||||
"react": "^19.0.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"react-dom": "^19.0.0",
|
"happy-dom": "^18.0.1",
|
||||||
"typescript": "^5.7.3"
|
"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';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface EmptyStateProps {
|
export interface EmptyStateProps {
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@ -8,22 +8,42 @@ interface EmptyStateProps {
|
|||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps): ReactNode {
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className = '',
|
||||||
|
}: EmptyStateProps): ReactNode {
|
||||||
return (
|
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 && (
|
{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}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">{title}</h3>
|
<h3
|
||||||
<p className="text-gray-500 dark:text-gray-400 max-w-sm mb-6">{description}</p>
|
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 && (
|
{action && (
|
||||||
<button
|
<button
|
||||||
onClick={action.onClick}
|
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}
|
{action.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,20 +1,31 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface ErrorPageProps {
|
export interface ErrorPageProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorPage({
|
export function ErrorPage({
|
||||||
title = 'Something went wrong',
|
title = 'Something went wrong',
|
||||||
message = 'An unexpected error occurred. Please try again.',
|
message = 'An unexpected error occurred. Please try again.',
|
||||||
onRetry,
|
onRetry,
|
||||||
|
className = '',
|
||||||
}: ErrorPageProps): ReactNode {
|
}: ErrorPageProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-8">
|
<div className={`flex flex-col items-center justify-center min-h-[400px] p-8 ${className}`}>
|
||||||
<div className="w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
|
<div
|
||||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@ -23,12 +34,23 @@ export function ErrorPage({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{title}</h2>
|
<h2
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-center max-w-md mb-6">{message}</p>
|
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 && (
|
{onRetry && (
|
||||||
<button
|
<button
|
||||||
onClick={onRetry}
|
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
|
Try Again
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,15 +1,19 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface LoadingSkeletonProps {
|
export interface LoadingSkeletonProps {
|
||||||
rows?: number;
|
rows?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoadingSkeleton({ rows = 3, className = '' }: LoadingSkeletonProps): ReactNode {
|
export function LoadingSkeleton({ rows = 3, className = '' }: LoadingSkeletonProps): ReactNode {
|
||||||
return (
|
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) => (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface LoadingSpinnerProps {
|
export interface LoadingSpinnerProps {
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@ -13,9 +13,10 @@ export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerPr
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${sizeClasses[size]} ${className}`}>
|
<div className={`${sizeClasses[size]} ${className}`} role="status" aria-label="Loading">
|
||||||
<svg
|
<svg
|
||||||
className="animate-spin text-blue-600"
|
className="animate-spin"
|
||||||
|
style={{ color: 'var(--color-primary, currentColor)' }}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
@ -1,43 +1,61 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface NotFoundPageProps {
|
export interface NotFoundPageProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
statusCode?: string;
|
||||||
|
backLabel?: string;
|
||||||
|
backHref?: string;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotFoundPage({
|
export function NotFoundPage({
|
||||||
title = 'Page Not Found',
|
title = 'Page not found',
|
||||||
message = 'The page you are looking for does not exist.',
|
message = "The page you're looking for doesn't exist or has been moved.",
|
||||||
|
statusCode = '404',
|
||||||
|
backLabel = 'Go Back',
|
||||||
|
backHref,
|
||||||
onBack,
|
onBack,
|
||||||
|
className = '',
|
||||||
}: NotFoundPageProps): ReactNode {
|
}: NotFoundPageProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-8">
|
<div className={`flex min-h-screen items-center justify-center p-4 ${className}`}>
|
||||||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
<div className="mx-auto max-w-md text-center">
|
||||||
<svg
|
<div
|
||||||
className="w-8 h-8 text-gray-500"
|
className="mb-4 text-6xl font-bold"
|
||||||
fill="none"
|
style={{ color: 'var(--color-muted-foreground, #9ca3af)' }}
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<path
|
{statusCode}
|
||||||
strokeLinecap="round"
|
</div>
|
||||||
strokeLinejoin="round"
|
<h2
|
||||||
strokeWidth={2}
|
className="mb-2 text-xl font-semibold"
|
||||||
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"
|
style={{ color: 'var(--color-foreground, #111827)' }}
|
||||||
/>
|
>
|
||||||
</svg>
|
{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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,41 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface PageHeaderProps {
|
export interface Breadcrumb {
|
||||||
title: string;
|
label: string;
|
||||||
breadcrumbs?: Array<{ label: string; href?: string }>;
|
href?: string;
|
||||||
actions?: ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className={`flex items-center justify-between mb-6 ${className}`}>
|
||||||
<div>
|
<div>
|
||||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
{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) => (
|
{breadcrumbs.map((crumb, index) => (
|
||||||
<span key={index} className="flex items-center">
|
<span key={index} className="flex items-center">
|
||||||
{index > 0 && <span className="mx-2">/</span>}
|
{index > 0 && <span className="mx-2">/</span>}
|
||||||
{crumb.href ? (
|
{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}
|
{crumb.label}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
@ -26,7 +45,9 @@ export function PageHeader({ title, breadcrumbs, actions }: PageHeaderProps): Re
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</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>
|
</div>
|
||||||
{actions && <div className="flex items-center space-x-3">{actions}</div>}
|
{actions && <div className="flex items-center space-x-3">{actions}</div>}
|
||||||
</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
|
* @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 { ErrorPage, type ErrorPageProps } from './ErrorPage.js';
|
||||||
export { NotFoundPage } from './NotFoundPage.js';
|
export { NotFoundPage, type NotFoundPageProps } from './NotFoundPage.js';
|
||||||
export { LoadingSpinner } from './LoadingSpinner.js';
|
export { LoadingSpinner, type LoadingSpinnerProps } from './LoadingSpinner.js';
|
||||||
export { LoadingSkeleton } from './LoadingSkeleton.js';
|
export { LoadingSkeleton, type LoadingSkeletonProps } from './LoadingSkeleton.js';
|
||||||
export { EmptyState } from './EmptyState.js';
|
export { EmptyState, type EmptyStateProps } from './EmptyState.js';
|
||||||
export { PageHeader } from './PageHeader.js';
|
export { PageHeader, type PageHeaderProps, type Breadcrumb } from './PageHeader.js';
|
||||||
|
|||||||
@ -8,5 +8,6 @@
|
|||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
"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
|
* 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
|
* @module @bytelyst/sync/engine
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -15,6 +24,7 @@ import type {
|
|||||||
EntityName,
|
EntityName,
|
||||||
SyncOperation,
|
SyncOperation,
|
||||||
ConflictStrategy,
|
ConflictStrategy,
|
||||||
|
Conflict,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -22,26 +32,60 @@ import type {
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_MAX_RETRIES = 5;
|
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 QUEUE_KEY = 'queue';
|
||||||
const LAST_SYNC_KEY = 'lastSync';
|
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
|
// Sync Engine Implementation
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class SyncEngineImpl implements SyncEngine {
|
export class SyncEngineImpl implements SyncEngine {
|
||||||
private config: SyncEngineConfig;
|
private config: Required<
|
||||||
|
Pick<SyncEngineConfig, 'maxRetries' | 'retryBaseDelayMs' | 'retryMaxDelayMs'>
|
||||||
|
> &
|
||||||
|
SyncEngineConfig;
|
||||||
private status: SyncStatus = 'idle';
|
private status: SyncStatus = 'idle';
|
||||||
private queueLength = 0;
|
private queueLength = 0;
|
||||||
private lastSyncAt?: string;
|
private lastSyncAt?: string;
|
||||||
|
private lastError?: string;
|
||||||
private statusListeners: Set<SyncStatusCallback> = new Set();
|
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) {
|
constructor(config: SyncEngineConfig) {
|
||||||
this.config = {
|
this.config = {
|
||||||
maxRetries: DEFAULT_MAX_RETRIES,
|
maxRetries: DEFAULT_MAX_RETRIES,
|
||||||
retryDelayMs: DEFAULT_RETRY_DELAY_MS,
|
retryBaseDelayMs: DEFAULT_RETRY_BASE_DELAY_MS,
|
||||||
|
retryMaxDelayMs: DEFAULT_RETRY_MAX_DELAY_MS,
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
this.setupConnectivityDetection();
|
this.setupConnectivityDetection();
|
||||||
@ -65,79 +109,95 @@ export class SyncEngineImpl implements SyncEngine {
|
|||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deduplication: Check if there's already a pending item for same entity/data
|
|
||||||
const existingQueue = await this.getQueue();
|
const existingQueue = await this.getQueue();
|
||||||
const dedupKey = this.getDedupKey(entity, data);
|
const dedupKey = this.getDedupKey(entity, data);
|
||||||
const existingIndex = existingQueue.findIndex(
|
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) {
|
if (existingIndex >= 0) {
|
||||||
// Replace existing item with newer data
|
|
||||||
existingQueue[existingIndex] = item;
|
existingQueue[existingIndex] = item;
|
||||||
} else {
|
} else {
|
||||||
existingQueue.push(item);
|
existingQueue.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveQueue(existingQueue);
|
await this.saveQueue(existingQueue);
|
||||||
this.queueLength = existingQueue.length;
|
this.notifyStatus();
|
||||||
this.updateStatus('idle');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(entity: EntityName, id: string): Promise<void> {
|
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> {
|
async pull(): Promise<SyncResult> {
|
||||||
const result: SyncResult = {
|
const result = this.emptyResult();
|
||||||
success: true,
|
this.setStatus('syncing');
|
||||||
pushed: 0,
|
|
||||||
pulled: 0,
|
|
||||||
conflicts: 0,
|
|
||||||
errors: 0,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.updateStatus('syncing');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pull changes from server for each entity
|
|
||||||
for (const [entityName, entityConfig] of Object.entries(this.config.entities)) {
|
for (const [entityName, entityConfig] of Object.entries(this.config.entities)) {
|
||||||
try {
|
try {
|
||||||
const pulled = await this.pullEntity(entityName, entityConfig.endpoint);
|
const count = await this.pullEntity(entityName, entityConfig.endpoint);
|
||||||
result.pulled += pulled;
|
result.pulled += count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result.errors++;
|
result.errors++;
|
||||||
this.trackError('pull', entityName, error);
|
this.trackTelemetry('sync_pull_error', entityName, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.timestamp = new Date().toISOString();
|
||||||
await this.setLastSyncTime(result.timestamp);
|
await this.setLastSyncTime(result.timestamp);
|
||||||
this.lastSyncAt = result.timestamp;
|
this.lastSyncAt = result.timestamp;
|
||||||
|
this.setStatus(result.errors > 0 ? 'error' : 'idle');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result.success = false;
|
result.success = false;
|
||||||
this.updateStatus('error', error instanceof Error ? error.message : 'Unknown error');
|
this.setStatus('error', error instanceof Error ? error.message : 'Unknown error');
|
||||||
}
|
|
||||||
|
|
||||||
if (result.success && result.errors === 0) {
|
|
||||||
this.updateStatus('idle');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fullSync(): Promise<SyncResult> {
|
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();
|
const pullResult = await this.pull();
|
||||||
|
|
||||||
return {
|
const combined: SyncResult = {
|
||||||
success: result.success && pullResult.success,
|
success: pushResult.success && pullResult.success,
|
||||||
pushed: result.pushed,
|
pushed: pushResult.pushed,
|
||||||
pulled: pullResult.pulled,
|
pulled: pullResult.pulled,
|
||||||
conflicts: result.conflicts + pullResult.conflicts,
|
conflicts: pushResult.conflicts + pullResult.conflicts,
|
||||||
errors: result.errors + pullResult.errors,
|
errors: pushResult.errors + pullResult.errors,
|
||||||
timestamp: new Date().toISOString(),
|
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> {
|
private async pushQueue(): Promise<SyncResult> {
|
||||||
const queue = await this.getQueue();
|
const queue = await this.getQueue();
|
||||||
const result: SyncResult = {
|
const result = this.emptyResult();
|
||||||
success: true,
|
|
||||||
pushed: 0,
|
|
||||||
pulled: 0,
|
|
||||||
conflicts: 0,
|
|
||||||
errors: 0,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (queue.length === 0) {
|
if (queue.length === 0) return result;
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateStatus('syncing');
|
|
||||||
|
|
||||||
|
this.setStatus('syncing');
|
||||||
const remaining: SyncItem[] = [];
|
const remaining: SyncItem[] = [];
|
||||||
|
|
||||||
for (const item of queue) {
|
for (const item of queue) {
|
||||||
try {
|
try {
|
||||||
const success = await this.pushItem(item);
|
await this.pushItemWithRetry(item);
|
||||||
if (success) {
|
result.pushed++;
|
||||||
result.pushed++;
|
|
||||||
} else {
|
|
||||||
remaining.push(item);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} 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.retryCount++;
|
||||||
item.lastError = error instanceof Error ? error.message : String(error);
|
item.lastError = error instanceof Error ? error.message : String(error);
|
||||||
remaining.push(item);
|
remaining.push(item);
|
||||||
} else {
|
} else {
|
||||||
result.errors++;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pushItem(item: SyncItem): Promise<boolean> {
|
private async pushItemWithRetry(item: SyncItem): Promise<void> {
|
||||||
const entityConfig = this.config.entities[item.entity];
|
const entityConfig = this.config.entities[item.entity];
|
||||||
if (!entityConfig) {
|
if (!entityConfig) {
|
||||||
throw new Error(`Unknown entity: ${item.entity}`);
|
throw new Error(`Unknown entity: ${item.entity}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dataId = (item.data as { id?: string })?.id;
|
||||||
const path =
|
const path =
|
||||||
item.operation === 'delete' || item.operation === 'update'
|
(item.operation === 'delete' || item.operation === 'update') && dataId
|
||||||
? `${entityConfig.endpoint}/${(item.data as { id: string }).id}`
|
? `${entityConfig.endpoint}/${dataId}`
|
||||||
: entityConfig.endpoint;
|
: entityConfig.endpoint;
|
||||||
|
|
||||||
const method =
|
const method =
|
||||||
item.operation === 'delete' ? 'DELETE' : item.operation === 'update' ? 'PATCH' : 'POST';
|
item.operation === 'delete' ? 'DELETE' : item.operation === 'update' ? 'PATCH' : 'POST';
|
||||||
|
|
||||||
try {
|
const headers: Record<string, string> = {};
|
||||||
await this.config.apiClient.fetch(path, {
|
if (method !== 'DELETE') {
|
||||||
method,
|
headers['Content-Type'] = 'application/json';
|
||||||
body: method !== 'DELETE' ? JSON.stringify(item.data) : undefined,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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> {
|
private async pullEntity(entityName: string, endpoint: string): Promise<number> {
|
||||||
const lastSync = await this.getLastSyncTime();
|
const lastSync = await this.getLastSyncTime();
|
||||||
const path = lastSync ? `${endpoint}?since=${encodeURIComponent(lastSync)}` : endpoint;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store pulled items locally (consumer handles storage)
|
const items = response.data.items ?? [];
|
||||||
return result.data.items?.length || 0;
|
|
||||||
|
if (items.length > 0 && this.config.onPull) {
|
||||||
|
await this.config.onPull(entityName, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
// Conflict Resolution
|
// 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(
|
private async resolveConflict(
|
||||||
item: SyncItem,
|
item: SyncItem,
|
||||||
remoteData: unknown,
|
remoteData: unknown,
|
||||||
@ -253,55 +385,82 @@ export class SyncEngineImpl implements SyncEngine {
|
|||||||
switch (strategy) {
|
switch (strategy) {
|
||||||
case 'server-wins':
|
case 'server-wins':
|
||||||
return remoteData;
|
return remoteData;
|
||||||
|
|
||||||
case 'client-wins':
|
case 'client-wins':
|
||||||
return item.data;
|
return item.data;
|
||||||
|
|
||||||
case 'last-write-wins': {
|
case 'last-write-wins': {
|
||||||
const localTime = new Date(item.timestamp).getTime();
|
const localTime = new Date(item.timestamp).getTime();
|
||||||
const remoteTime = new Date(
|
const remoteTime = new Date(
|
||||||
(remoteData as { updatedAt?: string })?.updatedAt || 0
|
(remoteData as { updatedAt?: string })?.updatedAt ?? '1970-01-01'
|
||||||
).getTime();
|
).getTime();
|
||||||
return localTime > remoteTime ? item.data : remoteData;
|
return localTime > remoteTime ? item.data : remoteData;
|
||||||
}
|
}
|
||||||
case 'manual':
|
|
||||||
|
case 'manual': {
|
||||||
if (this.config.onConflict) {
|
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;
|
return remoteData;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return remoteData;
|
return remoteData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
// Connectivity
|
// Connectivity Detection
|
||||||
// ───────────────────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private setupConnectivityDetection(): void {
|
private setupConnectivityDetection(): void {
|
||||||
if (typeof window !== 'undefined' && window.addEventListener) {
|
if (typeof globalThis === 'undefined') return;
|
||||||
const handleOnline = () => {
|
const win = typeof window !== 'undefined' ? window : undefined;
|
||||||
void this.flush();
|
if (!win?.addEventListener) return;
|
||||||
this.connectivityListeners.forEach(cb => cb());
|
|
||||||
};
|
|
||||||
|
|
||||||
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 {
|
private isOnline(): boolean {
|
||||||
if (typeof navigator !== 'undefined') {
|
if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') {
|
||||||
return navigator.onLine;
|
return navigator.onLine;
|
||||||
}
|
}
|
||||||
return true;
|
return true; // Assume online in non-browser environments (Node.js, SSR)
|
||||||
}
|
}
|
||||||
|
|
||||||
async flush(): Promise<void> {
|
async flush(): Promise<void> {
|
||||||
if (this.status === 'syncing') return;
|
if (this.destroyed || this.status === 'syncing') return;
|
||||||
const result = await this.pushQueue();
|
const result = await this.pushQueue();
|
||||||
if (result.success && result.errors === 0) {
|
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
|
// Status & Monitoring
|
||||||
// ───────────────────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
@ -315,6 +474,7 @@ export class SyncEngineImpl implements SyncEngine {
|
|||||||
status: this.status,
|
status: this.status,
|
||||||
queueLength: this.queueLength,
|
queueLength: this.queueLength,
|
||||||
lastSyncAt: this.lastSyncAt,
|
lastSyncAt: this.lastSyncAt,
|
||||||
|
lastError: this.lastError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -323,13 +483,18 @@ export class SyncEngineImpl implements SyncEngine {
|
|||||||
return () => this.statusListeners.delete(callback);
|
return () => this.statusListeners.delete(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateStatus(status: SyncStatus, error?: string): void {
|
private setStatus(status: SyncStatus, error?: string): void {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
if (error) this.lastError = error;
|
||||||
|
this.notifyStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyStatus(): void {
|
||||||
const info: SyncStatusInfo = {
|
const info: SyncStatusInfo = {
|
||||||
status,
|
status: this.status,
|
||||||
queueLength: this.queueLength,
|
queueLength: this.queueLength,
|
||||||
lastSyncAt: this.lastSyncAt,
|
lastSyncAt: this.lastSyncAt,
|
||||||
lastError: error,
|
lastError: this.lastError,
|
||||||
};
|
};
|
||||||
this.statusListeners.forEach(cb => cb(info));
|
this.statusListeners.forEach(cb => cb(info));
|
||||||
}
|
}
|
||||||
@ -340,6 +505,7 @@ export class SyncEngineImpl implements SyncEngine {
|
|||||||
|
|
||||||
async clearQueue(): Promise<void> {
|
async clearQueue(): Promise<void> {
|
||||||
await this.saveQueue([]);
|
await this.saveQueue([]);
|
||||||
|
this.notifyStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async reprocessFailed(): Promise<void> {
|
async reprocessFailed(): Promise<void> {
|
||||||
@ -362,7 +528,7 @@ export class SyncEngineImpl implements SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateId(): string {
|
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 {
|
private getDedupKey(entity: string, data: unknown): string {
|
||||||
@ -370,9 +536,60 @@ export class SyncEngineImpl implements SyncEngine {
|
|||||||
return id ? `${entity}:${id}` : `${entity}:${JSON.stringify(data)}`;
|
return id ? `${entity}:${id}` : `${entity}:${JSON.stringify(data)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private trackError(_operation: string, _entity: string, _error: unknown): void {
|
private emptyResult(): SyncResult {
|
||||||
if (this.config.telemetryClient) {
|
return {
|
||||||
// Telemetry tracking would go here
|
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';
|
export { LocalStorageAdapter, InMemoryAdapter, MMKVAdapter, type MMKVInstance } from './storage.js';
|
||||||
|
|
||||||
@ -46,6 +46,7 @@ export type {
|
|||||||
SyncResult,
|
SyncResult,
|
||||||
SyncStatusInfo,
|
SyncStatusInfo,
|
||||||
SyncStatusCallback,
|
SyncStatusCallback,
|
||||||
|
PullHandler,
|
||||||
StorageAdapter,
|
StorageAdapter,
|
||||||
Conflict,
|
Conflict,
|
||||||
} from './types.js';
|
} 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 { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { createSyncEngine, InMemoryAdapter } from './index.js';
|
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 { ApiClient, ApiResult } from '@bytelyst/api-client';
|
||||||
|
import type { TelemetryClient } from '@bytelyst/telemetry-client';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Mock API Client
|
// Helpers
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function createMockApiClient(): ApiClient & {
|
interface MockApiClient extends ApiClient {
|
||||||
getRequests: () => { path: string; options?: RequestInit }[];
|
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 }[] = [];
|
const requests: { path: string; options?: RequestInit }[] = [];
|
||||||
|
let fetchBehavior: ((path: string, options?: RequestInit) => unknown) | null = null;
|
||||||
|
let safeFetchBehavior: ((path: string) => unknown) | null = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetch: async <T>(path: string, options?: RequestInit): Promise<T> => {
|
fetch: async <T>(path: string, options?: RequestInit): Promise<T> => {
|
||||||
requests.push({ path, options });
|
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>> => {
|
safeFetch: async <T>(path: string, options?: RequestInit): Promise<ApiResult<T>> => {
|
||||||
requests.push({ path, options });
|
requests.push({ path, options });
|
||||||
|
if (safeFetchBehavior) return safeFetchBehavior(path) as ApiResult<T>;
|
||||||
return { data: { items: [] } as unknown as T, error: null };
|
return { data: { items: [] } as unknown as T, error: null };
|
||||||
},
|
},
|
||||||
getRequests: () => requests,
|
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', () => {
|
describe('Sync Engine', () => {
|
||||||
let storage: InMemoryAdapter;
|
let storage: InMemoryAdapter;
|
||||||
let apiClient: ReturnType<typeof createMockApiClient>;
|
let apiClient: MockApiClient;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
storage = new InMemoryAdapter();
|
storage = new InMemoryAdapter();
|
||||||
apiClient = createMockApiClient();
|
apiClient = createMockApiClient();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createSyncEngine', () => {
|
// ─── Creation ──────────────────────────────────────────────────────────
|
||||||
it('creates a sync engine with default config', () => {
|
|
||||||
const engine = createSyncEngine({
|
it('creates engine with all interface methods', () => {
|
||||||
productId: 'test',
|
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: {
|
entities: {
|
||||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
||||||
},
|
},
|
||||||
storage,
|
onPull: (_entity, items) => {
|
||||||
apiClient,
|
pulled.push(items);
|
||||||
});
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
expect(engine).toBeDefined();
|
await engine.push('tasks', { id: 'c1', title: 'Client Version' });
|
||||||
expect(engine.push).toBeDefined();
|
const result = await engine.fullSync();
|
||||||
expect(engine.pull).toBeDefined();
|
|
||||||
expect(engine.fullSync).toBeDefined();
|
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', () => {
|
// ─── Multiple Entities ─────────────────────────────────────────────────
|
||||||
it('adds item to queue', async () => {
|
|
||||||
const engine = createSyncEngine({
|
|
||||||
productId: 'test',
|
|
||||||
entities: {
|
|
||||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
||||||
},
|
|
||||||
storage,
|
|
||||||
apiClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
await engine.push('tasks', { title: 'Test Task' });
|
it('handles multiple entity types', async () => {
|
||||||
|
const pulled: { entity: string; items: unknown[] }[] = [];
|
||||||
const status = engine.getStatus();
|
apiClient.setSafeFetchBehavior(path => {
|
||||||
expect(status.status).toBe('idle');
|
if (path.startsWith('/tasks')) {
|
||||||
expect(status.queueLength).toBe(1);
|
return { data: { items: [{ id: 't1' }] }, error: null };
|
||||||
expect(engine.getQueueLength()).toBe(1);
|
}
|
||||||
|
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(
|
||||||
const engine = createSyncEngine({
|
makeConfig(storage, apiClient, {
|
||||||
productId: 'test',
|
|
||||||
entities: {
|
entities: {
|
||||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
||||||
|
notes: { endpoint: '/notes', partitionKey: 'userId', conflictStrategy: 'client-wins' },
|
||||||
},
|
},
|
||||||
storage,
|
onPull: (entity, items) => {
|
||||||
apiClient,
|
pulled.push({ entity, items });
|
||||||
});
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await engine.push('tasks', { id: '1', title: 'Task 1' });
|
await engine.push('tasks', { id: 't-new', title: 'Task' });
|
||||||
await engine.push('tasks', { id: '1', title: 'Task 1 Updated' });
|
await engine.push('notes', { id: 'n-new', body: 'Note' });
|
||||||
|
|
||||||
expect(engine.getQueueLength()).toBe(1);
|
const result = await engine.fullSync();
|
||||||
|
expect(result.pushed).toBe(2);
|
||||||
// Queue should have 1 item (deduplicated)
|
expect(result.pulled).toBe(3); // 1 task + 2 notes
|
||||||
const result = await engine.fullSync();
|
expect(pulled).toHaveLength(2);
|
||||||
expect(result.pushed).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('delete', () => {
|
// ─── Status Monitoring ─────────────────────────────────────────────────
|
||||||
it('creates delete operation', async () => {
|
|
||||||
const engine = createSyncEngine({
|
|
||||||
productId: 'test',
|
|
||||||
entities: {
|
|
||||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
||||||
},
|
|
||||||
storage,
|
|
||||||
apiClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
await engine.delete('tasks', 'task-123');
|
it('returns correct initial status', () => {
|
||||||
|
const engine = createSyncEngine(makeConfig(storage, apiClient));
|
||||||
const requests = apiClient.getRequests();
|
const status = engine.getStatus();
|
||||||
// Delete is queued but not flushed until sync
|
expect(status.status).toBe('idle');
|
||||||
expect(requests.length).toBe(0);
|
expect(status.queueLength).toBe(0);
|
||||||
});
|
expect(status.lastSyncAt).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fullSync', () => {
|
it('notifies listeners on status changes', async () => {
|
||||||
it('pushes queued items', async () => {
|
const engine = createSyncEngine(makeConfig(storage, apiClient));
|
||||||
const engine = createSyncEngine({
|
const statuses: SyncStatusInfo[] = [];
|
||||||
productId: 'test',
|
engine.onStatusChange(s => statuses.push({ ...s }));
|
||||||
entities: {
|
|
||||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
||||||
},
|
|
||||||
storage,
|
|
||||||
apiClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
await engine.push('tasks', { title: 'Test Task' });
|
await engine.push('tasks', { id: 'x', title: 'X' });
|
||||||
const result = await engine.fullSync();
|
await engine.fullSync();
|
||||||
|
|
||||||
expect(result.pushed).toBe(1);
|
const statusNames = statuses.map(s => s.status);
|
||||||
expect(engine.getQueueLength()).toBe(0);
|
expect(statusNames).toContain('syncing');
|
||||||
expect(engine.getStatus().lastSyncAt).toBeTruthy();
|
expect(statusNames).toContain('idle');
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('status and monitoring', () => {
|
it('unsubscribe stops notifications', async () => {
|
||||||
it('returns initial status', () => {
|
const engine = createSyncEngine(makeConfig(storage, apiClient));
|
||||||
const engine = createSyncEngine({
|
const statuses: string[] = [];
|
||||||
productId: 'test',
|
const unsub = engine.onStatusChange(s => statuses.push(s.status));
|
||||||
entities: {
|
|
||||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
||||||
},
|
|
||||||
storage,
|
|
||||||
apiClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = engine.getStatus();
|
await engine.push('tasks', { title: 'A' });
|
||||||
expect(status.status).toBe('idle');
|
const countBefore = statuses.length;
|
||||||
expect(status.queueLength).toBe(0);
|
|
||||||
expect(status.lastSyncAt).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('notifies status changes', async () => {
|
unsub();
|
||||||
const engine = createSyncEngine({
|
await engine.push('tasks', { title: 'B' });
|
||||||
productId: 'test',
|
expect(statuses.length).toBe(countBefore);
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('clearQueue', () => {
|
// ─── clearQueue ────────────────────────────────────────────────────────
|
||||||
it('removes all queued items', async () => {
|
|
||||||
const engine = createSyncEngine({
|
|
||||||
productId: 'test',
|
|
||||||
entities: {
|
|
||||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
||||||
},
|
|
||||||
storage,
|
|
||||||
apiClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
await engine.push('tasks', { title: 'Task 1' });
|
it('clearQueue empties the queue', async () => {
|
||||||
await engine.push('tasks', { title: 'Task 2' });
|
const engine = createSyncEngine(makeConfig(storage, apiClient));
|
||||||
expect(engine.getQueueLength()).toBe(2);
|
await engine.push('tasks', { title: 'A' });
|
||||||
|
await engine.push('tasks', { title: 'B' });
|
||||||
|
expect(engine.getQueueLength()).toBe(2);
|
||||||
|
|
||||||
await engine.clearQueue();
|
await engine.clearQueue();
|
||||||
expect(engine.getQueueLength()).toBe(0);
|
expect(engine.getQueueLength()).toBe(0);
|
||||||
|
|
||||||
const result = await engine.fullSync();
|
const result = await engine.fullSync();
|
||||||
expect(result.pushed).toBe(0);
|
expect(result.pushed).toBe(0);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('reprocessFailed', () => {
|
// ─── 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
await engine.push('tasks', { title: 'Test' });
|
it('reprocessFailed resets retry counts and re-flushes', async () => {
|
||||||
const requestsBefore = apiClient.getRequests().length;
|
let failCount = 0;
|
||||||
await engine.reprocessFailed();
|
apiClient.setFetchBehavior(() => {
|
||||||
|
failCount++;
|
||||||
// reprocessFailed calls flush() which pushes the item
|
if (failCount <= 2) throw new Error('transient');
|
||||||
const requestsAfter = apiClient.getRequests().length;
|
return {};
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 type SyncOperation = 'create' | 'update' | 'delete';
|
||||||
|
|
||||||
export interface EntityConfig {
|
export interface EntityConfig {
|
||||||
|
/** REST endpoint path, e.g. '/api/timers' */
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
/** Cosmos partition key field name (for reference) */
|
||||||
partitionKey: string;
|
partitionKey: string;
|
||||||
|
/** Conflict resolution strategy */
|
||||||
conflictStrategy: ConflictStrategy;
|
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 {
|
export interface SyncEngineConfig {
|
||||||
productId: string;
|
productId: string;
|
||||||
entities: Record<EntityName, EntityConfig>;
|
entities: Record<EntityName, EntityConfig>;
|
||||||
storage: StorageAdapter;
|
storage: StorageAdapter;
|
||||||
apiClient: ApiClient;
|
apiClient: ApiClient;
|
||||||
telemetryClient?: TelemetryClient;
|
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;
|
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 {
|
export interface SyncItem {
|
||||||
@ -68,7 +84,6 @@ export interface Conflict {
|
|||||||
entity: EntityName;
|
entity: EntityName;
|
||||||
localItem: SyncItem;
|
localItem: SyncItem;
|
||||||
remoteData: unknown;
|
remoteData: unknown;
|
||||||
localData: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -87,18 +102,28 @@ export interface StorageAdapter {
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface SyncEngine {
|
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>;
|
push(entity: EntityName, data: unknown, operation?: SyncOperation): Promise<void>;
|
||||||
|
/** Queue a delete for later push. */
|
||||||
delete(entity: EntityName, id: string): Promise<void>;
|
delete(entity: EntityName, id: string): Promise<void>;
|
||||||
|
/** Pull remote changes for all entities. Invokes onPull callback. */
|
||||||
pull(): Promise<SyncResult>;
|
pull(): Promise<SyncResult>;
|
||||||
|
/** Push queued items, then pull remote changes. */
|
||||||
fullSync(): Promise<SyncResult>;
|
fullSync(): Promise<SyncResult>;
|
||||||
|
|
||||||
// Status and monitoring
|
/** Current number of items in the offline queue. */
|
||||||
getQueueLength(): number;
|
getQueueLength(): number;
|
||||||
|
/** Current sync status snapshot. */
|
||||||
getStatus(): SyncStatusInfo;
|
getStatus(): SyncStatusInfo;
|
||||||
|
/** Subscribe to status changes. Returns unsubscribe function. */
|
||||||
onStatusChange(callback: SyncStatusCallback): () => void;
|
onStatusChange(callback: SyncStatusCallback): () => void;
|
||||||
|
|
||||||
// Utility
|
/** Remove all items from the offline queue. */
|
||||||
clearQueue(): Promise<void>;
|
clearQueue(): Promise<void>;
|
||||||
|
/** Reset retry counts on all failed items and re-flush. */
|
||||||
reprocessFailed(): Promise<void>;
|
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