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,
|
MessageSquare,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
Beaker,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
@ -59,6 +60,7 @@ const navItems = [
|
|||||||
{ href: '/ops', label: 'Mission Control', icon: Activity },
|
{ href: '/ops', label: 'Mission Control', icon: Activity },
|
||||||
{ href: '/ops/client-logs', label: 'Client Logs', icon: FileText },
|
{ href: '/ops/client-logs', label: 'Client Logs', icon: FileText },
|
||||||
{ href: '/ops/telemetry-policies', label: 'Telemetry Policies', icon: Shield },
|
{ 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: '/feedback', label: 'User Feedback', icon: MessageSquare },
|
||||||
{ href: '/ops/secrets', label: 'Secrets Manager', icon: KeyRound },
|
{ href: '/ops/secrets', label: 'Secrets Manager', icon: KeyRound },
|
||||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
{ 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