@querykitjs/mongoose 1.0.0 → 2.0.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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Types, Model, Connection, ClientSession } from 'mongoose';
2
2
  import * as _querykitjs_core from '@querykitjs/core';
3
+ import { SkippedCondition } from '@querykitjs/core';
3
4
  export { f } from '@querykitjs/core';
4
5
 
5
6
  /**
@@ -9,10 +10,8 @@ export { f } from '@querykitjs/core';
9
10
  * escape-hatch bound to a native Mongo `FilterQuery`.
10
11
  *
11
12
  * The public surface mirrors `@querykitjs/drizzle-pg` so the same frontend
12
- * request contract works regardless of backend.
13
- *
14
- * NOTE: Stage 1 exposes only read methods (+ scoped). Writes/upsert/aggregate/
15
- * transaction land in stage 2.
13
+ * request contract works regardless of backend: same option names, same
14
+ * semantics, one shared source in core.
16
15
  */
17
16
 
18
17
  /** Plain (lean) document type — what reads return. */
@@ -33,18 +32,25 @@ type Id = string | number | Types.ObjectId;
33
32
  type RawFilter<_TDoc = unknown> = Record<string, unknown>;
34
33
  /** Union of a document's field keys. */
35
34
  type FieldKey<TDoc> = keyof TDoc & string;
35
+ /**
36
+ * A field key that also accepts any `string`. Filter/sort keys arrive from the
37
+ * wire as plain strings and the adapter skips the ones it cannot resolve, so a
38
+ * validated payload can go straight into a repository call with no `as` cast.
39
+ * Autocomplete still suggests the document's real fields.
40
+ */
41
+ type LooseFieldKey<TDoc> = _querykitjs_core.LooseKey<FieldKey<TDoc>>;
36
42
  type FilterOperator = _querykitjs_core.FilterOperator;
37
43
  type FilterScalar = _querykitjs_core.FilterScalar;
38
44
  type FilterValue = _querykitjs_core.FilterValue;
39
- type FieldCondition<TDoc = Record<string, unknown>> = _querykitjs_core.FieldCondition<FieldKey<TDoc>>;
40
- type AndGroup<TDoc = Record<string, unknown>> = _querykitjs_core.AndGroup<FieldKey<TDoc>, RawFilter<TDoc>>;
41
- type OrGroup<TDoc = Record<string, unknown>> = _querykitjs_core.OrGroup<FieldKey<TDoc>, RawFilter<TDoc>>;
42
- type NotGroup<TDoc = Record<string, unknown>> = _querykitjs_core.NotGroup<FieldKey<TDoc>, RawFilter<TDoc>>;
43
- type FilterNode<TDoc = Record<string, unknown>> = _querykitjs_core.FilterNode<FieldKey<TDoc>, RawFilter<TDoc>>;
44
- type Filter<TDoc = Record<string, unknown>> = _querykitjs_core.Filter<FieldKey<TDoc>, RawFilter<TDoc>>;
45
+ type FieldCondition<TDoc = Record<string, unknown>> = _querykitjs_core.FieldCondition<LooseFieldKey<TDoc>>;
46
+ type AndGroup<TDoc = Record<string, unknown>> = _querykitjs_core.AndGroup<LooseFieldKey<TDoc>, RawFilter<TDoc>>;
47
+ type OrGroup<TDoc = Record<string, unknown>> = _querykitjs_core.OrGroup<LooseFieldKey<TDoc>, RawFilter<TDoc>>;
48
+ type NotGroup<TDoc = Record<string, unknown>> = _querykitjs_core.NotGroup<LooseFieldKey<TDoc>, RawFilter<TDoc>>;
49
+ type FilterNode<TDoc = Record<string, unknown>> = _querykitjs_core.FilterNode<LooseFieldKey<TDoc>, RawFilter<TDoc>>;
50
+ type Filter<TDoc = Record<string, unknown>> = _querykitjs_core.Filter<LooseFieldKey<TDoc>, RawFilter<TDoc>>;
45
51
  type SortDirection = _querykitjs_core.SortDirection;
46
52
  /** One sort field by typed field key (`{ key, direction }`) — autocomplete. */
47
- type SortItem<TDoc = Record<string, unknown>> = _querykitjs_core.SortItem<FieldKey<TDoc>>;
53
+ type SortItem<TDoc = Record<string, unknown>> = _querykitjs_core.SortItem<LooseFieldKey<TDoc>>;
48
54
  /**
49
55
  * Sort — **always an array** of `{ key, direction }` (multi-field). This is the
50
56
  * exact shape the querykit frontend sends (`@querykitjs/web` decodes its
@@ -63,36 +69,15 @@ interface QueryParams<TDoc = Record<string, unknown>> {
63
69
  withDeleted?: boolean;
64
70
  }
65
71
  /** Equality scope applied to every operation of a scoped repository. */
