@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 +12 -2
- package/dist/databaseClient/databaseClient.core.d.ts +7 -1
- package/dist/databaseClient/databaseClient.core.js +49 -3
- package/dist/databaseClient/databaseClient.logger.d.ts +13 -2
- package/dist/databaseClient/databaseClient.logger.js +39 -11
- package/dist/locks/locks.core.d.ts +4 -0
- package/dist/locks/locks.core.js +12 -6
- package/dist/queryBuilder/queryBuilder.prisma.js +1 -6
- package/dist/repository/repository.base.d.ts +13 -0
- package/dist/repository/repository.base.js +25 -10
- 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 +8 -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
|
|
@@ -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
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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
|
-
*
|
|
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
|
|
@@ -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
|
/**
|
package/dist/locks/locks.core.js
CHANGED
|
@@ -203,9 +203,7 @@ export function resolveLockTransactionOptions(options = {}) {
|
|
|
203
203
|
const { timeoutMs, transaction } = options;
|
|
204
204
|
if (timeoutMs === undefined)
|
|
205
205
|
return transaction;
|
|
206
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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"
|