@kysera/soft-delete 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +573 -1065
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +9 -7
- package/src/index.ts +64 -84
package/README.md
CHANGED
|
@@ -1,1349 +1,857 @@
|
|
|
1
1
|
# @kysera/soft-delete
|
|
2
2
|
|
|
3
|
-
|
|
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.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
|
-
| **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
|
|
3
|
+
Soft delete plugin for Kysera. Implements soft delete functionality using the Method Override pattern with automatic filtering of deleted records.
|
|
35
4
|
|
|
36
|
-
|
|
37
|
-
# npm
|
|
38
|
-
npm install @kysera/soft-delete @kysera/repository kysely
|
|
5
|
+
## Features
|
|
39
6
|
|
|
40
|
-
|
|
41
|
-
|
|
7
|
+
- Automatic filtering of soft-deleted records in SELECT queries
|
|
8
|
+
- Repository methods for soft delete operations (softDelete, restore, hardDelete)
|
|
9
|
+
- Bulk operations (softDeleteMany, restoreMany, hardDeleteMany)
|
|
10
|
+
- Query methods for deleted records (findWithDeleted, findAllWithDeleted, findDeleted)
|
|
11
|
+
- Works with both Repository and DAL patterns through unified executor layer
|
|
12
|
+
- Full transaction support with ACID compliance
|
|
13
|
+
- Configurable deleted column name, primary key, and table filtering
|
|
14
|
+
- Cross-runtime compatible (Node.js, Bun, Deno)
|
|
15
|
+
- Zero runtime dependencies
|
|
42
16
|
|
|
43
|
-
|
|
44
|
-
bun add @kysera/soft-delete @kysera/repository kysely
|
|
17
|
+
## Installation
|
|
45
18
|
|
|
46
|
-
|
|
47
|
-
|
|
19
|
+
```bash
|
|
20
|
+
npm install @kysera/soft-delete
|
|
21
|
+
# or
|
|
22
|
+
pnpm add @kysera/soft-delete
|
|
23
|
+
# or
|
|
24
|
+
yarn add @kysera/soft-delete
|
|
25
|
+
# or
|
|
26
|
+
bun add @kysera/soft-delete
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Peer Dependencies
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"@kysera/executor": ">=0.7.0",
|
|
34
|
+
"kysely": ">=0.28.8",
|
|
35
|
+
"zod": ">=4.1.13"
|
|
36
|
+
}
|
|
48
37
|
```
|
|
49
38
|
|
|
50
|
-
|
|
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;
|
|
39
|
+
Note: `zod` is optional (used for configuration schema validation in `kysera-cli`)
|
|
57
40
|
|
|
58
|
-
|
|
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
|
-
```
|
|
41
|
+
## Quick Start
|
|
67
42
|
|
|
68
|
-
###
|
|
43
|
+
### With Repository Pattern
|
|
69
44
|
|
|
70
45
|
```typescript
|
|
71
|
-
import {
|
|
72
|
-
import {
|
|
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
|
-
})
|
|
46
|
+
import { createORM } from '@kysera/repository';
|
|
47
|
+
import { softDeletePlugin } from '@kysera/soft-delete';
|
|
94
48
|
|
|
95
|
-
// Create
|
|
49
|
+
// Create plugin container with soft-delete plugin
|
|
96
50
|
const orm = await createORM(db, [
|
|
97
|
-
softDeletePlugin(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
//
|
|
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
|
-
}
|
|
51
|
+
softDeletePlugin({
|
|
52
|
+
deletedAtColumn: 'deleted_at',
|
|
53
|
+
includeDeleted: false,
|
|
54
|
+
tables: ['users', 'posts'] // Only these tables support soft delete
|
|
112
55
|
})
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// Use repository with soft delete!
|
|
116
|
-
const user = await userRepo.create({
|
|
117
|
-
email: 'alice@example.com',
|
|
118
|
-
name: 'Alice'
|
|
119
|
-
})
|
|
56
|
+
]);
|
|
120
57
|
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// Find all - excludes soft-deleted records
|
|
125
|
-
const users = await userRepo.findAll() // Alice not included
|
|
58
|
+
// Create repository
|
|
59
|
+
const userRepo = orm.createRepository(createUserRepository);
|
|
126
60
|
|
|
127
|
-
//
|
|
128
|
-
|
|
61
|
+
// Soft delete a user (sets deleted_at timestamp)
|
|
62
|
+
await userRepo.softDelete(1);
|
|
129
63
|
|
|
130
|
-
//
|
|
131
|
-
await userRepo.
|
|
64
|
+
// Find all users (excludes soft-deleted automatically)
|
|
65
|
+
const users = await userRepo.findAll();
|
|
132
66
|
|
|
133
|
-
//
|
|
134
|
-
await userRepo.
|
|
135
|
-
```
|
|
67
|
+
// Find including deleted records
|
|
68
|
+
const allUsers = await userRepo.findAllWithDeleted();
|
|
136
69
|
|
|
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:**
|
|
70
|
+
// Restore a soft-deleted user
|
|
71
|
+
await userRepo.restore(1);
|
|
181
72
|
|
|
182
|
-
|
|
183
|
-
|
|
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!
|
|
73
|
+
// Permanently delete (real DELETE)
|
|
74
|
+
await userRepo.hardDelete(1);
|
|
191
75
|
```
|
|
192
76
|
|
|
193
|
-
###
|
|
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
|
|
77
|
+
### With DAL Pattern
|
|
214
78
|
|
|
215
79
|
```typescript
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
await userRepo.delete(userId) // Actually removes record
|
|
221
|
-
|
|
222
|
-
// ✅ Use hardDelete for clarity
|
|
223
|
-
await userRepo.hardDelete(userId) // Explicitly hard delete
|
|
224
|
-
```
|
|
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';
|
|
225
84
|
|
|
226
|
-
|
|
85
|
+
// Create executor with soft-delete plugin
|
|
86
|
+
const executor = await createExecutor(db, [
|
|
87
|
+
softDeletePlugin({
|
|
88
|
+
deletedAtColumn: 'deleted_at',
|
|
89
|
+
includeDeleted: false
|
|
90
|
+
})
|
|
91
|
+
]);
|
|
227
92
|
|
|
228
|
-
|
|
93
|
+
// Create context
|
|
94
|
+
const ctx = createContext(executor);
|
|
229
95
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
96
|
+
// Define queries - soft-delete filter applied automatically
|
|
97
|
+
const getUsers = createQuery((ctx) =>
|
|
98
|
+
ctx.db.selectFrom('users').selectAll().execute()
|
|
99
|
+
);
|
|
233
100
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
101
|
+
const getUserById = createQuery((ctx, id: number) =>
|
|
102
|
+
ctx.db
|
|
103
|
+
.selectFrom('users')
|
|
104
|
+
.selectAll()
|
|
105
|
+
.where('id', '=', id)
|
|
106
|
+
.executeTakeFirst()
|
|
107
|
+
);
|
|
237
108
|
|
|
238
|
-
|
|
239
|
-
|
|
109
|
+
// Execute queries - deleted records automatically filtered
|
|
110
|
+
const users = await getUsers(ctx); // Excludes soft-deleted
|
|
111
|
+
const user = await getUserById(ctx, 1);
|
|
240
112
|
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
113
|
+
// Soft delete within transaction
|
|
114
|
+
await withTransaction(executor, async (txCtx) => {
|
|
115
|
+
await txCtx.db
|
|
116
|
+
.updateTable('users')
|
|
117
|
+
.set({ deleted_at: sql`CURRENT_TIMESTAMP` })
|
|
118
|
+
.where('id', '=', 1)
|
|
119
|
+
.execute();
|
|
244
120
|
|
|
245
|
-
|
|
246
|
-
|
|
121
|
+
// Subsequent queries in same transaction see the deletion
|
|
122
|
+
const users = await getUsers(txCtx); // User 1 excluded
|
|
123
|
+
});
|
|
247
124
|
```
|
|
248
125
|
|
|
249
|
-
|
|
126
|
+
## Plugin Architecture
|
|
250
127
|
|
|
251
|
-
|
|
128
|
+
The soft-delete plugin leverages `@kysera/executor` for unified plugin support across both Repository and DAL patterns.
|
|
252
129
|
|
|
253
|
-
###
|
|
254
|
-
|
|
255
|
-
The plugin works with zero configuration using sensible defaults:
|
|
130
|
+
### How It Works
|
|
256
131
|
|
|
257
132
|
```typescript
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
// Equivalent to:
|
|
261
|
-
const plugin = softDeletePlugin({
|
|
262
|
-
deletedAtColumn: 'deleted_at',
|
|
263
|
-
includeDeleted: false,
|
|
264
|
-
tables: undefined, // All tables
|
|
265
|
-
primaryKeyColumn: 'id' // Default primary key
|
|
266
|
-
})
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
### Custom Column Names
|
|
133
|
+
import { createExecutor, getRawDb } from '@kysera/executor';
|
|
134
|
+
import type { Plugin, QueryBuilderContext } from '@kysera/executor';
|
|
270
135
|
|
|
271
|
-
|
|
136
|
+
// Plugin implements the Plugin interface
|
|
137
|
+
const plugin = softDeletePlugin();
|
|
272
138
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const plugin = softDeletePlugin({
|
|
276
|
-
deletedAtColumn: 'removed_at'
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
// Database schema
|
|
280
|
-
interface Database {
|
|
281
|
-
users: {
|
|
282
|
-
id: Generated<number>
|
|
283
|
-
email: string
|
|
284
|
-
removed_at: Date | null // ✅ Custom name
|
|
285
|
-
}
|
|
286
|
-
}
|
|
139
|
+
// Executor wraps Kysely with plugin interception
|
|
140
|
+
const executor = await createExecutor(db, [plugin]);
|
|
287
141
|
|
|
288
|
-
//
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
142
|
+
// All queries through executor have soft-delete filter applied
|
|
143
|
+
const users = await executor
|
|
144
|
+
.selectFrom('users')
|
|
145
|
+
.selectAll()
|
|
146
|
+
.execute(); // WHERE users.deleted_at IS NULL (added automatically)
|
|
292
147
|
```
|
|
293
148
|
|
|
294
|
-
###
|
|
149
|
+
### Plugin Interface
|
|
295
150
|
|
|
296
|
-
|
|
151
|
+
The plugin implements the `Plugin` interface from `@kysera/executor`:
|
|
297
152
|
|
|
298
153
|
```typescript
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
}
|
|
154
|
+
interface Plugin {
|
|
155
|
+
name: string;
|
|
156
|
+
version: string;
|
|
157
|
+
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB;
|
|
158
|
+
extendRepository<T extends object>(repo: T): T;
|
|
312
159
|
}
|
|
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
160
|
```
|
|
324
161
|
|
|
325
|
-
|
|
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
|
-
|
|
331
|
-
### Table Filtering
|
|
162
|
+
#### interceptQuery
|
|
332
163
|
|
|
333
|
-
|
|
164
|
+
Modifies SELECT query builders to automatically filter out soft-deleted records:
|
|
334
165
|
|
|
335
166
|
```typescript
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
tables
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
// users, posts, comments: ✅ Soft delete enabled
|
|
342
|
-
// other tables: ❌ Soft delete disabled
|
|
343
|
-
```
|
|
344
|
-
|
|
345
|
-
**When to use table filtering:**
|
|
167
|
+
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
|
|
168
|
+
// Check if table supports soft delete
|
|
169
|
+
const supportsSoftDelete = !tables || tables.includes(context.table);
|
|
346
170
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
171
|
+
// Only filter SELECT queries when not explicitly including deleted
|
|
172
|
+
if (
|
|
173
|
+
supportsSoftDelete &&
|
|
174
|
+
context.operation === 'select' &&
|
|
175
|
+
!context.metadata['includeDeleted'] &&
|
|
176
|
+
!includeDeleted
|
|
177
|
+
) {
|
|
178
|
+
// Add WHERE deleted_at IS NULL to the query builder
|
|
179
|
+
return qb.where(`${context.table}.${deletedAtColumn}`, 'is', null);
|
|
180
|
+
}
|
|
352
181
|
|
|
353
|
-
|
|
354
|
-
|
|
182
|
+
return qb;
|
|
183
|
+
}
|
|
355
184
|
```
|
|
356
185
|
|
|
357
|
-
|
|
186
|
+
#### extendRepository
|
|
358
187
|
|
|
359
|
-
|
|
188
|
+
Adds soft delete methods to repositories (Repository pattern only):
|
|
360
189
|
|
|
361
190
|
```typescript
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
// Now queries include soft-deleted records by default
|
|
367
|
-
const users = await userRepo.findAll() // Includes deleted
|
|
368
|
-
|
|
369
|
-
// You'd need to explicitly exclude
|
|
370
|
-
// (Note: this is less common)
|
|
191
|
+
extendRepository<T extends object>(repo: T): T {
|
|
192
|
+
// Adds: softDelete, restore, hardDelete, findWithDeleted,
|
|
193
|
+
// findAllWithDeleted, findDeleted, softDeleteMany, restoreMany, hardDeleteMany
|
|
194
|
+
}
|
|
371
195
|
```
|
|
372
196
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
## 🔧 Repository Methods
|
|
376
|
-
|
|
377
|
-
The plugin extends repositories with these methods:
|
|
378
|
-
|
|
379
|
-
### softDelete
|
|
380
|
-
|
|
381
|
-
Mark a record as deleted by setting `deleted_at` timestamp.
|
|
197
|
+
### Using getRawDb
|
|
382
198
|
|
|
383
|
-
|
|
384
|
-
async softDelete(id: number | string): Promise<T>
|
|
385
|
-
```
|
|
386
|
-
|
|
387
|
-
**Example:**
|
|
199
|
+
The plugin uses `getRawDb()` from `@kysera/executor` to bypass interceptors when needed:
|
|
388
200
|
|
|
389
201
|
```typescript
|
|
390
|
-
|
|
202
|
+
import { getRawDb } from '@kysera/executor';
|
|
391
203
|
|
|
392
|
-
|
|
204
|
+
// Inside plugin's extendRepository method
|
|
205
|
+
const rawDb = getRawDb(repo.executor);
|
|
393
206
|
|
|
394
|
-
//
|
|
395
|
-
|
|
207
|
+
// Use rawDb to bypass soft-delete filter
|
|
208
|
+
// (needed for findWithDeleted, restore, etc.)
|
|
209
|
+
const allRecords = await rawDb
|
|
396
210
|
.selectFrom('users')
|
|
397
211
|
.selectAll()
|
|
398
|
-
.
|
|
399
|
-
.executeTakeFirst()
|
|
400
|
-
|
|
401
|
-
console.log(directQuery) // Record exists with deleted_at set
|
|
212
|
+
.execute(); // No soft-delete filter applied
|
|
402
213
|
```
|
|
403
214
|
|
|
404
|
-
|
|
405
|
-
- User account deletion
|
|
406
|
-
- Content moderation
|
|
407
|
-
- Order cancellation
|
|
408
|
-
- Temporary removals
|
|
409
|
-
- Implementing "Trash" feature
|
|
215
|
+
This is critical for methods like `findWithDeleted()` and `restore()` that need to access soft-deleted records.
|
|
410
216
|
|
|
411
|
-
|
|
217
|
+
## Configuration Options
|
|
412
218
|
|
|
413
|
-
|
|
219
|
+
### SoftDeleteOptions
|
|
414
220
|
|
|
415
221
|
```typescript
|
|
416
|
-
|
|
222
|
+
interface SoftDeleteOptions {
|
|
223
|
+
/**
|
|
224
|
+
* Column name for soft delete timestamp.
|
|
225
|
+
* @default 'deleted_at'
|
|
226
|
+
*/
|
|
227
|
+
deletedAtColumn?: string;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Include deleted records by default in queries.
|
|
231
|
+
* When false, soft-deleted records are automatically filtered out.
|
|
232
|
+
* @default false
|
|
233
|
+
*/
|
|
234
|
+
includeDeleted?: boolean;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* List of tables that support soft delete.
|
|
238
|
+
* If not provided, all tables are assumed to support it.
|
|
239
|
+
* @example ['users', 'posts', 'comments']
|
|
240
|
+
*/
|
|
241
|
+
tables?: string[];
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Primary key column name used for identifying records.
|
|
245
|
+
* @default 'id'
|
|
246
|
+
* @example 'uuid', 'user_id', 'post_id'
|
|
247
|
+
*/
|
|
248
|
+
primaryKeyColumn?: string;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Logger for plugin operations.
|
|
252
|
+
* Uses KyseraLogger interface from @kysera/core.
|
|
253
|
+
* @default silentLogger (no output)
|
|
254
|
+
*/
|
|
255
|
+
logger?: KyseraLogger;
|
|
256
|
+
}
|
|
417
257
|
```
|
|
418
258
|
|
|
419
|
-
|
|
259
|
+
### Example Configurations
|
|
420
260
|
|
|
421
261
|
```typescript
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
// Later, restore them
|
|
426
|
-
const restored = await userRepo.restore(userId)
|
|
262
|
+
// Default configuration
|
|
263
|
+
softDeletePlugin();
|
|
427
264
|
|
|
428
|
-
|
|
265
|
+
// Custom deleted column
|
|
266
|
+
softDeletePlugin({
|
|
267
|
+
deletedAtColumn: 'removed_at'
|
|
268
|
+
});
|
|
429
269
|
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
//
|
|
433
|
-
|
|
270
|
+
// Only specific tables
|
|
271
|
+
softDeletePlugin({
|
|
272
|
+
tables: ['users', 'posts'], // Only these tables support soft delete
|
|
273
|
+
deletedAtColumn: 'deleted_at'
|
|
274
|
+
});
|
|
434
275
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
- Admin recovery tools
|
|
276
|
+
// Include deleted by default
|
|
277
|
+
softDeletePlugin({
|
|
278
|
+
includeDeleted: true // Don't filter deleted records
|
|
279
|
+
});
|
|
440
280
|
|
|
441
|
-
|
|
281
|
+
// Custom primary key
|
|
282
|
+
softDeletePlugin({
|
|
283
|
+
primaryKeyColumn: 'uuid' // For tables using 'uuid' instead of 'id'
|
|
284
|
+
});
|
|
442
285
|
|
|
443
|
-
|
|
286
|
+
// With logging
|
|
287
|
+
import { consoleLogger } from '@kysera/core';
|
|
444
288
|
|
|
445
|
-
|
|
446
|
-
|
|
289
|
+
softDeletePlugin({
|
|
290
|
+
logger: consoleLogger
|
|
291
|
+
});
|
|
447
292
|
```
|
|
448
293
|
|
|
449
|
-
|
|
294
|
+
## Repository Methods
|
|
450
295
|
|
|
451
|
-
|
|
452
|
-
// Permanently remove a user
|
|
453
|
-
await userRepo.hardDelete(userId)
|
|
296
|
+
The plugin extends repositories with the following methods:
|
|
454
297
|
|
|
455
|
-
|
|
456
|
-
const user = await db
|
|
457
|
-
.selectFrom('users')
|
|
458
|
-
.selectAll()
|
|
459
|
-
.where('id', '=', userId)
|
|
460
|
-
.executeTakeFirst()
|
|
298
|
+
### SoftDeleteMethods Interface
|
|
461
299
|
|
|
462
|
-
|
|
300
|
+
```typescript
|
|
301
|
+
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>;
|
|
311
|
+
}
|
|
463
312
|
```
|
|
464
313
|
|
|
465
|
-
|
|
466
|
-
- GDPR "right to be forgotten" compliance
|
|
467
|
-
- Cleaning up test data
|
|
468
|
-
- Purging old soft-deleted records
|
|
469
|
-
- Admin force-delete
|
|
314
|
+
### Method Documentation
|
|
470
315
|
|
|
471
|
-
|
|
316
|
+
#### softDelete(id)
|
|
472
317
|
|
|
473
|
-
|
|
318
|
+
Marks a record as deleted by setting the `deleted_at` timestamp to `CURRENT_TIMESTAMP`.
|
|
474
319
|
|
|
475
320
|
```typescript
|
|
476
|
-
|
|
321
|
+
// Soft delete user with id 1
|
|
322
|
+
const deletedUser = await userRepo.softDelete(1);
|
|
323
|
+
console.log(deletedUser.deleted_at); // '2025-12-11T10:30:00Z'
|
|
324
|
+
|
|
325
|
+
// Record still exists in database but won't appear in findAll()
|
|
326
|
+
const users = await userRepo.findAll(); // Excludes deleted user
|
|
477
327
|
```
|
|
478
328
|
|
|
479
|
-
**
|
|
329
|
+
**Returns**: `Promise<T>` - The soft-deleted record
|
|
330
|
+
**Throws**: `NotFoundError` if record doesn't exist
|
|
480
331
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
332
|
+
#### restore(id)
|
|
333
|
+
|
|
334
|
+
Restores a soft-deleted record by setting `deleted_at` to `null`.
|
|
484
335
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
336
|
+
```typescript
|
|
337
|
+
// Restore soft-deleted user
|
|
338
|
+
const restoredUser = await userRepo.restore(1);
|
|
339
|
+
console.log(restoredUser.deleted_at); // null
|
|
488
340
|
|
|
489
|
-
//
|
|
490
|
-
const
|
|
491
|
-
console.log(all.length) // 3 (includes Bob)
|
|
341
|
+
// Record now appears in queries again
|
|
342
|
+
const users = await userRepo.findAll(); // Includes restored user
|
|
492
343
|
```
|
|
493
344
|
|
|
494
|
-
**
|
|
495
|
-
|
|
496
|
-
- Audit trails
|
|
497
|
-
- Data export including deleted
|
|
498
|
-
- Recovery interfaces
|
|
345
|
+
**Returns**: `Promise<T>` - The restored record
|
|
346
|
+
**Throws**: `NotFoundError` if record doesn't exist
|
|
499
347
|
|
|
500
|
-
|
|
348
|
+
#### hardDelete(id)
|
|
501
349
|
|
|
502
|
-
|
|
350
|
+
Permanently deletes a record using real SQL DELETE. Cannot be restored.
|
|
503
351
|
|
|
504
352
|
```typescript
|
|
505
|
-
|
|
506
|
-
|
|
353
|
+
// Permanently delete user
|
|
354
|
+
await userRepo.hardDelete(1);
|
|
507
355
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
```typescript
|
|
511
|
-
// Soft delete some users
|
|
512
|
-
await userRepo.softDelete(aliceId)
|
|
513
|
-
await userRepo.softDelete(bobId)
|
|
514
|
-
|
|
515
|
-
// Find only deleted
|
|
516
|
-
const deleted = await userRepo.findDeleted()
|
|
517
|
-
console.log(deleted.length) // 2 (Alice and Bob)
|
|
518
|
-
console.log(deleted[0].deleted_at) // Not null
|
|
356
|
+
// Record is gone forever
|
|
357
|
+
const user = await userRepo.findWithDeleted(1); // null
|
|
519
358
|
```
|
|
520
359
|
|
|
521
|
-
**
|
|
522
|
-
- "Trash" or "Recycle Bin" view
|
|
523
|
-
- Deleted items list
|
|
524
|
-
- Cleanup candidates
|
|
525
|
-
- Audit reports
|
|
360
|
+
**Returns**: `Promise<void>`
|
|
526
361
|
|
|
527
|
-
|
|
362
|
+
#### findWithDeleted(id)
|
|
528
363
|
|
|
529
|
-
|
|
364
|
+
Finds a record by ID including soft-deleted records.
|
|
530
365
|
|
|
531
366
|
```typescript
|
|
532
|
-
|
|
367
|
+
// Find user even if soft-deleted
|
|
368
|
+
const user = await userRepo.findWithDeleted(1);
|
|
369
|
+
if (user?.deleted_at) {
|
|
370
|
+
console.log('User was soft-deleted');
|
|
371
|
+
}
|
|
533
372
|
```
|
|
534
373
|
|
|
535
|
-
**
|
|
374
|
+
**Returns**: `Promise<T | null>`
|
|
536
375
|
|
|
537
|
-
|
|
538
|
-
// Soft delete Alice
|
|
539
|
-
await userRepo.softDelete(aliceId)
|
|
376
|
+
#### findAllWithDeleted()
|
|
540
377
|
|
|
541
|
-
|
|
542
|
-
const user1 = await userRepo.findById(aliceId)
|
|
543
|
-
console.log(user1) // null
|
|
378
|
+
Returns all records including soft-deleted ones.
|
|
544
379
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
380
|
+
```typescript
|
|
381
|
+
// 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`);
|
|
549
385
|
```
|
|
550
386
|
|
|
551
|
-
**
|
|
552
|
-
- Recovery by ID
|
|
553
|
-
- Audit lookups
|
|
554
|
-
- Admin record inspection
|
|
555
|
-
- Restore confirmation
|
|
556
|
-
|
|
557
|
-
---
|
|
387
|
+
**Returns**: `Promise<T[]>`
|
|
558
388
|
|
|
559
|
-
|
|
389
|
+
#### findDeleted()
|
|
560
390
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
### How It Works
|
|
391
|
+
Returns only soft-deleted records.
|
|
564
392
|
|
|
565
393
|
```typescript
|
|
566
|
-
//
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
// Becomes:
|
|
570
|
-
db.selectFrom('users')
|
|
571
|
-
.selectAll()
|
|
572
|
-
.where('users.deleted_at', 'is', null) // Auto-added!
|
|
394
|
+
// Get only deleted users
|
|
395
|
+
const deletedUsers = await userRepo.findDeleted();
|
|
396
|
+
console.log(`Found ${deletedUsers.length} deleted users`);
|
|
573
397
|
```
|
|
574
398
|
|
|
575
|
-
|
|
399
|
+
**Returns**: `Promise<T[]>`
|
|
400
|
+
|
|
401
|
+
#### softDeleteMany(ids)
|
|
576
402
|
|
|
577
|
-
|
|
403
|
+
Soft deletes multiple records in a single operation (bulk operation).
|
|
578
404
|
|
|
579
405
|
```typescript
|
|
580
|
-
//
|
|
581
|
-
await userRepo.
|
|
582
|
-
|
|
583
|
-
await userRepo.find({ where: {...} }) // ✅ Filtered
|
|
584
|
-
|
|
585
|
-
// SELECT queries through ORM
|
|
586
|
-
const result = await orm.applyPlugins(
|
|
587
|
-
db.selectFrom('users').selectAll(),
|
|
588
|
-
'select',
|
|
589
|
-
'users',
|
|
590
|
-
{}
|
|
591
|
-
).execute() // ✅ Filtered
|
|
406
|
+
// Soft delete multiple users at once
|
|
407
|
+
const deletedUsers = await userRepo.softDeleteMany([1, 2, 3]);
|
|
408
|
+
console.log(`Soft deleted ${deletedUsers.length} users`);
|
|
592
409
|
```
|
|
593
410
|
|
|
594
|
-
|
|
411
|
+
**Returns**: `Promise<T[]>` - Array of deleted records
|
|
412
|
+
**Throws**: `NotFoundError` if any record doesn't exist
|
|
595
413
|
|
|
596
|
-
|
|
597
|
-
// Direct Kysely queries (bypass ORM)
|
|
598
|
-
await db.selectFrom('users').selectAll().execute()
|
|
599
|
-
// ❌ Not filtered (direct DB access)
|
|
414
|
+
#### restoreMany(ids)
|
|
600
415
|
|
|
601
|
-
|
|
602
|
-
await db.deleteFrom('users').where('id', '=', 1).execute()
|
|
603
|
-
// ❌ Still deletes (not converted to soft delete)
|
|
416
|
+
Restores multiple soft-deleted records in a single operation.
|
|
604
417
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
418
|
+
```typescript
|
|
419
|
+
// Restore multiple users at once
|
|
420
|
+
const restoredUsers = await userRepo.restoreMany([1, 2, 3]);
|
|
421
|
+
console.log(`Restored ${restoredUsers.length} users`);
|
|
608
422
|
```
|
|
609
423
|
|
|
610
|
-
|
|
424
|
+
**Returns**: `Promise<T[]>` - Array of restored records
|
|
425
|
+
|
|
426
|
+
#### hardDeleteMany(ids)
|
|
611
427
|
|
|
612
|
-
|
|
428
|
+
Permanently deletes multiple records in a single operation.
|
|
613
429
|
|
|
614
430
|
```typescript
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
const user = await userRepo.findWithDeleted(userId)
|
|
618
|
-
|
|
619
|
-
// Method 2: Use metadata flag (with ORM)
|
|
620
|
-
const result = await orm.applyPlugins(
|
|
621
|
-
db.selectFrom('users').selectAll(),
|
|
622
|
-
'select',
|
|
623
|
-
'users',
|
|
624
|
-
{ includeDeleted: true } // ✅ Include deleted
|
|
625
|
-
).execute()
|
|
626
|
-
|
|
627
|
-
// Method 3: Direct Kysely query (bypass plugin)
|
|
628
|
-
const all = await db.selectFrom('users').selectAll().execute()
|
|
431
|
+
// Permanently delete multiple users
|
|
432
|
+
await userRepo.hardDeleteMany([1, 2, 3]);
|
|
629
433
|
```
|
|
630
434
|
|
|
631
|
-
|
|
435
|
+
**Returns**: `Promise<void>`
|
|
632
436
|
|
|
633
|
-
##
|
|
437
|
+
## DAL Integration
|
|
634
438
|
|
|
635
|
-
|
|
439
|
+
The soft-delete plugin works seamlessly with the DAL pattern through the executor layer.
|
|
636
440
|
|
|
637
|
-
|
|
441
|
+
### Automatic Filtering in DAL Queries
|
|
638
442
|
|
|
639
443
|
```typescript
|
|
640
|
-
import {
|
|
641
|
-
import {
|
|
642
|
-
import { auditPlugin } from '@kysera/audit'
|
|
444
|
+
import { createExecutor } from '@kysera/executor';
|
|
445
|
+
import { createContext, createQuery } from '@kysera/dal';
|
|
643
446
|
|
|
644
|
-
const
|
|
645
|
-
timestampsPlugin(), // Auto timestamps
|
|
646
|
-
softDeletePlugin(), // Soft delete
|
|
647
|
-
auditPlugin({ userId }) // Audit logging
|
|
648
|
-
])
|
|
649
|
-
|
|
650
|
-
// All plugins work together:
|
|
651
|
-
await userRepo.softDelete(userId)
|
|
652
|
-
// ✅ deleted_at timestamp set
|
|
653
|
-
// ✅ updated_at timestamp updated (timestamps plugin)
|
|
654
|
-
// ✅ Audit log created (audit plugin)
|
|
655
|
-
```
|
|
656
|
-
|
|
657
|
-
### Transaction Support
|
|
447
|
+
const executor = await createExecutor(db, [softDeletePlugin()]);
|
|
658
448
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const txRepo = userRepo.withTransaction(trx)
|
|
664
|
-
|
|
665
|
-
// Soft delete in transaction
|
|
666
|
-
await txRepo.softDelete(userId)
|
|
449
|
+
// Define queries - filter applied automatically
|
|
450
|
+
const getAllUsers = createQuery((ctx) =>
|
|
451
|
+
ctx.db.selectFrom('users').selectAll().execute()
|
|
452
|
+
);
|
|
667
453
|
|
|
668
|
-
|
|
669
|
-
|
|
454
|
+
const getUserById = createQuery((ctx, id: number) =>
|
|
455
|
+
ctx.db
|
|
456
|
+
.selectFrom('users')
|
|
457
|
+
.selectAll()
|
|
458
|
+
.where('id', '=', id)
|
|
459
|
+
.executeTakeFirst()
|
|
460
|
+
);
|
|
670
461
|
|
|
671
|
-
|
|
672
|
-
|
|
462
|
+
// Execute queries
|
|
463
|
+
const ctx = createContext(executor);
|
|
464
|
+
const users = await getAllUsers(ctx); // Excludes deleted
|
|
465
|
+
const user = await getUserById(ctx, 1);
|
|
673
466
|
```
|
|
674
467
|
|
|
675
|
-
###
|
|
468
|
+
### Query Interception
|
|
676
469
|
|
|
677
|
-
The plugin
|
|
470
|
+
The plugin's `interceptQuery` method modifies SELECT query builders:
|
|
678
471
|
|
|
679
472
|
```typescript
|
|
680
|
-
//
|
|
681
|
-
|
|
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
|
|
473
|
+
// Original query
|
|
474
|
+
ctx.db.selectFrom('users').selectAll()
|
|
690
475
|
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
await userRepo.softDelete(id) // 5 separate UPDATE queries
|
|
697
|
-
}
|
|
476
|
+
// After plugin interception
|
|
477
|
+
ctx.db
|
|
478
|
+
.selectFrom('users')
|
|
479
|
+
.selectAll()
|
|
480
|
+
.where('users.deleted_at', 'is', null) // Added automatically
|
|
698
481
|
```
|
|
699
482
|
|
|
700
|
-
|
|
701
|
-
- Loop: 100 records = 100 queries (~2000ms)
|
|
702
|
-
- Batch: 100 records = 1 query (~20ms)
|
|
703
|
-
- **100x faster! 🚀**
|
|
483
|
+
### Operations Not Intercepted
|
|
704
484
|
|
|
705
|
-
|
|
485
|
+
The plugin uses Method Override pattern, not full query interception:
|
|
706
486
|
|
|
707
|
-
|
|
487
|
+
- **SELECT queries**: Automatically filtered
|
|
488
|
+
- **INSERT queries**: Not affected
|
|
489
|
+
- **UPDATE queries**: Not affected
|
|
490
|
+
- **DELETE queries**: NOT converted to soft deletes
|
|
491
|
+
|
|
492
|
+
To perform soft deletes, use the `softDelete()` method explicitly:
|
|
708
493
|
|
|
709
494
|
```typescript
|
|
710
|
-
|
|
711
|
-
async function conditionalDelete(userId: number) {
|
|
712
|
-
const user = await userRepo.findById(userId)
|
|
495
|
+
import { sql } from 'kysely';
|
|
713
496
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
}
|
|
497
|
+
// ❌ This performs a real DELETE (not soft delete)
|
|
498
|
+
await ctx.db.deleteFrom('users').where('id', '=', 1).execute();
|
|
717
499
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
.selectFrom('orders')
|
|
721
|
-
.select('id')
|
|
722
|
-
.where('user_id', '=', userId)
|
|
723
|
-
.executeTakeFirst()
|
|
500
|
+
// ✅ Use softDelete method instead (in Repository pattern)
|
|
501
|
+
await userRepo.softDelete(1);
|
|
724
502
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
}
|
|
732
|
-
}
|
|
503
|
+
// ✅ Or manual UPDATE in DAL pattern
|
|
504
|
+
await ctx.db
|
|
505
|
+
.updateTable('users')
|
|
506
|
+
.set({ deleted_at: sql`CURRENT_TIMESTAMP` })
|
|
507
|
+
.where('id', '=', 1)
|
|
508
|
+
.execute();
|
|
733
509
|
```
|
|
734
510
|
|
|
735
|
-
###
|
|
511
|
+
### DAL Transaction Support
|
|
736
512
|
|
|
737
513
|
```typescript
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const thirtyDaysAgo = new Date()
|
|
741
|
-
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
|
|
514
|
+
import { withTransaction } from '@kysera/dal';
|
|
515
|
+
import { sql } from 'kysely';
|
|
742
516
|
|
|
743
|
-
|
|
517
|
+
await withTransaction(executor, async (txCtx) => {
|
|
518
|
+
// Soft delete user
|
|
519
|
+
await txCtx.db
|
|
520
|
+
.updateTable('users')
|
|
521
|
+
.set({ deleted_at: sql`CURRENT_TIMESTAMP` })
|
|
522
|
+
.where('id', '=', 1)
|
|
523
|
+
.execute();
|
|
524
|
+
|
|
525
|
+
// Query in same transaction sees deletion
|
|
526
|
+
const users = await txCtx.db
|
|
744
527
|
.selectFrom('users')
|
|
745
528
|
.selectAll()
|
|
746
|
-
.
|
|
747
|
-
.where('deleted_at', '<', thirtyDaysAgo.toISOString())
|
|
748
|
-
.execute()
|
|
749
|
-
|
|
750
|
-
for (const user of oldDeleted) {
|
|
751
|
-
await userRepo.hardDelete(user.id)
|
|
752
|
-
}
|
|
529
|
+
.execute(); // User 1 excluded
|
|
753
530
|
|
|
754
|
-
|
|
755
|
-
}
|
|
531
|
+
// If transaction rolls back, soft delete is also rolled back
|
|
532
|
+
});
|
|
756
533
|
```
|
|
757
534
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
## 🗄️ Multi-Database Support
|
|
535
|
+
## Transaction Behavior
|
|
761
536
|
|
|
762
|
-
The plugin
|
|
537
|
+
The soft-delete plugin respects ACID properties and works correctly with transactions.
|
|
763
538
|
|
|
764
|
-
###
|
|
539
|
+
### ACID Compliance
|
|
765
540
|
|
|
766
541
|
```typescript
|
|
767
|
-
|
|
768
|
-
CREATE TABLE users (
|
|
769
|
-
id SERIAL PRIMARY KEY,
|
|
770
|
-
email VARCHAR(255) NOT NULL,
|
|
771
|
-
name VARCHAR(255) NOT NULL,
|
|
772
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
773
|
-
deleted_at TIMESTAMP NULL -- TIMESTAMP column
|
|
774
|
-
);
|
|
542
|
+
import { withTransaction } from '@kysera/dal';
|
|
775
543
|
|
|
776
|
-
//
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
544
|
+
// ✅ CORRECT: Soft delete commits with transaction
|
|
545
|
+
await withTransaction(executor, async (txCtx) => {
|
|
546
|
+
const repos = createRepositories(txCtx); // Use transaction executor
|
|
547
|
+
await repos.users.softDelete(1);
|
|
548
|
+
await repos.posts.softDeleteMany([1, 2, 3]);
|
|
549
|
+
// If transaction commits, both operations commit
|
|
550
|
+
// If transaction rolls back, both operations roll back
|
|
551
|
+
});
|
|
780
552
|
```
|
|
781
553
|
|
|
782
|
-
###
|
|
554
|
+
### Rollback Behavior
|
|
783
555
|
|
|
784
556
|
```typescript
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
email VARCHAR(255) NOT NULL,
|
|
789
|
-
name VARCHAR(255) NOT NULL,
|
|
790
|
-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
791
|
-
deleted_at DATETIME NULL -- DATETIME column
|
|
792
|
-
);
|
|
557
|
+
try {
|
|
558
|
+
await withTransaction(executor, async (txCtx) => {
|
|
559
|
+
const repos = createRepositories(txCtx);
|
|
793
560
|
|
|
794
|
-
//
|
|
795
|
-
|
|
796
|
-
deletedAtColumn: 'deleted_at'
|
|
797
|
-
})
|
|
798
|
-
```
|
|
799
|
-
|
|
800
|
-
### SQLite
|
|
801
|
-
|
|
802
|
-
```typescript
|
|
803
|
-
// Schema (TEXT for timestamps)
|
|
804
|
-
CREATE TABLE users (
|
|
805
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
806
|
-
email TEXT NOT NULL,
|
|
807
|
-
name TEXT NOT NULL,
|
|
808
|
-
created_at TEXT DEFAULT (datetime('now')),
|
|
809
|
-
deleted_at TEXT NULL -- TEXT column for timestamp
|
|
810
|
-
);
|
|
561
|
+
// Soft delete user
|
|
562
|
+
await repos.users.softDelete(1);
|
|
811
563
|
|
|
812
|
-
//
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
564
|
+
// Force rollback
|
|
565
|
+
throw new Error('Force rollback');
|
|
566
|
+
});
|
|
567
|
+
} catch (error) {
|
|
568
|
+
// Transaction rolled back
|
|
569
|
+
}
|
|
817
570
|
|
|
818
|
-
//
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
})
|
|
571
|
+
// Verify soft-delete was rolled back
|
|
572
|
+
const user = await userRepo.findById(1);
|
|
573
|
+
console.log(user?.deleted_at); // null (not deleted)
|
|
822
574
|
```
|
|
823
575
|
|
|
824
|
-
###
|
|
576
|
+
### Cascade Soft Delete Pattern
|
|
825
577
|
|
|
826
|
-
|
|
827
|
-
|---------|-----------|-------|--------|
|
|
828
|
-
| **Timestamp Format** | TIMESTAMP | DATETIME | TEXT or INTEGER |
|
|
829
|
-
| **NULL Handling** | ✅ Native | ✅ Native | ✅ Native |
|
|
830
|
-
| **CURRENT_TIMESTAMP** | ✅ Supported | ✅ Supported | ✅ Supported |
|
|
831
|
-
| **Index on deleted_at** | ✅ Recommended | ✅ Recommended | ✅ Recommended |
|
|
832
|
-
|
|
833
|
-
---
|
|
834
|
-
|
|
835
|
-
## 🎨 Type Safety
|
|
836
|
-
|
|
837
|
-
The plugin is fully type-safe with TypeScript.
|
|
838
|
-
|
|
839
|
-
### Extended Repository Interface
|
|
578
|
+
The plugin does not automatically cascade soft deletes. You must implement cascade patterns manually:
|
|
840
579
|
|
|
841
580
|
```typescript
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
//
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
await
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
### Database Schema Types
|
|
581
|
+
// Manual cascade soft delete
|
|
582
|
+
await db.transaction().execute(async (trx) => {
|
|
583
|
+
const repos = createRepositories(trx);
|
|
584
|
+
const userId = 123;
|
|
585
|
+
|
|
586
|
+
// Step 1: Find related records
|
|
587
|
+
const userPosts = await repos.posts.findBy({ user_id: userId });
|
|
588
|
+
const postIds = userPosts.map(p => p.id);
|
|
589
|
+
|
|
590
|
+
// Step 2: Soft delete children first
|
|
591
|
+
if (postIds.length > 0) {
|
|
592
|
+
const postComments = await repos.comments.findBy({
|
|
593
|
+
post_id: { in: postIds }
|
|
594
|
+
});
|
|
595
|
+
const commentIds = postComments.map(c => c.id);
|
|
596
|
+
|
|
597
|
+
if (commentIds.length > 0) {
|
|
598
|
+
await repos.comments.softDeleteMany(commentIds);
|
|
599
|
+
}
|
|
863
600
|
|
|
864
|
-
|
|
865
|
-
import type { Generated } from 'kysely'
|
|
866
|
-
|
|
867
|
-
interface Database {
|
|
868
|
-
users: {
|
|
869
|
-
id: Generated<number>
|
|
870
|
-
email: string
|
|
871
|
-
name: string
|
|
872
|
-
created_at: Generated<Date>
|
|
873
|
-
deleted_at: Date | null // ✅ Must be nullable
|
|
601
|
+
await repos.posts.softDeleteMany(postIds);
|
|
874
602
|
}
|
|
875
|
-
}
|
|
876
603
|
|
|
877
|
-
//
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
})
|
|
604
|
+
// Step 3: Soft delete parent
|
|
605
|
+
await repos.users.softDelete(userId);
|
|
606
|
+
});
|
|
881
607
|
```
|
|
882
608
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
## 📖 API Reference
|
|
609
|
+
### Transaction Isolation
|
|
886
610
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
Creates a soft delete plugin instance.
|
|
890
|
-
|
|
891
|
-
**Parameters:**
|
|
611
|
+
Soft-delete operations within a transaction are immediately visible to subsequent queries in the same transaction:
|
|
892
612
|
|
|
893
613
|
```typescript
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
includeDeleted?: boolean // Default: false
|
|
897
|
-
tables?: string[] // Default: undefined (all tables)
|
|
898
|
-
primaryKeyColumn?: string // Default: 'id'
|
|
899
|
-
logger?: KyseraLogger // Default: silentLogger (no output)
|
|
900
|
-
}
|
|
901
|
-
```
|
|
902
|
-
|
|
903
|
-
**Returns:** `Plugin` instance
|
|
904
|
-
|
|
905
|
-
**Example:**
|
|
906
|
-
|
|
907
|
-
```typescript
|
|
908
|
-
const plugin = softDeletePlugin({
|
|
909
|
-
deletedAtColumn: 'deleted_at',
|
|
910
|
-
tables: ['users', 'posts']
|
|
911
|
-
})
|
|
912
|
-
```
|
|
913
|
-
|
|
914
|
-
---
|
|
915
|
-
|
|
916
|
-
### Repository Methods
|
|
917
|
-
|
|
918
|
-
#### softDelete(id)
|
|
919
|
-
|
|
920
|
-
Soft delete a record by ID.
|
|
921
|
-
|
|
922
|
-
**Parameters:**
|
|
923
|
-
- `id: number | string` - Record ID
|
|
924
|
-
|
|
925
|
-
**Returns:** `Promise<T>` - The soft-deleted record
|
|
926
|
-
|
|
927
|
-
**Throws:** Error if record not found
|
|
928
|
-
|
|
929
|
-
---
|
|
930
|
-
|
|
931
|
-
#### restore(id)
|
|
932
|
-
|
|
933
|
-
Restore a soft-deleted record.
|
|
934
|
-
|
|
935
|
-
**Parameters:**
|
|
936
|
-
- `id: number | string` - Record ID
|
|
937
|
-
|
|
938
|
-
**Returns:** `Promise<T>` - The restored record
|
|
614
|
+
await withTransaction(executor, async (txCtx) => {
|
|
615
|
+
const repos = createRepositories(txCtx);
|
|
939
616
|
|
|
940
|
-
|
|
617
|
+
// Before soft delete
|
|
618
|
+
const usersBefore = await repos.users.findAll();
|
|
619
|
+
console.log(usersBefore.length); // 10
|
|
941
620
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
Permanently delete a record.
|
|
945
|
-
|
|
946
|
-
**Parameters:**
|
|
947
|
-
- `id: number | string` - Record ID
|
|
948
|
-
|
|
949
|
-
**Returns:** `Promise<void>`
|
|
950
|
-
|
|
951
|
-
---
|
|
952
|
-
|
|
953
|
-
#### findAllWithDeleted()
|
|
954
|
-
|
|
955
|
-
Find all records including soft-deleted.
|
|
956
|
-
|
|
957
|
-
**Returns:** `Promise<T[]>`
|
|
958
|
-
|
|
959
|
-
---
|
|
960
|
-
|
|
961
|
-
#### findDeleted()
|
|
962
|
-
|
|
963
|
-
Find only soft-deleted records.
|
|
964
|
-
|
|
965
|
-
**Returns:** `Promise<T[]>`
|
|
966
|
-
|
|
967
|
-
---
|
|
968
|
-
|
|
969
|
-
#### findWithDeleted(id)
|
|
970
|
-
|
|
971
|
-
Find a record by ID including if soft-deleted.
|
|
972
|
-
|
|
973
|
-
**Parameters:**
|
|
974
|
-
- `id: number | string` - Record ID
|
|
975
|
-
|
|
976
|
-
**Returns:** `Promise<T | null>`
|
|
621
|
+
// Soft delete user
|
|
622
|
+
await repos.users.softDelete(1);
|
|
977
623
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
|
624
|
+
// Immediately visible in same transaction
|
|
625
|
+
const usersAfter = await repos.users.findAll();
|
|
626
|
+
console.log(usersAfter.length); // 9
|
|
627
|
+
});
|
|
995
628
|
```
|
|
996
629
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
#### restoreMany(ids)
|
|
630
|
+
## Database Schema Requirements
|
|
1000
631
|
|
|
1001
|
-
|
|
632
|
+
Your database tables need a `deleted_at` column (or custom column name) to support soft delete:
|
|
1002
633
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
634
|
+
```sql
|
|
635
|
+
CREATE TABLE users (
|
|
636
|
+
id INTEGER PRIMARY KEY,
|
|
637
|
+
email TEXT NOT NULL,
|
|
638
|
+
name TEXT NOT NULL,
|
|
639
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
640
|
+
deleted_at TIMESTAMP NULL -- Required for soft delete
|
|
641
|
+
);
|
|
1007
642
|
|
|
1008
|
-
|
|
1009
|
-
```typescript
|
|
1010
|
-
const restoredUsers = await userRepo.restoreMany([1, 2, 3])
|
|
1011
|
-
console.log(restoredUsers.every(u => u.deleted_at === null)) // true
|
|
643
|
+
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
|
|
1012
644
|
```
|
|
1013
645
|
|
|
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
|
|
646
|
+
### Custom Column Name
|
|
1022
647
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
648
|
+
```sql
|
|
649
|
+
CREATE TABLE posts (
|
|
650
|
+
id INTEGER PRIMARY KEY,
|
|
651
|
+
title TEXT NOT NULL,
|
|
652
|
+
content TEXT,
|
|
653
|
+
removed_at TIMESTAMP NULL -- Custom name
|
|
654
|
+
);
|
|
1029
655
|
```
|
|
1030
656
|
|
|
1031
|
-
---
|
|
1032
|
-
|
|
1033
|
-
## ✨ Best Practices
|
|
1034
|
-
|
|
1035
|
-
### 1. Always Use Nullable deleted_at
|
|
1036
|
-
|
|
1037
657
|
```typescript
|
|
1038
|
-
//
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
// ❌ Bad: deleted_at not nullable
|
|
1047
|
-
interface Database {
|
|
1048
|
-
users: {
|
|
1049
|
-
deleted_at: Date // ❌ Must always have value
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
658
|
+
// Configure plugin to use custom column
|
|
659
|
+
softDeletePlugin({
|
|
660
|
+
deletedAtColumn: 'removed_at',
|
|
661
|
+
tables: ['posts']
|
|
662
|
+
});
|
|
1052
663
|
```
|
|
1053
664
|
|
|
1054
|
-
###
|
|
665
|
+
### Custom Primary Key
|
|
1055
666
|
|
|
1056
667
|
```sql
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
WHERE deleted_at IS NULL;
|
|
668
|
+
CREATE TABLE comments (
|
|
669
|
+
comment_id INTEGER PRIMARY KEY, -- Custom primary key
|
|
670
|
+
content TEXT NOT NULL,
|
|
671
|
+
deleted_at TIMESTAMP NULL
|
|
672
|
+
);
|
|
1063
673
|
```
|
|
1064
674
|
|
|
1065
|
-
### 3. Use Explicit Method Names
|
|
1066
|
-
|
|
1067
675
|
```typescript
|
|
1068
|
-
//
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
await userRepo.delete(userId) // Soft or hard delete?
|
|
676
|
+
// Configure plugin to use custom primary key
|
|
677
|
+
softDeletePlugin({
|
|
678
|
+
primaryKeyColumn: 'comment_id',
|
|
679
|
+
tables: ['comments']
|
|
680
|
+
});
|
|
1074
681
|
```
|
|
1075
682
|
|
|
1076
|
-
|
|
683
|
+
## Type Safety
|
|
1077
684
|
|
|
1078
|
-
|
|
1079
|
-
// ✅ Good: Regular cleanup
|
|
1080
|
-
async function cleanup() {
|
|
1081
|
-
const cutoff = new Date()
|
|
1082
|
-
cutoff.setDate(cutoff.getDate() - 90) // 90 days ago
|
|
1083
|
-
|
|
1084
|
-
const old = await db
|
|
1085
|
-
.selectFrom('users')
|
|
1086
|
-
.selectAll()
|
|
1087
|
-
.where('deleted_at', '<', cutoff.toISOString())
|
|
1088
|
-
.where('deleted_at', 'is not', null)
|
|
1089
|
-
.execute()
|
|
1090
|
-
|
|
1091
|
-
for (const user of old) {
|
|
1092
|
-
await userRepo.hardDelete(user.id)
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
```
|
|
1096
|
-
|
|
1097
|
-
### 5. Consider Cascade Behavior
|
|
685
|
+
The plugin maintains full type safety with TypeScript:
|
|
1098
686
|
|
|
1099
687
|
```typescript
|
|
1100
|
-
|
|
1101
|
-
async function softDeleteUserWithData(userId: number) {
|
|
1102
|
-
await db.transaction().execute(async (trx) => {
|
|
1103
|
-
const txUserRepo = userRepo.withTransaction(trx)
|
|
1104
|
-
const txPostRepo = postRepo.withTransaction(trx)
|
|
688
|
+
import type { SoftDeleteRepository } from '@kysera/soft-delete';
|
|
1105
689
|
|
|
1106
|
-
|
|
1107
|
-
|
|
690
|
+
// Extend repository type with soft delete methods
|
|
691
|
+
type UserRepository = SoftDeleteRepository<User>;
|
|
1108
692
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
693
|
+
const userRepo: UserRepository = orm.createRepository((executor) => {
|
|
694
|
+
const base = createRepositoryFactory(executor);
|
|
695
|
+
return base.create({
|
|
696
|
+
tableName: 'users',
|
|
697
|
+
mapRow: (row) => row as User
|
|
698
|
+
});
|
|
699
|
+
});
|
|
1115
700
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
}
|
|
701
|
+
// 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();
|
|
1121
705
|
```
|
|
1122
706
|
|
|
1123
|
-
|
|
707
|
+
## Error Handling
|
|
1124
708
|
|
|
1125
|
-
|
|
1126
|
-
// ✅ Good: Validate before restore
|
|
1127
|
-
async function safeRestore(userId: number) {
|
|
1128
|
-
const user = await userRepo.findWithDeleted(userId)
|
|
709
|
+
The plugin uses error types from `@kysera/core`:
|
|
1129
710
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
}
|
|
711
|
+
```typescript
|
|
712
|
+
import { NotFoundError } from '@kysera/core';
|
|
1133
713
|
|
|
1134
|
-
|
|
1135
|
-
|
|
714
|
+
try {
|
|
715
|
+
await userRepo.softDelete(999); // Non-existent ID
|
|
716
|
+
} catch (error) {
|
|
717
|
+
if (error instanceof NotFoundError) {
|
|
718
|
+
console.error('User not found:', error.metadata);
|
|
719
|
+
// error.metadata = { id: 999 }
|
|
1136
720
|
}
|
|
721
|
+
}
|
|
1137
722
|
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
)
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
throw new Error('Cannot restore: deleted more than 30 days ago')
|
|
723
|
+
try {
|
|
724
|
+
await userRepo.softDeleteMany([1, 2, 999]); // One ID doesn't exist
|
|
725
|
+
} catch (error) {
|
|
726
|
+
if (error instanceof NotFoundError) {
|
|
727
|
+
console.error('Some users not found:', error.metadata);
|
|
728
|
+
// error.metadata = { ids: [999] }
|
|
1145
729
|
}
|
|
1146
|
-
|
|
1147
|
-
return await userRepo.restore(userId)
|
|
1148
730
|
}
|
|
1149
731
|
```
|
|
1150
732
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
```typescript
|
|
1154
|
-
// ✅ Good: Only user-facing tables
|
|
1155
|
-
const plugin = softDeletePlugin({
|
|
1156
|
-
tables: ['users', 'posts', 'comments', 'orders']
|
|
1157
|
-
})
|
|
1158
|
-
|
|
1159
|
-
// ❌ Bad: Including system tables
|
|
1160
|
-
const plugin = softDeletePlugin({
|
|
1161
|
-
tables: ['users', 'posts', 'migrations', 'sessions']
|
|
1162
|
-
// migrations and sessions shouldn't need soft delete
|
|
1163
|
-
})
|
|
1164
|
-
```
|
|
1165
|
-
|
|
1166
|
-
---
|
|
733
|
+
## Performance Considerations
|
|
1167
734
|
|
|
1168
|
-
|
|
735
|
+
### Index Requirements
|
|
1169
736
|
|
|
1170
|
-
|
|
737
|
+
Always add an index on the `deleted_at` column for optimal query performance:
|
|
1171
738
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
| **findAll** | 15ms | 15.2ms | +0.2ms |
|
|
1177
|
-
| **softDelete** | - | 2ms | N/A |
|
|
1178
|
-
| **restore** | - | 2ms | N/A |
|
|
739
|
+
```sql
|
|
740
|
+
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
|
|
741
|
+
CREATE INDEX idx_posts_deleted_at ON posts(deleted_at);
|
|
742
|
+
```
|
|
1179
743
|
|
|
1180
744
|
### Query Performance
|
|
1181
745
|
|
|
1182
|
-
|
|
1183
|
-
// Without index on deleted_at
|
|
1184
|
-
SELECT * FROM users WHERE deleted_at IS NULL
|
|
1185
|
-
// Full table scan: O(n)
|
|
746
|
+
The plugin adds a `WHERE deleted_at IS NULL` condition to all SELECT queries. With proper indexing, this has minimal performance impact.
|
|
1186
747
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
748
|
+
```sql
|
|
749
|
+
-- Without index: Full table scan
|
|
750
|
+
SELECT * FROM users WHERE deleted_at IS NULL;
|
|
1190
751
|
|
|
1191
|
-
|
|
1192
|
-
CREATE INDEX
|
|
1193
|
-
WHERE deleted_at IS NULL;
|
|
1194
|
-
// Smallest index, fastest queries for non-deleted records
|
|
752
|
+
-- With index: Index scan (fast)
|
|
753
|
+
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
|
|
754
|
+
SELECT * FROM users WHERE deleted_at IS NULL;
|
|
1195
755
|
```
|
|
1196
756
|
|
|
1197
|
-
###
|
|
757
|
+
### Bulk Operations
|
|
1198
758
|
|
|
1199
|
-
|
|
1200
|
-
@kysera/soft-delete: 477 B (minified)
|
|
1201
|
-
├── softDeletePlugin: 350 B
|
|
1202
|
-
├── Type definitions: 77 B
|
|
1203
|
-
└── Repository extensions: 50 B
|
|
1204
|
-
```
|
|
759
|
+
Use bulk methods for better performance when operating on multiple records:
|
|
1205
760
|
|
|
1206
|
-
|
|
761
|
+
```typescript
|
|
762
|
+
// ❌ Inefficient: N queries
|
|
763
|
+
for (const id of userIds) {
|
|
764
|
+
await userRepo.softDelete(id);
|
|
765
|
+
}
|
|
1207
766
|
|
|
1208
|
-
|
|
767
|
+
// ✅ Efficient: Single query
|
|
768
|
+
await userRepo.softDeleteMany(userIds);
|
|
769
|
+
```
|
|
1209
770
|
|
|
1210
|
-
|
|
771
|
+
## Architecture Notes
|
|
1211
772
|
|
|
1212
|
-
|
|
773
|
+
### Method Override Pattern
|
|
1213
774
|
|
|
1214
|
-
|
|
775
|
+
The plugin uses Method Override, not full query interception:
|
|
1215
776
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
777
|
+
- **SELECT queries**: Automatically filtered using `interceptQuery`
|
|
778
|
+
- **DELETE operations**: NOT automatically converted to soft deletes
|
|
779
|
+
- Use `softDelete()` method explicitly instead of `delete()`
|
|
780
|
+
- Use `hardDelete()` method to bypass soft delete and perform real DELETE
|
|
1220
781
|
|
|
1221
|
-
|
|
1222
|
-
const orm = await createORM(db, [softDeletePlugin()])
|
|
1223
|
-
```
|
|
782
|
+
This design is intentional for simplicity and explicitness.
|
|
1224
783
|
|
|
1225
|
-
|
|
1226
|
-
```typescript
|
|
1227
|
-
// Check configuration
|
|
1228
|
-
const plugin = softDeletePlugin({
|
|
1229
|
-
tables: ['posts'] // ❌ 'users' not included!
|
|
1230
|
-
})
|
|
1231
|
-
|
|
1232
|
-
// Fix: Add 'users'
|
|
1233
|
-
const plugin = softDeletePlugin({
|
|
1234
|
-
tables: ['users', 'posts'] // ✅ Both included
|
|
1235
|
-
})
|
|
1236
|
-
```
|
|
784
|
+
### Plugin Execution Flow
|
|
1237
785
|
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
const repo = factory.create(/* ... */)
|
|
1243
|
-
|
|
1244
|
-
// ✅ Correct: ORM with plugins
|
|
1245
|
-
const orm = await createORM(db, [softDeletePlugin()])
|
|
1246
|
-
const repo = orm.createRepository((executor) => {
|
|
1247
|
-
const factory = createRepositoryFactory(executor)
|
|
1248
|
-
return factory.create(/* ... */)
|
|
1249
|
-
})
|
|
1250
|
-
```
|
|
786
|
+
1. Plugin is registered with `createORM()` or `createExecutor()`
|
|
787
|
+
2. `interceptQuery()` modifies SELECT query builders to add `WHERE deleted_at IS NULL`
|
|
788
|
+
3. `extendRepository()` adds soft delete methods to repositories (Repository pattern only)
|
|
789
|
+
4. Query execution flows through the executor with plugin interception applied
|
|
1251
790
|
|
|
1252
|
-
###
|
|
791
|
+
### Raw Database Access
|
|
1253
792
|
|
|
1254
|
-
|
|
793
|
+
The plugin uses `getRawDb()` to access the underlying Kysely instance without plugin interception. This is necessary for:
|
|
1255
794
|
|
|
1256
|
-
|
|
795
|
+
- `findWithDeleted()`: Needs to see soft-deleted records
|
|
796
|
+
- `findAllWithDeleted()`: Needs to see all records
|
|
797
|
+
- `findDeleted()`: Needs to query deleted records specifically
|
|
798
|
+
- `softDelete()`, `restore()`: Need to fetch records after update
|
|
1257
799
|
|
|
1258
800
|
```typescript
|
|
1259
|
-
|
|
1260
|
-
const repo = factory.create(/* ... */)
|
|
1261
|
-
await repo.softDelete(1) // ❌ Method doesn't exist
|
|
1262
|
-
|
|
1263
|
-
// ✅ Correct: ORM-extended repository
|
|
1264
|
-
const orm = await createORM(db, [softDeletePlugin()])
|
|
1265
|
-
const repo = orm.createRepository((executor) => {
|
|
1266
|
-
const factory = createRepositoryFactory(executor)
|
|
1267
|
-
return factory.create(/* ... */)
|
|
1268
|
-
})
|
|
1269
|
-
await repo.softDelete(1) // ✅ Method exists
|
|
1270
|
-
```
|
|
1271
|
-
|
|
1272
|
-
### Restore Not Working
|
|
1273
|
-
|
|
1274
|
-
**Problem:** `restore()` doesn't bring back the record.
|
|
801
|
+
import { getRawDb } from '@kysera/executor';
|
|
1275
802
|
|
|
1276
|
-
|
|
803
|
+
// Inside plugin
|
|
804
|
+
const rawDb = getRawDb(repo.executor);
|
|
1277
805
|
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
const user = await db
|
|
806
|
+
// Bypass soft-delete filter
|
|
807
|
+
const allRecords = await rawDb
|
|
1281
808
|
.selectFrom('users')
|
|
1282
809
|
.selectAll()
|
|
1283
|
-
.
|
|
1284
|
-
.executeTakeFirst()
|
|
1285
|
-
|
|
1286
|
-
if (!user) {
|
|
1287
|
-
// Record was hard-deleted, cannot restore
|
|
1288
|
-
console.error('Record permanently deleted')
|
|
1289
|
-
} else if (user.deleted_at) {
|
|
1290
|
-
// Record is soft-deleted, can restore
|
|
1291
|
-
await userRepo.restore(userId)
|
|
1292
|
-
} else {
|
|
1293
|
-
// Record is not deleted
|
|
1294
|
-
console.error('Record is not deleted')
|
|
1295
|
-
}
|
|
810
|
+
.execute();
|
|
1296
811
|
```
|
|
1297
812
|
|
|
1298
|
-
|
|
813
|
+
## Testing
|
|
1299
814
|
|
|
1300
|
-
|
|
815
|
+
The package includes comprehensive test coverage:
|
|
1301
816
|
|
|
1302
|
-
|
|
817
|
+
```bash
|
|
818
|
+
# Run all tests
|
|
819
|
+
pnpm test
|
|
1303
820
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
|
|
821
|
+
# Run with coverage
|
|
822
|
+
pnpm test:coverage
|
|
1307
823
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
WHERE deleted_at IS NULL;
|
|
824
|
+
# Run specific test file
|
|
825
|
+
pnpm test soft-delete-repository.test.ts
|
|
1311
826
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
ON users(status, deleted_at);
|
|
827
|
+
# Run DAL integration tests
|
|
828
|
+
pnpm test dal-integration.test.ts
|
|
1315
829
|
```
|
|
1316
830
|
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
-
|
|
1324
|
-
-
|
|
1325
|
-
-
|
|
1326
|
-
-
|
|
1327
|
-
-
|
|
1328
|
-
|
|
1329
|
-
See [CLAUDE.md](../../CLAUDE.md) for development guidelines.
|
|
1330
|
-
|
|
1331
|
-
---
|
|
1332
|
-
|
|
1333
|
-
## 📄 License
|
|
831
|
+
Test files:
|
|
832
|
+
- `test/dal-integration.test.ts` - DAL pattern with createQuery and withTransaction
|
|
833
|
+
- `test/soft-delete-comprehensive.test.ts` - All 9 methods + configuration options
|
|
834
|
+
- `test/soft-delete-repository.test.ts` - Repository pattern core functionality
|
|
835
|
+
- `test/soft-delete-edge-cases.test.ts` - Edge cases and error handling
|
|
836
|
+
- `test/batch-operations.test.ts` - Bulk operation tests (softDeleteMany, etc.)
|
|
837
|
+
- `test/custom-primary-key.test.ts` - Custom primary key column support
|
|
838
|
+
- `test/soft-delete-custom-keys.test.ts` - Custom column name configurations
|
|
839
|
+
- `test/soft-delete-operations.test.ts` - Core soft delete operations
|
|
840
|
+
- `test/soft-delete.test.ts` - Basic soft delete functionality
|
|
841
|
+
- `test/soft-delete-plugin-interaction.test.ts` - Plugin interaction tests
|
|
842
|
+
- `test/multi-db.test.ts` - Multi-database compatibility (PostgreSQL, MySQL, SQLite)
|
|
1334
843
|
|
|
1335
|
-
|
|
844
|
+
## License
|
|
1336
845
|
|
|
1337
|
-
|
|
846
|
+
MIT
|
|
1338
847
|
|
|
1339
|
-
##
|
|
848
|
+
## Contributing
|
|
1340
849
|
|
|
1341
|
-
|
|
1342
|
-
- [@kysera/repository Documentation](../repository/README.md)
|
|
1343
|
-
- [@kysera/core Documentation](../core/README.md)
|
|
1344
|
-
- [Kysely Documentation](https://kysely.dev)
|
|
1345
|
-
- [Issue Tracker](https://github.com/kysera-dev/kysera/issues)
|
|
850
|
+
See the main [Kysera repository](https://github.com/kysera-dev/kysera) for contribution guidelines.
|
|
1346
851
|
|
|
1347
|
-
|
|
852
|
+
## Links
|
|
1348
853
|
|
|
1349
|
-
|
|
854
|
+
- [Documentation](https://kysera.dev)
|
|
855
|
+
- [GitHub](https://github.com/kysera-dev/kysera)
|
|
856
|
+
- [Issues](https://github.com/kysera-dev/kysera/issues)
|
|
857
|
+
- [NPM](https://www.npmjs.com/package/@kysera/soft-delete)
|