@avytrix/nestjs-drizzle-crud 3.7.1 → 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,
@@ -390,8 +393,10 @@ consumer
390
393
  The scope is ANDed into every read, count and write (bulk, upsert, soft delete,
391
394
  restore, force delete, attach included) and written onto every created row, so
392
395
  another tenant's id is a 404. A missing context value is a 403, never an
393
- unscoped query. Do not add `tenant_id` to filters or DTOs by hand, and use
394
- `skipScope: true` only in trusted server code.
396
+ unscoped query. A belongs-to key a write sets must point at a row of the
397
+ caller's tenant when the related table has the scope columns; another tenant's
398
+ row is the same 400 as a missing one. Do not add `tenant_id` to filters or DTOs
399
+ by hand, and use `skipScope: true` only in trusted server code.
395
400
 
396
401
  # Optimistic locking
397
402
 
@@ -434,7 +439,8 @@ never reaches the caller as a 500: a unique violation is a 409, a value that is
434
439
  too long, fails a check, has the wrong type or is not a valid date is a 400 (on
435
440
  reads too), a foreign key pointing at a row that does not exist is a 400, and a
436
441
  delete blocked by dependent rows is a 409. The same holds on MySQL (strict mode
437
- errors included) and SQLite (constraint errors).
442
+ errors included) and SQLite (constraint errors, and the `STRICT` and
443
+ `SQLITE_MISMATCH` type errors).
438
444
 
439
445
  ---
440
446
 
@@ -453,6 +459,9 @@ whole set (plus one `SELECT` that checks the ids), so never loop `create` or
453
459
  `update` over an array yourself. The 400 from `BulkOperationException` lists
454
460
  only the failed items: `errors: [{ index | id, status, message }]`.
455
461
  `bulkMode: 'perItem'` runs one single-row call per item when a hook needs it.
462
+ One statement may bind at most 65,535 values on PostgreSQL and MySQL, 32,766 on
463
+ SQLite and 100 on Cloudflare D1; past that the call is a 400 before it runs, so
464
+ split very large batches into chunks.
456
465
 
457
466
  To insert or update by a unique key, use
458
467
  `upsert(dto, { conflictTarget: 'email' })` or `massUpsert(rows, ...)` rather
