@kysera/soft-delete 0.3.0 → 0.4.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 +1242 -0
- package/package.json +2 -2
package/README.md
ADDED
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
# @kysera/soft-delete
|
|
2
|
+
|
|
3
|
+
> Soft delete plugin for Kysera ORM - Mark records as deleted without actually removing them from the database, with powerful restore and query capabilities.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@kysera/soft-delete)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
|
|
9
|
+
## 📦 Package Information
|
|
10
|
+
|
|
11
|
+
| Metric | Value |
|
|
12
|
+
|--------|-------|
|
|
13
|
+
| **Version** | 0.3.0 |
|
|
14
|
+
| **Bundle Size** | 477 B (minified) |
|
|
15
|
+
| **Test Coverage** | 39 tests passing |
|
|
16
|
+
| **Dependencies** | @kysera/repository (workspace) |
|
|
17
|
+
| **Peer Dependencies** | kysely >=0.28.0 |
|
|
18
|
+
| **Target Runtimes** | Node.js 20+, Bun 1.0+, Deno |
|
|
19
|
+
| **Module System** | ESM only |
|
|
20
|
+
| **Database Support** | PostgreSQL, MySQL, SQLite |
|
|
21
|
+
|
|
22
|
+
## 🎯 Features
|
|
23
|
+
|
|
24
|
+
- ✅ **Soft Delete** - Mark records as deleted without removing them
|
|
25
|
+
- ✅ **Automatic Filtering** - Deleted records excluded from queries by default
|
|
26
|
+
- ✅ **Restore Capability** - Bring back soft-deleted records
|
|
27
|
+
- ✅ **Hard Delete** - Permanently remove records when needed
|
|
28
|
+
- ✅ **Query Helpers** - Find deleted, include deleted, or exclude deleted
|
|
29
|
+
- ✅ **Type-Safe** - Full TypeScript support
|
|
30
|
+
- ✅ **Table Filtering** - Apply to specific tables only
|
|
31
|
+
- ✅ **Custom Column Names** - Use any column name for deleted_at
|
|
32
|
+
- ✅ **Production Ready** - Battle-tested with comprehensive coverage
|
|
33
|
+
|
|
34
|
+
## 📥 Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# npm
|
|
38
|
+
npm install @kysera/soft-delete @kysera/repository kysely
|
|
39
|
+
|
|
40
|
+
# pnpm
|
|
41
|
+
pnpm add @kysera/soft-delete @kysera/repository kysely
|
|
42
|
+
|
|
43
|
+
# bun
|
|
44
|
+
bun add @kysera/soft-delete @kysera/repository kysely
|
|
45
|
+
|
|
46
|
+
# deno
|
|
47
|
+
import { softDeletePlugin } from "npm:@kysera/soft-delete"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 🚀 Quick Start
|
|
51
|
+
|
|
52
|
+
### 1. Add deleted_at Column to Your Database
|
|
53
|
+
|
|
54
|
+
```sql
|
|
55
|
+
-- PostgreSQL / MySQL / SQLite
|
|
56
|
+
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL;
|
|
57
|
+
|
|
58
|
+
-- Or include in table creation
|
|
59
|
+
CREATE TABLE users (
|
|
60
|
+
id SERIAL PRIMARY KEY,
|
|
61
|
+
email VARCHAR(255) NOT NULL,
|
|
62
|
+
name VARCHAR(255) NOT NULL,
|
|
63
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
64
|
+
deleted_at TIMESTAMP NULL -- Soft delete column
|
|
65
|
+
);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 2. Setup Plugin
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import { Kysely, PostgresDialect, Generated } from 'kysely'
|
|
72
|
+
import { Pool } from 'pg'
|
|
73
|
+
import { createORM, createRepositoryFactory } from '@kysera/repository'
|
|
74
|
+
import { softDeletePlugin } from '@kysera/soft-delete'
|
|
75
|
+
import { z } from 'zod'
|
|
76
|
+
|
|
77
|
+
// Define database schema
|
|
78
|
+
interface Database {
|
|
79
|
+
users: {
|
|
80
|
+
id: Generated<number>
|
|
81
|
+
email: string
|
|
82
|
+
name: string
|
|
83
|
+
created_at: Generated<Date>
|
|
84
|
+
deleted_at: Date | null // Nullable for soft delete
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Create database connection
|
|
89
|
+
const db = new Kysely<Database>({
|
|
90
|
+
dialect: new PostgresDialect({
|
|
91
|
+
pool: new Pool({ /* config */ })
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Create ORM with soft delete plugin
|
|
96
|
+
const orm = await createORM(db, [
|
|
97
|
+
softDeletePlugin() // ✨ That's it!
|
|
98
|
+
])
|
|
99
|
+
|
|
100
|
+
// Create repository
|
|
101
|
+
const userRepo = orm.createRepository((executor) => {
|
|
102
|
+
const factory = createRepositoryFactory(executor)
|
|
103
|
+
return factory.create<'users', User>({
|
|
104
|
+
tableName: 'users',
|
|
105
|
+
mapRow: (row) => row as User,
|
|
106
|
+
schemas: {
|
|
107
|
+
create: z.object({
|
|
108
|
+
email: z.string().email(),
|
|
109
|
+
name: z.string()
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// Use repository with soft delete!
|
|
116
|
+
const user = await userRepo.create({
|
|
117
|
+
email: 'alice@example.com',
|
|
118
|
+
name: 'Alice'
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// Soft delete (sets deleted_at timestamp)
|
|
122
|
+
await userRepo.softDelete(user.id)
|
|
123
|
+
|
|
124
|
+
// Find all - excludes soft-deleted records
|
|
125
|
+
const users = await userRepo.findAll() // Alice not included
|
|
126
|
+
|
|
127
|
+
// Find including deleted
|
|
128
|
+
const allUsers = await userRepo.findAllWithDeleted() // Alice included
|
|
129
|
+
|
|
130
|
+
// Restore
|
|
131
|
+
await userRepo.restore(user.id) // Alice is back!
|
|
132
|
+
|
|
133
|
+
// Hard delete (permanently remove)
|
|
134
|
+
await userRepo.hardDelete(user.id) // Alice gone forever
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 📚 Table of Contents
|
|
140
|
+
|
|
141
|
+
1. [Core Concepts](#-core-concepts)
|
|
142
|
+
- [What is Soft Delete?](#what-is-soft-delete)
|
|
143
|
+
- [Method Override Pattern](#method-override-pattern)
|
|
144
|
+
- [Automatic Filtering](#automatic-filtering)
|
|
145
|
+
2. [Configuration](#-configuration)
|
|
146
|
+
- [Default Configuration](#default-configuration)
|
|
147
|
+
- [Custom Column Names](#custom-column-names)
|
|
148
|
+
- [Table Filtering](#table-filtering)
|
|
149
|
+
- [Include Deleted by Default](#include-deleted-by-default)
|
|
150
|
+
3. [Repository Methods](#-repository-methods)
|
|
151
|
+
- [softDelete](#softdelete)
|
|
152
|
+
- [restore](#restore)
|
|
153
|
+
- [hardDelete](#harddelete)
|
|
154
|
+
- [findAllWithDeleted](#findallwithdeleted)
|
|
155
|
+
- [findDeleted](#finddeleted)
|
|
156
|
+
- [findWithDeleted](#findwithdeleted)
|
|
157
|
+
4. [Automatic Filtering](#-automatic-filtering-1)
|
|
158
|
+
5. [Advanced Usage](#-advanced-usage)
|
|
159
|
+
6. [Multi-Database Support](#-multi-database-support)
|
|
160
|
+
7. [Type Safety](#-type-safety)
|
|
161
|
+
8. [API Reference](#-api-reference)
|
|
162
|
+
9. [Best Practices](#-best-practices)
|
|
163
|
+
10. [Performance](#-performance)
|
|
164
|
+
11. [Troubleshooting](#-troubleshooting)
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 💡 Core Concepts
|
|
169
|
+
|
|
170
|
+
### What is Soft Delete?
|
|
171
|
+
|
|
172
|
+
Soft delete is a data management pattern where records are marked as deleted rather than actually removed from the database. This provides:
|
|
173
|
+
|
|
174
|
+
- **Data Recovery** - Restore accidentally deleted records
|
|
175
|
+
- **Audit Trail** - Keep history of what was deleted and when
|
|
176
|
+
- **Compliance** - Meet regulatory requirements for data retention
|
|
177
|
+
- **User Experience** - Implement "Trash" or "Recycle Bin" features
|
|
178
|
+
- **Safety** - Prevent permanent data loss
|
|
179
|
+
|
|
180
|
+
**Example:**
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
// Traditional hard delete (data lost forever)
|
|
184
|
+
await db.deleteFrom('users').where('id', '=', 1).execute()
|
|
185
|
+
// Record is GONE
|
|
186
|
+
|
|
187
|
+
// Soft delete (data preserved)
|
|
188
|
+
await userRepo.softDelete(1)
|
|
189
|
+
// Record still in database, just marked as deleted
|
|
190
|
+
// Can be restored later!
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Method Override Pattern
|
|
194
|
+
|
|
195
|
+
This plugin uses the **Method Override pattern**, not full query interception:
|
|
196
|
+
|
|
197
|
+
**✅ What happens automatically:**
|
|
198
|
+
- `SELECT` queries filter out soft-deleted records
|
|
199
|
+
- `findAll()` excludes soft-deleted records
|
|
200
|
+
- `findById()` excludes soft-deleted records
|
|
201
|
+
|
|
202
|
+
**❌ What does NOT happen automatically:**
|
|
203
|
+
- `DELETE` operations are NOT converted to soft deletes
|
|
204
|
+
- You must explicitly use `softDelete()` method
|
|
205
|
+
- Regular `delete()` performs a hard delete
|
|
206
|
+
|
|
207
|
+
**Why this design?**
|
|
208
|
+
|
|
209
|
+
This approach is intentional for:
|
|
210
|
+
- **Explicitness** - Clear intent: `softDelete()` vs `delete()`
|
|
211
|
+
- **Simplicity** - No magic query transformations
|
|
212
|
+
- **Control** - Choose soft or hard delete per operation
|
|
213
|
+
- **Performance** - No overhead on DELETE queries
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
// ✅ Explicit soft delete
|
|
217
|
+
await userRepo.softDelete(userId) // Sets deleted_at
|
|
218
|
+
|
|
219
|
+
// ❌ This performs a HARD delete (if repository has delete method)
|
|
220
|
+
await userRepo.delete(userId) // Actually removes record
|
|
221
|
+
|
|
222
|
+
// ✅ Use hardDelete for clarity
|
|
223
|
+
await userRepo.hardDelete(userId) // Explicitly hard delete
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Automatic Filtering
|
|
227
|
+
|
|
228
|
+
When the plugin is active, soft-deleted records are **automatically excluded** from queries:
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
// Create and soft-delete a user
|
|
232
|
+
await userRepo.softDelete(aliceId)
|
|
233
|
+
|
|
234
|
+
// Queries automatically exclude soft-deleted
|
|
235
|
+
const users = await userRepo.findAll()
|
|
236
|
+
// Alice NOT included
|
|
237
|
+
|
|
238
|
+
const user = await userRepo.findById(aliceId)
|
|
239
|
+
// Returns null (Alice is soft-deleted)
|
|
240
|
+
|
|
241
|
+
// Explicitly include deleted
|
|
242
|
+
const allUsers = await userRepo.findAllWithDeleted()
|
|
243
|
+
// Alice included
|
|
244
|
+
|
|
245
|
+
const userWithDeleted = await userRepo.findWithDeleted(aliceId)
|
|
246
|
+
// Returns Alice even though soft-deleted
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## ⚙️ Configuration
|
|
252
|
+
|
|
253
|
+
### Default Configuration
|
|
254
|
+
|
|
255
|
+
The plugin works with zero configuration using sensible defaults:
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
const plugin = softDeletePlugin()
|
|
259
|
+
|
|
260
|
+
// Equivalent to:
|
|
261
|
+
const plugin = softDeletePlugin({
|
|
262
|
+
deletedAtColumn: 'deleted_at',
|
|
263
|
+
includeDeleted: false,
|
|
264
|
+
tables: undefined // All tables
|
|
265
|
+
})
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### Custom Column Names
|
|
269
|
+
|
|
270
|
+
Use your own column naming convention:
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
// Example: Use "removed_at"
|
|
274
|
+
const plugin = softDeletePlugin({
|
|
275
|
+
deletedAtColumn: 'removed_at'
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
// Database schema
|
|
279
|
+
interface Database {
|
|
280
|
+
users: {
|
|
281
|
+
id: Generated<number>
|
|
282
|
+
email: string
|
|
283
|
+
removed_at: Date | null // ✅ Custom name
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Example: Use "archived_at"
|
|
288
|
+
const plugin = softDeletePlugin({
|
|
289
|
+
deletedAtColumn: 'archived_at'
|
|
290
|
+
})
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Table Filtering
|
|
294
|
+
|
|
295
|
+
Apply soft delete only to specific tables:
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
// Only enable for specific tables
|
|
299
|
+
const plugin = softDeletePlugin({
|
|
300
|
+
tables: ['users', 'posts', 'comments']
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// users, posts, comments: ✅ Soft delete enabled
|
|
304
|
+
// other tables: ❌ Soft delete disabled
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**When to use table filtering:**
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
// ✅ Good: User-facing data
|
|
311
|
+
const plugin = softDeletePlugin({
|
|
312
|
+
tables: ['users', 'posts', 'comments', 'orders']
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// ❌ Skip: System/config tables (don't need soft delete)
|
|
316
|
+
// migrations, config, sessions - not included in tables list
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Include Deleted by Default
|
|
320
|
+
|
|
321
|
+
Reverse the default behavior (include deleted records):
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
const plugin = softDeletePlugin({
|
|
325
|
+
includeDeleted: true // Include deleted by default
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Now queries include soft-deleted records by default
|
|
329
|
+
const users = await userRepo.findAll() // Includes deleted
|
|
330
|
+
|
|
331
|
+
// You'd need to explicitly exclude
|
|
332
|
+
// (Note: this is less common)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## 🔧 Repository Methods
|
|
338
|
+
|
|
339
|
+
The plugin extends repositories with these methods:
|
|
340
|
+
|
|
341
|
+
### softDelete
|
|
342
|
+
|
|
343
|
+
Mark a record as deleted by setting `deleted_at` timestamp.
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
async softDelete(id: number): Promise<T>
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
**Example:**
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
const user = await userRepo.softDelete(userId)
|
|
353
|
+
|
|
354
|
+
console.log(user.deleted_at) // 2024-01-15T10:30:00.000Z
|
|
355
|
+
|
|
356
|
+
// Record still exists in database
|
|
357
|
+
const directQuery = await db
|
|
358
|
+
.selectFrom('users')
|
|
359
|
+
.selectAll()
|
|
360
|
+
.where('id', '=', userId)
|
|
361
|
+
.executeTakeFirst()
|
|
362
|
+
|
|
363
|
+
console.log(directQuery) // Record exists with deleted_at set
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
**Use Cases:**
|
|
367
|
+
- User account deletion
|
|
368
|
+
- Content moderation
|
|
369
|
+
- Order cancellation
|
|
370
|
+
- Temporary removals
|
|
371
|
+
- Implementing "Trash" feature
|
|
372
|
+
|
|
373
|
+
### restore
|
|
374
|
+
|
|
375
|
+
Restore a soft-deleted record by setting `deleted_at` to `null`.
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
async restore(id: number): Promise<T>
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
**Example:**
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
// Soft delete a user
|
|
385
|
+
await userRepo.softDelete(userId)
|
|
386
|
+
|
|
387
|
+
// Later, restore them
|
|
388
|
+
const restored = await userRepo.restore(userId)
|
|
389
|
+
|
|
390
|
+
console.log(restored.deleted_at) // null
|
|
391
|
+
|
|
392
|
+
// User now appears in queries again
|
|
393
|
+
const users = await userRepo.findAll()
|
|
394
|
+
// Includes restored user
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
**Use Cases:**
|
|
398
|
+
- Undo accidental deletions
|
|
399
|
+
- User account reactivation
|
|
400
|
+
- Content restoration
|
|
401
|
+
- Admin recovery tools
|
|
402
|
+
|
|
403
|
+
### hardDelete
|
|
404
|
+
|
|
405
|
+
Permanently delete a record from the database (bypasses soft delete).
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
async hardDelete(id: number): Promise<void>
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
**Example:**
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
// Permanently remove a user
|
|
415
|
+
await userRepo.hardDelete(userId)
|
|
416
|
+
|
|
417
|
+
// Record is GONE from database
|
|
418
|
+
const user = await db
|
|
419
|
+
.selectFrom('users')
|
|
420
|
+
.selectAll()
|
|
421
|
+
.where('id', '=', userId)
|
|
422
|
+
.executeTakeFirst()
|
|
423
|
+
|
|
424
|
+
console.log(user) // undefined
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**Use Cases:**
|
|
428
|
+
- GDPR "right to be forgotten" compliance
|
|
429
|
+
- Cleaning up test data
|
|
430
|
+
- Purging old soft-deleted records
|
|
431
|
+
- Admin force-delete
|
|
432
|
+
|
|
433
|
+
### findAllWithDeleted
|
|
434
|
+
|
|
435
|
+
Find all records including soft-deleted ones.
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
async findAllWithDeleted(): Promise<T[]>
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
**Example:**
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
// Soft delete Bob
|
|
445
|
+
await userRepo.softDelete(bobId)
|
|
446
|
+
|
|
447
|
+
// Normal query excludes Bob
|
|
448
|
+
const active = await userRepo.findAll()
|
|
449
|
+
console.log(active.length) // 2
|
|
450
|
+
|
|
451
|
+
// Include deleted shows Bob
|
|
452
|
+
const all = await userRepo.findAllWithDeleted()
|
|
453
|
+
console.log(all.length) // 3 (includes Bob)
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
**Use Cases:**
|
|
457
|
+
- Admin panels showing all records
|
|
458
|
+
- Audit trails
|
|
459
|
+
- Data export including deleted
|
|
460
|
+
- Recovery interfaces
|
|
461
|
+
|
|
462
|
+
### findDeleted
|
|
463
|
+
|
|
464
|
+
Find only soft-deleted records.
|
|
465
|
+
|
|
466
|
+
```typescript
|
|
467
|
+
async findDeleted(): Promise<T[]>
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
**Example:**
|
|
471
|
+
|
|
472
|
+
```typescript
|
|
473
|
+
// Soft delete some users
|
|
474
|
+
await userRepo.softDelete(aliceId)
|
|
475
|
+
await userRepo.softDelete(bobId)
|
|
476
|
+
|
|
477
|
+
// Find only deleted
|
|
478
|
+
const deleted = await userRepo.findDeleted()
|
|
479
|
+
console.log(deleted.length) // 2 (Alice and Bob)
|
|
480
|
+
console.log(deleted[0].deleted_at) // Not null
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
**Use Cases:**
|
|
484
|
+
- "Trash" or "Recycle Bin" view
|
|
485
|
+
- Deleted items list
|
|
486
|
+
- Cleanup candidates
|
|
487
|
+
- Audit reports
|
|
488
|
+
|
|
489
|
+
### findWithDeleted
|
|
490
|
+
|
|
491
|
+
Find a specific record including if soft-deleted.
|
|
492
|
+
|
|
493
|
+
```typescript
|
|
494
|
+
async findWithDeleted(id: number): Promise<T | null>
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
**Example:**
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
// Soft delete Alice
|
|
501
|
+
await userRepo.softDelete(aliceId)
|
|
502
|
+
|
|
503
|
+
// Normal findById returns null
|
|
504
|
+
const user1 = await userRepo.findById(aliceId)
|
|
505
|
+
console.log(user1) // null
|
|
506
|
+
|
|
507
|
+
// findWithDeleted returns the record
|
|
508
|
+
const user2 = await userRepo.findWithDeleted(aliceId)
|
|
509
|
+
console.log(user2) // Alice's record
|
|
510
|
+
console.log(user2.deleted_at) // Not null
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
**Use Cases:**
|
|
514
|
+
- Recovery by ID
|
|
515
|
+
- Audit lookups
|
|
516
|
+
- Admin record inspection
|
|
517
|
+
- Restore confirmation
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
## 🎯 Automatic Filtering
|
|
522
|
+
|
|
523
|
+
The plugin automatically filters soft-deleted records from queries.
|
|
524
|
+
|
|
525
|
+
### How It Works
|
|
526
|
+
|
|
527
|
+
```typescript
|
|
528
|
+
// Behind the scenes, the plugin adds WHERE clause:
|
|
529
|
+
db.selectFrom('users').selectAll()
|
|
530
|
+
|
|
531
|
+
// Becomes:
|
|
532
|
+
db.selectFrom('users')
|
|
533
|
+
.selectAll()
|
|
534
|
+
.where('users.deleted_at', 'is', null) // Auto-added!
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
### What Gets Filtered
|
|
538
|
+
|
|
539
|
+
**✅ Automatically filtered:**
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
// Repository methods
|
|
543
|
+
await userRepo.findAll() // ✅ Filtered
|
|
544
|
+
await userRepo.findById(1) // ✅ Filtered
|
|
545
|
+
await userRepo.find({ where: {...} }) // ✅ Filtered
|
|
546
|
+
|
|
547
|
+
// SELECT queries through ORM
|
|
548
|
+
const result = await orm.applyPlugins(
|
|
549
|
+
db.selectFrom('users').selectAll(),
|
|
550
|
+
'select',
|
|
551
|
+
'users',
|
|
552
|
+
{}
|
|
553
|
+
).execute() // ✅ Filtered
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
**❌ NOT automatically filtered:**
|
|
557
|
+
|
|
558
|
+
```typescript
|
|
559
|
+
// Direct Kysely queries (bypass ORM)
|
|
560
|
+
await db.selectFrom('users').selectAll().execute()
|
|
561
|
+
// ❌ Not filtered (direct DB access)
|
|
562
|
+
|
|
563
|
+
// DELETE operations
|
|
564
|
+
await db.deleteFrom('users').where('id', '=', 1).execute()
|
|
565
|
+
// ❌ Still deletes (not converted to soft delete)
|
|
566
|
+
|
|
567
|
+
// Custom repository methods
|
|
568
|
+
await userRepo.customMethod()
|
|
569
|
+
// ❌ Not filtered (unless explicitly implemented)
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
### Bypassing Filters
|
|
573
|
+
|
|
574
|
+
When you need to include deleted records:
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
// Method 1: Use *WithDeleted methods
|
|
578
|
+
const all = await userRepo.findAllWithDeleted()
|
|
579
|
+
const user = await userRepo.findWithDeleted(userId)
|
|
580
|
+
|
|
581
|
+
// Method 2: Use metadata flag (with ORM)
|
|
582
|
+
const result = await orm.applyPlugins(
|
|
583
|
+
db.selectFrom('users').selectAll(),
|
|
584
|
+
'select',
|
|
585
|
+
'users',
|
|
586
|
+
{ includeDeleted: true } // ✅ Include deleted
|
|
587
|
+
).execute()
|
|
588
|
+
|
|
589
|
+
// Method 3: Direct Kysely query (bypass plugin)
|
|
590
|
+
const all = await db.selectFrom('users').selectAll().execute()
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
---
|
|
594
|
+
|
|
595
|
+
## 🔧 Advanced Usage
|
|
596
|
+
|
|
597
|
+
### Multiple Plugins
|
|
598
|
+
|
|
599
|
+
Combine soft delete with other plugins:
|
|
600
|
+
|
|
601
|
+
```typescript
|
|
602
|
+
import { softDeletePlugin } from '@kysera/soft-delete'
|
|
603
|
+
import { timestampsPlugin } from '@kysera/timestamps'
|
|
604
|
+
import { auditPlugin } from '@kysera/audit'
|
|
605
|
+
|
|
606
|
+
const orm = await createORM(db, [
|
|
607
|
+
timestampsPlugin(), // Auto timestamps
|
|
608
|
+
softDeletePlugin(), // Soft delete
|
|
609
|
+
auditPlugin({ userId }) // Audit logging
|
|
610
|
+
])
|
|
611
|
+
|
|
612
|
+
// All plugins work together:
|
|
613
|
+
await userRepo.softDelete(userId)
|
|
614
|
+
// ✅ deleted_at timestamp set
|
|
615
|
+
// ✅ updated_at timestamp updated (timestamps plugin)
|
|
616
|
+
// ✅ Audit log created (audit plugin)
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
### Transaction Support
|
|
620
|
+
|
|
621
|
+
Soft deletes work seamlessly with transactions:
|
|
622
|
+
|
|
623
|
+
```typescript
|
|
624
|
+
await db.transaction().execute(async (trx) => {
|
|
625
|
+
const txRepo = userRepo.withTransaction(trx)
|
|
626
|
+
|
|
627
|
+
// Soft delete in transaction
|
|
628
|
+
await txRepo.softDelete(userId)
|
|
629
|
+
|
|
630
|
+
// Other operations
|
|
631
|
+
await txRepo.create({ email: 'new@example.com', name: 'New User' })
|
|
632
|
+
|
|
633
|
+
// If transaction fails, soft delete is rolled back
|
|
634
|
+
})
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
### Bulk Operations
|
|
638
|
+
|
|
639
|
+
```typescript
|
|
640
|
+
// Soft delete multiple records
|
|
641
|
+
const userIds = [1, 2, 3, 4, 5]
|
|
642
|
+
|
|
643
|
+
for (const id of userIds) {
|
|
644
|
+
await userRepo.softDelete(id)
|
|
645
|
+
}
|
|
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
|
+
```
|
|
654
|
+
|
|
655
|
+
### Conditional Soft Delete
|
|
656
|
+
|
|
657
|
+
```typescript
|
|
658
|
+
// Only soft delete if certain conditions met
|
|
659
|
+
async function conditionalDelete(userId: number) {
|
|
660
|
+
const user = await userRepo.findById(userId)
|
|
661
|
+
|
|
662
|
+
if (!user) {
|
|
663
|
+
throw new Error('User not found')
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Check if user has important data
|
|
667
|
+
const hasOrders = await db
|
|
668
|
+
.selectFrom('orders')
|
|
669
|
+
.select('id')
|
|
670
|
+
.where('user_id', '=', userId)
|
|
671
|
+
.executeTakeFirst()
|
|
672
|
+
|
|
673
|
+
if (hasOrders) {
|
|
674
|
+
// Soft delete (preserve for order history)
|
|
675
|
+
await userRepo.softDelete(userId)
|
|
676
|
+
} else {
|
|
677
|
+
// Hard delete (no dependencies)
|
|
678
|
+
await userRepo.hardDelete(userId)
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
### Cleanup Old Soft-Deleted Records
|
|
684
|
+
|
|
685
|
+
```typescript
|
|
686
|
+
// Delete records soft-deleted more than 30 days ago
|
|
687
|
+
async function cleanupOldDeleted() {
|
|
688
|
+
const thirtyDaysAgo = new Date()
|
|
689
|
+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
|
|
690
|
+
|
|
691
|
+
const oldDeleted = await db
|
|
692
|
+
.selectFrom('users')
|
|
693
|
+
.selectAll()
|
|
694
|
+
.where('deleted_at', 'is not', null)
|
|
695
|
+
.where('deleted_at', '<', thirtyDaysAgo.toISOString())
|
|
696
|
+
.execute()
|
|
697
|
+
|
|
698
|
+
for (const user of oldDeleted) {
|
|
699
|
+
await userRepo.hardDelete(user.id)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
console.log(`Cleaned up ${oldDeleted.length} old records`)
|
|
703
|
+
}
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
---
|
|
707
|
+
|
|
708
|
+
## 🗄️ Multi-Database Support
|
|
709
|
+
|
|
710
|
+
The plugin works across PostgreSQL, MySQL, and SQLite.
|
|
711
|
+
|
|
712
|
+
### PostgreSQL
|
|
713
|
+
|
|
714
|
+
```typescript
|
|
715
|
+
// Schema
|
|
716
|
+
CREATE TABLE users (
|
|
717
|
+
id SERIAL PRIMARY KEY,
|
|
718
|
+
email VARCHAR(255) NOT NULL,
|
|
719
|
+
name VARCHAR(255) NOT NULL,
|
|
720
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
721
|
+
deleted_at TIMESTAMP NULL -- TIMESTAMP column
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
// Plugin uses CURRENT_TIMESTAMP (native PostgreSQL)
|
|
725
|
+
const plugin = softDeletePlugin({
|
|
726
|
+
deletedAtColumn: 'deleted_at'
|
|
727
|
+
})
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
### MySQL
|
|
731
|
+
|
|
732
|
+
```typescript
|
|
733
|
+
// Schema
|
|
734
|
+
CREATE TABLE users (
|
|
735
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
736
|
+
email VARCHAR(255) NOT NULL,
|
|
737
|
+
name VARCHAR(255) NOT NULL,
|
|
738
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
739
|
+
deleted_at DATETIME NULL -- DATETIME column
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
// Plugin uses CURRENT_TIMESTAMP (native MySQL)
|
|
743
|
+
const plugin = softDeletePlugin({
|
|
744
|
+
deletedAtColumn: 'deleted_at'
|
|
745
|
+
})
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
### SQLite
|
|
749
|
+
|
|
750
|
+
```typescript
|
|
751
|
+
// Schema (TEXT for timestamps)
|
|
752
|
+
CREATE TABLE users (
|
|
753
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
754
|
+
email TEXT NOT NULL,
|
|
755
|
+
name TEXT NOT NULL,
|
|
756
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
757
|
+
deleted_at TEXT NULL -- TEXT column for timestamp
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
// Or INTEGER for Unix timestamp
|
|
761
|
+
CREATE TABLE users (
|
|
762
|
+
...
|
|
763
|
+
deleted_at INTEGER NULL -- Unix timestamp
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
// Plugin uses CURRENT_TIMESTAMP (SQLite compatible)
|
|
767
|
+
const plugin = softDeletePlugin({
|
|
768
|
+
deletedAtColumn: 'deleted_at'
|
|
769
|
+
})
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
### Database-Specific Behavior
|
|
773
|
+
|
|
774
|
+
| Feature | PostgreSQL | MySQL | SQLite |
|
|
775
|
+
|---------|-----------|-------|--------|
|
|
776
|
+
| **Timestamp Format** | TIMESTAMP | DATETIME | TEXT or INTEGER |
|
|
777
|
+
| **NULL Handling** | ✅ Native | ✅ Native | ✅ Native |
|
|
778
|
+
| **CURRENT_TIMESTAMP** | ✅ Supported | ✅ Supported | ✅ Supported |
|
|
779
|
+
| **Index on deleted_at** | ✅ Recommended | ✅ Recommended | ✅ Recommended |
|
|
780
|
+
|
|
781
|
+
---
|
|
782
|
+
|
|
783
|
+
## 🎨 Type Safety
|
|
784
|
+
|
|
785
|
+
The plugin is fully type-safe with TypeScript.
|
|
786
|
+
|
|
787
|
+
### Extended Repository Interface
|
|
788
|
+
|
|
789
|
+
```typescript
|
|
790
|
+
interface SoftDeleteRepository<T> extends Repository<T> {
|
|
791
|
+
softDelete(id: number): Promise<T>
|
|
792
|
+
restore(id: number): Promise<T>
|
|
793
|
+
hardDelete(id: number): Promise<void>
|
|
794
|
+
findAllWithDeleted(): Promise<T[]>
|
|
795
|
+
findDeleted(): Promise<T[]>
|
|
796
|
+
findWithDeleted(id: number): Promise<T | null>
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Type-safe usage
|
|
800
|
+
const userRepo: SoftDeleteRepository<User> = orm.createRepository(/* ... */)
|
|
801
|
+
|
|
802
|
+
// ✅ Type-safe calls
|
|
803
|
+
const user: User = await userRepo.softDelete(1)
|
|
804
|
+
const deleted: User[] = await userRepo.findDeleted()
|
|
805
|
+
|
|
806
|
+
// ❌ Type error
|
|
807
|
+
await userRepo.softDelete('invalid') // Error: string not assignable to number
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
### Database Schema Types
|
|
811
|
+
|
|
812
|
+
```typescript
|
|
813
|
+
import type { Generated } from 'kysely'
|
|
814
|
+
|
|
815
|
+
interface Database {
|
|
816
|
+
users: {
|
|
817
|
+
id: Generated<number>
|
|
818
|
+
email: string
|
|
819
|
+
name: string
|
|
820
|
+
created_at: Generated<Date>
|
|
821
|
+
deleted_at: Date | null // ✅ Must be nullable
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// TypeScript ensures deleted_at is nullable
|
|
826
|
+
const plugin = softDeletePlugin({
|
|
827
|
+
deletedAtColumn: 'deleted_at' // ✅ Must exist in schema
|
|
828
|
+
})
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
---
|
|
832
|
+
|
|
833
|
+
## 📖 API Reference
|
|
834
|
+
|
|
835
|
+
### softDeletePlugin(options?)
|
|
836
|
+
|
|
837
|
+
Creates a soft delete plugin instance.
|
|
838
|
+
|
|
839
|
+
**Parameters:**
|
|
840
|
+
|
|
841
|
+
```typescript
|
|
842
|
+
interface SoftDeleteOptions {
|
|
843
|
+
deletedAtColumn?: string // Default: 'deleted_at'
|
|
844
|
+
includeDeleted?: boolean // Default: false
|
|
845
|
+
tables?: string[] // Default: undefined (all tables)
|
|
846
|
+
}
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
**Returns:** `Plugin` instance
|
|
850
|
+
|
|
851
|
+
**Example:**
|
|
852
|
+
|
|
853
|
+
```typescript
|
|
854
|
+
const plugin = softDeletePlugin({
|
|
855
|
+
deletedAtColumn: 'deleted_at',
|
|
856
|
+
tables: ['users', 'posts']
|
|
857
|
+
})
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
---
|
|
861
|
+
|
|
862
|
+
### Repository Methods
|
|
863
|
+
|
|
864
|
+
#### softDelete(id)
|
|
865
|
+
|
|
866
|
+
Soft delete a record by ID.
|
|
867
|
+
|
|
868
|
+
**Parameters:**
|
|
869
|
+
- `id: number` - Record ID
|
|
870
|
+
|
|
871
|
+
**Returns:** `Promise<T>` - The soft-deleted record
|
|
872
|
+
|
|
873
|
+
**Throws:** Error if record not found
|
|
874
|
+
|
|
875
|
+
---
|
|
876
|
+
|
|
877
|
+
#### restore(id)
|
|
878
|
+
|
|
879
|
+
Restore a soft-deleted record.
|
|
880
|
+
|
|
881
|
+
**Parameters:**
|
|
882
|
+
- `id: number` - Record ID
|
|
883
|
+
|
|
884
|
+
**Returns:** `Promise<T>` - The restored record
|
|
885
|
+
|
|
886
|
+
---
|
|
887
|
+
|
|
888
|
+
#### hardDelete(id)
|
|
889
|
+
|
|
890
|
+
Permanently delete a record.
|
|
891
|
+
|
|
892
|
+
**Parameters:**
|
|
893
|
+
- `id: number` - Record ID
|
|
894
|
+
|
|
895
|
+
**Returns:** `Promise<void>`
|
|
896
|
+
|
|
897
|
+
---
|
|
898
|
+
|
|
899
|
+
#### findAllWithDeleted()
|
|
900
|
+
|
|
901
|
+
Find all records including soft-deleted.
|
|
902
|
+
|
|
903
|
+
**Returns:** `Promise<T[]>`
|
|
904
|
+
|
|
905
|
+
---
|
|
906
|
+
|
|
907
|
+
#### findDeleted()
|
|
908
|
+
|
|
909
|
+
Find only soft-deleted records.
|
|
910
|
+
|
|
911
|
+
**Returns:** `Promise<T[]>`
|
|
912
|
+
|
|
913
|
+
---
|
|
914
|
+
|
|
915
|
+
#### findWithDeleted(id)
|
|
916
|
+
|
|
917
|
+
Find a record by ID including if soft-deleted.
|
|
918
|
+
|
|
919
|
+
**Parameters:**
|
|
920
|
+
- `id: number` - Record ID
|
|
921
|
+
|
|
922
|
+
**Returns:** `Promise<T | null>`
|
|
923
|
+
|
|
924
|
+
---
|
|
925
|
+
|
|
926
|
+
## ✨ Best Practices
|
|
927
|
+
|
|
928
|
+
### 1. Always Use Nullable deleted_at
|
|
929
|
+
|
|
930
|
+
```typescript
|
|
931
|
+
// ✅ Good: deleted_at is nullable
|
|
932
|
+
interface Database {
|
|
933
|
+
users: {
|
|
934
|
+
id: Generated<number>
|
|
935
|
+
deleted_at: Date | null // ✅ Can be null
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// ❌ Bad: deleted_at not nullable
|
|
940
|
+
interface Database {
|
|
941
|
+
users: {
|
|
942
|
+
deleted_at: Date // ❌ Must always have value
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
### 2. Index deleted_at Column
|
|
948
|
+
|
|
949
|
+
```sql
|
|
950
|
+
-- ✅ Good: Index for performance
|
|
951
|
+
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
|
|
952
|
+
|
|
953
|
+
-- Even better: Partial index (PostgreSQL)
|
|
954
|
+
CREATE INDEX idx_users_not_deleted ON users(id)
|
|
955
|
+
WHERE deleted_at IS NULL;
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
### 3. Use Explicit Method Names
|
|
959
|
+
|
|
960
|
+
```typescript
|
|
961
|
+
// ✅ Good: Clear intent
|
|
962
|
+
await userRepo.softDelete(userId) // Soft delete
|
|
963
|
+
await userRepo.hardDelete(userId) // Hard delete
|
|
964
|
+
|
|
965
|
+
// ❌ Confusing: What does delete do?
|
|
966
|
+
await userRepo.delete(userId) // Soft or hard delete?
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
### 4. Clean Up Old Soft-Deleted Records
|
|
970
|
+
|
|
971
|
+
```typescript
|
|
972
|
+
// ✅ Good: Regular cleanup
|
|
973
|
+
async function cleanup() {
|
|
974
|
+
const cutoff = new Date()
|
|
975
|
+
cutoff.setDate(cutoff.getDate() - 90) // 90 days ago
|
|
976
|
+
|
|
977
|
+
const old = await db
|
|
978
|
+
.selectFrom('users')
|
|
979
|
+
.selectAll()
|
|
980
|
+
.where('deleted_at', '<', cutoff.toISOString())
|
|
981
|
+
.where('deleted_at', 'is not', null)
|
|
982
|
+
.execute()
|
|
983
|
+
|
|
984
|
+
for (const user of old) {
|
|
985
|
+
await userRepo.hardDelete(user.id)
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
### 5. Consider Cascade Behavior
|
|
991
|
+
|
|
992
|
+
```typescript
|
|
993
|
+
// When soft deleting, consider related records
|
|
994
|
+
async function softDeleteUserWithData(userId: number) {
|
|
995
|
+
await db.transaction().execute(async (trx) => {
|
|
996
|
+
const txUserRepo = userRepo.withTransaction(trx)
|
|
997
|
+
const txPostRepo = postRepo.withTransaction(trx)
|
|
998
|
+
|
|
999
|
+
// Soft delete user
|
|
1000
|
+
await txUserRepo.softDelete(userId)
|
|
1001
|
+
|
|
1002
|
+
// Also soft delete their posts
|
|
1003
|
+
const posts = await db
|
|
1004
|
+
.selectFrom('posts')
|
|
1005
|
+
.selectAll()
|
|
1006
|
+
.where('user_id', '=', userId)
|
|
1007
|
+
.execute()
|
|
1008
|
+
|
|
1009
|
+
for (const post of posts) {
|
|
1010
|
+
await txPostRepo.softDelete(post.id)
|
|
1011
|
+
}
|
|
1012
|
+
})
|
|
1013
|
+
}
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
### 6. Implement Restore Validation
|
|
1017
|
+
|
|
1018
|
+
```typescript
|
|
1019
|
+
// ✅ Good: Validate before restore
|
|
1020
|
+
async function safeRestore(userId: number) {
|
|
1021
|
+
const user = await userRepo.findWithDeleted(userId)
|
|
1022
|
+
|
|
1023
|
+
if (!user) {
|
|
1024
|
+
throw new Error('User not found')
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (!user.deleted_at) {
|
|
1028
|
+
throw new Error('User is not deleted')
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Check if restore is allowed
|
|
1032
|
+
const daysSinceDeleted = Math.floor(
|
|
1033
|
+
(Date.now() - new Date(user.deleted_at).getTime()) / (1000 * 60 * 60 * 24)
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
if (daysSinceDeleted > 30) {
|
|
1037
|
+
throw new Error('Cannot restore: deleted more than 30 days ago')
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return await userRepo.restore(userId)
|
|
1041
|
+
}
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
### 7. Use Table Filtering Wisely
|
|
1045
|
+
|
|
1046
|
+
```typescript
|
|
1047
|
+
// ✅ Good: Only user-facing tables
|
|
1048
|
+
const plugin = softDeletePlugin({
|
|
1049
|
+
tables: ['users', 'posts', 'comments', 'orders']
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
// ❌ Bad: Including system tables
|
|
1053
|
+
const plugin = softDeletePlugin({
|
|
1054
|
+
tables: ['users', 'posts', 'migrations', 'sessions']
|
|
1055
|
+
// migrations and sessions shouldn't need soft delete
|
|
1056
|
+
})
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
---
|
|
1060
|
+
|
|
1061
|
+
## ⚡ Performance
|
|
1062
|
+
|
|
1063
|
+
### Plugin Overhead
|
|
1064
|
+
|
|
1065
|
+
| Operation | Base | With Soft Delete | Overhead |
|
|
1066
|
+
|-----------|------|------------------|----------|
|
|
1067
|
+
| **create** | 2ms | 2ms | 0ms |
|
|
1068
|
+
| **findById** | 1ms | 1.1ms | +0.1ms |
|
|
1069
|
+
| **findAll** | 15ms | 15.2ms | +0.2ms |
|
|
1070
|
+
| **softDelete** | - | 2ms | N/A |
|
|
1071
|
+
| **restore** | - | 2ms | N/A |
|
|
1072
|
+
|
|
1073
|
+
### Query Performance
|
|
1074
|
+
|
|
1075
|
+
```typescript
|
|
1076
|
+
// Without index on deleted_at
|
|
1077
|
+
SELECT * FROM users WHERE deleted_at IS NULL
|
|
1078
|
+
// Full table scan: O(n)
|
|
1079
|
+
|
|
1080
|
+
// With index on deleted_at
|
|
1081
|
+
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
|
|
1082
|
+
// Index scan: O(log n)
|
|
1083
|
+
|
|
1084
|
+
// Even better: Partial index (PostgreSQL only)
|
|
1085
|
+
CREATE INDEX idx_users_not_deleted ON users(id)
|
|
1086
|
+
WHERE deleted_at IS NULL;
|
|
1087
|
+
// Smallest index, fastest queries for non-deleted records
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
### Bundle Size
|
|
1091
|
+
|
|
1092
|
+
```
|
|
1093
|
+
@kysera/soft-delete: 477 B (minified)
|
|
1094
|
+
├── softDeletePlugin: 350 B
|
|
1095
|
+
├── Type definitions: 77 B
|
|
1096
|
+
└── Repository extensions: 50 B
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
---
|
|
1100
|
+
|
|
1101
|
+
## 🔧 Troubleshooting
|
|
1102
|
+
|
|
1103
|
+
### Records Not Filtered Out
|
|
1104
|
+
|
|
1105
|
+
**Problem:** Soft-deleted records still appear in queries.
|
|
1106
|
+
|
|
1107
|
+
**Solutions:**
|
|
1108
|
+
|
|
1109
|
+
1. **Check plugin is registered:**
|
|
1110
|
+
```typescript
|
|
1111
|
+
// ❌ No plugin
|
|
1112
|
+
const orm = await createORM(db, [])
|
|
1113
|
+
|
|
1114
|
+
// ✅ Plugin registered
|
|
1115
|
+
const orm = await createORM(db, [softDeletePlugin()])
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
2. **Check table is included:**
|
|
1119
|
+
```typescript
|
|
1120
|
+
// Check configuration
|
|
1121
|
+
const plugin = softDeletePlugin({
|
|
1122
|
+
tables: ['posts'] // ❌ 'users' not included!
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
// Fix: Add 'users'
|
|
1126
|
+
const plugin = softDeletePlugin({
|
|
1127
|
+
tables: ['users', 'posts'] // ✅ Both included
|
|
1128
|
+
})
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
3. **Check using ORM-created repository:**
|
|
1132
|
+
```typescript
|
|
1133
|
+
// ❌ Wrong: Direct factory (no plugins)
|
|
1134
|
+
const factory = createRepositoryFactory(db)
|
|
1135
|
+
const repo = factory.create(/* ... */)
|
|
1136
|
+
|
|
1137
|
+
// ✅ Correct: ORM with plugins
|
|
1138
|
+
const orm = await createORM(db, [softDeletePlugin()])
|
|
1139
|
+
const repo = orm.createRepository((executor) => {
|
|
1140
|
+
const factory = createRepositoryFactory(executor)
|
|
1141
|
+
return factory.create(/* ... */)
|
|
1142
|
+
})
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
### softDelete Method Not Available
|
|
1146
|
+
|
|
1147
|
+
**Problem:** `repo.softDelete` is undefined.
|
|
1148
|
+
|
|
1149
|
+
**Solution:** Ensure you're using the ORM-created repository:
|
|
1150
|
+
|
|
1151
|
+
```typescript
|
|
1152
|
+
// ❌ Wrong: Direct repository creation
|
|
1153
|
+
const repo = factory.create(/* ... */)
|
|
1154
|
+
await repo.softDelete(1) // ❌ Method doesn't exist
|
|
1155
|
+
|
|
1156
|
+
// ✅ Correct: ORM-extended repository
|
|
1157
|
+
const orm = await createORM(db, [softDeletePlugin()])
|
|
1158
|
+
const repo = orm.createRepository((executor) => {
|
|
1159
|
+
const factory = createRepositoryFactory(executor)
|
|
1160
|
+
return factory.create(/* ... */)
|
|
1161
|
+
})
|
|
1162
|
+
await repo.softDelete(1) // ✅ Method exists
|
|
1163
|
+
```
|
|
1164
|
+
|
|
1165
|
+
### Restore Not Working
|
|
1166
|
+
|
|
1167
|
+
**Problem:** `restore()` doesn't bring back the record.
|
|
1168
|
+
|
|
1169
|
+
**Solution:** Check if record was hard-deleted:
|
|
1170
|
+
|
|
1171
|
+
```typescript
|
|
1172
|
+
// Check if record exists at all
|
|
1173
|
+
const user = await db
|
|
1174
|
+
.selectFrom('users')
|
|
1175
|
+
.selectAll()
|
|
1176
|
+
.where('id', '=', userId)
|
|
1177
|
+
.executeTakeFirst()
|
|
1178
|
+
|
|
1179
|
+
if (!user) {
|
|
1180
|
+
// Record was hard-deleted, cannot restore
|
|
1181
|
+
console.error('Record permanently deleted')
|
|
1182
|
+
} else if (user.deleted_at) {
|
|
1183
|
+
// Record is soft-deleted, can restore
|
|
1184
|
+
await userRepo.restore(userId)
|
|
1185
|
+
} else {
|
|
1186
|
+
// Record is not deleted
|
|
1187
|
+
console.error('Record is not deleted')
|
|
1188
|
+
}
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
### Performance Issues
|
|
1192
|
+
|
|
1193
|
+
**Problem:** Queries with soft delete filtering are slow.
|
|
1194
|
+
|
|
1195
|
+
**Solution:** Add indexes:
|
|
1196
|
+
|
|
1197
|
+
```sql
|
|
1198
|
+
-- Basic index
|
|
1199
|
+
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
|
|
1200
|
+
|
|
1201
|
+
-- Partial index (PostgreSQL - even better)
|
|
1202
|
+
CREATE INDEX idx_users_not_deleted ON users(id)
|
|
1203
|
+
WHERE deleted_at IS NULL;
|
|
1204
|
+
|
|
1205
|
+
-- Composite index if you filter by other columns
|
|
1206
|
+
CREATE INDEX idx_users_status_deleted
|
|
1207
|
+
ON users(status, deleted_at);
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
---
|
|
1211
|
+
|
|
1212
|
+
## 🤝 Contributing
|
|
1213
|
+
|
|
1214
|
+
Contributions are welcome! This package follows strict development principles:
|
|
1215
|
+
|
|
1216
|
+
- ✅ **Minimal dependencies** (@kysera/repository only)
|
|
1217
|
+
- ✅ **100% type safe** (TypeScript strict mode)
|
|
1218
|
+
- ✅ **95%+ test coverage** (39+ tests)
|
|
1219
|
+
- ✅ **Multi-database tested** (PostgreSQL, MySQL, SQLite)
|
|
1220
|
+
- ✅ **ESM only** (no CommonJS)
|
|
1221
|
+
|
|
1222
|
+
See [CLAUDE.md](../../CLAUDE.md) for development guidelines.
|
|
1223
|
+
|
|
1224
|
+
---
|
|
1225
|
+
|
|
1226
|
+
## 📄 License
|
|
1227
|
+
|
|
1228
|
+
MIT © Kysera
|
|
1229
|
+
|
|
1230
|
+
---
|
|
1231
|
+
|
|
1232
|
+
## 🔗 Links
|
|
1233
|
+
|
|
1234
|
+
- [GitHub Repository](https://github.com/omnitron/kysera)
|
|
1235
|
+
- [@kysera/repository Documentation](../repository/README.md)
|
|
1236
|
+
- [@kysera/core Documentation](../core/README.md)
|
|
1237
|
+
- [Kysely Documentation](https://kysely.dev)
|
|
1238
|
+
- [Issue Tracker](https://github.com/omnitron/kysera/issues)
|
|
1239
|
+
|
|
1240
|
+
---
|
|
1241
|
+
|
|
1242
|
+
**Built with ❤️ for safe, recoverable data management**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kysera/soft-delete",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Soft delete plugin for Kysera ORM",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"kysely": ">=0.28.0"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@kysera/repository": "0.
|
|
22
|
+
"@kysera/repository": "0.4.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/better-sqlite3": "^7.6.13",
|