@lossless.org/client 1.6.0 → 1.8.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.
Files changed (51) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/nosqldb/classes.atomicdelete.d.ts +1 -0
  3. package/dist_ts/nosqldb/classes.atomicdelete.js +10 -7
  4. package/dist_ts/nosqldb/classes.atomicfindoneandupdate.d.ts +2 -0
  5. package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +9 -5
  6. package/dist_ts/nosqldb/classes.atomicupdate.d.ts +2 -0
  7. package/dist_ts/nosqldb/classes.atomicupdate.js +13 -8
  8. package/dist_ts/nosqldb/classes.collection.d.ts +13 -4
  9. package/dist_ts/nosqldb/classes.collection.js +57 -64
  10. package/dist_ts/nosqldb/classes.db.d.ts +1 -1
  11. package/dist_ts/nosqldb/classes.doc.d.ts +60 -3
  12. package/dist_ts/nosqldb/classes.doc.js +68 -26
  13. package/dist_ts/nosqldb/classes.exactpersistence.d.ts +36 -7
  14. package/dist_ts/nosqldb/classes.exactpersistence.js +92 -81
  15. package/dist_ts/nosqldb/classes.operationdeadline.d.ts +61 -0
  16. package/dist_ts/nosqldb/classes.operationdeadline.js +149 -0
  17. package/dist_ts/nosqldb/classes.persistence.d.ts +16 -1
  18. package/dist_ts/nosqldb/classes.persistence.js +20 -1
  19. package/dist_ts/nosqldb/classes.session.d.ts +73 -2
  20. package/dist_ts/nosqldb/classes.session.js +164 -38
  21. package/dist_ts/objectstorage/classes.bucket.d.ts +61 -3
  22. package/dist_ts/objectstorage/classes.bucket.js +243 -184
  23. package/dist_ts/objectstorage/classes.directory.js +17 -28
  24. package/dist_ts/objectstorage/classes.smartbucket.d.ts +13 -6
  25. package/dist_ts/objectstorage/classes.smartbucket.js +18 -12
  26. package/dist_ts/objectstorage/classes.watcher.js +1 -2
  27. package/dist_ts/objectstorage/interfaces.d.ts +8 -0
  28. package/dist_ts/objectstorage/internal.bucketrequests.d.ts +14 -0
  29. package/dist_ts/objectstorage/internal.bucketrequests.js +53 -0
  30. package/dist_ts/objectstorage/internal.exactupload.capability.d.ts +12 -0
  31. package/dist_ts/objectstorage/internal.exactupload.capability.js +46 -17
  32. package/package.json +3 -3
  33. package/readme.md +13 -2
  34. package/ts/00_commitinfo_data.ts +1 -1
  35. package/ts/nosqldb/classes.atomicdelete.ts +10 -6
  36. package/ts/nosqldb/classes.atomicfindoneandupdate.ts +10 -4
  37. package/ts/nosqldb/classes.atomicupdate.ts +14 -7
  38. package/ts/nosqldb/classes.collection.ts +82 -73
  39. package/ts/nosqldb/classes.db.ts +1 -1
  40. package/ts/nosqldb/classes.doc.ts +162 -33
  41. package/ts/nosqldb/classes.exactpersistence.ts +131 -93
  42. package/ts/nosqldb/classes.operationdeadline.ts +179 -0
  43. package/ts/nosqldb/classes.persistence.ts +36 -1
  44. package/ts/nosqldb/classes.session.ts +221 -39
  45. package/ts/objectstorage/classes.bucket.ts +350 -218
  46. package/ts/objectstorage/classes.directory.ts +18 -27
  47. package/ts/objectstorage/classes.smartbucket.ts +21 -11
  48. package/ts/objectstorage/classes.watcher.ts +0 -1
  49. package/ts/objectstorage/interfaces.ts +9 -0
  50. package/ts/objectstorage/internal.bucketrequests.ts +70 -0
  51. package/ts/objectstorage/internal.exactupload.capability.ts +105 -24
@@ -7,7 +7,17 @@ import type { SmartdataCollection } from './classes.collection.js';
7
7
  import { getIdentityIndexName } from './classes.collectiontopology.js';
8
8
  import { registerCollectionReconnectHandler } from './classes.collectionlifecycle.js';
9
9
  import type { SmartdataSession } from './classes.session.js';
