@zudojs/database 1.0.0 → 1.1.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 +4 -2
- package/dist/databaseClient/databaseClient.core.d.ts +7 -1
- package/dist/databaseClient/databaseClient.core.js +49 -3
- package/dist/locks/locks.core.d.ts +4 -0
- package/dist/locks/locks.core.js +12 -6
- package/dist/repository/repository.base.d.ts +13 -0
- package/dist/repository/repository.base.js +25 -10
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -191,7 +191,9 @@ import { assertDatabaseHealth, checkDatabaseHealth, createLockManager } from "@z
|
|
|
191
191
|
const locks = createLockManager(client);
|
|
192
192
|
|
|
193
193
|
// Advisory lock (FNV-1a 64 key, transaction-scoped). `timeoutMs` becomes
|
|
194
|
-
// `SET LOCAL lock_timeout
|
|
194
|
+
// `SET LOCAL lock_timeout` (at least 1 ms; PostgreSQL treats 0 as "no
|
|
195
|
+
// timeout", so 0 is rejected — use `noWait` to fail immediately); the
|
|
196
|
+
// transaction timeout is raised to cover it.
|
|
195
197
|
await locks.withAdvisoryLock("reports:nightly", async (tx) => {
|
|
196
198
|
await tx.$executeRawUnsafe("REFRESH MATERIALIZED VIEW nightly_report");
|
|
197
199
|
}, { timeoutMs: 10_000 });
|
|
@@ -233,7 +235,7 @@ await assertDatabaseHealth(client); // throws DatabaseUnhealthyError with the re
|
|
|
233
235
|
- PostgreSQL only. Migration, seed, and lock helpers emit PostgreSQL SQL; other dialects throw `UnsupportedDialectError`.
|
|
234
236
|
- No savepoints or nested transactions. Prisma interactive transactions are used as-is.
|
|
235
237
|
- 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.
|
|
238
|
+
- `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
239
|
- The cache is not transaction-aware. Do not populate it from inside a transaction that may roll back.
|
|
238
240
|
|
|
239
241
|
## 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) {
|
|
@@ -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.");
|
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/database",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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,7 @@
|
|
|
37
41
|
"!dist/.tsbuildinfo"
|
|
38
42
|
],
|
|
39
43
|
"dependencies": {
|
|
40
|
-
"@zudojs/errors": "1.0.
|
|
44
|
+
"@zudojs/errors": "1.0.1"
|
|
41
45
|
},
|
|
42
46
|
"peerDependencies": {
|
|
43
47
|
"@prisma/client": ">=7.0.0 <8"
|