Skip to content

Commit 04fac4d

Browse files
PAMulliganclaude
andauthored
feat(soft-delete): implement soft delete query filter, helper, and route pattern (#103)
pipeline.config.json enabled database.softDeleteDefault by default, but no implementation existed — generated projects got a deleted_at column with no filtering, forcing manual WHERE deleted_at IS NULL in every query. Add the full pattern: - templates/snippets/shared/src/db/soft-delete.ts: notDeleted/withNotDeleted/ softDeleteFilter query filters, softDelete() mutation (stamps deleted_at, guards on IS NULL, returns rows affected), and includeDeletedQuerySchema for the ?include_deleted=true admin flag - add nullable deleted_at column to the users schema template - soft-delete.test.ts: 13 tests asserting against real Drizzle SQL generation - setup-project.sh copies the helper and test into generated projects - database-design skill: generate deleted_at columns + indexes and the helper when softDeleteDefault is true - route-generation skill: service.delete() calls softDelete(); reads filter deleted rows; optionalAuth middleware exposes deleted rows to admins only - tdd-from-schema skill: soft-delete test cases Verified: tsc passes for both Cloudflare and Node targets; 13/13 tests pass. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2f4c406 commit 04fac4d

7 files changed

Lines changed: 506 additions & 34 deletions

File tree

.claude/skills/database-design/SKILL.md

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ import {
173173
text,
174174
timestamp,
175175
uniqueIndex,
176+
index,
176177
} from 'drizzle-orm/pg-core';
177178
import { relations } from 'drizzle-orm';
178179
import { userRoleEnum } from './enums';
@@ -193,9 +194,14 @@ export const users = pgTable(
193194
.defaultNow()
194195
.notNull()
195196
.$onUpdate(() => new Date()),
197+
// Soft delete: NULL = live, timestamp = deleted. Added to every table when
198+
// `database.softDeleteDefault` is true in pipeline.config.json (Step 6a).
199+
deletedAt: timestamp('deleted_at', { withTimezone: true }),
196200
},
197201
(table) => [
198202
uniqueIndex('users_email_idx').on(table.email),
203+
// Partial index keeps lookups of live rows fast even with many deleted rows.
204+
index('users_deleted_at_idx').on(table.deletedAt),
199205
],
200206
);
201207

@@ -349,6 +355,103 @@ export const orderItemsRelations = relations(orderItems, ({ one }) => ({
349355
}));
350356
```
351357

358+
### Step 6a -- Soft Delete Columns (when enabled)
359+
360+
Read `database.softDeleteDefault` from `.claude/pipeline.config.json`. When it is
361+
`true` (the default), **every** generated table gets a nullable `deleted_at`
362+
column plus an index, and a shared soft-delete helper module is generated.
363+
364+
```typescript
365+
// Add to the column block of every pgTable (after createdAt/updatedAt):
366+
deletedAt: timestamp('deleted_at', { withTimezone: true }),
367+
```
368+
369+
```typescript
370+
// Add to the index block of every table:
371+
index('<table>_deleted_at_idx').on(table.deletedAt),
372+
```
373+
374+
`deleted_at` is intentionally **nullable with no default**`NULL` means the row
375+
is live, a timestamp means it was soft-deleted. Do not add `.notNull()` or a
376+
default. Join/pivot tables that are physically deleted via `ON DELETE CASCADE`
377+
(e.g. `order_items`) may opt out — only add `deleted_at` to top-level resources.
378+
379+
Then generate the shared helper module once per project. It provides the query
380+
filter and the `softDelete()` mutation consumed by the service layer in Phase 3
381+
(`route-generation`):
382+
383+
```typescript
384+
// api/src/db/soft-delete.ts
385+
import { and, eq, isNull, type SQL } from 'drizzle-orm';
386+
import type {
387+
PgColumn,
388+
PgDatabase,
389+
PgQueryResultHKT,
390+
PgTable,
391+
PgUpdateSetSource,
392+
} from 'drizzle-orm/pg-core';
393+
import { z } from 'zod';
394+
395+
export interface SoftDeleteColumns {
396+
id: PgColumn;
397+
deletedAt: PgColumn;
398+
}
399+
export type SoftDeleteTable = PgTable & SoftDeleteColumns;
400+
401+
/** `WHERE deleted_at IS NULL` — add to the where clause of every read query. */
402+
export function notDeleted(table: SoftDeleteColumns): SQL {
403+
return isNull(table.deletedAt);
404+
}
405+
406+
/** AND the not-deleted filter with extra conditions (undefined ones ignored). */
407+
export function withNotDeleted(
408+
table: SoftDeleteColumns,
409+
...conditions: Array<SQL | undefined>
410+
): SQL | undefined {
411+
return and(notDeleted(table), ...conditions);
412+
}
413+
414+
/** Build a where clause honouring the `?include_deleted=true` admin flag. */
415+
export function softDeleteFilter(
416+
table: SoftDeleteColumns,
417+
includeDeleted: boolean,
418+
...conditions: Array<SQL | undefined>
419+
): SQL | undefined {
420+
return includeDeleted
421+
? and(...conditions)
422+
: and(notDeleted(table), ...conditions);
423+
}
424+
425+
/** Stamp deleted_at instead of deleting. Returns rows affected (0 or 1). */
426+
export async function softDelete<TTable extends SoftDeleteTable>(
427+
db: PgDatabase<PgQueryResultHKT>,
428+
table: TTable,
429+
id: string | number,
430+
): Promise<number> {
431+
const result = await db
432+
.update(table)
433+
.set({ deletedAt: new Date() } as unknown as PgUpdateSetSource<TTable>)
434+
.where(and(eq(table.id, id), notDeleted(table)))
435+
.returning({ id: table.id });
436+
return result.length;
437+
}
438+
439+
/** Query-param schema: `?include_deleted=true` for admin endpoints. */
440+
export const includeDeletedQuerySchema = z.object({
441+
include_deleted: z
442+
.enum(['true', 'false'])
443+
.optional()
444+
.default('false')
445+
.transform((value) => value === 'true'),
446+
});
447+
export type IncludeDeletedQuery = z.infer<typeof includeDeletedQuerySchema>;
448+
```
449+
450+
This module is also shipped as a template snippet
451+
(`templates/snippets/shared/src/db/soft-delete.ts`) and is copied automatically by
452+
`setup-project.sh`. When `softDeleteDefault` is `false`, omit the `deleted_at`
453+
columns and do not generate this module.
454+
352455
### Step 7 -- Generate Barrel Export
353456

354457
```typescript
@@ -410,6 +513,7 @@ export type UpdateUser = z.infer<typeof updateUserSchema>;
410513
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
411514
import { z } from 'zod';
412515
import { books } from '../db/schema/books';
516+
import { includeDeletedQuerySchema } from '../db/soft-delete';
413517

