Skip to content

Commit e1de208

Browse files
PAMulliganclaude
andcommitted
feat(database-design): add soft-delete query filters and helpers
Implements the soft-delete pattern that pipeline.config.json enables by default (database.softDeleteDefault: true) but was previously unimplemented. - Add nullable deletedAt column to the shared users schema template - Add shared db/soft-delete.ts with notDeleted, withSoftDeleteFilter, softDelete, and restore helpers (generic over postgres-js and neon-http) - Add a node users route demonstrating DELETE -> softDelete and the ?include_deleted=true admin query param - Add tests asserting generated SQL for each helper - Document soft-delete column/helper/route generation in the database-design skill, gated on softDeleteDefault Closes #53 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2f4c406 commit e1de208

5 files changed

Lines changed: 404 additions & 0 deletions

File tree

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

Lines changed: 139 additions & 0 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 marker — only emitted when `database.softDeleteDefault` is
198+
// true in pipeline.config.json. Null = active; a timestamp = soft-deleted.
199+
deletedAt: timestamp('deleted_at', { withTimezone: true }),
196200
},
197201
(table) => [
198202
uniqueIndex('users_email_idx').on(table.email),
203+
// Partial index keeps the common "active rows" lookups fast.
204+
index('users_deleted_at_idx').on(table.deletedAt),
199205
],
200206
);
201207

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

358+
### Step 6.5 -- Soft Delete Columns & Helpers (when enabled)
359+
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`.
364+
365+
```typescript
366+
// Read the flag from pipeline.config.json before generating schemas
367+
import { readFile } from 'fs/promises';
368+
import path from 'path';
369+
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;
377+
```
378+
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.
384+
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:
387+
388+
```typescript
389+
// 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';
398+
import type {
399+
PgColumn,
400+
PgDatabase,
401+
PgQueryResultHKT,
402+
PgTable,
403+
PgUpdateSetSource,
404+
} from 'drizzle-orm/pg-core';
405+
406+
export interface SoftDeletable {
407+
deletedAt: Column;
408+
}
409+
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 {
414+
return isNull(table.deletedAt);
415+
}
416+
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,
425+
): SQL | undefined {
426+
if (includeDeleted) {
427+
return extra;
428+
}
429+
const active = notDeleted(table);
430+
return extra ? and(active, extra) : active;
431+
}
432+
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));
443+
}
444+
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
452+
.update(table)
453+
.set({ deletedAt: null } as PgUpdateSetSource<SoftDeletableTable>)
454+
.where(eq(table.id, id));
455+
}
456+
```
457+
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();
482+
```
483+
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`.
487+
352488
### Step 7 -- Generate Barrel Export
353489

354490
```typescript
@@ -379,6 +515,7 @@ export const insertUserSchema = createInsertSchema(users, {
379515
id: true,
380516
createdAt: true,
381517
updatedAt: true,
518+
deletedAt: true, // server-managed soft-delete marker (when softDeleteDefault)
382519
passwordHash: true,
383520
});
384521

