@lossless.org/client 1.13.0 → 1.14.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.
@@ -0,0 +1,65 @@
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
+ export interface ISmartdataQueryPlanOptions<T> {
5
+ /** Validated exactly like the filter of the model's ordinary reads. */
6
+ filter: MongoFilter<T>;
7
+ /** Declared paths or `_id`, each 1 or -1, as for `getCursor()` and `getOffsetPage()`. */
8
+ sort?: TSmartdataSort<T>;
9
+ /** Declared paths or `_id`, 0 or 1, as for `getCursor()` and `getOffsetPage()`. */
10
+ projection?: TSmartdataProjection<T>;
11
+ /** 1..10000. Without it the plan executes over every match. */
12
+ limit?: number;
13
+ /** Client deadline for the explained execution. Default 5000ms, maximum 120000ms. */
14
+ timeoutMS?: number;
15
+ signal?: AbortSignal;
16
+ }
17
+ /** One stage of the winning plan, in depth-first order from the root. */
18
+ export interface ISmartdataQueryPlanStage {
19
+ /** The engine's stage name, such as `IXSCAN`, `COLLSCAN`, `FETCH`, `SORT` or `LIMIT`. */
20
+ readonly stage: string;
21
+ /** 0 for the root; a stage's inputs follow it at one level deeper. */
22
+ readonly depth: number;
23
+ /** The index the stage reads, when the engine names one. */
24
+ readonly indexName?: string;
25
+ /** The scan direction, when the engine states one. */
26
+ readonly direction?: 'forward' | 'backward';
27
+ }
28
+ export interface ISmartdataQueryPlan {
29
+ /** The winning plan's stage tree, flattened depth-first. */
30
+ readonly stages: readonly ISmartdataQueryPlanStage[];
31
+ readonly nReturned: number;
32
+ readonly totalKeysExamined: number;
33
+ readonly totalDocsExamined: number;
34
+ /** Present when the engine reports it. */
35
+ readonly executionTimeMillis?: number;
36
+ }
37
+ /** @internal The query-plan options after their bounds are checked. */
38
+ export interface IValidatedQueryPlanOptions<T> {
39
+ readonly filter: MongoFilter<T>;
40
+ readonly sort: TSmartdataSort<T> | undefined;
41
+ readonly projection: TSmartdataProjection<T> | undefined;
42
+ readonly limit: number | undefined;
43
+ readonly timeoutMS: number;
44
+ readonly signal: AbortSignal | undefined;
45
+ }
46
+ /** @internal The normalized query one inspection explains. */
47
+ export interface IQueryPlanRequest {
48
+ readonly collectionName: string;
49
+ readonly filter: plugins.mongodb.Filter<plugins.mongodb.Document>;
50
+ readonly sort: plugins.mongodb.Sort | undefined;
51
+ readonly projection: plugins.mongodb.Document | undefined;
52
+ readonly limit: number | undefined;
53
+ readonly timeoutMS: number;
54
+ readonly signal: AbortSignal | undefined;
55
+ }
56
+ /** @internal Refuses every option shape the query-plan inspection does not define. */
57
+ export declare const requireQueryPlanOptions: <T>(optionsArg: ISmartdataQueryPlanOptions<T>) => IValidatedQueryPlanOptions<T>;
58
+ /** @internal Reduces an `executionStats` explanation to the stable summary; exported for its unit test. */
59
+ export declare const summarizeQueryPlanExplanation: (explanationArg: plugins.mongodb.Document) => ISmartdataQueryPlan;
60
+ /**
61
+ * @internal Explains one model query with `executionStats` on the given
62
+ * database. Nothing is created, bound or hydrated; the raw explanation never
63
+ * leaves this module.
64
+ */
65
+ export declare const inspectQueryPlan: (dbArg: SmartdataDb, requestArg: IQueryPlanRequest) => Promise<ISmartdataQueryPlan>;
@@ -0,0 +1,148 @@
1
+ import * as plugins from './plugins.js';
2
+ import { SmartdataPersistenceError, createSmartdataTimeoutError, isMongoDeadlineError } from './classes.persistence.js';
3
+ import { requireOperationSignal, requireOperationTimeoutMS } from './classes.operationdeadline.js';
4
+ import { runCursorOperation } from './classes.cursoroperation.js';
5
+ import { collectionTopologyDbInspectionSymbol } from './classes.collectiontopology.js';
6
+ const queryPlanOptionKeys = ['filter', 'sort', 'projection', 'limit', 'timeoutMS', 'signal'];
7
+ const maximumPlanStages = 256;
8
+ const maximumPlanDepth = 64;
9
+ const maximumPlanStringLength = 1024;
10
+ /** MongoDB's `CommandNotFound`, the answer of an engine that implements no `explain`. */
11
+ const commandNotFoundCode = 59;
12
+ const isRecord = (valueArg) => typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
13
+ const isPlainRecord = (valueArg) => {
14
+ if (!isRecord(valueArg))
15
+ return false;
16
+ const prototype = Object.getPrototypeOf(valueArg);
17
+ return prototype === Object.prototype || prototype === null;
18
+ };
19
+ /** @internal Refuses every option shape the query-plan inspection does not define. */
20
+ export const requireQueryPlanOptions = (optionsArg) => {
21
+ const options = optionsArg;
22
+ if (!isPlainRecord(options) || Reflect.ownKeys(options).some((keyArg) => typeof keyArg !== 'string' || !queryPlanOptionKeys.includes(keyArg) ||
23
+ !('value' in Object.getOwnPropertyDescriptor(options, keyArg)))) {
24
+ throw new SmartdataPersistenceError('invalid_argument', 'Invalid query plan inspection options.');
25
+ }
26
+ if (!isPlainRecord(optionsArg.filter)) {
27
+ throw new SmartdataPersistenceError('invalid_argument', 'Query plan inspection requires a plain filter object.');
28
+ }
29
+ const limit = optionsArg.limit;
30
+ if (limit !== undefined &&
31
+ (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)) {
32
+ throw new SmartdataPersistenceError('invalid_argument', 'limit must be an integer from 1 through 10000.');
33
+ }
34
+ return {
35
+ filter: optionsArg.filter,
36
+ sort: optionsArg.sort,
37
+ projection: optionsArg.projection,
38
+ limit,
39
+ timeoutMS: requireOperationTimeoutMS(optionsArg.timeoutMS, 'timeoutMS') ?? 5_000,
40
+ signal: requireOperationSignal(optionsArg.signal, 'signal'),
41
+ };
42
+ };
43
+ const unreadablePlan = () => new SmartdataPersistenceError('unsupported_operation', 'The engine returned an unreadable query plan.');
44
+ const readCount = (valueArg) => {
45
+ if (typeof valueArg !== 'number' || !Number.isSafeInteger(valueArg) || valueArg < 0) {
46
+ throw unreadablePlan();
47
+ }
48
+ return valueArg;
49
+ };
50
+ const readPlanString = (valueArg) => {
51
+ if (typeof valueArg !== 'string' || valueArg.length === 0 || valueArg.length > maximumPlanStringLength) {
52
+ throw unreadablePlan();
53
+ }
54
+ return valueArg;
55
+ };
56
+ /**
57
+ * Flattens the winning plan depth-first. MongoDB's slot-based engine wraps the
58
+ * classic tree as `queryPlan`; every other field of a stage stays behind.
59
+ */
60
+ const flattenWinningPlan = (winningPlanArg) => {
61
+ const root = isRecord(winningPlanArg) && winningPlanArg.stage === undefined && isRecord(winningPlanArg.queryPlan)
62
+ ? winningPlanArg.queryPlan
63
+ : winningPlanArg;
64
+ const stages = [];
65
+ const visit = (nodeArg, depthArg) => {
66
+ if (!isRecord(nodeArg) || depthArg >= maximumPlanDepth || stages.length === maximumPlanStages) {
67
+ throw unreadablePlan();
68
+ }
69
+ const direction = nodeArg.direction === 'forward' || nodeArg.direction === 'backward'
70
+ ? nodeArg.direction
71
+ : undefined;
72
+ stages.push(Object.freeze({
73
+ stage: readPlanString(nodeArg.stage),
74
+ depth: depthArg,
75
+ ...(nodeArg.indexName === undefined ? {} : { indexName: readPlanString(nodeArg.indexName) }),
76
+ ...(direction === undefined ? {} : { direction }),
77
+ }));
78
+ const inputs = nodeArg.inputStages === undefined ? [] : nodeArg.inputStages;
79
+ if (!Array.isArray(inputs))
80
+ throw unreadablePlan();
81
+ if (nodeArg.inputStage !== undefined)
82
+ visit(nodeArg.inputStage, depthArg + 1);
83
+ for (const input of inputs)
84
+ visit(input, depthArg + 1);
85
+ };
86
+ visit(root, 0);
87
+ return stages;
88
+ };
89
+ /** @internal Reduces an `executionStats` explanation to the stable summary; exported for its unit test. */
90
+ export const summarizeQueryPlanExplanation = (explanationArg) => {
91
+ const queryPlanner = explanationArg.queryPlanner;
92
+ const executionStats = explanationArg.executionStats;
93
+ if (!isRecord(queryPlanner) || !isRecord(executionStats) || executionStats.executionSuccess === false) {
94
+ throw unreadablePlan();
95
+ }
96
+ const stages = flattenWinningPlan(queryPlanner.winningPlan);
97
+ const executionTimeMillis = executionStats.executionTimeMillis === undefined
98
+ ? undefined
99
+ : readCount(executionStats.executionTimeMillis);
100
+ return Object.freeze({
101
+ stages: Object.freeze(stages),
102
+ nReturned: readCount(executionStats.nReturned),
103
+ totalKeysExamined: readCount(executionStats.totalKeysExamined),
104
+ totalDocsExamined: readCount(executionStats.totalDocsExamined),
105
+ ...(executionTimeMillis === undefined ? {} : { executionTimeMillis }),
106
+ });
107
+ };
108
+ /**
109
+ * @internal Explains one model query with `executionStats` on the given
110
+ * database. Nothing is created, bound or hydrated; the raw explanation never
111
+ * leaves this module.
112
+ */
113
+ export const inspectQueryPlan = async (dbArg, requestArg) => {
114
+ const { signal } = requestArg;
115
+ signal?.throwIfAborted();
116
+ let failure;
117
+ let explanation;
118
+ try {
119
+ explanation = await dbArg[collectionTopologyDbInspectionSymbol](async (mongoDbArg) => {
120
+ const cursor = mongoDbArg.collection(requestArg.collectionName).find(requestArg.filter, {
121
+ ...(requestArg.sort === undefined ? {} : { sort: requestArg.sort }),
122
+ ...(requestArg.projection === undefined ? {} : { projection: requestArg.projection }),
123
+ ...(requestArg.limit === undefined ? {} : { limit: requestArg.limit }),
124
+ ...(signal === undefined ? {} : { signal }),
125
+ });
126
+ try {
127
+ return await runCursorOperation(cursor, (cursorArg) => cursorArg.explain('executionStats', { timeoutMS: requestArg.timeoutMS }), signal);
128
+ }
129
+ catch (errorArg) {
130
+ failure = { error: errorArg };
131
+ throw errorArg;
132
+ }
133
+ });
134
+ }
135
+ catch (errorArg) {
136
+ signal?.throwIfAborted();
137
+ if (failure !== undefined && isMongoDeadlineError(failure.error)) {
138
+ throw createSmartdataTimeoutError('Query plan inspection exceeded its client deadline.', failure.error);
139
+ }
140
+ if (failure !== undefined && failure.error instanceof plugins.mongodb.MongoServerError &&
141
+ failure.error.code === commandNotFoundCode) {
142
+ throw new SmartdataPersistenceError('unsupported_operation', 'The connected engine does not explain queries.');
143
+ }
144
+ throw errorArg;
145
+ }
146
+ return summarizeQueryPlanExplanation(explanation);
147
+ };
148
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5xdWVyeXBsYW5pbnNwZWN0aW9uLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHMvbm9zcWxkYi9jbGFzc2VzLnF1ZXJ5cGxhbmluc3BlY3Rpb24udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLE9BQU8sTUFBTSxjQUFjLENBQUM7QUFHeEMsT0FBTyxFQUFFLHlCQUF5QixFQUFFLDJCQUEyQixFQUFFLG9CQUFvQixFQUFFLE1BQU0sMEJBQTBCLENBQUM7QUFDeEgsT0FBTyxFQUFFLHNCQUFzQixFQUFFLHlCQUF5QixFQUFFLE1BQU0sZ0NBQWdDLENBQUM7QUFDbkcsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFDbEUsT0FBTyxFQUFFLG9DQUFvQyxFQUFFLE1BQU0saUNBQWlDLENBQUM7QUEyRHZGLE1BQU0sbUJBQW1CLEdBQXNCLENBQUMsUUFBUSxFQUFFLE1BQU0sRUFBRSxZQUFZLEVBQUUsT0FBTyxFQUFFLFdBQVcsRUFBRSxRQUFRLENBQUMsQ0FBQztBQUNoSCxNQUFNLGlCQUFpQixHQUFHLEdBQUcsQ0FBQztBQUM5QixNQUFNLGdCQUFnQixHQUFHLEVBQUUsQ0FBQztBQUM1QixNQUFNLHVCQUF1QixHQUFHLElBQUksQ0FBQztBQUNyQyx5RkFBeUY7QUFDekYsTUFBTSxtQkFBbUIsR0FBRyxFQUFFLENBQUM7QUFFL0IsTUFBTSxRQUFRLEdBQUcsQ0FBQyxRQUFpQixFQUF1QyxFQUFFLENBQzFFLE9BQU8sUUFBUSxLQUFLLFFBQVEsSUFBSSxRQUFRLEtBQUssSUFBSSxJQUFJLENBQUMsS0FBSyxDQUFDLE9BQU8sQ0FBQyxRQUFRLENBQUMsQ0FBQztBQUVoRixNQUFNLGFBQWEsR0FBRyxDQUFDLFFBQWlCLEVBQXVDLEVBQUU7SUFDL0UsSUFBSSxDQUFDLFFBQVEsQ0FBQyxRQUFRLENBQUM7UUFBRSxPQUFPLEtBQUssQ0FBQztJQUN0QyxNQUFNLFNBQVMsR0FBRyxN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
@@ -16,6 +16,7 @@ export * from './classes.doc.js';
16
16
  export { ordinaryPersistence } from './classes.ordinarypersistence.js';
