@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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -17
  3. package/dist/cache/cache.memory.d.ts +167 -0
  4. package/dist/cache/cache.memory.js +186 -40
  5. package/dist/cache/index.d.ts +2 -2
  6. package/dist/cache/index.js +2 -2
  7. package/dist/database/database.core.d.ts +91 -0
  8. package/dist/database/database.core.js +21 -4
  9. package/dist/databaseClient/databaseClient.core.d.ts +171 -14
  10. package/dist/databaseClient/databaseClient.core.js +350 -70
  11. package/dist/databaseClient/databaseClient.errors.d.ts +112 -0
  12. package/dist/databaseClient/databaseClient.errors.js +425 -0
  13. package/dist/databaseClient/index.d.ts +2 -1
  14. package/dist/databaseClient/index.js +2 -1
  15. package/dist/databaseConnection/databaseConnection.manager.d.ts +163 -0
  16. package/dist/databaseConnection/databaseConnection.manager.js +138 -76
  17. package/dist/databaseConnection/index.d.ts +1 -1
  18. package/dist/databaseType/databaseType.type.d.ts +224 -0
  19. package/dist/databaseType/databaseType.type.js +0 -35
  20. package/dist/health/health.check.d.ts +74 -0
  21. package/dist/health/health.check.js +78 -47
  22. package/dist/health/index.d.ts +1 -1
  23. package/dist/health/index.js +1 -1
  24. package/dist/index.d.ts +14 -14
  25. package/dist/index.js +11 -11
  26. package/dist/locks/index.d.ts +2 -2
  27. package/dist/locks/index.js +2 -2
  28. package/dist/locks/locks.core.d.ts +132 -0
  29. package/dist/locks/locks.core.js +150 -85
  30. package/dist/migration/index.d.ts +3 -1
  31. package/dist/migration/index.js +2 -0
  32. package/dist/migration/migration.dialect.d.ts +65 -0
  33. package/dist/migration/migration.dialect.js +63 -0
  34. package/dist/migration/migration.helpers.d.ts +65 -0
  35. package/dist/migration/migration.helpers.js +60 -18
  36. package/dist/migration/migration.runner.d.ts +75 -0
  37. package/dist/migration/migration.runner.js +195 -107
  38. package/dist/migration/migration.types.d.ts +85 -0
  39. package/dist/pagination/index.d.ts +2 -1
  40. package/dist/pagination/index.js +2 -1
  41. package/dist/pagination/pagination.core.d.ts +163 -0
  42. package/dist/pagination/pagination.core.js +78 -9
  43. package/dist/pagination/pagination.keyset.d.ts +55 -0
  44. package/dist/pagination/pagination.keyset.js +108 -0
  45. package/dist/queryBuilder/index.d.ts +4 -3
  46. package/dist/queryBuilder/index.js +3 -2
  47. package/dist/queryBuilder/queryBuilder.core.d.ts +178 -0
  48. package/dist/queryBuilder/queryBuilder.core.js +92 -14
  49. package/dist/queryBuilder/queryBuilder.factory.d.ts +6 -0
  50. package/dist/queryBuilder/queryBuilder.factory.js +0 -25
  51. package/dist/queryBuilder/queryBuilder.filter.d.ts +170 -0
  52. package/dist/queryBuilder/queryBuilder.filter.js +191 -30
  53. package/dist/queryBuilder/queryBuilder.prisma.d.ts +60 -0
  54. package/dist/queryBuilder/queryBuilder.prisma.js +233 -0
  55. package/dist/queryBuilder/queryBuilder.type.d.ts +48 -0
  56. package/dist/relations/index.d.ts +1 -1
  57. package/dist/relations/index.js +1 -1
  58. package/dist/relations/relations.definition.d.ts +184 -0
  59. package/dist/relations/relations.definition.js +186 -19
  60. package/dist/repository/index.d.ts +2 -1
  61. package/dist/repository/index.js +1 -0
  62. package/dist/repository/repository.base.d.ts +283 -0
  63. package/dist/repository/repository.base.js +370 -89
  64. package/dist/repository/repository.errors.d.ts +60 -0
  65. package/dist/repository/repository.errors.js +235 -0
  66. package/dist/seed/index.d.ts +2 -2
  67. package/dist/seed/index.js +1 -1
  68. package/dist/seed/seed.runner.d.ts +160 -0
  69. package/dist/seed/seed.runner.js +194 -225
  70. package/dist/transaction/index.d.ts +1 -1
  71. package/dist/transaction/index.js +1 -1
  72. package/dist/transaction/transaction.core.d.ts +117 -0
  73. package/dist/transaction/transaction.core.js +83 -56
  74. package/dist/unitOfWork/unitOfWork.core.d.ts +44 -0
  75. package/dist/unitOfWork/unitOfWork.core.js +9 -20
  76. package/package.json +44 -24
  77. package/dist/cache/cache.memory.d.ts.map +0 -1
  78. package/dist/cache/cache.memory.js.map +0 -1
  79. package/dist/cache/index.d.ts.map +0 -1
  80. package/dist/cache/index.js.map +0 -1
  81. package/dist/database/database.core.d.ts.map +0 -1
  82. package/dist/database/database.core.js.map +0 -1
  83. package/dist/database/index.d.ts.map +0 -1
  84. package/dist/database/index.js.map +0 -1
  85. package/dist/databaseClient/databaseClient.core.d.ts.map +0 -1
  86. package/dist/databaseClient/databaseClient.core.js.map +0 -1
  87. package/dist/databaseClient/databaseClient.logger.d.ts.map +0 -1
  88. package/dist/databaseClient/databaseClient.logger.js.map +0 -1
  89. package/dist/databaseClient/index.d.ts.map +0 -1
  90. package/dist/databaseClient/index.js.map +0 -1
  91. package/dist/databaseConnection/databaseConnection.manager.d.ts.map +0 -1
  92. package/dist/databaseConnection/databaseConnection.manager.js.map +0 -1
  93. package/dist/databaseConnection/index.d.ts.map +0 -1
  94. package/dist/databaseConnection/index.js.map +0 -1
  95. package/dist/databaseType/databaseType.type.d.ts.map +0 -1
  96. package/dist/databaseType/databaseType.type.js.map +0 -1
  97. package/dist/databaseType/index.d.ts.map +0 -1
  98. package/dist/databaseType/index.js.map +0 -1
  99. package/dist/health/health.check.d.ts.map +0 -1
  100. package/dist/health/health.check.js.map +0 -1
  101. package/dist/health/index.d.ts.map +0 -1
  102. package/dist/health/index.js.map +0 -1
  103. package/dist/index.d.ts.map +0 -1
  104. package/dist/index.js.map +0 -1
  105. package/dist/locks/index.d.ts.map +0 -1
  106. package/dist/locks/index.js.map +0 -1
  107. package/dist/locks/locks.core.d.ts.map +0 -1
  108. package/dist/locks/locks.core.js.map +0 -1
  109. package/dist/migration/index.d.ts.map +0 -1
  110. package/dist/migration/index.js.map +0 -1
  111. package/dist/migration/migration.helpers.d.ts.map +0 -1
  112. package/dist/migration/migration.helpers.js.map +0 -1
  113. package/dist/migration/migration.runner.d.ts.map +0 -1
  114. package/dist/migration/migration.runner.js.map +0 -1
  115. package/dist/migration/migration.types.d.ts.map +0 -1
  116. package/dist/migration/migration.types.js.map +0 -1
  117. package/dist/pagination/index.d.ts.map +0 -1
  118. package/dist/pagination/index.js.map +0 -1
  119. package/dist/pagination/pagination.core.d.ts.map +0 -1
  120. package/dist/pagination/pagination.core.js.map +0 -1
  121. package/dist/queryBuilder/index.d.ts.map +0 -1
  122. package/dist/queryBuilder/index.js.map +0 -1
  123. package/dist/queryBuilder/queryBuilder.core.d.ts.map +0 -1
  124. package/dist/queryBuilder/queryBuilder.core.js.map +0 -1
  125. package/dist/queryBuilder/queryBuilder.factory.d.ts.map +0 -1
  126. package/dist/queryBuilder/queryBuilder.factory.js.map +0 -1
  127. package/dist/queryBuilder/queryBuilder.filter.d.ts.map +0 -1
  128. package/dist/queryBuilder/queryBuilder.filter.js.map +0 -1
  129. package/dist/queryBuilder/queryBuilder.type.d.ts.map +0 -1
  130. package/dist/queryBuilder/queryBuilder.type.js.map +0 -1
  131. package/dist/relations/index.d.ts.map +0 -1
  132. package/dist/relations/index.js.map +0 -1
  133. package/dist/relations/relations.definition.d.ts.map +0 -1
  134. package/dist/relations/relations.definition.js.map +0 -1
  135. package/dist/repository/index.d.ts.map +0 -1
  136. package/dist/repository/index.js.map +0 -1
  137. package/dist/repository/repository.base.d.ts.map +0 -1
  138. package/dist/repository/repository.base.js.map +0 -1
  139. package/dist/seed/index.d.ts.map +0 -1
  140. package/dist/seed/index.js.map +0 -1
  141. package/dist/seed/seed.runner.d.ts.map +0 -1
  142. package/dist/seed/seed.runner.js.map +0 -1
  143. package/dist/transaction/index.d.ts.map +0 -1
  144. package/dist/transaction/index.js.map +0 -1
  145. package/dist/transaction/transaction.core.d.ts.map +0 -1
  146. package/dist/transaction/transaction.core.js.map +0 -1
  147. package/dist/unitOfWork/index.d.ts.map +0 -1
  148. package/dist/unitOfWork/index.js.map +0 -1
  149. package/dist/unitOfWork/unitOfWork.core.d.ts.map +0 -1
  150. 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.delegate.findUnique({
24
- where: {
25
- id,
26
- },
27
- }), options);
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 pagination = options?.pagination;
57
- const page = normalizePage(pagination);
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("findMany", () => this.delegate.findMany({
62
- where: filter,
128
+ this.execute("findPaginated", () => this.delegate.findMany({
129
+ where: this.scope(filter),
63
130
  skip,
64
131
  take: limit,
65
- orderBy: this.buildOrderBy(options?.sort),
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
- id,
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 entity = await this.execute("exists", () => this.delegate.findFirst({
117
- where: filter,
326
+ const total = await this.execute("exists", () => this.delegate.count({
327
+ where: this.scope(filter),
118
328
  }), options);
119
- return entity !== null;
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
- if (typeof this.delegate.upsert !== "function") {
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.upsert({
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
- if (options?.signal?.aborted) {
150
- throw new DatabaseError(`${this.modelName} ${operation} was aborted.`);
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 = callback();
155
- if (options?.timeoutMs && options.timeoutMs > 0) {
156
- return await withTimeout(promise, options.timeoutMs, `${this.modelName} ${operation} timed out.`);
400
+ const promise = Promise.resolve().then(callback);
401
+ if (guards.length === 0) {
402
+ return await promise;
157
403
  }
158
- return await promise;
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
- if (error instanceof DatabaseError) {
162
- throw error;
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 || filter === null) {
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
- if (!sort || sort.length === 0) {
200
- return undefined;
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 sort.map((entry) => ({
203
- [entry.field]: entry.direction,
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
- * Creates pagination metadata.
209
- */
210
- function createPaginationMeta(page, limit, total) {
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
- * 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)));
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