package/CHANGELOG.md CHANGED
@@ -11,6 +11,152 @@ 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
+
101
+ ## [3.7.2] - 2026-11-03
102
+
103
+ Fixes from the 3.7.1 audit. The batch limit now counts the statement the database receives.
104
+
105
+ ### Changed
106
+
107
+ - The bound parameter limit is checked per statement, against the database's own limit:
108
+ 65,535 on PostgreSQL and MySQL, 32,766 on SQLite and libSQL, 100 on Cloudflare D1. The service
109
+ counts the parameters of every statement as Drizzle builds it, so two OR'd `IN` lists, the
110
+ version checks of a `massUpdate` with `expectedVersions`, the children of a nested create and
111
+ the targets of `attach` and `sync` all count, and a statement past the limit is a 400 that
112
+ names its count and the limit before it runs. Before, each input list was checked against
113
+ 60,000 on its own: some of those calls still ended in a 500, a `massCreate` that would have fit
114
+ on PostgreSQL was refused (it counted four extra values per row), and on SQLite the cap sat
115
+ above the database's limit. The message changes from "Too many values in one call (n); the
116
+ limit is 60000" to "Too many values in one statement: n bound parameters, and PostgreSQL takes
117
+ at most 65535". `bindLimit(db)` is protected, for a server built with another limit.
118
+ `MAX_BATCH_PARAMETERS`, `assertBatchSize` and `insertParameters` stay for compatibility but are
119
+ deprecated and no longer used.
120
+ - On a scoped entity, `create`, `update`, `upsert` and the mass writes check every belongs-to key
121
+ they set against the caller's tenant when the related table has the scope columns, the way
122
+ `attach` checks its targets: one `SELECT` per relation for the whole call. A key to another
123
+ tenant's row is the same 400 `ForeignKeyViolationException` as a key to a missing row, so the
124
+ answer no longer tells them apart, and a missing row is refused even without a database
125
+ foreign key. Calls with `skipScope: true` and related tables without the scope columns are
126
+ not checked.
127
+ - On SQLite, `contains`, `startsWith` and `endsWith` with `sql.caseSensitive: true` compile to
128
+ `GLOB` and match case exactly. They used `LIKE`, which ignores ASCII case. The default
129
+ (case-insensitive) and the `like` operator are unchanged.
130
+ - On MySQL, `connection.idleTimeout` also sets mysql2's `maxIdle` to one below the pool size.
131
+ mysql2 only reaps idle connections when `maxIdle` is below `connectionLimit`, so the timeout
132
+ had no effect before.
133
+ - `forRootAsync` with more than one of `useFactory`, `useClass` and `useExisting` throws at
134
+ startup and names them. Before, `useFactory` won and the class was ignored.
135
+
136
+ ### Fixed
137
+
138
+ - On SQLite, a `STRICT` table refusing a value of the wrong type (extended code 3091, which
139
+ libSQL reports as `UNKNOWN_SQLITE_ERROR_3091`) and `SQLITE_MISMATCH` (`"abc"` for an
140
+ `INTEGER PRIMARY KEY`) are a 400 `ValidationFailedException`, not a 500. The dialect contract
141
+ checks the same mapping on every dialect.
142
+ - Filters inside `options.counts` get the same 32 level depth cap as a `findAll` filter. A very
143
+ deep one was a 500 (stack overflow).
144
+ - The filter depth check no longer overflows the call stack on an `IN` list of a few hundred
145
+ thousand items.
146
+ - A batch so large that Drizzle's own SQL builder overflows the call stack is the same 400 as
147
+ any other statement past the limit.
148
+ - Full-text search over several columns no longer drops rows that have `NULL` in one of them.
149
+ The vector was `to_tsvector(cfg, a) || to_tsvector(cfg, b)`, and `x || NULL` is `NULL`, so
150
+ such a row never matched, in `fullTextSearch()` and in `search.mode: "fullText"` alike. Each
151
+ column is now wrapped in `coalesce(column, '')` when there is more than one. A GIN index on the
152
+ old multi-column expression no longer matches the query; recreate it with the `coalesce` (the
153
+ full-text search page has the exact `CREATE INDEX`). A single column is unchanged.
154
+
155
+ ### Docs
156
+
157
+ - `AGENTS.md` names the package `@avytrix/nestjs-drizzle-crud`, and says that PGlite, the engine
158
+ of the unit and e2e specs, returns no rows above 32,767 bound parameters.
159
+
14
160
  ## [3.7.1] - 2026-11-02
15
161
 
16
162
  ### Fixed
@@ -276,7 +422,8 @@ app.
276
422
  only operator on MySQL is a 400.
277
423
  - **Keyset pagination.** `findByCursor(filters, { cursor, limit, sortBy, count }, options)` returns
278
424
  `{ data, limit, nextCursor, total? }`. The cursor is base64url JSON of the last row's sort values
279
- 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
280
427
  first. It works with every sort, filter, relation and `select`, keeps timestamp microseconds and
281
428
  follows the database's NULL ordering. The count only runs with `count: true`. A cursor that is not
282
429
  valid, or was made for another sort, is a 400.
