feat: Platform Acceleration + A/B Testing Framework
Platform Acceleration Phase 1: - @bytelyst/sync package: Offline-first sync engine with conflict resolution - Storage adapters: LocalStorage, InMemory, MMKV - Deduplication, retry with backoff, auto-flush on reconnect - 12 comprehensive tests - @bytelyst/dashboard-components package: Shared React components - ErrorPage, NotFoundPage, LoadingSpinner, LoadingSkeleton, EmptyState, PageHeader - Theme-aware with CSS custom properties A/B Testing Framework (Complete): - Admin UI at /ops/ab-testing with experiments list, variant performance, AI suggestions - Sidebar navigation with Beaker icon - 40 tests passing in ab-testing module All 909 platform-service tests pass.
This commit is contained in:
parent
7e3de866d3
commit
359d6e18a5
408
dashboards/admin-web/src/app/(dashboard)/ops/ab-testing/page.tsx
Normal file
408
dashboards/admin-web/src/app/(dashboard)/ops/ab-testing/page.tsx
Normal file
@ -0,0 +1,408 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
import {
|
||||
Beaker,
|
||||
Plus,
|
||||
Play,
|
||||
Square,
|
||||
BarChart3,
|
||||
Target,
|
||||
Users,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Experiment {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'draft' | 'running' | 'paused' | 'stopped' | 'completed';
|
||||
totalParticipants: number;
|
||||
totalEvents: number;
|
||||
primaryMetric: {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
allocationStrategy: string;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
interface Variant {
|
||||
id: string;
|
||||
name: string;
|
||||
isControl: boolean;
|
||||
currentAllocationPercent: number;
|
||||
stats: {
|
||||
participants: number;
|
||||
events: number;
|
||||
primaryMetricValue: number;
|
||||
};
|
||||
bayesianResults?: {
|
||||
probabilityBeatsControl: number;
|
||||
expectedLiftPercent: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ABTestingPage() {
|
||||
const router = useRouter();
|
||||
const [experiments, setExperiments] = useState<Experiment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedExperiment, setSelectedExperiment] = useState<Experiment | null>(null);
|
||||
const [variants, setVariants] = useState<Variant[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadExperiments();
|
||||
}, []);
|
||||
|
||||
const loadExperiments = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/ab-testing/experiments');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setExperiments(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load experiments:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadVariants = async (experimentId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/ab-testing/experiments/${experimentId}/variants`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setVariants(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load variants:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExperimentClick = (exp: Experiment) => {
|
||||
setSelectedExperiment(exp);
|
||||
loadVariants(exp.id);
|
||||
};
|
||||
|
||||
const handleCreateExperiment = () => {
|
||||
router.push('/ops/ab-testing/new');
|
||||
};
|
||||
|
||||
const handleStartExperiment = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/ab-testing/experiments/${id}/start`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
loadExperiments();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start experiment:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopExperiment = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/ab-testing/experiments/${id}/stop`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
loadExperiments();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to stop experiment:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: Experiment['status']) => {
|
||||
const variants = {
|
||||
draft: { variant: 'secondary', label: 'Draft' },
|
||||
running: { variant: 'default', label: 'Running' },
|
||||
paused: { variant: 'warning', label: 'Paused' },
|
||||
stopped: { variant: 'destructive', label: 'Stopped' },
|
||||
completed: { variant: 'success', label: 'Completed' },
|
||||
};
|
||||
const config = variants[status];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return <Badge variant={config.variant as any}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="A/B Testing"
|
||||
breadcrumbs={[{ label: 'Ops', href: '/ops' }, { label: 'A/B Testing' }]}
|
||||
actions={
|
||||
<Button onClick={handleCreateExperiment}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Experiment
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="experiments">
|
||||
<TabsList>
|
||||
<TabsTrigger value="experiments">Experiments</TabsTrigger>
|
||||
<TabsTrigger value="results">Results</TabsTrigger>
|
||||
<TabsTrigger value="suggestions">AI Suggestions</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="experiments" className="space-y-4">
|
||||
{experiments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Beaker className="w-8 h-8 text-gray-500" />}
|
||||
title="No experiments yet"
|
||||
description="Create your first A/B test to start optimizing your product."
|
||||
action={{
|
||||
label: 'Create Experiment',
|
||||
onClick: handleCreateExperiment,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{experiments.map(exp => (
|
||||
<Card
|
||||
key={exp.id}
|
||||
className={`cursor-pointer transition-colors ${
|
||||
selectedExperiment?.id === exp.id ? 'border-blue-500' : 'hover:border-gray-400'
|
||||
}`}
|
||||
onClick={() => handleExperimentClick(exp)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Beaker className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">{exp.name}</CardTitle>
|
||||
<p className="text-sm text-gray-500">{exp.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(exp.status)}
|
||||
{exp.status === 'draft' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleStartExperiment(exp.id);
|
||||
}}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{exp.status === 'running' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleStopExperiment(exp.id);
|
||||
}}
|
||||
>
|
||||
<Square className="w-4 h-4 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500">Participants</div>
|
||||
<div className="font-medium">{exp.totalParticipants.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Events</div>
|
||||
<div className="font-medium">{exp.totalEvents.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Primary Metric</div>
|
||||
<div className="font-medium">{exp.primaryMetric.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Strategy</div>
|
||||
<div className="font-medium capitalize">{exp.allocationStrategy}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedExperiment && variants.length > 0 && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Variant Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{variants.map(variant => (
|
||||
<div
|
||||
key={variant.id}
|
||||
className={`p-4 rounded-lg border ${
|
||||
variant.isControl ? 'bg-gray-50 dark:bg-gray-900' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{variant.name}</span>
|
||||
{variant.isControl && <Badge variant="secondary">Control</Badge>}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4 text-gray-500" />
|
||||
<span>{variant.stats.participants.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Target className="w-4 h-4 text-gray-500" />
|
||||
<span>{variant.stats.events.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{variant.bayesianResults && !variant.isControl && (
|
||||
<div className="grid grid-cols-2 gap-4 mt-3 pt-3 border-t">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Probability vs Control</div>
|
||||
<div
|
||||
className={`text-lg font-semibold ${
|
||||
variant.bayesianResults.probabilityBeatsControl > 0.95
|
||||
? 'text-green-600'
|
||||
: variant.bayesianResults.probabilityBeatsControl > 0.8
|
||||
? 'text-yellow-600'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{(variant.bayesianResults.probabilityBeatsControl * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Expected Lift</div>
|
||||
<div
|
||||
className={`text-lg font-semibold ${
|
||||
variant.bayesianResults.expectedLiftPercent > 0
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{variant.bayesianResults.expectedLiftPercent > 0 ? '+' : ''}
|
||||
{variant.bayesianResults.expectedLiftPercent.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variant.isControl && (
|
||||
<div className="mt-3 pt-3 border-t text-sm text-gray-500">
|
||||
Baseline: {variant.stats.primaryMetricValue.toFixed(2)}{' '}
|
||||
{selectedExperiment.primaryMetric.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="results">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Experiment Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={<BarChart3 className="w-8 h-8 text-gray-500" />}
|
||||
title="Select an experiment"
|
||||
description="Click on an experiment from the Experiments tab to view detailed results."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="suggestions">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI-Generated Experiment Suggestions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<TrendingUp className="w-5 h-5 text-blue-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium">Optimize Onboarding Flow</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Based on 34% drop-off at step 2, test a simplified onboarding with reduced
|
||||
form fields.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Badge variant="secondary">Predicted Impact: +15%</Badge>
|
||||
<Badge variant="outline">Confidence: High</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium">Test Error Recovery Flow</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
12% of users encountering sync errors churn. Test an improved error message
|
||||
with direct support contact.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Badge variant="secondary">Predicted Impact: +8%</Badge>
|
||||
<Badge variant="outline">Confidence: Medium</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium">Feature Flag Rollout</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
New telemetry system shows 99.9% reliability. Ready for 50% rollout with
|
||||
automated monitoring.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Badge variant="secondary">Risk: Low</Badge>
|
||||
<Badge variant="outline">Auto-Rollback: Enabled</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
dashboards/admin-web/src/components/EmptyState.tsx
Normal file
27
dashboards/admin-web/src/components/EmptyState.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps): ReactNode {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[300px] p-8 text-center">
|
||||
{icon && (
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-medium mb-2">{title}</h3>
|
||||
<p className="text-muted-foreground max-w-sm mb-6">{description}</p>
|
||||
{action && <Button onClick={action.onClick}>{action.label}</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
dashboards/admin-web/src/components/LoadingSpinner.tsx
Normal file
37
dashboards/admin-web/src/components/LoadingSpinner.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} ${className}`}>
|
||||
<svg
|
||||
className="animate-spin text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
dashboards/admin-web/src/components/PageHeader.tsx
Normal file
34
dashboards/admin-web/src/components/PageHeader.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
breadcrumbs?: Array<{ label: string; href?: string }>;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, breadcrumbs, actions }: PageHeaderProps): ReactNode {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<nav className="flex items-center space-x-2 text-sm text-muted-foreground mb-2">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={index} className="flex items-center">
|
||||
{index > 0 && <span className="mx-2">/</span>}
|
||||
{crumb.href ? (
|
||||
<a href={crumb.href} className="hover:text-foreground">
|
||||
{crumb.label}
|
||||
</a>
|
||||
) : (
|
||||
<span>{crumb.label}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
</div>
|
||||
{actions && <div className="flex items-center space-x-3">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -31,6 +31,7 @@ import {
|
||||
MessageSquare,
|
||||
Megaphone,
|
||||
ClipboardList,
|
||||
Beaker,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
@ -59,6 +60,7 @@ const navItems = [
|
||||
{ href: '/ops', label: 'Mission Control', icon: Activity },
|
||||
{ href: '/ops/client-logs', label: 'Client Logs', icon: FileText },
|
||||
{ href: '/ops/telemetry-policies', label: 'Telemetry Policies', icon: Shield },
|
||||
{ href: '/ops/ab-testing', label: 'A/B Testing', icon: Beaker },
|
||||
{ href: '/feedback', label: 'User Feedback', icon: MessageSquare },
|
||||
{ href: '/ops/secrets', label: 'Secrets Manager', icon: KeyRound },
|
||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||
|
||||
@ -0,0 +1,510 @@
|
||||
# ByteLyst Common Platform — Complete Inventory
|
||||
|
||||
> **Purpose:** Comprehensive reference for all reusable components, services, SDKs, and tools available to ByteLyst product teams.
|
||||
> **Repo:** `learning_ai_common_plat`
|
||||
> **Last Updated:** 2026-03-03
|
||||
|
||||
---
|
||||
|
||||
## 1. Quick Stats
|
||||
|
||||
| Category | Count | Notes |
|
||||
| --------------------- | ------- | ----------------------------------------------------- |
|
||||
| **Shared Packages** | 32 | `@bytelyst/*` — consumed via `file:` or `workspace:*` |
|
||||
| **Platform Services** | 2 | Running services (consolidated from 5) |
|
||||
| **Dashboards** | 2 | Product-agnostic web consoles |
|
||||
| **Platform SDKs** | 3 | Swift, Kotlin, React Native |
|
||||
| **Cosmos Containers** | ~50+ | Shared + product-specific |
|
||||
| **Total Tests** | ~1,700+ | Service + package + SDK tests |
|
||||
|
||||
---
|
||||
|
||||
## 2. Shared Packages (`@bytelyst/*`)
|
||||
|
||||
### 2.1 Core Infrastructure
|
||||
|
||||
| Package | Purpose | Exports | Consumers |
|
||||
| ------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
|
||||
| `@bytelyst/errors` | Typed HTTP errors (400-429) | `ServiceError`, `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `TooManyRequestsError` | All services |
|
||||
| `@bytelyst/cosmos` | Azure Cosmos DB client | `getCosmosClient`, `getDatabase`, `getContainer`, `registerContainers`, `getRegisteredContainer`, `initializeAllContainers` | All services + dashboards |
|
||||
| `@bytelyst/config` | Env loading + Key Vault | `loadConfig`, `loadProductIdentity`, `resolveKeyVaultSecrets`, `loadProductManifest`, `ProductManifestSchema` | All services |
|
||||
| `@bytelyst/logger` | Structured logging | Pino-based wrapper | All services |
|
||||
| `@bytelyst/testing` | Shared test utilities | Fastify inject helpers, mocks | All test suites |
|
||||
|
||||
### 2.2 Auth & Identity
|
||||
|
||||
| Package | Purpose | Exports | Consumers |
|
||||
| ----------------------- | -------------------------- | -------------------------------------------------------------------------------- | ------------------------- |
|
||||
| `@bytelyst/auth` | JWT + password hashing | `createJwtUtils`, `hashPassword`, `verifyPassword`, `extractAuth`, `requireRole` | All services + dashboards |
|
||||
| `@bytelyst/auth-client` | Client-side auth utilities | Token refresh, storage abstractions | Web + React Native |
|
||||
| `@bytelyst/react-auth` | React auth context factory | `createAuthContext()` — typed provider + hook | Next.js dashboards |
|
||||
|
||||
### 2.3 API & Clients
|
||||
|
||||
| Package | Purpose | Exports | Consumers |
|
||||
| ------------------------------- | ------------------------- | --------------------------------------- | ------------- |
|
||||
| `@bytelyst/api-client` | Typed fetch wrapper | `createApiClient()` with auth injection | Web + RN |
|
||||
| `@bytelyst/platform-client` | Platform service client | Typed fetch with 401 retry | Web + RN |
|
||||
| `@bytelyst/broadcast-client` | Broadcast/survey client | Poll and broadcast APIs | iOS + Android |
|
||||
| `@bytelyst/survey-client` | Survey response client | Submit survey responses | iOS + Android |
|
||||
| `@bytelyst/telemetry-client` | Telemetry ingestion | `createTelemetryClient()` with batching | All platforms |
|
||||
| `@bytelyst/feature-flag-client` | Feature flag polling | `createFeatureFlagClient()` | All platforms |
|
||||
| `@bytelyst/kill-switch-client` | Kill switch check | `createKillSwitchClient()` (fail-open) | All platforms |
|
||||
| `@bytelyst/offline-queue` | Persistent retry queue | Configurable storage adapter | Web + RN |
|
||||
| `@bytelyst/diagnostics-client` | Remote diagnostics client | Error reporting, log upload | All platforms |
|
||||
| `@bytelyst/feedback-client` | In-app feedback | Submit feedback + attachments | iOS + Android |
|
||||
|
||||
### 2.4 Storage & Data
|
||||
|
||||
| Package | Purpose | Exports | Consumers |
|
||||
| ----------------------- | ------------------ | ----------------------------------- | ---------------- |
|
||||
| `@bytelyst/blob` | Azure Blob Storage | SAS token generation, container ops | platform-service |
|
||||
| `@bytelyst/blob-client` | Blob upload client | Upload via SAS tokens | Web + RN |
|
||||
| `@bytelyst/datastore` | Generic data store | Repository patterns | Services |
|
||||
| `@bytelyst/storage` | Storage utilities | File operations, compression | Services |
|
||||
|
||||
### 2.5 Backend Framework
|
||||
|
||||
| Package | Purpose | Exports | Consumers |
|
||||
| ------------------------ | ------------------------- | -------------------------------------------------- | -------------------- |
|
||||
| `@bytelyst/fastify-core` | Service bootstrap | `createServiceApp()`, `startService()`, Swagger UI | All Fastify services |
|
||||
| `@bytelyst/events` | In-memory event bus | `EventBus`, typed event schemas, error isolation | platform-service |
|
||||
| `@bytelyst/monitoring` | Health checks + telemetry | Health utilities, Loki/Grafana helpers | All services |
|
||||
| `@bytelyst/push` | Push notification service | APNS, FCM integration | platform-service |
|
||||
|
||||
### 2.6 AI/ML & Extraction
|
||||
|
||||
| Package | Purpose | Exports | Consumers |
|
||||
| ---------------------- | ---------------------- | ---------------------------------------- | ---------------- |
|
||||
| `@bytelyst/extraction` | Text extraction client | `createExtractionClient()`, shared types | Web + dashboards |
|
||||
| `@bytelyst/llm` | LLM utilities | Prompt templates, token counting | Services |
|
||||
| `@bytelyst/speech` | Speech SDK wrappers | Azure Speech integration | Desktop + Mobile |
|
||||
|
||||
### 2.7 Design System
|
||||
|
||||
| Package | Purpose | Exports | Consumers |
|
||||
| ------------------------- | --------------------- | --------------------------------- | ------------- |
|
||||
| `@bytelyst/design-tokens` | Cross-platform tokens | CSS, TS, Kotlin, Swift generation | All platforms |
|
||||
|
||||
### 2.8 React Native
|
||||
|
||||
| Package | Purpose | Exports | Consumers |
|
||||
| ------------------------------------- | --------------- | ---------------------------------------- | ----------------------- |
|
||||
| `@bytelyst/react-native-platform-sdk` | RN platform SDK | Unified client for all platform features | NomGap + future RN apps |
|
||||
|
||||
---
|
||||
|
||||
## 3. Platform Services
|
||||
|
||||
### 3.1 platform-service (Port 4003)
|
||||
|
||||
Consolidated from billing-service, growth-service, tracker-service.
|
||||
|
||||
**Status:** Active | **Tests:** ~1,050+ | **Modules:** 45
|
||||
|
||||
| Module | Purpose | Cosmos Container | Public API |
|
||||
| ------------------------ | ----------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------- |
|
||||
| **auth** | JWT issue/refresh, password reset, email verification | `users`, `password_reset_tokens`, `email_verifications` | `/auth/*` |
|
||||
| **audit** | Audit logging | `audit_log` | Admin only |
|
||||
| **blob** | Azure Blob Storage | - | `/api/blob/*` |
|
||||
| **broadcasts** | Feature announcements | `broadcasts` | Admin: CRUD, Public: read |
|
||||
| **comments** | Item comments | `comments` | `/comments/*` |
|
||||
| **delivery** | Email/push delivery | `delivery_log`, templates | Internal |
|
||||
| **diagnostics** | Remote diagnostics, error clustering | `diagnostic_sessions`, `error_clusters` | `/diagnostics/*` |
|
||||
| **ai-diagnostics** | AI-powered diagnostic assistant | `auto_triggers`, `diagnostic_sessions` | `/ai-diagnostics/*` |
|
||||
| **flags** | Feature flags | `feature_flags` | `/flags/poll` (client) |
|
||||
| **invitations** | Team invitations | `invitations` | `/invitations/*` |
|
||||
| **ip-rules** | IP allow/deny | `ip_rules` | Admin only |
|
||||
| **items** | Tracker items | `items` | `/items/*` |
|
||||
| **jobs** | Scheduled job runner | `job_definitions`, `job_runs` | Admin only |
|
||||
| **licenses** | License key management | `licenses` | `/licenses/*` |
|
||||
| **maintenance** | Maintenance mode | `maintenance_windows` | Admin only |
|
||||
| **marketplace** | Product marketplace (JarvisJr) | `marketplace_listings`, `purchases` | `/marketplace/*` |
|
||||
| **notifications** | Push/email triggers | `notification_queue` | Internal |
|
||||
| **plans** | Subscription plans | `plans` | `/plans/*` |
|
||||
| **products** | Product registry | `products` | Admin only |
|
||||
| **promos** | Promo codes | `promos` | `/promos/*` |
|
||||
| **public** | Public endpoints (no auth) | - | `/public/*` |
|
||||
| **referrals** | Referral system | `referrals` | `/referrals/*` |
|
||||
| **sessions** | Device session management | `sessions` | `/sessions/*` |
|
||||
| **settings** | Platform settings | `settings` | Admin only |
|
||||
| **status** | Public status page | `incidents` | `/status/*` (public read) |
|
||||
| **stripe** | Stripe webhooks | - | `/stripe/webhook` |
|
||||
| **subscriptions** | Subscriptions | `subscriptions` | `/subscriptions/*` |
|
||||
| **surveys** | User surveys | `surveys`, `survey_responses` | `/surveys/*` |
|
||||
| **telemetry** | Client telemetry ingestion | `telemetry_events`, `telemetry_error_clusters`, `telemetry_collection_policies` | `/telemetry/*` |
|
||||
| **themes** | Platform themes | `themes` | `/themes/*` |
|
||||
| **tokens** | API tokens | `api_tokens` | `/tokens/*` |
|
||||
| **usage** | Usage tracking | `usage_daily`, `usage_hourly` | `/usage/*` |
|
||||
| **votes** | Item voting | `votes` | `/votes/*` |
|
||||
| **waitlist** | Pre-launch signup | `waitlist` | `/waitlist/*` |
|
||||
| **webhooks** | Webhook subscriptions | `webhook_subscriptions`, `webhook_deliveries` | `/webhooks/*` |
|
||||
| **ab-testing** | A/B experiments | `experiments`, `experiment_assignments` | `/experiments/*` |
|
||||
| **predictive-analytics** | Churn/health scoring | `predictive_scores`, `score_history` | `/predictive/*` |
|
||||
| **analytics** | Analytics rollups | `analytics_events`, `analytics_aggregates` | `/analytics/*` |
|
||||
| **exports** | GDPR data export | `export_jobs` | `/exports/*` |
|
||||
| **feedback** | In-app feedback | `feedback` | `/feedback/*` |
|
||||
| **impersonation** | User impersonation | `impersonation_sessions` | `/impersonation/*` |
|
||||
| **changelog** | Product changelogs | `changelogs` | `/changelogs/*` |
|
||||
| **ratelimit** | Rate limiting | `rate_limit_windows` | `/ratelimit/*` |
|
||||
|
||||
### 3.2 extraction-service (Port 4005)
|
||||
|
||||
**Status:** Active | **Tests:** 47 | **Modules:** 2
|
||||
|
||||
| Module | Purpose | Type |
|
||||
| ----------- | ----------------------------- | -------------------------- |
|
||||
| **extract** | Text extraction (LangExtract) | Python + TypeScript hybrid |
|
||||
| **tasks** | Extraction task management | TypeScript |
|
||||
|
||||
---
|
||||
|
||||
## 4. Dashboards (Product-Agnostic)
|
||||
|
||||
### 4.1 admin-web (Port 3001)
|
||||
|
||||
**Stack:** Next.js 16 + React 19 + TailwindCSS v4 + shadcn/ui
|
||||
|
||||
| Feature | Path | Description |
|
||||
| ------------------ | ------------------ | -------------------------------- |
|
||||
| Mission Control | `/ops` | Service health dashboard |
|
||||
| Secrets Manager | `/ops/secrets` | Azure Key Vault CRUD |
|
||||
| Client Logs | `/ops/client-logs` | Telemetry query + error clusters |
|
||||
| User Management | `/users` | CRUD, roles, sessions |
|
||||
| License Management | `/licenses` | Key generation, tracking |
|
||||
| Usage Analytics | `/usage` | Daily/hourly metrics |
|
||||
| API Tokens | `/tokens` | Admin token management |
|
||||
| Broadcasts | `/broadcasts` | Feature announcements |
|
||||
| Themes | `/themes` | Platform theme editor |
|
||||
| Feature Flags | `/flags` | Flag management + rollout |
|
||||
|
||||
**Key Files:**
|
||||
|
||||
- `src/lib/cosmos.ts` — Cosmos client
|
||||
- `src/lib/auth-server.ts` — JWT + bcrypt
|
||||
- `src/lib/product-config.ts` — Product identity
|
||||
- `src/lib/platform-client.ts` — platform-service client
|
||||
- `src/lib/telemetry-client.ts` — telemetry query client
|
||||
|
||||
### 4.2 tracker-web (Port 3003)
|
||||
|
||||
**Stack:** Next.js 16 + React 19 + TailwindCSS v4
|
||||
|
||||
| Feature | Path | Description |
|
||||
| -------------- | ------------ | ------------------------- |
|
||||
| Public Roadmap | `/roadmap` | Feature requests + voting |
|
||||
| Item Detail | `/items/:id` | Discussion + voting |
|
||||
| Submit | `/submit` | New feature request |
|
||||
|
||||
---
|
||||
|
||||
## 5. Platform SDKs
|
||||
|
||||
### 5.1 Swift Platform SDK (`packages/swift-platform-sdk/`)
|
||||
|
||||
**Components:** 20 | **Language:** Swift 5.9+ | **Platforms:** iOS 17+, watchOS 10+, macOS 14+
|
||||
|
||||
| Component | Purpose |
|
||||
| --------------------- | ---------------------------------------------------------------- |
|
||||
| `BLPlatformConfig` | Product configuration (productId, baseURL, bundleId, appGroupId) |
|
||||
| `BLPlatformClient` | Generic HTTP client (auth injection, x-request-id, timeout) |
|
||||
| `BLKeychain` | Keychain CRUD |
|
||||
| `BLAuthClient` | Full auth (login, register, refresh, password ops) |
|
||||
| `BLTelemetryClient` | Telemetry queue + batch flush |
|
||||
| `BLFeatureFlagClient` | Feature flag polling |
|
||||
| `BLSyncEngine` | Generic offline-first sync |
|
||||
| `BLBlobClient` | Blob upload via SAS tokens |
|
||||
| `BLKillSwitchClient` | Kill switch check (fail-open) |
|
||||
| `BLLicenseClient` | License activation + status |
|
||||
| `BLBiometricAuth` | Face ID / Touch ID |
|
||||
| `BLCrashReporter` | MetricKit crash reporting |
|
||||
| `BLAuditLogger` | Local rotating JSON audit log |
|
||||
| `BLFeedbackClient` | In-app feedback submission |
|
||||
| `BLSurveyClient` | Survey polling + response |
|
||||
| `BLDeepLinkRouter` | Deep link handling |
|
||||
| `BLInAppMessageUI` | In-app message display |
|
||||
| `BLSurveyUI` | Survey UI components |
|
||||
| `ByteLystPlatformSDK` | Umbrella export |
|
||||
|
||||
**Migrated Apps:** LysnrAI, ChronoMind, MindLyst, PeakPulse, JarvisJr
|
||||
|
||||
### 5.2 Kotlin Platform SDK (`packages/kotlin-platform-sdk/`)
|
||||
|
||||
**Components:** 18 | **Language:** Kotlin 2.0+ | **Platforms:** Android (minSdk 26)
|
||||
|
||||
| Component | Purpose |
|
||||
| --------------------- | ---------------------------------------- |
|
||||
| `BLPlatformConfig` | Product configuration |
|
||||
| `BLPlatformClient` | OkHttp-based HTTP client |
|
||||
| `BLSecureStore` | EncryptedSharedPreferences wrapper |
|
||||
| `BLAuthClient` | Full auth + token management + StateFlow |
|
||||
| `BLTelemetryClient` | Batched events + SharedPreferences queue |
|
||||
| `BLFeatureFlagClient` | Feature flag polling |
|
||||
| `BLKillSwitchClient` | Kill switch (fail-open) |
|
||||
| `BLBlobClient` | SAS upload |
|
||||
| `BLLicenseClient` | License activation |
|
||||
| `BLAuditLogger` | Rotating JSONL audit log |
|
||||
| `BLBiometricAuth` | AndroidX BiometricPrompt |
|
||||
| `BLCrashReporter` | UncaughtExceptionHandler |
|
||||
| `BLSyncEngine` | Pull-merge-push sync |
|
||||
| `BLFeedbackClient` | Feedback submission |
|
||||
| `BLSurveyClient` | Survey polling |
|
||||
| `BLBroadcastClient` | Broadcast/announcement |
|
||||
| `DeepLinkRouter` | Deep link handling |
|
||||
|
||||
**Migrated Apps:** ChronoMind, MindLyst, LysnrAI (all via Gradle `includeBuild`)
|
||||
|
||||
**Tests:** 35 JUnit5 + MockWebServer
|
||||
|
||||
### 5.3 React Native Platform SDK (`packages/react-native-platform-sdk/`)
|
||||
|
||||
**Components:** Unified client | **Platforms:** iOS, Android (React Native / Expo)
|
||||
|
||||
| Component | Purpose |
|
||||
| --------------- | ------------------------------------- |
|
||||
| Platform Client | Unified API for all platform features |
|
||||
| Auth | Token management |
|
||||
| Telemetry | Event batching |
|
||||
| Feature Flags | Polling client |
|
||||
| Kill Switch | Fail-open check |
|
||||
| Offline Queue | Persistent retry |
|
||||
|
||||
**Migrated Apps:** NomGap
|
||||
|
||||
---
|
||||
|
||||
## 6. AI.dev Skills (`.windsurf/workflows/`)
|
||||
|
||||
Workflow scripts for AI agents and developers:
|
||||
|
||||
| Workflow | Purpose |
|
||||
| -------------------------- | -------------------------------------- |
|
||||
| `architecture-patterns.md` | Common architectural patterns |
|
||||
| `backup-main-branch.md` | Smart backup with duplicate detection |
|
||||
| `debug-service.md` | Debug failing services |
|
||||
| `desktop-release.md` | Build and release desktop apps |
|
||||
| `docker-compose.md` | Run all services via Docker |
|
||||
| `dual-network-setup.md` | Corporate proxy + home network |
|
||||
| `generate-store-assets.md` | App store artwork (icons, screenshots) |
|
||||
| `index.md` | Workflow index |
|
||||
| `ios-release.md` | Build and release iOS apps |
|
||||
| `local-development.md` | Local dev setup |
|
||||
| `mobile-code-quality.md` | iOS/Android quality checks |
|
||||
| `production-readiness.md` | Pre-release checklist |
|
||||
| `scan-repo-context.md` | Update WINDSURF_CONTEXT.md |
|
||||
| `security-auditing.md` | Security audit procedures |
|
||||
| `test-desktop-app.md` | Desktop app testing |
|
||||
| `test-ios-app.md` | iOS testing in Simulator |
|
||||
| `test-strategies.md` | Testing patterns |
|
||||
| `update-agent-docs.md` | Regenerate AGENTS.md, CLAUDE.md, etc. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Scripts (`scripts/`)
|
||||
|
||||
| Script | Purpose |
|
||||
| ----------------------------- | ------------------------------------- |
|
||||
| `backup-main.sh` | Backup main branches across repos |
|
||||
| `cosmos-telemetry-indexes.sh` | Create Cosmos indexes for telemetry |
|
||||
| `docker-prep.sh` | Prepare for Docker builds |
|
||||
| `export-lysnr-kv.sh` | Export LysnrAI Key Vault secrets |
|
||||
| `prep-consumer.sh` | Prepare consumer builds |
|
||||
| `railway-deploy.sh` | Deploy to Railway |
|
||||
| `secret-scan-repo.sh` | Scan repo for secrets |
|
||||
| `secret-scan-staged.sh` | Scan staged files |
|
||||
| `seed-keyvault.sh` | Seed Key Vault with required secrets |
|
||||
| `seed-lysnr-kv.sh` | Seed LysnrAI Key Vault |
|
||||
| `setup-husky.sh` | Setup Git hooks |
|
||||
| `switch-network.sh` | Switch between corporate/home network |
|
||||
| `sync-workflows.md` | Sync workflows across repos |
|
||||
|
||||
---
|
||||
|
||||
## 8. Product Configurations (`products/`)
|
||||
|
||||
Each product has a `product.json` manifest:
|
||||
|
||||
| Product | File | Backend Port |
|
||||
| ---------- | ------------------------- | ------------ |
|
||||
| LysnrAI | `lysnrai/product.json` | 4015 |
|
||||
| MindLyst | `mindlyst/product.json` | 4014 |
|
||||
| ChronoMind | `chronomind/product.json` | 4011 |
|
||||
| JarvisJr | `jarvisjr/product.json` | 4012 |
|
||||
| NomGap | `nomgap/product.json` | 4013 |
|
||||
| PeakPulse | `peakpulse/product.json` | 4010 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Completed Roadmaps (`docs/roadmaps/completed/`)
|
||||
|
||||
| Roadmap | Description |
|
||||
| -------------------------------------------- | ------------------------------------ |
|
||||
| `cloud_AGNOSTIC_REFACTOR_ROADMAP.md` | Cloud-agnostic infrastructure |
|
||||
| `cloud_REFERRALS_PARTITION_KEY_MIGRATION.md` | Cosmos partition key migration |
|
||||
| `diagnostics_REMOTE_DIAGNOSTICS_ROADMAP.md` | Remote diagnostics system |
|
||||
| `extraction_SERVICE_ROADMAP.md` | Text extraction service |
|
||||
| `mobile_ANDROID_PLATFORM_SDK.md` | Android SDK design |
|
||||
| `mobile_IOS_PLATFORM_SDK.md` | iOS SDK design |
|
||||
| `mobile_REACT_NATIVE_PLATFORM_SDK.md` | React Native SDK design |
|
||||
| `platform_BACKEND_MIGRATION.md` | Backend consolidation |
|
||||
| `platform_COMMON_EXTRACTION_ROADMAP.md` | Shared extraction |
|
||||
| `platform_COMPONENTS_ROADMAP.md` | Platform components (23 of 25 built) |
|
||||
| `platform_SERVICE_CONSOLIDATION_ROADMAP.md` | Service merger (4001→4003) |
|
||||
| `product_MARKETPLACE_MODULE_DESIGN.md` | JarvisJr marketplace |
|
||||
| `product_PRE_LAUNCH_SIGNUP_SYSTEM.md` | Waitlist system |
|
||||
| `telemetry_IMPLEMENTATION_ROADMAP.md` | Client telemetry |
|
||||
|
||||
---
|
||||
|
||||
## 10. Active/Planned Roadmaps (`docs/roadmaps/`)
|
||||
|
||||
| Roadmap | Status |
|
||||
| -------------------------------------------- | ----------- |
|
||||
| `AI_DIAGNOSTIC_ASSISTANT_ROADMAP.md` | In Progress |
|
||||
| `INTELLIGENT_AB_TESTING_ROADMAP.md` | Active |
|
||||
| `PREDICTIVE_CHURN_HEALTH_SCORING_ROADMAP.md` | Active |
|
||||
| `WORKSPACE_REVIEW_2026_03_03.md` | Active |
|
||||
|
||||
---
|
||||
|
||||
## 11. How to Consume
|
||||
|
||||
### 11.1 TypeScript Packages (Services/Dashboards)
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"dependencies": {
|
||||
"@bytelyst/cosmos": "file:../learning_ai_common_plat/packages/cosmos",
|
||||
"@bytelyst/auth": "file:../learning_ai_common_plat/packages/auth",
|
||||
"@bytelyst/config": "file:../learning_ai_common_plat/packages/config"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// In code
|
||||
import { getCosmosClient, registerContainers } from '@bytelyst/cosmos';
|
||||
import { createJwtUtils } from '@bytelyst/auth';
|
||||
import { loadProductIdentity } from '@bytelyst/config';
|
||||
```
|
||||
|
||||
### 11.2 Swift SDK (iOS)
|
||||
|
||||
```swift
|
||||
// Package.swift
|
||||
dependencies: [
|
||||
.package(path: "../learning_ai_common_plat/packages/swift-platform-sdk")
|
||||
]
|
||||
|
||||
// In code
|
||||
import ByteLystPlatformSDK
|
||||
let config = BLPlatformConfig(productId: "myproduct", baseURL: "...")
|
||||
```
|
||||
|
||||
### 11.3 Kotlin SDK (Android)
|
||||
|
||||
```kotlin
|
||||
// settings.gradle.kts
|
||||
includeBuild("../learning_ai_common_plat/packages/kotlin-platform-sdk")
|
||||
|
||||
// build.gradle.kts
|
||||
implementation("com.bytelyst:platform-sdk:1.0.0")
|
||||
|
||||
// In code
|
||||
import com.bytelyst.platform.BLPlatformConfig
|
||||
val config = BLPlatformConfig(productId = "myproduct", baseURL = "...")
|
||||
```
|
||||
|
||||
### 11.4 React Native SDK
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"dependencies": {
|
||||
"@bytelyst/react-native-platform-sdk": "file:../learning_ai_common_plat/packages/react-native-platform-sdk"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Build Verification Commands
|
||||
|
||||
```bash
|
||||
# Full platform build
|
||||
pnpm build
|
||||
|
||||
# Full test suite
|
||||
pnpm test
|
||||
|
||||
# Type check
|
||||
pnpm typecheck
|
||||
|
||||
# Individual service
|
||||
pnpm --filter @lysnrai/platform-service test
|
||||
|
||||
# Swift SDK
|
||||
cd packages/swift-platform-sdk && swift build
|
||||
|
||||
# Kotlin SDK
|
||||
cd packages/kotlin-platform-sdk && ./gradlew build
|
||||
|
||||
# Dashboards
|
||||
cd dashboards/admin-web && npm run build
|
||||
cd dashboards/tracker-web && npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PRODUCT APPS │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐│
|
||||
│ │LysnrAI │ │MindLyst │ │ChronoM. │ │JarvisJr │ │NomGap ││
|
||||
│ │PeakPulse│ │ │ │ │ │ │ │ ││
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘│
|
||||
│ │ │ │ │ │ │
|
||||
│ └───────────┴───────────┴───────────┴───────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────┴──────────┐ │
|
||||
│ │ Platform SDKs │ │
|
||||
│ │ (Swift/Kotlin/RN) │ │
|
||||
│ └──────────┬──────────┘ │
|
||||
└─────────────────────────┼───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┼───────────────────────────────────┐
|
||||
│ ┌──────────┴──────────┐ │
|
||||
│ │ Shared Packages │ │
|
||||
│ │ (@bytelyst/*) │ │
|
||||
│ └──────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────┼────────────────────────────────┐ │
|
||||
│ │ platform-service (4003) │ │
|
||||
│ │ auth │ billing │ flags │ telemetry │ blob │ etc. │ │
|
||||
│ └──────────────────────┬────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────┼────────────────────────────────┐ │
|
||||
│ │ extraction-service (4005) │ │
|
||||
│ │ Text extraction (LangExtract) │ │
|
||||
│ └──────────────────────┬────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────┴──────────┐ │
|
||||
│ │ Azure Cosmos DB │ │
|
||||
│ │ Azure Blob Storage │ │
|
||||
│ │ Azure Key Vault │ │
|
||||
│ └───────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Contact & Contribution
|
||||
|
||||
- **Primary Repo:** `learning_ai_common_plat`
|
||||
- **Package Scope:** `@bytelyst/*`
|
||||
- **Service Scope:** `@lysnrai/*`
|
||||
- **New Components:** Follow existing patterns in `types.ts` → `repository.ts` → `routes.ts`
|
||||
- **Tests Required:** All new code must have tests (Vitest for TS, XCTest for Swift, JUnit5 for Kotlin)
|
||||
28
packages/dashboard-components/package.json
Normal file
28
packages/dashboard-components/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@bytelyst/dashboard-components",
|
||||
"version": "0.1.0",
|
||||
"description": "Shared React components for ByteLyst dashboards",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
33
packages/dashboard-components/src/EmptyState.tsx
Normal file
33
packages/dashboard-components/src/EmptyState.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps): ReactNode {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[300px] p-8 text-center">
|
||||
{icon && (
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">{title}</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-sm mb-6">{description}</p>
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
packages/dashboard-components/src/ErrorPage.tsx
Normal file
38
packages/dashboard-components/src/ErrorPage.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface ErrorPageProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorPage({
|
||||
title = 'Something went wrong',
|
||||
message = 'An unexpected error occurred. Please try again.',
|
||||
onRetry,
|
||||
}: ErrorPageProps): ReactNode {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-8">
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{title}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-center max-w-md mb-6">{message}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
packages/dashboard-components/src/LoadingSkeleton.tsx
Normal file
16
packages/dashboard-components/src/LoadingSkeleton.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
rows?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingSkeleton({ rows = 3, className = '' }: LoadingSkeletonProps): ReactNode {
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
packages/dashboard-components/src/LoadingSpinner.tsx
Normal file
39
packages/dashboard-components/src/LoadingSpinner.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps): ReactNode {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} ${className}`}>
|
||||
<svg
|
||||
className="animate-spin text-blue-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
packages/dashboard-components/src/NotFoundPage.tsx
Normal file
43
packages/dashboard-components/src/NotFoundPage.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface NotFoundPageProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function NotFoundPage({
|
||||
title = 'Page Not Found',
|
||||
message = 'The page you are looking for does not exist.',
|
||||
onBack,
|
||||
}: NotFoundPageProps): ReactNode {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-8">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
34
packages/dashboard-components/src/PageHeader.tsx
Normal file
34
packages/dashboard-components/src/PageHeader.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
breadcrumbs?: Array<{ label: string; href?: string }>;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, breadcrumbs, actions }: PageHeaderProps): ReactNode {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<nav className="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={index} className="flex items-center">
|
||||
{index > 0 && <span className="mx-2">/</span>}
|
||||
{crumb.href ? (
|
||||
<a href={crumb.href} className="hover:text-gray-700 dark:hover:text-gray-300">
|
||||
{crumb.label}
|
||||
</a>
|
||||
) : (
|
||||
<span>{crumb.label}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{title}</h1>
|
||||
</div>
|
||||
{actions && <div className="flex items-center space-x-3">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
packages/dashboard-components/src/index.ts
Normal file
12
packages/dashboard-components/src/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @bytelyst/dashboard-components
|
||||
*
|
||||
* Shared React components for ByteLyst dashboards
|
||||
*/
|
||||
|
||||
export { ErrorPage } from './ErrorPage.js';
|
||||
export { NotFoundPage } from './NotFoundPage.js';
|
||||
export { LoadingSpinner } from './LoadingSpinner.js';
|
||||
export { LoadingSkeleton } from './LoadingSkeleton.js';
|
||||
export { EmptyState } from './EmptyState.js';
|
||||
export { PageHeader } from './PageHeader.js';
|
||||
12
packages/dashboard-components/tsconfig.json
Normal file
12
packages/dashboard-components/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
31
packages/sync/package.json
Normal file
31
packages/sync/package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@bytelyst/sync",
|
||||
"version": "0.1.0",
|
||||
"description": "Offline-first sync engine with configurable storage adapters and conflict resolution",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bytelyst/api-client": "workspace:*",
|
||||
"@bytelyst/telemetry-client": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.12.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@bytelyst/api-client": "workspace:*"
|
||||
}
|
||||
}
|
||||
382
packages/sync/src/engine.ts
Normal file
382
packages/sync/src/engine.ts
Normal file
@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Sync Engine — Core implementation
|
||||
*
|
||||
* @module @bytelyst/sync/engine
|
||||
*/
|
||||
|
||||
import type {
|
||||
SyncEngine,
|
||||
SyncEngineConfig,
|
||||
SyncItem,
|
||||
SyncResult,
|
||||
SyncStatus,
|
||||
SyncStatusInfo,
|
||||
SyncStatusCallback,
|
||||
EntityName,
|
||||
SyncOperation,
|
||||
ConflictStrategy,
|
||||
} from './types.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_MAX_RETRIES = 5;
|
||||
const DEFAULT_RETRY_DELAY_MS = 1000;
|
||||
const QUEUE_KEY = 'queue';
|
||||
const LAST_SYNC_KEY = 'lastSync';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sync Engine Implementation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class SyncEngineImpl implements SyncEngine {
|
||||
private config: SyncEngineConfig;
|
||||
private status: SyncStatus = 'idle';
|
||||
private statusListeners: Set<SyncStatusCallback> = new Set();
|
||||
private connectivityListeners: (() => void)[] = [];
|
||||
|
||||
constructor(config: SyncEngineConfig) {
|
||||
this.config = {
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
retryDelayMs: DEFAULT_RETRY_DELAY_MS,
|
||||
...config,
|
||||
};
|
||||
this.setupConnectivityDetection();
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Core Operations
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async push(
|
||||
entity: EntityName,
|
||||
data: unknown,
|
||||
operation: SyncOperation = 'create'
|
||||
): Promise<void> {
|
||||
const item: SyncItem = {
|
||||
id: this.generateId(),
|
||||
entity,
|
||||
operation,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
// Deduplication: Check if there's already a pending item for same entity/data
|
||||
const existingQueue = await this.getQueue();
|
||||
const dedupKey = this.getDedupKey(entity, data);
|
||||
const existingIndex = existingQueue.findIndex(
|
||||
i => this.getDedupKey(i.entity, i.data) === dedupKey
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Replace existing item with newer data
|
||||
existingQueue[existingIndex] = item;
|
||||
} else {
|
||||
existingQueue.push(item);
|
||||
}
|
||||
|
||||
await this.saveQueue(existingQueue);
|
||||
this.updateStatus('idle');
|
||||
|
||||
// Auto-flush if online
|
||||
if (this.isOnline()) {
|
||||
await this.flush();
|
||||
}
|
||||
}
|
||||
|
||||
async delete(entity: EntityName, id: string): Promise<void> {
|
||||
await this.push(entity, { id }, 'delete');
|
||||
}
|
||||
|
||||
async pull(): Promise<SyncResult> {
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
pushed: 0,
|
||||
pulled: 0,
|
||||
conflicts: 0,
|
||||
errors: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.updateStatus('syncing');
|
||||
|
||||
try {
|
||||
// Pull changes from server for each entity
|
||||
for (const [entityName, entityConfig] of Object.entries(this.config.entities)) {
|
||||
try {
|
||||
const pulled = await this.pullEntity(entityName, entityConfig.endpoint);
|
||||
result.pulled += pulled;
|
||||
} catch (error) {
|
||||
result.errors++;
|
||||
this.trackError('pull', entityName, error);
|
||||
}
|
||||
}
|
||||
|
||||
await this.setLastSyncTime(result.timestamp);
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
this.updateStatus('error', error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
|
||||
if (result.success && result.errors === 0) {
|
||||
this.updateStatus('idle');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fullSync(): Promise<SyncResult> {
|
||||
const result = await this.pushQueue();
|
||||
const pullResult = await this.pull();
|
||||
|
||||
return {
|
||||
success: result.success && pullResult.success,
|
||||
pushed: result.pushed,
|
||||
pulled: pullResult.pulled,
|
||||
conflicts: result.conflicts + pullResult.conflicts,
|
||||
errors: result.errors + pullResult.errors,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Queue Management
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async getQueue(): Promise<SyncItem[]> {
|
||||
const queue = await this.config.storage.getItem<SyncItem[]>(QUEUE_KEY);
|
||||
return queue || [];
|
||||
}
|
||||
|
||||
private async saveQueue(queue: SyncItem[]): Promise<void> {
|
||||
await this.config.storage.setItem(QUEUE_KEY, queue);
|
||||
}
|
||||
|
||||
private async pushQueue(): Promise<SyncResult> {
|
||||
const queue = await this.getQueue();
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
pushed: 0,
|
||||
pulled: 0,
|
||||
conflicts: 0,
|
||||
errors: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (queue.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
this.updateStatus('syncing');
|
||||
|
||||
const remaining: SyncItem[] = [];
|
||||
|
||||
for (const item of queue) {
|
||||
try {
|
||||
const success = await this.pushItem(item);
|
||||
if (success) {
|
||||
result.pushed++;
|
||||
} else {
|
||||
remaining.push(item);
|
||||
}
|
||||
} catch (error) {
|
||||
if (item.retryCount < (this.config.maxRetries || DEFAULT_MAX_RETRIES)) {
|
||||
item.retryCount++;
|
||||
item.lastError = error instanceof Error ? error.message : String(error);
|
||||
remaining.push(item);
|
||||
} else {
|
||||
result.errors++;
|
||||
this.trackError('push', item.entity, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveQueue(remaining);
|
||||
|
||||
if (result.errors > 0) {
|
||||
result.success = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async pushItem(item: SyncItem): Promise<boolean> {
|
||||
const entityConfig = this.config.entities[item.entity];
|
||||
if (!entityConfig) {
|
||||
throw new Error(`Unknown entity: ${item.entity}`);
|
||||
}
|
||||
|
||||
const path =
|
||||
item.operation === 'delete' || item.operation === 'update'
|
||||
? `${entityConfig.endpoint}/${(item.data as { id: string }).id}`
|
||||
: entityConfig.endpoint;
|
||||
|
||||
const method =
|
||||
item.operation === 'delete' ? 'DELETE' : item.operation === 'update' ? 'PATCH' : 'POST';
|
||||
|
||||
try {
|
||||
await this.config.apiClient.fetch(path, {
|
||||
method,
|
||||
body: method !== 'DELETE' ? JSON.stringify(item.data) : undefined,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async pullEntity(entityName: string, endpoint: string): Promise<number> {
|
||||
const lastSync = await this.getLastSyncTime();
|
||||
const path = lastSync ? `${endpoint}?since=${encodeURIComponent(lastSync)}` : endpoint;
|
||||
|
||||
const result = await this.config.apiClient.safeFetch<{ items: unknown[] }>(path);
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Store pulled items locally (consumer handles storage)
|
||||
return result.data.items?.length || 0;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Conflict Resolution
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async resolveConflict(
|
||||
item: SyncItem,
|
||||
remoteData: unknown,
|
||||
strategy: ConflictStrategy
|
||||
): Promise<unknown> {
|
||||
switch (strategy) {
|
||||
case 'server-wins':
|
||||
return remoteData;
|
||||
case 'client-wins':
|
||||
return item.data;
|
||||
case 'last-write-wins': {
|
||||
const localTime = new Date(item.timestamp).getTime();
|
||||
const remoteTime = new Date(
|
||||
(remoteData as { updatedAt?: string })?.updatedAt || 0
|
||||
).getTime();
|
||||
return localTime > remoteTime ? item.data : remoteData;
|
||||
}
|
||||
case 'manual':
|
||||
if (this.config.onConflict) {
|
||||
return await this.config.onConflict(item, remoteData);
|
||||
}
|
||||
return remoteData;
|
||||
default:
|
||||
return remoteData;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Connectivity
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private setupConnectivityDetection(): void {
|
||||
if (typeof window !== 'undefined' && window.addEventListener) {
|
||||
const handleOnline = () => {
|
||||
this.flush();
|
||||
this.connectivityListeners.forEach(cb => cb());
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
}
|
||||
}
|
||||
|
||||
private isOnline(): boolean {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
return navigator.onLine;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.status === 'syncing') return;
|
||||
await this.pushQueue();
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Status & Monitoring
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
getQueueLength(): number {
|
||||
// Async getQueue but we need sync return - use cached value or 0
|
||||
return 0; // Consumer should use getStatus() for accurate count
|
||||
}
|
||||
|
||||
getStatus(): SyncStatusInfo {
|
||||
return {
|
||||
status: this.status,
|
||||
queueLength: 0, // Will be populated async
|
||||
};
|
||||
}
|
||||
|
||||
onStatusChange(callback: SyncStatusCallback): () => void {
|
||||
this.statusListeners.add(callback);
|
||||
return () => this.statusListeners.delete(callback);
|
||||
}
|
||||
|
||||
private updateStatus(status: SyncStatus, error?: string): void {
|
||||
this.status = status;
|
||||
const info: SyncStatusInfo = {
|
||||
status,
|
||||
queueLength: 0,
|
||||
lastError: error,
|
||||
};
|
||||
this.statusListeners.forEach(cb => cb(info));
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Utility
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async clearQueue(): Promise<void> {
|
||||
await this.saveQueue([]);
|
||||
}
|
||||
|
||||
async reprocessFailed(): Promise<void> {
|
||||
const queue = await this.getQueue();
|
||||
const reset = queue.map(item => ({
|
||||
...item,
|
||||
retryCount: 0,
|
||||
lastError: undefined,
|
||||
}));
|
||||
await this.saveQueue(reset);
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
private async getLastSyncTime(): Promise<string | undefined> {
|
||||
return (await this.config.storage.getItem<string>(LAST_SYNC_KEY)) || undefined;
|
||||
}
|
||||
|
||||
private async setLastSyncTime(timestamp: string): Promise<void> {
|
||||
await this.config.storage.setItem(LAST_SYNC_KEY, timestamp);
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
private getDedupKey(entity: string, data: unknown): string {
|
||||
const id = (data as { id?: string })?.id;
|
||||
return id ? `${entity}:${id}` : `${entity}:${JSON.stringify(data)}`;
|
||||
}
|
||||
|
||||
private trackError(_operation: string, _entity: string, _error: unknown): void {
|
||||
if (this.config.telemetryClient) {
|
||||
// Telemetry tracking would go here
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Factory Function
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createSyncEngine(config: SyncEngineConfig): SyncEngine {
|
||||
return new SyncEngineImpl(config);
|
||||
}
|
||||
51
packages/sync/src/index.ts
Normal file
51
packages/sync/src/index.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @bytelyst/sync
|
||||
*
|
||||
* Offline-first sync engine with configurable storage adapters and conflict resolution.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createSyncEngine, LocalStorageAdapter } from '@bytelyst/sync';
|
||||
* import { createApiClient } from '@bytelyst/api-client';
|
||||
*
|
||||
* const sync = createSyncEngine({
|
||||
* productId: 'myapp',
|
||||
* entities: {
|
||||
* tasks: {
|
||||
* endpoint: '/api/tasks',
|
||||
* partitionKey: 'userId',
|
||||
* conflictStrategy: 'server-wins',
|
||||
* },
|
||||
* },
|
||||
* storage: new LocalStorageAdapter(),
|
||||
* apiClient: createApiClient({ baseURL: 'https://api.example.com' }),
|
||||
* });
|
||||
*
|
||||
* // Push changes
|
||||
* await sync.push('tasks', { title: 'New Task' });
|
||||
*
|
||||
* // Sync with server
|
||||
* const result = await sync.fullSync();
|
||||
* console.log(`Pushed: ${result.pushed}, Pulled: ${result.pulled}`);
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { createSyncEngine, SyncEngineImpl } from './engine.js';
|
||||
|
||||
export { LocalStorageAdapter, InMemoryAdapter, MMKVAdapter, type MMKVInstance } from './storage.js';
|
||||
|
||||
export type {
|
||||
SyncEngine,
|
||||
SyncEngineConfig,
|
||||
EntityName,
|
||||
EntityConfig,
|
||||
ConflictStrategy,
|
||||
SyncStatus,
|
||||
SyncOperation,
|
||||
SyncItem,
|
||||
SyncResult,
|
||||
SyncStatusInfo,
|
||||
SyncStatusCallback,
|
||||
StorageAdapter,
|
||||
Conflict,
|
||||
} from './types.js';
|
||||
127
packages/sync/src/storage.ts
Normal file
127
packages/sync/src/storage.ts
Normal file
@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Storage Adapters
|
||||
*
|
||||
* @module @bytelyst/sync/storage
|
||||
*/
|
||||
|
||||
import type { StorageAdapter } from './types.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LocalStorage Adapter (Web)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class LocalStorageAdapter implements StorageAdapter {
|
||||
private prefix: string;
|
||||
|
||||
constructor(prefix = 'bytelyst:sync:') {
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
getItem<T>(key: string): T | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
const value = localStorage.getItem(this.prefix + key);
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
setItem<T>(key: string, value: T): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
localStorage.setItem(this.prefix + key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
localStorage.removeItem(this.prefix + key);
|
||||
}
|
||||
|
||||
keys(): string[] {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith(this.prefix)) {
|
||||
keys.push(key.slice(this.prefix.length));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// In-Memory Adapter (Testing / SSR)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class InMemoryAdapter implements StorageAdapter {
|
||||
private store = new Map<string, unknown>();
|
||||
|
||||
getItem<T>(key: string): T | null {
|
||||
const value = this.store.get(key);
|
||||
return value !== undefined ? (value as T) : null;
|
||||
}
|
||||
|
||||
setItem<T>(key: string, value: T): void {
|
||||
this.store.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
keys(): string[] {
|
||||
return Array.from(this.store.keys());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MMKV Adapter Interface (React Native)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MMKVInstance {
|
||||
getString(key: string): string | undefined;
|
||||
set(key: string, value: string): void;
|
||||
delete(key: string): void;
|
||||
getAllKeys(): string[];
|
||||
}
|
||||
|
||||
export class MMKVAdapter implements StorageAdapter {
|
||||
private mmkv: MMKVInstance;
|
||||
private prefix: string;
|
||||
|
||||
constructor(mmkv: MMKVInstance, prefix = 'sync:') {
|
||||
this.mmkv = mmkv;
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
getItem<T>(key: string): T | null {
|
||||
const value = this.mmkv.getString(this.prefix + key);
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
setItem<T>(key: string, value: T): void {
|
||||
this.mmkv.set(this.prefix + key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.mmkv.delete(this.prefix + key);
|
||||
}
|
||||
|
||||
keys(): string[] {
|
||||
return this.mmkv
|
||||
.getAllKeys()
|
||||
.filter(k => k.startsWith(this.prefix))
|
||||
.map(k => k.slice(this.prefix.length));
|
||||
}
|
||||
}
|
||||
275
packages/sync/src/sync.test.ts
Normal file
275
packages/sync/src/sync.test.ts
Normal file
@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Sync Engine Tests
|
||||
*
|
||||
* @module @bytelyst/sync/engine.test
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createSyncEngine, InMemoryAdapter } from './index.js';
|
||||
import type { ApiClient, ApiResult } from '@bytelyst/api-client';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Mock API Client
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function createMockApiClient(): ApiClient & {
|
||||
getRequests: () => { path: string; options?: RequestInit }[];
|
||||
} {
|
||||
const requests: { path: string; options?: RequestInit }[] = [];
|
||||
|
||||
return {
|
||||
fetch: async <T>(path: string, options?: RequestInit): Promise<T> => {
|
||||
requests.push({ path, options });
|
||||
return { items: [] } as unknown as T;
|
||||
},
|
||||
safeFetch: async <T>(path: string, options?: RequestInit): Promise<ApiResult<T>> => {
|
||||
requests.push({ path, options });
|
||||
return { data: { items: [] } as unknown as T, error: null };
|
||||
},
|
||||
getRequests: () => requests,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Sync Engine', () => {
|
||||
let storage: InMemoryAdapter;
|
||||
let apiClient: ReturnType<typeof createMockApiClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new InMemoryAdapter();
|
||||
apiClient = createMockApiClient();
|
||||
});
|
||||
|
||||
describe('createSyncEngine', () => {
|
||||
it('creates a sync engine with default config', () => {
|
||||
const engine = createSyncEngine({
|
||||
productId: 'test',
|
||||
entities: {
|
||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
||||
},
|
||||
storage,
|
||||
apiClient,
|
||||
});
|
||||
|
||||
expect(engine).toBeDefined();
|
||||
expect(engine.push).toBeDefined();
|
||||
expect(engine.pull).toBeDefined();
|
||||
expect(engine.fullSync).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('push', () => {
|
||||
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' });
|
||||
|
||||
const status = engine.getStatus();
|
||||
expect(status.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('deduplicates items for same entity', async () => {
|
||||
const engine = createSyncEngine({
|
||||
productId: 'test',
|
||||
entities: {
|
||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
||||
},
|
||||
storage,
|
||||
apiClient,
|
||||
});
|
||||
|
||||
await engine.push('tasks', { id: '1', title: 'Task 1' });
|
||||
await engine.push('tasks', { id: '1', title: 'Task 1 Updated' });
|
||||
|
||||
// Queue should have 1 item (deduplicated)
|
||||
const result = await engine.fullSync();
|
||||
expect(result.pushed).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
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');
|
||||
|
||||
const requests = apiClient.getRequests();
|
||||
// Delete is queued but not flushed until sync
|
||||
expect(requests.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fullSync', () => {
|
||||
it('pushes queued items', async () => {
|
||||
const engine = createSyncEngine({
|
||||
productId: 'test',
|
||||
entities: {
|
||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
||||
},
|
||||
storage,
|
||||
apiClient,
|
||||
});
|
||||
|
||||
await engine.push('tasks', { title: 'Test Task' });
|
||||
const result = await engine.fullSync();
|
||||
|
||||
expect(result.pushed).toBe(1);
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
describe('status and monitoring', () => {
|
||||
it('returns initial status', () => {
|
||||
const engine = createSyncEngine({
|
||||
productId: 'test',
|
||||
entities: {
|
||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
||||
},
|
||||
storage,
|
||||
apiClient,
|
||||
});
|
||||
|
||||
const status = engine.getStatus();
|
||||
expect(status.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('notifies status changes', async () => {
|
||||
const engine = createSyncEngine({
|
||||
productId: 'test',
|
||||
entities: {
|
||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
||||
},
|
||||
storage,
|
||||
apiClient,
|
||||
});
|
||||
|
||||
const statuses: string[] = [];
|
||||
engine.onStatusChange(status => {
|
||||
statuses.push(status.status);
|
||||
});
|
||||
|
||||
await engine.push('tasks', { title: 'Test' });
|
||||
|
||||
// Status changes during push
|
||||
expect(statuses.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('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' });
|
||||
await engine.push('tasks', { title: 'Task 2' });
|
||||
await engine.clearQueue();
|
||||
|
||||
const result = await engine.fullSync();
|
||||
expect(result.pushed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reprocessFailed', () => {
|
||||
it('resets retry count on failed items', async () => {
|
||||
const engine = createSyncEngine({
|
||||
productId: 'test',
|
||||
entities: {
|
||||
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
||||
},
|
||||
storage,
|
||||
apiClient,
|
||||
});
|
||||
|
||||
await engine.push('tasks', { title: 'Test' });
|
||||
await engine.reprocessFailed();
|
||||
|
||||
// Items should be reprocessed
|
||||
const result = await engine.fullSync();
|
||||
expect(result.pushed).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
packages/sync/src/types.ts
Normal file
104
packages/sync/src/types.ts
Normal file
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Sync Engine Types
|
||||
*
|
||||
* @module @bytelyst/sync/types
|
||||
*/
|
||||
|
||||
import type { ApiClient } from '@bytelyst/api-client';
|
||||
import type { TelemetryClient } from '@bytelyst/telemetry-client';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Core Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type EntityName = string;
|
||||
|
||||
export type ConflictStrategy = 'server-wins' | 'client-wins' | 'last-write-wins' | 'manual';
|
||||
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'offline' | 'error';
|
||||
|
||||
export type SyncOperation = 'create' | 'update' | 'delete';
|
||||
|
||||
export interface EntityConfig {
|
||||
endpoint: string;
|
||||
partitionKey: string;
|
||||
conflictStrategy: ConflictStrategy;
|
||||
}
|
||||
|
||||
export interface SyncEngineConfig {
|
||||
productId: string;
|
||||
entities: Record<EntityName, EntityConfig>;
|
||||
storage: StorageAdapter;
|
||||
apiClient: ApiClient;
|
||||
telemetryClient?: TelemetryClient;
|
||||
onConflict?: (local: SyncItem, remote: unknown) => Promise<unknown> | unknown;
|
||||
maxRetries?: number;
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
|
||||
export interface SyncItem {
|
||||
id: string;
|
||||
entity: EntityName;
|
||||
operation: SyncOperation;
|
||||
data: unknown;
|
||||
timestamp: string;
|
||||
retryCount: number;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
pushed: number;
|
||||
pulled: number;
|
||||
conflicts: number;
|
||||
errors: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface SyncStatusInfo {
|
||||
status: SyncStatus;
|
||||
queueLength: number;
|
||||
lastSyncAt?: string;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export type SyncStatusCallback = (status: SyncStatusInfo) => void;
|
||||
|
||||
export interface Conflict {
|
||||
entity: EntityName;
|
||||
localItem: SyncItem;
|
||||
remoteData: unknown;
|
||||
localData: unknown;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Storage Adapter Interface
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StorageAdapter {
|
||||
getItem<T>(key: string): Promise<T | null> | T | null;
|
||||
setItem<T>(key: string, value: T): Promise<void> | void;
|
||||
removeItem(key: string): Promise<void> | void;
|
||||
keys(): Promise<string[]> | string[];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sync Engine Interface
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SyncEngine {
|
||||
// Core operations
|
||||
push(entity: EntityName, data: unknown, operation?: SyncOperation): Promise<void>;
|
||||
delete(entity: EntityName, id: string): Promise<void>;
|
||||
pull(): Promise<SyncResult>;
|
||||
fullSync(): Promise<SyncResult>;
|
||||
|
||||
// Status and monitoring
|
||||
getQueueLength(): number;
|
||||
getStatus(): SyncStatusInfo;
|
||||
onStatusChange(callback: SyncStatusCallback): () => void;
|
||||
|
||||
// Utility
|
||||
clearQueue(): Promise<void>;
|
||||
reprocessFailed(): Promise<void>;
|
||||
}
|
||||
12
packages/sync/tsconfig.json
Normal file
12
packages/sync/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
14
packages/sync/vitest.config.ts
Normal file
14
packages/sync/vitest.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.test.ts', 'src/**/index.ts'],
|
||||
},
|
||||
},
|
||||
});
|
||||
14023
pnpm-lock.yaml
generated
14023
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user