Skip to content
Draft
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/query-insights-studio-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@prisma/studio-core": minor
---

Add the host-provided Query Insights Studio view with live `prisma-log` stream ingestion, ppg-dev demo support, charted query metrics, sortable query rows, and AI recommendation details.
2 changes: 1 addition & 1 deletion Architecture/demo-compute-bundling.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ That means when `build-compute.ts` bundles `demo/ppg-dev/server.ts` with Bun:

- Bun sees `@prisma/dev`'s literal Bun manifest import
- Bun emits hashed PGlite `.wasm`, `.data`, and extension archives next to the bundled server entrypoint
- `build-compute.ts` then copies the same runtime assets into `deploy/bundle/` with their canonical names like `pglite.wasm` and `pglite-seed.tar.gz`
- `build-compute.ts` then copies the same runtime assets into `deploy/bundle/` with their canonical names like `pglite.wasm`, `pglite.data`, `initdb.wasm`, and extension archives

That extra copy is a Studio-side workaround for the current Compute boot path:
the deployed `@prisma/dev` runtime still resolves stable filenames relative to
Expand Down
9 changes: 8 additions & 1 deletion Architecture/navigation-url-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Navigation state MUST be URL-driven and managed through `useNavigation` + Nuqs.

This architecture governs:

- active Studio view (`table`, `schema`, `console`, `sql`, `stream`)
- active Studio view (`table`, `schema`, `console`, `sql`, `stream`, `query-insights`)
- active schema/table/stream
- active stream follow mode
- active stream aggregation-panel visibility
Expand Down Expand Up @@ -47,6 +47,8 @@ Only keys declared in [`ui/hooks/nuqs.ts`](../ui/hooks/nuqs.ts) are allowed:
- `filter`
- `sort`
- `pin`
- `queryInsightsSort`
- `queryInsightsTable`
- `pageIndex`
- `pageSize`
- `search`
Expand All @@ -58,6 +60,8 @@ Notes:
- `searchScope` is legacy URL state and is not used for table-name navigation filtering.
- `pin` stores left-pinned data columns for the grid as a comma-separated list (for example `pin=id,bigint_col`).
- `pin` order is authoritative and MUST be updated when users drag-reorder pinned columns.
- `queryInsightsSort` stores Query Insights sort state as `<field>:<direction>`.
- `queryInsightsTable` stores the active Query Insights table-name filter.
- `pageIndex` remains URL-backed for table navigation.
- `pageSize` remains a supported hash key for compatibility, but table rendering now takes its authoritative rows-per-page preference from `studioUiCollection.tablePageSize` in [`Architecture/ui-state.md`](ui-state.md).
- `streamFollow` stores the active stream follow mode (`paused`, `live`, or `tail`).
Expand All @@ -82,11 +86,14 @@ Adding a new URL key requires updating `StateKey` in `nuqs.ts` first.
- `streamFollow`: no global default in `useNavigation`; the active stream view MUST resolve an absent value to `tail` and materialize that into the hash
- `aggregations`: no global default in `useNavigation`; the active stream view MUST treat an absent flag as closed and MUST NOT materialize that closed state into the hash
- `streamAggregationRange`: no standalone default; the active stream view MUST clear it whenever `aggregations` is absent, and MUST materialize its default range only after the aggregation panel is opened
- `queryInsightsSort`: no global default in `useNavigation`; the Query Insights view resolves an absent or invalid value to `reads:desc`
- `queryInsightsTable`: no global default in `useNavigation`; absent means all observed tables

When Studio is running without a database connection but with Streams enabled:

- the resolved default `view` MUST become `"stream"` instead of `"table"`
- stale database-oriented views such as `table`, `schema`, `console`, and `sql` MUST resolve back to the stream view instead of trying to render database-only UI against a disabled database session
- stale `query-insights` URLs MUST resolve back to the default view when the host did not provide a Query Insights transport

When URL params are stale from a previous DB, invalid `schema`/`table` values MUST be resolved to valid current defaults.
Shared table page size and infinite-scroll mode are not derived from URL defaults; they are restored through Studio UI state and then mirrored into query behavior by `usePagination`.
Expand Down
20 changes: 20 additions & 0 deletions Architecture/non-standard-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,26 @@ It deliberately excludes:
- The storage breakdowns also need collapsible ledger-style accounting boxes whose headers surface the section totals when folded shut, plus faint shared-cap annotations that sit beside right-aligned byte values and one shared cap marker spanning both Routing and Exact cache rows, which is not a stock ShadCN pattern.
- No stock ShadCN pattern covers that descriptor-driven observability layout, especially when the UI must distinguish logical bytes from physical storage signals, separate search coverage from historical run indexes, hide unconfigured routing rows, and keep the remaining cost caveats explicit instead of inventing unavailable totals.

