uql-orm 0.76.0 → 0.77.1

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.
Files changed (40) hide show
  1. package/dist/browser/uql-browser.min.js +2 -2
  2. package/dist/browser/uql-browser.min.js.map +4 -4
  3. package/dist/d1/d1Querier.js +2 -1
  4. package/dist/d1/d1SqliteDialect.js +2 -1
  5. package/dist/dialect/abstractDialect.d.ts +7 -1
  6. package/dist/dialect/abstractDialect.js +17 -5
  7. package/dist/dialect/abstractSqlDialect.d.ts +4 -4
  8. package/dist/dialect/abstractSqlDialect.js +30 -26
  9. package/dist/dialect/mysqlLikeSqlDialect.d.ts +3 -1
  10. package/dist/dialect/mysqlLikeSqlDialect.js +3 -3
  11. package/dist/dialect/pgLikeSqlDialect.js +1 -3
  12. package/dist/dialect/queryJoins.js +6 -5
  13. package/dist/dialect/vectorSqlDialect.js +2 -1
  14. package/dist/http/query.js +4 -2
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +1 -0
  17. package/dist/maria/mariaDialect.js +2 -2
  18. package/dist/mongo/mongoDialect.d.ts +0 -6
  19. package/dist/mongo/mongoDialect.js +21 -29
  20. package/dist/mongo/mongodbQuerier.js +0 -1
  21. package/dist/mssql/mssqlDialect.d.ts +1 -5
  22. package/dist/mssql/mssqlDialect.js +4 -11
  23. package/dist/querier/abstractQuerier.d.ts +16 -3
  24. package/dist/querier/abstractQuerier.js +100 -60
  25. package/dist/querier/abstractSqlQuerier.d.ts +0 -5
  26. package/dist/querier/abstractSqlQuerier.js +4 -17
  27. package/dist/querier/queryError.d.ts +1 -17
  28. package/dist/querier/queryError.js +3 -18
  29. package/dist/sqlite/sqliteDialect.js +0 -2
  30. package/dist/type/dialect.d.ts +18 -6
  31. package/dist/type/queryAggregate.js +2 -1
  32. package/dist/type/queryLock.js +2 -1
  33. package/dist/type/queryWhere.d.ts +7 -6
  34. package/dist/type/utility.d.ts +8 -0
  35. package/dist/util/dialect.util.js +14 -13
  36. package/dist/util/relationQuery.util.js +4 -3
  37. package/dist/util/uqlError.d.ts +39 -0
  38. package/dist/util/uqlError.js +35 -0
  39. package/package.json +1 -1
  40. package/skills/uql-orm/SKILL.md +7 -3
@@ -1,5 +1,6 @@
1
1
  import { getMeta, relationOf } from '../entity/index.js';
2
2
  import { getKeys, getRelationRequestSummary, hasKeys, isRecord, isToManyRelation, isVectorSearch, parseRelationAtKey, parseRelationSize, parseSortByCount, } from '../util/index.js';
3
+ import { UqlUsageError } from '../util/uqlError.js';
3
4
  export const NO_JOINS = new Map();
4
5
  /**
5
6
  * What the statement joins, from `$populate` and from a `$sort` by a to-one relation's field, so the
@@ -49,7 +50,7 @@ export function groupPathField(joins, path) {
49
50
  }
50
51
  const join = joins.get(path.slice(0, -1).join('.'));
51
52
  if (!join) {
52
- throw new TypeError(`cannot $group by '${path.join('.')}': only a to-one relation's field groups, since a to-many multiplies the rows it joins`);
53
+ throw new UqlUsageError(`cannot $group by '${path.join('.')}': only a to-one relation's field groups, since a to-many multiplies the rows it joins`);
53
54
  }
54
55
  return { key, join };
55
56
  }
@@ -149,14 +150,14 @@ function addPathJoins(joins, claimAlias, meta, map, required, parent) {
149
150
  /** The join a sort may address at `path` with the relation's own sort map, or why it may not; `unjoinable` is the dialect's remedy. */