package/README.md CHANGED
@@ -287,7 +287,7 @@ export class UsersController {
287
287
  |---|---|---|
288
288
  | `dialect` | `'postgresql' \| 'mysql' \| 'sqlite'` | **Required.** Database dialect. `'sqlite'` covers SQLite, libSQL and Cloudflare D1. |
289
289
  | `connectionString` | `string` | Connection string. The module builds the connection (PostgreSQL with `postgres`, MySQL with `mysql2`). |
290
- | `connection` | `{ poolSize?, idleTimeout?, ssl? }` | Pool size, idle timeout (seconds) and TLS for the connection built from `connectionString`. |
290
+ | `connection` | `{ poolSize?, idleTimeout?, ssl? }` | Pool size, idle timeout (seconds) and TLS for the connection built from `connectionString`. On MySQL an idle timeout also sets mysql2's `maxIdle` one below the pool size, so idle connections really close. |
291
291
  | `db` | `Drizzle instance` | Alternatively, pass a Drizzle instance you built yourself (any dialect). |
292
292
  | `schema` | `Record<string, unknown>` | Drizzle schema, used when building from `connectionString`. |
293
293
  | `defaults.softDelete` | `boolean` | Enable soft delete for all entities (default `true`). |
@@ -334,7 +334,8 @@ DrizzleCrudModule.forRootAsync({
334
334
  ```
335
335
 
336
336
  `forRootAsync` also takes `useClass` or `useExisting` with a class that
337
- implements `DrizzleCrudOptionsFactory`:
337
+ implements `DrizzleCrudOptionsFactory`. Give exactly one of the three; more than
338
+ one is a startup error:
338
339
 
339
340
  ```typescript
340
341
  @Injectable()
@@ -542,7 +543,7 @@ await service.findAll({
542
543
  | `in` / `notIn` | `IN (...)` / `NOT IN (...)` |
543
544
  | `between` | `BETWEEN a AND b`, `[from, to]` |
544
545
  | `isNull` / `isNotNull` | `true` / `"true"` gives `IS NULL` / `IS NOT NULL`, `false` / `"false"` the opposite |
545
- | `startsWith` / `endsWith` / `contains` | escaped `LIKE`, case-insensitive unless `sql.caseSensitive` |
546
+ | `startsWith` / `endsWith` / `contains` | escaped `LIKE`, case-insensitive unless `sql.caseSensitive` (then `GLOB` on SQLite, whose `LIKE` ignores case) |
546
547
  | `iStartsWith` / `iEndsWith` / `iContains` | escaped `ILIKE` (`LIKE` on MySQL), always case-insensitive |
547
548
  | `arrayContains` / `arrayOverlaps` | PostgreSQL arrays, `@>` / `&&` |
548
549
  | `jsonContains` | PostgreSQL `jsonb`, `@>` |
@@ -562,6 +563,12 @@ instead of returning an arbitrary row.
562
563
  > `like` / `ilike` operators and supply your own wildcards, or `startsWith` /
563
564
  > `endsWith` / `contains`, which escape the value for you.
564
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
+
565
572
  ---
566
573
 
567
574
  ## Query strings: parseQuery
@@ -653,7 +660,9 @@ in `forRoot` to apply a `created_at` fallback to every entity that doesn't defin
653
660
  ## Cursor pagination
654
661
 
655
662
  `findByCursor` pages with `WHERE (sort columns) > (last row)` instead of
656
- `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
657
666
  next page:
658
667
 
659
668
  ```typescript
@@ -930,7 +939,9 @@ Each bulk method is one statement for the whole set (plus one `SELECT` that
930
939
  checks the ids exist), inside a single transaction: `massUpdate` over 100 ids is
931
940
  two queries. If any item fails, a `BulkOperationException` (400) is thrown and
932
941
  the transaction rolls back. The failing items are found by replaying the batch
933
- 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:
934
945
 
935
946
  ```json
936
947
  { "statusCode": 400, "error": "Bad Request",
@@ -940,6 +951,11 @@ inside a savepoint that is rolled back, and the response body lists only them:
940
951
 
941
952
  `bulkMode: 'perItem'` runs the single-row method per item instead, as in 3.3.
942
953
 
954
+ One statement may bind at most 65,535 values on PostgreSQL and MySQL, 32,766 on
955
+ SQLite and 100 on Cloudflare D1. The service counts the real statement (every
956
+ column, version check and OR'd list included) and answers one past the limit
957
+ with a 400 that names both numbers, before it runs. Split larger batches.
958
+
943
959
  ---
944
960
 
945
961
  ## Upsert
@@ -1044,7 +1060,9 @@ await docs.findAll({}, {}, { context: { tenantId: 7 } });
1044
1060
 
1045
1061
  A missing context value is a 403 (`MissingScopeException`), never an unscoped
1046
1062
  query. A resolver that returns `null`, or `skipScope: true`, runs unscoped on
1047
- purpose.
1063
+ purpose. A belongs-to key that a write sets must point at a row of the same
1064
+ tenant when the related table has the scope columns: another tenant's row is the
1065
+ same 400 `ForeignKeyViolationException` as a missing one.
1048
1066
 
1049
1067
  ---
1050
1068
 
@@ -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[];
@@ -35,6 +37,14 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
35
37
  protected filterWritable(entity: Record<string, any>, operation: "create" | "update", options?: SqlOperationOptions): Record<string, any>;
36
38
  protected keyOf(value: unknown): string;
37
39
  constructor(config: SqlCrudConfig);
40
+ private readonly boundDbs;
41
+ protected dbFor(transaction?: any): any;
42
+ protected withBindLimit(db: any): any;
43
+ protected bindLimit(db: any): {
44
+ limit: number;
45
+ name: string;
46
+ };
47
+ protected isD1(db: any): boolean;
38
48
  private validateConfiguration;
39
49
  private validateColumns;
40
50
  private validateRelations;
@@ -53,6 +63,7 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
53
63
  protected beforeRestore(_id: any): Promise<void>;
54
64
  protected afterRestore(_entity: T): Promise<void>;
55
65
  protected relationType(relation: RelationConfig): RelationType;
66
+ private relationNamed;
56
67
  protected isCollection(relation: RelationConfig): boolean;
57
68
  protected eagerRelations(relations: string[]): string[];
58
69
  protected relationTree(paths: string[], configured?: RelationsConfig): RelationNode[];
@@ -70,7 +81,12 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
70
81
  protected loadRelations(db: any, hosts: any[], nodes: RelationNode[], select?: SelectTree, hostTable?: any, options?: SqlOperationOptions): Promise<void>;
71
82
  protected loadBelongsTo(db: any, hosts: any[], node: RelationNode, select: SelectTree, options?: SqlOperationOptions): Promise<void>;
72
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;
73
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;
74
90
  private groupCollection;
75
91
  protected applyJoins(query: any, relationNames: string[], options?: SqlOperationOptions): any;
76
92
  protected normalizeRelations(rows: any[], eager: string[]): any[];
@@ -103,6 +119,7 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
103
119
  vector: any;
104
120
  query: any;
105
121
  };
122
+ private isTsVector;
106
123
  protected buildSearchCondition(search?: SqlOperationOptions["search"]): any;
107
124
  protected caseInsensitiveLike(column: any, pattern: string): any;
108
125
  protected likePattern(column: any, pattern: string): any;
@@ -110,14 +127,20 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
110
127
  idKind(): "int" | "bigint" | "uuid" | "string";
111
128
  parseId(raw: unknown): Promise<unknown>;
112
129
  private findForGuard;
130
+ private guardRow;
131
+ private skipsGuard;
132
+ private guardOnError;
113
133
  protected assertVersion(id: any, actual: unknown, expected: unknown): void;
114
134
  protected writeWhere(ids: any[], options?: SqlOperationOptions, table?: any): any;
115
135
  protected withVersionBump(set: Record<string, any>): Record<string, any>;
116
136
  find(id: any, options?: SqlOperationOptions): Promise<T | null>;
117
137
  findOne(where: Partial<T>, options?: SqlOperationOptions): Promise<T | null>;
118
138
  private findFirst;
139
+ private readFirst;
140
+ private hydrate;
119
141
  private planList;
120
142
  private listConditions;
143
+ private parseCount;
121
144
  private buildCountQuery;
122
145
  findAll(filters: FilterDto | undefined, pagination: PaginationOptions & {
123
146
  withCount: false;
@@ -130,6 +153,10 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
130
153
  protected cursorField(column: any): any;
131
154
  private compareCursor;
132
155
  protected keysetCondition(specs: SortSpec[], values: unknown[]): any;
156
+ private keysetBound;
157
+ private keysetRowComparison;
158
+ private keysetNullable;
159
+ private nullsFirst;
133
160
  private afterCursor;
134
161
  protected emitEvent(phase: "before" | "after", operation: CrudWriteOperation, bulk: boolean, payload: {
135
162
  ids?: unknown[];
@@ -139,6 +166,8 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
139
166
  }, options?: SqlOperationOptions): Promise<void>;
140
167
  private idsOf;
141
168
  protected loadWriteRelations(db: any, rows: T[], options?: SqlOperationOptions): Promise<void>;
169
+ private loadsAfterCommit;
170
+ private writeThenLoad;
142
171
  private expectedVersionOf;
143
172
  protected prepareCreate(data: CreateDto, options?: SqlOperationOptions): Promise<Record<string, any>>;
144
173
  private insertOne;
@@ -146,6 +175,10 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
146
175
  private takeNested;
147
176
  private insertNested;
148
177
  private relatedRow;
178
+ private keysOutsideScope;
179
+ private assertBatchKeysInScope;
180
+ private assertKeysInScope;
181
+ private missingParentException;
149
182
  private linkTargets;
150
183
  attach(id: any, relation: string, relatedIds: unknown[], options?: SqlOperationOptions): Promise<T>;
151
184
  detach(id: any, relation: string, relatedIds?: unknown[], options?: SqlOperationOptions): Promise<T>;
@@ -159,6 +192,8 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
159
192
  private updateWhereIn;
160
193
  private fetchByIds;
161
194
  private inIdOrder;
195
+ private guardedWrite;
196
+ private staleOrFalse;
162
197
  softDelete(id: any, options?: SqlOperationOptions): Promise<boolean>;
163
198
  private softDeleteSet;
164
199
  restore(id: any, options?: SqlOperationOptions): Promise<T>;
@@ -166,14 +201,18 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
166
201
  forceDelete(id: any, options?: SqlOperationOptions): Promise<boolean>;
167
202
  upsert(data: CreateDto, upsert?: UpsertOptions, options?: SqlOperationOptions): Promise<T>;
168
203
  massUpsert(data: CreateDto[], upsert?: UpsertOptions, options?: SqlOperationOptions): Promise<T[]>;
204
+ private upsertSet;
169
205
  private conflictKeys;
170
206
  private conflictValue;
171
207
  private upsertRows;
172
208
  private excludedValue;
209
+ private massWrite;
173
210
  massCreate(data: CreateDto[], options?: SqlOperationOptions): Promise<T[]>;
174
211
  private insertMany;
212
+ private readBackCreated;
175
213
  private assertAllWritten;
176
214
  massUpdate(ids: any[], data: UpdateDto, options?: SqlOperationOptions): Promise<T[]>;
215
+ private updateSets;
177
216
  private runKeyed;
178
217
  massSoftDelete(ids: any[], options?: SqlOperationOptions): Promise<boolean>;
179
218
  massRestore(ids: any[], options?: SqlOperationOptions): Promise<T[]>;
@@ -189,12 +228,9 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
189
228
  id?: any;
190
229
  run: (sp: any) => Promise<unknown>;
191
230
  }>, operation: "write" | "delete"): Promise<R>;
231
+ private settleSet;
192
232
  private diagnose;
193
- private massCreatePerItem;
194
- private massUpdatePerItem;
195
- private massSoftDeletePerItem;
196
- private massRestorePerItem;
197
- private massDeletePerItem;
233
+ private eachInSavepoint;
198
234
  protected savepoint<R>(tx: any, run: (sp: any) => Promise<R>): Promise<R>;
199
235
  exists(id: any, options?: SqlOperationOptions): Promise<boolean>;
200
236
  count(filters?: FilterDto, options?: SqlOperationOptions): Promise<number>;
@@ -203,6 +239,7 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
203
239
  protected countNames(counts?: RelationCounts): string[];
204
240
  protected withCountKeys(base: string[], counts?: RelationCounts): string[];
205
241
  protected loadCounts(db: any, rows: any[], counts: RelationCounts | undefined, options?: SqlOperationOptions): Promise<void>;
242
+ private countPlan;
206
243
  private countQuery;
207
244
  protected pushColumnCondition(conditions: any[], column: any, value: any, caseInsensitive?: boolean): void;
208
245
  protected buildCollectionFilter(relation: RelationConfig, value: Record<string, any>, db?: any, name?: string, options?: SqlOperationOptions): any | undefined;
@@ -229,6 +266,7 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
229
266
  private patternCondition;
230
267
  protected applyComplexFilter(conditions: any[], column: any, filterObj: any): void;
231
268
  protected executeSqlTransaction<R>(operation: (tx: any) => Promise<R>, existingTransaction?: any): Promise<R>;
269
+ private readonly ownedTransactions;
232
270
  protected assertTransactions(db: any): void;
233
271
  protected getEntityName(): string;
234
272
  fullTextSearch(searchTerm: string, searchColumns: string[], pagination?: PaginationOptions, options?: FullTextSearchOptions<FilterDto>): Promise<{