Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/table-refresh-immediate-feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@prisma/studio-core": patch
---

Fix the "Refresh table" button giving no immediate feedback. The button now spins and disables for the whole refresh (schema re-introspection plus the row refetch), and a failed refresh keeps the last successfully loaded rows visible with an inline error notice (message, source, failed SQL preview, and retry) instead of only a generic message-less toast. Operation-error toasts now also include the underlying error message as their description.
5 changes: 5 additions & 0 deletions Architecture/db-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Database state architecture is implemented by these modules:

- [`ui/hooks/use-active-table-rows-collection.ts`](../ui/hooks/use-active-table-rows-collection.ts)
- [`ui/hooks/use-active-table-query.ts`](../ui/hooks/use-active-table-query.ts)
- [`ui/hooks/use-table-reload.ts`](../ui/hooks/use-table-reload.ts)
- [`ui/hooks/use-active-table-update.ts`](../ui/hooks/use-active-table-update.ts)
- [`ui/hooks/use-active-table-delete.ts`](../ui/hooks/use-active-table-delete.ts)
- [`ui/studio/context.tsx`](../ui/studio/context.tsx)
Expand Down Expand Up @@ -120,6 +121,10 @@ Manual refetch is done with:

`isFetching` for UI MUST come from collection/query state (`collection.utils.isFetching` or live-query loading fallback), not local booleans.

The busy state for the "Refresh table" action is derived the same way, never stored locally: the view binds the refresh button to `isFetching || isIntrospectionRefetching`, because a table refresh re-introspects the schema before refetching rows.

`collection.utils.refetch({ throwOnError: true })` rejections are caught by the view (through `useTableReload`) and shown inline as a refresh-failure notice; the rows collection keeps the last successfully loaded rows, so a failed refresh never clears the grid.

### Metadata cache

`filteredRowCount` MUST be stored in `tableQueryMetaCollection` keyed by `queryScopeKey`.
Expand Down
5 changes: 5 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ Studio keeps the last successful schema snapshot visible when a refresh fails, d
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.

## Refresh Table Feedback and Errors

Clicking "Refresh table" gives immediate feedback: the button spins and stays disabled for the entire refresh, covering both the schema re-introspection and the row refetch it performs, so the UI always reacts the moment the action starts.
When a refresh fails, the last successfully loaded rows stay visible in the grid and an inline notice above it explains what failed — the adapter error message, its source, and a preview of the failed SQL — with a Retry action.

## 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.
Expand Down
7 changes: 7 additions & 0 deletions ui/hooks/use-active-table-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export interface ActiveTableQueryData {
export interface UseActiveTableQueryResult {
data: ActiveTableQueryData | undefined;
isFetching: boolean;
/**
* Identity of the query scope the rows were loaded from. Consumers that
* need to reset per-scope state (for example the table reload error) can
* use it as a reset key.
*/
queryScopeKey: string;
refetch: () => Promise<void>;
}

Expand Down Expand Up @@ -72,6 +78,7 @@ export function useActiveTableQuery(
}
: undefined,
isFetching: state.isFetching,
queryScopeKey: state.queryScopeKey,
refetch: state.refetch,
};
}
Expand Down
104 changes: 87 additions & 17 deletions ui/hooks/use-active-table-rows-collection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";

import type {
Adapter,
AdapterIntrospectResult,
AdapterQueryDetails,
AdapterUpdateManyDetails,
Column,
FilterGroup,
SortOrderItem,
Table,
import {
type Adapter,
AdapterError,
type AdapterIntrospectResult,
type AdapterQueryDetails,
type AdapterUpdateManyDetails,
type Column,
type FilterGroup,
type SortOrderItem,
type Table,
} from "../../data/adapter";
import type { TableQueryMetaState } from "../studio/context";
import { useActiveTableRowsCollection } from "./use-active-table-rows-collection";
Expand Down Expand Up @@ -78,14 +79,15 @@ function createActiveTable(): Table {

function createAdapterMock(options?: {
queryImplementation?: (details: AdapterQueryDetails) => Promise<
[
null,
{
filteredRowCount: number;
query: { parameters: unknown[]; sql: string };
rows: Record<string, unknown>[];
},
]
| [AdapterError]
| [
null,
{
filteredRowCount: number;
query: { parameters: unknown[]; sql: string };
rows: Record<string, unknown>[];
},
]
>;
}): Adapter {
const introspection: AdapterIntrospectResult = {
Expand Down Expand Up @@ -628,6 +630,74 @@ describe("useActiveTableRowsCollection", () => {
cleanup();
});

it("keeps the last rows and clears isFetching when refetch rejects with an AdapterError", async () => {
let queryCalls = 0;
const queryError = new AdapterError("connection refused");
queryError.adapterSource = "postgresql";
queryError.query = { parameters: [], sql: "select * from users" };

const queryImplementation: QueryImplementation = () => {
queryCalls += 1;

if (queryCalls === 1) {
return Promise.resolve([
null,
{
filteredRowCount: 2,
query: { parameters: [], sql: "query-initial" },
rows: [
{ id: "u1", name: "Alice" },
{ id: "u2", name: "Bob" },
],
},
]);
}

return Promise.resolve([queryError]);
};

const harness = renderHookHarness({
queryImplementation,
});

await waitFor(() => (harness.getLatestState()?.rows.length ?? 0) === 2);
expect(harness.getLatestState()?.isFetching).toBe(false);

let caught: unknown;

await act(async () => {
try {
await harness.getLatestState()?.refetch();
} catch (error) {
caught = error;
}
});

expect(caught).toBe(queryError);

// The failed refetch keeps the last successfully loaded rows in the
// collection instead of clearing the grid.
await waitFor(() => harness.getLatestState()?.isFetching === false);
expect(harness.getLatestState()?.rows.map((row) => row.id)).toEqual([
"u1",
"u2",
]);
expect(
harness.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 === "query"
);
}),
).toBe(true);

harness.cleanup();
});

it("preserves filtered row count while a different page is loading", async () => {
let queryCalls = 0;
let releaseSecondPage: (() => void) | undefined;
Expand Down
10 changes: 1 addition & 9 deletions ui/hooks/use-introspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
INTROSPECTION_QUERY_KEY,
refreshIntrospection,
} from "./refresh-introspection";
import { getQueryPreview } from "./utils/get-query-preview";

export interface IntrospectionErrorState {
adapterSource: string;
Expand All @@ -33,15 +34,6 @@ function createInitialIntrospectionResult(
} satisfies AdapterIntrospectResult;
}

function getQueryPreview(query: Query<unknown> | undefined): string | null {
if (!query?.sql) {
return null;
}

const preview = query.sql.slice(0, 120);
return query.sql.length > 120 ? `${preview}...` : preview;
}

export function useIntrospection() {
const { adapter, hasDatabase, onEvent, queryClient } = useStudio();
const hasEmittedLaunchEventRef = useRef(false);
Expand Down
Loading
Loading