17
17
  export type { IOrdinaryPersistenceOptions } from './classes.ordinarypersistence.js';
18
18
  export type { ISmartdataStoredInspectionOptions, ISmartdataStoredInspection, ISmartdataStoredDocumentEvidence, ISmartdataStoredEvidenceInspection, TSmartdataOrdinaryDocumentEvidence, } from './classes.storedinspection.js';
19
+ export type { ISmartdataQueryPlanOptions, ISmartdataQueryPlan, ISmartdataQueryPlanStage, } from './classes.queryplaninspection.js';
19
20
  export { SmartdataPersistenceError } from './classes.persistence.js';
20
21
  export type { TSmartdataPersistenceErrorCode } from './classes.persistence.js';
21
22
  export * from './classes.exactpersistence.js';
@@ -16,4 +16,4 @@ export * from './classes.distributedcoordinator.js';
16
16
  export * from './classes.cached-document.js';
17
17
  import * as convenience from './classes.convenience.js';
18
18
  export { convenience };
19
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi90cy9ub3NxbGRiL2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDhFQUE4RTtBQUM5RSxPQUFPLFdBQVcsQ0FBQztBQUVuQixPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFhMUQsT0FBTyxFQUFFLGNBQWMsRUFBRSxNQUFNLGNBQWMsQ0FBQztBQUc5QyxjQUFjLGlCQUFpQixDQUFDO0FBQ2hDLGNBQWMsa0NBQWtDLENBQUM7QUFPakQsY0FBYyx5QkFBeUIsQ0FBQztBQVl4QyxjQUFjLGtCQUFrQixDQUFDO0FBQ2pDLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLGtDQUFrQyxDQUFDO0FBT3ZFLE9BQU8sRUFBRSx5QkFBeUIsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBRXJFLGNBQWMsK0JBQStCLENBQUM7QUFDOUMsY0FBYyx3QkFBd0IsQ0FBQztBQUN2QyxjQUFjLHFCQUFxQixDQUFDO0FBQ3BDLGNBQWMsc0JBQXNCLENBQUM7QUFDckMsY0FBYyxxQ0FBcUMsQ0FBQztBQUNwRCxjQUFjLDhCQUE4QixDQUFDO0FBRTdDLE9BQU8sS0FBSyxXQUFXLE1BQU0sMEJBQTBCLENBQUM7QUFFeEQsT0FBTyxFQUFFLFdBQVcsRUFBRSxDQUFDIn0=
19
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi90cy9ub3NxbGRiL2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDhFQUE4RTtBQUM5RSxPQUFPLFdBQVcsQ0FBQztBQUVuQixPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFhMUQsT0FBTyxFQUFFLGNBQWMsRUFBRSxNQUFNLGNBQWMsQ0FBQztBQUc5QyxjQUFjLGlCQUFpQixDQUFDO0FBQ2hDLGNBQWMsa0NBQWtDLENBQUM7QUFPakQsY0FBYyx5QkFBeUIsQ0FBQztBQVl4QyxjQUFjLGtCQUFrQixDQUFDO0FBQ2pDLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLGtDQUFrQyxDQUFDO0FBVXZFLE9BQU8sRUFBRSx5QkFBeUIsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBRXJFLGNBQWMsK0JBQStCLENBQUM7QUFDOUMsY0FBYyx3QkFBd0IsQ0FBQztBQUN2QyxjQUFjLHFCQUFxQixDQUFDO0FBQ3BDLGNBQWMsc0JBQXNCLENBQUM7QUFDckMsY0FBYyxxQ0FBcUMsQ0FBQztBQUNwRCxjQUFjLDhCQUE4QixDQUFDO0FBRTdDLE9BQU8sS0FBSyxXQUFXLE1BQU0sMEJBQTBCLENBQUM7QUFFeEQsT0FBTyxFQUFFLFdBQVcsRUFBRSxDQUFDIn0=
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lossless.org/client",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "private": false,
5
5
  "description": "One typed client for NoSQLDB, MongoDB, SQLDB, MariaDB, ClickHouse and S3 object storage.",
