uql-orm 0.28.2 → 0.30.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.
Files changed (48) hide show
  1. package/dist/browser/querier/httpQuerier.d.ts +5 -5
  2. package/dist/browser/uql-browser.min.js +2 -2
  3. package/dist/browser/uql-browser.min.js.map +3 -3
  4. package/dist/dialect/abstractSqlDialect.d.ts +6 -4
  5. package/dist/dialect/abstractSqlDialect.js +29 -29
  6. package/dist/dialect/mysqlLikeSqlDialect.d.ts +3 -1
  7. package/dist/dialect/mysqlLikeSqlDialect.js +9 -0
  8. package/dist/dialect/vectorSqlDialect.js +8 -1
  9. package/dist/mongo/mongoDialect.d.ts +3 -3
  10. package/dist/mongo/mongoDialect.js +17 -9
  11. package/dist/mongo/mongodbQuerier.d.ts +9 -4
  12. package/dist/mongo/mongodbQuerier.js +15 -7
  13. package/dist/mysql/mysql2Querier.d.ts +1 -1
  14. package/dist/mysql/mysql2Querier.js +7 -1
  15. package/dist/nestjs/uqlContextInterceptor.d.ts +2 -2
  16. package/dist/nestjs/uqlContextInterceptor.js +2 -2
  17. package/dist/nestjs/uqlModule.d.ts +3 -3
  18. package/dist/nestjs/uqlModule.js +5 -6
  19. package/dist/postgres/abstractPgQuerier.d.ts +3 -2
  20. package/dist/postgres/abstractPgQuerier.js +2 -2
  21. package/dist/querier/abstractPoolQuerier.d.ts +2 -2
  22. package/dist/querier/abstractPoolQuerier.js +7 -7
  23. package/dist/querier/abstractQuerier.d.ts +45 -16
  24. package/dist/querier/abstractQuerier.js +99 -40
  25. package/dist/querier/abstractQuerierPool.d.ts +7 -7
  26. package/dist/querier/abstractSqlQuerier.d.ts +17 -6
  27. package/dist/querier/abstractSqlQuerier.js +61 -34
  28. package/dist/querier/index.d.ts +0 -2
  29. package/dist/querier/index.js +0 -2
  30. package/dist/sqlite/abstractSqliteQuerier.d.ts +1 -2
  31. package/dist/sqlite/abstractSqliteQuerier.js +2 -8
  32. package/dist/sqlite/hranaQuerier.d.ts +5 -0
  33. package/dist/sqlite/hranaQuerier.js +10 -6
  34. package/dist/type/entity.d.ts +34 -11
  35. package/dist/type/querier.d.ts +26 -10
  36. package/dist/type/query.d.ts +22 -9
  37. package/dist/type/queryAggregate.d.ts +67 -24
  38. package/dist/type/queryWhere.d.ts +4 -1
  39. package/dist/type/universalQuerier.d.ts +11 -11
  40. package/dist/util/dialect.util.d.ts +26 -4
  41. package/dist/util/dialect.util.js +42 -0
  42. package/package.json +4 -5
  43. package/dist/querier/querierContext.browser.d.ts +0 -12
  44. package/dist/querier/querierContext.browser.js +0 -18
  45. package/dist/querier/querierContext.d.ts +0 -22
  46. package/dist/querier/querierContext.js +0 -42
  47. package/dist/querier/transactional.d.ts +0 -26
  48. package/dist/querier/transactional.js +0 -43
@@ -27,7 +27,13 @@ export class MySql2Querier extends AbstractPoolQuerier {
27
27
  stream.destroy();
28
28
  }
29
29
  }
30
- async releaseConn(conn) {
30
+ async releaseConn(conn, discard) {
31
+ // mysql2 resets nothing on release, so a connection the pool takes back after a failed rollback
32
+ // hands the next caller someone else's open transaction. `destroy` drops it from the pool instead.
33
+ if (discard) {
34
+ conn.destroy();
35
+ return;
36
+ }
31
37
  await conn.release();
32
38
  }
33
39
  }
@@ -3,8 +3,8 @@ import { Observable } from 'rxjs';
3
3
  import type { UqlContext } from '../type/index.js';
4
4
  /**
5
5
  * Runs each request inside `withContext`, so parameterized/`security` filters (multi-tenancy, RLS)
6
- * are scoped automatically for every query in the request - including relations, cascades, and
7
- * `@Transactional` services. Wired for you by {@link UqlModule.forRoot} when you pass `getContext`.
6
+ * are scoped automatically for every query in the request - including relations, cascades, and the
7
+ * queries a transaction runs. Wired for you by {@link UqlModule.forRoot} when you pass `getContext`.
8
8
  */
