@kysera/soft-delete 0.3.0 → 0.4.0

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