Skip to content

Commit 2cd422d

Browse files
authored
Bump studio-core dependency to 0.21.1 (prisma#29322)
Summary - update `@prisma/studio-core` in `packages/cli` and the lockfile to the new `0.21.1` release so Studio pulls the latest bundle - Adapted BFF to support SQL linting - refresh lockfile metadata so downstream installs resolve the updated package version Studio Features - More intuitive filtering - Filter by SQL - fulltext search across all columns - SQL Query tab ## BFF changes Implemented the CLI-side BFF extension needed for newer Studio SQL linting. What changed: - Added support for the `sql-lint` BFF procedure in `packages/cli/src/Studio.ts` - Forwarded `sql-lint` requests to `executor.lintSql(...)` when the executor supports it - Kept an explicit fallback for executors that do not implement SQL lint - Improved BFF error normalization so already-serialized upstream errors are passed through unchanged, including RPC envelopes like `{"@@error": ...}` Why this is needed: - `@prisma/studio-core` now sends SQL lint requests through the BFF as `procedure: "sql-lint"` - Without handling that procedure in the CLI, Studio falls through to `Unknown procedure` - Some upstream failures arrive as serialized RPC error envelopes rather than native `Error` instances, so the CLI needs to unwrap those instead of turning them into generic fallback errors Result: - SQL query linting works through the CLI BFF - When linting fails, Studio now gets the real database error payload instead of a generic `Unknown procedure` or fallback error Validation: - Added focused Vitest coverage for: - routing `sql-lint` requests through the BFF - unwrapping `@@error`-serialized failures - `pnpm --filter prisma test src/__tests__/Studio.vitest.ts` passes <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added server-side SQL linting for the Studio backend endpoint. * **Bug Fixes** * Standardized BFF-style error formatting and unwrapping for more consistent backend error responses. * **Tests** * New tests for the SQL lint flow, error-unwrapping behavior, and related server-side handling. * **Chores** * Updated dependency versions for improved compatibility. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 3e2392d commit 2cd422d

4 files changed

Lines changed: 288 additions & 16 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@
183183
"@prisma/config": "workspace:*",
184184
"@prisma/dev": "0.20.0",
185185
"@prisma/engines": "workspace:*",
186-
"@prisma/studio-core": "0.16.3",
186+
"@prisma/studio-core": "0.21.1",
187187
"mysql2": "3.15.3",
188188
"postgres": "3.4.7"
189189
},

packages/cli/src/Studio.ts

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { serve } from '@hono/node-server'
44
import type { PrismaConfigInternal } from '@prisma/config'
55
import { arg, type Command, format, HelpError, isError } from '@prisma/internals'
66
import type { Executor, SequenceExecutor } from '@prisma/studio-core/data'
7-
import { serializeError, type StudioBFFRequest } from '@prisma/studio-core/data/bff'
7+
import { type SerializedError, serializeError, type StudioBFFRequest } from '@prisma/studio-core/data/bff'
88
import { createMySQL2Executor } from '@prisma/studio-core/data/mysql2'
99
import { createNodeSQLiteExecutor } from '@prisma/studio-core/data/node-sqlite'
1010
import { createPostgresJSExecutor } from '@prisma/studio-core/data/postgresjs'
@@ -347,27 +347,27 @@ ${bold('Examples')}
347347
const [error, results] = await executor.execute(request.query)
348348

349349
if (error) {
350-
return ctx.json([serializeError(error)])
350+
return ctx.json([serializeBffError(error)])
351351
}
352352

353353
return ctx.json([null, results])
354354
}
355355

356356
if (procedure === 'sequence') {
357357
if (!('executeSequence' in executor)) {
358-
return ctx.json([[serializeError(new Error('Executor does not support sequences'))]])
358+
return ctx.json([[serializeBffError(new Error('Executor does not support sequences'))]])
359359
}
360360

361361
const [[error0, result0], maybeResult1] = await (executor as SequenceExecutor).executeSequence(request.sequence)
362362

363363
if (error0) {
364-
return ctx.json([[serializeError(error0)]])
364+
return ctx.json([[serializeBffError(error0)]])
365365
}
366366

367367
const [error1, result1] = maybeResult1 || []
368368

369369
if (error1) {
370-
return ctx.json([[null, result0], [serializeError(error1)]])
370+
return ctx.json([[null, result0], [serializeBffError(error1)]])
371371
}
372372

373373
return ctx.json([
@@ -376,6 +376,23 @@ ${bold('Examples')}
376376
])
377377
}
378378

379+
if (procedure === 'sql-lint') {
380+
if (!executor.lintSql) {
381+
return ctx.json([serializeBffError(new Error('Executor does not support SQL lint'))])
382+
}
383+
384+
const [error, result] = await executor.lintSql({
385+
schemaVersion: request.schemaVersion,
386+
sql: request.sql,
387+
})
388+
389+
if (error) {
390+
return ctx.json([serializeBffError(error)])
391+
}
392+
393+
return ctx.json([null, result])
394+
}
395+
379396
procedure satisfies undefined
380397

