diff --git a/apps/ottabase-template-app-tanstack/package.json b/apps/ottabase-template-app-tanstack/package.json index 853521291..67f927512 100644 --- a/apps/ottabase-template-app-tanstack/package.json +++ b/apps/ottabase-template-app-tanstack/package.json @@ -30,6 +30,7 @@ "@ottabase/analytics": "workspace:*", "@ottabase/api": "workspace:*", "@ottabase/auth": "workspace:*", + "@ottabase/backups": "workspace:*", "@ottabase/brand-engine": "workspace:*", "@ottabase/brand-engine-react": "workspace:*", "@ottabase/cf": "workspace:*", diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBackupsPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBackupsPage.tsx new file mode 100644 index 000000000..ee0cee909 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBackupsPage.tsx @@ -0,0 +1,590 @@ +import { useState } from 'react'; +import { Link } from '@tanstack/react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@ottabase/ui-shadcn'; +import { api, isApiError } from '@/lib/api'; +import { + ArrowLeft, + RefreshCw, + Download, + Trash2, + Plus, + HardDrive, + Database, + Clock, + AlertTriangle, + CheckCircle, + XCircle, + Shield, + Settings, + Save, +} from 'lucide-react'; +import { toast } from 'sonner'; + +// ============================================================ +// Types (matching @ottabase/backups) +// ============================================================ + +interface BackupMetadata { + id: string; + createdAt: string; + sizeBytes: number; + tableCount: number; + totalRows: number; + tables: string[]; + type: 'full' | 'diff'; + label?: string; + durationMs: number; + contentHash: string; + filename?: string; +} + +interface BackupSetupStatus { + d1Configured: boolean; + r2Configured: boolean; + hasBackups: boolean; + cronConfigured: boolean; + pendingItems: string[]; + ready: boolean; +} + +interface BackupsResponse { + backups: BackupMetadata[]; + stats: { + totalBackups: number; + totalSizeBytes: number; + oldestBackup: string | null; + newestBackup: string | null; + }; + setup: BackupSetupStatus; +} + +interface BackupSettings { + retentionDays: number; + schedule: string | null; + scheduleActive: boolean; + taskId: string | null; + taskName: string | null; +} + +// ============================================================ +// Helpers +// ============================================================ + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +function timeAgo(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diff = now - then; + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return new Date(dateStr).toLocaleDateString(); +} + +const SCHEDULE_PRESETS = [ + { label: 'Daily at 2:00 AM UTC', value: '0 2 * * *' }, + { label: 'Daily at 4:00 AM UTC', value: '0 4 * * *' }, + { label: 'Daily at midnight UTC', value: '0 0 * * *' }, + { label: 'Every 12 hours', value: '0 */12 * * *' }, + { label: 'Every 6 hours', value: '0 */6 * * *' }, + { label: 'Weekly (Sunday 3 AM UTC)', value: '0 3 * * 0' }, +]; + +// ============================================================ +// Component +// ============================================================ + +export function AdminBackupsPage() { + const queryClient = useQueryClient(); + const [deleteDialog, setDeleteDialog] = useState<{ id: string; label?: string } | null>(null); + const [showSettings, setShowSettings] = useState(false); + const [settingsForm, setSettingsForm] = useState({ + schedule: '', + retentionDays: 30, + }); + + // Fetch backups + setup status + const { + data: response, + isLoading, + refetch, + } = useQuery({ + queryKey: ['admin', 'backups'], + queryFn: () => api('/api/admin/backups'), + refetchInterval: 30000, + }); + + // Fetch backup settings + const { data: settings, refetch: refetchSettings } = useQuery({ + queryKey: ['admin', 'backups', 'settings'], + queryFn: () => api('/api/admin/backups/settings'), + refetchOnMount: true, + }); + + // Sync settings form when data loads + const syncSettingsForm = () => { + if (settings) { + setSettingsForm({ + schedule: settings.schedule || '0 2 * * *', + retentionDays: settings.retentionDays || 30, + }); + } + }; + + // Create backup mutation + const createMutation = useMutation({ + mutationFn: (label?: string) => + api<{ success: boolean; metadata: BackupMetadata }>('/api/admin/backups', { + method: 'POST', + body: { label: label || 'manual' }, + }), + onSuccess: () => { + toast.success('Backup created successfully'); + queryClient.invalidateQueries({ queryKey: ['admin', 'backups'] }); + }, + onError: (error) => { + toast.error(isApiError(error) ? error.message : 'Failed to create backup'); + }, + }); + + // Delete backup mutation + const deleteMutation = useMutation({ + mutationFn: (backupId: string) => api(`/api/admin/backups/${backupId}`, { method: 'DELETE' }), + onSuccess: () => { + toast.success('Backup deleted'); + setDeleteDialog(null); + queryClient.invalidateQueries({ queryKey: ['admin', 'backups'] }); + }, + onError: (error) => { + toast.error(isApiError(error) ? error.message : 'Failed to delete backup'); + }, + }); + + // Save settings mutation + const saveSettingsMutation = useMutation({ + mutationFn: (data: { schedule?: string; retentionDays?: number; isActive?: boolean }) => + api('/api/admin/backups/settings', { method: 'PUT', body: data }), + onSuccess: () => { + toast.success('Backup settings saved'); + queryClient.invalidateQueries({ queryKey: ['admin', 'backups'] }); + refetchSettings(); + }, + onError: (error) => { + toast.error(isApiError(error) ? error.message : 'Failed to save settings'); + }, + }); + + const handleDownload = (backup: BackupMetadata) => { + const link = document.createElement('a'); + link.href = `/api/admin/backups/${backup.id}`; + link.download = backup.filename || `backup-${backup.id}.sql`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleSaveSettings = () => { + saveSettingsMutation.mutate({ + schedule: settingsForm.schedule, + retentionDays: settingsForm.retentionDays, + isActive: true, + }); + }; + + const setup = response?.setup; + const stats = response?.stats; + const backups = response?.backups ?? []; + + return ( +
+ + +
+
+

Database Backups

+

Automated D1→R2 database backups with retention management

