@zudojs/database 0.1.0 → 1.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/LICENSE +21 -0
- package/README.md +225 -17
- package/dist/cache/cache.memory.d.ts +167 -0
- package/dist/cache/cache.memory.js +186 -40
- package/dist/cache/index.d.ts +2 -2
- package/dist/cache/index.js +2 -2
- package/dist/database/database.core.d.ts +91 -0
- package/dist/database/database.core.js +21 -4
- package/dist/databaseClient/databaseClient.core.d.ts +171 -14
- package/dist/databaseClient/databaseClient.core.js +350 -70
- package/dist/databaseClient/databaseClient.errors.d.ts +112 -0
- package/dist/databaseClient/databaseClient.errors.js +425 -0
- package/dist/databaseClient/index.d.ts +2 -1
- package/dist/databaseClient/index.js +2 -1
- package/dist/databaseConnection/databaseConnection.manager.d.ts +163 -0
- package/dist/databaseConnection/databaseConnection.manager.js +138 -76
- package/dist/databaseConnection/index.d.ts +1 -1
- package/dist/databaseType/databaseType.type.d.ts +224 -0
- package/dist/databaseType/databaseType.type.js +0 -35
- package/dist/health/health.check.d.ts +74 -0
- package/dist/health/health.check.js +78 -47
- package/dist/health/index.d.ts +1 -1
- package/dist/health/index.js +1 -1
- package/dist/index.d.ts +14 -14
- package/dist/index.js +11 -11
- package/dist/locks/index.d.ts +2 -2
- package/dist/locks/index.js +2 -2
- package/dist/locks/locks.core.d.ts +132 -0
- package/dist/locks/locks.core.js +150 -85
- package/dist/migration/index.d.ts +3 -1
- package/dist/migration/index.js +2 -0
- package/dist/migration/migration.dialect.d.ts +65 -0
- package/dist/migration/migration.dialect.js +63 -0
- package/dist/migration/migration.helpers.d.ts +65 -0
- package/dist/migration/migration.helpers.js +60 -18
- package/dist/migration/migration.runner.d.ts +75 -0
- package/dist/migration/migration.runner.js +195 -107
- package/dist/migration/migration.types.d.ts +85 -0
- package/dist/pagination/index.d.ts +2 -1
- package/dist/pagination/index.js +2 -1
- package/dist/pagination/pagination.core.d.ts +163 -0
- package/dist/pagination/pagination.core.js +78 -9
- package/dist/pagination/pagination.keyset.d.ts +55 -0
- package/dist/pagination/pagination.keyset.js +108 -0
- package/dist/queryBuilder/index.d.ts +4 -3
- package/dist/queryBuilder/index.js +3 -2
- package/dist/queryBuilder/queryBuilder.core.d.ts +178 -0
- package/dist/queryBuilder/queryBuilder.core.js +92 -14
- package/dist/queryBuilder/queryBuilder.factory.d.ts +6 -0
- package/dist/queryBuilder/queryBuilder.factory.js +0 -25
- package/dist/queryBuilder/queryBuilder.filter.d.ts +170 -0
- package/dist/queryBuilder/queryBuilder.filter.js +191 -30
- package/dist/queryBuilder/queryBuilder.prisma.d.ts +60 -0
- package/dist/queryBuilder/queryBuilder.prisma.js +233 -0
- package/dist/queryBuilder/queryBuilder.type.d.ts +48 -0
- package/dist/relations/index.d.ts +1 -1
- package/dist/relations/index.js +1 -1
- package/dist/relations/relations.definition.d.ts +184 -0
- package/dist/relations/relations.definition.js +186 -19
- package/dist/repository/index.d.ts +2 -1
- package/dist/repository/index.js +1 -0
- package/dist/repository/repository.base.d.ts +283 -0
- package/dist/repository/repository.base.js +370 -89
- package/dist/repository/repository.errors.d.ts +60 -0
- package/dist/repository/repository.errors.js +235 -0
- package/dist/seed/index.d.ts +2 -2
- package/dist/seed/index.js +1 -1
- package/dist/seed/seed.runner.d.ts +160 -0
- package/dist/seed/seed.runner.js +194 -225
- package/dist/transaction/index.d.ts +1 -1
- package/dist/transaction/index.js +1 -1
- package/dist/transaction/transaction.core.d.ts +117 -0
- package/dist/transaction/transaction.core.js +83 -56
- package/dist/unitOfWork/unitOfWork.core.d.ts +44 -0
- package/dist/unitOfWork/unitOfWork.core.js +9 -20
- package/package.json +44 -24
- package/dist/cache/cache.memory.d.ts.map +0 -1
- package/dist/cache/cache.memory.js.map +0 -1
- package/dist/cache/index.d.ts.map +0 -1
- package/dist/cache/index.js.map +0 -1
- package/dist/database/database.core.d.ts.map +0 -1
- package/dist/database/database.core.js.map +0 -1
- package/dist/database/index.d.ts.map +0 -1
- package/dist/database/index.js.map +0 -1
- package/dist/databaseClient/databaseClient.core.d.ts.map +0 -1
- package/dist/databaseClient/databaseClient.core.js.map +0 -1
- package/dist/databaseClient/databaseClient.logger.d.ts.map +0 -1
- package/dist/databaseClient/databaseClient.logger.js.map +0 -1
- package/dist/databaseClient/index.d.ts.map +0 -1
- package/dist/databaseClient/index.js.map +0 -1
- package/dist/databaseConnection/databaseConnection.manager.d.ts.map +0 -1
- package/dist/databaseConnection/databaseConnection.manager.js.map +0 -1
- package/dist/databaseConnection/index.d.ts.map +0 -1
- package/dist/databaseConnection/index.js.map +0 -1
- package/dist/databaseType/databaseType.type.d.ts.map +0 -1
- package/dist/databaseType/databaseType.type.js.map +0 -1
- package/dist/databaseType/index.d.ts.map +0 -1
- package/dist/databaseType/index.js.map +0 -1
- package/dist/health/health.check.d.ts.map +0 -1
- package/dist/health/health.check.js.map +0 -1
- package/dist/health/index.d.ts.map +0 -1
- package/dist/health/index.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/locks/index.d.ts.map +0 -1
- package/dist/locks/index.js.map +0 -1
- package/dist/locks/locks.core.d.ts.map +0 -1
- package/dist/locks/locks.core.js.map +0 -1
- package/dist/migration/index.d.ts.map +0 -1
- package/dist/migration/index.js.map +0 -1
- package/dist/migration/migration.helpers.d.ts.map +0 -1
- package/dist/migration/migration.helpers.js.map +0 -1
- package/dist/migration/migration.runner.d.ts.map +0 -1
- package/dist/migration/migration.runner.js.map +0 -1
- package/dist/migration/migration.types.d.ts.map +0 -1
- package/dist/migration/migration.types.js.map +0 -1
- package/dist/pagination/index.d.ts.map +0 -1
- package/dist/pagination/index.js.map +0 -1
- package/dist/pagination/pagination.core.d.ts.map +0 -1
- package/dist/pagination/pagination.core.js.map +0 -1
- package/dist/queryBuilder/index.d.ts.map +0 -1
- package/dist/queryBuilder/index.js.map +0 -1
- package/dist/queryBuilder/queryBuilder.core.d.ts.map +0 -1
- package/dist/queryBuilder/queryBuilder.core.js.map +0 -1
- package/dist/queryBuilder/queryBuilder.factory.d.ts.map +0 -1
- package/dist/queryBuilder/queryBuilder.factory.js.map +0 -1
- package/dist/queryBuilder/queryBuilder.filter.d.ts.map +0 -1
- package/dist/queryBuilder/queryBuilder.filter.js.map +0 -1
- package/dist/queryBuilder/queryBuilder.type.d.ts.map +0 -1
- package/dist/queryBuilder/queryBuilder.type.js.map +0 -1
- package/dist/relations/index.d.ts.map +0 -1
- package/dist/relations/index.js.map +0 -1
- package/dist/relations/relations.definition.d.ts.map +0 -1
- package/dist/relations/relations.definition.js.map +0 -1
- package/dist/repository/index.d.ts.map +0 -1
- package/dist/repository/index.js.map +0 -1
- package/dist/repository/repository.base.d.ts.map +0 -1
- package/dist/repository/repository.base.js.map +0 -1
- package/dist/seed/index.d.ts.map +0 -1
- package/dist/seed/index.js.map +0 -1
- package/dist/seed/seed.runner.d.ts.map +0 -1
- package/dist/seed/seed.runner.js.map +0 -1
- package/dist/transaction/index.d.ts.map +0 -1
- package/dist/transaction/index.js.map +0 -1
- package/dist/transaction/transaction.core.d.ts.map +0 -1
- package/dist/transaction/transaction.core.js.map +0 -1
- package/dist/unitOfWork/index.d.ts.map +0 -1
- package/dist/unitOfWork/index.js.map +0 -1
- package/dist/unitOfWork/unitOfWork.core.d.ts.map +0 -1
- package/dist/unitOfWork/unitOfWork.core.js.map +0 -1
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { DatabaseError } from "@zudojs/errors";
|
|
2
|
+
import { createPaginationMeta, normalizeLimit, normalizePage, } from "../pagination/pagination.core.js";
|
|
3
|
+
import { buildKeysetWhere, createKeysetPage, decodeKeysetCursor, } from "../pagination/pagination.keyset.js";
|
|
4
|
+
import { toPrismaArgs, toPrismaOrderBy, } from "../queryBuilder/queryBuilder.prisma.js";
|
|
5
|
+
import { createAbortError, createTimeoutError, mapRepositoryError, } from "./repository.errors.js";
|
|
6
|
+
const DEFAULT_SOFT_DELETE_FIELD = "deletedAt";
|
|
7
|
+
const FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
2
8
|
/**
|
|
3
9
|
* Generic base repository implementation.
|
|
4
10
|
*
|
|
@@ -8,23 +14,82 @@ import { DatabaseError } from "@zudojs/errors";
|
|
|
8
14
|
export class BaseRepository {
|
|
9
15
|
delegate;
|
|
10
16
|
modelName;
|
|
17
|
+
idField;
|
|
18
|
+
softDeleteField;
|
|
19
|
+
cursorSecret;
|
|
20
|
+
delegateKey;
|
|
21
|
+
relations;
|
|
22
|
+
relationParent;
|
|
23
|
+
/**
|
|
24
|
+
* When true, soft-deleted rows are visible to reads (see `withDeleted`).
|
|
25
|
+
*/
|
|
26
|
+
includeDeleted = false;
|
|
11
27
|
constructor(delegate, options = {}) {
|
|
12
28
|
if (!delegate) {
|
|
13
29
|
throw new TypeError("A repository delegate is required.");
|
|
14
30
|
}
|
|
15
31
|
this.delegate = delegate;
|
|
16
32
|
this.modelName = options.modelName ?? "DatabaseEntity";
|
|
33
|
+
this.idField = validateFieldName(options.idField ?? "id", "idField");
|
|
34
|
+
if (options.softDelete) {
|
|
35
|
+
const field = typeof options.softDelete === "object"
|
|
36
|
+
? (options.softDelete.field ?? DEFAULT_SOFT_DELETE_FIELD)
|
|
37
|
+
: DEFAULT_SOFT_DELETE_FIELD;
|
|
38
|
+
this.softDeleteField = validateFieldName(field, "softDelete.field");
|
|
39
|
+
}
|
|
40
|
+
if (options.cursorSecret !== undefined) {
|
|
41
|
+
if (typeof options.cursorSecret !== "string" ||
|
|
42
|
+
options.cursorSecret.length === 0) {
|
|
43
|
+
throw new TypeError("cursorSecret must be a non-empty string.");
|
|
44
|
+
}
|
|
45
|
+
this.cursorSecret = options.cursorSecret;
|
|
46
|
+
}
|
|
47
|
+
this.delegateKey = validateFieldName(options.delegateKey ?? lowerFirst(this.modelName), "delegateKey");
|
|
48
|
+
this.relations = options.relations;
|
|
49
|
+
this.relationParent = options.relationParent ?? this.modelName;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Returns a copy of this repository bound to a transaction client's
|
|
53
|
+
* delegate, so operations run inside the transaction.
|
|
54
|
+
*/
|
|
55
|
+
withTransaction(transaction) {
|
|
56
|
+
if (transaction === null || typeof transaction !== "object") {
|
|
57
|
+
throw new TypeError("A transaction client is required.");
|
|
58
|
+
}
|
|
59
|
+
const delegate = transaction[this.delegateKey];
|
|
60
|
+
if (!delegate || typeof delegate !== "object") {
|
|
61
|
+
throw new DatabaseError(`Transaction client has no "${this.delegateKey}" delegate for ${this.modelName}.`);
|
|
62
|
+
}
|
|
63
|
+
return this.withDelegate(delegate);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Returns a copy of this repository bound to a different delegate.
|
|
67
|
+
*/
|
|
68
|
+
withDelegate(delegate) {
|
|
69
|
+
if (!delegate) {
|
|
70
|
+
throw new TypeError("A repository delegate is required.");
|
|
71
|
+
}
|
|
72
|
+
return this.rebind({ delegate });
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Returns a copy of this repository whose reads include soft-deleted
|
|
76
|
+
* rows.
|
|
77
|
+
*/
|
|
78
|
+
withDeleted() {
|
|
79
|
+
return this.rebind({ includeDeleted: true });
|
|
17
80
|
}
|
|
18
81
|
/**
|
|
19
82
|
* Finds an entity by its primary identifier.
|
|
20
83
|
*/
|
|
21
84
|
async findById(id, options) {
|
|
22
85
|
this.validateId(id);
|
|
23
|
-
return this.execute("findById", () => this.
|
|
24
|
-
|
|
25
|
-
id,
|
|
26
|
-
}
|
|
27
|
-
|
|
86
|
+
return this.execute("findById", () => this.isScoped()
|
|
87
|
+
? this.delegate.findFirst({
|
|
88
|
+
where: this.scope(this.whereId(id)),
|
|
89
|
+
})
|
|
90
|
+
: this.delegate.findUnique({
|
|
91
|
+
where: this.whereId(id),
|
|
92
|
+
}), options);
|
|
28
93
|
}
|
|
29
94
|
/**
|
|
30
95
|
* Finds the first entity matching a filter.
|
|
@@ -32,7 +97,7 @@ export class BaseRepository {
|
|
|
32
97
|
async findOne(filter, options) {
|
|
33
98
|
this.validateFilter(filter);
|
|
34
99
|
return this.execute("findOne", () => this.delegate.findFirst({
|
|
35
|
-
where: filter,
|
|
100
|
+
where: this.scope(filter),
|
|
36
101
|
}), options);
|
|
37
102
|
}
|
|
38
103
|
/**
|
|
@@ -43,7 +108,7 @@ export class BaseRepository {
|
|
|
43
108
|
this.validateFilter(filter);
|
|
44
109
|
}
|
|
45
110
|
return this.execute("findMany", () => this.delegate.findMany({
|
|
46
|
-
where: filter,
|
|
111
|
+
where: this.scope(filter),
|
|
47
112
|
}), options);
|
|
48
113
|
}
|
|
49
114
|
/**
|
|
@@ -53,16 +118,18 @@ export class BaseRepository {
|
|
|
53
118
|
if (filter !== undefined) {
|
|
54
119
|
this.validateFilter(filter);
|
|
55
120
|
}
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const limit = normalizeLimit(pagination);
|
|
121
|
+
const page = normalizePage(options?.pagination?.page);
|
|
122
|
+
const limit = normalizeLimit(options?.pagination?.limit);
|
|
59
123
|
const skip = (page - 1) * limit;
|
|
124
|
+
// Validated before either query is dispatched, so an invalid sort never
|
|
125
|
+
// costs a `count` round-trip.
|
|
126
|
+
const orderBy = this.buildOrderBy(options?.sort);
|
|
60
127
|
const [data, total] = await Promise.all([
|
|
61
|
-
this.execute("
|
|
62
|
-
where: filter,
|
|
128
|
+
this.execute("findPaginated", () => this.delegate.findMany({
|
|
129
|
+
where: this.scope(filter),
|
|
63
130
|
skip,
|
|
64
131
|
take: limit,
|
|
65
|
-
orderBy
|
|
132
|
+
orderBy,
|
|
66
133
|
}), options),
|
|
67
134
|
this.count(filter, options),
|
|
68
135
|
]);
|
|
@@ -71,6 +138,83 @@ export class BaseRepository {
|
|
|
71
138
|
meta: createPaginationMeta(page, limit, total),
|
|
72
139
|
};
|
|
73
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Alias of {@link findPaginated}.
|
|
143
|
+
*/
|
|
144
|
+
async paginate(filter, options) {
|
|
145
|
+
return this.findPaginated(filter, options);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Finds entities using keyset (cursor) pagination.
|
|
149
|
+
*
|
|
150
|
+
* Rows are ordered by `options.sort` (the id field is appended as a
|
|
151
|
+
* tiebreaker), `limit + 1` rows are fetched and the extra row decides
|
|
152
|
+
* `hasNextPage`. Cursors are validated against the sort fields and, when
|
|
153
|
+
* `cursorSecret` is configured, signed.
|
|
154
|
+
*/
|
|
155
|
+
async paginateCursor(filter, options) {
|
|
156
|
+
if (filter !== undefined) {
|
|
157
|
+
this.validateFilter(filter);
|
|
158
|
+
}
|
|
159
|
+
const sort = this.buildCursorSort(options?.sort);
|
|
160
|
+
const orderBy = this.buildOrderBy(sort);
|
|
161
|
+
const limit = normalizeLimit(options?.limit);
|
|
162
|
+
const cursor = options?.cursor ?? null;
|
|
163
|
+
let where = this.scope(filter);
|
|
164
|
+
if (cursor !== null) {
|
|
165
|
+
const payload = decodeKeysetCursor(cursor, sort, this.cursorSecret);
|
|
166
|
+
const keyset = buildKeysetWhere(payload, sort);
|
|
167
|
+
where = where === undefined ? keyset : { AND: [where, keyset] };
|
|
168
|
+
}
|
|
169
|
+
const rows = await this.execute("paginateCursor", () => this.delegate.findMany({
|
|
170
|
+
where: where,
|
|
171
|
+
take: limit + 1,
|
|
172
|
+
orderBy,
|
|
173
|
+
}), options);
|
|
174
|
+
return createKeysetPage(rows, {
|
|
175
|
+
sort,
|
|
176
|
+
limit,
|
|
177
|
+
cursor,
|
|
178
|
+
secret: this.cursorSecret,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Finds entities from a query builder (or its built state), applying the
|
|
183
|
+
* filter, sort, select, include and pagination it carries.
|
|
184
|
+
*
|
|
185
|
+
* `options.includeDeleted` and `options.depth` control how relation
|
|
186
|
+
* includes are resolved (see `toPrismaInclude`); by default soft-deleted
|
|
187
|
+
* rows of collection relations are filtered whenever this repository
|
|
188
|
+
* filters its own rows.
|
|
189
|
+
*/
|
|
190
|
+
async findByQuery(query, options) {
|
|
191
|
+
const state = isQueryBuilder(query) ? query.build() : query;
|
|
192
|
+
if (!state || typeof state !== "object") {
|
|
193
|
+
throw new DatabaseError(`${this.modelName} query is required.`);
|
|
194
|
+
}
|
|
195
|
+
const includeDeleted = options?.includeDeleted ?? (this.includeDeleted || !this.softDeleteField);
|
|
196
|
+
const includeOptions = this.relations
|
|
197
|
+
? {
|
|
198
|
+
registry: this.relations,
|
|
199
|
+
parent: this.relationParent,
|
|
200
|
+
includeDeleted,
|
|
201
|
+
softDeleteField: this.softDeleteField,
|
|
202
|
+
...(options?.depth !== undefined ? { depth: options.depth } : {}),
|
|
203
|
+
}
|
|
204
|
+
: options?.depth !== undefined
|
|
205
|
+
? { depth: options.depth }
|
|
206
|
+
: undefined;
|
|
207
|
+
const args = toPrismaArgs(state, { include: includeOptions });
|
|
208
|
+
const where = this.scope(args.where);
|
|
209
|
+
return this.execute("findByQuery", () => this.delegate.findMany({
|
|
210
|
+
where,
|
|
211
|
+
skip: args.skip,
|
|
212
|
+
take: args.take,
|
|
213
|
+
orderBy: args.orderBy,
|
|
214
|
+
select: args.select,
|
|
215
|
+
include: args.include,
|
|
216
|
+
}), options);
|
|
217
|
+
}
|
|
74
218
|
/**
|
|
75
219
|
* Creates a new entity.
|
|
76
220
|
*/
|
|
@@ -82,6 +226,26 @@ export class BaseRepository {
|
|
|
82
226
|
data: input,
|
|
83
227
|
}), options);
|
|
84
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Creates many entities and returns the number created.
|
|
231
|
+
*/
|
|
232
|
+
async createMany(inputs, options) {
|
|
233
|
+
if (!Array.isArray(inputs)) {
|
|
234
|
+
throw new DatabaseError(`Cannot create ${this.modelName}: inputs must be an array.`);
|
|
235
|
+
}
|
|
236
|
+
if (inputs.some((input) => input === undefined || input === null)) {
|
|
237
|
+
throw new DatabaseError(`Cannot create ${this.modelName}: every input is required.`);
|
|
238
|
+
}
|
|
239
|
+
if (inputs.length === 0) {
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
const createMany = this.delegate.createMany;
|
|
243
|
+
if (typeof createMany !== "function") {
|
|
244
|
+
throw new DatabaseError(`createMany is not supported by ${this.modelName}.`);
|
|
245
|
+
}
|
|
246
|
+
const result = await this.execute("createMany", () => createMany.call(this.delegate, { data: inputs }), options);
|
|
247
|
+
return result.count;
|
|
248
|
+
}
|
|
85
249
|
/**
|
|
86
250
|
* Updates an entity by its identifier.
|
|
87
251
|
*/
|
|
@@ -91,21 +255,67 @@ export class BaseRepository {
|
|
|
91
255
|
throw new DatabaseError(`Cannot update ${this.modelName}: input is required.`);
|
|
92
256
|
}
|
|
93
257
|
return this.execute("update", () => this.delegate.update({
|
|
94
|
-
where:
|
|
95
|
-
id,
|
|
96
|
-
},
|
|
258
|
+
where: this.whereUniqueId(id),
|
|
97
259
|
data: input,
|
|
98
260
|
}), options);
|
|
99
261
|
}
|
|
100
262
|
/**
|
|
101
|
-
* Deletes an entity by its identifier.
|
|
263
|
+
* Deletes an entity by its identifier (hard delete).
|
|
102
264
|
*/
|
|
103
265
|
async delete(id, options) {
|
|
104
266
|
this.validateId(id);
|
|
105
267
|
await this.execute("delete", () => this.delegate.delete({
|
|
106
|
-
where:
|
|
107
|
-
|
|
108
|
-
|
|
268
|
+
where: this.whereId(id),
|
|
269
|
+
}), options);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Deletes every entity matching a filter (hard delete, including
|
|
273
|
+
* soft-deleted rows) and returns the number removed.
|
|
274
|
+
*/
|
|
275
|
+
async deleteMany(filter, options) {
|
|
276
|
+
this.validateFilter(filter);
|
|
277
|
+
const deleteMany = this.delegate.deleteMany;
|
|
278
|
+
if (typeof deleteMany !== "function") {
|
|
279
|
+
throw new DatabaseError(`deleteMany is not supported by ${this.modelName}.`);
|
|
280
|
+
}
|
|
281
|
+
const result = await this.execute("deleteMany", () => deleteMany.call(this.delegate, { where: filter }), options);
|
|
282
|
+
return result.count;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Marks an entity as deleted by setting its soft-delete field.
|
|
286
|
+
*/
|
|
287
|
+
async softDelete(id, options) {
|
|
288
|
+
const field = this.requireSoftDelete("softDelete");
|
|
289
|
+
this.validateId(id);
|
|
290
|
+
return this.execute("softDelete", () => this.delegate.update({
|
|
291
|
+
where: { ...this.whereId(id), [field]: null },
|
|
292
|
+
data: { [field]: new Date() },
|
|
293
|
+
}), options);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Restores a soft-deleted entity.
|
|
297
|
+
*/
|
|
298
|
+
async restore(id, options) {
|
|
299
|
+
const field = this.requireSoftDelete("restore");
|
|
300
|
+
this.validateId(id);
|
|
301
|
+
return this.execute("restore", () => this.delegate.update({
|
|
302
|
+
where: { ...this.whereId(id), [field]: { not: null } },
|
|
303
|
+
data: { [field]: null },
|
|
304
|
+
}), options);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Finds soft-deleted entities matching a filter.
|
|
308
|
+
*/
|
|
309
|
+
async findDeleted(filter, options) {
|
|
310
|
+
const field = this.requireSoftDelete("findDeleted");
|
|
311
|
+
if (filter !== undefined) {
|
|
312
|
+
this.validateFilter(filter);
|
|
313
|
+
}
|
|
314
|
+
const deleted = { [field]: { not: null } };
|
|
315
|
+
return this.execute("findDeleted", () => this.delegate.findMany({
|
|
316
|
+
where: (filter === undefined
|
|
317
|
+
? deleted
|
|
318
|
+
: { AND: [filter, deleted] }),
|
|
109
319
|
}), options);
|
|
110
320
|
}
|
|
111
321
|
/**
|
|
@@ -113,10 +323,10 @@ export class BaseRepository {
|
|
|
113
323
|
*/
|
|
114
324
|
async exists(filter, options) {
|
|
115
325
|
this.validateFilter(filter);
|
|
116
|
-
const
|
|
117
|
-
where: filter,
|
|
326
|
+
const total = await this.execute("exists", () => this.delegate.count({
|
|
327
|
+
where: this.scope(filter),
|
|
118
328
|
}), options);
|
|
119
|
-
return
|
|
329
|
+
return total > 0;
|
|
120
330
|
}
|
|
121
331
|
/**
|
|
122
332
|
* Counts entities matching a filter.
|
|
@@ -126,17 +336,25 @@ export class BaseRepository {
|
|
|
126
336
|
this.validateFilter(filter);
|
|
127
337
|
}
|
|
128
338
|
return this.execute("count", () => this.delegate.count({
|
|
129
|
-
where: filter,
|
|
339
|
+
where: this.scope(filter),
|
|
130
340
|
}), options);
|
|
131
341
|
}
|
|
132
342
|
/**
|
|
133
343
|
* Updates an entity if it exists, otherwise creates it.
|
|
134
344
|
*/
|
|
135
345
|
async upsert(where, create, update, options) {
|
|
136
|
-
|
|
346
|
+
this.validateFilter(where);
|
|
347
|
+
if (create === undefined || create === null) {
|
|
348
|
+
throw new DatabaseError(`Cannot upsert ${this.modelName}: create input is required.`);
|
|
349
|
+
}
|
|
350
|
+
if (update === undefined || update === null) {
|
|
351
|
+
throw new DatabaseError(`Cannot upsert ${this.modelName}: update input is required.`);
|
|
352
|
+
}
|
|
353
|
+
const upsert = this.delegate.upsert;
|
|
354
|
+
if (typeof upsert !== "function") {
|
|
137
355
|
throw new DatabaseError(`Upsert is not supported by ${this.modelName}.`);
|
|
138
356
|
}
|
|
139
|
-
return this.execute("upsert", () => this.delegate
|
|
357
|
+
return this.execute("upsert", () => upsert.call(this.delegate, {
|
|
140
358
|
where,
|
|
141
359
|
create,
|
|
142
360
|
update,
|
|
@@ -144,35 +362,64 @@ export class BaseRepository {
|
|
|
144
362
|
}
|
|
145
363
|
/**
|
|
146
364
|
* Executes a repository operation and normalizes database failures.
|
|
365
|
+
*
|
|
366
|
+
* Honours `options.signal` for the whole duration of the call and
|
|
367
|
+
* `options.timeoutMs` as a client-side deadline (the underlying query is
|
|
368
|
+
* not cancelled server-side).
|
|
147
369
|
*/
|
|
148
370
|
async execute(operation, callback, options) {
|
|
149
|
-
|
|
150
|
-
|
|
371
|
+
const context = {
|
|
372
|
+
model: this.modelName,
|
|
373
|
+
operation,
|
|
374
|
+
metadata: options?.metadata,
|
|
375
|
+
};
|
|
376
|
+
const signal = options?.signal;
|
|
377
|
+
if (signal?.aborted) {
|
|
378
|
+
throw createAbortError(context, signal.reason);
|
|
151
379
|
}
|
|
152
380
|
const startedAt = Date.now();
|
|
381
|
+
let timer;
|
|
382
|
+
let onAbort;
|
|
383
|
+
const guards = [];
|
|
384
|
+
if (signal) {
|
|
385
|
+
guards.push(new Promise((_, reject) => {
|
|
386
|
+
onAbort = () => reject(createAbortError(context, signal.reason));
|
|
387
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
if (options?.timeoutMs !== undefined) {
|
|
391
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
|
|
392
|
+
throw new DatabaseError(`${this.modelName} ${operation}: timeoutMs must be a positive number.`);
|
|
393
|
+
}
|
|
394
|
+
const timeoutMs = options.timeoutMs;
|
|
395
|
+
guards.push(new Promise((_, reject) => {
|
|
396
|
+
timer = setTimeout(() => reject(createTimeoutError(context, timeoutMs)), timeoutMs);
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
153
399
|
try {
|
|
154
|
-
const promise =
|
|
155
|
-
if (
|
|
156
|
-
return await
|
|
400
|
+
const promise = Promise.resolve().then(callback);
|
|
401
|
+
if (guards.length === 0) {
|
|
402
|
+
return await promise;
|
|
157
403
|
}
|
|
158
|
-
|
|
404
|
+
// Keep a handler on the operation so a late rejection after a
|
|
405
|
+
// timeout/abort does not surface as an unhandled rejection.
|
|
406
|
+
promise.catch(() => undefined);
|
|
407
|
+
return await Promise.race([promise, ...guards]);
|
|
159
408
|
}
|
|
160
409
|
catch (error) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
throw new DatabaseError(error instanceof Error
|
|
165
|
-
? error.message
|
|
166
|
-
: `${this.modelName} ${operation} failed.`, {
|
|
167
|
-
cause: error,
|
|
168
|
-
metadata: {
|
|
169
|
-
model: this.modelName,
|
|
170
|
-
operation,
|
|
171
|
-
durationMs: Date.now() - startedAt,
|
|
172
|
-
...(options?.metadata ?? {}),
|
|
173
|
-
},
|
|
410
|
+
throw mapRepositoryError(error, {
|
|
411
|
+
...context,
|
|
412
|
+
durationMs: Date.now() - startedAt,
|
|
174
413
|
});
|
|
175
414
|
}
|
|
415
|
+
finally {
|
|
416
|
+
if (timer !== undefined) {
|
|
417
|
+
clearTimeout(timer);
|
|
418
|
+
}
|
|
419
|
+
if (signal && onAbort) {
|
|
420
|
+
signal.removeEventListener("abort", onAbort);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
176
423
|
}
|
|
177
424
|
/**
|
|
178
425
|
* Validates an entity identifier.
|
|
@@ -180,7 +427,8 @@ export class BaseRepository {
|
|
|
180
427
|
validateId(id) {
|
|
181
428
|
if (id === undefined ||
|
|
182
429
|
id === null ||
|
|
183
|
-
(typeof id === "string" && id.trim().length === 0)
|
|
430
|
+
(typeof id === "string" && id.trim().length === 0) ||
|
|
431
|
+
(typeof id === "number" && !Number.isFinite(id))) {
|
|
184
432
|
throw new DatabaseError(`${this.modelName} identifier is required.`);
|
|
185
433
|
}
|
|
186
434
|
}
|
|
@@ -188,61 +436,94 @@ export class BaseRepository {
|
|
|
188
436
|
* Validates a repository filter.
|
|
189
437
|
*/
|
|
190
438
|
validateFilter(filter) {
|
|
191
|
-
if (filter === undefined ||
|
|
439
|
+
if (filter === undefined ||
|
|
440
|
+
filter === null ||
|
|
441
|
+
typeof filter !== "object" ||
|
|
442
|
+
Array.isArray(filter)) {
|
|
192
443
|
throw new DatabaseError(`${this.modelName} filter is required.`);
|
|
193
444
|
}
|
|
194
445
|
}
|
|
446
|
+
/**
|
|
447
|
+
* Builds the primary-key `where` for an identifier.
|
|
448
|
+
*/
|
|
449
|
+
whereId(id) {
|
|
450
|
+
return { [this.idField]: id };
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Builds the primary-key `where` for a *unique* operation (`update`),
|
|
454
|
+
* folding the soft-delete scope in as a sibling of the id rather than
|
|
455
|
+
* wrapping it in `AND`. Prisma's `WhereUniqueInput` requires the unique
|
|
456
|
+
* field at the top level, so `{ AND: [{ id }, { deletedAt: null }] }` is
|
|
457
|
+
* rejected with a validation error.
|
|
458
|
+
*/
|
|
459
|
+
whereUniqueId(id) {
|
|
460
|
+
const where = this.whereId(id);
|
|
461
|
+
return this.isScoped() ? { ...where, [this.softDeleteField]: null } : where;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Applies the soft-delete scope to a filter when enabled.
|
|
465
|
+
*/
|
|
466
|
+
scope(filter) {
|
|
467
|
+
if (!this.isScoped()) {
|
|
468
|
+
return filter;
|
|
469
|
+
}
|
|
470
|
+
const alive = { [this.softDeleteField]: null };
|
|
471
|
+
if (filter === undefined) {
|
|
472
|
+
return alive;
|
|
473
|
+
}
|
|
474
|
+
return { AND: [filter, alive] };
|
|
475
|
+
}
|
|
195
476
|
/**
|
|
196
477
|
* Converts generic sort definitions into Prisma-compatible orderBy.
|
|
478
|
+
*
|
|
479
|
+
* Field names and directions are validated the same way the query
|
|
480
|
+
* builder validates them (`toPrismaOrderBy`), so a sort taken from
|
|
481
|
+
* request input cannot reach the delegate with an arbitrary key or an
|
|
482
|
+
* unsupported direction.
|
|
197
483
|
*/
|
|
198
484
|
buildOrderBy(sort) {
|
|
199
|
-
|
|
200
|
-
|
|
485
|
+
return toPrismaOrderBy(sort);
|
|
486
|
+
}
|
|
487
|
+
isScoped() {
|
|
488
|
+
return this.softDeleteField !== undefined && !this.includeDeleted;
|
|
489
|
+
}
|
|
490
|
+
requireSoftDelete(operation) {
|
|
491
|
+
if (!this.softDeleteField) {
|
|
492
|
+
throw new DatabaseError(`${this.modelName} ${operation} requires the softDelete option.`);
|
|
201
493
|
}
|
|
202
|
-
return
|
|
203
|
-
|
|
494
|
+
return this.softDeleteField;
|
|
495
|
+
}
|
|
496
|
+
buildCursorSort(sort) {
|
|
497
|
+
const result = (sort ?? []).map((entry) => ({
|
|
498
|
+
field: entry.field,
|
|
499
|
+
direction: entry.direction,
|
|
204
500
|
}));
|
|
501
|
+
if (!result.some((entry) => entry.field === this.idField)) {
|
|
502
|
+
result.push({
|
|
503
|
+
field: this.idField,
|
|
504
|
+
direction: result[0]?.direction ?? "asc",
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
return result;
|
|
508
|
+
}
|
|
509
|
+
rebind(overrides) {
|
|
510
|
+
const copy = Object.create(Object.getPrototypeOf(this));
|
|
511
|
+
Object.assign(copy, this, overrides);
|
|
512
|
+
return copy;
|
|
205
513
|
}
|
|
206
514
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const totalPages = total === 0 ? 0 : Math.ceil(total / limit);
|
|
212
|
-
return {
|
|
213
|
-
page,
|
|
214
|
-
limit,
|
|
215
|
-
total,
|
|
216
|
-
totalPages,
|
|
217
|
-
hasNextPage: totalPages > 0 && page < totalPages,
|
|
218
|
-
hasPreviousPage: page > 1 && totalPages > 0,
|
|
219
|
-
};
|
|
515
|
+
function isQueryBuilder(value) {
|
|
516
|
+
return (value !== null &&
|
|
517
|
+
typeof value === "object" &&
|
|
518
|
+
typeof value.build === "function");
|
|
220
519
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
function normalizePage(input) {
|
|
225
|
-
return Math.max(1, Math.floor(input?.page ?? 1));
|
|
226
|
-
}
|
|
227
|
-
/**
|
|
228
|
-
* Normalizes a requested page size.
|
|
229
|
-
*/
|
|
230
|
-
function normalizeLimit(input) {
|
|
231
|
-
return Math.min(100, Math.max(1, Math.floor(input?.limit ?? 20)));
|
|
232
|
-
}
|
|
233
|
-
/**
|
|
234
|
-
* Resolves an operation with a timeout.
|
|
235
|
-
*/
|
|
236
|
-
async function withTimeout(promise, timeoutMs, message) {
|
|
237
|
-
let timer;
|
|
238
|
-
const timeout = new Promise((_, reject) => {
|
|
239
|
-
timer = setTimeout(() => reject(new DatabaseError(message)), timeoutMs);
|
|
240
|
-
});
|
|
241
|
-
try {
|
|
242
|
-
return await Promise.race([promise, timeout]);
|
|
243
|
-
}
|
|
244
|
-
finally {
|
|
245
|
-
clearTimeout(timer);
|
|
520
|
+
function validateFieldName(field, name) {
|
|
521
|
+
if (typeof field !== "string" || !FIELD_PATTERN.test(field)) {
|
|
522
|
+
throw new TypeError(`Invalid ${name} "${String(field)}".`);
|
|
246
523
|
}
|
|
524
|
+
return field;
|
|
525
|
+
}
|
|
526
|
+
function lowerFirst(value) {
|
|
527
|
+
return value.length === 0 ? value : value[0].toLowerCase() + value.slice(1);
|
|
247
528
|
}
|
|
248
529
|
//# sourceMappingURL=repository.base.js.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { DatabaseError, DatabaseOperation, type ErrorMetadata } from "@zudojs/errors";
|
|
2
|
+
/**
|
|
3
|
+
* Repository operation names used for error diagnostics.
|
|
4
|
+
*/
|
|
5
|
+
export type RepositoryOperation = "findById" | "findOne" | "findMany" | "findPaginated" | "findByQuery" | "paginateCursor" | "findDeleted" | "count" | "exists" | "create" | "createMany" | "update" | "upsert" | "softDelete" | "restore" | "delete" | "deleteMany";
|
|
6
|
+
/**
|
|
7
|
+
* Context attached to a mapped repository error.
|
|
8
|
+
*/
|
|
9
|
+
export interface RepositoryErrorContext {
|
|
10
|
+
readonly model: string;
|
|
11
|
+
readonly operation: RepositoryOperation | string;
|
|
12
|
+
readonly durationMs: number;
|
|
13
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Shape of a Prisma known request error, detected structurally so the
|
|
17
|
+
* mapper does not depend on which `@prisma/client` copy raised it.
|
|
18
|
+
*/
|
|
19
|
+
export interface PrismaErrorLike {
|
|
20
|
+
readonly code: string;
|
|
21
|
+
readonly message: string;
|
|
22
|
+
readonly meta?: Readonly<Record<string, unknown>>;
|
|
23
|
+
readonly clientVersion?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Determines whether a value looks like a Prisma known request error.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isPrismaErrorLike(value: unknown): value is PrismaErrorLike;
|
|
29
|
+
/**
|
|
30
|
+
* Maps a repository operation name to the errors package operation enum.
|
|
31
|
+
*/
|
|
32
|
+
export declare function toDatabaseOperation(operation: RepositoryOperation | string): DatabaseOperation;
|
|
33
|
+
/**
|
|
34
|
+
* Normalizes any failure raised by a repository operation into a
|
|
35
|
+
* `DatabaseError`, mapping Prisma error codes to typed outcomes:
|
|
36
|
+
*
|
|
37
|
+
* - `P2002` unique violation → `ERR_CONFLICT` / 409
|
|
38
|
+
* - `P2025` record not found → `ERR_NOT_FOUND` / 404
|
|
39
|
+
* - `P2003` foreign key violation → `ERR_CONFLICT` / 409
|
|
40
|
+
* - `P2034` serialization failure → `ERR_DATABASE_TRANSACTION` / 409 (retryable)
|
|
41
|
+
* - `P2024` pool timeout → `ERR_DATABASE_TIMEOUT` / 503
|
|
42
|
+
* - `P1xxx` connection failures → `ERR_DATABASE_CONNECTION` / 503
|
|
43
|
+
*
|
|
44
|
+
* Existing `DatabaseError`s are returned unchanged.
|
|
45
|
+
*/
|
|
46
|
+
export declare function mapRepositoryError(error: unknown, context: RepositoryErrorContext): DatabaseError;
|
|
47
|
+
/**
|
|
48
|
+
* Creates the error raised when an operation is aborted via `AbortSignal`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createAbortError(context: Omit<RepositoryErrorContext, "durationMs">, reason?: unknown): DatabaseError;
|
|
51
|
+
/**
|
|
52
|
+
* Creates the error raised when a client-side timeout elapses.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createTimeoutError(context: Omit<RepositoryErrorContext, "durationMs">, timeoutMs: number): DatabaseError;
|
|
55
|
+
/**
|
|
56
|
+
* Converts arbitrary metadata into the serializable shape the errors
|
|
57
|
+
* package accepts.
|
|
58
|
+
*/
|
|
59
|
+
export declare function toErrorMetadata(value?: Readonly<Record<string, unknown>>): ErrorMetadata;
|
|
60
|
+
//# sourceMappingURL=repository.errors.d.ts.map
|