From 8262c43aaecc40304a92bdade8f66507f9f6b972 Mon Sep 17 00:00:00 2001 From: Gremlin Date: Mon, 17 Aug 2026 14:53:46 +0000 Subject: [PATCH 1/4] fix: refresh stale introspection so editor type stays correct A varchar column could render as a boolean checkbox for the entire session because use-introspection cached the DB schema with staleTime: Infinity and refetchOnWindowFocus: false. If a column's type changed in the DB after Studio loaded, the editor never updated. Three fixes: 1. Refreshable introspection: drop staleTime: Infinity and enable refetchOnWindowFocus so the schema re-introspects when the user returns to Studio. Add a dedicated 'Refresh schema' toolbar button (Shadcn Button + Tooltip, DatabaseZap icon, loading/disabled state) next to the existing 'Refresh table' button in ActiveTableView. 2. Self-heal on type-mismatch write errors: when an insert/update fails with Postgres SQLSTATE 42804 (datatype_mismatch) or 22P02 (invalid_text_representation), invalidate the introspection query cache and refetch so the editor re-renders with the correct type. The original error is still surfaced; the self-heal only adds a background refetch. Hooked into the insert and both update error paths. Shared query key + helpers live in refresh-introspection.ts; Postgres error detection in postgres-core/postgres-error.ts. 3. Surface the DB type in the cell editor: wrap every dispatched editor in a ColumnTypeLabel (Shadcn Tooltip) showing 'type: ' so schema drift is visible instead of just producing a wrong widget. Architecture/introspection.md and cell-editing.md are updated to reflect the new fetch policy, self-heal contract, and type-label contract (the previous introspection doc mandated staleTime: Infinity, which this fix corrects). FEATURES.md documents the new capability. Tests cover the new behavior; typecheck, lint, tests, build, and check:exports all pass. --- Architecture/cell-editing.md | 2 + Architecture/introspection.md | 38 +- FEATURES.md | 16 + data/postgres-core/index.ts | 1 + data/postgres-core/postgres-error.test.ts | 74 ++++ data/postgres-core/postgres-error.ts | 65 +++ ui/hooks/refresh-introspection.ts | 51 +++ ui/hooks/use-active-table-insert.test.tsx | 373 ++++++++++++++++++ ui/hooks/use-active-table-insert.ts | 9 +- .../use-active-table-rows-collection.test.tsx | 208 +++++++++- ui/hooks/use-active-table-rows-collection.ts | 13 + ui/hooks/use-introspection.test.tsx | 68 +++- ui/hooks/use-introspection.ts | 27 +- ui/studio/input/ColumnTypeLabel.test.tsx | 135 +++++++ ui/studio/input/ColumnTypeLabel.tsx | 77 ++++ ui/studio/input/get-input.tsx | 12 +- ui/studio/views/table/ActiveTableView.tsx | 39 +- 17 files changed, 1183 insertions(+), 25 deletions(-) create mode 100644 data/postgres-core/postgres-error.test.ts create mode 100644 data/postgres-core/postgres-error.ts create mode 100644 ui/hooks/refresh-introspection.ts create mode 100644 ui/hooks/use-active-table-insert.test.tsx create mode 100644 ui/studio/input/ColumnTypeLabel.test.tsx create mode 100644 ui/studio/input/ColumnTypeLabel.tsx diff --git a/Architecture/cell-editing.md b/Architecture/cell-editing.md index 3e5e5c82..49591837 100644 --- a/Architecture/cell-editing.md +++ b/Architecture/cell-editing.md @@ -72,6 +72,8 @@ Selection rules MUST remain centralized here: Do not duplicate datatype branching in callers. +Every dispatched input MUST be wrapped by `ColumnTypeLabel` so the DB column type Studio believes the column has is surfaced inside the editor popover (see `Architecture/introspection.md` → "Cell Editor Type Label"). This makes schema drift visible instead of producing a silently wrong widget. + ## Readonly And Writeability Rules A cell is editable only when both are true: diff --git a/Architecture/introspection.md b/Architecture/introspection.md index e7504294..ec1bbf14 100644 --- a/Architecture/introspection.md +++ b/Architecture/introspection.md @@ -31,10 +31,35 @@ The query MUST use: - `retry: false` - `retryOnMount: false` - `refetchOnReconnect: false` -- `refetchOnWindowFocus: false` -- `staleTime: Infinity` +- `refetchOnWindowFocus: true` +- no `staleTime: Infinity` (the default `staleTime` is used so cached introspection is allowed to go stale) -Automatic retry loops are forbidden for introspection because they can spam operation events, repeat expensive metadata work, and hide the real startup failure state from the user. +Automatic retry loops are forbidden for introspection because they can spam operation events, repeat expensive metadata work, and hide the real startup failure state from the user. `refetchOnWindowFocus` is not a retry loop: it re-introspects at most once each time the user returns to Studio, which is the intent of the refreshable schema contract below. + +## Refreshable Schema Contract + +Introspection is cached but MUST be refreshable so the cell editor re-renders with the correct column type when the live DB schema drifts (e.g. a column changed from boolean to varchar after Studio loaded). + +All refresh paths go through one shared mechanism in `ui/hooks/refresh-introspection.ts`: + +- `INTROSPECTION_QUERY_KEY` is the single React Query key for introspection. +- `refreshIntrospection(queryClient)` invalidates the introspection cache and refetches any active observer. + +Three call sites share it: + +- `useIntrospection().refreshSchema` — backs the toolbar "Refresh schema" button. +- the write-error self-heal path (see Self-Heal Contract below). +- window-focus refetch (React Query built-in, enabled by `refetchOnWindowFocus: true` + non-`Infinity` `staleTime`). + +The "Refresh schema" button MUST be a ShadCN `Button` with a ShadCN `Tooltip`, an `aria-label`, and a loading/disabled state while refetching. + +## Self-Heal Contract + +When a row write (insert/update) fails with a PostgreSQL type-mismatch error, Studio MUST additionally invalidate cached introspection and trigger a refetch so the editor can re-render with the correct column type. + +- Detection: `data/postgres-core/postgres-error.ts` matches SQLSTATE `42804` (`datatype_mismatch`) and `22P02` (`invalid_text_representation`) by reading the string `code` property carried on the `AdapterError` (the Postgres driver attaches the server `SqlState` there, and `createAdapterError` preserves it on the same object). +- Wiring: `ui/hooks/refresh-introspection.ts` exports `selfHealOnWriteError({ error, queryClient })`, called from the row mutation error paths in `ui/hooks/use-active-table-rows-collection.ts` (`onUpdate`) and `ui/hooks/use-active-table-insert.ts`. +- Non-swallowing: the original error MUST still be surfaced to the user (the `studio_operation_error` event and resulting toast still fire). The self-heal only additionally triggers a background introspection refetch. ## Data Fallback Contract @@ -93,3 +118,10 @@ Changes to this subsystem MUST include tests for: - startup recovery UI rendering - adapter partial-success fallback when timezone lookup fails - single-emission behavior for `studio_launched` +- introspection refetching when the window regains focus (proves `staleTime` is not `Infinity` and `refetchOnWindowFocus` is enabled) +- `refreshSchema` invalidating and triggering an introspection refetch +- self-heal on a Postgres type-mismatch write error (SQLSTATE `42804` / `22P02`) without swallowing the user-facing error + +## Cell Editor Type Label + +The cell editor MUST surface the DB column type Studio believes a column has (via `ui/studio/input/ColumnTypeLabel.tsx`, composed around the input by `ui/studio/input/get-input.tsx`). This makes schema drift visible to the user instead of producing a silently wrong widget. The readable type string comes from `ui/lib/datatype-display.ts` (`formatDatatypeName`), which maps internal catalog names to common SQL aliases. The label uses standard ShadCN `Tooltip` composition; no non-standard UI is introduced. diff --git a/FEATURES.md b/FEATURES.md index a6a8c714..7d19dd32 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -16,6 +16,7 @@ A standalone `sslmode` without SSL file parameters is left in the connection str Studio introspects connected databases to build schemas, tables, columns, relationships, filter operators, and timezone metadata. This gives users an accurate live model of the database and keeps table navigation grounded in current structure. A fresh Studio mount performs this discovery once, while actual adapter or database-availability changes invalidate cached metadata and load it again. +Introspection is refreshable, not frozen: cached schema is allowed to go stale and is re-introspected when the user returns to Studio (window focus), when the user clicks "Refresh schema", or when a row write fails with a PostgreSQL type-mismatch error. The MySQL adapter detects MariaDB servers via `select version()` and switches to a MariaDB-compatible tables query that returns one row per column and groups the result on the client, so introspection works on all supported MariaDB versions where `json_arrayagg` or JSON casts are unavailable and cannot be truncated by `group_concat_max_len`. ## Deployable Prisma Postgres Demo @@ -47,6 +48,21 @@ destroys the preview service when the branch is deleted. Startup introspection failures show retryable diagnostics in both the sidebar and the main table panel instead of pretending the database has no tables. Studio keeps the last successful schema snapshot visible when a refresh fails, disables automatic retry loops for introspection, and falls back to `UTC` when PostgreSQL or MySQL timezone metadata is unavailable but table metadata succeeded. +## Refreshable Schema + +Studio re-introspects the database schema when the user returns to the window, and a dedicated "Refresh schema" toolbar button (next to "Refresh table") triggers an explicit refresh with a loading state and tooltip. +All refresh paths share one React Query key and a single `refreshIntrospection` helper, so the toolbar button, the write-error self-heal path, and window-focus refetch all invalidate the same cached introspection and refetch the active observer. + +## Self-Healing Editor on Type-Mismatch Write Errors + +When an insert or update fails with a PostgreSQL type-mismatch error (SQLSTATE `42804` datatype_mismatch or `22P02` invalid_text_representation), Studio invalidates cached introspection and refetches so the cell editor re-renders with the correct column type. +The original error is still surfaced to the user via the normal operation-error toast and Console; the self-heal only additionally triggers a background schema refresh. + +## Cell Editor DB Type Label + +The cell editor popover shows the DB column type Studio believes the column has (for example "type: varchar"), with a tooltip explaining what to do if it no longer matches the live schema. +This makes schema drift visible to the user instead of producing a silently wrong widget, and it stays accurate once the refresh/self-heal paths reload introspection. + ## URL-Driven Navigation and Deep Linking View, schema, table, filter, sort, pagination, and row-search state are encoded in URL hash parameters. diff --git a/data/postgres-core/index.ts b/data/postgres-core/index.ts index ddf9aed3..02de7043 100644 --- a/data/postgres-core/index.ts +++ b/data/postgres-core/index.ts @@ -2,5 +2,6 @@ export * from "./adapter"; export * from "./dml"; export * from "./full-table-search"; export * from "./introspection"; +export * from "./postgres-error"; export * from "./sql-lint"; export * from "./utility"; diff --git a/data/postgres-core/postgres-error.test.ts b/data/postgres-core/postgres-error.test.ts new file mode 100644 index 00000000..b536d2eb --- /dev/null +++ b/data/postgres-core/postgres-error.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { AdapterError } from "../adapter"; +import { + getPostgresErrorCode, + isPostgresTypeMismatchError, + POSTGRES_DATATYPE_MISMATCH_CODE, + POSTGRES_INVALID_TEXT_REPRESENTATION_CODE, +} from "./postgres-error"; + +function createAdapterErrorWithCode(code: string): AdapterError { + const error = new AdapterError(`db error: ${code}`) as AdapterError & { + code?: string; + }; + error.code = code; + error.adapterSource = "postgresql"; + return error; +} + +describe("getPostgresErrorCode", () => { + it("returns the string code attached to a driver/adapter error", () => { + expect(getPostgresErrorCode(createAdapterErrorWithCode("42804"))).toBe( + "42804", + ); + }); + + it("returns undefined when the error has no code", () => { + expect(getPostgresErrorCode(new Error("no code"))).toBeUndefined(); + }); + + it("returns undefined for non-string code values", () => { + expect( + getPostgresErrorCode({ code: 42, message: "numeric code" }), + ).toBeUndefined(); + }); + + it("returns undefined for null/undefined input", () => { + expect(getPostgresErrorCode(null)).toBeUndefined(); + expect(getPostgresErrorCode(undefined)).toBeUndefined(); + }); +}); + +describe("isPostgresTypeMismatchError", () => { + it("detects SQLSTATE 42804 (datatype_mismatch)", () => { + expect( + isPostgresTypeMismatchError( + createAdapterErrorWithCode(POSTGRES_DATATYPE_MISMATCH_CODE), + ), + ).toBe(true); + }); + + it("detects SQLSTATE 22P02 (invalid_text_representation)", () => { + expect( + isPostgresTypeMismatchError( + createAdapterErrorWithCode(POSTGRES_INVALID_TEXT_REPRESENTATION_CODE), + ), + ).toBe(true); + }); + + it("does not match unrelated Postgres SQLSTATEs", () => { + expect( + isPostgresTypeMismatchError(createAdapterErrorWithCode("42P01")), + ).toBe(false); + expect( + isPostgresTypeMismatchError(createAdapterErrorWithCode("23505")), + ).toBe(false); + }); + + it("does not match errors without a SQLSTATE code", () => { + expect(isPostgresTypeMismatchError(new Error("network failure"))).toBe( + false, + ); + }); +}); diff --git a/data/postgres-core/postgres-error.ts b/data/postgres-core/postgres-error.ts new file mode 100644 index 00000000..2a3196cb --- /dev/null +++ b/data/postgres-core/postgres-error.ts @@ -0,0 +1,65 @@ +import type { AdapterError } from "../adapter"; + +/** + * PostgreSQL SQLSTATE `datatype_mismatch`. + * + * Raised when an INSERT/UPDATE supplies a value whose type does not match + * the column's current type and Postgres cannot coerce it. + */ +export const POSTGRES_DATATYPE_MISMATCH_CODE = "42804"; + +/** + * PostgreSQL SQLSTATE `invalid_text_representation`. + * + * Raised when a text value cannot be parsed into the target type, e.g. the + * editor sent `"true"`/`true` for a column whose live type is no longer + * boolean and the value cannot be cast. + */ +export const POSTGRES_INVALID_TEXT_REPRESENTATION_CODE = "22P02"; + +const TYPE_MISMATCH_SQLSTATES = new Set([ + POSTGRES_DATATYPE_MISMATCH_CODE, + POSTGRES_INVALID_TEXT_REPRESENTATION_CODE, +]); + +/** + * Returns the PostgreSQL SQLSTATE code carried on an error, if any. + * + * Postgres drivers (e.g. `postgres`) attach the server's `SqlState` as a + * string `code` property on the thrown/rejected error. `createAdapterError` + * mutates that same error object to add `adapterSource`/`query`, so the code + * survives onto the `AdapterError` that reaches mutation handlers. This + * mirrors the shape already read by lint diagnostics + * (`getPostgresErrorCode` in `./sql-lint`). + */ +export function getPostgresErrorCode(error: unknown): string | undefined { + if ( + error == null || + (typeof error !== "object" && typeof error !== "function") + ) { + return undefined; + } + + const { code } = error as { code?: unknown }; + return typeof code === "string" ? code : undefined; +} + +/** + * Whether a write error is a PostgreSQL type-mismatch that warrants + * invalidating cached introspection so the cell editor re-renders with the + * correct column type. + * + * Used by the write-error self-heal path in the row mutation hooks. This only + * inspects the error; callers MUST still surface the original error to the + * user (e.g. via `studio_operation_error`). + */ +export function isPostgresTypeMismatchError(error: unknown): boolean { + const code = getPostgresErrorCode(error); + return code != null && TYPE_MISMATCH_SQLSTATES.has(code); +} + +/** + * Type helper for documentation: the error reaching mutation handlers is an + * `AdapterError` carrying the driver's SQLSTATE `code`. + */ +export type PostgresWriteError = AdapterError & { code?: string }; diff --git a/ui/hooks/refresh-introspection.ts b/ui/hooks/refresh-introspection.ts new file mode 100644 index 00000000..90b58999 --- /dev/null +++ b/ui/hooks/refresh-introspection.ts @@ -0,0 +1,51 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { isPostgresTypeMismatchError } from "../../data/postgres-core/postgres-error"; + +/** + * Stable React Query key for the introspection query. + * + * The manual "refresh schema" action, the write-error self-heal path, and any + * future invalidation all go through this key so there is a single place that + * owns the cache identity of the DB schema. + */ +export const INTROSPECTION_QUERY_KEY: ["introspection"] = ["introspection"]; + +/** + * Invalidate cached introspection and trigger a refetch of any active + * introspection query. + * + * This is the single shared mechanism used by: + * - the manual "refresh schema" button (via `useIntrospection().refreshSchema`) + * - the write-error self-heal path (via {@link selfHealOnWriteError}) + * + * `invalidateQueries` marks the query stale and refetches active observers, + * so the editor re-renders with the freshly introspected column types. + */ +export async function refreshIntrospection( + queryClient: QueryClient, +): Promise { + await queryClient.invalidateQueries({ + queryKey: INTROSPECTION_QUERY_KEY, + refetchType: "active", + }); +} + +/** + * Inspect a row-write (insert/update) error and, when it indicates the column + * type Studio cached no longer matches the live database schema, invalidate + * cached introspection so the cell editor re-renders with the correct type. + * + * The original error is NOT swallowed: callers still surface it to the user + * (e.g. via `studio_operation_error`). This helper only additionally triggers + * a background introspection refetch on Postgres SQLSTATE `42804` + * (`datatype_mismatch`) or `22P02` (`invalid_text_representation`). + */ +export function selfHealOnWriteError(args: { + error: unknown; + queryClient: QueryClient; +}): void { + if (isPostgresTypeMismatchError(args.error)) { + void refreshIntrospection(args.queryClient); + } +} diff --git a/ui/hooks/use-active-table-insert.test.tsx b/ui/hooks/use-active-table-insert.test.tsx new file mode 100644 index 00000000..7fe5c40f --- /dev/null +++ b/ui/hooks/use-active-table-insert.test.tsx @@ -0,0 +1,373 @@ +import { + createCollection, + localOnlyCollectionOptions, +} from "@tanstack/react-db"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { + Adapter, + AdapterQueryDetails, + Column, + Table, +} from "../../data/adapter"; +import type { TableQueryMetaState } from "../studio/context"; +import { useActiveTableInsert } from "./use-active-table-insert"; +import { useActiveTableQueryCollection } from "./use-active-table-query"; + +const useStudioMock = vi.fn(); +const useNavigationMock = vi.fn(); + +vi.mock("../studio/context", () => ({ + useStudio: () => useStudioMock(), +})); + +vi.mock("./use-navigation", () => ({ + useNavigation: () => useNavigationMock(), +})); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const TOTAL_ROW_COUNT = 2; + +function createColumn(params: { + name: string; + pkPosition: number | null; +}): Column { + const { name, pkPosition } = params; + + return { + datatype: { + group: "string", + isArray: false, + isNative: true, + name: "text", + options: [], + schema: "public", + }, + defaultValue: null, + fkColumn: null, + fkSchema: null, + fkTable: null, + isAutoincrement: false, + isComputed: false, + isRequired: pkPosition != null, + name, + nullable: pkPosition == null, + pkPosition, + schema: "public", + table: "users", + }; +} + +function createActiveTable(): Table { + return { + columns: { + id: createColumn({ name: "id", pkPosition: 1 }), + name: createColumn({ name: "name", pkPosition: null }), + }, + name: "users", + schema: "public", + }; +} + +function createAdapterMock(): Adapter { + return { + defaultSchema: "public", + insert: vi.fn(async () => { + return [ + null, + { + rows: [], + query: { parameters: [], sql: "insert" }, + }, + ]; + }), + query: vi.fn(async (details: AdapterQueryDetails) => { + const start = details.pageIndex * details.pageSize; + const end = Math.min(TOTAL_ROW_COUNT, start + details.pageSize); + const rows = Array.from({ length: Math.max(0, end - start) }, (_, i) => ({ + id: `u${start + i + 1}`, + name: `User ${start + i + 1}`, + })); + + return [ + null, + { + filteredRowCount: TOTAL_ROW_COUNT, + query: { parameters: [], sql: "query" }, + rows, + }, + ]; + }), + } as unknown as Adapter; +} + +function createRowsCollectionCache() { + const cache = new Map(); + + return { + getOrCreateRowsCollection(key: string, factory: () => T): T { + const existing = cache.get(key) as T | undefined; + + if (existing != null) { + return existing; + } + + const created = factory(); + cache.set(key, created); + + return created; + }, + }; +} + +function createTableQueryExecutionStateCache() { + const cache = new Map< + string, + { activeController: AbortController | null; latestRequestId: number } + >(); + + return { + getOrCreateTableQueryExecutionState(key: string) { + const existing = cache.get(key); + + if (existing != null) { + return existing; + } + + const created = { activeController: null, latestRequestId: 0 }; + cache.set(key, created); + + return created; + }, + }; +} + +function createTableQueryMetaCollection() { + return createCollection( + localOnlyCollectionOptions({ + id: "test-insert-table-query-meta", + getKey(item) { + return item.id; + }, + initialData: [], + }), + ); +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function waitFor(assertion: () => boolean): Promise { + const timeoutMs = 2000; + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + if (assertion()) { + return; + } + + await flush(); + } + + throw new Error("Timed out waiting for hook state"); +} + +const emptyFilter = { + after: "and" as const, + filters: [], + id: "root", + kind: "FilterGroup" as const, +}; + +function renderHookHarness(queryProps: { + pageIndex: number; + pageSize: number; +}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const adapter = createAdapterMock(); + const activeTable = createActiveTable(); + const tableQueryMetaCollection = createTableQueryMetaCollection(); + const queryClient = new QueryClient(); + const { getOrCreateRowsCollection } = createRowsCollectionCache(); + const { getOrCreateTableQueryExecutionState } = + createTableQueryExecutionStateCache(); + const onEvent = vi.fn(); + + useStudioMock.mockReturnValue({ + adapter, + getOrCreateTableQueryExecutionState, + getOrCreateRowsCollection, + onEvent, + queryClient, + tableQueryMetaCollection, + }); + useNavigationMock.mockReturnValue({ + metadata: { + activeTable, + }, + }); + + const fullQueryProps = { + filter: emptyFilter, + pageIndex: queryProps.pageIndex, + pageSize: queryProps.pageSize, + sortOrder: [], + }; + + let latestInsert: ReturnType | undefined; + + function Harness() { + // Ensure the rows collection / active table resolve the same way the view + // does, so the insert hook has a non-null active table. + useActiveTableQueryCollection(fullQueryProps); + latestInsert = useActiveTableInsert(fullQueryProps); + + return null; + } + + act(() => { + root.render( + + + , + ); + }); + + function cleanup() { + act(() => { + root.unmount(); + }); + queryClient.clear(); + container.remove(); + } + + return { + adapter, + cleanup, + getInsert() { + return latestInsert; + }, + onEvent, + }; +} + +afterEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +describe("useActiveTableInsert", () => { + it("self-heals introspection on a Postgres type-mismatch insert error (SQLSTATE 42804)", async () => { + const invalidateSpy = vi.spyOn(QueryClient.prototype, "invalidateQueries"); + const { adapter, cleanup, getInsert, onEvent } = renderHookHarness({ + pageIndex: 0, + pageSize: 25, + }); + + await waitFor(() => getInsert() != null); + + const typeMismatchError = new Error( + "column is of type varchar but expression is of type boolean", + ) as Error & { code?: string; query?: unknown }; + typeMismatchError.code = "42804"; + typeMismatchError.query = { parameters: [], sql: "insert" }; + + (adapter.insert as ReturnType).mockResolvedValueOnce([ + typeMismatchError, + ]); + + const insert = getInsert(); + + if (!insert) { + throw new Error("insert hook was not rendered"); + } + + let caught: unknown; + + await act(async () => { + try { + await insert.mutateAsync([{ name: "Drifted" }]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBe(typeMismatchError); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: ["introspection"] }), + ); + expect( + onEvent.mock.calls.some((call: unknown[]) => { + const event = call[0] as { + name: string; + payload: { operation: string }; + }; + return ( + event.name === "studio_operation_error" && + event.payload.operation === "insert" + ); + }), + ).toBe(true); + + invalidateSpy.mockRestore(); + cleanup(); + }); + + it("does not self-heal introspection on a non-type-mismatch insert error", async () => { + const invalidateSpy = vi.spyOn(QueryClient.prototype, "invalidateQueries"); + const { adapter, cleanup, getInsert } = renderHookHarness({ + pageIndex: 0, + pageSize: 25, + }); + + await waitFor(() => getInsert() != null); + + const uniqueViolation = new Error("duplicate key value") as Error & { + code?: string; + query?: unknown; + }; + uniqueViolation.code = "23505"; + uniqueViolation.query = { parameters: [], sql: "insert" }; + + (adapter.insert as ReturnType).mockResolvedValueOnce([ + uniqueViolation, + ]); + + const insert = getInsert(); + + if (!insert) { + throw new Error("insert hook was not rendered"); + } + + let caught: unknown; + + await act(async () => { + try { + await insert.mutateAsync([{ name: "Dupe" }]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBe(uniqueViolation); + expect(invalidateSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ queryKey: ["introspection"] }), + ); + + invalidateSpy.mockRestore(); + cleanup(); + }); +}); diff --git a/ui/hooks/use-active-table-insert.ts b/ui/hooks/use-active-table-insert.ts index abfa45cd..76f606de 100644 --- a/ui/hooks/use-active-table-insert.ts +++ b/ui/hooks/use-active-table-insert.ts @@ -1,6 +1,7 @@ import { useMutation } from "@tanstack/react-query"; import { useStudio } from "../studio/context"; +import { selfHealOnWriteError } from "./refresh-introspection"; import { useActiveTableQueryCollection, type UseActiveTableQueryProps, @@ -8,7 +9,7 @@ import { import { addRowIdToResult } from "./utils/add-row-id-to-result"; export function useActiveTableInsert(query: UseActiveTableQueryProps) { - const { adapter, onEvent } = useStudio(); + const { adapter, onEvent, queryClient } = useStudio(); const { activeTable, refetch } = useActiveTableQueryCollection(query); const { schema = null, name: table = null } = activeTable ?? {}; @@ -34,6 +35,12 @@ export function useActiveTableInsert(query: UseActiveTableQueryProps) { }, }); + // Schema drift can surface as a Postgres type-mismatch on insert. + // Invalidate cached introspection so the editor can re-render with + // the correct column type, then still throw so the user-facing error + // is preserved. + selfHealOnWriteError({ error, queryClient }); + throw error; } diff --git a/ui/hooks/use-active-table-rows-collection.test.tsx b/ui/hooks/use-active-table-rows-collection.test.tsx index bb2512c4..8d0b2a20 100644 --- a/ui/hooks/use-active-table-rows-collection.test.tsx +++ b/ui/hooks/use-active-table-rows-collection.test.tsx @@ -170,11 +170,13 @@ function createAdapterMock(options?: { parameters: [], sql: "update-many", })), - rows: details.updates.map((update: AdapterUpdateManyDetails["updates"][number]) => ({ - ...update.row, - ...update.changes, - __ps_updated_at__: new Date().toISOString(), - })), + rows: details.updates.map( + (update: AdapterUpdateManyDetails["updates"][number]) => ({ + ...update.row, + ...update.changes, + __ps_updated_at__: new Date().toISOString(), + }), + ), }, ]; }), @@ -497,8 +499,14 @@ describe("useActiveTableRowsCollection", () => { await tx.isPersisted.promise; }); - expect((adapter as Adapter & { updateMany: ReturnType }).updateMany).toHaveBeenCalledTimes(1); - expect((adapter as Adapter & { updateMany: ReturnType }).updateMany).toHaveBeenCalledWith( + expect( + (adapter as Adapter & { updateMany: ReturnType }) + .updateMany, + ).toHaveBeenCalledTimes(1); + expect( + (adapter as Adapter & { updateMany: ReturnType }) + .updateMany, + ).toHaveBeenCalledWith( expect.objectContaining({ table: createActiveTable(), updates: [ @@ -705,15 +713,20 @@ describe("useActiveTableRowsCollection", () => { }, }); - await waitFor(() => (adapter.query as ReturnType).mock.calls.length === 1); + await waitFor( + () => (adapter.query as ReturnType).mock.calls.length === 1, + ); - const firstQueryOptions = (adapter.query as ReturnType).mock.calls[0]?.[1]; + const firstQueryOptions = (adapter.query as ReturnType).mock + .calls[0]?.[1]; rerender({ sortOrder: [{ column: "name", direction: "desc" }], }); - await waitFor(() => (adapter.query as ReturnType).mock.calls.length === 2); + await waitFor( + () => (adapter.query as ReturnType).mock.calls.length === 2, + ); expect(firstQueryOptions?.abortSignal).toBeInstanceOf(AbortSignal); expect(firstQueryOptions?.abortSignal.aborted).toBe(true); @@ -722,4 +735,179 @@ describe("useActiveTableRowsCollection", () => { cleanup(); }); + + it("self-heals introspection on a Postgres type-mismatch updateMany error (SQLSTATE 42804)", async () => { + const invalidateSpy = vi.spyOn(QueryClient.prototype, "invalidateQueries"); + const { adapter, cleanup, getLatestState, onEvent } = renderHookHarness(); + + await waitFor(() => (getLatestState()?.rows.length ?? 0) === 2); + + const collection = getLatestState()?.collection; + + if (!collection) { + throw new Error("Rows collection was not created"); + } + + const rowIds = [...collection.keys()].map(String); + + const typeMismatchError = new Error( + "argument of type boolean does not match column type varchar", + ) as Error & { code?: string; query?: unknown }; + typeMismatchError.code = "42804"; + typeMismatchError.query = { parameters: [], sql: "update-many" }; + + // Persistently return the error so the self-heal fires regardless of + // whether the collection batches the two rows into one updateMany call + // or falls back to per-row adapter.update calls. + ( + adapter as Adapter & { updateMany: ReturnType } + ).updateMany.mockResolvedValue([typeMismatchError]); + (adapter.update as ReturnType).mockResolvedValue([ + typeMismatchError, + ]); + + let caught: unknown; + + await act(async () => { + try { + const tx = collection.update(rowIds, (drafts) => { + drafts[0]!.name = "Drifted"; + }); + await tx.isPersisted.promise; + } catch (error) { + caught = error; + } + }); + + expect(caught).toBe(typeMismatchError); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: ["introspection"] }), + ); + expect( + onEvent.mock.calls.some((call: unknown[]) => { + const event = call[0] as { + name: string; + payload: { operation: string }; + }; + return ( + event.name === "studio_operation_error" && + event.payload.operation === "update" + ); + }), + ).toBe(true); + + invalidateSpy.mockRestore(); + cleanup(); + }); + + it("self-heals introspection on a single-update type-mismatch error (SQLSTATE 22P02)", async () => { + const invalidateSpy = vi.spyOn(QueryClient.prototype, "invalidateQueries"); + const { adapter, cleanup, getLatestState } = renderHookHarness({ + withoutUpdateMany: true, + }); + + await waitFor(() => (getLatestState()?.rows.length ?? 0) === 2); + + const collection = getLatestState()?.collection; + + if (!collection) { + throw new Error("Rows collection was not created"); + } + + const rowId = String([...collection.keys()][0] ?? ""); + + const invalidTextError = new Error( + 'invalid input syntax for type bigint: "true"', + ) as Error & { code?: string; query?: unknown }; + invalidTextError.code = "22P02"; + invalidTextError.query = { parameters: [], sql: "update" }; + + (adapter.update as ReturnType).mockResolvedValue([ + invalidTextError, + ]); + + let caught: unknown; + + await act(async () => { + try { + const tx = collection.update(rowId, (draft) => { + draft.name = "Drifted"; + }); + await tx.isPersisted.promise; + } catch (error) { + caught = error; + } + }); + + expect(caught).toBe(invalidTextError); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: ["introspection"] }), + ); + + invalidateSpy.mockRestore(); + cleanup(); + }); + + it("does not self-heal introspection on a non-type-mismatch update error", async () => { + const invalidateSpy = vi.spyOn(QueryClient.prototype, "invalidateQueries"); + const { adapter, cleanup, getLatestState, onEvent } = renderHookHarness(); + + await waitFor(() => (getLatestState()?.rows.length ?? 0) === 2); + + const collection = getLatestState()?.collection; + + if (!collection) { + throw new Error("Rows collection was not created"); + } + + const rowIds = [...collection.keys()].map(String); + + const unrelatedError = new Error("relation does not exist") as Error & { + code?: string; + query?: unknown; + }; + unrelatedError.code = "42P01"; + unrelatedError.query = { parameters: [], sql: "update-many" }; + + ( + adapter as Adapter & { updateMany: ReturnType } + ).updateMany.mockResolvedValue([unrelatedError]); + (adapter.update as ReturnType).mockResolvedValue([ + unrelatedError, + ]); + + let caught: unknown; + + await act(async () => { + try { + const tx = collection.update(rowIds, (drafts) => { + drafts[0]!.name = "Drifted"; + }); + await tx.isPersisted.promise; + } catch (error) { + caught = error; + } + }); + + expect(caught).toBe(unrelatedError); + expect(invalidateSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ queryKey: ["introspection"] }), + ); + // The original error is still surfaced to the user. + expect( + onEvent.mock.calls.some((call: unknown[]) => { + const event = call[0] as { + name: string; + payload: { operation: string }; + }; + return ( + event.name === "studio_operation_error" && + event.payload.operation === "update" + ); + }), + ).toBe(true); + + invalidateSpy.mockRestore(); + cleanup(); + }); }); diff --git a/ui/hooks/use-active-table-rows-collection.ts b/ui/hooks/use-active-table-rows-collection.ts index bf9ce169..65c360ef 100644 --- a/ui/hooks/use-active-table-rows-collection.ts +++ b/ui/hooks/use-active-table-rows-collection.ts @@ -19,6 +19,7 @@ import type { } from "../../data/adapter"; import { AbortError } from "../../data/executor"; import { useStudio } from "../studio/context"; +import { selfHealOnWriteError } from "./refresh-introspection"; import { useNavigation } from "./use-navigation"; import { addRowIdToResult } from "./utils/add-row-id-to-result"; @@ -325,6 +326,12 @@ export function useActiveTableRowsCollection( }, }); + // Schema drift can surface as a Postgres type-mismatch on + // write. Invalidate cached introspection so the editor can + // re-render with the correct column type, then still throw + // so the user-facing error is preserved. + selfHealOnWriteError({ error, queryClient }); + throw error; } @@ -372,6 +379,12 @@ export function useActiveTableRowsCollection( }, }); + // Schema drift can surface as a Postgres type-mismatch on + // write. Invalidate cached introspection so the editor can + // re-render with the correct column type, then still throw + // so the user-facing error is preserved. + selfHealOnWriteError({ error, queryClient }); + throw error; } diff --git a/ui/hooks/use-introspection.test.tsx b/ui/hooks/use-introspection.test.tsx index fcff8aca..82b8afdc 100644 --- a/ui/hooks/use-introspection.test.tsx +++ b/ui/hooks/use-introspection.test.tsx @@ -1,4 +1,8 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + focusManager, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -19,6 +23,7 @@ const useStudioMock = vi.hoisted(() => adapter: Adapter; hasDatabase: boolean; onEvent: (event: StudioEventBase) => void; + queryClient: QueryClient; } >(), ); @@ -121,6 +126,7 @@ function renderHarness(args: { adapter, hasDatabase: true, onEvent, + queryClient, }); let latestState: ReturnType | undefined; @@ -276,4 +282,64 @@ describe("useIntrospection", () => { harness.cleanup(); }); + + it("re-introspects when the window regains focus (staleTime is not Infinity)", async () => { + // Reproduces the stale-introspection bug: with staleTime: Infinity and + // refetchOnWindowFocus: false the schema never refreshed on focus. The + // fix drops staleTime and enables refetchOnWindowFocus, so a focus event + // must trigger a second introspection. + const introspect = vi + .fn>() + .mockResolvedValue([null, createIntrospectionResult()] as [ + null, + AdapterIntrospectResult, + ]); + const harness = renderHarness({ + adapter: createAdapterMock({ introspect }), + }); + + await waitFor( + () => + harness.getLatestState()?.isSuccess === true && + introspect.mock.calls.length === 1, + ); + + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + await Promise.resolve(); + }); + + await waitFor(() => introspect.mock.calls.length === 2); + expect(introspect.mock.calls.length).toBe(2); + + harness.cleanup(); + }); + + it("refreshSchema invalidates and triggers an introspection refetch", async () => { + const introspect = vi + .fn>() + .mockResolvedValue([null, createIntrospectionResult()] as [ + null, + AdapterIntrospectResult, + ]); + const harness = renderHarness({ + adapter: createAdapterMock({ introspect }), + }); + + await waitFor( + () => + harness.getLatestState()?.isSuccess === true && + introspect.mock.calls.length === 1, + ); + + await act(async () => { + await harness.getLatestState()?.refreshSchema(); + }); + + await waitFor(() => introspect.mock.calls.length === 2); + expect(introspect.mock.calls.length).toBe(2); + + harness.cleanup(); + }); }); diff --git a/ui/hooks/use-introspection.ts b/ui/hooks/use-introspection.ts index 301c2d6b..ad412282 100644 --- a/ui/hooks/use-introspection.ts +++ b/ui/hooks/use-introspection.ts @@ -1,9 +1,13 @@ import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import type { AdapterError, AdapterIntrospectResult } from "../../data/adapter"; import type { Query } from "../../data/query"; import { useStudio } from "../studio/context"; +import { + INTROSPECTION_QUERY_KEY, + refreshIntrospection, +} from "./refresh-introspection"; export interface IntrospectionErrorState { adapterSource: string; @@ -39,7 +43,7 @@ function getQueryPreview(query: Query | undefined): string | null { } export function useIntrospection() { - const { adapter, hasDatabase, onEvent } = useStudio(); + const { adapter, hasDatabase, onEvent, queryClient } = useStudio(); const hasEmittedLaunchEventRef = useRef(false); useEffect(() => { @@ -48,7 +52,7 @@ export function useIntrospection() { const queryResult = useQuery({ enabled: hasDatabase, - queryKey: ["introspection"], + queryKey: INTROSPECTION_QUERY_KEY, queryFn: async ({ signal }) => { const [error, result] = await adapter.introspect({ abortSignal: signal }); @@ -93,13 +97,25 @@ export function useIntrospection() { return result; }, + // Refreshable schema contract: the cached introspection is allowed to go + // stale so that window-focus refetches and explicit invalidations (manual + // "refresh schema" + write-error self-heal) re-introspect the live DB. + // `retry`/`retryOnMount`/`refetchOnReconnect` stay off to preserve the + // no-automatic-retry-loop contract from the introspection architecture. refetchOnReconnect: false, - refetchOnWindowFocus: false, + refetchOnWindowFocus: true, retry: false, retryOnMount: false, - staleTime: Infinity, }); + // Single shared "refresh schema" action used by the toolbar button and by + // the write-error self-heal path (see `refresh-introspection.ts`). It + // invalidates the introspection cache and refetches the active observer. + const refreshSchema = useCallback( + () => refreshIntrospection(queryClient), + [queryClient], + ); + const fallbackData = useMemo(() => { return createInitialIntrospectionResult(adapter.defaultSchema); }, [adapter.defaultSchema]); @@ -138,5 +154,6 @@ export function useIntrospection() { hasResolvedIntrospection, isUsingLastKnownGoodData: queryResult.isError && queryResult.data != null, isUsingPlaceholderData: hasDatabase && queryResult.data == null, + refreshSchema, }; } diff --git a/ui/studio/input/ColumnTypeLabel.test.tsx b/ui/studio/input/ColumnTypeLabel.test.tsx new file mode 100644 index 00000000..111d7bda --- /dev/null +++ b/ui/studio/input/ColumnTypeLabel.test.tsx @@ -0,0 +1,135 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { Column } from "../../../data/adapter"; +import { ColumnTypeLabel } from "./ColumnTypeLabel"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createColumn(args: { + group: Column["datatype"]["group"]; + name: string; + schema?: string; + isArray?: boolean; + nullable?: boolean; +}): Column { + return { + datatype: { + group: args.group, + isArray: args.isArray ?? false, + isNative: true, + name: args.name, + options: [], + schema: args.schema ?? "pg_catalog", + }, + defaultValue: null, + fkColumn: null, + fkSchema: null, + fkTable: null, + isAutoincrement: false, + isComputed: false, + isRequired: false, + name: args.name, + nullable: args.nullable ?? true, + pkPosition: null, + schema: "public", + table: "users", + } as Column; +} + +function renderLabel(column: Column) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + +
editor
+
, + ); + }); + + return { + cleanup() { + act(() => { + root.unmount(); + }); + container.remove(); + }, + container, + }; +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("ColumnTypeLabel", () => { + it("surfaces the introspected DB type using the display alias", () => { + const harness = renderLabel( + createColumn({ group: "string", name: "varchar" }), + ); + + expect(harness.container.textContent).toContain("type: varchar"); + + harness.cleanup(); + }); + + it("aliases catalog names to common SQL spellings (int4 -> integer)", () => { + const harness = renderLabel( + createColumn({ group: "numeric", name: "int4" }), + ); + + expect(harness.container.textContent).toContain("type: integer"); + + harness.cleanup(); + }); + + it("annotates array columns", () => { + const harness = renderLabel( + createColumn({ group: "numeric", name: "int4[]", isArray: true }), + ); + + expect(harness.container.textContent).toContain("type: integer[] (array)"); + + harness.cleanup(); + }); + + it("keeps user-defined type names unchanged", () => { + const harness = renderLabel( + createColumn({ group: "enum", name: "mood", schema: "public" }), + ); + + expect(harness.container.textContent).toContain("type: mood"); + + harness.cleanup(); + }); + + it("exposes an accessible aria-label with the type name", () => { + const harness = renderLabel( + createColumn({ group: "boolean", name: "bool" }), + ); + + const trigger = harness.container.querySelector( + '[aria-label="Column type: boolean"]', + ); + + expect(trigger).not.toBeNull(); + + harness.cleanup(); + }); + + it("renders the editor children below the type label", () => { + const harness = renderLabel( + createColumn({ group: "string", name: "text" }), + ); + + expect(harness.container.textContent).toContain("editor"); + + harness.cleanup(); + }); +}); diff --git a/ui/studio/input/ColumnTypeLabel.tsx b/ui/studio/input/ColumnTypeLabel.tsx new file mode 100644 index 00000000..1419ee8d --- /dev/null +++ b/ui/studio/input/ColumnTypeLabel.tsx @@ -0,0 +1,77 @@ +import { Info } from "lucide-react"; +import type { PropsWithChildren } from "react"; + +import type { Column } from "../../../data/adapter"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../../components/ui/tooltip"; +import { formatDatatypeName } from "../../lib/datatype-display"; + +export interface ColumnTypeLabelProps { + column: Column; +} + +/** + * Surfaces the DB column type Studio believes a cell has, directly inside the + * cell editor popover. + * + * Why: introspection is cached, so when the live schema drifts (e.g. a column + * changed from boolean to varchar after Studio loaded) the editor can render + * the wrong widget. Showing the cached type makes that drift visible to the + * user instead of producing a silently wrong input. The refresh-schema action + * and the write-error self-heal path keep this label accurate. + * + * Composition: standard ShadCN `Tooltip` triggered by a small muted text + * label, so it stays unobtrusive but discoverable. The readable type string + * comes from {@link formatDatatypeName}, which maps internal catalog names to + * common SQL aliases. + */ +export function ColumnTypeLabel( + props: PropsWithChildren, +) { + const { children, column } = props; + const typeName = formatDatatypeName(column.datatype); + const isArray = column.datatype.isArray; + + return ( +
+
+ + + + + + +

