@zudojs/database 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,12 @@ that translates to Prisma `where` clauses, managed transactions, keyset
6
6
  pagination with signed cursors, migration and seed runners guarded by advisory
7
7
  locks, health checks with reconnect, and a bounded in-memory cache.
8
8
 
9
+ <!-- zudo-docs:start -->
10
+
11
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-database](https://zudojs.oyinlola.site/docs/packages-database) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-database.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
12
+
13
+ <!-- zudo-docs:end -->
14
+
9
15
  ## Installation
10
16
 
11
17
  ```bash
@@ -191,7 +197,9 @@ import { assertDatabaseHealth, checkDatabaseHealth, createLockManager } from "@z
191
197
  const locks = createLockManager(client);
192
198
 
193
199
  // Advisory lock (FNV-1a 64 key, transaction-scoped). `timeoutMs` becomes
194
- // `SET LOCAL lock_timeout`; the transaction timeout is raised to cover it.
200
+ // `SET LOCAL lock_timeout` (at least 1 ms; PostgreSQL treats 0 as "no
201
+ // timeout", so 0 is rejected — use `noWait` to fail immediately); the
202
+ // transaction timeout is raised to cover it.
195
203
  await locks.withAdvisoryLock("reports:nightly", async (tx) => {
196
204
  await tx.$executeRawUnsafe("REFRESH MATERIALIZED VIEW nightly_report");
197
205
  }, { timeoutMs: 10_000 });
@@ -221,6 +229,8 @@ await assertDatabaseHealth(client); // throws DatabaseUnhealthyError with the re
221
229
  - `BaseRepository` with CRUD, `createMany` / `deleteMany`, soft delete (`softDelete`, `restore`, `findDeleted`, `withDeleted`), transaction rebinding (`withTransaction`), and `findByQuery`
222
230
  - Query builder and 40 filter helpers that translate to Prisma `where` / `orderBy` / `select` / `include`
223
231
  - Managed transactions with context, error enrichment, and `withTransactionRetry` (serialization failures retried by default)
232
+ with exponential backoff capped by `maxRetryDelayMs` (30 s by default, never above the
233
+ 2^31-1 ms timer limit) and optional `jitter: "full"`
224
234
  - Offset pagination and keyset pagination with HMAC-signed, shape-validated cursors
225
235
  - Migration and seed runners using PostgreSQL advisory locks, per-item transactions, and BIGINT versioning
226
236
  - Advisory and row locks with `lock_timeout`, `SKIP LOCKED` / `NOWAIT`, and namespaced keys
@@ -233,7 +243,7 @@ await assertDatabaseHealth(client); // throws DatabaseUnhealthyError with the re
233
243
  - PostgreSQL only. Migration, seed, and lock helpers emit PostgreSQL SQL; other dialects throw `UnsupportedDialectError`.
234
244
  - No savepoints or nested transactions. Prisma interactive transactions are used as-is.
235
245
  - Connection pooling is handled by the driver adapter, not by this package.
236
- - `timeoutMs` and `signal` on repository operations are client-side only; the database query is not cancelled server-side.
246
+ - `timeoutMs` and `signal` on repository operations are client-side only; the database query is not cancelled server-side. A `signal` passed to `transaction()` / `withTransaction()` does roll the transaction back: the abort is raised inside the Prisma callback.
237
247
  - The cache is not transaction-aware. Do not populate it from inside a transaction that may roll back.
238
248
 
239
249
  ## Use Cases
@@ -11,7 +11,7 @@
11
11
  * Only PostgreSQL is exercised by the runners, locks and health helpers in
12
12
  * this package.
13
13
  */
14
- import { type Prisma } from "@prisma/client";
14
+ import type { Prisma } from "@prisma/client";
15
15
  import { DatabaseError } from "@zudojs/errors";
16
16
  import type { DatabaseClient as DatabaseClientContract, DatabaseConnectionOptions, DatabaseHealth, DatabaseLogger, DatabaseOperationOptions, DatabaseStatus, TransactionCallback, TransactionIsolationLevel, TransactionOptions } from "../databaseType/databaseType.type.js";
17
17
  /**
@@ -129,6 +129,12 @@ export declare class DatabaseClient implements DatabaseClientContract<DatabaseTr
129
129
  healthCheck(options?: RawQueryOptions): Promise<DatabaseHealth>;
130
130
  /**
131
131
  * Runs a callback inside a Prisma interactive transaction.
132
+ *
133
+ * When `options.signal` aborts, the abort is raised *inside* the
134
+ * transaction callback so Prisma rolls the transaction back; the caller
135
+ * is released at the same moment. Racing only the outer promise let the
136
+ * callback finish and the transaction commit after the caller had
137
+ * already been told it was aborted.
132
138
  */
133
139
  transaction<TResult>(callback: TransactionCallback<DatabaseTransactionContext, TResult>, options?: TransactionOptions): Promise<TResult>;
134
140
  /**
@@ -11,7 +11,7 @@
11
11
  * Only PostgreSQL is exercised by the runners, locks and health helpers in
12
12
  * this package.
13
13
  */
14
- import { PrismaClient } from "@prisma/client";
14
+ import { createRequire } from "node:module";
15
15
  import { DatabaseError, DatabaseOperation } from "@zudojs/errors";
16
16
  import { createDefaultLogger } from "./databaseClient.logger.js";
17
17
  import { normalizeDatabaseError } from "./databaseClient.errors.js";
@@ -174,6 +174,12 @@ export class DatabaseClient {
174
174
  }
175
175
  /**
176
176
  * Runs a callback inside a Prisma interactive transaction.
177
+ *
178
+ * When `options.signal` aborts, the abort is raised *inside* the
179
+ * transaction callback so Prisma rolls the transaction back; the caller
180
+ * is released at the same moment. Racing only the outer promise let the
181
+ * callback finish and the transaction commit after the caller had
182
+ * already been told it was aborted.
177
183
  */
178
184
  async transaction(callback, options = {}) {
179
185
  if (typeof callback !== "function") {
@@ -183,7 +189,7 @@ export class DatabaseClient {
183
189
  await this.ensureConnected();
184
190
  const transactionOptions = buildPrismaTransactionOptions(options);
185
191
  try {
186
- return await raceAbort(this.prisma.$transaction(async (transaction) => callback(transaction), transactionOptions), options.signal);
192
+ return await raceAbort(this.prisma.$transaction(async (transaction) => raceAbort(callback(transaction), options.signal), transactionOptions), options.signal);
187
193
  }
188
194
  catch (error) {
189
195
  const normalized = normalizeDatabaseError(error, {
@@ -321,6 +327,46 @@ export class DatabaseClient {
321
327
  }
322
328
  }
323
329
  }
330
+ const requirePeer = createRequire(import.meta.url);
331
+ let cachedPrismaClientConstructor;
332
+ /**
333
+ * Resolves the generated `PrismaClient` constructor on first use.
334
+ *
335
+ * The lookup is deliberately lazy. `@prisma/client` is a peer dependency, and
336
+ * until the consumer runs `prisma generate` the installed package is a stub
337
+ * that exports no `PrismaClient`. A static value import therefore made merely
338
+ * importing `@zudojs/database` fail with a bare `SyntaxError` — including for
339
+ * consumers who pass their own client through `options.prisma`, or whose
340
+ * generated client lives in a custom output directory (the Prisma 7 default)
341
+ * and so never needs this constructor at all.
342
+ *
343
+ * @throws {DatabaseError} when no generated client can be resolved.
344
+ */
345
+ function resolvePrismaClientConstructor() {
346
+ if (cachedPrismaClientConstructor)
347
+ return cachedPrismaClientConstructor;
348
+ const guidance = "Install it and run `prisma generate`, or pass an already-constructed client as `prisma` in the DatabaseClient options.";
349
+ let module;
350
+ try {
351
+ module = requirePeer("@prisma/client");
352
+ }
353
+ catch {
354
+ throw new DatabaseError(`DatabaseClient could not load the "@prisma/client" peer dependency. ${guidance}`, {
355
+ code: "ERR_DATABASE_CONNECTION",
356
+ operation: DatabaseOperation.CONNECT,
357
+ isOperational: false,
358
+ });
359
+ }
360
+ if (typeof module.PrismaClient !== "function") {
361
+ throw new DatabaseError(`"@prisma/client" is installed but exports no PrismaClient, which means the client has not been generated yet. ${guidance}`, {
362
+ code: "ERR_DATABASE_CONNECTION",
363
+ operation: DatabaseOperation.CONNECT,
364
+ isOperational: false,
365
+ });
366
+ }
367
+ cachedPrismaClientConstructor = module.PrismaClient;
368
+ return cachedPrismaClientConstructor;
369
+ }
324
370
  /**
325
371
  * Builds a Prisma client from the supplied options.
326
372
  *
@@ -342,7 +388,7 @@ function createPrismaClient(options) {
342
388
  ]
343
389
  : [{ emit: "stdout", level: "error" }];
344
390
  try {
345
- const Constructor = PrismaClient;
391
+ const Constructor = resolvePrismaClientConstructor();
346
392
  return new Constructor({ adapter: options.adapter, log });
347
393
  }
348
394
  catch (error) {
@@ -1,9 +1,20 @@
1
1
  /**
2
2
  * @zudojs/database — Default Logger
3
3
  *
4
- * Minimal logger for environments where no application logger has been configured yet.
4
+ * Fallback logger for environments where no application logger has been
5
+ * configured yet. It writes through `@zudojs/logger` (console transport,
6
+ * secret-name redaction) instead of calling `console.*` directly.
5
7
  */
8
+ import type { Logger } from "@zudojs/logger";
6
9
  import type { DatabaseLogger } from "../databaseType/databaseType.type.js";
7
- /** Creates a minimal logger for environments where no application logger has been configured yet. */
10
+ /** Logger name used by the database fallback logger. */
11
+ export declare const DEFAULT_DATABASE_LOGGER_NAME = "@zudojs/database";
12
+ /**
13
+ * Adapts a `@zudojs/logger` {@link Logger} to the {@link DatabaseLogger}
14
+ * contract. `debug`/`info` are dropped when `NODE_ENV` is `"production"`,
15
+ * checked per call as before; `warn`/`error` are always written.
16
+ */
17
+ export declare function createDatabaseLoggerAdapter(logger: Logger): DatabaseLogger;
18
+ /** Creates the fallback database logger, backed by `@zudojs/logger`. */
8
19
  export declare function createDefaultLogger(): DatabaseLogger;
9
20
  //# sourceMappingURL=databaseClient.logger.d.ts.map
@@ -1,27 +1,55 @@
1
1
  /**
2
2
  * @zudojs/database — Default Logger
3
3
  *
4
- * Minimal logger for environments where no application logger has been configured yet.
4
+ * Fallback logger for environments where no application logger has been
5
+ * configured yet. It writes through `@zudojs/logger` (console transport,
6
+ * secret-name redaction) instead of calling `console.*` directly.
5
7
  */
6
- /** Creates a minimal logger for environments where no application logger has been configured yet. */
7
- export function createDefaultLogger() {
8
- return {
8
+ import { createLogger, LoggerLevel } from "@zudojs/logger";
9
+ /** Logger name used by the database fallback logger. */
10
+ export const DEFAULT_DATABASE_LOGGER_NAME = "@zudojs/database";
11
+ function isVerboseEnabled() {
12
+ return process.env.NODE_ENV !== "production";
13
+ }
14
+ function toOptions(metadata, error) {
15
+ if (error === undefined) {
16
+ return metadata === undefined ? {} : { metadata };
17
+ }
18
+ if (error instanceof Error) {
19
+ return metadata === undefined ? { error } : { metadata, error };
20
+ }
21
+ return { metadata: { ...(metadata ?? {}), error } };
22
+ }
23
+ /**
24
+ * Adapts a `@zudojs/logger` {@link Logger} to the {@link DatabaseLogger}
25
+ * contract. `debug`/`info` are dropped when `NODE_ENV` is `"production"`,
26
+ * checked per call as before; `warn`/`error` are always written.
27
+ */
28
+ export function createDatabaseLoggerAdapter(logger) {
29
+ return Object.freeze({
9
30
  debug: (message, metadata) => {
10
- if (process.env.NODE_ENV !== "production") {
11
- console.debug(message, metadata);
31
+ if (isVerboseEnabled()) {
32
+ logger.log(LoggerLevel.DEBUG, message, toOptions(metadata));
12
33
  }
13
34
  },
14
35
  info: (message, metadata) => {
15
- if (process.env.NODE_ENV !== "production") {
16
- console.info(message, metadata);
36
+ if (isVerboseEnabled()) {
37
+ logger.log(LoggerLevel.INFO, message, toOptions(metadata));
17
38
  }
18
39
  },
19
40
  warn: (message, metadata) => {
20
- console.warn(message, metadata);
41
+ logger.log(LoggerLevel.WARN, message, toOptions(metadata));
21
42
  },
22
43
  error: (message, error, metadata) => {
23
- console.error(message, error, metadata);
44
+ logger.log(LoggerLevel.ERROR, message, toOptions(metadata, error));
24
45
  },
25
- };
46
+ });
47
+ }
48
+ /** Creates the fallback database logger, backed by `@zudojs/logger`. */
49
+ export function createDefaultLogger() {
50
+ return createDatabaseLoggerAdapter(createLogger({
51
+ name: DEFAULT_DATABASE_LOGGER_NAME,
52
+ level: LoggerLevel.DEBUG,
53
+ }));
26
54
  }
27
55
  //# sourceMappingURL=databaseClient.logger.js.map
@@ -22,6 +22,10 @@ export interface DatabaseLockOptions {
22
22
  * transaction timeout. When it exceeds Prisma's 5 s default and
23
23
  * `transaction.timeoutMs` is not set, the transaction timeout is raised
24
24
  * automatically (see `resolveLockTransactionOptions`).
25
+ *
26
+ * Must be a positive number: PostgreSQL treats `lock_timeout = 0` as
27
+ * "disabled" (wait forever), so `0` is rejected. Use `noWait` to fail
28
+ * immediately instead.
25
29
  */
26
30
  readonly timeoutMs?: number;
27
31
  /**
@@ -203,9 +203,7 @@ export function resolveLockTransactionOptions(options = {}) {
203
203
  const { timeoutMs, transaction } = options;
204
204
  if (timeoutMs === undefined)
205
205
  return transaction;
206
- if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
207
- throw new TypeError("Lock timeoutMs must be a non-negative finite number.");
208
- }
206
+ validateLockTimeout(timeoutMs);
209
207
  const explicit = transaction?.timeoutMs;
210
208
  if (explicit !== undefined) {
211
209
  if (explicit <= timeoutMs) {
@@ -226,11 +224,19 @@ export function resolveLockTransactionOptions(options = {}) {
226
224
  async function applyLockTimeout(transaction, timeoutMs) {
227
225
  if (timeoutMs === undefined)
228
226
  return;
229
- if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
230
- throw new TypeError("Lock timeoutMs must be a non-negative finite number.");
231
- }
227
+ validateLockTimeout(timeoutMs);
232
228
  await transaction.$executeRawUnsafe(`SET LOCAL lock_timeout = ${Math.floor(timeoutMs)}`);
233
229
  }
230
+ /**
231
+ * Rejects lock timeouts PostgreSQL would silently disable. `lock_timeout`
232
+ * is "no timeout" at `0`, and `Math.floor` turns any value below 1 ms into
233
+ * `0`, so anything under one millisecond is refused.
234
+ */
235
+ function validateLockTimeout(timeoutMs) {
236
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1) {
237
+ throw new TypeError("Lock timeoutMs must be a finite number of at least 1 ms; PostgreSQL treats lock_timeout = 0 as disabled. Use noWait to fail immediately.");
238
+ }
239
+ }
234
240
  function validateCallback(callback) {
235
241
  if (typeof callback !== "function") {
236
242
  throw new TypeError("A lock callback is required.");
@@ -1,3 +1,4 @@
1
+ import { isPlainObject } from "@zudojs/types";
1
2
  import { normalizePagination } from "../pagination/pagination.core.js";
2
3
  import { toPrismaInclude, } from "../relations/relations.definition.js";
3
4
  const FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -224,10 +225,4 @@ function validateFieldName(field) {
224
225
  throw new TypeError(`Invalid query field name "${String(field)}".`);
225
226
  }
226
227
  }
227
- function isPlainObject(value) {
228
- return (value !== null &&
229
- typeof value === "object" &&
230
- !Array.isArray(value) &&
231
- !(value instanceof Date));
232
- }
233
228
  //# sourceMappingURL=queryBuilder.prisma.js.map
@@ -254,12 +254,25 @@ export declare abstract class BaseRepository<TEntity, TId = string, TCreateInput
254
254
  * Builds the primary-key `where` for an identifier.
255
255
  */
256
256
  protected whereId(id: TId): Record<string, unknown>;
257
+ /**
258
+ * Builds the primary-key `where` for a *unique* operation (`update`),
259
+ * folding the soft-delete scope in as a sibling of the id rather than
260
+ * wrapping it in `AND`. Prisma's `WhereUniqueInput` requires the unique
261
+ * field at the top level, so `{ AND: [{ id }, { deletedAt: null }] }` is
262
+ * rejected with a validation error.
263
+ */
264
+ protected whereUniqueId(id: TId): Record<string, unknown>;
257
265
  /**
258
266
  * Applies the soft-delete scope to a filter when enabled.
259
267
  */
260
268
  protected scope(filter?: TWhereInput): TWhereInput | undefined;
261
269
  /**
262
270
  * Converts generic sort definitions into Prisma-compatible orderBy.
271
+ *
272
+ * Field names and directions are validated the same way the query
273
+ * builder validates them (`toPrismaOrderBy`), so a sort taken from
274
+ * request input cannot reach the delegate with an arbitrary key or an
275
+ * unsupported direction.
263
276
  */
264
277
  protected buildOrderBy<TField extends string>(sort?: readonly SortInput<TField>[]): ReadonlyArray<Record<string, string>> | undefined;
265
278
  private isScoped;
@@ -1,7 +1,7 @@
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 { toPrismaArgs } from "../queryBuilder/queryBuilder.prisma.js";
4
+ import { toPrismaArgs, toPrismaOrderBy, } from "../queryBuilder/queryBuilder.prisma.js";
5
5
  import { createAbortError, createTimeoutError, mapRepositoryError, } from "./repository.errors.js";
6
6
  const DEFAULT_SOFT_DELETE_FIELD = "deletedAt";
7
7
  const FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -121,12 +121,15 @@ export class BaseRepository {
121
121
  const page = normalizePage(options?.pagination?.page);
122
122
  const limit = normalizeLimit(options?.pagination?.limit);
123
123
  const skip = (page - 1) * limit;
124
+ // Validated before either query is dispatched, so an invalid sort never
125
+ // costs a `count` round-trip.
126
+ const orderBy = this.buildOrderBy(options?.sort);
124
127
  const [data, total] = await Promise.all([
125
128
  this.execute("findPaginated", () => this.delegate.findMany({
126
129
  where: this.scope(filter),
127
130
  skip,
128
131
  take: limit,
129
- orderBy: this.buildOrderBy(options?.sort),
132
+ orderBy,
130
133
  }), options),
131
134
  this.count(filter, options),
132
135
  ]);
@@ -154,6 +157,7 @@ export class BaseRepository {
154
157
  this.validateFilter(filter);
155
158
  }
156
159
  const sort = this.buildCursorSort(options?.sort);
160
+ const orderBy = this.buildOrderBy(sort);
157
161
  const limit = normalizeLimit(options?.limit);
158
162
  const cursor = options?.cursor ?? null;
159
163
  let where = this.scope(filter);
@@ -165,7 +169,7 @@ export class BaseRepository {
165
169
  const rows = await this.execute("paginateCursor", () => this.delegate.findMany({
166
170
  where: where,
167
171
  take: limit + 1,
168
- orderBy: this.buildOrderBy(sort),
172
+ orderBy,
169
173
  }), options);
170
174
  return createKeysetPage(rows, {
171
175
  sort,
@@ -251,7 +255,7 @@ export class BaseRepository {
251
255
  throw new DatabaseError(`Cannot update ${this.modelName}: input is required.`);
252
256
  }
253
257
  return this.execute("update", () => this.delegate.update({
254
- where: this.scope(this.whereId(id)),
258
+ where: this.whereUniqueId(id),
255
259
  data: input,
256
260
  }), options);
257
261
  }
@@ -445,6 +449,17 @@ export class BaseRepository {
445
449
  whereId(id) {
446
450
  return { [this.idField]: id };
447
451
  }
452
+ /**
453
+ * Builds the primary-key `where` for a *unique* operation (`update`),
454
+ * folding the soft-delete scope in as a sibling of the id rather than
455
+ * wrapping it in `AND`. Prisma's `WhereUniqueInput` requires the unique
456
+ * field at the top level, so `{ AND: [{ id }, { deletedAt: null }] }` is
457
+ * rejected with a validation error.
458
+ */
459
+ whereUniqueId(id) {
460
+ const where = this.whereId(id);
461
+ return this.isScoped() ? { ...where, [this.softDeleteField]: null } : where;
462
+ }
448
463
  /**
449
464
  * Applies the soft-delete scope to a filter when enabled.
450
465
  */
@@ -460,14 +475,14 @@ export class BaseRepository {
460
475
  }
461
476
  /**
462
477
  * Converts generic sort definitions into Prisma-compatible orderBy.
478
+ *
479
+ * Field names and directions are validated the same way the query
480
+ * builder validates them (`toPrismaOrderBy`), so a sort taken from
481
+ * request input cannot reach the delegate with an arbitrary key or an
482
+ * unsupported direction.
463
483
  */
464
484
  buildOrderBy(sort) {
465
- if (!sort || sort.length === 0) {
466
- return undefined;
467
- }
468
- return sort.map((entry) => ({
469
- [entry.field]: entry.direction,
470
- }));
485
+ return toPrismaOrderBy(sort);
471
486
  }
472
487
  isScoped() {
473
488
  return this.softDeleteField !== undefined && !this.includeDeleted;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @zudojs/database — Transaction retry backoff.
3
+ *
4
+ * `retryDelayMs * 2^(attempt-1)` passes 2^31-1 ms by attempt 26 at the
5
+ * default 100 ms base. `setTimeout` then warns and fires after 1 ms, so a
6
+ * large retry budget turned exponential backoff into a tight retry storm
7
+ * against a database that was already contended. The delay is clamped to a
8
+ * configurable ceiling and, below that, to the timer maximum.
9
+ */
10
+ /** Largest delay `setTimeout` honours (2^31-1 ms, about 24.8 days). */
11
+ export declare const MAX_TIMER_DELAY_MS = 2147483647;
12
+ /** Default ceiling for a single retry delay: 30 seconds. */
13
+ export declare const DEFAULT_MAX_RETRY_DELAY_MS = 30000;
14
+ /** Inputs to {@link computeRetryDelay}. */
15
+ export interface RetryDelayOptions {
16
+ /** Base delay, doubled each retry. */
17
+ readonly retryDelayMs: number;
18
+ /** Ceiling for any single delay. */
19
+ readonly maxRetryDelayMs?: number;
20
+ /** `"full"` picks uniformly in `[0, delay]` to spread contending retries. */
21
+ readonly jitter?: "none" | "full";
22
+ /** Random source in `[0, 1)`, injectable for tests. */
23
+ readonly random?: () => number;
24
+ }
25
+ /**
26
+ * Delay before retry number `attempt` (1-based).
27
+ *
28
+ * @param attempt - The retry about to be made, starting at 1.
29
+ * @param options - Base delay, ceiling and jitter.
30
+ * @returns A finite delay in `[0, min(maxRetryDelayMs, 2^31-1)]`.
31
+ */
32
+ export declare function computeRetryDelay(attempt: number, options: RetryDelayOptions): number;
33
+ //# sourceMappingURL=transaction.backoff.d.ts.map
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @zudojs/database — Transaction retry backoff.
3
+ *
4
+ * `retryDelayMs * 2^(attempt-1)` passes 2^31-1 ms by attempt 26 at the
5
+ * default 100 ms base. `setTimeout` then warns and fires after 1 ms, so a
6
+ * large retry budget turned exponential backoff into a tight retry storm
7
+ * against a database that was already contended. The delay is clamped to a
8
+ * configurable ceiling and, below that, to the timer maximum.
9
+ */
10
+ /** Largest delay `setTimeout` honours (2^31-1 ms, about 24.8 days). */
11
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
12
+ /** Default ceiling for a single retry delay: 30 seconds. */
13
+ export const DEFAULT_MAX_RETRY_DELAY_MS = 30_000;
14
+ /**
15
+ * Delay before retry number `attempt` (1-based).
16
+ *
17
+ * @param attempt - The retry about to be made, starting at 1.
18
+ * @param options - Base delay, ceiling and jitter.
19
+ * @returns A finite delay in `[0, min(maxRetryDelayMs, 2^31-1)]`.
20
+ */
21
+ export function computeRetryDelay(attempt, options) {
22
+ const ceiling = Math.min(Math.max(0, options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS), MAX_TIMER_DELAY_MS);
23
+ const exponential = options.retryDelayMs * 2 ** Math.max(0, attempt - 1);
24
+ const capped = Number.isFinite(exponential)
25
+ ? Math.min(exponential, ceiling)
26
+ : ceiling;
27
+ if (options.jitter !== "full")
28
+ return capped;
29
+ const random = options.random ?? Math.random;
30
+ return Math.floor(random() * (capped + 1));
31
+ }
32
+ //# sourceMappingURL=transaction.backoff.js.map
@@ -41,6 +41,16 @@ export interface TransactionRetryOptions extends ManagedTransactionOptions {
41
41
  * Base delay between attempts (doubles each retry). Defaults to 100 ms.
42
42
  */
43
43
  readonly retryDelayMs?: number;
44
+ /**
45
+ * Ceiling for a single retry delay. Defaults to 30000 ms, and never
46
+ * exceeds the timer maximum (2^31-1 ms).
47
+ */
48
+ readonly maxRetryDelayMs?: number;
49
+ /**
50
+ * `"full"` spreads each delay uniformly over `[0, delay]` so contending
51
+ * callers do not retry in lockstep. Defaults to `"none"`.
52
+ */
53
+ readonly jitter?: "none" | "full";
44
54
  /**
45
55
  * Predicate deciding whether an error is retryable. Defaults to
46
56
  * {@link isRetryableTransactionError} (Prisma P2034/P2028/P1017 and
@@ -1,5 +1,6 @@
1
1
  import { DatabaseOperation } from "@zudojs/errors";
2
2
  import { isDatabaseErrorLike, isRetryableTransactionError, normalizeDatabaseError, withDatabaseErrorMetadata, } from "../databaseClient/databaseClient.errors.js";
3
+ import { computeRetryDelay } from "./transaction.backoff.js";
3
4
  /**
4
5
  * Generates a transaction identifier.
5
6
  */
@@ -94,7 +95,13 @@ export async function withTransactionRetry(client, callback, options = {}) {
94
95
  throw error;
95
96
  }
96
97
  attempt += 1;
97
- const delay = retryDelayMs * Math.pow(2, attempt - 1);
98
+ const delay = computeRetryDelay(attempt, {
99
+ retryDelayMs,
100
+ ...(options.maxRetryDelayMs !== undefined
101
+ ? { maxRetryDelayMs: options.maxRetryDelayMs }
102
+ : {}),
103
+ ...(options.jitter !== undefined ? { jitter: options.jitter } : {}),
104
+ });
98
105
  if (delay > 0)
99
106
  await sleep(delay);
100
107
  }
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/database",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Database abstraction layer with clients, repositories, transactions, and query building for Zudojs applications.",
5
5
  "license": "MIT",
6
+ "author": {
7
+ "name": "Oluwayemi Oyinlola",
8
+ "url": "https://github.com/oyinlola-tech"
9
+ },
6
10
  "type": "module",
7
11
  "sideEffects": false,
8
12
  "main": "./dist/index.js",
@@ -37,7 +41,9 @@
37
41
  "!dist/.tsbuildinfo"
38
42
  ],
39
43
  "dependencies": {
40
- "@zudojs/errors": "1.0.0"
44
+ "@zudojs/errors": "1.1.0",
45
+ "@zudojs/logger": "1.2.0",
46
+ "@zudojs/types": "1.1.0"
41
47
  },
42
48
  "peerDependencies": {
43
49
  "@prisma/client": ">=7.0.0 <8"