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.
@@ -1,5 +1,6 @@
1
1
  import type { EntityMeta, QuerierPool, Query, RequestSuccessResponse, Type, UqlContext } from '../type/index.js';
2
2
  import { type CrudOperation, type HttpMethod } from './contract.js';
3
+ import { type WireFlags } from './query.js';
3
4
  /**
4
5
  * Framework-normalized request: adapters (express, fetch, ...) reduce their native
5
6
  * request to this shape and get back a status + JSON body.
@@ -30,7 +31,7 @@ export type HookContext<E extends object, Ctx = unknown> = {
30
31
  readonly op: CrudOperation;
31
32
  readonly method: HttpMethod;
32
33
  /** The parsed query, to reshape in place. Scope rows with a `security` filter instead, which a client cannot override. */
33
- query: Query<E>;
34
+ query: Query<E> & WireFlags;
34
35
  /**
35
36
  * request payload - reassignable for sanitization or field injection.
36
37
  */
@@ -2,7 +2,7 @@ import { withContext } from '../context/context.js';
2
2
  import { getEntities, getMeta, soleIdOf } from '../entity/index.js';
3
3
  import { whereIds, whereWith } from '../util/dialect.util.js';
4
4
  import { UqlUsageError } from '../util/uqlError.js';
5
- import { entityPath, matchRoute } from './contract.js';
5
+ import { CRUD_ROUTES, entityPath, matchRoute, } from './contract.js';
6
6
  import { parseQueryParams } from './query.js';
7
7
  /** `Company (crm.Company)`: the class, and the table it maps, which is what tells two apart. */
8
8
  function tableOf(entity) {
@@ -29,6 +29,7 @@ export function createRequestHandler(opts) {
29
29
  "pass an 'entityPath', or pass only one of them in 'include'.");
30
30
  }
31
31
  const entityByPath = new Map([...byPath].map(([path, [entity]]) => [path, entity]));
