@zudojs/database 0.0.1 → 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.
Files changed (134) hide show
  1. package/README.md +223 -17
  2. package/dist/cache/cache.memory.d.ts +167 -0
  3. package/dist/cache/cache.memory.js +186 -40
  4. package/dist/cache/index.d.ts +2 -2
  5. package/dist/cache/index.js +2 -2
  6. package/dist/database/database.core.d.ts +91 -0
  7. package/dist/database/database.core.js +150 -0
  8. package/dist/databaseClient/databaseClient.core.d.ts +165 -14
  9. package/dist/databaseClient/databaseClient.core.js +304 -70
  10. package/dist/databaseClient/databaseClient.errors.d.ts +112 -0
  11. package/dist/databaseClient/databaseClient.errors.js +425 -0
  12. package/dist/databaseClient/index.d.ts +2 -1
  13. package/dist/databaseClient/index.js +2 -1
  14. package/dist/databaseConnection/databaseConnection.manager.d.ts +163 -0
  15. package/dist/databaseConnection/databaseConnection.manager.js +138 -76
  16. package/dist/databaseConnection/index.d.ts +1 -1
  17. package/dist/databaseType/databaseType.type.d.ts +224 -0
  18. package/dist/databaseType/databaseType.type.js +10 -0
  19. package/dist/health/health.check.d.ts +74 -0
  20. package/dist/health/health.check.js +78 -47
  21. package/dist/health/index.d.ts +1 -1
  22. package/dist/health/index.js +1 -1
  23. package/dist/index.d.ts +14 -14
  24. package/dist/index.js +11 -11
  25. package/dist/locks/index.d.ts +2 -2
  26. package/dist/locks/index.js +2 -2
  27. package/dist/locks/locks.core.d.ts +128 -0
  28. package/dist/locks/locks.core.js +144 -85
  29. package/dist/migration/index.d.ts +3 -1
  30. package/dist/migration/index.js +2 -0
  31. package/dist/migration/migration.dialect.d.ts +65 -0
  32. package/dist/migration/migration.dialect.js +63 -0
  33. package/dist/migration/migration.helpers.d.ts +65 -0
  34. package/dist/migration/migration.helpers.js +141 -0
  35. package/dist/migration/migration.runner.d.ts +75 -0
  36. package/dist/migration/migration.runner.js +195 -107
  37. package/dist/migration/migration.types.d.ts +85 -0
  38. package/dist/migration/migration.types.js +2 -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 +279 -0
  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 +420 -0
  49. package/dist/queryBuilder/queryBuilder.factory.d.ts +6 -0
  50. package/dist/queryBuilder/queryBuilder.factory.js +8 -0
  51. package/dist/queryBuilder/queryBuilder.filter.d.ts +170 -0
  52. package/dist/queryBuilder/queryBuilder.filter.js +497 -0
  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/queryBuilder/queryBuilder.type.js +2 -0
  57. package/dist/relations/index.d.ts +1 -1
  58. package/dist/relations/index.js +1 -1
  59. package/dist/relations/relations.definition.d.ts +184 -0
  60. package/dist/relations/relations.definition.js +362 -0
  61. package/dist/repository/index.d.ts +2 -1
  62. package/dist/repository/index.js +1 -0
  63. package/dist/repository/repository.base.d.ts +270 -0
  64. package/dist/repository/repository.base.js +350 -84
  65. package/dist/repository/repository.errors.d.ts +60 -0
  66. package/dist/repository/repository.errors.js +235 -0
  67. package/dist/seed/index.d.ts +2 -2
  68. package/dist/seed/index.js +1 -1
  69. package/dist/seed/seed.runner.d.ts +160 -0
  70. package/dist/seed/seed.runner.js +194 -225
  71. package/dist/transaction/index.d.ts +1 -1
  72. package/dist/transaction/index.js +1 -1
  73. package/dist/transaction/transaction.core.d.ts +117 -0
  74. package/dist/transaction/transaction.core.js +83 -56
  75. package/dist/unitOfWork/unitOfWork.core.d.ts +44 -0
  76. package/dist/unitOfWork/unitOfWork.core.js +9 -20
  77. package/package.json +35 -19
  78. package/dist/cache/cache.memory.d.ts.map +0 -1
  79. package/dist/cache/cache.memory.js.map +0 -1
  80. package/dist/cache/index.d.ts.map +0 -1
  81. package/dist/cache/index.js.map +0 -1
  82. package/dist/database/database.core.d.ts.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/index.d.ts.map +0 -1
  96. package/dist/databaseType/index.js.map +0 -1
  97. package/dist/health/health.check.d.ts.map +0 -1
  98. package/dist/health/health.check.js.map +0 -1
  99. package/dist/health/index.d.ts.map +0 -1
  100. package/dist/health/index.js.map +0 -1
  101. package/dist/index.d.ts.map +0 -1
  102. package/dist/index.js.map +0 -1
  103. package/dist/locks/index.d.ts.map +0 -1
  104. package/dist/locks/index.js.map +0 -1
  105. package/dist/locks/locks.core.d.ts.map +0 -1
  106. package/dist/locks/locks.core.js.map +0 -1
  107. package/dist/migration/index.d.ts.map +0 -1
  108. package/dist/migration/index.js.map +0 -1
  109. package/dist/migration/migration.helpers.d.ts.map +0 -1
  110. package/dist/migration/migration.runner.d.ts.map +0 -1
  111. package/dist/migration/migration.runner.js.map +0 -1
  112. package/dist/migration/migration.types.d.ts.map +0 -1
  113. package/dist/pagination/index.d.ts.map +0 -1
  114. package/dist/pagination/index.js.map +0 -1
  115. package/dist/queryBuilder/index.d.ts.map +0 -1
  116. package/dist/queryBuilder/index.js.map +0 -1
  117. package/dist/relations/index.d.ts.map +0 -1
  118. package/dist/relations/index.js.map +0 -1
  119. package/dist/repository/index.d.ts.map +0 -1
  120. package/dist/repository/index.js.map +0 -1
  121. package/dist/repository/repository.base.d.ts.map +0 -1
  122. package/dist/repository/repository.base.js.map +0 -1
  123. package/dist/seed/index.d.ts.map +0 -1
  124. package/dist/seed/index.js.map +0 -1
  125. package/dist/seed/seed.runner.d.ts.map +0 -1
  126. package/dist/seed/seed.runner.js.map +0 -1
  127. package/dist/transaction/index.d.ts.map +0 -1
  128. package/dist/transaction/index.js.map +0 -1
  129. package/dist/transaction/transaction.core.d.ts.map +0 -1
  130. package/dist/transaction/transaction.core.js.map +0 -1
  131. package/dist/unitOfWork/index.d.ts.map +0 -1
  132. package/dist/unitOfWork/index.js.map +0 -1
  133. package/dist/unitOfWork/unitOfWork.core.d.ts.map +0 -1
  134. 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.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,13 +118,12 @@ 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;
60
124
  const [data, total] = await Promise.all([
61
- this.execute("findMany", () => this.delegate.findMany({
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
- id,
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 entity = await this.execute("exists", () => this.delegate.findFirst({
117
- where: filter,
322
+ const total = await this.execute("exists", () => this.delegate.count({
323
+ where: this.scope(filter),
118
324
  }), options);
119
- return entity !== null;
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
- if (typeof this.delegate.upsert !== "function") {
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.upsert({
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
- if (options?.signal?.aborted) {
150
- throw new DatabaseError(`${this.modelName} ${operation} was aborted.`);
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 = callback();
155
- if (options?.timeoutMs && options.timeoutMs > 0) {
156
- return await withTimeout(promise, options.timeoutMs, `${this.modelName} ${operation} timed out.`);
396
+ const promise = Promise.resolve().then(callback);
397
+ if (guards.length === 0) {
398
+ return await promise;
157
399
  }
158
- return await promise;
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
- 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
- },
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 || filter === null) {
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
- * 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
- };
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
- * 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);
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