@classytic/mongokit 3.18.0 → 3.20.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.md +66 -0
- package/dist/{PaginationEngine-BcvaXBQ-.mjs → PaginationEngine-BT1ywu9k.mjs} +327 -6
- package/dist/PaginationEngine-DYpOD2wY.d.mts +102 -0
- package/dist/{Repository-B01VW_fU.mjs → Repository-dbbgOGjK.mjs} +85 -11
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +2 -2
- package/dist/adapter/index.d.mts +10 -2
- package/dist/adapter/index.mjs +1 -1
- package/dist/{adapter-DYEMkLwK.mjs → adapter-CW1EL0fF.mjs} +10 -1
- package/dist/ai/index.d.mts +1 -2
- package/dist/ai/index.mjs +3 -2
- package/dist/better-auth/index.d.mts +0 -1
- package/dist/better-auth/index.mjs +2 -2
- package/dist/create-repository-0TkQhcv-.d.mts +1757 -0
- package/dist/create-repository-CJATiAKX.mjs +29 -0
- package/dist/{id-resolution-CnN0YA8t.mjs → id-resolution-P2OzcyRl.mjs} +10 -5
- package/dist/{index-fi91ycQc.d.mts → index-Cg3SWugF.d.mts} +1 -3
- package/dist/index.d.mts +11 -1666
- package/dist/index.mjs +51 -37
- package/dist/lock/index.d.mts +0 -1
- package/dist/{mongooseToJsonSchema-BdQJU39w.d.mts → mongooseToJsonSchema-D2CyAMIC.d.mts} +9 -5
- package/dist/{mongooseToJsonSchema-CBEQmeEa.mjs → mongooseToJsonSchema-khdzVo8x.mjs} +1 -1
- package/dist/multi-tenant.plugin-CTsWz0XK.d.mts +486 -0
- package/dist/pagination/PaginationEngine.d.mts +1 -102
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/plugins/index.d.mts +3 -3
- package/dist/plugins/index.mjs +2 -1
- package/dist/query/primitives/coercion.d.mts +1 -1
- package/dist/query/primitives/coercion.mjs +1 -1
- package/dist/query/primitives/geo.d.mts +1 -1
- package/dist/query/primitives/indexes.d.mts +1 -1
- package/dist/testkit/index.d.mts +128 -0
- package/dist/testkit/index.mjs +139 -0
- package/dist/{validation-chain.plugin-DJtC0KJ-.mjs → timestamp.plugin-nG9Z1JEk.mjs} +5 -1056
- package/dist/{types-CU3SEDbD.d.mts → types-DNFMofHy.d.mts} +51 -22
- package/dist/{update-CvCMv5Cl.mjs → update-1Lq9OMD2.mjs} +5 -5
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +5 -5
- package/dist/validation-chain.plugin-Dn_wZYLA.mjs +1056 -0
- package/dist/{validation-chain.plugin-BpMfy18N.d.mts → validation-chain.plugin-buy3uvyk.d.mts} +2 -487
- package/package.json +16 -7
- package/dist/cursor-_SbWVAn-.mjs +0 -254
- package/dist/sort-Ca1BPfvE.mjs +0 -72
- /package/dist/{coercion-TRMmR49h.d.mts → coercion-BjdPX3YF.d.mts} +0 -0
- /package/dist/{error-BTeepSNL.mjs → error-BuByZwKU.mjs} +0 -0
- /package/dist/{field-selection-CQW3WBiK.mjs → field-selection-FSQFLpsU.mjs} +0 -0
- /package/dist/{indexes-CgOF7Wjd.d.mts → indexes-DIjMpYES.d.mts} +0 -0
- /package/dist/{logger-B8wf-Mtb.mjs → logger-K7dPmu2U.mjs} +0 -0
- /package/dist/{chunk-CfYAbeIz.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -0
package/README.md
CHANGED
|
@@ -351,6 +351,72 @@ npm run build
|
|
|
351
351
|
|
|
352
352
|
The test suite uses `mongodb-memory-server` by default. Set `MONGODB_URI` to run against an external MongoDB deployment.
|
|
353
353
|
|
|
354
|
+
## Test Harness — `@classytic/mongokit/testkit`
|
|
355
|
+
|
|
356
|
+
A batteries-included harness for testing your own repositories. Spins an ephemeral MongoDB (standalone, or a single-node replica set for transactions), opens an **isolated** connection, and gives you a live mongokit `Repository` in one call.
|
|
357
|
+
|
|
358
|
+
`mongodb-memory-server` is an **optional peer** — install it where you use the testkit; it is never a production dependency and is dynamically imported, so it never enters your app bundle:
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
npm i -D mongodb-memory-server
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
import { createTestRepository } from '@classytic/mongokit/testkit';
|
|
366
|
+
|
|
367
|
+
const t = await createTestRepository({
|
|
368
|
+
name: 'Order',
|
|
369
|
+
schema: orderSchema,
|
|
370
|
+
config: { softDelete: true, timestamps: true }, // full CreateRepositoryConfig
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await t.repository.create({ total: 10 });
|
|
374
|
+
await t.clear(); // empty the collection between tests
|
|
375
|
+
await t.close(); // close connection + stop server (idempotent)
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Run against your own local or cloud DB
|
|
379
|
+
|
|
380
|
+
Pass a `uri` (or set `MONGODB_URI`) and **no in-memory server starts** — the testkit connects to your real database and `close()`/`stop()` become no-ops, so it never drops it:
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
const t = await createTestRepository({
|
|
384
|
+
name: 'Order', schema: orderSchema,
|
|
385
|
+
uri: process.env.ATLAS_TEST_URI ?? 'mongodb://localhost:27017/myapp-test',
|
|
386
|
+
});
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
# or for the whole suite, no code change:
|
|
391
|
+
MONGODB_URI="mongodb+srv://…/myapp-test" vitest run
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
> ⚠️ `clear()` empties **every collection** on the connection. Against a real database, always point at a **dedicated test DB** — never production.
|
|
395
|
+
|
|
396
|
+
### Testing indexes
|
|
397
|
+
|
|
398
|
+
`createTestRepository` runs `model.init()`, so declared indexes are really built — unique-constraint enforcement (`E11000`), TTL, compound, text, and geo indexes all behave for real on the in-memory server. Use the exposed `model` for runtime index work:
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
await t.model.syncIndexes();
|
|
402
|
+
await t.model.collection.indexes(); // inspect
|
|
403
|
+
await t.model.collection.dropIndex('tag_1'); // drop
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
Atlas **`$search` / `$vectorSearch`** indexes are Atlas-only and don't exist on any non-Atlas server — test those by pointing `uri` at a real Atlas test cluster.
|
|
407
|
+
|
|
408
|
+
### Helpers
|
|
409
|
+
|
|
410
|
+
| Helper | Use |
|
|
411
|
+
| --- | --- |
|
|
412
|
+
| `createTestRepository` | server + connection + live `Repository` in one call |
|
|
413
|
+
| `createTestConnection` | isolated connection + `clear()` / `close()` |
|
|
414
|
+
| `withMongoMemory(fn)` | scoped setup → run → teardown (`finally`) |
|
|
415
|
+
| `createMongoMemory` | raw server lifecycle (`uri` + `stop`) |
|
|
416
|
+
| `mongoMemoryBackend()` | a `TestBackend` seam for `@classytic/arc-testkit` |
|
|
417
|
+
|
|
418
|
+
All accept `{ replset?, dbName?, uri? }`.
|
|
419
|
+
|
|
354
420
|
## License
|
|
355
421
|
|
|
356
422
|
MIT
|
|
@@ -1,7 +1,258 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import { n as warn } from "./logger-K7dPmu2U.mjs";
|
|
3
|
+
import { t as createError } from "./error-BuByZwKU.mjs";
|
|
4
|
+
import mongoose from "mongoose";
|
|
5
|
+
//#region src/pagination/utils/filter.ts
|
|
6
|
+
/**
|
|
7
|
+
* Builds MongoDB filter for keyset pagination
|
|
8
|
+
* Creates compound $or condition for proper cursor-based filtering
|
|
9
|
+
*
|
|
10
|
+
* @param baseFilters - Existing query filters
|
|
11
|
+
* @param sort - Normalized sort specification
|
|
12
|
+
* @param cursorValue - Primary field value from cursor
|
|
13
|
+
* @param cursorId - _id value from cursor
|
|
14
|
+
* @returns MongoDB filter with keyset condition
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* buildKeysetFilter(
|
|
18
|
+
* { status: 'active' },
|
|
19
|
+
* { createdAt: -1, _id: -1 },
|
|
20
|
+
* new Date('2024-01-01'),
|
|
21
|
+
* new ObjectId('...')
|
|
22
|
+
* )
|
|
23
|
+
* // Returns:
|
|
24
|
+
* // {
|
|
25
|
+
* // status: 'active',
|
|
26
|
+
* // $or: [
|
|
27
|
+
* // { createdAt: { $lt: Date('2024-01-01') } },
|
|
28
|
+
* // { createdAt: Date('2024-01-01'), _id: { $lt: ObjectId('...') } }
|
|
29
|
+
* // ]
|
|
30
|
+
* // }
|
|
31
|
+
*/
|
|
32
|
+
function buildKeysetFilter(baseFilters, sort, cursorValue, cursorId, cursorValues) {
|
|
33
|
+
const sortFields = Object.keys(sort).filter((k) => k !== "_id");
|
|
34
|
+
if (sortFields.length <= 1 && !cursorValues) {
|
|
35
|
+
const primaryField = sortFields[0] || "_id";
|
|
36
|
+
const direction = sort[primaryField];
|
|
37
|
+
const operator = direction === 1 ? "$gt" : "$lt";
|
|
38
|
+
if (cursorValue === null || cursorValue === void 0) if (direction === 1) return {
|
|
39
|
+
...baseFilters,
|
|
40
|
+
$or: [{
|
|
41
|
+
[primaryField]: null,
|
|
42
|
+
_id: { $gt: cursorId }
|
|
43
|
+
}, { [primaryField]: { $ne: null } }]
|
|
44
|
+
};
|
|
45
|
+
else return {
|
|
46
|
+
...baseFilters,
|
|
47
|
+
[primaryField]: null,
|
|
48
|
+
_id: { $lt: cursorId }
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
...baseFilters,
|
|
52
|
+
$or: [{ [primaryField]: { [operator]: cursorValue } }, {
|
|
53
|
+
[primaryField]: cursorValue,
|
|
54
|
+
_id: { [operator]: cursorId }
|
|
55
|
+
}]
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const values = cursorValues || { [sortFields[0]]: cursorValue };
|
|
59
|
+
const allFields = [...sortFields, "_id"];
|
|
60
|
+
const allValues = {
|
|
61
|
+
...values,
|
|
62
|
+
_id: cursorId
|
|
63
|
+
};
|
|
64
|
+
const orConditions = [];
|
|
65
|
+
for (let i = 0; i < allFields.length; i++) {
|
|
66
|
+
const field = allFields[i];
|
|
67
|
+
const operator = (sort[field] ?? sort[sortFields[0]]) === 1 ? "$gt" : "$lt";
|
|
68
|
+
const condition = {};
|
|
69
|
+
for (let j = 0; j < i; j++) condition[allFields[j]] = allValues[allFields[j]];
|
|
70
|
+
condition[field] = { [operator]: allValues[field] };
|
|
71
|
+
orConditions.push(condition);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
...baseFilters,
|
|
75
|
+
$or: orConditions
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/pagination/utils/cursor.ts
|
|
80
|
+
/**
|
|
81
|
+
* Cursor Utilities
|
|
82
|
+
*
|
|
83
|
+
* Encoding and decoding of cursor tokens for keyset pagination.
|
|
84
|
+
* Cursors are base64-encoded JSON containing position data and metadata.
|
|
85
|
+
*/
|
|
86
|
+
var cursor_exports = /* @__PURE__ */ __exportAll({
|
|
87
|
+
decodeCursor: () => decodeCursor,
|
|
88
|
+
encodeCursor: () => encodeCursor,
|
|
89
|
+
resolveCursorFilter: () => resolveCursorFilter,
|
|
90
|
+
validateCursorSort: () => validateCursorSort,
|
|
91
|
+
validateCursorVersion: () => validateCursorVersion
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* Encodes document values and sort metadata into a base64 cursor token
|
|
95
|
+
*
|
|
96
|
+
* @param doc - Document to extract cursor values from
|
|
97
|
+
* @param primaryField - Primary sort field name
|
|
98
|
+
* @param sort - Normalized sort specification
|
|
99
|
+
* @param version - Cursor version for forward compatibility
|
|
100
|
+
* @returns Base64-encoded cursor token
|
|
101
|
+
*/
|
|
102
|
+
function encodeCursor(doc, primaryField, sort, version = 1) {
|
|
103
|
+
const primaryValue = doc[primaryField];
|
|
104
|
+
const idValue = doc._id;
|
|
105
|
+
const sortFields = Object.keys(sort).filter((k) => k !== "_id");
|
|
106
|
+
const vals = {};
|
|
107
|
+
const types = {};
|
|
108
|
+
for (const field of sortFields) {
|
|
109
|
+
vals[field] = serializeValue(doc[field]);
|
|
110
|
+
types[field] = getValueType(doc[field]);
|
|
111
|
+
}
|
|
112
|
+
const payload = {
|
|
113
|
+
v: serializeValue(primaryValue),
|
|
114
|
+
t: getValueType(primaryValue),
|
|
115
|
+
id: serializeValue(idValue),
|
|
116
|
+
idType: getValueType(idValue),
|
|
117
|
+
sort,
|
|
118
|
+
ver: version,
|
|
119
|
+
...sortFields.length > 1 && {
|
|
120
|
+
vals,
|
|
121
|
+
types
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64");
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Decodes a cursor token back into document values and sort metadata
|
|
128
|
+
*
|
|
129
|
+
* @param token - Base64-encoded cursor token
|
|
130
|
+
* @returns Decoded cursor data
|
|
131
|
+
* @throws Error if token is invalid or malformed
|
|
132
|
+
*/
|
|
133
|
+
function decodeCursor(token) {
|
|
134
|
+
let json;
|
|
135
|
+
try {
|
|
136
|
+
json = Buffer.from(token, "base64").toString("utf-8");
|
|
137
|
+
} catch {
|
|
138
|
+
throw new Error("Invalid cursor token: not valid base64");
|
|
139
|
+
}
|
|
140
|
+
let payload;
|
|
141
|
+
try {
|
|
142
|
+
payload = JSON.parse(json);
|
|
143
|
+
} catch {
|
|
144
|
+
throw new Error("Invalid cursor token: not valid JSON");
|
|
145
|
+
}
|
|
146
|
+
if (!payload || typeof payload !== "object" || !("v" in payload) || !("t" in payload) || !("id" in payload) || !("idType" in payload) || !payload.sort || typeof payload.sort !== "object" || typeof payload.ver !== "number") throw new Error("Invalid cursor token: malformed payload structure");
|
|
147
|
+
const VALID_TYPES = [
|
|
148
|
+
"date",
|
|
149
|
+
"objectid",
|
|
150
|
+
"boolean",
|
|
151
|
+
"number",
|
|
152
|
+
"string",
|
|
153
|
+
"null",
|
|
154
|
+
"unknown"
|
|
155
|
+
];
|
|
156
|
+
if (!VALID_TYPES.includes(payload.t) || !VALID_TYPES.includes(payload.idType)) throw new Error("Invalid cursor token: unrecognized value type");
|
|
157
|
+
try {
|
|
158
|
+
let values;
|
|
159
|
+
if (payload.vals && payload.types) {
|
|
160
|
+
values = {};
|
|
161
|
+
for (const [field, serialized] of Object.entries(payload.vals)) values[field] = rehydrateValue(serialized, payload.types[field]);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
value: rehydrateValue(payload.v, payload.t),
|
|
165
|
+
id: rehydrateValue(payload.id, payload.idType),
|
|
166
|
+
sort: payload.sort,
|
|
167
|
+
version: payload.ver,
|
|
168
|
+
...values && { values }
|
|
169
|
+
};
|
|
170
|
+
} catch {
|
|
171
|
+
throw new Error("Invalid cursor token: failed to rehydrate values");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Validates that cursor sort matches current query sort
|
|
176
|
+
*
|
|
177
|
+
* @param cursorSort - Sort specification from cursor
|
|
178
|
+
* @param currentSort - Sort specification from query
|
|
179
|
+
* @throws Error if sorts don't match
|
|
180
|
+
*/
|
|
181
|
+
function validateCursorSort(cursorSort, currentSort) {
|
|
182
|
+
if (JSON.stringify(cursorSort) !== JSON.stringify(currentSort)) throw new Error("Cursor sort does not match current query sort");
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Validates cursor version against the server's expected range.
|
|
186
|
+
*
|
|
187
|
+
* - Cursors newer than `expectedVersion` are rejected (client ahead of server).
|
|
188
|
+
* - Cursors older than `minVersion` are rejected (client cached a cursor from
|
|
189
|
+
* a pre-breaking-change deploy). Default `minVersion = 1` keeps the legacy
|
|
190
|
+
* "accept anything <= expected" behavior; bump it when you ship a breaking
|
|
191
|
+
* cursor format change so old clients restart pagination cleanly instead of
|
|
192
|
+
* silently paginating from the wrong position.
|
|
193
|
+
*/
|
|
194
|
+
function validateCursorVersion(cursorVersion, expectedVersion, minVersion = 1) {
|
|
195
|
+
if (cursorVersion > expectedVersion) throw new Error(`Cursor version ${cursorVersion} is newer than expected version ${expectedVersion}. Please upgrade.`);
|
|
196
|
+
if (cursorVersion < minVersion) throw new Error(`Cursor version ${cursorVersion} is older than minimum supported ${minVersion}. Pagination must restart.`);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Serializes a value for cursor storage
|
|
200
|
+
*/
|
|
201
|
+
function serializeValue(value) {
|
|
202
|
+
if (value === null || value === void 0) return null;
|
|
203
|
+
if (value instanceof Date) return value.toISOString();
|
|
204
|
+
if (value instanceof mongoose.Types.ObjectId) return value.toString();
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Gets the type identifier for a value
|
|
209
|
+
*/
|
|
210
|
+
function getValueType(value) {
|
|
211
|
+
if (value === null || value === void 0) return "null";
|
|
212
|
+
if (value instanceof Date) return "date";
|
|
213
|
+
if (value instanceof mongoose.Types.ObjectId) return "objectid";
|
|
214
|
+
if (typeof value === "boolean") return "boolean";
|
|
215
|
+
if (typeof value === "number") return "number";
|
|
216
|
+
if (typeof value === "string") return "string";
|
|
217
|
+
return "unknown";
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Rehydrates a serialized value back to its original type
|
|
221
|
+
*/
|
|
222
|
+
function rehydrateValue(serialized, type) {
|
|
223
|
+
if (type === "null" || serialized === null) return null;
|
|
224
|
+
switch (type) {
|
|
225
|
+
case "date": return new Date(serialized);
|
|
226
|
+
case "objectid": return new mongoose.Types.ObjectId(serialized);
|
|
227
|
+
case "boolean": return serialized === true || serialized === "true";
|
|
228
|
+
case "number": return Number(serialized);
|
|
229
|
+
default: return serialized;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Resolves cursor token into MongoDB query filters.
|
|
234
|
+
* Shared by PaginationEngine.stream() and Repository.lookupPopulate() keyset path.
|
|
235
|
+
*
|
|
236
|
+
* Handles:
|
|
237
|
+
* - Plain 24-char hex ObjectId strings (fallback cursor)
|
|
238
|
+
* - Base64-encoded cursor tokens (standard cursor)
|
|
239
|
+
* - Cursor version and sort validation
|
|
240
|
+
*/
|
|
241
|
+
function resolveCursorFilter(after, sort, cursorVersion, baseFilters = {}, minCursorVersion = 1) {
|
|
242
|
+
if (/^[a-f0-9]{24}$/i.test(after)) {
|
|
243
|
+
const objectId = new mongoose.Types.ObjectId(after);
|
|
244
|
+
const idOperator = (sort._id || -1) === 1 ? "$gt" : "$lt";
|
|
245
|
+
return {
|
|
246
|
+
...baseFilters,
|
|
247
|
+
_id: { [idOperator]: objectId }
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const cursor = decodeCursor(after);
|
|
251
|
+
validateCursorVersion(cursor.version, cursorVersion, minCursorVersion);
|
|
252
|
+
validateCursorSort(cursor.sort, sort);
|
|
253
|
+
return buildKeysetFilter(baseFilters, sort, cursor.value, cursor.id, cursor.values);
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
5
256
|
//#region src/pagination/utils/index-hint.ts
|
|
6
257
|
/**
|
|
7
258
|
* Read a Mongoose schema's declared indexes, defensively.
|
|
@@ -131,6 +382,76 @@ function calculateTotalPages(total, limit) {
|
|
|
131
382
|
return Math.ceil(total / limit);
|
|
132
383
|
}
|
|
133
384
|
//#endregion
|
|
385
|
+
//#region src/pagination/utils/sort.ts
|
|
386
|
+
var sort_exports = /* @__PURE__ */ __exportAll({
|
|
387
|
+
getPrimaryField: () => getPrimaryField,
|
|
388
|
+
normalizeSort: () => normalizeSort,
|
|
389
|
+
validateKeysetSort: () => validateKeysetSort
|
|
390
|
+
});
|
|
391
|
+
/**
|
|
392
|
+
* Normalizes sort object to ensure stable key order
|
|
393
|
+
* Primary fields first, _id last (not alphabetical)
|
|
394
|
+
*
|
|
395
|
+
* @param sort - Sort specification
|
|
396
|
+
* @returns Normalized sort with stable key order
|
|
397
|
+
*/
|
|
398
|
+
function normalizeSort(sort) {
|
|
399
|
+
const normalized = {};
|
|
400
|
+
Object.keys(sort).forEach((key) => {
|
|
401
|
+
if (key !== "_id") normalized[key] = sort[key];
|
|
402
|
+
});
|
|
403
|
+
if (sort._id !== void 0) normalized._id = sort._id;
|
|
404
|
+
return normalized;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Validates and normalizes sort for keyset pagination.
|
|
408
|
+
* Auto-adds `_id` tie-breaker if needed; ensures `_id` direction matches the
|
|
409
|
+
* primary field.
|
|
410
|
+
*
|
|
411
|
+
* **Known limitation (MongoDB mixed-type sort):** keyset pagination across a
|
|
412
|
+
* field that can hold both typed values (Date, Number) and `null`/`undefined`
|
|
413
|
+
* is lossy — `$lt/$gt` don't cross the type boundary in BSON comparison, so
|
|
414
|
+
* some docs at the null/non-null boundary are unreachable. If your sort field
|
|
415
|
+
* is nullable, either:
|
|
416
|
+
* - sort by `_id` alone (always safe), or
|
|
417
|
+
* - use `allowedPrimaryFields` to lock keyset sort to fields your schema
|
|
418
|
+
* guarantees non-null (see `PaginationConfig.strictKeysetSortFields`).
|
|
419
|
+
*
|
|
420
|
+
* @param sort - Sort specification
|
|
421
|
+
* @param allowedPrimaryFields - Optional allowlist of primary (non-_id) sort
|
|
422
|
+
* field names. When set, rejects sorts whose primary field isn't listed.
|
|
423
|
+
* `_id` is always allowed.
|
|
424
|
+
* @returns Validated and normalized sort
|
|
425
|
+
* @throws Error if sort is invalid for keyset pagination
|
|
426
|
+
*/
|
|
427
|
+
function validateKeysetSort(sort, allowedPrimaryFields) {
|
|
428
|
+
const keys = Object.keys(sort);
|
|
429
|
+
if (keys.length === 0) throw new Error("Keyset pagination requires at least one sort field");
|
|
430
|
+
if (keys.length === 1 && keys[0] === "_id") return normalizeSort(sort);
|
|
431
|
+
for (const key of keys) if (sort[key] !== 1 && sort[key] !== -1) throw new Error(`Invalid sort direction for "${key}": must be 1 or -1, got ${sort[key]}`);
|
|
432
|
+
const nonIdKeys = keys.filter((k) => k !== "_id");
|
|
433
|
+
const primaryDirection = sort[nonIdKeys[0]];
|
|
434
|
+
if (allowedPrimaryFields && allowedPrimaryFields.length > 0) {
|
|
435
|
+
for (const key of nonIdKeys) if (!allowedPrimaryFields.includes(key)) throw new Error(`Keyset sort field "${key}" is not in the strictKeysetSortFields allowlist. Allowed: ${allowedPrimaryFields.join(", ")}. (See PaginationConfig.strictKeysetSortFields — protects against lossy null/non-null keyset boundaries.)`);
|
|
436
|
+
}
|
|
437
|
+
for (const key of nonIdKeys) if (sort[key] !== primaryDirection) throw new Error("All sort fields must share the same direction for keyset pagination");
|
|
438
|
+
if (keys.includes("_id") && sort._id !== primaryDirection) throw new Error("_id direction must match primary field direction");
|
|
439
|
+
if (!keys.includes("_id")) return normalizeSort({
|
|
440
|
+
...sort,
|
|
441
|
+
_id: primaryDirection
|
|
442
|
+
});
|
|
443
|
+
return normalizeSort(sort);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Extracts primary sort field (first non-_id field)
|
|
447
|
+
*
|
|
448
|
+
* @param sort - Sort specification
|
|
449
|
+
* @returns Primary field name
|
|
450
|
+
*/
|
|
451
|
+
function getPrimaryField(sort) {
|
|
452
|
+
return Object.keys(sort).find((k) => k !== "_id") || "_id";
|
|
453
|
+
}
|
|
454
|
+
//#endregion
|
|
134
455
|
//#region src/pagination/PaginationEngine.ts
|
|
135
456
|
/**
|
|
136
457
|
* Strip a trailing `_id` tiebreaker from a normalized sort spec.
|
|
@@ -152,7 +473,7 @@ function stripTrailingIdTiebreaker(sort) {
|
|
|
152
473
|
}
|
|
153
474
|
function ensureKeysetSelectIncludesCursorFields(select, sort) {
|
|
154
475
|
if (!select) return select;
|
|
155
|
-
const requiredFields = new Set([...Object.keys(sort), "_id"]);
|
|
476
|
+
const requiredFields = /* @__PURE__ */ new Set([...Object.keys(sort), "_id"]);
|
|
156
477
|
if (typeof select === "string") {
|
|
157
478
|
const fields = select.split(/[,\s]+/).map((field) => field.trim()).filter(Boolean);
|
|
158
479
|
if (fields.length > 0 && fields.every((field) => field.startsWith("-"))) return select;
|
|
@@ -390,4 +711,4 @@ var PaginationEngine = class {
|
|
|
390
711
|
}
|
|
391
712
|
};
|
|
392
713
|
//#endregion
|
|
393
|
-
export { PaginationEngine as t };
|
|
714
|
+
export { sort_exports as n, cursor_exports as r, PaginationEngine as t };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { A as KeysetPaginationOptions, U as PaginationConfig, V as OffsetPaginationOptions, i as AnyDocument, n as AggregatePaginationOptions } from "./types-DNFMofHy.mjs";
|
|
2
|
+
import { Model } from "mongoose";
|
|
3
|
+
import { AggregatePaginationResult, KeysetPaginationResult, OffsetPaginationResult } from "@classytic/repo-core/pagination";
|
|
4
|
+
//#region src/pagination/PaginationEngine.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Internal pagination config with required values
|
|
7
|
+
*/
|
|
8
|
+
interface ResolvedPaginationConfig {
|
|
9
|
+
defaultLimit: number;
|
|
10
|
+
maxLimit: number;
|
|
11
|
+
maxPage: number;
|
|
12
|
+
deepPageThreshold: number;
|
|
13
|
+
cursorVersion: number;
|
|
14
|
+
minCursorVersion: number;
|
|
15
|
+
strictKeysetSortFields: string[] | undefined;
|
|
16
|
+
useEstimatedCount: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Production-grade pagination engine for MongoDB
|
|
20
|
+
* Supports offset, keyset (cursor), and aggregate pagination
|
|
21
|
+
*/
|
|
22
|
+
declare class PaginationEngine<TDoc = AnyDocument> {
|
|
23
|
+
readonly Model: Model<TDoc>;
|
|
24
|
+
readonly config: ResolvedPaginationConfig;
|
|
25
|
+
/**
|
|
26
|
+
* Lazily-cached schema index snapshot used by stream() to decide whether
|
|
27
|
+
* to emit the "missing compound index" warning. Computed on first use.
|
|
28
|
+
*/
|
|
29
|
+
private _cachedSchemaIndexes;
|
|
30
|
+
/**
|
|
31
|
+
* Create a new pagination engine
|
|
32
|
+
*
|
|
33
|
+
* @param Model - Mongoose model to paginate
|
|
34
|
+
* @param config - Pagination configuration
|
|
35
|
+
*/
|
|
36
|
+
constructor(Model: Model<TDoc, any, any, any>, config?: PaginationConfig);
|
|
37
|
+
/** Memoized schema index lookup — avoids re-walking schema on every stream(). */
|
|
38
|
+
private _getSchemaIndexes;
|
|
39
|
+
/**
|
|
40
|
+
* Offset-based pagination using skip/limit
|
|
41
|
+
* Best for small datasets and when users need random page access
|
|
42
|
+
* O(n) performance - slower for deep pages
|
|
43
|
+
*
|
|
44
|
+
* @param options - Pagination options
|
|
45
|
+
* @returns Pagination result with total count
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* const result = await engine.paginate({
|
|
49
|
+
* filters: { status: 'active' },
|
|
50
|
+
* sort: { createdAt: -1 },
|
|
51
|
+
* page: 1,
|
|
52
|
+
* limit: 20
|
|
53
|
+
* });
|
|
54
|
+
* console.log(result.data, result.total, result.hasNext);
|
|
55
|
+
*/
|
|
56
|
+
paginate(options?: OffsetPaginationOptions): Promise<OffsetPaginationResult<TDoc>>;
|
|
57
|
+
/**
|
|
58
|
+
* Keyset (cursor-based) pagination for high-performance streaming
|
|
59
|
+
* Best for large datasets, infinite scroll, real-time feeds
|
|
60
|
+
* O(1) performance - consistent speed regardless of position
|
|
61
|
+
*
|
|
62
|
+
* @param options - Pagination options (sort is required)
|
|
63
|
+
* @returns Pagination result with next cursor
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* // First page
|
|
67
|
+
* const page1 = await engine.stream({
|
|
68
|
+
* sort: { createdAt: -1 },
|
|
69
|
+
* limit: 20
|
|
70
|
+
* });
|
|
71
|
+
*
|
|
72
|
+
* // Next page using cursor
|
|
73
|
+
* const page2 = await engine.stream({
|
|
74
|
+
* sort: { createdAt: -1 },
|
|
75
|
+
* after: page1.next,
|
|
76
|
+
* limit: 20
|
|
77
|
+
* });
|
|
78
|
+
*/
|
|
79
|
+
stream(options: KeysetPaginationOptions): Promise<KeysetPaginationResult<TDoc>>;
|
|
80
|
+
/**
|
|
81
|
+
* Aggregate pipeline with pagination
|
|
82
|
+
* Best for complex queries requiring aggregation stages
|
|
83
|
+
* Uses $facet to combine results and count in single query
|
|
84
|
+
*
|
|
85
|
+
* @param options - Aggregation options
|
|
86
|
+
* @returns Pagination result with total count
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* const result = await engine.aggregatePaginate({
|
|
90
|
+
* pipeline: [
|
|
91
|
+
* { $match: { status: 'active' } },
|
|
92
|
+
* { $group: { _id: '$category', count: { $sum: 1 } } },
|
|
93
|
+
* { $sort: { count: -1 } }
|
|
94
|
+
* ],
|
|
95
|
+
* page: 1,
|
|
96
|
+
* limit: 20
|
|
97
|
+
* });
|
|
98
|
+
*/
|
|
99
|
+
aggregatePaginate(options?: AggregatePaginationOptions): Promise<AggregatePaginationResult<TDoc>>;
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
export { PaginationEngine as t };
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { i as isValidIdForType, t as getSchemaIdType } from "./id-resolution-
|
|
2
|
-
import { a as count, b as LookupBuilder, c as getByQuery, d as deleteById, f as deleteByQuery, h as createMany, l as getOrCreate, m as create, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, x as compileFilterToMongo, y as distinct } from "./update-
|
|
3
|
-
import { n as warn } from "./logger-
|
|
4
|
-
import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-
|
|
5
|
-
import { t as PaginationEngine } from "./PaginationEngine-
|
|
1
|
+
import { i as isValidIdForType, t as getSchemaIdType } from "./id-resolution-P2OzcyRl.mjs";
|
|
2
|
+
import { a as count, b as LookupBuilder, c as getByQuery, d as deleteById, f as deleteByQuery, h as createMany, l as getOrCreate, m as create, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, x as compileFilterToMongo, y as distinct } from "./update-1Lq9OMD2.mjs";
|
|
3
|
+
import { n as warn } from "./logger-K7dPmu2U.mjs";
|
|
4
|
+
import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-BuByZwKU.mjs";
|
|
5
|
+
import { t as PaginationEngine } from "./PaginationEngine-BT1ywu9k.mjs";
|
|
6
6
|
import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
|
|
7
|
-
import { RepositoryBase, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry } from "@classytic/repo-core/repository";
|
|
7
|
+
import { RepositoryBase, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry } from "@classytic/repo-core/repository";
|
|
8
8
|
import mongoose from "mongoose";
|
|
9
9
|
import { isFilter } from "@classytic/repo-core/filter";
|
|
10
10
|
import { HOOK_PRIORITY } from "@classytic/repo-core/hooks";
|
|
@@ -97,6 +97,7 @@ const MONGOKIT_CAPABILITIES = {
|
|
|
97
97
|
getOrCreate: true,
|
|
98
98
|
countAndExists: true,
|
|
99
99
|
purgeByField: true,
|
|
100
|
+
archiveByFilter: true,
|
|
100
101
|
arrayOperators: true,
|
|
101
102
|
regexFilter: true,
|
|
102
103
|
changeStreams: true,
|
|
@@ -617,7 +618,7 @@ function validateTopN(topN, groupCols, bucketAliases, measures) {
|
|
|
617
618
|
if (!Number.isInteger(topN.limit) || topN.limit <= 0) throw new Error(`mongokit/aggregate: topN.limit must be a positive integer — got ${String(topN.limit)}`);
|
|
618
619
|
if (!topN.sortBy || Object.keys(topN.sortBy).length === 0) throw new Error("mongokit/aggregate: topN.sortBy must declare at least one ranking field");
|
|
619
620
|
const partitionList = Array.isArray(topN.partitionBy) ? topN.partitionBy : [topN.partitionBy];
|
|
620
|
-
const validKeys = new Set([
|
|
621
|
+
const validKeys = /* @__PURE__ */ new Set([
|
|
621
622
|
...groupCols,
|
|
622
623
|
...bucketAliases,
|
|
623
624
|
...Object.keys(measures)
|
|
@@ -913,6 +914,30 @@ function buildKeysetPredicate(sort, cursor) {
|
|
|
913
914
|
return { $match: branches.length === 1 ? branches[0] : { $or: branches } };
|
|
914
915
|
}
|
|
915
916
|
//#endregion
|
|
917
|
+
//#region src/actions/archive.ts
|
|
918
|
+
/**
|
|
919
|
+
* Build an `ArchivePort` bound to a repository + a pre-compiled Mongo
|
|
920
|
+
* filter (the repository compiles Filter IR before calling this).
|
|
921
|
+
*/
|
|
922
|
+
function createMongoArchivePort(repo, filter, session) {
|
|
923
|
+
return {
|
|
924
|
+
async readChunk(limit) {
|
|
925
|
+
return await repo.Model.find(filter).sort({ _id: 1 }).limit(limit).session(session ?? null).lean().exec();
|
|
926
|
+
},
|
|
927
|
+
async deleteChunk(docs) {
|
|
928
|
+
const ids = docs.map((doc) => doc._id);
|
|
929
|
+
return (await repo.deleteMany({
|
|
930
|
+
...filter,
|
|
931
|
+
_id: { $in: ids }
|
|
932
|
+
}, {
|
|
933
|
+
session,
|
|
934
|
+
bypassTenant: true,
|
|
935
|
+
mode: "hard"
|
|
936
|
+
})).deletedCount;
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
//#endregion
|
|
916
941
|
//#region src/query/AggregationBuilder.ts
|
|
917
942
|
/**
|
|
918
943
|
* Normalize SortSpec to MongoDB's strict format (1 | -1)
|
|
@@ -1831,7 +1856,7 @@ var Repository = class extends RepositoryBase {
|
|
|
1831
1856
|
_wireStrictQueryStripDiagnostic(enabled) {
|
|
1832
1857
|
if (!enabled) return;
|
|
1833
1858
|
const schema = this.Model.schema;
|
|
1834
|
-
const declaredPaths = new Set(["_id"]);
|
|
1859
|
+
const declaredPaths = /* @__PURE__ */ new Set(["_id"]);
|
|
1835
1860
|
for (const path of Object.keys(schema.paths)) {
|
|
1836
1861
|
declaredPaths.add(path);
|
|
1837
1862
|
const segments = path.split(".");
|
|
@@ -2090,6 +2115,40 @@ var Repository = class extends RepositoryBase {
|
|
|
2090
2115
|
});
|
|
2091
2116
|
}
|
|
2092
2117
|
/**
|
|
2118
|
+
* Batch point-read — the `$in` counterpart to {@link getById}. Fetches every
|
|
2119
|
+
* doc whose id (the repo's `idField`, default `_id`) is in `ids` in ONE query,
|
|
2120
|
+
* routed through `findAll` so tenant + soft-delete scoping and hooks apply
|
|
2121
|
+
* exactly as a single read would. Returns a Map keyed by the stringified id;
|
|
2122
|
+
* ids are de-duplicated and ids with no matching doc are simply absent (no
|
|
2123
|
+
* throw — mirrors `getById(..., { throwOnNotFound: false })`). Structurally
|
|
2124
|
+
* INVALID ids (wrong ObjectId shape, non-numeric for a Number id field, ...)
|
|
2125
|
+
* are dropped the same way — an id that cannot exist in the collection is
|
|
2126
|
+
* unambiguously a miss, and one malformed id must never poison the rest of
|
|
2127
|
+
* the batch. Lean by default (findAll semantics) — pass `{ lean: false }`
|
|
2128
|
+
* for hydrated docs.
|
|
2129
|
+
*
|
|
2130
|
+
* The N+1 killer for hosts that resolve many ids in one tick (order-line
|
|
2131
|
+
* snapshotting, availability matrices, dashboard row enrichment):
|
|
2132
|
+
*
|
|
2133
|
+
* @example
|
|
2134
|
+
* const byId = await repo.getByIds(offerIds, ctx);
|
|
2135
|
+
* const doc = byId.get(offerId); // O(1); undefined if not found
|
|
2136
|
+
*
|
|
2137
|
+
* Sizing: there is no library-imposed cap — the bounds are MongoDB's (the
|
|
2138
|
+
* `$in` array must fit the 16 MB query document; `$in` on an indexed field
|
|
2139
|
+
* is n point-lookups). Keep batches ≤ ~10k ids per call and chunk beyond
|
|
2140
|
+
* that. Don't exclude the id field via `select` — result keys derive from
|
|
2141
|
+
* it.
|
|
2142
|
+
*/
|
|
2143
|
+
async getByIds(ids, options = {}) {
|
|
2144
|
+
const field = options.idField ?? this.idField;
|
|
2145
|
+
const idType = getSchemaIdType(this.Model.schema, field);
|
|
2146
|
+
const unique = [...new Set(ids.map((id) => String(id)))].filter((id) => isValidIdForType(id, idType));
|
|
2147
|
+
if (unique.length === 0) return /* @__PURE__ */ new Map();
|
|
2148
|
+
const docs = await this.findAll({ [field]: { $in: unique } }, options);
|
|
2149
|
+
return new Map(docs.map((d) => [String(d[field]), d]));
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2093
2152
|
* Get single document by query
|
|
2094
2153
|
*/
|
|
2095
2154
|
async getByQuery(query, options = {}) {
|
|
@@ -3246,6 +3305,21 @@ var Repository = class extends RepositoryBase {
|
|
|
3246
3305
|
return runChunkedPurge(strategy, options, createMongoPurgePort(this, field, value, options.session));
|
|
3247
3306
|
}
|
|
3248
3307
|
/**
|
|
3308
|
+
* Chunked cold-storage extraction — see `StandardRepo.archiveByFilter`
|
|
3309
|
+
* for the cross-kit contract (write-before-delete, at-least-once,
|
|
3310
|
+
* duplicate-tolerant sinks). Mongokit composes the kit-agnostic
|
|
3311
|
+
* `runChunkedArchive` orchestrator with `createMongoArchivePort`
|
|
3312
|
+
* (`_id`-ordered lean reads + plugin-routed deleteMany removal). See
|
|
3313
|
+
* [actions/archive.ts](./actions/archive.ts) for the port.
|
|
3314
|
+
*
|
|
3315
|
+
* Accepts Filter IR or a Mongo-shaped filter record — compiled once via
|
|
3316
|
+
* `compileFilterToMongo`, same dual-dialect rule as every other verb.
|
|
3317
|
+
*/
|
|
3318
|
+
async archiveByFilter(filter, sink, options = {}) {
|
|
3319
|
+
const compiled = compileFilterToMongo(filter);
|
|
3320
|
+
return runChunkedArchive(options, sink, createMongoArchivePort(this, compiled, options.session));
|
|
3321
|
+
}
|
|
3322
|
+
/**
|
|
3249
3323
|
* Kit-native aggregation pipeline — takes a MongoDB stage array and
|
|
3250
3324
|
* returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
|
|
3251
3325
|
* `$facet`, `$graphLookup`, and any other mongo-specific power
|
|
@@ -3614,9 +3688,9 @@ var Repository = class extends RepositoryBase {
|
|
|
3614
3688
|
};
|
|
3615
3689
|
if (isKeyset && sort) {
|
|
3616
3690
|
const parsedSort = this._parseSort(sort);
|
|
3617
|
-
const { validateKeysetSort } = await import("./
|
|
3618
|
-
const { encodeCursor, resolveCursorFilter } = await import("./
|
|
3619
|
-
const { getPrimaryField } = await import("./
|
|
3691
|
+
const { validateKeysetSort } = await import("./PaginationEngine-BT1ywu9k.mjs").then((n) => n.n);
|
|
3692
|
+
const { encodeCursor, resolveCursorFilter } = await import("./PaginationEngine-BT1ywu9k.mjs").then((n) => n.r);
|
|
3693
|
+
const { getPrimaryField } = await import("./PaginationEngine-BT1ywu9k.mjs").then((n) => n.n);
|
|
3620
3694
|
const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
|
|
3621
3695
|
const cursorVersion = this._pagination.config.cursorVersion ?? 1;
|
|
3622
3696
|
const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
|
package/dist/actions/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as create_d_exports, i as delete_d_exports, n as update_d_exports, o as aggregate_d_exports, r as read_d_exports } from "../index-
|
|
1
|
+
import { a as create_d_exports, i as delete_d_exports, n as update_d_exports, o as aggregate_d_exports, r as read_d_exports } from "../index-Cg3SWugF.mjs";
|
|
2
2
|
export { aggregate_d_exports as aggregate, create_d_exports as create, delete_d_exports as deleteActions, read_d_exports as read, update_d_exports as update };
|