@lossless.org/client 2.4.1 → 2.6.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.
@@ -9,6 +9,7 @@ import { registerCollectionReconnectHandler } from './classes.collectionlifecycl
9
9
  import {
10
10
  SmartdataSession,
11
11
  acquireSmartdataSession,
12
+ isOrdinarySessionInTransaction,
12
13
  needsSmartdataWriteTransaction,
13
14
  readPreviousWriteRevisions,
14
15
  reportSmartdataCollectionWrite,
@@ -19,6 +20,11 @@ import {
19
20
  trackSmartdataRead,
20
21
  } from './classes.session.js';
21
22
  import { runSmartdataWrite } from './classes.readtracker.js';
23
+ import {
24
+ type ISmartdataEngineIdentity,
25
+ readFenceEngineRelease,
26
+ smartdataReadFence,
27
+ } from './engineidentity.js';
22
28
  import {
23
29
  type SmartdataOperationDeadline,
24
30
  requireOperationSignal,
@@ -173,6 +179,22 @@ export type TExactDeleteResult =
173
179
  deletedCount: 0;
174
180
  };
175
181
 
182
+ /**
183
+ * The result of an exact read fence: the identity is fenced for the commit of
184
+ * the session's active transaction.
185
+ */
186
+ export type TExactReadFenceResult = {
187
+ status: 'fenced';
188
+ };
189
+
190
+ /**
191
+ * A model's identity in the form `findStoredOne()` takes it: its `reconcileBy`
192
+ * field and the value, `{ [reconcileBy]: value }`.
193
+ */
194
+ export type TExactIdentity<TPersisted extends object> = Partial<
195
+ Record<TExactIdentityKey<TPersisted>, string>
196
+ >;
197
+
176
198
  export type TExactPersistenceErrorCode =
177
199
  | 'ambiguous_write'
178
200
  | 'invalid_configuration'
@@ -236,7 +258,7 @@ export interface IExactReadOptions extends IExactSessionOptions {
236
258
 
237
259
  /**
238
260
  * Options of every exact call: `insert()`, `transition()`, `delete()`,
239
- * `count()`, `distinct()` and `findStoredOne()`, and through
261
+ * `readFence()`, `count()`, `distinct()` and `findStoredOne()`, and through
240
262
  * `IFindStoredOptions` `findStored()`.
241
263
  */
242
264
  export interface IExactBoundedOptions extends IExactReadOptions {
@@ -2132,8 +2154,106 @@ export class SmartdataExactCollection<
2132
2154
  );
2133
2155
  });
2134
2156
  }
