@kysera/soft-delete 0.4.1 → 0.6.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 CHANGED
@@ -10,11 +10,11 @@
10
10
 
11
11
  | Metric | Value |
12
12
  |--------|-------|
13
- | **Version** | 0.3.0 |
14
- | **Bundle Size** | 477 B (minified) |
15
- | **Test Coverage** | 39 tests passing |
16
- | **Dependencies** | @kysera/repository (workspace) |
17
- | **Peer Dependencies** | kysely >=0.28.0 |
13
+ | **Version** | 0.5.1 |
14
+ | **Bundle Size** | ~4 KB (minified) |
15
+ | **Test Coverage** | 39+ tests passing |
16
+ | **Dependencies** | @kysera/core (workspace) |
17
+ | **Peer Dependencies** | kysely >=0.28.8, @kysera/repository, zod ^4.1.13 (optional) |
18
18
  | **Target Runtimes** | Node.js 20+, Bun 1.0+, Deno |
19
19
  | **Module System** | ESM only |
20
20
  | **Database Support** | PostgreSQL, MySQL, SQLite |
@@ -261,7 +261,8 @@ const plugin = softDeletePlugin()
261
261
  const plugin = softDeletePlugin({
262
262
  deletedAtColumn: 'deleted_at',
263
263
  includeDeleted: false,
264
- tables: undefined // All tables
264
+ tables: undefined, // All tables
265
+ primaryKeyColumn: 'id' // Default primary key
265
266
  })
266
267
  ```
267
268
 
@@ -290,6 +291,43 @@ const plugin = softDeletePlugin({
290
291
  })
291
292
  ```
292
293
 
294
+ ### Custom Primary Key Column
295
+
296
+ Configure tables with different primary key column names:
297
+
298
+ ```typescript
299
+ // Example: Use "uuid" as primary key
300
+ const plugin = softDeletePlugin({
301
+ primaryKeyColumn: 'uuid'
302
+ })
303
+
304
+ // Database schema
305
+ interface Database {
306
+ users: {
307
+ uuid: Generated<string> // ✅ Custom primary key
308
+ email: string
309
+ name: string
310
+ deleted_at: Date | null
311
+ }
312
+ }
313
+
314
+ // Example: Use "user_id"
315
+ const plugin = softDeletePlugin({
316
+ primaryKeyColumn: 'user_id'
317
+ })
318
+
319
+ // Usage remains the same
320
+ await userRepo.softDelete(userId)
321
+ await userRepo.restore(userId)
322
+ await userRepo.hardDelete(userId)
323
+ ```
324
+
325
+ **When to use:**
326
+ - Tables with UUID primary keys (`uuid`, `guid`)
327
+ - Tables with composite naming (`user_id`, `post_id`)
328
+ - Legacy databases with custom key columns
329
+ - Multi-tenant systems with custom identifiers
330
+
293
331
  ### Table Filtering
294
332
 
295
333
  Apply soft delete only to specific tables:
@@ -343,7 +381,7 @@ The plugin extends repositories with these methods:
343
381
  Mark a record as deleted by setting `deleted_at` timestamp.
344
382
 
345
383
  ```typescript
346
- async softDelete(id: number): Promise<T>
384
+ async softDelete(id: number | string): Promise<T>
347
385
  ```
348
386
 
349
387
  **Example:**
@@ -375,7 +413,7 @@ console.log(directQuery) // Record exists with deleted_at set
375
413
  Restore a soft-deleted record by setting `deleted_at` to `null`.
376
414
 
377
415
  ```typescript
378
- async restore(id: number): Promise<T>
416
+ async restore(id: number | string): Promise<T>
379
417
  ```
380
418
 
381
419
  **Example:**
@@ -405,7 +443,7 @@ const users = await userRepo.findAll()
405
443
  Permanently delete a record from the database (bypasses soft delete).
406
444
 
407
445
  ```typescript
408
- async hardDelete(id: number): Promise<void>
446
+ async hardDelete(id: number | string): Promise<void>
409
447
  ```
410
448
 
411
449
  **Example:**
@@ -491,7 +529,7 @@ console.log(deleted[0].deleted_at) // Not null
491
529
  Find a specific record including if soft-deleted.
492
530
 
493
531
  ```typescript
494
- async findWithDeleted(id: number): Promise<T | null>
532
+ async findWithDeleted(id: number | string): Promise<T | null>
495
533
  ```
496
534
 
497
535
  **Example:**
@@ -634,24 +672,38 @@ await db.transaction().execute(async (trx) => {
634
672
  })
635
673
  ```
636
674
 
637
- ### Bulk Operations
675
+ ### Batch Operations
676
+
677
+ The plugin provides efficient batch operations for handling multiple records:
638
678
 
639
679
  ```typescript
640
- // Soft delete multiple records
680
+ // Efficient: Single query batch operations
641
681
  const userIds = [1, 2, 3, 4, 5]
642
682
 
683
+ // Soft delete multiple records (single UPDATE query)
684
+ const deletedUsers = await userRepo.softDeleteMany(userIds)
685
+ console.log(deletedUsers.length) // 5
686
+
687
+ // Restore multiple records (single UPDATE query)
688
+ const restoredUsers = await userRepo.restoreMany(userIds)
689
+ console.log(restoredUsers.length) // 5
690
+
691
+ // Hard delete multiple records (single DELETE query)
692
+ await userRepo.hardDeleteMany(userIds)
693
+
694
+ // ❌ Inefficient: Loop approach (N queries)
643
695
  for (const id of userIds) {
644
- await userRepo.softDelete(id)
696
+ await userRepo.softDelete(id) // 5 separate UPDATE queries
645
697
  }
646
-
647
- // Or use direct query for bulk
648
- await db
649
- .updateTable('users')
650
- .set({ deleted_at: sql`CURRENT_TIMESTAMP` })
651
- .where('id', 'in', userIds)
652
- .execute()
653
698
  ```
654
699
 
700
+ **Performance Comparison:**
701
+ - Loop: 100 records = 100 queries (~2000ms)
702
+ - Batch: 100 records = 1 query (~20ms)
703
+ - **100x faster! 🚀**
704
+
705
+ **See [BATCH_OPERATIONS.md](./BATCH_OPERATIONS.md) for detailed documentation.**
706
+
655
707
  ### Conditional Soft Delete
656
708
 
657
709
  ```typescript
@@ -788,12 +840,12 @@ The plugin is fully type-safe with TypeScript.
788
840
 
