@avytrix/nestjs-drizzle-crud 3.7.0 → 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,74 @@ 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
+
73
+ ## [3.7.1] - 2026-11-02
74
+
75
+ ### Fixed
76
+
77
+ - The error for an upsert on a scoped entity on MySQL now reads "upsert on a scoped entity needs
78
+ PostgreSQL or SQLite". It said PostgreSQL only, though SQLite supports a scoped upsert since 3.7.0.
79
+ - `?fields=` paths named `__proto__`, `constructor` or `prototype` are treated as plain names.
80
+ Before, they reached the select tree's object internals (CodeQL js/remote-property-injection).
81
+
14
82
  ## [3.7.0] - 2026-11-02
15
83
 
16
84
  SQLite support, MySQL connections built for you, and full-text search that works like `findAll`.
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");
@@ -394,15 +450,19 @@ class SqlBaseCrudService {
394
450
  return roots;
395
451
  }
396
452
  parseSelect(select) {
397
- const root = { base: [], relations: {} };
453
+ const tree = () => ({
454
+ base: [],
455
+ relations: Object.create(null),
456
+ });
457
+ const root = tree();
398
458
  for (const entry of select || []) {
399
459
  const segments = (entry || "").split(".").filter(Boolean);
400
460
  if (segments.length === 0)
401
461
  continue;
402
462
  let node = root;
403
463
  for (const name of segments.slice(0, -1)) {
404
- if (!node.relations[name])
405
- node.relations[name] = { base: [], relations: {} };
464
+ if (!Object.hasOwn(node.relations, name))
465
+ node.relations[name] = tree();
406
466
  node = node.relations[name];
407
467
  }
408
468
  node.base.push(segments[segments.length - 1]);
@@ -410,7 +470,9 @@ class SqlBaseCrudService {
410
470
  return root;
411
471
  }
412
472
  selectFor(select, name) {
413
- return select.relations[name] ?? { base: [], relations: {} };
473
+ return Object.hasOwn(select.relations, name)
474
+ ? select.relations[name]
475
+ : { base: [], relations: {} };
414
476
  }
415
477
  keysForChildren(nodes, hostTable = this.config.table) {
416
478
  return nodes
@@ -825,8 +887,17 @@ class SqlBaseCrudService {
825
887
  if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT_")) {
826
888
  return code.slice("SQLITE_CONSTRAINT_".length);
827
889
  }
890
+ if (code === "UNKNOWN_SQLITE_ERROR_3091" || e?.rawCode === 3091) {
891
+ return "DATATYPE";
892
+ }
893
+ if (code === "SQLITE_MISMATCH")
894
+ return "MISMATCH";
828
895
  }
829
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";
830
901
  const m = message.match(/(UNIQUE|PRIMARY KEY|FOREIGN KEY|NOT NULL|CHECK) constraint failed/);
831
902
  return m?.[1]?.replace(" ", "");
832
903
  }
@@ -861,6 +932,8 @@ class SqlBaseCrudService {
861
932
  static SQLITE_DATA_ERRORS = {
862
933
  NOTNULL: "a required value is missing",
863
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",
864
937
  };
865
938
  dataErrorMessage(error) {
866
939
  const code = error?.code ?? error?.cause?.code;
@@ -893,7 +966,7 @@ class SqlBaseCrudService {
893
966
  ? text.match(/column '([^']+)'/i)?.[1]
894
967
  : undefined) ??
895
968
  (this.config.dialect === "sqlite"
896
- ? text.match(/NOT NULL constraint failed: (?:[^.\s]+\.)?(\S+)/)?.[1]
969
+ ? text.match(/(?:NOT NULL constraint failed:|value in \S+ column) (?:[^.\s]+\.)?(\S+)/)?.[1]
897
970
  : undefined);
898
971
  return new crud_exceptions_1.ValidationFailedException(column ? `${message}: ${column}` : message);
899
972
  }
@@ -960,7 +1033,9 @@ class SqlBaseCrudService {
960
1033
  throw new common_1.BadRequestException(`Invalid full-text query mode: ${String(mode)} (expected 'plain' or 'websearch')`);
961
1034
  }
962
1035
  const parse = drizzle_orm_1.sql.raw(mode === "websearch" ? "websearch_to_tsquery" : "plainto_tsquery");
963
- 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) ` || `);
964
1039
  return { vector, query: (0, drizzle_orm_1.sql) `${parse}(${config}, ${term})` };