6
6
  "exports": {
@@ -83,7 +83,7 @@
83
83
  "@clickhouse/client": "1.23.1",
84
84
  "@git.zone/tsbuild": "5.0.0",
85
85
  "@git.zone/tsrun": "3.0.1",
86
- "@git.zone/tstest": "6.3.1",
86
+ "@git.zone/tstest": "6.3.2",
87
87
  "@lossless.org/nosqldb": "8.0.2",
88
88
  "@lossless.org/nosqldb-qualified": "npm:@lossless.org/nosqldb@10.7.2",
89
89
  "@lossless.org/objectstorage": "10.3.1",
package/readme.md CHANGED
@@ -108,6 +108,7 @@ Family imports preserve the established SmartData and SmartBucket names and cons
108
108
  | Transactions ended by close | a `session.withTransaction()` whose session `db.close()` ends while it runs rejects with `SmartdataPersistenceError` code `unsupported_operation` — "nothing was committed" before the commit, "the commit outcome is unknown" during it — instead of resolving with the callback's result |
109
109
  | Transaction commit outcome | `SmartdataSession.getCommitOutcome(error)` states for every value a `session.withTransaction()` rejected with whether the transaction could have committed: `not_committed` (nothing was committed) or `unknown` (a commit was sent and never confirmed); the rejection itself keeps its identity |
110
110
  | Cancellation | `signal` on every exact call, `atomicDeleteMany()` and `session.withTransaction(callback, { signal })`, which interrupts the session's operation in flight, rolls the attempt back and never commits after an abort |
111
+ | Query-plan inspection | `Model.inspectQueryPlan(db, { filter, sort?, projection?, limit?, timeoutMS?, signal? })` — the winning plan's stages (`stage`, `depth`, `indexName`, `direction`) and `nReturned`/`totalKeysExamined`/`totalDocsExamined`, read-only and bounded, so a consumer proves a model query uses its intended index without raw driver access |
111
112
  | Disposable test databases on a unix socket | `SmartdataTestDatabase.create({ connection: { socketPath } })` from `@lossless.org/client/testsupport` reaches an engine that listens only on a unix-domain socket, fault helpers included; the loopback `host`/`port` form is unchanged |
112
113
 
113
114
  `docs/source/smartdata/readme.md` documents each one with its refusals.
@@ -137,6 +138,7 @@ The document model runs on both NoSQLDB and MongoDB, but several APIs do not mea
137
138
  | collection validators (`validator`, `validationLevel`, `validationAction`) | no client API; a migration owns a validator through the driver. 10.7.0 and newer create the collection, list the options exactly as given and enforce them on every model write: a violating `insert()` or `atomicUpdate()` rejects with the engine's `DocumentValidationFailure` (121) and its `errInfo`, which the client passes through unchanged rather than mapping to a `SmartdataPersistenceError` code, and stores nothing. Models initialize on a validated collection like on any other. 8.0.2 refuses the option with `InvalidOptions` (72) and creates no collection, so a validator is never silently dropped | enforced; not exercised by the client's MongoDB suite |
138
139
  | write conflicts between transactions, and writes outside a transaction to a document a transaction holds | 10.7.2 (since 10.6.1) detects a conflict at the statement, as MongoDB does: the first writer wins, and the later transaction's statement rejects with the engine's `WriteConflict` (112) labelled `TransientTransactionError`, which the client passes through so `session.withTransaction()` re-runs that callback. A write outside a transaction waits until the transaction holding its document ends, then applies on top; the wait is bounded by the write's own `timeoutMS` and by the engine's fixed 60-second transaction lifetime, after which a waiting writer aborts the holder. Awaiting such a write inside the holding transaction's callback therefore stalls that transaction until one of the two expires. 8.0.2 detects the conflict at commit: the first transaction to commit wins, a write outside a transaction applies at once, and the transaction that wrote first re-runs. Both writes land on either engine; a callback must be safe to re-run and must not swallow the statement error | first writer wins at the statement and outside writes wait, MongoDB's documented rule; not exercised by the client's MongoDB suite |
139
140
  | value-form `$elemMatch` in read filters (`{ scores: { $elemMatch: { $gte: 80, $lt: 90 } } }`, `{ labels: { $not: { $elemMatch: { $nin: runnerLabels } } } }`) | exact on 10.7.1 and newer: the operators apply to each array element as a whole, so the range matches only a document with one score inside it and the claim selects a job only when every one of its labels is a runner label. 8.0.2 parses every operand as a query over embedded documents and refuses the value form with `TypeMismatch` (14). A non-document operand is refused on both, with MongoDB's `BadValue` (2) on 10.7.1 and newer and 14 on 8.0.2. The client passes the operand through unchanged; the filter type admits the value form over scalar elements and the document form over embedded documents. Atomic filters (`atomicUpdate()` and the other atomic writes) accept no `$elemMatch` on any backend: the client refuses it with `invalid_argument` before any backend is contacted | exact |
141
+ | `Model.inspectQueryPlan()` | explains on 10.7.2: the summary names the winning plan's stages and its execution totals. 8.0.2 answers `explain` with `CommandNotFound` (59), and the call rejects with `unsupported_operation` ("The connected engine does not explain queries.") rather than returning a plan it cannot know. Stage shapes are the engine's own: on 10.7.2 a limited sort on an unindexed field is `LIMIT` over `SORT` over `COLLSCAN`, an equality on a unique single-field index is `FETCH` over `IXSCAN`, and an `$or` whose branches each have an index is a `COLLSCAN` | explains: the same limited sort is `SORT` over `COLLSCAN`, the limit folded into the sort; the equality is `EXPRESS_IXSCAN` on the unique index; the `$or` is `SUBPLAN` over `FETCH` over `OR` over one `IXSCAN` per branch |
140
142
  | aggregation-pipeline (array) updates | refused by the client with `invalid_argument` before any backend is contacted. A client-owned refusal, not an engine limit: both qualified engines apply a pipeline update themselves | same client refusal |
141
143
 
142
144
  `test/nosqldb/smartdb/test.engine-capability-boundary.node.ts` asserts the identity, grouped-totals, guarded-`$expr`, partial-index, collection-validator, write-conflict, `$elemMatch` and pipeline-update rows against both qualified engines, so a boundary that moves shows up as a failing suite rather than as a changed number in a consumer. The two halves of the grouped-totals, guarded-`$expr`, write-conflict and `$elemMatch` rows are separate tests, each bound to the engine that answers it, and the engine's own composite-key answer stays under assertion through the raw `aggregateGroupedTotals()` boundary — one literal row on 8.0.2, the two exact pairs on 10.7.2. The same boundary pins the rest of the engine's `$group` stage: a non-document accumulator is refused with `TypeMismatch` (14) on 10.7.2 while 8.0.2 answers it, and a `$cond` inside a composite key is evaluated per document on 10.7.2 while 8.0.2 answers its single literal row. It also pins whether a migration can create a partial index — 10.7.2 creates it and lists the filter, 8.0.2 refuses with 72 — and a collection validator — 10.7.2 creates and enforces it against model inserts and updates, 8.0.2 refuses with 72 — which writer a conflict sends back: on 10.7.2 the later transaction fails at its statement and re-runs while a write outside a transaction waits for the holder's commit, on 8.0.2 the later transaction commits first and the holder re-runs — and what `$elemMatch` answers through `getInstances()`: 10.7.2 matches the documented range and the `$not`/`$elemMatch`/`$nin` claim exactly as the MongoDB filter suite asserts them, 8.0.2 refuses both with 14, and a non-document operand is refused with 2 on 10.7.2 and 14 on 8.0.2. The `watch()` row is the contract refusal `ts/nosqldb/classes.collection.ts` states; the engine suite opens no change stream.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@lossless.org/client',
6
- version: '1.13.0',
6
+ version: '1.14.0',
7
7
  description: 'One typed client for NoSQLDB, MongoDB, SQLDB, MariaDB, ClickHouse and S3 object storage.'
8
8
  }
