@avytrix/nestjs-drizzle-crud 3.7.1 → 3.7.2

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
@@ -390,8 +390,10 @@ consumer
390
390
  The scope is ANDed into every read, count and write (bulk, upsert, soft delete,
391
391
  restore, force delete, attach included) and written onto every created row, so
392
392
  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.
393
+ unscoped query. A belongs-to key a write sets must point at a row of the
394
+ caller's tenant when the related table has the scope columns; another tenant's
395
+ row is the same 400 as a missing one. Do not add `tenant_id` to filters or DTOs
396
+ by hand, and use `skipScope: true` only in trusted server code.
395
397
 
396
398
  # Optimistic locking
397
399
 
@@ -434,7 +436,8 @@ never reaches the caller as a 500: a unique violation is a 409, a value that is
434
436
  too long, fails a check, has the wrong type or is not a valid date is a 400 (on
435
437
  reads too), a foreign key pointing at a row that does not exist is a 400, and a
436
438
  delete blocked by dependent rows is a 409. The same holds on MySQL (strict mode
437
- errors included) and SQLite (constraint errors).
439
+ errors included) and SQLite (constraint errors, and the `STRICT` and
440
+ `SQLITE_MISMATCH` type errors).
438
441
 
439
442
  ---
440
443
 
@@ -453,6 +456,9 @@ whole set (plus one `SELECT` that checks the ids), so never loop `create` or
453
456
  `update` over an array yourself. The 400 from `BulkOperationException` lists
454
457
  only the failed items: `errors: [{ index | id, status, message }]`.
455
458
  `bulkMode: 'perItem'` runs one single-row call per item when a hook needs it.
459
+ One statement may bind at most 65,535 values on PostgreSQL and MySQL, 32,766 on
460
+ SQLite and 100 on Cloudflare D1; past that the call is a 400 before it runs, so
461
+ split very large batches into chunks.
456
462
 
457
463
  To insert or update by a unique key, use
458
464
  `upsert(dto, { conflictTarget: 'email' })` or `massUpsert(rows, ...)` rather