381398
return ctx.text('Unknown procedure', { status: 500 })
@@ -441,6 +458,54 @@ function getUrlBasePath(url: string | undefined, configPath: string | null): str
441458
return url ? process.cwd() : configPath ? dirname(configPath) : process.cwd()
442459
}
443460

461+
function serializeBffError(error: unknown): SerializedError {
462+
return getSerializedBffError(error) ?? serializeError(error)
463+
}
464+
465+
function getSerializedBffError(error: unknown): SerializedError | null {
466+
if (isSerializedError(error)) {
467+
return error
468+
}
469+
470+
if (!isRecord(error)) {
471+
return null
472+
}
473+
474+
const nestedError = error.error
475+
476+
if (isSerializedError(nestedError)) {
477+
return nestedError
478+
}
479+
480+
const rpcSerializedError = error['@@error']
481+
482+
if (isSerializedError(rpcSerializedError)) {
483+
return rpcSerializedError
484+
}
485+
486+
return null
487+
}
488+
489+
function isSerializedError(error: unknown): error is SerializedError {
490+
if (!isRecord(error)) {
491+
return false
492+
}
493+
494+
if (typeof error.name !== 'string' || typeof error.message !== 'string') {
495+
return false
496+
}
497+
498+
if (error.errors === undefined) {
499+
return true
500+
}
501+
502+
return Array.isArray(error.errors) && error.errors.every(isSerializedError)
503+
}
504+
505+
function isRecord(value: unknown): value is Record<string, unknown> {
506+
return typeof value === 'object' && value !== null
507+
}
508+
444509
function isAccelerateProtocol(protocol: string): boolean {
445510
return protocol === 'prisma' || protocol === 'prisma+postgres'
446511
}

packages/cli/src/__tests__/Studio.vitest.ts

Lines changed: 212 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@ import { defaultTestConfig } from '@prisma/config'
22
import { beforeEach, describe, expect, test, vi } from 'vitest'
33

44
const createPoolMock = vi.fn(() => ({ end: vi.fn() }))
5+
const serveMock = vi.fn(() => ({ close: vi.fn() }))
6+
const createPostgresJSExecutorMock = vi.fn(() => ({
7+
execute: vi.fn(),
8+
}))
9+
const serializeErrorMock = vi.fn((error: unknown) => {
10+
if (error instanceof Error) {
11+
return {
12+
message: error.message,
13+
name: error.name,
14+
}
15+
}
16+
17+
return {
18+
message: JSON.stringify(error),
19+
name: 'UnknownError',
20+
}
21+
})
522

