@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 CHANGED
@@ -1,1349 +1,857 @@
1
1
  # @kysera/soft-delete
2
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
- [![Version](https://img.shields.io/npm/v/@kysera/soft-delete.svg)](https://www.npmjs.com/package/@kysera/soft-delete)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue)](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
- ```bash
37
- # npm
38
- npm install @kysera/soft-delete @kysera/repository kysely
5
+ ## Features
39
6
 
40
- # pnpm
41
- pnpm add @kysera/soft-delete @kysera/repository kysely
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
- # bun
44
- bun add @kysera/soft-delete @kysera/repository kysely
17
+ ## Installation
45
18
 
46
- # deno
47
- import { softDeletePlugin } from "npm:@kysera/soft-delete"
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
- ## 🚀 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;
39
+ Note: `zod` is optional (used for configuration schema validation in `kysera-cli`)
57
40
 
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
- ```
41
+ ## Quick Start
67
42
 
68
- ### 2. Setup Plugin
43
+ ### With Repository Pattern
69
44
 
70
45
  ```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
- })
46
+ import { createORM } from '@kysera/repository';
47
+ import { softDeletePlugin } from '@kysera/soft-delete';
94
48
 
95
- // Create ORM with soft delete plugin
49
+ // Create plugin container with soft-delete plugin
96
50
  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
- }
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
- // 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
58
+ // Create repository
59
+ const userRepo = orm.createRepository(createUserRepository);
126
60
 
127
- // Find including deleted
128
- const allUsers = await userRepo.findAllWithDeleted() // Alice included
61
+ // Soft delete a user (sets deleted_at timestamp)
62
+ await userRepo.softDelete(1);
129
63
 
130
- // Restore
131
- await userRepo.restore(user.id) // Alice is back!
64
+ // Find all users (excludes soft-deleted automatically)
65
+ const users = await userRepo.findAll();
132
66
 
133
- // Hard delete (permanently remove)
134
- await userRepo.hardDelete(user.id) // Alice gone forever
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
- ```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!
73
+ // Permanently delete (real DELETE)
74
+ await userRepo.hardDelete(1);
191
75
  ```
192
76
 
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
77
+ ### With DAL Pattern
214
78
 
215
79
  ```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
- ```
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
- ### Automatic Filtering
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
- When the plugin is active, soft-deleted records are **automatically excluded** from queries:
93
+ // Create context
94
+ const ctx = createContext(executor);
229
95
 
230
- ```typescript
231
- // Create and soft-delete a user
232
- await userRepo.softDelete(aliceId)
96
+ // Define queries - soft-delete filter applied automatically
97
+ const getUsers = createQuery((ctx) =>
98
+ ctx.db.selectFrom('users').selectAll().execute()
99
+ );
233
100
 
234
- // Queries automatically exclude soft-deleted
235
- const users = await userRepo.findAll()
236
- // Alice NOT included
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
- const user = await userRepo.findById(aliceId)
239
- // Returns null (Alice is soft-deleted)
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
- // Explicitly include deleted
242
- const allUsers = await userRepo.findAllWithDeleted()
243
- // Alice included
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
- const userWithDeleted = await userRepo.findWithDeleted(aliceId)
246
- // Returns Alice even though soft-deleted
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
- ## ⚙️ Configuration
128
+ The soft-delete plugin leverages `@kysera/executor` for unified plugin support across both Repository and DAL patterns.
252
129
 
253
- ### Default Configuration
254
-
255
- The plugin works with zero configuration using sensible defaults:
130
+ ### How It Works
256
131
 
257
132
  ```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
- 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
- Use your own column naming convention:
136
+ // Plugin implements the Plugin interface
137
+ const plugin = softDeletePlugin();
272
138
 
273
- ```typescript
274
- // Example: Use "removed_at"
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
- // Example: Use "archived_at"
289
- const plugin = softDeletePlugin({
290
- deletedAtColumn: 'archived_at'
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
- ### Custom Primary Key Column
149
+ ### Plugin Interface
295
150
 
296
- Configure tables with different primary key column names:
151
+ The plugin implements the `Plugin` interface from `@kysera/executor`:
297
152
 
298
153
  ```typescript
299
- // Example: Use "uuid" as primary key
300
- const plugin = softDeletePlugin({
301
- primaryKeyColumn: 'uuid'
302
- })
303
-
304
- // Database schema
305
- interface Database {
306
- users: {
307
- uuid: Generated<string> // ✅ Custom primary key
308
- email: string
309
- name: string
310
- deleted_at: Date | null
311
- }
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
- **When to use:**
326
- - Tables with UUID primary keys (`uuid`, `guid`)
327
- - Tables with composite naming (`user_id`, `post_id`)
328
- - Legacy databases with custom key columns
329
- - Multi-tenant systems with custom identifiers
330
-
331
- ### Table Filtering
162
+ #### interceptQuery
332
163
 
333
- Apply soft delete only to specific tables:
164
+ Modifies SELECT query builders to automatically filter out soft-deleted records:
334
165
 
335
166
  ```typescript
336
- // Only enable for specific tables
337
- const plugin = softDeletePlugin({
338
- tables: ['users', 'posts', 'comments']
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
- ```typescript
348
- // ✅ Good: User-facing data
349
- const plugin = softDeletePlugin({
350
- tables: ['users', 'posts', 'comments', 'orders']
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
- // ❌ Skip: System/config tables (don't need soft delete)
354
- // migrations, config, sessions - not included in tables list
182
+ return qb;
183
+ }
355
184
  ```
356
185
 
357
- ### Include Deleted by Default
186
+ #### extendRepository
358
187
 
359
- Reverse the default behavior (include deleted records):
188
+ Adds soft delete methods to repositories (Repository pattern only):
360
189
 
361
190
  ```typescript
362
- const plugin = softDeletePlugin({
363
- includeDeleted: true // Include deleted by default
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
- ```typescript
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
- const user = await userRepo.softDelete(userId)
202
+ import { getRawDb } from '@kysera/executor';
391
203
 
392
- console.log(user.deleted_at) // 2024-01-15T10:30:00.000Z
204
+ // Inside plugin's extendRepository method
205
+ const rawDb = getRawDb(repo.executor);
393
206
 
394
- // Record still exists in database
395
- const directQuery = await db
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
- .where('id', '=', userId)
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
- **Use Cases:**
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
- ### restore
217
+ ## Configuration Options
412
218
 
413
- Restore a soft-deleted record by setting `deleted_at` to `null`.
219
+ ### SoftDeleteOptions
414
220
 
415
221
  ```typescript
416
- async restore(id: number | string): Promise<T>
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
- **Example:**
259
+ ### Example Configurations
420
260
 
421
261
  ```typescript
422
- // Soft delete a user
423
- await userRepo.softDelete(userId)
424
-
425
- // Later, restore them
426
- const restored = await userRepo.restore(userId)
262
+ // Default configuration
263
+ softDeletePlugin();
427
264
 
428
- console.log(restored.deleted_at) // null
265
+ // Custom deleted column
266
+ softDeletePlugin({
267
+ deletedAtColumn: 'removed_at'
268
+ });
429
269
 
430
- // User now appears in queries again
431
- const users = await userRepo.findAll()
432
- // Includes restored user
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
- **Use Cases:**
436
- - Undo accidental deletions
437
- - User account reactivation
438
- - Content restoration
439
- - Admin recovery tools
276
+ // Include deleted by default
277
+ softDeletePlugin({
278
+ includeDeleted: true // Don't filter deleted records
279
+ });
440
280
 
441
- ### hardDelete
281
+ // Custom primary key
282
+ softDeletePlugin({
283
+ primaryKeyColumn: 'uuid' // For tables using 'uuid' instead of 'id'
284
+ });
442
285
 
443
- Permanently delete a record from the database (bypasses soft delete).
286
+ // With logging
287
+ import { consoleLogger } from '@kysera/core';
444
288
 
445
- ```typescript
446
- async hardDelete(id: number | string): Promise<void>
289
+ softDeletePlugin({
290
+ logger: consoleLogger
291
+ });
447
292
  ```
448
293
 
449
- **Example:**
294
+ ## Repository Methods
450
295
 
451
- ```typescript
452
- // Permanently remove a user
453
- await userRepo.hardDelete(userId)
296
+ The plugin extends repositories with the following methods:
454
297
 
455
- // Record is GONE from database
456
- const user = await db
457
- .selectFrom('users')
458
- .selectAll()
459
- .where('id', '=', userId)
460
- .executeTakeFirst()
298
+ ### SoftDeleteMethods Interface
461
299
 
462
- console.log(user) // undefined
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
- **Use Cases:**
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
- ### findAllWithDeleted
316
+ #### softDelete(id)
472
317
 
473
- Find all records including soft-deleted ones.
318
+ Marks a record as deleted by setting the `deleted_at` timestamp to `CURRENT_TIMESTAMP`.
474
319
 
475
320
  ```typescript
476
- async findAllWithDeleted(): Promise<T[]>
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
- **Example:**
329
+ **Returns**: `Promise<T>` - The soft-deleted record
330
+ **Throws**: `NotFoundError` if record doesn't exist
480
331
 
481
- ```typescript
482
- // Soft delete Bob
483
- await userRepo.softDelete(bobId)
332
+ #### restore(id)
333
+
334
+ Restores a soft-deleted record by setting `deleted_at` to `null`.
484
335
 
485
- // Normal query excludes Bob
486
- const active = await userRepo.findAll()
487
- console.log(active.length) // 2
336
+ ```typescript
337
+ // Restore soft-deleted user
338
+ const restoredUser = await userRepo.restore(1);
339
+ console.log(restoredUser.deleted_at); // null
488
340
 
489
- // Include deleted shows Bob
490
- const all = await userRepo.findAllWithDeleted()
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
- **Use Cases:**
495
- - Admin panels showing all records
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
- ### findDeleted
348
+ #### hardDelete(id)
501
349
 
502
- Find only soft-deleted records.
350
+ Permanently deletes a record using real SQL DELETE. Cannot be restored.
503
351
 
504
352
  ```typescript
505
- async findDeleted(): Promise<T[]>
506
- ```
353
+ // Permanently delete user
354
+ await userRepo.hardDelete(1);
507
355
 
508
- **Example:**
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
- **Use Cases:**
522
- - "Trash" or "Recycle Bin" view
523
- - Deleted items list
524
- - Cleanup candidates
525
- - Audit reports
360
+ **Returns**: `Promise<void>`
526
361
 
527
- ### findWithDeleted
362
+ #### findWithDeleted(id)
528
363
 
529
- Find a specific record including if soft-deleted.
364
+ Finds a record by ID including soft-deleted records.
530
365
 
531
366
  ```typescript
532
- async findWithDeleted(id: number | string): Promise<T | null>
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
- **Example:**
374
+ **Returns**: `Promise<T | null>`
536
375
 
537
- ```typescript
538
- // Soft delete Alice
539
- await userRepo.softDelete(aliceId)
376
+ #### findAllWithDeleted()
540
377
 
541
- // Normal findById returns null
542
- const user1 = await userRepo.findById(aliceId)
543
- console.log(user1) // null
378
+ Returns all records including soft-deleted ones.
544
379
 
545
- // findWithDeleted returns the record
546
- const user2 = await userRepo.findWithDeleted(aliceId)
547
- console.log(user2) // Alice's record
548
- console.log(user2.deleted_at) // Not null
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
- **Use Cases:**
552
- - Recovery by ID
553
- - Audit lookups
554
- - Admin record inspection
555
- - Restore confirmation
556
-
557
- ---
387
+ **Returns**: `Promise<T[]>`
558
388
 
559
- ## 🎯 Automatic Filtering
389
+ #### findDeleted()
560
390
 
561
- The plugin automatically filters soft-deleted records from queries.
562
-
563
- ### How It Works
391
+ Returns only soft-deleted records.
564
392
 
565
393
  ```typescript
566
- // Behind the scenes, the plugin adds WHERE clause:
567
- db.selectFrom('users').selectAll()
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
- ### What Gets Filtered
399
+ **Returns**: `Promise<T[]>`
400
+
401
+ #### softDeleteMany(ids)
576
402
 
577
- **✅ Automatically filtered:**
403
+ Soft deletes multiple records in a single operation (bulk operation).
578
404
 
579
405
  ```typescript
580
- // Repository methods
581
- await userRepo.findAll() // Filtered
582
- await userRepo.findById(1) // Filtered
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
- **❌ NOT automatically filtered:**
411
+ **Returns**: `Promise<T[]>` - Array of deleted records
412
+ **Throws**: `NotFoundError` if any record doesn't exist
595
413
 
596
- ```typescript
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
- // DELETE operations
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
- // Custom repository methods
606
- await userRepo.customMethod()
607
- // Not filtered (unless explicitly implemented)
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
- ### Bypassing Filters
424
+ **Returns**: `Promise<T[]>` - Array of restored records
425
+
426
+ #### hardDeleteMany(ids)
611
427
 
612
- When you need to include deleted records:
428
+ Permanently deletes multiple records in a single operation.
613
429
 
614
430
  ```typescript
615
- // Method 1: Use *WithDeleted methods
616
- const all = await userRepo.findAllWithDeleted()
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
- ## 🔧 Advanced Usage
437
+ ## DAL Integration
634
438
 
635
- ### Multiple Plugins
439
+ The soft-delete plugin works seamlessly with the DAL pattern through the executor layer.
636
440
 
637
- Combine soft delete with other plugins:
441
+ ### Automatic Filtering in DAL Queries
638
442
 
639
443
  ```typescript
640
- import { softDeletePlugin } from '@kysera/soft-delete'
641
- import { timestampsPlugin } from '@kysera/timestamps'
642
- import { auditPlugin } from '@kysera/audit'
444
+ import { createExecutor } from '@kysera/executor';
445
+ import { createContext, createQuery } from '@kysera/dal';
643
446
 
644
- const orm = await createORM(db, [
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
- Soft deletes work seamlessly with transactions:
660
-
661
- ```typescript
662
- await db.transaction().execute(async (trx) => {
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
- // Other operations
669
- await txRepo.create({ email: 'new@example.com', name: 'New User' })
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
- // If transaction fails, soft delete is rolled back
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
- ### Batch Operations
468
+ ### Query Interception
676
469
 
677
- The plugin provides efficient batch operations for handling multiple records:
470
+ The plugin's `interceptQuery` method modifies SELECT query builders:
678
471
 
679
472
  ```typescript
680
- // Efficient: Single query batch operations
681
- const userIds = [1, 2, 3, 4, 5]
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
- // Hard delete multiple records (single DELETE query)
692
- await userRepo.hardDeleteMany(userIds)
693
-
694
- // ❌ Inefficient: Loop approach (N queries)
695
- for (const id of userIds) {
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
- **Performance Comparison:**
701
- - Loop: 100 records = 100 queries (~2000ms)
702
- - Batch: 100 records = 1 query (~20ms)
703
- - **100x faster! 🚀**
483
+ ### Operations Not Intercepted
704
484
 
705
- **See [BATCH_OPERATIONS.md](./BATCH_OPERATIONS.md) for detailed documentation.**
485
+ The plugin uses Method Override pattern, not full query interception:
706
486
 
707
- ### Conditional Soft Delete
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
- // Only soft delete if certain conditions met
711
- async function conditionalDelete(userId: number) {
712
- const user = await userRepo.findById(userId)
495
+ import { sql } from 'kysely';
713
496
 
714
- if (!user) {
715
- throw new Error('User not found')
716
- }
497
+ // ❌ This performs a real DELETE (not soft delete)
498
+ await ctx.db.deleteFrom('users').where('id', '=', 1).execute();
717
499
 
718
- // Check if user has important data
719
- const hasOrders = await db
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
- if (hasOrders) {
726
- // Soft delete (preserve for order history)
727
- await userRepo.softDelete(userId)
728
- } else {
729
- // Hard delete (no dependencies)
730
- await userRepo.hardDelete(userId)
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
- ### Cleanup Old Soft-Deleted Records
511
+ ### DAL Transaction Support
736
512
 
737
513
  ```typescript
738
- // Delete records soft-deleted more than 30 days ago
739
- async function cleanupOldDeleted() {
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
- const oldDeleted = await db
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
- .where('deleted_at', 'is not', null)
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
- console.log(`Cleaned up ${oldDeleted.length} old records`)
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 works across PostgreSQL, MySQL, and SQLite.
537
+ The soft-delete plugin respects ACID properties and works correctly with transactions.
763
538
 
764
- ### PostgreSQL
539
+ ### ACID Compliance
765
540
 
766
541
  ```typescript
767
- // Schema
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
- // Plugin uses CURRENT_TIMESTAMP (native PostgreSQL)
777
- const plugin = softDeletePlugin({
778
- deletedAtColumn: 'deleted_at'
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
- ### MySQL
554
+ ### Rollback Behavior
783
555
 
784
556
  ```typescript
785
- // Schema
786
- CREATE TABLE users (
787
- id INT AUTO_INCREMENT PRIMARY KEY,
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
- // Plugin uses CURRENT_TIMESTAMP (native MySQL)
795
- const plugin = softDeletePlugin({
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
- // Or INTEGER for Unix timestamp
813
- CREATE TABLE users (
814
- ...
815
- deleted_at INTEGER NULL -- Unix timestamp
816
- );
564
+ // Force rollback
565
+ throw new Error('Force rollback');
566
+ });
567
+ } catch (error) {
568
+ // Transaction rolled back
569
+ }
817
570
 
818
- // Plugin uses CURRENT_TIMESTAMP (SQLite compatible)
819
- const plugin = softDeletePlugin({
820
- deletedAtColumn: 'deleted_at'
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
- ### Database-Specific Behavior
576
+ ### Cascade Soft Delete Pattern
825
577
 
826
- | Feature | PostgreSQL | MySQL | SQLite |
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
- interface SoftDeleteRepository<T> extends Repository<T> {
843
- softDelete(id: number | string): Promise<T>
844
- restore(id: number | string): Promise<T>
845
- hardDelete(id: number | string): Promise<void>
846
- findAllWithDeleted(): Promise<T[]>
847
- findDeleted(): Promise<T[]>
848
- findWithDeleted(id: number | string): Promise<T | null>
849
- }
850
-
851
- // Type-safe usage
852
- const userRepo: SoftDeleteRepository<User> = orm.createRepository(/* ... */)
853
-
854
- // Type-safe calls
855
- const user: User = await userRepo.softDelete(1)
856
- const deleted: User[] = await userRepo.findDeleted()
857
-
858
- // Type error
859
- await userRepo.softDelete('invalid') // Error: string not assignable to number
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
- ```typescript
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
- // TypeScript ensures deleted_at is nullable
878
- const plugin = softDeletePlugin({
879
- deletedAtColumn: 'deleted_at' // ✅ Must exist in schema
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
- ### softDeletePlugin(options?)
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
- interface SoftDeleteOptions {
895
- deletedAtColumn?: string // Default: 'deleted_at'
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
- #### hardDelete(id)
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
- #### softDeleteMany(ids)
981
-
982
- Soft delete multiple records in a single query.
983
-
984
- **Parameters:**
985
- - `ids: (number | string)[]` - Array of record IDs
986
-
987
- **Returns:** `Promise<T[]>` - Array of soft-deleted records
988
-
989
- **Throws:** Error if any record not found
990
-
991
- **Example:**
992
- ```typescript
993
- const deletedUsers = await userRepo.softDeleteMany([1, 2, 3, 4, 5])
994
- console.log(deletedUsers.length) // 5
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
- Restore multiple soft-deleted records in a single query.
632
+ Your database tables need a `deleted_at` column (or custom column name) to support soft delete:
1002
633
 
1003
- **Parameters:**
1004
- - `ids: (number | string)[]` - Array of record IDs
1005
-
1006
- **Returns:** `Promise<T[]>` - Array of restored records
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
- **Example:**
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
- **Returns:** `Promise<void>`
1024
-
1025
- **Example:**
1026
- ```typescript
1027
- await userRepo.hardDeleteMany([1, 2, 3])
1028
- // Records permanently removed from database
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
- // Good: deleted_at is nullable
1039
- interface Database {
1040
- users: {
1041
- id: Generated<number>
1042
- deleted_at: Date | null // ✅ Can be null
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
- ### 2. Index deleted_at Column
665
+ ### Custom Primary Key
1055
666
 
1056
667
  ```sql
1057
- -- Good: Index for performance
1058
- CREATE INDEX idx_users_deleted_at ON users(deleted_at);
1059
-
1060
- -- Even better: Partial index (PostgreSQL)
1061
- CREATE INDEX idx_users_not_deleted ON users(id)
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
- // Good: Clear intent
1069
- await userRepo.softDelete(userId) // Soft delete
1070
- await userRepo.hardDelete(userId) // Hard delete
1071
-
1072
- // ❌ Confusing: What does delete do?
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
- ### 4. Clean Up Old Soft-Deleted Records
683
+ ## Type Safety
1077
684
 
1078
- ```typescript
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
- // When soft deleting, consider related records
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
- // Soft delete user
1107
- await txUserRepo.softDelete(userId)
690
+ // Extend repository type with soft delete methods
691
+ type UserRepository = SoftDeleteRepository<User>;
1108
692
 
1109
- // Also soft delete their posts
1110
- const posts = await db
1111
- .selectFrom('posts')
1112
- .selectAll()
1113
- .where('user_id', '=', userId)
1114
- .execute()
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
- for (const post of posts) {
1117
- await txPostRepo.softDelete(post.id)
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
- ### 6. Implement Restore Validation
707
+ ## Error Handling
1124
708
 
1125
- ```typescript
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
- if (!user) {
1131
- throw new Error('User not found')
1132
- }
711
+ ```typescript
712
+ import { NotFoundError } from '@kysera/core';
1133
713
 
1134
- if (!user.deleted_at) {
1135
- throw new Error('User is not deleted')
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
- // Check if restore is allowed
1139
- const daysSinceDeleted = Math.floor(
1140
- (Date.now() - new Date(user.deleted_at).getTime()) / (1000 * 60 * 60 * 24)
1141
- )
1142
-
1143
- if (daysSinceDeleted > 30) {
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
- ### 7. Use Table Filtering Wisely
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
- ## Performance
735
+ ### Index Requirements
1169
736
 
1170
- ### Plugin Overhead
737
+ Always add an index on the `deleted_at` column for optimal query performance:
1171
738
 
1172
- | Operation | Base | With Soft Delete | Overhead |
1173
- |-----------|------|------------------|----------|
1174
- | **create** | 2ms | 2ms | 0ms |
1175
- | **findById** | 1ms | 1.1ms | +0.1ms |
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
- ```typescript
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
- // With index on deleted_at
1188
- CREATE INDEX idx_users_deleted_at ON users(deleted_at);
1189
- // Index scan: O(log n)
748
+ ```sql
749
+ -- Without index: Full table scan
750
+ SELECT * FROM users WHERE deleted_at IS NULL;
1190
751
 
1191
- // Even better: Partial index (PostgreSQL only)
1192
- CREATE INDEX idx_users_not_deleted ON users(id)
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
- ### Bundle Size
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
- ## 🔧 Troubleshooting
767
+ // Efficient: Single query
768
+ await userRepo.softDeleteMany(userIds);
769
+ ```
1209
770
 
1210
- ### Records Not Filtered Out
771
+ ## Architecture Notes
1211
772
 
1212
- **Problem:** Soft-deleted records still appear in queries.
773
+ ### Method Override Pattern
1213
774
 
1214
- **Solutions:**
775
+ The plugin uses Method Override, not full query interception:
1215
776
 
1216
- 1. **Check plugin is registered:**
1217
- ```typescript
1218
- // No plugin
1219
- const orm = await createORM(db, [])
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
- // Plugin registered
1222
- const orm = await createORM(db, [softDeletePlugin()])
1223
- ```
782
+ This design is intentional for simplicity and explicitness.
1224
783
 
1225
- 2. **Check table is included:**
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
- 3. **Check using ORM-created repository:**
1239
- ```typescript
1240
- // Wrong: Direct factory (no plugins)
1241
- const factory = createRepositoryFactory(db)
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
- ### softDelete Method Not Available
791
+ ### Raw Database Access
1253
792
 
1254
- **Problem:** `repo.softDelete` is undefined.
793
+ The plugin uses `getRawDb()` to access the underlying Kysely instance without plugin interception. This is necessary for:
1255
794
 
1256
- **Solution:** Ensure you're using the ORM-created repository:
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
- // Wrong: Direct repository creation
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
- **Solution:** Check if record was hard-deleted:
803
+ // Inside plugin
804
+ const rawDb = getRawDb(repo.executor);
1277
805
 
1278
- ```typescript
1279
- // Check if record exists at all
1280
- const user = await db
806
+ // Bypass soft-delete filter
807
+ const allRecords = await rawDb
1281
808
  .selectFrom('users')
1282
809
  .selectAll()
1283
- .where('id', '=', userId)
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
- ### Performance Issues
813
+ ## Testing
1299
814
 
1300
- **Problem:** Queries with soft delete filtering are slow.
815
+ The package includes comprehensive test coverage:
1301
816
 
1302
- **Solution:** Add indexes:
817
+ ```bash
818
+ # Run all tests
819
+ pnpm test
1303
820
 
1304
- ```sql
1305
- -- Basic index
1306
- CREATE INDEX idx_users_deleted_at ON users(deleted_at);
821
+ # Run with coverage
822
+ pnpm test:coverage
1307
823
 
1308
- -- Partial index (PostgreSQL - even better)
1309
- CREATE INDEX idx_users_not_deleted ON users(id)
1310
- WHERE deleted_at IS NULL;
824
+ # Run specific test file
825
+ pnpm test soft-delete-repository.test.ts
1311
826
 
1312
- -- Composite index if you filter by other columns
1313
- CREATE INDEX idx_users_status_deleted
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
- ## 🤝 Contributing
1320
-
1321
- Contributions are welcome! This package follows strict development principles:
1322
-
1323
- - **Minimal dependencies** (@kysera/repository only)
1324
- - **100% type safe** (TypeScript strict mode)
1325
- - **95%+ test coverage** (39+ tests)
1326
- - **Multi-database tested** (PostgreSQL, MySQL, SQLite)
1327
- - **ESM only** (no CommonJS)
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
- MIT © Kysera
844
+ ## License
1336
845
 
1337
- ---
846
+ MIT
1338
847
 
1339
- ## 🔗 Links
848
+ ## Contributing
1340
849
 
1341
- - [GitHub Repository](https://github.com/kysera-dev/kysera)
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
- **Built with ❤️ for safe, recoverable data management**
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)