@@ -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' ;
398386import 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>;
547514import { createInsertSchema , createSelectSchema } from ' drizzle-zod' ;
548515import { z } from ' zod' ;
549516import { books } from ' ../db/schema/books' ;
517+ import { includeDeletedQuerySchema } from ' ../db/soft-delete' ;
550518
551519export const insertBookSchema = createInsertSchema (books , {
552520 title: z .string ().min (1 ).max (500 ),
@@ -564,15 +532,19 @@ export const selectBookSchema = createSelectSchema(books);
564532
565533export 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
577549export type InsertBook = z .infer <typeof insertBookSchema >;
578550export 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
648621CREATE 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
660634CREATE 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
669644CREATE TABLE IF NOT EXISTS " order_items" (
@@ -681,6 +656,11 @@ CREATE INDEX IF NOT EXISTS "books_author_idx" ON "books" USING btree ("author");
681656CREATE INDEX IF NOT EXISTS " orders_user_id_idx" ON " orders" USING btree (" user_id" );
682657CREATE INDEX IF NOT EXISTS " order_items_order_id_idx" ON " order_items" USING btree (" order_id" );
683658CREATE 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