66
- type Scope<TDoc = Record<string, unknown>> = Partial<Record<FieldKey<TDoc>, FilterScalar>>;
72
+ type Scope<TDoc = Record<string, unknown>> = _querykitjs_core.Scope<FieldKey<TDoc>>;
67
73
  interface ByIdParams<TDoc = Record<string, unknown>> extends QueryParams<TDoc> {
68
74
  /** Field to match against the id. Defaults to `"id"` (→ `_id`). */
69
- idKey?: FieldKey<TDoc>;
75
+ idKey?: LooseFieldKey<TDoc>;
70
76
  }
71
77
  /** Options for {@link Repository.upsert} / {@link Repository.upsertMany}. */
72
- interface UpsertOptions<TDoc = Record<string, unknown>> {
73
- /** Unique field(s) whose match triggers an update instead of an insert. */
74
- target: FieldKey<TDoc> | FieldKey<TDoc>[];
75
- /** Fields to update on conflict. Defaults to the inserted values minus `target`. */
76
- set?: Partial<Insert<TDoc>>;
77
- }
78
- type AggKeys<TDoc> = FieldKey<TDoc> | FieldKey<TDoc>[];
79
- /** Aggregate query specification (mirrors drizzle-pg). */
80
- interface AggregateSpec<TDoc = Record<string, unknown>> {
81
- filter?: Filter<TDoc>;
82
- /** Group by these field(s); each appears in the output rows. */
83
- groupBy?: AggKeys<TDoc>;
84
- /** `$sum:1` → `count`. */
85
- count?: boolean;
86
- /** `$sum` → `sum_<field>`. */
87
- sum?: AggKeys<TDoc>;
88
- /** `$avg` → `avg_<field>`. */
89
- avg?: AggKeys<TDoc>;
90
- /** `$min` → `min_<field>`. */
91
- min?: AggKeys<TDoc>;
92
- /** `$max` → `max_<field>`. */
93
- max?: AggKeys<TDoc>;
94
- withDeleted?: boolean;
95
- }
78
+ type UpsertOptions<TDoc = Record<string, unknown>> = _querykitjs_core.UpsertOptions<FieldKey<TDoc>, Insert<TDoc>>;
79
+ /** Aggregate query specification (`count`/`sum`/`avg`/`min`/`max` + `groupBy`). */
80
+ type AggregateSpec<TDoc = Record<string, unknown>> = _querykitjs_core.AggregateSpec<FieldKey<TDoc>, Filter<TDoc>>;
96
81
  /** One aggregated result row (group fields + aggregate values). */
97
82
  type AggregateRow = Record<string, unknown>;
98
83
  interface OffsetParams<TDoc = Record<string, unknown>> extends QueryParams<TDoc> {
@@ -117,7 +102,7 @@ interface CursorParams<TDoc = Record<string, unknown>> extends QueryParams<TDoc>
117
102
  limit?: number;
118
103
  cursor?: string | null;
119
104
  /** Field the cursor walks over. Defaults to `"id"` (→ `_id`). */
120
- cursorKey?: FieldKey<TDoc>;
105
+ cursorKey?: LooseFieldKey<TDoc>;
121
106
  order?: SortDirection;
122
107
  direction?: "forward" | "backward";
123
108
  }
@@ -133,10 +118,37 @@ type DocOf<M> = M extends Model<infer T> ? T : never;
133
118
  type RelationDocs<TRel extends RelationMap> = {
134
119
  [K in keyof TRel]: DocOf<TRel[K]>;
135
120
  };
