Skip to content
Open
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 examples/prisma-next-demo/src/prisma/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Contract>({
contractJson,
Expand All @@ -21,5 +22,9 @@ export const db = postgres<Contract>({
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 }),
],
});
39 changes: 39 additions & 0 deletions examples/prisma-next-demo/src/prisma/slow-query-warning.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
},
};
}
83 changes: 83 additions & 0 deletions examples/prisma-next-demo/test/slow-query-warning.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion packages/2-sql/5-runtime/src/middleware/budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SqlExecutionPlan, { count: number }>();

Expand Down Expand Up @@ -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,
Expand Down
3 changes: 0 additions & 3 deletions packages/2-sql/5-runtime/src/middleware/lints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down Expand Up @@ -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;
}
Expand Down
42 changes: 42 additions & 0 deletions packages/2-sql/5-runtime/test/budgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
5 changes: 2 additions & 3 deletions skills/prisma-next-runtime/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,6 @@ export const db = postgres<Contract>({
deleteWithoutWhere: 'error',
updateWithoutWhere: 'error',
readOnlyMutation: 'error',
unindexedPredicate: 'warn',
},
}),
budgets({
Expand All @@ -197,7 +196,7 @@ export const db = postgres<Contract>({
});
```

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

Expand Down Expand Up @@ -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<Contract>(...)` 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.

Expand Down
Loading