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(); + }); +});