9
9
  export declare class UqlContextInterceptor<Req = unknown> implements NestInterceptor {
10
10
  private readonly getContext;
@@ -37,8 +37,8 @@ import { Observable } from 'rxjs';
37
37
  import { withContext } from '../context/context.js';
38
38
  /**
39
39
  * Runs each request inside `withContext`, so parameterized/`security` filters (multi-tenancy, RLS)
40
- * are scoped automatically for every query in the request - including relations, cascades, and
41
- * `@Transactional` services. Wired for you by {@link UqlModule.forRoot} when you pass `getContext`.
40
+ * are scoped automatically for every query in the request - including relations, cascades, and the
41
+ * queries a transaction runs. Wired for you by {@link UqlModule.forRoot} when you pass `getContext`.
42
42
  */
43
43
  let UqlContextInterceptor = (() => {
44
44
  let _classDecorators = [Injectable()];
@@ -2,9 +2,9 @@ import { type DynamicModule, type FactoryProvider } from '@nestjs/common';
2
2
  import type { QuerierPool, UqlContext } from '../type/index.js';
3
3
  /**
4
4
  * Injection token for the configured {@link QuerierPool} - for injecting into your own
5
- * providers. UQL's own machinery (`getQuerier`, `querierMiddleware`, `createFetchHandler`,
6
- * `@Transactional`) reads the default pool set by {@link UqlModule.forRoot}, not this token,
7
- * so overriding the provider does not redirect UQL internals.
5
+ * providers. UQL's own machinery (`getQuerier`, `querierMiddleware`, `createFetchHandler`) reads
6
+ * the default pool set by {@link UqlModule.forRoot}, not this token, so overriding the provider
7
+ * does not redirect UQL internals.
8
8
  */
9
9
  export declare const UQL_QUERIER_POOL: unique symbol;
10
10
  /** Shared by {@link UqlModuleOptions} and {@link UqlModuleAsyncOptions}. */
@@ -38,16 +38,15 @@ import { setQuerierPool } from '../options.js';
38
38
  import { UqlContextInterceptor } from './uqlContextInterceptor.js';
39
39
  /**
40
40
  * Injection token for the configured {@link QuerierPool} - for injecting into your own
41
- * providers. UQL's own machinery (`getQuerier`, `querierMiddleware`, `createFetchHandler`,
42
- * `@Transactional`) reads the default pool set by {@link UqlModule.forRoot}, not this token,
43
- * so overriding the provider does not redirect UQL internals.
41
+ * providers. UQL's own machinery (`getQuerier`, `querierMiddleware`, `createFetchHandler`) reads
42
+ * the default pool set by {@link UqlModule.forRoot}, not this token, so overriding the provider
43
+ * does not redirect UQL internals.
44
44
  */
45
45
  export const UQL_QUERIER_POOL = Symbol('UQL_QUERIER_POOL');
46
46
  /**
47
47
  * NestJS integration: provides the pool via DI, sets it as UQL's default pool (so `getQuerier()`,
48
- * `querierMiddleware` (express platform), `createFetchHandler`, and `@Transactional` work unchanged),
49
- * optionally scopes every request to a {@link UqlContext} (multi-tenancy), and ends the pool on
50
- * application shutdown.
48
+ * `querierMiddleware` (express platform) and `createFetchHandler` work unchanged), optionally scopes
49
+ * every request to a {@link UqlContext} (multi-tenancy), and ends the pool on application shutdown.
51
50
  */
52
51
  /**
53
52
  * Ends the pool when Nest shuts down.
@@ -9,7 +9,8 @@ export interface PgAnyClient {
9
9
  query(stream: object): AsyncIterable<RawRow> & {
10
10
  destroy(): void;
11
11
  };
12
- release(): void | Promise<void>;
12
+ /** Any truthy argument makes `pg-pool` evict the client instead of returning it to the idle list. */
13
+ release(discard?: boolean): void | Promise<void>;
13
14
  }
14
15
  /**
15
16
  * Shared base class for Postgres-compatible queriers (standard pg, CockroachDB, Neon).
@@ -19,5 +20,5 @@ export declare abstract class AbstractPgQuerier<C extends PgAnyClient, D extends
19
20
  internalAll<T>(query: string, values?: unknown[]): Promise<T[]>;
20
21
  internalRun(query: string, values?: unknown[]): Promise<import("../type/query.js").QueryUpdateResult>;
21
22
  internalStream<T>(query: string, values?: unknown[]): AsyncGenerator<Awaited<T>, void, unknown>;
22
- protected releaseConn(conn: C): Promise<void>;
23
+ protected releaseConn(conn: C, discard: boolean): Promise<void>;
23
24
  }
@@ -26,7 +26,7 @@ export class AbstractPgQuerier extends AbstractPoolQuerier {
26
26
  stream.destroy();
27
27
  }
28
28
  }
29
- async releaseConn(conn) {
30
- await conn.release();
29
+ async releaseConn(conn, discard) {
30
+ await conn.release(discard);
31
31
  }
32
32
  }
@@ -8,6 +8,6 @@ export declare abstract class AbstractPoolQuerier<C> extends AbstractSqlQuerier
8
8
  protected getConn(): C;
9
9
  constructor(dialect: AbstractSqlDialect, connect: () => Promise<C>, extra?: ExtraOptions | undefined);
10
10
  protected lazyConnect(): Promise<void>;
11
- internalRelease(): Promise<void>;
12
- protected abstract releaseConn(conn: C): Promise<void>;
11
+ internalRelease(discard: boolean): Promise<void>;
12
+ protected abstract releaseConn(conn: C, discard: boolean): Promise<void>;
13
13
  }
@@ -1,4 +1,3 @@
1
- import { throwPendingTransaction } from '../util/index.js';
2
1
  import { AbstractSqlQuerier } from './abstractSqlQuerier.js';
3
2
  export class AbstractPoolQuerier extends AbstractSqlQuerier {
4
3
  connect;
@@ -15,16 +14,17 @@ export class AbstractPoolQuerier extends AbstractSqlQuerier {
15
14
  this.extra = extra;
16
15
  }
17
16
  async lazyConnect() {
17
+ await super.lazyConnect();
18
18
  this.conn ??= await this.connect();
19
19
  }
20
- async internalRelease() {
21
- if (this.hasOpenTransaction) {
22
- throwPendingTransaction();
23
- }
24
- if (!this.conn) {
20
+ async internalRelease(discard) {
21
+ const conn = this.conn;
22
+ if (!conn) {
25
23
  return;
26
24
  }
27
- await this.releaseConn(this.conn);
25
+ // Cleared even when the hand-back fails: keeping a connection the pool has already been given
26
+ // back means the next `release()` returns it twice, which pg reports as an already-released client.
28
27
  this.conn = undefined;
28
+ await this.releaseConn(conn, discard);
29
29
  }
30
30
  }
@@ -1,4 +1,4 @@
1
- import type { ExtraOptions, IdValue, Querier, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryGroupMap, QueryOne, QueryOptions, QueryPopulate, QuerySearch, QueryUpdateResult, RawRow, RelationKey, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
1
+ import type { EntityData, ExtraOptions, IdValue, Querier, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryGroupMap, QueryOne, QueryOptions, QueryPopulate, QuerySearch, QueryUpdateResult, RawRow, RelationKey, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
2
  import { LoggerWrapper } from '../util/index.js';
3
3
  /**
4
4
  * Base class for all database queriers.
@@ -12,6 +12,11 @@ export declare abstract class AbstractQuerier implements Querier {
12
12
  * and ensuring that the database connection is used safely across concurrent calls.
13
13
  */
14
14
  private taskQueue;
15
+ /**
16
+ * A querier is one unit of work, so releasing ends it. Checked where each backend reaches for its
17
+ * connection, on every driver and not just the pooled ones.
18
+ */
19
+ protected released: boolean;
15
20
  protected readonly logger: LoggerWrapper;
16
21
  constructor(extra?: ExtraOptions | undefined);
17
22
  protected validateProjectionQuery<E extends object>(entity: Type<E>, q: Query<E>): void;
@@ -80,16 +85,16 @@ export declare abstract class AbstractQuerier implements Querier {
80
85
  */
81
86
  aggregate<E extends object, const G extends QueryGroupMap<E>, const A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): Promise<QueryAggregateResult<E, G, A>[]>;
82
87
  protected abstract internalAggregate<E extends object, G extends QueryGroupMap<E>, A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): Promise<QueryAggregateResult<E, G, A>[]>;
83
- insertOne<E extends object>(entity: Type<E>, payload: E): Promise<IdValue<E> | undefined>;
84
- insertMany<E extends object>(entity: Type<E>, payload: E[]): Promise<IdValue<E>[]>;
85
- protected abstract internalInsertMany<E extends object>(entity: Type<E>, payload: E[]): Promise<IdValue<E>[]>;
88
+ insertOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
89
+ insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
90
+ protected abstract internalInsertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
86
91
  updateOneById<E extends object>(entity: Type<E>, id: IdValue<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
87
92
  updateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
88
93
  protected abstract internalUpdateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
89
94
  restoreOneById<E extends object>(entity: Type<E>, id: IdValue<E>): Promise<number>;
90
95
  restoreMany<E extends object>(entity: Type<E>, q: QuerySearch<E>): Promise<number>;
91
- abstract upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E): Promise<QueryUpdateResult>;
92
- abstract upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E[]): Promise<QueryUpdateResult>;
96
+ abstract upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
97
+ abstract upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
93
98
  deleteOneById<E extends object>(entity: Type<E>, id: IdValue<E>, opts?: QueryOptions): Promise<number>;
