@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/README.en.md CHANGED
@@ -83,7 +83,21 @@ export const registry = createRegistry(mongoose.connection);
83
83
  // export const registry = createRegistry(mongoose.connection, { defaultPerPage: 20, defaultLimit: 20 });
84
84
  ```
85
85
 
86
- `defaultPerPage` (findList) / `defaultLimit` (infinite/cursor) fall back to `@querykitjs/core`'s **20** when omitted. The `connection` is passed in (so transactions share the right session).
86
+ The `connection` is passed in (so transactions share the right session).
87
+
88
+ **`createRegistry` options** — identical to the drizzle-pg adapter:
89
+
90
+ | Option | Default | Meaning |
91
+ | -------------------- | --------------------------------- | ------------------------------------------------------------ |
92
+ | `defaultPerPage` | core `DEFAULT_PER_PAGE` (20) | `findList` page size |
93
+ | `defaultLimit` | core `DEFAULT_LIMIT` (20) | `findInfinite`/`findCursor` limit |
94
+ | `maxPerPage` | core `DEFAULT_MAX_PER_PAGE` (200) | upper bound for `perPage` (`Infinity` disables it) |
95
+ | `maxLimit` | core `DEFAULT_MAX_LIMIT` (200) | upper bound for `limit` |
96
+ | `strict` | `false` | unknown key → `QueryKitError` (400) instead of a silent drop |
97
+ | `onSkippedCondition` | — | callback for every dropped condition |
98
+
99
+ `maxPerPage`/`maxLimit` are a **second layer**: even if validation
100
+ (`@querykitjs/zod` factories) is bypassed, the repository clamps.
87
101
 
88
102
  ### 2. Per-model repositories
89
103
 
@@ -97,6 +111,82 @@ export const usersRepository = registry.repository(User, base => ({
97
111
  }));
98
112
  ```
99
113
 
114
+ `repository()` takes four shapes (the same as the drizzle-pg adapter):
115
+
116
+ ```ts
117
+ registry.repository(User); // plain
118
+ registry.repository(User, base => ({ … })); // + custom methods
119
+ registry.repository(User, options); // + per-repo options
120
+ registry.repository(User, options, base => ({ … })); // both
121
+ ```
122
+
123
+ ### 3. Projection guards (`RepositoryOptions`)
124
+
125
+ `columns` can arrive straight from the client, so the safe selection is declared
126
+ on the **repository** — where `scope` (RBAC) already lives:
127
+
128
+ ```ts
129
+ export const usersRepository = registry.repository(User, {
130
+ forcedColumns: { _id: true, fullName: true, email: true }, // password can never come out
131
+ });
132
+
133
+ // or softer: the client picks, but only from this list
134
+ export const postsRepository = registry.repository(Post, {
135
+ allowedColumns: ["_id", "title", "createdAt"],
136
+ });
137
+ ```
138
+
139
+ | Option | Behaviour |
140
+ | ---------------- | ----------------------------------------------------------------------------- |
141
+ | `forcedColumns` | the client's `columns` is **ignored entirely** |
142
+ | `allowedColumns` | the client's selection is **intersected**; empty intersection → the allowlist |
143
+ | `scope` | constant equality filter on every read/write (also available via `scoped()`) |
144
+ | `relations` | populatable relations (type-only — drives `with` inference) |
145
+
146
+ Details worth knowing:
147
+
148
+ - An empty intersection yields the allowlist, **not the full document**.
149
+ - `aggregate` is guarded too: `min("password")` or `groupBy: "password"` leaks as
150
+ much as a projection.
151
+ - `cursorKey` comes from the client and the cursor field must be selected for
152
+ pagination — under a guard it stays in the query but is **stripped** from the
153
+ documents you get back.
154
+ - `forcedColumns: {}` or `allowedColumns: []` **throws** when the repository is built.
155
+ - ⚠️ The guard affects **read** methods only (`findAll`/`findOne`/`findById`/
156
+ `findList`/`findInfinite`/`findCursor` + `aggregate`). Write methods (`create`,
157
+ `upsert`, `updateById`, `softDelete`, …) return the **full document** by type
158
+ contract. Re-read with `findById`, or map it yourself, before handing a write
159
+ result to a client.
160
+
161
+ ### 4. Bad conditions: watch them, or refuse them
162
+
163
+ An unknown filter/sort key is **dropped silently** by default. The catch: a filter
164
+ is meant to _narrow_ a result set, so a mistyped key that vanishes makes the
165
+ endpoint return **more** data than intended.
166
+
167
+ ```ts
168
+ // step 1: watch (behaviour unchanged)
169
+ createRegistry(mongoose.connection, {
170
+ onSkippedCondition: info => logger.warn({ querykit: info }, "condition dropped"),
171
+ });
172
+
173
+ // step 2: once the log is clean — strict mode
174
+ createRegistry(mongoose.connection, { strict: true });
175
+ ```
176
+
177
+ ```ts
178
+ import { QueryKitError } from "@querykitjs/core";
179
+
180
+ try {
181
+ return await usersRepository.findList(params);
182
+ } catch (err) {
183
+ if (err instanceof QueryKitError) return c.json({ error: err.message, info: err.info }, 400);
184
+ throw err;
185
+ }
186
+ ```
187
+
188
+ A `scope` or `cursorKey` key is **always** fatal, regardless of `strict`.
189
+
100
190
  ## MongoDB specifics
