Skip to content

Commit c070dac

Browse files
authored
Merge branch 'main' into 53-add-soft-delete-middleware-and-drizzle-query-filters
2 parents e1de208 + 04fac4d commit c070dac

7 files changed

Lines changed: 394 additions & 258 deletions

File tree

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

Lines changed: 94 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -194,13 +194,13 @@ export const users = pgTable(
194194
.defaultNow()
195195
.notNull()
196196
.$onUpdate(() => new Date()),
197-
// Soft-delete marker — only emitted when `database.softDeleteDefault` is
198-
// true in pipeline.config.json. Null = active; a timestamp = soft-deleted.
197+
// Soft delete: NULL = live, timestamp = deleted. Added to every table when
198+
// `database.softDeleteDefault` is true in pipeline.config.json (Step 6a).
199199
deletedAt: timestamp('deleted_at', { withTimezone: true }),
200200
},
201201
(table) => [
202202
uniqueIndex('users_email_idx').on(table.email),
203-
// Partial index keeps the common "active rows" lookups fast.
203+
// Partial index keeps lookups of live rows fast even with many deleted rows.
204204
index('users_deleted_at_idx').on(table.deletedAt),
205205
],
206206
);
@@ -355,135 +355,102 @@ export const orderItemsRelations = relations(orderItems, ({ one }) => ({
355355
}));
356356
```
357357

358-
### Step 6.5 -- Soft Delete Columns & Helpers (when enabled)
358+
### Step 6a -- Soft Delete Columns (when enabled)
359359

360-
Gate this step on `pipeline.config.json` -> `database.softDeleteDefault`. When it
361-
is `true` (the default), every table that represents a deletable resource gets a
362-
nullable `deletedAt` column and the build emits a shared soft-delete helper
363-
module. Skip this step entirely when `softDeleteDefault` is `false`.
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.
364363

365364
```typescript
366-
// Read the flag from pipeline.config.json before generating schemas
367-
import { readFile } from 'fs/promises';
368-
import path from 'path';
365+
// Add to the column block of every pgTable (after createdAt/updatedAt):
366+
deletedAt: timestamp('deleted_at', { withTimezone: true }),
367+
```
369368

370-
const pipelineConfig = JSON.parse(
371-
await readFile(
372-
path.join(process.cwd(), '.claude', 'pipeline.config.json'),
373-
'utf-8',
374-
),
375-
);
376-
const softDeleteEnabled: boolean = pipelineConfig.database?.softDeleteDefault ?? false;
369+
```typescript
370+
// Add to the index block of every table:
371+
index('<table>_deleted_at_idx').on(table.deletedAt),
377372
```
378373

379-
**1. Add the column to each soft-deletable table.** Use a nullable timestamp
380-
(no default) plus a supporting index — see the `deletedAt` column and
381-
`users_deleted_at_idx` in the Users example above. Pure join/pivot tables
382-
(e.g. `order_items`) are usually hard-deleted with their parent and can be left
383-
out.
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.
384378

385-
**2. Generate the shared helper module** at `api/src/db/soft-delete.ts`. This is
386-
the single source of truth for the soft-delete query filter and write helpers:
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`):
387382