package/CHANGELOG.md CHANGED
@@ -11,6 +11,65 @@ 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.7.2] - 2026-11-03
15
+
16
+ Fixes from the 3.7.1 audit. The batch limit now counts the statement the database receives.
17
+
18
+ ### Changed
19
+
20
+ - The bound parameter limit is checked per statement, against the database's own limit:
21
+ 65,535 on PostgreSQL and MySQL, 32,766 on SQLite and libSQL, 100 on Cloudflare D1. The service
22
+ counts the parameters of every statement as Drizzle builds it, so two OR'd `IN` lists, the
23
+ version checks of a `massUpdate` with `expectedVersions`, the children of a nested create and
24
+ the targets of `attach` and `sync` all count, and a statement past the limit is a 400 that
25
+ names its count and the limit before it runs. Before, each input list was checked against
26
+ 60,000 on its own: some of those calls still ended in a 500, a `massCreate` that would have fit
27
+ on PostgreSQL was refused (it counted four extra values per row), and on SQLite the cap sat
28
+ above the database's limit. The message changes from "Too many values in one call (n); the
29
+ limit is 60000" to "Too many values in one statement: n bound parameters, and PostgreSQL takes
30
+ at most 65535". `bindLimit(db)` is protected, for a server built with another limit.
31
+ `MAX_BATCH_PARAMETERS`, `assertBatchSize` and `insertParameters` stay for compatibility but are
32
+ deprecated and no longer used.
33
+ - On a scoped entity, `create`, `update`, `upsert` and the mass writes check every belongs-to key
34
+ they set against the caller's tenant when the related table has the scope columns, the way
35
+ `attach` checks its targets: one `SELECT` per relation for the whole call. A key to another
36
+ tenant's row is the same 400 `ForeignKeyViolationException` as a key to a missing row, so the
37
+ answer no longer tells them apart, and a missing row is refused even without a database
38
+ foreign key. Calls with `skipScope: true` and related tables without the scope columns are
39
+ not checked.
40
+ - On SQLite, `contains`, `startsWith` and `endsWith` with `sql.caseSensitive: true` compile to
41
+ `GLOB` and match case exactly. They used `LIKE`, which ignores ASCII case. The default
42
+ (case-insensitive) and the `like` operator are unchanged.
43
+ - On MySQL, `connection.idleTimeout` also sets mysql2's `maxIdle` to one below the pool size.
44
+ mysql2 only reaps idle connections when `maxIdle` is below `connectionLimit`, so the timeout
45
+ had no effect before.
46
+ - `forRootAsync` with more than one of `useFactory`, `useClass` and `useExisting` throws at
47
+ startup and names them. Before, `useFactory` won and the class was ignored.
48
+
49
+ ### Fixed
50
+
51
+ - On SQLite, a `STRICT` table refusing a value of the wrong type (extended code 3091, which
52
+ libSQL reports as `UNKNOWN_SQLITE_ERROR_3091`) and `SQLITE_MISMATCH` (`"abc"` for an
53
+ `INTEGER PRIMARY KEY`) are a 400 `ValidationFailedException`, not a 500. The dialect contract
54
+ checks the same mapping on every dialect.
55
+ - Filters inside `options.counts` get the same 32 level depth cap as a `findAll` filter. A very
56
+ deep one was a 500 (stack overflow).
57
+ - The filter depth check no longer overflows the call stack on an `IN` list of a few hundred
58
+ thousand items.
59
+ - A batch so large that Drizzle's own SQL builder overflows the call stack is the same 400 as
60
+ any other statement past the limit.
61
+ - Full-text search over several columns no longer drops rows that have `NULL` in one of them.
62
+ The vector was `to_tsvector(cfg, a) || to_tsvector(cfg, b)`, and `x || NULL` is `NULL`, so
63
+ such a row never matched, in `fullTextSearch()` and in `search.mode: "fullText"` alike. Each
64
+ column is now wrapped in `coalesce(column, '')` when there is more than one. A GIN index on the
65
+ old multi-column expression no longer matches the query; recreate it with the `coalesce` (the
66
+ full-text search page has the exact `CREATE INDEX`). A single column is unchanged.
67
+
68
+ ### Docs
69
+
70
+ - `AGENTS.md` names the package `@avytrix/nestjs-drizzle-crud`, and says that PGlite, the engine
71
+ of the unit and e2e specs, returns no rows above 32,767 bound parameters.
72
+
14
73
  ## [3.7.1] - 2026-11-02
15
74
 