101
191
 
102
192
  - **`id` ↔ `_id`.** Use `"id"` anywhere (filters, `columns`, `idKey`, `cursorKey`) — it resolves to Mongo's `_id`, so the wire contract is identical to the SQL adapter. String ids from the frontend cast to `ObjectId` automatically:
@@ -186,6 +276,36 @@ await mine.findList({ page: 1 }); // { supervisorId: user.id, ... }
186
276
  await mine.create({ ... }); // supervisorId forced to user.id
187
277
  ```
188
278
 
279
+ A scope can also be given as `registry.repository(Model, { scope })`. An unknown
280
+ scope key **throws** when the repository is built — dropping it silently would
281
+ remove the RBAC filter.
282
+
283
+ ## Wire (JSON) values and migrating from DbService
284
+
285
+ A request body is JSON, so a date always arrives as a **string**. The adapter
286
+ casts it according to the schema path:
287
+
288
+ ```json
289
+ { "key": "createdAt", "operation": "<=", "value": "2026-07-28T12:00:00.000Z" }
290
+ ```
291
+
292
+ - `Date` paths get the ISO string cast to a `Date`. The scope is **deliberately**
293
+ the same as drizzle-pg's: each adapter only judges the types it maps through JS
294
+ and leaves the rest to the database.
295
+ - `in`/`notIn` arrays, `between`/`notBetween` tuples and cursor tokens are covered
296
+ too (`cursorKey: "createdAt"` paginates correctly).
297
+ - ⚠️ **Documented divergence:** Mongo cannot `$regex` a `Date` path
298
+ (`Can't use $options with Date`), so a text-pattern operator
299
+ (`contains`/`ilike`/…) on a date field is **skipped**. drizzle-pg _can_ match it
300
+ (Postgres renders the timestamp as text). Neither adapter crashes, and the
301
+ difference shows up in `onSkippedCondition`.
302
+ - An unparseable value (`"not-a-date"`) drops the condition (or 400s under
303
+ `strict`). **One** bad element in an `in` list drops the whole condition.
304
+
305
+ **Migrating from DbService:** the old wire's `type: "date"` field is no longer
306
+ needed — `@querykitjs/zod` strips it (without erroring) and coercion happens
307
+ server-side from the path type.
308
+
189
309
  ## Soft-delete
190
310
 
191
311
  Auto-enabled when the schema has a `deletedAt` path. Reads exclude soft-deleted docs by default; `withDeleted: true` includes them. When the schema has no `{ timestamps: true }` but a manual `updatedAt` path, it is bumped on every update.
package/README.md CHANGED
@@ -83,7 +83,21 @@ export const registry = createRegistry(mongoose.connection);
83
83
  // export const registry = createRegistry(mongoose.connection, { defaultPerPage: 20, defaultLimit: 20 });