388383
```typescript
389384
// api/src/db/soft-delete.ts
390-
import {
391-
and,
392-
eq,
393-
isNull,
394-
type Column,
395-
type SQL,
396-
type TablesRelationalConfig,
397-
} from 'drizzle-orm';
385+
import { and, eq, isNull, type SQL } from 'drizzle-orm';
398386
import type {
399387
PgColumn,
400388
PgDatabase,
401389
PgQueryResultHKT,
402390
PgTable,
403391
PgUpdateSetSource,
404392
} from 'drizzle-orm/pg-core';
393+
import { z } from 'zod';
405394

406-
export interface SoftDeletable {
407-
deletedAt: Column;
395+
export interface SoftDeleteColumns {
396+
id: PgColumn;
397+
deletedAt: PgColumn;
408398
}
399+
export type SoftDeleteTable = PgTable & SoftDeleteColumns;
409400

410-
export type SoftDeletableTable = PgTable & SoftDeletable & { id: PgColumn };
411-
412-
/** WHERE filter that hides soft-deleted rows (`deleted_at IS NULL`). */
413-
export function notDeleted(table: SoftDeletable): SQL {
401+
/** `WHERE deleted_at IS NULL` — add to the where clause of every read query. */
402+
export function notDeleted(table: SoftDeleteColumns): SQL {
414403
return isNull(table.deletedAt);
415404
}
416405

417-
/**
418-
* Hide soft-deleted rows unless an admin explicitly asks for them via
419-
* `?include_deleted=true`. An optional `extra` clause is AND-ed in.
420-
*/
421-
export function withSoftDeleteFilter(
422-
table: SoftDeletable,
423-
includeDeleted: boolean,
424-
extra?: SQL,
406+
/** AND the not-deleted filter with extra conditions (undefined ones ignored). */
407+
export function withNotDeleted(
408+
table: SoftDeleteColumns,
409+
...conditions: Array<SQL | undefined>
425410
): SQL | undefined {
426-
if (includeDeleted) {
427-
return extra;
428-
}
429-
const active = notDeleted(table);
430-
return extra ? and(active, extra) : active;
411+
return and(notDeleted(table), ...conditions);
431412
}
432413

433-
/** Soft-delete one row by id: stamps `deletedAt` instead of removing it. */
434-
export function softDelete<
435-
TQueryResult extends PgQueryResultHKT,
436-
TFullSchema extends Record<string, unknown>,
437-
TSchema extends TablesRelationalConfig,
438-
>(db: PgDatabase<TQueryResult, TFullSchema, TSchema>, table: SoftDeletableTable, id: string) {
439-
return db
440-
.update(table)
441-
.set({ deletedAt: new Date() } as PgUpdateSetSource<SoftDeletableTable>)
442-
.where(eq(table.id, id));
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);
443423
}
444424

445-
/** Restore a soft-deleted row by clearing `deletedAt`. */
446-
export function restore<
447-
TQueryResult extends PgQueryResultHKT,
448-
TFullSchema extends Record<string, unknown>,
449-
TSchema extends TablesRelationalConfig,
450-
>(db: PgDatabase<TQueryResult, TFullSchema, TSchema>, table: SoftDeletableTable, id: string) {
451-
return db
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
452432
.update(table)
453-
.set({ deletedAt: null } as PgUpdateSetSource<SoftDeletableTable>)
454-
.where(eq(table.id, id));
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;
455437
}
456-
```
457438

458-
**3. Use the helpers in read and delete routes.** `route-generation` (Phase 3)
459-
consumes these helpers so generated handlers filter deleted rows by default and
460-
expose `?include_deleted=true` on admin/list endpoints:
461-
462-
```typescript
463-
import { eq } from 'drizzle-orm';
464-
import { db } from '../db';
465-
import { users } from '../db/schema';
466-
import { restore, softDelete, withSoftDeleteFilter } from '../db/soft-delete';
467-
468-
// GET /users — active rows only; ?include_deleted=true reveals soft-deleted rows
469-
const includeDeleted = c.req.query('include_deleted') === 'true';
470-
const rows = await db
471-
.select()
472-
.from(users)
473-
.where(withSoftDeleteFilter(users, includeDeleted));
474-
475-
// DELETE /users/:id — soft delete instead of a hard DELETE
476-
const [deleted] = await softDelete(db, users, c.req.param('id')).returning();
477-
if (!deleted) return c.json({ error: 'User not found' }, 404);
478-
return c.json({ message: 'User deleted' });
479-
480-
// POST /users/:id/restore — admin un-delete
481-
const [restored] = await restore(db, users, c.req.param('id')).returning();
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>;
482448
```
483449

484-
**4. Keep `deletedAt` out of write validators.** When generating Zod schemas in
485-
Step 8, `deletedAt` is server-managed: omit it from insert/update schemas exactly
486-
like `id`, `createdAt`, and `updatedAt`.
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.
487454

488455
### Step 7 -- Generate Barrel Export
489456

@@ -547,6 +514,7 @@ export type UpdateUser = z.infer<typeof updateUserSchema>;
547514
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
548515
import { z } from 'zod';
549516
import { books } from '../db/schema/books';
517+
import { includeDeletedQuerySchema } from '../db/soft-delete';
550518