+
+
+ + + +
+
+ + {/* Pending Setup Banner */} + {setup && !setup.ready && ( + + + + + Setup Required + + + Some items need your attention before automated backups are fully operational. + + + +
    + {setup.pendingItems.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+
+ )} + + {/* Settings Panel */} + {showSettings && ( + + + + + Backup Settings + + + Configure automated backup schedule and retention policy. + {settings?.taskName && ( + + Current task: {settings.taskName} + {settings.scheduleActive ? ' (active)' : ' (paused)'} + + )} + + + +
+
+ + +

+ Cron expression:{' '} + {settingsForm.schedule} +

+
+
+ + + setSettingsForm((s) => ({ + ...s, + retentionDays: Math.max(1, Math.min(365, parseInt(e.target.value) || 30)), + })) + } + /> +

+ Backups older than this will be automatically deleted (1–365 days). +

+
+
+
+ +
+
+
+ )} + + {/* Setup Status Checklist */} + {setup && ( +
+ + + + + D1 Database + + + {setup.d1Configured ? ( + + Connected + + ) : ( + + Not Configured + + )} + + + + + + + + + R2 Storage + + + {setup.r2Configured ? ( + + Connected + + ) : ( + + Not Configured + + )} + + + + + + + + + Scheduled Backup + + + {setup.cronConfigured ? ( + + Active + + ) : ( + + Not Set + + )} + + + {!setup.cronConfigured && ( + + + + )} + + + + + + + Backups + + + {stats?.totalBackups ?? 0} + + ({formatBytes(stats?.totalSizeBytes ?? 0)}) + + + + +
+ )} + + {/* Backups List */} + + + Backup History + + {stats?.newestBackup ? `Latest backup: ${timeAgo(stats.newestBackup)}` : 'No backups yet'} + + + + {isLoading ? ( +

Loading...

+ ) : backups.length === 0 ? ( +
+ +

+ No backups yet. Click "Backup Now" to create your first backup. +

+
+ ) : ( +
+ {backups.map((backup) => ( +
+
+
+ + {backup.filename || backup.label || 'Backup'} + + + {backup.type} + + + {timeAgo(backup.createdAt)} + +
+
+ {backup.tableCount} tables + {backup.totalRows.toLocaleString()} rows + {formatBytes(backup.sizeBytes)} + {backup.durationMs}ms + + {new Date(backup.createdAt).toLocaleString()} + +
+ {backup.tables.length > 0 && ( +
+ + View tables ({backup.tables.length}) + +
+ {backup.tables.map((t) => ( + + {t} + + ))} +
+
+ )} +
+ +
+ + +
+
+ ))} +
+ )} +
+
+ + {/* Delete Confirmation Dialog */} + !open && setDeleteDialog(null)}> + + + Delete Backup? + + This will permanently delete the backup + {deleteDialog?.label ? ` "${deleteDialog.label}"` : ''}. This action cannot be undone. + + + + Cancel + deleteDialog && deleteMutation.mutate(deleteDialog.id)} + disabled={deleteMutation.isPending} + > + {deleteMutation.isPending ? 'Deleting...' : 'Delete'} + + + + +
+ ); +} 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..6aa3f4104 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx @@ -9,6 +9,7 @@ import { Clock, Database, FileText, + HardDrive, Layers, Layout, Palette, @@ -133,6 +134,13 @@ export function AdminIndexPage() { icon: Clock, disabled: false, }, + { + title: 'Database Backups', + description: 'Manage automated D1→R2 database backups, view history, and download backups.', + href: '/admin/backups', + icon: HardDrive, + disabled: false, + }, { title: 'Notifications', description: 'Send notifications to users and broadcast system alerts to administrators.', diff --git a/apps/ottabase-template-app-tanstack/src/router.tsx b/apps/ottabase-template-app-tanstack/src/router.tsx index d8ef7b155..cd4074681 100644 --- a/apps/ottabase-template-app-tanstack/src/router.tsx +++ b/apps/ottabase-template-app-tanstack/src/router.tsx @@ -701,6 +701,17 @@ const adminCronRoute = new Route({ ), }); +// Admin Backups route +const adminBackupsRoute = new Route({ + getParentRoute: () => rootRoute, + path: '/admin/backups', + component: lazyRouteComponent(() => + import('@/pages/admin/AdminBackupsPage').then((m) => ({ + default: () => renderAdminRoute(), + })), + ), +}); + // Admin Notifications route const adminNotificationsRoute = new Route({ getParentRoute: () => rootRoute, @@ -998,6 +1009,7 @@ const coreRoutes = [ adminRoute, adminQueueRoute, adminCronRoute, + adminBackupsRoute, adminNotificationsRoute, adminDbRoute, adminRBACRoute, diff --git a/apps/ottabase-template-app-tanstack/worker/routes/admin-backups.ts b/apps/ottabase-template-app-tanstack/worker/routes/admin-backups.ts new file mode 100644 index 000000000..1332efeb2 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/worker/routes/admin-backups.ts @@ -0,0 +1,307 @@ +/** + * Admin Backup API Routes + * + * Endpoints for managing D1→R2 database backups: + * - GET /api/admin/backups — List backups + setup status + * - POST /api/admin/backups — Create a new backup + * - GET /api/admin/backups/settings — Get backup settings + * - PUT /api/admin/backups/settings — Update backup settings (retentionDays, schedule) + * - GET /api/admin/backups/:id — Download backup SQL + * - DELETE /api/admin/backups/:id — Delete a backup + */ + +import { createBackupService, type D1Like, type R2Like } from '@ottabase/backups'; +import { ScheduledTask } from '@ottabase/ottaorm/models'; +import { errorResponse } from '@ottabase/utils/http-errors'; +import { jsonResponse } from '@ottabase/utils/http-response'; +import { getOttabaseConfig } from '../../ottabase/config.loader'; +import { requireAdminAccess } from '../lib/admin-guard'; +import { initAdminCron } from '../lib/db-utils'; +import { readJson } from '../lib/utils'; +import type { ApiRouteContext } from './router'; + +/** Shared helper: create a BackupService from env bindings */ +function getBackupService(env: ApiRouteContext['env'], overrides?: { retentionDays?: number }) { + if (!env.OBCF_D1) { + return { error: errorResponse('D1 database binding not configured', 500, { code: 'CONFIG_ERROR' }) }; + } + if (!env.OBCF_R2) { + return { error: errorResponse('R2 bucket binding not configured', 500, { code: 'CONFIG_ERROR' }) }; + } + const config = getOttabaseConfig(env); + const appName = config.appName || config.appId || 'ottabase'; + return { + service: createBackupService(env.OBCF_D1 as unknown as D1Like, env.OBCF_R2 as unknown as R2Like, { + appName, + ...(overrides?.retentionDays ? { retentionDays: overrides.retentionDays } : {}), + }), + }; +} + +/** Extract retentionDays from the backup cron task's payload, or default 30 */ +function getRetentionDaysFromTask(task: ScheduledTask | null): number { + if (!task) return 30; + try { + const payload = String(task.get('payload') ?? ''); + if (payload) { + const parsed = JSON.parse(payload); + if (typeof parsed.retentionDays === 'number' && parsed.retentionDays >= 1 && parsed.retentionDays <= 365) { + return parsed.retentionDays; + } + } + } catch { + // Invalid JSON in payload + } + return 30; +} + +/** Find the backup:database cron task if it exists */ +async function findBackupCronTask(env: ApiRouteContext['env']): Promise { + try { + const initErr = initAdminCron(env); + if (initErr) return null; + + const tasks = await ScheduledTask.all(); + return ( + (tasks.find( + (t) => + String(t.get('task')).includes('backup') || String(t.get('name')).toLowerCase().includes('backup'), + ) as ScheduledTask | undefined) ?? null + ); + } catch { + return null; + } +} + +/** Check if a backup:database cron job is configured and active */ +async function isCronConfigured(env: ApiRouteContext['env']): Promise { + const task = await findBackupCronTask(env); + return task !== null && !!task.get('isActive'); +} + +/** + * GET /api/admin/backups — List all backups with setup status + */ +export async function handleAdminBackupsList(context: ApiRouteContext): Promise { + const auth = await requireAdminAccess(context, { scope: 'system' }); + if (auth instanceof Response) return auth; + + const { service, error } = getBackupService(context.env); + if (error) return error; + + try { + const [backupList, cronConfigured] = await Promise.all([service!.listBackups(), isCronConfigured(context.env)]); + + const setupStatus = await service!.checkSetup({ cronConfigured }); + + return jsonResponse({ + ...backupList, + setup: setupStatus, + }); + } catch (err) { + return errorResponse(err instanceof Error ? err.message : 'Failed to list backups', 500, { + code: 'BACKUP_LIST_ERROR', + }); + } +} + +/** + * POST /api/admin/backups — Create a new backup + */ +export async function handleAdminBackupsCreate(context: ApiRouteContext): Promise { + const auth = await requireAdminAccess(context, { scope: 'system' }); + if (auth instanceof Response) return auth; + + // Read retentionDays from cron task payload so enforcement uses saved setting + const task = await findBackupCronTask(context.env); + const retentionDays = getRetentionDaysFromTask(task); + + const { service, error } = getBackupService(context.env, { retentionDays }); + if (error) return error; + + const body = await readJson<{ label?: string }>(context.request); + + try { + const result = await service!.createBackup({ + label: body.label || 'manual', + type: 'full', + }); + + if (!result.success) { + return errorResponse(result.error || 'Backup failed', 500, { code: 'BACKUP_CREATE_ERROR' }); + } + + return jsonResponse(result, 201); + } catch (err) { + return errorResponse(err instanceof Error ? err.message : 'Backup failed', 500, { + code: 'BACKUP_CREATE_ERROR', + }); + } +} + +/** + * GET /api/admin/backups/settings — Get current backup settings + */ +export async function handleAdminBackupsSettingsGet(context: ApiRouteContext): Promise { + const auth = await requireAdminAccess(context, { scope: 'system' }); + if (auth instanceof Response) return auth; + + try { + const initErr = initAdminCron(context.env); + + // Find the backup cron task + const task = initErr ? null : await findBackupCronTask(context.env); + + return jsonResponse({ + retentionDays: getRetentionDaysFromTask(task), + schedule: task ? String(task.get('schedule')) : null, + scheduleActive: task ? !!task.get('isActive') : false, + taskId: task ? String(task.get('id')) : null, + taskName: task ? String(task.get('name')) : null, + }); + } catch (err) { + return errorResponse(err instanceof Error ? err.message : 'Failed to get settings', 500, { + code: 'BACKUP_SETTINGS_ERROR', + }); + } +} + +/** + * PUT /api/admin/backups/settings — Update backup settings (schedule, retention) + * Creates or updates a backup:database cron task. + */ +export async function handleAdminBackupsSettingsPut(context: ApiRouteContext): Promise { + const auth = await requireAdminAccess(context, { scope: 'system' }); + if (auth instanceof Response) return auth; + + const body = await readJson<{ + schedule?: string; + retentionDays?: number; + isActive?: boolean; + }>(context.request); + + try { + const initErr = initAdminCron(context.env); + if (initErr) return initErr; + + // Validate schedule if provided + if (body.schedule !== undefined && typeof body.schedule !== 'string') { + return errorResponse('schedule must be a cron expression string', 400, { code: 'VALIDATION_ERROR' }); + } + + // Validate retentionDays + if (body.retentionDays !== undefined) { + if (typeof body.retentionDays !== 'number' || body.retentionDays < 1 || body.retentionDays > 365) { + return errorResponse('retentionDays must be between 1 and 365', 400, { code: 'VALIDATION_ERROR' }); + } + } + + const existingTask = await findBackupCronTask(context.env); + + if (existingTask) { + // Update existing task + if (body.schedule !== undefined) { + existingTask.set('schedule', body.schedule); + } + if (body.isActive !== undefined) { + existingTask.set('isActive', body.isActive); + } + if (body.retentionDays !== undefined) { + const existingPayload = (() => { + try { + return JSON.parse(String(existingTask.get('payload') ?? '{}')) || {}; + } catch { + return {}; + } + })(); + existingPayload.retentionDays = body.retentionDays; + existingTask.set('payload', JSON.stringify(existingPayload)); + } + await existingTask.save(); + return jsonResponse({ + success: true, + message: 'Backup settings updated', + task: existingTask.toJson(), + }); + } else if (body.schedule) { + // Create new backup cron task + const newTask = await ScheduledTask.create({ + name: 'Automated Database Backup', + description: 'Automated D1→R2 database backup', + schedule: body.schedule, + taskType: 'handler', + task: 'backup:database', + payload: body.retentionDays ? JSON.stringify({ retentionDays: body.retentionDays }) : null, + isActive: body.isActive ?? true, + }); + return jsonResponse( + { + success: true, + message: 'Backup schedule created', + task: newTask.toJson(), + }, + 201, + ); + } + + return jsonResponse({ success: true, message: 'No changes made' }); + } catch (err) { + return errorResponse(err instanceof Error ? err.message : 'Failed to update settings', 500, { + code: 'BACKUP_SETTINGS_ERROR', + }); + } +} + +/** + * GET /api/admin/backups/:id — Download backup SQL content + */ +export async function handleAdminBackupDownload(context: ApiRouteContext, backupId: string): Promise { + const auth = await requireAdminAccess(context, { scope: 'system' }); + if (auth instanceof Response) return auth; + + const { service, error } = getBackupService(context.env); + if (error) return error; + + try { + const result = await service!.downloadBackup(backupId); + if (!result.success) { + return errorResponse(result.error || 'Backup not found', 404, { code: 'BACKUP_NOT_FOUND' }); + } + + const filename = result.filename || `backup-${backupId}.sql`; + return new Response(result.content, { + headers: { + 'Content-Type': 'application/sql', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }); + } catch (err) { + return errorResponse(err instanceof Error ? err.message : 'Download failed', 500, { + code: 'BACKUP_DOWNLOAD_ERROR', + }); + } +} + +/** + * DELETE /api/admin/backups/:id — Delete a specific backup + */ +export async function handleAdminBackupDelete(context: ApiRouteContext, backupId: string): Promise { + const auth = await requireAdminAccess(context, { scope: 'system' }); + if (auth instanceof Response) return auth; + + const { service, error } = getBackupService(context.env); + if (error) return error; + + try { + const result = await service!.deleteBackup(backupId); + if (!result.success) { + return errorResponse(result.error || 'Delete failed', 500, { code: 'BACKUP_DELETE_ERROR' }); + } + + return jsonResponse({ success: true, message: 'Backup deleted' }); + } catch (err) { + return errorResponse(err instanceof Error ? err.message : 'Delete failed', 500, { + code: 'BACKUP_DELETE_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..d53a68899 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/router.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/router.ts @@ -4,6 +4,14 @@ import { jsonResponse } from '@ottabase/utils/http-response'; import type { CloudflareEnv } from '../../cloudflare-env'; import { getKillSwitchStatus } from '../lib/killswitch'; import { handleAdminCronCreate, handleAdminCronList, handleCronTask } from './admin-cron'; +import { + handleAdminBackupDelete, + handleAdminBackupDownload, + handleAdminBackupsCreate, + handleAdminBackupsList, + handleAdminBackupsSettingsGet, + handleAdminBackupsSettingsPut, +} from './admin-backups'; import { handleAdminDbRowDelete, handleAdminDbTableData, @@ -182,6 +190,21 @@ async function handleGetRoutes(context: ApiRouteContext): Promise[]> = {}): D1Like { + const tableNames = Object.keys(tables); + + return { + prepare: vi.fn((query: string) => { + // Handle sqlite_master query (list tables) + if (query.includes('sqlite_master')) { + return { + all: vi.fn().mockResolvedValue({ + results: tableNames.map((name) => ({ name })), + }), + bind: vi.fn().mockReturnValue({ + all: vi.fn().mockResolvedValue({ results: [] }), + run: vi.fn().mockResolvedValue({}), + }), + run: vi.fn().mockResolvedValue({}), + }; + } + + // Handle COUNT queries + if (query.includes('COUNT(*)')) { + const tableName = query.match(/FROM "(\w+)"/)?.[1]; + const count = tableName ? (tables[tableName]?.length ?? 0) : 0; + return { + all: vi.fn().mockResolvedValue({ results: [{ count }] }), + bind: vi.fn().mockReturnValue({ + all: vi.fn().mockResolvedValue({ results: [{ count }] }), + run: vi.fn().mockResolvedValue({}), + }), + run: vi.fn().mockResolvedValue({}), + }; + } + + // Handle SELECT * queries (export table) + if (query.startsWith('SELECT *')) { + const tableName = query.match(/FROM "(\w+)"/)?.[1]; + const rows = tableName ? (tables[tableName] ?? []) : []; + return { + all: vi.fn().mockResolvedValue({ results: rows }), + bind: vi.fn().mockReturnValue({ + all: vi.fn().mockResolvedValue({ results: rows }), + run: vi.fn().mockResolvedValue({}), + }), + run: vi.fn().mockResolvedValue({}), + }; + } + + // Default: SELECT 1 for health check + return { + all: vi.fn().mockResolvedValue({ results: [{ '1': 1 }] }), + bind: vi.fn().mockReturnValue({ + all: vi.fn().mockResolvedValue({ results: [] }), + run: vi.fn().mockResolvedValue({}), + }), + run: vi.fn().mockResolvedValue({}), + }; + }), + exec: vi.fn().mockResolvedValue({}), + batch: vi.fn().mockResolvedValue([]), + }; +} + +function createMockR2( + existingObjects: Array<{ key: string; content: string; metadata?: Record }> = [], +): R2Like { + const storage = new Map }>(); + + // Pre-populate storage + for (const obj of existingObjects) { + storage.set(obj.key, { content: obj.content, metadata: obj.metadata ?? {} }); + } + + return { + put: vi.fn(async (key: string, value: string | ArrayBuffer | ReadableStream, options?: any) => { + const content = typeof value === 'string' ? value : ''; + storage.set(key, { + content, + metadata: options?.customMetadata ?? {}, + }); + }), + get: vi.fn(async (key: string) => { + const item = storage.get(key); + if (!item) return null; + return { + text: async () => item.content, + customMetadata: item.metadata, + }; + }), + delete: vi.fn(async (keys: string | string[]) => { + const keyArray = Array.isArray(keys) ? keys : [keys]; + for (const k of keyArray) { + storage.delete(k); + } + }), + list: vi.fn(async (options?: any) => { + const prefix = options?.prefix ?? ''; + const objects = Array.from(storage.entries()) + .filter(([key]) => key.startsWith(prefix)) + .map(([key, val]) => ({ + key, + size: new TextEncoder().encode(val.content).byteLength, + uploaded: new Date(), + customMetadata: val.metadata, + })); + return { + objects, + truncated: false, + cursor: undefined, + }; + }), + head: vi.fn(async (key: string) => { + const item = storage.get(key); + if (!item) return null; + return { + key, + size: new TextEncoder().encode(item.content).byteLength, + customMetadata: item.metadata, + }; + }), + }; +} + +// ============================================================ +// Tests +// ============================================================ + +describe('BackupService', () => { + describe('utility functions', () => { + describe('escapeSqlValue', () => { + it('should escape null values', () => { + expect(escapeSqlValue(null)).toBe('NULL'); + expect(escapeSqlValue(undefined)).toBe('NULL'); + }); + + it('should handle numbers', () => { + expect(escapeSqlValue(42)).toBe('42'); + expect(escapeSqlValue(3.14)).toBe('3.14'); + expect(escapeSqlValue(0)).toBe('0'); + }); + + it('should handle booleans', () => { + expect(escapeSqlValue(true)).toBe('1'); + expect(escapeSqlValue(false)).toBe('0'); + }); + + it('should escape strings with single quotes', () => { + expect(escapeSqlValue('hello')).toBe("'hello'"); + expect(escapeSqlValue("it's")).toBe("'it''s'"); + expect(escapeSqlValue("O'Brien's")).toBe("'O''Brien''s'"); + }); + + it('should handle empty strings', () => { + expect(escapeSqlValue('')).toBe("''"); + }); + }); + + describe('sha256', () => { + it('should produce a 64-char hex hash', async () => { + const hash = await sha256('test content'); + expect(hash).toHaveLength(64); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('should produce consistent hashes for the same input', async () => { + const hash1 = await sha256('hello'); + const hash2 = await sha256('hello'); + expect(hash1).toBe(hash2); + }); + + it('should produce different hashes for different inputs', async () => { + const hash1 = await sha256('hello'); + const hash2 = await sha256('world'); + expect(hash1).not.toBe(hash2); + }); + }); + + describe('formatBytes', () => { + it('should format zero bytes', () => { + expect(formatBytes(0)).toBe('0 B'); + }); + + it('should format bytes', () => { + expect(formatBytes(500)).toBe('500 B'); + }); + + it('should format kilobytes', () => { + expect(formatBytes(1024)).toBe('1 KB'); + expect(formatBytes(1536)).toBe('1.5 KB'); + }); + + it('should format megabytes', () => { + expect(formatBytes(1048576)).toBe('1 MB'); + }); + }); + + describe('isValidTableName', () => { + it('should accept valid table names', () => { + expect(isValidTableName('users')).toBe(true); + expect(isValidTableName('post_tags')).toBe(true); + expect(isValidTableName('_internal')).toBe(true); + expect(isValidTableName('Table123')).toBe(true); + }); + + it('should reject invalid table names', () => { + expect(isValidTableName('')).toBe(false); + expect(isValidTableName('123start')).toBe(false); + expect(isValidTableName('table name')).toBe(false); + expect(isValidTableName('table;DROP')).toBe(false); + expect(isValidTableName('table"name')).toBe(false); + expect(isValidTableName("table'name")).toBe(false); + }); + }); + describe('generateBackupFilename', () => { + it('should generate a timestamped filename with appName', () => { + const date = new Date('2024-06-15T02:30:00Z'); + const filename = generateBackupFilename('my-app', date); + expect(filename).toBe('2024-06-15-023000_my-app.sql'); + }); + + it('should sanitize special characters in appName', () => { + const date = new Date('2024-01-01T00:00:00Z'); + const filename = generateBackupFilename('My App (v2)', date); + expect(filename).toBe('2024-01-01-000000_my_app__v2_.sql'); + }); + + it('should use current date when no date provided', () => { + const filename = generateBackupFilename('test'); + // Should match pattern: yyyy-mm-dd-hhmmss_test.sql + expect(filename).toMatch(/^\d{4}-\d{2}-\d{2}-\d{6}_test\.sql$/); + }); + + it('should handle empty appName', () => { + const date = new Date('2024-01-01T00:00:00Z'); + const filename = generateBackupFilename('', date); + expect(filename).toBe('2024-01-01-000000_.sql'); + }); + }); + }); + + describe('createBackupService', () => { + it('should create a BackupService instance', () => { + const service = createBackupService(createMockD1(), createMockR2()); + expect(service).toBeInstanceOf(BackupService); + }); + }); + + describe('listTables', () => { + it('should return all user tables', async () => { + const db = createMockD1({ + users: [{ id: '1', name: 'Alice' }], + posts: [{ id: '1', title: 'Hello' }], + }); + const service = createBackupService(db, createMockR2()); + + const tables = await service.listTables(); + expect(tables).toEqual(['users', 'posts']); + }); + + it('should exclude configured tables', async () => { + const db = createMockD1({ + users: [], + sessions: [], + temp_data: [], + }); + const service = createBackupService(db, createMockR2(), { + excludeTables: ['temp_data'], + }); + + const tables = await service.listTables(); + expect(tables).toEqual(['users', 'sessions']); + }); + + it('should return empty array for empty database', async () => { + const db = createMockD1({}); + const service = createBackupService(db, createMockR2()); + + const tables = await service.listTables(); + expect(tables).toEqual([]); + }); + }); + + describe('exportTable', () => { + it('should export table data as SQL INSERT statements', async () => { + const db = createMockD1({ + users: [ + { id: '1', name: 'Alice', age: 30 }, + { id: '2', name: 'Bob', age: 25 }, + ], + }); + const service = createBackupService(db, createMockR2()); + + const result = await service.exportTable('users'); + expect(result.rowCount).toBe(2); + expect(result.sql).toContain('INSERT INTO "users"'); + expect(result.sql).toContain("'Alice'"); + expect(result.sql).toContain("'Bob'"); + }); + + it('should return empty string for empty tables', async () => { + const db = createMockD1({ users: [] }); + const service = createBackupService(db, createMockR2()); + + const result = await service.exportTable('users'); + expect(result.rowCount).toBe(0); + expect(result.sql).toBe(''); + }); + + it('should handle null values in rows', async () => { + const db = createMockD1({ + users: [{ id: '1', name: null, email: 'test@test.com' }], + }); + const service = createBackupService(db, createMockR2()); + + const result = await service.exportTable('users'); + expect(result.sql).toContain('NULL'); + }); + }); + + describe('createBackup', () => { + it('should create a full backup and store in R2', async () => { + const db = createMockD1({ + users: [{ id: '1', name: 'Alice' }], + posts: [{ id: '1', title: 'Hello' }], + }); + const r2 = createMockR2(); + const service = createBackupService(db, r2); + + const result = await service.createBackup({ label: 'test-backup' }); + + expect(result.success).toBe(true); + expect(result.metadata).toBeDefined(); + expect(result.metadata!.tableCount).toBe(2); + expect(result.metadata!.totalRows).toBe(2); + expect(result.metadata!.type).toBe('full'); + expect(result.metadata!.label).toBe('test-backup'); + expect(result.metadata!.contentHash).toHaveLength(64); + expect(result.metadata!.durationMs).toBeGreaterThanOrEqual(0); + + // Verify R2 was called + expect(r2.put).toHaveBeenCalledTimes(1); + }); + + it('should use timestamped filename with appName', async () => { + const db = createMockD1({ users: [{ id: '1' }] }); + const r2 = createMockR2(); + const service = createBackupService(db, r2, { appName: 'my-test-app' }); + + const result = await service.createBackup(); + expect(result.success).toBe(true); + + // Verify the R2 key uses the new filename pattern + const putCall = (r2.put as any).mock.calls[0]; + const key = putCall[0] as string; + expect(key).toMatch(/^backups\/d1\/\d{4}-\d{2}-\d{2}-\d{6}_my-test-app\.sql$/); + }); + + it('should fail when no tables exist', async () => { + const db = createMockD1({}); + const service = createBackupService(db, createMockR2()); + + const result = await service.createBackup(); + + expect(result.success).toBe(false); + expect(result.error).toContain('No tables found'); + }); + + it('should handle R2 errors gracefully', async () => { + const db = createMockD1({ users: [{ id: '1' }] }); + const r2 = createMockR2(); + (r2.put as any).mockRejectedValue(new Error('R2 write failed')); + const service = createBackupService(db, r2); + + const result = await service.createBackup(); + + expect(result.success).toBe(false); + expect(result.error).toBe('R2 write failed'); + }); + }); + + describe('listBackups', () => { + it('should list backups from R2', async () => { + const r2 = createMockR2([ + { + key: 'backups/d1/abc-123.sql', + content: '-- backup content', + metadata: { + backupId: 'abc-123', + backupType: 'full', + createdAt: '2024-01-01T00:00:00Z', + tableCount: '3', + totalRows: '100', + contentHash: 'hash123', + durationMs: '500', + tables: '["users","posts","comments"]', + }, + }, + ]); + const service = createBackupService(createMockD1(), r2); + + const result = await service.listBackups(); + + expect(result.backups).toHaveLength(1); + expect(result.backups[0].id).toBe('abc-123'); + expect(result.backups[0].tableCount).toBe(3); + expect(result.backups[0].totalRows).toBe(100); + expect(result.stats.totalBackups).toBe(1); + }); + + it('should return empty list when no backups exist', async () => { + const service = createBackupService(createMockD1(), createMockR2()); + + const result = await service.listBackups(); + + expect(result.backups).toEqual([]); + expect(result.stats.totalBackups).toBe(0); + expect(result.stats.oldestBackup).toBeNull(); + expect(result.stats.newestBackup).toBeNull(); + }); + + it('should sort backups newest first', async () => { + const r2 = createMockR2([ + { + key: 'backups/d1/old.sql', + content: '-- old', + metadata: { + backupId: 'old', + backupType: 'full', + createdAt: '2024-01-01T00:00:00Z', + tableCount: '1', + totalRows: '10', + contentHash: 'h1', + durationMs: '100', + tables: '["users"]', + }, + }, + { + key: 'backups/d1/new.sql', + content: '-- new', + metadata: { + backupId: 'new', + backupType: 'full', + createdAt: '2024-06-01T00:00:00Z', + tableCount: '2', + totalRows: '20', + contentHash: 'h2', + durationMs: '200', + tables: '["users","posts"]', + }, + }, + ]); + const service = createBackupService(createMockD1(), r2); + + const result = await service.listBackups(); + + expect(result.backups[0].id).toBe('new'); + expect(result.backups[1].id).toBe('old'); + }); + }); + + describe('deleteBackup', () => { + it('should delete a legacy backup from R2', async () => { + const r2 = createMockR2([{ key: 'backups/d1/abc.sql', content: '-- data', metadata: { backupId: 'abc' } }]); + const service = createBackupService(createMockD1(), r2); + + const result = await service.deleteBackup('abc'); + + expect(result.success).toBe(true); + expect(r2.delete).toHaveBeenCalledWith('backups/d1/abc.sql'); + }); + + it('should delete a new-format backup by scanning metadata', async () => { + const r2 = createMockR2([ + { + key: 'backups/d1/2024-06-15-023000_myapp.sql', + content: '-- data', + metadata: { + backupId: 'xyz-789', + backupType: 'full', + createdAt: '2024-06-15T02:30:00Z', + tableCount: '1', + totalRows: '5', + contentHash: 'h', + durationMs: '100', + tables: '["users"]', + filename: '2024-06-15-023000_myapp.sql', + }, + }, + ]); + const service = createBackupService(createMockD1(), r2); + + const result = await service.deleteBackup('xyz-789'); + expect(result.success).toBe(true); + }); + + it('should return error for non-existent backup', async () => { + const service = createBackupService(createMockD1(), createMockR2()); + const result = await service.deleteBackup('nonexistent'); + expect(result.success).toBe(false); + expect(result.error).toBe('Backup not found'); + }); + }); + + describe('downloadBackup', () => { + it('should download backup content', async () => { + const r2 = createMockR2([ + { key: 'backups/d1/abc.sql', content: '-- SQL backup content', metadata: { backupId: 'abc' } }, + ]); + const service = createBackupService(createMockD1(), r2); + + const result = await service.downloadBackup('abc'); + + expect(result.success).toBe(true); + expect(result.content).toBe('-- SQL backup content'); + expect(result.filename).toBe('abc.sql'); + }); + + it('should return error for non-existent backup', async () => { + const service = createBackupService(createMockD1(), createMockR2()); + + const result = await service.downloadBackup('nonexistent'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Backup not found'); + }); + }); + + describe('checkSetup', () => { + it('should report all items pending when nothing is configured', async () => { + const db = createMockD1(); + (db.prepare as any).mockImplementation(() => ({ + all: vi.fn().mockRejectedValue(new Error('not configured')), + bind: vi.fn().mockReturnValue({ + all: vi.fn().mockRejectedValue(new Error('not configured')), + run: vi.fn().mockRejectedValue(new Error('not configured')), + }), + run: vi.fn().mockRejectedValue(new Error('not configured')), + })); + + const r2 = createMockR2(); + (r2.list as any).mockRejectedValue(new Error('not configured')); + + const service = createBackupService(db, r2); + const status = await service.checkSetup(); + + expect(status.d1Configured).toBe(false); + expect(status.r2Configured).toBe(false); + expect(status.ready).toBe(false); + expect(status.pendingItems.length).toBeGreaterThan(0); + }); + + it('should report ready when everything is configured', async () => { + const db = createMockD1({ users: [{ id: '1' }] }); + const r2 = createMockR2([ + { key: 'backups/d1/test.sql', content: '-- backup', metadata: { backupId: 'test' } }, + ]); + + const service = createBackupService(db, r2); + const status = await service.checkSetup({ cronConfigured: true }); + + expect(status.d1Configured).toBe(true); + expect(status.r2Configured).toBe(true); + expect(status.hasBackups).toBe(true); + expect(status.cronConfigured).toBe(true); + expect(status.ready).toBe(true); + expect(status.pendingItems).toEqual([]); + }); + + it('should flag missing cron configuration', async () => { + const db = createMockD1({ users: [] }); + const r2 = createMockR2([ + { key: 'backups/d1/test.sql', content: '-- backup', metadata: { backupId: 'test' } }, + ]); + + const service = createBackupService(db, r2); + const status = await service.checkSetup({ cronConfigured: false }); + + expect(status.cronConfigured).toBe(false); + expect(status.pendingItems).toContain( + 'No scheduled backup cron job configured — set up automated backups in Scheduled Tasks', + ); + }); + }); + + describe('retention policy', () => { + it('should delete old backups when exceeding maxRetained', async () => { + // Create service with maxRetained = 2 + const r2 = createMockR2([ + { + key: 'backups/d1/a.sql', + content: '-- a', + metadata: { + backupId: 'a', + createdAt: '2024-01-01T00:00:00Z', + backupType: 'full', + tableCount: '1', + totalRows: '1', + contentHash: 'h', + durationMs: '1', + tables: '["t"]', + }, + }, + { + key: 'backups/d1/b.sql', + content: '-- b', + metadata: { + backupId: 'b', + createdAt: '2024-02-01T00:00:00Z', + backupType: 'full', + tableCount: '1', + totalRows: '1', + contentHash: 'h', + durationMs: '1', + tables: '["t"]', + }, + }, + ]); + + const db = createMockD1({ users: [{ id: '1' }] }); + const service = createBackupService(db, r2, { maxRetained: 2 }); + + // Creating a new backup should trigger retention + await service.createBackup(); + + // The oldest backup (a) should have been deleted since we now have 3 but max is 2 + // After the backup is created, we'll have 3 (a, b, new) and need to delete 1 + expect(r2.delete).toHaveBeenCalled(); + }); + + it('should delete backups older than retentionDays', async () => { + const now = new Date(); + const oldDate = new Date(now.getTime() - 40 * 24 * 60 * 60 * 1000); // 40 days ago + const recentDate = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days ago + + const r2 = createMockR2([ + { + key: 'backups/d1/old.sql', + content: '-- old', + metadata: { + backupId: 'old', + createdAt: oldDate.toISOString(), + backupType: 'full', + tableCount: '1', + totalRows: '1', + contentHash: 'h', + durationMs: '1', + tables: '["t"]', + }, + }, + { + key: 'backups/d1/recent.sql', + content: '-- recent', + metadata: { + backupId: 'recent', + createdAt: recentDate.toISOString(), + backupType: 'full', + tableCount: '1', + totalRows: '1', + contentHash: 'h', + durationMs: '1', + tables: '["t"]', + }, + }, + ]); + + const db = createMockD1({ users: [{ id: '1' }] }); + // retentionDays = 30, so 40-day-old backup should be deleted + const service = createBackupService(db, r2, { retentionDays: 30, maxRetained: 100 }); + + await service.createBackup(); + + // The old backup should be deleted + const deleteCalls = (r2.delete as any).mock.calls; + const allDeletedKeys = deleteCalls.flatMap((c: any) => (Array.isArray(c[0]) ? c[0] : [c[0]])); + expect(allDeletedKeys).toContain('backups/d1/old.sql'); + }); + }); +}); diff --git a/packages/backups/src/backup-service.ts b/packages/backups/src/backup-service.ts new file mode 100644 index 000000000..9dd91e1b1 --- /dev/null +++ b/packages/backups/src/backup-service.ts @@ -0,0 +1,484 @@ +/** + * @ottabase/backups — Backup Service + * + * Core logic for D1→R2 database backups. + * Exports all tables from D1 as SQL INSERT statements and stores them in R2. + * Supports full and differential backups with content hashing for integrity. + */ + +import type { + BackupConfig, + BackupListResponse, + BackupMetadata, + BackupResult, + BackupSetupStatus, + BackupType, +} from './types'; +import { DEFAULT_BACKUP_CONFIG } from './types'; + +// ============================================================ +// Interfaces for D1 and R2 (minimal, to avoid tight coupling) +// ============================================================ + +/** Minimal D1-compatible database interface */ +export interface D1Like { + prepare(query: string): { + all>(): Promise<{ results: T[] }>; + bind(...values: unknown[]): { + all>(): Promise<{ results: T[] }>; + run(): Promise; + }; + run(): Promise; + }; + exec(query: string): Promise; + batch(statements: unknown[]): Promise; +} + +/** Minimal R2-compatible bucket interface */ +export interface R2Like { + put( + key: string, + value: string | ArrayBuffer | ReadableStream, + options?: { customMetadata?: Record; httpMetadata?: Record }, + ): Promise; + get(key: string): Promise<{ text(): Promise; customMetadata?: Record } | null>; + delete(keys: string | string[]): Promise; + list(options?: { prefix?: string; cursor?: string; limit?: number; include?: string[] }): Promise<{ + objects: Array<{ key: string; size: number; uploaded: Date; customMetadata?: Record }>; + truncated: boolean; + cursor?: string; + }>; + head(key: string): Promise<{ key: string; size: number; customMetadata?: Record } | null>; +} + +// ============================================================ +// Utility helpers +// ============================================================ + +/** Generate a SHA-256 hex hash of a string */ +export async function sha256(content: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(content); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** Escape a SQL value for safe inclusion in an INSERT statement */ +export function escapeSqlValue(value: unknown): string { + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'number') return String(value); + if (typeof value === 'boolean') return value ? '1' : '0'; + // Escape single quotes by doubling them + const str = String(value).replace(/'/g, "''"); + return `'${str}'`; +} + +/** Format bytes to a human-readable string */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +/** Validate that a table name contains only safe characters (alphanumeric, underscores) */ +export function isValidTableName(name: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); +} + +/** + * Generate a backup filename: yyyy-mm-dd-hhmmss_appName.sql + * Uses UTC time for consistency across timezones. + */ +export function generateBackupFilename(appName: string, date?: Date): string { + const d = date ?? new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + const timestamp = `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`; + // Sanitize appName: keep alphanumeric, hyphens, underscores + const safe = appName.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); + return `${timestamp}_${safe}.sql`; +} + +// ============================================================ +// Backup Service +// ============================================================ + +export class BackupService { + private db: D1Like; + private bucket: R2Like; + private config: Required; + + constructor(db: D1Like, bucket: R2Like, config?: BackupConfig) { + this.db = db; + this.bucket = bucket; + this.config = { ...DEFAULT_BACKUP_CONFIG, ...config }; + } + + /** + * List all user tables in D1 (excludes internal SQLite tables and excluded tables from config) + */ + async listTables(): Promise { + const result = await this.db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%' AND name != 'd1_migrations' ORDER BY name", + ) + .all<{ name: string }>(); + + return result.results + .map((r) => r.name) + .filter((name) => isValidTableName(name) && !this.config.excludeTables.includes(name)); + } + + /** + * Get row count for a specific table + */ + async getRowCount(table: string): Promise { + if (!isValidTableName(table)) throw new Error(`Invalid table name: ${table}`); + const result = await this.db.prepare(`SELECT COUNT(*) as count FROM "${table}"`).all<{ count: number }>(); + return result.results[0]?.count ?? 0; + } + + /** + * Export a single table as SQL INSERT statements + */ + async exportTable(table: string): Promise<{ sql: string; rowCount: number }> { + if (!isValidTableName(table)) throw new Error(`Invalid table name: ${table}`); + const rows = await this.db.prepare(`SELECT * FROM "${table}"`).all>(); + if (!rows.results.length) { + return { sql: '', rowCount: 0 }; + } + + const columns = Object.keys(rows.results[0]); + const columnList = columns.map((c) => `"${c}"`).join(', '); + const lines: string[] = []; + + for (const row of rows.results) { + const values = columns.map((col) => escapeSqlValue(row[col])).join(', '); + lines.push(`INSERT INTO "${table}" (${columnList}) VALUES (${values});`); + } + + return { + sql: lines.join('\n'), + rowCount: rows.results.length, + }; + } + + /** + * Create a full database backup (all tables → R2) + */ + async createBackup(options?: { label?: string; type?: BackupType }): Promise { + const startTime = Date.now(); + const backupType = options?.type ?? 'full'; + + try { + const tables = await this.listTables(); + if (tables.length === 0) { + return { success: false, error: 'No tables found in database' }; + } + + const sqlParts: string[] = [ + `-- Ottabase D1 Backup`, + `-- Type: ${backupType}`, + `-- Created: ${new Date().toISOString()}`, + `-- Tables: ${tables.length}`, + '', + ]; + + let totalRows = 0; + + for (const table of tables) { + const { sql, rowCount } = await this.exportTable(table); + totalRows += rowCount; + + sqlParts.push(`-- Table: ${table} (${rowCount} rows)`); + if (sql) { + sqlParts.push(`DELETE FROM "${table}";`); + sqlParts.push(sql); + } + sqlParts.push(''); + } + + const content = sqlParts.join('\n'); + const contentHash = await sha256(content); + const sizeBytes = new TextEncoder().encode(content).byteLength; + const durationMs = Date.now() - startTime; + + const id = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + const filename = generateBackupFilename(this.config.appName); + const key = `${this.config.prefix}/${filename}`; + + const metadata: BackupMetadata = { + id, + createdAt, + sizeBytes, + tableCount: tables.length, + totalRows, + tables, + type: backupType, + label: options?.label, + durationMs, + contentHash, + }; + + // Store the backup SQL in R2 with metadata + await this.bucket.put(key, content, { + httpMetadata: { contentType: 'application/sql' }, + customMetadata: { + backupId: id, + backupType, + createdAt, + tableCount: String(tables.length), + totalRows: String(totalRows), + contentHash, + label: options?.label ?? '', + durationMs: String(durationMs), + tables: JSON.stringify(tables), + filename, + }, + }); + + // Enforce retention policy + await this.enforceRetention(); + + return { success: true, metadata }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Backup failed', + }; + } + } + + /** + * List all backups from R2 + */ + async listBackups(): Promise { + const backups: BackupMetadata[] = []; + let cursor: string | undefined; + + // Paginate through all R2 objects with our prefix + do { + const result = await this.bucket.list({ + prefix: `${this.config.prefix}/`, + cursor, + limit: 100, + include: ['customMetadata'], + }); + + for (const obj of result.objects) { + if (!obj.key.endsWith('.sql')) continue; + + const meta = obj.customMetadata; + if (meta?.backupId) { + backups.push({ + id: meta.backupId, + createdAt: meta.createdAt ?? obj.uploaded.toISOString(), + sizeBytes: obj.size, + tableCount: parseInt(meta.tableCount ?? '0', 10), + totalRows: parseInt(meta.totalRows ?? '0', 10), + tables: meta.tables ? JSON.parse(meta.tables) : [], + type: (meta.backupType as BackupMetadata['type']) ?? 'full', + label: meta.label || undefined, + durationMs: parseInt(meta.durationMs ?? '0', 10), + contentHash: meta.contentHash ?? '', + filename: meta.filename || obj.key.split('/').pop(), + }); + } + } + + cursor = result.truncated ? result.cursor : undefined; + } while (cursor); + + // Sort by creation date (newest first) + backups.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + const totalSizeBytes = backups.reduce((sum, b) => sum + b.sizeBytes, 0); + + return { + backups, + stats: { + totalBackups: backups.length, + totalSizeBytes, + oldestBackup: backups.length > 0 ? backups[backups.length - 1].createdAt : null, + newestBackup: backups.length > 0 ? backups[0].createdAt : null, + }, + }; + } + + /** + * Delete a specific backup from R2 by its ID. + * Scans the prefix to find the matching key (supports both legacy uuid.sql and new timestamped filenames). + */ + async deleteBackup(backupId: string): Promise<{ success: boolean; error?: string }> { + try { + const key = await this.resolveBackupKey(backupId); + if (!key) { + return { success: false, error: 'Backup not found' }; + } + await this.bucket.delete(key); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Delete failed', + }; + } + } + + /** + * Download a backup's SQL content from R2 + */ + async downloadBackup( + backupId: string, + ): Promise<{ success: boolean; content?: string; filename?: string; error?: string }> { + try { + const key = await this.resolveBackupKey(backupId); + if (!key) { + return { success: false, error: 'Backup not found' }; + } + const obj = await this.bucket.get(key); + if (!obj) { + return { success: false, error: 'Backup not found' }; + } + const content = await obj.text(); + // Extract filename from key + const filename = key.split('/').pop() ?? `backup-${backupId}.sql`; + return { success: true, content, filename }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Download failed', + }; + } + } + + /** + * Resolve an R2 key for a given backup ID. + * Checks the new metadata-based lookup and falls back to legacy `{id}.sql` pattern. + */ + private async resolveBackupKey(backupId: string): Promise { + // Try legacy path first (fast path for old backups) + const legacyKey = `${this.config.prefix}/${backupId}.sql`; + const legacyHead = await this.bucket.head(legacyKey); + if (legacyHead) return legacyKey; + + // Scan prefix for matching backupId in customMetadata + const { backups } = await this.listBackups(); + const match = backups.find((b) => b.id === backupId); + if (!match) return null; + + // Reconstruct key from the metadata filename or fall back + const meta = match as BackupMetadata & { filename?: string }; + if (meta.filename) { + return `${this.config.prefix}/${meta.filename}`; + } + return null; + } + + /** + * Check setup status — reports what's configured and what needs admin attention + */ + async checkSetup(options?: { cronConfigured?: boolean }): Promise { + const pendingItems: string[] = []; + let d1Configured = false; + let r2Configured = false; + let hasBackups = false; + + // Check D1 + try { + await this.db.prepare('SELECT 1').all(); + d1Configured = true; + } catch { + pendingItems.push('D1 database binding (OBCF_D1) is not configured'); + } + + // Check R2 + try { + await this.bucket.list({ prefix: this.config.prefix, limit: 1 }); + r2Configured = true; + } catch { + pendingItems.push('R2 bucket binding (OBCF_R2) is not configured'); + } + + // Check if backups exist + if (r2Configured) { + try { + const result = await this.bucket.list({ prefix: `${this.config.prefix}/`, limit: 1 }); + hasBackups = result.objects.length > 0; + if (!hasBackups) { + pendingItems.push('No backups exist yet — run your first backup'); + } + } catch { + // R2 list failed + } + } + + // Check cron + const cronConfigured = options?.cronConfigured ?? false; + if (!cronConfigured) { + pendingItems.push('No scheduled backup cron job configured — set up automated backups in Scheduled Tasks'); + } + + return { + d1Configured, + r2Configured, + hasBackups, + cronConfigured, + pendingItems, + ready: d1Configured && r2Configured && hasBackups && cronConfigured, + }; + } + + /** + * Enforce backup retention policy: + * 1. Delete backups older than retentionDays + * 2. Delete oldest backups exceeding maxRetained count + */ + private async enforceRetention(): Promise { + const { backups } = await this.listBackups(); + + const keysToDelete: string[] = []; + + // Day-based retention: delete backups older than retentionDays + if (this.config.retentionDays > 0) { + const cutoff = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000; + for (const b of backups) { + if (new Date(b.createdAt).getTime() < cutoff) { + const key = b.filename + ? `${this.config.prefix}/${b.filename}` + : `${this.config.prefix}/${b.id}.sql`; + keysToDelete.push(key); + } + } + } + + // Count-based retention: delete oldest beyond maxRetained + const remaining = backups.filter((b) => { + const key = b.filename ? `${this.config.prefix}/${b.filename}` : `${this.config.prefix}/${b.id}.sql`; + return !keysToDelete.includes(key); + }); + if (remaining.length > this.config.maxRetained) { + const overflow = remaining.slice(this.config.maxRetained); + for (const b of overflow) { + const key = b.filename ? `${this.config.prefix}/${b.filename}` : `${this.config.prefix}/${b.id}.sql`; + if (!keysToDelete.includes(key)) { + keysToDelete.push(key); + } + } + } + + if (keysToDelete.length > 0) { + await this.bucket.delete(keysToDelete); + } + } +} + +/** + * Create a BackupService instance + */ +export function createBackupService(db: D1Like, bucket: R2Like, config?: BackupConfig): BackupService { + return new BackupService(db, bucket, config); +} diff --git a/packages/backups/src/index.ts b/packages/backups/src/index.ts new file mode 100644 index 000000000..bd1ea19d8 --- /dev/null +++ b/packages/backups/src/index.ts @@ -0,0 +1,19 @@ +/** + * @ottabase/backups + * + * Automated D1→R2 database backup service for Cloudflare Workers. + * Provides full SQL export backups with R2 storage, retention policies, + * and admin setup status checking. + */ + +export { + BackupService, + createBackupService, + sha256, + escapeSqlValue, + formatBytes, + isValidTableName, + generateBackupFilename, +} from './backup-service'; +export type { D1Like, R2Like } from './backup-service'; +export * from './types'; diff --git a/packages/backups/src/types.ts b/packages/backups/src/types.ts new file mode 100644 index 000000000..882a3d1db --- /dev/null +++ b/packages/backups/src/types.ts @@ -0,0 +1,113 @@ +/** + * @ottabase/backups — Type definitions + * + * Types for D1→R2 automated backup operations. + */ + +// ============================================================ +// Backup metadata stored alongside each backup in R2 +// ============================================================ + +export interface BackupMetadata { + /** Unique backup ID */ + id: string; + /** ISO timestamp of when the backup was created */ + createdAt: string; + /** Backup size in bytes */ + sizeBytes: number; + /** Number of tables included in the backup */ + tableCount: number; + /** Total row count across all tables */ + totalRows: number; + /** List of table names included */ + tables: string[]; + /** Whether this is a full or differential backup */ + type: BackupType; + /** Optional label (e.g., "pre-migration", "daily-auto") */ + label?: string; + /** ID of the base backup (for diff backups) */ + baseBackupId?: string; + /** Duration of the backup operation in milliseconds */ + durationMs: number; + /** SHA-256 hash of the backup content for integrity verification */ + contentHash: string; + /** Filename in R2 (e.g., "2024-06-15-023000_myapp.sql") */ + filename?: string; +} + +export type BackupType = 'full' | 'diff'; + +// ============================================================ +// Backup configuration +// ============================================================ + +export interface BackupConfig { + /** R2 key prefix for backups (default: "backups/d1") */ + prefix?: string; + /** Maximum number of backups to retain (default: 30) */ + maxRetained?: number; + /** Rolling window in days — backups older than this are deleted (default: 30) */ + retentionDays?: number; + /** Tables to exclude from backup */ + excludeTables?: string[]; + /** App name used in backup filenames (default: "ottabase") */ + appName?: string; +} + +export const DEFAULT_BACKUP_CONFIG: Required = { + prefix: 'backups/d1', + maxRetained: 30, + retentionDays: 30, + excludeTables: [], + appName: 'ottabase', +}; + +// ============================================================ +// Backup operation result +// ============================================================ + +export interface BackupResult { + success: boolean; + metadata?: BackupMetadata; + error?: string; +} + +export interface RestoreResult { + success: boolean; + tablesRestored?: number; + rowsRestored?: number; + error?: string; +} + +// ============================================================ +// Backup list response (for admin API) +// ============================================================ + +export interface BackupListResponse { + backups: BackupMetadata[]; + stats: { + totalBackups: number; + totalSizeBytes: number; + oldestBackup: string | null; + newestBackup: string | null; + }; +} + +// ============================================================ +// Setup status (for admin UI pending setup detection) +// ============================================================ + +export interface BackupSetupStatus { + /** Whether D1 binding is configured */ + d1Configured: boolean; + /** Whether R2 binding is configured */ + r2Configured: boolean; + /** Whether at least one backup exists */ + hasBackups: boolean; + /** Whether a scheduled backup cron job exists */ + cronConfigured: boolean; + /** List of issues that need admin attention */ + pendingItems: string[]; + /** Overall readiness */ + ready: boolean; +} diff --git a/packages/backups/tsconfig.json b/packages/backups/tsconfig.json new file mode 100644 index 000000000..b57cfd1f4 --- /dev/null +++ b/packages/backups/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/backups/vitest.config.ts b/packages/backups/vitest.config.ts new file mode 100644 index 000000000..70d68bd77 --- /dev/null +++ b/packages/backups/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', 'dist/', '**/*.config.ts', '**/*.d.ts'], + }, + include: ['src/**/*.{test,spec}.{ts,tsx}'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a99b630ca..8a15402f2 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 @@ -489,6 +489,9 @@ importers: '@ottabase/auth': specifier: workspace:* version: link:../../packages/auth + '@ottabase/backups': + specifier: workspace:* + version: link:../../packages/backups '@ottabase/brand-engine': specifier: workspace:* version: link:../../packages/brand-engine @@ -839,6 +842,24 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/backups: + devDependencies: + '@cloudflare/workers-types': + specifier: 'catalog:' + version: 4.20251225.0 + '@types/node': + specifier: 'catalog:' + version: 20.19.28 + 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) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 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) + packages/brand-engine: dependencies: '@ottabase/audit': @@ -6544,16 +6565,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 +6585,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 +6626,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 +6643,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 +8513,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 +10880,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 +17035,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 +17052,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 +17074,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 +17091,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 +17113,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 +17124,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 +17150,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 +17174,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 +17331,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 +17576,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 +17592,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 +17608,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 +17668,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 +17726,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 +17841,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 +17849,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 +17870,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 +17912,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 +19122,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 +19150,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 +19185,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 +19199,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 +19215,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 +19224,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 +19243,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 +19257,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 +20408,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 +20419,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 +20459,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 +21955,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 +22315,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 +22455,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 +22642,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 +22653,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 +22669,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 +22699,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 +22737,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