@zudojs/database 0.1.0 → 1.0.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 +223 -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 +165 -14
- package/dist/databaseClient/databaseClient.core.js +304 -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 +128 -0
- package/dist/locks/locks.core.js +144 -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 +270 -0
- package/dist/repository/repository.base.js +350 -84
- 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 +40 -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 } 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,13 +118,12 @@ 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;
|
|
60
124
|
const [data, total] = await Promise.all([
|
|
61
|
-
this.execute("
|
|
62
|
-
where: filter,
|
|
125
|
+
this.execute("findPaginated", () => this.delegate.findMany({
|
|
126
|
+
where: this.scope(filter),
|
|
63
127
|
skip,
|
|
64
128
|
take: limit,
|
|
65
129
|
orderBy: this.buildOrderBy(options?.sort),
|
|
@@ -71,6 +135,82 @@ export class BaseRepository {
|
|
|
71
135
|
meta: createPaginationMeta(page, limit, total),
|
|
72
136
|
};
|
|
73
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Alias of {@link findPaginated}.
|
|
140
|
+
*/
|
|
141
|
+
async paginate(filter, options) {
|
|
142
|
+
return this.findPaginated(filter, options);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Finds entities using keyset (cursor) pagination.
|
|
146
|
+
*
|
|
147
|
+
* Rows are ordered by `options.sort` (the id field is appended as a
|
|
148
|
+
* tiebreaker), `limit + 1` rows are fetched and the extra row decides
|
|
149
|
+
* `hasNextPage`. Cursors are validated against the sort fields and, when
|
|
150
|
+
* `cursorSecret` is configured, signed.
|
|
151
|
+
*/
|
|
152
|
+
async paginateCursor(filter, options) {
|
|
153
|
+
if (filter !== undefined) {
|
|
154
|
+
this.validateFilter(filter);
|
|
155
|
+
}
|
|
156
|
+
const sort = this.buildCursorSort(options?.sort);
|
|
157
|
+
const limit = normalizeLimit(options?.limit);
|
|
158
|
+
const cursor = options?.cursor ?? null;
|
|
159
|
+
let where = this.scope(filter);
|
|
160
|
+
if (cursor !== null) {
|
|
161
|
+
const payload = decodeKeysetCursor(cursor, sort, this.cursorSecret);
|
|
162
|
+
const keyset = buildKeysetWhere(payload, sort);
|
|
163
|
+
where = where === undefined ? keyset : { AND: [where, keyset] };
|
|
164
|
+
}
|
|
165
|
+
const rows = await this.execute("paginateCursor", () => this.delegate.findMany({
|
|
166
|
+
where: where,
|
|
167
|
+
take: limit + 1,
|
|
168
|
+
orderBy: this.buildOrderBy(sort),
|
|
169
|
+
}), options);
|
|
170
|
+
return createKeysetPage(rows, {
|
|
171
|
+
sort,
|
|
172
|
+
limit,
|
|
173
|
+
cursor,
|
|
174
|
+
secret: this.cursorSecret,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Finds entities from a query builder (or its built state), applying the
|
|
179
|
+
* filter, sort, select, include and pagination it carries.
|
|
180
|
+
*
|
|
181
|
+
* `options.includeDeleted` and `options.depth` control how relation
|
|
182
|
+
* includes are resolved (see `toPrismaInclude`); by default soft-deleted
|
|
183
|
+
* rows of collection relations are filtered whenever this repository
|
|
184
|
+
* filters its own rows.
|
|
185
|
+
*/
|
|
186
|
+
async findByQuery(query, options) {
|
|
187
|
+
const state = isQueryBuilder(query) ? query.build() : query;
|
|
188
|
+
if (!state || typeof state !== "object") {
|
|
189
|
+
throw new DatabaseError(`${this.modelName} query is required.`);
|
|
190
|
+
}
|
|
191
|
+
const includeDeleted = options?.includeDeleted ?? (this.includeDeleted || !this.softDeleteField);
|
|
192
|
+
const includeOptions = this.relations
|
|
193
|
+
? {
|
|
194
|
+
registry: this.relations,
|
|
195
|
+
parent: this.relationParent,
|
|
196
|
+
includeDeleted,
|
|
197
|
+
softDeleteField: this.softDeleteField,
|
|
198
|
+
...(options?.depth !== undefined ? { depth: options.depth } : {}),
|
|
199
|
+
}
|
|
200
|
+
: options?.depth !== undefined
|
|
201
|
+
? { depth: options.depth }
|
|
202
|
+
: undefined;
|
|
203
|
+
const args = toPrismaArgs(state, { include: includeOptions });
|
|
204
|
+
const where = this.scope(args.where);
|
|
205
|
+
return this.execute("findByQuery", () => this.delegate.findMany({
|
|
206
|
+
where,
|
|
207
|
+
skip: args.skip,
|
|
208
|
+
take: args.take,
|
|
209
|
+
orderBy: args.orderBy,
|
|
210
|
+
select: args.select,
|
|
211
|
+
include: args.include,
|
|
212
|
+
}), options);
|
|
213
|
+
}
|
|
74
214
|
/**
|
|
75
215
|
* Creates a new entity.
|
|
76
216
|
*/
|
|
@@ -82,6 +222,26 @@ export class BaseRepository {
|
|
|
82
222
|
data: input,
|
|
83
223
|
}), options);
|
|
84
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Creates many entities and returns the number created.
|
|
227
|
+
*/
|
|
228
|
+
async createMany(inputs, options) {
|
|
229
|
+
if (!Array.isArray(inputs)) {
|
|
230
|
+
throw new DatabaseError(`Cannot create ${this.modelName}: inputs must be an array.`);
|
|
231
|
+
}
|
|
232
|
+
if (inputs.some((input) => input === undefined || input === null)) {
|
|
233
|
+
throw new DatabaseError(`Cannot create ${this.modelName}: every input is required.`);
|
|
234
|
+
}
|
|
235
|
+
if (inputs.length === 0) {
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
const createMany = this.delegate.createMany;
|
|
239
|
+
if (typeof createMany !== "function") {
|
|
240
|
+
throw new DatabaseError(`createMany is not supported by ${this.modelName}.`);
|
|
241
|
+
}
|
|
242
|
+
const result = await this.execute("createMany", () => createMany.call(this.delegate, { data: inputs }), options);
|
|
243
|
+
return result.count;
|
|
244
|
+
}
|
|
85
245
|
/**
|
|
86
246
|
* Updates an entity by its identifier.
|
|
87
247
|
*/
|
|
@@ -91,21 +251,67 @@ export class BaseRepository {
|
|
|
91
251
|
throw new DatabaseError(`Cannot update ${this.modelName}: input is required.`);
|
|
92
252
|
}
|
|
93
253
|
return this.execute("update", () => this.delegate.update({
|
|
94
|
-
where:
|
|
95
|
-
id,
|
|
96
|
-
},
|
|
254
|
+
where: this.scope(this.whereId(id)),
|
|
97
255
|
data: input,
|
|
98
256
|
}), options);
|
|
99
257
|
}
|
|
100
258
|
/**
|
|
101
|
-
* Deletes an entity by its identifier.
|
|
259
|
+
* Deletes an entity by its identifier (hard delete).
|
|
102
260
|
*/
|
|
103
261
|
async delete(id, options) {
|
|
104
262
|
this.validateId(id);
|
|
105
263
|
await this.execute("delete", () => this.delegate.delete({
|
|
106
|
-
where:
|
|
107
|
-
|
|
108
|
-
|
|
264
|
+
where: this.whereId(id),
|
|
265
|
+
}), options);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Deletes every entity matching a filter (hard delete, including
|
|
269
|
+
* soft-deleted rows) and returns the number removed.
|
|
270
|
+
*/
|
|
271
|
+
async deleteMany(filter, options) {
|
|
272
|
+
this.validateFilter(filter);
|
|
273
|
+
const deleteMany = this.delegate.deleteMany;
|
|
274
|
+
if (typeof deleteMany !== "function") {
|
|
275
|
+
throw new DatabaseError(`deleteMany is not supported by ${this.modelName}.`);
|
|
276
|
+
}
|
|
277
|
+
const result = await this.execute("deleteMany", () => deleteMany.call(this.delegate, { where: filter }), options);
|
|
278
|
+
return result.count;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Marks an entity as deleted by setting its soft-delete field.
|
|
282
|
+
*/
|
|
283
|
+
async softDelete(id, options) {
|
|
284
|
+
const field = this.requireSoftDelete("softDelete");
|
|
285
|
+
this.validateId(id);
|
|
286
|
+
return this.execute("softDelete", () => this.delegate.update({
|
|
287
|
+
where: { ...this.whereId(id), [field]: null },
|
|
288
|
+
data: { [field]: new Date() },
|
|
289
|
+
}), options);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Restores a soft-deleted entity.
|
|
293
|
+
*/
|
|
294
|
+
async restore(id, options) {
|
|
295
|
+
const field = this.requireSoftDelete("restore");
|
|
296
|
+
this.validateId(id);
|
|
297
|
+
return this.execute("restore", () => this.delegate.update({
|
|
298
|
+
where: { ...this.whereId(id), [field]: { not: null } },
|
|
299
|
+
data: { [field]: null },
|
|
300
|
+
}), options);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Finds soft-deleted entities matching a filter.
|
|
304
|
+
*/
|
|
305
|
+
async findDeleted(filter, options) {
|
|
306
|
+
const field = this.requireSoftDelete("findDeleted");
|
|
307
|
+
if (filter !== undefined) {
|
|
308
|
+
this.validateFilter(filter);
|
|
309
|
+
}
|
|
310
|
+
const deleted = { [field]: { not: null } };
|
|
311
|
+
return this.execute("findDeleted", () => this.delegate.findMany({
|
|
312
|
+
where: (filter === undefined
|
|
313
|
+
? deleted
|
|
314
|
+
: { AND: [filter, deleted] }),
|
|
109
315
|
}), options);
|
|
110
316
|
}
|
|
111
317
|
/**
|
|
@@ -113,10 +319,10 @@ export class BaseRepository {
|
|
|
113
319
|
*/
|
|
114
320
|
async exists(filter, options) {
|
|
115
321
|
this.validateFilter(filter);
|
|
116
|
-
const
|
|
117
|
-
where: filter,
|
|
322
|
+
const total = await this.execute("exists", () => this.delegate.count({
|
|
323
|
+
where: this.scope(filter),
|
|
118
324
|
}), options);
|
|
119
|
-
return
|
|
325
|
+
return total > 0;
|
|
120
326
|
}
|
|
121
327
|
/**
|
|
122
328
|
* Counts entities matching a filter.
|
|
@@ -126,17 +332,25 @@ export class BaseRepository {
|
|
|
126
332
|
this.validateFilter(filter);
|
|
127
333
|
}
|
|
128
334
|
return this.execute("count", () => this.delegate.count({
|
|
129
|
-
where: filter,
|
|
335
|
+
where: this.scope(filter),
|
|
130
336
|
}), options);
|
|
131
337
|
}
|
|
132
338
|
/**
|
|
133
339
|
* Updates an entity if it exists, otherwise creates it.
|
|
134
340
|
*/
|
|
135
341
|
async upsert(where, create, update, options) {
|
|
136
|
-
|
|
342
|
+
this.validateFilter(where);
|
|
343
|
+
if (create === undefined || create === null) {
|
|
344
|
+
throw new DatabaseError(`Cannot upsert ${this.modelName}: create input is required.`);
|
|
345
|
+
}
|
|
346
|
+
if (update === undefined || update === null) {
|
|
347
|
+
throw new DatabaseError(`Cannot upsert ${this.modelName}: update input is required.`);
|
|
348
|
+
}
|
|
349
|
+
const upsert = this.delegate.upsert;
|
|
350
|
+
if (typeof upsert !== "function") {
|
|
137
351
|
throw new DatabaseError(`Upsert is not supported by ${this.modelName}.`);
|
|
138
352
|
}
|
|
139
|
-
return this.execute("upsert", () => this.delegate
|
|
353
|
+
return this.execute("upsert", () => upsert.call(this.delegate, {
|
|
140
354
|
where,
|
|
141
355
|
create,
|
|
142
356
|
update,
|
|
@@ -144,35 +358,64 @@ export class BaseRepository {
|
|
|
144
358
|
}
|
|
145
359
|
/**
|
|
146
360
|
* Executes a repository operation and normalizes database failures.
|
|
361
|
+
*
|
|
362
|
+
* Honours `options.signal` for the whole duration of the call and
|
|
363
|
+
* `options.timeoutMs` as a client-side deadline (the underlying query is
|
|
364
|
+
* not cancelled server-side).
|
|
147
365
|
*/
|
|
148
366
|
async execute(operation, callback, options) {
|
|
149
|
-
|
|
150
|
-
|
|
367
|
+
const context = {
|
|
368
|
+
model: this.modelName,
|
|
369
|
+
operation,
|
|
370
|
+
metadata: options?.metadata,
|
|
371
|
+
};
|
|
372
|
+
const signal = options?.signal;
|
|
373
|
+
if (signal?.aborted) {
|
|
374
|
+
throw createAbortError(context, signal.reason);
|
|
151
375
|
}
|
|
152
376
|
const startedAt = Date.now();
|
|
377
|
+
let timer;
|
|
378
|
+
let onAbort;
|
|
379
|
+
const guards = [];
|
|
380
|
+
if (signal) {
|
|
381
|
+
guards.push(new Promise((_, reject) => {
|
|
382
|
+
onAbort = () => reject(createAbortError(context, signal.reason));
|
|
383
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
384
|
+
}));
|
|
385
|
+
}
|
|
386
|
+
if (options?.timeoutMs !== undefined) {
|
|
387
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
|
|
388
|
+
throw new DatabaseError(`${this.modelName} ${operation}: timeoutMs must be a positive number.`);
|
|
389
|
+
}
|
|
390
|
+
const timeoutMs = options.timeoutMs;
|
|
391
|
+
guards.push(new Promise((_, reject) => {
|
|
392
|
+
timer = setTimeout(() => reject(createTimeoutError(context, timeoutMs)), timeoutMs);
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
153
395
|
try {
|
|
154
|
-
const promise =
|
|
155
|
-
if (
|
|
156
|
-
return await
|
|
396
|
+
const promise = Promise.resolve().then(callback);
|
|
397
|
+
if (guards.length === 0) {
|
|
398
|
+
return await promise;
|
|
157
399
|
}
|
|
158
|
-
|
|
400
|
+
// Keep a handler on the operation so a late rejection after a
|
|
401
|
+
// timeout/abort does not surface as an unhandled rejection.
|
|
402
|
+
promise.catch(() => undefined);
|
|
403
|
+
return await Promise.race([promise, ...guards]);
|
|
159
404
|
}
|
|
160
405
|
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
|
-
},
|
|
406
|
+
throw mapRepositoryError(error, {
|
|
407
|
+
...context,
|
|
408
|
+
durationMs: Date.now() - startedAt,
|
|
174
409
|
});
|
|
175
410
|
}
|
|
411
|
+
finally {
|
|
412
|
+
if (timer !== undefined) {
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
}
|
|
415
|
+
if (signal && onAbort) {
|
|
416
|
+
signal.removeEventListener("abort", onAbort);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
176
419
|
}
|
|
177
420
|
/**
|
|
178
421
|
* Validates an entity identifier.
|
|
@@ -180,7 +423,8 @@ export class BaseRepository {
|
|
|
180
423
|
validateId(id) {
|
|
181
424
|
if (id === undefined ||
|
|
182
425
|
id === null ||
|
|
183
|
-
(typeof id === "string" && id.trim().length === 0)
|
|
426
|
+
(typeof id === "string" && id.trim().length === 0) ||
|
|
427
|
+
(typeof id === "number" && !Number.isFinite(id))) {
|
|
184
428
|
throw new DatabaseError(`${this.modelName} identifier is required.`);
|
|
185
429
|
}
|
|
186
430
|
}
|
|
@@ -188,10 +432,32 @@ export class BaseRepository {
|
|
|
188
432
|
* Validates a repository filter.
|
|
189
433
|
*/
|
|
190
434
|
validateFilter(filter) {
|
|
191
|
-
if (filter === undefined ||
|
|
435
|
+
if (filter === undefined ||
|
|
436
|
+
filter === null ||
|
|
437
|
+
typeof filter !== "object" ||
|
|
438
|
+
Array.isArray(filter)) {
|
|
192
439
|
throw new DatabaseError(`${this.modelName} filter is required.`);
|
|
193
440
|
}
|
|
194
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* Builds the primary-key `where` for an identifier.
|
|
444
|
+
*/
|
|
445
|
+
whereId(id) {
|
|
446
|
+
return { [this.idField]: id };
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Applies the soft-delete scope to a filter when enabled.
|
|
450
|
+
*/
|
|
451
|
+
scope(filter) {
|
|
452
|
+
if (!this.isScoped()) {
|
|
453
|
+
return filter;
|
|
454
|
+
}
|
|
455
|
+
const alive = { [this.softDeleteField]: null };
|
|
456
|
+
if (filter === undefined) {
|
|
457
|
+
return alive;
|
|
458
|
+
}
|
|
459
|
+
return { AND: [filter, alive] };
|
|
460
|
+
}
|
|
195
461
|
/**
|
|
196
462
|
* Converts generic sort definitions into Prisma-compatible orderBy.
|
|
197
463
|
*/
|
|
@@ -203,46 +469,46 @@ export class BaseRepository {
|
|
|
203
469
|
[entry.field]: entry.direction,
|
|
204
470
|
}));
|
|
205
471
|
}
|
|
472
|
+
isScoped() {
|
|
473
|
+
return this.softDeleteField !== undefined && !this.includeDeleted;
|
|
474
|
+
}
|
|
475
|
+
requireSoftDelete(operation) {
|
|
476
|
+
if (!this.softDeleteField) {
|
|
477
|
+
throw new DatabaseError(`${this.modelName} ${operation} requires the softDelete option.`);
|
|
478
|
+
}
|
|
479
|
+
return this.softDeleteField;
|
|
480
|
+
}
|
|
481
|
+
buildCursorSort(sort) {
|
|
482
|
+
const result = (sort ?? []).map((entry) => ({
|
|
483
|
+
field: entry.field,
|
|
484
|
+
direction: entry.direction,
|
|
485
|
+
}));
|
|
486
|
+
if (!result.some((entry) => entry.field === this.idField)) {
|
|
487
|
+
result.push({
|
|
488
|
+
field: this.idField,
|
|
489
|
+
direction: result[0]?.direction ?? "asc",
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return result;
|
|
493
|
+
}
|
|
494
|
+
rebind(overrides) {
|
|
495
|
+
const copy = Object.create(Object.getPrototypeOf(this));
|
|
496
|
+
Object.assign(copy, this, overrides);
|
|
497
|
+
return copy;
|
|
498
|
+
}
|
|
206
499
|
}
|
|
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
|
-
};
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* Normalizes a requested page number.
|
|
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)));
|
|
500
|
+
function isQueryBuilder(value) {
|
|
501
|
+
return (value !== null &&
|
|
502
|
+
typeof value === "object" &&
|
|
503
|
+
typeof value.build === "function");
|
|
232
504
|
}
|
|
233
|
-
|
|
234
|
-
|
|
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);
|
|
505
|
+
function validateFieldName(field, name) {
|
|
506
|
+
if (typeof field !== "string" || !FIELD_PATTERN.test(field)) {
|
|
507
|
+
throw new TypeError(`Invalid ${name} "${String(field)}".`);
|
|
246
508
|
}
|
|
509
|
+
return field;
|
|
510
|
+
}
|
|
511
|
+
function lowerFirst(value) {
|
|
512
|
+
return value.length === 0 ? value : value[0].toLowerCase() + value.slice(1);
|
|
247
513
|
}
|
|
248
514
|
//# 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
|