150
151
  export function resolveSortableJoin(relation, path, value, joins, unjoinable) {
151
152
  if (isToManyRelation(relation)) {
152
- throw new TypeError(`cannot $sort by '${path}': a parent has many of them, so there is no single value to order by. Sort the relation's own rows inside $populate instead.`);
153
+ throw new UqlUsageError(`cannot $sort by '${path}': a parent has many of them, so there is no single value to order by. Sort the relation's own rows inside $populate instead.`);
153
154
  }
154
155
  if (!isSortMap(value)) {
155
- throw new TypeError(`$sort by relation '${path}' expects a map of its fields, got ${String(value)}`);
156
+ throw new UqlUsageError(`$sort by relation '${path}' expects a map of its fields, got ${String(value)}`);
156
157
  }
157
158
  const join = joins.get(path);
158
159
  if (!join) {
159
- throw new TypeError(unjoinable);
160
+ throw new UqlUsageError(unjoinable);
160
161
  }
161
162
  return { join, sort: value };
162
163
  }
@@ -177,7 +178,7 @@ export function relationSortTerms(relKey, path, value) {
177
178
  return [];
178
179
  }
179
180
  if (search.$project !== undefined) {
180
- throw new TypeError(`cannot $project the distance of relation '${path}': it ranks the parent, and no one row answers under it`);
181
+ throw new UqlUsageError(`cannot $project the distance of relation '${path}': it ranks the parent, and no one row answers under it`);
181
182
  }
182
183
  return [{ spec: { relation: relKey, op: '$min', field, search }, direction: undefined }];
183
184
  });
@@ -1,5 +1,6 @@
1
1
  import { unsupportedVectorMetric } from '../type/vector.js';
2
2
  import { findVectorIndex, findVectorSort, vectorCandidates, vectorDistanceOf } from '../util/dialect.util.js';
3
+ import { UqlUsageError } from '../util/uqlError.js';
3
4
  import { AbstractDialect } from './abstractDialect.js';
4
5
  import { encodeFloat32s } from './vectorCast.js';
5
6
  /**
@@ -70,7 +71,7 @@ export class VectorSqlDialect extends AbstractDialect {
70
71
  */
