diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index f670c8a42..6519e7c76 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -2960,6 +2960,44 @@ export const TestIds = { /** button · Unlock */ cloneUnlock: 'clone-unlock', }, + ColumnChangePanel: { + /** input */ + columnChangeBatch: 'column-change-batch', + /** select · Pick a column */ + columnChangeColumn: 'column-change-column', + /** button */ + columnChangeCopyAll: 'column-change-copy-all', + /** p */ + columnChangeHint: 'column-change-hint', + /** section · What it touches */ + columnChangeImpact: 'column-change-impact', + /** p */ + columnChangeLoadError: 'column-change-load-error', + /** input */ + columnChangeNewName: 'column-change-new-name', + /** input */ + columnChangeNewType: 'column-change-new-type', + /** button · Open in editor */ + columnChangeOpenEditor: 'column-change-open-editor', + /** div · What it touches */ + columnChangePanel: 'column-change-panel', + /** input */ + columnChangePassword: 'column-change-password', + /** p */ + columnChangeRefused: 'column-change-refused', + /** article */ + columnChangeStep: (value: string | number) => `column-change-step-${value}`, + /** button */ + columnChangeStepCopy: (value: string | number) => `column-change-step-${value}-copy`, + /** section · Steps ( ) */ + columnChangeSteps: 'column-change-steps', + /** select */ + columnChangeTable: 'column-change-table', + /** button · Unlock */ + columnChangeUnlock: 'column-change-unlock', + /** ul */ + columnChangeWarnings: 'column-change-warnings', + }, DatabaseAccessModal: { /** div */ dbAccessAllForm: 'db-access-all-form', @@ -3229,6 +3267,8 @@ export const TestIds = { sqlSidebarUtilities: 'sql-sidebar-utilities', /** data · Backup & Restore */ utilitiesBackup: 'utilities-backup', + /** data · Change Column */ + utilitiesChangeColumn: 'utilities-change-column', /** data · Clone Table */ utilitiesCloneTable: 'utilities-clone-table', /** select */ @@ -4077,6 +4117,24 @@ export type TestId = | 'cloud-cred-save' | `cloud-cred-status-${string}` | 'cloud-provider-credentials' + | 'column-change-batch' + | 'column-change-column' + | 'column-change-copy-all' + | 'column-change-hint' + | 'column-change-impact' + | 'column-change-load-error' + | 'column-change-new-name' + | 'column-change-new-type' + | 'column-change-open-editor' + | 'column-change-panel' + | 'column-change-password' + | 'column-change-refused' + | `column-change-step-${string}` + | `column-change-step-${string}-copy` + | 'column-change-steps' + | 'column-change-table' + | 'column-change-unlock' + | 'column-change-warnings' | 'command-palette' | 'command-palette-btn' | 'command-palette-input' @@ -5190,6 +5248,7 @@ export type TestId = | 'user-valid-until' | 'user-warnings' | 'utilities-backup' + | 'utilities-change-column' | 'utilities-clone-table' | 'utilities-connection' | 'utilities-connection-pool' diff --git a/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.test.tsx b/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.test.tsx new file mode 100644 index 000000000..caa88d1c0 --- /dev/null +++ b/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TableSchema } from '@/shared/lib/types'; + +vi.mock('@/features/compare', () => { + const state = { connections: [{ id: 'pg', name: 'Shop', dialect: 'postgres', schema: 'shop', hasPassword: true }] }; + const useSyncStore = (sel: (s: typeof state) => unknown) => sel(state); + useSyncStore.getState = () => state; + return { useSyncStore }; +}); + +import { useSqlEditorStore } from '@/features/sql-editor/state'; +import { useUiStore } from '@/platform/uiStore'; +import { ColumnChangePanel } from './ColumnChangePanel'; + +const orders: TableSchema = { + name: 'orders', + objectType: 'TABLE', + columns: [ + { name: 'id', type: 'integer', nullable: false, primaryKey: true }, + { name: 'status', type: 'varchar(20)', nullable: false, primaryKey: false }, + ], + primaryKey: { columns: ['id'] }, + indices: [{ name: 'orders_status_idx', columns: ['status'], unique: false }], + foreignKeys: [], +}; + +const pick = (testId: string, value: string) => fireEvent.change(screen.getByTestId(testId), { target: { value } }); + +describe('ColumnChangePanel', () => { + beforeEach(() => { + useSqlEditorStore.setState({ + schemaCache: { pg: { status: 'ready', tables: [orders] } }, + ensureSchema: async () => undefined, + ensureConnectionSelected: () => undefined, + } as never); + }); + + it('shows what a type change touches and its steps, and opens them in a new tab for this connection only', () => { + const setActiveView = vi.spyOn(useUiStore.getState(), 'setActiveView'); + render(); + pick('column-change-table', 'orders'); + pick('column-change-column', 'status'); + expect(screen.getByTestId('column-change-hint')).toBeTruthy(); + pick('column-change-new-type', 'varchar(40)'); + + expect(screen.getByTestId('column-change-impact').textContent).toContain('orders_status_idx'); + expect(screen.getAllByTestId(/^column-change-step-\d+$/)).toHaveLength(7); + expect(screen.getByTestId('column-change-step-1').textContent).toContain( + 'ALTER TABLE "shop"."orders" ADD COLUMN IF NOT EXISTS "status__new" varchar(40);' + ); + + const tabsBefore = useSqlEditorStore.getState().tabs.length; + fireEvent.click(screen.getByTestId('column-change-open-editor')); + const editor = useSqlEditorStore.getState(); + const tab = editor.activeTab(); + expect(editor.tabs).toHaveLength(tabsBefore + 1); + expect(tab.title).toBe('Change orders.status'); + expect(tab.sql.startsWith('-- Step 1: Add the new column status__new (varchar(40))\n')).toBe(true); + expect(tab.sql).toContain('-- Step 7: Drop the old column status__old'); + // Only this connection: the steps belong to one database. + expect(editor.activeConnectionIds()).toEqual(['pg']); + expect(setActiveView).toHaveBeenCalledWith('sqlEditor'); + }); + + it('gives a rename alone as one step', () => { + render(); + pick('column-change-table', 'orders'); + pick('column-change-column', 'status'); + pick('column-change-new-name', 'state'); + expect(screen.getAllByTestId(/^column-change-step-\d+$/)).toHaveLength(1); + expect(screen.getByTestId('column-change-step-1').textContent).toContain( + 'ALTER TABLE "shop"."orders" RENAME COLUMN "status" TO "state";' + ); + }); + + it('says why when the change is refused', () => { + render(); + pick('column-change-table', 'orders'); + pick('column-change-column', 'id'); + pick('column-change-new-type', 'bigint'); + expect(screen.getByTestId('column-change-refused').textContent).toMatch(/primary key column/); + expect(screen.queryByTestId('column-change-steps')).toBeNull(); + }); + + it('starts a new change when another column is picked', () => { + render(); + pick('column-change-table', 'orders'); + pick('column-change-column', 'status'); + pick('column-change-new-name', 'state'); + pick('column-change-column', 'id'); + expect((screen.getByTestId('column-change-new-name') as HTMLInputElement).value).toBe(''); + }); +}); diff --git a/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.tsx b/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.tsx new file mode 100644 index 000000000..fda4c88d9 --- /dev/null +++ b/apps/web/src/frontend/features/utilities/components/ColumnChangePanel.tsx @@ -0,0 +1,357 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Utilities → Change Column: rename a column or change its type the safe way. + * Shows what the change touches, then the steps as SQL to review, copy, or + * open in a new editor tab for that connection. Nothing runs from here + * (docs/plans/2026-11-01-safe-column-change.md). + */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Check, Copy, Loader2, SquareTerminal } from 'lucide-react'; +import { useSyncStore } from '@/features/compare'; +import { useSqlEditorStore } from '@/features/sql-editor/state'; +import { useUiStore } from '@/platform/uiStore'; +import { DEFAULT_COLUMN_CHANGE_BATCH, planColumnChange, type ColumnChangeStep } from '@/shared/lib/column-change'; +import { needsSessionPassword } from '@/shared/lib/provider-settings'; +import { sectionLabelCls } from '@/shared/components/surfaces'; +import type { TableSchema } from '@/shared/lib/types'; + +interface Props { + /** The credential picked in the Utilities header; none while nothing is saved. */ + lockedConnectionId?: string; +} + +const inputCls = + 'w-full rounded-lg border border-slate-700 bg-slate-950/70 px-2.5 py-1.5 text-[13px] text-slate-100 outline-none accent-focus'; + +/** The steps as one script, each under a comment naming it. */ +export function columnChangeScript(steps: ColumnChangeStep[]): string { + return steps.map((s, i) => `-- Step ${i + 1}: ${s.title}\n${s.sql}`).join('\n\n') + '\n'; +} + +const CopyButton: React.FC<{ text: string; testId: string; label: string }> = ({ text, testId, label }) => { + const [done, setDone] = useState(false); + return ( + + ); +}; + +export const ColumnChangePanel: React.FC = ({ lockedConnectionId = '' }) => { + const connections = useSyncStore((s) => s.connections); + const ensureSchema = useSqlEditorStore((s) => s.ensureSchema); + const ensureConnectionSelected = useSqlEditorStore((s) => s.ensureConnectionSelected); + const submitSessionPassword = useSqlEditorStore((s) => s.submitSessionPassword); + const sessionPasswords = useSqlEditorStore((s) => s.sessionPasswords); + const schemaCache = useSqlEditorStore((s) => s.schemaCache); + const setActiveView = useUiStore((s) => s.setActiveView); + + const [tableName, setTableName] = useState(''); + const [columnName, setColumnName] = useState(''); + const [newName, setNewName] = useState(''); + const [newType, setNewType] = useState(''); + const [batchSize, setBatchSize] = useState(DEFAULT_COLUMN_CHANGE_BATCH); + const [passwordDraft, setPasswordDraft] = useState(''); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + + const conn = connections.find((c) => c.id === lockedConnectionId) ?? null; + const needsPassword = Boolean(conn && needsSessionPassword(conn, sessionPasswords[lockedConnectionId])); + const cache = lockedConnectionId ? schemaCache[lockedConnectionId] : undefined; + const all: TableSchema[] = useMemo(() => cache?.tables ?? [], [cache?.tables]); + const tables = useMemo( + () => all.filter((t) => t.objectType === 'TABLE').slice().sort((a, b) => a.name.localeCompare(b.name)), + [all] + ); + const table = tables.find((t) => t.name === tableName) ?? null; + const column = table?.columns.find((c) => c.name === columnName) ?? null; + const knownTypes = useMemo( + () => [...new Set(all.flatMap((t) => t.columns.map((c) => c.type)))].sort((a, b) => a.localeCompare(b)), + [all] + ); + + const load = useCallback(async () => { + if (!lockedConnectionId) return; + setLoading(true); + setLoadError(null); + try { + ensureConnectionSelected(lockedConnectionId); + await ensureSchema(lockedConnectionId); + const entry = useSqlEditorStore.getState().schemaCache[lockedConnectionId]; + if (entry?.status === 'error') setLoadError(entry.error || 'Could not load the schema'); + } catch (e: unknown) { + setLoadError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [lockedConnectionId, ensureConnectionSelected, ensureSchema]); + + useEffect(() => { + setTableName(''); + setColumnName(''); + if (!needsPassword) void load(); + // Reload when the credential changes, or once its password is given. + }, [lockedConnectionId, needsPassword, load]); + + // A new table or column starts a new change. + useEffect(() => { + setNewName(''); + setNewType(''); + }, [tableName, columnName]); + + const plan = useMemo(() => { + if (!conn || !table || !column || (!newName.trim() && !newType.trim())) return null; + return planColumnChange({ + dialect: conn.dialect, + schema: conn.schema?.trim() || undefined, + table, + tables: all, + column: column.name, + change: { newName: newName.trim() || undefined, newType: newType.trim() || undefined }, + batchSize, + }); + }, [conn, table, column, newName, newType, batchSize, all]); + + const script = plan?.ok ? columnChangeScript(plan.steps) : ''; + + return ( +
+ {needsPassword && ( +
+ + +
+ )} + +
+ + + + + +
+ + {loadError && ( +

+ {loadError} +

+ )} + + {!plan && column && ( +

+ Give a new name, a new type, or both. Nothing runs from here: you get the steps as SQL to review. +

+ )} + + {plan && !plan.ok && ( +

+ {plan.error} +

+ )} + + {plan?.ok && ( + <> +
+

What it touches

+
    + {plan.impact.indexes.map((i) => ( +
  • + {i.unique ? 'Unique index' : 'Index'} {i.name} ({i.columns.join(', ')}) +
  • + ))} + {plan.impact.outboundForeignKeys.map((fk) => ( +
  • + Foreign key {fk.name} → {fk.referencedTable} +
  • + ))} + {plan.impact.views.map((v) => ( +
  • + View {v} may use it +
  • + ))} + {plan.impact.triggers.map((t) => ( +
  • + Trigger {t.name} may use it +
  • + ))} + {plan.impact.indexes.length + + plan.impact.outboundForeignKeys.length + + plan.impact.views.length + + plan.impact.triggers.length === + 0 &&
  • No index, key, view or trigger uses it.
  • } +
+
+ + {plan.warnings.length > 0 && ( +
    + {plan.warnings.map((w) => ( +
  • {w}
  • + ))} +
+ )} + +
+
+

Steps ({plan.steps.length})

+ + +
+ {plan.steps.map((step, i) => ( +
+
+ + {i + 1}. {step.title} + + {step.rerunSafe && ( + safe to rerun + )} + + +
+
{step.sql}
+ {step.note &&

{step.note}

} +
+ ))} +
+ + )} + + {loading && ( +

+ Loading the schema… +

+ )} +
+ ); +}; diff --git a/apps/web/src/frontend/features/utilities/components/UtilitiesView.tsx b/apps/web/src/frontend/features/utilities/components/UtilitiesView.tsx index 69a9c3a8f..6cb2f9037 100644 --- a/apps/web/src/frontend/features/utilities/components/UtilitiesView.tsx +++ b/apps/web/src/frontend/features/utilities/components/UtilitiesView.tsx @@ -12,6 +12,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import type { Permission } from '@foxschema/shared'; import { Activity, + ArrowRightLeft, Copy, Cpu, Database, @@ -29,6 +30,7 @@ import { FileImportsPanel } from '@/features/sql-editor/ui'; import { SQL_ICON_STROKE } from '@/shared/lib/iconStyle'; import { BackupRestorePanel } from './BackupRestorePanel'; import { CloneTableModal } from './CloneTableModal'; +import { ColumnChangePanel } from './ColumnChangePanel'; import { DatabaseAccessModal } from './DatabaseAccessModal'; import { FileQueryModal } from './FileQueryModal'; import { IndexManagementModal } from './IndexManagementModal'; @@ -43,6 +45,7 @@ export type UtilityTool = | 'backup' | 'dbAccess' | 'clone' + | 'columns' | 'files' | 'pool' | 'sessions' @@ -87,6 +90,14 @@ const TOOLS: { blurb: 'Archive a huge table as name_N and recreate an empty live twin.', icon: Copy, }, + { + id: 'columns', + testId: 'utilities-change-column', + label: 'Change Column', + group: 'Maintenance', + blurb: 'Rename a column or change its type the safe way: what it touches, then the steps as SQL to review.', + icon: ArrowRightLeft, + }, { id: 'pool', testId: 'utilities-connection-pool', @@ -316,6 +327,7 @@ export const UtilitiesView: React.FC = () => { onClose={() => undefined} /> )} + {tool === 'columns' && } {insights && ( u4[utilities.backup] UTIL --> u5[utilities.dbaccess] UTIL --> u6[utilities.trace] + UTIL --> u7[utilities.columns] ACC --> a1[access.accounts] ACC --> a2[access.builder] @@ -178,6 +179,7 @@ in its folders and its e2e specs (`apps/e2e/src/tests`). |---|---|---|---|---|---|---|---| | `utilities.indexes` | Index management — fragmentation (custom probe is one read-only SELECT), batch, rebuild / reorg / optimize SQL (Db2: every index on the table), drop | Utils (rail) | `/api/schema/index-fragmentation`, `-batch` | server `access` (`index-fragmentation.service`); sql `utilities` | `utility.access`, `utility.index.drop` | — | sql 7; e2e `sql-editor-utilities`, `-utilities-dialects` | | `utilities.clone` | Clone table | Utils | (editor execute) | web `CloneTableModal` | `utility.access`, `editor.ddl` | — | (utilities e2e) | +| `utilities.columns` | Change column — rename or change a type the safe way: what it touches (indexes, foreign keys, views, triggers, lossy conversions), then numbered steps as SQL to review (add, sync trigger, batched copy, check, switch over, drop last); nothing runs | Utils | — (computed in the browser) | sql `utilities/column-change` + `providers/*/*.column-change.ts`; web `ColumnChangePanel` | `utility.access` | — | sql 56 (+ live runs on PostgreSQL, MySQL, SQL Server); web 4 | | `utilities.insights` | Server insights, table insight, query files | Utils | `/api/schema/dba-utility`, `/api/schema/table-insight` | server `access` (`dba-utilities.service`) | `utility.access` | — | (utilities e2e) | | `utilities.trace` | SQL Tracer — Live (engine sessions, ~1 s) and Fox activity (statements run from Fox on a saved connection); redacted, 15 min / 10,000 statements | Utils | `/api/sql-trace/start`, `/:id/events`, `/:id/stop` | server `sql-trace` (+ `platform/sql-activity`); sql `utilities/sql-trace` | `utility.trace` | — | server 27; web 7; e2e `utilities-sql-tracer` | | `utilities.backup` | Backup & Restore — per-engine command builders, server-side SQL backup after confirmation, recorded backups, saved defaults | Utils | `/api/backup-settings` (2) | server `backup`; sql `providers/*/*.backup.ts` | `utility.access` | `backup_settings` | server 2; e2e `backup-restore-ui`, `backup-commands-dialects` | diff --git a/docs/feature-map.html b/docs/feature-map.html index 6b0402f04..8bd70dc0a 100644 --- a/docs/feature-map.html +++ b/docs/feature-map.html @@ -241,7 +241,7 @@

Fox Feature Map

Measured on main · b12bcd5607 · 2026-10-31 · mirrors docs/FEATURE_MAP.md
    -
  • 34 features
  • +
  • 35 features
  • 158 HTTP routes
  • 18 server modules
  • 14 web features
  • @@ -781,6 +781,10 @@

    End-to-end suites

    subs: ['Clone a table definition and rows'], ui: 'Utils', api: 'through /api/sql/execute', code: 'web CloneTableModal', perms: 'utility.access, editor.ddl', tables: '—', tests: 'utilities e2e' }, + { id: 'utilities.columns', area: 'utilities', name: 'Change column', + subs: ['Rename in place', 'Type change: add, sync, batched copy, check, switch over, drop last', 'What it touches', 'Lossy conversions', 'Open in editor'], + ui: 'Utils', api: '— (computed in the browser)', code: 'sql utilities/column-change, providers/*/*.column-change · web ColumnChangePanel', perms: 'utility.access', + tables: '—', tests: 'sql 56 · web 4 · live: PostgreSQL, MySQL, SQL Server' }, { id: 'utilities.insights', area: 'utilities', name: 'Server insights', subs: ['Server insights', 'Table insight', 'Query files'], ui: 'Utils', api: '/api/schema/dba-utility, /api/schema/table-insight', diff --git a/docs/feature-map/data.js b/docs/feature-map/data.js index 2bef07833..18293cd47 100644 --- a/docs/feature-map/data.js +++ b/docs/feature-map/data.js @@ -114,6 +114,10 @@ const FEATURES = [ subs: ['Clone a table definition and rows'], ui: 'Utils', api: 'through /api/sql/execute', code: 'web CloneTableModal', perms: 'utility.access, editor.ddl', tables: '—', tests: 'utilities e2e' }, + { id: 'utilities.columns', area: 'utilities', name: 'Change column', + subs: ['Rename in place', 'Type change: add, sync, batched copy, check, switch over, drop last', 'What it touches', 'Lossy conversions', 'Open in editor'], + ui: 'Utils', api: '— (computed in the browser)', code: 'sql utilities/column-change, providers/*/*.column-change · web ColumnChangePanel', perms: 'utility.access', + tables: '—', tests: 'sql 56 · web 4 · live: PostgreSQL, MySQL, SQL Server' }, { id: 'utilities.insights', area: 'utilities', name: 'Server insights', subs: ['Server insights', 'Table insight', 'Query files'], ui: 'Utils', api: '/api/schema/dba-utility, /api/schema/table-insight', diff --git a/docs/feature-map/template.html b/docs/feature-map/template.html index 714e437ac..07772029c 100644 --- a/docs/feature-map/template.html +++ b/docs/feature-map/template.html @@ -230,7 +230,7 @@

    Fox Feature Map

    Measured on main · b12bcd5607 · 2026-10-31 · mirrors docs/FEATURE_MAP.md
      -
    • 34 features
    • +
    • 35 features
    • 158 HTTP routes
    • 18 server modules
    • 14 web features
    • diff --git a/docs/testing/TEST_IDS.md b/docs/testing/TEST_IDS.md index 1628c380d..39e0f7a4e 100644 --- a/docs/testing/TEST_IDS.md +++ b/docs/testing/TEST_IDS.md @@ -8,7 +8,7 @@ or `TestIds` for the same tree with autocomplete. `{name}` marks a part filled i time; "in FilterPicker" marks an ID a shared component draws from the prop written here. How to write a test with them: [WRITING_E2E.md](WRITING_E2E.md). -Every control has a test ID: **1015** buttons, text boxes, selects and textareas. +Every control has a test ID: **1024** buttons, text boxes, selects and textareas. ## access @@ -1578,6 +1578,26 @@ Every control has a test ID: **1015** buttons, text boxes, selects and textareas - `clone-table-name-option-{value}` · button in Autocomplete - `clone-table-name-toggle` · button in Autocomplete - `clone-unlock` · button · Unlock +- **ColumnChangePanel** · `features/utilities/components/ColumnChangePanel.tsx` + - `{testId}` · button · passed in by the parent + - `column-change-batch` · input + - `column-change-column` · select · Pick a column + - `column-change-copy-all` · button in CopyButton + - `column-change-hint` · p + - `column-change-impact` · section · What it touches + - `column-change-load-error` · p + - `column-change-new-name` · input + - `column-change-new-type` · input + - `column-change-open-editor` · button · Open in editor + - `column-change-panel` · div · What it touches + - `column-change-password` · input + - `column-change-refused` · p + - `column-change-step-{value}` · article + - `column-change-step-{value}-copy` · button in CopyButton + - `column-change-steps` · section · Steps ( ) + - `column-change-table` · select + - `column-change-unlock` · button · Unlock + - `column-change-warnings` · ul - **DatabaseAccessModal** · `features/utilities/components/DatabaseAccessModal.tsx` - `db-access-all-form` · div - `db-access-all-note` · p @@ -1714,6 +1734,7 @@ Every control has a test ID: **1015** buttons, text boxes, selects and textareas - `index-management-modal` · div - `sql-sidebar-utilities` · nav · Database utilities - `utilities-backup` · data · Backup & Restore + - `utilities-change-column` · data · Change Column - `utilities-clone-table` · data · Clone Table - `utilities-connection` · select - `utilities-connection-pool` · data · Connection Pool diff --git a/packages/ui-shared/src/index.ts b/packages/ui-shared/src/index.ts index 070b7dfd7..28e967310 100644 --- a/packages/ui-shared/src/index.ts +++ b/packages/ui-shared/src/index.ts @@ -166,6 +166,10 @@ export type { TableSizeGroup, } from '@foxschema/sql'; +// Safe column change: what a rename or type change touches, and its steps as SQL to review. +export { DEFAULT_COLUMN_CHANGE_BATCH, planColumnChange } from '@foxschema/sql'; +export type { ColumnChange, ColumnChangeImpact, ColumnChangePlan, ColumnChangeStep } from '@foxschema/sql'; + // SQL Tracer: shapes and bounds (the server redacts; the screen only shows). export { TRACE_MAX_EVENTS, TRACE_MAX_MS } from '@foxschema/sql'; export type { TraceEvent, TraceMode } from '@foxschema/sql';