uql-orm 0.83.0 → 0.84.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.
@@ -138,35 +138,37 @@ export type QueryWhereFieldOperatorMap<T, Raw = QueryRaw> = {
138
138
  */
139
139
  $between?: readonly [ExpandScalar<T>, ExpandScalar<T>];
140
140
  /**
141
- * whether a string begins with the given string (case sensitive).
141
+ * whether a string begins with the given text, taken literally (case sensitive).
142
142
  */
143
143
  $startsWith?: string;
144
144
  /**
145
- * whether a string begins with the given string (case insensitive).
145
+ * whether a string begins with the given text, taken literally (case insensitive).
146
146
  */
147
147
  $istartsWith?: string;
148
148
  /**
149
- * whether a string ends with the given string (case sensitive).
149
+ * whether a string ends with the given text, taken literally (case sensitive).
150
150
  */
151
151
  $endsWith?: string;
152
152
  /**
153
- * whether a string ends with the given string (case insensitive).
153
+ * whether a string ends with the given text, taken literally (case insensitive).
154
154
  */
155
155
  $iendsWith?: string;
156
156
  /**
157
- * whether a string is contained within the given string (case sensitive).
157
+ * whether a string contains the given text, taken literally (case sensitive).
158
158
  */
159
159
  $includes?: string;
160
160
  /**
161
- * whether a string is contained within the given string (case insensitive).
161
+ * whether a string contains the given text, taken literally (case insensitive).
162
162
  */
163
163
  $iincludes?: string;
164
164
  /**
165
- * whether a string fulfills the given pattern (case sensitive).
165
+ * whether a whole string matches the given pattern, the same on every engine: `%` is any run of
166
+ * characters, `_` any one, and `\` makes the next one literal; a last `\` with nothing after it to
167
+ * escape, `'John\'`, is refused (case sensitive).
166
168
  */
167
169
  $like?: string;
168
170
  /**
169
- * whether a string fulfills the given pattern (case insensitive).
171
+ * whether a whole string matches the given pattern, as `$like` reads it (case insensitive).
170
172
  */
171
173
  $ilike?: string;
172
174
  /**
@@ -63,11 +63,8 @@ export declare function whereEach<E>(keys: readonly FieldKey<E>[], valueOf: (key
63
63
  export declare function whereAnyOf<E>(clauses: QueryWhereArray<E>): QueryWhere<E>;
64
64
  /** `q` selecting nothing but the id: what a write hands its backend's own read builder to settle the rows it will name. */
65
65
  export declare function idOnlyQuery<E>(meta: EntityMeta<E>, q: QuerySearch<E>): Query<E>;
66
- /**
67
- * The map form of a `$select` value, or `undefined` for the raw-array form. Centralizes the one
68
- * narrowing cast: `Array.isArray` does not narrow `readonly` arrays out of a union.
69
- */
70
- export declare function asSelectMap<E>(select: QuerySelectValue<E> | undefined): QuerySelect<E> | undefined;
66
+ /** Whether `select` is the list form `raw()` fills, narrowing both ways, which `Array.isArray` does not for a `readonly` array. */
67
+ export declare function isSelectList<E>(select: QuerySelectValue<E> | undefined): select is readonly QueryRaw[];
71
68
  export declare function normalizeScalarFieldSelection<E>(meta: EntityMeta<E>, select?: QuerySelect<E>, exclude?: QueryExclude<E>): FieldKey<E>[];
72
69
  /** Type guard: checks whether a sort value is a vector similarity search. */
73
70
  export declare function isVectorSearch(value: unknown): value is QueryVectorSearch;
@@ -122,10 +119,21 @@ export declare function assertWhere<E>(meta: EntityMeta<E>, where: unknown): voi
122
119
  export declare function withoutSoftDeleteFilter(filters: QueryOptions['filters']): QueryOptions['filters'];
123
120
  /**
124
121
  * `$where` with the entity's active filters merged in, against the ambient {@link UqlContext}. A convenience
125
- * filter yields to a `$where` on its key; a `security` one is always ANDed, and throws where its condition
126
- * resolves to nothing, unless `onMissing: 'skip'`.
122
+ * filter yields to a `$where` on its key; a `security` one is always ANDed, from {@link securityConditions}.
127
123
  */
128
124
  export declare function applyFilters<E>(meta: EntityMeta<E>, whereMap: QueryWhere<E>, opts?: QueryOptions): QueryWhere<E>;
125
+ /**
126
+ * Each `security` filter's condition, by name, resolved against the ambient context. One resolving to
127
+ * `{}`, a trusted context's "no restriction", is left out. Reads AND these in; writes are held to them.
128
+ */
129
+ export declare function securityConditions<E>(meta: EntityMeta<E>): [name: string, condition: QueryWhere<E>][];
130
+ /**
131
+ * Holds written rows to the `security` filters, as {@link applyFilters} holds reads: an inserted row gets
132
+ * each field a condition names and it leaves out, and a row naming one must carry the condition's value.
133
+ * A condition other than field equalities refuses the write, having nothing a row can be checked against.
134
+ * See architecture/security-filter-writes.md.
135
+ */
136
+ export declare function guardWrite<E, R extends EntityData<E> | UpdatePayload<E>>(meta: EntityMeta<E>, rows: readonly R[], write: 'insert' | 'update'): void;
129
137
  /**
130
138
  * Parsed entry from a `$group` map - either a raw group key or an aggregate function call.
131
139
  */
@@ -1,10 +1,10 @@
1
- import { getContext, UqlSecurityError } from '../context/context.js';
1
+ import { getContext } from '../context/context.js';
2
2
  import { soleIdOf } from '../entity/metadata/definition.js';
3
3
  import { QueryRaw, resolveAggregateOp, SOFT_DELETE_FILTER, } from '../type/index.js';
4
4
  import { DEFAULT_VECTOR_DISTANCE, VECTOR_INDEX_TYPES } from '../type/vector.js';
5
5
  import { defaultReadKeys, fieldKeys, 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
+ import { UqlSecurityError, UqlUsageError } from './uqlError.js';
8
8
  /** The keys of `payload` a write persists as columns. */
9
9
  export function filterFieldKeys(meta, payload, callbackKey) {
10
10
  return getKeys(payload).filter((key) => {
@@ -138,12 +138,9 @@ export function whereAnyOf(clauses) {
138
138
  export function idOnlyQuery(meta, q) {
139
139
  return { ...q, $select: keySet(meta.ids) };
140
140
  }
141
- /**
142
- * The map form of a `$select` value, or `undefined` for the raw-array form. Centralizes the one
143
- * narrowing cast: `Array.isArray` does not narrow `readonly` arrays out of a union.
144
- */
145
- export function asSelectMap(select) {
146
- return Array.isArray(select) ? undefined : select;
141
+ /** Whether `select` is the list form `raw()` fills, narrowing both ways, which `Array.isArray` does not for a `readonly` array. */
142
+ export function isSelectList(select) {
143
+ return Array.isArray(select);
147
144
  }
148
145
  export function normalizeScalarFieldSelection(meta, select, exclude) {
149
146
  // A positive `$select` (the common case) wins outright and returns
@@ -298,59 +295,78 @@ export function withoutSoftDeleteFilter(filters) {
298
295
  }
299
296
  /**
300
297
  * `$where` with the entity's active filters merged in, against the ambient {@link UqlContext}. A convenience
301
- * filter yields to a `$where` on its key; a `security` one is always ANDed, and throws where its condition
302
- * resolves to nothing, unless `onMissing: 'skip'`.
298
+ * filter yields to a `$where` on its key; a `security` one is always ANDed, from {@link securityConditions}.
303
299
  */
304
300
  export function applyFilters(meta, whereMap, opts) {
305
301
  if (!meta.filters) {
306
302
  return whereMap;
307
303
  }
308
- const context = getContext();
309
304
  const result = { ...whereMap };
310
- const securityConditions = [];
311
- for (const name of getKeys(meta.filters)) {
312
- const filter = meta.filters[name];
313
- let active;
314
- if (filter.security) {
315
- active = true;
316
- }
317
- else if (opts?.filters === false) {
318
- active = false;
319
- }
320
- else {
321
- active = opts?.filters?.[name] ?? filter.default !== false;
322
- }
323
- if (!active) {
305
+ for (const [name, filter] of Object.entries(meta.filters)) {
306
+ const active = opts?.filters !== false && (opts?.filters?.[name] ?? filter.default !== false);
307
+ if (filter.security || !active) {
324
308
  continue;
325
309
  }
326
- const condition = typeof filter.where === 'function' ? filter.where(context) : filter.where;
327
- if (condition === undefined) {
328
- const onMissing = filter.onMissing ?? (filter.security ? 'throw' : 'skip');
329
- if (onMissing === 'throw') {
330
- throw new UqlSecurityError(`filter '${name}' on '${entityName(meta)}' could not resolve (missing context)`);
310
+ const condition = resolveFilter(meta, name, filter) ?? {};
311
+ for (const key of getKeys(condition)) {
312
+ if (result[key] === undefined) {
313
+ result[key] = condition[key];
331
314
  }
332
- continue;
333
- }
334
- const conditionMap = condition;
335
- if (!hasKeys(conditionMap)) {
336
- continue; // resolved to "no restriction" (e.g. a trusted system context) - nothing to merge
337
315
  }
338
- if (filter.security) {
339
- securityConditions.push(conditionMap);
316
+ }
317
+ const security = securityConditions(meta).map(([, condition]) => condition);
318
+ if (security.length) {
319
+ const existing = result['$and'];
320
+ result['$and'] = existing ? [...existing, ...security] : security;
321
+ }
322
+ return result;
323
+ }
324
+ /**
325
+ * Each `security` filter's condition, by name, resolved against the ambient context. One resolving to
326
+ * `{}`, a trusted context's "no restriction", is left out. Reads AND these in; writes are held to them.
327
+ */
328
+ export function securityConditions(meta) {
329
+ return Object.entries(meta.filters ?? {}).flatMap(([name, filter]) => {
330
+ const condition = filter.security ? resolveFilter(meta, name, filter) : undefined;
331
+ return condition && hasKeys(condition) ? [[name, condition]] : [];
332
+ });
333
+ }
334
+ /** A filter's condition, `undefined` where it resolves to nothing and may skip; throws where it may not. */
335
+ function resolveFilter(meta, name, filter) {
336
+ const condition = typeof filter.where === 'function' ? filter.where(getContext()) : filter.where;
337
+ const onMissing = filter.onMissing ?? (filter.security ? 'throw' : 'skip');
338
+ if (condition === undefined && onMissing === 'throw') {
339
+ throw new UqlSecurityError(`filter '${name}' on '${entityName(meta)}' could not resolve (missing context)`);
340
+ }
341
+ return condition;
342
+ }
343
+ /**
344
+ * Holds written rows to the `security` filters, as {@link applyFilters} holds reads: an inserted row gets
345
+ * each field a condition names and it leaves out, and a row naming one must carry the condition's value.
346
+ * A condition other than field equalities refuses the write, having nothing a row can be checked against.
347
+ * See architecture/security-filter-writes.md.
348
+ */
349
+ export function guardWrite(meta, rows, write) {
350
+ for (const [name, condition] of securityConditions(meta)) {
351
+ const values = condition;
352
+ const keys = fieldKeys(meta, () => true).filter((key) => Object.hasOwn(values, key));
353
+ const equalities = keys.length === getKeys(values).length;
354
+ if (!equalities || keys.some((key) => Array.isArray(values[key]) || isOperatorObject(values[key]))) {
355
+ throw new UqlSecurityError(`'${entityName(meta)}' security filter '${name}' is not field equalities, so no write can be checked against it`);
340
356
  }
341
- else {
342
- for (const key of getKeys(conditionMap)) {
343
- if (result[key] === undefined) {
344
- result[key] = conditionMap[key];
357
+ for (const key of keys) {
358
+ for (const row of rows) {
359
+ if (row[key] === undefined) {
360
+ if (write === 'insert') {
361
+ row[key] = values[key];
362
+ }
363
+ }
364
+ else if (row[key] !== values[key]) {
365
+ throw new UqlSecurityError(`'${entityName(meta)}' row sets '${key}' outside security filter '${name}'`);
345
366
  }
346
367
  }
347
368
  }
348
369
  }
349
- if (securityConditions.length) {
350
- const existing = result['$and'];
351
- result['$and'] = existing ? [...existing, ...securityConditions] : securityConditions;
352
- }
353
- return result;
354
370
  }
355
371
  /**
356
372
  * The `$size` of a relation condition, `{ comments: { $size: { $gte: 2 } } }`, or `undefined` where it
@@ -2,22 +2,33 @@
2
2
  * What a failed query ran into, named the same on every engine - what {@link queryErrorKind} answers
3
3
  * with, whether a driver raised the error or UQL did. `retryable` is a deadlock, a serialization
4
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.
5
+ * caller's own mistake, which running it again will not fix. `security` is a `security` filter refusing.
6
6
  */
7
- export type QueryErrorKind = 'uniqueViolation' | 'foreignKeyViolation' | 'notNullViolation' | 'checkViolation' | 'optimisticLock' | 'retryable' | 'usage';
7
+ export type QueryErrorKind = 'uniqueViolation' | 'foreignKeyViolation' | 'notNullViolation' | 'checkViolation' | 'optimisticLock' | 'retryable' | 'usage' | 'security';
8
+ /** Every error UQL raises of its own: the kind `queryErrorKind` answers, and the status HTTP answers with. */
9
+ export declare abstract class UqlError extends Error {
10
+ abstract readonly kind: QueryErrorKind;
11
+ abstract readonly status: number;
12
+ }
8
13
  /**
9
14
  * 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
15
+ * version, a `$lock` outside a transaction, a method with no version to match. A `400` over HTTP: the
12
16
  * request is malformed, not the server's failure, and an untyped client is exactly who reaches this.
13
17
  */
14
- export declare class UqlUsageError extends TypeError {
18
+ export declare class UqlUsageError extends UqlError {
15
19
  name: string;
16
- /** What `queryErrorKind` answers, so a caller branches on it rather than on the class. */
17
20
  readonly kind = "usage";
18
- /** What an HTTP transport answers with. */
19
21
  readonly status = 400;
20
22
  }
23
+ /**
24
+ * Thrown where a `security` filter refuses: its context is missing, or a write would leave a row outside it.
25
+ * Fails the statement closed.
26
+ */
27
+ export declare class UqlSecurityError extends UqlError {
28
+ name: string;
29
+ readonly kind = "security";
30
+ readonly status = 403;
31
+ }
21
32
  /** What a value is, for a refusal naming what `/http` handed over instead of what the types require. */
22
33
  export declare function kindOf(value: unknown): string;
23
34
  /**
@@ -31,7 +42,7 @@ export type UqlLockUsageError = UqlUsageError;
31
42
  * or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
32
43
  * where there is no row left.
33
44
  */
34
- export declare class UqlOptimisticLockError extends Error {
45
+ export declare class UqlOptimisticLockError extends UqlError {
35
46
  readonly expected: unknown;
36
47
  readonly actual: unknown;
37
48
  name: string;
@@ -1,16 +1,25 @@
1
+ /** Every error UQL raises of its own: the kind `queryErrorKind` answers, and the status HTTP answers with. */
2
+ export class UqlError extends Error {
3
+ }
1
4
  /**
2
5
  * 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
6
+ * version, a `$lock` outside a transaction, a method with no version to match. A `400` over HTTP: the
5
7
  * request is malformed, not the server's failure, and an untyped client is exactly who reaches this.
6
8
  */
7
- export class UqlUsageError extends TypeError {
9
+ export class UqlUsageError extends UqlError {
8
10
  name = 'UqlUsageError';
9
- /** What `queryErrorKind` answers, so a caller branches on it rather than on the class. */
10
11
  kind = 'usage';
11
- /** What an HTTP transport answers with. */
12
12
  status = 400;
13
13
  }
14
+ /**
15
+ * Thrown where a `security` filter refuses: its context is missing, or a write would leave a row outside it.
16
+ * Fails the statement closed.
17
+ */
18
+ export class UqlSecurityError extends UqlError {
19
+ name = 'UqlSecurityError';
20
+ kind = 'security';
21
+ status = 403;
22
+ }
14
23
  /** What a value is, for a refusal naming what `/http` handed over instead of what the types require. */
15
24
  export function kindOf(value) {
16
25
  return value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
@@ -25,7 +34,7 @@ export const UqlLockUsageError = UqlUsageError;
25
34
  * or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
26
35
  * where there is no row left.
27
36
  */
28
- export class UqlOptimisticLockError extends Error {
37
+ export class UqlOptimisticLockError extends UqlError {
29
38
  expected;
30
39
  actual;
31
40
  name = 'UqlOptimisticLockError';
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "uql-orm",
3
3
  "homepage": "https://uql-orm.dev",
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.",
4
+ "description": "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.83.0",
6
+ "version": "0.84.0",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"
@@ -56,7 +56,7 @@
56
56
  "README.md"
57
57
  ],
58
58
  "scripts": {
59
- "prepack": "bun run build && bun run verify-dist.ts && cp ../../README.md . && cp -R ../../skills .",
59
+ "prepack": "bun run build && cp ../../README.md . && cp -R ../../skills .",
60
60
  "postpack": "rm -r README.md skills && npm pkg delete gitHead",
61
61
  "compile.browser": "bun build src/browser/index.ts --minify --sourcemap=linked --format=esm --target=browser --outdir=dist/browser --entry-naming 'uql-browser.min.[ext]'",
62
62
  "build": "bun run clean && tsc -b tsconfig.build.json && bun run compile.browser && bun run verify-dist.ts",
@@ -131,8 +131,8 @@ const users = await pool.findMany(User, {
131
131
  someone else holds) and needs an open transaction; SQLite, libSQL, Turso, D1 and MongoDB have no row lock and
132
132
  refuse it.
133
133
  - `queryErrorKind(err)` names any failure the same on every engine - `uniqueViolation`, `foreignKeyViolation`,
134
- `notNullViolation`, `checkViolation`, `optimisticLock`, `retryable`, `usage` - so catch by kind rather than by
135
- a driver's code or an `instanceof`.
134
+ `notNullViolation`, `checkViolation`, `optimisticLock`, `retryable`, `usage`, `security` - so catch by kind
135
+ rather than by a driver's code or an `instanceof`. Every error UQL raises itself is a `UqlError`.
136
136
  - `raw()` embeds SQL anywhere a value or field goes; `pool.all(sql, values)` runs a raw `SELECT`. A field read off `refs(Entity)` carries its type: on its own as a value it fits only a field of that type.
137
137
 
138
138
  ## Connections and transactions
@@ -1,4 +0,0 @@
1
- /** Thrown when a `security` filter can't resolve its condition - fails the query closed. */
2
- export declare class UqlSecurityError extends Error {
3
- name: string;
4
- }
@@ -1,4 +0,0 @@
1
- /** Thrown when a `security` filter can't resolve its condition - fails the query closed. */
2
- export class UqlSecurityError extends Error {
3
- name = 'UqlSecurityError';
4
- }