136
- /** Options for `registry.repository(model, options)`. */
137
- interface RepositoryOptions<TRel extends RelationMap = RelationMap> {
121
+ /**
122
+ * Options for `registry.repository(model, options)`.
123
+ *
124
+ * Philosophy: the guard lives in the **repository**, not the route — `scope`
125
+ * (RBAC) already worked that way and projection joins it. Same names and same
126
+ * semantics as the drizzle-pg adapter's `RepositoryOptions`.
127
+ *
128
+ * ⚠️ `forcedColumns`/`allowedColumns` affect **read** methods only
129
+ * (`findAll`/`findOne`/`findById`/`findList`/`findInfinite`/`findCursor` +
130
+ * `aggregate`). Write methods (`create`, `upsert`, `updateById`, `softDelete`, …)
131
+ * return the full `Row` by type contract — trimming it at runtime would make the
132
+ * type a lie. Re-read with `findById`, or map it yourself, before handing a write
133
+ * result to a client.
134
+ */
135
+ interface RepositoryOptions<TDoc = any, TRel extends RelationMap = RelationMap> {
138
136
  /** Declare populatable relations (field → model) so `with` types the result. */
139
137
  relations?: TRel;
138
+ /** Constant equality filter added to every read/write (RBAC / multi-tenancy). */
139
+ scope?: Scope<TDoc>;
140
+ /**
141
+ * Forced projection — the client's `columns` is **ignored entirely**. Keeps a
142
+ * password out of a `users` response without a `{ ...params, columns }` trick in
143
+ * every route. Must select at least one field, else building the repository throws.
144
+ */
145
+ forcedColumns?: ColumnSelection<TDoc>;
146
+ /**
147
+ * Allowlist — the client's `columns` is intersected with it. An empty
148
+ * intersection yields the allowlist itself, **never** the full document.
149
+ * An empty array throws (a projection selecting nothing means "everything").
150
+ */
151
+ allowedColumns?: readonly FieldKey<TDoc>[];
140
152
  }
141
153
  type TrueKeys<T> = {
142
154
  [K in keyof T]: T[K] extends true ? K : never;
@@ -154,7 +166,7 @@ type Params<TBase, TColumns, TWith> = Omit<TBase, "columns" | "with"> & {
154
166
  with?: TWith;
155
167
  };
156
168
  /**
157
- * Table-scoped repository (stage 1: reads + scoped). Read methods infer their
169
+ * Model-scoped repository. Read methods infer their
158
170
  * return type from the `columns` argument, mirroring `@querykitjs/drizzle-pg`.
159
171
  */
160
172
  interface Repository<TDoc = Record<string, unknown>, TRel extends Record<string, unknown> = Record<never, never>> {
@@ -192,12 +204,35 @@ interface Repository<TDoc = Record<string, unknown>, TRel extends Record<string,
192
204
  aggregate(spec: AggregateSpec<TDoc>): Promise<AggregateRow[]>;
193
205
  scoped(scope: Scope<TDoc>): Repository<TDoc, TRel>;
194
206
  }
195
- /** {@link createRegistry} options — pagination defaults. */
207
+ /** {@link createRegistry} options — pagination defaults and bounds. */
196
208
  interface RegistryOptions {
197
209
  /** `findList` default page size (core `DEFAULT_PER_PAGE` = 20). */
198
210
  defaultPerPage?: number;
199
211
  /** `findInfinite`/`findCursor` default limit (core `DEFAULT_LIMIT` = 20). */
200
212
  defaultLimit?: number;
213
+ /**
214
+ * Upper bound for `perPage` (core `DEFAULT_MAX_PER_PAGE` = 200). Applied even
215
+ * when validation was bypassed — defense in depth. Disable with `Infinity`.
216
+ */
217
+ maxPerPage?: number;
218
+ /** Upper bound for `limit` (`findInfinite`/`findCursor`, core `DEFAULT_MAX_LIMIT` = 200). */
219
+ maxLimit?: number;
220
+ /**
221
+ * **Strict mode.** An unknown filter/sort key, or a value the operator cannot
222
+ * use, is no longer dropped silently — a `QueryKitError` is thrown (the backend
223
+ * maps it to a 400). Defaults to `false`, keeping the legacy (DbService)
224
+ * behaviour that dozens of existing endpoints rely on.
225
+ *
226
+ * Why it matters: a filter is meant to **narrow** a result set, so a mistyped
227
+ * key that vanishes silently makes the endpoint return **more** data than
228
+ * intended, with nothing in the log to show it.
229
+ */
230
+ strict?: boolean;
231
+ /**
232
+ * Called for every dropped condition (also when `strict` is off). Migration
233
+ * path: watch with the hook first, flip `strict: true` once the log is clean.
234
+ */
235
+ onSkippedCondition?: (info: _querykitjs_core.SkippedCondition) => void;
201
236
  }
202
237
  /** Extends a base repository with custom, model-specific methods (drizzle-pg style). */
203
238
  type RepositoryExtender<TDoc, TRel extends Record<string, unknown>, TExt extends Record<string, unknown>> = (base: Repository<TDoc, TRel>) => TExt;
@@ -208,9 +243,9 @@ interface Registry {
208
243
  /** …extended with custom methods (2nd-arg extender fn — same as drizzle-pg). */
209
244
  repository<TDoc, TExt extends Record<string, unknown>>(model: Model<TDoc>, extend: RepositoryExtender<TDoc, Record<never, never>, TExt>): Repository<TDoc> & TExt;
210
245
  /** …with typed relations so populated `with` fields are typed. */
211
- repository<TDoc, TRel extends RelationMap>(model: Model<TDoc>, options: RepositoryOptions<TRel>): Repository<TDoc, RelationDocs<TRel>>;
246
+ repository<TDoc, TRel extends RelationMap>(model: Model<TDoc>, options: RepositoryOptions<TDoc, TRel>): Repository<TDoc, RelationDocs<TRel>>;
212
247
  /** …with both typed relations and custom methods. */
213
- repository<TDoc, TRel extends RelationMap, TExt extends Record<string, unknown>>(model: Model<TDoc>, options: RepositoryOptions<TRel>, extend: RepositoryExtender<TDoc, RelationDocs<TRel>, TExt>): Repository<TDoc, RelationDocs<TRel>> & TExt;
248
+ repository<TDoc, TRel extends RelationMap, TExt extends Record<string, unknown>>(model: Model<TDoc>, options: RepositoryOptions<TDoc, TRel>, extend: RepositoryExtender<TDoc, RelationDocs<TRel>, TExt>): Repository<TDoc, RelationDocs<TRel>> & TExt;
214
249
  /** Runs `fn` inside a MongoDB transaction (requires a replica set). */
215
250
  transaction<T>(fn: () => Promise<T>): Promise<T>;
216
251
  }
@@ -238,16 +273,20 @@ interface RepoRuntime {
238
273
  getSession: () => ClientSession | undefined;
239
274
  defaultPerPage: number;
240
275
  defaultLimit: number;
241
- }
242
- /** Per-repository configuration (scope). */
243
- interface RepoConfig<TDoc> {
244
- scope?: Scope<TDoc>;
276
+ /** Upper bound for `perPage`, applied even when validation was bypassed. */
277
+ maxPerPage: number;
278
+ /** Upper bound for `limit` (`findInfinite`/`findCursor`). */
279
+ maxLimit: number;
280
+ /** Throw `QueryKitError` instead of dropping an unresolvable condition. */
281
+ strict: boolean;
282
+ /** Called for every dropped condition (also when `strict` is off). */
283
+ onSkippedCondition?: (info: SkippedCondition) => void;
245
284
  }
246
285
  /**
247
286
  * Build a model-scoped repository. `runtime` (session getter + defaults) comes
248
287
  * from the registry, so this file never touches the connection directly.
249
288
  */
250
- declare function buildRepository<TDoc>(runtime: RepoRuntime, model: Model<TDoc>, config?: RepoConfig<TDoc>): Repository<TDoc>;
289
+ declare function buildRepository<TDoc>(runtime: RepoRuntime, model: Model<TDoc>, config?: RepositoryOptions<TDoc>): Repository<TDoc>;
251
290
 
252
291
  /**
253
292
  * Typed, ergonomic filter builders for a document type (field-name autocomplete).
@@ -276,6 +315,7 @@ declare function createFilters<TDoc>(): {
276
315
  in: (key: FieldKey<TDoc>, value: Array<string | number>) => _querykitjs_core.FieldCondition<FieldKey<TDoc>>;
277
316
  notIn: (key: FieldKey<TDoc>, value: Array<string | number>) => _querykitjs_core.FieldCondition<FieldKey<TDoc>>;
278
317
  between: (key: FieldKey<TDoc>, min: _querykitjs_core.FilterScalar | Date, max: _querykitjs_core.FilterScalar | Date) => _querykitjs_core.FieldCondition<FieldKey<TDoc>>;
318
+ notBetween: (key: FieldKey<TDoc>, min: _querykitjs_core.FilterScalar | Date, max: _querykitjs_core.FilterScalar | Date) => _querykitjs_core.FieldCondition<FieldKey<TDoc>>;
279
319
  range: (key: FieldKey<TDoc>, from: _querykitjs_core.FilterScalar | Date, to: _querykitjs_core.FilterScalar | Date) => _querykitjs_core.FieldCondition<FieldKey<TDoc>>[];
280
320
  isNull: (key: FieldKey<TDoc>) => _querykitjs_core.FieldCondition<FieldKey<TDoc>>;
281
321
  isNotNull: (key: FieldKey<TDoc>) => _querykitjs_core.FieldCondition<FieldKey<TDoc>>;
@@ -284,4 +324,4 @@ declare function createFilters<TDoc>(): {
284
324
  not: (node: _querykitjs_core.FilterNode<FieldKey<TDoc>, RawFilter<_TDoc>>) => _querykitjs_core.NotGroup<FieldKey<TDoc>, RawFilter<_TDoc>>;
285
325
  };
286
326
 
287
- export { type AggregateRow, type AggregateSpec, type AndGroup, type ByIdParams, type ColumnSelection, type CursorMeta, type CursorParams, type CursorResult, type FieldCondition, type FieldKey, type Filter, type FilterNode, type FilterOperator, type FilterScalar, type FilterValue, type Id, type InfiniteMeta, type InfiniteParams, type InfiniteResult, type Insert, type NotGroup, type OffsetMeta, type OffsetParams, type OffsetResult, type OrGroup, type QueryParams, type RawFilter, type Registry, type RegistryOptions, type RelationDocs, type RelationMap, type RepoConfig, type RepoRuntime, type Repository, type RepositoryExtender, type RepositoryOptions, type Result, type Row, type Scope, type Sort, type SortDirection, type SortItem, type UpsertOptions, type WithRelations, buildRepository, createFilters, createRegistry };
327
+ export { type AggregateRow, type AggregateSpec, type AndGroup, type ByIdParams, type ColumnSelection, type CursorMeta, type CursorParams, type CursorResult, type FieldCondition, type FieldKey, type Filter, type FilterNode, type FilterOperator, type FilterScalar, type FilterValue, type Id, type InfiniteMeta, type InfiniteParams, type InfiniteResult, type Insert, type LooseFieldKey, type NotGroup, type OffsetMeta, type OffsetParams, type OffsetResult, type OrGroup, type QueryParams, type RawFilter, type Registry, type RegistryOptions, type RelationDocs, type RelationMap, type RepoRuntime, type Repository, type RepositoryExtender, type RepositoryOptions, type Result, type Row, type Scope, type Sort, type SortDirection, type SortItem, type UpsertOptions, type WithRelations, buildRepository, createFilters, createRegistry };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DEFAULT_LIMIT, DEFAULT_PER_PAGE, createFilters as createFilters$1 } from '@querykitjs/core';
1
+ import { TEXT_FILTER_OPERATORS, QueryKitError, DEFAULT_MAX_LIMIT, DEFAULT_MAX_PER_PAGE, DEFAULT_LIMIT, DEFAULT_PER_PAGE, createFilters as createFilters$1 } from '@querykitjs/core';
2
2
  export { f } from '@querykitjs/core';
3
3
  import { AsyncLocalStorage } from 'async_hooks';
4
4
 
@@ -22,6 +22,44 @@ function castValue(model, field, value) {
22
22
  return value;
23
23
  }
24
24
  }
25
+ var TEXT_OPERATORS = new Set(TEXT_FILTER_OPERATORS);
26
+ var INVALID_VALUE = /* @__PURE__ */ Symbol("querykit.invalid-value");
27
+ var isTextOperator = (operator) => TEXT_OPERATORS.has(operator);
28
+ function isDatePath(model, field) {
29
+ return model.schema.path(field)?.instance === "Date";
30
+ }
31
+ function castForPath(model, field, value) {
32
+ const path = model.schema.path(field);
33
+ if (!path || path.instance !== "Date" || value === null || value === void 0) return value;
34
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? INVALID_VALUE : value;
35
+ try {
36
+ const cast = path.cast(value);
37
+ if (cast instanceof Date) return Number.isNaN(cast.getTime()) ? INVALID_VALUE : cast;
38
+ return cast ?? INVALID_VALUE;
39
+ } catch {
40
+ return INVALID_VALUE;
41
+ }
42
+ }
43
+ function coerceCondition(model, field, operator, value) {
44
+ if (value === void 0 || TEXT_OPERATORS.has(operator)) return value;
45
+ if (Array.isArray(value)) {
46
+ const items = [];
47
+ for (const item of value) {
48
+ const cast2 = castForPath(model, field, item);
49
+ if (cast2 === INVALID_VALUE) return INVALID_VALUE;
50
+ items.push(cast2);
51
+ }
52
+ return items;
53
+ }
54
+ const cast = castForPath(model, field, value);
55
+ return cast === INVALID_VALUE ? INVALID_VALUE : cast;
56
+ }
57
+ function reportSkip(diag, info) {
58
+ if (!diag) return;
59
+ const full = { ...info, source: diag.source };
60
+ if (diag.strict) throw new QueryKitError(full);
61
+ diag.onSkipped?.(full);
62
+ }
25
63
 
26
64
  // src/internal/operators.ts
27
65
  var esc = (s) => String(s ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -83,32 +121,47 @@ function combine(kind, parts) {
83
121
  if (valid.length === 1) return valid[0];
84
122
  return { [kind]: valid };
85
123
  }
86
- function buildCondition(model, cond) {
124
+ function buildCondition(model, cond, diag) {
87
125
  const operator = cond.operation ?? "=";
88
126
  const build = operators[operator];
89
127
  if (!build) throw new Error(`Unsupported filter operator: "${operator}"`);
90
128
  const field = resolveField(model, cond.key);
91
- if (!field) return void 0;
92
- const fragment = build(cond.value);
93
- if (fragment === void 0) return void 0;
129
+ if (!field) {
130
+ reportSkip(diag, { site: "filter", key: cond.key, operation: operator, reason: "unknown-key" });
131
+ return void 0;
132
+ }
133
+ if (isTextOperator(operator) && isDatePath(model, field)) {
134
+ reportSkip(diag, { site: "filter", key: cond.key, operation: operator, reason: "invalid-value" });
135
+ return void 0;
136
+ }
137
+ const value = coerceCondition(model, field, operator, cond.value);
138
+ if (value === INVALID_VALUE) {
139
+ reportSkip(diag, { site: "filter", key: cond.key, operation: operator, reason: "invalid-value" });
140
+ return void 0;
141
+ }
142
+ const fragment = build(value);
143
+ if (fragment === void 0) {
144
+ reportSkip(diag, { site: "filter", key: cond.key, operation: operator, reason: "invalid-value" });
145
+ return void 0;
146
+ }
94
147
  return { [field]: fragment };
95
148
  }
96
- function buildNode(model, node) {
97
- if (isFieldCondition(node)) return buildCondition(model, node);
149
+ function buildNode(model, node, diag) {
150
+ if (isFieldCondition(node)) return buildCondition(model, node, diag);
98
151
  if (isObject(node)) {
99
152
  if ("and" in node)
100
153
  return combine(
101
154
  "$and",
102
- node.and.map((n) => buildNode(model, n))
155
+ node.and.map((n) => buildNode(model, n, diag))
103
156
  );
104
157
  if ("or" in node)
105
158
  return combine(
106
159
  "$or",
107
- node.or.map((n) => buildNode(model, n))
160
+ node.or.map((n) => buildNode(model, n, diag))
108
161
  );
109
162
  if ("not" in node) {
110
163
  const notNode = node.not;
111
- const inner = buildNode(model, notNode);
164
+ const inner = buildNode(model, notNode, diag);
112
165
  if (!inner) return void 0;
113
166
  if (isFieldCondition(notNode)) {
114
167
  const op = notNode.operation ?? "=";
@@ -123,14 +176,14 @@ function buildNode(model, node) {
123
176
  }
124
177
  return void 0;
125
178
  }
126
- function buildWhere(model, filter) {
179
+ function buildWhere(model, filter, diag) {
127
180
  if (filter === void 0) return void 0;
128
181
  if (Array.isArray(filter))
129
182
  return combine(
130
183
  "$and",
131
- filter.map((c) => buildNode(model, c))
184
+ filter.map((c) => buildNode(model, c, diag))
132
185
  );
133
- return buildNode(model, filter);
186
+ return buildNode(model, filter, diag);
134
187
  }
135
188
 
136
189
  // src/internal/order-by.ts
@@ -138,11 +191,12 @@ function normalize(sort) {
138
191
  if (!sort) return [];
139
192
  return sort.filter((item) => item && item.key).map((item) => ({ key: item.key, direction: item.direction ?? "asc" }));
140
193
  }
141
- function buildSort(model, sort) {
194
+ function buildSort(model, sort, diag) {
142
195
  const spec = {};
143
196
  for (const item of normalize(sort)) {
144
197
  const field = resolveField(model, item.key);
145
198
  if (field) spec[field] = item.direction === "desc" ? -1 : 1;
199
+ else reportSkip(diag, { site: "sort", key: item.key, reason: "unknown-key" });
146
200
  }
147
201
  if (Object.keys(spec).length === 0) {
148
202
  if (hasPath(model, "createdAt")) spec.createdAt = -1;
@@ -169,6 +223,7 @@ function decodeCursor(cursor) {
169
223
  }
170
224
 
171
225
  // src/repository.ts
226
+ var clampPageSize = (requested, fallback, max) => Math.min(max, Math.max(1, Math.trunc(requested ?? fallback)));
172
227
  function buildRepository(runtime, model, config = {}) {
173
228
  const m = model;
174
229
  const hasDeletedAt = hasPath(m, "deletedAt");
@@ -176,12 +231,24 @@ function buildRepository(runtime, model, config = {}) {
176
231
  const stampUpdatedAt = hasPath(m, "updatedAt") && !timestampsOn;
177
232
  const forUpdate = (patch) => stampUpdatedAt ? { ...patch, updatedAt: /* @__PURE__ */ new Date() } : patch;
178
233
  const scopeConditions = config.scope ? Object.entries(config.scope).map(([key, value]) => ({ key, operation: "=", value })) : [];
234
+ for (const condition of scopeConditions) {
235
+ if (!resolveField(m, condition.key)) {
236
+ throw new QueryKitError({ source: m.modelName, site: "filter", key: condition.key, reason: "unknown-key" });
237
+ }
238
+ }
179
239
  const scopeWhere = scopeConditions.length ? buildWhere(m, scopeConditions) : void 0;
180
- const sortSpec = (sort) => buildSort(m, sort);
240
+ const diag = {
241
+ source: m.modelName,
242
+ strict: runtime.strict,
243
+ onSkipped: runtime.onSkippedCondition
244
+ };
245
+ const sortSpec = (sort) => buildSort(m, sort, diag);
181
246
  const composeWhere = (userFilter, withDeleted) => {
182
- const parts = [buildWhere(m, userFilter), scopeWhere, hasDeletedAt && !withDeleted ? { deletedAt: { $eq: null } } : void 0].filter(
183
- (p) => p !== void 0
184
- );
247
+ const parts = [
248
+ buildWhere(m, userFilter, diag),
249
+ scopeWhere,
250
+ hasDeletedAt && !withDeleted ? { deletedAt: { $eq: null } } : void 0
251
+ ].filter((p) => p !== void 0);
185
252
  if (parts.length === 0) return {};
186
253
  if (parts.length === 1) return parts[0];
187
254
  return { $and: parts };
@@ -192,9 +259,24 @@ function buildRepository(runtime, model, config = {}) {
192
259
  if (Array.isArray(filter)) return [...filter, idCondition];
193
260
  return { and: [filter, idCondition] };
194
261
  };
262
+ const truthyKeys = (selection) => Object.entries(selection).filter(([, on]) => on).map(([key]) => key);
263
+ const forcedKeys = config.forcedColumns ? truthyKeys(config.forcedColumns) : void 0;
264
+ if (config.forcedColumns && forcedKeys.length === 0) {
265
+ throw new Error("buildRepository: forcedColumns must select at least one field (an empty selection would return the full document).");
266
+ }
267
+ const allowed = config.allowedColumns ? new Set(config.allowedColumns) : void 0;
268
+ if (config.allowedColumns && allowed.size === 0) {
269
+ throw new Error("buildRepository: allowedColumns must list at least one field (an empty allowlist would return the full document).");
270
+ }
271
+ const guarded = Boolean(forcedKeys ?? allowed);
272
+ const permits = (key) => forcedKeys ? forcedKeys.includes(key) : allowed ? allowed.has(key) : true;
273
+ const intersectAllowed = (requested) => {
274
+ const hit = requested.filter((key) => allowed.has(key));
275
+ return hit.length > 0 ? hit : [...allowed];
276
+ };
195
277
  const projection = (columns) => {
196
- if (!columns) return void 0;
197
- const keys = Object.keys(columns).filter((k) => columns[k]);
278
+ const requested = columns ? truthyKeys(columns) : [];
279
+ const keys = forcedKeys ?? (allowed ? intersectAllowed(requested) : requested);
198
280
  if (keys.length === 0) return void 0;
199
281
  const proj = {};
200
282
  let includesId = false;
@@ -280,7 +362,7 @@ function buildRepository(runtime, model, config = {}) {
280
362
  },
281
363
  async findList(params = {}) {
282
364
  const page = Math.max(1, Math.trunc(params.page ?? 1));
283
- const perPage = Math.max(1, Math.trunc(params.perPage ?? runtime.defaultPerPage));
365
+ const perPage = clampPageSize(params.perPage, runtime.defaultPerPage, runtime.maxPerPage);
284
366
  const where = composeWhere(params.filter, params.withDeleted);
285
367
  const [data, total_items] = await Promise.all([
286
368
  runFind(where, { sort: sortSpec(params.sort), columns: params.columns, with: params.with, limit: perPage, skip: (page - 1) * perPage }),
@@ -300,7 +382,7 @@ function buildRepository(runtime, model, config = {}) {
300
382
  };
301
383
  },
302
384
  async findInfinite(params = {}) {
303
- const limit = Math.max(1, Math.trunc(params.limit ?? runtime.defaultLimit));
385
+ const limit = clampPageSize(params.limit, runtime.defaultLimit, runtime.maxLimit);
304
386
  const offset = Math.max(0, Math.trunc(params.offset ?? 0));
305
387
  const rows = await runFind(composeWhere(params.filter, params.withDeleted), {
306
388
  sort: sortSpec(params.sort),
@@ -317,12 +399,12 @@ function buildRepository(runtime, model, config = {}) {
317
399
  };
318
400
  },
319
401
  async findCursor(params = {}) {
320
- const limit = Math.max(1, Math.trunc(params.limit ?? runtime.defaultLimit));
402
+ const limit = clampPageSize(params.limit, runtime.defaultLimit, runtime.maxLimit);
321
403
  const cursorKey = params.cursorKey ?? "id";
322
404
  const order = params.order ?? "asc";
323
405
  const direction = params.direction ?? "forward";
324
406
  const field = resolveField(m, cursorKey);
325
- if (!field) throw new Error(`findCursor: unknown cursorKey "${cursorKey}".`);
407
+ if (!field) throw new QueryKitError({ source: diag.source, site: "cursorKey", key: cursorKey, reason: "unknown-key" });
326
408
  const decoded = decodeCursor(params.cursor);
327
409
  const cursorValue = decoded !== void 0 ? castValue(m, field, decoded) : void 0;
328
410
  const baseWhere = composeWhere(params.filter, params.withDeleted);
@@ -331,6 +413,7 @@ function buildRepository(runtime, model, config = {}) {
331
413
  const where = seek ? Object.keys(baseWhere).length ? { $and: [baseWhere, seek] } : seek : baseWhere;
332
414
  const selected = projection(params.columns);
333
415
  const proj = selected ? { ...selected, [field]: 1 } : void 0;
416
+ const leaksCursorField = guarded && selected !== void 0 && !permits(cursorKey) && !permits(field);
334
417
  let q = m.find(where, proj).sort({ [field]: ascInQuery ? 1 : -1 }).limit(limit + 1);
335
418
  q = applyPopulate(q, params.with);
336
419
  const rows = await attachSession(q).lean().exec();
@@ -341,16 +424,20 @@ function buildRepository(runtime, model, config = {}) {
341
424
  const last = pageRows[pageRows.length - 1];
342
425
  const has_next = direction === "forward" ? hasExtra : cursorValue !== void 0;
343
426
  const has_prev = direction === "forward" ? cursorValue !== void 0 : hasExtra;
344
- return {
345
- data: pageRows,
346
- meta: {
347
- limit,
348
- has_next,
349
- has_prev,
350
- next_cursor: has_next && last ? encodeCursor(last[field]) : null,
351
- prev_cursor: has_prev && first ? encodeCursor(first[field]) : null
352
- }
427
+ const meta = {
428
+ limit,
429
+ has_next,
430
+ has_prev,
431
+ next_cursor: has_next && last ? encodeCursor(last[field]) : null,
432
+ prev_cursor: has_prev && first ? encodeCursor(first[field]) : null
353
433
  };
434
+ if (leaksCursorField) {
435
+ pageRows = pageRows.map((row) => {
436
+ const { [field]: _cursor, ...rest } = row;
437
+ return rest;
438
+ });
439
+ }
440
+ return { data: pageRows, meta };
354
441
  },
355
442
  count(filter) {
356
443
  return countWhere(composeWhere(filter));
@@ -428,7 +515,12 @@ function buildRepository(runtime, model, config = {}) {
428
515
  return row ?? void 0;
429
516
  },
430
517
  async aggregate(spec) {
431
- const groupKeys = toArr(spec.groupBy);
518
+ const aggregable = (key) => {
519
+ const field = permits(key) ? resolveField(m, key) : void 0;
520
+ if (!field) reportSkip(diag, { site: "aggregate", key, reason: "unknown-key" });
521
+ return field;
522
+ };
523
+ const groupKeys = toArr(spec.groupBy).filter((key) => Boolean(aggregable(key)));
432
524
  const rawMatch = composeWhere(spec.filter, spec.withDeleted);
433
525
  let match = rawMatch;
434
526
  try {
@@ -437,12 +529,12 @@ function buildRepository(runtime, model, config = {}) {
437
529
  match = q.getFilter();
438
530
  } catch {
439
531
  }
440
- const groupId = groupKeys.length ? Object.fromEntries(groupKeys.map((k) => [k, `$${resolveField(m, k) ?? k}`])) : null;
532
+ const groupId = groupKeys.length ? Object.fromEntries(groupKeys.map((k) => [k, `$${aggregable(k) ?? k}`])) : null;
441
533
  const group = { _id: groupId };
442
534
  if (spec.count) group.count = { $sum: 1 };
443
535
  const addAgg = (op, keys, prefix) => {
444
536
  for (const k of keys) {
445
- const field = resolveField(m, k);
537
+ const field = aggregable(k);
446
538
  if (field) group[`${prefix}_${k}`] = { [op]: `$${field}` };
447
539
  }
448
540
  };
@@ -476,11 +568,16 @@ function createRegistry(connection, options = {}) {
476
568
  const runtime = {
477
569
  getSession: () => ctx.get()?.session,
478
570
  defaultPerPage: options.defaultPerPage ?? DEFAULT_PER_PAGE,
479
- defaultLimit: options.defaultLimit ?? DEFAULT_LIMIT
571
+ defaultLimit: options.defaultLimit ?? DEFAULT_LIMIT,
572
+ maxPerPage: options.maxPerPage ?? DEFAULT_MAX_PER_PAGE,
573
+ maxLimit: options.maxLimit ?? DEFAULT_MAX_LIMIT,
574
+ strict: options.strict ?? false,
575
+ onSkippedCondition: options.onSkippedCondition
480
576
  };
481
577
  function repository(model, arg2, arg3) {
578
+ const repoOptions = typeof arg2 === "function" ? void 0 : arg2;
482
579
  const extend = typeof arg2 === "function" ? arg2 : arg3;
483
- const base = buildRepository(runtime, model);
580
+ const base = buildRepository(runtime, model, repoOptions);
484
581
  return extend ? { ...base, ...extend(base) } : base;
485
582
  }
486
583
  const transaction = async (fn) => {