diff --git a/.claude/skills/database-design/SKILL.md b/.claude/skills/database-design/SKILL.md
index fd9a1dc..2071a24 100644
--- a/.claude/skills/database-design/SKILL.md
+++ b/.claude/skills/database-design/SKILL.md
@@ -173,6 +173,7 @@ import {
text,
timestamp,
uniqueIndex,
+ index,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { userRoleEnum } from './enums';
@@ -193,9 +194,14 @@ export const users = pgTable(
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
+ // Soft delete: NULL = live, timestamp = deleted. Added to every table when
+ // `database.softDeleteDefault` is true in pipeline.config.json (Step 6a).
+ deletedAt: timestamp('deleted_at', { withTimezone: true }),
},
(table) => [
uniqueIndex('users_email_idx').on(table.email),
+ // Partial index keeps lookups of live rows fast even with many deleted rows.
+ index('users_deleted_at_idx').on(table.deletedAt),
],
);
@@ -349,6 +355,103 @@ export const orderItemsRelations = relations(orderItems, ({ one }) => ({
}));
```
+### Step 6a -- Soft Delete Columns (when enabled)
+
+Read `database.softDeleteDefault` from `.claude/pipeline.config.json`. When it is
+`true` (the default), **every** generated table gets a nullable `deleted_at`
+column plus an index, and a shared soft-delete helper module is generated.
+
+```typescript
+// Add to the column block of every pgTable (after createdAt/updatedAt):
+deletedAt: timestamp('deleted_at', { withTimezone: true }),
+```
+
+```typescript
+// Add to the index block of every table:
+index('
_deleted_at_idx').on(table.deletedAt),
+```
+
+`deleted_at` is intentionally **nullable with no default** — `NULL` means the row
+is live, a timestamp means it was soft-deleted. Do not add `.notNull()` or a
+default. Join/pivot tables that are physically deleted via `ON DELETE CASCADE`
+(e.g. `order_items`) may opt out — only add `deleted_at` to top-level resources.
+
+Then generate the shared helper module once per project. It provides the query
+filter and the `softDelete()` mutation consumed by the service layer in Phase 3
+(`route-generation`):
+
+```typescript
+// api/src/db/soft-delete.ts
+import { and, eq, isNull, type SQL } from 'drizzle-orm';
+import type {
+ PgColumn,
+ PgDatabase,
+ PgQueryResultHKT,
+ PgTable,
+ PgUpdateSetSource,
+} from 'drizzle-orm/pg-core';
+import { z } from 'zod';
+
+export interface SoftDeleteColumns {
+ id: PgColumn;
+ deletedAt: PgColumn;
+}
+export type SoftDeleteTable = PgTable & SoftDeleteColumns;
+
+/** `WHERE deleted_at IS NULL` — add to the where clause of every read query. */
+export function notDeleted(table: SoftDeleteColumns): SQL {
+ return isNull(table.deletedAt);
+}
+
+/** AND the not-deleted filter with extra conditions (undefined ones ignored). */
+export function withNotDeleted(
+ table: SoftDeleteColumns,
+ ...conditions: Array
+): SQL | undefined {
+ return and(notDeleted(table), ...conditions);
+}
+
+/** Build a where clause honouring the `?include_deleted=true` admin flag. */
+export function softDeleteFilter(
+ table: SoftDeleteColumns,
+ includeDeleted: boolean,
+ ...conditions: Array
+): SQL | undefined {
+ return includeDeleted
+ ? and(...conditions)
+ : and(notDeleted(table), ...conditions);
+}
+
+/** Stamp deleted_at instead of deleting. Returns rows affected (0 or 1). */
+export async function softDelete(
+ db: PgDatabase,
+ table: TTable,
+ id: string | number,
+): Promise {
+ const result = await db
+ .update(table)
+ .set({ deletedAt: new Date() } as unknown as PgUpdateSetSource)
+ .where(and(eq(table.id, id), notDeleted(table)))
+ .returning({ id: table.id });
+ return result.length;
+}
+
+/** Query-param schema: `?include_deleted=true` for admin endpoints. */
+export const includeDeletedQuerySchema = z.object({
+ include_deleted: z
+ .enum(['true', 'false'])
+ .optional()
+ .default('false')
+ .transform((value) => value === 'true'),
+});
+export type IncludeDeletedQuery = z.infer;
+```
+
+This module is also shipped as a template snippet
+(`templates/snippets/shared/src/db/soft-delete.ts`) and is copied automatically by
+`setup-project.sh`. When `softDeleteDefault` is `false`, omit the `deleted_at`
+columns and do not generate this module.
+
### Step 7 -- Generate Barrel Export
```typescript
@@ -410,6 +513,7 @@ export type UpdateUser = z.infer;
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { z } from 'zod';
import { books } from '../db/schema/books';
+import { includeDeletedQuerySchema } from '../db/soft-delete';
export const insertBookSchema = createInsertSchema(books, {
title: z.string().min(1).max(500),
@@ -427,15 +531,19 @@ export const selectBookSchema = createSelectSchema(books);
export const updateBookSchema = insertBookSchema.partial();
-// Query params for list endpoint
-export const listBooksQuerySchema = z.object({
- page: z.coerce.number().int().min(1).default(1),
- limit: z.coerce.number().int().min(1).max(100).default(20),
- search: z.string().optional(),
- author: z.string().optional(),
- sortBy: z.enum(['title', 'author', 'price', 'createdAt']).default('createdAt'),
- sortOrder: z.enum(['asc', 'desc']).default('desc'),
-});
+// Query params for list endpoint.
+// `.merge(includeDeletedQuerySchema)` adds the `?include_deleted=true` flag used
+// by admin endpoints to also return soft-deleted rows. Defaults to false.
+export const listBooksQuerySchema = z
+ .object({
+ page: z.coerce.number().int().min(1).default(1),
+ limit: z.coerce.number().int().min(1).max(100).default(20),
+ search: z.string().optional(),
+ author: z.string().optional(),
+ sortBy: z.enum(['title', 'author', 'price', 'createdAt']).default('createdAt'),
+ sortOrder: z.enum(['asc', 'desc']).default('desc'),
+ })
+ .merge(includeDeletedQuerySchema);
export type InsertBook = z.infer;
export type SelectBook = z.infer;
@@ -505,7 +613,8 @@ CREATE TABLE IF NOT EXISTS "users" (
"name" text NOT NULL,
"role" "user_role" DEFAULT 'customer' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
- "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "deleted_at" timestamp with time zone
);
CREATE TABLE IF NOT EXISTS "books" (
@@ -517,7 +626,8 @@ CREATE TABLE IF NOT EXISTS "books" (
"stock" integer DEFAULT 0 NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
- "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "deleted_at" timestamp with time zone
);
CREATE TABLE IF NOT EXISTS "orders" (
@@ -526,7 +636,8 @@ CREATE TABLE IF NOT EXISTS "orders" (
"status" "order_status" DEFAULT 'pending' NOT NULL,
"total" numeric(10, 2) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
- "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "deleted_at" timestamp with time zone
);
CREATE TABLE IF NOT EXISTS "order_items" (
@@ -544,6 +655,11 @@ CREATE INDEX IF NOT EXISTS "books_author_idx" ON "books" USING btree ("author");
CREATE INDEX IF NOT EXISTS "orders_user_id_idx" ON "orders" USING btree ("user_id");
CREATE INDEX IF NOT EXISTS "order_items_order_id_idx" ON "order_items" USING btree ("order_id");
CREATE INDEX IF NOT EXISTS "order_items_book_id_idx" ON "order_items" USING btree ("book_id");
+
+-- Soft-delete indexes (generated when database.softDeleteDefault is true)
+CREATE INDEX IF NOT EXISTS "users_deleted_at_idx" ON "users" USING btree ("deleted_at");
+CREATE INDEX IF NOT EXISTS "books_deleted_at_idx" ON "books" USING btree ("deleted_at");
+CREATE INDEX IF NOT EXISTS "orders_deleted_at_idx" ON "orders" USING btree ("deleted_at");
```
### Step 10 -- Install Dependencies
@@ -557,7 +673,8 @@ cd api && pnpm add -D drizzle-kit drizzle-zod
| Artifact | Location | Description |
|---|---|---|
-| Schema files | `api/src/db/schema/*.ts` | One Drizzle pgTable per model |
+| Schema files | `api/src/db/schema/*.ts` | One Drizzle pgTable per model (incl. `deleted_at`) |
+| Soft-delete helper | `api/src/db/soft-delete.ts` | `notDeleted`/`softDelete` filters (when `softDeleteDefault`) |
| Enum definitions | `api/src/db/schema/enums.ts` | PostgreSQL enum types |
| Schema barrel | `api/src/db/schema/index.ts` | Re-exports all schemas |
| DB connection | `api/src/db/index.ts` | Drizzle client setup |
@@ -583,4 +700,5 @@ cd api && pnpm add -D drizzle-kit drizzle-zod
- **Zod inference from Drizzle**: Single source of truth -- change the Drizzle schema, the Zod validators follow automatically.
- **UUID primary keys**: Default for all tables. Avoids sequential ID enumeration attacks and works well in distributed systems.
- **Soft timestamps**: `createdAt` and `updatedAt` on every table. `$onUpdate` handles automatic timestamp refresh.
+- **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.
- **snake_case columns**: PostgreSQL convention. Drizzle maps to camelCase in TypeScript automatically.
diff --git a/.claude/skills/route-generation/SKILL.md b/.claude/skills/route-generation/SKILL.md
index 32252a4..c5c1d31 100644
--- a/.claude/skills/route-generation/SKILL.md
+++ b/.claude/skills/route-generation/SKILL.md
@@ -240,6 +240,23 @@ export const requireRole = (...roles: string[]) =>
}
await next();
});
+
+// Parses a bearer token when present but never rejects. Used on otherwise-public
+// routes that change behaviour for authenticated users -- e.g. a public list
+// endpoint that exposes soft-deleted rows to admins via ?include_deleted=true.
+export const optionalAuth = createMiddleware(async (c, next) => {
+ const authHeader = c.req.header('Authorization');
+ if (authHeader?.startsWith('Bearer ')) {
+ try {
+ const { payload } = await jwtVerify(authHeader.slice(7), JWT_SECRET);
+ c.set('userId', payload.sub as string);
+ c.set('userRole', (payload.role as string) ?? 'customer');
+ } catch {
+ // Anonymous: ignore an invalid/expired token rather than 401.
+ }
+ }
+ await next();
+});
```
### Step 4 -- Create the Service Layer
@@ -248,20 +265,26 @@ One service per resource, encapsulating all database operations:
```typescript
// api/src/services/books.service.ts
-import { eq, sql, ilike, asc, desc } from 'drizzle-orm';
+import { eq, sql, ilike, asc, desc, and } from 'drizzle-orm';
import { books } from '../db/schema';
import type { Database } from '../db';
import type { InsertBook, UpdateBook, ListBooksQuery } from '../validators/books';
import { NotFoundError, ConflictError } from '../middleware/error-handler';
+import { notDeleted, softDelete } from '../db/soft-delete';
export class BooksService {
constructor(private db: Database) {}
async list(query: ListBooksQuery) {
- const { page, limit, search, author, sortBy, sortOrder } = query;
+ const { page, limit, search, author, sortBy, sortOrder, include_deleted } = query;
const offset = (page - 1) * limit;
const conditions = [];
+ // Exclude soft-deleted rows unless an admin asked for them via
+ // ?include_deleted=true. This filter is applied to every read query.
+ if (!include_deleted) {
+ conditions.push(notDeleted(books));
+ }
if (search) {
conditions.push(ilike(books.title, `%${search}%`));
}
@@ -269,9 +292,7 @@ export class BooksService {
conditions.push(ilike(books.author, `%${author}%`));
}
- const whereClause = conditions.length
- ? sql`${sql.join(conditions, sql` AND `)}`
- : undefined;
+ const whereClause = and(...conditions);
const [{ count }] = await this.db
.select({ count: sql`count(*)::int` })
@@ -301,8 +322,9 @@ export class BooksService {
}
async getById(id: string) {
+ // Soft-deleted books are treated as not found for normal reads.
const book = await this.db.query.books.findFirst({
- where: eq(books.id, id),
+ where: and(eq(books.id, id), notDeleted(books)),
});
if (!book) throw new NotFoundError('Book', id);
return book;
@@ -322,25 +344,24 @@ export class BooksService {
async update(id: string, data: UpdateBook) {
const existing = await this.db.query.books.findFirst({
- where: eq(books.id, id),
+ where: and(eq(books.id, id), notDeleted(books)),
});
if (!existing) throw new NotFoundError('Book', id);
const [updated] = await this.db
.update(books)
.set(data)
- .where(eq(books.id, id))
+ .where(and(eq(books.id, id), notDeleted(books)))
.returning();
return updated;
}
async delete(id: string) {
- const existing = await this.db.query.books.findFirst({
- where: eq(books.id, id),
- });
- if (!existing) throw new NotFoundError('Book', id);
-
- await this.db.delete(books).where(eq(books.id, id));
+ // Soft delete: stamp deleted_at instead of removing the row. softDelete()
+ // returns 0 when the book is missing or already deleted (it guards on
+ // `deleted_at IS NULL`), which we surface as a 404.
+ const affected = await softDelete(this.db, books, id);
+ if (affected === 0) throw new NotFoundError('Book', id);
}
}
```
@@ -353,7 +374,7 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import type { AppEnv } from '../app';
import { BooksService } from '../services/books.service';
-import { requireAuth, requireRole } from '../middleware/auth';
+import { requireAuth, requireRole, optionalAuth } from '../middleware/auth';
import {
insertBookSchema,
updateBookSchema,
@@ -364,14 +385,22 @@ import { uuidParamSchema } from '../validators';
export function booksRoutes() {
const router = new Hono();
- // GET /books -- public, paginated list
+ // GET /books -- public, paginated list.
+ // listBooksQuerySchema includes `?include_deleted=true`, but only admins may
+ // use it: non-admins always get live rows only. Drop the flag unless the
+ // caller is an admin so soft-deleted rows are never exposed publicly.
router.get(
'/',
+ optionalAuth,
zValidator('query', listBooksQuerySchema),
async (c) => {
const query = c.req.valid('query');
+ const isAdmin = c.get('userRole') === 'admin';
const service = new BooksService(c.get('db'));
- const result = await service.list(query);
+ const result = await service.list({
+ ...query,
+ include_deleted: query.include_deleted && isAdmin,
+ });
return c.json(result);
},
);
@@ -418,7 +447,9 @@ export function booksRoutes() {
},
);
- // DELETE /books/:id -- admin only
+ // DELETE /books/:id -- admin only.
+ // Soft delete: service.delete() stamps deleted_at via softDelete() rather than
+ // physically removing the row. The response is still 204 No Content.
router.delete(
'/:id',
requireAuth,
diff --git a/.claude/skills/tdd-from-schema/SKILL.md b/.claude/skills/tdd-from-schema/SKILL.md
index dcf4ad0..aa1254f 100644
--- a/.claude/skills/tdd-from-schema/SKILL.md
+++ b/.claude/skills/tdd-from-schema/SKILL.md
@@ -579,9 +579,12 @@ describe('Books API', () => {
});
});
- // --- DELETE BOOK ---
+ // --- DELETE BOOK (soft delete) ---
+ // When database.softDeleteDefault is true, DELETE stamps deleted_at instead of
+ // physically removing the row. Deleted books disappear from normal reads (404)
+ // but remain visible to admins via ?include_deleted=true.
describe('DELETE /books/:id', () => {
- it('should delete a book when authenticated as admin', async () => {
+ it('should soft-delete a book when authenticated as admin', async () => {
const book = await createTestBook();
const admin = await createTestAdmin();
const token = await generateTestToken(admin.id, 'admin');
@@ -593,11 +596,62 @@ describe('Books API', () => {
expect(res.status).toBe(204);
- // Verify it is gone
+ // Hidden from normal reads
const getRes = await app.request(`/books/${book.id}`);
expect(getRes.status).toBe(404);
});
+ it('should hide soft-deleted books from the default list', async () => {
+ const book = await createTestBook();
+ const admin = await createTestAdmin();
+ const token = await generateTestToken(admin.id, 'admin');
+
+ await app.request(`/books/${book.id}`, {
+ method: 'DELETE',
+ headers: authHeader(token),
+ });
+
+ const listRes = await app.request('/books');
+ const body = (await listRes.json()) as { data: Array<{ id: string }> };
+ expect(body.data.find((b) => b.id === book.id)).toBeUndefined();
+ });
+
+ it('should still expose soft-deleted books to admins via ?include_deleted=true', async () => {
+ const book = await createTestBook();
+ const admin = await createTestAdmin();
+ const token = await generateTestToken(admin.id, 'admin');
+
+ await app.request(`/books/${book.id}`, {
+ method: 'DELETE',
+ headers: authHeader(token),
+ });
+
+ const listRes = await app.request('/books?include_deleted=true', {
+ headers: authHeader(token),
+ });
+ const body = (await listRes.json()) as { data: Array<{ id: string }> };
+ expect(body.data.find((b) => b.id === book.id)).toBeDefined();
+ });
+
+ it('should return 404 when deleting an already-deleted book', async () => {
+ const book = await createTestBook();
+ const admin = await createTestAdmin();
+ const token = await generateTestToken(admin.id, 'admin');
+
+ const first = await app.request(`/books/${book.id}`, {
+ method: 'DELETE',
+ headers: authHeader(token),
+ });
+ expect(first.status).toBe(204);
+
+ // Second delete is idempotent: the row is already deleted, so 404.
+ const second = await app.request(`/books/${book.id}`, {
+ method: 'DELETE',
+ headers: authHeader(token),
+ });
+ expect(second.status).toBe(404);
+ });
+
it('should return 404 for non-existent book', async () => {
const admin = await createTestAdmin();
const token = await generateTestToken(admin.id, 'admin');
diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh
index 0491092..2f2869e 100644
--- a/scripts/setup-project.sh
+++ b/scripts/setup-project.sh
@@ -197,6 +197,7 @@ DEOF
copy_file "$TEMPLATES_DIR/snippets/shared/src/db/schema.ts" "$API_DIR/src/db/schema.ts"
copy_file "$TEMPLATES_DIR/snippets/shared/src/db/client.ts" "$API_DIR/src/db/client.ts"
+copy_file "$TEMPLATES_DIR/snippets/shared/src/db/soft-delete.ts" "$API_DIR/src/db/soft-delete.ts"
copy_file "$TEMPLATES_DIR/snippets/shared/src/db/seed.ts" "$API_DIR/src/db/seed.ts"
if [[ "$PLATFORM" == "cloudflare" ]]; then
@@ -208,6 +209,7 @@ else
fi
copy_file "$TEMPLATES_DIR/snippets/shared/tests/setup.ts" "$API_DIR/tests/setup.ts"
+copy_file "$TEMPLATES_DIR/snippets/shared/tests/soft-delete.test.ts" "$API_DIR/tests/soft-delete.test.ts"
if [[ "$PLATFORM" == "cloudflare" ]]; then
copy_file "$TEMPLATES_DIR/snippets/cloudflare/tests/unit/health.test.ts" "$API_DIR/tests/unit/health.test.ts"
diff --git a/templates/snippets/shared/src/db/schema.ts b/templates/snippets/shared/src/db/schema.ts
index d10468d..c5d6ed6 100644
--- a/templates/snippets/shared/src/db/schema.ts
+++ b/templates/snippets/shared/src/db/schema.ts
@@ -6,4 +6,7 @@ export const users = pgTable('users', {
name: text('name').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
+ // Soft-delete marker. NULL = row is live; a timestamp = row was soft-deleted.
+ // Read queries must filter `WHERE deleted_at IS NULL` (see ./soft-delete.ts).
+ deletedAt: timestamp('deleted_at', { withTimezone: true }),
});
diff --git a/templates/snippets/shared/src/db/soft-delete.ts b/templates/snippets/shared/src/db/soft-delete.ts
new file mode 100644
index 0000000..013c1a1
--- /dev/null
+++ b/templates/snippets/shared/src/db/soft-delete.ts
@@ -0,0 +1,127 @@
+import { and, eq, isNull, type SQL } from 'drizzle-orm';
+import type {
+ PgColumn,
+ PgDatabase,
+ PgQueryResultHKT,
+ PgTable,
+ PgUpdateSetSource,
+} from 'drizzle-orm/pg-core';
+import { z } from 'zod';
+
+/**
+ * Soft-delete utilities.
+ *
+ * The pipeline enables soft deletes by default (`database.softDeleteDefault` in
+ * `.claude/pipeline.config.json`). Every soft-deletable table carries a nullable
+ * `deleted_at` timestamp column. A row is "alive" while `deleted_at IS NULL` and
+ * "deleted" once a timestamp is written. Records are never physically removed by
+ * DELETE endpoints — `softDelete()` stamps `deleted_at` instead.
+ *
+ * Use {@link notDeleted} / {@link withNotDeleted} in every read query so deleted
+ * rows are filtered out automatically, and {@link softDelete} in DELETE handlers.
+ */
+
+/**
+ * Minimal column shape a table must expose to participate in soft deletes:
+ * an `id` primary key and a nullable `deletedAt` timestamp column.
+ */
+export interface SoftDeleteColumns {
+ id: PgColumn;
+ deletedAt: PgColumn;
+}
+
+/** A Drizzle pgTable that carries the soft-delete columns. */
+export type SoftDeleteTable = PgTable & SoftDeleteColumns;
+
+/**
+ * Query filter that matches only rows that have NOT been soft-deleted
+ * (`WHERE deleted_at IS NULL`). Add this to the `where` clause of every read.
+ *
+ * @example
+ * const rows = await db.select().from(users).where(notDeleted(users));
+ */
+export function notDeleted(table: SoftDeleteColumns): SQL {
+ return isNull(table.deletedAt);
+}
+
+/**
+ * Combine the not-deleted filter with any number of additional conditions,
+ * ANDed together. `undefined` conditions are ignored, mirroring Drizzle's
+ * `and()` semantics.
+ *
+ * @example
+ * db.select().from(users).where(withNotDeleted(users, eq(users.email, email)));
+ */
+export function withNotDeleted(
+ table: SoftDeleteColumns,
+ ...conditions: Array
+): SQL | undefined {
+ return and(notDeleted(table), ...conditions);
+}
+
+/**
+ * Build the `where` clause for a list/read query, honouring the
+ * `?include_deleted=true` admin flag. When `includeDeleted` is true the
+ * soft-delete filter is omitted so deleted rows are returned alongside live
+ * ones; otherwise deleted rows are excluded.
+ *
+ * @example
+ * const where = softDeleteFilter(users, query.include_deleted, search);
+ * await db.select().from(users).where(where);
+ */
+export function softDeleteFilter(
+ table: SoftDeleteColumns,
+ includeDeleted: boolean,
+ ...conditions: Array
+): SQL | undefined {
+ if (includeDeleted) {
+ return and(...conditions);
+ }
+ return and(notDeleted(table), ...conditions);
+}
+
+/**
+ * Soft-delete a single row by id: sets `deleted_at` to the current time instead
+ * of issuing a physical DELETE. Already-deleted rows are not touched (the
+ * `deleted_at IS NULL` guard makes the call idempotent), so the returned row
+ * count is 0 when the row is missing or already deleted and 1 on success.
+ *
+ * @returns the number of rows transitioned from alive to deleted (0 or 1).
+ *
+ * @example
+ * const affected = await softDelete(db, users, id);
+ * if (affected === 0) throw new NotFoundError('User', id);
+ */
+export async function softDelete(
+ db: PgDatabase,
+ table: TTable,
+ id: string | number,
+): Promise {
+ const result = await db
+ .update(table)
+ .set({ deletedAt: new Date() } as unknown as PgUpdateSetSource)
+ .where(and(eq(table.id, id), notDeleted(table)))
+ .returning({ id: table.id });
+
+ return result.length;
+}
+
+/**
+ * Query-param schema for admin endpoints that may opt into seeing soft-deleted
+ * rows via `?include_deleted=true`. Any value other than the literal string
+ * `"true"` (including an absent param) resolves to `false`.
+ *
+ * @example
+ * app.get('/users', zValidator('query', includeDeletedQuerySchema), (c) => {
+ * const { include_deleted } = c.req.valid('query');
+ * });
+ */
+export const includeDeletedQuerySchema = z.object({
+ include_deleted: z
+ .enum(['true', 'false'])
+ .optional()
+ .default('false')
+ .transform((value) => value === 'true'),
+});
+
+export type IncludeDeletedQuery = z.infer;
diff --git a/templates/snippets/shared/tests/soft-delete.test.ts b/templates/snippets/shared/tests/soft-delete.test.ts
new file mode 100644
index 0000000..f5d6cfd
--- /dev/null
+++ b/templates/snippets/shared/tests/soft-delete.test.ts
@@ -0,0 +1,137 @@
+import { describe, it, expect, vi } from 'vitest';
+import { eq } from 'drizzle-orm';
+import { PgDialect } from 'drizzle-orm/pg-core';
+import { users } from '../src/db/schema.js';
+import {
+ notDeleted,
+ withNotDeleted,
+ softDeleteFilter,
+ softDelete,
+ includeDeletedQuerySchema,
+} from '../src/db/soft-delete.js';
+
+const dialect = new PgDialect();
+const render = (sql: ReturnType | undefined) =>
+ sql ? dialect.sqlToQuery(sql).sql : undefined;
+
+/**
+ * A chainable stub that records the arguments passed to update/set/where and
+ * resolves `.returning()` with the configured rows. Stands in for a real
+ * Drizzle PgDatabase so soft-delete behaviour can be asserted without Postgres.
+ */
+function makeStubDb(returningRows: Array<{ id: unknown }>) {
+ const calls: { table?: unknown; set?: any; where?: any } = {};
+ const chain = {
+ set: vi.fn((values: unknown) => {
+ calls.set = values;
+ return chain;
+ }),
+ where: vi.fn((clause: unknown) => {
+ calls.where = clause;
+ return chain;
+ }),
+ returning: vi.fn(() => Promise.resolve(returningRows)),
+ };
+ const db = {
+ update: vi.fn((table: unknown) => {
+ calls.table = table;
+ return chain;
+ }),
+ };
+ return { db, chain, calls };
+}
+
+describe('soft-delete schema', () => {
+ it('users table exposes a nullable deleted_at column', () => {
+ expect(users.deletedAt).toBeDefined();
+ expect(users.deletedAt.name).toBe('deleted_at');
+ // Nullable: no NOT NULL constraint, so the soft-delete marker can be unset.
+ expect(users.deletedAt.notNull).toBe(false);
+ });
+});
+
+describe('notDeleted', () => {
+ it('produces a `deleted_at IS NULL` predicate', () => {
+ const rendered = render(notDeleted(users));
+ expect(rendered).toContain('"deleted_at"');
+ expect(rendered?.toLowerCase()).toContain('is null');
+ });
+});
+
+describe('withNotDeleted', () => {
+ it('ANDs the not-deleted filter with extra conditions', () => {
+ const rendered = render(withNotDeleted(users, eq(users.email, 'a@b.com')))!;
+ expect(rendered.toLowerCase()).toContain('is null');
+ expect(rendered).toContain('"email"');
+ expect(rendered.toLowerCase()).toContain('and');
+ });
+
+ it('returns just the not-deleted filter when no extra conditions are given', () => {
+ const rendered = render(withNotDeleted(users))!;
+ expect(rendered.toLowerCase()).toContain('is null');
+ expect(rendered).not.toContain('"email"');
+ });
+});
+
+describe('softDeleteFilter (?include_deleted handling)', () => {
+ it('excludes deleted rows by default (include_deleted = false)', () => {
+ const rendered = render(softDeleteFilter(users, false));
+ expect(rendered?.toLowerCase()).toContain('is null');
+ });
+
+ it('returns no soft-delete filter when include_deleted = true', () => {
+ // With no other conditions, the where clause is empty (undefined).
+ expect(softDeleteFilter(users, true)).toBeUndefined();
+ });
+
+ it('keeps caller conditions but drops the not-deleted filter when include_deleted = true', () => {
+ const rendered = render(softDeleteFilter(users, true, eq(users.email, 'a@b.com')))!;
+ expect(rendered).toContain('"email"');
+ expect(rendered.toLowerCase()).not.toContain('is null');
+ });
+});
+
+describe('softDelete', () => {
+ it('sets deleted_at to a Date instead of issuing a DELETE', async () => {
+ const { db, calls } = makeStubDb([{ id: '00000000-0000-0000-0000-000000000001' }]);
+
+ const affected = await softDelete(db as never, users, '00000000-0000-0000-0000-000000000001');
+
+ expect(db.update).toHaveBeenCalledWith(users);
+ expect(calls.set).toMatchObject({ deletedAt: expect.any(Date) });
+ expect(affected).toBe(1);
+ });
+
+ it('guards on `deleted_at IS NULL` so a missing/already-deleted row affects 0 rows', async () => {
+ const { db, calls } = makeStubDb([]);
+
+ const affected = await softDelete(db as never, users, 'missing');
+
+ expect(affected).toBe(0);
+ const renderedWhere = render(calls.where);
+ expect(renderedWhere?.toLowerCase()).toContain('is null');
+ expect(renderedWhere).toContain('"id"');
+ });
+});
+
+describe('includeDeletedQuerySchema', () => {
+ it('defaults to false when the param is absent', () => {
+ expect(includeDeletedQuerySchema.parse({})).toEqual({ include_deleted: false });
+ });
+
+ it('parses the literal string "true" as boolean true', () => {
+ expect(includeDeletedQuerySchema.parse({ include_deleted: 'true' })).toEqual({
+ include_deleted: true,
+ });
+ });
+
+ it('parses "false" as boolean false', () => {
+ expect(includeDeletedQuerySchema.parse({ include_deleted: 'false' })).toEqual({
+ include_deleted: false,
+ });
+ });
+
+ it('rejects values other than "true"/"false"', () => {
+ expect(() => includeDeletedQuerySchema.parse({ include_deleted: 'yes' })).toThrow();
+ });
+});