84
84
  ```
85
85
 
86
- `defaultPerPage` (findList) / `defaultLimit` (infinite/cursor) berilmasa `@querykitjs/core`ning **20**siga tushadi. `connection` tashqaridan beriladi (tranzaksiya to'g'ri session'ni ulashsin uchun).
86
+ `connection` tashqaridan beriladi (tranzaksiya to'g'ri session'ni ulashsin uchun).
87
+
88
+ **`createRegistry` opsiyalari** — drizzle-pg adapteri bilan **aynan bir xil**:
89
+
90
+ | Opsiya | Default | Ma'nosi |
91
+ | -------------------- | --------------------------------- | --------------------------------------------------------- |
92
+ | `defaultPerPage` | core `DEFAULT_PER_PAGE` (20) | `findList` sahifa o'lchami |
93
+ | `defaultLimit` | core `DEFAULT_LIMIT` (20) | `findInfinite`/`findCursor` `limit`i |
94
+ | `maxPerPage` | core `DEFAULT_MAX_PER_PAGE` (200) | `perPage` yuqori chegarasi (`Infinity` — o'chirish) |
95
+ | `maxLimit` | core `DEFAULT_MAX_LIMIT` (200) | `limit` yuqori chegarasi |
96
+ | `strict` | `false` | noma'lum kalit → `QueryKitError` (400), jimgina skip emas |
97
+ | `onSkippedCondition` | — | tashlab yuborilgan har bir shart uchun callback |
98
+
99
+ `maxPerPage`/`maxLimit` — **ikkinchi himoya qatlami**: validatsiya
100
+ (`@querykitjs/zod` factory'lari) chetlab o'tilsa ham repository o'zi clamp qiladi.
87
101
 
88
102
  ### 2. Har model uchun repository
89
103
 
@@ -97,6 +111,82 @@ export const usersRepository = registry.repository(User, base => ({
97
111
  }));
98
112
  ```
99
113
 
