@lossless.org/client 1.13.0 → 1.15.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/nosqldb/classes.collection.d.ts +4 -1
- package/dist_ts/nosqldb/classes.collection.js +7 -2
- package/dist_ts/nosqldb/classes.doc.d.ts +88 -30
- package/dist_ts/nosqldb/classes.doc.js +114 -36
- package/dist_ts/nosqldb/classes.queryplaninspection.d.ts +65 -0
- package/dist_ts/nosqldb/classes.queryplaninspection.js +148 -0
- package/dist_ts/nosqldb/index.d.ts +1 -0
- package/dist_ts/nosqldb/index.js +1 -1
- package/package.json +3 -3
- package/readme.md +6 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.collection.ts +13 -2
- package/ts/nosqldb/classes.doc.ts +257 -70
- package/ts/nosqldb/classes.queryplaninspection.ts +220 -0
- package/ts/nosqldb/index.ts +3 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { SmartdataDb } from './classes.db.js';
|
|
3
|
+
import type { MongoFilter, TSmartdataProjection, TSmartdataSort } from './classes.doc.js';
|
|
4
|
+
import { SmartdataPersistenceError, createSmartdataTimeoutError, isMongoDeadlineError } from './classes.persistence.js';
|
|
5
|
+
import { requireOperationSignal, requireOperationTimeoutMS } from './classes.operationdeadline.js';
|
|
6
|
+
import { runCursorOperation } from './classes.cursoroperation.js';
|
|
7
|
+
import { collectionTopologyDbInspectionSymbol } from './classes.collectiontopology.js';
|
|
8
|
+
|
|
9
|
+
export interface ISmartdataQueryPlanOptions<T> {
|
|
10
|
+
/** Validated exactly like the filter of the model's ordinary reads. */
|
|
11
|
+
filter: MongoFilter<T>;
|
|
12
|
+
/** Declared paths or `_id`, each 1 or -1, as for `getCursor()` and `getOffsetPage()`. */
|
|
13
|
+
sort?: TSmartdataSort<T>;
|
|
14
|
+
/** Declared paths or `_id`, 0 or 1, as for `getCursor()` and `getOffsetPage()`. */
|
|
15
|
+
projection?: TSmartdataProjection<T>;
|
|
16
|
+
/** 1..10000. Without it the plan executes over every match. */
|
|
17
|
+
limit?: number;
|
|
18
|
+
/** Client deadline for the explained execution. Default 5000ms, maximum 120000ms. */
|
|
19
|
+
timeoutMS?: number;
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One stage of the winning plan, in depth-first order from the root. */
|
|
24
|
+
export interface ISmartdataQueryPlanStage {
|
|
25
|
+
/** The engine's stage name, such as `IXSCAN`, `COLLSCAN`, `FETCH`, `SORT` or `LIMIT`. */
|
|
26
|
+
readonly stage: string;
|
|
27
|
+
/** 0 for the root; a stage's inputs follow it at one level deeper. */
|
|
28
|
+
readonly depth: number;
|
|
29
|
+
/** The index the stage reads, when the engine names one. */
|
|
30
|
+
readonly indexName?: string;
|
|
31
|
+
/** The scan direction, when the engine states one. */
|
|
32
|
+
readonly direction?: 'forward' | 'backward';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ISmartdataQueryPlan {
|
|
36
|
+
/** The winning plan's stage tree, flattened depth-first. */
|
|
37
|
+
readonly stages: readonly ISmartdataQueryPlanStage[];
|
|
38
|
+
readonly nReturned: number;
|
|
39
|
+
readonly totalKeysExamined: number;
|
|
40
|
+
readonly totalDocsExamined: number;
|
|
41
|
+
/** Present when the engine reports it. */
|
|
42
|
+
readonly executionTimeMillis?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @internal The query-plan options after their bounds are checked. */
|
|
46
|
+
export interface IValidatedQueryPlanOptions<T> {
|
|
47
|
+
readonly filter: MongoFilter<T>;
|
|
48
|
+
readonly sort: TSmartdataSort<T> | undefined;
|
|
49
|
+
readonly projection: TSmartdataProjection<T> | undefined;
|
|
50
|
+
readonly limit: number | undefined;
|
|
51
|
+
readonly timeoutMS: number;
|
|
52
|
+
readonly signal: AbortSignal | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @internal The normalized query one inspection explains. */
|
|
56
|
+
export interface IQueryPlanRequest {
|
|
57
|
+
readonly collectionName: string;
|
|
58
|
+
readonly filter: plugins.mongodb.Filter<plugins.mongodb.Document>;
|
|
59
|
+
readonly sort: plugins.mongodb.Sort | undefined;
|
|
60
|
+
readonly projection: plugins.mongodb.Document | undefined;
|
|
61
|
+
readonly limit: number | undefined;
|
|
62
|
+
readonly timeoutMS: number;
|
|
63
|
+
readonly signal: AbortSignal | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const queryPlanOptionKeys: readonly string[] = ['filter', 'sort', 'projection', 'limit', 'timeoutMS', 'signal'];
|
|
67
|
+
const maximumPlanStages = 256;
|
|
68
|
+
const maximumPlanDepth = 64;
|
|
69
|
+
const maximumPlanStringLength = 1024;
|
|
70
|
+
/** MongoDB's `CommandNotFound`, the answer of an engine that implements no `explain`. */
|
|
71
|
+
const commandNotFoundCode = 59;
|
|
72
|
+
|
|
73
|
+
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> =>
|
|
74
|
+
typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
|
75
|
+
|
|
76
|
+
const isPlainRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
|
|
77
|
+
if (!isRecord(valueArg)) return false;
|
|
78
|
+
const prototype = Object.getPrototypeOf(valueArg);
|
|
79
|
+
return prototype === Object.prototype || prototype === null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** @internal Refuses every option shape the query-plan inspection does not define. */
|
|
83
|
+
export const requireQueryPlanOptions = <T>(
|
|
84
|
+
optionsArg: ISmartdataQueryPlanOptions<T>,
|
|
85
|
+
): IValidatedQueryPlanOptions<T> => {
|
|
86
|
+
const options: unknown = optionsArg;
|
|
87
|
+
if (!isPlainRecord(options) || Reflect.ownKeys(options).some((keyArg) =>
|
|
88
|
+
typeof keyArg !== 'string' || !queryPlanOptionKeys.includes(keyArg) ||
|
|
89
|
+
!('value' in Object.getOwnPropertyDescriptor(options, keyArg)!))) {
|
|
90
|
+
throw new SmartdataPersistenceError('invalid_argument', 'Invalid query plan inspection options.');
|
|
91
|
+
}
|
|
92
|
+
if (!isPlainRecord(optionsArg.filter)) {
|
|
93
|
+
throw new SmartdataPersistenceError('invalid_argument', 'Query plan inspection requires a plain filter object.');
|
|
94
|
+
}
|
|
95
|
+
const limit: unknown = optionsArg.limit;
|
|
96
|
+
if (limit !== undefined &&
|
|
97
|
+
(typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)) {
|
|
98
|
+
throw new SmartdataPersistenceError('invalid_argument', 'limit must be an integer from 1 through 10000.');
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
filter: optionsArg.filter,
|
|
102
|
+
sort: optionsArg.sort,
|
|
103
|
+
projection: optionsArg.projection,
|
|
104
|
+
limit,
|
|
105
|
+
timeoutMS: requireOperationTimeoutMS(optionsArg.timeoutMS, 'timeoutMS') ?? 5_000,
|
|
106
|
+
signal: requireOperationSignal(optionsArg.signal, 'signal'),
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const unreadablePlan = (): SmartdataPersistenceError =>
|
|
111
|
+
new SmartdataPersistenceError('unsupported_operation', 'The engine returned an unreadable query plan.');
|
|
112
|
+
|
|
113
|
+
const readCount = (valueArg: unknown): number => {
|
|
114
|
+
if (typeof valueArg !== 'number' || !Number.isSafeInteger(valueArg) || valueArg < 0) {
|
|
115
|
+
throw unreadablePlan();
|
|
116
|
+
}
|
|
117
|
+
return valueArg;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const readPlanString = (valueArg: unknown): string => {
|
|
121
|
+
if (typeof valueArg !== 'string' || valueArg.length === 0 || valueArg.length > maximumPlanStringLength) {
|
|
122
|
+
throw unreadablePlan();
|
|
123
|
+
}
|
|
124
|
+
return valueArg;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Flattens the winning plan depth-first. MongoDB's slot-based engine wraps the
|
|
129
|
+
* classic tree as `queryPlan`; every other field of a stage stays behind.
|
|
130
|
+
*/
|
|
131
|
+
const flattenWinningPlan = (winningPlanArg: unknown): ISmartdataQueryPlanStage[] => {
|
|
132
|
+
const root = isRecord(winningPlanArg) && winningPlanArg.stage === undefined && isRecord(winningPlanArg.queryPlan)
|
|
133
|
+
? winningPlanArg.queryPlan
|
|
134
|
+
: winningPlanArg;
|
|
135
|
+
const stages: ISmartdataQueryPlanStage[] = [];
|
|
136
|
+
const visit = (nodeArg: unknown, depthArg: number): void => {
|
|
137
|
+
if (!isRecord(nodeArg) || depthArg >= maximumPlanDepth || stages.length === maximumPlanStages) {
|
|
138
|
+
throw unreadablePlan();
|
|
139
|
+
}
|
|
140
|
+
const direction = nodeArg.direction === 'forward' || nodeArg.direction === 'backward'
|
|
141
|
+
? nodeArg.direction
|
|
142
|
+
: undefined;
|
|
143
|
+
stages.push(Object.freeze({
|
|
144
|
+
stage: readPlanString(nodeArg.stage),
|
|
145
|
+
depth: depthArg,
|
|
146
|
+
...(nodeArg.indexName === undefined ? {} : { indexName: readPlanString(nodeArg.indexName) }),
|
|
147
|
+
...(direction === undefined ? {} : { direction }),
|
|
148
|
+
}));
|
|
149
|
+
const inputs = nodeArg.inputStages === undefined ? [] : nodeArg.inputStages;
|
|
150
|
+
if (!Array.isArray(inputs)) throw unreadablePlan();
|
|
151
|
+
if (nodeArg.inputStage !== undefined) visit(nodeArg.inputStage, depthArg + 1);
|
|
152
|
+
for (const input of inputs) visit(input, depthArg + 1);
|
|
153
|
+
};
|
|
154
|
+
visit(root, 0);
|
|
155
|
+
return stages;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** @internal Reduces an `executionStats` explanation to the stable summary; exported for its unit test. */
|
|
159
|
+
export const summarizeQueryPlanExplanation = (explanationArg: plugins.mongodb.Document): ISmartdataQueryPlan => {
|
|
160
|
+
const queryPlanner = explanationArg.queryPlanner;
|
|
161
|
+
const executionStats = explanationArg.executionStats;
|
|
162
|
+
if (!isRecord(queryPlanner) || !isRecord(executionStats) || executionStats.executionSuccess === false) {
|
|
163
|
+
throw unreadablePlan();
|
|
164
|
+
}
|
|
165
|
+
const stages = flattenWinningPlan(queryPlanner.winningPlan);
|
|
166
|
+
const executionTimeMillis = executionStats.executionTimeMillis === undefined
|
|
167
|
+
? undefined
|
|
168
|
+
: readCount(executionStats.executionTimeMillis);
|
|
169
|
+
return Object.freeze({
|
|
170
|
+
stages: Object.freeze(stages),
|
|
171
|
+
nReturned: readCount(executionStats.nReturned),
|
|
172
|
+
totalKeysExamined: readCount(executionStats.totalKeysExamined),
|
|
173
|
+
totalDocsExamined: readCount(executionStats.totalDocsExamined),
|
|
174
|
+
...(executionTimeMillis === undefined ? {} : { executionTimeMillis }),
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @internal Explains one model query with `executionStats` on the given
|
|
180
|
+
* database. Nothing is created, bound or hydrated; the raw explanation never
|
|
181
|
+
* leaves this module.
|
|
182
|
+
*/
|
|
183
|
+
export const inspectQueryPlan = async (
|
|
184
|
+
dbArg: SmartdataDb,
|
|
185
|
+
requestArg: IQueryPlanRequest,
|
|
186
|
+
): Promise<ISmartdataQueryPlan> => {
|
|
187
|
+
const { signal } = requestArg;
|
|
188
|
+
signal?.throwIfAborted();
|
|
189
|
+
let failure: { readonly error: unknown } | undefined;
|
|
190
|
+
let explanation: plugins.mongodb.Document;
|
|
191
|
+
try {
|
|
192
|
+
explanation = await dbArg[collectionTopologyDbInspectionSymbol](async (mongoDbArg) => {
|
|
193
|
+
const cursor = mongoDbArg.collection(requestArg.collectionName).find(requestArg.filter, {
|
|
194
|
+
...(requestArg.sort === undefined ? {} : { sort: requestArg.sort }),
|
|
195
|
+
...(requestArg.projection === undefined ? {} : { projection: requestArg.projection }),
|
|
196
|
+
...(requestArg.limit === undefined ? {} : { limit: requestArg.limit }),
|
|
197
|
+
...(signal === undefined ? {} : { signal }),
|
|
198
|
+
});
|
|
199
|
+
try {
|
|
200
|
+
return await runCursorOperation(cursor, (cursorArg) =>
|
|
201
|
+
cursorArg.explain('executionStats', { timeoutMS: requestArg.timeoutMS }), signal);
|
|
202
|
+
} catch (errorArg) {
|
|
203
|
+
failure = { error: errorArg };
|
|
204
|
+
throw errorArg;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
} catch (errorArg) {
|
|
208
|
+
signal?.throwIfAborted();
|
|
209
|
+
if (failure !== undefined && isMongoDeadlineError(failure.error)) {
|
|
210
|
+
throw createSmartdataTimeoutError('Query plan inspection exceeded its client deadline.', failure.error);
|
|
211
|
+
}
|
|
212
|
+
if (failure !== undefined && failure.error instanceof plugins.mongodb.MongoServerError &&
|
|
213
|
+
failure.error.code === commandNotFoundCode) {
|
|
214
|
+
throw new SmartdataPersistenceError('unsupported_operation',
|
|
215
|
+
'The connected engine does not explain queries.');
|
|
216
|
+
}
|
|
217
|
+
throw errorArg;
|
|
218
|
+
}
|
|
219
|
+
return summarizeQueryPlanExplanation(explanation);
|
|
220
|
+
};
|
package/ts/nosqldb/index.ts
CHANGED
|
@@ -45,6 +45,9 @@ export type {
|
|
|
45
45
|
ISmartdataStoredDocumentEvidence, ISmartdataStoredEvidenceInspection,
|
|
46
46
|
TSmartdataOrdinaryDocumentEvidence,
|
|
47
47
|
} from './classes.storedinspection.js';
|
|
48
|
+
export type {
|
|
49
|
+
ISmartdataQueryPlanOptions, ISmartdataQueryPlan, ISmartdataQueryPlanStage,
|
|
50
|
+
} from './classes.queryplaninspection.js';
|
|
48
51
|
export { SmartdataPersistenceError } from './classes.persistence.js';
|
|
49
52
|
export type { TSmartdataPersistenceErrorCode } from './classes.persistence.js';
|
|
50
53
|
export * from './classes.exactpersistence.js';
|