@avytrix/nestjs-drizzle-crud 3.7.2 → 3.8.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/AGENT.md CHANGED
@@ -189,7 +189,10 @@ Operators inside an object value: `eq`, `gt`, `gte`, `lt`, `lte`, `neq`, `like`,
189
189
  `sql.caseSensitive`), `iStartsWith`, `iEndsWith`, `iContains` (always
190
190
  case-insensitive), and on PostgreSQL only `arrayContains`, `arrayOverlaps` and
191
191
  `jsonContains`. Any other operator is a 400. A bare string is an exact match,
192
- not a pattern, and `%` in it is a literal character. `isNull` and `isNotNull`
192
+ not a pattern, and `%` in it is a literal character. On a text column it is
193
+ `lower(col) = lower($1)` in `findAll` and `count` (index `lower(col)`), but a
194
+ plain, case-sensitive `=` in `findOne`. https://crud.avytrix.dev/docs/indexes/
195
+ lists the index each query shape needs; the package creates none. `isNull` and `isNotNull`
193
196
  take `true` / `false` or the strings `"true"` / `"false"`.
194
197
 
195
198
  OR and NOT need no raw Drizzle. `and`, `or` and `not` group filter objects, nest,
package/CHANGELOG.md CHANGED
@@ -11,6 +11,93 @@ restore, `ER_DUP_ENTRY` mapping, dialect locking) are tracked separately and unc
11
11
  3.3.1, 3.4.0, 3.5.0 and 3.5.2 were tagged but never published to npm. Their changes reached npm
12
12
  in 3.5.1 (3.3.1 to 3.5.0) and 3.6.0 (3.5.2). The unscoped `nestjs-drizzle-crud` stops at 3.3.0.
13
13
 
