@zudojs/database 1.2.1 → 1.3.1

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 (31) hide show
  1. package/README.md +31 -1
  2. package/dist/databaseClient/databaseClient.core.d.ts +16 -1
  3. package/dist/databaseClient/databaseClient.core.js +15 -2
  4. package/dist/databaseClient/databaseClient.errors.d.ts +12 -1
  5. package/dist/databaseClient/databaseClient.errors.js +14 -1
  6. package/dist/databaseClient/index.d.ts +1 -1
  7. package/dist/databaseClient/index.js +1 -1
  8. package/dist/databaseConnection/databaseConnection.manager.d.ts +7 -2
  9. package/dist/databaseConnection/databaseConnection.manager.js +42 -8
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.js +2 -2
  12. package/dist/pagination/index.d.ts +2 -0
  13. package/dist/pagination/index.js +2 -0
  14. package/dist/pagination/pagination.core.d.ts +4 -0
  15. package/dist/pagination/pagination.core.js +12 -9
  16. package/dist/pagination/pagination.cursorError.d.ts +17 -0
  17. package/dist/pagination/pagination.cursorError.js +17 -0
  18. package/dist/pagination/pagination.keyset.d.ts +25 -8
  19. package/dist/pagination/pagination.keyset.js +44 -16
  20. package/dist/pagination/pagination.keysetDirection.d.ts +29 -0
  21. package/dist/pagination/pagination.keysetDirection.js +30 -0
  22. package/dist/repository/index.d.ts +2 -1
  23. package/dist/repository/index.js +1 -0
  24. package/dist/repository/repository.base.d.ts +7 -53
  25. package/dist/repository/repository.base.js +13 -3
  26. package/dist/repository/repository.delegate.d.ts +93 -0
  27. package/dist/repository/repository.delegate.js +12 -0
  28. package/dist/transaction/transaction.core.d.ts +13 -3
  29. package/dist/transaction/transaction.core.js +16 -4
  30. package/dist/unitOfWork/unitOfWork.core.js +3 -1
  31. package/package.json +6 -6
