@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.
@@ -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
- if (typeof callbackArg !== 'function') {
201
- throw new SmartdataPersistenceError(
202
- 'invalid_argument',
203
- 'SmartData transaction callback must be a function.',
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?.throwIfAborted();
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
- let sessionEndedError: SmartdataPersistenceError | undefined;
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
- sessionEndedError = createTransactionSessionEndedError(
264
- binding.transactionEnded,
265
- callbackThrown?.error,
266
- );
267
- throw sessionEndedError;
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
- let result: TResult | undefined;
291
- let transactionEnded: TSmartdataTransactionEnd | undefined;
292
- try {
293
- result = timeoutMS === undefined
294
- ? await binding.rawSession.withTransaction(runAttempt)
295
- : await binding.rawSession.withTransaction(runAttempt, { timeoutMS });
296
- transactionEnded = binding.transactionEnded;
297
- } catch (errorArg) {
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
- throw normalizeDeadlineError(attemptInterruption.error);
388
+ return { error: normalizeDeadlineError(attemptInterruption.error), outcome: 'not_committed' };
303
389
  }
304
- if (sessionEndedError) {
305
- throw sessionEndedError;
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
- throw createTransactionSessionEndedError(binding.transactionEnded, errorArg);
399
+ return {
400
+ error: createTransactionSessionEndedError(binding.transactionEnded, errorArg),
401
+ outcome: commitOutcomeOfTransactionEnd(binding.transactionEnded),
402
+ };
314
403
  }
315
404
  if (errorArg === callbackError) {
316
- throw normalizeDeadlineError(errorArg);
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
- throw createSmartdataTimeoutError(
320
- binding.transactionCommitReached
321
- ? 'SmartData transaction exceeded its client deadline during commit; the commit outcome is unknown.'
322
- : 'SmartData transaction exceeded its client deadline before commit; nothing was committed.',
323
- errorArg,
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
- return normalizeOrdinaryPersistenceError(
328
- errorArg,
329
- 'SmartData transaction conflicts with a unique index.',
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
- throw errorArg;
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 createTransactionSessionEndedError(transactionEnded);
456
+ throw rejectTransaction(
457
+ createTransactionSessionEndedError(transactionEnded),
458
+ commitOutcomeOfTransactionEnd(transactionEnded),
459
+ );
344
460
  }
345
461
  return result;
346
462
  }
@@ -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';