uql-orm 0.77.0 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/dist/browser/uql-browser.min.js +2 -2
  3. package/dist/browser/uql-browser.min.js.map +4 -4
  4. package/dist/d1/d1Querier.js +2 -1
  5. package/dist/d1/d1SqliteDialect.js +2 -1
  6. package/dist/dialect/abstractDialect.d.ts +7 -1
  7. package/dist/dialect/abstractDialect.js +17 -5
  8. package/dist/dialect/abstractSqlDialect.d.ts +4 -4
  9. package/dist/dialect/abstractSqlDialect.js +30 -26
  10. package/dist/dialect/mysqlLikeSqlDialect.d.ts +3 -1
  11. package/dist/dialect/mysqlLikeSqlDialect.js +3 -3
  12. package/dist/dialect/pgLikeSqlDialect.js +1 -3
  13. package/dist/dialect/queryJoins.js +6 -5
  14. package/dist/dialect/vectorSqlDialect.js +2 -1
  15. package/dist/http/query.js +4 -2
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/maria/mariaDialect.js +2 -2
  19. package/dist/migrate/introspection/mssqlIntrospector.d.ts +1 -0
  20. package/dist/migrate/introspection/mssqlIntrospector.js +4 -1
  21. package/dist/migrate/introspection/mysqlIntrospector.d.ts +1 -0
  22. package/dist/migrate/introspection/mysqlIntrospector.js +2 -0
  23. package/dist/migrate/introspection/postgresIntrospector.d.ts +4 -0
  24. package/dist/migrate/introspection/postgresIntrospector.js +9 -0
  25. package/dist/migrate/introspection/sqliteIntrospector.d.ts +9 -0
  26. package/dist/migrate/introspection/sqliteIntrospector.js +77 -0
  27. package/dist/mongo/mongoDialect.d.ts +0 -6
  28. package/dist/mongo/mongoDialect.js +21 -29
  29. package/dist/mongo/mongodbQuerier.js +0 -1
  30. package/dist/mssql/mssqlDialect.d.ts +1 -5
  31. package/dist/mssql/mssqlDialect.js +4 -11
  32. package/dist/querier/abstractQuerier.d.ts +8 -1
  33. package/dist/querier/abstractQuerier.js +32 -14
  34. package/dist/querier/abstractSqlQuerier.d.ts +0 -5
  35. package/dist/querier/abstractSqlQuerier.js +4 -17
  36. package/dist/querier/queryError.d.ts +1 -27
  37. package/dist/querier/queryError.js +3 -28
  38. package/dist/sqlite/sqliteDialect.js +0 -2
  39. package/dist/type/dialect.d.ts +18 -6
  40. package/dist/type/queryAggregate.js +2 -1
  41. package/dist/type/queryLock.js +2 -1
  42. package/dist/type/queryWhere.d.ts +7 -6
  43. package/dist/type/utility.d.ts +8 -0
  44. package/dist/util/dialect.util.js +14 -13
  45. package/dist/util/relationQuery.util.js +4 -3
  46. package/dist/util/uqlError.d.ts +39 -0
  47. package/dist/util/uqlError.js +35 -0
  48. package/package.json +1 -1
  49. package/skills/uql-orm/SKILL.md +7 -1
@@ -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, UqlLockUsageError, 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,7 +38,7 @@ 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
44
  * An optimistic lock as one update applies it: the version the payload carried, the one that replaces