71
72
  appendVectorDistance(ctx, meta, key, search, prefix) {
72
73
  if (this.vectorMetrics.size === 0) {
73
- throw new TypeError(`${this.dialectName} does not support vector similarity search. Use raw() for vector queries.`);
74
+ throw new UqlUsageError(`${this.dialectName} does not support vector similarity search. Use raw() for vector queries.`);
74
75
  }
75
76
  const { colName, distance, field } = this.resolveVectorDistance(meta, key, search);
76
77
  const metric = this.vectorMetrics.get(distance);
@@ -5,6 +5,8 @@ import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES, QUER
5
5
  import { RAW_VALUE } from '../type/queryRaw.js';
6
6
  // the specific util module, not the barrel, so the browser bundle does not pull in entity metadata
7
7
  import { getKeys, isWhereMap } from '../util/object.util.js';
8
+ // the error class alone, from its own leaf module: `queryError.ts` carries every driver's code map
9
+ import { UqlUsageError } from '../util/uqlError.js';
8
10
  /**
9
11
  * Keys accepted from the wire - query structure ({@link Query}) plus the `hardDelete`/`count` scalar
10
12
  * flags. Anything else (e.g. `filters`, `context`, `$entity`) is dropped so a remote client can't
@@ -35,7 +37,7 @@ export function parseQueryParams(params = {}) {
35
37
  const query = {};
36
38
  for (const key of getKeys(params)) {
37
39
  if (REJECTED_QUERY_KEYS.has(key)) {
38
- throw Object.assign(new TypeError(`'${key}' is not supported over HTTP`), { status: 400 });
40
+ throw new UqlUsageError(`'${key}' is not supported over HTTP`);
39
41
  }
40
42
  if (ALLOWED_QUERY_KEYS.has(key)) {
41
43
  query[key] = params[key];
@@ -54,7 +56,7 @@ export function parseQueryParams(params = {}) {
54
56
  }
55
57
  query['$where'] ??= {};
56
58
  if (!isWhereMap(query['$where'])) {
57
- throw Object.assign(new TypeError("'$where' must be a JSON object"), { status: 400 });
59
+ throw new UqlUsageError("'$where' must be a JSON object");
58
60
  }
59
61
  // A query string carries every value as text, so what decodes a clause is the shape its group
60
62
  // declares. `'false'` is the reason the boolean pass exists rather than the raw value being taken:
package/dist/index.d.ts CHANGED
@@ -8,3 +8,4 @@ export { withDeleted } from './util/filters.util.js';
8
8
  export type { HookContext } from './util/hook.util.js';
9
9
  export { DefaultLogger } from './util/logger.js';
10
10
  export { raw, refs } from './util/raw.js';
11
+ export * from './util/uqlError.js';
package/dist/index.js CHANGED
@@ -7,3 +7,4 @@ export * from './type/index.js';
7
7
  export { withDeleted } from './util/filters.util.js';
8
8
  export { DefaultLogger } from './util/logger.js';
9
9
  export { raw, refs } from './util/raw.js';
10
+ export * from './util/uqlError.js';
@@ -1,6 +1,6 @@
1
1
  import { relationTermKey } from '../dialect/abstractSqlDialect.js';
2
2
  import { jsonPath } from '../dialect/jsonSql.js';
3
- import { MYSQL_FEATURES, MysqlLikeSqlDialect } from '../dialect/mysqlLikeSqlDialect.js';
3
+ import { MYSQL_FEATURES, MYSQL_ROW_LOCKS, MysqlLikeSqlDialect } from '../dialect/mysqlLikeSqlDialect.js';
4
4
  import { getMeta } from '../entity/index.js';
5
5
  import { columnFamily } from '../util/field.util.js';
6
6
  export class MariaDialect extends MysqlLikeSqlDialect {
@@ -18,7 +18,7 @@ export class MariaDialect extends MysqlLikeSqlDialect {
18
18
  vectorBytes: true,
19
19
  vectorIndexRequiresNotNull: true,
20
20
  indexIfNotExists: true,
21
- rowLockOf: false,
21
+ rowLocks: { ...MYSQL_ROW_LOCKS, of: false },
22
22
  };
23
23
  /**
24
24
  * A derived table here reads no column of the statement around it, so the aggregate reads the
@@ -95,12 +95,6 @@ export declare class MongoDialect extends AbstractDialect {
95
95
  private static compareCount;
96
96
  /** Whether a query subtracts `key` from the projection, via `$exclude` or a negative `$select`. */
97
97
  private subtractsKey;
98
- /**
99
- * MongoDB has no row-level lock to map `$lock` onto: its concurrency control is the transaction
100
- * plus atomic document updates. Rejected rather than ignored, like `raw()` below, since a dropped
101
- * lock silently removes the mutual exclusion the caller asked for.
102
- */
103
- assertNoLock<E>(q: Query<E>): void;
104
98
  /** `raw()` renders SQL, so it has no MongoDB equivalent - say so instead of emitting `{}`. */
105
99
  private assertNoRaw;
106
100
  /**
@@ -6,6 +6,7 @@ import { assertSoleId, fieldOf, getMeta, relationOf, soleIdOf } from '../entity/
6
6
  import { COUNT_RESULT_KEY } from '../type/query.js';
7
7
  import { QueryRaw } from '../type/queryRaw.js';
8
8
  import { aggregateOf, asSelectMap, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fieldUpdateOf, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isFieldUpdateOp, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, rankedTextSearch, someKey, targetKeyColumns, textSortOf, vectorDistanceOf, } from '../util/index.js';
9
+ import { UqlUsageError } from '../util/uqlError.js';
9
10
  import { decodeBigIntsExcept } from '../util/wideNumber.js';
10
11
  import { textLanguage } from './textLanguage.js';
11
12
  import { vectorDistanceExpr } from './vectorDistance.js';
@@ -30,6 +31,7 @@ export const mongoDialectFeatures = {
30
31
  supportsUnsigned: false,
31
32
  serverSideCursors: false,
32
33
  correlatedWrites: false,
34
+ rowLocks: false, // its concurrency control is the transaction plus atomic document updates
33
35
  };
34
36
  /** What `toWireId` converts: the hex spelling of an `ObjectId`, and nothing looser. */
35
37
  const HEX_24 = /^[0-9a-f]{24}$/i;
@@ -109,7 +111,7 @@ export class MongoDialect extends AbstractDialect {
109
111
  else if (meta.relations[key]) {
110
112
  this.assertNoRaw(val);
111
113
  if (!lookups) {
112
- throw new TypeError(`filtering by relation '${key}' is not supported here on MongoDB`);
114
+ throw new UqlUsageError(`filtering by relation '${key}' is not supported here on MongoDB`);
113
115
  }
114
116
  this.appendRelationLookup(filter, meta, key, val, lookups);
115
117
  }
@@ -118,7 +120,7 @@ export class MongoDialect extends AbstractDialect {
118
120
  this.assertKnownPathRoot(meta, key);
119
121
  if (aggregateOf(meta.fields[key])) {
120
122
  if (!lookups) {
121
- throw new TypeError(`filtering by relation aggregate '${key}' is not supported here on MongoDB`);
123
+ throw new UqlUsageError(`filtering by relation aggregate '${key}' is not supported here on MongoDB`);
122
124
  }
123
125
  this.appendAggregateField(meta, key, lookups);
124
126
  }
@@ -270,7 +272,7 @@ export class MongoDialect extends AbstractDialect {
270
272
  ? [{ $gte: [count, bound[0]] }, { $lte: [count, bound[1]] }]
271
273
  : [{ [op]: [count, bound] }]);
272
274
  if (!comparisons.length) {
273
- throw new TypeError('$size needs at least one comparison');
275
+ throw new UqlUsageError('$size needs at least one comparison');
274
276
  }
275
277
  return comparisons.length === 1 ? comparisons[0] : { $and: comparisons };
276
278
  }
@@ -279,20 +281,10 @@ export class MongoDialect extends AbstractDialect {
279
281
  const at = (map) => map?.[key];
280
282
  return at(exclude) === true || at(select) === false;
281
283
  }
282
- /**
283
- * MongoDB has no row-level lock to map `$lock` onto: its concurrency control is the transaction
284
- * plus atomic document updates. Rejected rather than ignored, like `raw()` below, since a dropped
285
- * lock silently removes the mutual exclusion the caller asked for.
286
- */
287
- assertNoLock(q) {
288
- if (q.$lock) {
289
- throw new TypeError('$lock (row-level locking) is not supported on MongoDB');
290
- }
291
- }
292
284
  /** `raw()` renders SQL, so it has no MongoDB equivalent - say so instead of emitting `{}`. */
293
285
  assertNoRaw(value) {
294
286
  if (value instanceof QueryRaw) {
295
- throw new TypeError('raw() in $where is not supported on MongoDB');
287
+ throw new UqlUsageError('raw() in $where is not supported on MongoDB');
296
288
  }
297
289
  }
298
290
  /**
@@ -305,7 +297,7 @@ export class MongoDialect extends AbstractDialect {
305
297
  if (root === MongoDialect.ID_KEY || meta.fields[root]) {
306
298
  return;
307
299
  }
308
- throw new TypeError(`path ${key} does not exist in ${entityName(meta)}`);
300
+ throw new UqlUsageError(`path ${key} does not exist in ${entityName(meta)}`);
309
301
  }
310
302
  /** String operators -> { pattern: (v) => regex, caseInsensitive } */
311
303
  static REGEX_OP_MAP = new Map([
@@ -383,7 +375,7 @@ export class MongoDialect extends AbstractDialect {
383
375
  case '$near':
384
376
  // Atlas offers only a similarity threshold, on the index's own scale, which UQL neither emits nor
385
377
  // reads: converting a distance would mean guessing the metric, so this refuses.
386
- throw new TypeError('$near is not supported on MongoDB: Atlas scores by index-defined similarity, not distance. ' +
378
+ throw new UqlUsageError('$near is not supported on MongoDB: Atlas scores by index-defined similarity, not distance. ' +
387
379
  "Project the score with $sort's $project and filter on it instead.");
388
380
  default:
389
381
  result[op] = val;
@@ -430,7 +422,7 @@ export class MongoDialect extends AbstractDialect {
430
422
  return {};
431
423
  }
432
424
  if (Array.isArray(select)) {
433
- throw new TypeError('raw $select is not supported on MongoDB');
425
+ throw new UqlUsageError('raw $select is not supported on MongoDB');
434
426
  }
435
427
  const selectMap = asSelectMap(select);
436
428
  // Projected by column, not by field key; `normalizeId` maps them back on the way out.
@@ -497,7 +489,7 @@ export class MongoDialect extends AbstractDialect {
497
489
  const relation = meta.relations[key];
498
490
  if (key === '$text') {
499
491
  if (path) {
500
- throw new TypeError(`$sort by $text is only supported on the queried entity, not on relation '${path.slice(0, -1)}'`);
492
+ throw new UqlUsageError(`$sort by $text is only supported on the queried entity, not on relation '${path.slice(0, -1)}'`);
501
493
  }
502
494
  const { order, project } = textSortOf(sort);
503
495
  out[project ?? TEXT_SCORE_ALIAS] = sortDirection(order);
@@ -508,7 +500,7 @@ export class MongoDialect extends AbstractDialect {
508
500
  // so one reaching it is a second. `sortDirection` would read the operator object as "ascending"
509
501
  // and order by the raw vector column instead, a silent answer where the caller asked for a rank.
510
502
  if (isVectorSearch(value)) {
511
- throw new TypeError(`cannot $sort by a second vector '${key}' on MongoDB: $vectorSearch ranks by one`);
503
+ throw new UqlUsageError(`cannot $sort by a second vector '${key}' on MongoDB: $vectorSearch ranks by one`);
512
504
  }
513
505
  const docPath = path + this.pathOf(meta, key);
514
506
  const nulls = sortNulls(value);
@@ -530,7 +522,7 @@ export class MongoDialect extends AbstractDialect {
530
522
  // own pipeline has: a nested one is built inside its parent's `$lookup`, where there is no
531
523
  // parent document left to hang it off.
532
524
  if (path) {
533
- throw new TypeError(`$sort by '${relPath}.${spec.field ?? '$count'}' is only supported on the queried entity`);
525
+ throw new UqlUsageError(`$sort by '${relPath}.${spec.field ?? '$count'}' is only supported on the queried entity`);
534
526
  }
535
527
  if (spec.search) {
536
528
  nearest[sortAggregateField(spec)] = 1;
@@ -773,10 +765,10 @@ export class MongoDialect extends AbstractDialect {
773
765
  // that reads a lookup those columns do not carry. Refused rather than answered all-equal, and in
774
766
  // the same terms the SQL dialects refuse `SELECT DISTINCT` ordered by an unselected column.
775
767
  if (q.$distinct && aggregated.fields.length) {
776
- throw new TypeError(`cannot $sort by a relation's aggregate with $distinct: the grouping keeps only the columns it projects`);
768
+ throw new UqlUsageError(`cannot $sort by a relation's aggregate with $distinct: the grouping keeps only the columns it projects`);
777
769
  }
778
770
  if (q.$distinct && sortOnly.length) {
779
- throw new TypeError(`cannot $sort by relation '${sortOnly[0]}' with $distinct unless '${sortOnly[0]}' is populated: the grouping keeps only the columns it projects`);
771
+ throw new UqlUsageError(`cannot $sort by relation '${sortOnly[0]}' with $distinct unless '${sortOnly[0]}' is populated: the grouping keeps only the columns it projects`);
780
772
  }
781
773
  // `$distinct` inverts the usual order twice over: the projection decides which columns make two
782
774
  // rows the same, so it has to run *before* the grouping, and the grouping collapses rows, so the
@@ -1141,7 +1133,7 @@ export class MongoDialect extends AbstractDialect {
1141
1133
  */
1142
1134
  buildGroupSpec(meta, groupEntries, joins) {
1143
1135
  if (!groupEntries.length) {
1144
- throw new TypeError('aggregate requires at least one $group column or $select function');
1136
+ throw new UqlUsageError('aggregate requires at least one $group column or $select function');
1145
1137
  }
1146
1138
  const groupId = {};
1147
1139
  const accumulators = {};
@@ -1204,7 +1196,7 @@ export class MongoDialect extends AbstractDialect {
1204
1196
  return this.columnOf(meta, key);
1205
1197
  }
1206
1198
  if (aggregateOf(join.meta.fields[key])) {
1207
- throw new TypeError(`cannot $group by '${path.join('.')}' on MongoDB: a joined row's relation aggregate is not read`);
1199
+ throw new UqlUsageError(`cannot $group by '${path.join('.')}' on MongoDB: a joined row's relation aggregate is not read`);
1208
1200
  }
1209
1201
  return `${join.path}.${this.columnOf(join.meta, key)}`;
1210
1202
  }
@@ -1221,14 +1213,14 @@ export class MongoDialect extends AbstractDialect {
1221
1213
  const { join, negate } = MongoDialect.GROUP_OPS[key];
1222
1214
  const clauses = MongoDialect.groupClauses(key, where[key]).map((clause) => {
1223
1215
  if (clause instanceof QueryRaw) {
1224
- throw new TypeError('raw SQL is not supported in an aggregate $where on MongoDB');
1216
+ throw new UqlUsageError('raw SQL is not supported in an aggregate $where on MongoDB');
1225
1217
  }
1226
1218
  return this.whereExpression(meta, clause, named);
1227
1219
  });
1228
1220
  return negate ? { $not: [{ [join]: clauses }] } : { [join]: clauses };
1229
1221
  }
1230
1222
  if (key.startsWith('$')) {
1231
- throw new TypeError(`aggregate $where operator '${key}' is not supported on MongoDB`);
1223
+ throw new UqlUsageError(`aggregate $where operator '${key}' is not supported on MongoDB`);
1232
1224
  }
1233
1225
  const val = where[key];
1234
1226
  named.push(key);
@@ -1271,7 +1263,7 @@ export class MongoDialect extends AbstractDialect {
1271
1263
  case '$isNotNull':
1272
1264
  return operand ? present : MongoDialect.isNullExpr(ref);
1273
1265
  default:
1274
- throw new TypeError(`aggregate $where operator '${op}' is not supported on MongoDB`);
1266
+ throw new UqlUsageError(`aggregate $where operator '${op}' is not supported on MongoDB`);
1275
1267
  }
1276
1268
  });
1277
1269
  return terms.length === 1 ? terms[0] : { $and: terms };
@@ -1326,12 +1318,12 @@ export class MongoDialect extends AbstractDialect {
1326
1318
  const meta = getMeta(entity);
1327
1319
  const field = meta.fields[key];
1328
1320
  if (!field) {
1329
- throw new TypeError(`Field '${key}' not found in entity '${meta.name}'`);
1321
+ throw new UqlUsageError(`Field '${key}' not found in entity '${meta.name}'`);
1330
1322
  }
1331
1323
  const colName = this.resolveColumnName(key, field);
1332
1324
  const indexName = this.vectorSearchIndexName(findVectorIndex(meta, key)?.name, colName);
1333
1325
  if (!limit) {
1334
- throw new TypeError(`$vectorSearch requires $limit (vector sort on '${key}' of '${meta.name}')`);
1326
+ throw new UqlUsageError(`$vectorSearch requires $limit (vector sort on '${key}' of '${meta.name}')`);
1335
1327
  }
1336
1328
  const stage = {
1337
1329
  index: indexName,
@@ -58,7 +58,6 @@ export class MongodbQuerier extends AbstractQuerier {
58
58
  * for a read and a stream alike, so both load the same relations.
59
59
  */
60
60
  readCursor(entity, q, opts) {
61
- this.dialect.assertNoLock(q);
62
61
  const vectorSort = this.dialect.extractVectorSort(q.$sort);
63
62
  const pipeline = vectorSort
64
63
  ? this.buildVectorPipeline(entity, q, vectorSort, opts)
@@ -69,11 +69,7 @@ export declare class MsSqlDialect extends MergeSqlDialect {
69
69
  returningId<E>(meta: EntityMeta<E>): string;
70
70
  protected returningIdExpression<E>(meta: EntityMeta<E>): string;
71
71
  protected mergeReturning(expression: string): string;
72
- /**
73
- * A row lock is a hint on the table here, not a clause at the end of the statement, so
74
- * {@link lockHint} emits it and this only keeps the guard - see the base declaration.
75
- */
76
- protected appendLock<E>(_ctx: QueryContext, entity: Type<E>, q: Query<E>): void;
72
+ /** A row lock is a hint on the table here, which `rowLocks.placement` says instead of a trailing clause. */
77
73
  protected lockHint<E>(q: Query<E>): string;
78
74
  /**
79
75
  * `N'...'`, always. A bare literal is `VARCHAR`, whose codepage silently destroys anything outside
@@ -10,6 +10,7 @@ import { parseQueryLock } from '../type/index.js';
10
10
  import { isAutoIncrement } from '../util/field.util.js';
11
11
  import { assertNonNegativeInteger } from '../util/index.js';
12
12
  import { escapeSingleQuotes } from '../util/sqlLiteral.js';
13
+ import { UqlUsageError } from '../util/uqlError.js';
13
14
  /** What SQL Server has. */
14
15
  const MSSQL_FEATURES = {
15
16
  // Neither object takes an `IF NOT EXISTS`; both need a `sys` catalogue lookup around them, which
@@ -31,10 +32,8 @@ const MSSQL_FEATURES = {
31
32
  supportsUnsigned: false,
32
33
  serverSideCursors: false,
33
34
  correlatedWrites: true,
34
- rowLocks: true,
35
- rowLockWithWindow: true,
35
+ rowLocks: { of: true, withWindow: true, placement: 'tableHint' },
36
36
  nullsOrdering: 'case',
37
- rowLockOf: true,
38
37
  textScoreIndexes: false,
39
38
  orderedUpsertReturning: false,
40
39
  orderedJsonAggregates: true,
@@ -164,13 +163,7 @@ export class MsSqlDialect extends MergeSqlDialect {
164
163
  mergeReturning(expression) {
165
164
  return `OUTPUT ${expression}`;
166
165
  }
167
- /**
168
- * A row lock is a hint on the table here, not a clause at the end of the statement, so
169
- * {@link lockHint} emits it and this only keeps the guard - see the base declaration.
170
- */
171
- appendLock(_ctx, entity, q) {
172
- this.assertLockSupported(entity, q);
173
- }
166
+ /** A row lock is a hint on the table here, which `rowLocks.placement` says instead of a trailing clause. */
174
167
  lockHint(q) {
175
168
  const wait = parseQueryLock(q.$lock);
176
169
  if (!wait) {
@@ -324,7 +317,7 @@ export class MsSqlDialect extends MergeSqlDialect {
324
317
  jsonSet(ctx, expr, set, _field) {
325
318
  for (const [key, value] of Object.entries(set)) {
326
319
  if (value === null) {
327
- throw new TypeError(`mssql cannot $set '${key}' to null: JSON_MODIFY deletes the key instead. Use $unset, or store a JSON null through a whole-column write.`);
320
+ throw new UqlUsageError(`mssql cannot $set '${key}' to null: JSON_MODIFY deletes the key instead. Use $unset, or store a JSON null through a whole-column write.`);
328
321
  }
329
322
  }
330
323
  return Object.entries(set).reduce((acc, [key, value]) => `JSON_MODIFY(${acc}, ${jsonPath(key)}, ${this.jsonWriteParam(ctx, value)})`, `COALESCE(${expr}, '{}')`);
@@ -18,7 +18,14 @@ export declare abstract class AbstractQuerier implements Querier {
18
18
  protected readonly logger: LoggerWrapper;
19
19
  abstract readonly dialect: AbstractDialect;
20
20
  constructor(extra?: ExtraOptions | undefined);
21
- protected validateProjectionQuery<E extends object>(entity: Type<E>, q: Query<E>): void;
21
+ /** What every read is checked for before it runs, whichever backend runs it. */
22
+ protected validateReadQuery<E extends object>(entity: Type<E>, q: Query<E>): void;
23
+ /**
24
+ * Refuses a `$lock` the engine cannot take, then one outside a transaction, where the lock would
25
+ * drop as the statement commits: only the querier knows whether one is open. Here rather than in
26
+ * each backend's read, so the rule reaches a find, a stream and a paged count alike.
27
+ */
28
+ private assertLockable;
22
29
  private validateProjectionQueryRecursive;
23
30
  /** `[entity, query, opts]` from either call form, `(entity, q, opts)` or `({ $entity, ...q }, opts)`. */
24
31
  protected resolveEntityQuery<E extends object, Q extends object>(entityOrQuery: Type<E> | (Q & {
@@ -87,8 +94,14 @@ export declare abstract class AbstractQuerier implements Querier {
87
94
  /** Settles the rows first where the update cascades, so a payload changing what `$where` reads still names them. */
88
95
  updateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdateWrite<E>, opts?: QueryOptions): Promise<number>;
89
96
  /**
90
- * Why an update matched no row: another writer moved the version on, or the row is gone. One read
91
- * without the version predicate answers it, and it runs only on the failure, so the happy path
97
+ * The write every update runs, matching the version `lockKey` names where one is being held. Only a
98
+ * restore passes none: it writes no content, so there is no update of anyone's to lose.
99
+ */
100
+ private updateRows;
101
+ /**
102
+ * Why an update matched no row. The filter named the row by its id, so reading by that id alone
103
+ * separates the three: the row is gone, another writer moved the version on, or the rest of the
104
+ * filter excluded a row still at that version. One read, only on the failure, so the happy path
92
105
  * still costs one statement. Best effort by nature - the row can change again while we ask.
93
106
  */
94
107
  private throwStaleVersion;