diff --git a/apps/ottabase-template-app-tanstack/ottabase/config.migrations.ts b/apps/ottabase-template-app-tanstack/ottabase/config.migrations.ts index 78a69eb93..aaae8d23d 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/config.migrations.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/config.migrations.ts @@ -38,6 +38,7 @@ import { seriesTable, } from '@ottabase/ottablog'; import type { Migration } from '@ottabase/ottaorm'; +import { portJobsTable } from '@ottabase/ottaport'; import { referralTrackingTable } from '@ottabase/referrals'; import { shortlinksTable } from '@ottabase/shortlinks'; import { getOttabaseConfig } from './config.loader'; @@ -68,6 +69,10 @@ const PACKAGE_REGISTRY = { tables: { referralTrackingTable }, migrations: [] as Migration[], }, + ottaport: { + tables: { portJobsTable }, + migrations: [] as Migration[], + }, brandEngine: { tables: { brandKitsTable, @@ -88,11 +93,18 @@ const PACKAGE_REGISTRY = { */ export type MigrationPackageName = keyof typeof PACKAGE_REGISTRY; +/** Core packages are always enabled regardless of ottabase.config */ +const CORE_PACKAGES: MigrationPackageName[] = ['brandEngine', 'ottaport']; + +function isCorePackage(name: string): boolean { + return CORE_PACKAGES.includes(name as MigrationPackageName); +} + export function getMigrationConfig(env?: Record): Record { const config = getOttabaseConfig(env); const result: Record = {}; for (const pkg of Object.keys(PACKAGE_REGISTRY) as MigrationPackageName[]) { - result[pkg] = pkg === 'brandEngine' ? true : (config.packages[pkg as BuiltInPackageName] ?? false); + result[pkg] = isCorePackage(pkg) ? true : (config.packages[pkg as BuiltInPackageName] ?? false); } return result as Record; } @@ -109,9 +121,9 @@ export function getEnabledPackageTables(env?: Record) { const config = getOttabaseConfig(env); const tables: Record = {}; - // Built-in packages (brandEngine is core — always included) + // Built-in packages (core packages are always included) for (const [pkgName, pkgConfig] of Object.entries(PACKAGE_REGISTRY)) { - if (pkgName === 'brandEngine' || config.packages[pkgName as BuiltInPackageName]) { + if (isCorePackage(pkgName) || config.packages[pkgName as BuiltInPackageName]) { Object.assign(tables, pkgConfig.tables); } } @@ -134,9 +146,9 @@ export function getEnabledPackageMigrations(env?: Record): Migr const config = getOttabaseConfig(env); const migrations: Migration[] = []; - // Built-in packages (brandEngine is core — always included) + // Built-in packages (core packages are always included) for (const [pkgName, pkgConfig] of Object.entries(PACKAGE_REGISTRY)) { - if ((pkgName === 'brandEngine' || config.packages[pkgName as BuiltInPackageName]) && pkgConfig.migrations) { + if ((isCorePackage(pkgName) || config.packages[pkgName as BuiltInPackageName]) && pkgConfig.migrations) { migrations.push(...pkgConfig.migrations); } } diff --git a/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts b/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts index aa2aefc8d..63877c855 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts @@ -33,6 +33,7 @@ import { postsTable, seriesTable, } from '@ottabase/ottablog'; +import { portJobsTable } from '@ottabase/ottaport'; import { referralTrackingTable } from '@ottabase/referrals'; import { shortlinksTable } from '@ottabase/shortlinks'; @@ -55,6 +56,7 @@ export { postVersionsTable, postsTable, seriesTable, + portJobsTable, referralTrackingTable, shortlinksTable, }; diff --git a/apps/ottabase-template-app-tanstack/package.json b/apps/ottabase-template-app-tanstack/package.json index 853521291..a13849d00 100644 --- a/apps/ottabase-template-app-tanstack/package.json +++ b/apps/ottabase-template-app-tanstack/package.json @@ -48,6 +48,7 @@ "@ottabase/ottalayout": "workspace:*", "@ottabase/ottamenu": "workspace:*", "@ottabase/ottaorm": "workspace:*", + "@ottabase/ottaport": "workspace:*", "@ottabase/ottarenderer": "workspace:*", "@ottabase/ottaselect": "workspace:*", "@ottabase/ottaupload": "workspace:*", diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx index c91716a0c..6a7a0b4fe 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx @@ -4,6 +4,7 @@ import { Link } from '@tanstack/react-router'; import { IconMenu2 } from '@tabler/icons-react'; import { Activity, + ArrowLeftRight, Bell, Building2, Clock, @@ -119,6 +120,13 @@ export function AdminIndexPage() { icon: UserPlus, disabled: false, }, + { + title: 'Data Import/Export', + description: 'Import CSV/JSON/TSV data into models or export with filters. Track all operations.', + href: '/admin/ottaport', + icon: ArrowLeftRight, + disabled: false, + }, { title: 'Queue Management', description: 'Monitor background job queues, view processing stats, and manage failed jobs.', diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/AdminOttaportPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/AdminOttaportPage.tsx new file mode 100644 index 000000000..5eddde18b --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/AdminOttaportPage.tsx @@ -0,0 +1,71 @@ +// ============================================================ +// OttaPort Admin — Data Import/Export Hub +// ============================================================ + +import { useState } from 'react'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@ottabase/ui-shadcn'; +import { ArrowDownToLine, ArrowUpFromLine, History } from 'lucide-react'; +import { OttaportImportWizard } from './OttaportImportWizard'; +import { OttaportExportPage } from './OttaportExportPage'; +import { OttaportHistoryPage } from './OttaportHistoryPage'; + +export function AdminOttaportPage() { + const [activeTab, setActiveTab] = useState('import'); + + return ( +
+
+
+

Data Import/Export

+

+ Import data from CSV/JSON/TSV files or export model data with filters +

+
+ + OttaPort + +
+ + + + + + Import + + + + Export + + + + History + + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/OttaportExportPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/OttaportExportPage.tsx new file mode 100644 index 000000000..1d74adbce --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/OttaportExportPage.tsx @@ -0,0 +1,310 @@ +// ============================================================ +// OttaPort Export Page +// ============================================================ + +import { useState } from 'react'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@ottabase/ui-shadcn'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { ArrowDownToLine, Download, Loader2, Search } from 'lucide-react'; + +interface ModelInfo { + entity: string; + displayName: string; + fields: Array<{ + name: string; + type: string; + label: string; + filterable: boolean; + searchable: boolean; + }>; +} + +export function OttaportExportPage() { + const [selectedModel, setSelectedModel] = useState(''); + const [format, setFormat] = useState('csv'); + const [search, setSearch] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [page, setPage] = useState(1); + const perPage = 20; + + // Fetch models + const { data: modelsData } = useQuery({ + queryKey: ['ottaport', 'models'], + queryFn: async () => { + const res = await fetch('/api/admin/ottaport/models'); + if (!res.ok) throw new Error('Failed to fetch models'); + return res.json(); + }, + }); + + const models: ModelInfo[] = modelsData?.data || []; + const selectedModelInfo = models.find((m) => m.entity === selectedModel); + + // Preview data + const { data: previewData, isLoading: previewLoading } = useQuery({ + queryKey: ['ottaport', 'preview', selectedModel, page, search], + queryFn: async () => { + const params = new URLSearchParams({ + model: selectedModel, + page: String(page), + perPage: String(perPage), + }); + if (search) params.set('search', search); + + const res = await fetch(`/api/admin/ottaport/export/preview?${params}`); + if (!res.ok) throw new Error('Failed to fetch preview'); + return res.json(); + }, + enabled: !!selectedModel, + }); + + const previewResult = previewData?.data; + const records = previewResult?.data || []; + const totalRecords = previewResult?.total || 0; + const totalPages = previewResult?.lastPage || 1; + + // Export mutation + const exportMutation = useMutation({ + mutationFn: async () => { + const body: Record = { + modelEntity: selectedModel, + format, + }; + if (search) body.search = search; + if (dateFrom || dateTo) { + body.dateRange = { + field: 'createdAt', + ...(dateFrom ? { from: dateFrom } : {}), + ...(dateTo ? { to: dateTo } : {}), + }; + } + + const res = await fetch('/api/admin/ottaport/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Export failed'); + } + + // Download the file + const blob = await res.blob(); + const filename = + res.headers.get('Content-Disposition')?.match(/filename="(.+)"/)?.[1] || + `${selectedModel}-export.${format}`; + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, + }); + + // Get display columns from first record + const columns = records.length > 0 ? Object.keys(records[0]).slice(0, 8) : []; + + return ( +
+ + + + + Export Data + + + Select a model, apply optional filters, preview the data, and download + + + +
+
+ + +
+
+ + +
+
+ +
+ + { + setSearch(e.target.value); + setPage(1); + }} + className="pl-8" + /> +
+
+
+ +
+
+ + setDateFrom(e.target.value)} + className="mt-1" + /> +
+
+ + setDateTo(e.target.value)} + className="mt-1" + /> +
+
+
+
+ + {/* Data Preview */} + {selectedModel && ( + + +
+ Preview: {selectedModelInfo?.displayName} + {totalRecords} total records +
+ +
+ + {previewLoading ? ( +
+ +
+ ) : records.length === 0 ? ( +
No records found
+ ) : ( +
+
+ + + + {columns.map((col) => ( + + ))} + + + + {records.map((record: Record, i: number) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col} +
+ {record[col] !== null && record[col] !== undefined + ? String(record[col]) + : '—'} +
+
+ + {/* Pagination */} +
+ + Page {page} of {totalPages} + +
+ + +
+
+
+ )} +
+
+ )} +
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/OttaportHistoryPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/OttaportHistoryPage.tsx new file mode 100644 index 000000000..7358b3426 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/OttaportHistoryPage.tsx @@ -0,0 +1,207 @@ +// ============================================================ +// OttaPort History Page +// ============================================================ + +import { useState } from 'react'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@ottabase/ui-shadcn'; +import { useQuery } from '@tanstack/react-query'; +import { ArrowDownToLine, ArrowUpFromLine, Clock, Loader2 } from 'lucide-react'; + +interface PortJobItem { + id: string; + direction: string; + modelEntity: string; + status: string; + format: string; + filename: string; + totalRows: number; + totalCreated: number; + totalUpdated: number; + totalFailed: number; + durationMs: number; + userEmail: string; + createdAt: string | number; +} + +function formatDate(value: string | number | null): string { + if (!value) return '—'; + const date = new Date(typeof value === 'number' ? value : Date.parse(value)); + if (isNaN(date.getTime())) return '—'; + return date.toLocaleString(); +} + +function getStatusBadge(status: string) { + switch (status) { + case 'completed': + return ( + Completed + ); + case 'partial': + return ( + Partial + ); + case 'failed': + return Failed; + case 'processing': + return Processing; + default: + return {status}; + } +} + +export function OttaportHistoryPage() { + const [directionFilter, setDirectionFilter] = useState('all'); + const [page, setPage] = useState(1); + const perPage = 20; + + const { data: jobsData, isLoading } = useQuery({ + queryKey: ['ottaport', 'jobs', directionFilter, page], + queryFn: async () => { + const params = new URLSearchParams({ + page: String(page), + perPage: String(perPage), + }); + if (directionFilter !== 'all') params.set('direction', directionFilter); + + const res = await fetch(`/api/admin/ottaport/jobs?${params}`); + if (!res.ok) throw new Error('Failed to fetch jobs'); + return res.json(); + }, + }); + + const jobsResult = jobsData?.data; + const jobs: PortJobItem[] = jobsResult?.data || []; + const totalPages = jobsResult?.lastPage || 1; + + return ( + + +
+ + + Job History + + Import and export operation history +
+ +
+ + {isLoading ? ( +
+ +
+ ) : jobs.length === 0 ? ( +
No import/export jobs found
+ ) : ( +
+
+ + + + + + + + + + + + + + + + + {jobs.map((job) => ( + + + + + + + + + + + + + ))} + +
DirectionModelStatusFileRowsCreatedUpdatedFailedUserDate
+ {job.direction === 'import' ? ( +
+ Import +
+ ) : ( +
+ Export +
+ )} +
{job.modelEntity}{getStatusBadge(job.status)}{job.filename || '—'}{job.totalRows} + {job.totalCreated || 0} + + {job.totalUpdated || 0} + + {job.totalFailed || 0} + {job.userEmail || '—'} + {formatDate(job.createdAt)} +
+
+ + {/* Pagination */} +
+ + Page {page} of {totalPages} + +
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/OttaportImportWizard.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/OttaportImportWizard.tsx new file mode 100644 index 000000000..0c925881d --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/OttaportImportWizard.tsx @@ -0,0 +1,646 @@ +// ============================================================ +// OttaPort Import Wizard +// ============================================================ +// Step 1: Upload file → Step 2: Select model & map fields → +// Step 3: Validate & preview → Step 4: Execute & show results +// ============================================================ + +import { useCallback, useMemo, useState } from 'react'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Label, + Progress, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, +} from '@ottabase/ui-shadcn'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { AlertCircle, ArrowLeft, ArrowRight, CheckCircle2, FileUp, Loader2, Upload, XCircle } from 'lucide-react'; + +interface ModelInfo { + entity: string; + displayName: string; + fields: Array<{ + name: string; + type: string; + label: string; + required: boolean; + editable: boolean; + }>; + primaryKey: string; +} + +interface ParseResult { + headers: string[]; + preview: Record[]; + totalRows: number; + format: string; + filename: string; + r2Key?: string; +} + +interface FieldMapping { + sourceColumn: string; + targetField: string; +} + +interface ImportResult { + status: string; + totalRows: number; + totalCreated: number; + totalUpdated: number; + totalFailed: number; + totalSkipped: number; + errors: Array<{ row: number; field?: string; message: string }>; + durationMs: number; +} + +type WizardStep = 'upload' | 'mapping' | 'preview' | 'result'; + +export function OttaportImportWizard() { + const [step, setStep] = useState('upload'); + const [file, setFile] = useState(null); + const [format, setFormat] = useState('csv'); + const [saveToR2, setSaveToR2] = useState(false); + const [parseResult, setParseResult] = useState(null); + const [selectedModel, setSelectedModel] = useState(''); + const [uniqueField, setUniqueField] = useState(''); + const [fieldMappings, setFieldMappings] = useState([]); + const [importResult, setImportResult] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + // Fetch available models + const { data: modelsData } = useQuery({ + queryKey: ['ottaport', 'models'], + queryFn: async () => { + const res = await fetch('/api/admin/ottaport/models'); + if (!res.ok) throw new Error('Failed to fetch models'); + return res.json(); + }, + }); + + const models: ModelInfo[] = modelsData?.data || []; + + const selectedModelInfo = useMemo(() => models.find((m) => m.entity === selectedModel), [models, selectedModel]); + + // Parse file mutation + const parseMutation = useMutation({ + mutationFn: async (uploadFile: File) => { + const formData = new FormData(); + formData.append('file', uploadFile); + formData.append('format', format); + formData.append('saveToR2', String(saveToR2)); + + const res = await fetch('/api/admin/ottaport/import/parse', { + method: 'POST', + body: formData, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Failed to parse file'); + } + return res.json(); + }, + onSuccess: (data) => { + setParseResult(data.data); + setStep('mapping'); + }, + }); + + // Import mutation + const importMutation = useMutation({ + mutationFn: async () => { + if (!file) throw new Error('No file selected'); + + const config = { + modelEntity: selectedModel, + fieldMappings, + uniqueField, + batchSize: 50, + saveToR2, + }; + + const formData = new FormData(); + formData.append('file', file); + formData.append('config', JSON.stringify(config)); + formData.append('format', format); + + const res = await fetch('/api/admin/ottaport/import/execute', { + method: 'POST', + body: formData, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Import failed'); + } + return res.json(); + }, + onSuccess: (data) => { + setImportResult(data.data); + setStep('result'); + }, + }); + + // Auto-map fields when model is selected + const handleModelSelect = useCallback( + (entity: string) => { + setSelectedModel(entity); + const model = models.find((m) => m.entity === entity); + if (!model || !parseResult) return; + + // Auto-map by matching header names to field names (case-insensitive) + const autoMappings: FieldMapping[] = []; + for (const header of parseResult.headers) { + const headerLower = header.toLowerCase().replace(/[_\s-]/g, ''); + const match = model.fields.find((f) => { + const fieldLower = f.name.toLowerCase().replace(/[_\s-]/g, ''); + const labelLower = f.label.toLowerCase().replace(/[_\s-]/g, ''); + return fieldLower === headerLower || labelLower === headerLower; + }); + if (match) { + autoMappings.push({ sourceColumn: header, targetField: match.name }); + } + } + setFieldMappings(autoMappings); + }, + [models, parseResult], + ); + + const updateMapping = (sourceColumn: string, targetField: string) => { + setFieldMappings((prev) => { + const existing = prev.findIndex((m) => m.sourceColumn === sourceColumn); + if (targetField === '__skip__') { + return prev.filter((m) => m.sourceColumn !== sourceColumn); + } + if (existing >= 0) { + const updated = [...prev]; + updated[existing] = { sourceColumn, targetField }; + return updated; + } + return [...prev, { sourceColumn, targetField }]; + }); + }; + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile) { + setFile(droppedFile); + // Auto-detect format + const ext = droppedFile.name.split('.').pop()?.toLowerCase(); + if (ext === 'tsv' || ext === 'tab') setFormat('tsv'); + else if (ext === 'json') setFormat('json'); + else setFormat('csv'); + } + }, []); + + const handleFileInput = (e: React.ChangeEvent) => { + const selected = e.target.files?.[0]; + if (selected) { + setFile(selected); + const ext = selected.name.split('.').pop()?.toLowerCase(); + if (ext === 'tsv' || ext === 'tab') setFormat('tsv'); + else if (ext === 'json') setFormat('json'); + else setFormat('csv'); + } + }; + + const resetWizard = () => { + setStep('upload'); + setFile(null); + setParseResult(null); + setSelectedModel(''); + setUniqueField(''); + setFieldMappings([]); + setImportResult(null); + }; + + // ── Step 1: Upload ── + const renderUploadStep = () => ( + + + + + Upload File + + Upload a CSV, JSON, or TSV file to import data into any OttaORM model + + +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + > + +

Drag and drop your file here, or click to browse

+

Supports CSV, JSON, and TSV files

+ +
+ + {file && ( +
+
+ + {file.name} + + {(file.size / 1024).toFixed(1)} KB + +
+ +
+ )} + +
+
+ + +
+
+ + +
+
+ + + + {parseMutation.isError && ( +
+ + {parseMutation.error?.message} +
+ )} +
+
+ ); + + // ── Step 2: Field Mapping ── + const renderMappingStep = () => ( + + + Field Mapping + + {parseResult?.totalRows} rows detected in {parseResult?.filename}. Select a target + model and map source columns to model fields. + + + +
+
+ + +
+
+ + +
+
+ + {selectedModel && parseResult && ( +
+ +
+
+ Source Column + + Target Field +
+ {parseResult.headers.map((header) => { + const mapping = fieldMappings.find((m) => m.sourceColumn === header); + return ( +
+ {header} + + +
+ ); + })} +
+
+ )} + + {/* Preview table */} + {parseResult && parseResult.preview.length > 0 && ( +
+ +
+ + + + {parseResult.headers.map((h) => ( + + ))} + + + + {parseResult.preview.map((row, i) => ( + + {parseResult.headers.map((h) => ( + + ))} + + ))} + +
+ {h} +
+ {row[h] || '—'} +
+
+
+ )} + +
+ + +
+
+
+ ); + + // ── Step 3: Review & Execute ── + const renderPreviewStep = () => ( + + + Review & Import + Review the configuration before executing the import. + + +
+
+ File: {parseResult?.filename} +
+
+ Total Rows:{' '} + {parseResult?.totalRows} +
+
+ Target Model:{' '} + {selectedModelInfo?.displayName} +
+
+ Unique Field: {uniqueField} +
+
+ Mapped Fields:{' '} + {fieldMappings.length} +
+
+ Format:{' '} + {format.toUpperCase()} +
+
+ +
+ +
+ {fieldMappings.map((m) => ( + + {m.sourceColumn} → {m.targetField} + + ))} +
+
+ + {importMutation.isPending && ( +
+
+ + Importing data... +
+ +
+ )} + + {importMutation.isError && ( +
+ + {importMutation.error?.message} +
+ )} + +
+ + +
+
+
+ ); + + // ── Step 4: Results ── + const renderResultStep = () => { + if (!importResult) return null; + const isSuccess = importResult.status === 'completed'; + const isPartial = importResult.status === 'partial'; + + return ( + + + + {isSuccess ? ( + + ) : isPartial ? ( + + ) : ( + + )} + Import {isSuccess ? 'Completed' : isPartial ? 'Partially Completed' : 'Failed'} + + Processed in {(importResult.durationMs / 1000).toFixed(1)}s + + +
+
+
{importResult.totalRows}
+
Total Rows
+
+
+
+ {importResult.totalCreated} +
+
Created
+
+
+
+ {importResult.totalUpdated} +
+
Updated
+
+
+
+ {importResult.totalFailed} +
+
Failed
+
+
+ + {importResult.errors.length > 0 && ( +
+ +
+ {importResult.errors.slice(0, 50).map((err, i) => ( +
+ + Row {err.row} + + {err.message} +
+ ))} + {importResult.errors.length > 50 && ( +
+ ...and {importResult.errors.length - 50} more errors +
+ )} +
+
+ )} + + +
+
+ ); + }; + + return ( +
+ {/* Step indicator */} +
+ {(['upload', 'mapping', 'preview', 'result'] as WizardStep[]).map((s, i) => ( +
+ {i > 0 && } + + {i + 1}. {s.charAt(0).toUpperCase() + s.slice(1)} + +
+ ))} +
+ + {step === 'upload' && renderUploadStep()} + {step === 'mapping' && renderMappingStep()} + {step === 'preview' && renderPreviewStep()} + {step === 'result' && renderResultStep()} +
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/index.ts b/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/index.ts new file mode 100644 index 000000000..87d8767a6 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/ottaport/index.ts @@ -0,0 +1 @@ +export { AdminOttaportPage } from './AdminOttaportPage'; diff --git a/apps/ottabase-template-app-tanstack/src/router.tsx b/apps/ottabase-template-app-tanstack/src/router.tsx index d8ef7b155..e27e96011 100644 --- a/apps/ottabase-template-app-tanstack/src/router.tsx +++ b/apps/ottabase-template-app-tanstack/src/router.tsx @@ -929,6 +929,17 @@ const adminKillSwitchesRoute = new Route({ ), }); +// Admin OttaPort (Import/Export) route +const adminOttaportRoute = new Route({ + getParentRoute: () => rootRoute, + path: '/admin/ottaport', + component: lazyRouteComponent(() => + import('@/pages/admin/ottaport').then((m) => ({ + default: () => renderAdminRoute(), + })), + ), +}); + demoLayoutRoute.addChildren([ demoIndexRoute, demoMantineRoute, @@ -1006,6 +1017,7 @@ const coreRoutes = [ adminAuditRoute, adminSecurityRLSRoute, adminKillSwitchesRoute, + adminOttaportRoute, adminUsersRoute, adminUserRBACRoute, organizationsRoute, diff --git a/apps/ottabase-template-app-tanstack/worker/lib/db-utils.ts b/apps/ottabase-template-app-tanstack/worker/lib/db-utils.ts index 7376f8502..2160d062f 100644 --- a/apps/ottabase-template-app-tanstack/worker/lib/db-utils.ts +++ b/apps/ottabase-template-app-tanstack/worker/lib/db-utils.ts @@ -10,6 +10,7 @@ import { PostTagLink, PostVersion, } from '@ottabase/ottablog'; +import { PortJob } from '@ottabase/ottaport'; import { clearConnection, hasConnection, initRLS, registerConnection, registerModels } from '@ottabase/ottaorm'; import { Account, @@ -93,6 +94,7 @@ export function initDbConnection(env: CloudflareEnv): void { ? [Post, PostTag, PostTagLink, PostCategory, PostSeries, PostVersion, OttablogPlugin, OttablogTheme] : []; const packageModels = [ + PortJob, ...(packages.shortlinks ? [Shortlink] : []), ...(packages.referrals ? [ReferralTracking] : []), ]; diff --git a/apps/ottabase-template-app-tanstack/worker/routes/ottaport.ts b/apps/ottabase-template-app-tanstack/worker/routes/ottaport.ts new file mode 100644 index 000000000..546fe4ada --- /dev/null +++ b/apps/ottabase-template-app-tanstack/worker/routes/ottaport.ts @@ -0,0 +1,364 @@ +// ============================================================ +// OttaPort Worker Routes — Import/Export API Endpoints +// ============================================================ + +import { AuditLog } from '@ottabase/ottaorm'; +import { getModel, getRegisteredModels, getAllModelsMetadata } from '@ottabase/ottaorm'; +import { PortJob, parseFileContent, processExport, processImport } from '@ottabase/ottaport'; +import type { ExportConfig, FieldMapping, FileFormat, ImportConfig } from '@ottabase/ottaport'; +import { errorResponse } from '@ottabase/utils/http-errors'; +import { jsonResponse } from '@ottabase/utils/http-response'; +import { requireAdminAccess } from '../lib/admin-guard'; +import type { ApiRouteContext } from './router'; + +/** Sanitize a filename to prevent path traversal — strips directory components and unsafe chars */ +function sanitizeFilename(name: string): string { + // Remove directory components (path traversal) + const basename = name.split(/[\\/]/).pop() || 'upload'; + // Keep only safe characters + return basename.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200) || 'upload'; +} + +// ============================================================ +// GET /api/admin/ottaport/models — List available models for import/export +// ============================================================ +export async function handleOttaportModels(context: ApiRouteContext): Promise { + const auth = await requireAdminAccess(context, { scope: 'system' }); + if (auth instanceof Response) return auth; + + const models = getRegisteredModels(); + const metadata = getAllModelsMetadata(); + + const modelList = models.map((name) => { + const meta = metadata.find((m: any) => m.entity === name); + const Model = getModel(name); + const fields = Model?.getFieldDescriptors?.() ?? {}; + + return { + entity: name, + displayName: (Model as any)?.displayName || name, + displayNamePlural: (Model as any)?.displayNamePlural || name, + fields: Object.entries(fields).map(([key, desc]: [string, any]) => ({ + name: key, + type: desc.type || 'string', + label: desc.uiConfig?.label || key, + required: desc.required ?? false, + editable: desc.editable ?? true, + filterable: desc.filterable ?? false, + searchable: desc.searchable ?? false, + })), + primaryKey: (Model as any)?.primaryKey || 'id', + }; + }); + + return jsonResponse({ data: modelList }); +} + +// ============================================================ +// POST /api/admin/ottaport/import/parse — Parse uploaded file and return headers + preview +// ============================================================ +export async function handleOttaportImportParse(context: ApiRouteContext): Promise { + const auth = await requireAdminAccess(context, { scope: 'system' }); + if (auth instanceof Response) return auth; + + try { + const formData = await context.request.formData(); + const file = formData.get('file') as File | null; + const format = (formData.get('format') as FileFormat) || 'csv'; + + if (!file) { + return errorResponse('No file provided', 400, { code: 'MISSING_FILE' }); + } + + const content = await file.text(); + const parsed = parseFileContent(content, format); + + // Optionally save file to R2 + let r2Key: string | undefined; + const saveToR2 = formData.get('saveToR2') === 'true'; + if (saveToR2 && context.env.OBCF_R2) { + const timestamp = Date.now(); + r2Key = `ottaport/imports/${timestamp}-${sanitizeFilename(file.name)}`; + await context.env.OBCF_R2.put(r2Key, content, { + httpMetadata: { contentType: file.type || 'text/plain' }, + customMetadata: { originalName: sanitizeFilename(file.name), uploadedAt: new Date().toISOString() }, + }); + } + + return jsonResponse({ + data: { + headers: parsed.headers, + preview: parsed.rows.slice(0, 5), + totalRows: parsed.totalRows, + format: parsed.format, + filename: file.name, + r2Key, + }, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to parse file'; + return errorResponse(message, 400, { code: 'PARSE_ERROR' }); + } +} + +// ============================================================ +// POST /api/admin/ottaport/import/execute — Execute import with field mappings +// ============================================================ +export async function handleOttaportImportExecute(context: ApiRouteContext): Promise { + const auth = await requireAdminAccess(context, { scope: 'system' }); + if (auth instanceof Response) return auth; + + try { + const formData = await context.request.formData(); + const file = formData.get('file') as File | null; + const configJson = formData.get('config') as string | null; + + if (!file || !configJson) { + return errorResponse('File and config are required', 400, { code: 'MISSING_DATA' }); + } + + const config: ImportConfig = JSON.parse(configJson); + const format = (formData.get('format') as FileFormat) || 'csv'; + + // Validate that the target model exists + const targetModel = getModel(config.modelEntity); + if (!targetModel) { + return errorResponse(`Model '${config.modelEntity}' not found`, 404, { code: 'MODEL_NOT_FOUND' }); + } + + // Validate field mappings against actual model fields + const modelTable = (targetModel as any).table; + if (modelTable && config.fieldMappings) { + for (const mapping of config.fieldMappings) { + if (!(mapping.targetField in modelTable)) { + return errorResponse( + `Invalid target field '${mapping.targetField}' for model '${config.modelEntity}'`, + 400, + { code: 'INVALID_FIELD' }, + ); + } + } + } + + const content = await file.text(); + const parsed = parseFileContent(content, format); + + // Optionally save file to R2 + let r2Key: string | undefined; + if (config.saveToR2 && context.env.OBCF_R2) { + const timestamp = Date.now(); + r2Key = `ottaport/imports/${timestamp}-${sanitizeFilename(file.name)}`; + await context.env.OBCF_R2.put(r2Key, content, { + httpMetadata: { contentType: file.type || 'text/plain' }, + customMetadata: { originalName: sanitizeFilename(file.name), uploadedAt: new Date().toISOString() }, + }); + } + + // Execute import + const result = await processImport(parsed.rows, config); + + // Log the job + try { + await PortJob.create({ + direction: 'import', + modelEntity: config.modelEntity, + status: result.status, + format, + filename: file.name, + r2Key: r2Key || null, + uniqueField: config.uniqueField, + totalRows: result.totalRows, + totalCreated: result.totalCreated, + totalUpdated: result.totalUpdated, + totalFailed: result.totalFailed, + totalSkipped: result.totalSkipped, + durationMs: result.durationMs, + metadata: JSON.stringify({ + fieldMappings: config.fieldMappings, + batchSize: config.batchSize, + errors: result.errors.slice(0, 100), // Limit stored errors + }), + userId: auth.user?.id || null, + userEmail: auth.user?.email || null, + }); + } catch { + // Non-critical: job logging failure shouldn't break the import + } + + // Log to audit + try { + await AuditLog.log({ + userId: auth.user?.id, + userEmail: auth.user?.email, + action: 'import', + resourceType: config.modelEntity, + status: result.status === 'completed' ? 'success' : 'failure', + metadata: { + totalRows: result.totalRows, + totalCreated: result.totalCreated, + totalUpdated: result.totalUpdated, + totalFailed: result.totalFailed, + filename: file.name, + format, + }, + }); + } catch { + // Non-critical + } + + return jsonResponse({ data: result }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Import failed'; + return errorResponse(message, 500, { code: 'IMPORT_ERROR' }); + } +} + +// ============================================================ +// POST /api/admin/ottaport/export — Export model data +// ============================================================ +export async function handleOttaportExport(context: ApiRouteContext): Promise { + const auth = await requireAdminAccess(context, { scope: 'system' }); + if (auth instanceof Response) return auth; + + try { + const body = (await context.request.json()) as ExportConfig; + + if (!body.modelEntity) { + return errorResponse('modelEntity is required', 400, { code: 'MISSING_MODEL' }); + } + + const startTime = Date.now(); + const result = await processExport(body); + + // Log the export job + try { + await PortJob.create({ + direction: 'export', + modelEntity: body.modelEntity, + status: 'completed', + format: body.format || 'csv', + filename: result.filename, + totalRows: result.totalRows, + durationMs: Date.now() - startTime, + metadata: JSON.stringify({ + fields: body.fields, + where: body.where, + dateRange: body.dateRange, + search: body.search, + }), + userId: auth.user?.id || null, + userEmail: auth.user?.email || null, + }); + } catch { + // Non-critical + } + + // Log to audit + try { + await AuditLog.log({ + userId: auth.user?.id, + userEmail: auth.user?.email, + action: 'export', + resourceType: body.modelEntity, + status: 'success', + metadata: { + totalRows: result.totalRows, + filename: result.filename, + format: body.format || 'csv', + }, + }); + } catch { + // Non-critical + } + + // Return file as download + return new Response(result.content, { + status: 200, + headers: { + 'Content-Type': result.contentType, + 'Content-Disposition': `attachment; filename="${sanitizeFilename(result.filename)}"`, + ...context.corsHeaders, + }, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Export failed'; + return errorResponse(message, 500, { code: 'EXPORT_ERROR' }); + } +} + +// ============================================================ +// GET /api/admin/ottaport/export/preview — Preview export data (paginated) +// ============================================================ +export async function handleOttaportExportPreview(context: ApiRouteContext): Promise { + const auth = await requireAdminAccess(context, { scope: 'system' }); + if (auth instanceof Response) return auth; + + try { + const url = context.url; + const modelEntity = url.searchParams.get('model'); + const page = parseInt(url.searchParams.get('page') || '1', 10); + const perPage = parseInt(url.searchParams.get('perPage') || '20', 10); + const search = url.searchParams.get('search') || undefined; + const orderBy = url.searchParams.get('orderBy') || undefined; + const orderDirection = (url.searchParams.get('orderDirection') as 'asc' | 'desc') || undefined; + + if (!modelEntity) { + return errorResponse('model parameter is required', 400, { code: 'MISSING_MODEL' }); + } + + const Model = getModel(modelEntity); + if (!Model) { + return errorResponse(`Model '${modelEntity}' not found`, 404, { code: 'MODEL_NOT_FOUND' }); + } + + // Build where from query params (key-value filter) + const where: Record = {}; + for (const [key, value] of url.searchParams.entries()) { + if (['model', 'page', 'perPage', 'search', 'orderBy', 'orderDirection'].includes(key)) continue; + if (key.startsWith('filter_')) { + const fieldName = key.replace('filter_', ''); + where[fieldName] = value; + } + } + + let result; + if (search) { + result = await Model.searchPaginate(search, [], page, perPage, where, { orderBy, orderDirection }); + } else { + result = await Model.paginate(page, perPage, where, { orderBy, orderDirection }); + } + + return jsonResponse({ data: result }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Preview failed'; + return errorResponse(message, 500, { code: 'PREVIEW_ERROR' }); + } +} + +// ============================================================ +// GET /api/admin/ottaport/jobs — List import/export job history +// ============================================================ +export async function handleOttaportJobs(context: ApiRouteContext): Promise { + const auth = await requireAdminAccess(context, { scope: 'system' }); + if (auth instanceof Response) return auth; + + try { + const url = context.url; + const page = parseInt(url.searchParams.get('page') || '1', 10); + const perPage = parseInt(url.searchParams.get('perPage') || '20', 10); + const direction = url.searchParams.get('direction') || undefined; + + const where: Record = {}; + if (direction) where.direction = direction; + + const result = await PortJob.paginate(page, perPage, where, { + orderBy: 'createdAt', + orderDirection: 'desc', + }); + + return jsonResponse({ data: result }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to fetch jobs'; + return errorResponse(message, 500, { code: 'JOBS_ERROR' }); + } +} diff --git a/apps/ottabase-template-app-tanstack/worker/routes/router.ts b/apps/ottabase-template-app-tanstack/worker/routes/router.ts index 09bc276d6..f929ce1a4 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/router.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/router.ts @@ -73,6 +73,14 @@ import { handleAuditLogs, handleDemo, handleDemoError } from './demo'; import { handleEmailProviders, handleEmailTest } from './email'; import { handleOttaormCrud } from './ottaorm-crud'; import { handleModelsMetadata, handleOttaormInit } from './ottaorm-init'; +import { + handleOttaportExport, + handleOttaportExportPreview, + handleOttaportImportExecute, + handleOttaportImportParse, + handleOttaportJobs, + handleOttaportModels, +} from './ottaport'; import { handleReferralStats, handleReferralTrack, @@ -299,6 +307,17 @@ async function handleGetRoutes(context: ApiRouteContext): Promise( + this: T, + records: Record[], + uniqueField: string, + options?: { batchSize?: number; driver?: DbDriver }, + ): Promise<{ + created: number; + updated: number; + failed: number; + errors: Array<{ index: number; message: string }>; + }> { + const batchSize = options?.batchSize ?? 50; + let created = 0; + let updated = 0; + let failed = 0; + const errors: Array<{ index: number; message: string }> = []; + + for (let i = 0; i < records.length; i += batchSize) { + const batch = records.slice(i, i + batchSize); + + for (let j = 0; j < batch.length; j++) { + const idx = i + j; + const data = batch[j]; + + try { + const uniqueValue = data[uniqueField]; + if (uniqueValue === undefined || uniqueValue === null || uniqueValue === '') { + failed++; + errors.push({ index: idx, message: `Missing required unique field '${uniqueField}'` }); + continue; + } + + const existing = await this.first({ [uniqueField]: uniqueValue }, options?.driver); + if (existing) { + const id = existing.get(this.primaryKey); + await this.update(id, data, options?.driver); + updated++; + } else { + await this.create(data, options?.driver); + created++; + } + } catch (err: unknown) { + failed++; + errors.push({ index: idx, message: err instanceof Error ? err.message : String(err) }); + } + } + } + + return { created, updated, failed, errors }; + } + /** * Count records matching conditions */ diff --git a/packages/ottaport/README.md b/packages/ottaport/README.md new file mode 100644 index 000000000..4862ee62e --- /dev/null +++ b/packages/ottaport/README.md @@ -0,0 +1,129 @@ +# @ottabase/ottaport + +Data import/export engine for OttaORM models. Provides CSV/JSON/TSV parsing, field mapping, batched bulk upserts, and +filtered exports. + +## Features + +- **Import**: Upload CSV/JSON/TSV → map fields to OttaORM model → validate → batched upserts +- **Export**: Select model → apply filters (date range, search, field filters) → download as CSV/JSON/TSV +- **History**: Track all import/export jobs with metadata (row counts, status, user, filename) +- **File Storage**: Optionally save uploaded files to Cloudflare R2 for audit trail + +## Installation + +```bash +pnpm add @ottabase/ottaport +``` + +## Usage + +### Parsing Files + +```typescript +import { parseCsv, parseJson, parseFileContent } from '@ottabase/ottaport'; + +// Parse CSV +const csvResult = parseCsv('name,email\nAlice,alice@test.com'); +// { headers: ['name', 'email'], rows: [{ name: 'Alice', email: 'alice@test.com' }], ... } + +// Auto-detect format +const result = parseFileContent(fileContent, 'csv'); // or 'json', 'tsv' +``` + +### Importing Data (Server-side) + +```typescript +import { processImport } from '@ottabase/ottaport/server'; + +const result = await processImport(parsedRows, { + modelEntity: 'users', + fieldMappings: [ + { sourceColumn: 'Full Name', targetField: 'name' }, + { sourceColumn: 'Email Address', targetField: 'email' }, + ], + uniqueField: 'email', // Upsert based on this field + batchSize: 50, +}); + +console.log(result); +// { status: 'completed', totalCreated: 45, totalUpdated: 5, totalFailed: 0, ... } +``` + +### Exporting Data (Server-side) + +```typescript +import { processExport } from '@ottabase/ottaport/server'; + +const { content, filename, contentType } = await processExport({ + modelEntity: 'users', + format: 'csv', + fields: ['name', 'email', 'createdAt'], + where: { status: 'active' }, + dateRange: { field: 'createdAt', from: '2024-01-01', to: '2024-12-31' }, + orderBy: 'createdAt', + orderDirection: 'desc', +}); +``` + +### Formatting Output + +```typescript +import { formatCsv, formatJson, formatTsv } from '@ottabase/ottaport'; + +const csv = formatCsv(records, ['name', 'email']); +const json = formatJson(records, ['name', 'email']); +const tsv = formatTsv(records, ['name', 'email']); +``` + +### Job Tracking (PortJob Model) + +```typescript +import { PortJob } from '@ottabase/ottaport'; + +// Create a job log entry +const job = await PortJob.create({ + direction: 'import', + modelEntity: 'users', + status: 'completed', + format: 'csv', + filename: 'users-upload.csv', + totalRows: 100, + totalCreated: 95, + totalUpdated: 5, + userId: 'user-123', + userEmail: 'admin@example.com', +}); + +// Query job history +const jobs = await PortJob.where({ direction: 'import' }, { orderBy: 'createdAt', orderDirection: 'desc' }); +``` + +## App Integration + +### 1. Add to schema (`ottabase/db/schema.ts`) + +```typescript +export { portJobsTable } from '@ottabase/ottaport'; +``` + +### 2. Register model (`worker/lib/db-utils.ts`) + +```typescript +import { PortJob } from '@ottabase/ottaport'; +// Add to packageModels array +``` + +### 3. Run migrations + +```bash +curl -X POST http://localhost:3004/api/ottaorm/init +``` + +## Supported Formats + +| Format | Import | Export | MIME Type | +| ------ | ------ | ------ | ------------------------- | +| CSV | ✅ | ✅ | text/csv | +| JSON | ✅ | ✅ | application/json | +| TSV | ✅ | ✅ | text/tab-separated-values | diff --git a/packages/ottaport/package.json b/packages/ottaport/package.json new file mode 100644 index 000000000..804a3bbed --- /dev/null +++ b/packages/ottaport/package.json @@ -0,0 +1,35 @@ +{ + "name": "@ottabase/ottaport", + "version": "0.0.1", + "description": "Data import/export engine for OttaORM models — CSV/JSON/TSV parsing, field mapping, bulk upserts, and export with filters", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./schema": { + "types": "./src/schema.ts", + "default": "./src/schema.ts" + }, + "./server": { + "types": "./src/server/index.ts", + "default": "./src/server/index.ts" + } + }, + "scripts": { + "clean": "rm -rf .turbo node_modules", + "test": "vitest", + "test:coverage": "vitest --coverage" + }, + "peerDependencies": { + "drizzle-orm": "catalog:" + }, + "dependencies": { + "@ottabase/ottaorm": "workspace:*" + }, + "devDependencies": { + "drizzle-orm": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/ottaport/src/__tests__/csv-parser.test.ts b/packages/ottaport/src/__tests__/csv-parser.test.ts new file mode 100644 index 000000000..497dd7bbc --- /dev/null +++ b/packages/ottaport/src/__tests__/csv-parser.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; +import { formatCsv, formatJson, formatTsv, parseCsv, parseFileContent, parseJson } from '../parsers/csv-parser'; + +describe('CSV Parser', () => { + it('should parse simple CSV', () => { + const csv = 'name,email,age\nAlice,alice@example.com,30\nBob,bob@example.com,25'; + const result = parseCsv(csv); + + expect(result.headers).toEqual(['name', 'email', 'age']); + expect(result.rows).toHaveLength(2); + expect(result.rows[0]).toEqual({ name: 'Alice', email: 'alice@example.com', age: '30' }); + expect(result.rows[1]).toEqual({ name: 'Bob', email: 'bob@example.com', age: '25' }); + expect(result.totalRows).toBe(2); + expect(result.format).toBe('csv'); + }); + + it('should handle quoted fields with commas', () => { + const csv = 'name,description\n"Smith, John","A person, indeed"'; + const result = parseCsv(csv); + + expect(result.rows[0]).toEqual({ name: 'Smith, John', description: 'A person, indeed' }); + }); + + it('should handle escaped quotes', () => { + const csv = 'name,quote\nAlice,"She said ""hello"""'; + const result = parseCsv(csv); + + expect(result.rows[0]).toEqual({ name: 'Alice', quote: 'She said "hello"' }); + }); + + it('should handle CRLF line endings', () => { + const csv = 'name,email\r\nAlice,alice@test.com\r\nBob,bob@test.com'; + const result = parseCsv(csv); + + expect(result.rows).toHaveLength(2); + expect(result.rows[0].name).toBe('Alice'); + }); + + it('should skip empty rows', () => { + const csv = 'name,email\nAlice,alice@test.com\n\nBob,bob@test.com'; + const result = parseCsv(csv); + + expect(result.rows).toHaveLength(2); + }); + + it('should handle empty CSV', () => { + const result = parseCsv(''); + expect(result.headers).toEqual([]); + expect(result.rows).toHaveLength(0); + }); + + it('should handle header-only CSV', () => { + const csv = 'name,email,age'; + const result = parseCsv(csv); + + expect(result.headers).toEqual(['name', 'email', 'age']); + expect(result.rows).toHaveLength(0); + }); + + it('should handle missing trailing fields', () => { + const csv = 'name,email,age\nAlice,alice@test.com'; + const result = parseCsv(csv); + + expect(result.rows[0]).toEqual({ name: 'Alice', email: 'alice@test.com', age: '' }); + }); +}); + +describe('TSV Parser', () => { + it('should parse TSV content', () => { + const tsv = 'name\temail\tage\nAlice\talice@example.com\t30'; + const result = parseCsv(tsv, 'tsv'); + + expect(result.headers).toEqual(['name', 'email', 'age']); + expect(result.rows[0]).toEqual({ name: 'Alice', email: 'alice@example.com', age: '30' }); + expect(result.format).toBe('tsv'); + }); +}); + +describe('JSON Parser', () => { + it('should parse JSON array', () => { + const json = JSON.stringify([ + { name: 'Alice', email: 'alice@example.com', age: 30 }, + { name: 'Bob', email: 'bob@example.com', age: 25 }, + ]); + const result = parseJson(json); + + expect(result.headers).toContain('name'); + expect(result.headers).toContain('email'); + expect(result.headers).toContain('age'); + expect(result.rows).toHaveLength(2); + expect(result.rows[0].name).toBe('Alice'); + expect(result.rows[0].age).toBe('30'); // Converted to string + expect(result.format).toBe('json'); + }); + + it('should handle null values', () => { + const json = JSON.stringify([{ name: 'Alice', email: null }]); + const result = parseJson(json); + + expect(result.rows[0].email).toBe(''); + }); + + it('should handle empty array', () => { + const result = parseJson('[]'); + expect(result.headers).toEqual([]); + expect(result.rows).toHaveLength(0); + }); + + it('should throw for non-array JSON', () => { + expect(() => parseJson('{"name": "Alice"}')).toThrow('JSON content must be an array of objects'); + }); + + it('should collect all keys from heterogeneous objects', () => { + const json = JSON.stringify([ + { name: 'Alice', email: 'a@b.com' }, + { name: 'Bob', phone: '1234' }, + ]); + const result = parseJson(json); + + expect(result.headers).toContain('name'); + expect(result.headers).toContain('email'); + expect(result.headers).toContain('phone'); + expect(result.rows[0].phone).toBe(''); + expect(result.rows[1].email).toBe(''); + }); +}); + +describe('parseFileContent', () => { + it('should auto-dispatch to CSV parser', () => { + const result = parseFileContent('a,b\n1,2', 'csv'); + expect(result.format).toBe('csv'); + }); + + it('should auto-dispatch to TSV parser', () => { + const result = parseFileContent('a\tb\n1\t2', 'tsv'); + expect(result.format).toBe('tsv'); + }); + + it('should auto-dispatch to JSON parser', () => { + const result = parseFileContent('[{"a":"1","b":"2"}]', 'json'); + expect(result.format).toBe('json'); + }); +}); + +describe('CSV Formatter', () => { + it('should format records as CSV', () => { + const records = [ + { name: 'Alice', email: 'alice@test.com' }, + { name: 'Bob', email: 'bob@test.com' }, + ]; + const result = formatCsv(records, ['name', 'email']); + + expect(result).toBe('name,email\nAlice,alice@test.com\nBob,bob@test.com'); + }); + + it('should escape commas and quotes', () => { + const records = [{ name: 'Smith, John', quote: 'He said "hi"' }]; + const result = formatCsv(records, ['name', 'quote']); + + expect(result).toBe('name,quote\n"Smith, John","He said ""hi"""'); + }); + + it('should handle null/undefined values', () => { + const records = [{ name: 'Alice', email: null, age: undefined }]; + const result = formatCsv(records as any, ['name', 'email', 'age']); + + expect(result).toBe('name,email,age\nAlice,,'); + }); +}); + +describe('TSV Formatter', () => { + it('should format records as TSV', () => { + const records = [{ name: 'Alice', email: 'alice@test.com' }]; + const result = formatTsv(records, ['name', 'email']); + + expect(result).toBe('name\temail\nAlice\talice@test.com'); + }); + + it('should replace tabs in values', () => { + const records = [{ name: 'with\ttab' }]; + const result = formatTsv(records, ['name']); + + expect(result).toBe('name\nwith tab'); + }); +}); + +describe('JSON Formatter', () => { + it('should format records as JSON', () => { + const records = [{ name: 'Alice', email: 'alice@test.com' }]; + const result = formatJson(records); + + expect(JSON.parse(result)).toEqual(records); + }); + + it('should filter by fields', () => { + const records = [{ name: 'Alice', email: 'alice@test.com', age: 30 }]; + const result = formatJson(records, ['name', 'email']); + const parsed = JSON.parse(result); + + expect(parsed[0]).toEqual({ name: 'Alice', email: 'alice@test.com' }); + expect(parsed[0].age).toBeUndefined(); + }); +}); diff --git a/packages/ottaport/src/__tests__/import-handler.test.ts b/packages/ottaport/src/__tests__/import-handler.test.ts new file mode 100644 index 000000000..831c3fb36 --- /dev/null +++ b/packages/ottaport/src/__tests__/import-handler.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ImportConfig, ParsedRow } from '../types'; + +// Mock @ottabase/ottaorm +vi.mock('@ottabase/ottaorm', () => { + const mockRecords = new Map>(); + + const MockModel = { + entity: 'test_entity', + primaryKey: 'id', + first: vi.fn(async (where: Record) => { + for (const [, record] of mockRecords) { + const matches = Object.entries(where).every(([k, v]) => record[k] === v); + if (matches) { + return { + get: (key: string) => record[key], + }; + } + } + return null; + }), + create: vi.fn(async (data: Record) => { + const id = data.id || `generated-${mockRecords.size + 1}`; + const record = { ...data, id }; + mockRecords.set(String(id), record); + return { get: (key: string) => (record as Record)[key] }; + }), + update: vi.fn(async (id: string, data: Record) => { + const existing = mockRecords.get(id); + if (existing) { + Object.assign(existing, data); + } + return { get: (key: string) => existing?.[key] }; + }), + _mockRecords: mockRecords, + }; + + return { + hasModel: vi.fn((entity: string) => entity === 'test_entity'), + getModel: vi.fn((entity: string) => (entity === 'test_entity' ? MockModel : null)), + _MockModel: MockModel, + }; +}); + +describe('Import Handler', () => { + let processImport: typeof import('../server/import-handler').processImport; + let mockOttaorm: any; + + beforeEach(async () => { + vi.clearAllMocks(); + mockOttaorm = await import('@ottabase/ottaorm'); + mockOttaorm._MockModel._mockRecords.clear(); + + const module = await import('../server/import-handler'); + processImport = module.processImport; + }); + + const baseConfig: ImportConfig = { + modelEntity: 'test_entity', + fieldMappings: [ + { sourceColumn: 'Name', targetField: 'name' }, + { sourceColumn: 'Email', targetField: 'email' }, + ], + uniqueField: 'email', + batchSize: 10, + }; + + it('should create new records when no match exists', async () => { + const rows: ParsedRow[] = [ + { Name: 'Alice', Email: 'alice@test.com' }, + { Name: 'Bob', Email: 'bob@test.com' }, + ]; + + const result = await processImport(rows, baseConfig); + + expect(result.status).toBe('completed'); + expect(result.totalCreated).toBe(2); + expect(result.totalUpdated).toBe(0); + expect(result.totalFailed).toBe(0); + expect(result.totalRows).toBe(2); + }); + + it('should update existing records when match exists', async () => { + // Pre-populate a record + mockOttaorm._MockModel._mockRecords.set('existing-1', { + id: 'existing-1', + name: 'Old Name', + email: 'alice@test.com', + }); + + const rows: ParsedRow[] = [{ Name: 'Alice Updated', Email: 'alice@test.com' }]; + + const result = await processImport(rows, baseConfig); + + expect(result.status).toBe('completed'); + expect(result.totalCreated).toBe(0); + expect(result.totalUpdated).toBe(1); + }); + + it('should handle missing unique field', async () => { + const rows: ParsedRow[] = [{ Name: 'Alice', Email: '' }]; + + const result = await processImport(rows, baseConfig); + + expect(result.totalFailed).toBe(1); + expect(result.errors[0].message).toContain("Missing required unique field 'email'"); + }); + + it('should fail for unregistered model', async () => { + const rows: ParsedRow[] = [{ Name: 'Alice', Email: 'alice@test.com' }]; + + const result = await processImport(rows, { + ...baseConfig, + modelEntity: 'nonexistent', + }); + + expect(result.status).toBe('failed'); + expect(result.errors[0].message).toContain("Model 'nonexistent' not registered"); + }); + + it('should process in batches', async () => { + const rows: ParsedRow[] = Array.from({ length: 25 }, (_, i) => ({ + Name: `User ${i}`, + Email: `user${i}@test.com`, + })); + + const result = await processImport(rows, { ...baseConfig, batchSize: 10 }); + + expect(result.batches).toHaveLength(3); // 10 + 10 + 5 + expect(result.batches[0].totalInBatch).toBe(10); + expect(result.batches[1].totalInBatch).toBe(10); + expect(result.batches[2].totalInBatch).toBe(5); + expect(result.totalCreated).toBe(25); + }); + + it('should return partial status on mixed results', async () => { + const rows: ParsedRow[] = [ + { Name: 'Alice', Email: 'alice@test.com' }, + { Name: 'Missing Email', Email: '' }, + ]; + + const result = await processImport(rows, baseConfig); + + expect(result.status).toBe('partial'); + expect(result.totalCreated).toBe(1); + expect(result.totalFailed).toBe(1); + }); +}); diff --git a/packages/ottaport/src/index.ts b/packages/ottaport/src/index.ts new file mode 100644 index 000000000..0f9e3cc9e --- /dev/null +++ b/packages/ottaport/src/index.ts @@ -0,0 +1,34 @@ +// ============================================================ +// @ottabase/ottaport - Main Exports +// ============================================================ +// Data import/export engine for OttaORM models. +// Provides CSV/JSON/TSV parsing, field mapping, bulk upserts, +// and export with filters. +// ============================================================ + +// Schema & Model +export { PortJob } from './ottaorm-models/PortJob'; +export { portJobsTable } from './schema'; +export type { NewPortJobRecord, PortJobRecord } from './schema'; + +// Parsers +export { formatCsv, formatJson, formatTsv, parseCsv, parseFileContent, parseJson } from './parsers/csv-parser'; + +// Server handlers +export { processExport, processImport } from './server'; + +// Types +export { DEFAULT_BATCH_SIZE } from './types'; +export type { + BatchResult, + ExportConfig, + FieldMapping, + FileFormat, + ImportConfig, + ImportResult, + JobDirection, + JobStatus, + ParsedFile, + ParsedRow, + PortJobMeta, +} from './types'; diff --git a/packages/ottaport/src/ottaorm-models/PortJob.ts b/packages/ottaport/src/ottaorm-models/PortJob.ts new file mode 100644 index 000000000..14f6880aa --- /dev/null +++ b/packages/ottaport/src/ottaorm-models/PortJob.ts @@ -0,0 +1,201 @@ +// ============================================================ +// @ottabase/ottaport - PortJob Model (Fat Model) +// ============================================================ + +import { BaseModel, ModelFields, type PackageType } from '@ottabase/ottaorm'; +import { portJobsTable } from '../schema'; + +export { portJobsTable, type NewPortJobRecord, type PortJobRecord } from '../schema'; + +/** + * PortJob model — tracks import/export operations with metadata + */ +export class PortJob extends BaseModel { + static entity = 'ottaport_jobs'; + static table = portJobsTable; + static primaryKey = 'id'; + static packageName = '@ottabase/ottaport'; + static packageType: PackageType = 'package'; + + // UI metadata + static displayName = 'Import/Export Job'; + static displayNamePlural = 'Import/Export Jobs'; + static defaultSort = 'createdAt'; + static defaultSortDirection = 'desc' as const; + + static casts = { + createdAt: 'date' as const, + metadata: 'json' as const, + }; + + static writable = { + create: [ + 'direction', + 'modelEntity', + 'status', + 'format', + 'filename', + 'r2Key', + 'uniqueField', + 'totalRows', + 'totalCreated', + 'totalUpdated', + 'totalFailed', + 'totalSkipped', + 'durationMs', + 'metadata', + 'userId', + 'userEmail', + 'organizationId', + ], + update: [ + 'status', + 'totalRows', + 'totalCreated', + 'totalUpdated', + 'totalFailed', + 'totalSkipped', + 'durationMs', + 'metadata', + ], + }; + + protected static fields: ModelFields = { + id: { + type: 'id', + primaryKey: true, + editable: false, + uiConfig: { label: 'ID' }, + }, + direction: { + type: 'string', + editable: false, + filterable: true, + uiConfig: { label: 'Direction', description: 'Import or Export' }, + tableConfig: { visible: true }, + }, + modelEntity: { + type: 'string', + editable: false, + filterable: true, + searchable: true, + uiConfig: { label: 'Model', description: 'OttaORM model entity name' }, + tableConfig: { visible: true }, + }, + status: { + type: 'string', + editable: false, + filterable: true, + uiConfig: { label: 'Status' }, + tableConfig: { visible: true }, + }, + format: { + type: 'string', + editable: false, + uiConfig: { label: 'Format' }, + tableConfig: { visible: true }, + }, + filename: { + type: 'string', + editable: false, + searchable: true, + uiConfig: { label: 'Filename' }, + tableConfig: { visible: true }, + }, + r2Key: { + type: 'string', + editable: false, + uiConfig: { label: 'R2 Key' }, + tableConfig: { visible: false }, + }, + uniqueField: { + type: 'string', + editable: false, + uiConfig: { label: 'Unique Field' }, + tableConfig: { visible: false }, + }, + totalRows: { + type: 'number', + editable: false, + uiConfig: { label: 'Total Rows' }, + tableConfig: { visible: true }, + }, + totalCreated: { + type: 'number', + editable: false, + uiConfig: { label: 'Created' }, + tableConfig: { visible: true }, + }, + totalUpdated: { + type: 'number', + editable: false, + uiConfig: { label: 'Updated' }, + tableConfig: { visible: true }, + }, + totalFailed: { + type: 'number', + editable: false, + uiConfig: { label: 'Failed' }, + tableConfig: { visible: true }, + }, + totalSkipped: { + type: 'number', + editable: false, + uiConfig: { label: 'Skipped' }, + tableConfig: { visible: false }, + }, + durationMs: { + type: 'number', + editable: false, + uiConfig: { label: 'Duration (ms)' }, + tableConfig: { visible: true }, + }, + metadata: { + type: 'json', + editable: false, + uiConfig: { label: 'Metadata' }, + tableConfig: { visible: false }, + }, + userId: { + type: 'string', + editable: false, + filterable: true, + uiConfig: { label: 'User ID' }, + tableConfig: { visible: false }, + }, + userEmail: { + type: 'string', + editable: false, + searchable: true, + uiConfig: { label: 'User Email' }, + tableConfig: { visible: true }, + }, + organizationId: { + type: 'string', + editable: false, + filterable: true, + uiConfig: { label: 'Organization ID' }, + tableConfig: { visible: false }, + }, + createdAt: { + type: 'date', + editable: false, + sortable: true, + uiConfig: { label: 'Created At' }, + tableConfig: { visible: true }, + }, + }; + + /** Get metadata as a parsed object */ + getJobMeta(): Record { + const meta = this.get('metadata'); + if (typeof meta === 'string') { + try { + return JSON.parse(meta); + } catch { + return {}; + } + } + return meta || {}; + } +} diff --git a/packages/ottaport/src/parsers/csv-parser.ts b/packages/ottaport/src/parsers/csv-parser.ts new file mode 100644 index 000000000..10529479a --- /dev/null +++ b/packages/ottaport/src/parsers/csv-parser.ts @@ -0,0 +1,243 @@ +// ============================================================ +// @ottabase/ottaport - CSV/TSV Parser +// ============================================================ +// Edge-runtime compatible parser — no Node.js-only APIs. +// ============================================================ + +import type { FileFormat, ParsedFile, ParsedRow } from '../types'; + +/** + * Parse a CSV or TSV string into structured data. + * Handles quoted fields, escaped quotes, and newlines within quotes. + */ +export function parseCsv(content: string, format: FileFormat = 'csv'): ParsedFile { + const delimiter = format === 'tsv' ? '\t' : ','; + const rows = parseDelimited(content, delimiter); + + if (rows.length === 0) { + return { headers: [], rows: [], totalRows: 0, format }; + } + + const headers = rows[0].map((h) => h.trim()); + const dataRows: ParsedRow[] = []; + + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + // Skip empty rows + if (row.length === 1 && row[0].trim() === '') continue; + + const record: ParsedRow = {}; + for (let j = 0; j < headers.length; j++) { + record[headers[j]] = row[j]?.trim() ?? ''; + } + dataRows.push(record); + } + + return { + headers, + rows: dataRows, + totalRows: dataRows.length, + format, + }; +} + +/** + * Parse a JSON string (array of objects) into structured data. + */ +export function parseJson(content: string): ParsedFile { + const data = JSON.parse(content); + if (!Array.isArray(data)) { + throw new Error('JSON content must be an array of objects'); + } + if (data.length === 0) { + return { headers: [], rows: [], totalRows: 0, format: 'json' }; + } + + // Collect all unique keys as headers + const headerSet = new Set(); + for (const obj of data) { + if (typeof obj === 'object' && obj !== null) { + Object.keys(obj).forEach((k) => headerSet.add(k)); + } + } + const headers = Array.from(headerSet); + + const rows: ParsedRow[] = data.map((obj) => { + const record: ParsedRow = {}; + for (const h of headers) { + const val = obj[h]; + record[h] = val === null || val === undefined ? '' : String(val); + } + return record; + }); + + return { headers, rows, totalRows: rows.length, format: 'json' }; +} + +/** + * Auto-detect format and parse file content. + */ +export function parseFileContent(content: string, format: FileFormat): ParsedFile { + switch (format) { + case 'json': + return parseJson(content); + case 'tsv': + return parseCsv(content, 'tsv'); + case 'csv': + default: + return parseCsv(content, 'csv'); + } +} + +// ============================================================ +// Internal helpers +// ============================================================ + +/** + * Parse delimited text handling quoted fields. + * Returns array of rows, each row is array of field values. + */ +function parseDelimited(text: string, delimiter: string): string[][] { + const rows: string[][] = []; + let currentRow: string[] = []; + let currentField = ''; + let inQuotes = false; + let i = 0; + + while (i < text.length) { + const char = text[i]; + + if (inQuotes) { + if (char === '"') { + // Check for escaped quote + if (i + 1 < text.length && text[i + 1] === '"') { + currentField += '"'; + i += 2; + continue; + } + // End of quoted field + inQuotes = false; + i++; + continue; + } + currentField += char; + i++; + continue; + } + + if (char === '"') { + inQuotes = true; + i++; + continue; + } + + if (char === delimiter) { + currentRow.push(currentField); + currentField = ''; + i++; + continue; + } + + if (char === '\r') { + // Handle \r\n + if (i + 1 < text.length && text[i + 1] === '\n') { + i++; + } + currentRow.push(currentField); + currentField = ''; + rows.push(currentRow); + currentRow = []; + i++; + continue; + } + + if (char === '\n') { + currentRow.push(currentField); + currentField = ''; + rows.push(currentRow); + currentRow = []; + i++; + continue; + } + + currentField += char; + i++; + } + + // Don't forget last field/row + if (currentField || currentRow.length > 0) { + currentRow.push(currentField); + rows.push(currentRow); + } + + return rows; +} + +// ============================================================ +// Export formatters +// ============================================================ + +/** + * Format an array of records as CSV string. + */ +export function formatCsv(records: Record[], fields: string[]): string { + const lines: string[] = []; + // Header row + lines.push(fields.map(escapeCsvField).join(',')); + + for (const record of records) { + const row = fields.map((f) => { + const val = record[f]; + return escapeCsvField(val === null || val === undefined ? '' : String(val)); + }); + lines.push(row.join(',')); + } + + return lines.join('\n'); +} + +/** + * Format an array of records as TSV string. + */ +export function formatTsv(records: Record[], fields: string[]): string { + const lines: string[] = []; + lines.push(fields.join('\t')); + + for (const record of records) { + const row = fields.map((f) => { + const val = record[f]; + const str = val === null || val === undefined ? '' : String(val); + // TSV: replace tabs and newlines in values + return str.replace(/[\t\n\r]/g, ' '); + }); + lines.push(row.join('\t')); + } + + return lines.join('\n'); +} + +/** + * Format an array of records as JSON string. + */ +export function formatJson(records: Record[], fields?: string[]): string { + if (!fields || fields.length === 0) { + return JSON.stringify(records, null, 2); + } + + const filtered = records.map((r) => { + const obj: Record = {}; + for (const f of fields) { + obj[f] = r[f]; + } + return obj; + }); + return JSON.stringify(filtered, null, 2); +} + +/** Escape a CSV field value — wraps in quotes if it contains comma, quote, or newline */ +function escapeCsvField(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) { + return '"' + value.replace(/"/g, '""') + '"'; + } + return value; +} diff --git a/packages/ottaport/src/schema.ts b/packages/ottaport/src/schema.ts new file mode 100644 index 000000000..baf6a71d7 --- /dev/null +++ b/packages/ottaport/src/schema.ts @@ -0,0 +1,57 @@ +// ============================================================ +// @ottabase/ottaport - Database Schema +// ============================================================ +// Tracks import/export job history and metadata. +// Uses AuditLog for action tracking + this table for detailed job data. +// ============================================================ + +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +/** + * Import/Export job log table + * Stores metadata and history of all import/export operations. + */ +export const portJobsTable = sqliteTable('ottaport_jobs', { + id: text('id').primaryKey(), + /** 'import' or 'export' */ + direction: text('direction').notNull(), + /** OttaORM model entity name (e.g. 'users') */ + modelEntity: text('model_entity').notNull(), + /** Job status */ + status: text('status').notNull().default('pending'), + /** File format (csv, json, tsv) */ + format: text('format'), + /** Original filename */ + filename: text('filename'), + /** R2 object key if file was saved */ + r2Key: text('r2_key'), + /** Field used for upsert matching */ + uniqueField: text('unique_field'), + /** Total rows processed */ + totalRows: integer('total_rows').default(0), + /** Successfully created records */ + totalCreated: integer('total_created').default(0), + /** Successfully updated records */ + totalUpdated: integer('total_updated').default(0), + /** Failed records */ + totalFailed: integer('total_failed').default(0), + /** Skipped records */ + totalSkipped: integer('total_skipped').default(0), + /** Processing duration in milliseconds */ + durationMs: integer('duration_ms'), + /** JSON blob for field mappings, errors, filters, etc. */ + metadata: text('metadata'), + /** User who initiated the job */ + userId: text('user_id'), + /** User email for quick reference */ + userEmail: text('user_email'), + /** Organization/tenant context */ + organizationId: text('organization_id'), + /** Created timestamp (unix ms) */ + createdAt: integer('created_at') + .notNull() + .$defaultFn(() => Date.now()), +}); + +export type PortJobRecord = typeof portJobsTable.$inferSelect; +export type NewPortJobRecord = typeof portJobsTable.$inferInsert; diff --git a/packages/ottaport/src/server/export-handler.ts b/packages/ottaport/src/server/export-handler.ts new file mode 100644 index 000000000..11fdf913a --- /dev/null +++ b/packages/ottaport/src/server/export-handler.ts @@ -0,0 +1,124 @@ +// ============================================================ +// @ottabase/ottaport - Export Handler (Server-side) +// ============================================================ +// Queries OttaORM models with filters and formats output. +// ============================================================ + +import { getModel, hasModel } from '@ottabase/ottaorm'; +import { formatCsv, formatJson, formatTsv } from '../parsers/csv-parser'; +import type { ExportConfig, FileFormat } from '../types'; + +/** + * Export data from an OttaORM model with filters, formatted as CSV/JSON/TSV. + * Returns the formatted string content and suggested filename. + */ +export async function processExport(config: ExportConfig): Promise<{ + content: string; + filename: string; + contentType: string; + totalRows: number; +}> { + const { modelEntity, format, fields, where, dateRange, search, orderBy, orderDirection } = config; + + if (!hasModel(modelEntity)) { + throw new Error(`Model '${modelEntity}' not registered in OttaORM`); + } + + const Model = getModel(modelEntity)!; + + // Build where conditions + const whereConditions: Record = { ...where }; + + // Fetch records with filters + let records: InstanceType[]; + const queryOptions: { + orderBy?: string; + orderDirection?: 'asc' | 'desc'; + } = {}; + + if (orderBy) queryOptions.orderBy = orderBy; + if (orderDirection) queryOptions.orderDirection = orderDirection; + + if (search) { + // Get searchable fields from model + const modelFields = (Model as any).getFields?.() ?? {}; + const searchableFields = Object.entries(modelFields) + .filter(([, desc]: [string, any]) => desc.searchable) + .map(([key]: [string, any]) => key); + + if (searchableFields.length > 0) { + records = await Model.search(search, searchableFields, whereConditions, queryOptions); + } else { + records = await Model.where(whereConditions, queryOptions); + } + } else { + records = await Model.where(whereConditions, queryOptions); + } + + // Apply date range filter in-memory (OttaORM stores dates as timestamps) + if (dateRange?.field && (dateRange.from || dateRange.to)) { + records = records.filter((r: any) => { + const val = r.get(dateRange.field); + if (!val) return false; + const ts = val instanceof Date ? val.getTime() : Number(val); + if (dateRange.from && ts < new Date(dateRange.from).getTime()) return false; + if (dateRange.to && ts > new Date(dateRange.to).getTime()) return false; + return true; + }); + } + + // Convert model instances to plain objects + const plainRecords = records.map((r: any) => { + if (typeof r.toJSON === 'function') return r.toJSON(); + if (typeof r.getData === 'function') return r.getData(); + return r; + }); + + // Determine fields to export + const exportFields = + fields && fields.length > 0 ? fields : plainRecords.length > 0 ? Object.keys(plainRecords[0]) : []; + + // Format output + const { content, contentType, extension } = formatOutput(plainRecords, exportFields, format); + + const timestamp = new Date().toISOString().slice(0, 10); + const filename = `${modelEntity}-export-${timestamp}.${extension}`; + + return { + content, + filename, + contentType, + totalRows: plainRecords.length, + }; +} + +/** + * Format records into the requested format. + */ +function formatOutput( + records: Record[], + fields: string[], + format: FileFormat, +): { content: string; contentType: string; extension: string } { + switch (format) { + case 'json': + return { + content: formatJson(records, fields), + contentType: 'application/json', + extension: 'json', + }; + case 'tsv': + return { + content: formatTsv(records, fields), + contentType: 'text/tab-separated-values', + extension: 'tsv', + }; + case 'csv': + default: + return { + content: formatCsv(records, fields), + contentType: 'text/csv', + extension: 'csv', + }; + } +} diff --git a/packages/ottaport/src/server/import-handler.ts b/packages/ottaport/src/server/import-handler.ts new file mode 100644 index 000000000..8e5869bd8 --- /dev/null +++ b/packages/ottaport/src/server/import-handler.ts @@ -0,0 +1,126 @@ +// ============================================================ +// @ottabase/ottaport - Import Handler (Server-side) +// ============================================================ +// Processes file data, validates against model, and performs +// batched bulk upserts using the OttaORM model system. +// ============================================================ + +import { getModel, hasModel } from '@ottabase/ottaorm'; +import { DEFAULT_BATCH_SIZE } from '../types'; +import type { BatchResult, FieldMapping, ImportConfig, ImportResult, ParsedRow } from '../types'; + +/** + * Process imported rows against an OttaORM model with batched upserts. + * + * For each row: + * 1. Map source columns → target model fields using fieldMappings + * 2. Check if a record with the uniqueField value already exists + * 3. If exists → update, otherwise → create + * 4. Collect results per batch + */ +export async function processImport(rows: ParsedRow[], config: ImportConfig): Promise { + const startTime = Date.now(); + const { modelEntity, fieldMappings, uniqueField, batchSize = DEFAULT_BATCH_SIZE } = config; + + if (!hasModel(modelEntity)) { + return { + status: 'failed', + totalRows: rows.length, + totalCreated: 0, + totalUpdated: 0, + totalFailed: rows.length, + totalSkipped: 0, + batches: [], + errors: [{ row: 0, message: `Model '${modelEntity}' not registered in OttaORM` }], + durationMs: Date.now() - startTime, + }; + } + + const Model = getModel(modelEntity)!; + const batches: BatchResult[] = []; + const allErrors: ImportResult['errors'] = []; + let totalCreated = 0; + let totalUpdated = 0; + let totalFailed = 0; + let totalSkipped = 0; + + // Process in batches + for (let batchStart = 0; batchStart < rows.length; batchStart += batchSize) { + const batchRows = rows.slice(batchStart, batchStart + batchSize); + const batchIndex = Math.floor(batchStart / batchSize); + const batchResult: BatchResult = { + batchIndex, + totalInBatch: batchRows.length, + created: 0, + updated: 0, + failed: 0, + errors: [], + }; + + for (let i = 0; i < batchRows.length; i++) { + const rowIndex = batchStart + i + 1; // 1-based (header is row 0) + const row = batchRows[i]; + + try { + // Map fields + const mappedData: Record = {}; + for (const mapping of fieldMappings) { + const value = row[mapping.sourceColumn]; + if (value !== undefined && value !== '') { + mappedData[mapping.targetField] = value; + } + } + + // Ensure unique field has a value + const uniqueValue = mappedData[uniqueField]; + if (uniqueValue === undefined || uniqueValue === null || uniqueValue === '') { + batchResult.failed++; + batchResult.errors.push({ + row: rowIndex, + field: uniqueField, + message: `Missing required unique field '${uniqueField}'`, + }); + continue; + } + + // Check if record exists by unique field + const existing = await Model.first({ [uniqueField]: uniqueValue }); + + if (existing) { + // Update existing record + const id = existing.get(Model.primaryKey); + await Model.update(id, mappedData); + batchResult.updated++; + } else { + // Create new record + await Model.create(mappedData); + batchResult.created++; + } + } catch (err: unknown) { + batchResult.failed++; + const message = err instanceof Error ? err.message : String(err); + batchResult.errors.push({ row: rowIndex, message }); + } + } + + totalCreated += batchResult.created; + totalUpdated += batchResult.updated; + totalFailed += batchResult.failed; + allErrors.push(...batchResult.errors); + batches.push(batchResult); + } + + const status = totalFailed === 0 ? 'completed' : totalFailed === rows.length ? 'failed' : 'partial'; + + return { + status, + totalRows: rows.length, + totalCreated, + totalUpdated, + totalFailed, + totalSkipped, + batches, + errors: allErrors, + durationMs: Date.now() - startTime, + }; +} diff --git a/packages/ottaport/src/server/index.ts b/packages/ottaport/src/server/index.ts new file mode 100644 index 000000000..ebb9e599a --- /dev/null +++ b/packages/ottaport/src/server/index.ts @@ -0,0 +1,6 @@ +// ============================================================ +// @ottabase/ottaport - Server Exports +// ============================================================ + +export { processImport } from './import-handler'; +export { processExport } from './export-handler'; diff --git a/packages/ottaport/src/types.ts b/packages/ottaport/src/types.ts new file mode 100644 index 000000000..213c16dd2 --- /dev/null +++ b/packages/ottaport/src/types.ts @@ -0,0 +1,113 @@ +// ============================================================ +// @ottabase/ottaport - Types +// ============================================================ + +/** Default batch size for chunked imports */ +export const DEFAULT_BATCH_SIZE = 50; + +/** Supported file formats for import/export */ +export type FileFormat = 'csv' | 'json' | 'tsv'; + +/** Status of an import/export job */ +export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'partial'; + +/** Direction of the operation */ +export type JobDirection = 'import' | 'export'; + +/** A single field mapping from source column to target model field */ +export interface FieldMapping { + /** Column name in the source file */ + sourceColumn: string; + /** Field name in the target OttaORM model */ + targetField: string; + /** Whether this field is the unique key for upserts */ + isUniqueKey?: boolean; +} + +/** Configuration for an import job */ +export interface ImportConfig { + /** OttaORM model entity name (e.g., 'users', 'shortlinks') */ + modelEntity: string; + /** Mapping of source columns to model fields */ + fieldMappings: FieldMapping[]; + /** Field to use for upsert matching (e.g., 'email') */ + uniqueField: string; + /** Batch size for chunked imports (default: 50) */ + batchSize?: number; + /** Whether to save the uploaded file to R2 for future reference */ + saveToR2?: boolean; +} + +/** Result of a single batch operation */ +export interface BatchResult { + batchIndex: number; + totalInBatch: number; + created: number; + updated: number; + failed: number; + errors: Array<{ row: number; field?: string; message: string }>; +} + +/** Overall result of an import job */ +export interface ImportResult { + status: JobStatus; + totalRows: number; + totalCreated: number; + totalUpdated: number; + totalFailed: number; + totalSkipped: number; + batches: BatchResult[]; + errors: Array<{ row: number; field?: string; message: string }>; + durationMs: number; +} + +/** Configuration for an export job */ +export interface ExportConfig { + /** OttaORM model entity name */ + modelEntity: string; + /** Output format */ + format: FileFormat; + /** Fields to include in export (empty = all) */ + fields?: string[]; + /** Where filters */ + where?: Record; + /** Date range filter */ + dateRange?: { + field: string; + from?: string; + to?: string; + }; + /** Search query */ + search?: string; + /** Order by field */ + orderBy?: string; + /** Order direction */ + orderDirection?: 'asc' | 'desc'; +} + +/** Parsed row from a file (before mapping) */ +export type ParsedRow = Record; + +/** Parsed file result */ +export interface ParsedFile { + headers: string[]; + rows: ParsedRow[]; + totalRows: number; + format: FileFormat; +} + +/** Import/export log entry metadata */ +export interface PortJobMeta { + totalRows?: number; + totalCreated?: number; + totalUpdated?: number; + totalFailed?: number; + totalSkipped?: number; + filename?: string; + r2Key?: string; + format?: FileFormat; + fields?: string[]; + uniqueField?: string; + durationMs?: number; + filters?: Record; +} diff --git a/packages/ottaport/tsconfig.json b/packages/ottaport/tsconfig.json new file mode 100644 index 000000000..e3f0e2933 --- /dev/null +++ b/packages/ottaport/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "baseUrl": ".", + "paths": {}, + "skipLibCheck": true, + "lib": ["esnext", "dom", "dom.iterable"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ottaport/vitest.config.ts b/packages/ottaport/vitest.config.ts new file mode 100644 index 000000000..9c73cd1f2 --- /dev/null +++ b/packages/ottaport/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + coverage: { + provider: 'c8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: ['node_modules/', 'dist/', '**/*.config.ts', '**/*.config.js', '**/index.ts', '**/*.d.ts'], + all: true, + lines: 75, + functions: 75, + branches: 70, + statements: 75, + }, + include: ['src/**/*.{test,spec}.{ts,tsx}', '__tests__/**/*.{test,spec}.{ts,tsx}'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a99b630ca..0c7a4740b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,17 +46,17 @@ catalogs: specifier: ^5.22.0 version: 5.22.0 '@storybook/addon-docs': - specifier: ^10.2.3 - version: 10.2.3 + specifier: ^10.2.10 + version: 10.2.13 '@storybook/addon-links': - specifier: ^10.2.3 - version: 10.2.3 + specifier: ^10.2.10 + version: 10.2.13 '@storybook/addon-styling-webpack': specifier: ^3.0.0 version: 3.0.0 '@storybook/react-webpack5': - specifier: ^10.2.3 - version: 10.2.3 + specifier: ^10.2.10 + version: 10.2.13 '@tabler/icons-react': specifier: ^3.35.0 version: 3.35.0 @@ -130,8 +130,8 @@ catalogs: specifier: ^9.39.2 version: 9.39.2 eslint-plugin-storybook: - specifier: ^10.2.3 - version: 10.2.3 + specifier: ^10.2.10 + version: 10.2.13 handlebars: specifier: ^4.7.8 version: 4.7.8 @@ -199,8 +199,8 @@ catalogs: specifier: ^1.5.1 version: 1.7.4 storybook: - specifier: ^10.2.3 - version: 10.2.3 + specifier: ^10.2.10 + version: 10.2.13 style-loader: specifier: ^4.0.0 version: 4.0.0 @@ -256,16 +256,16 @@ importers: version: 7.28.5(@babel/core@7.28.6) '@storybook/addon-docs': specifier: 'catalog:' - version: 10.2.3(@types/react@19.2.7)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) + version: 10.2.13(@types/react@19.2.7)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) '@storybook/addon-links': specifier: 'catalog:' - version: 10.2.3(react@19.2.4)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 10.2.13(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@storybook/addon-styling-webpack': specifier: 'catalog:' - version: 3.0.0(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) + version: 3.0.0(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) '@storybook/react-webpack5': specifier: 'catalog:' - version: 10.2.3(@swc/core@1.13.5)(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + version: 10.2.13(@swc/core@1.13.5)(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -274,13 +274,13 @@ importers: version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@vitejs/plugin-react': specifier: 'catalog:' - version: 4.7.0(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/ui': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18) @@ -295,7 +295,7 @@ importers: version: 7.1.3(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) eslint-plugin-storybook: specifier: 'catalog:' - version: 10.2.3(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + version: 10.2.13(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) happy-dom: specifier: ^20.4.0 version: 20.4.0 @@ -325,7 +325,7 @@ importers: version: 6.1.2 storybook: specifier: 'catalog:' - version: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) style-loader: specifier: 'catalog:' version: 4.0.0(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) @@ -337,13 +337,13 @@ importers: version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) tsup: specifier: 'catalog:' - version: 8.5.1(@swc/core@1.13.5)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@swc/core@1.13.5)(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) turbo: specifier: ^2.8.0 version: 2.8.0 vitest: specifier: 'catalog:' - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.1.0)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.1.0)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@1.21.7)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) webpack: specifier: ^5.104.1 version: 5.104.1(@swc/core@1.13.5)(esbuild@0.27.2) @@ -431,13 +431,13 @@ importers: version: 10.4.21(postcss@8.5.6) eslint: specifier: 'catalog:' - version: 9.39.2(jiti@1.21.7) + version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: ^16.1.1 - version: 16.1.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 16.1.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) localflare: specifier: 'catalog:' - version: 0.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(vite@7.3.1(@types/node@20.19.28)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(vite@7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) postcss: specifier: 'catalog:' version: 8.5.6 @@ -543,6 +543,9 @@ importers: '@ottabase/ottaorm': specifier: workspace:* version: link:../../packages/ottaorm + '@ottabase/ottaport': + specifier: workspace:* + version: link:../../packages/ottaport '@ottabase/ottarenderer': specifier: workspace:* version: link:../../packages/ottarenderer @@ -1519,6 +1522,19 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/ottaport: + dependencies: + '@ottabase/ottaorm': + specifier: workspace:* + version: link:../ottaorm + devDependencies: + drizzle-orm: + specifier: 'catalog:' + version: 0.38.4(@cloudflare/workers-types@4.20251225.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/pg@8.15.6)(@types/react@19.2.7)(prisma@5.22.0)(react@19.2.4) + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/ottarenderer: dependencies: '@ottabase/ui-code-highlight': @@ -6544,16 +6560,16 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-docs@10.2.3': - resolution: {integrity: sha512-IPprt2qp4HN1uyE1Ki1sH0ZOE5B6z5sKzEMfrKMGokYKYk/AAJVfSiVIKju3q525GrBFlNhRW2+fB4pQfklv2w==} + '@storybook/addon-docs@10.2.13': + resolution: {integrity: sha512-puMxpJbt/CuodLIbKDxWrW1ZgADYomfNHWEKp2d2l2eJjp17rADx0h3PABuNbX+YHbJwYcDdqluSnQwMysFEOA==} peerDependencies: - storybook: ^10.2.3 + storybook: ^10.2.13 - '@storybook/addon-links@10.2.3': - resolution: {integrity: sha512-ewOUga9zhcGQRGTTl7PyaV8kwLL4Jj1oeXWF2fq4fx+Fhzcn+d99gu3uV+zrGZa1gueBIRwf+p6NJTO//xSVUw==} + '@storybook/addon-links@10.2.13': + resolution: {integrity: sha512-8wnAomGiHaUpNIc+lOzmazTrebxa64z9rihIbM/Q59vkOImHQNkGp7KP/qNgJA4GPTFtu8+fLjX2qCoAQPM0jQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.3 + storybook: ^10.2.13 peerDependenciesMeta: react: optional: true @@ -6564,26 +6580,26 @@ packages: storybook: ^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 webpack: ^5.0.0 - '@storybook/builder-webpack5@10.2.3': - resolution: {integrity: sha512-H6lp7Mc5aTZjVefXfPSxI4ZuimnOEwui/DfEvKC7Rhlig21O501F9+tHl+zHIhrrcXheYxqZCmARJ/hAYwO/0g==} + '@storybook/builder-webpack5@10.2.13': + resolution: {integrity: sha512-LVQPuCiadOvCgyhkF2Y70D5bxdzsD7Ib8YXrGoiLYRGtv9m5ZTh91ky5cEJEXxOvkD19acLZ4TfRc2KwERDaqQ==} peerDependencies: - storybook: ^10.2.3 + storybook: ^10.2.13 typescript: '*' peerDependenciesMeta: typescript: optional: true - '@storybook/core-webpack@10.2.3': - resolution: {integrity: sha512-pqlPj9mSv0rtTFgz+ok3YT2LPIi/CPn9p0XEUhdE8a/vP80ne8CpkAS1jZ3ceY5awfwXCTaz5ROKBx7tc+Q1Kw==} + '@storybook/core-webpack@10.2.13': + resolution: {integrity: sha512-xGud9eeRe3hMNdS3yO2UFHTMMu80rNKpHdejBH6J6+5HGq2BSegaXpmGwRim+SF7BkXN70P8RQrKnWOgwMx9ug==} peerDependencies: - storybook: ^10.2.3 + storybook: ^10.2.13 - '@storybook/csf-plugin@10.2.3': - resolution: {integrity: sha512-/b/C8C40ukzXs3Xauud2+yOJqwBdOkADfRtJ9O4TzrhftzkEdqsNI03xXZySeh7eXW8eI3Vq4t75Ljuj27Xytw==} + '@storybook/csf-plugin@10.2.13': + resolution: {integrity: sha512-gUCR7PmyrWYj3dIJJgxOm25dcXFolPIUPmug3z90Aaon7YPXw3pUN+dNDx8KqDJqRK1WDIB4HaefgYZIm5V7iA==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.2.3 + storybook: ^10.2.13 vite: '*' webpack: '*' peerDependenciesMeta: @@ -6605,12 +6621,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/preset-react-webpack@10.2.3': - resolution: {integrity: sha512-q49cmtDwb/6EdnwA0+jSYFi2V+ki0imiBA5SnCO3EEJK8dIXxevC0elt6PG5QPJtKSZWBclrdR2iAo95ALu7hA==} + '@storybook/preset-react-webpack@10.2.13': + resolution: {integrity: sha512-i3BhcnAGzSif7E/Jl4bAF0rdU9UfMafHohUXZlHvbTqV+SdLkev6DZDIwJbehZu6pBLJatn5TGNmbvvl/Dlskw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.3 + storybook: ^10.2.13 typescript: '*' peerDependenciesMeta: typescript: @@ -6622,30 +6638,30 @@ packages: typescript: '>= 4.x' webpack: '>= 4' - '@storybook/react-dom-shim@10.2.3': - resolution: {integrity: sha512-xMZXvjfQCsmzOTqFCRQ1/gxs//jDGLlnmBCikH4NSGPPogRPaNUkxgdNjOResd6pB+G3ZYAOspJkmGEEbq8dVw==} + '@storybook/react-dom-shim@10.2.13': + resolution: {integrity: sha512-ZSduoB10qTI0V9z22qeULmQLsvTs8d/rtJi03qbVxpPiMRor86AmyAaBrfhGGmWBxWQZpOGQQm6yIT2YLoPs7w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.3 + storybook: ^10.2.13 - '@storybook/react-webpack5@10.2.3': - resolution: {integrity: sha512-FhG4lJgX4WExF7/QzZIsoOb9smIQiuvn6yUzLPn3VhtxQVs34ZOM+6XC5/tiVX9XtCLLWDai1O1kxbS1QCHEJA==} + '@storybook/react-webpack5@10.2.13': + resolution: {integrity: sha512-3gBm7ZgDQ869R/lD5GsHf9GwZO6cMDi86Tu36XhZkrVmvyeh9zFvMDHwkOqEWe5qU+tLUCDWF+UPTDAPyvyujQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.3 + storybook: ^10.2.13 typescript: '>= 4.9.x' peerDependenciesMeta: typescript: optional: true - '@storybook/react@10.2.3': - resolution: {integrity: sha512-M67G7IY9TcLQQJ/9mHPItIiNvZFyuXf5r/wBY03YGquwCqo4GtLdp9uyGg3uCc2i0dS5VV5OQenisldmdjWFWQ==} + '@storybook/react@10.2.13': + resolution: {integrity: sha512-gavZbGMkrjR53a6gSaBJPCelXQf8Rumpej9Jm6HdrAYlEJgFssPah5Frbar9yVCZiXiZkFLfAu7RkZzZhnGyZg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.3 + storybook: ^10.2.13 typescript: '>= 4.9.x' peerDependenciesMeta: typescript: @@ -8492,11 +8508,11 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-plugin-storybook@10.2.3: - resolution: {integrity: sha512-5wy+OKe6VexZecAedroKv+GR+agciZqK/Su7cdo6b1mICWaWwejU/XjjTLL9zr6wiEjCN/0mhYg7yz70DoaMQQ==} + eslint-plugin-storybook@10.2.13: + resolution: {integrity: sha512-ftNfZVL5zXhGMPEy/7PTCEriVH0zCBI89uiYYgSSTtM1b4l++VP+/MzJ17U1R1/jgENsp9LJm+jwRJnViv79RQ==} peerDependencies: eslint: '>=8' - storybook: ^10.2.3 + storybook: ^10.2.13 eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} @@ -10859,8 +10875,8 @@ packages: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} - storybook@10.2.3: - resolution: {integrity: sha512-kjsJ0hctkTO0ipHiyv1MY39wP4tAyVM7rPQGyVMU1iQ7NYHxthiiCHhFB/szmVjXdJa58fu3ZH5cwENMn8Y5eA==} + storybook@10.2.13: + resolution: {integrity: sha512-heMfJjOfbHvL+wlCAwFZlSxcakyJ5yQDam6e9k2RRArB1veJhRnsjO6lO1hOXjJYrqxfHA/ldIugbBVlCDqfvQ==} hasBin: true peerDependencies: prettier: ^2 || ^3 @@ -17014,15 +17030,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.2.3(@types/react@19.2.7)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2))': + '@storybook/addon-docs@10.2.13(@types/react@19.2.7)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.7)(react@19.2.4) - '@storybook/csf-plugin': 10.2.3(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) + '@storybook/csf-plugin': 10.2.13(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/react-dom-shim': 10.2.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@storybook/react-dom-shim': 10.2.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' @@ -17031,21 +17047,21 @@ snapshots: - vite - webpack - '@storybook/addon-links@10.2.3(react@19.2.4)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-links@10.2.13(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: '@storybook/global': 5.0.0 - storybook: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) optionalDependencies: react: 19.2.4 - '@storybook/addon-styling-webpack@3.0.0(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2))': + '@storybook/addon-styling-webpack@3.0.0(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2))': dependencies: - storybook: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) webpack: 5.104.1(@swc/core@1.13.5)(esbuild@0.27.2) - '@storybook/builder-webpack5@10.2.3(@swc/core@1.13.5)(esbuild@0.27.2)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': + '@storybook/builder-webpack5@10.2.13(@swc/core@1.13.5)(esbuild@0.27.2)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': dependencies: - '@storybook/core-webpack': 10.2.3(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@storybook/core-webpack': 10.2.13(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) case-sensitive-paths-webpack-plugin: 2.4.0 cjs-module-lexer: 1.4.3 css-loader: 7.1.3(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) @@ -17053,7 +17069,7 @@ snapshots: fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) html-webpack-plugin: 5.6.6(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) magic-string: 0.30.21 - storybook: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) style-loader: 4.0.0(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) terser-webpack-plugin: 5.3.16(@swc/core@1.13.5)(esbuild@0.27.2)(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) ts-dedent: 2.2.0 @@ -17070,19 +17086,19 @@ snapshots: - uglify-js - webpack-cli - '@storybook/core-webpack@10.2.3(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/core-webpack@10.2.13(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: - storybook: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 - '@storybook/csf-plugin@10.2.3(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2))': + '@storybook/csf-plugin@10.2.13(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2))': dependencies: - storybook: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.2 rollup: 4.57.1 - vite: 7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) webpack: 5.104.1(@swc/core@1.13.5)(esbuild@0.27.2) '@storybook/global@5.0.0': {} @@ -17092,9 +17108,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@storybook/preset-react-webpack@10.2.3(@swc/core@1.13.5)(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': + '@storybook/preset-react-webpack@10.2.13(@swc/core@1.13.5)(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': dependencies: - '@storybook/core-webpack': 10.2.3(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@storybook/core-webpack': 10.2.13(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.13.5)(esbuild@0.27.2)) '@types/semver': 7.7.1 magic-string: 0.30.21 @@ -17103,7 +17119,7 @@ snapshots: react-dom: 19.2.4(react@19.2.4) resolve: 1.22.11 semver: 7.7.3 - storybook: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tsconfig-paths: 4.2.0 webpack: 5.104.1(@swc/core@1.13.5)(esbuild@0.27.2) optionalDependencies: @@ -17129,20 +17145,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@storybook/react-dom-shim@10.2.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/react-dom-shim@10.2.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/react-webpack5@10.2.3(@swc/core@1.13.5)(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': + '@storybook/react-webpack5@10.2.13(@swc/core@1.13.5)(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': dependencies: - '@storybook/builder-webpack5': 10.2.3(@swc/core@1.13.5)(esbuild@0.27.2)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) - '@storybook/preset-react-webpack': 10.2.3(@swc/core@1.13.5)(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) - '@storybook/react': 10.2.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@storybook/builder-webpack5': 10.2.13(@swc/core@1.13.5)(esbuild@0.27.2)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@storybook/preset-react-webpack': 10.2.13(@swc/core@1.13.5)(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@storybook/react': 10.2.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -17153,14 +17169,14 @@ snapshots: - uglify-js - webpack-cli - '@storybook/react@10.2.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': + '@storybook/react@10.2.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.2.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@storybook/react-dom-shim': 10.2.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) react: 19.2.4 react-docgen: 8.0.2 react-dom: 19.2.4(react@19.2.4) - storybook: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -17310,12 +17326,12 @@ snapshots: tailwindcss: 4.1.18 vite: 5.4.21(@types/node@20.19.28)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0) - '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@20.19.28)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.1(@types/node@20.19.28)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@tanstack/history@1.145.7': {} @@ -17555,15 +17571,15 @@ snapshots: dependencies: '@types/node': 25.1.0 - '@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.52.0 - '@typescript-eslint/type-utils': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.52.0 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -17571,15 +17587,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.54.0 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -17587,26 +17603,26 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.52.0 '@typescript-eslint/types': 8.52.0 '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.52.0 debug: 4.4.3 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/types': 8.54.0 '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.54.0 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -17647,25 +17663,25 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.52.0 '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.54.0 '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -17705,24 +17721,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.52.0 '@typescript-eslint/types': 8.52.0 '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/types': 8.54.0 '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -17820,7 +17836,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) @@ -17828,7 +17844,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -17849,13 +17865,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -17891,7 +17907,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.1.0)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.1.0)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@1.21.7)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: @@ -19101,18 +19117,18 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.1.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): + eslint-config-next@16.1.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.1.1 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@1.21.7)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) globals: 16.4.0 - typescript-eslint: 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + typescript-eslint: 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -19129,33 +19145,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) get-tsconfig: 4.13.1 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -19164,9 +19180,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -19178,13 +19194,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -19194,7 +19210,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -19203,18 +19219,18 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): dependencies: '@babel/core': 7.28.6 '@babel/parser': 7.28.6 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) hermes-parser: 0.25.1 zod: 3.25.76 zod-validation-error: 4.0.2(zod@3.25.76) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -19222,7 +19238,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.39.2(jiti@1.21.7) + eslint: 9.39.2(jiti@2.6.1) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -19236,11 +19252,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@10.2.3(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3): + eslint-plugin-storybook@10.2.13(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) - storybook: 10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + storybook: 10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - supports-color - typescript @@ -20387,7 +20403,7 @@ snapshots: - '@types/react-dom' - vite - localflare-dashboard@0.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(vite@7.3.1(@types/node@20.19.28)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + localflare-dashboard@0.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(vite@7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@base-ui/react': 1.0.0(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@fontsource-variable/figtree': 5.2.10 @@ -20398,7 +20414,7 @@ snapshots: '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.4) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tailwindcss/vite': 4.1.18(vite@7.3.1(@types/node@20.19.28)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@tailwindcss/vite': 4.1.18(vite@7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/react-query': 5.90.16(react@19.2.4) '@tanstack/react-table': 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: 0.7.1 @@ -20438,11 +20454,11 @@ snapshots: - utf-8-validate - vite - localflare@0.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(vite@7.3.1(@types/node@20.19.28)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + localflare@0.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(vite@7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: cac: 6.7.14 localflare-core: 0.1.2 - localflare-dashboard: 0.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(vite@7.3.1(@types/node@20.19.28)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + localflare-dashboard: 0.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(vite@7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) localflare-server: 0.1.2 picocolors: 1.1.1 transitivePeerDependencies: @@ -21934,7 +21950,7 @@ snapshots: stoppable@1.1.0: {} - storybook@10.2.3(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -22294,6 +22310,35 @@ snapshots: tslib@2.8.1: {} + tsup@8.5.1(@swc/core@1.13.5)(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.2) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.2 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + resolve-from: 5.0.0 + rollup: 4.57.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.13.5 + postcss: 8.5.6 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsup@8.5.1(@swc/core@1.13.5)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: bundle-require: 5.1.0(esbuild@0.27.2) @@ -22405,13 +22450,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): + typescript-eslint@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -22592,7 +22637,7 @@ snapshots: sugarss: 5.0.1(postcss@8.5.6) terser: 5.46.0 - vite@7.3.1(@types/node@20.19.28)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -22603,14 +22648,14 @@ snapshots: optionalDependencies: '@types/node': 20.19.28 fsevents: 2.3.3 - jiti: 1.21.7 + jiti: 2.6.1 lightningcss: 1.30.2 sugarss: 5.0.1(postcss@8.5.6) terser: 5.46.0 tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -22619,9 +22664,9 @@ snapshots: rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 20.19.28 + '@types/node': 25.1.0 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 1.21.7 lightningcss: 1.30.2 sugarss: 5.0.1(postcss@8.5.6) terser: 5.46.0 @@ -22649,7 +22694,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.28)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -22687,10 +22732,51 @@ snapshots: - tsx - yaml + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.1.0)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@1.21.7)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.1.0 + '@vitest/ui': 4.0.18(vitest@4.0.18) + happy-dom: 20.4.0 + jsdom: 27.4.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.1.0)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6e79b322f..5e37a19ab 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,10 +17,10 @@ catalog: "@mantine/notifications": ^8.3.15 "@prisma/adapter-d1": ^5.22.0 "@prisma/client": ^5.22.0 - "@storybook/addon-docs": ^10.2.3 - "@storybook/addon-links": ^10.2.3 + "@storybook/addon-docs": ^10.2.10 + "@storybook/addon-links": ^10.2.10 "@storybook/addon-styling-webpack": ^3.0.0 - "@storybook/react-webpack5": ^10.2.3 + "@storybook/react-webpack5": ^10.2.10 "@storybook/test": ^9.1.15 "@tabler/icons-react": ^3.35.0 "@tailwindcss/forms": ^0.5.10 @@ -47,7 +47,7 @@ catalog: drizzle-kit: ^0.31.8 drizzle-orm: ^0.38.3 eslint: ^9.39.2 - eslint-plugin-storybook: ^10.2.3 + eslint-plugin-storybook: ^10.2.10 handlebars: ^4.7.8 highlight.js: ^11.11.1 husky: ^9.1.7 @@ -70,7 +70,7 @@ catalog: react-hook-form: ^7.54.2 rimraf: ^6.1.2 sonner: ^1.5.1 - storybook: ^10.2.3 + storybook: ^10.2.10 style-loader: ^4.0.0 tailwind-merge: ^2.6.0 tailwindcss: ^3.4.19