@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 +8 -0
- package/dist/cache/cache.memory.js +15 -10
- package/dist/databaseClient/databaseClient.logger.d.ts +13 -2
- package/dist/databaseClient/databaseClient.logger.js +39 -11
- package/dist/queryBuilder/queryBuilder.prisma.js +1 -6
- package/dist/relations/relations.definition.js +26 -6
- package/dist/transaction/transaction.backoff.d.ts +33 -0
- package/dist/transaction/transaction.backoff.js +32 -0
- package/dist/transaction/transaction.core.d.ts +10 -0
- package/dist/transaction/transaction.core.js +8 -1
- package/package.json +4 -2
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 (
|
|
11
|
-
|
|
31
|
+
if (isVerboseEnabled()) {
|
|
32
|
+
logger.log(LoggerLevel.DEBUG, message, toOptions(metadata));
|
|
12
33
|
}
|
|
13
34
|
},
|
|
14
35
|
info: (message, metadata) => {
|
|
15
|
-
if (
|
|
16
|
-
|
|
36
|
+
if (isVerboseEnabled()) {
|
|
37
|
+
logger.log(LoggerLevel.INFO, message, toOptions(metadata));
|
|
17
38
|
}
|
|
18
39
|
},
|
|
19
40
|
warn: (message, metadata) => {
|
|
20
|
-
|
|
41
|
+
logger.log(LoggerLevel.WARN, message, toOptions(metadata));
|
|
21
42
|
},
|
|
22
43
|
error: (message, error, metadata) => {
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
if (include.relation
|
|
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 =
|
|
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
|
|
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
|
|
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"
|