14
+ ## [3.8.0] - 2026-11-03
15
+
16
+ Performance fixes from the 3.7.1 performance report. Calls return the same rows, in the same
17
+ order, with the same errors, in fewer statements and shorter transactions. The one visible
18
+ difference is under Changed: a row soft-deleted between the guard read and the write is now a 404
19
+ instead of a write. Inside `SqlBaseCrudService` the six mass writes share one skeleton, and so
20
+ do `softDelete`, `restore` and `delete`; no public API, override point or message changed.
21
+
22
+ ### Added
23
+
24
+ - Full-text search takes a column that already is a `tsvector`, such as a generated, stored
25
+ column, and uses it as is instead of wrapping it in `to_tsvector`, so a GIN index on the column
26
+ serves `fullTextSearch()` and `search.mode: "fullText"`. Before, such a column failed with
27
+ `function to_tsvector(unknown, tsvector) does not exist`. Next to text columns it is
28
+ `coalesce(col, ''::tsvector)`; text columns keep the 3.7.2 vector, bare for one column and
29
+ coalesced for several. On 200,000 rows a search on the stored column with its index took
30
+ 0.6 ms, against 600 to 900 ms computing `to_tsvector` per row without one. The full-text page
31
+ shows the index for each shape.
32
+ - A docs page, [Database indexes for fast Drizzle queries](https://crud.avytrix.dev/docs/indexes/),
33
+ lists the index each query shape needs: `lower(col)` (or `citext`, or `caseSensitive: true`) for
34
+ the default case-insensitive equality, the sort columns plus the primary key for cursor pages,
35
+ GIN for full-text search, `pg_trgm` for `contains` and `search`, foreign keys and both join
36
+ table columns for relations, partial indexes on `deleted_at IS NULL`, and the tenant column
37
+ first. It says when to use `withCount: false` or the cursor on a large table. The filtering
38
+ page now spells out that `findOne` compares strings with a plain, case-sensitive `=` while
39
+ `findAll` and `count` use `lower()` by default (unchanged; a default change waits for a major),
40
+ and the hooks page says where after hooks run: after the commit for a single write, inside
41
+ the transaction for a mass write.
42
+
43
+ ### Changed
44
+
45
+ - A row soft-deleted between the guard read and the write is no longer written: `update` and
46
+ `softDelete` only touch a live row, `restore` only a row in the trash, and `delete` no longer
47
+ hard-deletes a row that was soft-deleted meanwhile (`forceDelete` still does). Such a call is a
48
+ 404 instead of a write, a `false` return or a "Failed to update entity" error, and one whose
49
+ row vanished with an expected version is a 404 instead of a 409.
50
+
51
+ ### Fixed
52
+
53
+ - Keyset pages seek instead of filtering. `findByCursor` adds a bound on the first sort column
54
+ (`col <= $1` descending, `col >= $1` ascending) ahead of the OR expansion, drops the `IS NULL`
55
+ branch for NOT NULL and primary key columns (nullable and relation columns keep it), and on
56
+ PostgreSQL compares `(a, id) > ($1, $2)` as one row value when every column is NOT NULL and
57
+ sorts the same way. With an index on the sort columns and the primary key, page 9,000 of
58
+ 200,000 rows went from 24.4 ms to 0.6 ms (`created_at` descending) and from 50.3 ms to 0.5 ms
59
+ (`views` ascending) on PGlite. A sort column the row already carries is no longer selected a
60
+ second time for the cursor. Pages, their order and cursors are unchanged.
61
+ - Single-row writes skip the read first. On PostgreSQL and SQLite, `update`, `softDelete`,
62
+ `restore`, `delete` and `forceDelete` send one statement whose `WHERE` holds the whole guard
63
+ (primary key, tenant scope, expected version, soft delete state) and read the row only when
64
+ nothing came back, to answer with the same 404 or 409 as before. The read still runs first
65
+ when something a caller can observe sits between it and the write (an overridden `before*`
66
+ hook, `validateUpdate` or `mapUpdateDtoToEntity`, or a before event), so hooks and events see
67
+ exactly what they saw before, and on MySQL. A missing row is still a 404 ahead of a bad body.
68
+ - Mass writes send no savepoint on the happy path when they open their own transaction:
69
+ `massUpdate`, `massSoftDelete`, `massRestore` and `massDelete` take 4 round trips instead of 8
70
+ (`BEGIN`, `SELECT`, the set statement, `COMMIT`), `massCreate` and `massUpsert` 3 instead of 5.
71
+ When the set statement fails, the transaction rolls back and the items are replayed in a fresh
72
+ transaction that is rolled back too, so `BulkOperationException` names the same items with the
73
+ same statuses and messages. Inside a caller's transaction, or once a before event handed the
74
+ transaction to a subscriber, the savepoints stay, so a failed item does not abort the caller's
75
+ PostgreSQL transaction.
76
+ - `massUpdate` sends one `UPDATE` per distinct SET when `beforeUpdate` returns different data
77
+ per id, instead of one `UPDATE` and a savepoint pair per id: a hook with two results over 50
78
+ ids is 5 round trips instead of 155. Rows still come back in the input order, after hooks run
79
+ in that order, and a failure still names every failing id.
80
+ - On MySQL, `massCreate` of rows with auto-increment keys reads the inserted rows back with one
81
+ `SELECT ... WHERE id IN (...)` instead of one per row. The inserts stay one per row: MySQL only
82
+ reports the first id of a multi-row `INSERT`, and InnoDB does not promise the others are
83
+ consecutive.
84
+ - Relation loaders and relation counts split a parent key list that would not fit in one
85
+ statement. `loadBelongsTo`, `loadHasMany`, `loadManyToMany` and `loadCounts` send the keys in
86
+ chunks of 64,535 on PostgreSQL and MySQL, 31,766 on SQLite and 90 on Cloudflare D1, one query per
87
+ chunk, so a nested page whose level holds 70,000 keys loads in two queries instead of failing
88
+ with a 400 (3.7.2) or a 500 (before). Below that, it is one query per relation as before. The
89
+ chunk size is the new protected `relationChunkSize(db)`.
90
+ - Fewer statements in a few calls. `exists(id, options)` is one `SELECT` and no longer loads
91
+ `options.relations` or `options.counts` it throws away (4 statements to 1 with two relations and
92
+ a count); they are still checked against the allowlists. A nested create skips the "already
93
+ linked?" read for its many-to-many ids. `attach`, `sync` and `detach` read the row once and load
94
+ the relation onto it, instead of reading it before and after (8, 9 and 6 round trips to 7, 8 and
95
+ 5). A mass write that opened its own transaction loads `options.relations` and `options.counts`
96
+ after it commits, so it holds its row locks for less time; with a count filter, or in a caller's
97
+ transaction, they still load inside. The returned rows are the same.
98
+ - A `scope` function runs once per call instead of once per statement and relation: 5 times for
99
+ a `findAll` with three relations and a count, 7 for a `massUpdate`, now 1 each.
100
+
14
101
  ## [3.7.2] - 2026-11-03
15
102
 
16
103
  Fixes from the 3.7.1 audit. The batch limit now counts the statement the database receives.
@@ -335,7 +422,8 @@ app.
335
422
  only operator on MySQL is a 400.
336
423
  - **Keyset pagination.** `findByCursor(filters, { cursor, limit, sortBy, count }, options)` returns
337
424
  `{ data, limit, nextCursor, total? }`. The cursor is base64url JSON of the last row's sort values
338
- plus the primary key, so pages stay stable under inserts and deep pages cost the same as the
425
+ plus the primary key, so pages stay stable under inserts and, with an index on the sort columns
426
+ (since 3.8.0), deep pages cost the same as the
339
427
  first. It works with every sort, filter, relation and `select`, keeps timestamp microseconds and
340
428
  follows the database's NULL ordering. The count only runs with `count: true`. A cursor that is not
341
429
  valid, or was made for another sort, is a 400.
package/README.md CHANGED
@@ -563,6 +563,12 @@ instead of returning an arbitrary row.
563
563
  > `like` / `ilike` operators and supply your own wildcards, or `startsWith` /
564
564
  > `endsWith` / `contains`, which escape the value for you.
565
565
 
566
+ `findOne` is the exception: it compiles a string to a plain `=`, so it is
567
+ case-sensitive whatever `sql.caseSensitive` says. A plain index on the column
568
+ cannot serve `lower(column) = lower($1)`; create one on `lower(column)`. The
569
+ [indexes page](https://crud.avytrix.dev/docs/indexes/) lists the index each
570
+ filter, sort, search and relation needs.
571
+
566
572
  ---
567
573
 
568
574
  ## Query strings: parseQuery
@@ -654,7 +660,9 @@ in `forRoot` to apply a `created_at` fallback to every entity that doesn't defin
654
660
  ## Cursor pagination
655
661
 
656
662
  `findByCursor` pages with `WHERE (sort columns) > (last row)` instead of
657
- `OFFSET`, so deep pages stay fast and rows inserted earlier do not shift the
663
+ `OFFSET`, starting with a bound on the first sort column, so with an index on
664
+ the sort columns plus the primary key (`(created_at DESC, id)` below) deep
665
+ pages stay as fast as the first, and rows inserted earlier do not shift the
658
666
  next page:
659
667
 
660
668
  ```typescript
@@ -931,7 +939,9 @@ Each bulk method is one statement for the whole set (plus one `SELECT` that
931
939
  checks the ids exist), inside a single transaction: `massUpdate` over 100 ids is
932
940
  two queries. If any item fails, a `BulkOperationException` (400) is thrown and
933
941
  the transaction rolls back. The failing items are found by replaying the batch
934
- inside a savepoint that is rolled back, and the response body lists only them:
942
+ in a transaction (or, inside your own transaction, a savepoint) that is rolled
943
+ back, and the response body lists only them. The happy path sends no savepoint
944
+ unless it runs inside your transaction:
935
945
 
936
946
  ```json
937
947
  { "statusCode": 400, "error": "Bad Request",
@@ -26,6 +26,8 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
26
26
  protected checkReadOptions(options?: SqlOperationOptions): void;
27
27
  protected rowScope(options?: SqlOperationOptions): any[];
28
28
  protected resolveScope(options?: SqlOperationOptions): CrudScopeValues | null;
29
+ private withScopeMemo;
30
+ private checkScope;
29
31
  protected scopeConditions(options?: SqlOperationOptions, table?: any): any[];
30
32
  protected relatedRowScope(target: any, options?: SqlOperationOptions): any[];
31
33
  protected relatedScopeConditions(target: any, options?: SqlOperationOptions): any[];
@@ -61,6 +63,7 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
61
63
  protected beforeRestore(_id: any): Promise<void>;
62
64
  protected afterRestore(_entity: T): Promise<void>;
63
65
  protected relationType(relation: RelationConfig): RelationType;
66
+ private relationNamed;
64
67
  protected isCollection(relation: RelationConfig): boolean;
65
68
  protected eagerRelations(relations: string[]): string[];
66
69
  protected relationTree(paths: string[], configured?: RelationsConfig): RelationNode[];
@@ -78,7 +81,12 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
78
81
  protected loadRelations(db: any, hosts: any[], nodes: RelationNode[], select?: SelectTree, hostTable?: any, options?: SqlOperationOptions): Promise<void>;
79
82
  protected loadBelongsTo(db: any, hosts: any[], node: RelationNode, select: SelectTree, options?: SqlOperationOptions): Promise<void>;
80
83
  protected loadHasMany(db: any, hosts: any[], node: RelationNode, select: SelectTree, hostTable?: any, options?: SqlOperationOptions): Promise<void>;
84
+ private loaderKeys;
85
+ private loadCollection;
86
+ private selectThrough;
81
87
  protected loadManyToMany(db: any, hosts: any[], node: RelationNode, select: SelectTree, hostTable?: any, options?: SqlOperationOptions): Promise<void>;
88
+ protected relationChunkSize(db: any): number;
89
+ private inChunks;
82
90
  private groupCollection;
83
91
  protected applyJoins(query: any, relationNames: string[], options?: SqlOperationOptions): any;
84
92
  protected normalizeRelations(rows: any[], eager: string[]): any[];
@@ -111,6 +119,7 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
111
119
  vector: any;
112
120
  query: any;
113
121
  };
122
+ private isTsVector;
114
123
  protected buildSearchCondition(search?: SqlOperationOptions["search"]): any;
115
124
  protected caseInsensitiveLike(column: any, pattern: string): any;
116
125
  protected likePattern(column: any, pattern: string): any;
@@ -118,14 +127,20 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
118
127
  idKind(): "int" | "bigint" | "uuid" | "string";
119
128
  parseId(raw: unknown): Promise<unknown>;
120
129
  private findForGuard;
130
+ private guardRow;
131
+ private skipsGuard;
132
+ private guardOnError;
121
133
  protected assertVersion(id: any, actual: unknown, expected: unknown): void;
122
134
  protected writeWhere(ids: any[], options?: SqlOperationOptions, table?: any): any;
123
135
  protected withVersionBump(set: Record<string, any>): Record<string, any>;
124
136
  find(id: any, options?: SqlOperationOptions): Promise<T | null>;
125
137
  findOne(where: Partial<T>, options?: SqlOperationOptions): Promise<T | null>;
126
138
  private findFirst;
139
+ private readFirst;
140
+ private hydrate;
127
141
  private planList;
128
142
  private listConditions;
143
+ private parseCount;
129
144
  private buildCountQuery;
130
145
  findAll(filters: FilterDto | undefined, pagination: PaginationOptions & {
131
146
  withCount: false;
@@ -138,6 +153,10 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
138
153
  protected cursorField(column: any): any;
139
154
  private compareCursor;
140
155
  protected keysetCondition(specs: SortSpec[], values: unknown[]): any;
156
+ private keysetBound;
157
+ private keysetRowComparison;
158
+ private keysetNullable;
159
+ private nullsFirst;
141
160
  private afterCursor;
142
161
  protected emitEvent(phase: "before" | "after", operation: CrudWriteOperation, bulk: boolean, payload: {
143
162
  ids?: unknown[];
@@ -147,6 +166,8 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
147
166
  }, options?: SqlOperationOptions): Promise<void>;
148
167
  private idsOf;
149
168
  protected loadWriteRelations(db: any, rows: T[], options?: SqlOperationOptions): Promise<void>;
169
+ private loadsAfterCommit;
170
+ private writeThenLoad;
150
171
  private expectedVersionOf;
151
172
  protected prepareCreate(data: CreateDto, options?: SqlOperationOptions): Promise<Record<string, any>>;
152
173
  private insertOne;
@@ -171,6 +192,8 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
171
192
  private updateWhereIn;
172
193
  private fetchByIds;
173
194
  private inIdOrder;
195
+ private guardedWrite;
196
+ private staleOrFalse;
174
197
  softDelete(id: any, options?: SqlOperationOptions): Promise<boolean>;
175
198
  private softDeleteSet;
176
199
  restore(id: any, options?: SqlOperationOptions): Promise<T>;
@@ -178,14 +201,18 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
178
201
  forceDelete(id: any, options?: SqlOperationOptions): Promise<boolean>;
179
202
  upsert(data: CreateDto, upsert?: UpsertOptions, options?: SqlOperationOptions): Promise<T>;
180
203
  massUpsert(data: CreateDto[], upsert?: UpsertOptions, options?: SqlOperationOptions): Promise<T[]>;
204
+ private upsertSet;
181
205
  private conflictKeys;
182
206
  private conflictValue;
183
207
  private upsertRows;
184
208
  private excludedValue;
209
+ private massWrite;
185
210
  massCreate(data: CreateDto[], options?: SqlOperationOptions): Promise<T[]>;
186
211
  private insertMany;
212
+ private readBackCreated;
187
213
  private assertAllWritten;
188
214
  massUpdate(ids: any[], data: UpdateDto, options?: SqlOperationOptions): Promise<T[]>;
215
+ private updateSets;
189
216
  private runKeyed;
190
217
  massSoftDelete(ids: any[], options?: SqlOperationOptions): Promise<boolean>;
191
218
  massRestore(ids: any[], options?: SqlOperationOptions): Promise<T[]>;
@@ -201,12 +228,9 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
201
228
  id?: any;
202
229
  run: (sp: any) => Promise<unknown>;
203
230
  }>, operation: "write" | "delete"): Promise<R>;
231
+ private settleSet;
204
232
  private diagnose;
205
- private massCreatePerItem;
206
- private massUpdatePerItem;
207
- private massSoftDeletePerItem;
208
- private massRestorePerItem;
209
- private massDeletePerItem;
233
+ private eachInSavepoint;
210
234
  protected savepoint<R>(tx: any, run: (sp: any) => Promise<R>): Promise<R>;
211
235
  exists(id: any, options?: SqlOperationOptions): Promise<boolean>;
212
236
  count(filters?: FilterDto, options?: SqlOperationOptions): Promise<number>;
@@ -215,6 +239,7 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
215
239
  protected countNames(counts?: RelationCounts): string[];
216
240
  protected withCountKeys(base: string[], counts?: RelationCounts): string[];
217
241
  protected loadCounts(db: any, rows: any[], counts: RelationCounts | undefined, options?: SqlOperationOptions): Promise<void>;
242
+ private countPlan;
218
243
  private countQuery;
219
244
  protected pushColumnCondition(conditions: any[], column: any, value: any, caseInsensitive?: boolean): void;
220
245
  protected buildCollectionFilter(relation: RelationConfig, value: Record<string, any>, db?: any, name?: string, options?: SqlOperationOptions): any | undefined;
@@ -241,6 +266,7 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
241
266
  private patternCondition;
242
267
  protected applyComplexFilter(conditions: any[], column: any, filterObj: any): void;
243
268
  protected executeSqlTransaction<R>(operation: (tx: any) => Promise<R>, existingTransaction?: any): Promise<R>;
269
+ private readonly ownedTransactions;
244
270
  protected assertTransactions(db: any): void;
245
271
  protected getEntityName(): string;
246
272
  fullTextSearch(searchTerm: string, searchColumns: string[], pagination?: PaginationOptions, options?: FullTextSearchOptions<FilterDto>): Promise<{