965
1040
  }
966
1041
  buildSearchCondition(search) {
@@ -1105,7 +1180,7 @@ class SqlBaseCrudService {
1105
1180
  }
1106
1181
  async findFirst(conditions, options) {
1107
1182
  const { transaction, relations = [], select = [] } = options || {};
1108
- const db = transaction || this.config.db;
1183
+ const db = this.dbFor(transaction);
1109
1184
  const nodes = this.relationTree(relations);
1110
1185
  const joined = this.planJoins(nodes);
1111
1186
  const selection = this.parseSelect(select);
@@ -1127,7 +1202,7 @@ class SqlBaseCrudService {
1127
1202
  planList(filters, sortBy, sortOrder, options) {
1128
1203
  this.checkReadOptions(options);
1129
1204
  const { transaction, relations = [], select = [] } = options || {};
1130
- const db = transaction || this.config.db;
1205
+ const db = this.dbFor(transaction);
1131
1206
  const nodes = this.relationTree(relations);
1132
1207
  const joined = this.planJoins(nodes);
1133
1208
  const selection = this.parseSelect(select);
@@ -1395,9 +1470,10 @@ class SqlBaseCrudService {
1395
1470
  this.checkReadOptions(options);
1396
1471
  const entityData = await this.prepareCreate(data, options);
1397
1472
  const nested = this.takeNested(entityData, options);
1398
- const db = transaction || this.config.db;
1473
+ const db = this.dbFor(transaction);
1399
1474
  await this.emitEvent("before", "create", false, { input: data, transaction }, options);
1400
1475
  try {
1476
+ await this.assertKeysInScope(db, entityData, options, nested.map((entry) => entry.name));
1401
1477
  const createdEntity = nested.length === 0
1402
1478
  ? await this.insertOne(db, entityData, options)
1403
1479
  : await this.executeSqlTransaction((tx) => this.insertNested(tx, entityData, nested, {
@@ -1520,6 +1596,55 @@ class SqlBaseCrudService {
1520
1596
  }
1521
1597
  return out;
1522
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
+ }
1523
1648
  async linkTargets(tx, relation, parentValue, ids, options) {
1524
1649
  const wanted = this.uniqueValues(ids);
1525
1650
  if (wanted.length === 0)
@@ -1686,9 +1811,10 @@ class SqlBaseCrudService {
1686
1811
  if (this.config.timestamps?.updatedAt) {
1687
1812
  entityData[this.config.timestamps.updatedAt] = new Date();
1688
1813
  }
1689
- const db = transaction || this.config.db;
1814
+ const db = this.dbFor(transaction);
1690
1815
  await this.emitEvent("before", "update", false, { ids: [id], input: data, transaction }, options);
1691
1816
  try {
1817
+ await this.assertKeysInScope(db, entityData, opts);
1692
1818
  const [updatedEntity] = await this.updateWhereIn(db, this.withVersionBump(entityData), [id], opts);
1693
1819
  if (!updatedEntity) {
1694
1820
  if (expected !== undefined) {
@@ -1751,7 +1877,7 @@ class SqlBaseCrudService {
1751
1877
  if (!hooks.skipBefore)
1752
1878
  await this.beforeSoftDelete(id);
1753
1879
  await this.emitEvent("before", "softDelete", false, { ids: [id], transaction }, options);
1754
- const db = transaction || this.config.db;
1880
+ const db = this.dbFor(transaction);
1755
1881
  let updateQuery = db
1756
1882
  .update(this.config.table)
1757
1883
  .set(this.softDeleteSet(new Date()))
@@ -1793,7 +1919,7 @@ class SqlBaseCrudService {
1793
1919
  if (!hooks.skipBefore)
1794
1920
  await this.beforeRestore(id);
1795
1921
  await this.emitEvent("before", "restore", false, { ids: [id], transaction }, options);
1796
- const db = transaction || this.config.db;
1922
+ const db = this.dbFor(transaction);
1797
1923
  const [restoredEntity] = await this.runRead(this.updateWhereIn(db, this.softDeleteSet(null), [id], options));
1798
1924
  if (!restoredEntity) {
1799
1925
  if (options?.expectedVersion != null && this.config.versionColumn) {
@@ -1815,7 +1941,7 @@ class SqlBaseCrudService {
1815
1941
  if (!hooks.skipBefore)
1816
1942
  await this.beforeDelete(id);
1817
1943
  await this.emitEvent("before", "delete", false, { ids: [id], transaction }, options);
1818
- const db = transaction || this.config.db;
1944
+ const db = this.dbFor(transaction);
1819
1945
  const deleteQuery = db
1820
1946
  .delete(this.config.table)
1821
1947
  .where(this.writeWhere([id], options));
@@ -1846,10 +1972,11 @@ class SqlBaseCrudService {
1846
1972
  async upsert(data, upsert = {}, options) {
1847
1973
  this.checkReadOptions(options);
1848
1974
  const entityData = await this.prepareCreate(data, options);
1849
- const db = options?.transaction || this.config.db;
1975
+ const db = this.dbFor(options?.transaction);
1850
1976
  await this.emitEvent("before", "upsert", false, { input: data, transaction: options?.transaction }, options);
1851
1977
  let row;
1852
1978
  try {
1979
+ await this.assertKeysInScope(db, entityData, options);
1853
1980
  [row] = await this.upsertRows(db, [entityData], upsert, options);
1854
1981
  }
1855
1982
  catch (error) {
@@ -1869,7 +1996,6 @@ class SqlBaseCrudService {
1869
1996
  }
1870
1997
  async massUpsert(data, upsert = {}, options) {
1871
1998
  this.checkReadOptions(options);
1872
- this.assertBatchSize(this.insertParameters(data));
1873
1999
  const message = "Mass upsert errors";
1874
2000
  return this.executeSqlTransaction(async (tx) => {
1875
2001
  const opts = { ...options, transaction: tx };
@@ -1880,6 +2006,7 @@ class SqlBaseCrudService {
1880
2006
  if (rows.length === 0)
1881
2007
  return [];
1882
2008
  await this.emitEvent("before", "upsert", true, { input: data, transaction: tx }, options);
2009
+ await this.assertBatchKeysInScope(tx, rows, rows.map((_, index) => ({ index })), message, opts);
1883
2010
  const written = await this.runSet(tx, message, (sp) => this.upsertRows(sp, rows, upsert, opts), rows.map((row, index) => ({
1884
2011
  index,
1885
2012
  run: (sp) => this.upsertRows(sp, [row], upsert, opts),
@@ -1969,7 +2096,7 @@ class SqlBaseCrudService {
1969
2096
  let statement;
1970
2097
  if (this.config.dialect === "mysql") {
1971
2098
  if (scope) {
1972
- throw new common_1.BadRequestException("upsert on a scoped entity needs PostgreSQL");
2099
+ throw new common_1.BadRequestException("upsert on a scoped entity needs PostgreSQL or SQLite");
1973
2100
  }
1974
2101
  statement = insert.onDuplicateKeyUpdate({ set });
1975
2102
  }
@@ -2004,7 +2131,6 @@ class SqlBaseCrudService {
2004
2131
  return this.massCreatePerItem(data, options);
2005
2132
  }
2006
2133
  this.checkReadOptions(options);
2007
- this.assertBatchSize(this.insertParameters(data));
2008
2134
  return this.executeSqlTransaction(async (tx) => {
2009
2135
  const opts = { ...options, transaction: tx };
2010
2136
  const rows = await this.eachItem(data.map((item, index) => ({
@@ -2014,6 +2140,7 @@ class SqlBaseCrudService {
2014
2140
  if (rows.length === 0)
2015
2141
  return [];
2016
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);
2017
2144
  const created = await this.runSet(tx, "Mass create errors", (sp) => this.insertMany(sp, rows, opts), rows.map((row, index) => ({
2018
2145
  index,
2019
2146
  run: (sp) => this.insertOne(sp, row, opts),
@@ -2063,7 +2190,6 @@ class SqlBaseCrudService {
2063
2190
  if (options?.bulkMode === "perItem") {
2064
2191
  return this.massUpdatePerItem(ids, data, options);
2065
2192
  }
2066
- this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2067
2193
  this.checkReadOptions(options);
2068
2194
  const message = "Mass update errors";
2069
2195
  return this.executeSqlTransaction(async (tx) => {
@@ -2084,6 +2210,7 @@ class SqlBaseCrudService {
2084
2210
  },
2085
2211
  })), message);
2086
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);
2087
2214
  const now = new Date();
2088
2215
  const stamp = (set) => this.withVersionBump(this.config.timestamps?.updatedAt
2089
2216
  ? { ...set, [this.config.timestamps.updatedAt]: now }
@@ -2131,7 +2258,6 @@ class SqlBaseCrudService {
2131
2258
  if (options?.bulkMode === "perItem") {
2132
2259
  return this.massSoftDeletePerItem(ids, options);
2133
2260
  }
2134
- this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2135
2261
  const message = "Mass soft delete errors";
2136
2262
  return this.executeSqlTransaction(async (tx) => {
2137
2263
  const opts = {
@@ -2175,7 +2301,6 @@ class SqlBaseCrudService {
2175
2301
  if (options?.bulkMode === "perItem") {
2176
2302
  return this.massRestorePerItem(ids, options);
2177
2303
  }
2178
- this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2179
2304
  this.checkReadOptions(options);
2180
2305
  const message = "Mass restore errors";
2181
2306
  return this.executeSqlTransaction(async (tx) => {
@@ -2213,7 +2338,6 @@ class SqlBaseCrudService {
2213
2338
  if (options?.bulkMode === "perItem") {
2214
2339
  return this.massDeletePerItem(ids, options);
2215
2340
  }
2216
- this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2217
2341
  const message = "Mass delete errors";
2218
2342
  return this.executeSqlTransaction(async (tx) => {
2219
2343
  const opts = { ...options, transaction: tx };
@@ -2326,6 +2450,8 @@ class SqlBaseCrudService {
2326
2450
  return await this.savepoint(tx, statement);
2327
2451
  }
2328
2452
  catch (error) {
2453
+ if (error instanceof BindLimitException)
2454
+ throw error;
2329
2455
  const failures = await this.diagnose(tx, items, operation);
2330
2456
  if (failures.length > 0) {
2331
2457
  throw new crud_exceptions_1.BulkOperationException(message, failures);
@@ -2391,7 +2517,7 @@ class SqlBaseCrudService {
2391
2517
  }
2392
2518
  savepoint(tx, run) {
2393
2519
  return typeof tx?.transaction === "function"
2394
- ? tx.transaction(run)
2520
+ ? tx.transaction((sp) => run(this.withBindLimit(sp)))
2395
2521
  : run(tx);
2396
2522
  }
2397
2523
  async exists(id, options) {
@@ -2403,14 +2529,14 @@ class SqlBaseCrudService {
2403
2529
  }
2404
2530
  async count(filters, options) {
2405
2531
  this.checkReadOptions(options);
2406
- const db = options?.transaction || this.config.db;
2532
+ const db = this.dbFor(options?.transaction);
2407
2533
  const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
2408
2534
  const result = await this.runRead(this.buildCountQuery(db, conditions, filterRelations, options));
2409
2535
  return parseInt(result[0]?.count?.toString() || "0", 10);
2410
2536
  }
2411
2537
  async countBy(column, filters, options) {
2412
2538
  this.checkReadOptions(options);
2413
- const db = options?.transaction || this.config.db;
2539
+ const db = this.dbFor(options?.transaction);
2414
2540
  const spec = this.resolveGroupColumn(column, options);
2415
2541
  const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
2416
2542
  const joins = Array.from(new Set([...filterRelations, ...(spec.relation ? [spec.relation] : [])]));
@@ -2494,6 +2620,8 @@ class SqlBaseCrudService {
2494
2620
  const filter = !Array.isArray(counts) && (0, merge_config_1.isPlainObject)(counts?.[name])
2495
2621
  ? counts?.[name]
2496
2622
  : undefined;
2623
+ if (filter)
2624
+ (0, query_parser_1.assertFilterDepth)(filter);
2497
2625
  return { name, relation, filter };
2498
2626
  });
2499
2627
  const hosts = rows.filter((row) => row && typeof row === "object");
@@ -2550,7 +2678,6 @@ class SqlBaseCrudService {
2550
2678
  }
2551
2679
  pushColumnCondition(conditions, column, value, caseInsensitive = this.config.sql?.caseSensitive === false) {
2552
2680
  if (Array.isArray(value)) {
2553
- this.assertBatchSize(value.length);
2554
2681
  conditions.push((0, drizzle_orm_1.inArray)(column, value.map((item) => this.coerceFilterValue(column, item))));
2555
2682
  }
2556
2683
  else if ((0, merge_config_1.isPlainObject)(value)) {
@@ -2799,7 +2926,6 @@ class SqlBaseCrudService {
2799
2926
  if (!Array.isArray(raw)) {
2800
2927
  throw new common_1.BadRequestException(`Filter operator "${op}" expects an array`);
2801
2928
  }
2802
- this.assertBatchSize(raw.length);
2803
2929
  return raw;
2804
2930
  }
2805
2931
  static MAX_BATCH_PARAMETERS = 60_000;
@@ -2837,9 +2963,19 @@ class SqlBaseCrudService {
2837
2963
  : kind === "endsWith"
2838
2964
  ? `%${escaped}`
2839
2965
  : `%${escaped}%`;
2840
- return insensitive || this.config.sql?.caseSensitive === false
2841
- ? this.caseInsensitiveLike(column, pattern)
2842
- : 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);
2843
2979
  }
2844
2980
  applyComplexFilter(conditions, column, filterObj) {
2845
2981
  const coerce = (value) => this.coerceFilterValue(column, value);
@@ -2930,15 +3066,15 @@ class SqlBaseCrudService {
2930
3066
  }
2931
3067
  async executeSqlTransaction(operation, existingTransaction) {
2932
3068
  this.assertTransactions(existingTransaction ?? this.config.db);
3069
+ const run = (tx) => operation(this.withBindLimit(tx));
2933
3070
  if (existingTransaction)
2934
- return operation(existingTransaction);
2935
- return this.config.db.transaction(operation);
3071
+ return run(existingTransaction);
3072
+ return this.config.db.transaction(run);
2936
3073
  }
2937
3074
  assertTransactions(db) {
2938
3075
  if (this.config.dialect !== "sqlite")
2939
3076
  return;
2940
- const kind = db?.constructor?.[drizzle_orm_1.entityKind];
2941
- if (kind === "D1Database" || kind === "D1Transaction") {
3077
+ if (this.isD1(db)) {
2942
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.");
2943
3079
  }
2944
3080
  if (db?.resultKind === "sync") {