551519
export const insertBookSchema = createInsertSchema(books, {
552520
title: z.string().min(1).max(500),
@@ -564,15 +532,19 @@ export const selectBookSchema = createSelectSchema(books);
564532

565533
export const updateBookSchema = insertBookSchema.partial();
566534

567-
// Query params for list endpoint
568-
export const listBooksQuerySchema = z.object({
569-
page: z.coerce.number().int().min(1).default(1),
570-
limit: z.coerce.number().int().min(1).max(100).default(20),
571-
search: z.string().optional(),
572-
author: z.string().optional(),
573-
sortBy: z.enum(['title', 'author', 'price', 'createdAt']).default('createdAt'),
574-
sortOrder: z.enum(['asc', 'desc']).default('desc'),
575-
});
535+
// Query params for list endpoint.
536+
// `.merge(includeDeletedQuerySchema)` adds the `?include_deleted=true` flag used
537+
// by admin endpoints to also return soft-deleted rows. Defaults to false.
538+
export const listBooksQuerySchema = z
539+
.object({
540+
page: z.coerce.number().int().min(1).default(1),
541+
limit: z.coerce.number().int().min(1).max(100).default(20),
542+
search: z.string().optional(),
543+
author: z.string().optional(),
544+
sortBy: z.enum(['title', 'author', 'price', 'createdAt']).default('createdAt'),
545+
sortOrder: z.enum(['asc', 'desc']).default('desc'),
546+
})
547+
.merge(includeDeletedQuerySchema);
576548

577549
export type InsertBook = z.infer<typeof insertBookSchema>;
578550
export type SelectBook = z.infer<typeof selectBookSchema>;
@@ -642,7 +614,8 @@ CREATE TABLE IF NOT EXISTS "users" (
642614
"name" text NOT NULL,
643615
"role" "user_role" DEFAULT 'customer' NOT NULL,
644616
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
645-
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
617+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
618+
"deleted_at" timestamp with time zone
646619
);
647620

648621
CREATE TABLE IF NOT EXISTS "books" (
@@ -654,7 +627,8 @@ CREATE TABLE IF NOT EXISTS "books" (
654627
"stock" integer DEFAULT 0 NOT NULL,
655628
"description" text,
656629
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
657-
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
630+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
631+
"deleted_at" timestamp with time zone
658632
);
659633

660634
CREATE TABLE IF NOT EXISTS "orders" (
@@ -663,7 +637,8 @@ CREATE TABLE IF NOT EXISTS "orders" (
663637
"status" "order_status" DEFAULT 'pending' NOT NULL,
664638
"total" numeric(10, 2) NOT NULL,
665639
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
666-
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
640+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
641+
"deleted_at" timestamp with time zone
667642
);
668643

669644
CREATE TABLE IF NOT EXISTS "order_items" (
@@ -681,6 +656,11 @@ CREATE INDEX IF NOT EXISTS "books_author_idx" ON "books" USING btree ("author");
681656
CREATE INDEX IF NOT EXISTS "orders_user_id_idx" ON "orders" USING btree ("user_id");
682657
CREATE INDEX IF NOT EXISTS "order_items_order_id_idx" ON "order_items" USING btree ("order_id");
683658
CREATE INDEX IF NOT EXISTS "order_items_book_id_idx" ON "order_items" USING btree ("book_id");
659+
660+
-- Soft-delete indexes (generated when database.softDeleteDefault is true)
661+
CREATE INDEX IF NOT EXISTS "users_deleted_at_idx" ON "users" USING btree ("deleted_at");
662+
CREATE INDEX IF NOT EXISTS "books_deleted_at_idx" ON "books" USING btree ("deleted_at");
663+
CREATE INDEX IF NOT EXISTS "orders_deleted_at_idx" ON "orders" USING btree ("deleted_at");
684664
```
685665

686666
### Step 10 -- Install Dependencies
@@ -694,7 +674,8 @@ cd api && pnpm add -D drizzle-kit drizzle-zod
694674

695675
| Artifact | Location | Description |
696676
|---|---|---|
697-
| Schema files | `api/src/db/schema/*.ts` | One Drizzle pgTable per model |
677+
| Schema files | `api/src/db/schema/*.ts` | One Drizzle pgTable per model (incl. `deleted_at`) |
678+
| Soft-delete helper | `api/src/db/soft-delete.ts` | `notDeleted`/`softDelete` filters (when `softDeleteDefault`) |
698679
| Enum definitions | `api/src/db/schema/enums.ts` | PostgreSQL enum types |
699680
| Schema barrel | `api/src/db/schema/index.ts` | Re-exports all schemas |
700681
| Soft-delete helpers | `api/src/db/soft-delete.ts` | `notDeleted`, `withSoftDeleteFilter`, `softDelete`, `restore` (when `softDeleteDefault`) |
@@ -721,5 +702,5 @@ cd api && pnpm add -D drizzle-kit drizzle-zod
721702
- **Zod inference from Drizzle**: Single source of truth -- change the Drizzle schema, the Zod validators follow automatically.
722703
- **UUID primary keys**: Default for all tables. Avoids sequential ID enumeration attacks and works well in distributed systems.
723704
- **Soft timestamps**: `createdAt` and `updatedAt` on every table. `$onUpdate` handles automatic timestamp refresh.
724-
- **Soft deletes by default**: when `database.softDeleteDefault` is true, deletable tables get a nullable `deletedAt` column and a shared `soft-delete.ts` helper module instead of issuing hard `DELETE`s. Reads filter with `notDeleted`/`withSoftDeleteFilter`, deletes stamp `deletedAt` via `softDelete`, and `?include_deleted=true` exposes deleted rows to admin endpoints. Set the flag to `false` to opt back into hard deletes.
705+
- **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.
725706
- **snake_case columns**: PostgreSQL convention. Drizzle maps to camelCase in TypeScript automatically.

0 commit comments

Comments
 (0)