@zudojs/database 1.1.0 → 1.2.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.
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
@@ -223,6 +229,8 @@ await assertDatabaseHealth(client); // throws DatabaseUnhealthyError with the re
223
229
  - `BaseRepository` with CRUD, `createMany` / `deleteMany`, soft delete (`softDelete`, `restore`, `findDeleted`, `withDeleted`), transaction rebinding (`withTransaction`), and `findByQuery`
224
230
  - Query builder and 40 filter helpers that translate to Prisma `where` / `orderBy` / `select` / `include`
225
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"`
226
234
  - Offset pagination and keyset pagination with HMAC-signed, shape-validated cursors
227
235
  - Migration and seed runners using PostgreSQL advisory locks, per-item transactions, and BIGINT versioning
228
236
  - Advisory and row locks with `lock_timeout`, `SKIP LOCKED` / `NOWAIT`, and namespaced keys
@@ -303,19 +303,24 @@ export async function getOrSet(cache, key, loader, options) {
303
303
  const pending = inflight.get(key);
304
304
  if (pending)
305
305
  return pending;
306
+ // The cleanup lives outside the promise: the body runs synchronously up to
307
+ // its first `await`, so a loader that throws synchronously would otherwise
308
+ // run the cleanup before the entry is registered and leave the rejected
309
+ // promise cached for the lifetime of the cache.
306
310
  const promise = (async () => {
307
- try {
308
- const value = await loader();
309
- if (value !== undefined)
310
- cache.set(key, value, options);
311
- return value;
312
- }
313
- finally {
314
- inflight.delete(key);
315
- }
311
+ const value = await loader();
312
+ if (value !== undefined)
313
+ cache.set(key, value, options);
314
+ return value;
316
315
  })();
317
316
  inflight.set(key, promise);
318
- return promise;
317
+ try {
318
+ return await promise;
319
+ }
320
+ finally {
321
+ if (inflight.get(key) === promise)
322
+ inflight.delete(key);
323
+ }
319
324
  }
320
325
  /**
321
326
  * Invalidates all entries whose keys start with a prefix.
@@ -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
@@ -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
@@ -84,6 +84,23 @@ export function validateInclude(include) {
84
84
  validateInclude(nested);
85
85
  }
86
86
  }
87
+ /**
88
+ * Validates a single include level: its shape, relation name and select
89
+ * fields, leaving nested includes to the caller.
90
+ *
91
+ * `buildInclude` uses this so the depth bound applies per level — a
92
+ * recursive validation would walk the whole caller-supplied subtree before
93
+ * the depth guard could refuse it.
94
+ */
95
+ function validateIncludeLevel(include) {
96
+ if (!include || typeof include !== "object") {
97
+ throw new TypeError("A relation include is required.");
98
+ }
99
+ validateRelationName(include.relation);
100
+ for (const field of include.select ?? []) {
101
+ validateFieldName(field);
102
+ }
103
+ }
87
104
  /**
88
105
  * Translates relation includes into a Prisma `include` object.
89
106
  *
@@ -105,10 +122,13 @@ function buildInclude(includes, options, parent, depth, maxDepth, path) {
105
122
  if (depth > maxDepth) {
106
123
  throw new RangeError(`Relation include depth exceeds the maximum of ${maxDepth}.`);
107
124
  }
108
- const result = {};
125
+ // A null-prototype accumulator: relation names such as `toString` or
126
+ // `__proto__` must behave as ordinary keys. It is spread into a plain
127
+ // object before it is returned.
128
+ const result = Object.create(null);
109
129
  for (const include of includes) {
110
- validateInclude(include);
111
- if (include.relation in result) {
130
+ validateIncludeLevel(include);
131
+ if (Object.hasOwn(result, include.relation)) {
112
132
  throw new TypeError(`Relation "${include.relation}" is included more than once.`);
113
133
  }
114
134
  let definition;
@@ -123,11 +143,11 @@ function buildInclude(includes, options, parent, depth, maxDepth, path) {
123
143
  }
124
144
  const entry = {};
125
145
  if (include.select && include.select.length > 0) {
126
- const select = {};
146
+ const select = Object.create(null);
127
147
  for (const field of include.select) {
128
148
  select[field] = true;
129
149
  }
130
- entry["select"] = select;
150
+ entry["select"] = { ...select };
131
151
  }
132
152
  if (include.include && include.include.length > 0) {
133
153
  const nested = buildInclude(include.include, options, definition ? definition.child : undefined, depth + 1, maxDepth, definition ? [...path, definition] : path);
@@ -147,7 +167,7 @@ function buildInclude(includes, options, parent, depth, maxDepth, path) {
147
167
  }
148
168
  result[include.relation] = Object.keys(entry).length === 0 ? true : entry;
149
169
  }
150
- return result;
170
+ return { ...result };
151
171
  }
152
172
  /**
153
173
  * Creates a nested relation include.
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/database",
3
- "version": "1.1.0",
3
+ "version": "1.2.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,7 +41,9 @@
41
41
  "!dist/.tsbuildinfo"
42
42
  ],
43
43
  "dependencies": {
44
- "@zudojs/errors": "1.0.1"
44
+ "@zudojs/errors": "1.2.0",
45
+ "@zudojs/logger": "1.3.0",
46
+ "@zudojs/types": "1.1.1"
45
47
  },
46
48
  "peerDependencies": {
47
49
  "@prisma/client": ">=7.0.0 <8"