### Query Insights Live Observability View

- Canonical components:
- [`ui/studio/views/query-insights/QueryInsightsView.tsx`](../ui/studio/views/query-insights/QueryInsightsView.tsx)
- [`ui/studio/views/query-insights/QueryInsightsChart.tsx`](../ui/studio/views/query-insights/QueryInsightsChart.tsx)
- Closest standard ShadCN alternatives:
- `Card`
- `Table`
- `Sheet`
- Why it stays non-standard:
- Query Insights needs a bounded live SSE session, chart ticks that update independently from table pause state, row grouping by Prisma operation, pause-buffer flushing highlights, and previous/next navigation inside a detail sheet.
- No stock ShadCN block covers that observability-specific control model, so Studio composes the surface from standard primitives while keeping the stream, chart, and row-state behavior local to this view.
- Required internals:
- `Badge`
- `Button`
- `Card`
- `Select`
- `Sheet`
- `Table`

## Standardization Candidates

These are the current high-signal places where Studio is bypassing a plausible standard ShadCN component or composition pattern.
Expand Down
93 changes: 93 additions & 0 deletions Architecture/query-insights.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Query Insights Architecture

This document is normative for Studio's Query Insights view (`view=query-insights`).

Query Insights is an optional host-provided observability surface. Studio core owns the UI, URL state, stream decoding, bounded in-browser session state, charts, table controls, and detail sheet. The host owns the backend stream, authorization, tenant or demo database lookup, and structured analysis endpoint.

## Studio Boundary

Studio receives Query Insights through `StudioProps.queryInsights`.

The transport provides:

- initial AI recommendation consent state
- an SSE `streamUrl` that reads a JSON `prisma-log` Prisma Streams stream
- a structured `analyze(input)` function
- an `enableAiRecommendations()` function
- optional UI event forwarding

When the transport is absent, Studio MUST hide the sidebar item and command-palette action. A stale `#view=query-insights` URL MUST resolve back to the normal default view.

## URL State

Query Insights uses Studio's normal hash navigation state:

- `view=query-insights`
- `queryInsightsSort=<latency|reads|executions|lastSeen>:<asc|desc>`
- `queryInsightsTable=<table name>`

All reads and writes MUST go through `useNavigation`.

## Runtime State

Query Insights rows are intentionally local React state rather than TanStack DB collections.

This is a documented exception to the database-state architecture because Query Insights data is not table data, has no persistence contract, resets per stream session, and is bounded to 500 unique query patterns plus a 500-row paused buffer. Chart points are also local and capped to 100 points.

The stream MUST only be opened while the Query Insights view is mounted. Unmounting the view closes the EventSource.

## Stream Contract

The canonical Query Insights data source is a Prisma Streams JSON stream named
`prisma-log`. Query execution records MUST be appended to that stream
continuously by the host while queries execute, regardless of whether the Query
Insights view is currently open.

The Studio UI reads that stream through the normal Streams HTTP surface. On
mount, it first performs a non-live snapshot read from the same stream URL so
already-recorded queries populate the view. It then opens the live SSE URL from
the returned `Stream-Next-Offset` cursor so new appends continue streaming
without replay gaps. The live connection receives standard Streams SSE events:

- `data`: a JSON array of `prisma-log` events
- `control`: stream cursor and up-to-date metadata, currently ignored by Query
Insights

Each query event in the `data` batch uses `type: "query"` plus SQL, latency,
count, reads, rows returned, tables, optional Prisma metadata, optional
`groupKey`, optional `queryId`, and optional min/max latency. Studio derives
one-second chart buckets from each query event timestamp and aggregates repeated
query patterns in browser state.

The client MAY still decode legacy `queries` and `chartTick` SSE event names for
compatibility, but new demo and host integrations MUST feed Query Insights from
`prisma-log`.

## UI Contract

The view uses standard ShadCN primitives for cards, buttons, badges, selects, table, and sheet.

The live observability composition is Studio-specific: two Chart.js-backed metric cards feed from chart ticks, a bounded ShadCN table renders sorted and filtered query rows, and a ShadCN sheet renders details and recommendations.

Selecting a row auto-pauses table updates. Closing a sheet that caused auto-pause resumes and flushes the paused buffer.

## Query Visibility

Studio's `Query` contract includes `meta.visibility`.