@@ -46,7 +48,7 @@ function assertNamesRows(entity, method, q, opts) {
46
48
  function lockVersion(meta, key, q, row) {
47
49
  const expected = row[key];
48
50
  if (typeof expected !== 'number' && typeof expected !== 'bigint') {
49
- throw new UqlLockUsageError(`an update of '${entityName(meta)}' carries no '${key}': a versioned row is written against the version it was read at`);
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
53
  const next = typeof expected === 'bigint' ? expected + 1n : expected + 1;
52
54
  // Spread, as every other added predicate here is: one flat `AND`, and a caller already filtering on
@@ -60,7 +62,7 @@ function lockVersion(meta, key, q, row) {
60
62
  */
61
63
  function assertUnversioned(meta, what) {
62
64
  if (meta.version) {
63
- throw new UqlLockUsageError(`cannot ${what} 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`);
64
66
  }
65
67
  }
66
68
  /**
@@ -73,7 +75,7 @@ function assertLockableUpdate(meta, q, settles) {
73
75
  const where = q.$where;
74
76
  const namesOneRow = meta.ids.every((key) => where?.[key] !== undefined && isScalarId(where[key]));
75
77
  if (!namesOneRow || settles) {
76
- throw new UqlLockUsageError(`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`);
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`);
77
79
  }
78
80
  }
79
81
  /**
@@ -116,15 +118,31 @@ export class AbstractQuerier {
116
118
  this.extra = extra;
117
119
  this.logger = queryLoggerFor(extra);
118
120
  }
119
- 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);
120
124
  this.validateProjectionQueryRecursive(entity, q, entityName(getMeta(entity)));
121
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
+ }
122
140
  validateProjectionQueryRecursive(entity, q, path) {
123
141
  const meta = getMeta(entity);
124
142
  if (q.$select && q.$exclude) {
125
143
  for (const [key, value] of Object.entries(q.$select)) {
126
144
  if (key in meta.fields && value) {
127
- 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.`);
128
146
  }
129
147
  }
130
148
  }
@@ -144,7 +162,7 @@ export class AbstractQuerier {
144
162
  }
145
163
  const q = entityOrQuery;
146
164
  if (!q.$entity) {
147
- throw new TypeError('$entity is required when using query-object syntax');
165
+ throw new UqlUsageError('$entity is required when using query-object syntax');
148
166
  }
149
167
  const { $entity, ...query } = q;
150
168
  return [$entity, query, maybeQueryOrOpts];
@@ -160,7 +178,7 @@ export class AbstractQuerier {
160
178
  }
161
179
  async findMany(entityOrQuery, maybeQueryOrOpts, maybeOpts) {
162
180
  const [entity, q, opts] = this.resolveEntityQuery(entityOrQuery, maybeQueryOrOpts, maybeOpts);
163
- this.validateProjectionQuery(entity, q);
181
+ this.validateReadQuery(entity, q);
164
182
  const founds = await this.internalFindMany(entity, q, opts);
165
183
  // Guarded here rather than only inside: awaiting a call that returns at once still costs every read
166
184
  // a promise and a turn of the microtask queue, and most reads hook nothing.
@@ -171,12 +189,12 @@ export class AbstractQuerier {
171
189
  }
172
190
  findManyStream(entityOrQuery, maybeQueryOrOpts, maybeOpts) {
173
191
  const [entity, q, opts] = this.resolveEntityQuery(entityOrQuery, maybeQueryOrOpts, maybeOpts);
174
- this.validateProjectionQuery(entity, q);
192
+ this.validateReadQuery(entity, q);
175
193
  return this.internalFindManyStream(entity, q, opts);
176
194
  }
177
195
  async findManyAndCount(entityOrQuery, maybeQueryOrOpts, maybeOpts) {
178
196
  const [entity, q, opts] = this.resolveEntityQuery(entityOrQuery, maybeQueryOrOpts, maybeOpts);
179
- this.validateProjectionQuery(entity, q);
197
+ this.validateReadQuery(entity, q);
180
198
  const [founds, count] = await this.internalFindManyAndCount(entity, q, opts);
181
199
  if (this.listensForLoad(entity, q.$populate)) {
182
200
  await this.emitLoaded(entity, founds, q.$populate);
@@ -309,7 +327,7 @@ export class AbstractQuerier {
309
327
  async restoreMany(entity, q) {
310
328
  const meta = getMeta(entity);
311
329
  if (!meta.softDelete) {
312
- throw new TypeError(`'${entity.name}' has not enabled 'softDelete'`);
330
+ throw new UqlUsageError(`'${entity.name}' has not enabled 'softDelete'`);
313
331
  }
314
332
  const $where = { ...q.$where, [meta.softDelete]: { $ne: null } };
315
333
  // No version: a restore only undoes the stamp a delete left, which takes none either, and two of
@@ -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,33 +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
- /**
28
- * Thrown where a write cannot carry the optimistic lock: an update payload without its version, or a
29
- * method with no version to match. A `TypeError` still, since the caller used the API wrong, but one
30
- * carrying the `status` an HTTP transport answers with - the request is malformed, not the server's
31
- * failure, and an untyped client is exactly who reaches this.
32
- */
33
- export declare class UqlLockUsageError extends TypeError {
34
- name: string;
35
- readonly status = 400;
36
- }
37
11
  /**
38
12
  * Names what `err` ran into on any engine, or `undefined` for anything else. Pure: the error is only
39
13
  * read, so it works on any driver error, whether or not a querier saw it first.
@@ -1,29 +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
- }
17
- /**
18
- * Thrown where a write cannot carry the optimistic lock: an update payload without its version, or a
19
- * method with no version to match. A `TypeError` still, since the caller used the API wrong, but one
20
- * carrying the `status` an HTTP transport answers with - the request is malformed, not the server's
21
- * failure, and an untyped client is exactly who reaches this.
22
- */
23
- export class UqlLockUsageError extends TypeError {
24
- name = 'UqlLockUsageError';
25
- status = 400;
26
- }
1
+ import { UqlOptimisticLockError, UqlUsageError } from '../util/uqlError.js';
27
2
  /** Postgres, CockroachDB, PGlite and Neon in `code`; Bun SQL in `errno`. */
28
3
  const SQLSTATE_KINDS = new Map([
29
4
  ['23505', 'uniqueViolation'],
@@ -78,8 +53,8 @@ export function queryErrorKind(err) {
78
53
  if (typeof err !== 'object' || err === null) {
79
54
  return undefined;
80
55
  }
81
- if (err instanceof UqlOptimisticLockError) {
82
- return 'optimisticLock';
56
+ if (err instanceof UqlOptimisticLockError || err instanceof UqlUsageError) {
57
+ return err.kind;
83
58
  }
84
59
  const { code, errno, number, errorLabels, message } = err;
85
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
@@ -4,6 +4,7 @@ import { QueryRaw, resolveAggregateOp, SOFT_DELETE_FILTER, } from '../type/index
4
4
  import { DEFAULT_VECTOR_DISTANCE, VECTOR_INDEX_TYPES } from '../type/vector.js';
5
5
  import { getFieldKeys, isDatabaseWritten } from './field.util.js';
6
6
  import { entityName, getKeys, hasKeys, isOperatorObject, isScalarId, isRecord, isWhereMap, someKey, } from './object.util.js';
7
+ import { UqlUsageError } from './uqlError.js';
7
8
  /** The keys of `payload` a write persists as columns. */
8
9
  export function filterFieldKeys(meta, payload, callbackKey) {
9
10
  return getKeys(payload).filter((key) => {
@@ -194,7 +195,7 @@ export function findVectorSort(sort) {
194
195
  export function vectorCandidates(q) {
195
196
  const candidates = q.$candidates;
196
197
  if (candidates !== undefined && (!Number.isInteger(candidates) || candidates < 1)) {
197
- throw new TypeError(`$candidates must be a positive integer, got ${JSON.stringify(candidates)}`);
198
+ throw new UqlUsageError(`$candidates must be a positive integer, got ${JSON.stringify(candidates)}`);
198
199
  }
199
200
  return candidates;
200
201
  }
@@ -260,7 +261,7 @@ export function isFieldUpdateOp(value) {
260
261
  */
261
262
  export function fieldUpdateOf(key, value) {
262
263
  if (value.$inc !== undefined && value.$mul !== undefined) {
263
- throw new TypeError(`'${key}' takes one of $inc and $mul`);
264
+ throw new UqlUsageError(`'${key}' takes one of $inc and $mul`);
264
265
  }
265
266
  return value.$inc === undefined ? ['$mul', value.$mul] : ['$inc', value.$inc];
266
267
  }
@@ -280,7 +281,7 @@ export function whereIds(meta, ids) {
280
281
  */
281
282
  export function assertWhere(meta, where) {
282
283
  if (!isWhereMap(where)) {
283
- throw new TypeError(`$where on '${entityName(meta)}' must be a map of conditions, such as { id: 1 }`);
284
+ throw new UqlUsageError(`$where on '${entityName(meta)}' must be a map of conditions, such as { id: 1 }`);
284
285
  }
285
286
  }
286
287
  /** Returns a `QueryOptions.filters` value with the built-in soft-delete filter disabled (used by hard delete). */
@@ -353,7 +354,7 @@ export function parseRelationSize(val) {
353
354
  }
354
355
  const siblings = getKeys(val).filter((key) => key !== '$size');
355
356
  if (siblings.length) {
356
- throw new TypeError(`$size on a relation cannot be combined with other conditions: ${siblings.join(', ')}`);
357
+ throw new UqlUsageError(`$size on a relation cannot be combined with other conditions: ${siblings.join(', ')}`);
357
358
  }
358
359
  return val.$size;
359
360
  }
@@ -368,7 +369,7 @@ export function parseSortByCount(val) {
368
369
  }
369
370
  const siblings = getKeys(val).filter((key) => key !== '$count');
370
371
  if (siblings.length) {
371
- throw new TypeError(`$count in a $sort cannot be combined with other keys: ${siblings.join(', ')}`);
372
+ throw new UqlUsageError(`$count in a $sort cannot be combined with other keys: ${siblings.join(', ')}`);
372
373
  }
373
374
  return val.$count;
374
375
  }
@@ -393,13 +394,13 @@ export function parseGroupMap(group, select) {
393
394
  const call = select[alias];
394
395
  const key = getKeys(call).find((name) => name !== '$where');
395
396
  if (key === undefined) {
396
- throw new TypeError(`aggregate '${alias}' names no op, only a $where`);
397
+ throw new UqlUsageError(`aggregate '${alias}' names no op, only a $where`);
397
398
  }
398
399
  // `$countDistinct` normalizes to `$count` plus a `distinct` flag.
399
400
  const { op, distinct } = resolveAggregateOp(key);
400
401
  const field = aggregateField(alias, call[key]);
401
402
  if (field === undefined && (op !== '$count' || distinct)) {
402
- throw new TypeError(`aggregate '${alias}' takes '*' only as a $count`);
403
+ throw new UqlUsageError(`aggregate '${alias}' takes '*' only as a $count`);
403
404
  }
404
405
  entries.push({ kind: 'fn', alias, op, distinct, ...(field && { field }), ...(hasKeys(where) ? { where } : {}) });
405
406
  }
@@ -409,7 +410,7 @@ export function parseGroupMap(group, select) {
409
410
  function groupRefPath(alias, ref) {
410
411
  const [key, ...rest] = isRecord(ref) ? getKeys(ref) : [];
411
412
  if (!isRecord(ref) || key === undefined || rest.length) {
412
- throw new TypeError(`$group '${alias}' names one field by the path to it: got ${JSON.stringify(ref)}`);
413
+ throw new UqlUsageError(`$group '${alias}' names one field by the path to it: got ${JSON.stringify(ref)}`);
413
414
  }
414
415
  return ref[key] === true ? [key] : [key, ...groupRefPath(alias, ref[key])];
415
416
  }
@@ -420,7 +421,7 @@ function aggregateField(alias, arg) {
420
421
  }
421
422
  const [field, ...rest] = namedKeys(arg);
422
423
  if (field === undefined || rest.length) {
423
- throw new TypeError(`aggregate '${alias}' takes one field as { field: true }, or '*': got ${JSON.stringify(arg)}`);
424
+ throw new UqlUsageError(`aggregate '${alias}' takes one field as { field: true }, or '*': got ${JSON.stringify(arg)}`);
424
425
  }
425
426
  return field;
426
427
  }
@@ -454,7 +455,7 @@ export function isJsonObject(value) {
454
455
  */
455
456
  export function assertNonNegativeInteger(value, clause) {
456
457
  if (!Number.isInteger(value) || value < 0) {
457
- throw new TypeError(`${clause} must be a non-negative integer, got ${value}`);
458
+ throw new UqlUsageError(`${clause} must be a non-negative integer, got ${value}`);
458
459
  }
459
460
  return value;
460
461
  }
@@ -464,7 +465,7 @@ export function assertNonNegativeInteger(value, clause) {
464
465
  * SQL and MongoDB refuse the same query with the same words.
465
466
  */
466
467
  export function throwUnknownAggregateColumn(key, clause) {
467
- throw new TypeError(`cannot ${clause} by '${key}': it is neither a $group column nor a $select alias`);
468
+ throw new UqlUsageError(`cannot ${clause} by '${key}': it is neither a $group column nor a $select alias`);
468
469
  }
469
470
  /** {@link throwUnknownAggregateColumn} over every key of a clause, for backends that check up front. */
470
471
  export function assertAggregateColumns(clauseMap, emitted, clause) {
@@ -529,7 +530,7 @@ export function textSortOf(sort) {
529
530
  */
530
531
  export function rankedTextSearch(where) {
531
532
  if (!where?.$text) {
532
- throw new TypeError('$sort by $text ranks by the $text at the root of $where, which this query has none of');
533
+ throw new UqlUsageError('$sort by $text ranks by the $text at the root of $where, which this query has none of');
533
534
  }
534
535
  return where.$text;
535
536
  }
@@ -551,5 +552,5 @@ export function textSearchFields(meta, search) {
551
552
  const declared = fulltext.length
552
553
  ? `${fulltext.length} fulltext indexes to choose from`
553
554
  : 'no fulltext index to search';
554
- throw new TypeError(`$text on '${name}' names no $fields, and '${name}' declares ${declared}. Name them with $fields.`);
555
+ throw new UqlUsageError(`$text on '${name}' names no $fields, and '${name}' declares ${declared}. Name them with $fields.`);
555
556
  }
@@ -1,5 +1,6 @@
1
1
  import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES, QUERY_STATEMENT_CLAUSES, } from '../type/query.js';
2
2
  import { getKeys, isRecord, someKey } from './object.util.js';
3
+ import { UqlUsageError } from './uqlError.js';
3
4
  /** What a query populating nothing requests, shared: most reads populate nothing, and ask on every one. */
4
5
  const NOTHING_REQUESTED = Object.freeze({
5
6
  requestedKeys: Object.freeze([]),
@@ -65,7 +66,7 @@ function assertJoinableRelationQuery(relKey, value) {
65
66
  }
66
67
  for (const [key, reason] of JOINED_RELATION_REJECTED_KEYS) {
67
68
  if (key in value) {
68
- throw new TypeError(`'${key}' is not supported inside $populate of the to-one relation '${relKey}': ${reason}.`);
69
+ throw new UqlUsageError(`'${key}' is not supported inside $populate of the to-one relation '${relKey}': ${reason}.`);
69
70
  }
70
71
  }
71
72
  }
@@ -138,7 +139,7 @@ export function parseRelationQueryValue(value) {
138
139
  if (isRecord(value)) {
139
140
  const statementOnly = QUERY_STATEMENT_CLAUSES.find((clause) => clause in value);
140
141
  if (statementOnly) {
141
- throw new TypeError(`'${statementOnly}' applies to the whole statement, not to a populated relation. Move it to the top level of the query.`);
142
+ throw new UqlUsageError(`'${statementOnly}' applies to the whole statement, not to a populated relation. Move it to the top level of the query.`);
142
143
  }
143
144
  }
144
145
  if (isRelationQueryObject(value)) {
@@ -152,7 +153,7 @@ export function parseRelationQueryValue(value) {
152
153
  return { query: { $select: selectMap }, required: false, nested: false };
153
154
  }
154
155
  if (value !== undefined && value !== null && value !== true && value !== 1) {
155
- throw new TypeError(`Invalid relation query value '${String(value)}'. Expected true/1, relation query object, or relation $populate array.`);
156
+ throw new UqlUsageError(`Invalid relation query value '${String(value)}'. Expected true/1, relation query object, or relation $populate array.`);
156
157
  }
157
158
  return { query: {}, required: false, nested: false };
158
159
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * What a failed query ran into, named the same on every engine - what {@link queryErrorKind} answers
3
+ * with, whether a driver raised the error or UQL did. `retryable` is a deadlock, a serialization
4
+ * failure, a lock timeout or a busy database: the transaction can simply run again. `usage` is the
5
+ * caller's own mistake, which running it again will not fix.
6
+ */
7
+ export type QueryErrorKind = 'uniqueViolation' | 'foreignKeyViolation' | 'notNullViolation' | 'checkViolation' | 'optimisticLock' | 'retryable' | 'usage';
8
+ /**
9
+ * Thrown where the caller used the API in a way no statement can carry out: an update payload with no
10
+ * version, a `$lock` outside a transaction, a method with no version to match. A `TypeError` still,
11
+ * since the call itself is wrong, but one carrying the `status` an HTTP transport answers with - the
12
+ * request is malformed, not the server's failure, and an untyped client is exactly who reaches this.
13
+ */
14
+ export declare class UqlUsageError extends TypeError {
15
+ name: string;
16
+ /** What `queryErrorKind` answers, so a caller branches on it rather than on the class. */
17
+ readonly kind = "usage";
18
+ /** What an HTTP transport answers with. */
19
+ readonly status = 400;
20
+ }
21
+ /**
22
+ * @deprecated since 0.77.1 - use {@link UqlUsageError}, which every misuse throws, lock or not. The
23
+ * same class under both names, so an existing `instanceof` keeps working.
24
+ */
25
+ export declare const UqlLockUsageError: typeof UqlUsageError;
26
+ export type UqlLockUsageError = UqlUsageError;
27
+ /**
28
+ * Thrown when an update's `@Field({ version })` no longer matches the row: another writer moved it on,
29
+ * or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
30
+ * where there is no row left.
31
+ */
32
+ export declare class UqlOptimisticLockError extends Error {
33
+ readonly expected: unknown;
34
+ readonly actual: unknown;
35
+ name: string;
36
+ readonly kind = "optimisticLock";
37
+ readonly status = 409;
38
+ constructor(message: string, expected: unknown, actual: unknown);
39
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Thrown where the caller used the API in a way no statement can carry out: an update payload with no
3
+ * version, a `$lock` outside a transaction, a method with no version to match. A `TypeError` still,
4
+ * since the call itself is wrong, but one carrying the `status` an HTTP transport answers with - the
5
+ * request is malformed, not the server's failure, and an untyped client is exactly who reaches this.
6
+ */
7
+ export class UqlUsageError extends TypeError {
8
+ name = 'UqlUsageError';
9
+ /** What `queryErrorKind` answers, so a caller branches on it rather than on the class. */
10
+ kind = 'usage';
11
+ /** What an HTTP transport answers with. */
12
+ status = 400;
13
+ }
14
+ /**
15
+ * @deprecated since 0.77.1 - use {@link UqlUsageError}, which every misuse throws, lock or not. The
16
+ * same class under both names, so an existing `instanceof` keeps working.
17
+ */
18
+ export const UqlLockUsageError = UqlUsageError;
19
+ /**
20
+ * Thrown when an update's `@Field({ version })` no longer matches the row: another writer moved it on,
21
+ * or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
22
+ * where there is no row left.
23
+ */
24
+ export class UqlOptimisticLockError extends Error {
25
+ expected;
26
+ actual;
27
+ name = 'UqlOptimisticLockError';
28
+ kind = 'optimisticLock';
29
+ status = 409;
30
+ constructor(message, expected, actual) {
31
+ super(message);
32
+ this.expected = expected;
33
+ this.actual = actual;
34
+ }
35
+ }