uql-orm 0.37.1 → 0.39.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 (85) hide show
  1. package/dist/browser/uql-browser.min.js +2 -2
  2. package/dist/browser/uql-browser.min.js.map +5 -5
  3. package/dist/cockroachdb/cockroachDialect.d.ts +5 -24
  4. package/dist/cockroachdb/cockroachDialect.js +3 -29
  5. package/dist/dialect/abstractSqlDialect.d.ts +42 -12
  6. package/dist/dialect/abstractSqlDialect.js +117 -48
  7. package/dist/dialect/aliases.d.ts +6 -0
  8. package/dist/dialect/aliases.js +6 -0
  9. package/dist/dialect/index.d.ts +0 -5
  10. package/dist/dialect/index.js +2 -5
  11. package/dist/dialect/jsonSql.d.ts +17 -2
  12. package/dist/dialect/jsonSql.js +15 -0
  13. package/dist/dialect/mysqlLikeSqlDialect.d.ts +13 -14
  14. package/dist/dialect/mysqlLikeSqlDialect.js +16 -20
  15. package/dist/dialect/pgLikeSqlDialect.d.ts +17 -20
  16. package/dist/dialect/pgLikeSqlDialect.js +30 -62
  17. package/dist/dialect/vectorCast.d.ts +0 -9
  18. package/dist/dialect/vectorCast.js +0 -8
  19. package/dist/dialect/vectorSqlDialect.d.ts +47 -17
  20. package/dist/dialect/vectorSqlDialect.js +78 -30
  21. package/dist/entity/decorator/entity.d.ts +1 -1
  22. package/dist/entity/metadata/definition.d.ts +1 -1
  23. package/dist/entity/metadata/definition.js +4 -3
  24. package/dist/http/query.js +3 -2
  25. package/dist/libsql/libsqlDialect.d.ts +2 -2
  26. package/dist/libsql/libsqlDialect.js +3 -3
  27. package/dist/maria/mariaDialect.d.ts +12 -17
  28. package/dist/maria/mariaDialect.js +21 -36
  29. package/dist/maria/mariaVectorMetrics.d.ts +8 -0
  30. package/dist/maria/mariaVectorMetrics.js +10 -0
  31. package/dist/maria/mariadbQuerier.d.ts +5 -0
  32. package/dist/maria/mariadbQuerier.js +5 -0
  33. package/dist/migrate/builder/migrationBuilder.d.ts +8 -17
  34. package/dist/migrate/builder/migrationBuilder.js +48 -136
  35. package/dist/migrate/builder/types.d.ts +0 -2
  36. package/dist/migrate/ddl/index.d.ts +11 -0
  37. package/dist/migrate/ddl/index.js +34 -0
  38. package/dist/{dialect/indexSqlDialect.d.ts → migrate/ddl/indexDdl.d.ts} +23 -19
  39. package/dist/migrate/ddl/indexDdl.js +126 -0
  40. package/dist/migrate/ddl/mysqlIndexDdl.d.ts +52 -0
  41. package/dist/migrate/ddl/mysqlIndexDdl.js +125 -0
  42. package/dist/migrate/ddl/pgIndexDdl.d.ts +36 -0
  43. package/dist/migrate/ddl/pgIndexDdl.js +87 -0
  44. package/dist/migrate/drift/driftDetector.js +6 -1
  45. package/dist/migrate/generator/mongoSchemaGenerator.js +1 -1
  46. package/dist/migrate/index.d.ts +1 -0
  47. package/dist/migrate/index.js +2 -0
  48. package/dist/migrate/introspection/mysqlIntrospector.d.ts +9 -3
  49. package/dist/migrate/introspection/mysqlIntrospector.js +27 -5
  50. package/dist/migrate/migrator.js +3 -2
  51. package/dist/migrate/schemaGenerator.d.ts +6 -6
  52. package/dist/migrate/schemaGenerator.js +13 -22
  53. package/dist/mongo/mongoDialect.d.ts +1 -2
  54. package/dist/mongo/mongoDialect.js +29 -22
  55. package/dist/mongo/mongodbQuerier.js +1 -1
  56. package/dist/mysql/mysqlDialect.d.ts +3 -6
  57. package/dist/mysql/mysqlDialect.js +4 -11
  58. package/dist/postgres/postgresDialect.d.ts +2 -0
  59. package/dist/postgres/postgresDialect.js +2 -0
  60. package/dist/querier/abstractSqlQuerier.d.ts +11 -0
  61. package/dist/querier/abstractSqlQuerier.js +35 -0
  62. package/dist/schema/canonicalType.js +35 -36
  63. package/dist/schema/indexDifferences.d.ts +2 -1
  64. package/dist/schema/indexDifferences.js +3 -2
  65. package/dist/sqlite/sqliteDialect.d.ts +2 -2
  66. package/dist/sqlite/sqliteDialect.js +5 -5
  67. package/dist/turso/tursoDialect.d.ts +2 -2
  68. package/dist/turso/tursoDialect.js +4 -4
  69. package/dist/type/dialect.d.ts +21 -7
  70. package/dist/type/dialect.js +17 -0
  71. package/dist/type/entity.d.ts +117 -8
  72. package/dist/type/entity.js +7 -0
  73. package/dist/type/query.d.ts +18 -0
  74. package/dist/type/query.js +6 -0
  75. package/dist/type/queryWhere.d.ts +46 -2
  76. package/dist/type/vector.d.ts +45 -7
  77. package/dist/type/vector.js +16 -1
  78. package/dist/util/dialect.util.d.ts +20 -1
  79. package/dist/util/dialect.util.js +49 -4
  80. package/dist/util/object.util.d.ts +7 -1
  81. package/dist/util/object.util.js +8 -0
  82. package/dist/util/relationQuery.util.d.ts +3 -1
  83. package/dist/util/relationQuery.util.js +8 -3
  84. package/package.json +1 -1
  85. package/dist/dialect/indexSqlDialect.js +0 -103