Postgres adapter-generated introspection, table reads, mutations, and fallback lint helper queries MUST be marked `studio-system`. Raw SQL editor executions remain user-visible. BFF hosts should append `-- prisma:studio` to system queries before forwarding them to the database so backend Query Insights implementations can classify them consistently with `-- prisma:console`. System classification MUST NOT prevent successful query executions from being appended to `prisma-log`.

## ppg-dev Demo

The local `ppg-dev` demo hosts the Query Insights backend itself:

- `/api/config` advertises the Query Insights transport URLs
- ppg-dev ensures the `prisma-log` stream exists on the configured Prisma
Streams server at startup
- `/api/query` appends the Studio system suffix to system queries, executes the database request, and appends each successful SQL execution to `prisma-log` with query visibility metadata
- the Query Insights UI reads from `/api/streams/v1/stream/prisma-log`, so it
uses the same same-origin Streams proxy as the Stream view
- `/api/query-insights/analyze` returns deterministic demo analysis
- `/api/query-insights/enable-ai` stores consent in demo memory

This keeps the demo same-origin and exercises the Studio transport without depending on production control-plane services.
1 change: 1 addition & 0 deletions Architecture/tanstack-db-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ Any change to guard semantics MUST update tests for:
Architecture-compliance tests MUST also cover:

- allowed `createCollection(...)` boundaries in production UI code
- stream-event row collections created through Studio context and reused by query scope
- `cleanupOnUnmount` paths avoiding shared TanStack DB reads/writes
- per-cell components avoiding direct global hook reads that fan out across wide grids
- grid-wide shared context-menu usage instead of per-cell context wrappers
Expand Down
7 changes: 7 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ The same demo entrypoint can also run against external development infrastructur
Studio can run without a database connection when a Streams server is configured, which makes it usable as a focused event-log and stream-search tool.
In that mode the shell hides schema selection, table navigation, and database-only views, defaults into the stream view, and keeps all Streams browsing, search, aggregation, and live/tail behavior working through the normal `/api/streams` proxy.

## Query Insights

Embedders can provide a Query Insights transport that adds a native Studio view at `view=query-insights`.
The view reads query events from a `prisma-log` Prisma Streams stream while active, replays the latest stream snapshot on mount, buckets event timestamps into latency and queries-per-second charts, groups Prisma operations when metadata is available, and keeps the query table sortable, filterable by table, and bounded in memory.
Selecting a row opens a Studio sheet with SQL, runtime stats, Prisma operation context, and structured recommendations after workspace-scoped AI consent.
The `ppg-dev` demo appends every successful SQL execution from `/api/query` into `prisma-log` continuously through the same Streams server used by the demo, with Studio system queries annotated for downstream classification.

## Local Streams Development Override