414518
export const insertBookSchema = createInsertSchema(books, {
415519
title: z.string().min(1).max(500),
@@ -427,15 +531,19 @@ export const selectBookSchema = createSelectSchema(books);
427531

428532
export const updateBookSchema = insertBookSchema.partial();
429533

430-
// Query params for list endpoint
431-
export const listBooksQuerySchema = z.object({
432-
page: z.coerce.number().int().min(1).default(1),
433-
limit: z.coerce.number().int().min(1).max(100).default(20),
434-
search: z.string().optional(),
435-
author: z.string().optional(),
436-
sortBy: z.enum(['title', 'author', 'price', 'createdAt']).default('createdAt'),
437-
sortOrder: z.enum(['asc', 'desc']).default('desc'),
438-
});
534+
// Query params for list endpoint.
535+
// `.merge(includeDeletedQuerySchema)` adds the `?include_deleted=true` flag used
536+
// by admin endpoints to also return soft-deleted rows. Defaults to false.
537+
export const listBooksQuerySchema = z
538+
.object({
539+
page: z.coerce.number().int().min(1).default(1),
540+
limit: z.coerce.number().int().min(1).max(100).default(20),
541+
search: z.string().optional(),
542+
author: z.string().optional(),
543+
sortBy: z.enum(['title', 'author', 'price', 'createdAt']).default('createdAt'),
544+
sortOrder: z.enum(['asc', 'desc']).default('desc'),
545+
})
546+
.merge(includeDeletedQuerySchema);
439547

440548
export type InsertBook = z.infer<typeof insertBookSchema>;
441549
export type SelectBook = z.infer<typeof selectBookSchema>;
@@ -505,7 +613,8 @@ CREATE TABLE IF NOT EXISTS "users" (
505613
"name" text NOT NULL,
506614
"role" "user_role" DEFAULT 'customer' NOT NULL,
507615
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
508-
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
616+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
617+
"deleted_at" timestamp with time zone
509618
);
510619

511620
CREATE TABLE IF NOT EXISTS "books" (
@@ -517,7 +626,8 @@ CREATE TABLE IF NOT EXISTS "books" (
517626
"stock" integer DEFAULT 0 NOT NULL,
518627
"description" text,
519628
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
520-
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
629+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
630+
"deleted_at" timestamp with time zone
521631
);
522632

523633
CREATE TABLE IF NOT EXISTS "orders" (
@@ -526,7 +636,8 @@ CREATE TABLE IF NOT EXISTS "orders" (
526636
"status" "order_status" DEFAULT 'pending' NOT NULL,
527637
"total" numeric(10, 2) NOT NULL,
528638
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
529-
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
639+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
640+
"deleted_at" timestamp with time zone
530641
);
531642

532643
CREATE TABLE IF NOT EXISTS "order_items" (
@@ -544,6 +655,11 @@ CREATE INDEX IF NOT EXISTS "books_author_idx" ON "books" USING btree ("author");
544655
CREATE INDEX IF NOT EXISTS "orders_user_id_idx" ON "orders" USING btree ("user_id");
545656
CREATE INDEX IF NOT EXISTS "order_items_order_id_idx" ON "order_items" USING btree ("order_id");
546657
CREATE INDEX IF NOT EXISTS "order_items_book_id_idx" ON "order_items" USING btree ("book_id");
658+
659+
-- Soft-delete indexes (generated when database.softDeleteDefault is true)
660+
CREATE INDEX IF NOT EXISTS "users_deleted_at_idx" ON "users" USING btree ("deleted_at");
661+
CREATE INDEX IF NOT EXISTS "books_deleted_at_idx" ON "books" USING btree ("deleted_at");
662+
CREATE INDEX IF NOT EXISTS "orders_deleted_at_idx" ON "orders" USING btree ("deleted_at");
547663
```
548664

549665
### Step 10 -- Install Dependencies
@@ -557,7 +673,8 @@ cd api && pnpm add -D drizzle-kit drizzle-zod
557673

558674
| Artifact | Location | Description |
559675
|---|---|---|
560-
| Schema files | `api/src/db/schema/*.ts` | One Drizzle pgTable per model |
676+
| Schema files | `api/src/db/schema/*.ts` | One Drizzle pgTable per model (incl. `deleted_at`) |
677+
| Soft-delete helper | `api/src/db/soft-delete.ts` | `notDeleted`/`softDelete` filters (when `softDeleteDefault`) |
561678
| Enum definitions | `api/src/db/schema/enums.ts` | PostgreSQL enum types |
562679
| Schema barrel | `api/src/db/schema/index.ts` | Re-exports all schemas |
563680
| DB connection | `api/src/db/index.ts` | Drizzle client setup |
@@ -583,4 +700,5 @@ cd api && pnpm add -D drizzle-kit drizzle-zod
583700
- **Zod inference from Drizzle**: Single source of truth -- change the Drizzle schema, the Zod validators follow automatically.
584701
- **UUID primary keys**: Default for all tables. Avoids sequential ID enumeration attacks and works well in distributed systems.
585702
- **Soft timestamps**: `createdAt` and `updatedAt` on every table. `$onUpdate` handles automatic timestamp refresh.
703+
- **Soft deletes by default**: When `database.softDeleteDefault` is true, tables carry a nullable `deleted_at` column and rows are never physically removed. Reads filter `WHERE deleted_at IS NULL` via the generated `soft-delete.ts` helper, and DELETE handlers call `softDelete()`. Admin endpoints can opt back in to deleted rows with `?include_deleted=true`. See [route-generation](../route-generation/SKILL.md) for the handler pattern.
586704
- **snake_case columns**: PostgreSQL convention. Drizzle maps to camelCase in TypeScript automatically.

.claude/skills/route-generation/SKILL.md

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,23 @@ export const requireRole = (...roles: string[]) =>
240240
}
241241
await next();
242242
});
243+
244+
// Parses a bearer token when present but never rejects. Used on otherwise-public
245+
// routes that change behaviour for authenticated users -- e.g. a public list
246+
// endpoint that exposes soft-deleted rows to admins via ?include_deleted=true.
247+
export const optionalAuth = createMiddleware<AppEnv>(async (c, next) => {
248+
const authHeader = c.req.header('Authorization');
249+
if (authHeader?.startsWith('Bearer ')) {
250+
try {
251+
const { payload } = await jwtVerify(authHeader.slice(7), JWT_SECRET);
252+
c.set('userId', payload.sub as string);
253+
c.set('userRole', (payload.role as string) ?? 'customer');
254+
} catch {
255+
// Anonymous: ignore an invalid/expired token rather than 401.
256+
}
257+
}
258+
await next();
259+
});
243260
```
244261

245262
### Step 4 -- Create the Service Layer
@@ -248,30 +265,34 @@ One service per resource, encapsulating all database operations:
248265

249266
```typescript
250267
// api/src/services/books.service.ts
251-
import { eq, sql, ilike, asc, desc } from 'drizzle-orm';
268+
import { eq, sql, ilike, asc, desc, and } from 'drizzle-orm';
252269
import { books } from '../db/schema';
253270
import type { Database } from '../db';
254271
import type { InsertBook, UpdateBook, ListBooksQuery } from '../validators/books';
255272
import { NotFoundError, ConflictError } from '../middleware/error-handler';
273+
import { notDeleted, softDelete } from '../db/soft-delete';
256274

257275
export class BooksService {
258276
constructor(private db: Database) {}
259277

260278
async list(query: ListBooksQuery) {
261-
const { page, limit, search, author, sortBy, sortOrder } = query;
279+
const { page, limit, search, author, sortBy, sortOrder, include_deleted } = query;
262280
const offset = (page - 1) * limit;
263281

264282
const conditions = [];
283+
// Exclude soft-deleted rows unless an admin asked for them via
284+
// ?include_deleted=true. This filter is applied to every read query.
285+
if (!include_deleted) {
286+
conditions.push(notDeleted(books));
287+
}
265288
if (search) {
266289
conditions.push(ilike(books.title, `%${search}%`));
267290
}
268291
if (author) {
269292
conditions.push(ilike(books.author, `%${author}%`));
270293
}
271294

272-
const whereClause = conditions.length
273-
? sql`${sql.join(conditions, sql` AND `)}`
274-
: undefined;
295+
const whereClause = and(...conditions);
275296

276297
const [{ count }] = await this.db
277298
.select({ count: sql<number>`count(*)::int` })
@@ -301,8 +322,9 @@ export class BooksService {
301322
}
302323

303324
async getById(id: string) {
325+
// Soft-deleted books are treated as not found for normal reads.
304326
const book = await this.db.query.books.findFirst({
305-
where: eq(books.id, id),
327+
where: and(eq(books.id, id), notDeleted(books)),
306328
});
307329
if (!book) throw new NotFoundError('Book', id);
308330
return book;
@@ -322,25 +344,24 @@ export class BooksService {
322344

323345
async update(id: string, data: UpdateBook) {
324346
const existing = await this.db.query.books.findFirst({
325-
where: eq(books.id, id),
347+
where: and(eq(books.id, id), notDeleted(books)),
326348
});
327349
if (!existing) throw new NotFoundError('Book', id);
328350

329351
const [updated] = await this.db
330352
.update(books)
331353
.set(data)
332-
.where(eq(books.id, id))
354+
.where(and(eq(books.id, id), notDeleted(books)))
333355
.returning();
334356
return updated;
335357
}
336358

337359
async delete(id: string) {
338-
const existing = await this.db.query.books.findFirst({
339-
where: eq(books.id, id),
340-
});
341-
if (!existing) throw new NotFoundError('Book', id);
342-
343-
await this.db.delete(books).where(eq(books.id, id));
360+
// Soft delete: stamp deleted_at instead of removing the row. softDelete()
361+
// returns 0 when the book is missing or already deleted (it guards on
362+
// `deleted_at IS NULL`), which we surface as a 404.
363+
const affected = await softDelete(this.db, books, id);
364+
if (affected === 0) throw new NotFoundError('Book', id);
344365
}
345366
}
346367
```
@@ -353,7 +374,7 @@ import { Hono } from 'hono';
353374
import { zValidator } from '@hono/zod-validator';
354375
import type { AppEnv } from '../app';
355376
import { BooksService } from '../services/books.service';
356-
import { requireAuth, requireRole } from '../middleware/auth';
377+
import { requireAuth, requireRole, optionalAuth } from '../middleware/auth';
357378
import {
358379
insertBookSchema,
359380
updateBookSchema,
@@ -364,14 +385,22 @@ import { uuidParamSchema } from '../validators';
364385
export function booksRoutes() {
365386
const router = new Hono<AppEnv>();
366387

367-
// GET /books -- public, paginated list
388+
// GET /books -- public, paginated list.
389+
// listBooksQuerySchema includes `?include_deleted=true`, but only admins may
390+
// use it: non-admins always get live rows only. Drop the flag unless the
391+
// caller is an admin so soft-deleted rows are never exposed publicly.
368392
router.get(
369393
'/',
394+
optionalAuth,
370395
zValidator('query', listBooksQuerySchema),
371396
async (c) => {
372397
const query = c.req.valid('query');
398+
const isAdmin = c.get('userRole') === 'admin';
373399
const service = new BooksService(c.get('db'));
374-
const result = await service.list(query);
400+
const result = await service.list({
401+
...query,
402+
include_deleted: query.include_deleted && isAdmin,
403+
});
375404
return c.json(result);
376405
},
377406
);
@@ -418,7 +447,9 @@ export function booksRoutes() {
418447
},
419448
);
420449

421-
// DELETE /books/:id -- admin only
450+
// DELETE /books/:id -- admin only.
451+
// Soft delete: service.delete() stamps deleted_at via softDelete() rather than
452+
// physically removing the row. The response is still 204 No Content.
422453
router.delete(
423454
'/:id',
424455
requireAuth,

0 commit comments

Comments
 (0)