94
99
  /**
95
100
  * Delete records matching the query. Soft-deletes when the entity has a soft-delete field (unless
@@ -99,9 +104,18 @@ export declare abstract class AbstractQuerier implements Querier {
99
104
  deleteMany<E extends object>(q: QuerySearch<E> & {
100
105
  $entity: Type<E>;
101
106
  }, opts?: QueryOptions): Promise<number>;
107
+ /**
108
+ * The rows a delete is about to take, loaded only when a hook or listener is there to receive
109
+ * them: the round trip is pure overhead for the (common) delete nobody is watching, and
110
+ * `internalDeleteMany` has its own fast path that never reads the rows at all.
111
+ *
112
+ * `undefined` means nobody was watching, which is not the same as the empty array meaning nothing
113
+ * matched - the caller deletes by `q` for the first and skips the statement entirely for the second.
114
+ */
115
+ private findDoomed;
102
116
  protected abstract internalDeleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
103
- saveOne<E extends object>(entity: Type<E>, payload: E): Promise<IdValue<E>>;
104
- saveMany<E extends object>(entity: Type<E>, payload: E[]): Promise<IdValue<E>[]>;
117
+ saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E>>;
118
+ saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
105
119
  protected fillToManyRelations<E>(entity: Type<E>, payload: E[], populate?: QueryPopulate<E>): Promise<void>;
