From ddb8c01b8cada0d58b99bbf58b67af89968bcad9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:16:19 +0000 Subject: [PATCH 1/8] Initial plan From 9219c35bf7049aeb809e9164143ba1908ce24348 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:30:09 +0000 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20add=20@ottabase/backups=20package?= =?UTF-8?q?=20with=20D1=E2=86=92R2=20backup=20service=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- packages/backups/package.json | 39 ++ .../src/__tests__/backup-service.test.ts | 549 ++++++++++++++++++ packages/backups/src/backup-service.ts | 400 +++++++++++++ packages/backups/src/index.ts | 11 + packages/backups/src/types.ts | 105 ++++ packages/backups/tsconfig.json | 19 + packages/backups/vitest.config.ts | 20 + pnpm-lock.yaml | 270 ++++++--- 8 files changed, 1322 insertions(+), 91 deletions(-) create mode 100644 packages/backups/package.json create mode 100644 packages/backups/src/__tests__/backup-service.test.ts create mode 100644 packages/backups/src/backup-service.ts create mode 100644 packages/backups/src/index.ts create mode 100644 packages/backups/src/types.ts create mode 100644 packages/backups/tsconfig.json create mode 100644 packages/backups/vitest.config.ts diff --git a/packages/backups/package.json b/packages/backups/package.json new file mode 100644 index 000000000..70ebac600 --- /dev/null +++ b/packages/backups/package.json @@ -0,0 +1,39 @@ +{ + "name": "@ottabase/backups", + "version": "1.0.0", + "description": "Automated D1→R2 database backup service for Cloudflare Workers", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "sideEffects": false, + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts --clean", + "dev": "tsup src/index.ts --format cjs,esm --dts --watch", + "type-check": "tsc --noEmit", + "test": "vitest", + "test:coverage": "vitest --coverage" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@types/node": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@cloudflare/workers-types": "catalog:" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } +} diff --git a/packages/backups/src/__tests__/backup-service.test.ts b/packages/backups/src/__tests__/backup-service.test.ts new file mode 100644 index 000000000..5e724b3fe --- /dev/null +++ b/packages/backups/src/__tests__/backup-service.test.ts @@ -0,0 +1,549 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BackupService, createBackupService, sha256, escapeSqlValue, formatBytes } from '../backup-service'; +import type { D1Like, R2Like } from '../backup-service'; +import type { BackupMetadata } from '../types'; + +// ============================================================ +// Mock helpers +// ============================================================ + +function createMockD1(tables: Record[]> = {}): 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('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 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 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'); + }); + }); + + 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'); + }); + + 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(); + }); + }); +}); diff --git a/packages/backups/src/backup-service.ts b/packages/backups/src/backup-service.ts new file mode 100644 index 000000000..798f04772 --- /dev/null +++ b/packages/backups/src/backup-service.ts @@ -0,0 +1,400 @@ +/** + * @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]}`; +} + +// ============================================================ +// 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) => !this.config.excludeTables.includes(name)); + } + + /** + * Get row count for a specific table + */ + async getRowCount(table: string): Promise { + // Table name is from sqlite_master, safe to use directly + 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 }> { + 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 key = `${this.config.prefix}/${id}.sql`; + + 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), + }, + }); + + // 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 ?? '', + }); + } + } + + 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 + */ + async deleteBackup(backupId: string): Promise<{ success: boolean; error?: string }> { + try { + const key = `${this.config.prefix}/${backupId}.sql`; + 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; error?: string }> { + try { + const key = `${this.config.prefix}/${backupId}.sql`; + const obj = await this.bucket.get(key); + if (!obj) { + return { success: false, error: 'Backup not found' }; + } + const content = await obj.text(); + return { success: true, content }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Download failed', + }; + } + } + + /** + * 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 — deletes oldest backups exceeding maxRetained + */ + private async enforceRetention(): Promise { + const { backups } = await this.listBackups(); + + if (backups.length <= this.config.maxRetained) return; + + // Delete oldest backups beyond retention limit + const toDelete = backups.slice(this.config.maxRetained); + const keys = toDelete.map((b) => `${this.config.prefix}/${b.id}.sql`); + + if (keys.length > 0) { + await this.bucket.delete(keys); + } + } +} + +/** + * 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..1a0c59552 --- /dev/null +++ b/packages/backups/src/index.ts @@ -0,0 +1,11 @@ +/** + * @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 } 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..e80cdf536 --- /dev/null +++ b/packages/backups/src/types.ts @@ -0,0 +1,105 @@ +/** + * @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; +} + +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; + /** Tables to exclude from backup */ + excludeTables?: string[]; +} + +export const DEFAULT_BACKUP_CONFIG: Required = { + prefix: 'backups/d1', + maxRetained: 30, + excludeTables: [], +}; + +// ============================================================ +// 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..0bee68da4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,7 +256,7 @@ 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.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@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)) @@ -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.3(eslint@9.39.2(jiti@1.21.7))(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) happy-dom: specifier: ^20.4.0 version: 20.4.0 @@ -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 @@ -839,6 +839,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': @@ -17014,10 +17032,10 @@ 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.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@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.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@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)) react: 19.2.4 @@ -17075,14 +17093,14 @@ snapshots: 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) 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.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@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) 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': {} @@ -17310,12 +17328,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 +17573,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 +17589,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 +17605,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 +17665,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 +17723,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 +17838,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 +17846,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 +17867,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 +17909,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 +19119,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 +19147,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 +19182,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 +19196,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 +19212,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 +19221,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 +19240,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,10 +19254,10 @@ 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.3(eslint@9.39.2(jiti@1.21.7))(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): 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) + '@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.3(@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 @@ -20387,7 +20405,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 +20416,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 +20456,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: @@ -22294,6 +22312,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 +22452,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 +22639,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 +22650,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 +22666,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 +22696,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 +22734,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 From aaf7540cf5e887e1337a7b6add17cfd57f1a6208 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:33:37 +0000 Subject: [PATCH 3/8] feat: add admin backup routes, UI page, and README Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- .../package.json | 1 + .../src/pages/admin/AdminBackupsPage.tsx | 426 ++++++++++++++++++ .../src/pages/admin/AdminIndexPage.tsx | 8 + .../src/router.tsx | 12 + .../worker/routes/admin-backups.ts | 155 +++++++ .../worker/routes/router.ts | 26 ++ packages/backups/README.md | 87 ++++ pnpm-lock.yaml | 3 + 8 files changed, 718 insertions(+) create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/AdminBackupsPage.tsx create mode 100644 apps/ottabase-template-app-tanstack/worker/routes/admin-backups.ts create mode 100644 packages/backups/README.md 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..aa6144415 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBackupsPage.tsx @@ -0,0 +1,426 @@ +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, +} from '@ottabase/ui-shadcn'; +import { api, isApiError } from '@/lib/api'; +import { + ArrowLeft, + RefreshCw, + Download, + Trash2, + Plus, + HardDrive, + Database, + Clock, + AlertTriangle, + CheckCircle, + XCircle, + Shield, +} 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; +} + +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; +} + +// ============================================================ +// 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(); +} + +// ============================================================ +// Component +// ============================================================ + +export function AdminBackupsPage() { + const queryClient = useQueryClient(); + const [deleteDialog, setDeleteDialog] = useState<{ id: string; label?: string } | null>(null); + + // Fetch backups + setup status + const { + data: response, + isLoading, + refetch, + } = useQuery({ + queryKey: ['admin', 'backups'], + queryFn: () => api('/api/admin/backups'), + refetchInterval: 30000, + }); + + // 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'); + }, + }); + + const handleDownload = (backup: BackupMetadata) => { + // Trigger download via direct link + const link = document.createElement('a'); + link.href = `/api/admin/backups/${backup.id}`; + link.download = `backup-${backup.id}.sql`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + 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} +
  • + ))} +