789
841
  ```typescript
790
842
  interface SoftDeleteRepository<T> extends Repository<T> {
791
- softDelete(id: number): Promise<T>
792
- restore(id: number): Promise<T>
793
- hardDelete(id: number): Promise<void>
843
+ softDelete(id: number | string): Promise<T>
844
+ restore(id: number | string): Promise<T>
845
+ hardDelete(id: number | string): Promise<void>
794
846
  findAllWithDeleted(): Promise<T[]>
795
847
  findDeleted(): Promise<T[]>
796
- findWithDeleted(id: number): Promise<T | null>
848
+ findWithDeleted(id: number | string): Promise<T | null>
797
849
  }
798
850
 
799
851
  // Type-safe usage
@@ -843,6 +895,8 @@ interface SoftDeleteOptions {
843
895
  deletedAtColumn?: string // Default: 'deleted_at'
844
896
  includeDeleted?: boolean // Default: false
845
897
  tables?: string[] // Default: undefined (all tables)
898
+ primaryKeyColumn?: string // Default: 'id'
899
+ logger?: KyseraLogger // Default: silentLogger (no output)
846
900
  }
847
901
  ```
848
902
 
@@ -866,7 +920,7 @@ const plugin = softDeletePlugin({
866
920
  Soft delete a record by ID.
867
921
 
868
922
  **Parameters:**
869
- - `id: number` - Record ID
923
+ - `id: number | string` - Record ID
870
924
 
871
925
  **Returns:** `Promise<T>` - The soft-deleted record
872
926
 
@@ -879,7 +933,7 @@ Soft delete a record by ID.
879
933
  Restore a soft-deleted record.
880
934
 
881
935
  **Parameters:**
882
- - `id: number` - Record ID
936
+ - `id: number | string` - Record ID
883
937
 
884
938
  **Returns:** `Promise<T>` - The restored record
885
939
 
@@ -890,7 +944,7 @@ Restore a soft-deleted record.
890
944
  Permanently delete a record.
891
945
 
892
946
  **Parameters:**
893
- - `id: number` - Record ID
947
+ - `id: number | string` - Record ID
894
948
 
895
949
  **Returns:** `Promise<void>`
896
950
 
@@ -917,12 +971,65 @@ Find only soft-deleted records.
917
971
  Find a record by ID including if soft-deleted.
918
972
 
919
973
  **Parameters:**
920
- - `id: number` - Record ID
974
+ - `id: number | string` - Record ID
921
975
 
922
976
  **Returns:** `Promise<T | null>`
923
977
 
924
978
  ---
925
979
 
980
+ #### softDeleteMany(ids)
981
+
982
+ Soft delete multiple records in a single query.
983
+
984
+ **Parameters:**
985
+ - `ids: (number | string)[]` - Array of record IDs
986
+
987
+ **Returns:** `Promise<T[]>` - Array of soft-deleted records
988
+
989
+ **Throws:** Error if any record not found
990
+
991
+ **Example:**
992
+ ```typescript
993
+ const deletedUsers = await userRepo.softDeleteMany([1, 2, 3, 4, 5])
994
+ console.log(deletedUsers.length) // 5
995
+ ```
996
+
997
+ ---
998
+
999
+ #### restoreMany(ids)
1000
+
1001
+ Restore multiple soft-deleted records in a single query.
1002
+
1003
+ **Parameters:**
1004
+ - `ids: (number | string)[]` - Array of record IDs
1005
+
1006
+ **Returns:** `Promise<T[]>` - Array of restored records
1007
+
1008
+ **Example:**
1009
+ ```typescript
1010
+ const restoredUsers = await userRepo.restoreMany([1, 2, 3])
1011
+ console.log(restoredUsers.every(u => u.deleted_at === null)) // true
1012
+ ```
1013
+
1014
+ ---
1015
+
1016
+ #### hardDeleteMany(ids)
1017
+
1018
+ Permanently delete multiple records in a single query.
1019
+
1020
+ **Parameters:**
1021
+ - `ids: (number | string)[]` - Array of record IDs
1022
+
1023
+ **Returns:** `Promise<void>`
1024
+
1025
+ **Example:**
1026
+ ```typescript
1027
+ await userRepo.hardDeleteMany([1, 2, 3])
1028
+ // Records permanently removed from database
1029
+ ```
1030
+
1031
+ ---
1032
+
926
1033
  ## ✨ Best Practices
927
1034
 
928
1035
  ### 1. Always Use Nullable deleted_at
@@ -1231,11 +1338,11 @@ MIT © Kysera
1231
1338
 
1232
1339
  ## 🔗 Links
1233
1340
 
1234
- - [GitHub Repository](https://github.com/omnitron/kysera)
1341
+ - [GitHub Repository](https://github.com/kysera-dev/kysera)
1235
1342
  - [@kysera/repository Documentation](../repository/README.md)
1236
1343
  - [@kysera/core Documentation](../core/README.md)
1237
1344
  - [Kysely Documentation](https://kysely.dev)
1238
- - [Issue Tracker](https://github.com/omnitron/kysera/issues)
1345
+ - [Issue Tracker](https://github.com/kysera-dev/kysera/issues)
1239
1346
 
1240
1347
  ---
1241
1348
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { Plugin } from '@kysera/repository';
1
+ import { Repository, Plugin } from '@kysera/repository';
2
+ import { KyseraLogger } from '@kysera/core';
3
+ import { z } from 'zod';
2
4
 
3
5
  /**
4
6
  * Configuration options for the soft delete plugin.
@@ -8,7 +10,8 @@ import { Plugin } from '@kysera/repository';
8
10
  * const plugin = softDeletePlugin({
9
11
  * deletedAtColumn: 'deleted_at',
10
12
  * includeDeleted: false,
11
- * tables: ['users', 'posts'] // Only these tables support soft delete
13
+ * tables: ['users', 'posts'], // Only these tables support soft delete
14
+ * primaryKeyColumn: 'id' // Default primary key column
12
15
  * })
13
16
  * ```
14
17
  */
@@ -33,7 +36,50 @@ interface SoftDeleteOptions {
33
36
  * @example ['users', 'posts', 'comments']
34
37
  */
35
38
  tables?: string[];
39
+ /**
40
+ * Primary key column name used for identifying records.
41
+ * Tables with different primary key names (uuid, user_id, etc.) can be configured.
42
+ *
43
+ * @default 'id'
44
+ * @example 'uuid', 'user_id', 'post_id'
45
+ */
46
+ primaryKeyColumn?: string;
47
+ /**
48
+ * Logger for plugin operations.
49
+ * Uses KyseraLogger interface from @kysera/core.
50
+ *
51
+ * @default silentLogger (no output)
52
+ */
53
+ logger?: KyseraLogger;
36
54
  }
55
+ /**
56
+ * Zod schema for SoftDeleteOptions
57
+ * Used for validation and configuration in the kysera-cli
58
+ */
59
+ declare const SoftDeleteOptionsSchema: z.ZodObject<{
60
+ deletedAtColumn: z.ZodOptional<z.ZodString>;
61
+ includeDeleted: z.ZodOptional<z.ZodBoolean>;
62
+ tables: z.ZodOptional<z.ZodArray<z.ZodString>>;
63
+ primaryKeyColumn: z.ZodOptional<z.ZodString>;
64
+ }, z.core.$strip>;
65
+ /**
66
+ * Methods added to repositories by the soft delete plugin
67
+ */
68
+ interface SoftDeleteMethods<T> {
69
+ softDelete(id: number | string): Promise<T>;
70
+ restore(id: number | string): Promise<T>;
71
+ hardDelete(id: number | string): Promise<void>;
72
+ findWithDeleted(id: number | string): Promise<T | null>;
73
+ findAllWithDeleted(): Promise<T[]>;
74
+ findDeleted(): Promise<T[]>;
75
+ softDeleteMany(ids: (number | string)[]): Promise<T[]>;
76
+ restoreMany(ids: (number | string)[]): Promise<T[]>;
77
+ hardDeleteMany(ids: (number | string)[]): Promise<void>;
78
+ }
79
+ /**
80
+ * Repository extended with soft delete methods
81
+ */
82
+ type SoftDeleteRepository<Entity, DB> = Repository<Entity, DB> & SoftDeleteMethods<Entity>;
37
83
  /**
38
84
  * Soft Delete Plugin for Kysera ORM
39
85
  *
@@ -82,9 +128,50 @@ interface SoftDeleteOptions {
82
128
  *
83
129
  * This design is intentional for simplicity and explicitness.
84
130
  *
131
+ * ## Transaction Behavior
132
+ *
133
+ * **IMPORTANT**: Soft delete operations respect ACID properties and work correctly with transactions:
134
+ *
135
+ * - ✅ **Commits with transaction**: softDelete/restore operations use the same executor
136
+ * as other repository operations, so they commit together
137
+ * - ✅ **Rolls back with transaction**: If a transaction is rolled back, soft delete
138
+ * operations are also rolled back
139
+ * - ✅ **Atomic operations**: All soft delete operations (including bulk) are atomic
140
+ *
141
+ * ### Correct Transaction Usage
142
+ *
143
+ * ```typescript
144
+ * // ✅ CORRECT: Soft delete is part of transaction
145
+ * await db.transaction().execute(async (trx) => {
146
+ * const repos = createRepositories(trx) // Use transaction executor
147
+ * await repos.users.softDelete(1)
148
+ * await repos.posts.softDeleteMany([1, 2, 3])
149
+ * // If transaction rolls back, both operations roll back
150
+ * })
151
+ * ```
152
+ *
153
+ * ### Cascade Soft Delete Pattern
154
+ *
155
+ * For related entities, you need to manually implement cascade soft delete:
156
+ *
157
+ * ```typescript
158
+ * // Cascade soft delete pattern
159
+ * await db.transaction().execute(async (trx) => {
160
+ * const repos = createRepositories(trx)
161
+ * const userId = 123
162
+ *
163
+ * // First, soft delete child records
164
+ * const userPosts = await repos.posts.findBy({ user_id: userId })
165
+ * await repos.posts.softDeleteMany(userPosts.map(p => p.id))
166
+ *
167
+ * // Then, soft delete parent
168
+ * await repos.users.softDelete(userId)
169
+ * })
170
+ * ```
171
+ *
85
172
  * @param options - Configuration options for soft delete behavior
86
173
  * @returns Plugin instance that can be used with createORM
87
174
  */
88
175
  declare const softDeletePlugin: (options?: SoftDeleteOptions) => Plugin;
89
176
 
90
- export { type SoftDeleteOptions, softDeletePlugin };
177
+ export { type SoftDeleteMethods, type SoftDeleteOptions, SoftDeleteOptionsSchema, type SoftDeleteRepository, softDeletePlugin };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import {sql}from'kysely';var y=(u={})=>{let{deletedAtColumn:t="deleted_at",includeDeleted:o=false,tables:s}=u;return {name:"@kysera/soft-delete",version:"1.0.0",interceptQuery(r,e){return (!s||s.includes(e.table))&&e.operation==="select"&&!e.metadata.includeDeleted&&!o?r.where(`${e.table}.${t}`,"is",null):r},extendRepository(r){let e=r;if(!("tableName"in e)||!("executor"in e)||!(!s||s.includes(e.tableName)))return r;let a=e.findAll.bind(e),l=e.findById.bind(e);return {...e,async findAll(){return o?await a():await e.executor.selectFrom(e.tableName).selectAll().where(t,"is",null).execute()},async findById(n){return o?await l(n):await e.executor.selectFrom(e.tableName).selectAll().where("id","=",n).where(t,"is",null).executeTakeFirst()??null},async softDelete(n){await e.executor.updateTable(e.tableName).set({[t]:sql`CURRENT_TIMESTAMP`}).where("id","=",n).execute();let i=await l(n);if(!i)throw new Error(`Record with id ${n} not found`);return i},async restore(n){return await e.update(n,{[t]:null})},async hardDelete(n){await e.executor.deleteFrom(e.tableName).where("id","=",n).execute();},async findWithDeleted(n){return await l(n)},async findAllWithDeleted(){return await a()},async findDeleted(){return await e.executor.selectFrom(e.tableName).selectAll().where(t,"is not",null).execute()}}}}};export{y as softDeletePlugin};//# sourceMappingURL=index.js.map
1
+ import {sql}from'kysely';import {silentLogger,NotFoundError}from'@kysera/core';import {z}from'zod';var N=z.object({deletedAtColumn:z.string().optional(),includeDeleted:z.boolean().optional(),tables:z.array(z.string()).optional(),primaryKeyColumn:z.string().optional()}),P=(y={})=>{let{deletedAtColumn:s="deleted_at",includeDeleted:d=false,tables:i,primaryKeyColumn:r="id",logger:o=silentLogger}=y;return {name:"@kysera/soft-delete",version:"0.5.1",interceptQuery(a,e){return (!i||i.includes(e.table))&&e.operation==="select"&&!e.metadata.includeDeleted&&!d?(o.debug(`Filtering soft-deleted records from ${e.table}`),a.where(`${e.table}.${s}`,"is",null)):a},extendRepository(a){let e=a;if(!("tableName"in e)||!("executor"in e))return a;if(!(!i||i.includes(e.tableName)))return o.debug(`Table ${e.tableName} does not support soft delete, skipping extension`),a;o.debug(`Extending repository for table ${e.tableName} with soft delete methods`);let f=e.findAll.bind(e),u=e.findById.bind(e);return {...e,async findAll(){return d?await f():await e.executor.selectFrom(e.tableName).selectAll().where(s,"is",null).execute()},async findById(t){return d?r!=="id"?await e.executor.selectFrom(e.tableName).selectAll().where(r,"=",t).executeTakeFirst()??null:await u(t):await e.executor.selectFrom(e.tableName).selectAll().where(r,"=",t).where(s,"is",null).executeTakeFirst()??null},async softDelete(t){o.info(`Soft deleting record ${t} from ${e.tableName}`),await e.executor.updateTable(e.tableName).set({[s]:sql`CURRENT_TIMESTAMP`}).where(r,"=",t).execute();let n;if(r!=="id"?n=await e.executor.selectFrom(e.tableName).selectAll().where(r,"=",t).executeTakeFirst():n=await u(t),!n)throw o.warn(`Record ${t} not found in ${e.tableName} for soft delete`),new NotFoundError("Record",{id:t});return n},async restore(t){o.info(`Restoring soft-deleted record ${t} from ${e.tableName}`),await e.executor.updateTable(e.tableName).set({[s]:null}).where(r,"=",t).execute();let n;if(r!=="id"?n=await e.executor.selectFrom(e.tableName).selectAll().where(r,"=",t).executeTakeFirst():n=await u(t),!n)throw o.warn(`Record ${t} not found in ${e.tableName} for restore`),new NotFoundError("Record",{id:t});return n},async hardDelete(t){o.info(`Hard deleting record ${t} from ${e.tableName}`),await e.executor.deleteFrom(e.tableName).where(r,"=",t).execute();},async findWithDeleted(t){return r!=="id"?await e.executor.selectFrom(e.tableName).selectAll().where(r,"=",t).executeTakeFirst()??null:await u(t)},async findAllWithDeleted(){return await f()},async findDeleted(){return await e.executor.selectFrom(e.tableName).selectAll().where(s,"is not",null).execute()},async softDeleteMany(t){if(t.length===0)return [];o.info(`Soft deleting ${t.length} records from ${e.tableName}`),await e.executor.updateTable(e.tableName).set({[s]:sql`CURRENT_TIMESTAMP`}).where(r,"in",t).execute();let n=await e.executor.selectFrom(e.tableName).selectAll().where(r,"in",t).execute();if(n.length!==t.length){let p=n.map(c=>c[r]),w=t.filter(c=>!p.includes(c));throw o.warn(`Some records not found for soft delete: ${w.join(", ")}`),new NotFoundError("Records",{ids:w})}return n},async restoreMany(t){return t.length===0?[]:(o.info(`Restoring ${t.length} soft-deleted records from ${e.tableName}`),await e.executor.updateTable(e.tableName).set({[s]:null}).where(r,"in",t).execute(),await e.executor.selectFrom(e.tableName).selectAll().where(r,"in",t).execute())},async hardDeleteMany(t){t.length!==0&&(o.info(`Hard deleting ${t.length} records from ${e.tableName}`),await e.executor.deleteFrom(e.tableName).where(r,"in",t).execute());}}}}};export{N as SoftDeleteOptionsSchema,P 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":["softDeletePlugin","options","deletedAtColumn","includeDeleted","tables","qb","context","repo","baseRepo","originalFindAll","originalFindById","id","sql","record"],"mappings":"yBAoGO,IAAMA,CAAAA,CAAmB,CAACC,CAAAA,CAA6B,EAAC,GAAc,CAC3E,GAAM,CACJ,gBAAAC,CAAAA,CAAkB,YAAA,CAClB,cAAA,CAAAC,CAAAA,CAAiB,KAAA,CACjB,MAAA,CAAAC,CACF,CAAA,CAAIH,EAEJ,OAAO,CACL,IAAA,CAAM,qBAAA,CACN,OAAA,CAAS,OAAA,CAaT,cAAA,CAA2CI,CAAAA,CAAQC,EAAsF,CAKvI,OAAA,CAH2B,CAACF,CAAAA,EAAUA,CAAAA,CAAO,QAAA,CAASE,CAAAA,CAAQ,KAAK,IAKjEA,CAAAA,CAAQ,SAAA,GAAc,QAAA,EACtB,CAACA,CAAAA,CAAQ,QAAA,CAAS,cAAA,EAClB,CAACH,EAQOE,CAAAA,CACL,KAAA,CAAM,CAAA,EAAGC,CAAAA,CAAQ,KAAK,CAAA,CAAA,EAAIJ,CAAe,CAAA,CAAA,CAAa,KAAM,IAAI,CAAA,CAO9DG,CACT,CAAA,CAgBA,gBAAA,CAAmCE,CAAAA,CAAY,CAE7C,IAAMC,EAAWD,CAAAA,CAWjB,GARI,EAAE,WAAA,GAAeC,CAAAA,CAAAA,EAAa,EAAE,UAAA,GAAcA,CAAAA,CAAAA,EAQ9C,EAHuB,CAACJ,CAAAA,EAAUA,CAAAA,CAAO,QAAA,CAASI,CAAAA,CAAS,SAAS,CAAA,CAAA,CAItE,OAAOD,EAIT,IAAME,CAAAA,CAAkBD,CAAAA,CAAS,OAAA,CAAQ,IAAA,CAAKA,CAAQ,CAAA,CAChDE,CAAAA,CAAmBF,EAAS,QAAA,CAAS,IAAA,CAAKA,CAAQ,CAAA,CAoFxD,OAlFqB,CACnB,GAAGA,CAAAA,CAGH,MAAM,OAAA,EAA8B,CAClC,OAAKL,CAAAA,CAQE,MAAMM,CAAAA,EAAgB,CAPZ,MAAMD,EAAS,QAAA,CAC3B,UAAA,CAAWA,CAAAA,CAAS,SAAS,CAAA,CAC7B,SAAA,EAAU,CACV,KAAA,CAAMN,EAA0B,IAAA,CAAM,IAAI,CAAA,CAC1C,OAAA,EAIP,CAAA,CAEA,MAAM,QAAA,CAASS,EAA8B,CAC3C,OAAKR,CAAAA,CASE,MAAMO,CAAAA,CAAiBC,CAAE,CAAA,CARf,MAAMH,EAAS,QAAA,CAC3B,UAAA,CAAWA,CAAAA,CAAS,SAAS,CAAA,CAC7B,SAAA,EAAU,CACV,KAAA,CAAM,IAAA,CAAe,GAAA,CAAKG,CAAW,CAAA,CACrC,KAAA,CAAMT,CAAAA,CAA0B,IAAA,CAAM,IAAI,EAC1C,gBAAA,EAAiB,EACH,IAGrB,CAAA,CAEA,MAAM,UAAA,CAAWS,CAAAA,CAA8B,CAI7C,MAAMH,CAAAA,CAAS,QAAA,CACZ,WAAA,CAAYA,CAAAA,CAAS,SAAS,CAAA,CAC9B,GAAA,CAAI,CAAE,CAACN,CAAe,EAAGU,GAAAA,CAAAA,iBAAAA,CAAuB,CAAU,CAAA,CAC1D,KAAA,CAAM,IAAA,CAAe,GAAA,CAAKD,CAAW,CAAA,CACrC,OAAA,EAAQ,CAGX,IAAME,CAAAA,CAAS,MAAMH,CAAAA,CAAiBC,CAAE,EAGxC,GAAI,CAACE,CAAAA,CACH,MAAM,IAAI,KAAA,CAAM,CAAA,eAAA,EAAkBF,CAAE,YAAY,CAAA,CAGlD,OAAOE,CACT,CAAA,CAEA,MAAM,OAAA,CAAQF,CAAAA,CAA8B,CAC1C,OAAO,MAAMH,CAAAA,CAAS,MAAA,CAAOG,CAAAA,CAAI,CAAE,CAACT,CAAe,EAAG,IAAK,CAAC,CAC9D,CAAA,CAEA,MAAM,UAAA,CAAWS,CAAAA,CAA2B,CAE1C,MAAMH,EAAS,QAAA,CACZ,UAAA,CAAWA,CAAAA,CAAS,SAAS,CAAA,CAC7B,KAAA,CAAM,IAAA,CAAe,GAAA,CAAKG,CAAW,CAAA,CACrC,OAAA,GACL,CAAA,CAEA,MAAM,eAAA,CAAgBA,CAAAA,CAA8B,CAElD,OAAO,MAAMD,CAAAA,CAAiBC,CAAE,CAClC,CAAA,CAEA,MAAM,kBAAA,EAAyC,CAE7C,OAAO,MAAMF,CAAAA,EACf,CAAA,CAEA,MAAM,WAAA,EAAkC,CAMtC,OALe,MAAMD,CAAAA,CAAS,QAAA,CAC3B,UAAA,CAAWA,CAAAA,CAAS,SAAS,CAAA,CAC7B,SAAA,EAAU,CACV,MAAMN,CAAAA,CAA0B,QAAA,CAAU,IAAI,CAAA,CAC9C,OAAA,EAEL,CACF,CAGF,CACF,CACF","file":"index.js","sourcesContent":["import type { Plugin, AnyQueryBuilder } from '@kysera/repository'\nimport type { SelectQueryBuilder, Kysely } from 'kysely'\nimport { sql } from 'kysely'\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 * })\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\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 * @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 } = options\n\n return {\n name: '@kysera/soft-delete',\n version: '1.0.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 interceptQuery<QB extends AnyQueryBuilder>(qb: QB, context: { operation: string; table: string; metadata: Record<string, unknown> }): 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 // Add WHERE deleted_at IS NULL to the query builder\n type GenericSelectQueryBuilder = SelectQueryBuilder<\n Record<string, unknown>,\n string,\n Record<string, unknown>\n >\n return (qb as unknown as GenericSelectQueryBuilder)\n .where(`${context.table}.${deletedAtColumn}` as never, 'is', null) 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 *\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 return repo\n }\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('id' as never, '=', id as never)\n .where(deletedAtColumn as never, 'is', null)\n .executeTakeFirst()\n return result ?? null\n }\n return await originalFindById(id)\n },\n\n async softDelete(id: number): Promise<unknown> {\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('id' as never, '=', id as never)\n .execute()\n\n // Fetch the updated record to verify it exists\n const record = await originalFindById(id)\n\n // If record not found or deleted_at not set, throw error\n if (!record) {\n throw new Error(`Record with id ${id} not found`)\n }\n\n return record\n },\n\n async restore(id: number): Promise<unknown> {\n return await baseRepo.update(id, { [deletedAtColumn]: null })\n },\n\n async hardDelete(id: number): Promise<void> {\n // Direct hard delete - bypass soft delete\n await baseRepo.executor\n .deleteFrom(baseRepo.tableName)\n .where('id' as never, '=', id as never)\n .execute()\n },\n\n async findWithDeleted(id: number): Promise<unknown> {\n // Use original method without filtering\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\n return extendedRepo as T\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","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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kysera/soft-delete",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Soft delete plugin for Kysera ORM",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,22 +15,30 @@
15
15
  "dist",
16
16
  "src"
17
17
  ],
18
- "peerDependencies": {
19
- "kysely": ">=0.28.7"
20
- },
21
18
  "dependencies": {
22
- "@kysera/repository": "0.4.1"
19
+ "@kysera/core": "0.6.0"
23
20
  },
24
21
  "devDependencies": {
25
22
  "@types/better-sqlite3": "^7.6.13",
26
- "@types/node": "^24.6.2",
27
- "@vitest/coverage-v8": "^3.2.4",
28
- "better-sqlite3": "^12.4.1",
29
- "kysely": "^0.28.7",
30
- "tsup": "^8.5.0",
23
+ "@types/node": "^24.10.1",
24
+ "@vitest/coverage-v8": "^4.0.15",
25
+ "better-sqlite3": "^12.5.0",
26
+ "kysely": "^0.28.8",
27
+ "tsup": "^8.5.1",
31
28
  "typescript": "^5.9.3",
32
- "vitest": "^3.2.4",
33
- "zod": "^4.1.11"
29
+ "vitest": "^4.0.15",
30
+ "zod": "^4.1.13",
31
+ "@kysera/repository": "0.6.0"
32
+ },
33
+ "peerDependencies": {
34
+ "kysely": ">=0.28.8",
35
+ "zod": "^4.1.13",
36
+ "@kysera/repository": "0.6.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "zod": {
40
+ "optional": true
41
+ }
34
42
  },
35
43
  "keywords": [
36
44
  "kysely",
@@ -38,17 +46,17 @@
38
46
  "soft-delete",
39
47
  "plugin"
40
48
  ],
41
- "author": "",
49
+ "author": "Kysera Team",
42
50
  "license": "MIT",
43
51
  "repository": {
44
52
  "type": "git",
45
- "url": "git+https://github.com/omnitron/kysera.git",
53
+ "url": "git+https://github.com/kysera-dev/kysera.git",
46
54
  "directory": "packages/soft-delete"
47
55
  },
48
56
  "bugs": {
49
- "url": "https://github.com/omnitron/kysera/issues"
57
+ "url": "https://github.com/kysera-dev/kysera/issues"
50
58
  },
51
- "homepage": "https://github.com/omnitron/kysera#readme",
59
+ "homepage": "https://github.com/kysera-dev/kysera#readme",
52
60
  "publishConfig": {
53
61
  "access": "public"
54
62
  },
package/src/index.ts CHANGED
@@ -1,6 +1,9 @@
1
- import type { Plugin, AnyQueryBuilder } from '@kysera/repository'
2
- import type { SelectQueryBuilder, Kysely } from 'kysely'
3
- import { sql } from 'kysely'
1
+ import type { Plugin, AnyQueryBuilder, Repository } from '@kysera/repository';
2
+ import type { SelectQueryBuilder, Kysely } from 'kysely';
3
+ import { sql } from 'kysely';
4
+ import { NotFoundError, silentLogger } from '@kysera/core';
5
+ import type { KyseraLogger } from '@kysera/core';
6
+ import { z } from 'zod';
4
7
 
5
8
  /**
6
9
  * Configuration options for the soft delete plugin.
@@ -10,7 +13,8 @@ import { sql } from 'kysely'
10
13
  * const plugin = softDeletePlugin({
11
14
  * deletedAtColumn: 'deleted_at',
12
15
  * includeDeleted: false,
13
- * tables: ['users', 'posts'] // Only these tables support soft delete
16
+ * tables: ['users', 'posts'], // Only these tables support soft delete
17
+ * primaryKeyColumn: 'id' // Default primary key column
14
18
  * })
15
19
  * ```
16
20
  */
@@ -20,7 +24,7 @@ export interface SoftDeleteOptions {
20
24
  *
21
25
  * @default 'deleted_at'
22
26
  */
23
- deletedAtColumn?: string
27
+ deletedAtColumn?: string;
24
28
 
25
29
  /**
26
30
  * Include deleted records by default in queries.
@@ -28,7 +32,7 @@ export interface SoftDeleteOptions {
28
32
  *
29
33
  * @default false
30
34
  */
31
- includeDeleted?: boolean
35
+ includeDeleted?: boolean;
32
36
 
33
37
  /**
34
38
  * List of tables that support soft delete.
@@ -36,15 +40,63 @@ export interface SoftDeleteOptions {
36
40
  *
37
41
  * @example ['users', 'posts', 'comments']
38
42
  */
39
- tables?: string[]
43
+ tables?: string[];
44
+
45
+ /**
46
+ * Primary key column name used for identifying records.
47
+ * Tables with different primary key names (uuid, user_id, etc.) can be configured.
48
+ *
49
+ * @default 'id'
50
+ * @example 'uuid', 'user_id', 'post_id'
51
+ */
52
+ primaryKeyColumn?: string;
53
+
54
+ /**
55
+ * Logger for plugin operations.
56
+ * Uses KyseraLogger interface from @kysera/core.
57
+ *
58
+ * @default silentLogger (no output)
59
+ */
60
+ logger?: KyseraLogger;
40
61
  }
41
62
 
63
+ /**
64
+ * Zod schema for SoftDeleteOptions
65
+ * Used for validation and configuration in the kysera-cli
66
+ */
67
+ export const SoftDeleteOptionsSchema = z.object({
68
+ deletedAtColumn: z.string().optional(),
69
+ includeDeleted: z.boolean().optional(),
70
+ tables: z.array(z.string()).optional(),
71
+ primaryKeyColumn: z.string().optional(),
72
+ });
73
+
74
+ /**
75
+ * Methods added to repositories by the soft delete plugin
76
+ */
77
+ export interface SoftDeleteMethods<T> {
78
+ softDelete(id: number | string): Promise<T>;
79
+ restore(id: number | string): Promise<T>;
80
+ hardDelete(id: number | string): Promise<void>;
81
+ findWithDeleted(id: number | string): Promise<T | null>;
82
+ findAllWithDeleted(): Promise<T[]>;
83
+ findDeleted(): Promise<T[]>;
84
+ softDeleteMany(ids: (number | string)[]): Promise<T[]>;
85
+ restoreMany(ids: (number | string)[]): Promise<T[]>;
86
+ hardDeleteMany(ids: (number | string)[]): Promise<void>;
87
+ }
88
+
89
+ /**
90
+ * Repository extended with soft delete methods
91
+ */
92
+ export type SoftDeleteRepository<Entity, DB> = Repository<Entity, DB> & SoftDeleteMethods<Entity>;
93
+
42
94
  interface BaseRepository {
43
- tableName: string
44
- executor: Kysely<Record<string, unknown>>
45
- findAll: () => Promise<unknown[]>
46
- findById: (id: number) => Promise<unknown>
47
- update: (id: number, data: Record<string, unknown>) => Promise<unknown>
95
+ tableName: string;
96
+ executor: Kysely<Record<string, unknown>>;
97
+ findAll: () => Promise<unknown[]>;
98
+ findById: (id: number) => Promise<unknown>;
99
+ update: (id: number, data: Record<string, unknown>) => Promise<unknown>;
48
100
  }
49
101
 
50
102
  /**
@@ -95,6 +147,47 @@ interface BaseRepository {
95
147
  *
96
148
  * This design is intentional for simplicity and explicitness.
97
149
  *
150
+ * ## Transaction Behavior
151
+ *
152
+ * **IMPORTANT**: Soft delete operations respect ACID properties and work correctly with transactions:
153
+ *
154
+ * - ✅ **Commits with transaction**: softDelete/restore operations use the same executor
155
+ * as other repository operations, so they commit together
156
+ * - ✅ **Rolls back with transaction**: If a transaction is rolled back, soft delete
157
+ * operations are also rolled back
158
+ * - ✅ **Atomic operations**: All soft delete operations (including bulk) are atomic
159
+ *
160
+ * ### Correct Transaction Usage
161
+ *
162
+ * ```typescript
163
+ * // ✅ CORRECT: Soft delete is part of transaction
164
+ * await db.transaction().execute(async (trx) => {
165
+ * const repos = createRepositories(trx) // Use transaction executor
166
+ * await repos.users.softDelete(1)
167
+ * await repos.posts.softDeleteMany([1, 2, 3])
168
+ * // If transaction rolls back, both operations roll back
169
+ * })
170
+ * ```
171
+ *
172
+ * ### Cascade Soft Delete Pattern
173
+ *
174
+ * For related entities, you need to manually implement cascade soft delete:
175
+ *
176
+ * ```typescript
177
+ * // Cascade soft delete pattern
178
+ * await db.transaction().execute(async (trx) => {
179
+ * const repos = createRepositories(trx)
180
+ * const userId = 123
181
+ *
182
+ * // First, soft delete child records
183
+ * const userPosts = await repos.posts.findBy({ user_id: userId })
184
+ * await repos.posts.softDeleteMany(userPosts.map(p => p.id))
185
+ *
186
+ * // Then, soft delete parent
187
+ * await repos.users.softDelete(userId)
188
+ * })
189
+ * ```
190
+ *
98
191
  * @param options - Configuration options for soft delete behavior
99
192
  * @returns Plugin instance that can be used with createORM
100
193
  */
@@ -102,12 +195,14 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
102
195
  const {
103
196
  deletedAtColumn = 'deleted_at',
104
197
  includeDeleted = false,
105
- tables
106
- } = options
198
+ tables,
199
+ primaryKeyColumn = 'id',
200
+ logger = silentLogger,
201
+ } = options;
107
202
 
108
203
  return {
109
204
  name: '@kysera/soft-delete',
110
- version: '1.0.0',
205
+ version: '0.5.1',
111
206
 
112
207
  /**
113
208
  * Intercept queries to automatically filter soft-deleted records.
@@ -120,9 +215,12 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
120
215
  *
121
216
  * This approach is simpler and more explicit than full query interception.
122
217
  */
123
- interceptQuery<QB extends AnyQueryBuilder>(qb: QB, context: { operation: string; table: string; metadata: Record<string, unknown> }): QB {
218
+ interceptQuery<QB extends AnyQueryBuilder>(
219
+ qb: QB,
220
+ context: { operation: string; table: string; metadata: Record<string, unknown> }
221
+ ): QB {
124
222
  // Check if table supports soft delete
125
- const supportsSoftDelete = !tables || tables.includes(context.table)
223
+ const supportsSoftDelete = !tables || tables.includes(context.table);
126
224
 
127
225
  // Only filter SELECT queries when not explicitly including deleted
128
226
  if (
@@ -131,21 +229,21 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
131
229
  !context.metadata['includeDeleted'] &&
132
230
  !includeDeleted
133
231
  ) {
232
+ logger.debug(`Filtering soft-deleted records from ${context.table}`);
134
233
  // Add WHERE deleted_at IS NULL to the query builder
135
- type GenericSelectQueryBuilder = SelectQueryBuilder<
136
- Record<string, unknown>,
137
- string,
138
- Record<string, unknown>
139
- >
140
- return (qb as unknown as GenericSelectQueryBuilder)
141
- .where(`${context.table}.${deletedAtColumn}` as never, 'is', null) as QB
234
+ type GenericSelectQueryBuilder = SelectQueryBuilder<Record<string, unknown>, string, Record<string, unknown>>;
235
+ return (qb as unknown as GenericSelectQueryBuilder).where(
236
+ `${context.table}.${deletedAtColumn}` as never,
237
+ 'is',
238
+ null
239
+ ) as QB;
142
240
  }
143
241
 
144
242
  // Note: DELETE operations are NOT intercepted here
145
243
  // Use softDelete() method instead of delete() to perform soft deletes
146
244
  // This is by design - method override is simpler and more explicit
147
245
 
148
- return qb
246
+ return qb;
149
247
  },
150
248
 
151
249
  /**
@@ -158,30 +256,36 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
158
256
  * - findWithDeleted(id): Find a record including soft-deleted ones
159
257
  * - findAllWithDeleted(): Find all records including soft-deleted ones
160
258
  * - findDeleted(): Find only soft-deleted records
259
+ * - softDeleteMany(ids): Soft delete multiple records (bulk operation)
260
+ * - restoreMany(ids): Restore multiple soft-deleted records (bulk operation)
261
+ * - hardDeleteMany(ids): Permanently delete multiple records (bulk operation)
161
262
  *
162
263
  * Also overrides findAll() and findById() to automatically filter out
163
264
  * soft-deleted records (unless includeDeleted option is set).
164
265
  */
165
266
  extendRepository<T extends object>(repo: T): T {
166
267
  // Type assertion is safe here as we're checking for BaseRepository properties
167
- const baseRepo = repo as unknown as BaseRepository
268
+ const baseRepo = repo as unknown as BaseRepository;
168
269
 
169
270
  // Check if it's actually a repository (has required properties)
170
271
  if (!('tableName' in baseRepo) || !('executor' in baseRepo)) {
171
- return repo
272
+ return repo;
172
273
  }
173
274
 
174
275
  // Check if table supports soft delete
175
- const supportsSoftDelete = !tables || tables.includes(baseRepo.tableName)
276
+ const supportsSoftDelete = !tables || tables.includes(baseRepo.tableName);
176
277
 
177
278
  // If table doesn't support soft delete, return unmodified repo
178
279
  if (!supportsSoftDelete) {
179
- return repo
280
+ logger.debug(`Table ${baseRepo.tableName} does not support soft delete, skipping extension`);
281
+ return repo;
180
282
  }
181
283
 
284
+ logger.debug(`Extending repository for table ${baseRepo.tableName} with soft delete methods`);
285
+
182
286
  // Wrap original methods to apply soft delete filtering
183
- const originalFindAll = baseRepo.findAll.bind(baseRepo)
184
- const originalFindById = baseRepo.findById.bind(baseRepo)
287
+ const originalFindAll = baseRepo.findAll.bind(baseRepo);
288
+ const originalFindById = baseRepo.findById.bind(baseRepo);
185
289
 
186
290
  const extendedRepo = {
187
291
  ...baseRepo,
@@ -193,10 +297,10 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
193
297
  .selectFrom(baseRepo.tableName)
194
298
  .selectAll()
195
299
  .where(deletedAtColumn as never, 'is', null)
196
- .execute()
197
- return result as unknown[]
300
+ .execute();
301
+ return result as unknown[];
198
302
  }
199
- return await originalFindAll()
303
+ return await originalFindAll();
200
304
  },
201
305
 
202
306
  async findById(id: number): Promise<unknown> {
@@ -204,55 +308,111 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
204
308
  const result = await baseRepo.executor
205
309
  .selectFrom(baseRepo.tableName)
206
310
  .selectAll()
207
- .where('id' as never, '=', id as never)
311
+ .where(primaryKeyColumn as never, '=', id as never)
208
312
  .where(deletedAtColumn as never, 'is', null)
209
- .executeTakeFirst()
210
- return result ?? null
313
+ .executeTakeFirst();
314
+ return result ?? null;
211
315
  }
212
- return await originalFindById(id)
316
+ // When includeDeleted is true, use custom PK column if configured
317
+ // Otherwise fall back to original method (which uses 'id')
318
+ if (primaryKeyColumn !== 'id') {
319
+ const result = await baseRepo.executor
320
+ .selectFrom(baseRepo.tableName)
321
+ .selectAll()
322
+ .where(primaryKeyColumn as never, '=', id as never)
323
+ .executeTakeFirst();
324
+ return result ?? null;
325
+ }
326
+ return await originalFindById(id);
213
327
  },
214
328
 
215
329
  async softDelete(id: number): Promise<unknown> {
330
+ logger.info(`Soft deleting record ${id} from ${baseRepo.tableName}`);
216
331
  // Use CURRENT_TIMESTAMP directly in SQL to avoid datetime format issues
217
332
  // This works across all databases (MySQL, PostgreSQL, SQLite)
218
333
  // We bypass repository.update() to avoid Zod validation issues with RawBuilder
219
334
  await baseRepo.executor
220
335
  .updateTable(baseRepo.tableName)
221
336
  .set({ [deletedAtColumn]: sql`CURRENT_TIMESTAMP` } as never)
222
- .where('id' as never, '=', id as never)
223
- .execute()
337
+ .where(primaryKeyColumn as never, '=', id as never)
338
+ .execute();
224
339
 
225
340
  // Fetch the updated record to verify it exists
226
- const record = await originalFindById(id)
341
+ // Use custom PK column if configured
342
+ let record;
343
+ if (primaryKeyColumn !== 'id') {
344
+ record = await baseRepo.executor
345
+ .selectFrom(baseRepo.tableName)
346
+ .selectAll()
347
+ .where(primaryKeyColumn as never, '=', id as never)
348
+ .executeTakeFirst();
349
+ } else {
350
+ record = await originalFindById(id);
351
+ }
227
352
 
228
353
  // If record not found or deleted_at not set, throw error
229
354
  if (!record) {
230
- throw new Error(`Record with id ${id} not found`)
355
+ logger.warn(`Record ${id} not found in ${baseRepo.tableName} for soft delete`);
356
+ throw new NotFoundError('Record', { id });
231
357
  }
232
358
 
233
- return record
359
+ return record;
234
360
  },
235
361
 
236
362
  async restore(id: number): Promise<unknown> {
237
- return await baseRepo.update(id, { [deletedAtColumn]: null })
363
+ logger.info(`Restoring soft-deleted record ${id} from ${baseRepo.tableName}`);
364
+ // Use direct executor to support custom primary key columns
365
+ await baseRepo.executor
366
+ .updateTable(baseRepo.tableName)
367
+ .set({ [deletedAtColumn]: null } as never)
368
+ .where(primaryKeyColumn as never, '=', id as never)
369
+ .execute();
370
+
371
+ // Fetch and return the restored record
372
+ let record;
373
+ if (primaryKeyColumn !== 'id') {
374
+ record = await baseRepo.executor
375
+ .selectFrom(baseRepo.tableName)
376
+ .selectAll()
377
+ .where(primaryKeyColumn as never, '=', id as never)
378
+ .executeTakeFirst();
379
+ } else {
380
+ record = await originalFindById(id);
381
+ }
382
+
383
+ if (!record) {
384
+ logger.warn(`Record ${id} not found in ${baseRepo.tableName} for restore`);
385
+ throw new NotFoundError('Record', { id });
386
+ }
387
+
388
+ return record;
238
389
  },
239
390
 
240
391
  async hardDelete(id: number): Promise<void> {
392
+ logger.info(`Hard deleting record ${id} from ${baseRepo.tableName}`);
241
393
  // Direct hard delete - bypass soft delete
242
394
  await baseRepo.executor
243
395
  .deleteFrom(baseRepo.tableName)
244
- .where('id' as never, '=', id as never)
245
- .execute()
396
+ .where(primaryKeyColumn as never, '=', id as never)
397
+ .execute();
246
398
  },
247
399
 
248
400
  async findWithDeleted(id: number): Promise<unknown> {
249
- // Use original method without filtering
250
- return await originalFindById(id)
401
+ // Use custom PK column if configured, otherwise use original method
402
+ if (primaryKeyColumn !== 'id') {
403
+ const result = await baseRepo.executor
404
+ .selectFrom(baseRepo.tableName)
405
+ .selectAll()
406
+ .where(primaryKeyColumn as never, '=', id as never)
407
+ .executeTakeFirst();
408
+ return result ?? null;
409
+ }
410
+ return await originalFindById(id);
251
411
  },
252
412
 
253
413
  async findAllWithDeleted(): Promise<unknown[]> {
254
414
  // Use original method without filtering
255
- return await originalFindAll()
415
+ return await originalFindAll();
256
416
  },
257
417
 
258
418
  async findDeleted(): Promise<unknown[]> {
@@ -260,12 +420,85 @@ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
260
420
  .selectFrom(baseRepo.tableName)
261
421
  .selectAll()
262
422
  .where(deletedAtColumn as never, 'is not', null)
263
- .execute()
264
- return result as unknown[]
265
- }
266
- }
423
+ .execute();
424
+ return result as unknown[];
425
+ },
426
+
427
+ async softDeleteMany(ids: (number | string)[]): Promise<unknown[]> {
428
+ // Handle empty arrays gracefully
429
+ if (ids.length === 0) {
430
+ return [];
431
+ }
432
+
433
+ logger.info(`Soft deleting ${ids.length} records from ${baseRepo.tableName}`);
434
+
435
+ // Efficient bulk UPDATE query
436
+ await baseRepo.executor
437
+ .updateTable(baseRepo.tableName)
438
+ .set({ [deletedAtColumn]: sql`CURRENT_TIMESTAMP` } as never)
439
+ .where(primaryKeyColumn as never, 'in', ids as never)
440
+ .execute();
441
+
442
+ // Fetch all affected records to verify and return them
443
+ const records = await baseRepo.executor
444
+ .selectFrom(baseRepo.tableName)
445
+ .selectAll()
446
+ .where(primaryKeyColumn as never, 'in', ids as never)
447
+ .execute();
267
448
 
268
- return extendedRepo as T
269
- }
270
- }
271
- }
449
+ // Verify all records were found
450
+ if (records.length !== ids.length) {
451
+ const foundIds = records.map((r: any) => r[primaryKeyColumn]);
452
+ const missingIds = ids.filter((id) => !foundIds.includes(id));
453
+ logger.warn(`Some records not found for soft delete: ${missingIds.join(', ')}`);
454
+ throw new NotFoundError('Records', { ids: missingIds });
455
+ }
456
+
457
+ return records as unknown[];
458
+ },
459
+
460
+ async restoreMany(ids: (number | string)[]): Promise<unknown[]> {
461
+ // Handle empty arrays gracefully
462
+ if (ids.length === 0) {
463
+ return [];
464
+ }
465
+
466
+ logger.info(`Restoring ${ids.length} soft-deleted records from ${baseRepo.tableName}`);
467
+
468
+ // Efficient bulk UPDATE query to restore records
469
+ await baseRepo.executor
470
+ .updateTable(baseRepo.tableName)
471
+ .set({ [deletedAtColumn]: null } as never)
472
+ .where(primaryKeyColumn as never, 'in', ids as never)
473
+ .execute();
474
+
475
+ // Fetch all affected records to return them
476
+ const records = await baseRepo.executor
477
+ .selectFrom(baseRepo.tableName)
478
+ .selectAll()
479
+ .where(primaryKeyColumn as never, 'in', ids as never)
480
+ .execute();
481
+
482
+ return records as unknown[];
483
+ },
484
+
485
+ async hardDeleteMany(ids: (number | string)[]): Promise<void> {
486
+ // Handle empty arrays gracefully
487
+ if (ids.length === 0) {
488
+ return;
489
+ }
490
+
491
+ logger.info(`Hard deleting ${ids.length} records from ${baseRepo.tableName}`);
492
+
493
+ // Efficient bulk DELETE query
494
+ await baseRepo.executor
495
+ .deleteFrom(baseRepo.tableName)
496
+ .where(primaryKeyColumn as never, 'in', ids as never)
497
+ .execute();
498
+ },
499
+ };
500
+
501
+ return extendedRepo as T;
502
+ },
503
+ };
504
+ };