package/README.md CHANGED
@@ -72,6 +72,11 @@ await withTransaction(client, async (tx) => {
72
72
  await client.disconnect();
73
73
  ```
74
74
 
75
+ A generated model delegate such as `prisma.user` is passed as-is, with no cast.
76
+ `RepositoryDelegate` accepts any Prisma-style delegate whose rows match the
77
+ entity type; `this.delegate` inside a subclass is typed with the arguments the
78
+ repository passes (`RepositoryDelegateOperations`).
79
+
75
80
  `createDatabaseClient` accepts either a pre-built `prisma` instance or an
76
81
  `adapter` (in which case it constructs the `PrismaClient` for you). It throws a
77
82
  `DatabaseError` if neither is supplied.
@@ -116,8 +121,22 @@ const next = await users.paginateCursor(undefined, {
116
121
  limit: 25,
117
122
  sort: [{ field: "createdAt", direction: "desc" }],
118
123
  });
124
+ // Page back: `previousCursor` is set on any non-empty page requested with a
125
+ // cursor. The rows come back in the requested sort order.
126
+ const back = await users.paginateCursor(undefined, {
127
+ cursor: next.meta.previousCursor,
128
+ limit: 25,
129
+ sort: [{ field: "createdAt", direction: "desc" }],
130
+ });
119
131
  ```
120
132
 
133
+ A missing, forged, tampered or malformed cursor rejects with a
134
+ `ValidationError` (400, exposable, `issues[0].code` such as
135
+ `"cursor_signature"`) before any query runs. The message never echoes the
136
+ cursor or its contents. Building keyset pages by hand? Fetch with
137
+ `keysetFetchSort(sort, getKeysetDirection(payload))` and pass that
138
+ `direction` to `createKeysetPage`.
139
+
121
140
  ## Migrations and seeds
122
141
 
123
142
  ```typescript
@@ -163,7 +182,14 @@ to size it for long-running steps.
163
182
 
164
183
  ## Errors
165
184
 
166
- Every failure surfaces as a `DatabaseError` from `@zudojs/errors`. Prisma codes
185
+ Errors your own transaction callback throws that are already `@zudojs/errors`
186
+ errors but not database errors (a `NotFoundError`, `DomainError`,
187
+ `ValidationError`, ...) roll the transaction back and propagate **unchanged**
188
+ from `transaction()`, `withTransaction()`, `TransactionManager` and units of
189
+ work: same instance, same status, and not logged as a database error.
190
+ Everything else, driver and database failures included, is normalised as below.
191
+
192
+ Every database failure surfaces as a `DatabaseError` from `@zudojs/errors`. Prisma codes
167
193
  are mapped to `databaseCode`, an `ErrorCode` and an HTTP status (`P2002` /
168
194
  `P2003` → 409, `P2025` → 404, `P2034` → retryable, `P1xxx` → 503 with a fixed
169
195
  message that never includes the host name).
@@ -209,6 +235,10 @@ await locks.withRowLock("orders", orderId, async (tx) => {
209
235
  await tx.$executeRawUnsafe('UPDATE "orders" SET "status" = $1 WHERE "id" = $2', "paid", orderId);
210
236
  }, { mode: "for-no-key-update", skipLocked: true });
211
237
 
238
+ // The connection manager reconnects with exponential back-off after failed
239
+ // health checks. The back-off wait keeps the process alive until the reconnect
240
+ // finishes; `disconnect()` / `destroy()` cancel it immediately.
241
+
212
242
  const health = await checkDatabaseHealth(client, { timeoutMs: 2_000 });
213
243
  health.status; // "healthy" | "degraded" | "unhealthy"
214
244
  await assertDatabaseHealth(client); // throws DatabaseUnhealthyError with the real cause
@@ -41,7 +41,16 @@ export interface PrismaTransactionOptions {
41
41
  export interface PrismaClientLike {
42
42
  $connect(): Promise<void>;
43
43
  $disconnect(): Promise<void>;
44
- $transaction<TResult>(callback: (transaction: DatabaseTransactionContext) => Promise<TResult>, options?: PrismaTransactionOptions): Promise<TResult>;
44
+ /**
45
+ * Declared loosely on purpose. Prisma 7 generates the client into the
46
+ * application (`prisma-client` generator), and its overloaded
47
+ * `$transaction` (batch array or interactive callback) cannot be assigned
48
+ * to a single generic signature, so a real client failed to type-check
49
+ * against this interface and needed a cast. Any `$transaction` is
50
+ * accepted here; `DatabaseClient` only ever calls the interactive form
51
+ * (see `InteractiveTransaction`).
52
+ */
53
+ $transaction(...args: never[]): Promise<unknown>;
45
54
  $queryRawUnsafe<TResult = unknown>(query: string, ...values: unknown[]): Promise<TResult>;
46
55
  $executeRawUnsafe(query: string, ...values: unknown[]): Promise<number>;
47
56
  $on?(event: "query", callback: (event: PrismaQueryEvent) => void): void;
@@ -135,6 +144,12 @@ export declare class DatabaseClient implements DatabaseClientContract<DatabaseTr
135
144
  * is released at the same moment. Racing only the outer promise let the
136
145
  * callback finish and the transaction commit after the caller had
137
146
  * already been told it was aborted.
147
+ *
148
+ * A `@zudojs/errors` `BaseError` thrown by the callback that is not a
149
+ * `DatabaseError` (a `NotFoundError`, `DomainError`, `ValidationError`,
150
+ * ...) rolls the transaction back and is rethrown unchanged, without
151
+ * being logged as a database failure. Driver and database failures, and
152
+ * any other thrown value, are normalised to a `DatabaseError` and logged.
138
153
  */
139
154
  transaction<TResult>(callback: TransactionCallback<DatabaseTransactionContext, TResult>, options?: TransactionOptions): Promise<TResult>;
140
155
  /**
@@ -14,7 +14,7 @@
14
14
  import { createRequire } from "node:module";
15
15
  import { DatabaseError, DatabaseOperation } from "@zudojs/errors";
16
16
  import { createDefaultLogger } from "./databaseClient.logger.js";
17
- import { normalizeDatabaseError } from "./databaseClient.errors.js";
17
+ import { isNonDatabaseBaseError, normalizeDatabaseError, } from "./databaseClient.errors.js";
18
18
  /**
19
19
  * Isolation levels accepted by Prisma's interactive transactions. The
20
20
  * values are identical to the string names, so no namespace lookup is
@@ -180,6 +180,12 @@ export class DatabaseClient {
180
180
  * is released at the same moment. Racing only the outer promise let the
181
181
  * callback finish and the transaction commit after the caller had
182
182
  * already been told it was aborted.
183
+ *
184
+ * A `@zudojs/errors` `BaseError` thrown by the callback that is not a
185
+ * `DatabaseError` (a `NotFoundError`, `DomainError`, `ValidationError`,
186
+ * ...) rolls the transaction back and is rethrown unchanged, without
187
+ * being logged as a database failure. Driver and database failures, and
188
+ * any other thrown value, are normalised to a `DatabaseError` and logged.
183
189
  */
184
190
  async transaction(callback, options = {}) {
185
191
  if (typeof callback !== "function") {
@@ -188,10 +194,17 @@ export class DatabaseClient {
188
194
  throwIfAborted(options.signal);
189
195
  await this.ensureConnected();
190
196
  const transactionOptions = buildPrismaTransactionOptions(options);
197
+ const interactive = this.prisma.$transaction.bind(this.prisma);
191
198
  try {
192
- return await raceAbort(this.prisma.$transaction(async (transaction) => raceAbort(callback(transaction), options.signal), transactionOptions), options.signal);
199
+ return await raceAbort(interactive(async (transaction) => raceAbort(callback(transaction), options.signal), transactionOptions), options.signal);
193
200
  }
194
201
  catch (error) {
202
+ if (isNonDatabaseBaseError(error)) {
203
+ this.logger.debug("Database transaction rolled back by caller error.", {
204
+ code: error.code,
205
+ });
206
+ throw error;
207
+ }
195
208
  const normalized = normalizeDatabaseError(error, {
196
209
  operation: DatabaseOperation.TRANSACTION,
197
210
  fallbackMessage: "Database transaction failed.",
@@ -11,7 +11,7 @@
11
11
  * `clientVersion`), so the mapping works regardless of which copy of
12
12
  * `@prisma/client` produced the error.
13
13
  */
14
- import { DatabaseError, DatabaseOperation, type DatabaseErrorOptions } from "@zudojs/errors";
14
+ import { DatabaseError, DatabaseOperation, type BaseError, type DatabaseErrorOptions } from "@zudojs/errors";
15
15
  import type { DatabaseErrorInfo } from "../databaseType/databaseType.type.js";
16
16
  /**
17
17
  * Structural `DatabaseError` guard.
@@ -23,6 +23,17 @@ import type { DatabaseErrorInfo } from "../databaseType/databaseType.type.js";
23
23
  * (`category: "database"` plus a string `code`).
24
24
  */
25
25
  export declare function isDatabaseErrorLike(value: unknown): value is DatabaseError;
26
+ /**
27
+ * Determines whether an error is a `@zudojs/errors` `BaseError` that is
28
+ * *not* a database failure: a domain, application, validation, not-found
29
+ * or other error raised by caller code.
30
+ *
31
+ * Transaction helpers (`DatabaseClient.transaction`, `withTransaction`,
32
+ * `TransactionManager`, units of work) roll back on such an error and
33
+ * rethrow the same instance unchanged, instead of wrapping it in a 500
34
+ * `DatabaseError` and logging it as a database failure.
35
+ */
36
+ export declare function isNonDatabaseBaseError(value: unknown): value is BaseError;
26
37
  /**
27
38
  * Semantic outcome of a database failure.
28
39
  */
@@ -11,7 +11,7 @@
11
11
  * `clientVersion`), so the mapping works regardless of which copy of
12
12
  * `@prisma/client` produced the error.
13
13
  */
14
- import { DatabaseError, DatabaseOperation, ErrorCategory, isDatabaseError, ErrorCode, } from "@zudojs/errors";
14
+ import { DatabaseError, DatabaseOperation, ErrorCategory, isBaseError, isDatabaseError, ErrorCode, } from "@zudojs/errors";
15
15
  /**
16
16
  * Structural `DatabaseError` guard.
17
17
  *
@@ -31,6 +31,19 @@ export function isDatabaseErrorLike(value) {
31
31
  typeof candidate.code === "string" &&
32
32
  typeof candidate.statusCode === "number");
33
33
  }
34
+ /**
35
+ * Determines whether an error is a `@zudojs/errors` `BaseError` that is
36
+ * *not* a database failure: a domain, application, validation, not-found
37
+ * or other error raised by caller code.
38
+ *
39
+ * Transaction helpers (`DatabaseClient.transaction`, `withTransaction`,
40
+ * `TransactionManager`, units of work) roll back on such an error and
41
+ * rethrow the same instance unchanged, instead of wrapping it in a 500
42
+ * `DatabaseError` and logging it as a database failure.
43
+ */
44
+ export function isNonDatabaseBaseError(value) {
45
+ return isBaseError(value) && !isDatabaseErrorLike(value);
46
+ }
34
47
  const CONNECTION_MESSAGE = "Database connection failed.";
35
48
  const PRISMA_CODE_MAP = Object.freeze({
36
49
  // Query engine errors
@@ -4,5 +4,5 @@
4
4
  * Prisma-backed database client and connection infrastructure.
5
5
  */
6
6
  export { DatabaseClient, DatabaseAbortError, createDatabaseClient, buildPrismaTransactionOptions, createAbortError, raceAbort, throwIfAborted, SUPPORTED_ISOLATION_LEVELS, type DatabaseClientOptions, type DatabaseTransactionContext, type PrismaClientLike, type PrismaDriverAdapterLike, type PrismaQueryEvent, type PrismaTransactionOptions, type RawQueryOptions, } from "./databaseClient.core.js";
7
- export { normalizeDatabaseError, withDatabaseErrorMetadata, isPrismaError, isRetryableTransactionError, isConflictError, isNotFoundError, getDatabaseErrorCode, getDatabaseErrorKind, isDatabaseErrorLike, toDatabaseErrorInfo, RETRYABLE_DATABASE_CODES, type DatabaseErrorKind, type NormalizeDatabaseErrorOptions, type PrismaErrorLike, } from "./databaseClient.errors.js";
7
+ export { normalizeDatabaseError, withDatabaseErrorMetadata, isPrismaError, isRetryableTransactionError, isConflictError, isNotFoundError, getDatabaseErrorCode, getDatabaseErrorKind, isDatabaseErrorLike, isNonDatabaseBaseError, toDatabaseErrorInfo, RETRYABLE_DATABASE_CODES, type DatabaseErrorKind, type NormalizeDatabaseErrorOptions, type PrismaErrorLike, } from "./databaseClient.errors.js";
8
8
  //# sourceMappingURL=index.d.ts.map
@@ -4,5 +4,5 @@
4
4
  * Prisma-backed database client and connection infrastructure.
5
5
  */
6
6
  export { DatabaseClient, DatabaseAbortError, createDatabaseClient, buildPrismaTransactionOptions, createAbortError, raceAbort, throwIfAborted, SUPPORTED_ISOLATION_LEVELS, } from "./databaseClient.core.js";
7
- export { normalizeDatabaseError, withDatabaseErrorMetadata, isPrismaError, isRetryableTransactionError, isConflictError, isNotFoundError, getDatabaseErrorCode, getDatabaseErrorKind, isDatabaseErrorLike, toDatabaseErrorInfo, RETRYABLE_DATABASE_CODES, } from "./databaseClient.errors.js";
7
+ export { normalizeDatabaseError, withDatabaseErrorMetadata, isPrismaError, isRetryableTransactionError, isConflictError, isNotFoundError, getDatabaseErrorCode, getDatabaseErrorKind, isDatabaseErrorLike, isNonDatabaseBaseError, toDatabaseErrorInfo, RETRYABLE_DATABASE_CODES, } from "./databaseClient.errors.js";
8
8
  //# sourceMappingURL=index.js.map
@@ -33,7 +33,9 @@ export interface DatabaseReconnectOptions {
33
33
  readonly maxAttempts?: number;
34
34
  /**
35
35
  * Base delay between reconnect attempts; doubles each attempt up to
36
- * `maxDelayMs`. Defaults to 500 ms.
36
+ * `maxDelayMs`. Defaults to 500 ms. The wait keeps the process alive
37
+ * (a reconnect is never abandoned because the event loop drained) and
38
+ * is cancelled by `disconnect()` / `destroy()`.
37
39
  */
38
40
  readonly baseDelayMs?: number;
39
41
  /**
@@ -84,6 +86,7 @@ export declare class DatabaseConnectionManager {
84
86
  private healthCheckInFlight?;
85
87
  private consecutiveFailures;
86
88
  private reconnectPromise?;
89
+ private reconnectAbort?;
87
90
  private lastHealth?;
88
91
  private destroyed;
89
92
  constructor(options?: DatabaseConnectionManagerOptions);
@@ -97,7 +100,8 @@ export declare class DatabaseConnectionManager {
97
100
  */
98
101
  connect(): Promise<void>;
99
102
  /**
100
- * Closes the database connection.
103
+ * Closes the database connection. Cancels an in-progress reconnect,
104
+ * including its backoff wait, so no further attempt is made.
101
105
  */
102
106
  disconnect(): Promise<void>;
103
107
  /**
@@ -153,6 +157,7 @@ export declare class DatabaseConnectionManager {
153
157
  destroy(): Promise<void>;
154
158
  private performScheduledHealthCheck;
155
159
  private reconnectWithBackoff;
160
+ private cancelReconnect;
156
161
  private performReconnect;
157
162
  private emit;
158
163
  }
@@ -20,6 +20,7 @@ export class DatabaseConnectionManager {
20
20
  healthCheckInFlight;
21
21
  consecutiveFailures = 0;
22
22
  reconnectPromise;
23
+ reconnectAbort;
23
24
  lastHealth;
24
25
  destroyed = false;
25
26
  constructor(options = {}) {
@@ -78,10 +79,12 @@ export class DatabaseConnectionManager {
78
79
  }
79
80
  }
80
81
  /**
81
- * Closes the database connection.
82
+ * Closes the database connection. Cancels an in-progress reconnect,
83
+ * including its backoff wait, so no further attempt is made.
82
84
  */
83
85
  async disconnect() {
84
86
  this.stopHealthChecks();
87
+ this.cancelReconnect();
85
88
  const status = this.client.getStatus();
86
89
  if (status === "disconnected" || status === "disconnecting")
87
90
  return;
@@ -219,33 +222,47 @@ export class DatabaseConnectionManager {
219
222
  reconnectWithBackoff() {
220
223
  if (this.reconnectPromise)
221
224
  return this.reconnectPromise;
222
- this.reconnectPromise = this.performReconnect().finally(() => {
225
+ const abort = new AbortController();
226
+ this.reconnectAbort = abort;
227
+ this.reconnectPromise = this.performReconnect(abort.signal).finally(() => {
223
228
  this.reconnectPromise = undefined;
229
+ if (this.reconnectAbort === abort)
230
+ this.reconnectAbort = undefined;
224
231
  });
225
232
  return this.reconnectPromise;
226
233
  }
227
- async performReconnect() {
234
+ cancelReconnect() {
235
+ this.reconnectAbort?.abort();
236
+ this.reconnectAbort = undefined;
237
+ }
238
+ async performReconnect(signal) {
228
239
  const policy = this.reconnect;
229
240
  if (!policy)
230
241
  return;
231
242
  for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
232
- if (this.destroyed)
243
+ if (this.destroyed || signal.aborted)
233
244
  return;
234
245
  this.emit("reconnecting", undefined, attempt);
235
246
  try {
236
247
  await this.client.disconnect().catch(() => undefined);
248
+ if (signal.aborted)
249
+ return;
237
250
  await this.client.connect();
251
+ if (signal.aborted)
252
+ return;
238
253
  this.consecutiveFailures = 0;
239
254
  this.emit("connected", undefined, attempt);
240
255
  return;
241
256
  }
242
257
  catch (error) {
258
+ if (signal.aborted)
259
+ return;
243
260
  this.emit("error", error, attempt);
244
261
  if (attempt === policy.maxAttempts)
245
262
  return;
246
263
  const delay = Math.min(policy.maxDelayMs, policy.baseDelayMs * Math.pow(2, attempt - 1));
247
264
  if (delay > 0)
248
- await sleep(delay);
265
+ await sleep(delay, signal);
249
266
  }
250
267
  }
251
268
  }
@@ -266,10 +283,27 @@ export class DatabaseConnectionManager {
266
283
  }
267
284
  }
268
285
  }
269
- function sleep(milliseconds) {
286
+ /**
287
+ * Backoff wait between reconnect attempts. The timer is deliberately *not*
288
+ * unref'd: a script whose only pending work is a reconnect must stay alive
289
+ * until the reconnect finishes. Aborting `signal` (from `disconnect()` or
290
+ * `destroy()`) clears the timer and resolves at once.
291
+ */
292
+ function sleep(milliseconds, signal) {
270
293
  return new Promise((resolve) => {
271
- const timer = setTimeout(resolve, milliseconds);
272
- timer.unref?.();
294
+ if (signal.aborted) {
295
+ resolve();
296
+ return;
297
+ }
298
+ const onAbort = () => {
299
+ clearTimeout(timer);
300
+ resolve();
301
+ };
302
+ const timer = setTimeout(() => {
303
+ signal.removeEventListener("abort", onAbort);
304
+ resolve();
305
+ }, milliseconds);
306
+ signal.addEventListener("abort", onAbort, { once: true });
273
307
  });
274
308
  }
275
309
  /**
package/dist/index.d.ts CHANGED
@@ -18,15 +18,15 @@
18
18
  */
19
19
  export type { DatabaseOperationOptions, DatabaseStatus, TransactionIsolationLevel, DatabaseOperation, DatabaseConnectionOptions, DatabaseClientHealth, DatabaseHealth as DatabaseHealthInfo, TransactionOptions, TransactionCallback, Repository, SoftDeletableRepository, PaginationInput, PaginationMeta, PaginatedResult, SortDirection, SortInput, QueryOptions, DatabaseEntity, SoftDeletableEntity, AuditableEntity, DatabaseErrorInfo, DatabaseLogger, } from "./databaseType/index.js";
20
20
  export { noopDatabaseLogger } from "./databaseType/index.js";
21
- export { DatabaseClient, DatabaseAbortError, createDatabaseClient, buildPrismaTransactionOptions, createAbortError, raceAbort, throwIfAborted, SUPPORTED_ISOLATION_LEVELS, normalizeDatabaseError, withDatabaseErrorMetadata, isPrismaError, isRetryableTransactionError, isConflictError, isNotFoundError, getDatabaseErrorCode, getDatabaseErrorKind, isDatabaseErrorLike, toDatabaseErrorInfo, RETRYABLE_DATABASE_CODES, type DatabaseClientOptions, type DatabaseTransactionContext, type PrismaClientLike, type PrismaDriverAdapterLike, type PrismaQueryEvent, type PrismaTransactionOptions, type RawQueryOptions, type DatabaseErrorKind, type NormalizeDatabaseErrorOptions, type PrismaErrorLike, } from "./databaseClient/index.js";
21
+ export { DatabaseClient, DatabaseAbortError, createDatabaseClient, buildPrismaTransactionOptions, createAbortError, raceAbort, throwIfAborted, SUPPORTED_ISOLATION_LEVELS, normalizeDatabaseError, withDatabaseErrorMetadata, isPrismaError, isRetryableTransactionError, isConflictError, isNotFoundError, getDatabaseErrorCode, getDatabaseErrorKind, isDatabaseErrorLike, isNonDatabaseBaseError, toDatabaseErrorInfo, RETRYABLE_DATABASE_CODES, type DatabaseClientOptions, type DatabaseTransactionContext, type PrismaClientLike, type PrismaDriverAdapterLike, type PrismaQueryEvent, type PrismaTransactionOptions, type RawQueryOptions, type DatabaseErrorKind, type NormalizeDatabaseErrorOptions, type PrismaErrorLike, } from "./databaseClient/index.js";
22
22
  export { DatabaseConnectionManager, createConnectionManager, type DatabaseConnectionEvent, type DatabaseConnectionListener, type DatabaseConnectionEventDetails, type DatabaseConnectionManagerOptions, type DatabaseReconnectOptions, } from "./databaseConnection/index.js";
23
23
  export { Database, createDatabase, getDatabase, connectDatabase, disconnectDatabase, resetDatabase, } from "./database/index.js";
24
- export { BaseRepository, mapRepositoryError, isPrismaErrorLike, toDatabaseOperation, type RepositoryDelegate, type BaseRepositoryOptions, type SoftDeleteOptions, type CursorQueryOptions, type TransactionClientLike, type RepositoryOperation, type RepositoryErrorContext, } from "./repository/index.js";
24
+ export { BaseRepository, mapRepositoryError, isPrismaErrorLike, toDatabaseOperation, type RepositoryDelegate, type RepositoryDelegateOperations, type BaseRepositoryOptions, type SoftDeleteOptions, type CursorQueryOptions, type TransactionClientLike, type RepositoryOperation, type RepositoryErrorContext, } from "./repository/index.js";
25
25
  export { TransactionManager, createTransactionManager, withTransaction, withTransactionRetry, createTransactionContext, createTransactionId, getTransactionContextFromError, isTransactionActive, isTransactionCommitted, isTransactionFailed, type TransactionStatus, type TransactionContext, type TransactionOutcome, type TransactionRetryOptions, type ManagedTransactionOptions, } from "./transaction/index.js";
26
26
  export { DatabaseUnitOfWork, createUnitOfWork, executeUnitOfWork, type UnitOfWork, type UnitOfWorkOptions, } from "./unitOfWork/index.js";
27
27
  export { QueryBuilder, createQueryBuilder, toPrismaWhere, toPrismaArgs, toPrismaOrderBy, toPrismaSelect, toPrismaSkipTake, type QueryCondition, type QueryFilter, type QueryOperator, type RelationOperator, type QueryBuilderState, type PrismaWhere, type PrismaQueryArgs, type ToPrismaArgsOptions, } from "./queryBuilder/index.js";
28
28
  export { equals, notEquals, inList, notInList, lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual, contains, startsWith, endsWith, isNull, isNotNull, and, or, not, condition, allOf, anyOf, fromObject, dateRange, oneOf, noneOf, optionalEquals, optionalContains, hasConditions, flattenAnd, cloneFilter, between, matchesPattern, isEmpty, isNotEmpty, dateOnly, isBefore, isAfter, isBetween, notCondition, relational, } from "./queryBuilder/index.js";
29
- export { normalizePagination, normalizePage, normalizeLimit, calculateOffset, calculateTotalPages, createPaginationMeta, createPaginatedResult, getNextPage, getPreviousPage, isValidPage, getItemRange, paginateCollection, encodeCursor, decodeCursor, validateCursorPayload, decodeKeysetCursor, buildKeysetWhere, createKeysetCursor, createKeysetPage, normalizeCursorPagination, createCursorPaginationMeta, createCursorPaginatedResult, DEFAULT_PAGE, DEFAULT_LIMIT, MAX_LIMIT, type NormalizedPagination, type CursorPaginationInput, type CursorPaginationMeta, type CursorPaginatedResult, type CursorPayload, type EncodeCursorOptions, type DecodeCursorOptions, type KeysetPageOptions, type KeysetWhere, } from "./pagination/index.js";
29
+ export { normalizePagination, normalizePage, normalizeLimit, calculateOffset, calculateTotalPages, createPaginationMeta, createPaginatedResult, getNextPage, getPreviousPage, isValidPage, getItemRange, paginateCollection, encodeCursor, decodeCursor, validateCursorPayload, decodeKeysetCursor, buildKeysetWhere, createKeysetCursor, createKeysetPage, KEYSET_BACKWARD_KEY, getKeysetDirection, reverseKeysetSort, keysetFetchSort, createInvalidCursorError, normalizeCursorPagination, createCursorPaginationMeta, createCursorPaginatedResult, DEFAULT_PAGE, DEFAULT_LIMIT, MAX_LIMIT, type NormalizedPagination, type CursorPaginationInput, type CursorPaginationMeta, type CursorPaginatedResult, type CursorPayload, type EncodeCursorOptions, type DecodeCursorOptions, type KeysetPageOptions, type KeysetWhere, type KeysetDirection, type InvalidCursorReason, } from "./pagination/index.js";
30
30
  export { oneToOne, oneToMany, manyToOne, manyToMany, includeRelation, includeRelations, RelationRegistry, createRelationRegistry, validateRelation, validateInclude, toPrismaInclude, DEFAULT_INCLUDE_DEPTH, isRelationType, isCollectionRelation, isSingleRelation, type RelationDefinition, type RelationType, type RelationLoadOptions, type RelationInclude, type ToPrismaIncludeOptions, } from "./relations/index.js";
31
31
  export { DatabaseLockManager, createLockManager, acquireAdvisoryLock, lockRow, buildLockClause, normalizeAdvisoryKey, normalizeAdvisoryKeyPair, resolveLockTransactionOptions, type DatabaseLockMode, type DatabaseLockOptions, type DatabaseLockResult, } from "./locks/index.js";
32
32
  export { MemoryDatabaseCache, createDatabaseCache, createCacheKey, escapeCachePart, serializeCachePart, getOrSet, invalidateByPrefix, CACHE_KEY_SEPARATOR, type CacheEntry, type CacheOptions, type MemoryCacheOptions, type CacheStats, type DatabaseCache, } from "./cache/index.js";
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
  export { noopDatabaseLogger } from "./databaseType/index.js";
20
20
  // Client
21
- export { DatabaseClient, DatabaseAbortError, createDatabaseClient, buildPrismaTransactionOptions, createAbortError, raceAbort, throwIfAborted, SUPPORTED_ISOLATION_LEVELS, normalizeDatabaseError, withDatabaseErrorMetadata, isPrismaError, isRetryableTransactionError, isConflictError, isNotFoundError, getDatabaseErrorCode, getDatabaseErrorKind, isDatabaseErrorLike, toDatabaseErrorInfo, RETRYABLE_DATABASE_CODES, } from "./databaseClient/index.js";
21
+ export { DatabaseClient, DatabaseAbortError, createDatabaseClient, buildPrismaTransactionOptions, createAbortError, raceAbort, throwIfAborted, SUPPORTED_ISOLATION_LEVELS, normalizeDatabaseError, withDatabaseErrorMetadata, isPrismaError, isRetryableTransactionError, isConflictError, isNotFoundError, getDatabaseErrorCode, getDatabaseErrorKind, isDatabaseErrorLike, isNonDatabaseBaseError, toDatabaseErrorInfo, RETRYABLE_DATABASE_CODES, } from "./databaseClient/index.js";
22
22
  // Connection
23
23
  export { DatabaseConnectionManager, createConnectionManager, } from "./databaseConnection/index.js";
24
24
  // Database facade
@@ -33,7 +33,7 @@ export { DatabaseUnitOfWork, createUnitOfWork, executeUnitOfWork, } from "./unit
33
33
  export { QueryBuilder, createQueryBuilder, toPrismaWhere, toPrismaArgs, toPrismaOrderBy, toPrismaSelect, toPrismaSkipTake, } from "./queryBuilder/index.js";
34
34
  export { equals, notEquals, inList, notInList, lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual, contains, startsWith, endsWith, isNull, isNotNull, and, or, not, condition, allOf, anyOf, fromObject, dateRange, oneOf, noneOf, optionalEquals, optionalContains, hasConditions, flattenAnd, cloneFilter, between, matchesPattern, isEmpty, isNotEmpty, dateOnly, isBefore, isAfter, isBetween, notCondition, relational, } from "./queryBuilder/index.js";
35
35
  // Pagination
36
- export { normalizePagination, normalizePage, normalizeLimit, calculateOffset, calculateTotalPages, createPaginationMeta, createPaginatedResult, getNextPage, getPreviousPage, isValidPage, getItemRange, paginateCollection, encodeCursor, decodeCursor, validateCursorPayload, decodeKeysetCursor, buildKeysetWhere, createKeysetCursor, createKeysetPage, normalizeCursorPagination, createCursorPaginationMeta, createCursorPaginatedResult, DEFAULT_PAGE, DEFAULT_LIMIT, MAX_LIMIT, } from "./pagination/index.js";
36
+ export { normalizePagination, normalizePage, normalizeLimit, calculateOffset, calculateTotalPages, createPaginationMeta, createPaginatedResult, getNextPage, getPreviousPage, isValidPage, getItemRange, paginateCollection, encodeCursor, decodeCursor, validateCursorPayload, decodeKeysetCursor, buildKeysetWhere, createKeysetCursor, createKeysetPage, KEYSET_BACKWARD_KEY, getKeysetDirection, reverseKeysetSort, keysetFetchSort, createInvalidCursorError, normalizeCursorPagination, createCursorPaginationMeta, createCursorPaginatedResult, DEFAULT_PAGE, DEFAULT_LIMIT, MAX_LIMIT, } from "./pagination/index.js";
37
37
  // Relations
38
38
  export { oneToOne, oneToMany, manyToOne, manyToMany, includeRelation, includeRelations, RelationRegistry, createRelationRegistry, validateRelation, validateInclude, toPrismaInclude, DEFAULT_INCLUDE_DEPTH, isRelationType, isCollectionRelation, isSingleRelation, } from "./relations/index.js";
39
39
  // Locks
@@ -5,4 +5,6 @@
5
5
  */
6
6
  export { normalizePagination, normalizePage, normalizeLimit, calculateOffset, calculateTotalPages, createPaginationMeta, createPaginatedResult, getNextPage, getPreviousPage, isValidPage, getItemRange, paginateCollection, encodeCursor, decodeCursor, validateCursorPayload, normalizeCursorPagination, createCursorPaginationMeta, createCursorPaginatedResult, DEFAULT_PAGE, DEFAULT_LIMIT, MAX_LIMIT, type NormalizedPagination, type CursorPaginationInput, type CursorPaginationMeta, type CursorPaginatedResult, type CursorPayload, type EncodeCursorOptions, type DecodeCursorOptions, } from "./pagination.core.js";
7
7
  export { decodeKeysetCursor, buildKeysetWhere, createKeysetCursor, createKeysetPage, type KeysetPageOptions, type KeysetWhere, } from "./pagination.keyset.js";
8
+ export { KEYSET_BACKWARD_KEY, getKeysetDirection, reverseKeysetSort, keysetFetchSort, type KeysetDirection, } from "./pagination.keysetDirection.js";
9
+ export { createInvalidCursorError, type InvalidCursorReason, } from "./pagination.cursorError.js";
8
10
  //# sourceMappingURL=index.d.ts.map
@@ -5,4 +5,6 @@
5
5
  */
6
6
  export { normalizePagination, normalizePage, normalizeLimit, calculateOffset, calculateTotalPages, createPaginationMeta, createPaginatedResult, getNextPage, getPreviousPage, isValidPage, getItemRange, paginateCollection, encodeCursor, decodeCursor, validateCursorPayload, normalizeCursorPagination, createCursorPaginationMeta, createCursorPaginatedResult, DEFAULT_PAGE, DEFAULT_LIMIT, MAX_LIMIT, } from "./pagination.core.js";
7
7
  export { decodeKeysetCursor, buildKeysetWhere, createKeysetCursor, createKeysetPage, } from "./pagination.keyset.js";
8
+ export { KEYSET_BACKWARD_KEY, getKeysetDirection, reverseKeysetSort, keysetFetchSort, } from "./pagination.keysetDirection.js";
9
+ export { createInvalidCursorError, } from "./pagination.cursorError.js";
8
10
  //# sourceMappingURL=index.js.map
@@ -153,6 +153,10 @@ export declare function encodeCursor(value: unknown, options?: EncodeCursorOptio
153
153
  * When `secret` is supplied the signature is verified; when
154
154
  * `allowedFields` is supplied the payload must be a flat object whose keys
155
155
  * are all allowed and whose values are primitives.
156
+ *
157
+ * A missing, forged, tampered or malformed cursor throws a
158
+ * `ValidationError` (HTTP 400) whose message never echoes the cursor. An
159
+ * invalid `secret` is a programming error and still throws `TypeError`.
156
160
  */
157
161
  export declare function decodeCursor<T = unknown>(cursor: string, options?: DecodeCursorOptions): T;
158
162
  /**
@@ -1,4 +1,5 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { createInvalidCursorError } from "./pagination.cursorError.js";
2
3
  /**
3
4
  * Default pagination values.
4
5
  */
@@ -213,24 +214,28 @@ export function encodeCursor(value, options = {}) {
213
214
  * When `secret` is supplied the signature is verified; when
214
215
  * `allowedFields` is supplied the payload must be a flat object whose keys
215
216
  * are all allowed and whose values are primitives.
217
+ *
218
+ * A missing, forged, tampered or malformed cursor throws a
219
+ * `ValidationError` (HTTP 400) whose message never echoes the cursor. An
220
+ * invalid `secret` is a programming error and still throws `TypeError`.
216
221
  */
217
222
  export function decodeCursor(cursor, options = {}) {
218
223
  if (typeof cursor !== "string" || cursor.trim().length === 0) {
219
- throw new TypeError("A cursor value is required.");
224
+ throw createInvalidCursorError("A cursor value is required.", "cursor_required");
220
225
  }
221
226
  let payload = cursor;
222
227
  if (options.secret !== undefined) {
223
228
  validateSecret(options.secret);
224
229
  const separator = cursor.lastIndexOf(CURSOR_SIGNATURE_SEPARATOR);
225
230
  if (separator <= 0) {
226
- throw new TypeError("Invalid pagination cursor signature.");
231
+ throw createInvalidCursorError("Invalid pagination cursor signature.", "cursor_signature");
227
232
  }
228
233
  payload = cursor.slice(0, separator);
229
234
  const signature = cursor.slice(separator + 1);
230
235
  const expected = signCursor(payload, options.secret);
231
236
  if (signature.length !== expected.length ||
232
237
  !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
233
- throw new TypeError("Invalid pagination cursor signature.");
238
+ throw createInvalidCursorError("Invalid pagination cursor signature.", "cursor_signature");
234
239
  }
235
240
  }
236
241
  let decoded;
@@ -238,9 +243,7 @@ export function decodeCursor(cursor, options = {}) {
238
243
  decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
239
244
  }
240
245
  catch (error) {
241
- throw new TypeError("Invalid pagination cursor.", {
242
- cause: error,
243
- });
246
+ throw createInvalidCursorError("Invalid pagination cursor.", "cursor_malformed", error);
244
247
  }
245
248
  if (options.allowedFields !== undefined) {
246
249
  validateCursorPayload(decoded, options.allowedFields);
@@ -253,18 +256,18 @@ export function decodeCursor(cursor, options = {}) {
253
256
  */
254
257
  export function validateCursorPayload(value, allowedFields) {
255
258
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
256
- throw new TypeError("Pagination cursor payload must be an object.");
259
+ throw createInvalidCursorError("Pagination cursor payload must be an object.", "cursor_payload");
257
260
  }
258
261
  const allowed = new Set(allowedFields);
259
262
  for (const [key, entry] of Object.entries(value)) {
260
263
  if (FORBIDDEN_CURSOR_KEYS.has(key) || !allowed.has(key)) {
261
- throw new TypeError(`Pagination cursor contains an unexpected field "${key}".`);
264
+ throw createInvalidCursorError("Pagination cursor contains an unexpected field.", "cursor_field");
262
265
  }
263
266
  if (entry !== null &&
264
267
  typeof entry !== "string" &&
265
268
  typeof entry !== "number" &&
266
269
  typeof entry !== "boolean") {
267
- throw new TypeError(`Pagination cursor field "${key}" must be a primitive value.`);
270
+ throw createInvalidCursorError("Pagination cursor fields must be primitive values.", "cursor_payload");
268
271
  }
269
272
  }
270
273
  }
@@ -0,0 +1,17 @@
1
+ import { ValidationError } from "@zudojs/errors";
2
+ /**
3
+ * Why a client-supplied pagination cursor was rejected. Carried as the
4
+ * `code` of the single issue on the thrown `ValidationError`.
5
+ */
6
+ export type InvalidCursorReason = "cursor_required" | "cursor_signature" | "cursor_malformed" | "cursor_payload" | "cursor_field";
7
+ /**
8
+ * Creates the error thrown for a missing, forged, tampered or malformed
9
+ * pagination cursor.
10
+ *
11
+ * A cursor is client input, so a bad one is a `ValidationError` (HTTP 400,
12
+ * exposable) rather than a `TypeError` that an HTTP layer would turn into a
13
+ * 500. Messages never echo the cursor or its contents, and the decoding
14
+ * failure is kept only as the non-exposed `cause`.
15
+ */
16
+ export declare function createInvalidCursorError(message: string, reason: InvalidCursorReason, cause?: unknown): ValidationError;
17
+ //# sourceMappingURL=pagination.cursorError.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { ValidationError } from "@zudojs/errors";
2
+ /**
3
+ * Creates the error thrown for a missing, forged, tampered or malformed
4
+ * pagination cursor.
5
+ *
6
+ * A cursor is client input, so a bad one is a `ValidationError` (HTTP 400,
7
+ * exposable) rather than a `TypeError` that an HTTP layer would turn into a
8
+ * 500. Messages never echo the cursor or its contents, and the decoding
9
+ * failure is kept only as the non-exposed `cause`.
10
+ */
11
+ export function createInvalidCursorError(message, reason, cause) {
12
+ return new ValidationError(message, {
13
+ issues: [{ field: "cursor", path: ["cursor"], code: reason, message }],
14
+ ...(cause !== undefined ? { cause } : {}),
15
+ });
16
+ }
17
+ //# sourceMappingURL=pagination.cursorError.js.map
@@ -1,5 +1,6 @@
1
1
  import type { SortInput } from "../databaseType/databaseType.type.js";
2
2
  import { type CursorPaginatedResult, type CursorPayload } from "./pagination.core.js";
3
+ import { type KeysetDirection } from "./pagination.keysetDirection.js";
3
4
  /**
4
5
  * Options for building a keyset page.
5
6
  */
@@ -21,6 +22,13 @@ export interface KeysetPageOptions<TField extends string = string> {
21
22
  * Secret used to sign cursors. Strongly recommended for public APIs.
22
23
  */
23
24
  readonly secret?: string;
25
+ /**
26
+ * Direction the page was fetched in (default `"forward"`). Use
27
+ * `getKeysetDirection` on the decoded cursor. A backward page must be
28
+ * fetched with `keysetFetchSort(sort, "backward")`; its rows are put
29
+ * back into `sort` order here.
30
+ */
31
+ readonly direction?: KeysetDirection;
24
32
  }
25
33
  /**
26
34
  * A Prisma-style filter object produced by the keyset helpers.
@@ -29,27 +37,36 @@ export type KeysetWhere = Readonly<Record<string, unknown>>;
29
37
  /**
30
38
  * Decodes and validates a keyset cursor against the sort definition.
31
39
  *
32
- * The payload may only contain the sort fields and primitive values.
40
+ * The payload may only contain the sort fields and primitive values (plus
41
+ * the backward marker of a `previousCursor`). A forged, tampered or
42
+ * malformed cursor throws a `ValidationError` (HTTP 400).
33
43
  */
34
44
  export declare function decodeKeysetCursor<TField extends string = string>(cursor: string, sort: readonly SortInput<TField>[], secret?: string): CursorPayload;
35
45
  /**
36
46
  * Builds a Prisma-compatible `where` fragment that selects the rows that
37
- * come strictly after the cursor position in the given sort order.
47
+ * come strictly after the cursor position in the given sort order, or
48
+ * strictly before it for a backward cursor (a `previousCursor`).
38
49
  *
39
- * For a sort of `[a asc, b desc]` the result is
50
+ * For a sort of `[a asc, b desc]` the forward result is
40
51
  * `OR: [{ a: { gt: A } }, { AND: [{ a: A }, { b: { lt: B } }] }]`.
41
52
  */
42
53
  export declare function buildKeysetWhere<TField extends string = string>(cursor: CursorPayload, sort: readonly SortInput<TField>[]): KeysetWhere;
43
54
  /**
44
- * Derives the cursor payload for a row from the sort fields.
55
+ * Derives the cursor payload for a row from the sort fields. A
56
+ * `"backward"` cursor selects the rows before `row` instead of after it.
45
57
  */
46
- export declare function createKeysetCursor<TField extends string = string>(row: Readonly<Record<string, unknown>>, sort: readonly SortInput<TField>[], secret?: string): string;
58
+ export declare function createKeysetCursor<TField extends string = string>(row: Readonly<Record<string, unknown>>, sort: readonly SortInput<TField>[], secret?: string, direction?: KeysetDirection): string;
47
59
  /**
48
60
  * Turns `limit + 1` fetched rows into a cursor-paginated result.
49
61
  *
50
- * Fetch `limit + 1` rows ordered by `sort`, then pass them here: the extra
51
- * row signals a next page and is dropped, and `nextCursor` is derived from
52
- * the last returned row.
62
+ * Fetch `limit + 1` rows ordered by `keysetFetchSort(sort, direction)`,
63
+ * then pass them here: the extra row signals another page in the fetch
64
+ * direction and is dropped. `nextCursor` is derived from the last returned
65
+ * row and `previousCursor` from the first, so a client can page both ways.
66
+ *
67
+ * Forward: `previousCursor` is set whenever the page was requested with a
68
+ * cursor and is not empty. Backward: `nextCursor` is always set for a
69
+ * non-empty page, and `previousCursor` only when more rows precede it.
53
70
  */
54
71
  export declare function createKeysetPage<TEntity extends Readonly<Record<string, unknown>>, TField extends string = string>(rows: readonly TEntity[], options: KeysetPageOptions<TField>): CursorPaginatedResult<TEntity>;
55
72
  //# sourceMappingURL=pagination.keyset.d.ts.map
@@ -1,35 +1,45 @@
1
1
  import { createCursorPaginatedResult, decodeCursor, encodeCursor, normalizeLimit, } from "./pagination.core.js";
2
+ import { createInvalidCursorError } from "./pagination.cursorError.js";
3
+ import { KEYSET_BACKWARD_KEY, getKeysetDirection, reverseKeysetSort, } from "./pagination.keysetDirection.js";
2
4
  /**
3
5
  * Decodes and validates a keyset cursor against the sort definition.
4
6
  *
5
- * The payload may only contain the sort fields and primitive values.
7
+ * The payload may only contain the sort fields and primitive values (plus
8
+ * the backward marker of a `previousCursor`). A forged, tampered or
9
+ * malformed cursor throws a `ValidationError` (HTTP 400).
6
10
  */
7
11
  export function decodeKeysetCursor(cursor, sort, secret) {
8
12
  validateSort(sort);
9
13
  const payload = decodeCursor(cursor, {
10
14
  secret,
11
- allowedFields: sort.map((entry) => entry.field),
15
+ allowedFields: [...sort.map((entry) => entry.field), KEYSET_BACKWARD_KEY],
12
16
  });
13
17
  for (const entry of sort) {
14
- if (!(entry.field in payload)) {
15
- throw new TypeError(`Pagination cursor is missing sort field "${entry.field}".`);
18
+ if (!Object.hasOwn(payload, entry.field)) {
19
+ throw createInvalidCursorError(`Pagination cursor is missing sort field "${entry.field}".`, "cursor_field");
16
20
  }
17
21
  }
22
+ const marker = payload[KEYSET_BACKWARD_KEY];
23
+ if (marker !== undefined && marker !== true) {
24
+ throw createInvalidCursorError("Pagination cursor fields must be primitive values.", "cursor_payload");
25
+ }
18
26
  return payload;
19
27
  }
20
28
  /**
21
29
  * Builds a Prisma-compatible `where` fragment that selects the rows that
22
- * come strictly after the cursor position in the given sort order.
30
+ * come strictly after the cursor position in the given sort order, or
31
+ * strictly before it for a backward cursor (a `previousCursor`).
23
32
  *
24
- * For a sort of `[a asc, b desc]` the result is
33
+ * For a sort of `[a asc, b desc]` the forward result is
25
34
  * `OR: [{ a: { gt: A } }, { AND: [{ a: A }, { b: { lt: B } }] }]`.
26
35
  */
27
36
  export function buildKeysetWhere(cursor, sort) {
28
37
  validateSort(sort);
29
38
  const branches = [];
30
- sort.forEach((entry, index) => {
39
+ const effective = getKeysetDirection(cursor) === "backward" ? reverseKeysetSort(sort) : sort;
40
+ effective.forEach((entry, index) => {
31
41
  const comparison = entry.direction === "desc" ? "lt" : "gt";
32
- const conditions = sort
42
+ const conditions = effective
33
43
  .slice(0, index)
34
44
  .map((previous) => ({
35
45
  [previous.field]: { equals: cursor[previous.field] },
@@ -42,34 +52,52 @@ export function buildKeysetWhere(cursor, sort) {
42
52
  return branches.length === 1 ? branches[0] : { OR: branches };
43
53
  }
44
54
  /**
45
- * Derives the cursor payload for a row from the sort fields.
55
+ * Derives the cursor payload for a row from the sort fields. A
56
+ * `"backward"` cursor selects the rows before `row` instead of after it.
46
57
  */
47
- export function createKeysetCursor(row, sort, secret) {
58
+ export function createKeysetCursor(row, sort, secret, direction = "forward") {
48
59
  validateSort(sort);
49
60
  const payload = {};
50
61
  for (const entry of sort) {
51
62
  payload[entry.field] = toCursorValue(row[entry.field], entry.field);
52
63
  }
64
+ if (direction === "backward") {
65
+ payload[KEYSET_BACKWARD_KEY] = true;
66
+ }
53
67
  return encodeCursor(payload, { secret });
54
68
  }
55
69
  /**
56
70
  * Turns `limit + 1` fetched rows into a cursor-paginated result.
57
71
  *
58
- * Fetch `limit + 1` rows ordered by `sort`, then pass them here: the extra
59
- * row signals a next page and is dropped, and `nextCursor` is derived from
60
- * the last returned row.
72
+ * Fetch `limit + 1` rows ordered by `keysetFetchSort(sort, direction)`,
73
+ * then pass them here: the extra row signals another page in the fetch
74
+ * direction and is dropped. `nextCursor` is derived from the last returned
75
+ * row and `previousCursor` from the first, so a client can page both ways.
76
+ *
77
+ * Forward: `previousCursor` is set whenever the page was requested with a
78
+ * cursor and is not empty. Backward: `nextCursor` is always set for a
79
+ * non-empty page, and `previousCursor` only when more rows precede it.
61
80
  */
62
81
  export function createKeysetPage(rows, options) {
63
82
  const limit = normalizeLimit(options.limit);
64
- const hasNextPage = rows.length > limit;
65
- const data = rows.slice(0, limit);
83
+ const backward = options.direction === "backward";
84
+ const hasMore = rows.length > limit;
85
+ const page = rows.slice(0, limit);
86
+ const data = backward ? page.reverse() : page;
87
+ const first = data[0];
66
88
  const last = data[data.length - 1];
89
+ const hasCursor = options.cursor !== undefined && options.cursor !== null;
90
+ const hasNextPage = backward ? hasCursor : hasMore;
91
+ const hasPreviousPage = backward ? hasMore : hasCursor;
67
92
  return createCursorPaginatedResult(data, limit, {
68
93
  hasNextPage,
69
- hasPreviousPage: options.cursor !== undefined && options.cursor !== null,
94
+ hasPreviousPage,
70
95
  nextCursor: hasNextPage && last
71
96
  ? createKeysetCursor(last, options.sort, options.secret)
72
97
  : null,
98
+ previousCursor: hasPreviousPage && first
99
+ ? createKeysetCursor(first, options.sort, options.secret, "backward")
100
+ : null,
73
101
  });
74
102
  }
75
103
  function toCursorValue(value, field) {
@@ -0,0 +1,29 @@
1
+ import type { SortInput } from "../databaseType/databaseType.type.js";
2
+ import type { CursorPayload } from "./pagination.core.js";
3
+ /**
4
+ * Direction a keyset cursor pages in. A `nextCursor` pages forward (rows
5
+ * after the cursor position); a `previousCursor` pages backward (rows
6
+ * before it).
7
+ */
8
+ export type KeysetDirection = "forward" | "backward";
9
+ /**
10
+ * Reserved cursor payload key marking a backward cursor. It can never
11
+ * collide with a sort field, because sort fields must be identifiers.
12
+ */
13
+ export declare const KEYSET_BACKWARD_KEY = "$before";
14
+ /**
15
+ * Returns the direction a decoded keyset cursor pages in.
16
+ */
17
+ export declare function getKeysetDirection(cursor: CursorPayload): KeysetDirection;
18
+ /**
19
+ * Flips every direction in a sort definition. A backward page is fetched
20
+ * with the reversed sort (so the rows nearest the cursor come first) and
21
+ * then put back into the requested order by `createKeysetPage`.
22
+ */
23
+ export declare function reverseKeysetSort<TField extends string>(sort: readonly SortInput<TField>[]): readonly SortInput<TField>[];
24
+ /**
25
+ * Returns the sort a page must be fetched with for the given direction:
26
+ * the requested sort going forward, the reversed sort going backward.
27
+ */
28
+ export declare function keysetFetchSort<TField extends string>(sort: readonly SortInput<TField>[], direction: KeysetDirection): readonly SortInput<TField>[];
29
+ //# sourceMappingURL=pagination.keysetDirection.d.ts.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Reserved cursor payload key marking a backward cursor. It can never
3
+ * collide with a sort field, because sort fields must be identifiers.
4
+ */
5
+ export const KEYSET_BACKWARD_KEY = "$before";
6
+ /**
7
+ * Returns the direction a decoded keyset cursor pages in.
8
+ */
9
+ export function getKeysetDirection(cursor) {
10
+ return cursor[KEYSET_BACKWARD_KEY] === true ? "backward" : "forward";
11
+ }
12
+ /**
13
+ * Flips every direction in a sort definition. A backward page is fetched
14
+ * with the reversed sort (so the rows nearest the cursor come first) and
15
+ * then put back into the requested order by `createKeysetPage`.
16
+ */
17
+ export function reverseKeysetSort(sort) {
18
+ return sort.map((entry) => ({
19
+ ...entry,
20
+ direction: entry.direction === "desc" ? "asc" : "desc",
21
+ }));
22
+ }
23
+ /**
24
+ * Returns the sort a page must be fetched with for the given direction:
25
+ * the requested sort going forward, the reversed sort going backward.
26
+ */
27
+ export function keysetFetchSort(sort, direction) {
28
+ return direction === "backward" ? reverseKeysetSort(sort) : sort;
29
+ }
30
+ //# sourceMappingURL=pagination.keysetDirection.js.map
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Generic repository pattern with Prisma delegate support.
5
5
  */
6
- export { BaseRepository, type RepositoryDelegate, type BaseRepositoryOptions, type SoftDeleteOptions, type CursorQueryOptions, type TransactionClientLike, } from "./repository.base.js";
6
+ export { BaseRepository, type BaseRepositoryOptions, type SoftDeleteOptions, type CursorQueryOptions, type TransactionClientLike, } from "./repository.base.js";
7
+ export { type RepositoryDelegate, type RepositoryDelegateOperations, } from "./repository.delegate.js";
7
8
  export { mapRepositoryError, isPrismaErrorLike, toDatabaseOperation, toErrorMetadata, createAbortError, createTimeoutError, type RepositoryOperation, type RepositoryErrorContext, type PrismaErrorLike, } from "./repository.errors.js";
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -4,5 +4,6 @@
4
4
  * Generic repository pattern with Prisma delegate support.
5
5
  */
6
6
  export { BaseRepository, } from "./repository.base.js";
7
+ export {} from "./repository.delegate.js";
7
8
  export { mapRepositoryError, isPrismaErrorLike, toDatabaseOperation, toErrorMetadata, createAbortError, createTimeoutError, } from "./repository.errors.js";
8
9
  //# sourceMappingURL=index.js.map
@@ -3,59 +3,8 @@ import { type CursorPaginatedResult } from "../pagination/pagination.core.js";
3
3
  import type { QueryBuilder } from "../queryBuilder/queryBuilder.core.js";
4
4
  import type { QueryBuilderState } from "../queryBuilder/queryBuilder.type.js";
5
5
  import type { RelationLoadOptions, RelationRegistry } from "../relations/relations.definition.js";
6
+ import { type RepositoryDelegate, type RepositoryDelegateOperations } from "./repository.delegate.js";
6
7
  import { type RepositoryOperation } from "./repository.errors.js";
7
- /**
8
- * Generic Prisma-style delegate contract.
9
- *
10
- * This keeps the repository base class independent from generated
11
- * Prisma model types while still supporting standard CRUD operations.
12
- */
13
- export interface RepositoryDelegate<TEntity, TId = string, TCreateInput = Partial<TEntity>, TUpdateInput = Partial<TEntity>, TWhereInput = unknown> {
14
- findUnique(args: {
15
- where: unknown;
16
- }): Promise<TEntity | null>;
17
- findFirst(args: {
18
- where?: TWhereInput;
19
- orderBy?: unknown;
20
- select?: unknown;
21
- }): Promise<TEntity | null>;
22
- findMany(args?: {
23
- where?: TWhereInput;
24
- skip?: number;
25
- take?: number;
26
- orderBy?: unknown;
27
- select?: unknown;
28
- include?: unknown;
29
- }): Promise<readonly TEntity[]>;
30
- create(args: {
31
- data: TCreateInput;
32
- }): Promise<TEntity>;
33
- update(args: {
34
- where: unknown;
35
- data: TUpdateInput;
36
- }): Promise<TEntity>;
37
- delete(args: {
38
- where: unknown;
39
- }): Promise<TEntity>;
40
- count(args?: {
41
- where?: TWhereInput;
42
- }): Promise<number>;
43
- upsert?(args: {
44
- where: unknown;
45
- create: TCreateInput;
46
- update: TUpdateInput;
47
- }): Promise<TEntity>;
48
- createMany?(args: {
49
- data: readonly TCreateInput[];
50
- }): Promise<{
51
- count: number;
52
- }>;
53
- deleteMany?(args: {
54
- where?: TWhereInput;
55
- }): Promise<{
56
- count: number;
57
- }>;
58
- }
59
8
  /**
60
9
  * Soft-delete configuration.
61
10
  */
@@ -123,7 +72,7 @@ export type TransactionClientLike = Readonly<Record<string, unknown>>;
123
72
  * appropriate Prisma delegate plus any domain-specific behavior.
124
73
  */
125
74
  export declare abstract class BaseRepository<TEntity, TId = string, TCreateInput = Partial<TEntity>, TUpdateInput = Partial<TEntity>, TWhereInput = Record<string, unknown>> implements Repository<TEntity, TId, TCreateInput, TUpdateInput, TWhereInput>, SoftDeletableRepository<TEntity, TId, TCreateInput, TUpdateInput, TWhereInput> {
126
- protected readonly delegate: RepositoryDelegate<TEntity, TId, TCreateInput, TUpdateInput, TWhereInput>;
75
+ protected readonly delegate: RepositoryDelegateOperations<TEntity, TId, TCreateInput, TUpdateInput, TWhereInput>;
127
76
  protected readonly modelName: string;
128
77
  protected readonly idField: string;
129
78
  protected readonly softDeleteField?: string;
@@ -177,6 +126,11 @@ export declare abstract class BaseRepository<TEntity, TId = string, TCreateInput
177
126
  * tiebreaker), `limit + 1` rows are fetched and the extra row decides
178
127
  * `hasNextPage`. Cursors are validated against the sort fields and, when
179
128
  * `cursorSecret` is configured, signed.
129
+ *
130
+ * Pass `meta.nextCursor` to page forward and `meta.previousCursor` to
131
+ * page backward; both come back in `options.sort` order. A forged,
132
+ * tampered or malformed cursor rejects with a `ValidationError` (400)
133
+ * before any query runs.
180
134
  */
181
135
  paginateCursor<TField extends string = string>(filter?: TWhereInput, options?: CursorQueryOptions<TField>): Promise<CursorPaginatedResult<TEntity>>;
182
136
  /**
@@ -1,7 +1,9 @@
1
1
  import { DatabaseError } from "@zudojs/errors";
2
2
  import { createPaginationMeta, normalizeLimit, normalizePage, } from "../pagination/pagination.core.js";
3
3
  import { buildKeysetWhere, createKeysetPage, decodeKeysetCursor, } from "../pagination/pagination.keyset.js";
4
+ import { getKeysetDirection, keysetFetchSort, } from "../pagination/pagination.keysetDirection.js";
4
5
  import { toPrismaArgs, toPrismaOrderBy, } from "../queryBuilder/queryBuilder.prisma.js";
6
+ import { toDelegateOperations, } from "./repository.delegate.js";
5
7
  import { createAbortError, createTimeoutError, mapRepositoryError, } from "./repository.errors.js";
6
8
  const DEFAULT_SOFT_DELETE_FIELD = "deletedAt";
7
9
  const FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -28,7 +30,7 @@ export class BaseRepository {
28
30
  if (!delegate) {
29
31
  throw new TypeError("A repository delegate is required.");
30
32
  }
31
- this.delegate = delegate;
33
+ this.delegate = toDelegateOperations(delegate);
32
34
  this.modelName = options.modelName ?? "DatabaseEntity";
33
35
  this.idField = validateFieldName(options.idField ?? "id", "idField");
34
36
  if (options.softDelete) {
@@ -69,7 +71,7 @@ export class BaseRepository {
69
71
  if (!delegate) {
70
72
  throw new TypeError("A repository delegate is required.");
71
73
  }
72
- return this.rebind({ delegate });
74
+ return this.rebind({ delegate: toDelegateOperations(delegate) });
73
75
  }
74
76
  /**
75
77
  * Returns a copy of this repository whose reads include soft-deleted
@@ -151,21 +153,28 @@ export class BaseRepository {
151
153
  * tiebreaker), `limit + 1` rows are fetched and the extra row decides
152
154
  * `hasNextPage`. Cursors are validated against the sort fields and, when
153
155
  * `cursorSecret` is configured, signed.
156
+ *
157
+ * Pass `meta.nextCursor` to page forward and `meta.previousCursor` to
158
+ * page backward; both come back in `options.sort` order. A forged,
159
+ * tampered or malformed cursor rejects with a `ValidationError` (400)
160
+ * before any query runs.
154
161
  */
155
162
  async paginateCursor(filter, options) {
156
163
  if (filter !== undefined) {
157
164
  this.validateFilter(filter);
158
165
  }
159
166
  const sort = this.buildCursorSort(options?.sort);
160
- const orderBy = this.buildOrderBy(sort);
161
167
  const limit = normalizeLimit(options?.limit);
162
168
  const cursor = options?.cursor ?? null;
163
169
  let where = this.scope(filter);
170
+ let direction = "forward";
164
171
  if (cursor !== null) {
165
172
  const payload = decodeKeysetCursor(cursor, sort, this.cursorSecret);
173
+ direction = getKeysetDirection(payload);
166
174
  const keyset = buildKeysetWhere(payload, sort);
167
175
  where = where === undefined ? keyset : { AND: [where, keyset] };
168
176
  }
177
+ const orderBy = this.buildOrderBy(keysetFetchSort(sort, direction));
169
178
  const rows = await this.execute("paginateCursor", () => this.delegate.findMany({
170
179
  where: where,
171
180
  take: limit + 1,
@@ -176,6 +185,7 @@ export class BaseRepository {
176
185
  limit,
177
186
  cursor,
178
187
  secret: this.cursorSecret,
188
+ direction,
179
189
  });
180
190
  }
181
191
  /**
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Model delegate accepted by `BaseRepository`: any object exposing
3
+ * Prisma-style CRUD methods, including a generated Prisma model delegate
4
+ * such as `prisma.user`.
5
+ *
6
+ * The parameters are declared loosely on purpose, the same way
7
+ * `PrismaClientLike.$transaction` is. A generated delegate's methods are
8
+ * generic (`findFirst<T extends UserFindFirstArgs>(args?: SelectSubset<T,
9
+ * …>)`) and their argument types (`select?: UserSelect | null`,
10
+ * `where?: UserWhereInput`) cannot be assigned from a single hand-written
11
+ * argument shape, so a real client failed to type-check against the
12
+ * previous signatures and needed a cast. Any argument list is accepted
13
+ * here; the return types are still checked, so a delegate whose rows do not
14
+ * match `TEntity` is rejected. The arguments the repository actually passes
15
+ * are described by {@link RepositoryDelegateOperations}.
16
+ */
17
+ export interface RepositoryDelegate<TEntity, TId = string, TCreateInput = Partial<TEntity>, TUpdateInput = Partial<TEntity>, TWhereInput = unknown> {
18
+ findUnique(...args: never[]): Promise<TEntity | null>;
19
+ findFirst(...args: never[]): Promise<TEntity | null>;
20
+ findMany(...args: never[]): Promise<readonly TEntity[]>;
21
+ create(...args: never[]): Promise<TEntity>;
22
+ update(...args: never[]): Promise<TEntity>;
23
+ delete(...args: never[]): Promise<TEntity>;
24
+ count(...args: never[]): Promise<number>;
25
+ upsert?(...args: never[]): Promise<TEntity>;
26
+ createMany?(...args: never[]): Promise<{
27
+ count: number;
28
+ }>;
29
+ deleteMany?(...args: never[]): Promise<{
30
+ count: number;
31
+ }>;
32
+ }
33
+ /**
34
+ * The calls `BaseRepository` makes on its delegate, with the arguments it
35
+ * passes. This is the type of `BaseRepository#delegate`, so subclasses can
36
+ * call the delegate directly.
37
+ */
38
+ export interface RepositoryDelegateOperations<TEntity, TId = string, TCreateInput = Partial<TEntity>, TUpdateInput = Partial<TEntity>, TWhereInput = unknown> {
39
+ findUnique(args: {
40
+ where: unknown;
41
+ }): Promise<TEntity | null>;
42
+ findFirst(args: {
43
+ where?: TWhereInput;
44
+ orderBy?: unknown;
45
+ select?: unknown;
46
+ }): Promise<TEntity | null>;
47
+ findMany(args?: {
48
+ where?: TWhereInput;
49
+ skip?: number;
50
+ take?: number;
51
+ orderBy?: unknown;
52
+ select?: unknown;
53
+ include?: unknown;
54
+ }): Promise<readonly TEntity[]>;
55
+ create(args: {
56
+ data: TCreateInput;
57
+ }): Promise<TEntity>;
58
+ update(args: {
59
+ where: unknown;
60
+ data: TUpdateInput;
61
+ }): Promise<TEntity>;
62
+ delete(args: {
63
+ where: unknown;
64
+ }): Promise<TEntity>;
65
+ count(args?: {
66
+ where?: TWhereInput;
67
+ }): Promise<number>;
68
+ upsert?(args: {
69
+ where: unknown;
70
+ create: TCreateInput;
71
+ update: TUpdateInput;
72
+ }): Promise<TEntity>;
73
+ createMany?(args: {
74
+ data: readonly TCreateInput[];
75
+ }): Promise<{
76
+ count: number;
77
+ }>;
78
+ deleteMany?(args: {
79
+ where?: TWhereInput;
80
+ }): Promise<{
81
+ count: number;
82
+ }>;
83
+ }
84
+ /**
85
+ * Views a delegate through the calls the repository makes on it.
86
+ *
87
+ * The assertion is the narrowing that `RepositoryDelegate`'s loose
88
+ * parameters defer: the repository only ever passes the Prisma argument
89
+ * shapes described by `RepositoryDelegateOperations`, which every generated
90
+ * model delegate accepts at runtime.
91
+ */
92
+ export declare function toDelegateOperations<TEntity, TId, TCreateInput, TUpdateInput, TWhereInput>(delegate: RepositoryDelegate<TEntity, TId, TCreateInput, TUpdateInput, TWhereInput>): RepositoryDelegateOperations<TEntity, TId, TCreateInput, TUpdateInput, TWhereInput>;
93
+ //# sourceMappingURL=repository.delegate.d.ts.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Views a delegate through the calls the repository makes on it.
3
+ *
4
+ * The assertion is the narrowing that `RepositoryDelegate`'s loose
5
+ * parameters defer: the repository only ever passes the Prisma argument
6
+ * shapes described by `RepositoryDelegateOperations`, which every generated
7
+ * model delegate accepts at runtime.
8
+ */
9
+ export function toDelegateOperations(delegate) {
10
+ return delegate;
11
+ }
12
+ //# sourceMappingURL=repository.delegate.js.map
@@ -77,9 +77,14 @@ export declare class TransactionManager {
77
77
  * Executes a callback inside a managed transaction and returns the
78
78
  * result together with the final context (`status: "committed"`).
79
79
  *
80
- * On failure the thrown `DatabaseError` carries `transactionId`,
81
- * `transactionStatus: "failed"` and the supplied metadata; use
82
- * {@link getTransactionContextFromError} to recover the context.
80
+ * On a database failure the thrown `DatabaseError` carries
81
+ * `transactionId`, `transactionStatus: "failed"` and the supplied
82
+ * metadata; use {@link getTransactionContextFromError} to recover the
83
+ * context.
84
+ *
85
+ * A non-database `BaseError` thrown by the callback (for example a
86
+ * `NotFoundError` or `DomainError`) rolls the transaction back and is
87
+ * rethrown as the same, unmodified instance.
83
88
  */
84
89
  run<TResult>(callback: (transaction: DatabaseTransactionContext, context: TransactionContext) => Promise<TResult>, options?: ManagedTransactionOptions): Promise<TransactionOutcome<TResult>>;
85
90
  /**
@@ -93,6 +98,11 @@ export declare class TransactionManager {
93
98
  export declare function createTransactionManager(client: DatabaseClient): TransactionManager;
94
99
  /**
95
100
  * Executes a managed database transaction.
101
+ *
102
+ * The transaction rolls back when the callback throws. A `@zudojs/errors`
103
+ * `BaseError` that is not a `DatabaseError` (domain, application,
104
+ * validation, not-found, ...) propagates unchanged; driver and database
105
+ * failures and any other thrown value become a `DatabaseError`.
96
106
  */
97
107
  export declare function withTransaction<TResult>(client: DatabaseClient, callback: (transaction: DatabaseTransactionContext, context: TransactionContext) => Promise<TResult>, options?: ManagedTransactionOptions): Promise<TResult>;
98
108
  /**
@@ -1,5 +1,5 @@
1
1
  import { DatabaseOperation } from "@zudojs/errors";
2
- import { isDatabaseErrorLike, isRetryableTransactionError, normalizeDatabaseError, withDatabaseErrorMetadata, } from "../databaseClient/databaseClient.errors.js";
2
+ import { isDatabaseErrorLike, isNonDatabaseBaseError, isRetryableTransactionError, normalizeDatabaseError, withDatabaseErrorMetadata, } from "../databaseClient/databaseClient.errors.js";
3
3
  import { computeRetryDelay } from "./transaction.backoff.js";
4
4
  /**
5
5
  * Generates a transaction identifier.
@@ -33,9 +33,14 @@ export class TransactionManager {
33
33
  * Executes a callback inside a managed transaction and returns the
34
34
  * result together with the final context (`status: "committed"`).
35
35
  *
36
- * On failure the thrown `DatabaseError` carries `transactionId`,
37
- * `transactionStatus: "failed"` and the supplied metadata; use
38
- * {@link getTransactionContextFromError} to recover the context.
36
+ * On a database failure the thrown `DatabaseError` carries
37
+ * `transactionId`, `transactionStatus: "failed"` and the supplied
38
+ * metadata; use {@link getTransactionContextFromError} to recover the
39
+ * context.
40
+ *
41
+ * A non-database `BaseError` thrown by the callback (for example a
42
+ * `NotFoundError` or `DomainError`) rolls the transaction back and is
43
+ * rethrown as the same, unmodified instance.
39
44
  */
40
45
  async run(callback, options = {}) {
41
46
  if (typeof callback !== "function") {
@@ -48,6 +53,8 @@ export class TransactionManager {
48
53
  return { result, context: withStatus(base, "committed") };
49
54
  }
50
55
  catch (error) {
56
+ if (isNonDatabaseBaseError(error))
57
+ throw error;
51
58
  const failed = withStatus(base, "failed");
52
59
  throw attachTransactionContext(normalizeDatabaseError(error, {
53
60
  operation: DatabaseOperation.TRANSACTION,
@@ -70,6 +77,11 @@ export function createTransactionManager(client) {
70
77
  }
71
78
  /**
72
79
  * Executes a managed database transaction.
80
+ *
81
+ * The transaction rolls back when the callback throws. A `@zudojs/errors`
82
+ * `BaseError` that is not a `DatabaseError` (domain, application,
83
+ * validation, not-found, ...) propagates unchanged; driver and database
84
+ * failures and any other thrown value become a `DatabaseError`.
73
85
  */
74
86
  export async function withTransaction(client, callback, options) {
75
87
  return createTransactionManager(client).execute(callback, options);
@@ -1,5 +1,5 @@
1
1
  import { DatabaseOperation } from "@zudojs/errors";
2
- import { normalizeDatabaseError } from "../databaseClient/databaseClient.errors.js";
2
+ import { isNonDatabaseBaseError, normalizeDatabaseError, } from "../databaseClient/databaseClient.errors.js";
3
3
  /**
4
4
  * Prisma-backed unit of work.
5
5
  */
@@ -23,6 +23,8 @@ export class DatabaseUnitOfWork {
23
23
  return await callback(transaction);
24
24
  }
25
25
  catch (error) {
26
+ if (isNonDatabaseBaseError(error))
27
+ throw error;
26
28
  throw normalizeDatabaseError(error, {
27
29
  operation: DatabaseOperation.TRANSACTION,
28
30
  fallbackMessage: "Unit of work execution failed.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/database",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "Database abstraction layer with clients, repositories, transactions, and query building for Zudojs applications.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -41,9 +41,9 @@
41
41
  "!dist/.tsbuildinfo"
42
42
  ],
43
43
  "dependencies": {
44
- "@zudojs/errors": "1.2.0",
45
- "@zudojs/logger": "1.3.0",
46
- "@zudojs/types": "1.1.1"
44
+ "@zudojs/errors": "1.3.0",
45
+ "@zudojs/logger": "1.4.1",
46
+ "@zudojs/types": "1.2.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@prisma/client": ">=7.0.0 <8"
@@ -52,7 +52,7 @@
52
52
  "@prisma/client": "7.10.0",
53
53
  "prisma": "7.10.0",
54
54
  "typescript": "7.0.2",
55
- "vitest": "^4.1.11"
55
+ "vitest": "^5.0.1"
56
56
  },
57
57
  "engines": {
58
58
  "node": ">=24.0.0"
@@ -67,7 +67,7 @@
67
67
  "query",
68
68
  "transactions"
69
69
  ],
70
- "homepage": "https://github.com/oyinlola-tech/zudo#readme",
70
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-database",
71
71
  "bugs": {
72
72
  "url": "https://github.com/oyinlola-tech/zudo/issues"
73
73
  },