2157
+
2158
+ /**
2159
+ * Fences a model identity for the commit of the session's active
2160
+ * transaction, whether a document holds it or not: the engine refuses the
2161
+ * commit as a transient write conflict, so `withTransaction()` re-runs the
2162
+ * attempt, when after the transaction's snapshot a committed write inserted
2163
+ * a document with that identity, or changed, deleted or re-inserted the one
2164
+ * holding it. Fences claim nothing: two transactions that fence one
2165
+ * identity both commit, and a fence never makes a writer wait or fail.
2166
+ *
2167
+ * `identityArg` names the identity as `findStoredOne()` does,
2168
+ * `{ [reconcileBy]: value }`, and is sent as one key fence on the model's
2169
+ * unique `reconcileBy` index, which preparation creates. Refused with
2170
+ * `unsupported_operation` before any command is sent without a session in an
2171
+ * active transaction, for a collection that is not prepared, and on an
2172
+ * engine that is not identified as NoSQLDB 10.11.0 or later.
2173
+ */
2174
+ public async readFence(
2175
+ identityArg: TExactIdentity<TPersisted>,
2176
+ sessionOptionsArg: IExactBoundedOptions = {},
2177
+ ): Promise<TExactReadFenceResult> {
2178
+ const identity = this.readFenceIdentity(identityArg);
2179
+ const session = sessionOptionsArg.session;
2180
+ if (!session || !isOrdinarySessionInTransaction(session)) {
2181
+ throw new SmartdataExactPersistenceError(
2182
+ 'unsupported_operation',
2183
+ 'Exact readFence needs a session in an active transaction: a fence only guards a transaction commit.',
2184
+ );
2185
+ }
2186
+ const engine = this.collection.smartdataDb.engineIdentity;
2187
+ if (!smartdataReadFence(engine)) {
2188
+ throw new SmartdataExactPersistenceError(
2189
+ 'unsupported_operation',
2190
+ `Exact readFence needs NoSQLDB ${readFenceEngineRelease} or later; `
2191
+ + `the connected engine is ${describeEngine(engine)}.`,
2192
+ );
2193
+ }
2194
+ return this.runOperation(sessionOptionsArg, async (sessionArg, deadlineArg) => {
2195
+ // A transient error, such as an aborted transaction, reaches the
2196
+ // driver's withTransaction unchanged.
2197
+ await this.collection.smartdataDb.mongoDb.command(
2198
+ {
2199
+ readFence: this.collection.mongoDbCollection.collectionName,
2200
+ key: this.reconcileBy,
2201
+ values: [identity],
2202
+ },
2203
+ { session: sessionArg, ...deadlineArg.commandOptions() },
2204
+ );
2205
+ return { status: 'fenced' };
2206
+ });
2207
+ }
2208
+
2209
+ /**
2210
+ * Validates `{ [reconcileBy]: value }` without invoking accessors or proxies:
2211
+ * exactly one enumerable data property, the model's `reconcileBy`, holding
2212
+ * a nonempty string.
2213
+ */
2214
+ private readFenceIdentity(identityArg: unknown): string {
2215
+ if (
2216
+ typeof identityArg !== 'object' ||
2217
+ identityArg === null ||
2218
+ plugins.nodeUtil.types.isProxy(identityArg) ||
2219
+ !isPlainObject(identityArg)
2220
+ ) {
2221
+ throw new SmartdataExactPersistenceError(
2222
+ 'invalid_document',
2223
+ `Exact readFence takes the identity as { ${this.reconcileBy}: value }.`,
2224
+ );
2225
+ }
2226
+ const keys = Reflect.ownKeys(identityArg);
2227
+ const descriptor = Object.getOwnPropertyDescriptor(identityArg, this.reconcileBy);
2228
+ if (
2229
+ keys.length !== 1 ||
2230
+ keys[0] !== this.reconcileBy ||
2231
+ !descriptor ||
2232
+ !('value' in descriptor) ||
2233
+ descriptor.enumerable !== true ||
2234
+ typeof descriptor.value !== 'string' ||
2235
+ descriptor.value.trim().length === 0
2236
+ ) {
2237
+ throw new SmartdataExactPersistenceError(
2238
+ 'invalid_document',
2239
+ `Exact readFence takes the identity as one inert { ${this.reconcileBy}: nonempty string } property.`,
2240
+ );
2241
+ }
2242
+ return descriptor.value;
2243
+ }
2135
2244
  }
2136
2245
 
2246
+ const describeEngine = (identityArg: ISmartdataEngineIdentity): string => {
2247
+ switch (identityArg.product) {
2248
+ case 'NoSQLDB':
2249
+ return `NoSQLDB ${identityArg.version ?? 'of an unstated version'}`;
2250
+ case 'MongoDB':
2251
+ return 'MongoDB';
2252
+ case 'unknown':
2253
+ return 'unidentified';
2254
+ }
2255
+ };
2256
+
2137
2257
  export function exactPersistence<
2138
2258
  TAssertion extends TExactDocumentAssertion<object>,