32
+ const served = new Set(entities);
32
33
  return (req) => {
33
34
  const entity = entityByPath.get(req.entityPath);
34
35
  if (!entity) {
@@ -43,121 +44,113 @@ export function createRequestHandler(opts) {
43
44
  async function run(entity, { op, method, id }, req) {
44
45
  const meta = getMeta(entity);
45
46
  // QUERY (RFC 10008) carries the JSON query in the body instead of the query string
46
- const rawQuery = method === 'QUERY' ? req.body : req.query;
47
- const hookCtx = {
48
- meta,
49
- op,
50
- method,
51
- query: parseQueryParams(rawQuery),
52
- body: req.body,
53
- context: req.context,
54
- };
47
+ const query = parseQueryParams(method === 'QUERY' ? req.body : req.query);
48
+ const { method: verb } = CRUD_ROUTES[op];
49
+ // What the client sent, before the hooks: a relation a hook adds is the server's own to add.
50
+ assertServed(meta, query, served);
51
+ assertServed(meta, req.body, served);
52
+ const hookCtx = { meta, op, method, query, body: req.body, context: req.context };
55
53
  const appContext = (await getContext?.(req.context)) ?? {};
56
- // Resolved where the statement runs, not up front: a hook that aborts the request must not reach
57
- // the pool at all, and picking one per request may cost a lookup this request will never use.
58
- const resolvePool = async () => (typeof pool === 'function' ? pool(req.context, appContext) : pool);
59
- /** Read paths: the pool acquires and releases; nothing here owns a connection. */
60
- const withQuerier = async (fn) => (await resolvePool()).withQuerier(fn);
61
- /** Write paths: one transaction per request, so a cascade that fails takes its parent with it. */
62
- const withTransaction = async (fn) => (await resolvePool()).transaction(fn);
63
54
  // Scope the whole request (hooks + querier + relation/cascade queries) to the resolved context.
64
55
  return withContext(appContext, async () => {
65
56
  await pre?.(hookCtx);
66
- if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
67
- await preSave?.(hookCtx);
68
- }
69
- else {
70
- await preFilter?.(hookCtx);
71
- }
72
- const resp = await dispatch();
73
- if (post) {
74
- await post(hookCtx, resp.body);
75
- }
76
- return resp;
57
+ await (verb === 'GET' || verb === 'DELETE' ? preFilter : preSave)?.(hookCtx);
58
+ // Resolved after the hooks: one that aborts the request must not reach the pool at all.
59
+ const resolved = typeof pool === 'function' ? await pool(req.context, appContext) : pool;
60
+ // A read acquires and releases; a write is one transaction, so a failing cascade takes its parent with it.
61
+ const envelope = await (verb === 'GET' ? resolved.withQuerier(execute) : resolved.transaction(execute));
62
+ await post?.(hookCtx, envelope);
63
+ return { status: 200, body: envelope };
77
64
  });
78
- function dispatch() {
79
- // read post-hooks so both in-place mutation and reassignment of hookCtx.query apply
80
- const query = hookCtx.query;
81
- const flags = query;
82
- const hardDelete = flags.hardDelete === 'true' || flags.hardDelete === true;
65
+ /** Runs `op`, reading the query and body after the hooks, which may have reshaped or reassigned them. */
66
+ async function execute(querier) {
67
+ const { query, body } = hookCtx;
68
+ const { hardDelete = false, count: counts } = query;
69
+ const scoped = id === undefined
70
+ ? query
71
+ : { ...query, $where: whereWith(soleIdOf(meta, 'the HTTP handler'), id, query.$where) };
83
72
  switch (op) {
84
73
  case 'findOne':
85
- return withQuerier(async (querier) => {
86
- const data = await querier.findOne(entity, query);
87
- return ok({ data, count: data ? 1 : 0 });
88
- });
89
- case 'count':
90
- return withQuerier(async (querier) => {
91
- const count = await querier.count(entity, query);
92
- return ok({ data: count, count });
93
- });
94
- case 'findOneById':
95
- return withQuerier(async (querier) => {
96
- const data = await querier.findOne(entity, buildIdQuery(meta, id, query));
97
- return ok({ data, count: data ? 1 : 0 });
98
- });
99
- case 'findMany':
100
- return withQuerier(async (querier) => {
101
- const findManyPromise = querier.findMany(entity, query);
102
- const countPromise = flags.count ? querier.count(entity, query) : undefined;
103
- const [data, count] = await Promise.all([findManyPromise, countPromise]);
104
- return ok({ data, count });
105
- });
74
+ case 'findOneById': {
75
+ const data = await querier.findOne(entity, scoped);
76
+ return { data, count: data ? 1 : 0 };
77
+ }
78
+ case 'count': {
79
+ const count = await querier.count(entity, query);
80
+ return { data: count, count };
81
+ }
82
+ case 'findMany': {
83
+ const [data, count] = await Promise.all([
84
+ querier.findMany(entity, query),
85
+ counts ? querier.count(entity, query) : undefined,
86
+ ]);
87
+ return { data, count };
88
+ }
106
89
  case 'insertOne':
107
- return withTransaction(async (querier) => {
108
- const data = await querier.insertOne(entity, hookCtx.body);
109
- return ok({ data, count: 1 });
110
- });
111
- case 'insertMany':
112
- return withTransaction(async (querier) => {
113
- const data = await querier.insertMany(entity, hookCtx.body);
114
- return ok({ data, count: data.length });
115
- });
90
+ return { data: await querier.insertOne(entity, body), count: 1 };
116
91
  case 'saveOne':
117
- return withTransaction(async (querier) => {
118
- const data = await querier.saveOne(entity, hookCtx.body);
119
- return ok({ data, count: 1 });
120
- });
121
- case 'saveMany':
122
- return withTransaction(async (querier) => {
123
- const data = await querier.saveMany(entity, hookCtx.body);
124
- return ok({ data, count: data.length });
125
- });
126
- case 'updateOneById':
127
- return withTransaction(async (querier) => {
128
- const count = await querier.updateMany(entity, buildIdQuery(meta, id, query), hookCtx.body);
129
- return ok({ data: id, count });
130
- });
92
+ return { data: await querier.saveOne(entity, body), count: 1 };
93
+ case 'insertMany': {
94
+ const data = await querier.insertMany(entity, body);
95
+ return { data, count: data.length };
96
+ }
97
+ case 'saveMany': {
98
+ const data = await querier.saveMany(entity, body);
99
+ return { data, count: data.length };
100
+ }
131
101
  case 'updateMany':
132
- return withTransaction(async (querier) => {
133
- const count = await querier.updateMany(entity, query, hookCtx.body);
134
- return ok({ data: count, count });
135
- });
136
- case 'deleteOneById':
137
- return withTransaction(async (querier) => {
138
- const count = await querier.deleteMany(entity, buildIdQuery(meta, id, query), { hardDelete });
139
- return ok({ data: id, count });
140
- });
141
- case 'deleteMany':
142
- return withTransaction(async (querier) => {
143
- const founds = await querier.findMany(entity, query);
144
- let ids = [];
145
- let count = 0;
146
- if (founds.length) {
147
- const idKey = soleIdOf(meta, 'the HTTP handler');
148
- ids = founds.map((found) => found[idKey]);
149
- count = await querier.deleteMany(entity, { $where: whereIds(meta, ids) }, { hardDelete });
150
- }
151
- return ok({ data: ids, count });
152
- });
102
+ case 'updateOneById': {
103
+ const count = await querier.updateMany(entity, scoped, body);
104
+ return { data: id ?? count, count };
105
+ }
106
+ case 'deleteOneById': {
107
+ const count = await querier.deleteMany(entity, scoped, { hardDelete });
108
+ return { data: id, count };
109
+ }
110
+ case 'deleteMany': {
111
+ const founds = await querier.findMany(entity, query);
112
+ if (!founds.length) {
113
+ return { data: [], count: 0 };
114
+ }
115
+ const idKey = soleIdOf(meta, 'the HTTP handler');
116
+ const ids = founds.map((found) => found[idKey]);
117
+ return {
118
+ data: ids,
119
+ count: await querier.deleteMany(entity, { $where: whereIds(meta, ids) }, { hardDelete }),
120
+ };
121
+ }
153
122
  }
154
123
  }
155
124
  }
156
125
  }
157
- function ok(body) {
158
- return { status: 200, body };
159
- }
160
- function buildIdQuery(meta, id, query) {
161
- query.$where = whereWith(soleIdOf(meta, 'the HTTP handler'), id, query.$where);
162
- return query;
126
+ /**
127
+ * Refuses a relation, at any depth of what a client sent, leading to an entity this handler does not
128
+ * serve: `include` fences the routes, and this the rows a `$populate`, a filter, a sort, a tally or a
129
+ * written row would otherwise reach through them. Every `$` clause holds keys of the entity it sits on.
130
+ */
131
+ function assertServed(meta, sent, served, path = '') {
132
+ if (Array.isArray(sent)) {
133
+ for (const item of sent) {
134
+ assertServed(meta, item, served, path);
135
+ }
136
+ return;
137
+ }
138
+ if (typeof sent !== 'object' || sent === null) {
139
+ return;
140
+ }
141
+ const relations = meta.relations;
142
+ for (const [key, value] of Object.entries(sent)) {
143
+ const relation = Object.hasOwn(relations, key) ? relations[key] : undefined;
144
+ if (relation) {
145
+ const target = relation.entity();
146
+ const at = path ? `${path}.${key}` : key;
147
+ if (!served.has(target)) {
148
+ throw new UqlUsageError(`'${at}' reaches '${target.name}', which this handler does not serve`);
149
+ }
150
+ assertServed(getMeta(target), value, served, at);
151
+ }
152
+ else if (key.startsWith('$')) {
153
+ assertServed(meta, value, served, path);
154
+ }
155
+ }
163
156
  }
@@ -1,9 +1,15 @@
1
1
  import type { WireQuery } from '../type/index.js';
2
+ /** The flags a request carries beside its query: `hardDelete` on a delete, `count` on a `findMany`. */
3
+ declare const WIRE_FLAGS: ["hardDelete", "count"];
4
+ /** {@link WIRE_FLAGS} as the booleans {@link parseQueryParams} decodes them to, where a hook may also set them. */
5
+ export type WireFlags = {
6
+ readonly [K in (typeof WIRE_FLAGS)[number]]?: boolean;
7
+ };
2
8
  /**
3
- * Parse raw query-string entries (with JSON-stringified values) into a UQL query object.
4
- * Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
9
+ * Parse raw query-string entries (with JSON-stringified values), or a `QUERY` body, into a UQL query
10
+ * object. Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
5
11
  */
6
- export declare function parseQueryParams<E = unknown>(params?: Record<string, unknown>): WireQuery<E>;
12
+ export declare function parseQueryParams<E = unknown>(params?: unknown): WireQuery<E> & WireFlags;
7
13
  /**
8
14
  * Serialize a UQL query object into a percent-encoded query string where object values
9
15
  * are JSON-stringified. Symmetric counterpart of {@link parseQueryParams}.
@@ -17,3 +23,4 @@ export declare function stringifyQuery(query?: Record<string, unknown>): string;
17
23
  * hits where the client's types already refuse a fragment.
18
24
  */
19
25
  export declare function wireJson(value: unknown): string;
26
+ export {};
@@ -4,13 +4,15 @@ import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES, QUER
4
4
  // with it, in the browser bundle, which is on a size budget
5
5
  import { RAW_VALUE } from '../type/queryRaw.js';
6
6
  // the specific util module, not the barrel, so the browser bundle does not pull in entity metadata
7
- import { getKeys, isWhereMap } from '../util/object.util.js';
7
+ import { getKeys, isRecord, isWhereMap } from '../util/object.util.js';
8
8
  // the error class alone, from its own leaf module: `queryError.ts` carries every driver's code map
9
9
  import { UqlUsageError } from '../util/uqlError.js';
10
+ /** The flags a request carries beside its query: `hardDelete` on a delete, `count` on a `findMany`. */
11
+ const WIRE_FLAGS = ['hardDelete', 'count'];
10
12
  /**
11
- * Keys accepted from the wire - query structure ({@link Query}) plus the `hardDelete`/`count` scalar
12
- * flags. Anything else (e.g. `filters`, `context`, `$entity`) is dropped so a remote client can't
13
- * bypass a security filter or inject ambient context - those are server-only. The `satisfies` ties
13
+ * Keys accepted from the wire - query structure ({@link Query}) plus the {@link WIRE_FLAGS}. Anything else
14
+ * (e.g. `filters`, `context`, `$entity`) is dropped so a remote client can't bypass a security filter or
15
+ * inject ambient context - those are server-only. The `satisfies` ties
14
16
  * every entry to a real query/option key, so a typo or a renamed option fails to compile.
15
17
  */
16
18
  const ALLOWED_QUERY_KEYS = new Set([
@@ -19,8 +21,7 @@ const ALLOWED_QUERY_KEYS = new Set([
19
21
  ...QUERY_NUMBER_CLAUSES,
20
22
  ...QUERY_ROOT_NUMBER_CLAUSES,
21
23
  ...QUERY_BOOLEAN_CLAUSES,
22
- 'hardDelete',
23
- 'count',
24
+ ...WIRE_FLAGS,
24
25
  ]);
25
26
  /**
26
27
  * Keys that mean something locally but that this transport can never honor, so they are rejected
@@ -30,10 +31,13 @@ const ALLOWED_QUERY_KEYS = new Set([
30
31
  */
31
32
  const REJECTED_QUERY_KEYS = new Set(['$lock']);
32
33
  /**
33
- * Parse raw query-string entries (with JSON-stringified values) into a UQL query object.
34
- * Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
34
+ * Parse raw query-string entries (with JSON-stringified values), or a `QUERY` body, into a UQL query
35
+ * object. Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
35
36
  */
36
37
  export function parseQueryParams(params = {}) {
38
+ if (!isRecord(params)) {
39
+ throw new UqlUsageError('the query must be a JSON object');
40
+ }
37
41
  const query = {};
38
42
  for (const key of getKeys(params)) {
39
43
  if (REJECTED_QUERY_KEYS.has(key)) {
@@ -50,7 +54,7 @@ export function parseQueryParams(params = {}) {
50
54
  query[key] = JSON.parse(value);
51
55
  }
52
56
  catch {
53
- throw Object.assign(new SyntaxError(`invalid JSON in '${key}'`), { status: 400 });
57
+ throw new UqlUsageError(`invalid JSON in '${key}'`);
54
58
  }
55
59
  }
56
60
  }
@@ -66,7 +70,7 @@ export function parseQueryParams(params = {}) {
66
70
  query[key] = Number(query[key]);
67
71
  }
68
72
  }
69
- for (const key of QUERY_BOOLEAN_CLAUSES) {
73
+ for (const key of [...QUERY_BOOLEAN_CLAUSES, ...WIRE_FLAGS]) {
70
74
  if (query[key] !== undefined) {
71
75
  query[key] = query[key] === true || query[key] === 'true';
72
76
  }
@@ -1,12 +1,12 @@
1
1
  import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
3
  import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, SUM_COUNT_ALIAS, nullsSortField, sortAggregateField, TEXT_SCORE_ALIAS, } from '../dialect/aliases.js';
4
- import { betweenBounds, GROUP_OPS, groupClauses, isGroupOp, whereOperators } from '../dialect/operators.js';
4
+ import { betweenBounds, GROUP_OPS, groupClauses, isGroupOp, LIKE_OPS, likeRegex, whereOperators, } from '../dialect/operators.js';
5
5
  import { aggregateColumnField, groupPathField, resolveGroupJoins, relationSortTerms, resolveQueryJoins, resolveSortableJoin, } from '../dialect/queryJoins.js';
6
6
  import { assertSoleId, fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
7
7
  import { COUNT_RESULT_KEY } from '../type/query.js';
8
8
  import { QueryRaw } from '../type/queryRaw.js';
9
- import { aggregateOf, asSelectMap, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fieldUpdateOf, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isFieldUpdateOp, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, rankedTextSearch, someKey, targetKeyColumns, textSortOf, vectorDistanceOf, } from '../util/index.js';
9
+ import { aggregateOf, isSelectList, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fieldUpdateOf, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isFieldUpdateOp, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, rankedTextSearch, someKey, targetKeyColumns, textSortOf, vectorDistanceOf, } from '../util/index.js';
10
10
  import { UqlUsageError } from '../util/uqlError.js';
11
11
  import { decodeBigIntsExcept } from '../util/wideNumber.js';
12
12
  import { textLanguage } from './textLanguage.js';
@@ -71,17 +71,6 @@ function compareCount(count, size) {
71
71
  }
72
72
  return comparisons.length === 1 ? comparisons[0] : { $and: comparisons };
73
73
  }
74
- /** String operators -> { pattern: (v) => regex, caseInsensitive } */
75
- const REGEX_OP_MAP = new Map([
76
- ['$startsWith', { wrap: (v) => `^${v}`, ci: false }],
77
- ['$istartsWith', { wrap: (v) => `^${v}`, ci: true }],
78
- ['$endsWith', { wrap: (v) => `${v}$`, ci: false }],
79
- ['$iendsWith', { wrap: (v) => `${v}$`, ci: true }],
80
- ['$includes', { wrap: (v) => String(v), ci: false }],
81
- ['$iincludes', { wrap: (v) => String(v), ci: true }],
82
- ['$like', { wrap: (v) => String(v).replace(/%/g, '.*').replace(/_/g, '.'), ci: false }],
83
- ['$ilike', { wrap: (v) => String(v).replace(/%/g, '.*').replace(/_/g, '.'), ci: true }],
84
- ]);
85
74
  /** MongoDB native operators - pass through as-is. */
86
75
  const NATIVE_OPS = new Set([
87
76
  '$all',
@@ -361,11 +350,11 @@ export class MongoDialect extends AbstractDialect {
361
350
  result[op] = val;
362
351
  continue;
363
352
  }
364
- // String/pattern -> regex operators (8 variants including $like/$ilike)
365
- const regexEntry = REGEX_OP_MAP.get(op);
366
- if (regexEntry) {
367
- result['$regex'] = regexEntry.wrap(val);
368
- if (regexEntry.ci)
353
+ // The `$like` family, as the regex matching what its `LIKE` pattern matches on SQL.
354
+ const like = LIKE_OPS.get(op);
355
+ if (like) {
356
+ result['$regex'] = likeRegex(like.pattern(String(val)));
357
+ if (like.insensitive)
369
358
  result['$options'] = 'i';
370
359
  continue;
371
360
  }
@@ -429,17 +418,16 @@ export class MongoDialect extends AbstractDialect {
429
418
  if (!select && !exclude) {
430
419
  return {};
431
420
  }
432
- if (Array.isArray(select)) {
421
+ if (isSelectList(select)) {
433
422
  throw new UqlUsageError('raw $select is not supported on MongoDB');
434
423
  }
435
- const selectMap = asSelectMap(select);
436
424
  // Projected by column, not by field key; `normalizeId` maps them back on the way out.
437
- const projection = normalizeScalarFieldSelection(meta, selectMap, exclude).reduce((acc, key) => {
425
+ const projection = normalizeScalarFieldSelection(meta, select, exclude).reduce((acc, key) => {
438
426
  // A computed field writing SQL leaves the document nothing to project: refused asked for by
439
427
  // name, skipped swept in with the rest. A relation aggregate is on it by now, like any column.
440
428
  const field = meta.fields[key];
441
429
  if (field?.computed && !aggregateOf(field)) {
442
- if (selectMap && key in selectMap) {
430
+ if (select && key in select) {
443
431
  assertReadable(meta, key);
444
432
  }
445
433
  return acc;
@@ -450,7 +438,7 @@ export class MongoDialect extends AbstractDialect {
450
438
  // MongoDB returns `_id` unless it is explicitly excluded, so subtracting the primary key needs
451
439
  // `_id: 0` - the one inclusion/exclusion mix MongoDB allows - or `$exclude: { id: true }` would
452
440
  // have no effect at all.
453
- if (this.subtractsKey(soleIdOf(meta, 'MongoDB'), selectMap, exclude)) {
441
+ if (this.subtractsKey(soleIdOf(meta, 'MongoDB'), select, exclude)) {
454
442
  projection[ID_KEY] = 0;
455
443
  }
456
444
  return projection;
@@ -565,7 +553,7 @@ export class MongoDialect extends AbstractDialect {
565
553
  /** The relation aggregates a read projects or sorts by; its `$where` puts its own on the document. */
566
554
  aggregateKeys(entity, q) {
567
555
  const meta = getMeta(entity);
568
- const projected = normalizeScalarFieldSelection(meta, asSelectMap(q.$select), q.$exclude);
556
+ const projected = normalizeScalarFieldSelection(meta, isSelectList(q.$select) ? undefined : q.$select, q.$exclude);
569
557
  return [...projected, ...Object.keys(q.$sort ?? {})].filter((key) => aggregateOf(meta.fields[key]));
570
558
  }
571
559
  /**
@@ -88,6 +88,8 @@ export declare abstract class AbstractQuerier implements Querier {
88
88
  * same rows the statement wrote.
89
89
  */
90
90
  insertMany<E extends object>(entity: Type<E>, payload: readonly EntityWrite<E>[]): Promise<(WrittenId<E> | undefined)[]>;
91
+ /** Fills and guards `rows`, then writes them: an insert, and the insert half of a guarded upsert. */
92
+ private insertRows;
91
93
  /** Writes `rows`, and onto each one the key the database generated for it, where it can tell. */
92
94
  protected abstract internalInsertMany<E extends object>(entity: Type<E>, rows: EntityData<E>[]): Promise<void>;
93
95
  updateOneById<E extends object>(entity: Type<E>, id: EntityId<E>, payload: UpdateWrite<E>, opts?: QueryOptions): Promise<number>;
@@ -126,6 +128,12 @@ export declare abstract class AbstractQuerier implements Querier {
126
128
  /** Fires `beforeUpsert`/`afterUpsert`: which branch a row takes is the database's to decide, so neither the insert's nor the update's pair fits. */
127
129
  upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityWrite<E>): Promise<QueryUpsertOneResult<E>>;
128
130
  upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: readonly EntityWrite<E>[]): Promise<QueryUpsertManyResult<E>>;
131
+ /**
132
+ * An upsert on an entity a `security` filter guards. `ON CONFLICT` updates the conflicting row whoever
133
+ * it belongs to, so the rows the conflict names are read through the filter: those found update, the
134
+ * rest insert, where another tenant's row fails on its key. See architecture/security-filter-writes.md.
135
+ */
136
+ private guardedUpsert;
129
137
  protected abstract internalUpsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
130
138
  protected abstract internalUpsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
131
139
  deleteOneById<E extends object>(entity: Type<E>, id: EntityId<E>, opts?: QueryOptions): Promise<number>;
@@ -171,9 +179,9 @@ export declare abstract class AbstractQuerier implements Querier {
171
179
  */
172
180
  private emitLoaded;
173
181
  /**
174
- * The ids of `rows`, read back by the columns an upsert matched them on, for a statement that could
175
- * not report them in payload order. A row that no read row matches, or that two do, keeps
176
- * `undefined`: a missing id is honest where a guessed one is not.
182
+ * The ids of `rows`, read through the filters by the columns an upsert matches them on: after a
183
+ * statement that could not report them in payload order, or before a guarded upsert. A row that no
184
+ * read row matches, or that two do, keeps `undefined`: a missing id is honest where a guessed one is not.
177
185
  */
178
186
  protected idsByConflict<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, rows: EntityData<E>[]): Promise<(PrimaryKey | undefined)[]>;
179
187
  /**
@@ -1,7 +1,7 @@
1
1
  import { assertSoleId, getMeta, idOf, namesKey, relationOf } from '../entity/index.js';
2
2
  import { namesRows } from '../dialect/operators.js';
3
3
  import { parseQueryLock } from '../type/index.js';
4
- import { cascadesOnDelete, childrenOf, clone, entityName, fillOnFields, filterFieldKeys, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, keySet, isPagedQuery, isScalarId, LoggerWrapper, parentJoins, queryLoggerFor, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, someKey, targetKeyColumns, whereAnyOf, whereEach, whereIds, whereWith, withoutSoftDeleteFilter, } from '../util/index.js';
4
+ import { cascadesOnDelete, childrenOf, clone, entityName, fillOnFields, filterFieldKeys, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, guardWrite, idOnlyQuery, keySet, isPagedQuery, isScalarId, LoggerWrapper, parentJoins, queryLoggerFor, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, securityConditions, someKey, targetKeyColumns, whereAnyOf, whereEach, whereIds, whereWith, withoutSoftDeleteFilter, } from '../util/index.js';
5
5
  import { UqlOptimisticLockError, UqlUsageError } from '../util/uqlError.js';
6
6
  import { enrichError } from './queryError.js';
7
7
  /**
@@ -240,11 +240,17 @@ export class AbstractQuerier {
240
240
  }
241
241
  const meta = getMeta(entity);
242
242
  return this.hooked(entity, 'Insert', payload, async (rows) => {
243
- fillOnFields(meta, rows, 'onInsert');
244
- await this.internalInsertMany(entity, rows);
243
+ await this.insertRows(entity, rows);
245
244
  return writtenIds(meta, rows);
246
245
  });
247
246
  }
247
+ /** Fills and guards `rows`, then writes them: an insert, and the insert half of a guarded upsert. */
248
+ async insertRows(entity, rows) {
249
+ const meta = getMeta(entity);
250
+ fillOnFields(meta, rows, 'onInsert');
251
+ guardWrite(meta, rows, 'insert');
252
+ await this.internalInsertMany(entity, rows);
253
+ }
248
254
  async updateOneById(entity, id, payload, opts) {
249
255
  assertIdValue(entity, id);
250
256
  return this.updateMany(entity, { $where: whereIds(getMeta(entity), id) }, payload, opts);
@@ -261,6 +267,7 @@ export class AbstractQuerier {
261
267
  async updateRows(entity, q, row, opts, lockKey) {
262
268
  const meta = getMeta(entity);
263
269
  fillOnFields(meta, [row], 'onUpdate');
270
+ guardWrite(meta, [row], 'update');
264
271
  const relKeys = filterPersistableRelationKeys(meta, row, 'persist');
265
272
  const settles = !!relKeys.length || this.settlesWrite(entity, q);
266
273
  if (lockKey) {
@@ -345,7 +352,9 @@ export class AbstractQuerier {
345
352
  const meta = getMeta(entity);
346
353
  assertUnversioned(meta, "'upsertOne'");
347
354
  return this.hooked(entity, 'Upsert', [payload], async (rows) => {
348
- const { ids, changes, created } = await this.internalUpsertOne(entity, conflictPaths, rows[0]);
355
+ const { ids, changes, created } = securityConditions(meta).length
356
+ ? await this.guardedUpsert(entity, conflictPaths, rows)
357
+ : await this.internalUpsertOne(entity, conflictPaths, rows[0]);
349
358
  adoptReportedIds(meta, rows, ids);
350
359
  const [id] = writtenIds(meta, rows);
351
360
  return { id, changes, created };
@@ -355,11 +364,49 @@ export class AbstractQuerier {
355
364
  const meta = getMeta(entity);
356
365
  assertUnversioned(meta, "'upsertMany'");
357
366
  return this.hooked(entity, 'Upsert', payload, async (rows) => {
358
- const { ids, changes } = await this.internalUpsertMany(entity, conflictPaths, rows);
367
+ const { ids, changes } = securityConditions(meta).length
368
+ ? await this.guardedUpsert(entity, conflictPaths, rows)
369
+ : await this.internalUpsertMany(entity, conflictPaths, rows);
359
370
  adoptReportedIds(meta, rows, ids);
360
371
  return { ids: writtenIds(meta, rows), changes };
361
372
  });
362
373
  }
374
+ /**
375
+ * An upsert on an entity a `security` filter guards. `ON CONFLICT` updates the conflicting row whoever
376
+ * it belongs to, so the rows the conflict names are read through the filter: those found update, the
377
+ * rest insert, where another tenant's row fails on its key. See architecture/security-filter-writes.md.
378
+ */
379
+ async guardedUpsert(entity, conflictPaths, rows) {
380
+ guardWrite(getMeta(entity), rows, 'insert');
381
+ if (!rows.length) {
382
+ return { changes: 0 };
383
+ }
384
+ const keys = getKeys(conflictPaths);
385
+ const write = async () => {
386
+ const ids = await this.idsByConflict(entity, conflictPaths, rows);
387
+ let changes = 0;
388
+ for (const [index, row] of rows.entries()) {
389
+ if (ids[index] !== undefined) {
390
+ // What `ON CONFLICT` would assign: the row, less the columns it matched on.
391
+ const payload = { ...row };
392
+ for (const key of keys) {
393
+ delete payload[key];
394
+ }
395
+ changes += await this.updateColumns(entity, { $where: whereEach(keys, (key) => row[key]) }, payload, undefined, 0);
396
+ }
397
+ }
398
+ const inserts = rows.filter((_, index) => ids[index] === undefined);
399
+ if (inserts.length) {
400
+ await this.insertRows(entity, inserts);
401
+ changes += inserts.length;
402
+ }
403
+ const created = rows.length === 1 ? ids[0] === undefined : undefined;
404
+ // A found row's key is adopted by the caller; an inserted one carries the key its insert wrote.
405
+ return { changes, created, ids };
406
+ };
407
+ // One row is one statement after the read, so it needs no transaction of its own.
408
+ return rows.length === 1 ? write() : this.transaction(write);
409
+ }
363
410
  async deleteOneById(entity, id, opts) {
364
411
  assertIdValue(entity, id);
365
412
  return this.deleteMany(entity, { $where: whereIds(getMeta(entity), id) }, opts);
@@ -571,9 +618,9 @@ export class AbstractQuerier {
571
618
  await this.emitHook(entity, 'afterLoad', rows);
572
619
  }
573
620
  /**
574
- * The ids of `rows`, read back by the columns an upsert matched them on, for a statement that could
575
- * not report them in payload order. A row that no read row matches, or that two do, keeps
576
- * `undefined`: a missing id is honest where a guessed one is not.
621
+ * The ids of `rows`, read through the filters by the columns an upsert matches them on: after a
622
+ * statement that could not report them in payload order, or before a guarded upsert. A row that no
623
+ * read row matches, or that two do, keeps `undefined`: a missing id is honest where a guessed one is not.
577
624
  */
578
625
  async idsByConflict(entity, conflictPaths, rows) {
579
626
  const meta = getMeta(entity);
@@ -1,4 +1,4 @@
1
- import { UqlOptimisticLockError, UqlUsageError } from '../util/uqlError.js';
1
+ import { UqlError } from '../util/uqlError.js';
2
2
  /** Postgres, CockroachDB, PGlite and Neon in `code`; Bun SQL in `errno`. */
3
3
  const SQLSTATE_KINDS = new Map([
4
4
  ['23505', 'uniqueViolation'],
@@ -53,7 +53,7 @@ export function queryErrorKind(err) {
53
53
  if (typeof err !== 'object' || err === null) {
54
54
  return undefined;
55
55
  }
56
- if (err instanceof UqlOptimisticLockError || err instanceof UqlUsageError) {
56
+ if (err instanceof UqlError) {
57
57
  return err.kind;
58
58
  }
59
59
  const { code, errno, number, errorLabels, message } = err;