16
75
  ### Fixed
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`, `@>` |
@@ -940,6 +941,11 @@ inside a savepoint that is rolled back, and the response body lists only them:
940
941
 
941
942
  `bulkMode: 'perItem'` runs the single-row method per item instead, as in 3.3.
942
943
 
944
+ One statement may bind at most 65,535 values on PostgreSQL and MySQL, 32,766 on
945
+ SQLite and 100 on Cloudflare D1. The service counts the real statement (every
946
+ column, version check and OR'd list included) and answers one past the limit
947
+ with a 400 that names both numbers, before it runs. Split larger batches.
948
+
943
949
  ---
944
950
 
945
951
  ## Upsert
@@ -1044,7 +1050,9 @@ await docs.findAll({}, {}, { context: { tenantId: 7 } });
1044
1050
 
1045
1051
  A missing context value is a 403 (`MissingScopeException`), never an unscoped
1046
1052
  query. A resolver that returns `null`, or `skipScope: true`, runs unscoped on
1047
- purpose.
1053
+ purpose. A belongs-to key that a write sets must point at a row of the same
1054
+ tenant when the related table has the scope columns: another tenant's row is the
1055
+ same 400 `ForeignKeyViolationException` as a missing one.
1048
1056
 
1049
1057
  ---
1050
1058
 
@@ -35,6 +35,14 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
35
35
  protected filterWritable(entity: Record<string, any>, operation: "create" | "update", options?: SqlOperationOptions): Record<string, any>;
36
36
  protected keyOf(value: unknown): string;
37
37
  constructor(config: SqlCrudConfig);
38
+ private readonly boundDbs;
39
+ protected dbFor(transaction?: any): any;
40
+ protected withBindLimit(db: any): any;
41
+ protected bindLimit(db: any): {
42
+ limit: number;
43
+ name: string;
44
+ };
45
+ protected isD1(db: any): boolean;
38
46
  private validateConfiguration;
39
47
  private validateColumns;
40
48
  private validateRelations;
@@ -146,6 +154,10 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
146
154
  private takeNested;
147
155
  private insertNested;
148
156
  private relatedRow;
157
+ private keysOutsideScope;
158
+ private assertBatchKeysInScope;
159
+ private assertKeysInScope;
160
+ private missingParentException;
149
161
  private linkTargets;
150
162
  attach(id: any, relation: string, relatedIds: unknown[], options?: SqlOperationOptions): Promise<T>;
151
163
  detach(id: any, relation: string, relatedIds?: unknown[], options?: SqlOperationOptions): Promise<T>;
@@ -11,6 +11,15 @@ const query_parser_1 = require("../utils/query-parser");
11
11
  const JOIN_KEY = "__crudJoinKey";
12
12
  const CURSOR_KEY = "__crudCursor";
13
13
  const ROLLBACK = Symbol("rollback");
14
+ const BIND_LIMITS = {
15
+ postgresql: { limit: 65_535, name: "PostgreSQL" },
16
+ mysql: { limit: 65_535, name: "MySQL" },
17
+ sqlite: { limit: 32_766, name: "SQLite" },
18
+ d1: { limit: 100, name: "Cloudflare D1" },
19
+ };
20
+ class BindLimitException extends common_1.BadRequestException {
21
+ }
22
+ const CHECKED_DIALECTS = new WeakSet();
14
23
  const GROUP_KEYS = new Set(["and", "or", "not"]);
15
24
  const QUANTIFIERS = new Set(["some", "none", "every"]);
16
25
  const COUNT_KEY = "__crudCountKey";
@@ -273,6 +282,53 @@ class SqlBaseCrudService {
273
282
  this.config = (0, merge_config_1.mergeCrudConfig)(this.defaultConfig, config);
274
283
  this.validateConfiguration();
275
284
  }
285
+ boundDbs = new WeakMap();
286
+ dbFor(transaction) {
287
+ return this.withBindLimit(transaction || this.config.db);
288
+ }
289
+ withBindLimit(db) {
290
+ const dialect = db?.dialect;
291
+ if (typeof dialect?.sqlToQuery !== "function" ||
292
+ CHECKED_DIALECTS.has(dialect)) {
293
+ return db;
294
+ }
295
+ const cached = this.boundDbs.get(db);
296
+ if (cached)
297
+ return cached;
298
+ const { limit, name } = this.bindLimit(db);
299
+ const checked = Object.create(dialect);
300
+ const tooMany = (count) => new BindLimitException(`Too many values in one statement: ${count} bound parameters, and ${name} takes at most ${limit}. Split the batch.`);
301
+ checked.sqlToQuery = (...args) => {
302
+ let query;
303
+ try {
304
+ query = dialect.sqlToQuery(...args);
305
+ }
306
+ catch (error) {
307
+ if (error instanceof RangeError)
308
+ throw tooMany(`more than ${limit}`);
309
+ throw error;
310
+ }
311
+ const count = query?.params?.length ?? 0;
312
+ if (count > limit)
313
+ throw tooMany(String(count));
314
+ return query;
315
+ };
316
+ CHECKED_DIALECTS.add(checked);
317
+ const view = Object.create(db, {
318
+ dialect: { value: checked, writable: true, configurable: true },
319
+ });
320
+ this.boundDbs.set(db, view);
321
+ return view;
322
+ }
323
+ bindLimit(db) {
324
+ if (this.isD1(db))
325
+ return BIND_LIMITS.d1;
326
+ return BIND_LIMITS[this.config.dialect] ?? BIND_LIMITS.postgresql;
327
+ }
328
+ isD1(db) {
329
+ const kind = db?.constructor?.[drizzle_orm_1.entityKind];
330
+ return kind === "D1Database" || kind === "D1Transaction";
331
+ }
276
332
  validateConfiguration() {
277
333
  if (!this.config.db)
278
334
  throw new Error("Database instance is required");
@@ -831,8 +887,17 @@ class SqlBaseCrudService {
831
887
  if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT_")) {
832
888
  return code.slice("SQLITE_CONSTRAINT_".length);
833
889
  }
890
+ if (code === "UNKNOWN_SQLITE_ERROR_3091" || e?.rawCode === 3091) {
891
+ return "DATATYPE";
892
+ }
893
+ if (code === "SQLITE_MISMATCH")
894
+ return "MISMATCH";
834
895
  }
835
896
  const message = `${error?.cause?.message ?? ""} ${error?.message ?? ""}`;
897
+ if (/cannot store \S+ value in \S+ column/.test(message))
898
+ return "DATATYPE";
899
+ if (/datatype mismatch/.test(message))
900
+ return "MISMATCH";
836
901
  const m = message.match(/(UNIQUE|PRIMARY KEY|FOREIGN KEY|NOT NULL|CHECK) constraint failed/);
837
902
  return m?.[1]?.replace(" ", "");
838
903
  }
@@ -867,6 +932,8 @@ class SqlBaseCrudService {
867
932
  static SQLITE_DATA_ERRORS = {
868
933
  NOTNULL: "a required value is missing",
869
934
  CHECK: "a value violates a column check constraint",
935
+ DATATYPE: "a value does not match its column type",
936
+ MISMATCH: "a value does not match its column type",
870
937
  };
871
938
  dataErrorMessage(error) {
872
939
  const code = error?.code ?? error?.cause?.code;
@@ -899,7 +966,7 @@ class SqlBaseCrudService {
899
966
  ? text.match(/column '([^']+)'/i)?.[1]
900
967
  : undefined) ??
901
968
  (this.config.dialect === "sqlite"
902
- ? text.match(/NOT NULL constraint failed: (?:[^.\s]+\.)?(\S+)/)?.[1]
969
+ ? text.match(/(?:NOT NULL constraint failed:|value in \S+ column) (?:[^.\s]+\.)?(\S+)/)?.[1]
903
970
  : undefined);
904
971
  return new crud_exceptions_1.ValidationFailedException(column ? `${message}: ${column}` : message);
905
972
  }
@@ -966,7 +1033,9 @@ class SqlBaseCrudService {
966
1033
  throw new common_1.BadRequestException(`Invalid full-text query mode: ${String(mode)} (expected 'plain' or 'websearch')`);
967
1034
  }
968
1035
  const parse = drizzle_orm_1.sql.raw(mode === "websearch" ? "websearch_to_tsquery" : "plainto_tsquery");
969
- const vector = drizzle_orm_1.sql.join(columns.map((column) => (0, drizzle_orm_1.sql) `to_tsvector(${config}, ${column})`), (0, drizzle_orm_1.sql) ` || `);
1036
+ const vector = drizzle_orm_1.sql.join(columns.map((column) => columns.length > 1
1037
+ ? (0, drizzle_orm_1.sql) `to_tsvector(${config}, coalesce(${column}, ''))`
1038
+ : (0, drizzle_orm_1.sql) `to_tsvector(${config}, ${column})`), (0, drizzle_orm_1.sql) ` || `);
970
1039
  return { vector, query: (0, drizzle_orm_1.sql) `${parse}(${config}, ${term})` };
971
1040
  }
972
1041
  buildSearchCondition(search) {
@@ -1111,7 +1180,7 @@ class SqlBaseCrudService {
1111
1180
  }
1112
1181
  async findFirst(conditions, options) {
1113
1182
  const { transaction, relations = [], select = [] } = options || {};
1114
- const db = transaction || this.config.db;
1183
+ const db = this.dbFor(transaction);
1115
1184
  const nodes = this.relationTree(relations);
1116
1185
  const joined = this.planJoins(nodes);
1117
1186
  const selection = this.parseSelect(select);
@@ -1133,7 +1202,7 @@ class SqlBaseCrudService {
1133
1202
  planList(filters, sortBy, sortOrder, options) {
1134
1203
  this.checkReadOptions(options);
1135
1204
  const { transaction, relations = [], select = [] } = options || {};
1136
- const db = transaction || this.config.db;
1205
+ const db = this.dbFor(transaction);
1137
1206
  const nodes = this.relationTree(relations);
1138
1207
  const joined = this.planJoins(nodes);
1139
1208
  const selection = this.parseSelect(select);
@@ -1401,9 +1470,10 @@ class SqlBaseCrudService {
1401
1470
  this.checkReadOptions(options);
1402
1471
  const entityData = await this.prepareCreate(data, options);
1403
1472
  const nested = this.takeNested(entityData, options);
1404
- const db = transaction || this.config.db;
1473
+ const db = this.dbFor(transaction);
1405
1474
  await this.emitEvent("before", "create", false, { input: data, transaction }, options);
1406
1475
  try {
1476
+ await this.assertKeysInScope(db, entityData, options, nested.map((entry) => entry.name));
1407
1477
  const createdEntity = nested.length === 0
1408
1478
  ? await this.insertOne(db, entityData, options)
1409
1479
  : await this.executeSqlTransaction((tx) => this.insertNested(tx, entityData, nested, {
@@ -1526,6 +1596,55 @@ class SqlBaseCrudService {
1526
1596
  }
1527
1597
  return out;
1528
1598
  }
1599
+ async keysOutsideScope(db, rows, options, skip = []) {
1600
+ const failures = new Map();
1601
+ if (!this.resolveScope(options))
1602
+ return failures;
1603
+ for (const [name, relation] of Object.entries(this.config.relations ?? {})) {
1604
+ const local = relation.localKey;
1605
+ if (!local || this.isCollection(relation) || skip.includes(name)) {
1606
+ continue;
1607
+ }
1608
+ const scope = this.relatedScopeConditions(relation.table, options);
1609
+ const referenced = relation.table[relation.references ?? "id"];
1610
+ if (scope.length === 0 || !referenced)
1611
+ continue;
1612
+ const keys = this.uniqueValues(rows.map((row) => row[local]).filter((value) => value != null));
1613
+ if (keys.length === 0)
1614
+ continue;
1615
+ const found = await db
1616
+ .select({ id: referenced })
1617
+ .from(relation.table)
1618
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(referenced, keys), ...scope));
1619
+ const present = new Set(found.map((row) => this.valueKey(row.id)));
1620
+ rows.forEach((row, index) => {
1621
+ const value = row[local];
1622
+ if (value == null || present.has(this.valueKey(value)))
1623
+ return;
1624
+ if (!failures.has(index)) {
1625
+ failures.set(index, this.missingParentException(local));
1626
+ }
1627
+ });
1628
+ }
1629
+ return failures;
1630
+ }
1631
+ async assertBatchKeysInScope(db, rows, refs, message, options) {
1632
+ const failures = await this.keysOutsideScope(db, rows, options);
1633
+ if (failures.size === 0)
1634
+ return;
1635
+ throw new crud_exceptions_1.BulkOperationException(message, Array.from(failures, ([index, error]) => ({ ...refs[index], error })));
1636
+ }
1637
+ async assertKeysInScope(db, row, options, skip = []) {
1638
+ const failure = (await this.keysOutsideScope(db, [row], options, skip)).get(0);
1639
+ if (failure)
1640
+ throw failure;
1641
+ }
1642
+ missingParentException(localKey) {
1643
+ const field = this.config.dialect === "postgresql"
1644
+ ? (this.columnOf(this.config.table, localKey)?.name ?? localKey)
1645
+ : "reference";
1646
+ return new crud_exceptions_1.ForeignKeyViolationException(this.getEntityName(), field);
1647
+ }
1529
1648
  async linkTargets(tx, relation, parentValue, ids, options) {
1530
1649
  const wanted = this.uniqueValues(ids);
1531
1650
  if (wanted.length === 0)
@@ -1692,9 +1811,10 @@ class SqlBaseCrudService {
1692
1811
  if (this.config.timestamps?.updatedAt) {
1693
1812
  entityData[this.config.timestamps.updatedAt] = new Date();
1694
1813
  }
1695
- const db = transaction || this.config.db;
1814
+ const db = this.dbFor(transaction);
1696
1815
  await this.emitEvent("before", "update", false, { ids: [id], input: data, transaction }, options);
1697
1816
  try {
1817
+ await this.assertKeysInScope(db, entityData, opts);
1698
1818
  const [updatedEntity] = await this.updateWhereIn(db, this.withVersionBump(entityData), [id], opts);
1699
1819
  if (!updatedEntity) {
1700
1820
  if (expected !== undefined) {
@@ -1757,7 +1877,7 @@ class SqlBaseCrudService {
1757
1877
  if (!hooks.skipBefore)
1758
1878
  await this.beforeSoftDelete(id);
1759
1879
  await this.emitEvent("before", "softDelete", false, { ids: [id], transaction }, options);
1760
- const db = transaction || this.config.db;
1880
+ const db = this.dbFor(transaction);
1761
1881
  let updateQuery = db
1762
1882
  .update(this.config.table)
1763
1883
  .set(this.softDeleteSet(new Date()))
@@ -1799,7 +1919,7 @@ class SqlBaseCrudService {
1799
1919
  if (!hooks.skipBefore)
1800
1920
  await this.beforeRestore(id);
1801
1921
  await this.emitEvent("before", "restore", false, { ids: [id], transaction }, options);
1802
- const db = transaction || this.config.db;
1922
+ const db = this.dbFor(transaction);
1803
1923
  const [restoredEntity] = await this.runRead(this.updateWhereIn(db, this.softDeleteSet(null), [id], options));
1804
1924
  if (!restoredEntity) {
1805
1925
  if (options?.expectedVersion != null && this.config.versionColumn) {
@@ -1821,7 +1941,7 @@ class SqlBaseCrudService {
1821
1941
  if (!hooks.skipBefore)
1822
1942
  await this.beforeDelete(id);
1823
1943
  await this.emitEvent("before", "delete", false, { ids: [id], transaction }, options);
1824
- const db = transaction || this.config.db;
1944
+ const db = this.dbFor(transaction);
1825
1945
  const deleteQuery = db
1826
1946
  .delete(this.config.table)
1827
1947
  .where(this.writeWhere([id], options));
@@ -1852,10 +1972,11 @@ class SqlBaseCrudService {
1852
1972
  async upsert(data, upsert = {}, options) {
1853
1973
  this.checkReadOptions(options);
1854
1974
  const entityData = await this.prepareCreate(data, options);
1855
- const db = options?.transaction || this.config.db;
1975
+ const db = this.dbFor(options?.transaction);
1856
1976
  await this.emitEvent("before", "upsert", false, { input: data, transaction: options?.transaction }, options);
1857
1977
  let row;
1858
1978
  try {
1979
+ await this.assertKeysInScope(db, entityData, options);
1859
1980
  [row] = await this.upsertRows(db, [entityData], upsert, options);
1860
1981
  }
1861
1982
  catch (error) {
@@ -1875,7 +1996,6 @@ class SqlBaseCrudService {
1875
1996
  }
1876
1997
  async massUpsert(data, upsert = {}, options) {
1877
1998
  this.checkReadOptions(options);
1878
- this.assertBatchSize(this.insertParameters(data));
1879
1999
  const message = "Mass upsert errors";
1880
2000
  return this.executeSqlTransaction(async (tx) => {
1881
2001
  const opts = { ...options, transaction: tx };
@@ -1886,6 +2006,7 @@ class SqlBaseCrudService {
1886
2006
  if (rows.length === 0)
1887
2007
  return [];
1888
2008
  await this.emitEvent("before", "upsert", true, { input: data, transaction: tx }, options);
2009
+ await this.assertBatchKeysInScope(tx, rows, rows.map((_, index) => ({ index })), message, opts);
1889
2010
  const written = await this.runSet(tx, message, (sp) => this.upsertRows(sp, rows, upsert, opts), rows.map((row, index) => ({
1890
2011
  index,
1891
2012
  run: (sp) => this.upsertRows(sp, [row], upsert, opts),
@@ -2010,7 +2131,6 @@ class SqlBaseCrudService {
2010
2131
  return this.massCreatePerItem(data, options);
2011
2132
  }
2012
2133
  this.checkReadOptions(options);
2013
- this.assertBatchSize(this.insertParameters(data));
2014
2134
  return this.executeSqlTransaction(async (tx) => {
2015
2135
  const opts = { ...options, transaction: tx };
2016
2136
  const rows = await this.eachItem(data.map((item, index) => ({
@@ -2020,6 +2140,7 @@ class SqlBaseCrudService {
2020
2140
  if (rows.length === 0)
2021
2141
  return [];
2022
2142
  await this.emitEvent("before", "create", true, { input: data, transaction: tx }, options);
2143
+ await this.assertBatchKeysInScope(tx, rows, rows.map((_, index) => ({ index })), "Mass create errors", opts);
2023
2144
  const created = await this.runSet(tx, "Mass create errors", (sp) => this.insertMany(sp, rows, opts), rows.map((row, index) => ({
2024
2145
  index,
2025
2146
  run: (sp) => this.insertOne(sp, row, opts),
@@ -2069,7 +2190,6 @@ class SqlBaseCrudService {
2069
2190
  if (options?.bulkMode === "perItem") {
2070
2191
  return this.massUpdatePerItem(ids, data, options);
2071
2192
  }
2072
- this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2073
2193
  this.checkReadOptions(options);
2074
2194
  const message = "Mass update errors";
2075
2195
  return this.executeSqlTransaction(async (tx) => {
@@ -2090,6 +2210,7 @@ class SqlBaseCrudService {
2090
2210
  },
2091
2211
  })), message);
2092
2212
  await this.emitEvent("before", "update", true, { ids: unique, input: data, transaction: tx }, options);
2213
+ await this.assertBatchKeysInScope(tx, sets, unique.map((id) => ({ id })), message, opts);
2093
2214
  const now = new Date();
2094
2215
  const stamp = (set) => this.withVersionBump(this.config.timestamps?.updatedAt
2095
2216
  ? { ...set, [this.config.timestamps.updatedAt]: now }
@@ -2137,7 +2258,6 @@ class SqlBaseCrudService {
2137
2258
  if (options?.bulkMode === "perItem") {
2138
2259
  return this.massSoftDeletePerItem(ids, options);
2139
2260
  }
2140
- this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2141
2261
  const message = "Mass soft delete errors";
2142
2262
  return this.executeSqlTransaction(async (tx) => {
2143
2263
  const opts = {
@@ -2181,7 +2301,6 @@ class SqlBaseCrudService {
2181
2301
  if (options?.bulkMode === "perItem") {
2182
2302
  return this.massRestorePerItem(ids, options);
2183
2303
  }
2184
- this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2185
2304
  this.checkReadOptions(options);
2186
2305
  const message = "Mass restore errors";
2187
2306
  return this.executeSqlTransaction(async (tx) => {
@@ -2219,7 +2338,6 @@ class SqlBaseCrudService {
2219
2338
  if (options?.bulkMode === "perItem") {
2220
2339
  return this.massDeletePerItem(ids, options);
2221
2340
  }
2222
- this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2223
2341
  const message = "Mass delete errors";
2224
2342
  return this.executeSqlTransaction(async (tx) => {
2225
2343
  const opts = { ...options, transaction: tx };
@@ -2332,6 +2450,8 @@ class SqlBaseCrudService {
2332
2450
  return await this.savepoint(tx, statement);
2333
2451
  }
2334
2452
  catch (error) {
2453
+ if (error instanceof BindLimitException)
2454
+ throw error;
2335
2455
  const failures = await this.diagnose(tx, items, operation);
2336
2456
  if (failures.length > 0) {
2337
2457
  throw new crud_exceptions_1.BulkOperationException(message, failures);
@@ -2397,7 +2517,7 @@ class SqlBaseCrudService {
2397
2517
  }
2398
2518
  savepoint(tx, run) {
2399
2519
  return typeof tx?.transaction === "function"
2400
- ? tx.transaction(run)
2520
+ ? tx.transaction((sp) => run(this.withBindLimit(sp)))
2401
2521
  : run(tx);
2402
2522
  }
2403
2523
  async exists(id, options) {
@@ -2409,14 +2529,14 @@ class SqlBaseCrudService {
2409
2529
  }
2410
2530
  async count(filters, options) {
2411
2531
  this.checkReadOptions(options);
2412
- const db = options?.transaction || this.config.db;
2532
+ const db = this.dbFor(options?.transaction);
2413
2533
  const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
2414
2534
  const result = await this.runRead(this.buildCountQuery(db, conditions, filterRelations, options));
2415
2535
  return parseInt(result[0]?.count?.toString() || "0", 10);
2416
2536
  }
2417
2537
  async countBy(column, filters, options) {
2418
2538
  this.checkReadOptions(options);
2419
- const db = options?.transaction || this.config.db;
2539
+ const db = this.dbFor(options?.transaction);
2420
2540
  const spec = this.resolveGroupColumn(column, options);
2421
2541
  const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
2422
2542
  const joins = Array.from(new Set([...filterRelations, ...(spec.relation ? [spec.relation] : [])]));
@@ -2500,6 +2620,8 @@ class SqlBaseCrudService {
2500
2620
  const filter = !Array.isArray(counts) && (0, merge_config_1.isPlainObject)(counts?.[name])
2501
2621
  ? counts?.[name]
2502
2622
  : undefined;
2623
+ if (filter)
2624
+ (0, query_parser_1.assertFilterDepth)(filter);
2503
2625
  return { name, relation, filter };
2504
2626
  });
2505
2627
  const hosts = rows.filter((row) => row && typeof row === "object");
@@ -2556,7 +2678,6 @@ class SqlBaseCrudService {
2556
2678
  }
2557
2679
  pushColumnCondition(conditions, column, value, caseInsensitive = this.config.sql?.caseSensitive === false) {
2558
2680
  if (Array.isArray(value)) {
2559
- this.assertBatchSize(value.length);
2560
2681
  conditions.push((0, drizzle_orm_1.inArray)(column, value.map((item) => this.coerceFilterValue(column, item))));
2561
2682
  }
2562
2683
  else if ((0, merge_config_1.isPlainObject)(value)) {
@@ -2805,7 +2926,6 @@ class SqlBaseCrudService {
2805
2926
  if (!Array.isArray(raw)) {
2806
2927
  throw new common_1.BadRequestException(`Filter operator "${op}" expects an array`);
2807
2928
  }
2808
- this.assertBatchSize(raw.length);
2809
2929
  return raw;
2810
2930
  }
2811
2931
  static MAX_BATCH_PARAMETERS = 60_000;
@@ -2843,9 +2963,19 @@ class SqlBaseCrudService {
2843
2963
  : kind === "endsWith"
2844
2964
  ? `%${escaped}`
2845
2965
  : `%${escaped}%`;
2846
- return insensitive || this.config.sql?.caseSensitive === false
2847
- ? this.caseInsensitiveLike(column, pattern)
2848
- : this.likePattern(column, pattern);
2966
+ if (insensitive || this.config.sql?.caseSensitive === false) {
2967
+ return this.caseInsensitiveLike(column, pattern);
2968
+ }
2969
+ if (this.config.dialect === "sqlite") {
2970
+ const literal = raw.replace(/[*?[]/g, "[$&]");
2971
+ const glob = kind === "startsWith"
2972
+ ? `${literal}*`
2973
+ : kind === "endsWith"
2974
+ ? `*${literal}`
2975
+ : `*${literal}*`;
2976
+ return (0, drizzle_orm_1.sql) `${column} glob ${glob}`;
2977
+ }
2978
+ return this.likePattern(column, pattern);
2849
2979
  }
2850
2980
  applyComplexFilter(conditions, column, filterObj) {
2851
2981
  const coerce = (value) => this.coerceFilterValue(column, value);
@@ -2936,15 +3066,15 @@ class SqlBaseCrudService {
2936
3066
  }
2937
3067
  async executeSqlTransaction(operation, existingTransaction) {
2938
3068
  this.assertTransactions(existingTransaction ?? this.config.db);
3069
+ const run = (tx) => operation(this.withBindLimit(tx));
2939
3070
  if (existingTransaction)
2940
- return operation(existingTransaction);
2941
- return this.config.db.transaction(operation);
3071
+ return run(existingTransaction);
3072
+ return this.config.db.transaction(run);
2942
3073
  }
2943
3074
  assertTransactions(db) {
2944
3075
  if (this.config.dialect !== "sqlite")
2945
3076
  return;
2946
- const kind = db?.constructor?.[drizzle_orm_1.entityKind];
2947
- if (kind === "D1Database" || kind === "D1Transaction") {
3077
+ if (this.isD1(db)) {
2948
3078
  throw new crud_exceptions_1.TransactionException("Cloudflare D1 has no interactive transactions, so mass writes, nested writes and attach/detach/sync are not available on it. Use the single-row methods, or db.batch() yourself.");
2949
3079
  }
2950
3080
  if (db?.resultKind === "sync") {