@@ -560,6 +697,7 @@ cd api && pnpm add -D drizzle-kit drizzle-zod
560697
| Schema files | `api/src/db/schema/*.ts` | One Drizzle pgTable per model |
561698
| Enum definitions | `api/src/db/schema/enums.ts` | PostgreSQL enum types |
562699
| Schema barrel | `api/src/db/schema/index.ts` | Re-exports all schemas |
700+
| Soft-delete helpers | `api/src/db/soft-delete.ts` | `notDeleted`, `withSoftDeleteFilter`, `softDelete`, `restore` (when `softDeleteDefault`) |
563701
| DB connection | `api/src/db/index.ts` | Drizzle client setup |
564702
| Zod validators | `api/src/validators/*.ts` | Insert/select/update schemas |
565703
| Drizzle config | `api/drizzle.config.ts` | Migration configuration |
@@ -583,4 +721,5 @@ cd api && pnpm add -D drizzle-kit drizzle-zod
583721
- **Zod inference from Drizzle**: Single source of truth -- change the Drizzle schema, the Zod validators follow automatically.
584722
- **UUID primary keys**: Default for all tables. Avoids sequential ID enumeration attacks and works well in distributed systems.
585723
- **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.
586725
- **snake_case columns**: PostgreSQL convention. Drizzle maps to camelCase in TypeScript automatically.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Hono } from 'hono';
2+
import { eq } from 'drizzle-orm';
3+
import { db } from '../db/client.js';
4+
import { users } from '../db/schema.js';
5+
import { restore, softDelete, withSoftDeleteFilter } from '../db/soft-delete.js';
6+
7+
/**
8+
* Example resource routes demonstrating the soft-delete pattern.
9+
*
10+
* Wire these into the app where a database is available:
11+
*
12+
* import { usersRoutes } from './routes/users';
13+
* app.route('/users', usersRoutes);
14+
*
15+
* Soft-delete rules of thumb:
16+
* - Reads filter out deleted rows with `withSoftDeleteFilter` / `notDeleted`.
17+
* - `?include_deleted=true` lets admin callers see soft-deleted rows.
18+
* - DELETE stamps `deleted_at` via `softDelete` instead of removing the row.
19+
* - A restore endpoint clears `deleted_at` via `restore`.
20+
*/
21+
export const usersRoutes = new Hono()
22+
// GET /users — list active users. Admins pass ?include_deleted=true for all.
23+
.get('/', async (c) => {
24+
const includeDeleted = c.req.query('include_deleted') === 'true';
25+
const rows = await db
26+
.select()
27+
.from(users)
28+
.where(withSoftDeleteFilter(users, includeDeleted));
29+
return c.json({ users: rows, total: rows.length });
30+
})
31+
32+
// GET /users/:id — fetch one user. Soft-deleted rows return 404 unless
33+
// ?include_deleted=true is supplied.
34+
.get('/:id', async (c) => {
35+
const id = c.req.param('id');
36+
const includeDeleted = c.req.query('include_deleted') === 'true';
37+
const rows = await db
38+
.select()
39+
.from(users)
40+
.where(withSoftDeleteFilter(users, includeDeleted, eq(users.id, id)));
41+
const user = rows[0];
42+
if (!user) {
43+
return c.json({ error: 'User not found' }, 404);
44+
}
45+
return c.json(user);
46+
})
47+
48+
// DELETE /users/:id — soft delete: stamps deleted_at instead of removing.
49+
.delete('/:id', async (c) => {
50+
const id = c.req.param('id');
51+
const [deleted] = await softDelete(db, users, id).returning();
52+
if (!deleted) {
53+
return c.json({ error: 'User not found' }, 404);
54+
}
55+
return c.json({ message: 'User deleted' });
56+
})
57+
58+
// POST /users/:id/restore — admin action to undo a soft delete.
59+
.post('/:id/restore', async (c) => {
60+
const id = c.req.param('id');
61+
const [restored] = await restore(db, users, id).returning();
62+
if (!restored) {
63+
return c.json({ error: 'User not found' }, 404);
64+
}
65+
return c.json(restored);
66+
});