106
120
  private fillToManyThroughRelation;
107
121
  private fillToManyOneToMany;
@@ -121,8 +135,8 @@ export declare abstract class AbstractQuerier implements Querier {
121
135
  * below were got wrong by code that hand-rolled it:
122
136
  *
123
137
  * - `beginTransaction` connects before it begins, so a refused connection lands in the catch with no
124
- * transaction open. Rolling back regardless threw `not a pending transaction`, and that replaced the
125
- * real cause: a wrong password surfaced as a transaction-state error.
138
+ * transaction open. `rollbackTransaction` is a no-op there rather than an error, which is why a
139
+ * wrong password no longer surfaces as a transaction-state error.
126
140
  * - A rollback that fails too is a consequence of the original failure, not news, so it must not
127
141
  * replace it either.
128
142
  *
@@ -132,18 +146,19 @@ export declare abstract class AbstractQuerier implements Querier {
132
146
  * one afterwards.
133
147
  */
134
148
  transaction<T>(callback: () => Promise<T>, opts?: TransactionOptions): Promise<T>;
149
+ /** Whether anything at all - a global listener or the entity itself - handles `event`. */
150
+ private hasHook;
135
151
  /**
136
152
  * Emit a lifecycle hook event for the given entity.
137
153
  * Fires global listeners first, then entity-level hooks.
138
154
  */
139
155
  private emitHook;
140
- releaseIfFree(): Promise<void>;
141
156
  /**
142
- * Schedules a task to be executed serially in the querier instance.
143
- * This is used by the @Serialized decorator to protect database-level operations.
157
+ * Runs `task` after everything already queued on this querier, one at a time.
144
158
  *
145
- * @param task - The async task to execute.
146
- * @returns A promise that resolves with the task's result.
159
+ * @remarks Not re-entrant: only one task runs at a time, so a serialized method awaited from inside
160
+ * another one would wait for a task queued behind itself. Callers below keep their `serialize` calls
161
+ * sequential rather than nested.
147
162
  */
148
163
  protected serialize<T>(task: () => Promise<T>): Promise<T>;
149
164
  /**
@@ -157,9 +172,23 @@ export declare abstract class AbstractQuerier implements Querier {
157
172
  */
158
173
  protected timed<T>(query: string, values: unknown[] | undefined, task: () => Promise<T>): Promise<T>;
159
174
  abstract beginTransaction(opts?: TransactionOptions): Promise<void>;
175
+ /** Strict: this is the check that catches a forgotten `beginTransaction`. */
160
176
  abstract commitTransaction(): Promise<void>;
177
+ /**
178
+ * Rolls the open transaction back, or does nothing when there is none: it is called from `catch` and
179
+ * `finally`, where the caller cannot know whether `beginTransaction` got far enough to open one.
180
+ */
161
181
  abstract rollbackTransaction(): Promise<void>;
162
- protected abstract internalRelease(): Promise<void>;
182
+ /**
183
+ * Rolls back an unfinished transaction, then hands the connection back.
184
+ *
185
+ * @remarks Refusing to release was the opposite of safe: the throw came *before* the connection went
186
+ * back, so it destroyed the error that got here and cost the pool a connection with a live `BEGIN` on
187
+ * it. It is also the only option `await using` can reach, which calls `Symbol.asyncDispose` with no
188
+ * arguments and discards what it returns.
189
+ */
163
190
  release(): Promise<void>;
164
191
  [Symbol.asyncDispose](): Promise<void>;
192
+ /** `discard` means the connection must not be reused; backends with a pool evict it instead. */
193
+ protected abstract internalRelease(discard: boolean): Promise<void>;
165
194
  }
@@ -1,6 +1,20 @@
1
1
  import { getMeta } from '../entity/index.js';
2
- import { asSelectMap, augmentWhere, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, LoggerWrapper, parseRelationAtKey, parseRelationQueryValue, runHooks, } from '../util/index.js';
2
+ import { asSelectMap, augmentWhere, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, LoggerWrapper, parseRelationAtKey, parseRelationQueryValue, runHooks, withoutSoftDeleteFilter, } from '../util/index.js';
3
3
  import { enrichError } from './queryError.js';
4
+ /**
5
+ * Rejects a nullish primary key before it reaches a statement. The by-id methods reduce to
6
+ * `{ $where: id }`, and a nullish `$where` is *no filter*, so an unchecked one addresses the whole
7
+ * table. An entity declares its id optional, which puts `undefined` inside `IdValue<E>`, and the
8
+ * HTTP layer reaches these methods with parsed JSON regardless, so the guard belongs at runtime.
9
+ *
10
+ * Its callers are all `async` so this surfaces as a rejection on every one of them: a guard that
11
+ * threw synchronously from some and rejected from others would escape a caller's `.catch()`.
12
+ */
13
+ function assertIdValue(entity, id) {
14
+ if (id === undefined || id === null) {
15
+ throw new TypeError(`'${entity.name}' was addressed by id, but the id is ${String(id)}`);
16
+ }
17
+ }
4
18
  /**
5
19
  * Base class for all database queriers.
6
20
  * It provides a standardized way to execute tasks serially to prevent race conditions on database connections.
@@ -13,6 +27,11 @@ export class AbstractQuerier {
13
27
  * and ensuring that the database connection is used safely across concurrent calls.
14
28
  */
15
29
  taskQueue = Promise.resolve();
30
+ /**
31
+ * A querier is one unit of work, so releasing ends it. Checked where each backend reaches for its
32
+ * connection, on every driver and not just the pooled ones.
33
+ */
34
+ released = false;
16
35
  logger;
17
36
  constructor(extra) {
18
37
  this.extra = extra;
@@ -60,6 +79,7 @@ export class AbstractQuerier {
60
79
  return [$entity, query, maybeQueryOrOpts];
61
80
  }
62
81
  async findOneById(entity, id, q = {}, opts) {
82
+ assertIdValue(entity, id);
63
83
  return this.findOne(entity, { ...q, $where: augmentWhere(getMeta(entity), q.$where, id) }, opts);
64
84
  }
65
85
  async findOne(entityOrQuery, maybeQueryOrOpts, maybeOpts) {
@@ -110,7 +130,8 @@ export class AbstractQuerier {
110
130
  await this.emitHook(entity, 'afterInsert', payload);
111
131
  return ids;
112
132
  }
113
- updateOneById(entity, id, payload, opts) {
133
+ async updateOneById(entity, id, payload, opts) {
134
+ assertIdValue(entity, id);
114
135
  return this.updateMany(entity, { $where: id }, payload, opts);
115
136
  }
116
137
  async updateMany(entity, q, payload, opts) {
@@ -119,7 +140,8 @@ export class AbstractQuerier {
119
140
  await this.emitHook(entity, 'afterUpdate', [payload]);
120
141
  return changes;
121
142
  }
122
- restoreOneById(entity, id) {
143
+ async restoreOneById(entity, id) {
144
+ assertIdValue(entity, id);
123
145
  return this.restoreMany(entity, { $where: id });
124
146
  }
125
147
  async restoreMany(entity, q) {
@@ -132,16 +154,41 @@ export class AbstractQuerier {
132
154
  filters: { softDelete: false },
133
155
  });
134
156
  }
135
- deleteOneById(entity, id, opts) {
157
+ async deleteOneById(entity, id, opts) {
158
+ assertIdValue(entity, id);
136
159
  return this.deleteMany(entity, { $where: id }, opts);
137
160
  }
138
161
  async deleteMany(entityOrQuery, qOrOpts, maybeOpts) {
139
162
  const [entity, q, opts] = this.resolveEntityQuery(entityOrQuery, qOrOpts, maybeOpts);
140
- await this.emitHook(entity, 'beforeDelete', []);
141
- const changes = await this.internalDeleteMany(entity, q, opts);
142
- await this.emitHook(entity, 'afterDelete', []);
163
+ const doomed = await this.findDoomed(entity, q, opts);
164
+ if (doomed?.length === 0) {
165
+ return 0;
166
+ }
167
+ // Where a snapshot was taken, the statement names those rows rather than resolving `q` a second
168
+ // time. Two reads of one `$limit` with no total order are free to disagree, which would fire the
169
+ // hooks for one row and delete another; naming them also spares the second read.
170
+ const target = doomed ? { $where: doomed.map((it) => it[getMeta(entity).id]) } : q;
171
+ await this.emitHook(entity, 'beforeDelete', doomed ?? []);
172
+ const changes = await this.internalDeleteMany(entity, target, opts);
173
+ await this.emitHook(entity, 'afterDelete', doomed ?? []);
143
174
  return changes;
144
175
  }
176
+ /**
177
+ * The rows a delete is about to take, loaded only when a hook or listener is there to receive
178
+ * them: the round trip is pure overhead for the (common) delete nobody is watching, and
179
+ * `internalDeleteMany` has its own fast path that never reads the rows at all.
180
+ *
181
+ * `undefined` means nobody was watching, which is not the same as the empty array meaning nothing
182
+ * matched - the caller deletes by `q` for the first and skips the statement entirely for the second.
183
+ */
184
+ async findDoomed(entity, q, opts) {
185
+ if (!this.hasHook(entity, 'beforeDelete') && !this.hasHook(entity, 'afterDelete')) {
186
+ return undefined;
187
+ }
188
+ // A hard delete takes already-soft-deleted rows too, so reading them back has to see them.
189
+ const findOpts = opts?.hardDelete ? { ...opts, filters: withoutSoftDeleteFilter(opts.filters) } : opts;
190
+ return this.internalFindMany(entity, q, findOpts);
191
+ }
145
192
  async saveOne(entity, payload) {
146
193
  const [id] = await this.saveMany(entity, [payload]);
147
194
  return id;
@@ -365,8 +412,8 @@ export class AbstractQuerier {
365
412
  * below were got wrong by code that hand-rolled it:
366
413
  *
367
414
  * - `beginTransaction` connects before it begins, so a refused connection lands in the catch with no
368
- * transaction open. Rolling back regardless threw `not a pending transaction`, and that replaced the
369
- * real cause: a wrong password surfaced as a transaction-state error.
415
+ * transaction open. `rollbackTransaction` is a no-op there rather than an error, which is why a
416
+ * wrong password no longer surfaces as a transaction-state error.
370
417
  * - A rollback that fails too is a consequence of the original failure, not news, so it must not
371
418
  * replace it either.
372
419
  *
@@ -386,50 +433,42 @@ export class AbstractQuerier {
386
433
  return res;
387
434
  }
388
435
  catch (err) {
389
- if (this.hasOpenTransaction) {
390
- await this.rollbackTransaction().catch(() => { });
391
- }
436
+ // Reported rather than thrown: the error being unwound is the useful one. Inline rather than
437
+ // shared with `release` below, because this method is grafted onto plain objects in tests and
438
+ // every `this.x` it reaches for has to exist there too.
439
+ await this.rollbackTransaction().catch((rollbackErr) => {
440
+ this.logger.logError('rollback failed', rollbackErr);
441
+ });
392
442
  throw err;
393
443
  }
394
444
  }
445
+ /** Whether anything at all - a global listener or the entity itself - handles `event`. */
446
+ hasHook(entity, event) {
447
+ return (this.extra?.listeners?.some((listener) => listener[event]) || (getMeta(entity).hooks?.[event]?.length ?? 0) > 0);
448
+ }
395
449
  /**
396
450
  * Emit a lifecycle hook event for the given entity.
397
451
  * Fires global listeners first, then entity-level hooks.
398
452
  */
399
453
  async emitHook(entity, event, payloads) {
400
- const listeners = this.extra?.listeners;
401
- const meta = getMeta(entity);
402
- const registrations = meta.hooks?.[event];
403
- // Fast bail-out: skip if no listeners and no entity hooks
404
- if (!listeners?.length && !registrations?.length)
454
+ if (!this.hasHook(entity, event))
405
455
  return;
406
- // Fire global listeners first
407
- if (listeners?.length) {
408
- for (const listener of listeners) {
409
- const fn = listener[event];
410
- if (fn) {
411
- const result = fn({ entity, querier: this, payloads, event });
412
- if (result instanceof Promise)
413
- await result;
414
- }
456
+ for (const listener of this.extra?.listeners ?? []) {
457
+ const fn = listener[event];
458
+ if (fn) {
459
+ const result = fn({ entity, querier: this, payloads, event });
460
+ if (result instanceof Promise)
461
+ await result;
415
462
  }
416
463
  }
417
- // Fire entity-level hooks
418
- if (registrations?.length) {
419
- await runHooks(entity, event, payloads, { querier: this });
420
- }
421
- }
422
- async releaseIfFree() {
423
- if (!this.hasOpenTransaction) {
424
- await this.internalRelease();
425
- }
464
+ await runHooks(entity, event, payloads, { querier: this });
426
465
  }
427
466
  /**
428
- * Schedules a task to be executed serially in the querier instance.
429
- * This is used by the @Serialized decorator to protect database-level operations.
467
+ * Runs `task` after everything already queued on this querier, one at a time.
430
468
  *
431
- * @param task - The async task to execute.
432
- * @returns A promise that resolves with the task's result.
469
+ * @remarks Not re-entrant: only one task runs at a time, so a serialized method awaited from inside
470
+ * another one would wait for a task queued behind itself. Callers below keep their `serialize` calls
471
+ * sequential rather than nested.
433
472
  */
434
473
  async serialize(task) {
435
474
  const res = this.taskQueue.then(task);
@@ -457,8 +496,28 @@ export class AbstractQuerier {
457
496
  this.logger.logQuery(query, values, Math.round(performance.now() - startTime));
458
497
  }
459
498
  }
499
+ /**
500
+ * Rolls back an unfinished transaction, then hands the connection back.
501
+ *
502
+ * @remarks Refusing to release was the opposite of safe: the throw came *before* the connection went
503
+ * back, so it destroyed the error that got here and cost the pool a connection with a live `BEGIN` on
504
+ * it. It is also the only option `await using` can reach, which calls `Symbol.asyncDispose` with no
505
+ * arguments and discards what it returns.
506
+ */
460
507
  async release() {
461
- return this.serialize(() => this.internalRelease());
508
+ let discard = false;
509
+ if (this.hasOpenTransaction) {
510
+ this.logger.logWarn('rolling back a transaction left open at release');
511
+ // The rollback doubles as a health check. One that succeeds proves the connection round-trips and
512
+ // left no transaction behind, so it is safe to reuse. One that fails leaves a session state
513
+ // nothing here can name, and the next borrower would inherit it.
514
+ await this.rollbackTransaction().catch((err) => {
515
+ this.logger.logError('rollback failed; discarding the connection', err);
516
+ discard = true;
517
+ });
518
+ }
519
+ this.released = true;
520
+ return this.serialize(() => this.internalRelease(discard));
462
521
  }
463
522
  async [Symbol.asyncDispose]() {
464
523
  return this.release();
@@ -1,5 +1,5 @@
1
1
  import type { AbstractDialect } from '../dialect/index.js';
2
- import type { ExtraOptions, IdValue, PoolRunOptions, Querier, QuerierPool, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryGroupMap, QueryOne, QueryOptions, QuerySearch, QueryUpdateResult, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
+ import type { EntityData, ExtraOptions, IdValue, PoolRunOptions, Querier, QuerierPool, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryGroupMap, QueryOne, QueryOptions, QuerySearch, QueryUpdateResult, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
3
3
  /**
4
4
  * Base pool: dialect id and behavior come only from the `dialect` instance (see {@link QuerierPool}).
5
5
  */
@@ -35,14 +35,14 @@ export declare abstract class AbstractQuerierPool<Q extends Querier, D extends A
35
35
  findManyAndCount<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<[E[], number]>;
36
36
  count<E extends object>(entity: Type<E>, q?: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
37
37
  aggregate<E extends object, const G extends QueryGroupMap<E>, const A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): Promise<QueryAggregateResult<E, G, A>[]>;
38
- insertOne<E extends object>(entity: Type<E>, payload: E): Promise<IdValue<E> | undefined>;
39
- insertMany<E extends object>(entity: Type<E>, payload: E[]): Promise<IdValue<E>[]>;
38
+ insertOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E> | undefined>;
39
+ insertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
40
40
  updateOneById<E extends object>(entity: Type<E>, id: IdValue<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
41
41
  updateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
42
- upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E): Promise<QueryUpdateResult>;
43
- upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E[]): Promise<QueryUpdateResult>;
44
- saveOne<E extends object>(entity: Type<E>, payload: E): Promise<IdValue<E>>;
45
- saveMany<E extends object>(entity: Type<E>, payload: E[]): Promise<IdValue<E>[]>;
42
+ upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
43
+ upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
44
+ saveOne<E extends object>(entity: Type<E>, payload: EntityData<E>): Promise<IdValue<E>>;
45
+ saveMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
46
46
  deleteOneById<E extends object>(entity: Type<E>, id: IdValue<E>, opts?: QueryOptions): Promise<number>;
47
47
  deleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
48
48
  restoreOneById<E extends object>(entity: Type<E>, id: IdValue<E>): Promise<number>;
@@ -1,5 +1,5 @@
1
1
  import type { AbstractSqlDialect } from '../dialect/index.js';
2
- import type { ExtraOptions, IdValue, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryGroupMap, QueryOptions, QuerySearch, QueryUpdateResult, SqlQuerier, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
+ import type { EntityData, ExtraOptions, IdValue, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryGroupMap, QueryOptions, QuerySearch, QueryUpdateResult, SqlQuerier, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
3
3
  import type { BuildUpdateResultPayload } from '../util/sql.util.js';
4
4
  import { AbstractQuerier } from './abstractQuerier.js';
5
5
  export declare abstract class AbstractSqlQuerier extends AbstractQuerier implements SqlQuerier {
@@ -27,8 +27,9 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
27
27
  */
28
28
  protected loadInsertIdIncrement(): Promise<number>;
29
29
  /**
30
- * Hook for subclasses (e.g. pool queriers) to establish a connection.
31
- * Called before every query but outside the timing window.
30
+ * Hook for subclasses (e.g. pool queriers) to establish a connection. Called before every query and
31
+ * before `BEGIN`, outside the timing window, which makes it the one place a released querier is
32
+ * caught for every SQL backend.
32
33
  */
33
34
  protected lazyConnect(): Promise<void>;
34
35
  all<T>(query: string, values?: unknown[]): Promise<T[]>;
@@ -63,13 +64,23 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
63
64
  private hydrateFields;
64
65
  protected internalCount<E extends object>(entity: Type<E>, q?: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
65
66
  protected internalAggregate<E extends object, G extends QueryGroupMap<E>, A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): Promise<QueryAggregateResult<E, G, A>[]>;
66
- internalInsertMany<E extends object>(entity: Type<E>, payload: E[]): Promise<IdValue<E>[]>;
67
+ internalInsertMany<E extends object>(entity: Type<E>, payload: EntityData<E>[]): Promise<IdValue<E>[]>;
67
68
  internalUpdateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
68
- upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E): Promise<QueryUpdateResult>;
69
- upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E[]): Promise<QueryUpdateResult>;
69
+ /** The ids matching `q`, in `q`'s own order and page, so a write can name the rows it settled on. */
70
+ private settleIds;
71
+ upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
72
+ upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
70
73
  protected internalDeleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
71
74
  get hasOpenTransaction(): boolean;
72
75
  beginTransaction(opts?: TransactionOptions): Promise<void>;
73
76
  commitTransaction(): Promise<void>;
74
77
  rollbackTransaction(): Promise<void>;
78
+ /**
79
+ * Only a statement that succeeded ends the transaction. A `COMMIT` that fails can leave it open
80
+ * (SQLite answers `SQLITE_BUSY` and keeps it), so the flag has to stay set for the `catch` in
81
+ * {@link AbstractQuerier.transaction} or {@link AbstractQuerier.release} to roll it back.
82
+ */
83
+ private endTransactionWith;
84
+ /** Transaction statements skip `timed()`, so they attach their own query context to a failure. */
85
+ private runTransactionCommand;
75
86
  }