@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 +137 -30
- package/dist/index.d.ts +90 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +24 -16
- package/src/index.ts +290 -57
package/README.md
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
| Metric | Value |
|
|
12
12
|
|--------|-------|
|
|
13
|
-
| **Version** | 0.
|
|
14
|
-
| **Bundle Size** |
|
|
15
|
-
| **Test Coverage** | 39 tests passing |
|
|
16
|
-
| **Dependencies** | @kysera/
|
|
17
|
-
| **Peer Dependencies** | kysely >=0.28.
|
|
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
|
-
###
|
|
675
|
+
### Batch Operations
|
|
676
|
+
|
|
677
|
+
The plugin provides efficient batch operations for handling multiple records:
|
|
638
678
|
|
|
639
679
|
```typescript
|
|
640
|
-
//
|
|
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/
|
|
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/
|
|
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
|
|
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":"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","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.
|
|
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/
|
|
19
|
+
"@kysera/core": "0.6.0"
|
|
23
20
|
},
|
|
24
21
|
"devDependencies": {
|
|
25
22
|
"@types/better-sqlite3": "^7.6.13",
|
|
26
|
-
"@types/node": "^24.
|
|
27
|
-
"@vitest/coverage-v8": "^
|
|
28
|
-
"better-sqlite3": "^12.
|
|
29
|
-
"kysely": "^0.28.
|
|
30
|
-
"tsup": "^8.5.
|
|
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": "^
|
|
33
|
-
"zod": "^4.1.
|
|
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/
|
|
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/
|
|
57
|
+
"url": "https://github.com/kysera-dev/kysera/issues"
|
|
50
58
|
},
|
|
51
|
-
"homepage": "https://github.com/
|
|
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
|
-
|
|
198
|
+
tables,
|
|
199
|
+
primaryKeyColumn = 'id',
|
|
200
|
+
logger = silentLogger,
|
|
201
|
+
} = options;
|
|
107
202
|
|
|
108
203
|
return {
|
|
109
204
|
name: '@kysera/soft-delete',
|
|
110
|
-
version: '
|
|
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>(
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
250
|
-
|
|
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
|
-
|
|
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
|
+
};
|