From 8b807ae1086f44b1508194abe7975cc4ae558fd0 Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Sat, 6 Jun 2026 22:03:07 -0400 Subject: [PATCH] fix(soft-delete): make softDelete() callable with a typed db client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit softDelete()'s `db` parameter was typed `PgDatabase`, which pins the schema generics to their `Record` defaults. Because that schema generic is invariant, passing a real, schema-typed Drizzle client (e.g. `PostgresJsDatabase`) fails to compile with TS2379 — so the documented `await softDelete(db, table, id)` pattern could not actually be used in a generated project. Widen the signature to infer the query-result and schema generics, which accepts any concrete Drizzle client while staying fully backward compatible (broadening a constraint never breaks existing callers). Add templates/snippets/node/src/routes/users.ts: a typechecked Hono route that exercises softDelete(), softDeleteFilter(), and includeDeletedQuerySchema against the real db client, so the typecheck suite now covers an actual soft-delete invocation and this regression cannot return unnoticed. Refs #53 (soft-delete feature landed in #103). Co-Authored-By: Claude Opus 4.8 --- templates/snippets/node/src/routes/users.ts | 61 +++++++++++++++++++ .../snippets/shared/src/db/soft-delete.ts | 17 +++++- 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 templates/snippets/node/src/routes/users.ts diff --git a/templates/snippets/node/src/routes/users.ts b/templates/snippets/node/src/routes/users.ts new file mode 100644 index 0000000..38a970a --- /dev/null +++ b/templates/snippets/node/src/routes/users.ts @@ -0,0 +1,61 @@ +import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/client'; +import { users } from '../db/schema'; +import { + includeDeletedQuerySchema, + softDelete, + softDeleteFilter, +} from '../db/soft-delete'; + +/** + * Example resource routes demonstrating the soft-delete pattern from + * `../db/soft-delete`. Wire them into the app where a database is available: + * + * import { usersRoutes } from './routes/users'; + * app.route('/users', usersRoutes); + * + * Soft-delete rules of thumb: + * - Reads filter out deleted rows with `softDeleteFilter` (or `notDeleted`). + * - `?include_deleted=true` lets admin callers see soft-deleted rows; the + * `includeDeletedQuerySchema` parses that flag into a boolean. + * - DELETE stamps `deleted_at` via `softDelete` instead of removing the row. + */ +export const usersRoutes = new Hono() + // GET /users — list active users. Admins pass ?include_deleted=true for all. + .get('/', async (c) => { + const { include_deleted } = includeDeletedQuerySchema.parse(c.req.query()); + const rows = await db + .select() + .from(users) + .where(softDeleteFilter(users, include_deleted)); + return c.json({ users: rows, total: rows.length }); + }) + + // GET /users/:id — fetch one user. Soft-deleted rows return 404 unless + // ?include_deleted=true is supplied. + .get('/:id', async (c) => { + const id = c.req.param('id'); + const { include_deleted } = includeDeletedQuerySchema.parse(c.req.query()); + const rows = await db + .select() + .from(users) + .where(softDeleteFilter(users, include_deleted, eq(users.id, id))); + const user = rows[0]; + if (!user) { + return c.json({ error: 'User not found' }, 404); + } + return c.json(user); + }) + + // DELETE /users/:id — soft delete: stamps deleted_at instead of removing. + // `softDelete` returns the number of rows transitioned to deleted (0 or 1), + // so a missing or already-deleted row resolves to a 404. + .delete('/:id', async (c) => { + const id = c.req.param('id'); + const affected = await softDelete(db, users, id); + if (affected === 0) { + return c.json({ error: 'User not found' }, 404); + } + return c.json({ message: 'User deleted' }); + }); diff --git a/templates/snippets/shared/src/db/soft-delete.ts b/templates/snippets/shared/src/db/soft-delete.ts index 013c1a1..6a1ea7a 100644 --- a/templates/snippets/shared/src/db/soft-delete.ts +++ b/templates/snippets/shared/src/db/soft-delete.ts @@ -1,4 +1,10 @@ -import { and, eq, isNull, type SQL } from 'drizzle-orm'; +import { + and, + eq, + isNull, + type SQL, + type TablesRelationalConfig, +} from 'drizzle-orm'; import type { PgColumn, PgDatabase, @@ -92,8 +98,13 @@ export function softDeleteFilter( * const affected = await softDelete(db, users, id); * if (affected === 0) throw new NotFoundError('User', id); */ -export async function softDelete( - db: PgDatabase, +export async function softDelete< + TTable extends SoftDeleteTable, + TQueryResult extends PgQueryResultHKT, + TFullSchema extends Record, + TSchema extends TablesRelationalConfig, +>( + db: PgDatabase, table: TTable, id: string | number, ): Promise {