623
vi.mock('mysql2/promise', () => {
724
return {
@@ -11,7 +28,7 @@ vi.mock('mysql2/promise', () => {
1128

1229
vi.mock('@hono/node-server', () => {
1330
return {
14-
serve: vi.fn(() => ({ close: vi.fn() })),
31+
serve: serveMock,
1532
}
1633
})
1734

@@ -25,7 +42,7 @@ vi.mock('@prisma/studio-core/data/mysql2', () => {
2542

2643
vi.mock('@prisma/studio-core/data/bff', () => {
2744
return {
28-
serializeError: vi.fn(() => ({ message: 'mock-error' })),
45+
serializeError: serializeErrorMock,
2946
}
3047
})
3148

@@ -39,16 +56,17 @@ vi.mock('@prisma/studio-core/data/node-sqlite', () => {
3956

4057
vi.mock('@prisma/studio-core/data/postgresjs', () => {
4158
return {
42-
createPostgresJSExecutor: vi.fn(() => ({
43-
execute: vi.fn(),
44-
})),
59+
createPostgresJSExecutor: createPostgresJSExecutorMock,
4560
}
4661
})
4762

4863
describe('Studio MySQL URL compatibility', () => {
4964
beforeEach(() => {
5065
vi.resetModules()
5166
createPoolMock.mockClear()
67+
createPostgresJSExecutorMock.mockClear()
68+
serveMock.mockClear()
69+
serializeErrorMock.mockClear()
5270
})
5371

5472
test('converts sslaccept=strict to mysql2 ssl JSON', async () => {
@@ -120,3 +138,192 @@ describe('Studio MySQL URL compatibility', () => {
120138
expect(passedUrl.searchParams.get('ssl')).toBe('{"rejectUnauthorized":false}')
121139
})
122140
})
141+
142+
describe('Studio BFF', () => {
143+
beforeEach(() => {
144+
vi.resetModules()
145+
createPoolMock.mockClear()
146+
createPostgresJSExecutorMock.mockClear()
147+
serveMock.mockClear()
148+
serializeErrorMock.mockClear()
149+
})
150+
151+
test('routes sql-lint requests to executor.lintSql', async () => {
152+
const lintSqlMock = vi.fn(() =>
153+
Promise.resolve([
154+
null,
155+
{
156+
diagnostics: [{ from: 0, message: 'lint-ok', severity: 'info', to: 1 }],
157+
schemaVersion: 'v1',
158+
},
159+
]),
160+
)
161+
162+
await startStudioBff({
163+
execute: vi.fn(),
164+
lintSql: lintSqlMock,
165+
})
166+
167+
const response = await getBffResponse({
168+
procedure: 'sql-lint',
169+
schemaVersion: 'v1',
170+
sql: 'select 1',
171+
})
172+
173+
expect(lintSqlMock).toHaveBeenCalledWith({
174+
schemaVersion: 'v1',
175+
sql: 'select 1',
176+
})
177+
expect(await response.json()).toEqual([
178+
null,
179+
{
180+
diagnostics: [{ from: 0, message: 'lint-ok', severity: 'info', to: 1 }],
181+
schemaVersion: 'v1',
182+
},
183+
])
184+
})
185+
186+
test('unwraps RPC-serialized sql-lint errors', async () => {
187+
await startStudioBff({
188+
execute: vi.fn(),
189+
lintSql: vi.fn(() =>
190+
Promise.resolve([
191+
{
192+
'@@error': {
193+
message: 'relation "missing_table" does not exist',
194+
name: 'PostgresError',
195+
},
196+
},
197+
]),
198+
),
199+
})
200+
201+
const response = await getBffResponse({
202+
procedure: 'sql-lint',
203+
schemaVersion: 'v1',
204+
sql: 'select * from missing_table',
205+
})
206+
207+
expect(serializeErrorMock).not.toHaveBeenCalled()
208+
expect(await response.json()).toEqual([
209+
{
210+
message: 'relation "missing_table" does not exist',
211+
name: 'PostgresError',
212+
},
213+
])
214+
})
215+
216+
test('passes through top-level serialized sql-lint errors', async () => {
217+
await startStudioBff({
218+
execute: vi.fn(),
219+
lintSql: vi.fn(() =>
220+
Promise.resolve([
221+
{
222+
message: 'syntax error at or near "from"',
223+
name: 'PostgresError',
224+
},
225+
]),
226+
),
227+
})
228+
229+
const response = await getBffResponse({
230+
procedure: 'sql-lint',
231+
schemaVersion: 'v1',
232+
sql: 'select from',
233+
})
234+
235+
expect(serializeErrorMock).not.toHaveBeenCalled()
236+
expect(await response.json()).toEqual([
237+
{
238+
message: 'syntax error at or near "from"',
239+
name: 'PostgresError',
240+
},
241+
])
242+
})
243+
244+
test('unwraps nested serialized sql-lint errors', async () => {
245+
await startStudioBff({
246+
execute: vi.fn(),
247+
lintSql: vi.fn(() =>
248+
Promise.resolve([
249+
{
250+
error: {
251+
message: 'relation "users" does not exist',
252+
name: 'PostgresError',
253+
},
254+
},
255+
]),
256+
),
257+
})
258+
259+
const response = await getBffResponse({
260+
procedure: 'sql-lint',
261+
schemaVersion: 'v1',
262+
sql: 'select * from users',
263+
})
264+
265+
expect(serializeErrorMock).not.toHaveBeenCalled()
266+
expect(await response.json()).toEqual([
267+
{
268+
message: 'relation "users" does not exist',
269+
name: 'PostgresError',
270+
},
271+
])
272+
})
273+
274+
test('falls back to serializeError for unknown sql-lint error shapes', async () => {
275+
await startStudioBff({
276+
execute: vi.fn(),
277+
lintSql: vi.fn(() =>
278+
Promise.resolve([
279+
{
280+
message: 'missing name field',
281+
} as never,
282+
]),
283+
),
284+
})
285+
286+
const response = await getBffResponse({
287+
procedure: 'sql-lint',
288+
schemaVersion: 'v1',
289+
sql: 'select 1',
290+
})
291+
292+
expect(serializeErrorMock).toHaveBeenCalledTimes(1)
293+
expect(await response.json()).toEqual([
294+
{
295+
message: '{"message":"missing name field"}',
296+
name: 'UnknownError',
297+
},
298+
])
299+
})
300+
})
301+
302+
async function getBffResponse(body: unknown): Promise<Response> {
303+
const fetchHandler = serveMock.mock.calls.at(-1)?.[0]?.fetch as ((request: Request) => Promise<Response>) | undefined
304+
305+
if (!fetchHandler) {
306+
throw new Error('Studio server fetch handler was not registered')
307+
}
308+
309+
return fetchHandler(
310+
new Request('http://localhost:5555/bff', {
311+
body: JSON.stringify(body),
312+
headers: {
313+
'content-type': 'application/json',
314+
},
315+
method: 'POST',
316+
}),
317+
)
318+
}
319+
320+
async function startStudioBff(executor: { execute: ReturnType<typeof vi.fn>; lintSql?: ReturnType<typeof vi.fn> }) {
321+
createPostgresJSExecutorMock.mockReturnValueOnce(executor)
322+
323+
const { Studio } = await import('../Studio')
324+
325+
await Studio.new().parse(
326+
['--browser', 'none', '--port', '5555', '--url', 'postgresql://user:password@localhost:5432/db'],
327+
defaultTestConfig(),
328+
)
329+
}

0 commit comments

Comments
 (0)