10
- import { acquireSmartdataSession } from './classes.session.js';
10
+ import {
11
+ acquireSmartdataSession,
12
+ settleSmartdataOperationError,
13
+ resolveSmartdataOperationDeadline,
14
+ } from './classes.session.js';
15
+ import {
16
+ type SmartdataOperationDeadline,
17
+ requireOperationSignal,
18
+ requireOperationTimeoutMS,
19
+ } from './classes.operationdeadline.js';
20
+ import { normalizeDeadlineError } from './classes.persistence.js';
11
21
  import {
12
22
  convertFilterForMongoDb,
13
23
  type MongoFilter,
@@ -217,7 +227,22 @@ export interface IExactReadOptions extends IExactSessionOptions {
217
227
  signal?: AbortSignal;
218
228
  }
219
229
 
220
- export interface IFindStoredOptions<TPersisted extends object> extends IExactReadOptions {
230
+ /**
231
+ * Options of every exact call: `insert()`, `transition()`, `delete()`,
232
+ * `count()`, `distinct()` and `findStoredOne()`, and through
233
+ * `IFindStoredOptions` `findStored()`.
234
+ */
235
+ export interface IExactBoundedOptions extends IExactReadOptions {
236
+ /**
237
+ * Client deadline (1..120000 ms), handed to the driver as its client-side
238
+ * operation timeout for every command of the call, including the wait for
239
+ * the lazy first-touch preparation. Inside a transaction with a deadline it
240
+ * is refused; the transaction's deadline bounds the call.
241
+ */
242
+ timeoutMS?: number;
243
+ }
244
+
245
+ export interface IFindStoredOptions<TPersisted extends object> extends IExactBoundedOptions {
221
246
  filter?: MongoFilter<TStoredDocument<TPersisted>>;
222
247
  sort?: Partial<
223
248
  Record<TExactDocumentPath<TStoredDocument<TPersisted>>, 1 | -1>
@@ -253,36 +278,6 @@ const bytesEqual = (leftArg: Uint8Array, rightArg: Uint8Array): boolean => {
253
278
  return true;
254
279
  };
255
280
 
256
- const waitWithSignal = async <T>(
257
- promiseArg: Promise<T>,
258
- signalArg?: AbortSignal,
259
- ): Promise<T> => {
260
- if (!signalArg) return promiseArg;
261
- signalArg.throwIfAborted();
262
- return new Promise<T>((resolve, reject) => {
263
- let settled = false;
264
- const beginSettlement = (): boolean => {
265
- if (settled) return false;
266
- settled = true;
267
- signalArg.removeEventListener('abort', onAbort);
268
- return true;
269
- };
270
- const onAbort = () => {
271
- if (beginSettlement()) reject(signalArg.reason);
272
- };
273
- signalArg.addEventListener('abort', onAbort, { once: true });
274
- if (signalArg.aborted) onAbort();
275
- void promiseArg.then(
276
- (valueArg) => {
277
- if (beginSettlement()) resolve(valueArg);
278
- },
279
- (errorArg) => {
280
- if (beginSettlement()) reject(errorArg);
281
- },
282
- );
283
- });
284
- };
285
-
286
281
  const hasOwn = (documentArg: object, keyArg: PropertyKey): boolean =>
287
282
  Object.prototype.hasOwnProperty.call(documentArg, keyArg);
288
283
 
@@ -1073,17 +1068,17 @@ export class SmartdataExactCollection<
1073
1068
  }
1074
1069
 
1075
1070
  private async beginOperation(
1076
- optionsArg: IExactSessionOptions,
1077
- signalArg?: AbortSignal,
1071
+ sessionArg: SmartdataSession | undefined,
1072
+ boundsArg: { timeoutMS?: number; signal?: AbortSignal },
1078
1073
  ): Promise<{
1079
1074
  session?: plugins.mongodb.ClientSession;
1075
+ deadline: SmartdataOperationDeadline;
1080
1076
  release: () => void;
1081
1077
  }> {
1082
- signalArg?.throwIfAborted();
1083
- if (!optionsArg.session) {
1084
- await waitWithSignal(this.initialize(), signalArg);
1085
- signalArg?.throwIfAborted();
1086
- return { release: () => {} };
1078
+ if (!sessionArg) {
1079
+ const deadline = resolveSmartdataOperationDeadline(undefined, boundsArg);
1080
+ await deadline.join(this.initialize());
1081
+ return { deadline, release: () => {} };
1087
1082
  }
1088
1083
  if (
1089
1084
  !this.collection.isInitializedForCurrentDatabase()
@@ -1095,35 +1090,66 @@ export class SmartdataExactCollection<
1095
1090
  );
1096
1091
  }
1097
1092
  const lease = acquireSmartdataSession(
1098
- optionsArg.session,
1093
+ sessionArg,
1099
1094
  this.collection.smartdataDb,
1100
1095
  );
1101
- if (signalArg?.aborted) {
1096
+ try {
1097
+ const deadline = resolveSmartdataOperationDeadline(sessionArg, boundsArg);
1098
+ deadline.throwIfAborted();
1099
+ return {
1100
+ session: lease.rawSession,
1101
+ deadline,
1102
+ release: lease.release,
1103
+ };
1104
+ } catch (errorArg) {
1102
1105
  lease.release();
1103
- signalArg.throwIfAborted();
1106
+ throw errorArg;
1104
1107
  }
1105
- return {
1106
- session: lease.rawSession,
1107
- release: lease.release,
1108
- };
1109
1108
  }
1110
1109
 
1110
+ /**
1111
+ * Runs one exact call on its session lease under its client deadline and
1112
+ * signal. A signal that is already aborted rejects with its reason before
1113
+ * anything is sent. An expired driver deadline surfaces as the `timeout`
1114
+ * persistence error; a write the deadline or signal interrupts in flight
1115
+ * is reported by the write itself as `ambiguous_write`, and inside a
1116
+ * transaction any interruption fails the attempt.
1117
+ */
1111
1118
  private async runOperation<TResult>(
1112
- optionsArg: IExactSessionOptions,
1119
+ optionsArg: IExactBoundedOptions,
1113
1120
  operationArg: (
1114
- sessionArg?: plugins.mongodb.ClientSession,
1121
+ sessionArg: plugins.mongodb.ClientSession | undefined,
1122
+ deadlineArg: SmartdataOperationDeadline,
1115
1123
  ) => Promise<TResult>,
1116
- signalArg?: AbortSignal,
1117
1124
  ): Promise<TResult> {
1118
- const operation = await this.beginOperation(optionsArg, signalArg);
1125
+ const bounds = {
1126
+ timeoutMS: requireOperationTimeoutMS(optionsArg.timeoutMS, 'Exact operation timeoutMS'),
1127
+ signal: requireOperationSignal(optionsArg.signal, 'Exact operation signal'),
1128
+ };
1129
+ bounds.signal?.throwIfAborted();
1130
+ const operation = await this.beginOperation(optionsArg.session, bounds);
1119
1131
  try {
1120
- signalArg?.throwIfAborted();
1121
- return await operationArg(operation.session);
1132
+ return await operationArg(operation.session, operation.deadline);
1133
+ } catch (errorArg) {
1134
+ throw settleSmartdataOperationError(optionsArg.session, operation.deadline, errorArg);
1122
1135
  } finally {
1123
1136
  operation.release();
1124
1137
  }
1125
1138
  }
1126
1139
 
1140
+ /**
1141
+ * The error of a non-transactional write that its client deadline or
1142
+ * signal interrupted after the write was handed to the driver: the command
1143
+ * may have reached the database, so its outcome is unknown and no
1144
+ * reconciliation read runs on the interrupted call's budget.
1145
+ */
1146
+ private interruptedWriteError(labelArg: string, causeArg: unknown): Error {
1147
+ return this.ambiguousWriteError(
1148
+ `${labelArg} was interrupted by its client deadline or signal in flight; its outcome is unknown.`,
1149
+ normalizeDeadlineError(causeArg),
1150
+ );
1151
+ }
1152
+
1127
1153
  private canonicalizeDocument<TDocument extends object>(
1128
1154
  documentArg: TDocument,
1129
1155
  labelArg: string,
@@ -1355,11 +1381,12 @@ export class SmartdataExactCollection<
1355
1381
 
1356
1382
  private async findByReconcileIdentity(
1357
1383
  identityArg: string,
1358
- sessionArg?: plugins.mongodb.ClientSession,
1384
+ sessionArg: plugins.mongodb.ClientSession | undefined,
1385
+ deadlineArg: SmartdataOperationDeadline,
1359
1386
  ): Promise<TStoredDocument<TPersisted> | null> {
1360
1387
  const rawDocument = await this.collection.mongoDbCollection.findOne({
1361
1388
  [this.reconcileBy]: identityArg,
1362
- }, { session: sessionArg });
1389
+ }, { session: sessionArg, ...deadlineArg.commandOptions() });
1363
1390
  return rawDocument
1364
1391
  ? this.canonicalizeStoredDocument(rawDocument)
1365
1392
  : null;
@@ -1424,11 +1451,12 @@ export class SmartdataExactCollection<
1424
1451
  currentArg: TStoredDocument<TPersisted>,
1425
1452
  identityArg: string,
1426
1453
  causeArg: unknown,
1427
- sessionArg?: plugins.mongodb.ClientSession,
1454
+ sessionArg: plugins.mongodb.ClientSession | undefined,
1455
+ deadlineArg: SmartdataOperationDeadline,
1428
1456
  ): Promise<TExactDeleteResult> {
1429
1457
  let reconciled: TStoredDocument<TPersisted> | null;
1430
1458
  try {
1431
- reconciled = await this.findByReconcileIdentity(identityArg, sessionArg);
1459
+ reconciled = await this.findByReconcileIdentity(identityArg, sessionArg, deadlineArg);
1432
1460
  } catch (errorArg) {
1433
1461
  if (isTransientTransactionError(errorArg, sessionArg)) {
1434
1462
  throw errorArg;
@@ -1449,9 +1477,9 @@ export class SmartdataExactCollection<
1449
1477
 
1450
1478
  public async insert(
1451
1479
  documentArg: TModel | TExactPersistedDocument<TPersisted>,
1452
- optionsArg: IExactSessionOptions = {},
1480
+ optionsArg: IExactBoundedOptions = {},
1453
1481
  ): Promise<TExactInsertResult<TPersisted>> {
1454
- return this.runOperation(optionsArg, async (sessionArg) => {
1482
+ return this.runOperation(optionsArg, async (sessionArg, deadlineArg) => {
1455
1483
  const persistedDocument = await this.createPersistedDocument(documentArg);
1456
1484
  const identity = this.validateReconcileIdentity(persistedDocument);
1457
1485
  const expectedStoredDocument = this.canonicalizeStoredDocument({
@@ -1459,7 +1487,7 @@ export class SmartdataExactCollection<
1459
1487
  ...persistedDocument,
1460
1488
  });
1461
1489
  if (hasActiveTransaction(sessionArg)) {
1462
- const existing = await this.findByReconcileIdentity(identity, sessionArg);
1490
+ const existing = await this.findByReconcileIdentity(identity, sessionArg, deadlineArg);
1463
1491
  if (existing) {
1464
1492
  return this.persistedDocumentsEqual(
1465
1493
  this.getPersistedBody(existing),
@@ -1471,10 +1499,11 @@ export class SmartdataExactCollection<
1471
1499
  }
1472
1500
 
1473
1501
  let result: plugins.mongodb.InsertOneResult;
1502
+ const insertOptions = { session: sessionArg, ...deadlineArg.commandOptions() };
1474
1503
  try {
1475
1504
  result = await this.collection.mongoDbCollection.insertOne(
1476
1505
  expectedStoredDocument,
1477
- { session: sessionArg },
1506
+ insertOptions,
1478
1507
  );
1479
1508
  } catch (errorArg) {
1480
1509
  if (isTransientTransactionError(errorArg, sessionArg)) {
@@ -1490,9 +1519,12 @@ export class SmartdataExactCollection<
1490
1519
  }
1491
1520
  throw errorArg;
1492
1521
  }
1522
+ if (deadlineArg.interrupted(errorArg)) {
1523
+ throw this.interruptedWriteError('Exact insert', errorArg);
1524
+ }
1493
1525
  let reconciled: TStoredDocument<TPersisted> | null;
1494
1526
  try {
1495
- reconciled = await this.findByReconcileIdentity(identity, sessionArg);
1527
+ reconciled = await this.findByReconcileIdentity(identity, sessionArg, deadlineArg);
1496
1528
  } catch (reconciliationError) {
1497
1529
  if (isTransientTransactionError(reconciliationError, sessionArg)) {
1498
1530
  throw reconciliationError;
@@ -1544,7 +1576,7 @@ export class SmartdataExactCollection<
1544
1576
  }
1545
1577
  let reconciled: TStoredDocument<TPersisted> | null;
1546
1578
  try {
1547
- reconciled = await this.findByReconcileIdentity(identity, sessionArg);
1579
+ reconciled = await this.findByReconcileIdentity(identity, sessionArg, deadlineArg);
1548
1580
  } catch (errorArg) {
1549
1581
  if (isTransientTransactionError(errorArg, sessionArg)) {
1550
1582
  throw errorArg;
@@ -1592,15 +1624,15 @@ export class SmartdataExactCollection<
1592
1624
 
1593
1625
  public async findStoredOne(
1594
1626
  filterArg: MongoFilter<TStoredDocument<TPersisted>>,
1595
- optionsArg: IExactReadOptions = {},
1627
+ optionsArg: IExactBoundedOptions = {},
1596
1628
  ): Promise<TStoredDocument<TPersisted> | null> {
1597
- return this.runOperation(optionsArg, async (sessionArg) => {
1629
+ return this.runOperation(optionsArg, async (sessionArg, deadlineArg) => {
1598
1630
  const cursor = this.collection.mongoDbCollection.find(
1599
1631
  convertFilterForMongoDb(filterArg),
1600
1632
  {
1601
1633
  session: sessionArg,
1602
- signal: optionsArg.signal,
1603
1634
  singleBatch: true,
1635
+ ...deadlineArg.cursorOptions(),
1604
1636
  },
1605
1637
  ).limit(1);
1606
1638
  const nativeCursorClose = cursor.close.bind(cursor);
@@ -1625,17 +1657,16 @@ export class SmartdataExactCollection<
1625
1657
  }
1626
1658
  await cursor.close();
1627
1659
  return storedDocument;
1628
- }, optionsArg.signal);
1660
+ });
1629
1661
  }
1630
1662
 
1631
1663
  public async findStored(
1632
1664
  optionsArg: IFindStoredOptions<TPersisted> = {},
1633
1665
  ): Promise<Array<TStoredDocument<TPersisted>>> {
1634
- optionsArg.signal?.throwIfAborted();
1635
- return this.runOperation(optionsArg, async (sessionArg) => {
1666
+ return this.runOperation(optionsArg, async (sessionArg, deadlineArg) => {
1636
1667
  let cursor = this.collection.mongoDbCollection.find(
1637
1668
  convertFilterForMongoDb(optionsArg.filter || {}),
1638
- { session: sessionArg, signal: optionsArg.signal },
1669
+ { session: sessionArg, ...deadlineArg.cursorOptions() },
1639
1670
  );
1640
1671
  // The driver abort listener does not await close, so every close must join
1641
1672
  // the same native cleanup before runOperation releases its session lease.
@@ -1670,17 +1701,17 @@ export class SmartdataExactCollection<
1670
1701
  }
1671
1702
  await cursor.close();
1672
1703
  return storedDocuments;
1673
- }, optionsArg.signal);
1704
+ });
1674
1705
  }
1675
1706
 
1676
1707
  public async count(
1677
1708
  filterArg: MongoFilter<TStoredDocument<TPersisted>> = {},
1678
- optionsArg: IExactSessionOptions = {},
1709
+ optionsArg: IExactBoundedOptions = {},
1679
1710
  ): Promise<number> {
1680
- return this.runOperation(optionsArg, async (sessionArg) =>
1711
+ return this.runOperation(optionsArg, async (sessionArg, deadlineArg) =>
1681
1712
  this.collection.mongoDbCollection.countDocuments(
1682
1713
  convertFilterForMongoDb(filterArg),
1683
- { session: sessionArg },
1714
+ { session: sessionArg, ...deadlineArg.commandOptions() },
1684
1715
  )
1685
1716
  );
1686
1717
  }
@@ -1690,15 +1721,15 @@ export class SmartdataExactCollection<
1690
1721
  >(
1691
1722
  fieldArg: TField,
1692
1723
  filterArg: MongoFilter<TStoredDocument<TPersisted>> = {},
1693
- optionsArg: IExactSessionOptions = {},
1724
+ optionsArg: IExactBoundedOptions = {},
1694
1725
  ): Promise<Array<TExactDistinctValue<
1695
1726
  TExactDocumentPathValue<TStoredDocument<TPersisted>, TField>
1696
1727
  >>> {
1697
- return this.runOperation(optionsArg, async (sessionArg) =>
1728
+ return this.runOperation(optionsArg, async (sessionArg, deadlineArg) =>
1698
1729
  (await this.collection.mongoDbCollection.distinct(
1699
1730
  fieldArg as string,
1700
1731
  convertFilterForMongoDb(filterArg),
1701
- { session: sessionArg },
1732
+ { session: sessionArg, ...deadlineArg.commandOptions() },
1702
1733
  )) as Array<TExactDistinctValue<
1703
1734
  TExactDocumentPathValue<TStoredDocument<TPersisted>, TField>
1704
1735
  >>
@@ -1710,9 +1741,9 @@ export class SmartdataExactCollection<
1710
1741
  current: TStoredDocument<TPersisted>;
1711
1742
  change: (modelArg: TModel) => void;
1712
1743
  },
1713
- sessionOptionsArg: IExactSessionOptions = {},
1744
+ sessionOptionsArg: IExactBoundedOptions = {},
1714
1745
  ): Promise<TExactTransitionResult<TPersisted>> {
1715
- return this.runOperation(sessionOptionsArg, async (sessionArg) => {
1746
+ return this.runOperation(sessionOptionsArg, async (sessionArg, deadlineArg) => {
1716
1747
  const current = this.canonicalizeStoredDocument(optionsArg.current);
1717
1748
  const currentBody = this.getPersistedBody(current);
1718
1749
  const currentIdentity = this.validateReconcileIdentity(currentBody);
@@ -1756,16 +1787,18 @@ export class SmartdataExactCollection<
1756
1787
  ? { _id: current._id, _smartdataRevision: current._smartdataRevision }
1757
1788
  : { _id: current._id, _smartdataRevision: { $exists: false } };
1758
1789
 
1790
+ const replaceOptions = {
1791
+ returnDocument: 'after' as const,
1792
+ includeResultMetadata: false as const,
1793
+ upsert: false,
1794
+ session: sessionArg,
1795
+ ...deadlineArg.commandOptions(),
1796
+ };
1759
1797
  try {
1760
1798
  const postimage = await this.collection.mongoDbCollection.findOneAndReplace(
1761
1799
  selector,
1762
1800
  expectedStoredDocument,
1763
- {
1764
- returnDocument: 'after',
1765
- includeResultMetadata: false,
1766
- upsert: false,
1767
- session: sessionArg,
1768
- },
1801
+ replaceOptions,
1769
1802
  );
1770
1803
  if (!postimage) {
1771
1804
  return { status: 'concurrent_change' };
@@ -1792,10 +1825,13 @@ export class SmartdataExactCollection<
1792
1825
  if (hasActiveTransaction(sessionArg)) {
1793
1826
  throw errorArg;
1794
1827
  }
1828
+ if (deadlineArg.interrupted(errorArg)) {
1829
+ throw this.interruptedWriteError('Exact transition', errorArg);
1830
+ }
1795
1831
  try {
1796
1832
  const reconciledRaw = await this.collection.mongoDbCollection.findOne({
1797
1833
  _id: current._id,
1798
- }, { session: sessionArg });
1834
+ }, { session: sessionArg, ...deadlineArg.commandOptions() });
1799
1835
  if (reconciledRaw) {
1800
1836
  const reconciled = this.canonicalizeStoredDocument(reconciledRaw);
1801
1837
  if (
@@ -1826,10 +1862,10 @@ export class SmartdataExactCollection<
1826
1862
  optionsArg: {
1827
1863
  current: TStoredDocument<TPersisted>;
1828
1864
  },
1829
- sessionOptionsArg: IExactSessionOptions = {},
1865
+ sessionOptionsArg: IExactBoundedOptions = {},
1830
1866
  ): Promise<TExactDeleteResult> {
1831
1867
  const { current, identity } = this.canonicalizeDeleteCurrent(optionsArg);
1832
- return this.runOperation(sessionOptionsArg, async (sessionArg) => {
1868
+ return this.runOperation(sessionOptionsArg, async (sessionArg, deadlineArg) => {
1833
1869
  const selector = current._smartdataRevision !== undefined
1834
1870
  ? {
1835
1871
  _id: current._id,
@@ -1842,10 +1878,9 @@ export class SmartdataExactCollection<
1842
1878
  _smartdataRevision: { $exists: false },
1843
1879
  };
1844
1880
  let result: plugins.mongodb.DeleteResult;
1881
+ const deleteOptions = { session: sessionArg, ...deadlineArg.commandOptions() };
1845
1882
  try {
1846
- result = await this.collection.mongoDbCollection.deleteOne(selector, {
1847
- session: sessionArg,
1848
- });
1883
+ result = await this.collection.mongoDbCollection.deleteOne(selector, deleteOptions);
1849
1884
  } catch (errorArg) {
1850
1885
  if (isTransientTransactionError(errorArg, sessionArg)) {
1851
1886
  throw errorArg;
@@ -1853,7 +1888,10 @@ export class SmartdataExactCollection<
1853
1888
  if (hasActiveTransaction(sessionArg)) {
1854
1889
  throw errorArg;
1855
1890
  }
1856
- return this.reconcileDeleteOutcome(current, identity, errorArg, sessionArg);
1891
+ if (deadlineArg.interrupted(errorArg)) {
1892
+ throw this.interruptedWriteError('Exact delete', errorArg);
1893
+ }
1894
+ return this.reconcileDeleteOutcome(current, identity, errorArg, sessionArg, deadlineArg);
1857
1895
  }
1858
1896
  if (
1859
1897
  result.acknowledged === true &&
@@ -1874,11 +1912,11 @@ export class SmartdataExactCollection<
1874
1912
  result,
1875
1913
  );
1876
1914
  }
1877
- return this.reconcileDeleteOutcome(current, identity, result, sessionArg);
1915
+ return this.reconcileDeleteOutcome(current, identity, result, sessionArg, deadlineArg);
1878
1916
  }
1879
1917
 
1880
1918
  try {
1881
- await this.findByReconcileIdentity(identity, sessionArg);
1919
+ await this.findByReconcileIdentity(identity, sessionArg, deadlineArg);
1882
1920
  } catch (errorArg) {
1883
1921
  if (isTransientTransactionError(errorArg, sessionArg)) {
1884
1922
  throw errorArg;
@@ -0,0 +1,179 @@
1
+ import * as plugins from './plugins.js';
2
+ import {
3
+ SmartdataPersistenceError,
4
+ createSmartdataTimeoutError,
5
+ isMongoDeadlineError,
6
+ } from './classes.persistence.js';
7
+
8
+ const deadlineExceededMessage = 'SmartData operation exceeded its client deadline.';
9
+
10
+ /** Validates a caller's client deadline: a positive safe integer of at most 120000 ms. */
11
+ export const requireOperationTimeoutMS = (
12
+ valueArg: unknown,
13
+ labelArg: string,
14
+ ): number | undefined => {
15
+ if (valueArg === undefined) {
16
+ return undefined;
17
+ }
18
+ if (
19
+ typeof valueArg !== 'number'
20
+ || !Number.isSafeInteger(valueArg)
21
+ || valueArg < 1
22
+ || valueArg > 120_000
23
+ ) {
24
+ throw new SmartdataPersistenceError(
25
+ 'invalid_argument',
26
+ `${labelArg} must be a positive safe integer no greater than 120000.`,
27
+ );
28
+ }
29
+ return valueArg;
30
+ };
31
+
32
+ /** Validates a caller's cancellation signal: a genuine `AbortSignal`. */
33
+ export const requireOperationSignal = (
34
+ valueArg: unknown,
35
+ labelArg: string,
36
+ ): AbortSignal | undefined => {
37
+ if (valueArg === undefined) {
38
+ return undefined;
39
+ }
40
+ if (plugins.nodeUtil.types.isProxy(valueArg) || !(valueArg instanceof AbortSignal)) {
41
+ throw new SmartdataPersistenceError('invalid_argument', `${labelArg} must be an AbortSignal.`);
42
+ }
43
+ return valueArg;
44
+ };
45
+
46
+ /**
47
+ * @internal The client-side deadline and cancellation of one model API call.
48
+ *
49
+ * Every database command of the call receives the time that is left as the
50
+ * driver's `timeoutMS`, so server selection, connection checkout, the round
51
+ * trip and the server-side `maxTimeMS` the driver derives all run on one
52
+ * clock, and the driver closes a connection whose reply did not arrive in
53
+ * time. An unbounded deadline hands the driver nothing, which leaves an
54
+ * enclosing transaction deadline or the connection's own settings in charge.
55
+ *
56
+ * The call's `signal` — its own, an enclosing transaction's, or both — goes
57
+ * to the driver with every command, which stops waiting for server
58
+ * selection or a pooled connection and closes a connection whose command is
59
+ * in flight, rejecting with the signal's reason.
60
+ */
61
+ export class SmartdataOperationDeadline {
62
+ public static readonly unbounded = new SmartdataOperationDeadline(undefined);
63
+
64
+ private readonly expiresAt: number | undefined;
65
+
66
+ constructor(
67
+ timeoutMSArg: number | undefined,
68
+ public readonly signal?: AbortSignal,
69
+ ) {
70
+ this.expiresAt = timeoutMSArg === undefined
71
+ ? undefined
72
+ : performance.now() + timeoutMSArg;
73
+ }
74
+
75
+ public get bounded(): boolean {
76
+ return this.expiresAt !== undefined;
77
+ }
78
+
79
+ /** Throws the signal's reason once the call was aborted. */
80
+ public throwIfAborted(): void {
81
+ this.signal?.throwIfAborted();
82
+ }
83
+
84
+ /**
85
+ * Driver options for one command. Throws the abort reason once the call was
86
+ * aborted and `timeout` once the deadline has passed, so a command these
87
+ * options were built for was never handed to the driver.
88
+ */
89
+ public commandOptions(): { timeoutMS?: number } & plugins.mongodb.Abortable {
90
+ this.throwIfAborted();
91
+ return {
92
+ ...(this.expiresAt === undefined ? {} : { timeoutMS: this.remainingMS() }),
93
+ ...(this.signal === undefined ? {} : { signal: this.signal }),
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Driver options for a cursor the deadline bounds from its first command to
99
+ * its last reply. When a `getMore` times out, the driver's own cleanup runs
100
+ * the cursor's `killCursors` on a fresh deadline of the full `timeoutMS`
101
+ * before it rethrows, and no driver option shortens that, so a stalled
102
+ * database settles a multi-batch read within about twice the deadline.
103
+ */
104
+ public cursorOptions(): {
105
+ timeoutMS?: number;
106
+ timeoutMode?: 'cursorLifetime';
107
+ } & plugins.mongodb.Abortable {
108
+ this.throwIfAborted();
109
+ return {
110
+ ...(this.expiresAt === undefined
111
+ ? {}
112
+ : { timeoutMS: this.remainingMS(), timeoutMode: 'cursorLifetime' as const }),
113
+ ...(this.signal === undefined ? {} : { signal: this.signal }),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Whether `errorArg` is this call's interruption: its signal's abort reason
119
+ * or the driver's deadline expiry.
120
+ */
121
+ public interrupted(errorArg: unknown): boolean {
122
+ return (this.signal?.aborted === true && errorArg === this.signal.reason)
123
+ || isMongoDeadlineError(errorArg);
124
+ }
125
+
126
+ /**
127
+ * Waits within the deadline for work another owner drives, such as the
128
+ * collection initialization every model call shares. Expiry or an abort
129
+ * ends only this call's wait: the owner keeps the work, its outcome and its
130
+ * cleanup, and the next caller joins the same work again.
131
+ */
132
+ public async join(workArg: Promise<void>): Promise<void> {
133
+ this.throwIfAborted();
134
+ if (this.expiresAt === undefined && this.signal === undefined) {
135
+ await workArg;
136
+ return;
137
+ }
138
+ const remainingMS = this.expiresAt === undefined ? undefined : this.remainingMS();
139
+ let timer: ReturnType<typeof setTimeout> | undefined;
140
+ let onAbort: (() => void) | undefined;
141
+ let expired = false;
142
+ const interruption = new Promise<void>((resolveArg) => {
143
+ if (remainingMS !== undefined) {
144
+ timer = setTimeout(() => {
145
+ expired = true;
146
+ resolveArg();
147
+ }, remainingMS);
148
+ }
149
+ if (this.signal !== undefined) {
150
+ onAbort = () => resolveArg();
151
+ this.signal.addEventListener('abort', onAbort, { once: true });
152
+ }
153
+ });
154
+ // The owner observes the work's failure itself; this wait must not turn a
155
+ // failure it stopped waiting for into an unhandled rejection.
156
+ const settled = workArg.then(() => undefined, () => undefined);
157
+ try {
158
+ await Promise.race([settled, interruption]);
159
+ } finally {
160
+ clearTimeout(timer);
161
+ if (onAbort !== undefined) {
162
+ this.signal?.removeEventListener('abort', onAbort);
163
+ }
164
+ }
165
+ this.throwIfAborted();
166
+ if (expired) {
167
+ throw createSmartdataTimeoutError(deadlineExceededMessage);
168
+ }
169
+ await workArg;
170
+ }
171
+
172
+ private remainingMS(): number {
173
+ const remainingMS = this.expiresAt! - performance.now();
174
+ if (remainingMS <= 0) {
175
+ throw createSmartdataTimeoutError(deadlineExceededMessage);
176
+ }
177
+ return Math.ceil(remainingMS);
178
+ }
179
+ }
@@ -6,7 +6,8 @@ export type TSmartdataPersistenceErrorCode =
6
6
  | 'invalid_document'
7
7
  | 'unique_conflict'
8
8
  | 'ambiguous_write'
9
- | 'unsupported_operation';
9
+ | 'unsupported_operation'
10
+ | 'timeout';
10
11
 
11
12
  /**
12
13
  * Stable errors for ordinary SmartData model configuration and persistence.
@@ -27,6 +28,40 @@ export class SmartdataPersistenceError extends Error {
27
28
  }
28
29
  }
29
30
 
31
+ /**
32
+ * The client-side deadline of a model call or transaction expired. The
33
+ * driver's own `MongoOperationTimeoutError` stays available as `cause` when
34
+ * the driver enforced it. A write or commit that expired has an unknown
35
+ * outcome: it may have been applied before the reply was lost.
36
+ */
37
+ export const createSmartdataTimeoutError = (
38
+ messageArg: string,
39
+ causeArg?: unknown,
40
+ ): SmartdataPersistenceError =>
41
+ new SmartdataPersistenceError(
42
+ 'timeout',
43
+ messageArg,
44
+ causeArg === undefined ? undefined : { cause: causeArg },
45
+ );
46
+
47
+ /**
48
+ * The driver enforced a client deadline. A bulk write reports a driver error
49
+ * wrapped in `MongoBulkWriteError`, whose `errorResponse` is the original
50
+ * error and whose result still states what earlier batches wrote.
51
+ */
52
+ export const isMongoDeadlineError = (errorArg: unknown): boolean =>
53
+ errorArg instanceof plugins.mongodb.MongoOperationTimeoutError
54
+ || (
55
+ errorArg instanceof plugins.mongodb.MongoBulkWriteError
56
+ && errorArg.errorResponse instanceof plugins.mongodb.MongoOperationTimeoutError
57
+ );
58
+
59
+ /** Maps an expired driver deadline to `timeout`; every other error passes through unchanged. */
60
+ export const normalizeDeadlineError = (errorArg: unknown): unknown =>
61
+ isMongoDeadlineError(errorArg)
62
+ ? createSmartdataTimeoutError('SmartData operation exceeded its client deadline.', errorArg)
63
+ : errorArg;
64
+
30
65
  export const isMongoTransactionRetryError = (errorArg: unknown): boolean =>
31
66
  errorArg instanceof plugins.mongodb.MongoServerError &&
32
67
  (