114
+ `repository()` to'rt shaklda chaqiriladi (drizzle-pg adapteri bilan bir xil):
115
+
116
+ ```ts
117
+ registry.repository(User); // sof
118
+ registry.repository(User, base => ({ … })); // + custom metodlar
119
+ registry.repository(User, options); // + per-repo opsiyalar
120
+ registry.repository(User, options, base => ({ … })); // ikkisi ham
121
+ ```
122
+
123
+ ### 3. Projection himoyasi (`RepositoryOptions`)
124
+
125
+ `columns` clientdan kelishi mumkin, shuning uchun xavfsiz tanlov **repository'da**
126
+ belgilanadi — `scope` (RBAC) allaqachon shu yo'lda:
127
+
128
+ ```ts
129
+ export const usersRepository = registry.repository(User, {
130
+ forcedColumns: { _id: true, fullName: true, email: true }, // parol hech qachon chiqmaydi
131
+ });
132
+
133
+ // yoki yumshoqroq: client faqat shu ro'yxatdan tanlaydi
134
+ export const postsRepository = registry.repository(Post, {
135
+ allowedColumns: ["_id", "title", "createdAt"],
136
+ });
137
+ ```
138
+
139
+ | Opsiya | Xulqi |
140
+ | ---------------- | ------------------------------------------------------------------------------------- |
141
+ | `forcedColumns` | client `columns`i **butunlay e'tiborsiz** |
142
+ | `allowedColumns` | client tanlovi allowlist bilan **kesiladi**; kesishma bo'sh bo'lsa → allowlist |
143
+ | `scope` | har bir o'qish/yozishga doimiy tenglik filtri (`scoped()` bilan ham berilishi mumkin) |
144
+ | `relations` | populate qilinadigan relation'lar (faqat tip — `with` inference uchun) |
145
+
146
+ Muhim nozikliklar:
147
+
148
+ - Kesishma bo'sh bo'lganda natija **to'liq hujjat emas** — allowlistning o'zi.
149
+ - `aggregate` ham shu guard ostida: `min("password")` yoki
150
+ `groupBy: "password"` projection bilan bir xil miqdorda ma'lumot chiqaradi.
151
+ - `cursorKey` clientdan keladi va cursor maydoni paginatsiya uchun majburan
152
+ tanlanadi — guard bor bo'lsa u maydon so'rovda qoladi, lekin qaytariladigan
153
+ hujjatlardan **olib tashlanadi**.
154
+ - `forcedColumns: {}` yoki `allowedColumns: []` — repository yaratilishida **xato**.
155
+ - ⚠️ Guard **faqat o'qish** metodlariga ta'sir qiladi (`findAll`/`findOne`/
156
+ `findById`/`findList`/`findInfinite`/`findCursor` + `aggregate`). Yozish
157
+ metodlari (`create`, `upsert`, `updateById`, `softDelete`, …) tip kontrakti
158
+ bo'yicha **to'liq hujjatni** qaytaradi. Yozish natijasini clientga
159
+ qaytarishdan oldin `findById` bilan qayta o'qing yoki o'zingiz map qiling.
160
+
161
+ ### 4. Noto'g'ri shartlar: kuzatish yoki rad etish
162
+
163
+ Noma'lum filter/sort kaliti default'da **jimgina tashlab yuboriladi**. Muammosi:
164
+ filter natijani _cheklash_ uchun ishlatiladi, shuning uchun typo qilingan kalit
165
+ yo'qolsa endpoint kutilganidan **ko'proq** data qaytaradi.
166
+
167
+ ```ts
168
+ // 1-qadam: kuzatish (xulq o'zgarmaydi)
169
+ createRegistry(mongoose.connection, {
170
+ onSkippedCondition: info => logger.warn({ querykit: info }, "shart tashlab yuborildi"),
171
+ });
172
+
173
+ // 2-qadam: log tozalanganda — qattiq rejim
174
+ createRegistry(mongoose.connection, { strict: true });
175
+ ```
176
+
177
+ ```ts
178
+ import { QueryKitError } from "@querykitjs/core";
179
+
180
+ try {
181
+ return await usersRepository.findList(params);
182
+ } catch (err) {
183
+ if (err instanceof QueryKitError) return c.json({ error: err.message, info: err.info }, 400);
184
+ throw err;
185
+ }
186
+ ```
187
+
188
+ `scope` va `cursorKey` kaliti **har doim** fatal (`strict`dan qat'i nazar).
189
+
100
190
  ## MongoDB-ga xos jihatlar
101
191
 
102
192
  - **`id` ↔ `_id`.** Istalgan joyda `"id"` ishlating (filter, `columns`, `idKey`, `cursorKey`) — u Mongo'ning `_id`siga o'giriladi, wire kontrakti SQL adapter bilan bir xil. Frontend'дан kelган string id'lar avtomatik `ObjectId`ga cast bo'ladi:
@@ -186,6 +276,36 @@ await mine.findList({ page: 1 }); // { supervisorId: user.id, ... }
186
276
  await mine.create({ ... }); // supervisorId majburan user.id
187
277
  ```
188
278
 
279
+ Scope'ni `registry.repository(Model, { scope })` bilan ham berish mumkin.
280
+ Noma'lum scope kaliti — repository yaratilishida **xato** (jimgina tashlansa
281
+ RBAC filtri yo'qolib ketardi).
282
+
283
+ ## Wire (JSON) qiymatlari va DbService'dan migratsiya
284
+
285
+ Backend so'rov body'sini JSON'da oladi, ya'ni sana har doim **string** bo'lib
286
+ keladi. Adapter uni schema path tipiga qarab avtomatik cast qiladi:
287
+
288
+ ```json
289
+ { "key": "createdAt", "operation": "<=", "value": "2026-07-28T12:00:00.000Z" }
290
+ ```
291
+
292
+ - `Date` path'lari → ISO string avtomatik `Date`ga aylanadi. Qamrov **ataylab**
293
+ drizzle-pg bilan bir xil: u ham faqat JS orqali map qilinadigan tiplarni
294
+ hukm qiladi, qolganini bazaga qoldiradi.
295
+ - `in`/`notIn` massivlari, `between`/`notBetween` tuple'lari va cursor token ham
296
+ qamraladi (`cursorKey: "createdAt"` ishlaydi).
297
+ - ⚠️ **Hujjatlashtirilgan farq:** Mongo `Date` path'da `$regex` qila olmaydi
298
+ (`Can't use $options with Date`), shuning uchun date maydonidagi text-pattern
299
+ operatori (`contains`/`ilike`/…) **tashlab yuboriladi**. drizzle-pg esa buni
300
+ bajaradi (Postgres timestamp'ni text sifatida render qiladi). Ikkalasi ham
301
+ crash qilmaydi; farq `onSkippedCondition`da ko'rinadi.
302
+ - Parse bo'lmaydigan qiymat (`"not-a-date"`) shartni bekor qiladi (`strict`da —
303
+ 400). `in` ro'yxatidagi **bitta** yaroqsiz element butun shartni bekor qiladi.
304
+
305
+ **DbService migratsiyasi:** eski wire'dagi `type: "date"` maydoni endi keraksiz —
306
+ `@querykitjs/zod` uni strip qiladi (xato bermaydi), coercion esa server tomonda
307
+ path tipidan avtomatik.
308
+
189
309
  ## Soft-delete
190
310
 
191
311
  Schema'да `deletedAt` path bo'lsa avtomatik yoqiladi. O'qishlar default'да soft-delete qilingan hujjatlarni chiqarib tashlaydi; `withDeleted: true` ularni ham qo'shadi. `{ timestamps: true }` bo'lmasa lekin qo'lда `updatedAt` path bo'lsa, har update'да bumps qilinadi.
package/dist/index.cjs CHANGED
@@ -23,6 +23,44 @@ function castValue(model, field, value) {
23
23
  return value;
24
24
  }
25
25
  }
