@querykitjs/mongoose 2.0.0 → 3.0.1
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 +177 -22
- package/README.md +178 -22
- package/dist/index.cjs +1 -610
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +120 -55
- package/dist/index.d.ts +120 -55
- package/dist/index.js +1 -603
- package/dist/index.js.map +1 -1
- package/package.json +18 -9
- package/skills/querykit-mongoose/SKILL.md +190 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: querykit-mongoose
|
|
3
|
+
description: Use when code builds or calls a querykit repository over Mongoose models (@querykitjs/mongoose) - createRegistry(mongoose.connection), registry.repository(Model, options), filterable / sortable / limits policy, maxTimeMS, forcedColumns / allowedColumns, relations and with (populate), findList / findInfinite / findCursor, updateWhere / deleteWhere, scoped, softDelete, upsert, aggregate, or registry.transaction.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Safety rules
|
|
7
|
+
|
|
8
|
+
- DO list what a client may filter on in `filterable` (field → operators). `"*"` admits every operator, so DON'T put `"*"` on a secret field. Without `filterable` a client gets only `=` / `in` on `allowedColumns` (else the `forcedColumns` keys, else only `_id`). Anything else is `QueryKitError` `forbidden-key` / `forbidden-operator` before MongoDB is called.
|
|
9
|
+
- DO list sortable fields in `sortable`. The default is `_id` and `createdAt`, and a client `cursorKey` must be in it too.
|
|
10
|
+
- DO declare `forcedColumns` (always these fields) or `allowedColumns` (the client may narrow them) on the repository. They guard reads only.
|
|
11
|
+
- DON'T pass a plain Mongo object from request data as a filter node. Server code brands one with `raw()` from `@querykitjs/core`; an unbranded object (`{ $where: … }`, `{ $expr: … }`) is `forbidden-key`. `raw()` skips `filterable`.
|
|
12
|
+
- DO list every relation a client may populate in `relations` as `{ model, columns }` (a list, or `"*"` on purpose). The shorthand `{ author: User }` only types the result: `with: { author: true }` is still `forbidden-key`, `site: "with"`.
|
|
13
|
+
- DO keep the request limits. They are core `DEFAULT_QUERY_LIMITS`: depth `5`, conditions `30`, array items `200`, string length `256`, offset `10000`, sort fields `3`, LIKE wildcards `2`. Page size is capped at `200` (`maxPerPage` / `maxLimit`). Raise one with `limits` on the registry or the repository. DON'T hard-code your own checks.
|
|
14
|
+
- DO keep `maxTimeMS` (default `2000`, per registry or repository) and map `QueryKitTimeoutError` to 503/504.
|
|
15
|
+
- DO set `strict: true` (a dropped condition throws) or `onSkippedCondition` (a dropped condition is reported). By default an unknown key is skipped silently.
|
|
16
|
+
- DON'T call `updateWhere` / `deleteWhere` without a filter to mean "all rows": that is `empty-filter`. Pass `{ all: true }` on purpose. The scope still applies.
|
|
17
|
+
- DO set `cursorSecret` on the registry when cursor tokens leave the server. Unsigned or altered tokens are then `invalid-value`.
|
|
18
|
+
- DON'T call HTTP, queues or email inside `registry.transaction`: the callback may run again, and only database work rolls back.
|
|
19
|
+
|
|
20
|
+
## When to use
|
|
21
|
+
|
|
22
|
+
- A Node backend on Mongoose models that serves list requests from `@querykitjs/web`.
|
|
23
|
+
- Offset, infinite and cursor pages, RBAC / tenant scoping, soft delete, bulk writes, upserts, aggregation and populated relations over one model.
|
|
24
|
+
- Typed results: `columns` and `with` infer the row type. Reads are `.lean()` plain objects.
|
|
25
|
+
|
|
26
|
+
With the plain `mongodb` driver use `@querykitjs/mongodb`; the request contract is the same.
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import mongoose from "mongoose";
|
|
32
|
+
import { createRegistry } from "@querykitjs/mongoose";
|
|
33
|
+
import { User } from "@querykitjs/mongoose/skill-prelude";
|
|
34
|
+
|
|
35
|
+
export const registry = createRegistry(mongoose.connection, {
|
|
36
|
+
strict: true,
|
|
37
|
+
maxTimeMS: 2000,
|
|
38
|
+
cursorSecret: process.env.CURSOR_SECRET,
|
|
39
|
+
limits: { maxInItems: 100 },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const usersRepo = registry.repository(User, {
|
|
43
|
+
forcedColumns: { _id: true, name: true, age: true, createdAt: true },
|
|
44
|
+
filterable: { name: ["=", "startsWith"], age: [">=", "<=", "between"], createdAt: ["between"] },
|
|
45
|
+
sortable: ["_id", "name", "createdAt"],
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Common tasks
|
|
50
|
+
|
|
51
|
+
### Filter with the typed helper
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import mongoose from "mongoose";
|
|
55
|
+
import { createFilters, createRegistry } from "@querykitjs/mongoose";
|
|
56
|
+
import { User, type IUser } from "@querykitjs/mongoose/skill-prelude";
|
|
57
|
+
|
|
58
|
+
const usersRepo = createRegistry(mongoose.connection).repository(User, { filterable: { age: [">=", "isNull"], name: ["startsWith"] } });
|
|
59
|
+
const f = createFilters<IUser>();
|
|
60
|
+
|
|
61
|
+
const adults = await usersRepo.findAll({
|
|
62
|
+
filter: f.and(f.gte("age", 18), f.or(f.startsWith("name", "ali"), f.isNull("age"))),
|
|
63
|
+
});
|
|
64
|
+
console.log(adults.length);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Offset page with a multi-field sort
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import mongoose from "mongoose";
|
|
71
|
+
import { createRegistry } from "@querykitjs/mongoose";
|
|
72
|
+
import { User } from "@querykitjs/mongoose/skill-prelude";
|
|
73
|
+
|
|
74
|
+
const usersRepo = createRegistry(mongoose.connection).repository(User, { sortable: ["_id", "name", "createdAt"] });
|
|
75
|
+
|
|
76
|
+
const page = await usersRepo.findList({
|
|
77
|
+
page: 2,
|
|
78
|
+
perPage: 20,
|
|
79
|
+
sort: [
|
|
80
|
+
{ key: "name", direction: "asc" },
|
|
81
|
+
{ key: "createdAt", direction: "desc" },
|
|
82
|
+
],
|
|
83
|
+
withTotal: false, // no countDocuments: total_items / total_pages are null, has_next still works
|
|
84
|
+
});
|
|
85
|
+
console.log(page.meta.has_next);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Infinite scroll
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import mongoose from "mongoose";
|
|
92
|
+
import { createRegistry } from "@querykitjs/mongoose";
|
|
93
|
+
import { User } from "@querykitjs/mongoose/skill-prelude";
|
|
94
|
+
|
|
95
|
+
const usersRepo = createRegistry(mongoose.connection).repository(User);
|
|
96
|
+
|
|
97
|
+
const feed = await usersRepo.findInfinite({ limit: 20, offset: 40 });
|
|
98
|
+
console.log(feed.meta.has_more, feed.meta.next_offset);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Cursor (keyset) pages
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import mongoose from "mongoose";
|
|
105
|
+
import { createRegistry } from "@querykitjs/mongoose";
|
|
106
|
+
import { User } from "@querykitjs/mongoose/skill-prelude";
|
|
107
|
+
|
|
108
|
+
const usersRepo = createRegistry(mongoose.connection).repository(User);
|
|
109
|
+
|
|
110
|
+
const first = await usersRepo.findCursor({ limit: 20, cursorKey: "createdAt", order: "desc" });
|
|
111
|
+
const next = await usersRepo.findCursor({ limit: 20, cursorKey: "createdAt", order: "desc", cursor: first.meta.next_cursor });
|
|
112
|
+
const back = await usersRepo.findCursor({ limit: 20, cursorKey: "createdAt", order: "desc", cursor: next.meta.prev_cursor, direction: "backward" });
|
|
113
|
+
console.log(back.data.length);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Relations with `with` (populate)
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import mongoose from "mongoose";
|
|
120
|
+
import { createRegistry } from "@querykitjs/mongoose";
|
|
121
|
+
import { Post, User } from "@querykitjs/mongoose/skill-prelude";
|
|
122
|
+
|
|
123
|
+
const postsRepo = createRegistry(mongoose.connection).repository(Post, {
|
|
124
|
+
relations: { author: { model: User, columns: ["name"] } },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const post = await postsRepo.findOne({ with: { author: true } });
|
|
128
|
+
console.log(post?.author.name);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Scope every call to some owners
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import mongoose from "mongoose";
|
|
135
|
+
import { createRegistry } from "@querykitjs/mongoose";
|
|
136
|
+
import { Post } from "@querykitjs/mongoose/skill-prelude";
|
|
137
|
+
|
|
138
|
+
declare const authorIds: string[];
|
|
139
|
+
const postsRepo = createRegistry(mongoose.connection).repository(Post);
|
|
140
|
+
|
|
141
|
+
const mine = postsRepo.scoped({ author: { in: authorIds } }); // { in: [] } matches no document
|
|
142
|
+
await mine.findList({ page: 1 });
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Bulk update / delete, upsert
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import mongoose from "mongoose";
|
|
149
|
+
import { createRegistry } from "@querykitjs/mongoose";
|
|
150
|
+
import { User } from "@querykitjs/mongoose/skill-prelude";
|
|
151
|
+
|
|
152
|
+
const usersRepo = createRegistry(mongoose.connection).repository(User, { filterable: { age: ["<", "isNull"] } });
|
|
153
|
+
|
|
154
|
+
const { count } = await usersRepo.updateWhere([{ key: "age", operation: "<", value: 18 }], { age: 18 });
|
|
155
|
+
const { rows } = await usersRepo.deleteWhere([{ key: "age", operation: "isNull" }], { returning: { limit: 50 } });
|
|
156
|
+
await usersRepo.upsert({ name: "Ali", email: "ali@example.com" }, { target: "email" });
|
|
157
|
+
console.log(count, rows.length);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Soft delete, aggregate and transaction
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import mongoose from "mongoose";
|
|
164
|
+
import { createRegistry } from "@querykitjs/mongoose";
|
|
165
|
+
import { Post } from "@querykitjs/mongoose/skill-prelude";
|
|
166
|
+
|
|
167
|
+
const registry = createRegistry(mongoose.connection);
|
|
168
|
+
const postsRepo = registry.repository(Post, { filterable: { author: ["="] } }); // a deletedAt path turns soft delete on
|
|
169
|
+
|
|
170
|
+
await postsRepo.softDelete("665f0e9a1c2b3a4d5e6f7a8b");
|
|
171
|
+
await postsRepo.restore("665f0e9a1c2b3a4d5e6f7a8b");
|
|
172
|
+
const perAuthor = await postsRepo.aggregate({ count: true, groupBy: "author" }); // groupBy / sum / … fields must be in filterable
|
|
173
|
+
await registry.transaction(async () => {
|
|
174
|
+
await postsRepo.updateWhere([{ key: "author", value: "665f0e9a1c2b3a4d5e6f7a8b" }], { title: "moved" });
|
|
175
|
+
}); // needs a replica set; a throw rolls back
|
|
176
|
+
console.log(perAuthor);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Request contract
|
|
180
|
+
|
|
181
|
+
The same JSON shape as every querykit package (`@querykitjs/core`): conditions `{ key, operation?, value? }`, groups `{ and }` / `{ or }` / `{ not }` (a flat array = AND), `sort` always `[{ key, direction? }]`, offset `page` / `perPage` / `withTotal`, infinite `limit` / `offset`, cursor `limit` / `cursor` / `cursorKey` / `order` / `direction` (no `sort`). Meta is snake_case: `total_items`, `total_pages`, `has_next`, `has_more`, `next_offset`, `next_cursor`, `prev_cursor`. The body types are `WireOffsetParams`, `WireInfiniteParams` and `WireCursorParams`; validate with `@querykitjs/zod` or `@querykitjs/class-validator`, then pass the body as is. Refusals are `QueryKitError` with `{ source, site, key, operation?, reason }`.
|
|
182
|
+
|
|
183
|
+
## Mistakes to avoid
|
|
184
|
+
|
|
185
|
+
- Relying on an index on `(col)` alone for `findList` or `findInfinite`: they end the order on `_id`, so with no compound `(col, _id)` index MongoDB sorts in memory. `_id` does not count against `maxSortFields`; `findAll` and `findOne` do not add it.
|
|
186
|
+
- Opening `contains`, `endsWith`, `!=` or `notIn` on a large collection: they cannot use an index (COLLSCAN). `startsWith` is a case-insensitive regex and uses an index poorly; keep a lowercase shadow field (`nameLower`) and give that `=` / `startsWith`.
|
|
187
|
+
- Using a text operator on a Date path: it is skipped as `invalid-value`.
|
|
188
|
+
- Running `registry.transaction` on a standalone `mongod`: transactions need a replica set.
|
|
189
|
+
- Expecting hydrated documents: reads are `.lean()` plain objects. `"id"` in a filter, `columns` or `cursorKey` means `_id`, and a 24-hex string is cast to `ObjectId`.
|
|
190
|
+
- Writing `relations: { author: User }` and expecting `with` to work: give `{ model: User, columns: [...] }`.
|