Studio's local development workflow can temporarily replace the published npm `@prisma/dev` package with the sibling source package from `../team-expansion/dev/server`, while also swapping its `@prisma/streams-local` dependency over to a built local Streams checkout.
Expand Down
7 changes: 6 additions & 1 deletion data/postgres-core/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ describe("postgres-core/adapter", () => {
"not ilike",
],
"query": {
"meta": {
"visibility": "studio-system",
},
"parameters": [
"",
"nextval(%",
Expand Down Expand Up @@ -485,7 +488,9 @@ describe("postgres-core/adapter", () => {
expect(error).toBeNull();
expect(result?.rowCount).toBe(2);
expect(result?.rows).toEqual([{ one: 1 }, { one: 2 }]);
expect(result?.query.sql).toBe("select 1 as one union all select 2 as one");
expect(result?.query.sql).toBe(
"select 1 as one union all select 2 as one",
);
});

it("returns adapter errors for invalid SQL", async () => {
Expand Down
46 changes: 34 additions & 12 deletions data/postgres-core/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
type Adapter,
type AdapterUpdateDetails,
type AdapterDeleteResult,
type AdapterError,
type AdapterInsertResult,
Expand All @@ -12,6 +11,7 @@ import {
type AdapterSqlLintDetails,
type AdapterSqlLintResult,
type AdapterSqlSchemaResult,
type AdapterUpdateDetails,
type AdapterUpdateManyResult,
type AdapterUpdateResult,
type Column,
Expand All @@ -23,7 +23,12 @@ import {
createFullTableSearchExecutionState,
executeQueryWithFullTableSearchGuardrails,
} from "../full-table-search";
import { asQuery, type Query, QueryResult } from "../query";
import {
asQuery,
type Query,
QueryResult,
withQueryVisibility,
} from "../query";
import { createSqlEditorSchemaFromIntrospection } from "../sql-editor-schema";
import type { Either } from "../type-utils";
import { POSTGRESQL_DATA_TYPES_TO_METADATA } from "./datatype";
Expand All @@ -46,6 +51,10 @@ import {

export type PostgresAdapterRequirements = AdapterRequirements;

function markStudioSystemQuery<T>(query: Query<T>): Query<T> {
return withQueryVisibility(query, "studio-system");
}

export function createPostgresAdapter(
requirements: PostgresAdapterRequirements,
): Adapter {
Expand All @@ -61,7 +70,7 @@ export function createPostgresAdapter(
options: Parameters<NonNullable<Adapter["update"]>>[1],
): Promise<Either<AdapterError, AdapterUpdateManyResult>> {
const queries = updates.map((update) =>
getUpdateQuery(update, otherRequirements),
markStudioSystemQuery(getUpdateQuery(update, otherRequirements)),
);

try {
Expand Down Expand Up @@ -126,17 +135,19 @@ export function createPostgresAdapter(
try {
const tablesQuery = getTablesQuery(otherRequirements);
const timezoneQuery = getTimezoneQuery();
const systemTablesQuery = markStudioSystemQuery(tablesQuery);
const systemTimezoneQuery = markStudioSystemQuery(timezoneQuery);

const [[tablesError, tables], [timezoneError, timezones]] =
await Promise.all([
executor.execute(tablesQuery, options),
executor.execute(timezoneQuery, options),
executor.execute(systemTablesQuery, options),
executor.execute(systemTimezoneQuery, options),
]);

if (tablesError) {
return createPostgresAdapterError({
error: tablesError,
query: tablesQuery,
query: systemTablesQuery,
});
}

Expand All @@ -146,7 +157,7 @@ export function createPostgresAdapter(

return [
null,
createIntrospection({ query: tablesQuery, tables, timezone }),
createIntrospection({ query: systemTablesQuery, tables, timezone }),
];
} catch (error: unknown) {
return createPostgresAdapterError({ error: error as Error });
Expand All @@ -173,7 +184,9 @@ export function createPostgresAdapter(
options,
): Promise<Either<AdapterError, AdapterQueryResult>> {
try {
const query = getSelectQuery(details, otherRequirements);
const query = markStudioSystemQuery(
getSelectQuery(details, otherRequirements),
);
const [error, results] =
await executeQueryWithFullTableSearchGuardrails({
executor,
Expand Down Expand Up @@ -268,7 +281,9 @@ export function createPostgresAdapter(
options,
): Promise<Either<AdapterError, AdapterInsertResult>> {
try {
const query = getInsertQuery(details, otherRequirements);
const query = markStudioSystemQuery(
getInsertQuery(details, otherRequirements),
);

const [error, rows] = await executor.execute(query, options);

Expand All @@ -287,7 +302,9 @@ export function createPostgresAdapter(
options,
): Promise<Either<AdapterError, AdapterUpdateResult>> {
try {
const query = getUpdateQuery(details, otherRequirements);
const query = markStudioSystemQuery(
getUpdateQuery(details, otherRequirements),
);

const [error, results] = await executor.execute(query, options);

Expand Down Expand Up @@ -319,7 +336,9 @@ export function createPostgresAdapter(
options,
): Promise<Either<AdapterError, AdapterDeleteResult>> {
try {
const query = getDeleteQuery(details, otherRequirements);
const query = markStudioSystemQuery(
getDeleteQuery(details, otherRequirements),
);

const [error] = await executor.execute(query, options);

Expand Down Expand Up @@ -393,7 +412,10 @@ async function lintWithExplainFallback(
const explainQuery = asQuery<Record<string, unknown>>(
`EXPLAIN ${statement.statement}`,
);
const [error] = await executor.execute(explainQuery, options);
const [error] = await executor.execute(
markStudioSystemQuery(explainQuery),
options,
);

if (!error) {
continue;
Expand Down
16 changes: 16 additions & 0 deletions data/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ class ImmediateValueTransformer extends OperationNodeTransformer {
declare const queryType: unique symbol;
export interface Query<T = Record<string, unknown>> {
[queryType]?: T;
meta?: {
visibility?: "studio-system" | "user";
};
parameters: readonly unknown[];
sql: string;
transformations?: Partial<Record<keyof T, "json-parse">>;
Expand All @@ -111,6 +114,19 @@ export function asQuery<T>(query: string | Query<unknown>): Query<T> {
return query as never;
}

export function withQueryVisibility<T>(
query: Query<T>,
visibility: NonNullable<Query["meta"]>["visibility"],
): Query<T> {
return {
...query,
meta: {
...query.meta,
visibility,
},
};
}

export type QueryResult<T> =
T extends Query<infer R>
? R[]
Expand Down
Loading