diff --git a/examples/prisma-next-demo/src/prisma/db.ts b/examples/prisma-next-demo/src/prisma/db.ts index 72a067e0ef..30d63ebce0 100644 --- a/examples/prisma-next-demo/src/prisma/db.ts +++ b/examples/prisma-next-demo/src/prisma/db.ts @@ -4,6 +4,7 @@ import postgres from '@prisma-next/postgres/runtime'; import { budgets, lints } from '@prisma-next/sql-runtime'; import type { Contract } from './contract.d'; import contractJson from './contract.json' with { type: 'json' }; +import { slowQueryWarning } from './slow-query-warning'; export const db = postgres({ contractJson, @@ -21,5 +22,9 @@ export const db = postgres({ tableRows: { user: 10_000, post: 10_000 }, maxLatencyMs: 1_000, }), + // Custom middleware (see `slow-query-warning.ts`): observes every + // execution's latency via `afterExecute` and logs a warning past the + // threshold. Cache hits flow through it too, with `source: 'middleware'`. + slowQueryWarning({ thresholdMs: 250 }), ], }); diff --git a/examples/prisma-next-demo/src/prisma/slow-query-warning.ts b/examples/prisma-next-demo/src/prisma/slow-query-warning.ts new file mode 100644 index 0000000000..d50dbdeb0a --- /dev/null +++ b/examples/prisma-next-demo/src/prisma/slow-query-warning.ts @@ -0,0 +1,39 @@ +/** + * Custom middleware example: warn when a query runs longer than a threshold. + * + * `afterExecute` fires once per `execute()` call after the rows have been + * consumed — whether they came from the driver or from an `intercept` hit + * (`result.source` says which) — with the observed `latencyMs` and + * `rowCount` for the run. Registered on the runtime in `src/prisma/db.ts` + * via the `middleware: [...]` option. + */ +import type { SqlMiddleware } from '@prisma-next/sql-runtime'; + +export interface SlowQueryWarningOptions { + /** Latency above this many milliseconds logs a warning. Default: 250. */ + readonly thresholdMs?: number; +} + +export function slowQueryWarning(options?: SlowQueryWarningOptions): SqlMiddleware { + const thresholdMs = options?.thresholdMs ?? 250; + + return { + name: 'slow-query-warning', + familyId: 'sql', + + async afterExecute(plan, result, ctx) { + if (result.latencyMs <= thresholdMs) return; + ctx.log.warn({ + code: 'APP.SLOW_QUERY', + message: `Query took ${result.latencyMs}ms (threshold: ${thresholdMs}ms)`, + details: { + sql: plan.sql, + rowCount: result.rowCount, + latencyMs: result.latencyMs, + source: result.source, + planExecutionId: ctx.planExecutionId, + }, + }); + }, + }; +} diff --git a/examples/prisma-next-demo/test/slow-query-warning.test.ts b/examples/prisma-next-demo/test/slow-query-warning.test.ts new file mode 100644 index 0000000000..fab4635f8a --- /dev/null +++ b/examples/prisma-next-demo/test/slow-query-warning.test.ts @@ -0,0 +1,83 @@ +/** + * Offline unit tests for the custom `slowQueryWarning` middleware example. + * + * Drives the middleware's `afterExecute` hook directly with a hand-built + * execution plan and context — no database required. The plan's `ast` and + * `meta` come from a real DSL build so the shapes match what the runtime + * hands to middleware. + */ +import type { AfterExecuteResult, SqlMiddlewareContext } from '@prisma-next/sql-runtime'; +import { describe, expect, it } from 'vitest'; +import { db } from '../src/prisma/db'; +import { slowQueryWarning } from '../src/prisma/slow-query-warning'; + +function makeContext(warnEvents: unknown[]): SqlMiddlewareContext { + return { + contract: db.contract, + mode: 'strict', + now: () => Date.now(), + log: { + info: () => {}, + warn: (event) => { + warnEvents.push(event); + }, + error: () => {}, + }, + contentHash: async () => 'test-content-hash', + scope: 'runtime', + planExecutionId: 'test-plan-execution', + }; +} + +function makeExecutionPlan() { + const built = db.sql.public.user.select('id', 'email').limit(1).build(); + return { + sql: 'SELECT "id", "email" FROM "user" LIMIT 1', + params: [], + ast: built.ast, + meta: built.meta, + }; +} + +function makeResult(latencyMs: number): AfterExecuteResult { + return { rowCount: 1, latencyMs, completed: true, source: 'driver' }; +} + +describe('slowQueryWarning middleware', () => { + it('logs a warning when latency exceeds the threshold', async () => { + const warnEvents: unknown[] = []; + const middleware = slowQueryWarning({ thresholdMs: 250 }); + + await middleware.afterExecute?.(makeExecutionPlan(), makeResult(900), makeContext(warnEvents)); + + expect(warnEvents).toHaveLength(1); + expect(warnEvents[0]).toMatchObject({ + code: 'APP.SLOW_QUERY', + details: { + sql: 'SELECT "id", "email" FROM "user" LIMIT 1', + latencyMs: 900, + rowCount: 1, + source: 'driver', + planExecutionId: 'test-plan-execution', + }, + }); + }); + + it('stays silent at or below the threshold', async () => { + const warnEvents: unknown[] = []; + const middleware = slowQueryWarning({ thresholdMs: 250 }); + + await middleware.afterExecute?.(makeExecutionPlan(), makeResult(250), makeContext(warnEvents)); + + expect(warnEvents).toHaveLength(0); + }); + + it('defaults the threshold to 250ms', async () => { + const warnEvents: unknown[] = []; + const middleware = slowQueryWarning(); + + await middleware.afterExecute?.(makeExecutionPlan(), makeResult(251), makeContext(warnEvents)); + + expect(warnEvents).toHaveLength(1); + }); +}); diff --git a/packages/2-sql/5-runtime/src/middleware/budgets.ts b/packages/2-sql/5-runtime/src/middleware/budgets.ts index 44f629f3c2..c768fcb54a 100644 --- a/packages/2-sql/5-runtime/src/middleware/budgets.ts +++ b/packages/2-sql/5-runtime/src/middleware/budgets.ts @@ -87,6 +87,7 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware { const tableRows = options?.tableRows ?? {}; const maxLatencyMs = options?.maxLatencyMs ?? 1_000; const rowSeverity = options?.severities?.rowCount ?? 'error'; + const latencySeverity = options?.severities?.latency ?? 'warn'; const observedRowsByPlan = new WeakMap(); @@ -122,7 +123,7 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware { ) { const latencyMs = result.latencyMs; if (latencyMs > maxLatencyMs) { - const shouldBlock = ctx.mode === 'strict'; + const shouldBlock = latencySeverity === 'error' || ctx.mode === 'strict'; emitBudgetViolation( runtimeError('BUDGET.TIME_EXCEEDED', 'Query latency exceeds budget', { latencyMs, diff --git a/packages/2-sql/5-runtime/src/middleware/lints.ts b/packages/2-sql/5-runtime/src/middleware/lints.ts index d05e5606bb..2c12cc5c0a 100644 --- a/packages/2-sql/5-runtime/src/middleware/lints.ts +++ b/packages/2-sql/5-runtime/src/middleware/lints.ts @@ -16,7 +16,6 @@ export interface LintsOptions { readonly deleteWithoutWhere?: 'warn' | 'error'; readonly updateWithoutWhere?: 'warn' | 'error'; readonly readOnlyMutation?: 'warn' | 'error'; - readonly unindexedPredicate?: 'warn' | 'error'; }; readonly fallbackWhenAstMissing?: 'raw' | 'skip'; } @@ -125,8 +124,6 @@ function getConfiguredSeverity(code: string, options?: LintsOptions): 'warn' | ' return severities.updateWithoutWhere; case 'LINT.READ_ONLY_MUTATION': return severities.readOnlyMutation; - case 'LINT.UNINDEXED_PREDICATE': - return severities.unindexedPredicate; default: return undefined; } diff --git a/packages/2-sql/5-runtime/test/budgets.test.ts b/packages/2-sql/5-runtime/test/budgets.test.ts index df404901c3..1a702d1ef1 100644 --- a/packages/2-sql/5-runtime/test/budgets.test.ts +++ b/packages/2-sql/5-runtime/test/budgets.test.ts @@ -168,6 +168,48 @@ describe('budgets middleware', () => { timeouts.default, ); + it( + 'throws when latency exceeds budget with error severity in permissive mode', + async () => { + const mw = budgets({ maxLatencyMs: 100, severities: { latency: 'error' } }); + const plan = createPlan({ sql: 'SELECT 1 LIMIT 1' }); + const ctx = createMiddlewareContext({ mode: 'permissive' }); + const result: AfterExecuteResult = { + rowCount: 1, + latencyMs: 200, + completed: true, + source: 'driver', + }; + + await expect(mw.afterExecute?.(plan, result, ctx)).rejects.toMatchObject({ + code: 'BUDGET.TIME_EXCEEDED', + category: 'BUDGET', + }); + }, + timeouts.default, + ); + + it( + 'warns by default when latency exceeds budget in permissive mode', + async () => { + const mw = budgets({ maxLatencyMs: 100 }); + const plan = createPlan({ sql: 'SELECT 1 LIMIT 1' }); + const ctx = createMiddlewareContext({ mode: 'permissive' }); + const result: AfterExecuteResult = { + rowCount: 1, + latencyMs: 200, + completed: true, + source: 'driver', + }; + + await mw.afterExecute?.(plan, result, ctx); + expect(ctx.log.warn).toHaveBeenCalledWith( + expect.objectContaining({ code: 'BUDGET.TIME_EXCEEDED' }), + ); + }, + timeouts.default, + ); + it( 'does not warn when latency is within budget', async () => { diff --git a/skills/prisma-next-runtime/SKILL.md b/skills/prisma-next-runtime/SKILL.md index bede7038f1..c85268fad2 100644 --- a/skills/prisma-next-runtime/SKILL.md +++ b/skills/prisma-next-runtime/SKILL.md @@ -183,7 +183,6 @@ export const db = postgres({ deleteWithoutWhere: 'error', updateWithoutWhere: 'error', readOnlyMutation: 'error', - unindexedPredicate: 'warn', }, }), budgets({ @@ -197,7 +196,7 @@ export const db = postgres({ }); ``` -For the full option surface, read the source: `packages/2-sql/5-runtime/src/middleware/lints.ts` and `.../budgets.ts`. The `severities` keys (`selectStar`, `noLimit`, `deleteWithoutWhere`, `updateWithoutWhere`, `readOnlyMutation`, `unindexedPredicate` for lints; `rowCount`, `latency` for budgets) are the source of truth; do not extrapolate to a key that ripgrep can't find. +For the full option surface, read the source: `packages/2-sql/5-runtime/src/middleware/lints.ts` and `.../budgets.ts`. The `severities` keys (`selectStar`, `noLimit`, `deleteWithoutWhere`, `updateWithoutWhere`, `readOnlyMutation` for lints; `rowCount`, `latency` for budgets) are the source of truth; do not extrapolate to a key that ripgrep can't find. ## Workflow — Compose multiple middleware @@ -317,7 +316,7 @@ The runtime side (this skill) is the same regardless: `db.ts` reads `contract.js 3. **Forgetting `with { type: 'json' }` on the contract import.** Required by Node's ESM JSON-import-attribute spec. 4. **Middleware order matters.** Outermost wraps. Put telemetry first if you want it to capture inner-middleware errors. 5. **Importing middleware from a non-existent façade subpath.** `@prisma-next/postgres/middleware` does *not* exist. Telemetry comes from `@prisma-next/middleware-telemetry`; lints / budgets come from `@prisma-next/sql-runtime` today (see *What Prisma Next doesn't do yet*). -6. **Confabulating lint / budget option names.** Lints take `severities` (with the six keys above), not `requireWhere` / `maxRowsWithoutLimit`. Budgets use `maxLatencyMs` (not `maxDurationMs`) plus `maxRows` / `defaultTableRows` / `tableRows`. When in doubt, read the source. +6. **Confabulating lint / budget option names.** Lints take `severities` (with the five keys above), not `requireWhere` / `maxRowsWithoutLimit`. Budgets use `maxLatencyMs` (not `maxDurationMs`) plus `maxRows` / `defaultTableRows` / `tableRows`. When in doubt, read the source. 7. **Switching targets without re-emitting.** The contract artefacts are target-shaped; emit after the target change. 8. **Script hangs after queries finish on Postgres.** The `pg.Pool` keeps Node's event loop alive. Solution: `await db.close()` before the script returns, or `await using db = postgres(...)` at the top of a script module. Do not put `await using db = postgres(...)` inside a request handler — it's block-scoped and would close the pool after every request. The right server pattern is a module-level singleton in `db.ts` that lives for the process lifetime.