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,13 +1,15 @@
1
1
  import { assertSoleId, getMeta, idOf, namesKey, relationOf } from '../entity/index.js';
2
+ import { parseQueryLock } from '../type/index.js';
2
3
  import { cascadesOnDelete, childrenOf, clone, entityName, fillOnFields, filterFieldKeys, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isPagedQuery, hasKeys, isScalarId, LoggerWrapper, parentJoins, queryLoggerFor, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, someKey, targetKeyColumns, whereIds, withoutSoftDeleteFilter, } from '../util/index.js';
3
- import { enrichError, UqlOptimisticLockError } from './queryError.js';
4
+ import { UqlOptimisticLockError, UqlUsageError } from '../util/uqlError.js';
5
+ import { enrichError } from './queryError.js';
4
6
  /**
5
7
  * Refuses a nullish id, which would reduce to no filter at all, and a composite id missing a column,
6
8
  * which would address every row agreeing on the rest. Callers are `async`, so it always rejects.
7
9
  */
8
10
  function assertIdValue(entity, id) {
9
11
  if (id === undefined || id === null) {
10
- throw new TypeError(`'${entity.name}' was addressed by id, but the id is ${String(id)}`);
12
+ throw new UqlUsageError(`'${entity.name}' was addressed by id, but the id is ${String(id)}`);
11
13
  }
12
14
  if (isScalarId(id)) {
13
15
  // One value names one column, which `whereIds` refuses on a composite.
@@ -20,7 +22,7 @@ function assertIdValue(entity, id) {
20
22
  const { ids } = getMeta(entity);
21
23
  const missing = ids.filter((key) => given[key] == null);
22
24
  if (missing.length) {
23
- throw new TypeError(`'${entity.name}' is addressed by an object carrying every key of its primary key (${ids.join(', ')}); missing ${missing.join(', ')}.`);
25
+ throw new UqlUsageError(`'${entity.name}' is addressed by an object carrying every key of its primary key (${ids.join(', ')}); missing ${missing.join(', ')}.`);
24
26
  }
25
27
  }
26
28
  /** The column a write matches a parent's sole key against, on the junction or the child. */
@@ -36,29 +38,44 @@ function assertNamesRows(entity, method, q, opts) {
36
38
  if (opts?.unfiltered || hasKeys(q?.$where) || q?.$limit !== undefined) {
37
39
  return;
38
40
  }
39
- throw new TypeError(`'${method}' over '${entity.name}' names no rows, so it would address every one: pass '{ unfiltered: true }' to mean it`);
41
+ throw new UqlUsageError(`'${method}' over '${entity.name}' names no rows, so it would address every one: pass '{ unfiltered: true }' to mean it`);
40
42
  }
41
43
  /**
42
- * An optimistic lock as one update applies it: the value the payload carried, out of the payload and
43
- * into the filter, and the next one back in its place. The bump is a plain value rather than SQL
44
- * arithmetic, since the filter pins what the column holds, which spares every engine a read-back.
44
+ * An optimistic lock as one update applies it: the version the payload carried, the one that replaces
45
+ * it, and the filter pinning what the column still holds. The bump is a plain value rather than SQL
46
+ * arithmetic, since that filter already pins it, which spares every engine a read-back.
45
47
  */
46
48
  function lockVersion(meta, key, q, row) {
47
49
  const expected = row[key];
48
- if (expected === undefined || expected === null) {
49
- throw new TypeError(`an update of '${entityName(meta)}' carries no '${key}': a versioned row is written against the version it was read at`);
50
+ if (typeof expected !== 'number' && typeof expected !== 'bigint') {
51
+ throw new UqlUsageError(`an update of '${entityName(meta)}' carries no '${key}': a versioned row is written against the version it was read at`);
50
52
  }
51
- row[key] = (typeof expected === 'bigint' ? expected + 1n : Number(expected) + 1);
52
- return { expected, q: { ...q, $where: { $and: [q.$where ?? {}, { [key]: expected }] } } };
53
+ const next = typeof expected === 'bigint' ? expected + 1n : expected + 1;
54
+ // Spread, as every other added predicate here is: one flat `AND`, and a caller already filtering on
55
+ // the version contradicts itself into matching nothing, which is what they asked for.
56
+ return { expected, next, q: { ...q, $where: { ...q.$where, [key]: expected } } };
53
57
  }
54
58
  /**
55
59
  * Refuses a write that cannot carry the lock, rather than writing over whatever the row holds now.
56
60
  * An upsert has no portable way to match a version - MySQL's `ON DUPLICATE KEY UPDATE` takes no
57
61
  * `WHERE` - and a write the library itself composes has no version to carry.
58
62
  */
59
- function assertUnversioned(meta, method) {
63
+ function assertUnversioned(meta, what) {
60
64
  if (meta.version) {
61
- throw new TypeError(`cannot '${method}' the versioned '${entityName(meta)}': it carries no '${meta.version}' to match, so update it by id`);
65
+ throw new UqlUsageError(`cannot ${what} the versioned '${entityName(meta)}': it carries no '${meta.version}' to match, so update it by id`);
66
+ }
67
+ }
68
+ /**
69
+ * What a versioned update has to be for its lock to hold: one row, named by its id, written by one
70
+ * statement. A filter naming more than one row cannot say which of them the payload's single version
71
+ * belongs to, and anything settled first - a page, a relation write, a filter an engine cannot read in
72
+ * an `UPDATE` - reads the ids and writes them separately, putting the race back in the gap between.
73
+ */
74
+ function assertLockableUpdate(meta, q, settles) {
75
+ const where = q.$where;
76
+ const namesOneRow = meta.ids.every((key) => where?.[key] !== undefined && isScalarId(where[key]));
77
+ if (!namesOneRow || settles) {
78
+ throw new UqlUsageError(`cannot update '${entityName(meta)}' this way: a versioned row is matched and written in one statement, so it is named by its ${meta.ids.map((id) => `'${id}'`).join(', ')}, takes no '$sort', '$limit' or '$skip', writes no relation, and filters by none`);
62
79
  }
63
80
  }
64
81
  /**
@@ -101,15 +118,31 @@ export class AbstractQuerier {
101
118
  this.extra = extra;
102
119
  this.logger = queryLoggerFor(extra);
103
120
  }
104
- validateProjectionQuery(entity, q) {
121
+ /** What every read is checked for before it runs, whichever backend runs it. */
122
+ validateReadQuery(entity, q) {
123
+ this.assertLockable(entity, q);
105
124
  this.validateProjectionQueryRecursive(entity, q, entityName(getMeta(entity)));
106
125
  }
126
+ /**
127
+ * Refuses a `$lock` the engine cannot take, then one outside a transaction, where the lock would
128
+ * drop as the statement commits: only the querier knows whether one is open. Here rather than in
129
+ * each backend's read, so the rule reaches a find, a stream and a paged count alike.
130
+ */
131
+ assertLockable(entity, q) {
132
+ if (!parseQueryLock(q.$lock)) {
133
+ return;
134
+ }
135
+ this.dialect.assertLockSupported(entity, q);
136
+ if (!this.hasOpenTransaction) {
137
+ throw new UqlUsageError('$lock requires an open transaction');
138
+ }
139
+ }
107
140
  validateProjectionQueryRecursive(entity, q, path) {
108
141
  const meta = getMeta(entity);
109
142
  if (q.$select && q.$exclude) {
110
143
  for (const [key, value] of Object.entries(q.$select)) {
111
144
  if (key in meta.fields && value) {
112
- throw new TypeError(`Cannot combine $select and $exclude when $select includes positive scalar fields (${key}) at ${path}. Use either $select (whitelist) or $exclude (subtractive) in a single query.`);
145
+ throw new UqlUsageError(`Cannot combine $select and $exclude when $select includes positive scalar fields (${key}) at ${path}. Use either $select (whitelist) or $exclude (subtractive) in a single query.`);
113
146
  }
114
147
  }
115
148
  }
@@ -129,7 +162,7 @@ export class AbstractQuerier {
129
162
  }
130
163
  const q = entityOrQuery;
131
164
  if (!q.$entity) {
132
- throw new TypeError('$entity is required when using query-object syntax');
165
+ throw new UqlUsageError('$entity is required when using query-object syntax');
133
166
  }
134
167
  const { $entity, ...query } = q;
135
168
  return [$entity, query, maybeQueryOrOpts];
@@ -145,7 +178,7 @@ export class AbstractQuerier {
145
178
  }
146
179
  async findMany(entityOrQuery, maybeQueryOrOpts, maybeOpts) {
147
180
  const [entity, q, opts] = this.resolveEntityQuery(entityOrQuery, maybeQueryOrOpts, maybeOpts);
148
- this.validateProjectionQuery(entity, q);
181
+ this.validateReadQuery(entity, q);
149
182
  const founds = await this.internalFindMany(entity, q, opts);
150
183
  // Guarded here rather than only inside: awaiting a call that returns at once still costs every read
151
184
  // a promise and a turn of the microtask queue, and most reads hook nothing.
@@ -156,12 +189,12 @@ export class AbstractQuerier {
156
189
  }
157
190
  findManyStream(entityOrQuery, maybeQueryOrOpts, maybeOpts) {
158
191
  const [entity, q, opts] = this.resolveEntityQuery(entityOrQuery, maybeQueryOrOpts, maybeOpts);
159
- this.validateProjectionQuery(entity, q);
192
+ this.validateReadQuery(entity, q);
160
193
  return this.internalFindManyStream(entity, q, opts);
161
194
  }
162
195
  async findManyAndCount(entityOrQuery, maybeQueryOrOpts, maybeOpts) {
163
196
  const [entity, q, opts] = this.resolveEntityQuery(entityOrQuery, maybeQueryOrOpts, maybeOpts);
164
- this.validateProjectionQuery(entity, q);
197
+ this.validateReadQuery(entity, q);
165
198
  const [founds, count] = await this.internalFindManyAndCount(entity, q, opts);
166
199
  if (this.listensForLoad(entity, q.$populate)) {
167
200
  await this.emitLoaded(entity, founds, q.$populate);
@@ -218,45 +251,55 @@ export class AbstractQuerier {
218
251
  /** Settles the rows first where the update cascades, so a payload changing what `$where` reads still names them. */
219
252
  async updateMany(entity, q, payload, opts) {
220
253
  assertNamesRows(entity, 'updateMany', q, opts);
254
+ return this.hooked(entity, 'Update', [payload], ([row]) => this.updateRows(entity, q, row, opts, getMeta(entity).version));
255
+ }
256
+ /**
257
+ * The write every update runs, matching the version `lockKey` names where one is being held. Only a
258
+ * restore passes none: it writes no content, so there is no update of anyone's to lose.
259
+ */
260
+ async updateRows(entity, q, row, opts, lockKey) {
221
261
  const meta = getMeta(entity);
222
- return this.hooked(entity, 'Update', [payload], async ([row]) => {
223
- fillOnFields(meta, [row], 'onUpdate');
224
- const { version } = meta;
225
- const lock = version && lockVersion(meta, version, q, row);
226
- const relKeys = filterPersistableRelationKeys(meta, row, 'persist');
227
- if (!relKeys.length && !this.settlesWrite(entity, q)) {
228
- const changes = await this.updateColumns(entity, lock ? lock.q : q, row, opts, 0);
229
- return lock && !changes ? this.throwStaleVersion(entity, version, q, lock.expected, opts) : changes;
230
- }
231
- if (lock) {
232
- // Everything below reads the ids first and writes them in a second statement, which puts the
233
- // race back in the gap between the two - the very thing the version is here to close.
234
- // `settlesWrite` covers the paged forms, so `$sort`, `$limit` and `$skip` land here as well.
235
- throw new TypeError(`cannot update '${entityName(meta)}' this way: a versioned row is matched and written in one statement, so it takes no '$sort', '$limit' or '$skip', writes no relation, and filters by none`);
236
- }
237
- const ids = await this.settleIds(entity, q, opts);
238
- if (!ids.length) {
239
- return 0;
240
- }
241
- const changes = await this.updateColumns(entity, { $where: whereIds(meta, ids) }, row, opts, ids.length);
242
- for (const relKey of relKeys) {
243
- await this.saveRelation(entity, relKey, ids.map((id) => ({ id, value: row[relKey] })), true);
244
- }
245
- return changes;
246
- });
262
+ fillOnFields(meta, [row], 'onUpdate');
263
+ const relKeys = filterPersistableRelationKeys(meta, row, 'persist');
264
+ const settles = !!relKeys.length || this.settlesWrite(entity, q);
265
+ if (lockKey) {
266
+ assertLockableUpdate(meta, q, settles);
267
+ const lock = lockVersion(meta, lockKey, q, row);
268
+ row[lockKey] = lock.next;
269
+ const changes = await this.updateColumns(entity, lock.q, row, opts, 0);
270
+ return changes || this.throwStaleVersion(entity, lockKey, q, lock.expected, opts);
271
+ }
272
+ if (!settles) {
273
+ return this.updateColumns(entity, q, row, opts, 0);
274
+ }
275
+ const ids = await this.settleIds(entity, q, opts);
276
+ if (!ids.length) {
277
+ return 0;
278
+ }
279
+ const changes = await this.updateColumns(entity, { $where: whereIds(meta, ids) }, row, opts, ids.length);
280
+ for (const relKey of relKeys) {
281
+ await this.saveRelation(entity, relKey, ids.map((id) => ({ id, value: row[relKey] })), true);
282
+ }
283
+ return changes;
247
284
  }
248
285
  /**
249
- * Why an update matched no row: another writer moved the version on, or the row is gone. One read
250
- * without the version predicate answers it, and it runs only on the failure, so the happy path
286
+ * Why an update matched no row. The filter named the row by its id, so reading by that id alone
287
+ * separates the three: the row is gone, another writer moved the version on, or the rest of the
288
+ * filter excluded a row still at that version. One read, only on the failure, so the happy path
251
289
  * still costs one statement. Best effort by nature - the row can change again while we ask.
252
290
  */
253
291
  async throwStaleVersion(entity, key, q, expected, opts) {
254
292
  const meta = getMeta(entity);
255
- const row = await this.findOne(entity, { $select: { [key]: true }, $where: q.$where }, opts);
293
+ const where = q.$where;
294
+ const byId = Object.fromEntries(meta.ids.map((id) => [id, where[id]]));
295
+ const row = await this.findOne(entity, { $select: { [key]: true }, $where: byId }, opts);
256
296
  const actual = row?.[key];
257
- throw new UqlOptimisticLockError(actual === undefined
258
- ? `no row of '${entityName(meta)}' matched the update: it is gone, or the filter names none`
259
- : `'${entityName(meta)}' moved on: the payload carries '${key}' ${String(expected)}, the row is at ${String(actual)}`, expected, actual);
297
+ const message = actual === undefined
298
+ ? `no row of '${entityName(meta)}' has that id any more: it is gone`
299
+ : actual === expected
300
+ ? `'${entityName(meta)}' is still at '${key}' ${String(actual)}: another condition of the update's '$where' excluded it`
301
+ : `'${entityName(meta)}' moved on: the payload carries '${key}' ${String(expected)}, the row is at ${String(actual)}`;
302
+ throw new UqlOptimisticLockError(message, expected, actual);
260
303
  }
261
304
  /** The UPDATE, skipped where the payload writes no column, reporting `unwritten` instead. */
262
305
  async updateColumns(entity, q, row, opts, unwritten) {
@@ -284,18 +327,17 @@ export class AbstractQuerier {
284
327
  async restoreMany(entity, q) {
285
328
  const meta = getMeta(entity);
286
329
  if (!meta.softDelete) {
287
- throw new TypeError(`'${entity.name}' has not enabled 'softDelete'`);
330
+ throw new UqlUsageError(`'${entity.name}' has not enabled 'softDelete'`);
288
331
  }
289
- assertUnversioned(meta, 'restoreMany');
290
332
  const $where = { ...q.$where, [meta.softDelete]: { $ne: null } };
291
- return this.updateMany(entity, { ...q, $where }, { [meta.softDelete]: null }, {
292
- filters: { softDelete: false },
293
- });
333
+ // No version: a restore only undoes the stamp a delete left, which takes none either, and two of
334
+ // them racing agree on the result anyway. A lock is for content, and a restore writes none.
335
+ return this.hooked(entity, 'Update', [{ [meta.softDelete]: null }], ([row]) => this.updateRows(entity, { ...q, $where }, row, { filters: { softDelete: false } }, undefined));
294
336
  }
295
337
  /** Fires `beforeUpsert`/`afterUpsert`: which branch a row takes is the database's to decide, so neither the insert's nor the update's pair fits. */
296
338
  async upsertOne(entity, conflictPaths, payload) {
297
339
  const meta = getMeta(entity);
298
- assertUnversioned(meta, 'upsertOne');
340
+ assertUnversioned(meta, "'upsertOne'");
299
341
  return this.hooked(entity, 'Upsert', [payload], async (rows) => {
300
342
  const { ids, changes, created } = await this.internalUpsertOne(entity, conflictPaths, rows[0]);
301
343
  adoptReportedIds(meta, rows, ids);
@@ -305,7 +347,7 @@ export class AbstractQuerier {
305
347
  }
306
348
  async upsertMany(entity, conflictPaths, payload) {
307
349
  const meta = getMeta(entity);
308
- assertUnversioned(meta, 'upsertMany');
350
+ assertUnversioned(meta, "'upsertMany'");
309
351
  return this.hooked(entity, 'Upsert', payload, async (rows) => {
310
352
  const { ids, changes } = await this.internalUpsertMany(entity, conflictPaths, rows);
311
353
  adoptReportedIds(meta, rows, ids);
@@ -343,8 +385,6 @@ export class AbstractQuerier {
343
385
  return changes;
344
386
  }
345
387
  async saveOne(entity, payload) {
346
- // Named here as well as in `saveMany`, so the refusal names the method the caller reached for.
347
- assertUnversioned(getMeta(entity), 'saveOne');
348
388
  const [id] = await this.saveMany(entity, [payload]);
349
389
  return id;
350
390
  }
@@ -355,7 +395,7 @@ export class AbstractQuerier {
355
395
  */
356
396
  async saveMany(entity, payload) {
357
397
  const meta = getMeta(entity);
358
- assertUnversioned(meta, 'saveMany');
398
+ assertUnversioned(meta, "'save'");
359
399
  // Indexes, not rows: the result is reported in payload order so it can be zipped with what was
360
400
  // passed, which concatenating the branches did not do.
361
401
  const toInsert = [];
@@ -38,11 +38,6 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
38
38
  private query;
39
39
  /** Runs a statement the dialect builds. */
40
40
  private exec;
41
- /**
42
- * Refuses a `$lock` the engine lacks, then one outside a transaction, where the lock would drop as the
43
- * statement commits; only the querier knows whether one is open.
44
- */
45
- protected assertLockable<E>(entity: Type<E>, q: Query<E>): void;
46
41
  /**
47
42
  * Runs the `SET`s tuning an ANN index for the query on its connection, refusing where they would apply to
48
43
  * nothing: a `SET LOCAL` outside a transaction.
@@ -3,6 +3,7 @@ import { decodeColumn } from '../dialect/hydrateColumn.js';
3
3
  import { getMeta, namesKey } from '../entity/index.js';
4
4
  import { COUNT_RESULT_KEY } from '../type/index.js';
5
5
  import { buildUpdateResult, clone, getInsertFieldKeys, insertShapeOf, isAutoIncrement, isRecord, obtainAttrsPaths, throwNoPendingTransaction, throwPendingTransaction, unflatObject, unflatObjects, } from '../util/index.js';
6
+ import { UqlUsageError } from '../util/uqlError.js';
6
7
  import { AbstractQuerier } from './abstractQuerier.js';
7
8
  import { streamViaCursor } from './cursorStream.js';
8
9
  import { enrichError } from './queryError.js';
@@ -117,19 +118,6 @@ export class AbstractSqlQuerier extends AbstractQuerier {
117
118
  build(ctx);
118
119
  return this.run(ctx.sql, ctx.values);
119
120
  }
120
- /**
121
- * Refuses a `$lock` the engine lacks, then one outside a transaction, where the lock would drop as the
122
- * statement commits; only the querier knows whether one is open.
123
- */
124
- assertLockable(entity, q) {
125
- if (!q.$lock) {
126
- return;
127
- }
128
- this.dialect.assertLockSupported(entity, q);
129
- if (!this.hasOpenTransaction) {
130
- throw new TypeError('$lock requires an open transaction');
131
- }
132
- }
133
121
  /**
134
122
  * Runs the `SET`s tuning an ANN index for the query on its connection, refusing where they would apply to
135
123
  * nothing: a `SET LOCAL` outside a transaction.
@@ -143,7 +131,7 @@ export class AbstractSqlQuerier extends AbstractQuerier {
143
131
  return;
144
132
  }
145
133
  if (this.dialect.features.vectorTuningNeedsTransaction && !this.hasOpenTransaction) {
146
- throw new TypeError(`$candidates requires an open transaction on ${this.dialect.dialectName}; run the query inside pool.transaction(...)`);
134
+ throw new UqlUsageError(`$candidates requires an open transaction on ${this.dialect.dialectName}; run the query inside pool.transaction(...)`);
147
135
  }
148
136
  for (const statement of statements) {
149
137
  await this.internalRun(statement);
@@ -164,7 +152,8 @@ export class AbstractSqlQuerier extends AbstractQuerier {
164
152
  * so those count apart.
165
153
  */
166
154
  async internalFindManyAndCount(entity, q, opts) {
167
- if (q.$distinct || (q.$lock && !this.dialect.features.rowLockWithWindow)) {
155
+ const { rowLocks } = this.dialect.features;
156
+ if (q.$distinct || (q.$lock && !(rowLocks && rowLocks.withWindow))) {
168
157
  return Promise.all([this.internalFindMany(entity, q, opts), this.countUnpaged(entity, q, opts)]);
169
158
  }
170
159
  const rows = await this.selectRows(entity, q, opts, TOTAL_ALIAS);
@@ -175,7 +164,6 @@ export class AbstractSqlQuerier extends AbstractQuerier {
175
164
  return [this.hydrateRows(entity, rows), total];
176
165
  }
177
166
  async selectRows(entity, q, opts, totalAlias) {
178
- this.assertLockable(entity, q);
179
167
  // Guarded rather than awaited unconditionally, here and in the stream below: an `await` on this
180
168
  // path defers a microtask on every read, which reorders the two statements `findManyAndCount`
181
169
  // issues concurrently. Keep the guard at any new call site.
@@ -190,7 +178,6 @@ export class AbstractSqlQuerier extends AbstractQuerier {
190
178
  return founds;
191
179
  }
192
180
  async *internalFindManyStream(entity, q, opts) {
193
- this.assertLockable(entity, q);
194
181
  // Guarded for the reason `selectRows` above spells out.
195
182
  if (q.$candidates !== undefined) {
196
183
  await this.applyVectorTuning(entity, q);
@@ -1,4 +1,5 @@
1
1
  import type { LoggerWrapper } from '../util/logger.js';
2
+ import { type QueryErrorKind } from '../util/uqlError.js';
2
3
  /**
3
4
  * A driver error tagged by {@link enrichError}: `query` always, `values` only when the logger already
4
5
  * surfaces them, since they can carry PII or tokens into whatever serializes the error.
@@ -7,23 +8,6 @@ export interface QueryError extends Error {
7
8
  query?: string;
8
9
  values?: unknown[];
9
10
  }
10
- /**
11
- * What a failed query ran into, named the same on every engine. `retryable` is a deadlock, a
12
- * serialization failure, a lock timeout or a busy database: the transaction can simply run again.
13
- */
14
- export type QueryErrorKind = 'uniqueViolation' | 'foreignKeyViolation' | 'notNullViolation' | 'checkViolation' | 'optimisticLock' | 'retryable';
15
- /**
16
- * Thrown when an update's `@Field({ version })` no longer matches the row: another writer moved it on,
17
- * or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
18
- * where there is no row left. `status` is what an HTTP transport answers with.
19
- */
20
- export declare class UqlOptimisticLockError extends Error {
21
- readonly expected: unknown;
22
- readonly actual: unknown;
23
- name: string;
24
- readonly status = 409;
25
- constructor(message: string, expected: unknown, actual: unknown);
26
- }
27
11
  /**
28
12
  * Names what `err` ran into on any engine, or `undefined` for anything else. Pure: the error is only
29
13
  * read, so it works on any driver error, whether or not a querier saw it first.
@@ -1,19 +1,4 @@
1
- /**
2
- * Thrown when an update's `@Field({ version })` no longer matches the row: another writer moved it on,
3
- * or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
4
- * where there is no row left. `status` is what an HTTP transport answers with.
5
- */
6
- export class UqlOptimisticLockError extends Error {
7
- expected;
8
- actual;
9
- name = 'UqlOptimisticLockError';
10
- status = 409;
11
- constructor(message, expected, actual) {
12
- super(message);
13
- this.expected = expected;
14
- this.actual = actual;
15
- }
16
- }
1
+ import { UqlOptimisticLockError, UqlUsageError } from '../util/uqlError.js';
17
2
  /** Postgres, CockroachDB, PGlite and Neon in `code`; Bun SQL in `errno`. */
18
3
  const SQLSTATE_KINDS = new Map([
19
4
  ['23505', 'uniqueViolation'],
@@ -68,8 +53,8 @@ export function queryErrorKind(err) {
68
53
  if (typeof err !== 'object' || err === null) {
69
54
  return undefined;
70
55
  }
71
- if (err instanceof UqlOptimisticLockError) {
72
- return 'optimisticLock';
56
+ if (err instanceof UqlOptimisticLockError || err instanceof UqlUsageError) {
57
+ return err.kind;
73
58
  }
74
59
  const { code, errno, number, errorLabels, message } = err;
75
60
  const text = typeof message === 'string' ? message : '';
@@ -34,9 +34,7 @@ export const SQLITE_FEATURES = {
34
34
  serverSideCursors: false,
35
35
  correlatedWrites: true,
36
36
  rowLocks: false,
37
- rowLockWithWindow: true,
38
37
  nullsOrdering: 'clause',
39
- rowLockOf: true,
40
38
  textScoreIndexes: false,
41
39
  orderedUpsertReturning: true,
42
40
  orderedJsonAggregates: true,
@@ -135,15 +135,27 @@ export interface DialectFeatures {
135
135
  * such a write reads the ids of the rows it names first.
136
136
  */
137
137
  readonly correlatedWrites: boolean;
138
+ /**
139
+ * What the engine's row locks can do, or `false` where it has none: the SQLite family locks the
140
+ * database and MongoDB has no row lock at all, so both refuse `$lock` rather than ignoring it. One
141
+ * value rather than a flag each, since the details mean nothing without a lock.
142
+ */
143
+ readonly rowLocks: RowLockFeatures | false;
144
+ }
145
+ /** How a dialect spells a row lock, once {@link DialectFeatures.rowLocks} says it has one. */
146
+ export interface RowLockFeatures {
147
+ /** Whether a lock can be narrowed to one table of a join, `FOR UPDATE OF`, which MariaDB lacks. */
148
+ readonly of: boolean;
149
+ /** Whether the lock may share a statement with a window function, which the Postgres family refuses. */
150
+ readonly withWindow: boolean;
151
+ /**
152
+ * Where the lock is spelled: after the statement (`FOR UPDATE`), or as a hint on the table it reads
153
+ * (`WITH (UPDLOCK)`, SQL Server). A dialect's `lockHint` states the hint itself.
154
+ */
155
+ readonly placement: 'suffix' | 'tableHint';
138
156
  }
139
157
  /** What a SQL engine can do beyond {@link DialectFeatures}, read where a statement is built. */
140
158
  export interface SqlDialectFeatures extends DialectFeatures {
141
- /** Whether the engine has row locks at all. The SQLite family locks the database instead. */
142
- readonly rowLocks: boolean;
143
- /** Whether `FOR UPDATE` may share a statement with a window function, which the Postgres family refuses. */
144
- readonly rowLockWithWindow: boolean;
145
- /** Whether a lock can be narrowed to one table of a join, `FOR UPDATE OF`, which MariaDB lacks. */
146
- readonly rowLockOf: boolean;
147
159
  /**
148
160
  * How a `$sort` states where nulls land: the `NULLS FIRST/LAST` clause, a leading `IS NULL` term
149
161
  * (MySQL, MariaDB), or a leading `CASE` (SQL Server, which has no orderable boolean).
@@ -1,3 +1,4 @@
1
+ import { UqlUsageError } from '../util/uqlError.js';
1
2
  const QUERY_AGGREGATE_OPS = ['$count', '$sum', '$avg', '$min', '$max'];
2
3
  /**
3
4
  * Whether `op` is one of {@link QueryAggregateOp}'s known aggregate operators - validates operator
@@ -17,5 +18,5 @@ export function resolveAggregateOp(key) {
17
18
  if (isQueryAggregateOp(key)) {
18
19
  return { op: key, distinct: false };
19
20
  }
20
- throw new TypeError(`unsupported aggregate operator: ${key}`);
21
+ throw new UqlUsageError(`unsupported aggregate operator: ${key}`);
21
22
  }
@@ -1,3 +1,4 @@
1
+ import { UqlUsageError } from '../util/uqlError.js';
1
2
  const QUERY_LOCK_WAITS = ['nowait', 'skip'];
2
3
  function isOneOf(vals, val) {
3
4
  return vals.includes(val);
@@ -15,7 +16,7 @@ export function parseQueryLock(lock) {
15
16
  return 'block';
16
17
  }
17
18
  if (!isOneOf(QUERY_LOCK_WAITS, lock.$wait)) {
18
- throw new TypeError(`unknown $lock wait policy: ${String(lock.$wait)}`);
19
+ throw new UqlUsageError(`unknown $lock wait policy: ${String(lock.$wait)}`);
19
20
  }
20
21
  return lock.$wait;
21
22
  }
@@ -1,7 +1,7 @@
1
1
  import type { FieldKey, JsonFieldPaths, JsonFieldPathValue, RelationKey, RelationTarget } from './entity.js';
2
2
  import type { QuerySelect } from './query.js';
3
3
  import type { QueryRaw } from './queryRaw.js';
4
- import type { ExpandScalar, IsMany, QueryComparableScalar, Scalar } from './utility.js';
4
+ import type { AtLeastOne, ExpandScalar, IsMany, QueryComparableScalar, Scalar } from './utility.js';
5
5
  import type { QueryVectorQuery } from './vector.js';
6
6
  /**
7
7
  * options for full-text-search operator.
@@ -95,12 +95,13 @@ export type QuerySizeComparisonOps = {
95
95
  };
96
96
  /**
97
97
  * Filter by distance to a vector, `{ $near: { $vector: v, $lt: 0.35 } }`: ordered bounds only, since a
98
- * distance is a float. Each clause names its own `$vector`, and `$distance` falls back to the field's.
99
- * One with no bound is refused at run time, where `/http` input is checked anyway.
98
+ * distance is a float, and at least one, since none would filter nothing. Each clause names its own
99
+ * `$vector`, and `$distance` falls back to the field's. `/http` input is untyped, so the dialect
100
+ * checks it again at run time.
100
101
  */
101
- export type QueryVectorNear = QueryVectorQuery & {
102
- [K in QueryOrderedOp]?: NonNullable<QueryWhereFieldOperatorMap<number>[K]>;
103
- };
102
+ export type QueryVectorNear = QueryVectorQuery & AtLeastOne<{
103
+ [K in QueryOrderedOp]: NonNullable<QueryWhereFieldOperatorMap<number>[K]>;
104
+ }>;
104
105
  export type QueryWhereFieldOperatorMap<T, Raw = QueryRaw> = {
105
106
  /**
106
107
  * whether a value is equal to the given value.
@@ -59,6 +59,14 @@ export type RejectKeys<K> = [K] extends [never] ? unknown : Record<K & string, n
59
59
  export type ExactlyOne<T> = {
60
60
  [K in keyof T]: Readonly<Pick<T, K>> & Partial<Readonly<Record<Exclude<keyof T, K>, never>>>;
61
61
  }[keyof T];
62
+ /**
63
+ * At least one key of `T` with its value, the rest optional: the looser sibling of {@link ExactlyOne},
64
+ * for options that combine but cannot all be left out. `Pick`, so every key stays linked to `T`'s own
65
+ * property and renames follow it through.
66
+ */
67
+ export type AtLeastOne<T> = Partial<T> & {
68
+ [K in keyof T]: Pick<T, K>;
69
+ }[keyof T];
62
70
  export type Unpacked<T> = T extends readonly (infer U)[] ? U : T extends (...args: unknown[]) => infer U ? U : T extends Promise<infer U> ? U : T;
63
71
  /**
64
72
  * Whether the value a property holds is many rather than one: a to-many relation, a scalar array, a