prisma-safe-delete 0.1.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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +436 -0
- package/dist/bin.d.ts +9 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +9 -0
- package/dist/bin.js.map +1 -0
- package/dist/cascade-graph.d.ts +54 -0
- package/dist/cascade-graph.d.ts.map +1 -0
- package/dist/cascade-graph.js +123 -0
- package/dist/cascade-graph.js.map +1 -0
- package/dist/codegen/emit-cascade-graph.d.ts +6 -0
- package/dist/codegen/emit-cascade-graph.d.ts.map +1 -0
- package/dist/codegen/emit-cascade-graph.js +56 -0
- package/dist/codegen/emit-cascade-graph.js.map +1 -0
- package/dist/codegen/emit-index.d.ts +6 -0
- package/dist/codegen/emit-index.d.ts.map +1 -0
- package/dist/codegen/emit-index.js +26 -0
- package/dist/codegen/emit-index.js.map +1 -0
- package/dist/codegen/emit-runtime.d.ts +8 -0
- package/dist/codegen/emit-runtime.d.ts.map +1 -0
- package/dist/codegen/emit-runtime.js +873 -0
- package/dist/codegen/emit-runtime.js.map +1 -0
- package/dist/codegen/emit-types.d.ts +8 -0
- package/dist/codegen/emit-types.d.ts.map +1 -0
- package/dist/codegen/emit-types.js +109 -0
- package/dist/codegen/emit-types.js.map +1 -0
- package/dist/codegen/index.d.ts +5 -0
- package/dist/codegen/index.d.ts.map +1 -0
- package/dist/codegen/index.js +5 -0
- package/dist/codegen/index.js.map +1 -0
- package/dist/dmmf-parser.d.ts +62 -0
- package/dist/dmmf-parser.d.ts.map +1 -0
- package/dist/dmmf-parser.js +171 -0
- package/dist/dmmf-parser.js.map +1 -0
- package/dist/generator.d.ts +2 -0
- package/dist/generator.d.ts.map +1 -0
- package/dist/generator.js +71 -0
- package/dist/generator.js.map +1 -0
- package/package.json +80 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2025-02-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release
|
|
14
|
+
- Type-safe soft deletion wrapper for Prisma 7
|
|
15
|
+
- Automatic filter injection on all read operations
|
|
16
|
+
- Deep relation filtering for `include`, `select`, `_count`, and relation filters (`some`/`every`/`none`)
|
|
17
|
+
- Cascade soft-delete following `onDelete: Cascade` relations
|
|
18
|
+
- Unique string field mangling to free values for reuse
|
|
19
|
+
- Transaction support with wrapped clients
|
|
20
|
+
- Compound primary key support
|
|
21
|
+
- Escape hatches: `$prisma`, `$includingDeleted`, `$onlyDeleted`
|
|
22
|
+
- `hardDelete` and `hardDeleteMany` methods
|
|
23
|
+
- `softDelete` and `softDeleteMany` methods
|
|
24
|
+
- Support for both `deleted_at` and `deletedAt` field names
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 EddieRydell
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
# prisma-safe-delete
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/prisma-safe-delete)
|
|
4
|
+
[](https://www.npmjs.com/package/prisma-safe-delete)
|
|
5
|
+
[](https://github.com/EddieRydell/prisma-safe-delete/actions/workflows/ci.yml)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
|
|
9
|
+
A Prisma generator that creates a type-safe wrapper for soft deletion with automatic cascade support. Designed to be a drop-in replacement that you configure once and never think about again.
|
|
10
|
+
|
|
11
|
+
## Why This Library?
|
|
12
|
+
|
|
13
|
+
Soft deletion is a common pattern where records are marked as deleted (typically with a timestamp) rather than being permanently removed. This preserves data for auditing, recovery, and maintaining referential integrity.
|
|
14
|
+
|
|
15
|
+
**The problem:** Implementing soft deletion correctly is tedious and error-prone. You need to remember to filter out deleted records in every query, handle cascading deletes manually, and deal with unique constraint conflicts when "deleted" records still occupy unique values.
|
|
16
|
+
|
|
17
|
+
**prisma-safe-delete solves this by:**
|
|
18
|
+
- Automatically filtering deleted records from all read operations
|
|
19
|
+
- Cascading soft-deletes through your relation tree (following `onDelete: Cascade`)
|
|
20
|
+
- Mangling unique string fields to free them for reuse
|
|
21
|
+
- Providing escape hatches when you need to access deleted data
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- **Automatic filter injection**: All read operations automatically exclude soft-deleted records
|
|
26
|
+
- **Deep relation filtering**: Filters applied to `include`, `select`, `_count`, and relation filters (`some`/`every`/`none`)
|
|
27
|
+
- **Cascade soft-delete**: Automatically cascades based on `onDelete: Cascade` relations
|
|
28
|
+
- **Unique constraint handling**: Automatically mangles unique string fields to free up values for reuse
|
|
29
|
+
- **Transaction support**: Interactive transactions receive wrapped clients with filtering
|
|
30
|
+
- **Compound key support**: Full support for compound primary keys and foreign keys
|
|
31
|
+
- **Escape hatches**: Access raw client, query deleted records, or hard delete when needed
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install prisma-safe-delete
|
|
37
|
+
# or
|
|
38
|
+
pnpm add prisma-safe-delete
|
|
39
|
+
# or
|
|
40
|
+
yarn add prisma-safe-delete
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
### 1. Add the generator to your Prisma schema
|
|
46
|
+
|
|
47
|
+
```prisma
|
|
48
|
+
generator client {
|
|
49
|
+
provider = "prisma-client"
|
|
50
|
+
output = "./generated/client"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
generator softDelete {
|
|
54
|
+
provider = "prisma-safe-delete"
|
|
55
|
+
output = "./generated/soft-delete"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
datasource db {
|
|
59
|
+
provider = "postgresql"
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 2. Add `deleted_at` to soft-deletable models
|
|
64
|
+
|
|
65
|
+
```prisma
|
|
66
|
+
model User {
|
|
67
|
+
id String @id @default(cuid())
|
|
68
|
+
email String @unique
|
|
69
|
+
name String?
|
|
70
|
+
posts Post[]
|
|
71
|
+
deleted_at DateTime? // Makes this model soft-deletable
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
model Post {
|
|
75
|
+
id String @id @default(cuid())
|
|
76
|
+
title String
|
|
77
|
+
authorId String
|
|
78
|
+
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
|
79
|
+
comments Comment[]
|
|
80
|
+
deleted_at DateTime?
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
model Comment {
|
|
84
|
+
id String @id @default(cuid())
|
|
85
|
+
content String
|
|
86
|
+
postId String
|
|
87
|
+
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
|
88
|
+
deleted_at DateTime?
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 3. Generate and use
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npx prisma generate
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { PrismaClient } from './generated/client';
|
|
100
|
+
import { PrismaPg } from '@prisma/adapter-pg';
|
|
101
|
+
import { Pool } from 'pg';
|
|
102
|
+
import { wrapPrismaClient } from './generated/soft-delete';
|
|
103
|
+
|
|
104
|
+
// Prisma 7 requires an adapter
|
|
105
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
106
|
+
const adapter = new PrismaPg(pool);
|
|
107
|
+
|
|
108
|
+
const prisma = new PrismaClient({ adapter });
|
|
109
|
+
const safePrisma = wrapPrismaClient(prisma);
|
|
110
|
+
|
|
111
|
+
// All queries automatically filter out soft-deleted records
|
|
112
|
+
const users = await safePrisma.user.findMany();
|
|
113
|
+
|
|
114
|
+
// Soft delete with automatic cascade
|
|
115
|
+
await safePrisma.user.softDelete({ where: { id: 'user-1' } });
|
|
116
|
+
// ^ This soft-deletes the user AND all their posts AND all comments on those posts
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## API Reference
|
|
120
|
+
|
|
121
|
+
### Read Operations (Auto-filtered)
|
|
122
|
+
|
|
123
|
+
All read operations automatically inject `deleted_at: null` filters:
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
// These all exclude soft-deleted records automatically
|
|
127
|
+
await safePrisma.user.findMany();
|
|
128
|
+
await safePrisma.user.findFirst({ where: { name: 'John' } });
|
|
129
|
+
await safePrisma.user.findUnique({ where: { id: 'user-1' } });
|
|
130
|
+
await safePrisma.user.findFirstOrThrow({ where: { email: 'john@example.com' } });
|
|
131
|
+
await safePrisma.user.findUniqueOrThrow({ where: { id: 'user-1' } });
|
|
132
|
+
await safePrisma.user.count();
|
|
133
|
+
await safePrisma.user.aggregate({ _count: true });
|
|
134
|
+
await safePrisma.user.groupBy({ by: ['name'], _count: true });
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Relation Queries (Auto-filtered)
|
|
138
|
+
|
|
139
|
+
Filters are automatically injected into relation queries:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// Posts in include are filtered
|
|
143
|
+
const user = await safePrisma.user.findUnique({
|
|
144
|
+
where: { id: 'user-1' },
|
|
145
|
+
include: { posts: true } // Only returns non-deleted posts
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Nested relations are filtered too
|
|
149
|
+
const user = await safePrisma.user.findUnique({
|
|
150
|
+
where: { id: 'user-1' },
|
|
151
|
+
include: {
|
|
152
|
+
posts: {
|
|
153
|
+
include: { comments: true } // Only non-deleted comments
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// select works the same way
|
|
159
|
+
const user = await safePrisma.user.findUnique({
|
|
160
|
+
where: { id: 'user-1' },
|
|
161
|
+
select: {
|
|
162
|
+
email: true,
|
|
163
|
+
posts: { select: { title: true } } // Only non-deleted posts
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// _count is filtered
|
|
168
|
+
const user = await safePrisma.user.findUnique({
|
|
169
|
+
where: { id: 'user-1' },
|
|
170
|
+
include: {
|
|
171
|
+
_count: { select: { posts: true } } // Counts only non-deleted posts
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Relation Filters (Auto-filtered)
|
|
177
|
+
|
|
178
|
+
The `some`, `every`, and `none` relation filters exclude soft-deleted records:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
// Find users who have at least one active post
|
|
182
|
+
const users = await safePrisma.user.findMany({
|
|
183
|
+
where: {
|
|
184
|
+
posts: { some: { title: { contains: 'hello' } } } // Ignores deleted posts
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Find users where all their posts are published
|
|
189
|
+
const users = await safePrisma.user.findMany({
|
|
190
|
+
where: {
|
|
191
|
+
posts: { every: { published: true } } // Only considers non-deleted posts
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Write Operations
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
// Standard write operations pass through unchanged
|
|
200
|
+
await safePrisma.user.create({ data: { email: 'new@example.com' } });
|
|
201
|
+
await safePrisma.user.createMany({ data: [...] });
|
|
202
|
+
await safePrisma.user.update({ where: { id: 'user-1' }, data: { name: 'Jane' } });
|
|
203
|
+
await safePrisma.user.updateMany({ where: { ... }, data: { ... } });
|
|
204
|
+
await safePrisma.user.upsert({ where: { ... }, create: { ... }, update: { ... } });
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Soft Delete
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
// Soft delete a single record (with cascade)
|
|
211
|
+
await safePrisma.user.softDelete({ where: { id: 'user-1' } });
|
|
212
|
+
|
|
213
|
+
// Soft delete multiple records (with cascade)
|
|
214
|
+
const result = await safePrisma.user.softDeleteMany({ where: { name: 'Test' } });
|
|
215
|
+
console.log(result.count); // Number of records soft-deleted
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Hard Delete (Escape Hatch)
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
// Permanently delete when needed
|
|
222
|
+
await safePrisma.user.hardDelete({ where: { id: 'user-1' } });
|
|
223
|
+
await safePrisma.user.hardDeleteMany({ where: { createdAt: { lt: oldDate } } });
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Escape Hatches
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
// Access the raw Prisma client (no filtering)
|
|
230
|
+
const allUsers = await safePrisma.$prisma.user.findMany();
|
|
231
|
+
|
|
232
|
+
// Query including soft-deleted records
|
|
233
|
+
const allUsers = await safePrisma.$includingDeleted.user.findMany();
|
|
234
|
+
|
|
235
|
+
// Query only soft-deleted records
|
|
236
|
+
const deletedUsers = await safePrisma.$onlyDeleted.user.findMany();
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Transactions
|
|
240
|
+
|
|
241
|
+
Interactive transactions receive wrapped clients with filtering:
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
const result = await safePrisma.$transaction(async (tx) => {
|
|
245
|
+
// tx has the same filtering as safePrisma
|
|
246
|
+
const users = await tx.user.findMany(); // Excludes deleted
|
|
247
|
+
const posts = await tx.post.findMany(); // Excludes deleted
|
|
248
|
+
return { users, posts };
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Cascade Behavior
|
|
253
|
+
|
|
254
|
+
Soft-delete cascades follow `onDelete: Cascade` relations defined in your schema:
|
|
255
|
+
|
|
256
|
+
```prisma
|
|
257
|
+
model User {
|
|
258
|
+
id String @id
|
|
259
|
+
posts Post[]
|
|
260
|
+
profile Profile?
|
|
261
|
+
deleted_at DateTime?
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
model Post {
|
|
265
|
+
id String @id
|
|
266
|
+
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
|
267
|
+
authorId String
|
|
268
|
+
comments Comment[]
|
|
269
|
+
deleted_at DateTime?
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
model Comment {
|
|
273
|
+
id String @id
|
|
274
|
+
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
|
275
|
+
postId String
|
|
276
|
+
deleted_at DateTime?
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
await safePrisma.user.softDelete({ where: { id: 'user-1' } });
|
|
282
|
+
// Soft-deletes:
|
|
283
|
+
// 1. The user
|
|
284
|
+
// 2. All their posts
|
|
285
|
+
// 3. All comments on those posts
|
|
286
|
+
// All with the same timestamp (transactional)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Cascade Rules
|
|
290
|
+
|
|
291
|
+
- Only follows `onDelete: Cascade` relations
|
|
292
|
+
- Only soft-deletes children that have a `deleted_at` field
|
|
293
|
+
- Children without `deleted_at` are left unchanged
|
|
294
|
+
- All cascaded records get the same `deleted_at` timestamp
|
|
295
|
+
- Entire operation is transactional (all-or-nothing)
|
|
296
|
+
|
|
297
|
+
## Unique Constraint Handling
|
|
298
|
+
|
|
299
|
+
When you soft-delete a record with unique string fields, the values are automatically mangled to free them up for reuse:
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
// Before soft delete
|
|
303
|
+
{ id: 'user-1', email: 'john@example.com', deleted_at: null }
|
|
304
|
+
|
|
305
|
+
// After soft delete
|
|
306
|
+
{ id: 'user-1', email: 'john@example.com__deleted_user-1', deleted_at: '2024-...' }
|
|
307
|
+
|
|
308
|
+
// Now you can create a new user with the same email
|
|
309
|
+
await safePrisma.user.create({ data: { email: 'john@example.com' } }); // Works!
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Mangling Rules
|
|
313
|
+
|
|
314
|
+
- Only **string** fields with `@unique` or `@@unique` are mangled
|
|
315
|
+
- Suffix format: `__deleted_{primaryKey}`
|
|
316
|
+
- For compound PKs: `__deleted_{pk1}_{pk2}` (sorted alphabetically)
|
|
317
|
+
- NULL values are not mangled (already allow duplicates)
|
|
318
|
+
- Mangling is idempotent (won't double-mangle)
|
|
319
|
+
- Fails with clear error if mangled value would exceed max string length
|
|
320
|
+
|
|
321
|
+
### Non-String Unique Fields
|
|
322
|
+
|
|
323
|
+
For non-string unique fields (Int, UUID, etc.), use a **partial unique index** in your database:
|
|
324
|
+
|
|
325
|
+
```sql
|
|
326
|
+
-- PostgreSQL
|
|
327
|
+
CREATE UNIQUE INDEX user_employee_id_active ON "User"(employee_id) WHERE deleted_at IS NULL;
|
|
328
|
+
|
|
329
|
+
-- MySQL (8.0+)
|
|
330
|
+
-- Use a generated column + unique index
|
|
331
|
+
|
|
332
|
+
-- SQLite
|
|
333
|
+
CREATE UNIQUE INDEX user_employee_id_active ON User(employee_id) WHERE deleted_at IS NULL;
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## Compound Primary Keys
|
|
337
|
+
|
|
338
|
+
Full support for compound primary keys:
|
|
339
|
+
|
|
340
|
+
```prisma
|
|
341
|
+
model TenantUser {
|
|
342
|
+
tenantId String
|
|
343
|
+
userId String
|
|
344
|
+
email String
|
|
345
|
+
deleted_at DateTime?
|
|
346
|
+
|
|
347
|
+
@@id([tenantId, userId])
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
await safePrisma.tenantUser.softDelete({
|
|
353
|
+
where: {
|
|
354
|
+
tenantId_userId: { tenantId: 'tenant-1', userId: 'user-1' }
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## Soft Delete Detection
|
|
360
|
+
|
|
361
|
+
Models are automatically detected as soft-deletable if they have a nullable DateTime field named:
|
|
362
|
+
- `deleted_at` (snake_case)
|
|
363
|
+
- `deletedAt` (camelCase)
|
|
364
|
+
|
|
365
|
+
```prisma
|
|
366
|
+
// Both of these work:
|
|
367
|
+
model User {
|
|
368
|
+
deleted_at DateTime? // snake_case
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
model Post {
|
|
372
|
+
deletedAt DateTime? // camelCase
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Known Limitations
|
|
377
|
+
|
|
378
|
+
### Fluent API
|
|
379
|
+
|
|
380
|
+
The Prisma fluent API bypasses the wrapper. Use `include` instead:
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
// ❌ Does NOT filter deleted posts
|
|
384
|
+
const posts = await safePrisma.user.findUnique({ where: { id: '1' } }).posts();
|
|
385
|
+
|
|
386
|
+
// ✅ Correctly filters deleted posts
|
|
387
|
+
const user = await safePrisma.user.findUnique({
|
|
388
|
+
where: { id: '1' },
|
|
389
|
+
include: { posts: true }
|
|
390
|
+
});
|
|
391
|
+
const posts = user.posts;
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### Raw Queries
|
|
395
|
+
|
|
396
|
+
Raw queries bypass the wrapper entirely (by design):
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
// No filtering applied - returns all records including deleted
|
|
400
|
+
const users = await safePrisma.$queryRaw`SELECT * FROM User`;
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Update/Upsert on Soft-Deleted Records
|
|
404
|
+
|
|
405
|
+
`update` and `upsert` can still modify soft-deleted records. This is intentional to allow restoration workflows:
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
// This works even if the user is soft-deleted
|
|
409
|
+
await safePrisma.user.update({
|
|
410
|
+
where: { id: 'deleted-user' },
|
|
411
|
+
data: { deleted_at: null } // Restore the user
|
|
412
|
+
});
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
## Requirements
|
|
416
|
+
|
|
417
|
+
- Node.js >= 18
|
|
418
|
+
- Prisma >= 7.0.0
|
|
419
|
+
- TypeScript >= 5.0 (recommended)
|
|
420
|
+
|
|
421
|
+
## Development
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
# Start Postgres
|
|
425
|
+
docker compose up -d
|
|
426
|
+
|
|
427
|
+
# Run tests
|
|
428
|
+
pnpm test
|
|
429
|
+
|
|
430
|
+
# Stop Postgres
|
|
431
|
+
docker compose down
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
## License
|
|
435
|
+
|
|
436
|
+
MIT
|
package/dist/bin.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for prisma-safe-delete generator
|
|
4
|
+
*
|
|
5
|
+
* This file is invoked by Prisma when running `prisma generate`.
|
|
6
|
+
* It simply re-exports the generator module.
|
|
7
|
+
*/
|
|
8
|
+
import './generator.js';
|
|
9
|
+
//# sourceMappingURL=bin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../src/bin.ts"],"names":[],"mappings":";AAEA;;;;;GAKG;AAEH,OAAO,gBAAgB,CAAC"}
|
package/dist/bin.js
ADDED
package/dist/bin.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bin.js","sourceRoot":"","sources":["../src/bin.ts"],"names":[],"mappings":";AAEA;;;;;GAKG;AAEH,OAAO,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ParsedModel, ParsedSchema } from './dmmf-parser.js';
|
|
2
|
+
/**
|
|
3
|
+
* Represents a child model in a cascade relationship
|
|
4
|
+
*/
|
|
5
|
+
export interface CascadeChild {
|
|
6
|
+
/** The name of the child model */
|
|
7
|
+
model: string;
|
|
8
|
+
/** The foreign key field(s) on the child model */
|
|
9
|
+
foreignKey: string[];
|
|
10
|
+
/** The primary key field(s) on the parent model that the FK references */
|
|
11
|
+
parentKey: string[];
|
|
12
|
+
/** Whether the child model supports soft deletion */
|
|
13
|
+
isSoftDeletable: boolean;
|
|
14
|
+
/** The name of the deleted_at field if soft-deletable */
|
|
15
|
+
deletedAtField: string | null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Maps parent model names to their cascade children
|
|
19
|
+
* When a parent is soft-deleted, all children in this map should be cascaded
|
|
20
|
+
*/
|
|
21
|
+
export type CascadeGraph = Record<string, CascadeChild[]>;
|
|
22
|
+
/**
|
|
23
|
+
* Builds a cascade graph from a parsed schema
|
|
24
|
+
*
|
|
25
|
+
* The graph maps parent model names to lists of child models that should be
|
|
26
|
+
* soft-deleted when the parent is soft-deleted. Only relations with
|
|
27
|
+
* onDelete: Cascade are included.
|
|
28
|
+
*
|
|
29
|
+
* @param schema - The parsed Prisma schema
|
|
30
|
+
* @returns A cascade graph mapping parents to children
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildCascadeGraph(schema: ParsedSchema): CascadeGraph;
|
|
33
|
+
/**
|
|
34
|
+
* Gets all models that would be affected by cascading from a given model
|
|
35
|
+
* Returns models in depth-first order (leaf nodes first)
|
|
36
|
+
*
|
|
37
|
+
* @param graph - The cascade graph
|
|
38
|
+
* @param modelName - The starting model name
|
|
39
|
+
* @returns Array of model names in cascade order
|
|
40
|
+
*/
|
|
41
|
+
export declare function getCascadeOrder(graph: CascadeGraph, modelName: string): string[];
|
|
42
|
+
/**
|
|
43
|
+
* Gets the direct children of a model in the cascade graph
|
|
44
|
+
*/
|
|
45
|
+
export declare function getDirectChildren(graph: CascadeGraph, modelName: string): CascadeChild[];
|
|
46
|
+
/**
|
|
47
|
+
* Checks if a model has any cascade children
|
|
48
|
+
*/
|
|
49
|
+
export declare function hasCascadeChildren(graph: CascadeGraph, modelName: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Gets all soft-deletable children (direct and indirect) of a model
|
|
52
|
+
*/
|
|
53
|
+
export declare function getSoftDeletableDescendants(graph: CascadeGraph, schema: ParsedSchema, modelName: string): ParsedModel[];
|
|
54
|
+
//# sourceMappingURL=cascade-graph.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cascade-graph.d.ts","sourceRoot":"","sources":["../src/cascade-graph.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAElE;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,0EAA0E;IAC1E,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,qDAAqD;IACrD,eAAe,EAAE,OAAO,CAAC;IACzB,yDAAyD;IACzD,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAED;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC;AAE1D;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAyDpE;AASD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,YAAY,EACnB,SAAS,EAAE,MAAM,GAChB,MAAM,EAAE,CAsBV;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,YAAY,EACnB,SAAS,EAAE,MAAM,GAChB,YAAY,EAAE,CAEhB;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,YAAY,EACnB,SAAS,EAAE,MAAM,GAChB,OAAO,CAGT;AAED;;GAEG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,YAAY,EACnB,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,GAChB,WAAW,EAAE,CAiBf"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a cascade graph from a parsed schema
|
|
3
|
+
*
|
|
4
|
+
* The graph maps parent model names to lists of child models that should be
|
|
5
|
+
* soft-deleted when the parent is soft-deleted. Only relations with
|
|
6
|
+
* onDelete: Cascade are included.
|
|
7
|
+
*
|
|
8
|
+
* @param schema - The parsed Prisma schema
|
|
9
|
+
* @returns A cascade graph mapping parents to children
|
|
10
|
+
*/
|
|
11
|
+
export function buildCascadeGraph(schema) {
|
|
12
|
+
const graph = {};
|
|
13
|
+
// Initialize empty arrays for all models
|
|
14
|
+
for (const model of schema.models) {
|
|
15
|
+
graph[model.name] = [];
|
|
16
|
+
}
|
|
17
|
+
// For each model, look at its relations to find cascade relationships
|
|
18
|
+
for (const model of schema.models) {
|
|
19
|
+
for (const relation of model.relations) {
|
|
20
|
+
// We only care about relations that have onDelete: Cascade
|
|
21
|
+
if (relation.onDelete !== 'Cascade') {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
// Skip list relations - they're the "one" side, we want the "many" side
|
|
25
|
+
// The relation with the foreign key is the child
|
|
26
|
+
if (relation.isList) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
// This model (with the FK) is the child, relation.type is the parent
|
|
30
|
+
const parentModelName = relation.type;
|
|
31
|
+
const parentModel = schema.modelMap.get(parentModelName);
|
|
32
|
+
if (parentModel === undefined) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// Get the parent's primary key (what the FK references)
|
|
36
|
+
const parentKey = normalizeKey(parentModel.primaryKey);
|
|
37
|
+
const foreignKey = relation.foreignKey;
|
|
38
|
+
// Skip if we don't have FK information
|
|
39
|
+
if (foreignKey.length === 0) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// Get or create the children array for this parent
|
|
43
|
+
let children = graph[parentModelName];
|
|
44
|
+
if (children === undefined) {
|
|
45
|
+
children = [];
|
|
46
|
+
graph[parentModelName] = children;
|
|
47
|
+
}
|
|
48
|
+
children.push({
|
|
49
|
+
model: model.name,
|
|
50
|
+
foreignKey,
|
|
51
|
+
parentKey,
|
|
52
|
+
isSoftDeletable: model.isSoftDeletable,
|
|
53
|
+
deletedAtField: model.deletedAtField,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return graph;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Normalizes a primary key to always be an array
|
|
61
|
+
*/
|
|
62
|
+
function normalizeKey(key) {
|
|
63
|
+
return Array.isArray(key) ? key : [key];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Gets all models that would be affected by cascading from a given model
|
|
67
|
+
* Returns models in depth-first order (leaf nodes first)
|
|
68
|
+
*
|
|
69
|
+
* @param graph - The cascade graph
|
|
70
|
+
* @param modelName - The starting model name
|
|
71
|
+
* @returns Array of model names in cascade order
|
|
72
|
+
*/
|
|
73
|
+
export function getCascadeOrder(graph, modelName) {
|
|
74
|
+
const visited = new Set();
|
|
75
|
+
const result = [];
|
|
76
|
+
function visit(name) {
|
|
77
|
+
if (visited.has(name)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
visited.add(name);
|
|
81
|
+
const children = graph[name];
|
|
82
|
+
if (children !== undefined) {
|
|
83
|
+
for (const child of children) {
|
|
84
|
+
visit(child.model);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
result.push(name);
|
|
88
|
+
}
|
|
89
|
+
visit(modelName);
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Gets the direct children of a model in the cascade graph
|
|
94
|
+
*/
|
|
95
|
+
export function getDirectChildren(graph, modelName) {
|
|
96
|
+
return graph[modelName] ?? [];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Checks if a model has any cascade children
|
|
100
|
+
*/
|
|
101
|
+
export function hasCascadeChildren(graph, modelName) {
|
|
102
|
+
const children = graph[modelName];
|
|
103
|
+
return children !== undefined && children.length > 0;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Gets all soft-deletable children (direct and indirect) of a model
|
|
107
|
+
*/
|
|
108
|
+
export function getSoftDeletableDescendants(graph, schema, modelName) {
|
|
109
|
+
const order = getCascadeOrder(graph, modelName);
|
|
110
|
+
const descendants = [];
|
|
111
|
+
for (const name of order) {
|
|
112
|
+
// Skip the starting model itself
|
|
113
|
+
if (name === modelName) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const model = schema.modelMap.get(name);
|
|
117
|
+
if (model?.isSoftDeletable === true) {
|
|
118
|
+
descendants.push(model);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return descendants;
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=cascade-graph.js.map
|