+
+
+ )} + + {/* 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 && ( + + + Configure in Scheduled Tasks → + + + )} + + + + + + + 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.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..8c83cd0f9 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/worker/routes/admin-backups.ts @@ -0,0 +1,155 @@ +/** + * 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/:id — Download backup SQL + * - DELETE /api/admin/backups/:id — Delete a backup + */ + +import { createBackupService } from '@ottabase/backups'; +import { ScheduledTask } from '@ottabase/ottaorm/models'; +import { errorResponse } from '@ottabase/utils/http-errors'; +import { jsonResponse } from '@ottabase/utils/http-response'; +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']) { + 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' }) }; + } + return { service: createBackupService(env.OBCF_D1 as any, env.OBCF_R2 as any) }; +} + +/** Check if a backup:database cron job is configured */ +async function isCronConfigured(env: ApiRouteContext['env']): Promise { + try { + const initErr = initAdminCron(env as any); + if (initErr) return false; + + const tasks = await ScheduledTask.all(); + return tasks.some( + (t) => + t.get('isActive') && + (String(t.get('task')).includes('backup') || String(t.get('name')).toLowerCase().includes('backup')), + ); + } catch { + return false; + } +} + +/** + * 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; + + const { service, error } = getBackupService(context.env); + 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/: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' }); + } + + return new Response(result.content, { + headers: { + 'Content-Type': 'application/sql', + 'Content-Disposition': `attachment; filename="backup-${backupId}.sql"`, + }, + }); + } 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..e69f6fd0f 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/router.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/router.ts @@ -4,6 +4,12 @@ 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, +} from './admin-backups'; import { handleAdminDbRowDelete, handleAdminDbTableData, @@ -182,6 +188,16 @@ async function handleGetRoutes(context: ApiRouteContext): Promise Date: Sat, 28 Feb 2026 21:39:23 +0000 Subject: [PATCH 4/8] fix: add table name validation for SQL injection prevention, improve type safety Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- .../worker/routes/admin-backups.ts | 6 ++--- .../src/__tests__/backup-service.test.ts | 27 ++++++++++++++++++- packages/backups/src/backup-service.ts | 12 +++++++-- packages/backups/src/index.ts | 9 ++++++- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/apps/ottabase-template-app-tanstack/worker/routes/admin-backups.ts b/apps/ottabase-template-app-tanstack/worker/routes/admin-backups.ts index 8c83cd0f9..ad13b0cbf 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/admin-backups.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/admin-backups.ts @@ -8,7 +8,7 @@ * - DELETE /api/admin/backups/:id — Delete a backup */ -import { createBackupService } from '@ottabase/backups'; +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'; @@ -25,13 +25,13 @@ function getBackupService(env: ApiRouteContext['env']) { if (!env.OBCF_R2) { return { error: errorResponse('R2 bucket binding not configured', 500, { code: 'CONFIG_ERROR' }) }; } - return { service: createBackupService(env.OBCF_D1 as any, env.OBCF_R2 as any) }; + return { service: createBackupService(env.OBCF_D1 as unknown as D1Like, env.OBCF_R2 as unknown as R2Like) }; } /** Check if a backup:database cron job is configured */ async function isCronConfigured(env: ApiRouteContext['env']): Promise { try { - const initErr = initAdminCron(env as any); + const initErr = initAdminCron(env); if (initErr) return false; const tasks = await ScheduledTask.all(); diff --git a/packages/backups/src/__tests__/backup-service.test.ts b/packages/backups/src/__tests__/backup-service.test.ts index 5e724b3fe..04471a207 100644 --- a/packages/backups/src/__tests__/backup-service.test.ts +++ b/packages/backups/src/__tests__/backup-service.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BackupService, createBackupService, sha256, escapeSqlValue, formatBytes } from '../backup-service'; +import { + BackupService, + createBackupService, + sha256, + escapeSqlValue, + formatBytes, + isValidTableName, +} from '../backup-service'; import type { D1Like, R2Like } from '../backup-service'; import type { BackupMetadata } from '../types'; @@ -201,6 +208,24 @@ describe('BackupService', () => { 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('createBackupService', () => { diff --git a/packages/backups/src/backup-service.ts b/packages/backups/src/backup-service.ts index 798f04772..5bbe8a66f 100644 --- a/packages/backups/src/backup-service.ts +++ b/packages/backups/src/backup-service.ts @@ -83,6 +83,11 @@ export function formatBytes(bytes: number): string { 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); +} + // ============================================================ // Backup Service // ============================================================ @@ -108,14 +113,16 @@ export class BackupService { ) .all<{ name: string }>(); - return result.results.map((r) => r.name).filter((name) => !this.config.excludeTables.includes(name)); + 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 { - // Table name is from sqlite_master, safe to use directly + 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; } @@ -124,6 +131,7 @@ export class BackupService { * 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 }; diff --git a/packages/backups/src/index.ts b/packages/backups/src/index.ts index 1a0c59552..41a4e14f9 100644 --- a/packages/backups/src/index.ts +++ b/packages/backups/src/index.ts @@ -6,6 +6,13 @@ * and admin setup status checking. */ -export { BackupService, createBackupService, sha256, escapeSqlValue, formatBytes } from './backup-service'; +export { + BackupService, + createBackupService, + sha256, + escapeSqlValue, + formatBytes, + isValidTableName, +} from './backup-service'; export type { D1Like, R2Like } from './backup-service'; export * from './types'; From 040e60d14dcb34e1a84e087b5a6998fe07cc1ec7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:47:33 +0000 Subject: [PATCH 5/8] fix: update storybook packages to ^10.2.10 to fix WebSocket Hijacking CVE Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- pnpm-lock.yaml | 158 ++++++++++++++++++++++---------------------- pnpm-workspace.yaml | 10 +-- 2 files changed, 84 insertions(+), 84 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e3e890be..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@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)) + 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 @@ -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@1.21.7))(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)) @@ -6565,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 @@ -6585,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: @@ -6626,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: @@ -6643,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: @@ -8513,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==} @@ -10880,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 @@ -17035,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@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-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@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/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' @@ -17052,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)) @@ -17074,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 @@ -17091,14 +17091,14 @@ 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@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/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 @@ -17113,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 @@ -17124,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: @@ -17150,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: @@ -17174,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: @@ -19257,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@1.21.7))(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@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) - 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) transitivePeerDependencies: - supports-color - typescript @@ -21955,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) 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 From 679ea55e630944c66d02744bcf656cfde4a5456e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 22:50:29 +0000 Subject: [PATCH 6/8] fix: timestamped filenames, day-based retention, appName support in @ottabase/backups Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- .../src/__tests__/backup-service.test.ts | 122 +++++++++++++++++- packages/backups/src/backup-service.ts | 102 +++++++++++++-- packages/backups/src/index.ts | 1 + packages/backups/src/types.ts | 8 ++ 4 files changed, 219 insertions(+), 14 deletions(-) diff --git a/packages/backups/src/__tests__/backup-service.test.ts b/packages/backups/src/__tests__/backup-service.test.ts index 04471a207..2cfdfc058 100644 --- a/packages/backups/src/__tests__/backup-service.test.ts +++ b/packages/backups/src/__tests__/backup-service.test.ts @@ -6,6 +6,7 @@ import { escapeSqlValue, formatBytes, isValidTableName, + generateBackupFilename, } from '../backup-service'; import type { D1Like, R2Like } from '../backup-service'; import type { BackupMetadata } from '../types'; @@ -226,6 +227,31 @@ describe('BackupService', () => { 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', () => { @@ -331,6 +357,20 @@ describe('BackupService', () => { 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()); @@ -435,7 +475,7 @@ describe('BackupService', () => { }); describe('deleteBackup', () => { - it('should delete a backup from R2', async () => { + 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); @@ -444,6 +484,37 @@ describe('BackupService', () => { 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', () => { @@ -457,6 +528,7 @@ describe('BackupService', () => { 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 () => { @@ -570,5 +642,53 @@ describe('BackupService', () => { // 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 index 5bbe8a66f..9dd91e1b1 100644 --- a/packages/backups/src/backup-service.ts +++ b/packages/backups/src/backup-service.ts @@ -88,6 +88,19 @@ 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 // ============================================================ @@ -194,7 +207,8 @@ export class BackupService { const id = crypto.randomUUID(); const createdAt = new Date().toISOString(); - const key = `${this.config.prefix}/${id}.sql`; + const filename = generateBackupFilename(this.config.appName); + const key = `${this.config.prefix}/${filename}`; const metadata: BackupMetadata = { id, @@ -222,6 +236,7 @@ export class BackupService { label: options?.label ?? '', durationMs: String(durationMs), tables: JSON.stringify(tables), + filename, }, }); @@ -269,6 +284,7 @@ export class BackupService { label: meta.label || undefined, durationMs: parseInt(meta.durationMs ?? '0', 10), contentHash: meta.contentHash ?? '', + filename: meta.filename || obj.key.split('/').pop(), }); } } @@ -293,11 +309,15 @@ export class BackupService { } /** - * Delete a specific backup from R2 + * 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 = `${this.config.prefix}/${backupId}.sql`; + 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) { @@ -311,15 +331,22 @@ export class BackupService { /** * Download a backup's SQL content from R2 */ - async downloadBackup(backupId: string): Promise<{ success: boolean; content?: string; error?: string }> { + async downloadBackup( + backupId: string, + ): Promise<{ success: boolean; content?: string; filename?: string; error?: string }> { try { - const key = `${this.config.prefix}/${backupId}.sql`; + 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(); - return { success: true, content }; + // Extract filename from key + const filename = key.split('/').pop() ?? `backup-${backupId}.sql`; + return { success: true, content, filename }; } catch (error) { return { success: false, @@ -328,6 +355,29 @@ export class BackupService { } } + /** + * 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 */ @@ -383,19 +433,45 @@ export class BackupService { } /** - * Enforce backup retention policy — deletes oldest backups exceeding maxRetained + * 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(); - if (backups.length <= this.config.maxRetained) return; + 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); + } + } + } - // Delete oldest backups beyond retention limit - const toDelete = backups.slice(this.config.maxRetained); - const keys = toDelete.map((b) => `${this.config.prefix}/${b.id}.sql`); + // 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 (keys.length > 0) { - await this.bucket.delete(keys); + if (keysToDelete.length > 0) { + await this.bucket.delete(keysToDelete); } } } diff --git a/packages/backups/src/index.ts b/packages/backups/src/index.ts index 41a4e14f9..bd1ea19d8 100644 --- a/packages/backups/src/index.ts +++ b/packages/backups/src/index.ts @@ -13,6 +13,7 @@ export { 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 index e80cdf536..882a3d1db 100644 --- a/packages/backups/src/types.ts +++ b/packages/backups/src/types.ts @@ -31,6 +31,8 @@ export interface BackupMetadata { 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'; @@ -44,14 +46,20 @@ export interface BackupConfig { 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', }; // ============================================================ From dd370d41cf67519b4946bb4d330340032230ddf9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 22:56:04 +0000 Subject: [PATCH 7/8] feat: admin backup settings UI, schedule/retention config, route collision fix Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- .../src/pages/admin/AdminBackupsPage.tsx | 180 +++++++++++++++++- .../worker/routes/admin-backups.ts | 145 ++++++++++++-- .../worker/routes/router.ts | 11 ++ packages/backups/README.md | 51 +++-- 4 files changed, 344 insertions(+), 43 deletions(-) diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBackupsPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBackupsPage.tsx index aa6144415..ee0cee909 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBackupsPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBackupsPage.tsx @@ -16,6 +16,13 @@ import { CardDescription, CardHeader, CardTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from '@ottabase/ui-shadcn'; import { api, isApiError } from '@/lib/api'; import { @@ -31,6 +38,8 @@ import { CheckCircle, XCircle, Shield, + Settings, + Save, } from 'lucide-react'; import { toast } from 'sonner'; @@ -49,6 +58,7 @@ interface BackupMetadata { label?: string; durationMs: number; contentHash: string; + filename?: string; } interface BackupSetupStatus { @@ -71,6 +81,14 @@ interface BackupsResponse { setup: BackupSetupStatus; } +interface BackupSettings { + retentionDays: number; + schedule: string | null; + scheduleActive: boolean; + taskId: string | null; + taskName: string | null; +} + // ============================================================ // Helpers // ============================================================ @@ -97,6 +115,15 @@ function timeAgo(dateStr: string): string { 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 // ============================================================ @@ -104,6 +131,11 @@ function timeAgo(dateStr: string): string { 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 { @@ -116,6 +148,23 @@ export function AdminBackupsPage() { 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) => @@ -145,16 +194,37 @@ export function AdminBackupsPage() { }, }); + // 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) => { - // Trigger download via direct link const link = document.createElement('a'); link.href = `/api/admin/backups/${backup.id}`; - link.download = `backup-${backup.id}.sql`; + 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 ?? []; @@ -174,6 +244,17 @@ export function AdminBackupsPage() {

Automated D1→R2 database backups with retention management

+ +
+ + + )} + {/* Setup Status Checklist */} {setup && (
@@ -286,9 +442,15 @@ export function AdminBackupsPage() { {!setup.cronConfigured && ( - - Configure in Scheduled Tasks → - + )} @@ -336,8 +498,10 @@ export function AdminBackupsPage() { className="flex items-center justify-between rounded-lg border p-4" >
-
- {backup.label || 'Backup'} +
+ + {backup.filename || backup.label || 'Backup'} + {backup.type} @@ -373,7 +537,7 @@ export function AdminBackupsPage() { )}
-
+