mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-11 02:33:10 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7145ca9dfe | ||
|
|
a8fe098c88 | ||
|
|
faa51fba11 |
@@ -199,8 +199,7 @@
|
|||||||
"skills/database-migrations",
|
"skills/database-migrations",
|
||||||
"skills/jpa-patterns",
|
"skills/jpa-patterns",
|
||||||
"skills/mysql-patterns",
|
"skills/mysql-patterns",
|
||||||
"skills/postgres-patterns",
|
"skills/postgres-patterns"
|
||||||
"skills/prisma-patterns"
|
|
||||||
],
|
],
|
||||||
"targets": [
|
"targets": [
|
||||||
"claude",
|
"claude",
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
* the actual code. This hook steers the agent back to fixing the source.
|
* the actual code. This hook steers the agent back to fixing the source.
|
||||||
*
|
*
|
||||||
* Exit codes:
|
* Exit codes:
|
||||||
* 0 = allow (not a config file)
|
* 0 = allow (not a config file, or first-time creation of one)
|
||||||
* 2 = block (config file modification attempted)
|
* 2 = block (existing config file modification attempted)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const MAX_STDIN = 1024 * 1024;
|
const MAX_STDIN = 1024 * 1024;
|
||||||
@@ -58,7 +59,7 @@ const PROTECTED_FILES = new Set([
|
|||||||
'.stylelintrc.yml',
|
'.stylelintrc.yml',
|
||||||
'.markdownlint.json',
|
'.markdownlint.json',
|
||||||
'.markdownlint.yaml',
|
'.markdownlint.yaml',
|
||||||
'.markdownlintrc',
|
'.markdownlintrc'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function parseInput(inputOrRaw) {
|
function parseInput(inputOrRaw) {
|
||||||
@@ -94,13 +95,41 @@ function run(inputOrRaw, options = {}) {
|
|||||||
|
|
||||||
const basename = path.basename(filePath);
|
const basename = path.basename(filePath);
|
||||||
if (PROTECTED_FILES.has(basename)) {
|
if (PROTECTED_FILES.has(basename)) {
|
||||||
|
// Allow first-time creation — there's no existing config to weaken.
|
||||||
|
// The hook's purpose is blocking modifications; writing a brand-new
|
||||||
|
// config file in a project that has none is a legitimate bootstrap
|
||||||
|
// path (e.g. scaffolding ESLint into a fresh repo).
|
||||||
|
//
|
||||||
|
// Fail closed on any stat error other than ENOENT. Use lstatSync so a
|
||||||
|
// symlink at the protected path is treated as present even if its target
|
||||||
|
// is missing — a dangling symlink at e.g. .eslintrc.js still represents
|
||||||
|
// an existing config entry that an agent should not silently replace.
|
||||||
|
// fs.existsSync would swallow EACCES/EPERM as false; lstatSync exposes
|
||||||
|
// the error code so we can treat only genuine "path not found" (ENOENT)
|
||||||
|
// as absent.
|
||||||
|
let exists = true;
|
||||||
|
try {
|
||||||
|
fs.lstatSync(filePath);
|
||||||
|
// lstat succeeded — something (file, dir, or symlink) exists here.
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code === 'ENOENT') {
|
||||||
|
exists = false;
|
||||||
|
}
|
||||||
|
// Any other error (EACCES, EPERM, ELOOP, etc.) leaves exists=true
|
||||||
|
// so the guard is never silently weakened.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
return { exitCode: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exitCode: 2,
|
exitCode: 2,
|
||||||
stderr:
|
stderr:
|
||||||
`BLOCKED: Modifying ${basename} is not allowed. ` +
|
`BLOCKED: Modifying ${basename} is not allowed. ` +
|
||||||
'Fix the source code to satisfy linter/formatter rules instead of ' +
|
'Fix the source code to satisfy linter/formatter rules instead of ' +
|
||||||
'weakening the config. If this is a legitimate config change, ' +
|
'weakening the config. If this is a legitimate config change, ' +
|
||||||
'disable the config-protection hook temporarily.',
|
'disable the config-protection hook temporarily.'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +154,7 @@ process.stdin.on('data', chunk => {
|
|||||||
process.stdin.on('end', () => {
|
process.stdin.on('end', () => {
|
||||||
const result = run(raw, {
|
const result = run(raw, {
|
||||||
truncated,
|
truncated,
|
||||||
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN,
|
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.stderr) {
|
if (result.stderr) {
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
---
|
|
||||||
name: prisma-patterns
|
|
||||||
description: Prisma ORM patterns for TypeScript backends — schema design, query optimization, transactions, pagination, and critical traps like updateMany returning count not records, $transaction timeouts, migrate dev resetting the DB, @updatedAt skipped on bulk writes, and serverless connection exhaustion.
|
|
||||||
origin: ECC
|
|
||||||
---
|
|
||||||
|
|
||||||
# Prisma Patterns
|
|
||||||
|
|
||||||
Production patterns and non-obvious traps for Prisma ORM in TypeScript backends.
|
|
||||||
Tested against Prisma 5.x and 6.x. Some behaviors differ from Prisma 4.
|
|
||||||
|
|
||||||
Check the Prisma version before applying version-specific patterns:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma --version
|
|
||||||
```
|
|
||||||
|
|
||||||
Prisma 5 introduced `relationJoins`, which can load relations via JOIN rather than separate queries depending on query strategy and configuration. The `omit` field modifier and `prisma.$extends` Client Extensions API were also added. Note: `relationJoins` can cause row explosion on large 1:N relations or deep nested `include` — benchmark both approaches when relations may return many rows per parent.
|
|
||||||
|
|
||||||
## When to Activate
|
|
||||||
|
|
||||||
- Designing or modifying Prisma schema models and relations
|
|
||||||
- Writing queries, transactions, or pagination logic
|
|
||||||
- Using `updateMany`, `deleteMany`, or any bulk operation
|
|
||||||
- Running or planning database migrations
|
|
||||||
- Deploying to serverless environments (Vercel, Lambda, Cloudflare Workers)
|
|
||||||
- Implementing soft delete or multi-tenant row filtering
|
|
||||||
|
|
||||||
## Core Concepts
|
|
||||||
|
|
||||||
### ID Strategy
|
|
||||||
|
|
||||||
| Strategy | Use When | Avoid When |
|
|
||||||
|---|---|---|
|
|
||||||
| `@default(cuid())` | Default choice — URL-safe, sortable, no collisions | Sequential IDs needed for external systems |
|
|
||||||
| `@default(uuid())` | Interoperability with non-Prisma systems required | High-write tables (random UUIDs fragment B-tree indexes) |
|
|
||||||
| `@default(autoincrement())` | Internal join tables, audit logs | Public-facing IDs (exposes record count) |
|
|
||||||
|
|
||||||
### Schema Defaults
|
|
||||||
|
|
||||||
```prisma
|
|
||||||
model User {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
email String @unique // @unique already creates an index — no @@index needed
|
|
||||||
name String
|
|
||||||
role Role @default(USER)
|
|
||||||
posts Post[]
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
deletedAt DateTime?
|
|
||||||
|
|
||||||
@@index([createdAt])
|
|
||||||
@@index([deletedAt, createdAt]) // composite for soft-delete + sort queries
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Add `@@index` on every foreign key and column used in `WHERE` or `ORDER BY`.
|
|
||||||
- Declare `deletedAt DateTime?` upfront when soft delete is a foreseeable requirement — adding it later requires a migration on a live table.
|
|
||||||
- `updatedAt @updatedAt` is set automatically by Prisma on `update` and `upsert` only (see Anti-Patterns for bulk update trap).
|
|
||||||
|
|
||||||
### `include` vs `select`
|
|
||||||
|
|
||||||
| | `include` | `select` |
|
|
||||||
|---|---|---|
|
|
||||||
| Returns | All scalar fields + specified relations | Only specified fields |
|
|
||||||
| Use when | You need most fields plus a relation | Hot paths, large tables, avoiding over-fetch |
|
|
||||||
| Performance | May over-fetch on wide tables | Minimal payload, faster on large datasets |
|
|
||||||
| Prisma 5 note | Uses JOIN by default (`relationJoins`) | Same |
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// include — all columns + relation
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: { posts: { select: { id: true, title: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
// select — explicit allowlist
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id },
|
|
||||||
select: { id: true, email: true, name: true },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Never return raw Prisma entities from API responses — map to response DTOs to control exposed fields:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// BAD: leaks passwordHash, deletedAt, internal fields
|
|
||||||
return await prisma.user.findUniqueOrThrow({ where: { id } });
|
|
||||||
|
|
||||||
// GOOD: explicit DTO mapping
|
|
||||||
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
|
|
||||||
return { id: user.id, name: user.name, email: user.email };
|
|
||||||
```
|
|
||||||
|
|
||||||
### Transaction Form Selection
|
|
||||||
|
|
||||||
| Situation | Use |
|
|
||||||
|---|---|
|
|
||||||
| Independent operations, no inter-dependency | Array form |
|
|
||||||
| Later step depends on earlier result | Interactive form |
|
|
||||||
| External calls (email, HTTP) involved | Outside transaction entirely |
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Array form — batched in one round trip
|
|
||||||
const [user, post] = await prisma.$transaction([
|
|
||||||
prisma.user.update({ where: { id }, data: { name } }),
|
|
||||||
prisma.post.create({ data: { title, authorId: id } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Interactive form — use tx client only, never the outer prisma client
|
|
||||||
const post = await prisma.$transaction(async (tx) => {
|
|
||||||
const user = await tx.user.findUniqueOrThrow({ where: { id } });
|
|
||||||
if (user.role !== 'ADMIN') throw new Error('Forbidden');
|
|
||||||
return tx.post.create({ data: { title, authorId: user.id } });
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### PrismaClient Singleton
|
|
||||||
|
|
||||||
Each `PrismaClient` instance opens its own connection pool. Instantiate once.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// lib/prisma.ts
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
|
||||||
|
|
||||||
export const prisma =
|
|
||||||
globalForPrisma.prisma ??
|
|
||||||
new PrismaClient({
|
|
||||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
|
||||||
```
|
|
||||||
|
|
||||||
The `globalThis` pattern prevents duplicate instances during hot reload (Next.js, nodemon, ts-node-dev).
|
|
||||||
|
|
||||||
### N+1 Problem
|
|
||||||
|
|
||||||
Loading relations inside a loop issues one query per row.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// BAD: N+1 — one extra query per user
|
|
||||||
const users = await prisma.user.findMany();
|
|
||||||
for (const user of users) {
|
|
||||||
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// GOOD: single query
|
|
||||||
const users = await prisma.user.findMany({ include: { posts: true } });
|
|
||||||
```
|
|
||||||
|
|
||||||
With Prisma 5+ `relationJoins`, the `include` form uses a single JOIN. On large 1:N sets this may increase result set size — benchmark both approaches if the relation can return many rows per parent.
|
|
||||||
|
|
||||||
## Code Examples
|
|
||||||
|
|
||||||
### Cursor Pagination (preferred for feeds and large datasets)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
async function getPosts(cursor?: string, limit = 20) {
|
|
||||||
const items = await prisma.post.findMany({
|
|
||||||
where: { published: true },
|
|
||||||
orderBy: [
|
|
||||||
{ createdAt: 'desc' },
|
|
||||||
{ id: 'desc' }, // secondary sort prevents unstable pagination on duplicate timestamps
|
|
||||||
],
|
|
||||||
take: limit + 1,
|
|
||||||
...(cursor && { cursor: { id: cursor }, skip: 1 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasNextPage = items.length > limit;
|
|
||||||
if (hasNextPage) items.pop();
|
|
||||||
|
|
||||||
return { items, nextCursor: hasNextPage ? items[items.length - 1].id : null };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Fetch `limit + 1` and pop — canonical way to detect `hasNextPage` without an extra count query. Always include a unique field (e.g. `id`) as a secondary `orderBy` to prevent unstable pagination when multiple rows share the same timestamp. Use offset pagination only when users need to jump to arbitrary pages (admin tables).
|
|
||||||
|
|
||||||
### Soft Delete
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Always filter explicitly — do not rely on middleware (hides behavior, hard to debug)
|
|
||||||
const activeUsers = await prisma.user.findMany({ where: { deletedAt: null } });
|
|
||||||
|
|
||||||
await prisma.user.update({ where: { id }, data: { deletedAt: new Date() } });
|
|
||||||
await prisma.user.update({ where: { id }, data: { deletedAt: null } }); // restore
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.user.create({ data: { email } });
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
|
||||||
if (e.code === 'P2002') throw new ConflictError('Email already exists');
|
|
||||||
if (e.code === 'P2025') throw new NotFoundError('Record not found');
|
|
||||||
if (e.code === 'P2003') throw new BadRequestError('Referenced record does not exist');
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Common codes: `P2002` unique violation · `P2025` not found · `P2003` foreign key violation.
|
|
||||||
|
|
||||||
Catch at the service boundary and translate to domain errors. Never expose raw Prisma messages to API consumers.
|
|
||||||
|
|
||||||
### Connection Pool — Serverless
|
|
||||||
|
|
||||||
Embed connection params directly in `DATABASE_URL` — string concatenation breaks if the URL already has query parameters (e.g. `?schema=public`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# .env — preferred: embed params in the URL
|
|
||||||
DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20"
|
|
||||||
|
|
||||||
# With an external pooler (PgBouncer, Supabase pooler)
|
|
||||||
DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1"
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Vercel, AWS Lambda, and similar serverless runtimes: cap pool to 1 per instance
|
|
||||||
// connection_limit and pool_timeout are controlled via DATABASE_URL
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-Patterns
|
|
||||||
|
|
||||||
### `updateMany` returns a count, not records
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// BAD: result is { count: 2 } — users[0] is undefined
|
|
||||||
const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } });
|
|
||||||
|
|
||||||
// GOOD: capture IDs first, then update, then fetch only the affected rows
|
|
||||||
const targets = await prisma.user.findMany({
|
|
||||||
where: { role: 'GUEST' },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const ids = targets.map((u) => u.id);
|
|
||||||
await prisma.user.updateMany({ where: { id: { in: ids } }, data: { role: 'USER' } });
|
|
||||||
const updated = await prisma.user.findMany({ where: { id: { in: ids } } });
|
|
||||||
```
|
|
||||||
|
|
||||||
Same applies to `deleteMany` — returns `{ count: n }`, never the deleted rows.
|
|
||||||
|
|
||||||
### `$transaction` interactive form times out after 5 seconds
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// BAD: external call inside transaction exceeds 5s default → "Transaction already closed"
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
const user = await tx.user.findUniqueOrThrow({ where: { id } });
|
|
||||||
await sendWelcomeEmail(user.email); // external call
|
|
||||||
await tx.user.update({ where: { id }, data: { emailSent: true } });
|
|
||||||
});
|
|
||||||
|
|
||||||
// GOOD: external calls outside the transaction
|
|
||||||
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
|
|
||||||
await sendWelcomeEmail(user.email);
|
|
||||||
await prisma.user.update({ where: { id }, data: { emailSent: true } });
|
|
||||||
|
|
||||||
// Only raise timeout when bulk processing genuinely needs it
|
|
||||||
await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 });
|
|
||||||
```
|
|
||||||
|
|
||||||
### `migrate dev` can reset the database
|
|
||||||
|
|
||||||
`migrate dev` detects schema drift and may prompt to reset the DB, dropping all data.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# NEVER on shared dev, staging, or production
|
|
||||||
npx prisma migrate dev --name add_column
|
|
||||||
|
|
||||||
# Safe everywhere except local solo dev
|
|
||||||
npx prisma migrate deploy
|
|
||||||
|
|
||||||
# Check drift without applying
|
|
||||||
npx prisma migrate diff \
|
|
||||||
--from-migrations ./prisma/migrations \
|
|
||||||
--to-schema-datamodel ./prisma/schema.prisma \
|
|
||||||
--shadow-database-url "$SHADOW_DATABASE_URL"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manually editing a migration file breaks future deploys
|
|
||||||
|
|
||||||
Prisma checksums every migration file. Editing after apply causes `P3006 checksum mismatch` on every environment where the original already ran. Create a new migration instead.
|
|
||||||
|
|
||||||
### Breaking schema changes require multi-step migration
|
|
||||||
|
|
||||||
Adding `NOT NULL` to an existing column or renaming a column in one migration will lock the table or drop data. Use expand-and-contract:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Step 1: create migration locally, then deploy
|
|
||||||
npx prisma migrate dev --name add_new_column # local only
|
|
||||||
npx prisma migrate deploy # staging / production
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Step 2: backfill data (run in a script or migration job, not in the shell)
|
|
||||||
await prisma.user.updateMany({ data: { newColumn: derivedValue } });
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Step 3: create the NOT NULL constraint migration locally, then deploy
|
|
||||||
npx prisma migrate dev --name make_new_column_required # local only
|
|
||||||
npx prisma migrate deploy # staging / production
|
|
||||||
```
|
|
||||||
|
|
||||||
### `@updatedAt` does not fire on `updateMany`
|
|
||||||
|
|
||||||
`@updatedAt` is set automatically only on `update` and `upsert`. Bulk writes leave it stale.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// BAD: updatedAt stays at its old value
|
|
||||||
await prisma.post.updateMany({ where: { authorId }, data: { published: true } });
|
|
||||||
|
|
||||||
// GOOD
|
|
||||||
await prisma.post.updateMany({
|
|
||||||
where: { authorId },
|
|
||||||
data: { published: true, updatedAt: new Date() },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Soft delete + `findUniqueOrThrow` leaks deleted records
|
|
||||||
|
|
||||||
`findUniqueOrThrow` throws `P2025` only when the row does not exist in the DB. Soft-deleted rows still exist and are returned without error.
|
|
||||||
|
|
||||||
`findUniqueOrThrow` requires a unique constraint field in `where` — adding `deletedAt: null` alongside `id` breaks the type because `{ id, deletedAt }` is not a compound unique constraint. Use `findFirstOrThrow` instead.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// BAD: returns soft-deleted user
|
|
||||||
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
|
|
||||||
|
|
||||||
// BAD: Prisma type error — { id, deletedAt } is not a unique constraint
|
|
||||||
const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } });
|
|
||||||
|
|
||||||
// GOOD: findFirstOrThrow supports arbitrary where conditions
|
|
||||||
const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } });
|
|
||||||
```
|
|
||||||
|
|
||||||
### `deleteMany` without `where` deletes every row
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// BAD: silently wipes the table
|
|
||||||
await prisma.post.deleteMany();
|
|
||||||
|
|
||||||
// GOOD
|
|
||||||
await prisma.post.deleteMany({ where: { authorId: userId } });
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
| Rule | Reason |
|
|
||||||
|---|---|
|
|
||||||
| `migrate deploy` in CI/CD, `migrate dev` only locally | `migrate dev` can reset the DB on drift |
|
|
||||||
| Map entities to response DTOs | Prevents leaking internal fields |
|
|
||||||
| Catch `PrismaClientKnownRequestError` at service boundary | Translate to domain errors |
|
|
||||||
| Prefer `*OrThrow` methods over manual null checks | Throws P2025 automatically; use `findFirstOrThrow` when filtering non-unique fields |
|
|
||||||
| `connection_limit=1` + external pooler in serverless | Prevents connection exhaustion |
|
|
||||||
| Always provide `where` on `deleteMany` | Prevents accidental table wipe |
|
|
||||||
| Set `updatedAt: new Date()` manually in `updateMany` | `@updatedAt` skips bulk writes |
|
|
||||||
|
|
||||||
## Related Skills
|
|
||||||
|
|
||||||
- `nestjs-patterns` — NestJS service layer that integrates Prisma
|
|
||||||
- `postgres-patterns` — PostgreSQL-level indexing and connection tuning
|
|
||||||
- `database-migrations` — multi-step migration planning for production
|
|
||||||
- `backend-patterns` — general API and service layer design
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawnSync } = require('child_process');
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
@@ -70,85 +71,249 @@ function runTests() {
|
|||||||
let passed = 0;
|
let passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
if (test('blocks protected config file edits through run-with-flags', () => {
|
if (
|
||||||
const input = {
|
test('blocks protected config file edits through run-with-flags', () => {
|
||||||
tool_name: 'Write',
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
|
||||||
tool_input: {
|
try {
|
||||||
file_path: '.eslintrc.js',
|
const absPath = path.join(tmpDir, '.eslintrc.js');
|
||||||
content: 'module.exports = {};'
|
fs.writeFileSync(absPath, 'module.exports = {};');
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
tool_name: 'Write',
|
||||||
|
tool_input: {
|
||||||
|
file_path: absPath,
|
||||||
|
content: 'module.exports = {};'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = runHook(input);
|
||||||
|
assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked');
|
||||||
|
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
||||||
|
assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
const result = runHook(input);
|
if (
|
||||||
assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked');
|
test('passes through safe file edits unchanged', () => {
|
||||||
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
const input = {
|
||||||
assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`);
|
tool_name: 'Write',
|
||||||
})) passed++; else failed++;
|
tool_input: {
|
||||||
|
file_path: 'src/index.js',
|
||||||
|
content: 'console.log("ok");'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (test('passes through safe file edits unchanged', () => {
|
const rawInput = JSON.stringify(input);
|
||||||
const input = {
|
const result = runHook(input);
|
||||||
tool_name: 'Write',
|
assert.strictEqual(result.code, 0, 'Expected safe file edit to pass');
|
||||||
tool_input: {
|
assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough');
|
||||||
file_path: 'src/index.js',
|
assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits');
|
||||||
content: 'console.log("ok");'
|
})
|
||||||
}
|
)
|
||||||
};
|
passed++;
|
||||||
|
else failed++;
|
||||||
const rawInput = JSON.stringify(input);
|
|
||||||
const result = runHook(input);
|
|
||||||
assert.strictEqual(result.code, 0, 'Expected safe file edit to pass');
|
|
||||||
assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough');
|
|
||||||
assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits');
|
|
||||||
})) passed++; else failed++;
|
|
||||||
|
|
||||||
if (test('blocks truncated protected config payloads instead of failing open', () => {
|
|
||||||
const rawInput = JSON.stringify({
|
|
||||||
tool_name: 'Write',
|
|
||||||
tool_input: {
|
|
||||||
file_path: '.eslintrc.js',
|
|
||||||
content: 'x'.repeat(1024 * 1024 + 2048)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runHook(rawInput);
|
|
||||||
assert.strictEqual(result.code, 2, 'Expected truncated protected payload to be blocked');
|
|
||||||
assert.strictEqual(result.stdout, '', 'Blocked truncated payload should not echo raw input');
|
|
||||||
assert.ok(result.stderr.includes('Hook input exceeded 1048576 bytes'), `Expected size warning, got: ${result.stderr}`);
|
|
||||||
assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`);
|
|
||||||
})) passed++; else failed++;
|
|
||||||
|
|
||||||
if (test('legacy hooks do not echo raw input when they fail without stdout', () => {
|
|
||||||
const pluginRoot = path.join(__dirname, '..', `tmp-runner-plugin-${Date.now()}`);
|
|
||||||
const scriptDir = path.join(pluginRoot, 'scripts', 'hooks');
|
|
||||||
const scriptPath = path.join(scriptDir, 'legacy-block.js');
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(scriptDir, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
scriptPath,
|
|
||||||
'#!/usr/bin/env node\nprocess.stderr.write("blocked by legacy hook\\n");\nprocess.exit(2);\n'
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('blocks truncated protected config payloads instead of failing open', () => {
|
||||||
const rawInput = JSON.stringify({
|
const rawInput = JSON.stringify({
|
||||||
tool_name: 'Write',
|
tool_name: 'Write',
|
||||||
tool_input: {
|
tool_input: {
|
||||||
file_path: '.eslintrc.js',
|
file_path: '.eslintrc.js',
|
||||||
content: 'module.exports = {};'
|
content: 'x'.repeat(1024 * 1024 + 2048)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = runCustomHook(pluginRoot, 'pre:legacy-block', 'scripts/hooks/legacy-block.js', rawInput);
|
const result = runHook(rawInput);
|
||||||
assert.strictEqual(result.code, 2, 'Expected failing legacy hook exit code to propagate');
|
assert.strictEqual(result.code, 2, 'Expected truncated protected payload to be blocked');
|
||||||
assert.strictEqual(result.stdout, '', 'Expected failing legacy hook to avoid raw passthrough');
|
assert.strictEqual(result.stdout, '', 'Blocked truncated payload should not echo raw input');
|
||||||
assert.ok(result.stderr.includes('blocked by legacy hook'), `Expected legacy hook stderr, got: ${result.stderr}`);
|
assert.ok(result.stderr.includes('Hook input exceeded 1048576 bytes'), `Expected size warning, got: ${result.stderr}`);
|
||||||
} finally {
|
assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('allows first-time creation of a protected config file', () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
|
||||||
try {
|
try {
|
||||||
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
const absPath = path.join(tmpDir, 'eslint.config.mjs');
|
||||||
} catch {
|
const input = {
|
||||||
// best-effort cleanup
|
tool_name: 'Write',
|
||||||
|
tool_input: {
|
||||||
|
file_path: absPath,
|
||||||
|
content: 'export default [];'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawInput = JSON.stringify(input);
|
||||||
|
const result = runHook(input);
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit 0 for first-time creation, got ${result.code}; stderr: ${result.stderr}`);
|
||||||
|
assert.strictEqual(result.stdout, rawInput, 'Expected raw passthrough when creation is allowed');
|
||||||
|
assert.strictEqual(result.stderr, '', `Expected no stderr for first-time creation, got: ${result.stderr}`);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})) passed++; else failed++;
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('allows first-time creation when the parent directory does not exist yet', () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
|
||||||
|
try {
|
||||||
|
// Path under a non-existent subdirectory — statSync returns ENOENT
|
||||||
|
// on the final segment, which should be treated as "does not exist"
|
||||||
|
// and allow the write. (Agent or CLI is expected to create parents
|
||||||
|
// during the Write itself; this hook does not need to.)
|
||||||
|
const absPath = path.join(tmpDir, 'no-such-parent', '.prettierrc');
|
||||||
|
const input = {
|
||||||
|
tool_name: 'Write',
|
||||||
|
tool_input: {
|
||||||
|
file_path: absPath,
|
||||||
|
content: '{}'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawInput = JSON.stringify(input);
|
||||||
|
const result = runHook(input);
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit 0 for ENOENT path, got ${result.code}; stderr: ${result.stderr}`);
|
||||||
|
assert.strictEqual(result.stdout, rawInput, 'Expected raw passthrough when path does not exist');
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('blocks protected paths that exist as a dangling symlink', () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
|
||||||
|
try {
|
||||||
|
const missingTarget = path.join(tmpDir, 'nowhere.js');
|
||||||
|
const linkPath = path.join(tmpDir, '.eslintrc.js');
|
||||||
|
try {
|
||||||
|
fs.symlinkSync(missingTarget, linkPath);
|
||||||
|
} catch (err) {
|
||||||
|
// Windows without Developer Mode or certain sandboxes disallow
|
||||||
|
// symlinks. Skip cleanly rather than fail the suite.
|
||||||
|
if (err.code === 'EPERM' || err.code === 'EACCES') {
|
||||||
|
console.log(' (skipped: symlink creation not permitted here)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
tool_name: 'Write',
|
||||||
|
tool_input: {
|
||||||
|
file_path: linkPath,
|
||||||
|
content: 'module.exports = {};'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = runHook(input);
|
||||||
|
assert.strictEqual(result.code, 2, `Expected exit 2 for dangling symlink, got ${result.code}; stderr: ${result.stderr}`);
|
||||||
|
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
||||||
|
assert.ok(
|
||||||
|
result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'),
|
||||||
|
`Expected block message, got: ${result.stderr}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('still blocks writes to an existing protected config file', () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
|
||||||
|
try {
|
||||||
|
const absPath = path.join(tmpDir, '.eslintrc.js');
|
||||||
|
fs.writeFileSync(absPath, 'module.exports = { rules: {} };');
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
tool_name: 'Edit',
|
||||||
|
tool_input: {
|
||||||
|
file_path: absPath,
|
||||||
|
content: 'module.exports = { rules: { "no-console": "off" } };'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = runHook(input);
|
||||||
|
assert.strictEqual(result.code, 2, 'Expected exit 2 when modifying an existing protected config');
|
||||||
|
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
||||||
|
assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('legacy hooks do not echo raw input when they fail without stdout', () => {
|
||||||
|
const pluginRoot = path.join(__dirname, '..', `tmp-runner-plugin-${Date.now()}`);
|
||||||
|
const scriptDir = path.join(pluginRoot, 'scripts', 'hooks');
|
||||||
|
const scriptPath = path.join(scriptDir, 'legacy-block.js');
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(scriptDir, { recursive: true });
|
||||||
|
fs.writeFileSync(scriptPath, '#!/usr/bin/env node\nprocess.stderr.write("blocked by legacy hook\\n");\nprocess.exit(2);\n');
|
||||||
|
|
||||||
|
const rawInput = JSON.stringify({
|
||||||
|
tool_name: 'Write',
|
||||||
|
tool_input: {
|
||||||
|
file_path: '.eslintrc.js',
|
||||||
|
content: 'module.exports = {};'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runCustomHook(pluginRoot, 'pre:legacy-block', 'scripts/hooks/legacy-block.js', rawInput);
|
||||||
|
assert.strictEqual(result.code, 2, 'Expected failing legacy hook exit code to propagate');
|
||||||
|
assert.strictEqual(result.stdout, '', 'Expected failing legacy hook to avoid raw passthrough');
|
||||||
|
assert.ok(result.stderr.includes('blocked by legacy hook'), `Expected legacy hook stderr, got: ${result.stderr}`);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user