@@ -16,6 +16,10 @@ import {
16
16
  type ISmartdataStoredInspectionOptions, type ISmartdataStoredInspection,
17
17
  type ISmartdataStoredEvidenceInspection,
18
18
  } from './classes.storedinspection.js';
19
+ import {
20
+ inspectQueryPlan, requireQueryPlanOptions,
21
+ type ISmartdataQueryPlan, type ISmartdataQueryPlanOptions,
22
+ } from './classes.queryplaninspection.js';
19
23
  import {
20
24
  getIdentityDocumentIdField,
21
25
  type IManager,
@@ -3727,6 +3731,32 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3727
3731
  return inspectStoredDocumentEvidence(this, dbArg, optionsArg);
3728
3732
  }
3729
3733
 
3734
+ /**
3735
+ * Explains one model query with `executionStats` on `dbArg` and returns the
3736
+ * winning plan's stages and execution totals, so a consumer can prove which
3737
+ * index serves the query. Read-only: nothing is created, bound or hydrated.
3738
+ */
3739
+ public static async inspectQueryPlan<T>(
3740
+ this: plugins.tsclass.typeFest.Class<T>,
3741
+ dbArg: SmartdataDb,
3742
+ optionsArg: ISmartdataQueryPlanOptions<T>,
3743
+ ): Promise<ISmartdataQueryPlan> {
3744
+ const model = this as unknown as typeof SmartDataDbDoc;
3745
+ const options = requireQueryPlanOptions(optionsArg);
3746
+ model.assertHydratedProjection(options.projection);
3747
+ const declaredRoots = model.getDeclaredPersistedRoots();
3748
+ const declaredQueryPaths = model.getDeclaredQueryPaths();
3749
+ return inspectQueryPlan(dbArg, {
3750
+ collectionName: getExpectedCollectionTopologyForModel(model).collectionName,
3751
+ filter: model.normalizeReadFilter(options.filter),
3752
+ sort: normalizeSort(options.sort, declaredRoots, declaredQueryPaths),
3753
+ projection: normalizeProjection(options.projection, declaredRoots, declaredQueryPaths),
3754
+ limit: options.limit,
3755
+ timeoutMS: options.timeoutMS,
3756
+ signal: options.signal,
3757
+ });
3758
+ }
3759
+
3730
3760
  public static createInstanceFromMongoDbNativeDoc<T>(
3731
3761
  this: plugins.tsclass.typeFest.Class<T>,
3732
3762
  mongoDbNativeDocArg: any,
@@ -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
+ };
@@ -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';