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
@@ -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
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "The JSON-native TypeScript ORM for Bun, Browsers, Edge, Deno, Node, Workers. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, SQL Server, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
5
5
  "license": "MIT",
6
- "version": "0.76.0",
6
+ "version": "0.77.1",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"
@@ -82,9 +82,7 @@ export class Post {
82
82
  - `@Field({ type: Number, version: true })`, with `[versionKey]?: 'version'` on the class, makes the column an
83
83
  optimistic lock: every update payload must carry the version it read (a compile error otherwise), the update
84
84
  matches on it and writes the next one, and a write against a row someone else moved on throws
85
- `UqlOptimisticLockError` (`status` 409) instead of overwriting it. Save, upsert and `restoreMany` are refused on such
86
- an entity (restore with `updateOneById`, `{ filters: { softDelete: false } }`); `updateMany` writes only the rows
87
- still at the version it carries, and delete needs none.
85
+ `UqlOptimisticLockError` (kind `optimisticLock`, HTTP 409) instead of overwriting it. Save and upsert are refused on such an entity; the update is named by its id, so `updateMany` over a many-row filter is refused too, and delete and restore carry no version.
88
86
  - `defineEntity` defines the same entity without decorators: https://uql-orm.dev/entities/imperative.md
89
87
 
90
88
  ## Queries
@@ -126,6 +124,12 @@ const users = await pool.findMany(User, {
126
124
  - An update takes `{ stock: { $inc: -1 } }` to add, or `$mul` to multiply, in the statement, a NULL counting as 0,
127
125
  so a guard in `$where` (`stock: { $gte: 1 }`) makes a decrement race-safe. JSON fields take `$set`, `$unset`,
128
126
  `$push`, `$pull`.
127
+ - `$lock: true` locks the rows a read returns (`{ $wait: 'skip' | 'nowait' }` says what to do about a row
128
+ someone else holds) and needs an open transaction; SQLite, libSQL, Turso, D1 and MongoDB have no row lock and
129
+ refuse it.
130
+ - `queryErrorKind(err)` names any failure the same on every engine - `uniqueViolation`, `foreignKeyViolation`,
131
+ `notNullViolation`, `checkViolation`, `optimisticLock`, `retryable`, `usage` - so catch by kind rather than by
132
+ a driver's code or an `instanceof`.
129
133
  - `raw()` embeds SQL anywhere a value or field goes; `pool.all(sql, values)` runs a raw `SELECT`.
130
134
 
131
135
  ## Connections and transactions