+ Database column type Studio uses for this editor. +

+

+ If this no longer matches the live schema, use “Refresh schema”. +

+
+
+
+ {column.nullable ? ( + + nullable + + ) : null} +
+ {children} +
+ ); +} diff --git a/ui/studio/input/get-input.tsx b/ui/studio/input/get-input.tsx index 8e801168..a833edf6 100644 --- a/ui/studio/input/get-input.tsx +++ b/ui/studio/input/get-input.tsx @@ -3,6 +3,7 @@ import { isObjectType } from "remeda"; import type { Column } from "../../../data/adapter"; import { BooleanInput } from "./BooleanInput"; +import { ColumnTypeLabel } from "./ColumnTypeLabel"; import { DateInput } from "./DateInput"; import { EnumInput } from "./EnumInput"; import { JsonInput } from "./JsonInput"; @@ -28,8 +29,15 @@ export interface GetInputProps { } export function getInput(props: GetInputProps) { - const { cell, column, context, onNavigate, onSubmit, showSaveAction } = - props; + return ( + + {resolveInput(props)} + + ); +} + +function resolveInput(props: GetInputProps) { + const { cell, column, context, onNavigate, onSubmit, showSaveAction } = props; const { datatype, isAutoincrement, isComputed, nullable } = column; const { format, group, isArray, options } = datatype; diff --git a/ui/studio/views/table/ActiveTableView.tsx b/ui/studio/views/table/ActiveTableView.tsx index d9d83367..ecfad2de 100644 --- a/ui/studio/views/table/ActiveTableView.tsx +++ b/ui/studio/views/table/ActiveTableView.tsx @@ -1,6 +1,6 @@ import { useIsMutating } from "@tanstack/react-query"; import { type ColumnDef, type ColumnPinningState } from "@tanstack/react-table"; -import { ChevronDown, History, RefreshCw } from "lucide-react"; +import { ChevronDown, DatabaseZap, History, RefreshCw } from "lucide-react"; import { type Dispatch, type KeyboardEvent as ReactKeyboardEvent, @@ -40,6 +40,12 @@ import { DropdownMenuTrigger, } from "../../../components/ui/dropdown-menu"; import { TableHead } from "../../../components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../../../components/ui/tooltip"; import { useActiveTableInsert } from "../../../hooks/use-active-table-insert"; import { useActiveTableQuery } from "../../../hooks/use-active-table-query"; import { useActiveTableUpdateMany } from "../../../hooks/use-active-table-update-many"; @@ -181,8 +187,12 @@ export function ActiveTableView(_props: ViewProps) { ], ); - const { data: introspection, refetch: refetchIntrospection } = - useIntrospection(); + const { + data: introspection, + isRefetching: isIntrospectionRefetching, + refreshSchema, + refetch: refetchIntrospection, + } = useIntrospection(); const sqlEditorSchema = useMemo(() => { return createSqlEditorSchemaFromIntrospection({ defaultSchema: adapter.defaultSchema, @@ -1594,6 +1604,29 @@ export function ActiveTableView(_props: ViewProps) { ) : null} + + + + + + + Refresh schema + + +