26
+ var TEXT_OPERATORS = new Set(core.TEXT_FILTER_OPERATORS);
27
+ var INVALID_VALUE = /* @__PURE__ */ Symbol("querykit.invalid-value");
28
+ var isTextOperator = (operator) => TEXT_OPERATORS.has(operator);
29
+ function isDatePath(model, field) {
30
+ return model.schema.path(field)?.instance === "Date";
31
+ }
32
+ function castForPath(model, field, value) {
33
+ const path = model.schema.path(field);
34
+ if (!path || path.instance !== "Date" || value === null || value === void 0) return value;
35
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? INVALID_VALUE : value;
36
+ try {
37
+ const cast = path.cast(value);
38
+ if (cast instanceof Date) return Number.isNaN(cast.getTime()) ? INVALID_VALUE : cast;
39
+ return cast ?? INVALID_VALUE;
40
+ } catch {
41
+ return INVALID_VALUE;
42
+ }
43
+ }
44
+ function coerceCondition(model, field, operator, value) {
45
+ if (value === void 0 || TEXT_OPERATORS.has(operator)) return value;
46
+ if (Array.isArray(value)) {
47
+ const items = [];
48
+ for (const item of value) {
49
+ const cast2 = castForPath(model, field, item);
50
+ if (cast2 === INVALID_VALUE) return INVALID_VALUE;
51
+ items.push(cast2);
52
+ }
53
+ return items;
54
+ }
55
+ const cast = castForPath(model, field, value);
56
+ return cast === INVALID_VALUE ? INVALID_VALUE : cast;
57
+ }
58
+ function reportSkip(diag, info) {
59
+ if (!diag) return;
60
+ const full = { ...info, source: diag.source };
61
+ if (diag.strict) throw new core.QueryKitError(full);
62
+ diag.onSkipped?.(full);
63
+ }
26
64
 
27
65
  // src/internal/operators.ts
