Merge branch 'main' into codex/expand-tests-for-syncfromflaskbulk

This commit is contained in:
2026-01-18 18:30:23 +00:00
committed by GitHub
13 changed files with 901 additions and 425 deletions

View File

@@ -80,7 +80,10 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "ComponentBindingDialog"
"wrapperFor": "ComponentBindingDialog",
"load": {
"export": "ComponentBindingDialogWrapper"
}
},
{
"type": "Container",
@@ -122,7 +125,10 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "DataSourceEditorDialog"
"wrapperFor": "DataSourceEditorDialog",
"load": {
"export": "DataSourceEditorDialogWrapper"
}
},
{
"type": "Dialog",
@@ -724,7 +730,10 @@
"canHaveChildren": false,
"description": "ArrowLeft icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "ArrowLeft"
}
},
{
"type": "ArrowRight",
@@ -733,7 +742,10 @@
"canHaveChildren": false,
"description": "ArrowRight icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "ArrowRight"
}
},
{
"type": "Check",
@@ -742,7 +754,10 @@
"canHaveChildren": false,
"description": "Check icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Check"
}
},
{
"type": "X",
@@ -751,7 +766,10 @@
"canHaveChildren": false,
"description": "X icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "X"
}
},
{
"type": "Plus",
@@ -760,7 +778,10 @@
"canHaveChildren": false,
"description": "Plus icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Plus"
}
},
{
"type": "Minus",
@@ -769,7 +790,10 @@
"canHaveChildren": false,
"description": "Minus icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Minus"
}
},
{
"type": "Search",
@@ -778,7 +802,10 @@
"canHaveChildren": false,
"description": "Search icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "MagnifyingGlass"
}
},
{
"type": "Filter",
@@ -787,7 +814,10 @@
"canHaveChildren": false,
"description": "Filter icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Funnel"
}
},
{
"type": "Download",
@@ -796,7 +826,10 @@
"canHaveChildren": false,
"description": "Download icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Download"
}
},
{
"type": "Upload",
@@ -805,7 +838,10 @@
"canHaveChildren": false,
"description": "Upload icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Upload"
}
},
{
"type": "Edit",
@@ -814,7 +850,10 @@
"canHaveChildren": false,
"description": "Edit icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "PencilSimple"
}
},
{
"type": "Trash",
@@ -823,7 +862,10 @@
"canHaveChildren": false,
"description": "Trash icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Trash"
}
},
{
"type": "Eye",
@@ -832,7 +874,10 @@
"canHaveChildren": false,
"description": "Eye icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Eye"
}
},
{
"type": "EyeOff",
@@ -841,7 +886,10 @@
"canHaveChildren": false,
"description": "EyeOff icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "EyeClosed"
}
},
{
"type": "ChevronUp",
@@ -850,7 +898,10 @@
"canHaveChildren": false,
"description": "ChevronUp icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "CaretUp"
}
},
{
"type": "ChevronDown",
@@ -859,7 +910,10 @@
"canHaveChildren": false,
"description": "ChevronDown icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "CaretDown"
}
},
{
"type": "ChevronLeft",
@@ -868,7 +922,10 @@
"canHaveChildren": false,
"description": "ChevronLeft icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "CaretLeft"
}
},
{
"type": "ChevronRight",
@@ -877,7 +934,10 @@
"canHaveChildren": false,
"description": "ChevronRight icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "CaretRight"
}
},
{
"type": "Settings",
@@ -886,7 +946,10 @@
"canHaveChildren": false,
"description": "Settings icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Gear"
}
},
{
"type": "User",
@@ -895,7 +958,10 @@
"canHaveChildren": false,
"description": "User icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "User"
}
},
{
"type": "Bell",
@@ -904,7 +970,10 @@
"canHaveChildren": false,
"description": "Bell icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Bell"
}
},
{
"type": "Mail",
@@ -913,7 +982,10 @@
"canHaveChildren": false,
"description": "Mail icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Envelope"
}
},
{
"type": "Calendar",
@@ -922,7 +994,10 @@
"canHaveChildren": false,
"description": "Calendar icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Calendar"
}
},
{
"type": "Clock",
@@ -931,7 +1006,10 @@
"canHaveChildren": false,
"description": "Clock icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Clock"
}
},
{
"type": "Star",
@@ -940,7 +1018,10 @@
"canHaveChildren": false,
"description": "Star icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Star"
}
},
{
"type": "Heart",
@@ -949,7 +1030,10 @@
"canHaveChildren": false,
"description": "Heart icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Heart"
}
},
{
"type": "Share",
@@ -958,7 +1042,10 @@
"canHaveChildren": false,
"description": "Share icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "ShareNetwork"
}
},
{
"type": "Link",
@@ -967,7 +1054,10 @@
"canHaveChildren": false,
"description": "Link icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "LinkSimple"
}
},
{
"type": "Copy",
@@ -976,7 +1066,10 @@
"canHaveChildren": false,
"description": "Copy icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Copy"
}
},
{
"type": "Save",
@@ -985,7 +1078,10 @@
"canHaveChildren": false,
"description": "Save icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "FloppyDisk"
}
},
{
"type": "RefreshCw",
@@ -994,7 +1090,10 @@
"canHaveChildren": false,
"description": "RefreshCw icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "ArrowClockwise"
}
},
{
"type": "AlertCircle",
@@ -1003,7 +1102,10 @@
"canHaveChildren": false,
"description": "AlertCircle icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "WarningCircle"
}
},
{
"type": "Info",
@@ -1012,7 +1114,10 @@
"canHaveChildren": false,
"description": "Info icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Info"
}
},
{
"type": "HelpCircle",
@@ -1021,7 +1126,10 @@
"canHaveChildren": false,
"description": "HelpCircle icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "Question"
}
},
{
"type": "Home",
@@ -1030,7 +1138,10 @@
"canHaveChildren": false,
"description": "Home icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "House"
}
},
{
"type": "Menu",
@@ -1039,7 +1150,10 @@
"canHaveChildren": false,
"description": "Menu icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "List"
}
},
{
"type": "MoreVertical",
@@ -1048,7 +1162,10 @@
"canHaveChildren": false,
"description": "MoreVertical icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "DotsThreeVertical"
}
},
{
"type": "MoreHorizontal",
@@ -1057,7 +1174,10 @@
"canHaveChildren": false,
"description": "MoreHorizontal icon",
"status": "supported",
"source": "icons"
"source": "icons",
"load": {
"export": "DotsThree"
}
},
{
"type": "Breadcrumb",
@@ -1275,7 +1395,10 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "GitHubBuildStatus"
"wrapperFor": "GitHubBuildStatus",
"load": {
"export": "GitHubBuildStatusWrapper"
}
},
{
"type": "InfoBox",
@@ -1437,7 +1560,10 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "LazyBarChart"
"wrapperFor": "LazyBarChart",
"load": {
"export": "LazyBarChartWrapper"
}
},
{
"type": "LazyD3BarChart",
@@ -1460,7 +1586,10 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "LazyD3BarChart"
"wrapperFor": "LazyD3BarChart",
"load": {
"export": "LazyD3BarChartWrapper"
}
},
{
"type": "LazyLineChart",
@@ -1483,7 +1612,10 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "LazyLineChart"
"wrapperFor": "LazyLineChart",
"load": {
"export": "LazyLineChartWrapper"
}
},
{
"type": "List",
@@ -1542,7 +1674,10 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "SeedDataManager"
"wrapperFor": "SeedDataManager",
"load": {
"export": "SeedDataManagerWrapper"
}
},
{
"type": "StatCard",
@@ -1829,7 +1964,10 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "ComponentTree"
"wrapperFor": "ComponentTree",
"load": {
"export": "ComponentTreeWrapper"
}
},
{
"type": "ComponentTreeNode",
@@ -2072,7 +2210,10 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "SaveIndicator"
"wrapperFor": "SaveIndicator",
"load": {
"export": "SaveIndicatorWrapper"
}
},
{
"type": "SchemaEditorCanvas",
@@ -2236,7 +2377,10 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "StorageSettings"
"wrapperFor": "StorageSettings",
"load": {
"export": "StorageSettingsWrapper"
}
},
{
"type": "Timestamp",

View File

@@ -73,6 +73,16 @@
"wrapperFor": {
"type": "string"
},
"load": {
"type": "object",
"properties": {
"export": {
"type": "string"
}
},
"required": ["export"],
"additionalProperties": false
},
"deprecated": {
"type": "object",
"properties": {

View File

@@ -2,6 +2,101 @@
import { PrismaModel, ComponentNode, ThemeConfig, ProjectFile } from '@/types/project'
import { ProtectedLLMService } from './protected-llm-service'
import { toast } from 'sonner'
import { z } from 'zod'
const componentNodeSchema: z.ZodType<ComponentNode> = z.lazy(() => z.object({
id: z.string(),
type: z.string(),
name: z.string(),
props: z.record(z.any()),
children: z.array(componentNodeSchema)
}))
const prismaFieldSchema = z.object({
id: z.string(),
name: z.string(),
type: z.string(),
isRequired: z.boolean(),
isUnique: z.boolean(),
isArray: z.boolean(),
defaultValue: z.string().optional(),
relation: z.string().optional()
})
const prismaModelSchema = z.object({
id: z.string(),
name: z.string(),
fields: z.array(prismaFieldSchema)
})
const themeSchema = z.object({
primaryColor: z.string(),
secondaryColor: z.string(),
errorColor: z.string(),
warningColor: z.string(),
successColor: z.string(),
fontFamily: z.string(),
fontSize: z.object({
small: z.number(),
medium: z.number(),
large: z.number()
}),
spacing: z.number(),
borderRadius: z.number()
})
const projectFileSchema = z.object({
id: z.string(),
name: z.string(),
path: z.string(),
content: z.string(),
language: z.string()
})
const componentResponseSchema = z.object({ component: componentNodeSchema })
const prismaModelResponseSchema = z.object({ model: prismaModelSchema })
const themeResponseSchema = z.object({ theme: themeSchema })
const suggestFieldsResponseSchema = z.object({ fields: z.array(z.string()) })
const completeAppResponseSchema = z.object({
files: z.array(projectFileSchema),
models: z.array(prismaModelSchema),
theme: themeSchema
})
const parseAndValidateJson = <T,>(
result: string,
schema: z.ZodType<T>,
context: string,
toastMessage: string
): T | null => {
let parsed: unknown
try {
parsed = JSON.parse(result)
} catch (error) {
console.error('AI response JSON parse failed', {
context,
error: error instanceof Error ? error.message : String(error),
rawResponse: result
})
toast.error(toastMessage)
return null
}
const validation = schema.safeParse(parsed)
if (!validation.success) {
console.error('AI response validation failed', {
context,
issues: validation.error.issues,
rawResponse: parsed
})
toast.error(toastMessage)
return null
}
return validation.data
}
export class AIService {
static async generateComponent(description: string): Promise<ComponentNode | null> {
@@ -29,8 +124,13 @@ Make sure to use appropriate Material UI components and props. Keep the structur
)
if (result) {
const parsed = JSON.parse(result)
return parsed.component
const parsed = parseAndValidateJson(
result,
componentResponseSchema,
'generate-component',
'AI component response was invalid. Please retry or clarify your description.'
)
return parsed ? parsed.component : null
}
return null
} catch (error) {
@@ -80,8 +180,13 @@ Return a valid JSON object with a single property "model" containing the model s
)
if (result) {
const parsed = JSON.parse(result)
return parsed.model
const parsed = parseAndValidateJson(
result,
prismaModelResponseSchema,
'generate-model',
'AI model response was invalid. Please retry or describe the model differently.'
)
return parsed ? parsed.model : null
}
return null
} catch (error) {
@@ -172,8 +277,13 @@ Return a valid JSON object with a single property "theme" containing:
)
if (result) {
const parsed = JSON.parse(result)
return parsed.theme
const parsed = parseAndValidateJson(
result,
themeResponseSchema,
'generate-theme',
'AI theme response was invalid. Please retry or specify the theme requirements.'
)
return parsed ? parsed.theme : null
}
return null
} catch (error) {
@@ -202,8 +312,13 @@ Suggest 3-5 common fields that would be useful for this model type. Use camelCas
)
if (result) {
const parsed = JSON.parse(result)
return parsed.fields
const parsed = parseAndValidateJson(
result,
suggestFieldsResponseSchema,
'suggest-fields',
'AI field suggestions were invalid. Please retry with a clearer model name.'
)
return parsed ? parsed.fields : null
}
return null
} catch (error) {
@@ -284,7 +399,12 @@ Create 2-4 essential files for the app structure. Include appropriate Prisma mod
)
if (result) {
return JSON.parse(result)
return parseAndValidateJson(
result,
completeAppResponseSchema,
'generate-app',
'AI app generation response was invalid. Please retry with more detail.'
)
}
return null
} catch (error) {

View File

@@ -1,63 +1,7 @@
import { ComponentType } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { InputOtp } from '@/components/ui/input-otp'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Alert as ShadcnAlert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertDialog } from '@/components/ui/alert-dialog'
import { AspectRatio } from '@/components/ui/aspect-ratio'
import { Carousel } from '@/components/ui/carousel'
import { ChartContainer as Chart } from '@/components/ui/chart'
import { Collapsible } from '@/components/ui/collapsible'
import { Command } from '@/components/ui/command'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { DropdownMenu } from '@/components/ui/dropdown-menu'
import { Menubar } from '@/components/ui/menubar'
import { NavigationMenu } from '@/components/ui/navigation-menu'
import { Table as ShadcnTable, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Skeleton as ShadcnSkeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Pagination } from '@/components/ui/pagination'
import { ResizablePanelGroup as Resizable } from '@/components/ui/resizable'
import { Sheet } from '@/components/ui/sheet'
import { Sidebar } from '@/components/ui/sidebar'
import { Toaster as Sonner } from '@/components/ui/sonner'
import { ToggleGroup } from '@/components/ui/toggle-group'
import { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { CircularProgress, Divider, ProgressBar } from '@/components/atoms'
import * as AtomComponents from '@/components/atoms'
import * as MoleculeComponents from '@/components/molecules'
import * as OrganismComponents from '@/components/organisms'
import {
ComponentBindingDialogWrapper,
ComponentTreeWrapper,
DataSourceEditorDialogWrapper,
GitHubBuildStatusWrapper,
LazyBarChartWrapper,
LazyD3BarChartWrapper,
LazyLineChartWrapper,
SaveIndicatorWrapper,
SeedDataManagerWrapper,
StorageSettingsWrapper,
} from '@/lib/json-ui/wrappers'
import * as PhosphorIcons from '@phosphor-icons/react'
import jsonComponentsRegistry from '../../../json-components-registry.json'
import {
ArrowLeft, ArrowRight, Check, X, Plus, Minus, MagnifyingGlass,
Funnel, Download, Upload, PencilSimple, Trash, Eye, EyeClosed,
CaretUp, CaretDown, CaretLeft, CaretRight,
Gear, User, Bell, Envelope, Calendar, Clock, Star,
Heart, ShareNetwork, LinkSimple, Copy, FloppyDisk, ArrowClockwise, WarningCircle,
Info, Question, House, List as ListIcon, DotsThreeVertical, DotsThree
} from '@phosphor-icons/react'
import { JSONUIShowcase } from '@/components/JSONUIShowcase'
export interface UIComponentRegistry {
[key: string]: ComponentType<any>
@@ -72,6 +16,9 @@ interface JsonRegistryEntry {
wrapperRequired?: boolean
wrapperComponent?: string
wrapperFor?: string
load?: {
export?: string
}
deprecated?: DeprecatedComponentInfo
}
@@ -86,69 +33,117 @@ export interface DeprecatedComponentInfo {
const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry
const getRegistryEntryName = (entry: JsonRegistryEntry): string | undefined =>
entry.export ?? entry.name ?? entry.type
const getRegistryEntryKey = (entry: JsonRegistryEntry): string | undefined =>
entry.name ?? entry.type
const buildRegistryFromNames = (
names: string[],
components: Record<string, ComponentType<any>>
): UIComponentRegistry => {
return names.reduce<UIComponentRegistry>((registry, name) => {
const component = components[name]
if (component) {
registry[name] = component
}
return registry
}, {})
}
const getRegistryEntryExportName = (entry: JsonRegistryEntry): string | undefined =>
entry.load?.export ?? entry.export ?? getRegistryEntryKey(entry)
const jsonRegistryEntries = jsonRegistry.components ?? []
const registryEntryByType = new Map(
jsonRegistryEntries
.map((entry) => {
const entryName = getRegistryEntryName(entry)
return entryName ? [entryName, entry] : null
const entryKey = getRegistryEntryKey(entry)
return entryKey ? [entryKey, entry] : null
})
.filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry))
)
const atomComponentMap = AtomComponents as Record<string, ComponentType<any>>
const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, DeprecatedComponentInfo>>(
(acc, entry) => {
const entryName = getRegistryEntryName(entry)
if (!entryName) {
const entryKey = getRegistryEntryKey(entry)
if (!entryKey) {
return acc
}
if (entry.status === 'deprecated' || entry.deprecated) {
acc[entryName] = entry.deprecated ?? {}
acc[entryKey] = entry.deprecated ?? {}
}
return acc
},
{}
)
const atomRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'atoms')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const moleculeRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'molecules')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const organismRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'organisms')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const shadcnRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'ui')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const wrapperRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'wrappers')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const iconRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'icons')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const buildComponentMapFromExports = (
exports: Record<string, unknown>
): Record<string, ComponentType<any>> => {
return Object.entries(exports).reduce<Record<string, ComponentType<any>>>((acc, [key, value]) => {
if (value && (typeof value === 'function' || typeof value === 'object')) {
acc[key] = value as ComponentType<any>
}
return acc
}, {})
}
const buildComponentMapFromModules = (
modules: Record<string, unknown>
): Record<string, ComponentType<any>> => {
return Object.values(modules).reduce<Record<string, ComponentType<any>>>((acc, moduleExports) => {
if (!moduleExports || typeof moduleExports !== 'object') {
return acc
}
Object.entries(buildComponentMapFromExports(moduleExports as Record<string, unknown>)).forEach(
([key, component]) => {
acc[key] = component
}
)
return acc
}, {})
}
const atomModules = import.meta.glob('@/components/atoms/*.tsx', { eager: true })
const moleculeModules = import.meta.glob('@/components/molecules/*.tsx', { eager: true })
const organismModules = import.meta.glob('@/components/organisms/*.tsx', { eager: true })
const uiModules = import.meta.glob('@/components/ui/**/*.{ts,tsx}', { eager: true })
const wrapperModules = import.meta.glob('@/lib/json-ui/wrappers/*.tsx', { eager: true })
const atomComponentMap = buildComponentMapFromModules(atomModules)
const moleculeComponentMap = buildComponentMapFromModules(moleculeModules)
const organismComponentMap = buildComponentMapFromModules(organismModules)
const uiComponentMap = buildComponentMapFromModules(uiModules)
const wrapperComponentMap = buildComponentMapFromModules(wrapperModules)
const iconComponentMap = buildComponentMapFromExports(PhosphorIcons)
const sourceAliases: Record<string, Record<string, string>> = {
atoms: {
PageHeader: 'BasicPageHeader',
SearchInput: 'BasicSearchInput',
},
molecules: {},
organisms: {},
ui: {
Chart: 'ChartContainer',
Resizable: 'ResizablePanelGroup',
},
wrappers: {},
}
const explicitComponentAllowlist: Record<string, ComponentType<any>> = {
JSONUIShowcase,
}
const buildRegistryFromEntries = (
source: string,
componentMap: Record<string, ComponentType<any>>,
aliases: Record<string, string> = {}
): UIComponentRegistry => {
return jsonRegistryEntries
.filter((entry) => entry.source === source)
.reduce<UIComponentRegistry>((registry, entry) => {
const entryKey = getRegistryEntryKey(entry)
const entryExportName = getRegistryEntryExportName(entry)
if (!entryKey || !entryExportName) {
return registry
}
const aliasName = aliases[entryKey]
const component =
componentMap[entryExportName] ??
(aliasName ? componentMap[aliasName] : undefined) ??
explicitComponentAllowlist[entryKey]
if (component) {
registry[entryKey] = component
}
return registry
}, {})
}
export const primitiveComponents: UIComponentRegistry = {
div: 'div' as any,
@@ -169,173 +164,38 @@ export const primitiveComponents: UIComponentRegistry = {
nav: 'nav' as any,
}
const shadcnComponentMap: Record<string, ComponentType<any>> = {
AlertDialog,
AspectRatio,
Button,
Carousel,
Chart,
Collapsible,
Command,
DropdownMenu,
Input,
InputOtp,
Textarea,
Label,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
Badge,
Separator,
Alert: ShadcnAlert,
AlertDescription,
AlertTitle,
Switch,
Checkbox,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table: ShadcnTable,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Menubar,
NavigationMenu,
Skeleton: ShadcnSkeleton,
Pagination,
Progress,
Resizable,
Sheet,
Sidebar,
Sonner,
ToggleGroup,
Avatar: ShadcnAvatar,
AvatarFallback,
AvatarImage,
}
export const shadcnComponents: UIComponentRegistry = buildRegistryFromNames(
shadcnRegistryNames,
shadcnComponentMap
export const shadcnComponents: UIComponentRegistry = buildRegistryFromEntries(
'ui',
uiComponentMap,
sourceAliases.ui
)
export const atomComponents: UIComponentRegistry = {
...buildRegistryFromNames(
atomRegistryNames,
atomComponentMap
),
DatePicker: atomComponentMap.DatePicker,
FileUpload: atomComponentMap.FileUpload,
CircularProgress,
Divider,
ProgressBar,
DataList: (AtomComponents as Record<string, ComponentType<any>>).DataList,
DataTable: (AtomComponents as Record<string, ComponentType<any>>).DataTable,
ListItem: (AtomComponents as Record<string, ComponentType<any>>).ListItem,
MetricCard: (AtomComponents as Record<string, ComponentType<any>>).MetricCard,
Timeline: (AtomComponents as Record<string, ComponentType<any>>).Timeline,
}
const breadcrumbComponent = AtomComponents.Breadcrumb ?? AtomComponents.BreadcrumbNav
if (breadcrumbComponent) {
atomComponents.Breadcrumb = breadcrumbComponent as ComponentType<any>
}
export const moleculeComponents: UIComponentRegistry = {
...buildRegistryFromNames(
moleculeRegistryNames,
MoleculeComponents as Record<string, ComponentType<any>>
),
AppBranding: (MoleculeComponents as Record<string, ComponentType<any>>).AppBranding,
LabelWithBadge: (MoleculeComponents as Record<string, ComponentType<any>>).LabelWithBadge,
NavigationGroupHeader: (MoleculeComponents as Record<string, ComponentType<any>>).NavigationGroupHeader,
}
export const organismComponents: UIComponentRegistry = buildRegistryFromNames(
organismRegistryNames,
OrganismComponents as Record<string, ComponentType<any>>
export const atomComponents: UIComponentRegistry = buildRegistryFromEntries(
'atoms',
atomComponentMap,
sourceAliases.atoms
)
const wrapperComponentMap: Record<string, ComponentType<any>> = {
ComponentBindingDialogWrapper,
ComponentTreeWrapper,
DataSourceEditorDialogWrapper,
GitHubBuildStatusWrapper,
SaveIndicatorWrapper,
LazyBarChartWrapper,
LazyLineChartWrapper,
LazyD3BarChartWrapper,
SeedDataManagerWrapper,
StorageSettingsWrapper,
}
export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromNames(
wrapperRegistryNames,
wrapperComponentMap
export const moleculeComponents: UIComponentRegistry = buildRegistryFromEntries(
'molecules',
moleculeComponentMap,
sourceAliases.molecules
)
const iconComponentMap: Record<string, ComponentType<any>> = {
ArrowLeft,
ArrowRight,
Check,
X,
Plus,
Minus,
Search: MagnifyingGlass,
Filter: Funnel,
Download,
Upload,
Edit: PencilSimple,
Trash,
Eye,
EyeOff: EyeClosed,
ChevronUp: CaretUp,
ChevronDown: CaretDown,
ChevronLeft: CaretLeft,
ChevronRight: CaretRight,
Settings: Gear,
User,
Bell,
Mail: Envelope,
Calendar,
Clock,
Star,
Heart,
Share: ShareNetwork,
Link: LinkSimple,
Copy,
Save: FloppyDisk,
RefreshCw: ArrowClockwise,
AlertCircle: WarningCircle,
Info,
HelpCircle: Question,
Home: House,
Menu: ListIcon,
MoreVertical: DotsThreeVertical,
MoreHorizontal: DotsThree,
}
export const organismComponents: UIComponentRegistry = buildRegistryFromEntries(
'organisms',
organismComponentMap,
sourceAliases.organisms
)
export const iconComponents: UIComponentRegistry = buildRegistryFromNames(
iconRegistryNames,
export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromEntries(
'wrappers',
wrapperComponentMap,
sourceAliases.wrappers
)
export const iconComponents: UIComponentRegistry = buildRegistryFromEntries(
'icons',
iconComponentMap
)

View File

@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RateLimiter } from './rate-limiter'
describe('RateLimiter.throttle', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(0))
})
afterEach(() => {
vi.useRealTimers()
})
it('returns null when the window is saturated for medium priority', async () => {
const limiter = new RateLimiter({
maxRequests: 1,
windowMs: 1000,
retryDelay: 10,
maxRetries: 2
})
const fn = vi.fn(async () => 'ok')
await limiter.throttle('key', fn, 'medium')
const result = await limiter.throttle('key', fn, 'medium')
expect(result).toBeNull()
expect(fn).toHaveBeenCalledTimes(1)
})
it('bounds high-priority retries without recursion when the window is saturated', async () => {
const limiter = new RateLimiter({
maxRequests: 1,
windowMs: 1000,
retryDelay: 10,
maxRetries: 3
})
const fn = vi.fn(async () => 'ok')
await limiter.throttle('key', fn, 'high')
const spy = vi.spyOn(limiter, 'throttle')
let resolved: unknown = 'pending'
const pending = limiter.throttle('key', fn, 'high').then(result => {
resolved = result
return result
})
await vi.advanceTimersByTimeAsync(30)
await pending
expect(resolved).toBeNull()
expect(fn).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledTimes(1)
})
})

