@kysera/soft-delete 0.7.2 → 0.7.4
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 +210 -193
- package/dist/index.d.ts +20 -15
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +41 -0
- package/dist/schema.js +2 -0
- package/dist/schema.js.map +1 -0
- package/package.json +11 -8
- package/src/index.ts +154 -126
- package/src/schema.ts +40 -0
- package/src/version.ts +7 -0
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# @kysera/soft-delete
|
|
2
2
|
|
|
3
|
-
Soft delete plugin for Kysera. Implements soft delete functionality
|
|
3
|
+
Soft delete plugin for Kysera (v0.7.3). Implements soft delete functionality through @kysera/executor's Unified Execution Layer with automatic filtering of deleted records.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- Automatic filtering of soft-deleted records in SELECT queries
|
|
7
|
+
- Automatic filtering of soft-deleted records in SELECT queries via @kysera/executor's plugin interception
|
|
8
8
|
- Repository methods for soft delete operations (softDelete, restore, hardDelete)
|
|
9
|
-
- Bulk operations (softDeleteMany, restoreMany, hardDeleteMany)
|
|
9
|
+
- Bulk operations (softDeleteMany, restoreMany, hardDeleteMany) with optimized single-query fetching
|
|
10
10
|
- Query methods for deleted records (findWithDeleted, findAllWithDeleted, findDeleted)
|
|
11
|
-
- Works with both Repository and DAL patterns through
|
|
11
|
+
- Works with both Repository and DAL patterns through @kysera/executor's Unified Execution Layer
|
|
12
12
|
- Full transaction support with ACID compliance
|
|
13
13
|
- Configurable deleted column name, primary key, and table filtering
|
|
14
14
|
- Cross-runtime compatible (Node.js, Bun, Deno)
|
|
@@ -38,112 +38,141 @@ bun add @kysera/soft-delete
|
|
|
38
38
|
|
|
39
39
|
Note: `zod` is optional (used for configuration schema validation in `kysera-cli`)
|
|
40
40
|
|
|
41
|
+
### Optional Zod Schema Validation
|
|
42
|
+
|
|
43
|
+
If you need to validate configuration options (e.g., in a CLI tool or config file), you can import the Zod schema separately:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { SoftDeleteOptionsSchema } from '@kysera/soft-delete/schema'
|
|
47
|
+
|
|
48
|
+
const result = SoftDeleteOptionsSchema.safeParse({
|
|
49
|
+
deletedAtColumn: 'deleted_at',
|
|
50
|
+
includeDeleted: false,
|
|
51
|
+
tables: ['users', 'posts']
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
if (result.success) {
|
|
55
|
+
console.log('Valid configuration:', result.data)
|
|
56
|
+
} else {
|
|
57
|
+
console.error('Invalid configuration:', result.error)
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Important**: The main package (`@kysera/soft-delete`) works without Zod installed. Only import `/schema` if you need validation functionality.
|
|
62
|
+
|
|
41
63
|
## Quick Start
|
|
42
64
|
|
|
43
65
|
### With Repository Pattern
|
|
44
66
|
|
|
45
67
|
```typescript
|
|
46
|
-
import { createORM } from '@kysera/repository'
|
|
47
|
-
import { softDeletePlugin } from '@kysera/soft-delete'
|
|
68
|
+
import { createORM } from '@kysera/repository'
|
|
69
|
+
import { softDeletePlugin } from '@kysera/soft-delete'
|
|
70
|
+
import { createExecutor } from '@kysera/executor'
|
|
48
71
|
|
|
49
|
-
// Create
|
|
50
|
-
const
|
|
72
|
+
// Step 1: Create executor with soft-delete plugin
|
|
73
|
+
const executor = await createExecutor(db, [
|
|
51
74
|
softDeletePlugin({
|
|
52
75
|
deletedAtColumn: 'deleted_at',
|
|
53
76
|
includeDeleted: false,
|
|
54
77
|
tables: ['users', 'posts'] // Only these tables support soft delete
|
|
55
78
|
})
|
|
56
|
-
])
|
|
79
|
+
])
|
|
57
80
|
|
|
58
|
-
// Create
|
|
59
|
-
const
|
|
81
|
+
// Step 2: Create ORM with plugin-enabled executor
|
|
82
|
+
const orm = await createORM(executor, [])
|
|
83
|
+
|
|
84
|
+
// Step 3: Create repository
|
|
85
|
+
const userRepo = orm.createRepository(createUserRepository)
|
|
60
86
|
|
|
61
87
|
// Soft delete a user (sets deleted_at timestamp)
|
|
62
|
-
await userRepo.softDelete(1)
|
|
88
|
+
await userRepo.softDelete(1)
|
|
63
89
|
|
|
64
90
|
// Find all users (excludes soft-deleted automatically)
|
|
65
|
-
const users = await userRepo.findAll()
|
|
91
|
+
const users = await userRepo.findAll()
|
|
66
92
|
|
|
67
93
|
// Find including deleted records
|
|
68
|
-
const allUsers = await userRepo.findAllWithDeleted()
|
|
94
|
+
const allUsers = await userRepo.findAllWithDeleted()
|
|
69
95
|
|
|
70
96
|
// Restore a soft-deleted user
|
|
71
|
-
await userRepo.restore(1)
|
|
97
|
+
await userRepo.restore(1)
|
|
72
98
|
|
|
73
99
|
// Permanently delete (real DELETE)
|
|
74
|
-
await userRepo.hardDelete(1)
|
|
100
|
+
await userRepo.hardDelete(1)
|
|
101
|
+
|
|
102
|
+
// Batch operations (optimized single-query fetching)
|
|
103
|
+
await userRepo.softDeleteMany([1, 2, 3])
|
|
104
|
+
await userRepo.restoreMany([1, 2, 3])
|
|
105
|
+
await userRepo.hardDeleteMany([1, 2, 3])
|
|
75
106
|
```
|
|
76
107
|
|
|
77
108
|
### With DAL Pattern
|
|
78
109
|
|
|
79
110
|
```typescript
|
|
80
|
-
import { createExecutor } from '@kysera/executor'
|
|
81
|
-
import { createContext, createQuery, withTransaction } from '@kysera/dal'
|
|
82
|
-
import { softDeletePlugin } from '@kysera/soft-delete'
|
|
83
|
-
import { sql } from 'kysely'
|
|
111
|
+
import { createExecutor } from '@kysera/executor'
|
|
112
|
+
import { createContext, createQuery, withTransaction } from '@kysera/dal'
|
|
113
|
+
import { softDeletePlugin } from '@kysera/soft-delete'
|
|
114
|
+
import { sql } from 'kysely'
|
|
84
115
|
|
|
85
|
-
// Create executor with soft-delete plugin
|
|
116
|
+
// Step 1: Create executor with soft-delete plugin (Unified Execution Layer)
|
|
86
117
|
const executor = await createExecutor(db, [
|
|
87
118
|
softDeletePlugin({
|
|
88
119
|
deletedAtColumn: 'deleted_at',
|
|
89
120
|
includeDeleted: false
|
|
90
121
|
})
|
|
91
|
-
])
|
|
122
|
+
])
|
|
92
123
|
|
|
93
|
-
// Create context
|
|
94
|
-
const ctx = createContext(executor)
|
|
124
|
+
// Step 2: Create context - plugins automatically apply to all queries
|
|
125
|
+
const ctx = createContext(executor)
|
|
95
126
|
|
|
96
|
-
// Define queries - soft-delete filter applied automatically
|
|
97
|
-
const getUsers = createQuery(
|
|
98
|
-
ctx.db.selectFrom('users').selectAll().execute()
|
|
99
|
-
);
|
|
127
|
+
// Step 3: Define queries - soft-delete filter applied automatically
|
|
128
|
+
const getUsers = createQuery(ctx => ctx.db.selectFrom('users').selectAll().execute())
|
|
100
129
|
|
|
101
130
|
const getUserById = createQuery((ctx, id: number) =>
|
|
102
|
-
ctx.db
|
|
103
|
-
|
|
104
|
-
.selectAll()
|
|
105
|
-
.where('id', '=', id)
|
|
106
|
-
.executeTakeFirst()
|
|
107
|
-
);
|
|
131
|
+
ctx.db.selectFrom('users').selectAll().where('id', '=', id).executeTakeFirst()
|
|
132
|
+
)
|
|
108
133
|
|
|
109
134
|
// Execute queries - deleted records automatically filtered
|
|
110
|
-
const users = await getUsers(ctx)
|
|
111
|
-
const user = await getUserById(ctx, 1)
|
|
135
|
+
const users = await getUsers(ctx) // Excludes soft-deleted
|
|
136
|
+
const user = await getUserById(ctx, 1)
|
|
112
137
|
|
|
113
138
|
// Soft delete within transaction
|
|
114
|
-
await withTransaction(executor, async
|
|
139
|
+
await withTransaction(executor, async txCtx => {
|
|
115
140
|
await txCtx.db
|
|
116
141
|
.updateTable('users')
|
|
117
142
|
.set({ deleted_at: sql`CURRENT_TIMESTAMP` })
|
|
118
143
|
.where('id', '=', 1)
|
|
119
|
-
.execute()
|
|
144
|
+
.execute()
|
|
120
145
|
|
|
121
146
|
// Subsequent queries in same transaction see the deletion
|
|
122
|
-
const users = await getUsers(txCtx)
|
|
123
|
-
})
|
|
147
|
+
const users = await getUsers(txCtx) // User 1 excluded
|
|
148
|
+
})
|
|
124
149
|
```
|
|
125
150
|
|
|
126
151
|
## Plugin Architecture
|
|
127
152
|
|
|
128
|
-
The soft-delete plugin leverages `@kysera/executor` for
|
|
153
|
+
The soft-delete plugin leverages `@kysera/executor`'s Unified Execution Layer for seamless plugin support across both Repository and DAL patterns.
|
|
129
154
|
|
|
130
155
|
### How It Works
|
|
131
156
|
|
|
132
157
|
```typescript
|
|
133
|
-
import { createExecutor, getRawDb } from '@kysera/executor'
|
|
134
|
-
import type { Plugin, QueryBuilderContext } from '@kysera/executor'
|
|
158
|
+
import { createExecutor, getRawDb } from '@kysera/executor'
|
|
159
|
+
import type { Plugin, QueryBuilderContext } from '@kysera/executor'
|
|
135
160
|
|
|
136
|
-
//
|
|
137
|
-
const
|
|
161
|
+
// Step 1: Register plugin with createExecutor() - Unified Execution Layer
|
|
162
|
+
const executor = await createExecutor(db, [
|
|
163
|
+
softDeletePlugin({
|
|
164
|
+
deletedAtColumn: 'deleted_at',
|
|
165
|
+
includeDeleted: false
|
|
166
|
+
})
|
|
167
|
+
])
|
|
138
168
|
|
|
139
|
-
//
|
|
140
|
-
const
|
|
169
|
+
// Step 2: Plugin interceptQuery hook adds WHERE clause automatically
|
|
170
|
+
const users = await executor.selectFrom('users').selectAll().execute()
|
|
171
|
+
// SQL: SELECT * FROM users WHERE users.deleted_at IS NULL
|
|
141
172
|
|
|
142
|
-
//
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
.selectAll()
|
|
146
|
-
.execute(); // WHERE users.deleted_at IS NULL (added automatically)
|
|
173
|
+
// Step 3: Works with both Repository and DAL patterns
|
|
174
|
+
const orm = await createORM(executor, [])
|
|
175
|
+
const ctx = createContext(executor)
|
|
147
176
|
```
|
|
148
177
|
|
|
149
178
|
### Plugin Interface
|
|
@@ -152,10 +181,10 @@ The plugin implements the `Plugin` interface from `@kysera/executor`:
|
|
|
152
181
|
|
|
153
182
|
```typescript
|
|
154
183
|
interface Plugin {
|
|
155
|
-
name: string
|
|
156
|
-
version: string
|
|
157
|
-
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB
|
|
158
|
-
extendRepository<T extends object>(repo: T): T
|
|
184
|
+
name: string
|
|
185
|
+
version: string
|
|
186
|
+
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB
|
|
187
|
+
extendRepository<T extends object>(repo: T): T
|
|
159
188
|
}
|
|
160
189
|
```
|
|
161
190
|
|
|
@@ -199,17 +228,14 @@ extendRepository<T extends object>(repo: T): T {
|
|
|
199
228
|
The plugin uses `getRawDb()` from `@kysera/executor` to bypass interceptors when needed:
|
|
200
229
|
|
|
201
230
|
```typescript
|
|
202
|
-
import { getRawDb } from '@kysera/executor'
|
|
231
|
+
import { getRawDb } from '@kysera/executor'
|
|
203
232
|
|
|
204
233
|
// Inside plugin's extendRepository method
|
|
205
|
-
const rawDb = getRawDb(repo.executor)
|
|
234
|
+
const rawDb = getRawDb(repo.executor)
|
|
206
235
|
|
|
207
236
|
// Use rawDb to bypass soft-delete filter
|
|
208
237
|
// (needed for findWithDeleted, restore, etc.)
|
|
209
|
-
const allRecords = await rawDb
|
|
210
|
-
.selectFrom('users')
|
|
211
|
-
.selectAll()
|
|
212
|
-
.execute(); // No soft-delete filter applied
|
|
238
|
+
const allRecords = await rawDb.selectFrom('users').selectAll().execute() // No soft-delete filter applied
|
|
213
239
|
```
|
|
214
240
|
|
|
215
241
|
This is critical for methods like `findWithDeleted()` and `restore()` that need to access soft-deleted records.
|
|
@@ -224,35 +250,35 @@ interface SoftDeleteOptions {
|
|
|
224
250
|
* Column name for soft delete timestamp.
|
|
225
251
|
* @default 'deleted_at'
|
|
226
252
|
*/
|
|
227
|
-
deletedAtColumn?: string
|
|
253
|
+
deletedAtColumn?: string
|
|
228
254
|
|
|
229
255
|
/**
|
|
230
256
|
* Include deleted records by default in queries.
|
|
231
257
|
* When false, soft-deleted records are automatically filtered out.
|
|
232
258
|
* @default false
|
|
233
259
|
*/
|
|
234
|
-
includeDeleted?: boolean
|
|
260
|
+
includeDeleted?: boolean
|
|
235
261
|
|
|
236
262
|
/**
|
|
237
263
|
* List of tables that support soft delete.
|
|
238
264
|
* If not provided, all tables are assumed to support it.
|
|
239
265
|
* @example ['users', 'posts', 'comments']
|
|
240
266
|
*/
|
|
241
|
-
tables?: string[]
|
|
267
|
+
tables?: string[]
|
|
242
268
|
|
|
243
269
|
/**
|
|
244
270
|
* Primary key column name used for identifying records.
|
|
245
271
|
* @default 'id'
|
|
246
272
|
* @example 'uuid', 'user_id', 'post_id'
|
|
247
273
|
*/
|
|
248
|
-
primaryKeyColumn?: string
|
|
274
|
+
primaryKeyColumn?: string
|
|
249
275
|
|
|
250
276
|
/**
|
|
251
277
|
* Logger for plugin operations.
|
|
252
278
|
* Uses KyseraLogger interface from @kysera/core.
|
|
253
279
|
* @default silentLogger (no output)
|
|
254
280
|
*/
|
|
255
|
-
logger?: KyseraLogger
|
|
281
|
+
logger?: KyseraLogger
|
|
256
282
|
}
|
|
257
283
|
```
|
|
258
284
|
|
|
@@ -260,35 +286,35 @@ interface SoftDeleteOptions {
|
|
|
260
286
|
|
|
261
287
|
```typescript
|
|
262
288
|
// Default configuration
|
|
263
|
-
softDeletePlugin()
|
|
289
|
+
softDeletePlugin()
|
|
264
290
|
|
|
265
291
|
// Custom deleted column
|
|
266
292
|
softDeletePlugin({
|
|
267
293
|
deletedAtColumn: 'removed_at'
|
|
268
|
-
})
|
|
294
|
+
})
|
|
269
295
|
|
|
270
296
|
// Only specific tables
|
|
271
297
|
softDeletePlugin({
|
|
272
298
|
tables: ['users', 'posts'], // Only these tables support soft delete
|
|
273
299
|
deletedAtColumn: 'deleted_at'
|
|
274
|
-
})
|
|
300
|
+
})
|
|
275
301
|
|
|
276
302
|
// Include deleted by default
|
|
277
303
|
softDeletePlugin({
|
|
278
304
|
includeDeleted: true // Don't filter deleted records
|
|
279
|
-
})
|
|
305
|
+
})
|
|
280
306
|
|
|
281
307
|
// Custom primary key
|
|
282
308
|
softDeletePlugin({
|
|
283
309
|
primaryKeyColumn: 'uuid' // For tables using 'uuid' instead of 'id'
|
|
284
|
-
})
|
|
310
|
+
})
|
|
285
311
|
|
|
286
312
|
// With logging
|
|
287
|
-
import { consoleLogger } from '@kysera/core'
|
|
313
|
+
import { consoleLogger } from '@kysera/core'
|
|
288
314
|
|
|
289
315
|
softDeletePlugin({
|
|
290
316
|
logger: consoleLogger
|
|
291
|
-
})
|
|
317
|
+
})
|
|
292
318
|
```
|
|
293
319
|
|
|
294
320
|
## Repository Methods
|
|
@@ -299,15 +325,15 @@ The plugin extends repositories with the following methods:
|
|
|
299
325
|
|
|
300
326
|
```typescript
|
|
301
327
|
interface SoftDeleteMethods<T> {
|
|
302
|
-
softDelete(id: number | string): Promise<T
|
|
303
|
-
restore(id: number | string): Promise<T
|
|
304
|
-
hardDelete(id: number | string): Promise<void
|
|
305
|
-
findWithDeleted(id: number | string): Promise<T | null
|
|
306
|
-
findAllWithDeleted(): Promise<T[]
|
|
307
|
-
findDeleted(): Promise<T[]
|
|
308
|
-
softDeleteMany(ids: (number | string)[]): Promise<T[]
|
|
309
|
-
restoreMany(ids: (number | string)[]): Promise<T[]
|
|
310
|
-
hardDeleteMany(ids: (number | string)[]): Promise<void
|
|
328
|
+
softDelete(id: number | string): Promise<T>
|
|
329
|
+
restore(id: number | string): Promise<T>
|
|
330
|
+
hardDelete(id: number | string): Promise<void>
|
|
331
|
+
findWithDeleted(id: number | string): Promise<T | null>
|
|
332
|
+
findAllWithDeleted(): Promise<T[]>
|
|
333
|
+
findDeleted(): Promise<T[]>
|
|
334
|
+
softDeleteMany(ids: (number | string)[]): Promise<T[]>
|
|
335
|
+
restoreMany(ids: (number | string)[]): Promise<T[]>
|
|
336
|
+
hardDeleteMany(ids: (number | string)[]): Promise<void>
|
|
311
337
|
}
|
|
312
338
|
```
|
|
313
339
|
|
|
@@ -319,11 +345,11 @@ Marks a record as deleted by setting the `deleted_at` timestamp to `CURRENT_TIME
|
|
|
319
345
|
|
|
320
346
|
```typescript
|
|
321
347
|
// Soft delete user with id 1
|
|
322
|
-
const deletedUser = await userRepo.softDelete(1)
|
|
323
|
-
console.log(deletedUser.deleted_at)
|
|
348
|
+
const deletedUser = await userRepo.softDelete(1)
|
|
349
|
+
console.log(deletedUser.deleted_at) // '2025-12-11T10:30:00Z'
|
|
324
350
|
|
|
325
351
|
// Record still exists in database but won't appear in findAll()
|
|
326
|
-
const users = await userRepo.findAll()
|
|
352
|
+
const users = await userRepo.findAll() // Excludes deleted user
|
|
327
353
|
```
|
|
328
354
|
|
|
329
355
|
**Returns**: `Promise<T>` - The soft-deleted record
|
|
@@ -335,11 +361,11 @@ Restores a soft-deleted record by setting `deleted_at` to `null`.
|
|
|
335
361
|
|
|
336
362
|
```typescript
|
|
337
363
|
// Restore soft-deleted user
|
|
338
|
-
const restoredUser = await userRepo.restore(1)
|
|
339
|
-
console.log(restoredUser.deleted_at)
|
|
364
|
+
const restoredUser = await userRepo.restore(1)
|
|
365
|
+
console.log(restoredUser.deleted_at) // null
|
|
340
366
|
|
|
341
367
|
// Record now appears in queries again
|
|
342
|
-
const users = await userRepo.findAll()
|
|
368
|
+
const users = await userRepo.findAll() // Includes restored user
|
|
343
369
|
```
|
|
344
370
|
|
|
345
371
|
**Returns**: `Promise<T>` - The restored record
|
|
@@ -351,10 +377,10 @@ Permanently deletes a record using real SQL DELETE. Cannot be restored.
|
|
|
351
377
|
|
|
352
378
|
```typescript
|
|
353
379
|
// Permanently delete user
|
|
354
|
-
await userRepo.hardDelete(1)
|
|
380
|
+
await userRepo.hardDelete(1)
|
|
355
381
|
|
|
356
382
|
// Record is gone forever
|
|
357
|
-
const user = await userRepo.findWithDeleted(1)
|
|
383
|
+
const user = await userRepo.findWithDeleted(1) // null
|
|
358
384
|
```
|
|
359
385
|
|
|
360
386
|
**Returns**: `Promise<void>`
|
|
@@ -365,9 +391,9 @@ Finds a record by ID including soft-deleted records.
|
|
|
365
391
|
|
|
366
392
|
```typescript
|
|
367
393
|
// Find user even if soft-deleted
|
|
368
|
-
const user = await userRepo.findWithDeleted(1)
|
|
394
|
+
const user = await userRepo.findWithDeleted(1)
|
|
369
395
|
if (user?.deleted_at) {
|
|
370
|
-
console.log('User was soft-deleted')
|
|
396
|
+
console.log('User was soft-deleted')
|
|
371
397
|
}
|
|
372
398
|
```
|
|
373
399
|
|
|
@@ -379,9 +405,9 @@ Returns all records including soft-deleted ones.
|
|
|
379
405
|
|
|
380
406
|
```typescript
|
|
381
407
|
// Get all users including deleted
|
|
382
|
-
const allUsers = await userRepo.findAllWithDeleted()
|
|
383
|
-
const deletedCount = allUsers.filter(u => u.deleted_at !== null).length
|
|
384
|
-
console.log(`${deletedCount} deleted users`)
|
|
408
|
+
const allUsers = await userRepo.findAllWithDeleted()
|
|
409
|
+
const deletedCount = allUsers.filter(u => u.deleted_at !== null).length
|
|
410
|
+
console.log(`${deletedCount} deleted users`)
|
|
385
411
|
```
|
|
386
412
|
|
|
387
413
|
**Returns**: `Promise<T[]>`
|
|
@@ -392,8 +418,8 @@ Returns only soft-deleted records.
|
|
|
392
418
|
|
|
393
419
|
```typescript
|
|
394
420
|
// Get only deleted users
|
|
395
|
-
const deletedUsers = await userRepo.findDeleted()
|
|
396
|
-
console.log(`Found ${deletedUsers.length} deleted users`)
|
|
421
|
+
const deletedUsers = await userRepo.findDeleted()
|
|
422
|
+
console.log(`Found ${deletedUsers.length} deleted users`)
|
|
397
423
|
```
|
|
398
424
|
|
|
399
425
|
**Returns**: `Promise<T[]>`
|
|
@@ -404,8 +430,8 @@ Soft deletes multiple records in a single operation (bulk operation).
|
|
|
404
430
|
|
|
405
431
|
```typescript
|
|
406
432
|
// Soft delete multiple users at once
|
|
407
|
-
const deletedUsers = await userRepo.softDeleteMany([1, 2, 3])
|
|
408
|
-
console.log(`Soft deleted ${deletedUsers.length} users`)
|
|
433
|
+
const deletedUsers = await userRepo.softDeleteMany([1, 2, 3])
|
|
434
|
+
console.log(`Soft deleted ${deletedUsers.length} users`)
|
|
409
435
|
```
|
|
410
436
|
|
|
411
437
|
**Returns**: `Promise<T[]>` - Array of deleted records
|
|
@@ -417,8 +443,8 @@ Restores multiple soft-deleted records in a single operation.
|
|
|
417
443
|
|
|
418
444
|
```typescript
|
|
419
445
|
// Restore multiple users at once
|
|
420
|
-
const restoredUsers = await userRepo.restoreMany([1, 2, 3])
|
|
421
|
-
console.log(`Restored ${restoredUsers.length} users`)
|
|
446
|
+
const restoredUsers = await userRepo.restoreMany([1, 2, 3])
|
|
447
|
+
console.log(`Restored ${restoredUsers.length} users`)
|
|
422
448
|
```
|
|
423
449
|
|
|
424
450
|
**Returns**: `Promise<T[]>` - Array of restored records
|
|
@@ -429,7 +455,7 @@ Permanently deletes multiple records in a single operation.
|
|
|
429
455
|
|
|
430
456
|
```typescript
|
|
431
457
|
// Permanently delete multiple users
|
|
432
|
-
await userRepo.hardDeleteMany([1, 2, 3])
|
|
458
|
+
await userRepo.hardDeleteMany([1, 2, 3])
|
|
433
459
|
```
|
|
434
460
|
|
|
435
461
|
**Returns**: `Promise<void>`
|
|
@@ -441,28 +467,22 @@ The soft-delete plugin works seamlessly with the DAL pattern through the executo
|
|
|
441
467
|
### Automatic Filtering in DAL Queries
|
|
442
468
|
|
|
443
469
|
```typescript
|
|
444
|
-
import { createExecutor } from '@kysera/executor'
|
|
445
|
-
import { createContext, createQuery } from '@kysera/dal'
|
|
470
|
+
import { createExecutor } from '@kysera/executor'
|
|
471
|
+
import { createContext, createQuery } from '@kysera/dal'
|
|
446
472
|
|
|
447
|
-
const executor = await createExecutor(db, [softDeletePlugin()])
|
|
473
|
+
const executor = await createExecutor(db, [softDeletePlugin()])
|
|
448
474
|
|
|
449
475
|
// Define queries - filter applied automatically
|
|
450
|
-
const getAllUsers = createQuery(
|
|
451
|
-
ctx.db.selectFrom('users').selectAll().execute()
|
|
452
|
-
);
|
|
476
|
+
const getAllUsers = createQuery(ctx => ctx.db.selectFrom('users').selectAll().execute())
|
|
453
477
|
|
|
454
478
|
const getUserById = createQuery((ctx, id: number) =>
|
|
455
|
-
ctx.db
|
|
456
|
-
|
|
457
|
-
.selectAll()
|
|
458
|
-
.where('id', '=', id)
|
|
459
|
-
.executeTakeFirst()
|
|
460
|
-
);
|
|
479
|
+
ctx.db.selectFrom('users').selectAll().where('id', '=', id).executeTakeFirst()
|
|
480
|
+
)
|
|
461
481
|
|
|
462
482
|
// Execute queries
|
|
463
|
-
const ctx = createContext(executor)
|
|
464
|
-
const users = await getAllUsers(ctx)
|
|
465
|
-
const user = await getUserById(ctx, 1)
|
|
483
|
+
const ctx = createContext(executor)
|
|
484
|
+
const users = await getAllUsers(ctx) // Excludes deleted
|
|
485
|
+
const user = await getUserById(ctx, 1)
|
|
466
486
|
```
|
|
467
487
|
|
|
468
488
|
### Query Interception
|
|
@@ -474,10 +494,7 @@ The plugin's `interceptQuery` method modifies SELECT query builders:
|
|
|
474
494
|
ctx.db.selectFrom('users').selectAll()
|
|
475
495
|
|
|
476
496
|
// After plugin interception
|
|
477
|
-
ctx.db
|
|
478
|
-
.selectFrom('users')
|
|
479
|
-
.selectAll()
|
|
480
|
-
.where('users.deleted_at', 'is', null) // Added automatically
|
|
497
|
+
ctx.db.selectFrom('users').selectAll().where('users.deleted_at', 'is', null) // Added automatically
|
|
481
498
|
```
|
|
482
499
|
|
|
483
500
|
### Operations Not Intercepted
|
|
@@ -492,44 +509,41 @@ The plugin uses Method Override pattern, not full query interception:
|
|
|
492
509
|
To perform soft deletes, use the `softDelete()` method explicitly:
|
|
493
510
|
|
|
494
511
|
```typescript
|
|
495
|
-
import { sql } from 'kysely'
|
|
512
|
+
import { sql } from 'kysely'
|
|
496
513
|
|
|
497
514
|
// ❌ This performs a real DELETE (not soft delete)
|
|
498
|
-
await ctx.db.deleteFrom('users').where('id', '=', 1).execute()
|
|
515
|
+
await ctx.db.deleteFrom('users').where('id', '=', 1).execute()
|
|
499
516
|
|
|
500
517
|
// ✅ Use softDelete method instead (in Repository pattern)
|
|
501
|
-
await userRepo.softDelete(1)
|
|
518
|
+
await userRepo.softDelete(1)
|
|
502
519
|
|
|
503
520
|
// ✅ Or manual UPDATE in DAL pattern
|
|
504
521
|
await ctx.db
|
|
505
522
|
.updateTable('users')
|
|
506
523
|
.set({ deleted_at: sql`CURRENT_TIMESTAMP` })
|
|
507
524
|
.where('id', '=', 1)
|
|
508
|
-
.execute()
|
|
525
|
+
.execute()
|
|
509
526
|
```
|
|
510
527
|
|
|
511
528
|
### DAL Transaction Support
|
|
512
529
|
|
|
513
530
|
```typescript
|
|
514
|
-
import { withTransaction } from '@kysera/dal'
|
|
515
|
-
import { sql } from 'kysely'
|
|
531
|
+
import { withTransaction } from '@kysera/dal'
|
|
532
|
+
import { sql } from 'kysely'
|
|
516
533
|
|
|
517
|
-
await withTransaction(executor, async
|
|
534
|
+
await withTransaction(executor, async txCtx => {
|
|
518
535
|
// Soft delete user
|
|
519
536
|
await txCtx.db
|
|
520
537
|
.updateTable('users')
|
|
521
538
|
.set({ deleted_at: sql`CURRENT_TIMESTAMP` })
|
|
522
539
|
.where('id', '=', 1)
|
|
523
|
-
.execute()
|
|
540
|
+
.execute()
|
|
524
541
|
|
|
525
542
|
// Query in same transaction sees deletion
|
|
526
|
-
const users = await txCtx.db
|
|
527
|
-
.selectFrom('users')
|
|
528
|
-
.selectAll()
|
|
529
|
-
.execute(); // User 1 excluded
|
|
543
|
+
const users = await txCtx.db.selectFrom('users').selectAll().execute() // User 1 excluded
|
|
530
544
|
|
|
531
545
|
// If transaction rolls back, soft delete is also rolled back
|
|
532
|
-
})
|
|
546
|
+
})
|
|
533
547
|
```
|
|
534
548
|
|
|
535
549
|
## Transaction Behavior
|
|
@@ -539,38 +553,38 @@ The soft-delete plugin respects ACID properties and works correctly with transac
|
|
|
539
553
|
### ACID Compliance
|
|
540
554
|
|
|
541
555
|
```typescript
|
|
542
|
-
import { withTransaction } from '@kysera/dal'
|
|
556
|
+
import { withTransaction } from '@kysera/dal'
|
|
543
557
|
|
|
544
558
|
// ✅ CORRECT: Soft delete commits with transaction
|
|
545
|
-
await withTransaction(executor, async
|
|
546
|
-
const repos = createRepositories(txCtx)
|
|
547
|
-
await repos.users.softDelete(1)
|
|
548
|
-
await repos.posts.softDeleteMany([1, 2, 3])
|
|
559
|
+
await withTransaction(executor, async txCtx => {
|
|
560
|
+
const repos = createRepositories(txCtx) // Use transaction executor
|
|
561
|
+
await repos.users.softDelete(1)
|
|
562
|
+
await repos.posts.softDeleteMany([1, 2, 3])
|
|
549
563
|
// If transaction commits, both operations commit
|
|
550
564
|
// If transaction rolls back, both operations roll back
|
|
551
|
-
})
|
|
565
|
+
})
|
|
552
566
|
```
|
|
553
567
|
|
|
554
568
|
### Rollback Behavior
|
|
555
569
|
|
|
556
570
|
```typescript
|
|
557
571
|
try {
|
|
558
|
-
await withTransaction(executor, async
|
|
559
|
-
const repos = createRepositories(txCtx)
|
|
572
|
+
await withTransaction(executor, async txCtx => {
|
|
573
|
+
const repos = createRepositories(txCtx)
|
|
560
574
|
|
|
561
575
|
// Soft delete user
|
|
562
|
-
await repos.users.softDelete(1)
|
|
576
|
+
await repos.users.softDelete(1)
|
|
563
577
|
|
|
564
578
|
// Force rollback
|
|
565
|
-
throw new Error('Force rollback')
|
|
566
|
-
})
|
|
579
|
+
throw new Error('Force rollback')
|
|
580
|
+
})
|
|
567
581
|
} catch (error) {
|
|
568
582
|
// Transaction rolled back
|
|
569
583
|
}
|
|
570
584
|
|
|
571
585
|
// Verify soft-delete was rolled back
|
|
572
|
-
const user = await userRepo.findById(1)
|
|
573
|
-
console.log(user?.deleted_at)
|
|
586
|
+
const user = await userRepo.findById(1)
|
|
587
|
+
console.log(user?.deleted_at) // null (not deleted)
|
|
574
588
|
```
|
|
575
589
|
|
|
576
590
|
### Cascade Soft Delete Pattern
|
|
@@ -579,31 +593,31 @@ The plugin does not automatically cascade soft deletes. You must implement casca
|
|
|
579
593
|
|
|
580
594
|
```typescript
|
|
581
595
|
// Manual cascade soft delete
|
|
582
|
-
await db.transaction().execute(async
|
|
583
|
-
const repos = createRepositories(trx)
|
|
584
|
-
const userId = 123
|
|
596
|
+
await db.transaction().execute(async trx => {
|
|
597
|
+
const repos = createRepositories(trx)
|
|
598
|
+
const userId = 123
|
|
585
599
|
|
|
586
600
|
// Step 1: Find related records
|
|
587
|
-
const userPosts = await repos.posts.findBy({ user_id: userId })
|
|
588
|
-
const postIds = userPosts.map(p => p.id)
|
|
601
|
+
const userPosts = await repos.posts.findBy({ user_id: userId })
|
|
602
|
+
const postIds = userPosts.map(p => p.id)
|
|
589
603
|
|
|
590
604
|
// Step 2: Soft delete children first
|
|
591
605
|
if (postIds.length > 0) {
|
|
592
606
|
const postComments = await repos.comments.findBy({
|
|
593
607
|
post_id: { in: postIds }
|
|
594
|
-
})
|
|
595
|
-
const commentIds = postComments.map(c => c.id)
|
|
608
|
+
})
|
|
609
|
+
const commentIds = postComments.map(c => c.id)
|
|
596
610
|
|
|
597
611
|
if (commentIds.length > 0) {
|
|
598
|
-
await repos.comments.softDeleteMany(commentIds)
|
|
612
|
+
await repos.comments.softDeleteMany(commentIds)
|
|
599
613
|
}
|
|
600
614
|
|
|
601
|
-
await repos.posts.softDeleteMany(postIds)
|
|
615
|
+
await repos.posts.softDeleteMany(postIds)
|
|
602
616
|
}
|
|
603
617
|
|
|
604
618
|
// Step 3: Soft delete parent
|
|
605
|
-
await repos.users.softDelete(userId)
|
|
606
|
-
})
|
|
619
|
+
await repos.users.softDelete(userId)
|
|
620
|
+
})
|
|
607
621
|
```
|
|
608
622
|
|
|
609
623
|
### Transaction Isolation
|
|
@@ -611,20 +625,20 @@ await db.transaction().execute(async (trx) => {
|
|
|
611
625
|
Soft-delete operations within a transaction are immediately visible to subsequent queries in the same transaction:
|
|
612
626
|
|
|
613
627
|
```typescript
|
|
614
|
-
await withTransaction(executor, async
|
|
615
|
-
const repos = createRepositories(txCtx)
|
|
628
|
+
await withTransaction(executor, async txCtx => {
|
|
629
|
+
const repos = createRepositories(txCtx)
|
|
616
630
|
|
|
617
631
|
// Before soft delete
|
|
618
|
-
const usersBefore = await repos.users.findAll()
|
|
619
|
-
console.log(usersBefore.length)
|
|
632
|
+
const usersBefore = await repos.users.findAll()
|
|
633
|
+
console.log(usersBefore.length) // 10
|
|
620
634
|
|
|
621
635
|
// Soft delete user
|
|
622
|
-
await repos.users.softDelete(1)
|
|
636
|
+
await repos.users.softDelete(1)
|
|
623
637
|
|
|
624
638
|
// Immediately visible in same transaction
|
|
625
|
-
const usersAfter = await repos.users.findAll()
|
|
626
|
-
console.log(usersAfter.length)
|
|
627
|
-
})
|
|
639
|
+
const usersAfter = await repos.users.findAll()
|
|
640
|
+
console.log(usersAfter.length) // 9
|
|
641
|
+
})
|
|
628
642
|
```
|
|
629
643
|
|
|
630
644
|
## Database Schema Requirements
|
|
@@ -659,7 +673,7 @@ CREATE TABLE posts (
|
|
|
659
673
|
softDeletePlugin({
|
|
660
674
|
deletedAtColumn: 'removed_at',
|
|
661
675
|
tables: ['posts']
|
|
662
|
-
})
|
|
676
|
+
})
|
|
663
677
|
```
|
|
664
678
|
|
|
665
679
|
### Custom Primary Key
|
|
@@ -677,31 +691,36 @@ CREATE TABLE comments (
|
|
|
677
691
|
softDeletePlugin({
|
|
678
692
|
primaryKeyColumn: 'comment_id',
|
|
679
693
|
tables: ['comments']
|
|
680
|
-
})
|
|
694
|
+
})
|
|
681
695
|
```
|
|
682
696
|
|
|
683
697
|
## Type Safety
|
|
684
698
|
|
|
685
|
-
The plugin maintains full type safety with TypeScript:
|
|
699
|
+
The plugin maintains full type safety with TypeScript. The `SoftDeleteRepository` type uses `Record<string, never>` for the database type parameter by default:
|
|
686
700
|
|
|
687
701
|
```typescript
|
|
688
|
-
import type { SoftDeleteRepository } from '@kysera/soft-delete'
|
|
702
|
+
import type { SoftDeleteRepository } from '@kysera/soft-delete'
|
|
689
703
|
|
|
690
704
|
// Extend repository type with soft delete methods
|
|
691
|
-
|
|
705
|
+
// Default: SoftDeleteRepository<User, Record<string, never>>
|
|
706
|
+
type UserRepository = SoftDeleteRepository<User>
|
|
692
707
|
|
|
693
|
-
const userRepo: UserRepository = orm.createRepository(
|
|
694
|
-
const base = createRepositoryFactory(executor)
|
|
708
|
+
const userRepo: UserRepository = orm.createRepository(executor => {
|
|
709
|
+
const base = createRepositoryFactory(executor)
|
|
695
710
|
return base.create({
|
|
696
711
|
tableName: 'users',
|
|
697
|
-
mapRow:
|
|
698
|
-
})
|
|
699
|
-
})
|
|
712
|
+
mapRow: row => row as User
|
|
713
|
+
})
|
|
714
|
+
})
|
|
700
715
|
|
|
701
716
|
// TypeScript knows about soft delete methods
|
|
702
|
-
const deletedUser: User = await userRepo.softDelete(1)
|
|
703
|
-
const allUsers: User[] = await userRepo.findAllWithDeleted()
|
|
704
|
-
const deletedUsers: User[] = await userRepo.findDeleted()
|
|
717
|
+
const deletedUser: User = await userRepo.softDelete(1)
|
|
718
|
+
const allUsers: User[] = await userRepo.findAllWithDeleted()
|
|
719
|
+
const deletedUsers: User[] = await userRepo.findDeleted()
|
|
720
|
+
|
|
721
|
+
// Batch operations are also typed
|
|
722
|
+
const deleted: User[] = await userRepo.softDeleteMany([1, 2, 3])
|
|
723
|
+
const restored: User[] = await userRepo.restoreMany([1, 2, 3])
|
|
705
724
|
```
|
|
706
725
|
|
|
707
726
|
## Error Handling
|
|
@@ -709,22 +728,22 @@ const deletedUsers: User[] = await userRepo.findDeleted();
|
|
|
709
728
|
The plugin uses error types from `@kysera/core`:
|
|
710
729
|
|
|
711
730
|
```typescript
|
|
712
|
-
import { NotFoundError } from '@kysera/core'
|
|
731
|
+
import { NotFoundError } from '@kysera/core'
|
|
713
732
|
|
|
714
733
|
try {
|
|
715
|
-
await userRepo.softDelete(999)
|
|
734
|
+
await userRepo.softDelete(999) // Non-existent ID
|
|
716
735
|
} catch (error) {
|
|
717
736
|
if (error instanceof NotFoundError) {
|
|
718
|
-
console.error('User not found:', error.metadata)
|
|
737
|
+
console.error('User not found:', error.metadata)
|
|
719
738
|
// error.metadata = { id: 999 }
|
|
720
739
|
}
|
|
721
740
|
}
|
|
722
741
|
|
|
723
742
|
try {
|
|
724
|
-
await userRepo.softDeleteMany([1, 2, 999])
|
|
743
|
+
await userRepo.softDeleteMany([1, 2, 999]) // One ID doesn't exist
|
|
725
744
|
} catch (error) {
|
|
726
745
|
if (error instanceof NotFoundError) {
|
|
727
|
-
console.error('Some users not found:', error.metadata)
|
|
746
|
+
console.error('Some users not found:', error.metadata)
|
|
728
747
|
// error.metadata = { ids: [999] }
|
|
729
748
|
}
|
|
730
749
|
}
|
|
@@ -761,11 +780,11 @@ Use bulk methods for better performance when operating on multiple records:
|
|
|
761
780
|
```typescript
|
|
762
781
|
// ❌ Inefficient: N queries
|
|
763
782
|
for (const id of userIds) {
|
|
764
|
-
await userRepo.softDelete(id)
|
|
783
|
+
await userRepo.softDelete(id)
|
|
765
784
|
}
|
|
766
785
|
|
|
767
786
|
// ✅ Efficient: Single query
|
|
768
|
-
await userRepo.softDeleteMany(userIds)
|
|
787
|
+
await userRepo.softDeleteMany(userIds)
|
|
769
788
|
```
|
|
770
789
|
|
|
771
790
|
## Architecture Notes
|
|
@@ -798,16 +817,13 @@ The plugin uses `getRawDb()` to access the underlying Kysely instance without pl
|
|
|
798
817
|
- `softDelete()`, `restore()`: Need to fetch records after update
|
|
799
818
|
|
|
800
819
|
```typescript
|
|
801
|
-
import { getRawDb } from '@kysera/executor'
|
|
820
|
+
import { getRawDb } from '@kysera/executor'
|
|
802
821
|
|
|
803
822
|
// Inside plugin
|
|
804
|
-
const rawDb = getRawDb(repo.executor)
|
|
823
|
+
const rawDb = getRawDb(repo.executor)
|
|
805
824
|
|
|
806
825
|
// Bypass soft-delete filter
|
|
807
|
-
const allRecords = await rawDb
|
|
808
|
-
.selectFrom('users')
|
|
809
|
-
.selectAll()
|
|
810
|
-
.execute();
|
|
826
|
+
const allRecords = await rawDb.selectFrom('users').selectAll().execute()
|
|
811
827
|
```
|
|
812
828
|
|
|
813
829
|
## Testing
|
|
@@ -829,6 +845,7 @@ pnpm test dal-integration.test.ts
|
|
|
829
845
|
```
|
|
830
846
|
|
|
831
847
|
Test files:
|
|
848
|
+
|
|
832
849
|
- `test/dal-integration.test.ts` - DAL pattern with createQuery and withTransaction
|
|
833
850
|
- `test/soft-delete-comprehensive.test.ts` - All 9 methods + configuration options
|
|
834
851
|
- `test/soft-delete-repository.test.ts` - Repository pattern core functionality
|