templates/snippets/shared/src/db/schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,8 @@ export const users = pgTable('users', {
66
name: text('name').notNull(),
77
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
88
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
9+
// Soft-delete marker. Null means the row is active; a timestamp marks when it
10+
// was soft-deleted. Filter active rows with `notDeleted(users)` from
11+
// ./soft-delete and remove rows with `softDelete(db, users, id)`.
12+
deletedAt: timestamp('deleted_at', { withTimezone: true }),
913
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import {
2+
and,
3+
eq,
4+
isNull,
5+
type Column,
6+
type SQL,
7+
type TablesRelationalConfig,
8+
} from 'drizzle-orm';
9+
import type {
10+
PgColumn,
11+
PgDatabase,
12+
PgQueryResultHKT,
13+
PgTable,
14+
PgUpdateSetSource,
15+
} from 'drizzle-orm/pg-core';
16+
17+
/**
18+
* Soft-delete helpers.
19+
*
20+
* Soft deletes are enabled by default in `pipeline.config.json`
21+
* (`database.softDeleteDefault: true`). Tables opt in by adding a nullable
22+
* `deletedAt` timestamp column (see `users` in ./schema). A null `deletedAt`
23+
* means the row is active; a timestamp marks when it was soft-deleted.
24+
*
25+
* Instead of issuing `DELETE`, callers stamp `deletedAt` so rows can be
26+
* filtered out of normal reads and optionally restored later. Use `notDeleted`
27+
* (or `withSoftDeleteFilter` for admin `?include_deleted=true` access) in every
28+
* read query, and `softDelete` / `restore` for writes.
29+
*/
30+
31+
/**
32+
* Minimal shape of a table that supports soft deletes: it must expose a
33+
* `deletedAt` column. This is all `notDeleted` / `withSoftDeleteFilter` need.
34+
*/
35+
export interface SoftDeletable {
36+
deletedAt: Column;
37+
}
38+
39+
/**
40+
* A soft-deletable table that also exposes an `id` primary key. Required by the
41+
* `softDelete` / `restore` helpers, which target a single row by id.
42+
*/
43+
export type SoftDeletableTable = PgTable & SoftDeletable & { id: PgColumn };
44+
45+
/**
46+
* Drizzle `where` filter that excludes soft-deleted rows (`deleted_at IS NULL`).
47+
*
48+
* @example
49+
* const active = await db.select().from(users).where(notDeleted(users));
50+
*/
51+
export function notDeleted(table: SoftDeletable): SQL {
52+
return isNull(table.deletedAt);
53+
}
54+
55+
/**
56+
* Build a `where` clause that hides soft-deleted rows unless they are explicitly
57+
* requested — e.g. an admin endpoint passing `?include_deleted=true`. An
58+
* optional `extra` filter (such as `eq(users.id, id)`) is combined with the
59+
* soft-delete filter via `AND`.
60+
*
61+
* Returns `undefined` only when `includeDeleted` is true and no `extra` filter
62+
* is supplied, which Drizzle treats as "no filter".
63+
*
64+
* @example
65+
* db.select().from(users)
66+
* .where(withSoftDeleteFilter(users, includeDeleted, eq(users.id, id)));
67+
*/
68+
export function withSoftDeleteFilter(
69+
table: SoftDeletable,
70+
includeDeleted: boolean,
71+
extra?: SQL,
72+
): SQL | undefined {
73+
if (includeDeleted) {
74+
return extra;
75+
}
76+
const active = notDeleted(table);
77+
return extra ? and(active, extra) : active;
78+
}
79+
80+
/**
81+
* Soft-delete a single row by id: sets `deletedAt` to the current time instead
82+
* of removing the row. Returns the Drizzle update query so callers can chain
83+
* `.returning()` and inspect whether a row was affected (404 vs success).
84+
*
85+
* @example
86+
* const [deleted] = await softDelete(db, users, id).returning();
87+
* if (!deleted) return c.json({ error: 'Not found' }, 404);
88+
*/
89+
export function softDelete<
90+
TQueryResult extends PgQueryResultHKT,
91+
TFullSchema extends Record<string, unknown>,
92+
TSchema extends TablesRelationalConfig,
93+
>(
94+
db: PgDatabase<TQueryResult, TFullSchema, TSchema>,
95+
table: SoftDeletableTable,
96+
id: string,
97+
) {
98+
return db
99+
.update(table)
100+
.set({ deletedAt: new Date() } as PgUpdateSetSource<SoftDeletableTable>)
101+
.where(eq(table.id, id));
102+
}
103+
104+
/**
105+
* Restore a previously soft-deleted row by clearing its `deletedAt`. Returns the
106+
* Drizzle update query so callers can chain `.returning()`.
107+
*
108+
* @example
109+
* const [restored] = await restore(db, users, id).returning();
110+
*/
111+
export function restore<
112+
TQueryResult extends PgQueryResultHKT,
113+
TFullSchema extends Record<string, unknown>,
114+
TSchema extends TablesRelationalConfig,
115+
>(
116+
db: PgDatabase<TQueryResult, TFullSchema, TSchema>,
117+
table: SoftDeletableTable,
118+
id: string,
119+
) {
120+
return db
121+
.update(table)
122+
.set({ deletedAt: null } as PgUpdateSetSource<SoftDeletableTable>)
123+
.where(eq(table.id, id));
124+
}

0 commit comments

Comments
 (0)