@lossless.org/client 1.12.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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/nosqldb/classes.db.d.ts +1 -1
- package/dist_ts/nosqldb/classes.db.js +1 -1
- package/dist_ts/nosqldb/classes.doc.d.ts +7 -0
- package/dist_ts/nosqldb/classes.doc.js +23 -1
- package/dist_ts/nosqldb/classes.persistence.d.ts +6 -0
- package/dist_ts/nosqldb/classes.persistence.js +8 -1
- package/dist_ts/nosqldb/classes.queryplaninspection.d.ts +65 -0
- package/dist_ts/nosqldb/classes.queryplaninspection.js +148 -0
- package/dist_ts/nosqldb/classes.session.d.ts +29 -0
- package/dist_ts/nosqldb/classes.session.js +114 -32
- package/dist_ts/nosqldb/index.d.ts +1 -0
- package/dist_ts/nosqldb/index.js +1 -1
- package/package.json +4 -4
- package/readme.md +10 -7
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.db.ts +5 -1
- package/ts/nosqldb/classes.doc.ts +30 -0
- package/ts/nosqldb/classes.persistence.ts +9 -0
- package/ts/nosqldb/classes.queryplaninspection.ts +220 -0
- package/ts/nosqldb/classes.session.ts +161 -45
- 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
|
+
};
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
SmartdataPersistenceError,
|
|
12
12
|
createSmartdataTimeoutError,
|
|
13
13
|
isMongoDeadlineError,
|
|
14
|
+
isMongoTransientTransactionError,
|
|
14
15
|
normalizeDeadlineError,
|
|
15
16
|
normalizeOrdinaryPersistenceError,
|
|
16
17
|
} from './classes.persistence.js';
|
|
@@ -44,6 +45,45 @@ interface ISmartdataSessionBinding {
|
|
|
44
45
|
/** How far a transaction had got when its session ended outside the callback's control. */
|
|
45
46
|
type TSmartdataTransactionEnd = 'before_commit' | 'during_commit';
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Whether a transaction that `SmartdataSession.withTransaction()` rejected
|
|
50
|
+
* could have committed:
|
|
51
|
+
*
|
|
52
|
+
* - `not_committed`: nothing of the transaction was committed. The call failed
|
|
53
|
+
* before the driver sent a commit, or the database answered the commit with
|
|
54
|
+
* an error that states the transaction was aborted.
|
|
55
|
+
* - `unknown`: a commit was sent and its outcome was never confirmed; it may
|
|
56
|
+
* have applied.
|
|
57
|
+
*/
|
|
58
|
+
export type TSmartdataTransactionCommitOutcome = 'not_committed' | 'unknown';
|
|
59
|
+
|
|
60
|
+
/** The commit outcome of every value a `withTransaction()` call rejected with. */
|
|
61
|
+
const transactionCommitOutcomes = new WeakMap<object, TSmartdataTransactionCommitOutcome>();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Records what `errorArg`, the rejection of a `withTransaction()` call, means
|
|
65
|
+
* for the commit, and returns it for the throw. A value several transactions
|
|
66
|
+
* rejected with keeps `unknown` once any of them may have committed. A
|
|
67
|
+
* primitive cannot be recorded.
|
|
68
|
+
*/
|
|
69
|
+
const rejectTransaction = (
|
|
70
|
+
errorArg: unknown,
|
|
71
|
+
outcomeArg: TSmartdataTransactionCommitOutcome,
|
|
72
|
+
): unknown => {
|
|
73
|
+
if (
|
|
74
|
+
((typeof errorArg === 'object' && errorArg !== null) || typeof errorArg === 'function')
|
|
75
|
+
&& transactionCommitOutcomes.get(errorArg) !== 'unknown'
|
|
76
|
+
) {
|
|
77
|
+
transactionCommitOutcomes.set(errorArg, outcomeArg);
|
|
78
|
+
}
|
|
79
|
+
return errorArg;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const commitOutcomeOfTransactionEnd = (
|
|
83
|
+
endArg: TSmartdataTransactionEnd,
|
|
84
|
+
): TSmartdataTransactionCommitOutcome =>
|
|
85
|
+
endArg === 'during_commit' ? 'unknown' : 'not_committed';
|
|
86
|
+
|
|
47
87
|
/**
|
|
48
88
|
* The session of a transaction ended while it ran, for example through the
|
|
49
89
|
* database's `close()`. The driver then returns the callback's result without
|
|
@@ -154,6 +194,30 @@ const normalizeTransactionOptions = (
|
|
|
154
194
|
};
|
|
155
195
|
};
|
|
156
196
|
|
|
197
|
+
/** Everything `withTransaction()` refuses before a transaction starts. */
|
|
198
|
+
const admitTransaction = (
|
|
199
|
+
sessionArg: SmartdataSession,
|
|
200
|
+
callbackArg: unknown,
|
|
201
|
+
optionsArg: ISmartdataTransactionOptions | undefined,
|
|
202
|
+
): { binding: ISmartdataSessionBinding; timeoutMS?: number; signal?: AbortSignal } => {
|
|
203
|
+
if (typeof callbackArg !== 'function') {
|
|
204
|
+
throw new SmartdataPersistenceError(
|
|
205
|
+
'invalid_argument',
|
|
206
|
+
'SmartData transaction callback must be a function.',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const { timeoutMS, signal } = normalizeTransactionOptions(optionsArg);
|
|
210
|
+
const binding = resolveBinding(sessionArg);
|
|
211
|
+
if (binding.operationActive || binding.transactionActive) {
|
|
212
|
+
throw new SmartdataPersistenceError(
|
|
213
|
+
'unsupported_operation',
|
|
214
|
+
'Parallel or nested SmartData session operations are not supported.',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
signal?.throwIfAborted();
|
|
218
|
+
return { binding, timeoutMS, signal };
|
|
219
|
+
};
|
|
220
|
+
|
|
157
221
|
const assertTransactionSucceeded = (bindingArg: ISmartdataSessionBinding): void => {
|
|
158
222
|
if (bindingArg.transactionFailure) {
|
|
159
223
|
throw bindingArg.transactionFailure.error;
|
|
@@ -161,6 +225,25 @@ const assertTransactionSucceeded = (bindingArg: ISmartdataSessionBinding): void
|
|
|
161
225
|
};
|
|
162
226
|
|
|
163
227
|
export class SmartdataSession {
|
|
228
|
+
/**
|
|
229
|
+
* Whether the transaction whose `withTransaction()` call rejected with
|
|
230
|
+
* `errorArg` could have committed: `not_committed` when nothing of it was
|
|
231
|
+
* committed, `unknown` when a commit was sent and never confirmed, so it may
|
|
232
|
+
* have applied. Every rejection of `withTransaction()` carries one, whatever
|
|
233
|
+
* it is: the client's own errors, the callback's error, the abort reason or
|
|
234
|
+
* a driver error, which all keep their identity. A value several calls
|
|
235
|
+
* rejected with reads `unknown` once any of them may have committed.
|
|
236
|
+
*
|
|
237
|
+
* Returns `undefined` for any other value, and for a primitive rejection —
|
|
238
|
+
* a callback that throws a string, or a string abort reason — which cannot
|
|
239
|
+
* carry the outcome; treat `undefined` as `unknown`.
|
|
240
|
+
*/
|
|
241
|
+
public static getCommitOutcome(errorArg: unknown): TSmartdataTransactionCommitOutcome | undefined {
|
|
242
|
+
return (typeof errorArg === 'object' && errorArg !== null) || typeof errorArg === 'function'
|
|
243
|
+
? transactionCommitOutcomes.get(errorArg)
|
|
244
|
+
: undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
164
247
|
/**
|
|
165
248
|
* Runs `callbackArg` in a transaction, retrying it as the driver decides.
|
|
166
249
|
*
|
|
@@ -192,26 +275,22 @@ export class SmartdataSession {
|
|
|
192
275
|
* `unsupported_operation`: before the callback returned nothing was
|
|
193
276
|
* committed, afterwards the commit outcome is unknown. The callback's
|
|
194
277
|
* error, if it threw, is the `cause`.
|
|
278
|
+
*
|
|
279
|
+
* Every rejection states whether the transaction could have committed:
|
|
280
|
+
* `SmartdataSession.getCommitOutcome(error)` returns `not_committed` or
|
|
281
|
+
* `unknown` for the value the call rejected with.
|
|
195
282
|
*/
|
|
196
283
|
public async withTransaction<TResult>(
|
|
197
284
|
callbackArg: (sessionArg: SmartdataSession) => Promise<TResult>,
|
|
198
285
|
optionsArg?: ISmartdataTransactionOptions,
|
|
199
286
|
): Promise<TResult | undefined> {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
const { timeoutMS, signal } = normalizeTransactionOptions(optionsArg);
|
|
207
|
-
const binding = resolveBinding(this);
|
|
208
|
-
if (binding.operationActive || binding.transactionActive) {
|
|
209
|
-
throw new SmartdataPersistenceError(
|
|
210
|
-
'unsupported_operation',
|
|
211
|
-
'Parallel or nested SmartData session operations are not supported.',
|
|
212
|
-
);
|
|
287
|
+
let admitted: ReturnType<typeof admitTransaction>;
|
|
288
|
+
try {
|
|
289
|
+
admitted = admitTransaction(this, callbackArg, optionsArg);
|
|
290
|
+
} catch (errorArg) {
|
|
291
|
+
throw rejectTransaction(errorArg, 'not_committed');
|
|
213
292
|
}
|
|
214
|
-
signal
|
|
293
|
+
const { binding, timeoutMS, signal } = admitted;
|
|
215
294
|
binding.transactionActive = true;
|
|
216
295
|
binding.transactionTimeoutMS = timeoutMS;
|
|
217
296
|
binding.transactionSignal = signal;
|
|
@@ -219,7 +298,8 @@ export class SmartdataSession {
|
|
|
219
298
|
let callbackError: unknown;
|
|
220
299
|
/** The signal or deadline interruption that failed the last attempt. */
|
|
221
300
|
let attemptInterruption: { error: unknown } | undefined;
|
|
222
|
-
|
|
301
|
+
/** The session-ended rejection the last attempt failed with, and how far that transaction had got. */
|
|
302
|
+
let sessionEnded: { error: SmartdataPersistenceError; end: TSmartdataTransactionEnd } | undefined;
|
|
223
303
|
const runAttempt = async () => {
|
|
224
304
|
const attempt = Symbol('smartdataTransactionAttempt');
|
|
225
305
|
binding.transactionAttempt = attempt;
|
|
@@ -260,11 +340,14 @@ export class SmartdataSession {
|
|
|
260
340
|
if (binding.transactionEnded) {
|
|
261
341
|
// The driver would commit or return the result on a session that is
|
|
262
342
|
// being ended; the attempt fails instead, keeping the callback's error.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
343
|
+
sessionEnded = {
|
|
344
|
+
error: createTransactionSessionEndedError(
|
|
345
|
+
binding.transactionEnded,
|
|
346
|
+
callbackThrown?.error,
|
|
347
|
+
),
|
|
348
|
+
end: binding.transactionEnded,
|
|
349
|
+
};
|
|
350
|
+
throw sessionEnded.error;
|
|
268
351
|
}
|
|
269
352
|
if (callbackReturned && activeOperation) {
|
|
270
353
|
throw new SmartdataPersistenceError(
|
|
@@ -287,22 +370,25 @@ export class SmartdataSession {
|
|
|
287
370
|
throw errorArg;
|
|
288
371
|
}
|
|
289
372
|
};
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
373
|
+
/**
|
|
374
|
+
* What the call rejects with when the driver's `withTransaction()` rejected
|
|
375
|
+
* with `errorArg`, and whether the transaction could have committed. Once
|
|
376
|
+
* the last attempt's callback returned, the driver was committing: every
|
|
377
|
+
* error from there on leaves the commit outcome unknown, unless it states
|
|
378
|
+
* that the transaction was aborted. The driver starts another attempt only
|
|
379
|
+
* after such an error, so an earlier attempt never committed.
|
|
380
|
+
*/
|
|
381
|
+
const settleTransactionRejection = (
|
|
382
|
+
errorArg: unknown,
|
|
383
|
+
): { error: unknown; outcome: TSmartdataTransactionCommitOutcome } => {
|
|
298
384
|
// The driver's closing abort replaces the attempt's error when it finds
|
|
299
385
|
// the transaction already aborted, as a session that close() ended leaves
|
|
300
386
|
// it; the interruption that failed the attempt keeps its rejection.
|
|
301
387
|
if (attemptInterruption) {
|
|
302
|
-
|
|
388
|
+
return { error: normalizeDeadlineError(attemptInterruption.error), outcome: 'not_committed' };
|
|
303
389
|
}
|
|
304
|
-
if (
|
|
305
|
-
|
|
390
|
+
if (sessionEnded) {
|
|
391
|
+
return { error: sessionEnded.error, outcome: commitOutcomeOfTransactionEnd(sessionEnded.end) };
|
|
306
392
|
}
|
|
307
393
|
if (
|
|
308
394
|
binding.transactionEnded
|
|
@@ -310,26 +396,53 @@ export class SmartdataSession {
|
|
|
310
396
|
&& !isMongoDeadlineError(errorArg)
|
|
311
397
|
&& !(errorArg instanceof SmartdataPersistenceError && errorArg.code === 'timeout')
|
|
312
398
|
) {
|
|
313
|
-
|
|
399
|
+
return {
|
|
400
|
+
error: createTransactionSessionEndedError(binding.transactionEnded, errorArg),
|
|
401
|
+
outcome: commitOutcomeOfTransactionEnd(binding.transactionEnded),
|
|
402
|
+
};
|
|
314
403
|
}
|
|
315
404
|
if (errorArg === callbackError) {
|
|
316
|
-
|
|
405
|
+
return { error: normalizeDeadlineError(errorArg), outcome: 'not_committed' };
|
|
317
406
|
}
|
|
407
|
+
const outcome: TSmartdataTransactionCommitOutcome =
|
|
408
|
+
binding.transactionCommitReached && !isMongoTransientTransactionError(errorArg)
|
|
409
|
+
? 'unknown'
|
|
410
|
+
: 'not_committed';
|
|
318
411
|
if (isMongoDeadlineError(errorArg)) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
412
|
+
return {
|
|
413
|
+
error: createSmartdataTimeoutError(
|
|
414
|
+
outcome === 'unknown'
|
|
415
|
+
? 'SmartData transaction exceeded its client deadline during commit; the commit outcome is unknown.'
|
|
416
|
+
: binding.transactionCommitReached
|
|
417
|
+
? 'SmartData transaction exceeded its client deadline after the database aborted its commit; nothing was committed.'
|
|
418
|
+
: 'SmartData transaction exceeded its client deadline before commit; nothing was committed.',
|
|
419
|
+
errorArg,
|
|
420
|
+
),
|
|
421
|
+
outcome,
|
|
422
|
+
};
|
|
325
423
|
}
|
|
326
424
|
if (binding.transactionUsesOrdinaryWrite) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
425
|
+
try {
|
|
426
|
+
normalizeOrdinaryPersistenceError(
|
|
427
|
+
errorArg,
|
|
428
|
+
'SmartData transaction conflicts with a unique index.',
|
|
429
|
+
);
|
|
430
|
+
} catch (normalizedArg) {
|
|
431
|
+
return { error: normalizedArg, outcome };
|
|
432
|
+
}
|
|
331
433
|
}
|
|
332
|
-
|
|
434
|
+
return { error: errorArg, outcome };
|
|
435
|
+
};
|
|
436
|
+
let result: TResult | undefined;
|
|
437
|
+
let transactionEnded: TSmartdataTransactionEnd | undefined;
|
|
438
|
+
try {
|
|
439
|
+
result = timeoutMS === undefined
|
|
440
|
+
? await binding.rawSession.withTransaction(runAttempt)
|
|
441
|
+
: await binding.rawSession.withTransaction(runAttempt, { timeoutMS });
|
|
442
|
+
transactionEnded = binding.transactionEnded;
|
|
443
|
+
} catch (errorArg) {
|
|
444
|
+
const { error, outcome } = settleTransactionRejection(errorArg);
|
|
445
|
+
throw rejectTransaction(error, outcome);
|
|
333
446
|
} finally {
|
|
334
447
|
binding.transactionActive = false;
|
|
335
448
|
binding.transactionTimeoutMS = undefined;
|
|
@@ -340,7 +453,10 @@ export class SmartdataSession {
|
|
|
340
453
|
binding.transactionEnded = undefined;
|
|
341
454
|
}
|
|
342
455
|
if (transactionEnded) {
|
|
343
|
-
throw
|
|
456
|
+
throw rejectTransaction(
|
|
457
|
+
createTransactionSessionEndedError(transactionEnded),
|
|
458
|
+
commitOutcomeOfTransactionEnd(transactionEnded),
|
|
459
|
+
);
|
|
344
460
|
}
|
|
345
461
|
return result;
|
|
346
462
|
}
|
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';
|