@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 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`; the transaction timeout is raised to cover it.
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 { 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) {
@@ -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.");
@@ -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;
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/database",
3
- "version": "1.0.0",
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.0"
44
+ "@zudojs/errors": "1.0.1"
41
45
  },
42
46
  "peerDependencies": {
43
47
  "@prisma/client": ">=7.0.0 <8"