2139
2259
  >(
@@ -38,6 +38,13 @@ const compositeGroupingEngineRelease = '10.5.0';
38
38
  */
39
39
  const commitJournalEngineRelease = '10.9.0';
40
40
 
41
+ /**
42
+ * The NoSQLDB release that answers the `readFence` transaction statement:
43
+ * @lossless.org/nosqldb 10.11.0. A minimum release, like every gate here:
44
+ * every later release supports it.
45
+ */
46
+ export const readFenceEngineRelease = '10.11.0';
47
+
41
48
  const parseEngineVersion = (versionArg: string): readonly number[] | undefined => {
42
49
  const parts = versionArg.split('.');
43
50
  if (parts.length !== 3) {
@@ -107,6 +114,17 @@ export const smartdataCommitJournal = (identityArg: ISmartdataEngineIdentity): b
107
114
  && identityArg.version !== null
108
115
  && engineVersionReaches(identityArg.version, commitJournalEngineRelease);
109
116
 
117
+ /**
118
+ * Whether the identified engine answers `readFence`: fences documents a
119
+ * transaction read so that its commit is refused when one of them changed
120
+ * after the transaction's snapshot. Only NoSQLDB does, from 10.11.0 on;
121
+ * MongoDB has no such statement, and an unidentified engine enables nothing.
122
+ */
123
+ export const smartdataReadFence = (identityArg: ISmartdataEngineIdentity): boolean =>
124
+ identityArg.product === 'NoSQLDB'
125
+ && identityArg.version !== null
126
+ && engineVersionReaches(identityArg.version, readFenceEngineRelease);
127
+
110
128
  export const composeSmartdataCapabilities = (
111
129
  backendArg: TSmartdataEngineBackend,
112
130
  compositeGroupingArg: TCompositeGrouping,
@@ -935,14 +935,21 @@ export class Bucket {
935
935
  }
936
936
 
937
937
  /**
938
- * Creates an object from a stream while enforcing the declared byte length
939
- * and create-only destination semantics.
938
+ * Stores an object from a stream while enforcing the declared byte length
939
+ * (or the maximum of an undeclared one) and the destination mode: create-only
940
+ * with a `capability` from probeExactUploadCapability(), or replace with
941
+ * `overwrite: true`, which sends no `If-None-Match` and needs no capability.
942
+ * A replaced object stays readable and unchanged until the final
943
+ * CompleteMultipartUpload (or the PutObject of an empty source) replaces it
944
+ * in one step.
940
945
  *
941
946
  * Non-empty inputs always use multipart upload and never fall back to a
942
- * single streaming PutObject request. Memory is bounded by the returned
947
+ * single streaming PutObject request, so each part is its own request,
948
+ * bounded by `requestTimeoutMs` on its own. Memory is bounded by the returned
943
949
  * multipart plan. Every source chunk must be no larger than the effective
944
950
  * multipart part size (5 MiB by default, or requestedPartSize after protocol
945
- * normalization).
951
+ * normalization). Any failure aborts the multipart upload instead of
952
+ * completing it.
946
953
  */
947
954
  public fastPutStreamExact(
948
955
  optionsArg: interfaces.IFastPutStreamExactOptions,
@@ -6,10 +6,10 @@ import type {
6
6
  const phaseMessages: Record<TExactUploadFailurePhase, string> = {
7
7
  capability: 'The exact upload capability is invalid or stale.',
8
8
  source: 'The exact upload source could not be consumed.',
9
- 'put-zero': 'The conditional zero-byte object upload failed.',
9
+ 'put-zero': 'The zero-byte object upload failed.',
10
10
  'create-multipart': 'The multipart upload could not be created.',
11
11
  'upload-part': 'A multipart upload part failed.',
12
- 'complete-multipart': 'The conditional multipart completion failed.',
12
+ 'complete-multipart': 'The multipart completion failed.',
13
13
  };
14
14
 
15
15
  export class ExactUploadError extends Error {
@@ -214,7 +214,7 @@ declare const exactUploadCapabilityBrand: unique symbol;
214
214
 
215
215
  /**
216
216
  * Opaque proof that the current bucket provider enforces the conditional
217
- * operations required by Bucket.fastPutStreamExact().
217
+ * operations required by a create-only Bucket.fastPutStreamExact().
218
218
  *
219
219
  * Capabilities can only be minted by Bucket.probeExactUploadCapability().
220
220
  * They are bound to one exact Bucket object and its current SmartBucket,
@@ -262,7 +262,25 @@ export interface IExactUploadNativeObjectProperties {
262
262
  }
263
263
 
264
264
  export interface IFastPutStreamExactOptions {
265
- capability: IExactUploadCapability;
265
+ /**
266
+ * Create-only upload: a capability from
267
+ * Bucket.probeExactUploadCapability(). The PutObject and
268
+ * CompleteMultipartUpload are sent with `If-None-Match: *`, so the upload
269
+ * fails instead of replacing an existing object. Exactly one of
270
+ * `capability` and `overwrite: true` must be given.
271
+ */
272
+ capability?: IExactUploadCapability;
273
+ /**
274
+ * Replace upload: `true` stores the object whether or not the key exists,
275
+ * with the same multipart plan, part bounds, buffering limit, failure
276
+ * phases and abort-on-failure cleanup as a create-only upload, but without
277
+ * `If-None-Match` and without a capability. An existing object stays
278
+ * readable and unchanged until the final CompleteMultipartUpload (or the
279
+ * PutObject of an empty source) replaces it in one step; a failure before
280
+ * that request leaves it as it was. Exactly one of `capability` and
281
+ * `overwrite: true` must be given.
282
+ */
283
+ overwrite?: boolean;
266
284
  path: string;
267
285
  /**
268
286
  * Every emitted chunk must be no larger than the effective multipart part
@@ -294,6 +312,10 @@ export interface IFastPutStreamExactOptions {
294
312
  signal?: AbortSignal;
295
313
  }
296
314
 
315
+ /**
316
+ * The step an exact upload failed in. A replace upload (`overwrite: true`)
317
+ * has no capability step and never fails in `'capability'`.
318
+ */
297
319
  export type TExactUploadFailurePhase =
298
320
  | 'capability'
299
321
  | 'source'
@@ -338,9 +360,16 @@ export interface IExactUploadFailureState {
338
360
  maxContentLength?: number;
339
361
  /**
340
362
  * Whether this operation can prove that it did not publish the destination.
341
- * A conditional PutObject or CompleteMultipartUpload response can be lost
342
- * after the provider has committed the object, so those failures are
343
- * intentionally reported as ambiguous.
363
+ * A PutObject or CompleteMultipartUpload response can be lost after the
364
+ * provider has committed the object, so those failures are intentionally
365
+ * reported as ambiguous. A create-only upload reports a definitive
366
+ * conditional rejection (HTTP 409 or 412) as not published.
367
+ *
368
+ * For a replace upload (`overwrite: true`), `'not-published'` means the
369
+ * destination is as it was before the upload, the old object included, and
370
+ * `'ambiguous'` means either the old object or the new one is visible; every
371
+ * failed PutObject or CompleteMultipartUpload that was sent is ambiguous,
372
+ * since no precondition was sent whose rejection would prove otherwise.
344
373
  */
345
374
  destinationPublication: 'not-published' | 'ambiguous';
346
375
  multipartUploadId?: string;
@@ -1,6 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { ExactUploadError } from './classes.exactuploaderror.js';
3
3
  import type {
4
+ IExactUploadCapability,
4
5
  IExactUploadCapabilityProbeOptions,
5
6
  IExactUploadResult,
6
7
  IFastPutStreamExactOptions,
@@ -10,6 +11,7 @@ import type {
10
11
  TExactUploadMultipartCleanup,
11
12
  } from './interfaces.js';
12
13
  import {
14
+ beginBasicBucketOperation,
13
15
  beginExactUploadCapabilityOperation,
14
16
  beginExactUploadProbeOperation,
15
17
  mintExactUploadCapability,
@@ -1345,10 +1347,48 @@ const planExactUpload = (optionsArg: IFastPutStreamExactOptions) => {
1345
1347
  };
1346
1348
  };
1347
1349
 
1348
- const sendConditionalZeroPut = (
1350
+ type TExactUploadDestinationMode =
1351
+ | {
1352
+ replace: false;
1353
+ capability: IExactUploadCapability;
1354
+ }
1355
+ | {
1356
+ replace: true;
1357
+ };
1358
+
1359
+ /**
1360
+ * Reads the destination mode: create-only with a capability, or replace with
1361
+ * `overwrite: true`. Exactly one of the two must be given.
1362
+ */
1363
+ const resolveDestinationMode = (
1364
+ optionsArg: IFastPutStreamExactOptions,
1365
+ ): TExactUploadDestinationMode => {
1366
+ if (
1367
+ optionsArg.overwrite !== undefined
1368
+ && typeof optionsArg.overwrite !== 'boolean'
1369
+ ) {
1370
+ throw new TypeError('overwrite must be a boolean.');
1371
+ }
1372
+ const replace = optionsArg.overwrite === true;
1373
+ if (replace === (optionsArg.capability !== undefined)) {
1374
+ throw new TypeError(
1375
+ 'Exactly one of capability and overwrite: true must be given.',
1376
+ );
1377
+ }
1378
+ return optionsArg.capability === undefined
1379
+ ? { replace: true }
1380
+ : { replace: false, capability: optionsArg.capability };
1381
+ };
1382
+
1383
+ /**
1384
+ * Sends the PutObject of an empty source, create-only with
1385
+ * `If-None-Match: *` unless the upload replaces.
1386
+ */
1387
+ const sendZeroPut = (
1349
1388
  operationArg: IExactUploadOperation,
1350
1389
  optionsArg: IFastPutStreamExactOptions,
1351
1390
  contentTypeArg: string | undefined,
1391
+ replaceArg: boolean,
1352
1392
  onAttemptArg: () => void,
1353
1393
  ) => {
1354
1394
  return sendOperationCommand(
@@ -1363,7 +1403,7 @@ const sendConditionalZeroPut = (
1363
1403
  ContentLength: 0,
1364
1404
  Metadata: optionsArg.nativeMetadata,
1365
1405
  ContentType: contentTypeArg,
1366
- IfNoneMatch: '*',
1406
+ ...(replaceArg ? {} : { IfNoneMatch: '*' }),
1367
1407
  }),
1368
1408
  {
1369
1409
  abortSignal: signalArg,
@@ -1380,31 +1420,43 @@ export const fastPutStreamExact = async (
1380
1420
  if (typeof optionsArg.path !== 'string' || optionsArg.path.length === 0) {
1381
1421
  throw new TypeError('path must be a non-empty string.');
1382
1422
  }
1423
+ const destinationMode = resolveDestinationMode(optionsArg);
1424
+ const replace = destinationMode.replace;
1383
1425
  const nativeObjectProperties = snapshotNativeObjectProperties(
1384
1426
  optionsArg.nativeObjectProperties,
1385
1427
  );
1386
1428
  const { plan, lengthState } = planExactUpload(optionsArg);
1429
+ // Only a create-only upload sends a precondition, so only its definitive
1430
+ // rejection proves that the request did not publish the destination.
1431
+ const provesNotPublished = (errorArg: unknown): boolean =>
1432
+ !replace && isDefinitiveConditionalWriteRejection(errorArg);
1387
1433
 
1388
1434
  let operation: IExactUploadOperation;
1389
- try {
1390
- operation = beginExactUploadCapabilityOperation(
1391
- bucketRefArg,
1392
- optionsArg.capability,
1393
- optionsArg.signal,
1394
- );
1395
- } catch (error) {
1396
- throw new ExactUploadError(
1397
- {
1398
- phase: 'capability',
1399
- destinationPath: optionsArg.path,
1400
- ...lengthState,
1401
- destinationPublication: 'not-published',
1402
- multipartCleanup: {
1403
- status: 'not-required',
1435
+ if (destinationMode.replace) {
1436
+ // A replace upload has no capability step: it starts like every other
1437
+ // bucket request, which refuses an invalid signal or a closed SmartBucket.
1438
+ operation = beginBasicBucketOperation(bucketRefArg, optionsArg.signal);
1439
+ } else {
1440
+ try {
1441
+ operation = beginExactUploadCapabilityOperation(
1442
+ bucketRefArg,
1443
+ destinationMode.capability,
1444
+ optionsArg.signal,
1445
+ );
1446
+ } catch (error) {
1447
+ throw new ExactUploadError(
1448
+ {
1449
+ phase: 'capability',
1450
+ destinationPath: optionsArg.path,
1451
+ ...lengthState,
1452
+ destinationPublication: 'not-published',
1453
+ multipartCleanup: {
1454
+ status: 'not-required',
1455
+ },
1404
1456
  },
1405
- },
1406
- error,
1407
- );
1457
+ error,
1458
+ );
1459
+ }
1408
1460
  }
1409
1461
 
1410
1462
  let readableStream: ReadableStream<Uint8Array>;
@@ -1467,10 +1519,11 @@ export const fastPutStreamExact = async (
1467
1519
  }
1468
1520
 
1469
1521
  try {
1470
- const putOutput = await sendConditionalZeroPut(
1522
+ const putOutput = await sendZeroPut(
1471
1523
  operation,
1472
1524
  optionsArg,
1473
1525
  nativeObjectProperties?.contentType,
1526
+ replace,
1474
1527
  () => {
1475
1528
  putRequestAttempted = true;
1476
1529
  },
@@ -1494,8 +1547,7 @@ export const fastPutStreamExact = async (
1494
1547
  destinationPath: optionsArg.path,
1495
1548
  ...lengthState,
1496
1549
  destinationPublication:
1497
- putRequestAttempted &&
1498
- !isDefinitiveConditionalWriteRejection(error)
1550
+ putRequestAttempted && !provesNotPublished(error)
1499
1551
  ? 'ambiguous'
1500
1552
  : 'not-published',
1501
1553
  multipartCleanup: {
@@ -1638,10 +1690,11 @@ export const fastPutStreamExact = async (
1638
1690
  if (!multipartUploadCreation) {
1639
1691
  // Only an empty source of undeclared length reaches this point.
1640
1692
  phase = 'put-zero';
1641
- const putOutput = await sendConditionalZeroPut(
1693
+ const putOutput = await sendZeroPut(
1642
1694
  operation,
1643
1695
  optionsArg,
1644
1696
  nativeObjectProperties?.contentType,
1697
+ replace,
1645
1698
  () => {
1646
1699
  zeroPutAttempted = true;
1647
1700
  },
@@ -1670,7 +1723,7 @@ export const fastPutStreamExact = async (
1670
1723
  MultipartUpload: {
1671
1724
  Parts: completedParts,
1672
1725
  },
1673
- IfNoneMatch: '*',
1726
+ ...(replace ? {} : { IfNoneMatch: '*' }),
1674
1727
  MpuObjectSize: consumptionResult.bytesConsumed,
1675
1728
  }),
1676
1729
  {
@@ -1789,7 +1842,7 @@ export const fastPutStreamExact = async (
1789
1842
  (phase === 'complete-multipart' && completeRequestAttempted) ||
1790
1843
  (phase === 'put-zero' && zeroPutAttempted)
1791
1844
  ) &&
1792
- !isDefinitiveConditionalWriteRejection(error)
1845
+ !provesNotPublished(error)
1793
1846
  ? 'ambiguous'
1794
1847
  : 'not-published',
1795
1848
  multipartUploadId: uploadId,