@kysera/soft-delete 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +573 -1065
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +9 -7
- package/src/index.ts +64 -84
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Plugin } from '@kysera/executor';
|
|
2
2
|
import { KyseraLogger } from '@kysera/core';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
@@ -78,10 +78,11 @@ interface SoftDeleteMethods<T> {
|
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
80
80
|
* Repository extended with soft delete methods
|
|
81
|
+
* Note: Repository type is from @kysera/repository
|
|
81
82
|
*/
|
|
82
|
-
type SoftDeleteRepository<Entity
|
|
83
|
+
type SoftDeleteRepository<Entity> = any & SoftDeleteMethods<Entity>;
|
|
83
84
|
/**
|
|
84
|
-
* Soft Delete Plugin for Kysera
|
|
85
|
+
* Soft Delete Plugin for Kysera
|
|
85
86
|
*
|
|
86
87
|
* This plugin implements soft delete functionality using the Method Override pattern:
|
|
87
88
|
* - Automatically filters out soft-deleted records from SELECT queries
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import {sql}from'kysely';import {silentLogger,NotFoundError}from'@kysera/core';import {z}from'zod';var
|
|
1
|
+
import {getRawDb}from'@kysera/executor';import {sql}from'kysely';import {silentLogger,NotFoundError}from'@kysera/core';import {z}from'zod';var P=z.object({deletedAtColumn:z.string().optional(),includeDeleted:z.boolean().optional(),tables:z.array(z.string()).optional(),primaryKeyColumn:z.string().optional()}),T=(y={})=>{let{deletedAtColumn:l="deleted_at",includeDeleted:m=false,tables:u,primaryKeyColumn:n="id",logger:s=silentLogger}=y;return {name:"@kysera/soft-delete",version:"0.7.0",interceptQuery(a,e){return (!u||u.includes(e.table))&&e.operation==="select"&&!e.metadata.includeDeleted&&!m?(s.debug(`Filtering soft-deleted records from ${e.table}`),a.where(`${e.table}.${l}`,"is",null)):a},extendRepository(a){let e=a;if(!("tableName"in e)||!("executor"in e))return a;if(!(!u||u.includes(e.tableName)))return s.debug(`Table ${e.tableName} does not support soft delete, skipping extension`),a;s.debug(`Extending repository for table ${e.tableName} with soft delete methods`);let r=getRawDb(e.executor);return {...e,async findAll(){return m?await r.selectFrom(e.tableName).selectAll().execute():await r.selectFrom(e.tableName).selectAll().where(l,"is",null).execute()},async findById(t){return m?await r.selectFrom(e.tableName).selectAll().where(n,"=",t).executeTakeFirst()??null:await r.selectFrom(e.tableName).selectAll().where(n,"=",t).where(l,"is",null).executeTakeFirst()??null},async softDelete(t){s.info(`Soft deleting record ${t} from ${e.tableName}`),await r.updateTable(e.tableName).set({[l]:sql`CURRENT_TIMESTAMP`}).where(n,"=",t).execute();let o=await r.selectFrom(e.tableName).selectAll().where(n,"=",t).executeTakeFirst();if(!o)throw s.warn(`Record ${t} not found in ${e.tableName} for soft delete`),new NotFoundError("Record",{id:t});return o},async restore(t){s.info(`Restoring soft-deleted record ${t} from ${e.tableName}`),await r.updateTable(e.tableName).set({[l]:null}).where(n,"=",t).execute();let o=await r.selectFrom(e.tableName).selectAll().where(n,"=",t).executeTakeFirst();if(!o)throw s.warn(`Record ${t} not found in ${e.tableName} for restore`),new NotFoundError("Record",{id:t});return o},async hardDelete(t){s.info(`Hard deleting record ${t} from ${e.tableName}`),await r.deleteFrom(e.tableName).where(n,"=",t).execute();},async findWithDeleted(t){return await r.selectFrom(e.tableName).selectAll().where(n,"=",t).executeTakeFirst()??null},async findAllWithDeleted(){return await r.selectFrom(e.tableName).selectAll().execute()},async findDeleted(){return await r.selectFrom(e.tableName).selectAll().where(l,"is not",null).execute()},async softDeleteMany(t){if(t.length===0)return [];s.info(`Soft deleting ${t.length} records from ${e.tableName}`),await r.updateTable(e.tableName).set({[l]:sql`CURRENT_TIMESTAMP`}).where(n,"in",t).execute();let o=await r.selectFrom(e.tableName).selectAll().where(n,"in",t).execute();if(o.length!==t.length){let f=o.map(c=>c[n]),w=t.filter(c=>!f.includes(c));throw s.warn(`Some records not found for soft delete: ${w.join(", ")}`),new NotFoundError("Records",{ids:w})}return o},async restoreMany(t){return t.length===0?[]:(s.info(`Restoring ${t.length} soft-deleted records from ${e.tableName}`),await r.updateTable(e.tableName).set({[l]:null}).where(n,"in",t).execute(),await r.selectFrom(e.tableName).selectAll().where(n,"in",t).execute())},async hardDeleteMany(t){t.length!==0&&(s.info(`Hard deleting ${t.length} records from ${e.tableName}`),await r.deleteFrom(e.tableName).where(n,"in",t).execute());}}}}};export{P as SoftDeleteOptionsSchema,T as softDeletePlugin};//# sourceMappingURL=index.js.map
|
|
2
2
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":["SoftDeleteOptionsSchema","z","softDeletePlugin","options","deletedAtColumn","includeDeleted","tables","primaryKeyColumn","logger","silentLogger","qb","context","repo","baseRepo","originalFindAll","originalFindById","id","sql","record","NotFoundError","ids","records","foundIds","r","missingIds"],"mappings":"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","file":"index.js","sourcesContent":["import type { Plugin, AnyQueryBuilder, Repository } from '@kysera/repository';\nimport type { SelectQueryBuilder, Kysely } from 'kysely';\nimport { sql } from 'kysely';\nimport { NotFoundError, silentLogger } from '@kysera/core';\nimport type { KyseraLogger } from '@kysera/core';\nimport { z } from 'zod';\n\n/**\n * Configuration options for the soft delete plugin.\n *\n * @example\n * ```typescript\n * const plugin = softDeletePlugin({\n * deletedAtColumn: 'deleted_at',\n * includeDeleted: false,\n * tables: ['users', 'posts'], // Only these tables support soft delete\n * primaryKeyColumn: 'id' // Default primary key column\n * })\n * ```\n */\nexport interface SoftDeleteOptions {\n /**\n * Column name for soft delete timestamp.\n *\n * @default 'deleted_at'\n */\n deletedAtColumn?: string;\n\n /**\n * Include deleted records by default in queries.\n * When false, soft-deleted records are automatically filtered out.\n *\n * @default false\n */\n includeDeleted?: boolean;\n\n /**\n * List of tables that support soft delete.\n * If not provided, all tables are assumed to support it.\n *\n * @example ['users', 'posts', 'comments']\n */\n tables?: string[];\n\n /**\n * Primary key column name used for identifying records.\n * Tables with different primary key names (uuid, user_id, etc.) can be configured.\n *\n * @default 'id'\n * @example 'uuid', 'user_id', 'post_id'\n */\n primaryKeyColumn?: string;\n\n /**\n * Logger for plugin operations.\n * Uses KyseraLogger interface from @kysera/core.\n *\n * @default silentLogger (no output)\n */\n logger?: KyseraLogger;\n}\n\n/**\n * Zod schema for SoftDeleteOptions\n * Used for validation and configuration in the kysera-cli\n */\nexport const SoftDeleteOptionsSchema = z.object({\n deletedAtColumn: z.string().optional(),\n includeDeleted: z.boolean().optional(),\n tables: z.array(z.string()).optional(),\n primaryKeyColumn: z.string().optional(),\n});\n\n/**\n * Methods added to repositories by the soft delete plugin\n */\nexport interface SoftDeleteMethods<T> {\n softDelete(id: number | string): Promise<T>;\n restore(id: number | string): Promise<T>;\n hardDelete(id: number | string): Promise<void>;\n findWithDeleted(id: number | string): Promise<T | null>;\n findAllWithDeleted(): Promise<T[]>;\n findDeleted(): Promise<T[]>;\n softDeleteMany(ids: (number | string)[]): Promise<T[]>;\n restoreMany(ids: (number | string)[]): Promise<T[]>;\n hardDeleteMany(ids: (number | string)[]): Promise<void>;\n}\n\n/**\n * Repository extended with soft delete methods\n */\nexport type SoftDeleteRepository<Entity, DB> = Repository<Entity, DB> & SoftDeleteMethods<Entity>;\n\ninterface BaseRepository {\n tableName: string;\n executor: Kysely<Record<string, unknown>>;\n findAll: () => Promise<unknown[]>;\n findById: (id: number) => Promise<unknown>;\n update: (id: number, data: Record<string, unknown>) => Promise<unknown>;\n}\n\n/**\n * Soft Delete Plugin for Kysera ORM\n *\n * This plugin implements soft delete functionality using the Method Override pattern:\n * - Automatically filters out soft-deleted records from SELECT queries\n * - Adds softDelete(), restore(), and hardDelete() methods to repositories\n * - Provides findWithDeleted() and findDeleted() utility methods\n *\n * ## Usage\n *\n * ```typescript\n * import { softDeletePlugin } from '@kysera/soft-delete'\n * import { createORM } from '@kysera/repository'\n *\n * const orm = await createORM(db, [\n * softDeletePlugin({\n * deletedAtColumn: 'deleted_at',\n * tables: ['users', 'posts']\n * })\n * ])\n *\n * const userRepo = orm.createRepository(createUserRepository)\n *\n * // Soft delete a user (sets deleted_at)\n * await userRepo.softDelete(1)\n *\n * // Find all users (excludes soft-deleted)\n * await userRepo.findAll()\n *\n * // Find including deleted\n * await userRepo.findAllWithDeleted()\n *\n * // Restore a soft-deleted user\n * await userRepo.restore(1)\n *\n * // Permanently delete (real DELETE)\n * await userRepo.hardDelete(1)\n * ```\n *\n * ## Architecture Note\n *\n * This plugin uses Method Override, not full query interception:\n * - ✅ SELECT queries are automatically filtered\n * - ❌ DELETE queries are NOT automatically converted to soft deletes\n * - Use softDelete() method explicitly instead of delete()\n *\n * This design is intentional for simplicity and explicitness.\n *\n * ## Transaction Behavior\n *\n * **IMPORTANT**: Soft delete operations respect ACID properties and work correctly with transactions:\n *\n * - ✅ **Commits with transaction**: softDelete/restore operations use the same executor\n * as other repository operations, so they commit together\n * - ✅ **Rolls back with transaction**: If a transaction is rolled back, soft delete\n * operations are also rolled back\n * - ✅ **Atomic operations**: All soft delete operations (including bulk) are atomic\n *\n * ### Correct Transaction Usage\n *\n * ```typescript\n * // ✅ CORRECT: Soft delete is part of transaction\n * await db.transaction().execute(async (trx) => {\n * const repos = createRepositories(trx) // Use transaction executor\n * await repos.users.softDelete(1)\n * await repos.posts.softDeleteMany([1, 2, 3])\n * // If transaction rolls back, both operations roll back\n * })\n * ```\n *\n * ### Cascade Soft Delete Pattern\n *\n * For related entities, you need to manually implement cascade soft delete:\n *\n * ```typescript\n * // Cascade soft delete pattern\n * await db.transaction().execute(async (trx) => {\n * const repos = createRepositories(trx)\n * const userId = 123\n *\n * // First, soft delete child records\n * const userPosts = await repos.posts.findBy({ user_id: userId })\n * await repos.posts.softDeleteMany(userPosts.map(p => p.id))\n *\n * // Then, soft delete parent\n * await repos.users.softDelete(userId)\n * })\n * ```\n *\n * @param options - Configuration options for soft delete behavior\n * @returns Plugin instance that can be used with createORM\n */\nexport const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {\n const {\n deletedAtColumn = 'deleted_at',\n includeDeleted = false,\n tables,\n primaryKeyColumn = 'id',\n logger = silentLogger,\n } = options;\n\n return {\n name: '@kysera/soft-delete',\n version: '0.5.1',\n\n /**\n * Intercept queries to automatically filter soft-deleted records.\n *\n * NOTE: This plugin uses the Method Override pattern, not full query interception.\n * - SELECT queries are automatically filtered to exclude soft-deleted records\n * - DELETE operations are NOT automatically converted to soft deletes\n * - Use the softDelete() method instead of delete() to perform soft deletes\n * - Use hardDelete() method to bypass soft delete and perform a real DELETE\n *\n * This approach is simpler and more explicit than full query interception.\n */\n interceptQuery<QB extends AnyQueryBuilder>(\n qb: QB,\n context: { operation: string; table: string; metadata: Record<string, unknown> }\n ): QB {\n // Check if table supports soft delete\n const supportsSoftDelete = !tables || tables.includes(context.table);\n\n // Only filter SELECT queries when not explicitly including deleted\n if (\n supportsSoftDelete &&\n context.operation === 'select' &&\n !context.metadata['includeDeleted'] &&\n !includeDeleted\n ) {\n logger.debug(`Filtering soft-deleted records from ${context.table}`);\n // Add WHERE deleted_at IS NULL to the query builder\n type GenericSelectQueryBuilder = SelectQueryBuilder<Record<string, unknown>, string, Record<string, unknown>>;\n return (qb as unknown as GenericSelectQueryBuilder).where(\n `${context.table}.${deletedAtColumn}` as never,\n 'is',\n null\n ) as QB;\n }\n\n // Note: DELETE operations are NOT intercepted here\n // Use softDelete() method instead of delete() to perform soft deletes\n // This is by design - method override is simpler and more explicit\n\n return qb;\n },\n\n /**\n * Extend repository with soft delete methods.\n *\n * Adds the following methods to repositories:\n * - softDelete(id): Marks record as deleted by setting deleted_at timestamp\n * - restore(id): Restores a soft-deleted record by setting deleted_at to null\n * - hardDelete(id): Permanently deletes a record (bypasses soft delete)\n * - findWithDeleted(id): Find a record including soft-deleted ones\n * - findAllWithDeleted(): Find all records including soft-deleted ones\n * - findDeleted(): Find only soft-deleted records\n * - softDeleteMany(ids): Soft delete multiple records (bulk operation)\n * - restoreMany(ids): Restore multiple soft-deleted records (bulk operation)\n * - hardDeleteMany(ids): Permanently delete multiple records (bulk operation)\n *\n * Also overrides findAll() and findById() to automatically filter out\n * soft-deleted records (unless includeDeleted option is set).\n */\n extendRepository<T extends object>(repo: T): T {\n // Type assertion is safe here as we're checking for BaseRepository properties\n const baseRepo = repo as unknown as BaseRepository;\n\n // Check if it's actually a repository (has required properties)\n if (!('tableName' in baseRepo) || !('executor' in baseRepo)) {\n return repo;\n }\n\n // Check if table supports soft delete\n const supportsSoftDelete = !tables || tables.includes(baseRepo.tableName);\n\n // If table doesn't support soft delete, return unmodified repo\n if (!supportsSoftDelete) {\n logger.debug(`Table ${baseRepo.tableName} does not support soft delete, skipping extension`);\n return repo;\n }\n\n logger.debug(`Extending repository for table ${baseRepo.tableName} with soft delete methods`);\n\n // Wrap original methods to apply soft delete filtering\n const originalFindAll = baseRepo.findAll.bind(baseRepo);\n const originalFindById = baseRepo.findById.bind(baseRepo);\n\n const extendedRepo = {\n ...baseRepo,\n\n // Override base methods to filter soft-deleted records\n async findAll(): Promise<unknown[]> {\n if (!includeDeleted) {\n const result = await baseRepo.executor\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(deletedAtColumn as never, 'is', null)\n .execute();\n return result as unknown[];\n }\n return await originalFindAll();\n },\n\n async findById(id: number): Promise<unknown> {\n if (!includeDeleted) {\n const result = await baseRepo.executor\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, '=', id as never)\n .where(deletedAtColumn as never, 'is', null)\n .executeTakeFirst();\n return result ?? null;\n }\n // When includeDeleted is true, use custom PK column if configured\n // Otherwise fall back to original method (which uses 'id')\n if (primaryKeyColumn !== 'id') {\n const result = await baseRepo.executor\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, '=', id as never)\n .executeTakeFirst();\n return result ?? null;\n }\n return await originalFindById(id);\n },\n\n async softDelete(id: number): Promise<unknown> {\n logger.info(`Soft deleting record ${id} from ${baseRepo.tableName}`);\n // Use CURRENT_TIMESTAMP directly in SQL to avoid datetime format issues\n // This works across all databases (MySQL, PostgreSQL, SQLite)\n // We bypass repository.update() to avoid Zod validation issues with RawBuilder\n await baseRepo.executor\n .updateTable(baseRepo.tableName)\n .set({ [deletedAtColumn]: sql`CURRENT_TIMESTAMP` } as never)\n .where(primaryKeyColumn as never, '=', id as never)\n .execute();\n\n // Fetch the updated record to verify it exists\n // Use custom PK column if configured\n let record;\n if (primaryKeyColumn !== 'id') {\n record = await baseRepo.executor\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, '=', id as never)\n .executeTakeFirst();\n } else {\n record = await originalFindById(id);\n }\n\n // If record not found or deleted_at not set, throw error\n if (!record) {\n logger.warn(`Record ${id} not found in ${baseRepo.tableName} for soft delete`);\n throw new NotFoundError('Record', { id });\n }\n\n return record;\n },\n\n async restore(id: number): Promise<unknown> {\n logger.info(`Restoring soft-deleted record ${id} from ${baseRepo.tableName}`);\n // Use direct executor to support custom primary key columns\n await baseRepo.executor\n .updateTable(baseRepo.tableName)\n .set({ [deletedAtColumn]: null } as never)\n .where(primaryKeyColumn as never, '=', id as never)\n .execute();\n\n // Fetch and return the restored record\n let record;\n if (primaryKeyColumn !== 'id') {\n record = await baseRepo.executor\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, '=', id as never)\n .executeTakeFirst();\n } else {\n record = await originalFindById(id);\n }\n\n if (!record) {\n logger.warn(`Record ${id} not found in ${baseRepo.tableName} for restore`);\n throw new NotFoundError('Record', { id });\n }\n\n return record;\n },\n\n async hardDelete(id: number): Promise<void> {\n logger.info(`Hard deleting record ${id} from ${baseRepo.tableName}`);\n // Direct hard delete - bypass soft delete\n await baseRepo.executor\n .deleteFrom(baseRepo.tableName)\n .where(primaryKeyColumn as never, '=', id as never)\n .execute();\n },\n\n async findWithDeleted(id: number): Promise<unknown> {\n // Use custom PK column if configured, otherwise use original method\n if (primaryKeyColumn !== 'id') {\n const result = await baseRepo.executor\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, '=', id as never)\n .executeTakeFirst();\n return result ?? null;\n }\n return await originalFindById(id);\n },\n\n async findAllWithDeleted(): Promise<unknown[]> {\n // Use original method without filtering\n return await originalFindAll();\n },\n\n async findDeleted(): Promise<unknown[]> {\n const result = await baseRepo.executor\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(deletedAtColumn as never, 'is not', null)\n .execute();\n return result as unknown[];\n },\n\n async softDeleteMany(ids: (number | string)[]): Promise<unknown[]> {\n // Handle empty arrays gracefully\n if (ids.length === 0) {\n return [];\n }\n\n logger.info(`Soft deleting ${ids.length} records from ${baseRepo.tableName}`);\n\n // Efficient bulk UPDATE query\n await baseRepo.executor\n .updateTable(baseRepo.tableName)\n .set({ [deletedAtColumn]: sql`CURRENT_TIMESTAMP` } as never)\n .where(primaryKeyColumn as never, 'in', ids as never)\n .execute();\n\n // Fetch all affected records to verify and return them\n const records = await baseRepo.executor\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, 'in', ids as never)\n .execute();\n\n // Verify all records were found\n if (records.length !== ids.length) {\n const foundIds = records.map((r: any) => r[primaryKeyColumn]);\n const missingIds = ids.filter((id) => !foundIds.includes(id));\n logger.warn(`Some records not found for soft delete: ${missingIds.join(', ')}`);\n throw new NotFoundError('Records', { ids: missingIds });\n }\n\n return records as unknown[];\n },\n\n async restoreMany(ids: (number | string)[]): Promise<unknown[]> {\n // Handle empty arrays gracefully\n if (ids.length === 0) {\n return [];\n }\n\n logger.info(`Restoring ${ids.length} soft-deleted records from ${baseRepo.tableName}`);\n\n // Efficient bulk UPDATE query to restore records\n await baseRepo.executor\n .updateTable(baseRepo.tableName)\n .set({ [deletedAtColumn]: null } as never)\n .where(primaryKeyColumn as never, 'in', ids as never)\n .execute();\n\n // Fetch all affected records to return them\n const records = await baseRepo.executor\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, 'in', ids as never)\n .execute();\n\n return records as unknown[];\n },\n\n async hardDeleteMany(ids: (number | string)[]): Promise<void> {\n // Handle empty arrays gracefully\n if (ids.length === 0) {\n return;\n }\n\n logger.info(`Hard deleting ${ids.length} records from ${baseRepo.tableName}`);\n\n // Efficient bulk DELETE query\n await baseRepo.executor\n .deleteFrom(baseRepo.tableName)\n .where(primaryKeyColumn as never, 'in', ids as never)\n .execute();\n },\n };\n\n return extendedRepo as T;\n },\n };\n};\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["SoftDeleteOptionsSchema","z","softDeletePlugin","options","deletedAtColumn","includeDeleted","tables","primaryKeyColumn","logger","silentLogger","qb","context","repo","baseRepo","rawDb","getRawDb","id","sql","record","NotFoundError","ids","records","foundIds","r","missingIds"],"mappings":"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","file":"index.js","sourcesContent":["import type { Plugin, QueryBuilderContext } from '@kysera/executor';\nimport { getRawDb } from '@kysera/executor';\nimport type { SelectQueryBuilder, Kysely } from 'kysely';\nimport { sql } from 'kysely';\nimport { NotFoundError, silentLogger } from '@kysera/core';\nimport type { KyseraLogger } from '@kysera/core';\nimport { z } from 'zod';\n\n/**\n * Configuration options for the soft delete plugin.\n *\n * @example\n * ```typescript\n * const plugin = softDeletePlugin({\n * deletedAtColumn: 'deleted_at',\n * includeDeleted: false,\n * tables: ['users', 'posts'], // Only these tables support soft delete\n * primaryKeyColumn: 'id' // Default primary key column\n * })\n * ```\n */\nexport interface SoftDeleteOptions {\n /**\n * Column name for soft delete timestamp.\n *\n * @default 'deleted_at'\n */\n deletedAtColumn?: string;\n\n /**\n * Include deleted records by default in queries.\n * When false, soft-deleted records are automatically filtered out.\n *\n * @default false\n */\n includeDeleted?: boolean;\n\n /**\n * List of tables that support soft delete.\n * If not provided, all tables are assumed to support it.\n *\n * @example ['users', 'posts', 'comments']\n */\n tables?: string[];\n\n /**\n * Primary key column name used for identifying records.\n * Tables with different primary key names (uuid, user_id, etc.) can be configured.\n *\n * @default 'id'\n * @example 'uuid', 'user_id', 'post_id'\n */\n primaryKeyColumn?: string;\n\n /**\n * Logger for plugin operations.\n * Uses KyseraLogger interface from @kysera/core.\n *\n * @default silentLogger (no output)\n */\n logger?: KyseraLogger;\n}\n\n/**\n * Zod schema for SoftDeleteOptions\n * Used for validation and configuration in the kysera-cli\n */\nexport const SoftDeleteOptionsSchema = z.object({\n deletedAtColumn: z.string().optional(),\n includeDeleted: z.boolean().optional(),\n tables: z.array(z.string()).optional(),\n primaryKeyColumn: z.string().optional(),\n});\n\n/**\n * Methods added to repositories by the soft delete plugin\n */\nexport interface SoftDeleteMethods<T> {\n softDelete(id: number | string): Promise<T>;\n restore(id: number | string): Promise<T>;\n hardDelete(id: number | string): Promise<void>;\n findWithDeleted(id: number | string): Promise<T | null>;\n findAllWithDeleted(): Promise<T[]>;\n findDeleted(): Promise<T[]>;\n softDeleteMany(ids: (number | string)[]): Promise<T[]>;\n restoreMany(ids: (number | string)[]): Promise<T[]>;\n hardDeleteMany(ids: (number | string)[]): Promise<void>;\n}\n\n/**\n * Repository extended with soft delete methods\n * Note: Repository type is from @kysera/repository\n */\nexport type SoftDeleteRepository<Entity> = any & SoftDeleteMethods<Entity>;\n\ninterface BaseRepository {\n tableName: string;\n executor: Kysely<Record<string, unknown>>;\n findAll: () => Promise<unknown[]>;\n findById: (id: number) => Promise<unknown>;\n update: (id: number, data: Record<string, unknown>) => Promise<unknown>;\n}\n\n/**\n * Soft Delete Plugin for Kysera\n *\n * This plugin implements soft delete functionality using the Method Override pattern:\n * - Automatically filters out soft-deleted records from SELECT queries\n * - Adds softDelete(), restore(), and hardDelete() methods to repositories\n * - Provides findWithDeleted() and findDeleted() utility methods\n *\n * ## Usage\n *\n * ```typescript\n * import { softDeletePlugin } from '@kysera/soft-delete'\n * import { createORM } from '@kysera/repository'\n *\n * const orm = await createORM(db, [\n * softDeletePlugin({\n * deletedAtColumn: 'deleted_at',\n * tables: ['users', 'posts']\n * })\n * ])\n *\n * const userRepo = orm.createRepository(createUserRepository)\n *\n * // Soft delete a user (sets deleted_at)\n * await userRepo.softDelete(1)\n *\n * // Find all users (excludes soft-deleted)\n * await userRepo.findAll()\n *\n * // Find including deleted\n * await userRepo.findAllWithDeleted()\n *\n * // Restore a soft-deleted user\n * await userRepo.restore(1)\n *\n * // Permanently delete (real DELETE)\n * await userRepo.hardDelete(1)\n * ```\n *\n * ## Architecture Note\n *\n * This plugin uses Method Override, not full query interception:\n * - ✅ SELECT queries are automatically filtered\n * - ❌ DELETE queries are NOT automatically converted to soft deletes\n * - Use softDelete() method explicitly instead of delete()\n *\n * This design is intentional for simplicity and explicitness.\n *\n * ## Transaction Behavior\n *\n * **IMPORTANT**: Soft delete operations respect ACID properties and work correctly with transactions:\n *\n * - ✅ **Commits with transaction**: softDelete/restore operations use the same executor\n * as other repository operations, so they commit together\n * - ✅ **Rolls back with transaction**: If a transaction is rolled back, soft delete\n * operations are also rolled back\n * - ✅ **Atomic operations**: All soft delete operations (including bulk) are atomic\n *\n * ### Correct Transaction Usage\n *\n * ```typescript\n * // ✅ CORRECT: Soft delete is part of transaction\n * await db.transaction().execute(async (trx) => {\n * const repos = createRepositories(trx) // Use transaction executor\n * await repos.users.softDelete(1)\n * await repos.posts.softDeleteMany([1, 2, 3])\n * // If transaction rolls back, both operations roll back\n * })\n * ```\n *\n * ### Cascade Soft Delete Pattern\n *\n * For related entities, you need to manually implement cascade soft delete:\n *\n * ```typescript\n * // Cascade soft delete pattern\n * await db.transaction().execute(async (trx) => {\n * const repos = createRepositories(trx)\n * const userId = 123\n *\n * // First, soft delete child records\n * const userPosts = await repos.posts.findBy({ user_id: userId })\n * await repos.posts.softDeleteMany(userPosts.map(p => p.id))\n *\n * // Then, soft delete parent\n * await repos.users.softDelete(userId)\n * })\n * ```\n *\n * @param options - Configuration options for soft delete behavior\n * @returns Plugin instance that can be used with createORM\n */\nexport const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {\n const {\n deletedAtColumn = 'deleted_at',\n includeDeleted = false,\n tables,\n primaryKeyColumn = 'id',\n logger = silentLogger,\n } = options;\n\n return {\n name: '@kysera/soft-delete',\n version: '0.7.0',\n\n /**\n * Intercept queries to automatically filter soft-deleted records.\n *\n * NOTE: This plugin uses the Method Override pattern, not full query interception.\n * - SELECT queries are automatically filtered to exclude soft-deleted records\n * - DELETE operations are NOT automatically converted to soft deletes\n * - Use the softDelete() method instead of delete() to perform soft deletes\n * - Use hardDelete() method to bypass soft delete and perform a real DELETE\n *\n * This approach is simpler and more explicit than full query interception.\n *\n * Works with both Repository and DAL patterns through the unified executor layer.\n */\n interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {\n // Check if table supports soft delete\n const supportsSoftDelete = !tables || tables.includes(context.table);\n\n // Only filter SELECT queries when not explicitly including deleted\n if (\n supportsSoftDelete &&\n context.operation === 'select' &&\n !context.metadata['includeDeleted'] &&\n !includeDeleted\n ) {\n logger.debug(`Filtering soft-deleted records from ${context.table}`);\n // Add WHERE deleted_at IS NULL to the query builder\n type GenericSelectQueryBuilder = SelectQueryBuilder<Record<string, unknown>, string, Record<string, unknown>>;\n return (qb as unknown as GenericSelectQueryBuilder).where(\n `${context.table}.${deletedAtColumn}` as never,\n 'is',\n null\n ) as QB;\n }\n\n // Note: DELETE operations are NOT intercepted here\n // Use softDelete() method instead of delete() to perform soft deletes\n // This is by design - method override is simpler and more explicit\n\n return qb;\n },\n\n /**\n * Extend repository with soft delete methods.\n *\n * Adds the following methods to repositories:\n * - softDelete(id): Marks record as deleted by setting deleted_at timestamp\n * - restore(id): Restores a soft-deleted record by setting deleted_at to null\n * - hardDelete(id): Permanently deletes a record (bypasses soft delete)\n * - findWithDeleted(id): Find a record including soft-deleted ones\n * - findAllWithDeleted(): Find all records including soft-deleted ones\n * - findDeleted(): Find only soft-deleted records\n * - softDeleteMany(ids): Soft delete multiple records (bulk operation)\n * - restoreMany(ids): Restore multiple soft-deleted records (bulk operation)\n * - hardDeleteMany(ids): Permanently delete multiple records (bulk operation)\n *\n * Also overrides findAll() and findById() to automatically filter out\n * soft-deleted records (unless includeDeleted option is set).\n */\n extendRepository<T extends object>(repo: T): T {\n // Type assertion is safe here as we're checking for BaseRepository properties\n const baseRepo = repo as unknown as BaseRepository;\n\n // Check if it's actually a repository (has required properties)\n if (!('tableName' in baseRepo) || !('executor' in baseRepo)) {\n return repo;\n }\n\n // Check if table supports soft delete\n const supportsSoftDelete = !tables || tables.includes(baseRepo.tableName);\n\n // If table doesn't support soft delete, return unmodified repo\n if (!supportsSoftDelete) {\n logger.debug(`Table ${baseRepo.tableName} does not support soft delete, skipping extension`);\n return repo;\n }\n\n logger.debug(`Extending repository for table ${baseRepo.tableName} with soft delete methods`);\n\n // Get raw db for queries that need to bypass interceptors\n const rawDb = getRawDb(baseRepo.executor);\n\n const extendedRepo = {\n ...baseRepo,\n\n // Override base methods to filter soft-deleted records\n // Use rawDb to avoid double filtering from interceptor\n async findAll(): Promise<unknown[]> {\n if (!includeDeleted) {\n const result = await rawDb\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(deletedAtColumn as never, 'is', null)\n .execute();\n return result as unknown[];\n }\n // Include deleted: return all records\n const result = await rawDb\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .execute();\n return result as unknown[];\n },\n\n async findById(id: number): Promise<unknown> {\n if (!includeDeleted) {\n const result = await rawDb\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, '=', id as never)\n .where(deletedAtColumn as never, 'is', null)\n .executeTakeFirst();\n return result ?? null;\n }\n // Include deleted: find by id regardless of deleted status\n const result = await rawDb\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, '=', id as never)\n .executeTakeFirst();\n return result ?? null;\n },\n\n async softDelete(id: number): Promise<unknown> {\n logger.info(`Soft deleting record ${id} from ${baseRepo.tableName}`);\n // Use rawDb to bypass interceptors (UPDATE doesn't need filtering anyway)\n await rawDb\n .updateTable(baseRepo.tableName)\n .set({ [deletedAtColumn]: sql`CURRENT_TIMESTAMP` } as never)\n .where(primaryKeyColumn as never, '=', id as never)\n .execute();\n\n // Fetch the updated record - use rawDb to see the just-deleted record\n const record = await rawDb\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, '=', id as never)\n .executeTakeFirst();\n\n if (!record) {\n logger.warn(`Record ${id} not found in ${baseRepo.tableName} for soft delete`);\n throw new NotFoundError('Record', { id });\n }\n\n return record;\n },\n\n async restore(id: number): Promise<unknown> {\n logger.info(`Restoring soft-deleted record ${id} from ${baseRepo.tableName}`);\n await rawDb\n .updateTable(baseRepo.tableName)\n .set({ [deletedAtColumn]: null } as never)\n .where(primaryKeyColumn as never, '=', id as never)\n .execute();\n\n // Fetch the restored record\n const record = await rawDb\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, '=', id as never)\n .executeTakeFirst();\n\n if (!record) {\n logger.warn(`Record ${id} not found in ${baseRepo.tableName} for restore`);\n throw new NotFoundError('Record', { id });\n }\n\n return record;\n },\n\n async hardDelete(id: number): Promise<void> {\n logger.info(`Hard deleting record ${id} from ${baseRepo.tableName}`);\n await rawDb\n .deleteFrom(baseRepo.tableName)\n .where(primaryKeyColumn as never, '=', id as never)\n .execute();\n },\n\n async findWithDeleted(id: number): Promise<unknown> {\n // Use rawDb to bypass soft-delete filter\n const result = await rawDb\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, '=', id as never)\n .executeTakeFirst();\n return result ?? null;\n },\n\n async findAllWithDeleted(): Promise<unknown[]> {\n // Use rawDb to bypass soft-delete filter and return ALL records\n const result = await rawDb\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .execute();\n return result as unknown[];\n },\n\n async findDeleted(): Promise<unknown[]> {\n // Use rawDb to bypass soft-delete filter, then filter for deleted only\n const result = await rawDb\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(deletedAtColumn as never, 'is not', null)\n .execute();\n return result as unknown[];\n },\n\n async softDeleteMany(ids: (number | string)[]): Promise<unknown[]> {\n if (ids.length === 0) {\n return [];\n }\n\n logger.info(`Soft deleting ${ids.length} records from ${baseRepo.tableName}`);\n\n await rawDb\n .updateTable(baseRepo.tableName)\n .set({ [deletedAtColumn]: sql`CURRENT_TIMESTAMP` } as never)\n .where(primaryKeyColumn as never, 'in', ids as never)\n .execute();\n\n // Use rawDb to see the just-deleted records\n const records = await rawDb\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, 'in', ids as never)\n .execute();\n\n if (records.length !== ids.length) {\n const foundIds = records.map((r: any) => r[primaryKeyColumn]);\n const missingIds = ids.filter((id) => !foundIds.includes(id));\n logger.warn(`Some records not found for soft delete: ${missingIds.join(', ')}`);\n throw new NotFoundError('Records', { ids: missingIds });\n }\n\n return records as unknown[];\n },\n\n async restoreMany(ids: (number | string)[]): Promise<unknown[]> {\n if (ids.length === 0) {\n return [];\n }\n\n logger.info(`Restoring ${ids.length} soft-deleted records from ${baseRepo.tableName}`);\n\n await rawDb\n .updateTable(baseRepo.tableName)\n .set({ [deletedAtColumn]: null } as never)\n .where(primaryKeyColumn as never, 'in', ids as never)\n .execute();\n\n const records = await rawDb\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(primaryKeyColumn as never, 'in', ids as never)\n .execute();\n\n return records as unknown[];\n },\n\n async hardDeleteMany(ids: (number | string)[]): Promise<void> {\n if (ids.length === 0) {\n return;\n }\n\n logger.info(`Hard deleting ${ids.length} records from ${baseRepo.tableName}`);\n\n await rawDb\n .deleteFrom(baseRepo.tableName)\n .where(primaryKeyColumn as never, 'in', ids as never)\n .execute();\n },\n };\n\n return extendedRepo as T;\n },\n };\n};\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kysera/soft-delete",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Soft delete plugin for
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Soft delete plugin for Kysely repositories",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
"src"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@kysera/
|
|
19
|
+
"@kysera/executor": "0.7.0",
|
|
20
|
+
"@kysera/core": "0.7.0"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
22
23
|
"@types/better-sqlite3": "^7.6.13",
|
|
23
|
-
"@types/node": "^
|
|
24
|
+
"@types/node": "^25.0.0",
|
|
24
25
|
"@vitest/coverage-v8": "^4.0.15",
|
|
25
26
|
"better-sqlite3": "^12.5.0",
|
|
26
27
|
"kysely": "^0.28.8",
|
|
@@ -28,12 +29,13 @@
|
|
|
28
29
|
"typescript": "^5.9.3",
|
|
29
30
|
"vitest": "^4.0.15",
|
|
30
31
|
"zod": "^4.1.13",
|
|
31
|
-
"@kysera/
|
|
32
|
+
"@kysera/dal": "0.7.0",
|
|
33
|
+
"@kysera/repository": "0.7.0"
|
|
32
34
|
},
|
|
33
35
|
"peerDependencies": {
|
|
34
36
|
"kysely": ">=0.28.8",
|
|
35
37
|
"zod": "^4.1.13",
|
|
36
|
-
"@kysera/
|
|
38
|
+
"@kysera/executor": "0.7.0"
|
|
37
39
|
},
|
|
38
40
|
"peerDependenciesMeta": {
|
|
39
41
|
"zod": {
|
|
@@ -42,7 +44,7 @@
|
|
|
42
44
|
},
|
|
43
45
|
"keywords": [
|
|
44
46
|
"kysely",
|
|
45
|
-
"
|
|
47
|
+
"data-access",
|
|
46
48
|
"soft-delete",
|
|
47
49
|
"plugin"
|
|
48
50
|
],
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Plugin,
|
|
1
|
+
import type { Plugin, QueryBuilderContext } from '@kysera/executor';
|
|
2
|
+
import { getRawDb } from '@kysera/executor';
|
|
2
3
|
import type { SelectQueryBuilder, Kysely } from 'kysely';
|
|
3
4
|
import { sql } from 'kysely';
|
|
4
5
|
import { NotFoundError, silentLogger } from '@kysera/core';
|
|
@@ -88,8 +89,9 @@ export interface SoftDeleteMethods<T> {
|
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
91
|
* Repository extended with soft delete methods
|
|
92
|
+
* Note: Repository type is from @kysera/repository
|
|
91
93
|
*/
|
|
92
|
-
export type SoftDeleteRepository<Entity
|
|
94
|
+
export type SoftDeleteRepository<Entity> = any & SoftDeleteMethods<Entity>;
|
|
93
95
|
|
|
94
96
|
interface BaseRepository {
|
|
95
97
|
tableName: string;
|
|
@@ -100,7 +102,7 @@ interface BaseRepository {
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
/**
|
|
103
|
-
* Soft Delete Plugin for Kysera
|
|
105
|
+
* Soft Delete Plugin for Kysera
|
|
104
106
|
*
|
|
105
107
|
* This plugin implements soft delete functionality using the Method Override pattern:
|
|
106
108
|
* - Automatically filters out soft-deleted records from SELECT queries
|
|
@@ -202,7 +204,7 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
|
|
|
202
204
|
|
|
203
205
|
return {
|
|
204
206
|
name: '@kysera/soft-delete',
|
|
205
|
-
version: '0.
|
|
207
|
+
version: '0.7.0',
|
|
206
208
|
|
|
207
209
|
/**
|
|
208
210
|
* Intercept queries to automatically filter soft-deleted records.
|
|
@@ -214,11 +216,10 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
|
|
|
214
216
|
* - Use hardDelete() method to bypass soft delete and perform a real DELETE
|
|
215
217
|
*
|
|
216
218
|
* This approach is simpler and more explicit than full query interception.
|
|
219
|
+
*
|
|
220
|
+
* Works with both Repository and DAL patterns through the unified executor layer.
|
|
217
221
|
*/
|
|
218
|
-
interceptQuery<QB
|
|
219
|
-
qb: QB,
|
|
220
|
-
context: { operation: string; table: string; metadata: Record<string, unknown> }
|
|
221
|
-
): QB {
|
|
222
|
+
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
|
|
222
223
|
// Check if table supports soft delete
|
|
223
224
|
const supportsSoftDelete = !tables || tables.includes(context.table);
|
|
224
225
|
|
|
@@ -283,29 +284,34 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
|
|
|
283
284
|
|
|
284
285
|
logger.debug(`Extending repository for table ${baseRepo.tableName} with soft delete methods`);
|
|
285
286
|
|
|
286
|
-
//
|
|
287
|
-
const
|
|
288
|
-
const originalFindById = baseRepo.findById.bind(baseRepo);
|
|
287
|
+
// Get raw db for queries that need to bypass interceptors
|
|
288
|
+
const rawDb = getRawDb(baseRepo.executor);
|
|
289
289
|
|
|
290
290
|
const extendedRepo = {
|
|
291
291
|
...baseRepo,
|
|
292
292
|
|
|
293
293
|
// Override base methods to filter soft-deleted records
|
|
294
|
+
// Use rawDb to avoid double filtering from interceptor
|
|
294
295
|
async findAll(): Promise<unknown[]> {
|
|
295
296
|
if (!includeDeleted) {
|
|
296
|
-
const result = await
|
|
297
|
+
const result = await rawDb
|
|
297
298
|
.selectFrom(baseRepo.tableName)
|
|
298
299
|
.selectAll()
|
|
299
300
|
.where(deletedAtColumn as never, 'is', null)
|
|
300
301
|
.execute();
|
|
301
302
|
return result as unknown[];
|
|
302
303
|
}
|
|
303
|
-
return
|
|
304
|
+
// Include deleted: return all records
|
|
305
|
+
const result = await rawDb
|
|
306
|
+
.selectFrom(baseRepo.tableName)
|
|
307
|
+
.selectAll()
|
|
308
|
+
.execute();
|
|
309
|
+
return result as unknown[];
|
|
304
310
|
},
|
|
305
311
|
|
|
306
312
|
async findById(id: number): Promise<unknown> {
|
|
307
313
|
if (!includeDeleted) {
|
|
308
|
-
const result = await
|
|
314
|
+
const result = await rawDb
|
|
309
315
|
.selectFrom(baseRepo.tableName)
|
|
310
316
|
.selectAll()
|
|
311
317
|
.where(primaryKeyColumn as never, '=', id as never)
|
|
@@ -313,44 +319,31 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
|
|
|
313
319
|
.executeTakeFirst();
|
|
314
320
|
return result ?? null;
|
|
315
321
|
}
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
.executeTakeFirst();
|
|
324
|
-
return result ?? null;
|
|
325
|
-
}
|
|
326
|
-
return await originalFindById(id);
|
|
322
|
+
// Include deleted: find by id regardless of deleted status
|
|
323
|
+
const result = await rawDb
|
|
324
|
+
.selectFrom(baseRepo.tableName)
|
|
325
|
+
.selectAll()
|
|
326
|
+
.where(primaryKeyColumn as never, '=', id as never)
|
|
327
|
+
.executeTakeFirst();
|
|
328
|
+
return result ?? null;
|
|
327
329
|
},
|
|
328
330
|
|
|
329
331
|
async softDelete(id: number): Promise<unknown> {
|
|
330
332
|
logger.info(`Soft deleting record ${id} from ${baseRepo.tableName}`);
|
|
331
|
-
// Use
|
|
332
|
-
|
|
333
|
-
// We bypass repository.update() to avoid Zod validation issues with RawBuilder
|
|
334
|
-
await baseRepo.executor
|
|
333
|
+
// Use rawDb to bypass interceptors (UPDATE doesn't need filtering anyway)
|
|
334
|
+
await rawDb
|
|
335
335
|
.updateTable(baseRepo.tableName)
|
|
336
336
|
.set({ [deletedAtColumn]: sql`CURRENT_TIMESTAMP` } as never)
|
|
337
337
|
.where(primaryKeyColumn as never, '=', id as never)
|
|
338
338
|
.execute();
|
|
339
339
|
|
|
340
|
-
// Fetch the updated record to
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
.selectAll()
|
|
347
|
-
.where(primaryKeyColumn as never, '=', id as never)
|
|
348
|
-
.executeTakeFirst();
|
|
349
|
-
} else {
|
|
350
|
-
record = await originalFindById(id);
|
|
351
|
-
}
|
|
340
|
+
// Fetch the updated record - use rawDb to see the just-deleted record
|
|
341
|
+
const record = await rawDb
|
|
342
|
+
.selectFrom(baseRepo.tableName)
|
|
343
|
+
.selectAll()
|
|
344
|
+
.where(primaryKeyColumn as never, '=', id as never)
|
|
345
|
+
.executeTakeFirst();
|
|
352
346
|
|
|
353
|
-
// If record not found or deleted_at not set, throw error
|
|
354
347
|
if (!record) {
|
|
355
348
|
logger.warn(`Record ${id} not found in ${baseRepo.tableName} for soft delete`);
|
|
356
349
|
throw new NotFoundError('Record', { id });
|
|
@@ -361,24 +354,18 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
|
|
|
361
354
|
|
|
362
355
|
async restore(id: number): Promise<unknown> {
|
|
363
356
|
logger.info(`Restoring soft-deleted record ${id} from ${baseRepo.tableName}`);
|
|
364
|
-
|
|
365
|
-
await baseRepo.executor
|
|
357
|
+
await rawDb
|
|
366
358
|
.updateTable(baseRepo.tableName)
|
|
367
359
|
.set({ [deletedAtColumn]: null } as never)
|
|
368
360
|
.where(primaryKeyColumn as never, '=', id as never)
|
|
369
361
|
.execute();
|
|
370
362
|
|
|
371
|
-
// Fetch
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
.where(primaryKeyColumn as never, '=', id as never)
|
|
378
|
-
.executeTakeFirst();
|
|
379
|
-
} else {
|
|
380
|
-
record = await originalFindById(id);
|
|
381
|
-
}
|
|
363
|
+
// Fetch the restored record
|
|
364
|
+
const record = await rawDb
|
|
365
|
+
.selectFrom(baseRepo.tableName)
|
|
366
|
+
.selectAll()
|
|
367
|
+
.where(primaryKeyColumn as never, '=', id as never)
|
|
368
|
+
.executeTakeFirst();
|
|
382
369
|
|
|
383
370
|
if (!record) {
|
|
384
371
|
logger.warn(`Record ${id} not found in ${baseRepo.tableName} for restore`);
|
|
@@ -390,33 +377,34 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
|
|
|
390
377
|
|
|
391
378
|
async hardDelete(id: number): Promise<void> {
|
|
392
379
|
logger.info(`Hard deleting record ${id} from ${baseRepo.tableName}`);
|
|
393
|
-
|
|
394
|
-
await baseRepo.executor
|
|
380
|
+
await rawDb
|
|
395
381
|
.deleteFrom(baseRepo.tableName)
|
|
396
382
|
.where(primaryKeyColumn as never, '=', id as never)
|
|
397
383
|
.execute();
|
|
398
384
|
},
|
|
399
385
|
|
|
400
386
|
async findWithDeleted(id: number): Promise<unknown> {
|
|
401
|
-
// Use
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
return result ?? null;
|
|
409
|
-
}
|
|
410
|
-
return await originalFindById(id);
|
|
387
|
+
// Use rawDb to bypass soft-delete filter
|
|
388
|
+
const result = await rawDb
|
|
389
|
+
.selectFrom(baseRepo.tableName)
|
|
390
|
+
.selectAll()
|
|
391
|
+
.where(primaryKeyColumn as never, '=', id as never)
|
|
392
|
+
.executeTakeFirst();
|
|
393
|
+
return result ?? null;
|
|
411
394
|
},
|
|
412
395
|
|
|
413
396
|
async findAllWithDeleted(): Promise<unknown[]> {
|
|
414
|
-
// Use
|
|
415
|
-
|
|
397
|
+
// Use rawDb to bypass soft-delete filter and return ALL records
|
|
398
|
+
const result = await rawDb
|
|
399
|
+
.selectFrom(baseRepo.tableName)
|
|
400
|
+
.selectAll()
|
|
401
|
+
.execute();
|
|
402
|
+
return result as unknown[];
|
|
416
403
|
},
|
|
417
404
|
|
|
418
405
|
async findDeleted(): Promise<unknown[]> {
|
|
419
|
-
|
|
406
|
+
// Use rawDb to bypass soft-delete filter, then filter for deleted only
|
|
407
|
+
const result = await rawDb
|
|
420
408
|
.selectFrom(baseRepo.tableName)
|
|
421
409
|
.selectAll()
|
|
422
410
|
.where(deletedAtColumn as never, 'is not', null)
|
|
@@ -425,28 +413,25 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
|
|
|
425
413
|
},
|
|
426
414
|
|
|
427
415
|
async softDeleteMany(ids: (number | string)[]): Promise<unknown[]> {
|
|
428
|
-
// Handle empty arrays gracefully
|
|
429
416
|
if (ids.length === 0) {
|
|
430
417
|
return [];
|
|
431
418
|
}
|
|
432
419
|
|
|
433
420
|
logger.info(`Soft deleting ${ids.length} records from ${baseRepo.tableName}`);
|
|
434
421
|
|
|
435
|
-
|
|
436
|
-
await baseRepo.executor
|
|
422
|
+
await rawDb
|
|
437
423
|
.updateTable(baseRepo.tableName)
|
|
438
424
|
.set({ [deletedAtColumn]: sql`CURRENT_TIMESTAMP` } as never)
|
|
439
425
|
.where(primaryKeyColumn as never, 'in', ids as never)
|
|
440
426
|
.execute();
|
|
441
427
|
|
|
442
|
-
//
|
|
443
|
-
const records = await
|
|
428
|
+
// Use rawDb to see the just-deleted records
|
|
429
|
+
const records = await rawDb
|
|
444
430
|
.selectFrom(baseRepo.tableName)
|
|
445
431
|
.selectAll()
|
|
446
432
|
.where(primaryKeyColumn as never, 'in', ids as never)
|
|
447
433
|
.execute();
|
|
448
434
|
|
|
449
|
-
// Verify all records were found
|
|
450
435
|
if (records.length !== ids.length) {
|
|
451
436
|
const foundIds = records.map((r: any) => r[primaryKeyColumn]);
|
|
452
437
|
const missingIds = ids.filter((id) => !foundIds.includes(id));
|
|
@@ -458,22 +443,19 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
|
|
|
458
443
|
},
|
|
459
444
|
|
|
460
445
|
async restoreMany(ids: (number | string)[]): Promise<unknown[]> {
|
|
461
|
-
// Handle empty arrays gracefully
|
|
462
446
|
if (ids.length === 0) {
|
|
463
447
|
return [];
|
|
464
448
|
}
|
|
465
449
|
|
|
466
450
|
logger.info(`Restoring ${ids.length} soft-deleted records from ${baseRepo.tableName}`);
|
|
467
451
|
|
|
468
|
-
|
|
469
|
-
await baseRepo.executor
|
|
452
|
+
await rawDb
|
|
470
453
|
.updateTable(baseRepo.tableName)
|
|
471
454
|
.set({ [deletedAtColumn]: null } as never)
|
|
472
455
|
.where(primaryKeyColumn as never, 'in', ids as never)
|
|
473
456
|
.execute();
|
|
474
457
|
|
|
475
|
-
|
|
476
|
-
const records = await baseRepo.executor
|
|
458
|
+
const records = await rawDb
|
|
477
459
|
.selectFrom(baseRepo.tableName)
|
|
478
460
|
.selectAll()
|
|
479
461
|
.where(primaryKeyColumn as never, 'in', ids as never)
|
|
@@ -483,15 +465,13 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
|
|
|
483
465
|
},
|
|
484
466
|
|
|
485
467
|
async hardDeleteMany(ids: (number | string)[]): Promise<void> {
|
|
486
|
-
// Handle empty arrays gracefully
|
|
487
468
|
if (ids.length === 0) {
|
|
488
469
|
return;
|
|
489
470
|
}
|
|
490
471
|
|
|
491
472
|
logger.info(`Hard deleting ${ids.length} records from ${baseRepo.tableName}`);
|
|
492
473
|
|
|
493
|
-
|
|
494
|
-
await baseRepo.executor
|
|
474
|
+
await rawDb
|
|
495
475
|
.deleteFrom(baseRepo.tableName)
|
|
496
476
|
.where(primaryKeyColumn as never, 'in', ids as never)
|
|
497
477
|
.execute();
|