@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 +121 -1
- package/README.md +121 -1
- package/dist/index.cjs +134 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +90 -50
- package/dist/index.d.ts +90 -50
- package/dist/index.js +135 -38
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
`
|
|
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)
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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 = [
|
|
184
|
-
(
|
|
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
|
-
|
|
198
|
-
const keys =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
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, `$${
|
|
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 =
|
|
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) => {
|