@@ -1,3 +1,18 @@
1
+ /**
2
+ * An index capability that some engines have and others reject outright. Verified live: expression
3
+ * indexes exist everywhere but MariaDB 12.3 (which needs a generated column); prefix lengths are
4
+ * MySQL-family only and *required* there to index `TEXT`; `NULLS FIRST/LAST` and operator classes are
5
+ * Postgres-only (CockroachDB 26.2 answers "unimplemented"); `INCLUDE` is Postgres-wire only; the
6
+ * MySQL family is alone in having no partial indexes. The two JSON ones split the other way: the
7
+ * engines that index a path inside a document are the ones whose planner matches the query's own
8
+ * extraction back to it (Postgres, CockroachDB, SQLite - MySQL matches neither
9
+ * `CAST(col->>'$.x' AS CHAR(n))` nor `JSON_VALUE(... RETURNING ...)`, verified on 26.7, and needs a
10
+ * generated column instead), while MySQL alone has the multi-valued index a JSON array needs.
11
+ *
12
+ * Introspectors reuse the vocabulary for what they can read *back*, which is what diffing may
13
+ * compare. The two sets are deliberately not the same object and must not be unified: Postgres can
14
+ * emit an expression index and read one back, but MySQL emits one it cannot describe afterwards.
15
+ */
1
16
  export const INDEX_FEATURE_LABELS = {
2
17
  expression: 'expression indexes',
3
18
  partial: 'partial indexes',
@@ -5,4 +20,6 @@ export const INDEX_FEATURE_LABELS = {
5
20
  nullsOrder: 'NULLS FIRST/LAST in an index',
6
21
  opsClass: 'index operator classes',
7
22
  include: 'covering indexes (INCLUDE)',
23
+ jsonPath: 'indexes over a path inside a JSON column',
24
+ jsonArray: 'multi-valued indexes over a JSON array',
8
25
  };
@@ -7,6 +7,13 @@ import type { VectorDistance, VectorIndexOptions, VectorIndexType } from './vect
7
7
  * Allow to customize the name of the property that identifies an entity
8
8
  */
9
9
  export declare const idKey: unique symbol;
10
+ /**
11
+ * The one filter name uql registers itself, from `@Field({ softDelete })`. Four ends have to agree on
12
+ * it and none would fail if they drifted: the field that registers it, the decorator that reserves
13
+ * the name against a user's own filter, the hard delete that switches it off, and the bypass check
14
+ * that lets it through on an entity which never declared one.
15
+ */
16
+ export declare const SOFT_DELETE_FILTER = "softDelete";
10
17
  /**
11
18
  * Infers the key names of an entity
12
19
  */
@@ -521,10 +528,59 @@ export type IndexTypeOptions = {
521
528
  * ```
522
529
  *
523
530
  * `C` is the entity's `FieldKey` on the `@Index`/`defineEntity` paths, where the decorated class says
524
- * which columns exist. It defaults to `string` for the migration builder's `table.index(...)`, which
525
- * names raw table columns with no entity in scope.
531
+ * which columns exist, and `E` the entity itself, which is what checks a JSON entry's path. Both
532
+ * default to the unchecked form for the migration builder's `table.index(...)`, which names raw
533
+ * table columns with no entity in scope.
526
534
  */
527
- export type IndexColumnInput<C extends string = string> = C | QueryRaw | IndexColumnOptions<C>;
535
+ export type IndexColumnInput<C extends string = string, E = unknown> = C | QueryRaw | IndexColumnOptions<C> | IndexJsonColumnOptions<C, E>;
536
+ /**
537
+ * The JSON entries, whose `path` is checked against the payload of the column the same entry names -
538
+ * a mapped union, one arm per JSON field, so `{ column: 'kind', jsonPath: { path: 'thema.color' } }`
539
+ * cannot compile. It matters more here than anywhere else in the index API: a path that is merely
540
+ * *misspelled* still builds a perfectly valid index, one no query will ever match, and nothing at
541
+ * runtime can tell that from the index you meant.
542
+ *
543
+ * `jsonArray`'s path is the array's own, so on a column that *is* the array (`Json<string[]>`) it
544
+ * resolves to `never` and the property can only be omitted, which is exactly the truth.
545
+ *
546
+ * Falls back to the unchecked shape only where there is no entity to check against - the migration
547
+ * builder. An entity with no JSON field at all offers no arm, which is also the truth.
548
+ */
549
+ type IndexJsonColumnOptions<C extends string, E> = unknown extends E ? IndexColumnModifiers & {
550
+ readonly column: C | QueryRaw;
551
+ } : {
552
+ [K in JsonColumnKey<E>]: IndexColumnPlainModifiers & {
553
+ readonly column: K;
554
+ } & ({
555
+ readonly jsonPath: WithCheckedPath<IndexJsonPath, E, K>;
556
+ readonly jsonArray?: never;
557
+ } | {
558
+ readonly jsonArray: WithCheckedPath<IndexJsonArray, E, K>;
559
+ readonly jsonPath?: never;
560
+ });
561
+ }[JsonColumnKey<E>];
562
+ /**
563
+ * The JSON columns an index can address, which is a wider set than {@link JsonFieldKey}: that one
564
+ * unwraps arrays to find the brand, so a column that *is* an array (`Json<string[]>`) reads as a
565
+ * plain one - right for `$where`, which has no path into it, and wrong for `jsonArray`, whose whole
566
+ * subject is that column.
567
+ */
568
+ type JsonColumnKey<E> = {
569
+ readonly [K in keyof E]-?: IsJson<NonNullable<E[K]>> extends true ? K : IsJson<JsonElement<E[K]>> extends true ? K : never;
570
+ }[Key<E>];
571
+ /** The payload a path is checked against: the column's own brand, or that of the documents it holds. */
572
+ type JsonColumnPayload<V> = IsJson<NonNullable<V>> extends true ? UnwrapJson<NonNullable<V>> : JsonPayload<V>;
573
+ /**
574
+ * A JSON modifier with its `path` narrowed to the ones that column's payload actually has. Everything
575
+ * else - and `path`'s own optionality, which `jsonArray` needs and `jsonPath` does not - is taken
576
+ * from the declared type rather than restated, so a property added to either cannot miss the checked
577
+ * form.
578
+ */
579
+ type WithCheckedPath<T extends {
580
+ path?: string;
581
+ }, E, K extends Key<E>> = Except<T, 'path' & keyof T> & {
582
+ [P in keyof Pick<T, Extract<keyof T, 'path'>>]: DeepJsonKeys<JsonColumnPayload<E[K]>>;
583
+ };
528
584
  /**
529
585
  * What an index entry can carry besides the thing being indexed. Shared with the normalized
530
586
  * `IndexColumnSchema`, so the authored and internal shapes cannot drift apart.
@@ -541,8 +597,56 @@ export type IndexColumnModifiers = {
541
597
  readonly nulls?: 'first' | 'last';
542
598
  /** Operator class, e.g. `jsonb_path_ops` for a smaller GIN index. Postgres only. */
543
599
  readonly opsClass?: string;
600
+ /** Index a path inside a JSON column. See {@link IndexJsonPath}. */
601
+ readonly jsonPath?: IndexJsonPath;
602
+ /** Index every element of a JSON array. See {@link IndexJsonArray}. */
603
+ readonly jsonArray?: IndexJsonArray;
544
604
  };
545
- export type IndexColumnOptions<C extends string = string> = IndexColumnModifiers & {
605
+ /**
606
+ * An index over one path inside a JSON column, compiled by the same code a `$where` on that path is:
607
+ * an expression index is matched by its own text, so an index spelled even slightly differently is
608
+ * one the planner never reaches for.
609
+ *
610
+ * `type` picks the reading the way an operand's own type does (`jsonCompareMode`): compared as a
611
+ * number, indexed as a number. Which engines have it is `IndexFeature`'s `jsonPath`.
612
+ *
613
+ * @example
614
+ * ```ts
615
+ * @Index([{ column: 'kind', jsonPath: { path: 'theme.color', type: String } }]) // 'kind.theme.color': 'red'
616
+ * @Index([{ column: 'kind', jsonPath: { path: 'rating', type: Number } }]) // 'kind.rating': { $gte: 4 }
617
+ * ```
618
+ */
619
+ export type IndexJsonPath = {
620
+ /** The path inside the column, spelled as a `$where` key spells it: `'theme.color'`. */
621
+ readonly path: string;
622
+ /** How the value is read, matching what the queries over it compare against. */
623
+ readonly type: FieldType;
624
+ };
625
+ /**
626
+ * MySQL's multi-valued index: one key per *element* of the JSON array at `path` (the column itself
627
+ * when there is none), which is the only index `$all`/`$elemMatch` containment can use. `type` is
628
+ * the element's, and a string or binary one needs a `length`, since the cast is what sizes the key.
629
+ *
630
+ * MySQL is alone in having it - `IndexFeature`'s `jsonArray` - and an index asking for it elsewhere
631
+ * is refused rather than silently built.
632
+ *
633
+ * @example
634
+ * ```ts
635
+ * @Index([{ column: 'tags', jsonArray: { type: String, length: 64 } }]) // tags: { $all: [...] }
636
+ * @Index([{ column: 'kind', jsonArray: { path: 'ids', type: Number } }]) // 'kind.ids': { $all: [...] }
637
+ * ```
638
+ */
639
+ export type IndexJsonArray = {
640
+ /** The array's path inside the column, spelled as a `$where` key spells it; omit for the column. */
641
+ readonly path?: string;
642
+ /** The element type, matching what the queries over the array compare against. */
643
+ readonly type: FieldType;
644
+ /** Length of a string or binary element, which MySQL's `CHAR(n) ARRAY` cast requires. */
645
+ readonly length?: number;
646
+ };
647
+ /** The modifiers that do not name a JSON path, and so need no entity to be checked against. */
648
+ type IndexColumnPlainModifiers = Except<IndexColumnModifiers, 'jsonPath' | 'jsonArray'>;
649
+ export type IndexColumnOptions<C extends string = string> = IndexColumnPlainModifiers & {
546
650
  /** The column to index, or `raw(...)` for an expression. */
547
651
  readonly column: C | QueryRaw;
548
652
  };
@@ -621,7 +725,7 @@ export type EntityOptions<E = unknown> = {
621
725
  readonly relations?: {
622
726
  readonly [K in RelationKey<E>]?: RelationOptionsFor<E[K]>;
623
727
  };
624
- readonly indexes?: readonly EntityIndexInput<FieldKey<E>>[];
728
+ readonly indexes?: readonly EntityIndexInput<FieldKey<E>, E>[];
625
729
  /** Map hook events to method names on the entity class. */
626
730
  readonly hooks?: Partial<Record<HookEvent, readonly MethodKey<E>[]>>;
627
731
  };
@@ -630,11 +734,16 @@ export type EntityOptions<E = unknown> = {
630
734
  * migration builder's `table.index(...)`. `Except` (not plain `Omit`) keeps `type`/`distance` a
631
735
  * discriminated pair: omitting `distance` on a vector index type is a compile error.
632
736
  */
633
- export type IndexOptions = Except<EntityIndexMeta, 'columns'>;
737
+ export type IndexOptions<E = unknown> = Except<EntityIndexMeta, 'columns' | 'include'> & {
738
+ /** Non-key columns stored in the index; a typo builds nothing, the server refusing the statement. */
739
+ readonly include?: readonly IndexFieldKey<E>[];
740
+ };
741
+ /** A field of `E`, or any name where there is no entity to check it against - the migration builder. */
742
+ type IndexFieldKey<E> = unknown extends E ? string : FieldKey<E>;
634
743
  /**
635
744
  * An index as authored, before `defineIndex` normalizes its columns.
636
745
  */
637
- export type EntityIndexInput<C extends string = string> = IndexOptions & {
638
- readonly columns: readonly IndexColumnInput<C>[];
746
+ export type EntityIndexInput<C extends string = string, E = unknown> = IndexOptions<E> & {
747
+ readonly columns: readonly IndexColumnInput<C, E>[];
639
748
  };
640
749
  export {};
@@ -2,3 +2,10 @@
2
2
  * Allow to customize the name of the property that identifies an entity
3
3
  */
4
4
  export const idKey = Symbol('idKey');
5
+ /**
6
+ * The one filter name uql registers itself, from `@Field({ softDelete })`. Four ends have to agree on
7
+ * it and none would fail if they drifted: the field that registers it, the decorator that reserves
8
+ * the name against a user's own filter, the hard delete that switches it off, and the bypass check
9
+ * that lets it through on an entity which never declared one.
10
+ */
11
+ export const SOFT_DELETE_FILTER = 'softDelete';
@@ -245,6 +245,18 @@ export type Query<E> = {
245
245
  * is what keeps the clause off those statements at the type level.
246
246
  */
247
247
  $lock?: QueryLock;
248
+ /**
249
+ * how many candidates an approximate-nearest-neighbour index explores before ranking, for a vector
250
+ * search. Higher trades speed for recall; the default is whatever the engine's own is, which is
251
+ * tuned for speed. Ignored where the search is exact (SQLite, libSQL and Turso scan every row) and
252
+ * where the field carries no ANN index, since there is nothing to widen.
253
+ *
254
+ * The units are the index's, not UQL's, so the number is not comparable across index types: it
255
+ * becomes `hnsw.ef_search` or `ivfflat.probes` on Postgres, `mhnsw_ef_search` on MariaDB, and
256
+ * `numCandidates` on MongoDB Atlas. On Postgres it needs an open transaction, since a `SET LOCAL`
257
+ * outside one applies to nothing.
258
+ */
259
+ $candidates?: number;
248
260
  /**
249
261
  * filtering options.
250
262
  */
@@ -275,6 +287,12 @@ export declare const QUERY_OBJECT_CLAUSES: readonly ["$select", "$populate", "$e
275
287
  */
276
288
  export declare const QUERY_ROOT_OBJECT_CLAUSES: readonly ["$count"];
277
289
  export declare const QUERY_NUMBER_CLAUSES: readonly ["$skip", "$limit"];
290
+ /**
291
+ * Number clauses only the statement itself takes - the numeric mirror of {@link QUERY_ROOT_OBJECT_CLAUSES}.
292
+ * `$candidates` tunes the index behind a vector search, and a vector search only ever ranks the rows
293
+ * the statement returns, so a relation's own query has nothing to tune.
294
+ */
295
+ export declare const QUERY_ROOT_NUMBER_CLAUSES: readonly ["$candidates"];
278
296
  export declare const QUERY_BOOLEAN_CLAUSES: readonly ["$distinct"];
279
297
  /**
280
298
  * options to get a single record.
@@ -32,4 +32,10 @@ export const QUERY_OBJECT_CLAUSES = [
32
32
  */
33
33
  export const QUERY_ROOT_OBJECT_CLAUSES = ['$count'];
34
34
  export const QUERY_NUMBER_CLAUSES = ['$skip', '$limit'];
35
+ /**
36
+ * Number clauses only the statement itself takes - the numeric mirror of {@link QUERY_ROOT_OBJECT_CLAUSES}.
37
+ * `$candidates` tunes the index behind a vector search, and a vector search only ever ranks the rows
38
+ * the statement returns, so a relation's own query has nothing to tune.
39
+ */
40
+ export const QUERY_ROOT_NUMBER_CLAUSES = ['$candidates'];
35
41
  export const QUERY_BOOLEAN_CLAUSES = ['$distinct'];
@@ -1,6 +1,7 @@
1
1
  import type { FieldKey, IdValue, JsonFieldPaths, JsonFieldPathValue, RelationKey, RelationTarget } from './entity.js';
2
2
  import type { QueryRaw } from './queryRaw.js';
3
3
  import type { ExpandScalar, IsMany, QueryComparableScalar, Scalar } from './utility.js';
4
+ import type { QueryVectorQuery } from './vector.js';
4
5
  /**
5
6
  * options for full-text-search operator.
6
7
  */
@@ -91,6 +92,34 @@ export type QueryNegateOp = keyof Pick<QueryWhereRootOperator<unknown>, '$not' |
91
92
  export type QuerySizeComparisonOps = {
92
93
  [K in QueryHavingOp | '$between']?: NonNullable<QueryWhereFieldOperatorMap<number>[K]>;
93
94
  };
95
+ /**
96
+ * Filter by distance to a query vector: `$where`'s counterpart to `$sort`'s ranking, so "the closest
97
+ * ten" and "everything closer than 0.35" stay separate asks.
98
+ *
99
+ * Bounded by {@link QueryOrderedOp} - what {@link QuerySizeComparisonOps} ranges over, minus
100
+ * `$eq`/`$ne`. A distance is a float, so exact equality against one is a bug every time, where
101
+ * `$size` compares an integer `COUNT`. No `$project` either: naming the distance is `$sort`'s job,
102
+ * since the `SELECT` list is built from `$sort` alone and a `$near` nested inside an `$or` has no
103
+ * business projecting a column.
104
+ *
105
+ * `$distance` is here for the same reason `$sort` has it: each clause states its own search
106
+ * completely, so neither depends on the other. Omitted, it falls back to the field's declared metric,
107
+ * which is where the metric belongs - beside the index it has to match. Naming a different one per
108
+ * query mostly buys a full scan, since an ANN index is built for exactly one operator class.
109
+ *
110
+ * `$vector` is required, and repeating it beside a `$sort` that ranks by the same field is the point:
111
+ * every other `$where` operator means the same thing wherever it appears, and inheriting one from a
112
+ * sibling clause would make this the first whose validity depends on what else the query contains -
113
+ * unfixable in the type, and carried into merged entity filters and `/http` payloads alike. Naming
114
+ * the vector in a `const` is what removes the repetition, at the call site where it belongs.
115
+ *
116
+ * A `$near` carrying no bound is a `WHERE` that is always true. The dialect rejects that rather than
117
+ * the type: `/http` casts client JSON straight to `Query`, so the check has to exist there anyway,
118
+ * and an "at least one of these five" union would cost every caller worse errors for a second copy.
119
+ */
120
+ export type QueryVectorNear = QueryVectorQuery & {
121
+ [K in QueryOrderedOp]?: NonNullable<QueryWhereFieldOperatorMap<number>[K]>;
122
+ };
94
123
  export type QueryWhereFieldOperatorMap<T> = {
95
124
  /**
96
125
  * whether a value is equal to the given value.
@@ -200,6 +229,12 @@ export type QueryWhereFieldOperatorMap<T> = {
200
229
  * @example { addresses: { $elemMatch: { city: { $like: 'New%' } } } }
201
230
  */
202
231
  $elemMatch?: unknown extends T ? QueryWhereElemMatch<unknown> : NonNullable<T> extends readonly (infer U)[] ? QueryWhereElemMatch<U> : never;
232
+ /**
233
+ * whether a vector is within a given distance of the query vector. `$sort` ranks by distance;
234
+ * this filters by it, so "the closest ten" and "everything closer than 0.35" are separate asks.
235
+ * @example { embedding: { $near: { $vector: queryVec, $lt: 0.35 } } }
236
+ */
237
+ $near?: QueryVectorNear;
203
238
  };
204
239
  /**
205
240
  * Element-level conditions for `$elemMatch`. Scalar elements take an operator map for the element
@@ -241,15 +276,24 @@ type QueryArrayOp = keyof Pick<QueryWhereFieldOperatorMap<unknown>, '$all' | '$s
241
276
  * Ordering operators: {@link QueryCompareOp} plus `$between`.
242
277
  */
243
278
  type QueryOrderedOp = QueryCompareOp | keyof Pick<QueryWhereFieldOperatorMap<unknown>, '$between'>;
279
+ /**
280
+ * Vector-only operators. `Pick`'s constraint ties this back to {@link QueryWhereFieldOperatorMap}
281
+ * so a rename there breaks this union at compile time.
282
+ */
283
+ type QueryVectorOp = keyof Pick<QueryWhereFieldOperatorMap<unknown>, '$near'>;
244
284
  /**
245
285
  * Operators applicable to every field type: equality, membership, negation, and null checks.
286
+ *
287
+ * @remarks This is a subtraction, not a list, so an operator added to
288
+ * {@link QueryWhereFieldOperatorMap} without also being classified above lands here and is offered
289
+ * on every field - `$near` on a `boolean`, say. Classify first, then add.
246
290
  */
247
- type QueryCommonOp = Exclude<keyof QueryWhereFieldOperatorMap<unknown>, QueryStringOp | QueryArrayOp | QueryOrderedOp>;
291
+ type QueryCommonOp = Exclude<keyof QueryWhereFieldOperatorMap<unknown>, QueryStringOp | QueryArrayOp | QueryOrderedOp | QueryVectorOp>;
248
292
  /**
249
293
  * Operator keys applicable to a field of type `T`. Brackets prevent union distribution so an
250
294
  * optional field (`string | undefined`) or a literal union (`'a' | 'b'`) gates as one type.
251
295
  */
252
- type QueryAllowedOp<T> = QueryCommonOp | ([NonNullable<T>] extends [QueryComparableScalar] ? QueryOrderedOp : never) | ([NonNullable<T>] extends [string] ? QueryStringOp : never) | (IsMany<T> extends true ? QueryArrayOp : never);
296
+ type QueryAllowedOp<T> = QueryCommonOp | ([NonNullable<T>] extends [QueryComparableScalar] ? QueryOrderedOp : never) | ([NonNullable<T>] extends [string] ? QueryStringOp : never) | ([NonNullable<T>] extends [readonly number[] | Uint8Array] ? QueryVectorOp : never) | (IsMany<T> extends true ? QueryArrayOp : never);
253
297
  /**
254
298
  * Operators applicable to a field of type `T`: string operators require string fields, ordering
255
299
  * operators comparable fields, array operators array fields. `unknown` stays fully permissive
@@ -11,6 +11,19 @@ import type { IndexType } from '../schema/types.js';
11
11
  * vector, which no field type maps to. It would be a value that compiles and always throws.
12
12
  */
13
13
  export type VectorDistance = 'cosine' | 'l2' | 'inner' | 'l1';
14
+ /**
15
+ * The vector and the metric: the half of a similarity search that names *what* distance to compute,
16
+ * shared by `$sort`'s ranking ({@link QueryVectorSearch}) and `$where`'s threshold
17
+ * ({@link QueryVectorNear}) so the two cannot describe the same distance differently.
18
+ */
19
+ export interface QueryVectorQuery {
20
+ /** The query vector to compare against. */
21
+ readonly $vector: readonly number[];
22
+ /** Distance metric. Overrides entity-level default. Falls back to `'cosine'`. */
23
+ readonly $distance?: VectorDistance;
24
+ }
25
+ /** The keys that describe the search rather than bound it, so `$near`'s bounds are what is left. */
26
+ export declare const VECTOR_QUERY_KEYS: readonly ["$vector", "$distance"];
14
27
  /**
15
28
  * Vector similarity search options - used inside `$sort` on vector fields.
16
29
  *
@@ -22,11 +35,7 @@ export type VectorDistance = 'cosine' | 'l2' | 'inner' | 'l1';
22
35
  * });
23
36
  * ```
24
37
  */
25
- export interface QueryVectorSearch {
26
- /** The query vector to compare against. */
27
- readonly $vector: readonly number[];
28
- /** Distance metric. Overrides entity-level default. Falls back to `'cosine'`. */
29
- readonly $distance?: VectorDistance;
38
+ export interface QueryVectorSearch extends QueryVectorQuery {
30
39
  /** Project the computed distance as a named field in the result. */
31
40
  readonly $project?: string;
32
41
  }
@@ -40,6 +49,29 @@ export interface QueryVectorSearch {
40
49
  * ```
41
50
  */
42
51
  export type WithDistance<E, K extends string = '_distance'> = E & Record<K, number>;
52
+ /**
53
+ * How one dialect spells one distance metric. Two shapes exist across engines - an infix operator
54
+ * (`"col" <=> $1`, pgvector) or a function call (`VEC_DISTANCE_COSINE(col, ?)`, MariaDB and the
55
+ * SQLite family) - so they are one discriminated map rather than two parallel ones. That is what
56
+ * lets a single `appendVectorSort` serve every engine, and makes the map's key set the one answer
57
+ * to "does this dialect have this metric".
58
+ *
59
+ * `opsSuffix` rides along on the operator form because pgvector's index operator class is named from
60
+ * the same metric (`vector_cosine_ops`): keeping them together is what stops a dialect from having
61
+ * the operator but not the class it indexes with.
62
+ */
63
+ export type VectorMetric = {
64
+ readonly op: string;
65
+ readonly opsSuffix: string;
66
+ } | {
67
+ readonly fn: string;
68
+ };
69
+ /** The operator form, for the pgvector-family dialects whose index DDL also needs `opsSuffix`. */
70
+ export type VectorOperatorMetric = Extract<VectorMetric, {
71
+ op: string;
72
+ }>;
73
+ /** Every dialect words this the same, and one of them used to throw a bare `Error` for it. */
74
+ export declare function unsupportedVectorMetric(dialectName: string, distance: VectorDistance, indexName?: string): TypeError;
43
75
  /**
44
76
  * Vector-specific tuning options shared by `@Index` decorator, entity metadata, and migration schema.
45
77
  */
@@ -53,5 +85,11 @@ export type VectorIndexOptions = {
53
85
  /** IVFFlat: number of inverted lists. */
54
86
  lists?: number;
55
87
  };
56
- /** Index types whose emitted DDL depends on the distance metric. */
57
- export type VectorIndexType = Extract<IndexType, 'hnsw' | 'ivfflat' | 'vector'>;
88
+ /**
89
+ * Index types whose emitted DDL depends on the distance metric. The runtime list is the source, so
90
+ * the type and every dialect's "do I have this one?" answer cannot drift from each other.
91
+ */
92
+ export declare const VECTOR_INDEX_TYPES: readonly ["hnsw", "ivfflat", "vector"];
93
+ export type VectorIndexType = (typeof VECTOR_INDEX_TYPES)[number];
94
+ /** Whether an index type is one of {@link VECTOR_INDEX_TYPES}; narrows an optional `IndexSchema.type`. */
95
+ export declare function isVectorIndexType(type: IndexType | undefined): type is VectorIndexType;
@@ -1 +1,16 @@
1
- export {};
1
+ /** The keys that describe the search rather than bound it, so `$near`'s bounds are what is left. */
2
+ export const VECTOR_QUERY_KEYS = ['$vector', '$distance'];
3
+ /** Every dialect words this the same, and one of them used to throw a bare `Error` for it. */
4
+ export function unsupportedVectorMetric(dialectName, distance, indexName) {
5
+ const where = indexName === undefined ? '' : ` (index "${indexName}")`;
6
+ return new TypeError(`${dialectName} does not support vector distance metric: ${distance}${where}`);
7
+ }
8
+ /**
9
+ * Index types whose emitted DDL depends on the distance metric. The runtime list is the source, so
10
+ * the type and every dialect's "do I have this one?" answer cannot drift from each other.
11
+ */
12
+ export const VECTOR_INDEX_TYPES = ['hnsw', 'ivfflat', 'vector'];
13
+ /** Whether an index type is one of {@link VECTOR_INDEX_TYPES}; narrows an optional `IndexSchema.type`. */
14
+ export function isVectorIndexType(type) {
15
+ return type !== undefined && VECTOR_INDEX_TYPES.includes(type);
16
+ }
@@ -1,4 +1,4 @@
1
- import { type CascadeType, type EntityData, type EntityMeta, type FieldKey, type FieldOptions, type JsonUpdateOp, type OnFieldCallback, type Query, type QueryAggMap, type QueryAggregateOp, type QueryExclude, type QueryGroupMap, type QueryOptions, QueryRaw, type QuerySearch, type QuerySelect, type QuerySelectValue, type QuerySizeComparisonOps, type QueryVectorSearch, type QueryWhere, type QueryWhereMap, type RelationKey } from '../type/index.js';
1
+ import { type CascadeType, type EntityData, type EntityIndexMeta, type EntityMeta, type FieldKey, type FieldOptions, type JsonUpdateOp, type OnFieldCallback, type Query, type QueryAggMap, type QueryAggregateOp, type QueryExclude, type QueryGroupMap, type QueryOptions, QueryRaw, type QuerySearch, type QuerySelect, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryVectorSearch, type QueryWhere, type QueryWhereMap, type RelationKey } from '../type/index.js';
2
2
  export type CallbackKey = keyof Pick<FieldOptions, 'onInsert' | 'onUpdate'>;
3
3
  export declare function filterFieldKeys<E>(meta: EntityMeta<E>, payload: EntityData<E>, callbackKey: CallbackKey): FieldKey<E>[];
4
4
  /**
@@ -62,6 +62,25 @@ export declare function asSelectMap<E>(select: QuerySelectValue<E> | undefined):
62
62
  export declare function normalizeScalarFieldSelection<E>(meta: EntityMeta<E>, select?: QuerySelect<E>, exclude?: QueryExclude<E>): FieldKey<E>[];
63
63
  /** Type guard: checks whether a sort value is a vector similarity search. */
64
64
  export declare function isVectorSearch(value: unknown): value is QueryVectorSearch;
65
+ /**
66
+ * The vector search a `$sort` carries, if it ranks by one. First entry wins when two fields are
67
+ * ranked at once - one scan for every dialect, so the SQL side and MongoDB cannot disagree about
68
+ * which, as they did when one took the first and the other the last.
69
+ */
70
+ export declare function findVectorSort<E>(sort: QuerySortMap<E> | undefined): {
71
+ key: string;
72
+ search: QueryVectorSearch;
73
+ } | undefined;
74
+ /**
75
+ * The vector index declared on `key`, if any. Answers both "is there an ANN index to tune here" and
76
+ * "which kind", which decide the name Atlas is queried by and the setting Postgres is tuned with.
77
+ */
78
+ export declare function findVectorIndex<E>(meta: EntityMeta<E>, key: string): EntityIndexMeta | undefined;
79
+ /**
80
+ * Whether a `$where` filters by vector distance anywhere in its tree, `$and`/`$or`/`$not` included.
81
+ * What tells Postgres that an HNSW scan needs to iterate rather than return one candidate list.
82
+ */
83
+ export declare function hasVectorNear(where: unknown): boolean;
65
84
  /** Type guard: checks whether an update payload value is a JSON operator object. */
66
85
  export declare function isJsonUpdateOp(value: unknown): value is JsonUpdateOp;
67
86
  export declare function augmentWhere<E>(meta: EntityMeta<E>, target?: QueryWhere<E>, source?: QueryWhere<E>): QueryWhere<E>;
@@ -1,6 +1,7 @@
1
1
  import { getContext, UqlSecurityError } from '../context/context.js';
2
- import { QueryRaw, resolveAggregateOp, } from '../type/index.js';
3
- import { getFieldKeys, getKeys, hasKeys, someKey } from './object.util.js';
2
+ import { QueryRaw, resolveAggregateOp, SOFT_DELETE_FILTER, } from '../type/index.js';
3
+ import { VECTOR_INDEX_TYPES } from '../type/vector.js';
4
+ import { entityName, getFieldKeys, getKeys, hasKeys, someKey } from './object.util.js';
4
5
  export function filterFieldKeys(meta, payload, callbackKey) {
5
6
  return getKeys(payload).filter((key) => {
6
7
  const fieldOpts = meta.fields[key];
@@ -162,6 +163,50 @@ export function normalizeScalarFieldSelection(meta, select, exclude) {
162
163
  export function isVectorSearch(value) {
163
164
  return value !== null && typeof value === 'object' && '$vector' in value;
164
165
  }
166
+ /**
167
+ * The vector search a `$sort` carries, if it ranks by one. First entry wins when two fields are
168
+ * ranked at once - one scan for every dialect, so the SQL side and MongoDB cannot disagree about
169
+ * which, as they did when one took the first and the other the last.
170
+ */
171
+ export function findVectorSort(sort) {
172
+ for (const key of getKeys(sort ?? {})) {
173
+ const search = sort?.[key];
174
+ // The guard narrows here, where a `.find()` over entries would hand back an untyped tuple.
175
+ if (isVectorSearch(search)) {
176
+ return { key, search };
177
+ }
178
+ }
179
+ return undefined;
180
+ }
181
+ /**
182
+ * Every index type that means "vector" to some engine: pgvector's two, the generic one MariaDB and
183
+ * CockroachDB share, and Atlas's. Wider than {@link VECTOR_INDEX_TYPES}, which is the set whose DDL
184
+ * depends on a distance metric - Atlas takes its metric from the index definition instead.
185
+ */
186
+ const VECTOR_INDEX_MATCH = new Set([...VECTOR_INDEX_TYPES, 'vectorSearch']);
187
+ /**
188
+ * The vector index declared on `key`, if any. Answers both "is there an ANN index to tune here" and
189
+ * "which kind", which decide the name Atlas is queried by and the setting Postgres is tuned with.
190
+ */
191
+ export function findVectorIndex(meta, key) {
192
+ return meta.indexes?.find((index) => index.type !== undefined && VECTOR_INDEX_MATCH.has(index.type) && indexCoversColumn(index, key));
193
+ }
194
+ /**
195
+ * Whether a `$where` filters by vector distance anywhere in its tree, `$and`/`$or`/`$not` included.
196
+ * What tells Postgres that an HNSW scan needs to iterate rather than return one candidate list.
197
+ */
198
+ export function hasVectorNear(where) {
199
+ if (where === null || typeof where !== 'object') {
200
+ return false;
201
+ }
202
+ if (Array.isArray(where)) {
203
+ return where.some(hasVectorNear);
204
+ }
205
+ return Object.entries(where).some(([key, value]) => key === '$near' || hasVectorNear(value));
206
+ }
207
+ function indexCoversColumn(index, key) {
208
+ return index.columns.some((entry) => !(entry instanceof QueryRaw) && entry.column === key);
209
+ }
165
210
  /** `satisfies` ties this to {@link JsonUpdateOp}, so renaming an operator breaks it at compile time. */
166
211
  const JSON_UPDATE_OPS = [
167
212
  '$set',
@@ -199,7 +244,7 @@ export function buildQueryWhereAsMap(meta, filter = {}) {
199
244
  }
200
245
  /** Returns a `QueryOptions.filters` value with the built-in soft-delete filter disabled (used by hard delete). */
201
246
  export function withoutSoftDeleteFilter(filters) {
202
- return filters === false ? false : { ...filters, softDelete: false };
247
+ return filters === false ? false : { ...filters, [SOFT_DELETE_FILTER]: false };
203
248
  }
204
249
  /**
205
250
  * Returns a new `$where` map with every active entity filter's condition merged in, resolving
@@ -241,7 +286,7 @@ export function applyFilters(meta, whereMap, opts) {
241
286
  if (condition === undefined) {
242
287
  const onMissing = filter.onMissing ?? (filter.security ? 'throw' : 'skip');
243
288
  if (onMissing === 'throw') {
244
- throw new UqlSecurityError(`filter '${name}' on '${meta.name ?? ''}' could not resolve (missing context)`);
289
+ throw new UqlSecurityError(`filter '${name}' on '${entityName(meta)}' could not resolve (missing context)`);
245
290
  }
246
291
  continue;
247
292
  }
@@ -1,4 +1,4 @@
1
- import type { FieldKey, FieldOptions } from '../type/index.js';
1
+ import type { EntityMeta, FieldKey, FieldOptions } from '../type/index.js';
2
2
  export declare function throwPendingTransaction(): never;
3
3
  export declare function throwNoPendingTransaction(): never;
4
4
  export declare function clone<T>(value: T): T;
@@ -20,6 +20,12 @@ export declare function isOperatorObject(value: unknown): value is Record<string
20
20
  /** Whether every key of the non-empty object `value` is an operator (no plain field names mixed in). */
21
21
  export declare function isOperatorOnlyObject(value: unknown): value is Record<string, unknown>;
22
22
  export declare function getKeys<T extends object>(obj: T): (keyof T & string)[];
23
+ /**
24
+ * The entity's own name for a message to carry, declared or its class's. `defineEntity` always sets
25
+ * one, so the fallback is for a meta a decorator is still building - which is why the sites spelling
26
+ * this out reached for three different fallbacks, `?? ''` among them, and named nothing at all.
27
+ */
28
+ export declare function entityName<E>(meta: EntityMeta<E>): string;
23
29
  export declare function getFieldKeys<E>(fields: {
24
30
  [K in FieldKey<E>]?: FieldOptions;
25
31
  }): FieldKey<E>[];
@@ -52,6 +52,14 @@ export function isOperatorOnlyObject(value) {
52
52
  export function getKeys(obj) {
53
53
  return obj ? Object.keys(obj) : [];
54
54
  }
55
+ /**
56
+ * The entity's own name for a message to carry, declared or its class's. `defineEntity` always sets
57
+ * one, so the fallback is for a meta a decorator is still building - which is why the sites spelling
58
+ * this out reached for three different fallbacks, `?? ''` among them, and named nothing at all.
59
+ */
60
+ export function entityName(meta) {
61
+ return meta.name ?? meta.entity.name;
62
+ }
55
63
  export function getFieldKeys(fields) {
56
64
  return getKeys(fields).filter((field) => fields[field].eager ?? true);
57
65
  }
@@ -30,9 +30,11 @@ export type JoinedRelationRejectedKey = (typeof JOINED_RELATION_REJECTIONS)[numb
30
30
  export declare function getRelationRequestSummary<E>(meta: EntityMeta<E>, populate?: QueryPopulate<E>): RelationRequestSummary<E>;
31
31
  /** True when `$populate` includes at least one relation key. */
32
32
  export declare function populatesRelations<E>(meta: EntityMeta<E>, populate?: QueryPopulate<E>): boolean;
33
- export type RelationQuery<E extends object = object> = Except<Query<E>, '$lock'> & {
33
+ export type RelationQuery<E extends object = object> = Except<Query<E>, StatementOnlyClause> & {
34
34
  $required?: boolean;
35
35
  };
36
+ /** The clauses that describe the statement rather than what a query selects. */
37
+ type StatementOnlyClause = '$lock' | '$candidates';
36
38
  export type ParsedRelationQuery<E extends object = object> = {
37
39
  query: RelationQuery<E>;
38
40
  required: boolean;
@@ -1,4 +1,4 @@
1
- import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES } from '../type/query.js';
1
+ import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES, QUERY_ROOT_NUMBER_CLAUSES, } from '../type/query.js';
2
2
  import { getKeys, someKey } from './object.util.js';
3
3
  /**
4
4
  * Whether a relation holds many rows per parent, so it cannot be joined into the parent's row. Takes
@@ -74,6 +74,8 @@ export function populatesRelations(meta, populate) {
74
74
  return false;
75
75
  return someKey(populate, (key) => !!populate[key] && key in meta.relations);
76
76
  }
77
+ /** Their runtime half, so the check below cannot drift from the type above. */
78
+ const STATEMENT_ONLY_CLAUSES = ['$lock', ...QUERY_ROOT_NUMBER_CLAUSES];
77
79
  // Taken from the clause groups declared beside `Query` itself, so a renamed clause fails to compile
78
80
  // here instead of quietly narrowing what a relation query accepts. `$required` is the one key that
79
81
  // is not a `Query` clause at all - it says how the relation joins, not what it selects.
@@ -91,8 +93,11 @@ function isRelationQueryObject(value) {
91
93
  export function parseRelationQueryValue(value) {
92
94
  // Caught before the shape check so the message names the key, rather than reporting the whole
93
95
  // object as an unrecognized relation query value.
94
- if (isRecord(value) && '$lock' in value) {
95
- throw new TypeError("'$lock' applies to the whole statement, not to a populated relation. Move it to the top level of the query.");
96
+ if (isRecord(value)) {
97
+ const statementOnly = STATEMENT_ONLY_CLAUSES.find((clause) => clause in value);
98
+ if (statementOnly) {
99
+ throw new TypeError(`'${statementOnly}' applies to the whole statement, not to a populated relation. Move it to the top level of the query.`);
100
+ }
96
101
  }
97
102
  if (isRelationQueryObject(value)) {
98
103
  return { query: value, required: value.$required === true, nested: true };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "JSON-native ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
5
5
  "license": "MIT",
6
- "version": "0.37.1",
6
+ "version": "0.39.0",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"