View File

@@ -2,6 +2,7 @@ interface RateLimitConfig {
maxRequests: number
windowMs: number
retryDelay: number
maxRetries?: number
}
interface RequestRecord {
@@ -9,14 +10,15 @@ interface RequestRecord {
count: number
}
class RateLimiter {
export class RateLimiter {
private requests: Map<string, RequestRecord> = new Map()
private config: RateLimitConfig
constructor(config: RateLimitConfig = {
maxRequests: 5,
windowMs: 60000,
retryDelay: 2000
retryDelay: 2000,
maxRetries: 3
}) {
this.config = config
}
@@ -26,49 +28,60 @@ class RateLimiter {
fn: () => Promise<T>,
priority: 'low' | 'medium' | 'high' = 'medium'
): Promise<T | null> {
const now = Date.now()
const record = this.requests.get(key)
const maxRetries = this.config.maxRetries ?? 3
let attempts = 0
if (record) {
const timeElapsed = now - record.timestamp
while (true) {
const now = Date.now()
const record = this.requests.get(key)
let isLimited = false
if (timeElapsed < this.config.windowMs) {
if (record.count >= this.config.maxRequests) {
console.warn(`Rate limit exceeded for ${key}. Try again in ${Math.ceil((this.config.windowMs - timeElapsed) / 1000)}s`)
if (priority === 'high') {
await new Promise(resolve => setTimeout(resolve, this.config.retryDelay))
return this.throttle(key, fn, priority)
if (record) {
const timeElapsed = now - record.timestamp
if (timeElapsed < this.config.windowMs) {
if (record.count >= this.config.maxRequests) {
console.warn(`Rate limit exceeded for ${key}. Try again in ${Math.ceil((this.config.windowMs - timeElapsed) / 1000)}s`)
isLimited = true
} else {
record.count++
}
return null
} else {
this.requests.set(key, { timestamp: now, count: 1 })
}
record.count++
} else {
this.requests.set(key, { timestamp: now, count: 1 })
}
} else {
this.requests.set(key, { timestamp: now, count: 1 })
}
this.cleanup()
this.cleanup()
try {
return await fn()
} catch (error) {
if (error instanceof Error && (
error.message.includes('502') ||
error.message.includes('Bad Gateway') ||
error.message.includes('429') ||
error.message.includes('rate limit')
)) {
console.error(`Gateway error for ${key}:`, error.message)
if (record) {
record.count = this.config.maxRequests
if (isLimited) {
if (priority === 'high' && attempts < maxRetries) {
attempts += 1
await new Promise(resolve => setTimeout(resolve, this.config.retryDelay))
continue
}
return null
}
try {
return await fn()
} catch (error) {
if (error instanceof Error && (
error.message.includes('502') ||
error.message.includes('Bad Gateway') ||
error.message.includes('429') ||
error.message.includes('rate limit')
)) {
console.error(`Gateway error for ${key}:`, error.message)
const updatedRecord = this.requests.get(key)
if (updatedRecord) {
updatedRecord.count = this.config.maxRequests
}
}
throw error
}
throw error
}
}

48
src/store/actionNames.ts Normal file
View File

@@ -0,0 +1,48 @@
const itemSlices = [
'files',
'models',
'components',
'componentTrees',
'workflows',
'lambdas',
] as const
const itemChangeActionNames = ['addItem', 'updateItem', 'removeItem'] as const
export const itemChangeActionTypes = new Set(
itemSlices.flatMap((slice) =>
itemChangeActionNames.map((actionName) => `${slice}/${actionName}`)
)
)
export const persistenceSingleItemActionNames = new Set([
'addItem',
'updateItem',
'saveFile',
'saveModel',
'saveComponent',
'saveComponentTree',
'saveWorkflow',
'saveLambda',
])
export const persistenceBulkActionNames = new Set([
'addItems',
'setItems',
'setFiles',
'setModels',
'setComponents',
'setComponentTrees',
'setWorkflows',
'setLambdas',
])
export const persistenceDeleteActionNames = new Set([
'removeItem',
'deleteFile',
'deleteModel',
'deleteComponent',
'deleteComponentTree',
'deleteWorkflow',
'deleteLambda',
])

View File

@@ -1,6 +1,7 @@
import { Middleware } from '@reduxjs/toolkit'
import { syncToFlaskBulk, checkFlaskConnection } from '../slices/syncSlice'
import { RootState } from '../index'
import { itemChangeActionTypes } from '../actionNames'
interface AutoSyncConfig {
enabled: boolean
@@ -20,6 +21,8 @@ class AutoSyncManager {
private timer: ReturnType<typeof setTimeout> | null = null
private lastSyncTime = 0
private changeCounter = 0
private inFlight = false
private pendingSync = false
private dispatch: any = null
configure(config: Partial<AutoSyncConfig>) {
@@ -68,18 +71,33 @@ class AutoSyncManager {
private async performSync() {
if (!this.dispatch) return
if (this.inFlight) {
this.pendingSync = true
return
}
this.inFlight = true
try {
await this.dispatch(syncToFlaskBulk())
this.lastSyncTime = Date.now()
this.changeCounter = 0
} catch (error) {
console.error('[AutoSync] Sync failed:', error)
} finally {
this.inFlight = false
}
if (this.pendingSync) {
this.pendingSync = false
await this.performSync()
}
}
trackChange() {
this.changeCounter++
if (this.inFlight) {
this.pendingSync = true
}
if (this.changeCounter >= this.config.maxQueueSize && this.config.syncOnChange) {
this.performSync()
@@ -127,28 +145,7 @@ export const createAutoSyncMiddleware = (): Middleware => {
})
}
const changeActions = [
'files/addItem',
'files/updateItem',
'files/removeItem',
'models/addItem',
'models/updateItem',
'models/removeItem',
'components/addItem',
'components/updateItem',
'components/removeItem',
'componentTrees/addItem',
'componentTrees/updateItem',
'componentTrees/removeItem',
'workflows/addItem',
'workflows/updateItem',
'workflows/removeItem',
'lambdas/addItem',
'lambdas/updateItem',
'lambdas/removeItem',
]
if (changeActions.includes(action.type)) {
if (itemChangeActionTypes.has(action.type)) {
autoSyncManager.trackChange()
}

View File

@@ -37,6 +37,7 @@ export async function syncToFlask(
}
} catch (error) {
console.error('[FlaskSync] Error syncing to Flask:', error)
throw error
}
}

View File

@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PersistenceQueue } from './persistenceMiddleware'
const { putMock, deleteMock, syncMock } = vi.hoisted(() => ({
putMock: vi.fn<[string, unknown], Promise<void>>(),
deleteMock: vi.fn<[string, string], Promise<void>>(),
syncMock: vi.fn<[string, string, unknown, string], Promise<void>>()
}))
vi.mock('@/lib/db', () => ({
db: {
put: putMock,
delete: deleteMock
}
}))
vi.mock('./flaskSync', () => ({
syncToFlask: syncMock
}))
const nextTick = () => new Promise(resolve => setTimeout(resolve, 0))
const waitFor = async (assertion: () => void, attempts = 5) => {
let lastError: unknown
for (let i = 0; i < attempts; i += 1) {
await nextTick()
try {
assertion()
return
} catch (error) {
lastError = error
}
}
throw lastError
}
const createControlledPromise = () => {
let resolve: () => void
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise
})
return {
promise,
resolve: resolve!
}
}
describe('PersistenceQueue', () => {
beforeEach(() => {
putMock.mockReset()
deleteMock.mockReset()
syncMock.mockReset()
syncMock.mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
it('flushes new operations enqueued while processing after the first batch finishes', async () => {
const queue = new PersistenceQueue()
const controlled = createControlledPromise()
putMock
.mockReturnValueOnce(controlled.promise)
.mockResolvedValueOnce(undefined)
queue.enqueue({
type: 'put',
storeName: 'files',
key: 'file-1',
value: { id: 'file-1' },
timestamp: Date.now(),
}, 0)
await waitFor(() => {
expect(putMock).toHaveBeenCalledTimes(1)
})
queue.enqueue({
type: 'put',
storeName: 'files',
key: 'file-2',
value: { id: 'file-2' },
timestamp: Date.now(),
}, 0)
await nextTick()
expect(putMock).toHaveBeenCalledTimes(1)
controlled.resolve()
await waitFor(() => {
expect(putMock).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -2,6 +2,11 @@ import { Middleware } from '@reduxjs/toolkit'
import { db } from '@/lib/db'
import { syncToFlask } from './flaskSync'
import { RootState } from '../index'
import {
persistenceBulkActionNames,
persistenceDeleteActionNames,
persistenceSingleItemActionNames,
} from '../actionNames'
interface PersistenceConfig {
storeName: string
@@ -38,10 +43,23 @@ type PendingOperation = {
timestamp: number
}
type FailedSyncOperation = PendingOperation & {
attempt: number
lastError: string
nextRetryAt: number
}
const MAX_SYNC_RETRIES = 5
const BASE_SYNC_RETRY_DELAY_MS = 1000
const MAX_SYNC_RETRY_DELAY_MS = 30000
class PersistenceQueue {
private queue: Map<string, PendingOperation> = new Map()
private processing = false
private pendingFlush = false
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private failedSyncs: Map<string, FailedSyncOperation> = new Map()
private retryTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
enqueue(operation: PendingOperation, debounceMs: number) {
const opKey = `${operation.storeName}:${operation.key}`
@@ -62,7 +80,12 @@ class PersistenceQueue {
}
async processQueue() {
if (this.processing || this.queue.size === 0) return
if (this.processing) {
this.pendingFlush = true
return
}
if (this.queue.size === 0) return
this.processing = true
@@ -75,14 +98,10 @@ class PersistenceQueue {
try {
if (op.type === 'put') {
await db.put(op.storeName as any, op.value)
if (sliceToPersistenceMap[op.storeName]?.syncToFlask) {
await syncToFlask(op.storeName, op.key, op.value, 'put')
}
await this.syncToFlaskWithRetry(op, op.value)
} else if (op.type === 'delete') {
await db.delete(op.storeName as any, op.key)
if (sliceToPersistenceMap[op.storeName]?.syncToFlask) {
await syncToFlask(op.storeName, op.key, null, 'delete')
}
await this.syncToFlaskWithRetry(op, null)
}
} catch (error) {
console.error(`[PersistenceMiddleware] Failed to persist ${op.type} for ${op.storeName}:${op.key}`, error)
@@ -97,6 +116,23 @@ class PersistenceQueue {
}
} finally {
this.processing = false
const needsFlush = this.pendingFlush || this.queue.size > 0
this.pendingFlush = false
if (needsFlush) {
await this.processQueue()
}
}
}
getFailedSyncs() {
return Array.from(this.failedSyncs.values()).sort((a, b) => a.nextRetryAt - b.nextRetryAt)
}
async retryFailedSyncs() {
for (const [opKey, failure] of this.failedSyncs.entries()) {
if (failure.nextRetryAt <= Date.now()) {
await this.retryFailedSync(opKey)
}
}
}
@@ -107,6 +143,89 @@ class PersistenceQueue {
this.debounceTimers.clear()
await this.processQueue()
}
private async syncToFlaskWithRetry(op: PendingOperation, value: any) {
if (!sliceToPersistenceMap[op.storeName]?.syncToFlask) return
try {
await syncToFlask(op.storeName, op.key, value, op.type)
this.clearSyncFailure(op)
} catch (error) {
this.recordSyncFailure(op, error)
console.warn(
`[PersistenceMiddleware] Flask sync failed for ${op.storeName}:${op.key} (${op.type}); queued for retry.`,
error
)
}
}
private recordSyncFailure(op: PendingOperation, error: unknown) {
const opKey = this.getFailureKey(op)
const previous = this.failedSyncs.get(opKey)
const attempt = previous ? previous.attempt + 1 : 1
const delayMs = this.getRetryDelayMs(attempt)
const nextRetryAt = Date.now() + delayMs
const lastError = error instanceof Error ? error.message : String(error)
this.failedSyncs.set(opKey, {
...op,
attempt,
lastError,
nextRetryAt,
})
const existingTimer = this.retryTimers.get(opKey)
if (existingTimer) {
clearTimeout(existingTimer)
}
if (attempt <= MAX_SYNC_RETRIES) {
const timer = setTimeout(() => {
this.retryTimers.delete(opKey)
void this.retryFailedSync(opKey)
}, delayMs)
this.retryTimers.set(opKey, timer)
}
}
private clearSyncFailure(op: PendingOperation) {
const opKey = this.getFailureKey(op)
const timer = this.retryTimers.get(opKey)
if (timer) {
clearTimeout(timer)
this.retryTimers.delete(opKey)
}
this.failedSyncs.delete(opKey)
}
private async retryFailedSync(opKey: string) {
const failure = this.failedSyncs.get(opKey)
if (!failure) return
if (failure.attempt > MAX_SYNC_RETRIES) {
return
}
this.enqueue(
{
type: failure.type,
storeName: failure.storeName,
key: failure.key,
value: failure.value,
timestamp: Date.now(),
},
0
)
}
private getRetryDelayMs(attempt: number) {
const delay = BASE_SYNC_RETRY_DELAY_MS * Math.pow(2, attempt - 1)
return Math.min(delay, MAX_SYNC_RETRY_DELAY_MS)
}
private getFailureKey(op: PendingOperation) {
return `${op.storeName}:${op.key}:${op.type}`
}
}
const persistenceQueue = new PersistenceQueue()
@@ -128,10 +247,7 @@ export const createPersistenceMiddleware = (): Middleware => {
if (!sliceState) return result
try {
if (actionName === 'addItem' || actionName === 'updateItem' || actionName === 'saveFile' ||
actionName === 'saveModel' || actionName === 'saveComponent' || actionName === 'saveComponentTree' ||
actionName === 'saveWorkflow' || actionName === 'saveLambda') {
if (persistenceSingleItemActionNames.has(actionName)) {
const item = action.payload
if (item && item.id) {
persistenceQueue.enqueue({
@@ -144,10 +260,7 @@ export const createPersistenceMiddleware = (): Middleware => {
}
}
if (actionName === 'addItems' || actionName === 'setItems' || actionName === 'setFiles' ||
actionName === 'setModels' || actionName === 'setComponents' || actionName === 'setComponentTrees' ||
actionName === 'setWorkflows' || actionName === 'setLambdas') {
if (persistenceBulkActionNames.has(actionName)) {
const items = action.payload
if (Array.isArray(items)) {
items.forEach((item: any) => {
@@ -164,10 +277,7 @@ export const createPersistenceMiddleware = (): Middleware => {
}
}
if (actionName === 'removeItem' || actionName === 'deleteFile' || actionName === 'deleteModel' ||
actionName === 'deleteComponent' || actionName === 'deleteComponentTree' ||
actionName === 'deleteWorkflow' || actionName === 'deleteLambda') {
if (persistenceDeleteActionNames.has(actionName)) {
const itemId = typeof action.payload === 'string' ? action.payload : action.payload?.id
if (itemId) {
persistenceQueue.enqueue({
@@ -208,6 +318,8 @@ export const createPersistenceMiddleware = (): Middleware => {
}
export const flushPersistence = () => persistenceQueue.flush()
export const getFailedSyncOperations = () => persistenceQueue.getFailedSyncs()
export const retryFailedSyncOperations = () => persistenceQueue.retryFailedSyncs()
export const configurePersistence = (sliceName: string, config: Partial<PersistenceConfig>) => {
if (sliceToPersistenceMap[sliceName]) {

View File

@@ -107,21 +107,18 @@ export const createSyncMonitorMiddleware = (): Middleware => {
const isFulfilledAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/fulfilled`)
const isRejectedAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/rejected`)
if (isPendingAction) {
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
syncMonitor.startOperation(operationId)
if (isPendingAction && action.meta?.requestId) {
syncMonitor.startOperation(action.meta.requestId)
}
const result = next(action)
if (isFulfilledAction) {
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
syncMonitor.endOperation(operationId, true)
if (isFulfilledAction && action.meta?.requestId) {
syncMonitor.endOperation(action.meta.requestId, true)
}
if (isRejectedAction) {
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
syncMonitor.endOperation(operationId, false)
if (isRejectedAction && action.meta?.requestId) {
syncMonitor.endOperation(action.meta.requestId, false)
}
return result

View File

@@ -9,6 +9,8 @@ import { db } from '@/lib/db'
export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'
const SYNCABLE_STORES = new Set(['files', 'models', 'components', 'workflows'])
interface SyncState {
status: SyncStatus
lastSyncedAt: number | null
@@ -68,37 +70,50 @@ export const syncFromFlaskBulk = createAsyncThunk(
async (_, { rejectWithValue }) => {
try {
const data = await fetchAllFromFlask()
const validStoreNames = ['files', 'models', 'components', 'workflows'] as const
const remoteIdsByStore = new Map<(typeof validStoreNames)[number], Set<string>>()
const allowedStoreNames = new Set(['files', 'models', 'components', 'workflows'])
const serverIdsByStore = {
files: new Set<string>(),
models: new Set<string>(),
components: new Set<string>(),
workflows: new Set<string>(),
}
for (const [key, value] of Object.entries(data)) {
const keyParts = key.split(':')
if (keyParts.length !== 2) continue
const [storeName, id] = keyParts
if (!validStoreNames.includes(storeName as (typeof validStoreNames)[number])) {
const [storeName, id] = key.split(':')
if (SYNCABLE_STORES.has(storeName)) {
await db.put(storeName as any, value)
if (typeof key !== 'string') {
continue
}
if (!id) continue
const parts = key.split(':')
if (parts.length !== 2) {
continue
}
const [storeName, id] = parts
if (!storeName || !id) {
continue
}
if (!allowedStoreNames.has(storeName)) {
continue
}
serverIdsByStore[storeName as keyof typeof serverIdsByStore].add(id)
await db.put(storeName as any, value)
const ids = remoteIdsByStore.get(storeName as any) ?? new Set<string>()
ids.add(id)
remoteIdsByStore.set(storeName as any, ids)
}
for (const storeName of validStoreNames) {
const localItems = await db.getAll(storeName)
const remoteIds = remoteIdsByStore.get(storeName) ?? new Set<string>()
for (const item of localItems) {
if (!item?.id) continue
if (!remoteIds.has(item.id)) {
await db.delete(storeName, item.id)
// Explicit merge strategy: server is source of truth; delete local records missing from server response.
const storeNames = Array.from(allowedStoreNames)
for (const storeName of storeNames) {
const localRecords = await db.getAll(storeName as any)
for (const record of localRecords) {
const recordId = record?.id
const recordIdString = recordId == null ? '' : String(recordId)
if (!serverIdsByStore[storeName as keyof typeof serverIdsByStore].has(recordIdString)) {
await db.delete(storeName as any, recordId)
}
}
}