@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.
|
|
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
|
|
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`,
|
|
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
|
-
|
|
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
|
|
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<{
|