diff --git a/README.md b/README.md index 71e36ac07..0524320dd 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ # Fox Schema -## 0.3.X COMMING SOON, NEED TO DO MORE MANUALLY TEST * - **Compare schemas · generate migrations · run SQL — across 14 SQL dialects.** Install once, then open the local web UI (`foxschema`) for **Compare**, the @@ -47,6 +45,18 @@ npm install -g foxschema Full matrix: **[docs/install.md](docs/install.md)**. +## Update + +```bash +npm install -g foxschema@latest # npm +brew update && brew upgrade foxschema # Homebrew +docker pull 5nickels/foxschema:latest # Docker, then recreate the container +``` + +A running `foxschema` also tells you when a new version is out, links to +**What's new**, and on a local install offers **Update now**. +Release notes: [0.3.1](docs/releases/RELEASE_0.3.1.md) · [all versions](docs/releases/SUMMARY.md). + ## Use the app ```bash @@ -64,7 +74,23 @@ foxschema migrate --source a --target b foxschema tui ``` -## SQL Editor (0.2) +## What's in 0.3 + +| Area | What you get | +|------|----------------| +| **Compare & deploy** | Color-coded schema diff, cross-dialect readiness, an execute gate that reviews a missing foreign key or a narrowing type before it runs, streamed deploy progress | +| **Snapshots** | Capture a schema version, browse the timeline and graph, compare two versions, revert, or force-migrate a version to another database | +| **Migrations in Git** | Commit a migration to a repository, pull and push, track what was applied where | +| **SQL Editor** | Multi-destination run, notebook cells, FoxScript, Data Peek, Safe mode, one transaction where the engine can roll back (below) | +| **Data compare & migrate** | Compare 2,000 rows in key order between two databases, then apply up to 2,000 changes with a backup snapshot you can restore | +| **Utilities** | **Rename Column** (in place, or Large table mode with a batched copy, on every SQL engine), Index management, Backup & Restore commands, SQL Tracer, Server insights, Clone table | +| **Access** | Database accounts, a permission builder that writes GRANT / REVOKE, an inspector of who holds what, an access report | +| **Workspaces & sign-in** | Personal and team workspaces, invites, Google / Microsoft / GitHub sign-in, admin roles and a role → permission matrix | +| **Workflow** | Optional designer and engine for scheduled and triggered jobs (below) | + +Every feature, where it lives and how it is tested: [docs/FEATURE_MAP.md](docs/FEATURE_MAP.md). + +## SQL Editor A full query workbench next to Schema Sync — not a thin prompt. @@ -81,7 +107,7 @@ A full query workbench next to Schema Sync — not a thin prompt. | **Schema explorer** | Insert names, autocomplete, edit table / view source | | **Variables & secrets** | `${{name}}`, `@set`, session secrets, code-cell `vars` | | **Safe mode** | Confirm writes; bookmarks & samples included | -| **Access control** | Optional `admin` / `editor` / `viewer` roles when multi-user login is on | +| **Access control** | Sign-in on every install; `admin` / `editor` / `viewer` roles and per-workspace roles | Walkthrough: [User guide → SQL Editor](docs/user-guide.md#sql-editor). @@ -133,8 +159,9 @@ Open **http://localhost:3210**. Guide: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). PostgreSQL · CockroachDB · YugabyteDB · MySQL · MariaDB · TiDB · SQL Server · Azure SQL · Oracle · IBM Db2 · SQLite · DuckDB · ClickHouse · Amazon Redshift -MongoDB and Redis appear in the connection list (settings only — no schema -compare). One product — Docker image includes Db2 on linux/amd64. +MongoDB and Redis run a SQL subset in the editor (single-table SELECT, INSERT, +UPDATE, DELETE; Redis keys read as tables) — no schema compare. One product — +Docker image includes Db2 on linux/amd64. ## Package notes (npm) diff --git a/apps/cli/README.md b/apps/cli/README.md index 2110974a7..a27679214 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,33 @@ npm install -g foxschema foxschema # UI → http://localhost:3210 ``` -## SQL Editor (0.2) +## Update + +```bash +npm install -g foxschema@latest # npm +brew update && brew upgrade foxschema # Homebrew +docker pull 5nickels/foxschema:latest # Docker, then recreate the container +``` + +A running `foxschema` tells you when a new version is out and offers **Update now**. +What's new: [0.3.1 release notes](https://github.com/tedious-code/foxschema/blob/main/docs/releases/RELEASE_0.3.1.md). + +## What's in 0.3 + +| | | +|--|--| +| Compare & deploy | Schema diff, cross-dialect readiness, a review before a missing FK or a narrowing type runs | +| Snapshots | Capture schema versions, compare, revert, force-migrate to another database | +| Migrations in Git | Commit, pull and push migrations; track what was applied | +| SQL Editor | Multi-destination run, notebook cells, Data Peek, Safe mode, one transaction | +| Data compare & migrate | 2,000 rows in key order; apply up to 2,000 changes with a restorable backup | +| Utilities | **Rename Column** on every SQL engine (in place or a batched copy), indexes, backup commands, SQL Tracer | +| Access | Database accounts, GRANT / REVOKE builder, who-holds-what inspector | +| Workspaces & sign-in | Team workspaces, invites, Google / Microsoft / GitHub sign-in, roles | + +All features: [FEATURE_MAP.md](https://github.com/tedious-code/foxschema/blob/main/docs/FEATURE_MAP.md). + +## SQL Editor Multi-destination queries, notebook-style cells, Data Peek, FoxScript, variables, and Safe mode — next to Schema Sync in the same UI. diff --git a/apps/cli/package.json b/apps/cli/package.json index 8f2a62d7d..324017736 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@foxschema/cli", - "version": "0.2.396", + "version": "0.3.1", "private": true, "type": "module", "description": "Fox Schema CLI — schema diff, migrations, and a rich SQL Editor (TypeScript / Node ESM)", diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index 0ff3bad1d..75e8a6525 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -2997,6 +2997,8 @@ export const TestIds = { columnChangeHint: 'column-change-hint', /** section · What it touches */ columnChangeImpact: 'column-change-impact', + /** input */ + columnChangeLargeTable: 'column-change-large-table', /** p */ columnChangeLoadError: 'column-change-load-error', /** input */ @@ -3029,7 +3031,7 @@ export const TestIds = { columnChangeStep: (value: string | number) => `column-change-step-${value}`, /** button */ columnChangeStepCopy: (value: string | number) => `column-change-step-${value}-copy`, - /** section · ( steps) */ + /** section · ( ) */ columnChangeSteps: 'column-change-steps', /** select */ columnChangeTable: 'column-change-table', @@ -4184,6 +4186,7 @@ export type TestId = | 'column-change-copy-all' | 'column-change-hint' | 'column-change-impact' + | 'column-change-large-table' | 'column-change-load-error' | 'column-change-new-name' | 'column-change-new-type' diff --git a/apps/web/package.json b/apps/web/package.json index e543f59c7..e60106efd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@foxschema/web", "private": true, - "version": "0.2.396", + "version": "0.3.1", "type": "module", "exports": { "./package.json": "./package.json" diff --git a/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.test.tsx b/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.test.tsx index d36b74e38..48d3c899b 100644 --- a/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.test.tsx +++ b/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.test.tsx @@ -8,8 +8,19 @@ vi.mock('@/shared/api/sqlApi', () => ({ executeSql: (...args: unknown[]) => executeSql(...args), })); +const fetchTableInsight = vi.hoisted(() => vi.fn()); +vi.mock('@/shared/api/schemaApi', async (importOriginal) => ({ + ...(await importOriginal>()), + fetchTableInsight: (...args: unknown[]) => fetchTableInsight(...args), +})); + vi.mock('@/features/compare', () => { - const state = { connections: [{ id: 'pg', name: 'Shop', dialect: 'postgres', schema: 'shop', hasPassword: true }] }; + const state = { + connections: [ + { id: 'pg', name: 'Shop', dialect: 'postgres', schema: 'shop', hasPassword: true }, + { id: 'ora', name: 'Ledger', dialect: 'oracle', schema: 'shop', hasPassword: true }, + ], + }; const useSyncStore = (sel: (s: typeof state) => unknown) => sel(state); useSyncStore.getState = () => state; return { useSyncStore }; @@ -45,8 +56,13 @@ const pick = (testId: string, value: string) => fireEvent.change(screen.getByTes describe('ColumnChangePanel', () => { beforeEach(() => { executeSql.mockReset(); + fetchTableInsight.mockReset(); + fetchTableInsight.mockResolvedValue({ estimatedRows: 120, sizeBytes: 16384 }); useSqlEditorStore.setState({ - schemaCache: { pg: { status: 'ready', tables: [orders, statusFn] } }, + schemaCache: { + pg: { status: 'ready', tables: [orders, statusFn] }, + ora: { status: 'ready', tables: [orders] }, + }, ensureSchema: async () => undefined, ensureConnectionSelected: () => undefined, } as never); @@ -59,6 +75,7 @@ describe('ColumnChangePanel', () => { pick('column-change-column', 'status'); expect(screen.getByTestId('column-change-hint')).toBeTruthy(); pick('column-change-new-type', 'varchar(40)'); + fireEvent.click(screen.getByTestId('column-change-large-table')); expect(screen.getByTestId('column-change-impact').textContent).toContain('orders_status_idx'); expect(screen.getByTestId('column-change-impact').textContent).toContain('orders_status_label'); @@ -110,6 +127,7 @@ describe('ColumnChangePanel', () => { pick('column-change-table', 'orders'); pick('column-change-column', 'status'); pick('column-change-new-type', 'varchar(40)'); + fireEvent.click(screen.getByTestId('column-change-large-table')); fireEvent.click(screen.getByTestId('column-change-run-add')); await waitFor(() => expect(executeSql).toHaveBeenCalled()); expect(executeSql.mock.calls[0]![0]).toMatchObject({ connectionId: 'pg' }); @@ -133,6 +151,7 @@ describe('ColumnChangePanel', () => { pick('column-change-table', 'orders'); pick('column-change-column', 'status'); pick('column-change-new-type', 'varchar(40)'); + fireEvent.click(screen.getByTestId('column-change-large-table')); expect(screen.getByTestId('column-change-step-3').textContent).toContain('DO $fox_copy$'); fireEvent.click(screen.getByTestId('column-change-run-copy')); await waitFor(() => expect(screen.getByTestId('column-change-progress').textContent).toMatch(/10/)); @@ -194,6 +213,7 @@ describe('ColumnChangePanel', () => { pick('column-change-table', 'orders'); pick('column-change-column', 'status'); pick('column-change-new-type', 'varchar(40)'); + fireEvent.click(screen.getByTestId('column-change-large-table')); fireEvent.click(screen.getByTestId('column-change-run-all')); await waitFor(() => expect(screen.getByTestId('column-change-run-status').textContent).toMatch(/All parts finished/)); const batches = executeSql.mock.calls.map((c) => (c[1] as string[]).join('\n')); @@ -207,4 +227,86 @@ describe('ColumnChangePanel', () => { expect(addAt).toBeGreaterThanOrEqual(0); expect(dropAt).toBeGreaterThan(addAt); }); + + describe('a type change in place (the default)', () => { + const typeIn = (type: string) => { + render(); + pick('column-change-table', 'orders'); + pick('column-change-column', 'status'); + pick('column-change-new-type', type); + }; + + it('is one ALTER, with no copy part and no batch size until large-table mode is on', () => { + typeIn('varchar(40)'); + expect(screen.getByTestId('column-change-steps').textContent).toContain('Change in place (1 step)'); + expect(screen.getByTestId('column-change-step-1').textContent).toContain( + 'ALTER TABLE "shop"."orders" ALTER COLUMN "status" TYPE varchar(40);' + ); + expect(screen.queryByTestId('column-change-part-copy')).toBeNull(); + expect(screen.queryByTestId('column-change-batch')).toBeNull(); + expect((screen.getByTestId('column-change-large-table') as HTMLInputElement).checked).toBe(false); + + fireEvent.click(screen.getByTestId('column-change-large-table')); + expect(screen.getByTestId('column-change-part-copy')).toBeTruthy(); + expect(screen.getByTestId('column-change-batch')).toBeTruthy(); + }); + + it('offers large-table mode only for a type change', () => { + render(); + pick('column-change-table', 'orders'); + pick('column-change-column', 'status'); + pick('column-change-new-name', 'state'); + expect(screen.queryByTestId('column-change-large-table')).toBeNull(); + }); + + it('shows no line-by-line references for a type change, only the one-line warnings', () => { + typeIn('varchar(40)'); + expect(screen.queryByTestId('column-change-references')).toBeNull(); + expect(screen.getByTestId('column-change-warnings').textContent).toContain( + 'Functions that may use the column: orders_status_label' + ); + }); + + it('warns on a large table, from the catalog estimate, and not in large-table mode', async () => { + fetchTableInsight.mockResolvedValue({ estimatedRows: 4_000_000, sizeBytes: 2 * 1024 ** 3 }); + typeIn('varchar(40)'); + await waitFor(() => + expect(screen.getByTestId('column-change-warnings').textContent).toMatch( + /orders is large \(about 4\.0 million rows, 2\.0 GB\)/ + ) + ); + expect(fetchTableInsight.mock.calls[0]).toEqual([{ connectionId: 'pg', password: undefined }, { table: 'orders', schema: 'shop' }]); + fireEvent.click(screen.getByTestId('column-change-large-table')); + expect(screen.getByTestId('column-change-warnings').textContent).not.toMatch(/is large/); + }); + + it('says nothing about size when the estimate cannot be read', async () => { + fetchTableInsight.mockRejectedValue(new Error('no stats')); + typeIn('varchar(40)'); + await waitFor(() => expect(fetchTableInsight).toHaveBeenCalled()); + expect(screen.getByTestId('column-change-warnings').textContent).not.toMatch(/is large/); + }); + + it('runs the ALTER against this connection and says the column changed', async () => { + useSqlEditorStore.setState({ safeMode: false } as never); + executeSql.mockResolvedValue({ results: [{ ok: true, columns: [], rows: [], rowCount: 0, truncated: false, durationMs: 1 }] }); + typeIn('varchar(40)'); + fireEvent.click(screen.getByTestId('column-change-run-all')); + await waitFor(() => expect(screen.getByTestId('column-change-run-status').textContent).toBe('Column changed.')); + expect(executeSql).toHaveBeenCalledTimes(1); + expect((executeSql.mock.calls[0]![1] as string[])[0]).toContain('ALTER COLUMN "status" TYPE varchar(40)'); + }); + }); + + it('changes the type in place on an engine without large-table mode, and does not offer it', () => { + render(); + pick('column-change-table', 'orders'); + pick('column-change-column', 'status'); + pick('column-change-new-type', 'VARCHAR2(40)'); + expect(screen.getByTestId('column-change-step-1').textContent).toContain( + 'ALTER TABLE "SHOP"."orders" MODIFY ("status" VARCHAR2(40));' + ); + expect(screen.queryByTestId('column-change-large-table')).toBeNull(); + expect(screen.queryByTestId('column-change-batch')).toBeNull(); + }); }); diff --git a/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.tsx b/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.tsx index 623351cdc..e1eed2687 100644 --- a/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.tsx +++ b/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.tsx @@ -3,11 +3,12 @@ * Copyright 2024-2026 Huy Phan * SPDX-License-Identifier: Apache-2.0 * - * Utilities → Rename Column: rename a column or change its type the safe way. - * A type change is three parts — add a nullable column, copy rows in a loop - * (with progress), then constraints / triggers / description — each with - * preview SQL to copy, run, or execute all. Lossy type conversions warn - * and must be acknowledged before Run or Execute all. + * Utilities → Rename Column: rename a column or change its type. + * A type change is one in-place ALTER by default. Large-table mode turns it + * into three parts — add a nullable column, copy rows in a loop (with + * progress), then constraints / triggers / description — so a big table stays + * writable. Each part has preview SQL to copy, run, or execute all. Lossy type + * conversions warn and must be acknowledged before Run or Execute all. */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Check, Copy, Loader2, Play, SquareTerminal } from 'lucide-react'; @@ -15,10 +16,13 @@ import { useSyncStore } from '@/features/compare'; import { useSqlEditorStore } from '@/features/sql-editor/state'; import { useUiStore } from '@/platform/uiStore'; import { executeSql, type SqlStatementResult } from '@/shared/api/sqlApi'; +import { fetchTableInsight } from '@/shared/api/schemaApi'; import { COLUMN_CHANGE_PHASES, DEFAULT_COLUMN_CHANGE_BATCH, + columnChangeLargeTable, planColumnChange, + type ColumnChangeMode, type ColumnChangePhase, type ColumnChangePlan, type ColumnChangeProgressSql, @@ -39,13 +43,17 @@ type RunTarget = ColumnChangePhase | 'all'; const PHASE_ORDER: ColumnChangePhase[] = ['add', 'copy', 'finish']; -function statusFor(phase: ColumnChangePhase): string { +function statusFor(phase: ColumnChangePhase, mode: ColumnChangeMode): string { + if (mode === 'rename') return 'Rename finished.'; + if (mode === 'direct') return 'Column changed.'; if (phase === 'add') return 'New column is in place.'; if (phase === 'copy') return 'Copy finished. Check the mismatch count is 0 before the last part.'; return 'Constraints, triggers and description are applied.'; } -function progressFor(phase: ColumnChangePhase): string { +function progressFor(phase: ColumnChangePhase, mode: ColumnChangeMode): string { + if (mode === 'rename') return 'Renaming…'; + if (mode === 'direct') return 'Changing the column…'; if (phase === 'add') return 'Adding the new column…'; if (phase === 'copy') return 'Copying data…'; return 'Applying constraints, triggers and description…'; @@ -133,6 +141,9 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) const [newName, setNewName] = useState(''); const [newType, setNewType] = useState(''); const [batchSize, setBatchSize] = useState(DEFAULT_COLUMN_CHANGE_BATCH); + const [largeTable, setLargeTable] = useState(false); + /** The catalog's row estimate and size for the picked table, for the size warning. */ + const [tableSize, setTableSize] = useState<{ rows: number | null; bytes: number | null } | null>(null); const [passwordDraft, setPasswordDraft] = useState(''); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); @@ -184,10 +195,31 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) // Reload when the credential changes, or once its password is given. }, [lockedConnectionId, needsPassword, load]); + // The table's size, for the warning that points at large-table mode. A + // catalog estimate, so no COUNT(*) on a big table; unknown when it fails. + const password = sessionPasswords[lockedConnectionId]; + useEffect(() => { + setTableSize(null); + if (!lockedConnectionId || !tableName || needsPassword) return; + let cancelled = false; + fetchTableInsight( + { connectionId: lockedConnectionId, password: password || undefined }, + { table: tableName, schema: conn?.schema?.trim() || undefined } + ) + .then((res) => { + if (!cancelled) setTableSize({ rows: res.estimatedRows, bytes: res.sizeBytes }); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [lockedConnectionId, tableName, needsPassword, password, conn?.schema]); + // A new table or column starts a new change. useEffect(() => { setNewName(''); setNewType(''); + setLargeTable(false); setRunError(null); setRunStatus(null); setConfirmTarget(null); @@ -209,18 +241,27 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) tables: all, column: column.name, change: { newName: newName.trim() || undefined, newType: newType.trim() || undefined }, + largeTable: largeTable && columnChangeLargeTable(conn.dialect), batchSize, + estimatedRows: tableSize?.rows, + sizeBytes: tableSize?.bytes, }); - }, [conn, table, column, newName, newType, batchSize, all]); + }, [conn, table, column, newName, newType, largeTable, batchSize, tableSize, all]); const script = plan?.ok ? columnChangeScript(plan.steps) : ''; - const typeChange = Boolean(plan?.ok && plan.progress); + const mode: ColumnChangeMode | undefined = plan?.ok ? plan.mode : undefined; + // Offered as soon as a type is typed in that is not the column's own. + const typeRequested = Boolean(column && newType.trim() && newType.trim().toLowerCase() !== column.type.toLowerCase()); + const largeTableOffered = typeRequested && Boolean(conn && columnChangeLargeTable(conn.dialect)); const conversionWarning = plan?.ok ? plan.impact.lossy : undefined; const runDisabled = Boolean(runningTarget) || needsPassword || Boolean(conversionWarning && !ackLossy); - const parts = typeChange - ? COLUMN_CHANGE_PHASES - : [{ id: 'add' as const, title: 'Rename', blurb: 'Instant catalog rename. Indexes follow; application SQL that names the column does not.' }]; + const parts = + mode === 'copy' + ? COLUMN_CHANGE_PHASES + : mode === 'direct' + ? [{ id: 'add' as const, title: 'Change in place', blurb: 'One ALTER on the column. Fine for small and medium tables.' }] + : [{ id: 'add' as const, title: 'Rename', blurb: 'Instant catalog rename. Indexes follow; application SQL that names the column does not.' }]; const runStatements = useCallback( async (sql: string): Promise => { @@ -273,7 +314,7 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) setConfirmTarget(null); try { await executePhase(phase, live, true); - setRunStatus(statusFor(phase)); + setRunStatus(statusFor(phase, live.mode)); } catch (e: unknown) { setRunError(e instanceof Error ? e.message : String(e)); } finally { @@ -285,7 +326,7 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) const runAll = useCallback( async (live: Extract) => { - const phases = live.progress ? PHASE_ORDER : (['add'] as ColumnChangePhase[]); + const phases = live.mode === 'copy' ? PHASE_ORDER : (['add'] as ColumnChangePhase[]); setRunningTarget('all'); setRunError(null); setRunStatus(null); @@ -293,10 +334,10 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) try { for (let i = 0; i < phases.length; i++) { const phase = phases[i]!; - setRunStatus(progressFor(phase)); + setRunStatus(progressFor(phase, live.mode)); await executePhase(phase, live, i === phases.length - 1); } - setRunStatus(live.progress ? 'All parts finished.' : 'Rename finished.'); + setRunStatus(live.mode === 'copy' ? 'All parts finished.' : statusFor('add', live.mode)); } catch (e: unknown) { setRunError(e instanceof Error ? e.message : String(e)); } finally { @@ -423,18 +464,40 @@ export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) ))} -