@classytic/mongokit 3.39.0 → 3.40.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.md +71 -0
- package/dist/{PaginationEngine-D-P19Y9m.d.mts → PaginationEngine-CRHyuUtw.d.mts} +17 -3
- package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-CfT2Cxkj.mjs} +320 -145
- package/dist/{Repository-D7fDpyFx.mjs → Repository-CYhSC9ut.mjs} +844 -794
- package/dist/{Repository-IxI983_m.d.mts → Repository-pnLw1rG5.d.mts} +113 -47
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +1 -1
- package/dist/adapter/index.mjs +1 -1
- package/dist/{adapter-BrVfPFSW.mjs → adapter-CkMoMF83.mjs} +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/better-auth/index.mjs +2 -2
- package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
- package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BYyWzLbt.mjs} +2 -2
- package/dist/idempotency/index.d.mts +23 -0
- package/dist/idempotency/index.mjs +134 -0
- package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
- package/dist/index.d.mts +105 -39
- package/dist/index.mjs +332 -676
- package/dist/indexes/verify-indexes.d.mts +14 -1
- package/dist/indexes/verify-indexes.mjs +54 -7
- package/dist/join-scope-DSAznE8E.mjs +168 -0
- package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
- package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +19 -3
- package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
- package/dist/outbox/index.d.mts +1 -1
- package/dist/outbox/index.mjs +6 -4
- package/dist/pagination/PaginationEngine.d.mts +2 -2
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/pagination/utils/limits.d.mts +1 -1
- package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
- package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-CRhIvSzx.d.mts} +16 -2
- package/dist/plugins/index.d.mts +8 -3
- package/dist/plugins/index.mjs +98 -25
- package/dist/query-defaults-BxUcSOoF.mjs +208 -0
- package/dist/read-model/index.d.mts +66 -0
- package/dist/read-model/index.mjs +193 -0
- package/dist/{repository-ZbLtL7Nk.d.mts → repository-DNHfjAZX.d.mts} +36 -4
- package/dist/sync/index.d.mts +53 -0
- package/dist/sync/index.mjs +371 -0
- package/dist/tenant/index.mjs +2 -0
- package/dist/testkit/index.d.mts +40 -3
- package/dist/testkit/index.mjs +113 -2
- package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-zfdLYZiO.mjs} +135 -32
- package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
- package/dist/transaction-BrRYtH_b.mjs +655 -0
- package/dist/{update-c_01A0if.mjs → update-WgeAUJ_I.mjs} +212 -33
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +1 -1
- package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-CHUHWMtk.mjs} +204 -279
- package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +27 -5
- package/package.json +18 -4
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { i as withTransaction } from "../transaction-BrRYtH_b.mjs";
|
|
2
|
+
import { Schema } from "mongoose";
|
|
3
|
+
//#region src/sync/index.ts
|
|
4
|
+
function createChangeLogModels(connection, options = {}) {
|
|
5
|
+
const { collection = "sync_changes", modelName = "SyncChange" } = options;
|
|
6
|
+
const entriesName = modelName;
|
|
7
|
+
const counterName = `${modelName}Counter`;
|
|
8
|
+
return {
|
|
9
|
+
entries: connection.models[entriesName] ?? connection.model(entriesName, (() => {
|
|
10
|
+
const schema = new Schema({
|
|
11
|
+
seq: {
|
|
12
|
+
type: Number,
|
|
13
|
+
required: true
|
|
14
|
+
},
|
|
15
|
+
scope: {
|
|
16
|
+
type: String,
|
|
17
|
+
required: true
|
|
18
|
+
},
|
|
19
|
+
docId: {
|
|
20
|
+
type: String,
|
|
21
|
+
required: true
|
|
22
|
+
},
|
|
23
|
+
op: {
|
|
24
|
+
type: String,
|
|
25
|
+
enum: ["upsert", "delete"],
|
|
26
|
+
required: true
|
|
27
|
+
},
|
|
28
|
+
version: {
|
|
29
|
+
type: Number,
|
|
30
|
+
required: true
|
|
31
|
+
},
|
|
32
|
+
doc: { type: Schema.Types.Mixed },
|
|
33
|
+
tenantId: { type: String },
|
|
34
|
+
at: {
|
|
35
|
+
type: Date,
|
|
36
|
+
required: true
|
|
37
|
+
}
|
|
38
|
+
}, {
|
|
39
|
+
collection,
|
|
40
|
+
versionKey: false,
|
|
41
|
+
minimize: false
|
|
42
|
+
});
|
|
43
|
+
schema.index({ seq: 1 }, {
|
|
44
|
+
unique: true,
|
|
45
|
+
name: "feed_order"
|
|
46
|
+
});
|
|
47
|
+
schema.index({
|
|
48
|
+
tenantId: 1,
|
|
49
|
+
scope: 1,
|
|
50
|
+
seq: 1
|
|
51
|
+
}, { name: "tenant_scope_feed" });
|
|
52
|
+
schema.index({
|
|
53
|
+
scope: 1,
|
|
54
|
+
docId: 1,
|
|
55
|
+
seq: 1
|
|
56
|
+
}, { name: "doc_versions" });
|
|
57
|
+
return schema;
|
|
58
|
+
})()),
|
|
59
|
+
counter: connection.models[counterName] ?? connection.model(counterName, new Schema({
|
|
60
|
+
_id: {
|
|
61
|
+
type: String,
|
|
62
|
+
required: true
|
|
63
|
+
},
|
|
64
|
+
seq: {
|
|
65
|
+
type: Number,
|
|
66
|
+
required: true
|
|
67
|
+
}
|
|
68
|
+
}, {
|
|
69
|
+
collection: `${collection}_counter`,
|
|
70
|
+
versionKey: false
|
|
71
|
+
})),
|
|
72
|
+
feed: collection
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Fixed-width, so the opaque cursor also sorts as text. */
|
|
76
|
+
const toCursor = (seq) => String(seq).padStart(16, "0");
|
|
77
|
+
const fromCursor = (cursor) => {
|
|
78
|
+
if (cursor === "") return 0;
|
|
79
|
+
const seq = Number(cursor);
|
|
80
|
+
if (!Number.isSafeInteger(seq) || seq < 0) throw new Error(`[mongokit:sync] "${cursor}" is not a cursor from this feed`);
|
|
81
|
+
return seq;
|
|
82
|
+
};
|
|
83
|
+
const DEFAULT_LIMIT = 500;
|
|
84
|
+
function createChangeLogStore(models) {
|
|
85
|
+
const { entries, counter, feed } = models;
|
|
86
|
+
const sessionOf = (options) => options?.session ? { session: options.session } : {};
|
|
87
|
+
const filterOf = (after, options = {}) => {
|
|
88
|
+
const { tenantId, scopes, sharedScopes } = options;
|
|
89
|
+
const tenant = tenantId === void 0 ? {} : sharedScopes?.length ? { $or: [{ tenantId }, { scope: { $in: [...sharedScopes] } }] } : { tenantId };
|
|
90
|
+
return {
|
|
91
|
+
seq: { $gt: after },
|
|
92
|
+
...tenant,
|
|
93
|
+
...scopes ? { scope: { $in: [...scopes] } } : {}
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
const toEntry = (row) => ({
|
|
97
|
+
scope: row.scope,
|
|
98
|
+
docId: row.docId,
|
|
99
|
+
op: row.op,
|
|
100
|
+
version: row.version,
|
|
101
|
+
...row.doc !== void 0 && row.doc !== null ? { doc: row.doc } : {},
|
|
102
|
+
...typeof row.tenantId === "string" ? { tenantId: row.tenantId } : {},
|
|
103
|
+
at: row.at,
|
|
104
|
+
cursor: toCursor(row.seq)
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
async append(entry, options) {
|
|
108
|
+
const session = sessionOf(options);
|
|
109
|
+
const seq = (await counter.findOneAndUpdate({ _id: feed }, { $inc: { seq: 1 } }, {
|
|
110
|
+
upsert: true,
|
|
111
|
+
returnDocument: "after",
|
|
112
|
+
lean: true,
|
|
113
|
+
...session
|
|
114
|
+
}).exec()).seq;
|
|
115
|
+
const at = /* @__PURE__ */ new Date();
|
|
116
|
+
await entries.create([{
|
|
117
|
+
seq,
|
|
118
|
+
scope: entry.scope,
|
|
119
|
+
docId: entry.docId,
|
|
120
|
+
op: entry.op,
|
|
121
|
+
version: entry.version,
|
|
122
|
+
...entry.op === "upsert" && entry.doc !== void 0 ? { doc: entry.doc } : {},
|
|
123
|
+
...entry.tenantId !== void 0 ? { tenantId: entry.tenantId } : {},
|
|
124
|
+
at
|
|
125
|
+
}], session);
|
|
126
|
+
return {
|
|
127
|
+
...entry,
|
|
128
|
+
at,
|
|
129
|
+
cursor: toCursor(seq)
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
async since(cursor, options = {}) {
|
|
133
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
134
|
+
const rows = await entries.find(filterOf(fromCursor(cursor), options)).sort({ seq: 1 }).limit(limit + 1).lean().exec();
|
|
135
|
+
const page = rows.slice(0, limit).map(toEntry);
|
|
136
|
+
const last = page[page.length - 1];
|
|
137
|
+
return {
|
|
138
|
+
changes: page,
|
|
139
|
+
cursor: last ? last.cursor : cursor,
|
|
140
|
+
hasMore: rows.length > limit
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
async latestCursor(options = {}) {
|
|
144
|
+
const row = await entries.findOne(filterOf(0, options)).sort({ seq: -1 }).select({ seq: 1 }).lean().exec();
|
|
145
|
+
return row?.seq ? toCursor(row.seq) : "";
|
|
146
|
+
},
|
|
147
|
+
async compactSuperseded(options = {}) {
|
|
148
|
+
const batchSize = options.batchSize ?? 1e3;
|
|
149
|
+
const groups = entries.aggregate([
|
|
150
|
+
{ $group: {
|
|
151
|
+
_id: {
|
|
152
|
+
scope: "$scope",
|
|
153
|
+
docId: "$docId"
|
|
154
|
+
},
|
|
155
|
+
latest: { $max: "$seq" },
|
|
156
|
+
n: { $sum: 1 }
|
|
157
|
+
} },
|
|
158
|
+
{ $match: { n: { $gt: 1 } } },
|
|
159
|
+
{ $project: { latest: 1 } }
|
|
160
|
+
]).allowDiskUse(true).cursor({ batchSize });
|
|
161
|
+
let removed = 0;
|
|
162
|
+
let ops = [];
|
|
163
|
+
const flush = async () => {
|
|
164
|
+
if (ops.length === 0) return;
|
|
165
|
+
removed += (await entries.bulkWrite(ops, { ordered: false })).deletedCount;
|
|
166
|
+
ops = [];
|
|
167
|
+
};
|
|
168
|
+
for await (const g of groups) {
|
|
169
|
+
ops.push({ deleteMany: { filter: {
|
|
170
|
+
scope: g._id.scope,
|
|
171
|
+
docId: g._id.docId,
|
|
172
|
+
seq: { $lt: g.latest }
|
|
173
|
+
} } });
|
|
174
|
+
if (ops.length >= batchSize) await flush();
|
|
175
|
+
}
|
|
176
|
+
await flush();
|
|
177
|
+
return { removed };
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function createCommandStreamModels(connection, options = {}) {
|
|
182
|
+
const { prefix = "sync", modelName = "SyncStream" } = options;
|
|
183
|
+
const model = (name, schema) => connection.models[name] ?? connection.model(name, schema());
|
|
184
|
+
return {
|
|
185
|
+
streams: model(modelName, () => new Schema({
|
|
186
|
+
_id: {
|
|
187
|
+
type: String,
|
|
188
|
+
required: true
|
|
189
|
+
},
|
|
190
|
+
tenantId: {
|
|
191
|
+
type: String,
|
|
192
|
+
required: true
|
|
193
|
+
},
|
|
194
|
+
processed: {
|
|
195
|
+
type: Number,
|
|
196
|
+
required: true
|
|
197
|
+
}
|
|
198
|
+
}, {
|
|
199
|
+
collection: `${prefix}_streams`,
|
|
200
|
+
versionKey: false
|
|
201
|
+
})),
|
|
202
|
+
verdicts: model(`${modelName}Verdict`, () => {
|
|
203
|
+
const schema = new Schema({
|
|
204
|
+
_id: {
|
|
205
|
+
type: String,
|
|
206
|
+
required: true
|
|
207
|
+
},
|
|
208
|
+
stream: {
|
|
209
|
+
type: String,
|
|
210
|
+
required: true
|
|
211
|
+
},
|
|
212
|
+
seq: {
|
|
213
|
+
type: Number,
|
|
214
|
+
required: true
|
|
215
|
+
},
|
|
216
|
+
outcome: {
|
|
217
|
+
type: String,
|
|
218
|
+
enum: [
|
|
219
|
+
"applied",
|
|
220
|
+
"rejected",
|
|
221
|
+
"blocked",
|
|
222
|
+
"resolved"
|
|
223
|
+
],
|
|
224
|
+
required: true
|
|
225
|
+
},
|
|
226
|
+
code: { type: String },
|
|
227
|
+
fingerprint: {
|
|
228
|
+
type: String,
|
|
229
|
+
required: true
|
|
230
|
+
},
|
|
231
|
+
body: { type: String },
|
|
232
|
+
dependsOn: {
|
|
233
|
+
type: [String],
|
|
234
|
+
default: void 0
|
|
235
|
+
},
|
|
236
|
+
resolution: {
|
|
237
|
+
type: {
|
|
238
|
+
action: String,
|
|
239
|
+
by: String,
|
|
240
|
+
at: String,
|
|
241
|
+
_id: false
|
|
242
|
+
},
|
|
243
|
+
default: void 0
|
|
244
|
+
}
|
|
245
|
+
}, {
|
|
246
|
+
collection: `${prefix}_verdicts`,
|
|
247
|
+
versionKey: false
|
|
248
|
+
});
|
|
249
|
+
schema.index({
|
|
250
|
+
stream: 1,
|
|
251
|
+
seq: 1
|
|
252
|
+
}, {
|
|
253
|
+
unique: true,
|
|
254
|
+
name: "stream_seq"
|
|
255
|
+
});
|
|
256
|
+
schema.index({
|
|
257
|
+
dependsOn: 1,
|
|
258
|
+
outcome: 1
|
|
259
|
+
}, {
|
|
260
|
+
name: "blocked_on",
|
|
261
|
+
partialFilterExpression: { outcome: "blocked" }
|
|
262
|
+
});
|
|
263
|
+
return schema;
|
|
264
|
+
}),
|
|
265
|
+
aliases: model(`${modelName}Alias`, () => {
|
|
266
|
+
const schema = new Schema({
|
|
267
|
+
tenantId: {
|
|
268
|
+
type: String,
|
|
269
|
+
required: true
|
|
270
|
+
},
|
|
271
|
+
localId: {
|
|
272
|
+
type: String,
|
|
273
|
+
required: true
|
|
274
|
+
},
|
|
275
|
+
serverId: {
|
|
276
|
+
type: String,
|
|
277
|
+
required: true
|
|
278
|
+
}
|
|
279
|
+
}, {
|
|
280
|
+
collection: `${prefix}_aliases`,
|
|
281
|
+
versionKey: false
|
|
282
|
+
});
|
|
283
|
+
schema.index({
|
|
284
|
+
tenantId: 1,
|
|
285
|
+
localId: 1
|
|
286
|
+
}, {
|
|
287
|
+
unique: true,
|
|
288
|
+
name: "tenant_local"
|
|
289
|
+
});
|
|
290
|
+
return schema;
|
|
291
|
+
})
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The mongo {@link CommandStreamStore} (replica set). Proven by `runCommandStreamStoreConformance`.
|
|
296
|
+
* Concurrent transactions on one stream write-conflict on its document and `withTransaction`
|
|
297
|
+
* retries the loser, which then reads the winner's watermark.
|
|
298
|
+
*/
|
|
299
|
+
function createCommandStreamStore(connection, models, changes) {
|
|
300
|
+
const { streams, verdicts, aliases } = models;
|
|
301
|
+
const toVerdict = ({ _id, code, ...rest }) => ({
|
|
302
|
+
commandId: _id,
|
|
303
|
+
...rest,
|
|
304
|
+
...code == null ? {} : { code }
|
|
305
|
+
});
|
|
306
|
+
return {
|
|
307
|
+
changes,
|
|
308
|
+
transaction: (work) => withTransaction(connection, (session) => work({ session })),
|
|
309
|
+
async stream({ session }, id) {
|
|
310
|
+
const row = await streams.findById(id).session(session).lean().exec();
|
|
311
|
+
return row ? {
|
|
312
|
+
tenantId: row.tenantId,
|
|
313
|
+
processed: row.processed
|
|
314
|
+
} : null;
|
|
315
|
+
},
|
|
316
|
+
async advance({ session }, id, tenantId, from, to) {
|
|
317
|
+
if (from === 0) {
|
|
318
|
+
if (await streams.exists({ _id: id }).session(session)) return false;
|
|
319
|
+
await streams.create([{
|
|
320
|
+
_id: id,
|
|
321
|
+
tenantId,
|
|
322
|
+
processed: to
|
|
323
|
+
}], { session });
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
return (await streams.updateOne({
|
|
327
|
+
_id: id,
|
|
328
|
+
processed: from
|
|
329
|
+
}, { $set: { processed: to } }, { session }).exec()).modifiedCount === 1;
|
|
330
|
+
},
|
|
331
|
+
async verdict({ session }, commandId) {
|
|
332
|
+
const row = await verdicts.findById(commandId).session(session).lean().exec();
|
|
333
|
+
return row ? toVerdict(row) : null;
|
|
334
|
+
},
|
|
335
|
+
async record({ session }, { commandId, ...rest }) {
|
|
336
|
+
await verdicts.replaceOne({ _id: commandId }, rest, {
|
|
337
|
+
upsert: true,
|
|
338
|
+
session
|
|
339
|
+
}).exec();
|
|
340
|
+
},
|
|
341
|
+
async blockedOn({ session }, commandId) {
|
|
342
|
+
return (await verdicts.find({
|
|
343
|
+
outcome: "blocked",
|
|
344
|
+
dependsOn: commandId
|
|
345
|
+
}).sort({ seq: 1 }).session(session).lean().exec()).map(toVerdict);
|
|
346
|
+
},
|
|
347
|
+
async alias({ session }, { tenantId, localId, serverId }) {
|
|
348
|
+
const existing = await aliases.findOne({
|
|
349
|
+
tenantId,
|
|
350
|
+
localId
|
|
351
|
+
}).session(session).lean().exec();
|
|
352
|
+
if (existing) {
|
|
353
|
+
if (existing.serverId !== serverId) throw new Error(`[mongokit:sync] "${localId}" already names "${existing.serverId}"`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
await aliases.create([{
|
|
357
|
+
tenantId,
|
|
358
|
+
localId,
|
|
359
|
+
serverId
|
|
360
|
+
}], { session });
|
|
361
|
+
},
|
|
362
|
+
async aliasOf({ session }, tenantId, localId) {
|
|
363
|
+
return (await aliases.findOne({
|
|
364
|
+
tenantId,
|
|
365
|
+
localId
|
|
366
|
+
}).session(session).lean().exec())?.serverId ?? null;
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
371
|
+
export { createChangeLogModels, createChangeLogStore, createCommandStreamModels, createCommandStreamStore };
|
package/dist/tenant/index.mjs
CHANGED
|
@@ -64,6 +64,8 @@ function declareIndexes(schema, tenant, declarations) {
|
|
|
64
64
|
*/
|
|
65
65
|
function injectTenantField(schema, tenant, options = {}) {
|
|
66
66
|
for (const declaration of options.indexes ?? []) assertIndexDeclaration(declaration, tenant);
|
|
67
|
+
const declaredNames = new Set(indexEntries(schema).map(([, o]) => o?.name).filter((n) => typeof n === "string"));
|
|
68
|
+
for (const name of options.skipIndexes ?? []) if (!declaredNames.has(name)) throw new Error(`[mongokit/tenant] skipIndexes names '${name}', but no index on this schema has that name — rename the exemption with the index, or the index silently gets the tenant prefix`);
|
|
67
69
|
const isObjectId = tenant.fieldType === "objectId";
|
|
68
70
|
const scoped = isScoped(tenant);
|
|
69
71
|
schema.add({ [tenant.tenantField]: {
|
package/dist/testkit/index.d.mts
CHANGED
|
@@ -1,7 +1,44 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { n as CreateRepositoryConfig } from "../create-repository-
|
|
1
|
+
import { r as Repository } from "../Repository-pnLw1rG5.mjs";
|
|
2
|
+
import { n as CreateRepositoryConfig } from "../create-repository-BUx9vfG9.mjs";
|
|
3
3
|
import { Connection, Model, Schema } from "mongoose";
|
|
4
4
|
import { MongoMemoryReplSet, MongoMemoryServer } from "mongodb-memory-server";
|
|
5
|
+
//#region src/testkit/query-plan.d.ts
|
|
6
|
+
interface RecordedCommand {
|
|
7
|
+
name: string;
|
|
8
|
+
collection: string | undefined;
|
|
9
|
+
command: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
/** Run `fn` and return every command it started on `connection` (in start order). */
|
|
12
|
+
declare function recordCommands<T>(connection: Connection, fn: () => Promise<T>): Promise<{
|
|
13
|
+
result: T;
|
|
14
|
+
commands: RecordedCommand[];
|
|
15
|
+
}>;
|
|
16
|
+
interface QueryPlanOptions {
|
|
17
|
+
connection: Connection;
|
|
18
|
+
/** Plan stages that fail the gate. Default `['COLLSCAN', 'SORT']` (a scan, an unindexed sort). */
|
|
19
|
+
forbid?: readonly string[];
|
|
20
|
+
/** The examined collection must hold at least this many documents. */
|
|
21
|
+
minDocs: number;
|
|
22
|
+
/** Every index scan must lead with these keys (directly, or after one equality scope field). */
|
|
23
|
+
leadingKeys?: readonly string[];
|
|
24
|
+
/** Only gate commands on these collections (default: every plannable command). */
|
|
25
|
+
collections?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
interface PlannedCommand {
|
|
28
|
+
name: string;
|
|
29
|
+
collection: string;
|
|
30
|
+
stages: string[];
|
|
31
|
+
indexes: string[][];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Run `run`, explain every read it issued and throw on a forbidden stage, a wrong leading index,
|
|
35
|
+
* an undersized fixture, or no plannable command at all. Returns what each command used.
|
|
36
|
+
*/
|
|
37
|
+
declare function assertQueryPlan<T>(run: () => Promise<T>, options: QueryPlanOptions): Promise<{
|
|
38
|
+
result: T;
|
|
39
|
+
plans: PlannedCommand[];
|
|
40
|
+
}>;
|
|
41
|
+
//#endregion
|
|
5
42
|
//#region src/testkit/index.d.ts
|
|
6
43
|
interface MongoMemoryOptions {
|
|
7
44
|
/**
|
|
@@ -132,4 +169,4 @@ interface MongoMemoryBackend {
|
|
|
132
169
|
*/
|
|
133
170
|
declare function mongoMemoryBackend(options?: MongoMemoryOptions): MongoMemoryBackend;
|
|
134
171
|
//#endregion
|
|
135
|
-
export { CreateTestRepositoryOptions, MongoMemoryBackend, MongoMemoryHandle, MongoMemoryOptions, MongoTestContext, TestConnection, TestRepository, clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, withMongoMemory };
|
|
172
|
+
export { CreateTestRepositoryOptions, MongoMemoryBackend, MongoMemoryHandle, MongoMemoryOptions, MongoTestContext, type PlannedCommand, type QueryPlanOptions, type RecordedCommand, TestConnection, TestRepository, assertQueryPlan, clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, recordCommands, withMongoMemory };
|
package/dist/testkit/index.mjs
CHANGED
|
@@ -1,5 +1,116 @@
|
|
|
1
|
-
import { t as createRepository } from "../create-repository-
|
|
1
|
+
import { t as createRepository } from "../create-repository-BYyWzLbt.mjs";
|
|
2
2
|
import { createConnection } from "mongoose";
|
|
3
|
+
//#region src/testkit/query-plan.ts
|
|
4
|
+
const PLANNABLE = /* @__PURE__ */ new Set([
|
|
5
|
+
"find",
|
|
6
|
+
"aggregate",
|
|
7
|
+
"count",
|
|
8
|
+
"distinct"
|
|
9
|
+
]);
|
|
10
|
+
/** Session/transport fields the server refuses inside an `explain`. */
|
|
11
|
+
const STRIP = /* @__PURE__ */ new Set([
|
|
12
|
+
"lsid",
|
|
13
|
+
"txnNumber",
|
|
14
|
+
"autocommit",
|
|
15
|
+
"startTransaction",
|
|
16
|
+
"readConcern",
|
|
17
|
+
"maxTimeMS"
|
|
18
|
+
]);
|
|
19
|
+
function assertMonitored(connection) {
|
|
20
|
+
if (connection.getClient().options.monitorCommands !== true) throw new Error("[mongokit/testkit] the connection was not opened with { monitorCommands: true }: no command can be observed");
|
|
21
|
+
}
|
|
22
|
+
/** Run `fn` and return every command it started on `connection` (in start order). */
|
|
23
|
+
async function recordCommands(connection, fn) {
|
|
24
|
+
assertMonitored(connection);
|
|
25
|
+
const client = connection.getClient();
|
|
26
|
+
const commands = [];
|
|
27
|
+
const listener = (event) => {
|
|
28
|
+
const target = event.command[event.commandName];
|
|
29
|
+
commands.push({
|
|
30
|
+
name: event.commandName,
|
|
31
|
+
collection: typeof target === "string" ? target : void 0,
|
|
32
|
+
command: event.command
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
client.on("commandStarted", listener);
|
|
36
|
+
try {
|
|
37
|
+
return {
|
|
38
|
+
result: await fn(),
|
|
39
|
+
commands
|
|
40
|
+
};
|
|
41
|
+
} finally {
|
|
42
|
+
client.off("commandStarted", listener);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function collectPlanNodes(node, out) {
|
|
46
|
+
if (Array.isArray(node)) {
|
|
47
|
+
for (const n of node) collectPlanNodes(n, out);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!node || typeof node !== "object") return;
|
|
51
|
+
const obj = node;
|
|
52
|
+
if (typeof obj.stage === "string") out.push(obj);
|
|
53
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
54
|
+
if (key === "rejectedPlans" || key === "allPlansExecution") continue;
|
|
55
|
+
collectPlanNodes(value, out);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function explainable(command) {
|
|
59
|
+
return Object.fromEntries(Object.entries(command).filter(([k]) => !k.startsWith("$") && !STRIP.has(k)));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Run `run`, explain every read it issued and throw on a forbidden stage, a wrong leading index,
|
|
63
|
+
* an undersized fixture, or no plannable command at all. Returns what each command used.
|
|
64
|
+
*/
|
|
65
|
+
async function assertQueryPlan(run, options) {
|
|
66
|
+
const forbid = options.forbid ?? ["COLLSCAN", "SORT"];
|
|
67
|
+
const { result, commands } = await recordCommands(options.connection, run);
|
|
68
|
+
const db = options.connection.db;
|
|
69
|
+
if (!db) throw new Error("[mongokit/testkit] connection has no db handle");
|
|
70
|
+
const plannable = commands.filter((c) => PLANNABLE.has(c.name) && c.collection !== void 0 && (!options.collections || options.collections.includes(c.collection)));
|
|
71
|
+
if (plannable.length === 0) throw new Error(`[mongokit/testkit] assertQueryPlan saw no plannable command (saw: ${commands.map((c) => c.name).join(", ") || "none"})`);
|
|
72
|
+
const plans = [];
|
|
73
|
+
const failures = [];
|
|
74
|
+
for (const c of plannable) {
|
|
75
|
+
const collection = c.collection;
|
|
76
|
+
const size = await db.collection(collection).countDocuments({});
|
|
77
|
+
if (size < options.minDocs) throw new Error(`[mongokit/testkit] '${collection}' holds ${size} documents, below minDocs ${options.minDocs}: the plan would not be representative`);
|
|
78
|
+
const explained = await db.command({
|
|
79
|
+
explain: explainable(c.command),
|
|
80
|
+
verbosity: "executionStats"
|
|
81
|
+
});
|
|
82
|
+
const nodes = [];
|
|
83
|
+
collectPlanNodes(explained.queryPlanner ?? explained.stages ?? explained, nodes);
|
|
84
|
+
const stages = nodes.map((n) => n.stage);
|
|
85
|
+
const indexes = nodes.filter((n) => n.keyPattern && typeof n.keyPattern === "object").map((n) => Object.keys(n.keyPattern));
|
|
86
|
+
plans.push({
|
|
87
|
+
name: c.name,
|
|
88
|
+
collection,
|
|
89
|
+
stages,
|
|
90
|
+
indexes
|
|
91
|
+
});
|
|
92
|
+
for (const stage of forbid) if (stages.includes(stage)) failures.push(`${c.name} on '${collection}': plan contains ${stage}`);
|
|
93
|
+
if (options.leadingKeys) {
|
|
94
|
+
const want = options.leadingKeys;
|
|
95
|
+
const leads = (key, at) => want.every((k, i) => key[at + i] === k);
|
|
96
|
+
if (indexes.length === 0) failures.push(`${c.name} on '${collection}': no index used, expected (${want.join(",")})`);
|
|
97
|
+
for (const key of indexes) if (!leads(key, 0) && !leads(key, 1)) failures.push(`${c.name} on '${collection}': index (${key.join(",")}) does not lead with (${want.join(",")})`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (failures.length > 0) {
|
|
101
|
+
const error = /* @__PURE__ */ new Error(`[mongokit/testkit] query plan gate failed:\n ${failures.join("\n ")}`);
|
|
102
|
+
Object.assign(error, {
|
|
103
|
+
code: "mongokit.testkit.query_plan",
|
|
104
|
+
plans
|
|
105
|
+
});
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
result,
|
|
110
|
+
plans
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
3
114
|
//#region src/testkit/index.ts
|
|
4
115
|
function idempotentStop(server) {
|
|
5
116
|
let stopped = false;
|
|
@@ -136,4 +247,4 @@ function mongoMemoryBackend(options = {}) {
|
|
|
136
247
|
} };
|
|
137
248
|
}
|
|
138
249
|
//#endregion
|
|
139
|
-
export { clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, withMongoMemory };
|
|
250
|
+
export { assertQueryPlan, clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, recordCommands, withMongoMemory };
|