28
66
  var esc = (s) => String(s ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -84,32 +122,47 @@ function combine(kind, parts) {
84
122
  if (valid.length === 1) return valid[0];
85
123
  return { [kind]: valid };
86
124
  }
87
- function buildCondition(model, cond) {
125
+ function buildCondition(model, cond, diag) {
88
126
  const operator = cond.operation ?? "=";
89
127
  const build = operators[operator];
90
128
  if (!build) throw new Error(`Unsupported filter operator: "${operator}"`);
91
129
  const field = resolveField(model, cond.key);
92
- if (!field) return void 0;
93
- const fragment = build(cond.value);
94
- if (fragment === void 0) return void 0;
130
+ if (!field) {
131
+ reportSkip(diag, { site: "filter", key: cond.key, operation: operator, reason: "unknown-key" });
132
+ return void 0;
133
+ }
134
+ if (isTextOperator(operator) && isDatePath(model, field)) {
135
+ reportSkip(diag, { site: "filter", key: cond.key, operation: operator, reason: "invalid-value" });
136
+ return void 0;
137
+ }
138
+ const value = coerceCondition(model, field, operator, cond.value);
139
+ if (value === INVALID_VALUE) {
140
+ reportSkip(diag, { site: "filter", key: cond.key, operation: operator, reason: "invalid-value" });
141
+ return void 0;
142
+ }
143
+ const fragment = build(value);
144
+ if (fragment === void 0) {
145
+ reportSkip(diag, { site: "filter", key: cond.key, operation: operator, reason: "invalid-value" });
146
+ return void 0;
147
+ }
95
148
  return { [field]: fragment };
96
149
  }
97
- function buildNode(model, node) {
98
- if (isFieldCondition(node)) return buildCondition(model, node);
150
+ function buildNode(model, node, diag) {
151
+ if (isFieldCondition(node)) return buildCondition(model, node, diag);
99
152
  if (isObject(node)) {
100
153
  if ("and" in node)
101
154
  return combine(
102
155
  "$and",
103
- node.and.map((n) => buildNode(model, n))
156
+ node.and.map((n) => buildNode(model, n, diag))
104
157
  );
105
158
  if ("or" in node)
106
159
  return combine(
107
160
  "$or",
108
- node.or.map((n) => buildNode(model, n))
161
+ node.or.map((n) => buildNode(model, n, diag))
109
162
  );
110
163
  if ("not" in node) {
111
164
  const notNode = node.not;
112
- const inner = buildNode(model, notNode);
165
+ const inner = buildNode(model, notNode, diag);
113
166
  if (!inner) return void 0;
114
167
  if (isFieldCondition(notNode)) {
115
168
  const op = notNode.operation ?? "=";
@@ -124,14 +177,14 @@ function buildNode(model, node) {
124
177
  }
125
178
  return void 0;
126
179
  }
127
- function buildWhere(model, filter) {
180
+ function buildWhere(model, filter, diag) {
128
181
  if (filter === void 0) return void 0;
129
182
  if (Array.isArray(filter))
130
183
  return combine(
131
184
  "$and",
132
- filter.map((c) => buildNode(model, c))
185
+ filter.map((c) => buildNode(model, c, diag))
133
186
  );
134
- return buildNode(model, filter);
187
+ return buildNode(model, filter, diag);
135
188
  }
136
189
 
137
190
  // src/internal/order-by.ts
@@ -139,11 +192,12 @@ function normalize(sort) {
139
192
  if (!sort) return [];
140
193
  return sort.filter((item) => item && item.key).map((item) => ({ key: item.key, direction: item.direction ?? "asc" }));
141
194
  }
142
- function buildSort(model, sort) {
195
+ function buildSort(model, sort, diag) {
143
196
  const spec = {};
144
197
  for (const item of normalize(sort)) {
145
198
  const field = resolveField(model, item.key);
146
199
  if (field) spec[field] = item.direction === "desc" ? -1 : 1;
200
+ else reportSkip(diag, { site: "sort", key: item.key, reason: "unknown-key" });
147
201
  }
148
202
  if (Object.keys(spec).length === 0) {
149
203
  if (hasPath(model, "createdAt")) spec.createdAt = -1;
@@ -170,6 +224,7 @@ function decodeCursor(cursor) {
170
224
  }
171
225
 
172
226
  // src/repository.ts
227
+ var clampPageSize = (requested, fallback, max) => Math.min(max, Math.max(1, Math.trunc(requested ?? fallback)));
173
228
  function buildRepository(runtime, model, config = {}) {
174
229
  const m = model;
175
230
  const hasDeletedAt = hasPath(m, "deletedAt");
@@ -177,12 +232,24 @@ function buildRepository(runtime, model, config = {}) {
177
232
  const stampUpdatedAt = hasPath(m, "updatedAt") && !timestampsOn;
178
233
  const forUpdate = (patch) => stampUpdatedAt ? { ...patch, updatedAt: /* @__PURE__ */ new Date() } : patch;
179
234
  const scopeConditions = config.scope ? Object.entries(config.scope).map(([key, value]) => ({ key, operation: "=", value })) : [];
235
+ for (const condition of scopeConditions) {
236
+ if (!resolveField(m, condition.key)) {
237
+ throw new core.QueryKitError({ source: m.modelName, site: "filter", key: condition.key, reason: "unknown-key" });
238
+ }
239
+ }
180
240
  const scopeWhere = scopeConditions.length ? buildWhere(m, scopeConditions) : void 0;
181
- const sortSpec = (sort) => buildSort(m, sort);
241
+ const diag = {
242
+ source: m.modelName,
243
+ strict: runtime.strict,
244
+ onSkipped: runtime.onSkippedCondition
245
+ };
246
+ const sortSpec = (sort) => buildSort(m, sort, diag);
182
247
  const composeWhere = (userFilter, withDeleted) => {
183
- const parts = [buildWhere(m, userFilter), scopeWhere, hasDeletedAt && !withDeleted ? { deletedAt: { $eq: null } } : void 0].filter(
184
- (p) => p !== void 0
185
- );
248
+ const parts = [
249
+ buildWhere(m, userFilter, diag),
250
+ scopeWhere,
251
+ hasDeletedAt && !withDeleted ? { deletedAt: { $eq: null } } : void 0
252
+ ].filter((p) => p !== void 0);
186
253
  if (parts.length === 0) return {};
187
254
  if (parts.length === 1) return parts[0];
188
255
  return { $and: parts };
@@ -193,9 +260,24 @@ function buildRepository(runtime, model, config = {}) {
193
260
  if (Array.isArray(filter)) return [...filter, idCondition];
194
261
  return { and: [filter, idCondition] };
195
262
  };
263
+ const truthyKeys = (selection) => Object.entries(selection).filter(([, on]) => on).map(([key]) => key);
264
+ const forcedKeys = config.forcedColumns ? truthyKeys(config.forcedColumns) : void 0;
265
+ if (config.forcedColumns && forcedKeys.length === 0) {
266
+ throw new Error("buildRepository: forcedColumns must select at least one field (an empty selection would return the full document).");
267
+ }
268
+ const allowed = config.allowedColumns ? new Set(config.allowedColumns) : void 0;
269
+ if (config.allowedColumns && allowed.size === 0) {
270
+ throw new Error("buildRepository: allowedColumns must list at least one field (an empty allowlist would return the full document).");
271
+ }
272
+ const guarded = Boolean(forcedKeys ?? allowed);
273
+ const permits = (key) => forcedKeys ? forcedKeys.includes(key) : allowed ? allowed.has(key) : true;
274
+ const intersectAllowed = (requested) => {
275
+ const hit = requested.filter((key) => allowed.has(key));
276
+ return hit.length > 0 ? hit : [...allowed];
277
+ };
196
278
  const projection = (columns) => {
197
- if (!columns) return void 0;
198
- const keys = Object.keys(columns).filter((k) => columns[k]);
279
+ const requested = columns ? truthyKeys(columns) : [];
280
+ const keys = forcedKeys ?? (allowed ? intersectAllowed(requested) : requested);
199
281
  if (keys.length === 0) return void 0;
200
282
  const proj = {};
201
283
  let includesId = false;
@@ -281,7 +363,7 @@ function buildRepository(runtime, model, config = {}) {
281
363
  },
282
364
  async findList(params = {}) {
283
365
  const page = Math.max(1, Math.trunc(params.page ?? 1));
284
- const perPage = Math.max(1, Math.trunc(params.perPage ?? runtime.defaultPerPage));
366
+ const perPage = clampPageSize(params.perPage, runtime.defaultPerPage, runtime.maxPerPage);
285
367
  const where = composeWhere(params.filter, params.withDeleted);
286
368
  const [data, total_items] = await Promise.all([
287
369
  runFind(where, { sort: sortSpec(params.sort), columns: params.columns, with: params.with, limit: perPage, skip: (page - 1) * perPage }),
@@ -301,7 +383,7 @@ function buildRepository(runtime, model, config = {}) {
301
383
  };
302
384
  },
303
385
  async findInfinite(params = {}) {
304
- const limit = Math.max(1, Math.trunc(params.limit ?? runtime.defaultLimit));
386
+ const limit = clampPageSize(params.limit, runtime.defaultLimit, runtime.maxLimit);
305
387
  const offset = Math.max(0, Math.trunc(params.offset ?? 0));
306
388
  const rows = await runFind(composeWhere(params.filter, params.withDeleted), {
307
389
  sort: sortSpec(params.sort),
@@ -318,12 +400,12 @@ function buildRepository(runtime, model, config = {}) {
318
400
  };
319
401
  },
320
402
  async findCursor(params = {}) {
321
- const limit = Math.max(1, Math.trunc(params.limit ?? runtime.defaultLimit));
403
+ const limit = clampPageSize(params.limit, runtime.defaultLimit, runtime.maxLimit);
322
404
  const cursorKey = params.cursorKey ?? "id";
323
405
  const order = params.order ?? "asc";
324
406
  const direction = params.direction ?? "forward";
325
407
  const field = resolveField(m, cursorKey);
326
- if (!field) throw new Error(`findCursor: unknown cursorKey "${cursorKey}".`);
408
+ if (!field) throw new core.QueryKitError({ source: diag.source, site: "cursorKey", key: cursorKey, reason: "unknown-key" });
327
409
  const decoded = decodeCursor(params.cursor);
328
410
  const cursorValue = decoded !== void 0 ? castValue(m, field, decoded) : void 0;
329
411
  const baseWhere = composeWhere(params.filter, params.withDeleted);
@@ -332,6 +414,7 @@ function buildRepository(runtime, model, config = {}) {
332
414
  const where = seek ? Object.keys(baseWhere).length ? { $and: [baseWhere, seek] } : seek : baseWhere;
333
415
  const selected = projection(params.columns);
334
416
  const proj = selected ? { ...selected, [field]: 1 } : void 0;
417
+ const leaksCursorField = guarded && selected !== void 0 && !permits(cursorKey) && !permits(field);
335
418
  let q = m.find(where, proj).sort({ [field]: ascInQuery ? 1 : -1 }).limit(limit + 1);
336
419
  q = applyPopulate(q, params.with);
337
420
  const rows = await attachSession(q).lean().exec();
@@ -342,16 +425,20 @@ function buildRepository(runtime, model, config = {}) {
342
425
  const last = pageRows[pageRows.length - 1];
343
426
  const has_next = direction === "forward" ? hasExtra : cursorValue !== void 0;
344
427
  const has_prev = direction === "forward" ? cursorValue !== void 0 : hasExtra;
345
- return {
346
- data: pageRows,
347
- meta: {
348
- limit,
349
- has_next,
350
- has_prev,
351
- next_cursor: has_next && last ? encodeCursor(last[field]) : null,
352
- prev_cursor: has_prev && first ? encodeCursor(first[field]) : null
353
- }
428
+ const meta = {
429
+ limit,
430
+ has_next,
431
+ has_prev,
432
+ next_cursor: has_next && last ? encodeCursor(last[field]) : null,
433
+ prev_cursor: has_prev && first ? encodeCursor(first[field]) : null
354
434
  };
435
+ if (leaksCursorField) {
436
+ pageRows = pageRows.map((row) => {
437
+ const { [field]: _cursor, ...rest } = row;
438
+ return rest;
439
+ });
440
+ }
441
+ return { data: pageRows, meta };
355
442
  },
356
443
  count(filter) {
357
444
  return countWhere(composeWhere(filter));
@@ -429,7 +516,12 @@ function buildRepository(runtime, model, config = {}) {
429
516
  return row ?? void 0;
430
517
  },
431
518
  async aggregate(spec) {
432
- const groupKeys = toArr(spec.groupBy);
519
+ const aggregable = (key) => {
520
+ const field = permits(key) ? resolveField(m, key) : void 0;
521
+ if (!field) reportSkip(diag, { site: "aggregate", key, reason: "unknown-key" });
522
+ return field;
523
+ };
524
+ const groupKeys = toArr(spec.groupBy).filter((key) => Boolean(aggregable(key)));
433
525
  const rawMatch = composeWhere(spec.filter, spec.withDeleted);
434
526
  let match = rawMatch;
435
527
  try {
@@ -438,12 +530,12 @@ function buildRepository(runtime, model, config = {}) {
438
530
  match = q.getFilter();
439
531
  } catch {
440
532
  }
441
- const groupId = groupKeys.length ? Object.fromEntries(groupKeys.map((k) => [k, `$${resolveField(m, k) ?? k}`])) : null;
533
+ const groupId = groupKeys.length ? Object.fromEntries(groupKeys.map((k) => [k, `$${aggregable(k) ?? k}`])) : null;
442
534
  const group = { _id: groupId };
443
535
  if (spec.count) group.count = { $sum: 1 };
444
536
  const addAgg = (op, keys, prefix) => {
445
537
  for (const k of keys) {
446
- const field = resolveField(m, k);
538
+ const field = aggregable(k);
447
539
  if (field) group[`${prefix}_${k}`] = { [op]: `$${field}` };
448
540
  }
449
541
  };
@@ -477,11 +569,16 @@ function createRegistry(connection, options = {}) {
477
569
  const runtime = {
478
570
  getSession: () => ctx.get()?.session,
479
571
  defaultPerPage: options.defaultPerPage ?? core.DEFAULT_PER_PAGE,
480
- defaultLimit: options.defaultLimit ?? core.DEFAULT_LIMIT
572
+ defaultLimit: options.defaultLimit ?? core.DEFAULT_LIMIT,
573
+ maxPerPage: options.maxPerPage ?? core.DEFAULT_MAX_PER_PAGE,
574
+ maxLimit: options.maxLimit ?? core.DEFAULT_MAX_LIMIT,
575
+ strict: options.strict ?? false,
576
+ onSkippedCondition: options.onSkippedCondition
481
577
  };
482
578
  function repository(model, arg2, arg3) {
579
+ const repoOptions = typeof arg2 === "function" ? void 0 : arg2;
483
580
  const extend = typeof arg2 === "function" ? arg2 : arg3;
484
- const base = buildRepository(runtime, model);
581
+ const base = buildRepository(runtime, model, repoOptions);
485
582
  return extend ? { ...base, ...extend(base) } : base;
486
583
  }
487
584
  const transaction = async (fn) => {