@kysera/soft-delete 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 LuxQuant
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,90 @@
1
+ import { Plugin } from '@kysera/repository';
2
+
3
+ /**
4
+ * Configuration options for the soft delete plugin.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * const plugin = softDeletePlugin({
9
+ * deletedAtColumn: 'deleted_at',
10
+ * includeDeleted: false,
11
+ * tables: ['users', 'posts'] // Only these tables support soft delete
12
+ * })
13
+ * ```
14
+ */
15
+ interface SoftDeleteOptions {
16
+ /**
17
+ * Column name for soft delete timestamp.
18
+ *
19
+ * @default 'deleted_at'
20
+ */
21
+ deletedAtColumn?: string;
22
+ /**
23
+ * Include deleted records by default in queries.
24
+ * When false, soft-deleted records are automatically filtered out.
25
+ *
26
+ * @default false
27
+ */
28
+ includeDeleted?: boolean;
29
+ /**
30
+ * List of tables that support soft delete.
31
+ * If not provided, all tables are assumed to support it.
32
+ *
33
+ * @example ['users', 'posts', 'comments']
34
+ */
35
+ tables?: string[];
36
+ }
37
+ /**
38
+ * Soft Delete Plugin for Kysera ORM
39
+ *
40
+ * This plugin implements soft delete functionality using the Method Override pattern:
41
+ * - Automatically filters out soft-deleted records from SELECT queries
42
+ * - Adds softDelete(), restore(), and hardDelete() methods to repositories
43
+ * - Provides findWithDeleted() and findDeleted() utility methods
44
+ *
45
+ * ## Usage
46
+ *
47
+ * ```typescript
48
+ * import { softDeletePlugin } from '@kysera/soft-delete'
49
+ * import { createORM } from '@kysera/repository'
50
+ *
51
+ * const orm = await createORM(db, [
52
+ * softDeletePlugin({
53
+ * deletedAtColumn: 'deleted_at',
54
+ * tables: ['users', 'posts']
55
+ * })
56
+ * ])
57
+ *
58
+ * const userRepo = orm.createRepository(createUserRepository)
59
+ *
60
+ * // Soft delete a user (sets deleted_at)
61
+ * await userRepo.softDelete(1)
62
+ *
63
+ * // Find all users (excludes soft-deleted)
64
+ * await userRepo.findAll()
65
+ *
66
+ * // Find including deleted
67
+ * await userRepo.findAllWithDeleted()
68
+ *
69
+ * // Restore a soft-deleted user
70
+ * await userRepo.restore(1)
71
+ *
72
+ * // Permanently delete (real DELETE)
73
+ * await userRepo.hardDelete(1)
74
+ * ```
75
+ *
76
+ * ## Architecture Note
77
+ *
78
+ * This plugin uses Method Override, not full query interception:
79
+ * - ✅ SELECT queries are automatically filtered
80
+ * - ❌ DELETE queries are NOT automatically converted to soft deletes
81
+ * - Use softDelete() method explicitly instead of delete()
82
+ *
83
+ * This design is intentional for simplicity and explicitness.
84
+ *
85
+ * @param options - Configuration options for soft delete behavior
86
+ * @returns Plugin instance that can be used with createORM
87
+ */
88
+ declare const softDeletePlugin: (options?: SoftDeleteOptions) => Plugin;
89
+
90
+ export { type SoftDeleteOptions, softDeletePlugin };
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import {sql}from'kysely';var y=(u={})=>{let{deletedAtColumn:t="deleted_at",includeDeleted:o=false,tables:s}=u;return {name:"@kysera/soft-delete",version:"1.0.0",interceptQuery(r,e){return (!s||s.includes(e.table))&&e.operation==="select"&&!e.metadata.includeDeleted&&!o?r.where(`${e.table}.${t}`,"is",null):r},extendRepository(r){let e=r;if(!("tableName"in e)||!("executor"in e)||!(!s||s.includes(e.tableName)))return r;let a=e.findAll.bind(e),l=e.findById.bind(e);return {...e,async findAll(){return o?await a():await e.executor.selectFrom(e.tableName).selectAll().where(t,"is",null).execute()},async findById(n){return o?await l(n):await e.executor.selectFrom(e.tableName).selectAll().where("id","=",n).where(t,"is",null).executeTakeFirst()??null},async softDelete(n){await e.executor.updateTable(e.tableName).set({[t]:sql`CURRENT_TIMESTAMP`}).where("id","=",n).execute();let i=await l(n);if(!i)throw new Error(`Record with id ${n} not found`);return i},async restore(n){return await e.update(n,{[t]:null})},async hardDelete(n){await e.executor.deleteFrom(e.tableName).where("id","=",n).execute();},async findWithDeleted(n){return await l(n)},async findAllWithDeleted(){return await a()},async findDeleted(){return await e.executor.selectFrom(e.tableName).selectAll().where(t,"is not",null).execute()}}}}};export{y as softDeletePlugin};//# sourceMappingURL=index.js.map
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":["softDeletePlugin","options","deletedAtColumn","includeDeleted","tables","qb","context","repo","baseRepo","originalFindAll","originalFindById","id","sql","record"],"mappings":"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","file":"index.js","sourcesContent":["import type { Plugin, AnyQueryBuilder } from '@kysera/repository'\nimport type { SelectQueryBuilder, Kysely } from 'kysely'\nimport { sql } from 'kysely'\n\n/**\n * Configuration options for the soft delete plugin.\n *\n * @example\n * ```typescript\n * const plugin = softDeletePlugin({\n * deletedAtColumn: 'deleted_at',\n * includeDeleted: false,\n * tables: ['users', 'posts'] // Only these tables support soft delete\n * })\n * ```\n */\nexport interface SoftDeleteOptions {\n /**\n * Column name for soft delete timestamp.\n *\n * @default 'deleted_at'\n */\n deletedAtColumn?: string\n\n /**\n * Include deleted records by default in queries.\n * When false, soft-deleted records are automatically filtered out.\n *\n * @default false\n */\n includeDeleted?: boolean\n\n /**\n * List of tables that support soft delete.\n * If not provided, all tables are assumed to support it.\n *\n * @example ['users', 'posts', 'comments']\n */\n tables?: string[]\n}\n\ninterface BaseRepository {\n tableName: string\n executor: Kysely<Record<string, unknown>>\n findAll: () => Promise<unknown[]>\n findById: (id: number) => Promise<unknown>\n update: (id: number, data: Record<string, unknown>) => Promise<unknown>\n}\n\n/**\n * Soft Delete Plugin for Kysera ORM\n *\n * This plugin implements soft delete functionality using the Method Override pattern:\n * - Automatically filters out soft-deleted records from SELECT queries\n * - Adds softDelete(), restore(), and hardDelete() methods to repositories\n * - Provides findWithDeleted() and findDeleted() utility methods\n *\n * ## Usage\n *\n * ```typescript\n * import { softDeletePlugin } from '@kysera/soft-delete'\n * import { createORM } from '@kysera/repository'\n *\n * const orm = await createORM(db, [\n * softDeletePlugin({\n * deletedAtColumn: 'deleted_at',\n * tables: ['users', 'posts']\n * })\n * ])\n *\n * const userRepo = orm.createRepository(createUserRepository)\n *\n * // Soft delete a user (sets deleted_at)\n * await userRepo.softDelete(1)\n *\n * // Find all users (excludes soft-deleted)\n * await userRepo.findAll()\n *\n * // Find including deleted\n * await userRepo.findAllWithDeleted()\n *\n * // Restore a soft-deleted user\n * await userRepo.restore(1)\n *\n * // Permanently delete (real DELETE)\n * await userRepo.hardDelete(1)\n * ```\n *\n * ## Architecture Note\n *\n * This plugin uses Method Override, not full query interception:\n * - ✅ SELECT queries are automatically filtered\n * - ❌ DELETE queries are NOT automatically converted to soft deletes\n * - Use softDelete() method explicitly instead of delete()\n *\n * This design is intentional for simplicity and explicitness.\n *\n * @param options - Configuration options for soft delete behavior\n * @returns Plugin instance that can be used with createORM\n */\nexport const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {\n const {\n deletedAtColumn = 'deleted_at',\n includeDeleted = false,\n tables\n } = options\n\n return {\n name: '@kysera/soft-delete',\n version: '1.0.0',\n\n /**\n * Intercept queries to automatically filter soft-deleted records.\n *\n * NOTE: This plugin uses the Method Override pattern, not full query interception.\n * - SELECT queries are automatically filtered to exclude soft-deleted records\n * - DELETE operations are NOT automatically converted to soft deletes\n * - Use the softDelete() method instead of delete() to perform soft deletes\n * - Use hardDelete() method to bypass soft delete and perform a real DELETE\n *\n * This approach is simpler and more explicit than full query interception.\n */\n interceptQuery<QB extends AnyQueryBuilder>(qb: QB, context: { operation: string; table: string; metadata: Record<string, unknown> }): QB {\n // Check if table supports soft delete\n const supportsSoftDelete = !tables || tables.includes(context.table)\n\n // Only filter SELECT queries when not explicitly including deleted\n if (\n supportsSoftDelete &&\n context.operation === 'select' &&\n !context.metadata['includeDeleted'] &&\n !includeDeleted\n ) {\n // Add WHERE deleted_at IS NULL to the query builder\n type GenericSelectQueryBuilder = SelectQueryBuilder<\n Record<string, unknown>,\n string,\n Record<string, unknown>\n >\n return (qb as unknown as GenericSelectQueryBuilder)\n .where(`${context.table}.${deletedAtColumn}` as never, 'is', null) as QB\n }\n\n // Note: DELETE operations are NOT intercepted here\n // Use softDelete() method instead of delete() to perform soft deletes\n // This is by design - method override is simpler and more explicit\n\n return qb\n },\n\n /**\n * Extend repository with soft delete methods.\n *\n * Adds the following methods to repositories:\n * - softDelete(id): Marks record as deleted by setting deleted_at timestamp\n * - restore(id): Restores a soft-deleted record by setting deleted_at to null\n * - hardDelete(id): Permanently deletes a record (bypasses soft delete)\n * - findWithDeleted(id): Find a record including soft-deleted ones\n * - findAllWithDeleted(): Find all records including soft-deleted ones\n * - findDeleted(): Find only soft-deleted records\n *\n * Also overrides findAll() and findById() to automatically filter out\n * soft-deleted records (unless includeDeleted option is set).\n */\n extendRepository<T extends object>(repo: T): T {\n // Type assertion is safe here as we're checking for BaseRepository properties\n const baseRepo = repo as unknown as BaseRepository\n\n // Check if it's actually a repository (has required properties)\n if (!('tableName' in baseRepo) || !('executor' in baseRepo)) {\n return repo\n }\n\n // Check if table supports soft delete\n const supportsSoftDelete = !tables || tables.includes(baseRepo.tableName)\n\n // If table doesn't support soft delete, return unmodified repo\n if (!supportsSoftDelete) {\n return repo\n }\n\n // Wrap original methods to apply soft delete filtering\n const originalFindAll = baseRepo.findAll.bind(baseRepo)\n const originalFindById = baseRepo.findById.bind(baseRepo)\n\n const extendedRepo = {\n ...baseRepo,\n\n // Override base methods to filter soft-deleted records\n async findAll(): Promise<unknown[]> {\n if (!includeDeleted) {\n const result = await baseRepo.executor\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(deletedAtColumn as never, 'is', null)\n .execute()\n return result as unknown[]\n }\n return await originalFindAll()\n },\n\n async findById(id: number): Promise<unknown> {\n if (!includeDeleted) {\n const result = await baseRepo.executor\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where('id' as never, '=', id as never)\n .where(deletedAtColumn as never, 'is', null)\n .executeTakeFirst()\n return result ?? null\n }\n return await originalFindById(id)\n },\n\n async softDelete(id: number): Promise<unknown> {\n // Use CURRENT_TIMESTAMP directly in SQL to avoid datetime format issues\n // This works across all databases (MySQL, PostgreSQL, SQLite)\n // We bypass repository.update() to avoid Zod validation issues with RawBuilder\n await baseRepo.executor\n .updateTable(baseRepo.tableName)\n .set({ [deletedAtColumn]: sql`CURRENT_TIMESTAMP` } as never)\n .where('id' as never, '=', id as never)\n .execute()\n\n // Fetch the updated record to verify it exists\n const record = await originalFindById(id)\n\n // If record not found or deleted_at not set, throw error\n if (!record) {\n throw new Error(`Record with id ${id} not found`)\n }\n\n return record\n },\n\n async restore(id: number): Promise<unknown> {\n return await baseRepo.update(id, { [deletedAtColumn]: null })\n },\n\n async hardDelete(id: number): Promise<void> {\n // Direct hard delete - bypass soft delete\n await baseRepo.executor\n .deleteFrom(baseRepo.tableName)\n .where('id' as never, '=', id as never)\n .execute()\n },\n\n async findWithDeleted(id: number): Promise<unknown> {\n // Use original method without filtering\n return await originalFindById(id)\n },\n\n async findAllWithDeleted(): Promise<unknown[]> {\n // Use original method without filtering\n return await originalFindAll()\n },\n\n async findDeleted(): Promise<unknown[]> {\n const result = await baseRepo.executor\n .selectFrom(baseRepo.tableName)\n .selectAll()\n .where(deletedAtColumn as never, 'is not', null)\n .execute()\n return result as unknown[]\n }\n }\n\n return extendedRepo as T\n }\n }\n}"]}
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@kysera/soft-delete",
3
+ "version": "0.3.0",
4
+ "description": "Soft delete plugin for Kysera ORM",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
18
+ "peerDependencies": {
19
+ "kysely": ">=0.28.0"
20
+ },
21
+ "dependencies": {
22
+ "@kysera/repository": "0.3.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/better-sqlite3": "^7.6.13",
26
+ "@types/node": "^24.6.0",
27
+ "@vitest/coverage-v8": "^3.2.4",
28
+ "better-sqlite3": "^12.4.1",
29
+ "kysely": "^0.28.7",
30
+ "tsup": "^8.5.0",
31
+ "typescript": "^5.9.2",
32
+ "vitest": "^3.2.4",
33
+ "zod": "^4.1.11"
34
+ },
35
+ "keywords": [
36
+ "kysely",
37
+ "orm",
38
+ "soft-delete",
39
+ "plugin"
40
+ ],
41
+ "author": "",
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/omnitron/kysera.git",
46
+ "directory": "packages/soft-delete"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/omnitron/kysera/issues"
50
+ },
51
+ "homepage": "https://github.com/omnitron/kysera#readme",
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "sideEffects": false,
56
+ "engines": {
57
+ "node": ">=20.0.0",
58
+ "bun": ">=1.0.0"
59
+ },
60
+ "scripts": {
61
+ "build": "tsup",
62
+ "dev": "tsup --watch",
63
+ "test": "vitest run",
64
+ "test:watch": "vitest watch",
65
+ "test:coverage": "vitest run --coverage",
66
+ "typecheck": "tsc --noEmit",
67
+ "lint": "eslint ."
68
+ }
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,271 @@
1
+ import type { Plugin, AnyQueryBuilder } from '@kysera/repository'
2
+ import type { SelectQueryBuilder, Kysely } from 'kysely'
3
+ import { sql } from 'kysely'
4
+
5
+ /**
6
+ * Configuration options for the soft delete plugin.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * const plugin = softDeletePlugin({
11
+ * deletedAtColumn: 'deleted_at',
12
+ * includeDeleted: false,
13
+ * tables: ['users', 'posts'] // Only these tables support soft delete
14
+ * })
15
+ * ```
16
+ */
17
+ export interface SoftDeleteOptions {
18
+ /**
19
+ * Column name for soft delete timestamp.
20
+ *
21
+ * @default 'deleted_at'
22
+ */
23
+ deletedAtColumn?: string
24
+
25
+ /**
26
+ * Include deleted records by default in queries.
27
+ * When false, soft-deleted records are automatically filtered out.
28
+ *
29
+ * @default false
30
+ */
31
+ includeDeleted?: boolean
32
+
33
+ /**
34
+ * List of tables that support soft delete.
35
+ * If not provided, all tables are assumed to support it.
36
+ *
37
+ * @example ['users', 'posts', 'comments']
38
+ */
39
+ tables?: string[]
40
+ }
41
+
42
+ interface BaseRepository {
43
+ tableName: string
44
+ executor: Kysely<Record<string, unknown>>
45
+ findAll: () => Promise<unknown[]>
46
+ findById: (id: number) => Promise<unknown>
47
+ update: (id: number, data: Record<string, unknown>) => Promise<unknown>
48
+ }
49
+
50
+ /**
51
+ * Soft Delete Plugin for Kysera ORM
52
+ *
53
+ * This plugin implements soft delete functionality using the Method Override pattern:
54
+ * - Automatically filters out soft-deleted records from SELECT queries
55
+ * - Adds softDelete(), restore(), and hardDelete() methods to repositories
56
+ * - Provides findWithDeleted() and findDeleted() utility methods
57
+ *
58
+ * ## Usage
59
+ *
60
+ * ```typescript
61
+ * import { softDeletePlugin } from '@kysera/soft-delete'
62
+ * import { createORM } from '@kysera/repository'
63
+ *
64
+ * const orm = await createORM(db, [
65
+ * softDeletePlugin({
66
+ * deletedAtColumn: 'deleted_at',
67
+ * tables: ['users', 'posts']
68
+ * })
69
+ * ])
70
+ *
71
+ * const userRepo = orm.createRepository(createUserRepository)
72
+ *
73
+ * // Soft delete a user (sets deleted_at)
74
+ * await userRepo.softDelete(1)
75
+ *
76
+ * // Find all users (excludes soft-deleted)
77
+ * await userRepo.findAll()
78
+ *
79
+ * // Find including deleted
80
+ * await userRepo.findAllWithDeleted()
81
+ *
82
+ * // Restore a soft-deleted user
83
+ * await userRepo.restore(1)
84
+ *
85
+ * // Permanently delete (real DELETE)
86
+ * await userRepo.hardDelete(1)
87
+ * ```
88
+ *
89
+ * ## Architecture Note
90
+ *
91
+ * This plugin uses Method Override, not full query interception:
92
+ * - ✅ SELECT queries are automatically filtered
93
+ * - ❌ DELETE queries are NOT automatically converted to soft deletes
94
+ * - Use softDelete() method explicitly instead of delete()
95
+ *
96
+ * This design is intentional for simplicity and explicitness.
97
+ *
98
+ * @param options - Configuration options for soft delete behavior
99
+ * @returns Plugin instance that can be used with createORM
100
+ */
101
+ export const softDeletePlugin = (options: SoftDeleteOptions = {}): Plugin => {
102
+ const {
103
+ deletedAtColumn = 'deleted_at',
104
+ includeDeleted = false,
105
+ tables
106
+ } = options
107
+
108
+ return {
109
+ name: '@kysera/soft-delete',
110
+ version: '1.0.0',
111
+
112
+ /**
113
+ * Intercept queries to automatically filter soft-deleted records.
114
+ *
115
+ * NOTE: This plugin uses the Method Override pattern, not full query interception.
116
+ * - SELECT queries are automatically filtered to exclude soft-deleted records
117
+ * - DELETE operations are NOT automatically converted to soft deletes
118
+ * - Use the softDelete() method instead of delete() to perform soft deletes
119
+ * - Use hardDelete() method to bypass soft delete and perform a real DELETE
120
+ *
121
+ * This approach is simpler and more explicit than full query interception.
122
+ */
123
+ interceptQuery<QB extends AnyQueryBuilder>(qb: QB, context: { operation: string; table: string; metadata: Record<string, unknown> }): QB {
124
+ // Check if table supports soft delete
125
+ const supportsSoftDelete = !tables || tables.includes(context.table)
126
+
127
+ // Only filter SELECT queries when not explicitly including deleted
128
+ if (
129
+ supportsSoftDelete &&
130
+ context.operation === 'select' &&
131
+ !context.metadata['includeDeleted'] &&
132
+ !includeDeleted
133
+ ) {
134
+ // Add WHERE deleted_at IS NULL to the query builder
135
+ type GenericSelectQueryBuilder = SelectQueryBuilder<
136
+ Record<string, unknown>,
137
+ string,
138
+ Record<string, unknown>
139
+ >
140
+ return (qb as unknown as GenericSelectQueryBuilder)
141
+ .where(`${context.table}.${deletedAtColumn}` as never, 'is', null) as QB
142
+ }
143
+
144
+ // Note: DELETE operations are NOT intercepted here
145
+ // Use softDelete() method instead of delete() to perform soft deletes
146
+ // This is by design - method override is simpler and more explicit
147
+
148
+ return qb
149
+ },
150
+
151
+ /**
152
+ * Extend repository with soft delete methods.
153
+ *
154
+ * Adds the following methods to repositories:
155
+ * - softDelete(id): Marks record as deleted by setting deleted_at timestamp
156
+ * - restore(id): Restores a soft-deleted record by setting deleted_at to null
157
+ * - hardDelete(id): Permanently deletes a record (bypasses soft delete)
158
+ * - findWithDeleted(id): Find a record including soft-deleted ones
159
+ * - findAllWithDeleted(): Find all records including soft-deleted ones
160
+ * - findDeleted(): Find only soft-deleted records
161
+ *
162
+ * Also overrides findAll() and findById() to automatically filter out
163
+ * soft-deleted records (unless includeDeleted option is set).
164
+ */
165
+ extendRepository<T extends object>(repo: T): T {
166
+ // Type assertion is safe here as we're checking for BaseRepository properties
167
+ const baseRepo = repo as unknown as BaseRepository
168
+
169
+ // Check if it's actually a repository (has required properties)
170
+ if (!('tableName' in baseRepo) || !('executor' in baseRepo)) {
171
+ return repo
172
+ }
173
+
174
+ // Check if table supports soft delete
175
+ const supportsSoftDelete = !tables || tables.includes(baseRepo.tableName)
176
+
177
+ // If table doesn't support soft delete, return unmodified repo
178
+ if (!supportsSoftDelete) {
179
+ return repo
180
+ }
181
+
182
+ // Wrap original methods to apply soft delete filtering
183
+ const originalFindAll = baseRepo.findAll.bind(baseRepo)
184
+ const originalFindById = baseRepo.findById.bind(baseRepo)
185
+
186
+ const extendedRepo = {
187
+ ...baseRepo,
188
+
189
+ // Override base methods to filter soft-deleted records
190
+ async findAll(): Promise<unknown[]> {
191
+ if (!includeDeleted) {
192
+ const result = await baseRepo.executor
193
+ .selectFrom(baseRepo.tableName)
194
+ .selectAll()
195
+ .where(deletedAtColumn as never, 'is', null)
196
+ .execute()
197
+ return result as unknown[]
198
+ }
199
+ return await originalFindAll()
200
+ },
201
+
202
+ async findById(id: number): Promise<unknown> {
203
+ if (!includeDeleted) {
204
+ const result = await baseRepo.executor
205
+ .selectFrom(baseRepo.tableName)
206
+ .selectAll()
207
+ .where('id' as never, '=', id as never)
208
+ .where(deletedAtColumn as never, 'is', null)
209
+ .executeTakeFirst()
210
+ return result ?? null
211
+ }
212
+ return await originalFindById(id)
213
+ },
214
+
215
+ async softDelete(id: number): Promise<unknown> {
216
+ // Use CURRENT_TIMESTAMP directly in SQL to avoid datetime format issues
217
+ // This works across all databases (MySQL, PostgreSQL, SQLite)
218
+ // We bypass repository.update() to avoid Zod validation issues with RawBuilder
219
+ await baseRepo.executor
220
+ .updateTable(baseRepo.tableName)
221
+ .set({ [deletedAtColumn]: sql`CURRENT_TIMESTAMP` } as never)
222
+ .where('id' as never, '=', id as never)
223
+ .execute()
224
+
225
+ // Fetch the updated record to verify it exists
226
+ const record = await originalFindById(id)
227
+
228
+ // If record not found or deleted_at not set, throw error
229
+ if (!record) {
230
+ throw new Error(`Record with id ${id} not found`)
231
+ }
232
+
233
+ return record
234
+ },
235
+
236
+ async restore(id: number): Promise<unknown> {
237
+ return await baseRepo.update(id, { [deletedAtColumn]: null })
238
+ },
239
+
240
+ async hardDelete(id: number): Promise<void> {
241
+ // Direct hard delete - bypass soft delete
242
+ await baseRepo.executor
243
+ .deleteFrom(baseRepo.tableName)
244
+ .where('id' as never, '=', id as never)
245
+ .execute()
246
+ },
247
+
248
+ async findWithDeleted(id: number): Promise<unknown> {
249
+ // Use original method without filtering
250
+ return await originalFindById(id)
251
+ },
252
+
253
+ async findAllWithDeleted(): Promise<unknown[]> {
254
+ // Use original method without filtering
255
+ return await originalFindAll()
256
+ },
257
+
258
+ async findDeleted(): Promise<unknown[]> {
259
+ const result = await baseRepo.executor
260
+ .selectFrom(baseRepo.tableName)
261
+ .selectAll()
262
+ .where(deletedAtColumn as never, 'is not', null)
263
+ .execute()
264
+ return result as unknown[]
265
+ }
266
+ }
267
+
268
+ return extendedRepo as T
269
+ }
270
+ }
271
+ }