@lossless.org/client 1.10.0 → 1.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lossless.org/client",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "private": false,
5
5
  "description": "One typed client for NoSQLDB, MongoDB, SQLDB, MariaDB, ClickHouse and S3 object storage.",
6
6
  "exports": {
@@ -85,7 +85,7 @@
85
85
  "@git.zone/tsrun": "3.0.0",
86
86
  "@git.zone/tstest": "6.3.0",
87
87
  "@lossless.org/nosqldb": "8.0.2",
88
- "@lossless.org/nosqldb-qualified": "npm:@lossless.org/nosqldb@10.7.0",
88
+ "@lossless.org/nosqldb-qualified": "npm:@lossless.org/nosqldb@10.7.1",
89
89
  "@lossless.org/objectstorage": "10.3.1",
90
90
  "@lossless.org/sqldb": "0.2.3",
91
91
  "@push.rocks/qenv": "^6.1.3",
package/readme.md CHANGED
@@ -102,7 +102,10 @@ Family imports preserve the established SmartData and SmartBucket names and cons
102
102
  | Migration-owned indexes | `toleratedIndexNames` on the model — named undeclared indexes are observed, never created, dropped or verified, and no longer make the topology divergent |
103
103
  | Partial index filters in inspection | `inspectNamespace()`, `inspectCollectionTopology()` and `getIndexInfo()` report a partial index's `partialFilterExpression` exactly as the backend lists it, as a frozen snapshot present only on a partial index |
104
104
  | Key-value store readiness | `EasyStore.ensureInitialized()` — installs the store's declared index at startup, outside any transaction window, so a first `readKey()`/`writeKey()` needs no index DDL |
105
+ | Key-value store copies | `EasyStore` `readKey()`/`readAll()` return copies and `writeKey()`/`writeAll()`/`replace()` store copies, in the persisted BSON form, so a read result is a stable snapshot and a caller-side mutation never reaches the store |
105
106
  | Client deadlines | `timeoutMS` (1..120000) on every ordinary model read and write, instance `save()`/`saveIf()`/`delete()` included, on every exact call, and `session.withTransaction(callback, { timeoutMS })` for a whole transaction, retries and commit included; expiry rejects with `SmartdataPersistenceError` code `timeout`, and an exact write interrupted in flight with `ambiguous_write` |
107
+ | Bounded database close | `db.close({ timeoutMs })` bounds the whole close — session cleanup, the optional database removal and the driver client's close — and rejects at its deadline with `SmartdataPersistenceError` code `timeout`, leaving the database `close_failed`; the driver's close keeps running until it settles on its own, and the next `close()` joins it |
108
+ | Transactions ended by close | a `session.withTransaction()` whose session `db.close()` ends while it runs rejects with `SmartdataPersistenceError` code `unsupported_operation` — "nothing was committed" before the commit, "the commit outcome is unknown" during it — instead of resolving with the callback's result |
106
109
  | Cancellation | `signal` on every exact call, `atomicDeleteMany()` and `session.withTransaction(callback, { signal })`, which interrupts the session's operation in flight, rolls the attempt back and never commits after an abort |
107
110
  | Disposable test databases on a unix socket | `SmartdataTestDatabase.create({ connection: { socketPath } })` from `@lossless.org/client/testsupport` reaches an engine that listens only on a unix-domain socket, fault helpers included; the loopback `host`/`port` form is unchanged |
108
111
 
@@ -119,7 +122,7 @@ A bucket lookup sends one HeadBucket for the named bucket, never ListBuckets, so
119
122
 
120
123
  ### Document APIs that need a MongoDB backend
121
124
 
122
- The document model runs on both NoSQLDB and MongoDB, but several APIs do not mean the same thing on both. The NoSQLDB engine is qualified at 8.0.2 and 10.7.0 (see [Verification](#verification)); the two releases part on the grouped-totals and guarded-`$expr` rows below — 10.7.0 groups composite keys exactly and evaluates the guard, 8.0.2 does neither — on whether a migration can create a partial index or a collection validator at all, and on when a transaction learns of a write conflict.
125
+ The document model runs on both NoSQLDB and MongoDB, but several APIs do not mean the same thing on both. The NoSQLDB engine is qualified at 8.0.2 and 10.7.1 (see [Verification](#verification)); the two releases part on the grouped-totals and guarded-`$expr` rows below — 10.7.1 groups composite keys exactly and evaluates the guard, 8.0.2 does neither — on whether a migration can create a partial index or a collection validator at all, on when a transaction learns of a write conflict, and on whether `$elemMatch` applies query operators to scalar array elements.
123
126
 
124
127
  `init()` asks the connected engine what it is with one `buildInfo` command and records the answer on the database as `engineIdentity` (`{ product, version }`). NoSQLDB answers with its own `nosqldb` identity document from 10.3.0 onward; a MongoDB server answers `buildInfo` without one; an engine that refuses the command — NoSQLDB 8.0.2 answers `CommandNotFound` — stays `{ product: 'unknown', version: null }`. The identity is what states `capabilities` on a plain `SmartdataDb`, and an unidentified engine states none at all, so every capability-gated call refuses. `NoSqlConnection` keeps stating the capabilities of its declared backend from construction on.
125
128
 
@@ -129,12 +132,13 @@ The document model runs on both NoSQLDB and MongoDB, but several APIs do not mea
129
132
  | the same with **two** `groupBy` fields | exact, `sums` included, on an engine that identifies itself as NoSQLDB 10.5.0 or newer: that release evaluates the composite group key per document. Below it the call is refused with `unsupported_operation` naming both fields and the single-field form, because the engine leaves the composite group key unresolved and answers with a single row whose group values are the literal field paths (`'$owner'`) and whose count is the whole filtered collection; the refusal is the guard against that wrong number, not a fix for it | exact |
130
133
  | guarded `$expr` atomic filters (`$add`, `$lte`, `$ifNull`) | exact on 10.6.0 and newer: the engine evaluates the client's fail-closed compilation (`$cond`, `$let`, `$isNumber`, `$type`), so the guard admits within the limit, refuses beyond it and never matches a stored value that is not a finite number. 8.0.2 rejects the compilation with `invalid argument: $cond` and leaves the document untouched | exact |
131
134
  | `watch()` change streams | unsupported. `NoSqlConnection` refuses with `unsupported_capability`, and so does a plain `SmartdataDb` whose engine identified itself; against an engine that answers no identity the refusal instead arrives from the engine as `CommandNotFound` once the change stream is read | supported on replica sets |
132
- | model-declared partial indexes (`partialFilterExpression`) | not expressible: the index-option type rejects it and the runtime refuses it with `invalid_configuration`. A client decision, not an engine limit on every release — 10.6.0 and newer create partial indexes, 8.0.2 refuses the option with `InvalidOptions` (72) — because one model declaration has to hold on every qualified backend. A migration may create one on 10.7.0; `toleratedIndexNames` accepts it and inspection reports its filter | not expressible, but a migration may own one; inspection reports its filter |
133
- | collection validators (`validator`, `validationLevel`, `validationAction`) | no client API; a migration owns a validator through the driver. 10.7.0 creates the collection, lists the options exactly as given and enforces them on every model write: a violating `insert()` or `atomicUpdate()` rejects with the engine's `DocumentValidationFailure` (121) and its `errInfo`, which the client passes through unchanged rather than mapping to a `SmartdataPersistenceError` code, and stores nothing. Models initialize on a validated collection like on any other. 8.0.2 refuses the option with `InvalidOptions` (72) and creates no collection, so a validator is never silently dropped | enforced; not exercised by the client's MongoDB suite |
134
- | write conflicts between transactions, and writes outside a transaction to a document a transaction holds | 10.7.0 (since 10.6.1) detects a conflict at the statement, as MongoDB does: the first writer wins, and the later transaction's statement rejects with the engine's `WriteConflict` (112) labelled `TransientTransactionError`, which the client passes through so `session.withTransaction()` re-runs that callback. A write outside a transaction waits until the transaction holding its document ends, then applies on top; the wait is bounded by the write's own `timeoutMS` and by the engine's fixed 60-second transaction lifetime, after which a waiting writer aborts the holder. Awaiting such a write inside the holding transaction's callback therefore stalls that transaction until one of the two expires. 8.0.2 detects the conflict at commit: the first transaction to commit wins, a write outside a transaction applies at once, and the transaction that wrote first re-runs. Both writes land on either engine; a callback must be safe to re-run and must not swallow the statement error | first writer wins at the statement and outside writes wait, MongoDB's documented rule; not exercised by the client's MongoDB suite |
135
+ | model-declared partial indexes (`partialFilterExpression`) | not expressible: the index-option type rejects it and the runtime refuses it with `invalid_configuration`. A client decision, not an engine limit on every release — 10.6.0 and newer create partial indexes, 8.0.2 refuses the option with `InvalidOptions` (72) — because one model declaration has to hold on every qualified backend. A migration may create one on 10.7.1; `toleratedIndexNames` accepts it and inspection reports its filter | not expressible, but a migration may own one; inspection reports its filter |
136
+ | collection validators (`validator`, `validationLevel`, `validationAction`) | no client API; a migration owns a validator through the driver. 10.7.0 and newer create the collection, list the options exactly as given and enforce them on every model write: a violating `insert()` or `atomicUpdate()` rejects with the engine's `DocumentValidationFailure` (121) and its `errInfo`, which the client passes through unchanged rather than mapping to a `SmartdataPersistenceError` code, and stores nothing. Models initialize on a validated collection like on any other. 8.0.2 refuses the option with `InvalidOptions` (72) and creates no collection, so a validator is never silently dropped | enforced; not exercised by the client's MongoDB suite |
137
+ | write conflicts between transactions, and writes outside a transaction to a document a transaction holds | 10.7.1 (since 10.6.1) detects a conflict at the statement, as MongoDB does: the first writer wins, and the later transaction's statement rejects with the engine's `WriteConflict` (112) labelled `TransientTransactionError`, which the client passes through so `session.withTransaction()` re-runs that callback. A write outside a transaction waits until the transaction holding its document ends, then applies on top; the wait is bounded by the write's own `timeoutMS` and by the engine's fixed 60-second transaction lifetime, after which a waiting writer aborts the holder. Awaiting such a write inside the holding transaction's callback therefore stalls that transaction until one of the two expires. 8.0.2 detects the conflict at commit: the first transaction to commit wins, a write outside a transaction applies at once, and the transaction that wrote first re-runs. Both writes land on either engine; a callback must be safe to re-run and must not swallow the statement error | first writer wins at the statement and outside writes wait, MongoDB's documented rule; not exercised by the client's MongoDB suite |
138
+ | value-form `$elemMatch` in read filters (`{ scores: { $elemMatch: { $gte: 80, $lt: 90 } } }`, `{ labels: { $not: { $elemMatch: { $nin: runnerLabels } } } }`) | exact on 10.7.1: the operators apply to each array element as a whole, so the range matches only a document with one score inside it and the claim selects a job only when every one of its labels is a runner label. 8.0.2 parses every operand as a query over embedded documents and refuses the value form with `TypeMismatch` (14). A non-document operand is refused on both, with MongoDB's `BadValue` (2) on 10.7.1 and 14 on 8.0.2. The client passes the operand through unchanged; the filter type admits the value form over scalar elements and the document form over embedded documents. Atomic filters (`atomicUpdate()` and the other atomic writes) accept no `$elemMatch` on any backend: the client refuses it with `invalid_argument` before any backend is contacted | exact |
135
139
  | aggregation-pipeline (array) updates | refused by the client with `invalid_argument` before any backend is contacted. A client-owned refusal, not an engine limit: both qualified engines apply a pipeline update themselves | same client refusal |
136
140
 
137
- `test/nosqldb/smartdb/test.engine-capability-boundary.node.ts` asserts the identity, grouped-totals, guarded-`$expr`, partial-index, collection-validator, write-conflict and pipeline-update rows against both qualified engines, so a boundary that moves shows up as a failing suite rather than as a changed number in a consumer. The two halves of the grouped-totals, guarded-`$expr` and write-conflict rows are separate tests, each bound to the engine that answers it, and the engine's own composite-key answer stays under assertion through the raw `aggregateGroupedTotals()` boundary — one literal row on 8.0.2, the two exact pairs on 10.7.0. The same boundary pins the rest of the engine's `$group` stage: a non-document accumulator is refused with `TypeMismatch` (14) on 10.7.0 while 8.0.2 answers it, and a `$cond` inside a composite key is evaluated per document on 10.7.0 while 8.0.2 answers its single literal row. It also pins whether a migration can create a partial index — 10.7.0 creates it and lists the filter, 8.0.2 refuses with 72 — and a collection validator — 10.7.0 creates and enforces it against model inserts and updates, 8.0.2 refuses with 72 — and which writer a conflict sends back: on 10.7.0 the later transaction fails at its statement and re-runs while a write outside a transaction waits for the holder's commit, on 8.0.2 the later transaction commits first and the holder re-runs. The `watch()` row is the contract refusal `ts/nosqldb/classes.collection.ts` states; the engine suite opens no change stream.
141
+ `test/nosqldb/smartdb/test.engine-capability-boundary.node.ts` asserts the identity, grouped-totals, guarded-`$expr`, partial-index, collection-validator, write-conflict, `$elemMatch` and pipeline-update rows against both qualified engines, so a boundary that moves shows up as a failing suite rather than as a changed number in a consumer. The two halves of the grouped-totals, guarded-`$expr`, write-conflict and `$elemMatch` rows are separate tests, each bound to the engine that answers it, and the engine's own composite-key answer stays under assertion through the raw `aggregateGroupedTotals()` boundary — one literal row on 8.0.2, the two exact pairs on 10.7.1. The same boundary pins the rest of the engine's `$group` stage: a non-document accumulator is refused with `TypeMismatch` (14) on 10.7.1 while 8.0.2 answers it, and a `$cond` inside a composite key is evaluated per document on 10.7.1 while 8.0.2 answers its single literal row. It also pins whether a migration can create a partial index — 10.7.1 creates it and lists the filter, 8.0.2 refuses with 72 — and a collection validator — 10.7.1 creates and enforces it against model inserts and updates, 8.0.2 refuses with 72 — which writer a conflict sends back: on 10.7.1 the later transaction fails at its statement and re-runs while a write outside a transaction waits for the holder's commit, on 8.0.2 the later transaction commits first and the holder re-runs — and what `$elemMatch` answers through `getInstances()`: 10.7.1 matches the documented range and the `$not`/`$elemMatch`/`$nin` claim exactly as the MongoDB filter suite asserts them, 8.0.2 refuses both with 14, and a non-document operand is refused with 2 on 10.7.1 and 14 on 8.0.2. The `watch()` row is the contract refusal `ts/nosqldb/classes.collection.ts` states; the engine suite opens no change stream.
138
142
 
139
143
  ## Relational SQL
140
144
 
@@ -214,14 +218,14 @@ Qualified engines:
214
218
 
215
219
  | Component | Qualified versions |
216
220
  | --- | --- |
217
- | NoSQLDB engine | **8.0.2 and 10.7.0** — the same `test/nosqldb/smartdb/` suite runs against both, through the single engine seam in `test/nosqldb/helpers/smartdb.ts`; `NOSQLDB_ENGINE=qualified` selects 10.7.0 |
221
+ | NoSQLDB engine | **8.0.2 and 10.7.1** — the same `test/nosqldb/smartdb/` suite runs against both, through the single engine seam in `test/nosqldb/helpers/smartdb.ts`; `NOSQLDB_ENGINE=qualified` selects 10.7.1 |
218
222
  | MongoDB | 8.0.26 replica sets |
219
223
  | ObjectStorage | 10.3.1 |
220
224
  | SQLDB | 0.2.3 |
221
225
  | MariaDB | 11.8 |
222
226
  | ClickHouse | 25.8 |
223
227
 
224
- Both engine versions run the whole document suite. They part on asserted boundaries — 10.7.0 groups composite keys exactly and answers two-field grouped totals, evaluates guarded `$expr` filters, creates migration-owned partial indexes and collection validators and reports a transaction write conflict at the statement, 8.0.2 does none of these, reports the conflict at commit, and the client refuses the two-field form there — which [Document APIs that need a MongoDB backend](#document-apis-that-need-a-mongodb-backend) states row by row. Text search, the Lucene adapter and the broad read-filter surface are exercised against MongoDB only. `pnpm run test:minio` uses a digest-pinned MinIO image: `sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`. That MinIO version enforces the tested conditional uploads but does not enforce conditional deletion; its exact-purge capability is correctly unavailable. ObjectStorage passes both live probes. AWS S3 has not been qualified against a live account in this migration; no universal S3 exact-operation guarantee is inferred from the SDK.
228
+ Both engine versions run the whole document suite. They part on asserted boundaries — 10.7.1 groups composite keys exactly and answers two-field grouped totals, evaluates guarded `$expr` filters, creates migration-owned partial indexes and collection validators, reports a transaction write conflict at the statement and matches value-form `$elemMatch`, 8.0.2 does none of these, reports the conflict at commit, and the client refuses the two-field form there — which [Document APIs that need a MongoDB backend](#document-apis-that-need-a-mongodb-backend) states row by row. Text search, the Lucene adapter and the broad read-filter surface are exercised against MongoDB only. `pnpm run test:minio` uses a digest-pinned MinIO image: `sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`. That MinIO version enforces the tested conditional uploads but does not enforce conditional deletion; its exact-purge capability is correctly unavailable. ObjectStorage passes both live probes. AWS S3 has not been qualified against a live account in this migration; no universal S3 exact-operation guarantee is inferred from the SDK.
225
229
 
226
230
  ## License and Legal Information
227
231
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@lossless.org/client',
6
- version: '1.10.0',
6
+ version: '1.12.0',
7
7
  description: 'One typed client for NoSQLDB, MongoDB, SQLDB, MariaDB, ClickHouse and S3 object storage.'
8
8
  }
@@ -2,7 +2,11 @@ import * as plugins from './plugins.js';
2
2
 
3
3
  import { SmartdataCollection } from './classes.collection.js';
4
4
  import { EasyStore } from './classes.easystore.js';
5
- import { SmartdataPersistenceError } from './classes.persistence.js';
5
+ import {
6
+ createSmartdataTimeoutError,
7
+ isMongoDeadlineError,
8
+ SmartdataPersistenceError,
9
+ } from './classes.persistence.js';
6
10
  import { SmartdataMongoAdministration } from './classes.mongoadministration.js';
7
11
  import {
8
12
  createSmartdataSession,
@@ -78,7 +82,14 @@ export interface ISmartdataInitOptions {
78
82
  }
79
83
 
80
84
  export interface ISmartdataCloseOptions {
81
- /** Total deadline for removal, including preceding session cleanup (1..120000 ms). */
85
+ /**
86
+ * Deadline for the whole close (1..120000 ms): session cleanup, the optional
87
+ * database removal and the driver client's close. When it expires the call
88
+ * rejects with `SmartdataPersistenceError` code `timeout` and the database is
89
+ * `close_failed`. Cleanup still running at that point is not abandoned: it
90
+ * stays owned by this `SmartdataDb`, and the next `close()` joins it.
91
+ * Without `timeoutMs`, close waits for every cleanup to settle.
92
+ */
82
93
  timeoutMs?: number;
83
94
  dropDatabase?: {
84
95
  expectedDatabaseName: string;
@@ -120,6 +131,9 @@ type TSmartdataDbLifecycleState =
120
131
 
121
132
  type TSmartdataDbLifecycleOperation = 'init' | 'close';
122
133
 
134
+ /** How the driver's close of an owned client ended, kept until a close() has observed it. */
135
+ type TSmartdataClientCloseOutcome = { failed: false } | { failed: true; error: unknown };
136
+
123
137
  const readinessDefaultTimeoutMs = 2_000;
124
138
  const readinessMaximumTimeoutMs = 30_000;
125
139
 
@@ -177,6 +191,8 @@ export class SmartdataDb {
177
191
  private lifecycleEpoch = 0;
178
192
  private activeLifecycleOperation: TSmartdataDbLifecycleOperation | null = null;
179
193
  private pendingClient?: plugins.mongodb.MongoClient;
194
+ /** The driver's close of the owned client until a close() has observed its outcome. */
195
+ private clientClosing?: Promise<TSmartdataClientCloseOutcome>;
180
196
  private engineIdentityState: ISmartdataEngineIdentity = unidentifiedSmartdataEngine;
181
197
  private capabilitiesState: ICapabilities | undefined;
182
198
 
@@ -327,7 +343,8 @@ export class SmartdataDb {
327
343
  }
328
344
 
329
345
  /**
330
- * closes the connection to the databse
346
+ * Closes the connection to the database. `timeoutMs` bounds the whole call;
347
+ * see `ISmartdataCloseOptions`.
331
348
  */
332
349
  public async close(optionsArg: ISmartdataCloseOptions = {}): Promise<void> {
333
350
  this.assertLifecycleOperationCanBegin('close');
@@ -341,7 +358,14 @@ export class SmartdataDb {
341
358
  try {
342
359
  const closeErrors: unknown[] = [];
343
360
  try {
344
- await invalidateSmartdataSessions(this);
361
+ const invalidation = invalidateSmartdataSessions(this, budget?.cleanupOptions());
362
+ // A session whose cleanup outlives the deadline stays owned and invalidated
363
+ // until the next close() ends it. Its abort already carries the same
364
+ // deadline, so a late failure can only be that deadline, which this close
365
+ // reports as `timeout`.
366
+ invalidation.catch(() => {});
367
+ if (budget) await budget.waitFor(invalidation);
368
+ await invalidation;
345
369
  } catch (errorArg) {
346
370
  closeErrors.push(
347
371
  ...(unwrapSmartdataSessionInvalidationErrors(errorArg) ?? [errorArg]),
@@ -360,16 +384,28 @@ export class SmartdataDb {
360
384
  closeErrors.push(errorArg);
361
385
  }
362
386
  }
363
- try {
364
- await mongoDbClient?.close();
365
- this.pendingClient = undefined;
366
- } catch (errorArg) {
367
- closeErrors.push(errorArg);
387
+ if (mongoDbClient) {
388
+ const clientClosing = this.closeOwnedClient(mongoDbClient);
389
+ try {
390
+ if (budget) await budget.waitFor(clientClosing);
391
+ // Settled in time: this close observes the outcome, and a later one starts afresh.
392
+ const outcome = await clientClosing;
393
+ this.clientClosing = undefined;
394
+ if (outcome.failed) throw outcome.error;
395
+ this.pendingClient = undefined;
396
+ } catch (errorArg) {
397
+ closeErrors.push(errorArg);
398
+ }
368
399
  }
369
400
  if (closeErrors.length > 0) {
370
401
  this.transitionLifecycle('close_failed');
371
- if (budget) throw new SmartdataPersistenceError('unsupported_operation',
372
- 'SmartData database cleanup failed; close must be retried.');
402
+ if (budget) {
403
+ throw budget.expired() || closeErrors.some(isMongoDeadlineError)
404
+ ? createSmartdataTimeoutError(
405
+ 'SmartData database close exceeded its deadline; close must be retried.')
406
+ : new SmartdataPersistenceError('unsupported_operation',
407
+ 'SmartData database cleanup failed; close must be retried.');
408
+ }
373
409
  if (closeErrors.length === 1) {
374
410
  throw closeErrors[0];
375
411
  }
@@ -653,6 +689,22 @@ export class SmartdataDb {
653
689
  return this.mongoDb;
654
690
  }
655
691
 
692
+ /**
693
+ * Starts the driver's close of an owned client, or joins the one a previous
694
+ * close() started. The driver offers no deadline for it, so a close that
695
+ * outlived its caller keeps running, and its outcome — a rejection included —
696
+ * waits here for the next close() instead of being lost.
697
+ */
698
+ private closeOwnedClient(
699
+ clientArg: plugins.mongodb.MongoClient,
700
+ ): Promise<TSmartdataClientCloseOutcome> {
701
+ this.clientClosing ??= clientArg.close().then(
702
+ (): TSmartdataClientCloseOutcome => ({ failed: false }),
703
+ (errorArg: unknown): TSmartdataClientCloseOutcome => ({ failed: true, error: errorArg }),
704
+ );
705
+ return this.clientClosing;
706
+ }
707
+
656
708
  private endLifecycleOperation(operationArg: TSmartdataDbLifecycleOperation): void {
657
709
  if (this.activeLifecycleOperation === operationArg) {
658
710
  this.activeLifecycleOperation = null;
@@ -561,7 +561,24 @@ export type TSmartdataOptionalNullPredicate<TValue> =
561
561
  type InValues<T> = ReadonlyArray<ElementOf<T> | TSmartdataOptionalNullPredicate<T>>;
562
562
 
563
563
  // Type that allows MongoDB operators on leaf values while maintaining nested type safety
564
- export type MongoFilterCondition<T> = T | TSmartdataOptionalNullPredicate<T> | {
564
+ export type MongoFilterCondition<T> =
565
+ | T
566
+ | TSmartdataOptionalNullPredicate<T>
567
+ | TSmartdataFilterOperators<T>;
568
+
569
+ /**
570
+ * The operand of `$elemMatch`: a query on each element, the document form, or
571
+ * an operator document applied to each element as a whole, the value form
572
+ * (`{ $gte: 80, $lt: 90 }`). Elements that are all documents take only the
573
+ * document form; any other element type takes either, so element unions that
574
+ * include documents, nullable documents and `unknown` keep the document form.
575
+ */
576
+ type TSmartdataElemMatchOperand<TElement> = [TElement] extends [object]
577
+ ? MongoFilter<TElement>
578
+ : MongoFilter<TElement> | TSmartdataFilterOperators<TElement>;
579
+
580
+ /** The operator document of a field condition. */
581
+ type TSmartdataFilterOperators<T> = {
565
582
  $eq?: T | TSmartdataOptionalNullPredicate<T>;
566
583
  $ne?: T | TSmartdataOptionalNullPredicate<T>;
567
584
  $gt?: T;
@@ -575,7 +592,7 @@ export type MongoFilterCondition<T> = T | TSmartdataOptionalNullPredicate<T> | {
575
592
  $regex?: string | RegExp;
576
593
  $options?: string;
577
594
  $all?: T extends ReadonlyArray<infer U> ? ReadonlyArray<U> : never;
578
- $elemMatch?: T extends ReadonlyArray<infer U> ? MongoFilter<U> : never;
595
+ $elemMatch?: T extends ReadonlyArray<infer U> ? TSmartdataElemMatchOperand<U> : never;
579
596
  $size?: T extends ReadonlyArray<any> ? number : never;
580
597
  $not?: MongoFilterCondition<T>;
581
598
  };
@@ -34,6 +34,24 @@ const getEasyStoreClassForDb = <T>(smartdataDbArg: SmartdataDb) => {
34
34
  return SmartdataEasyStore;
35
35
  };
36
36
 
37
+ /**
38
+ * Copies a store value into the form the store persists: undefined object
39
+ * properties are dropped, an undefined array element becomes null and every
40
+ * value takes its BSON form, exactly as a save and a later read from the
41
+ * database do. The copy shares no object with the argument, so neither side can
42
+ * change the other afterwards. A value BSON cannot encode (a cycle, for
43
+ * example) throws here, before the store's cache is touched.
44
+ */
45
+ const copyInPersistedForm = <TValue>(valueArg: TValue): TValue => {
46
+ if (valueArg === undefined) {
47
+ return valueArg;
48
+ }
49
+ const document = plugins.mongodb.BSON.deserialize(
50
+ plugins.mongodb.BSON.serialize({ value: valueArg }, { ignoreUndefined: true }),
51
+ );
52
+ return document.value as TValue;
53
+ };
54
+
37
55
  /**
38
56
  * EasyStore allows the storage of easy objects. It also allows easy sharing of the object between different instances
39
57
  */
@@ -98,65 +116,131 @@ export class EasyStore<T> {
98
116
  }
99
117
 
100
118
  /**
101
- * reads all keyValue pairs at once and returns them
119
+ * Runs store operations one at a time, in the order they were called. Every
120
+ * read and write of this instance goes through here, so a write builds its
121
+ * next state from the state every earlier write left, and a read never sees
122
+ * the state of a write whose save has not resolved yet. A rejected operation
123
+ * does not stop the ones queued behind it.
124
+ */
125
+ private operationQueue: Promise<void> = Promise.resolve();
126
+ private runExclusive<TResult>(operationArg: () => Promise<TResult>): Promise<TResult> {
127
+ const result = this.operationQueue.then(operationArg);
128
+ this.operationQueue = result.then(
129
+ () => undefined,
130
+ () => undefined,
131
+ );
132
+ return result;
133
+ }
134
+
135
+ /**
136
+ * Persists `nextDataArg` as the store's data. The document's `save()` writes
137
+ * the instance's own fields, so the next state is installed for the save and
138
+ * the previous one is put back when the save rejects: a failed write leaves
139
+ * the cache as it was. Callers hold the operation queue.
102
140
  */
103
- public async readAll() {
104
- const easyStore = await this.getEasyStore();
105
- return easyStore.data;
141
+ private async commit(
142
+ easyStoreArg: InstanceType<typeof this.easyStoreClass>,
143
+ nextDataArg: Partial<T>,
144
+ ): Promise<void> {
145
+ const previousData = easyStoreArg.data;
146
+ easyStoreArg.data = nextDataArg;
147
+ try {
148
+ await easyStoreArg.save();
149
+ } catch (error) {
150
+ easyStoreArg.data = previousData;
151
+ throw error;
152
+ }
106
153
  }
107
154
 
108
155
  /**
109
- * reads a keyValueFile from disk
156
+ * reads all keyValue pairs at once and returns them as a copy in the
157
+ * persisted form: mutating the result does not change the store, and later
158
+ * writes do not change the result.
159
+ */
160
+ public async readAll(): Promise<Partial<T>> {
161
+ return this.runExclusive(async () => {
162
+ const easyStore = await this.getEasyStore();
163
+ return copyInPersistedForm(easyStore.data);
164
+ });
165
+ }
166
+
167
+ /**
168
+ * reads one key and returns a copy of its value in the persisted form, or
169
+ * `undefined` when the key is absent
110
170
  */
111
171
  public async readKey<TKey extends keyof T>(keyArg: TKey): Promise<Partial<T>[TKey]> {
112
- const easyStore = await this.getEasyStore();
113
- return easyStore.data[keyArg];
172
+ return this.runExclusive(async () => {
173
+ const easyStore = await this.getEasyStore();
174
+ return copyInPersistedForm(easyStore.data[keyArg]);
175
+ });
114
176
  }
115
177
 
116
178
  /**
117
- * writes a specific key to the keyValueStore
179
+ * writes a specific key to the keyValueStore. The store keeps a copy, so
180
+ * mutating `valueArg` afterwards does not change the store. When the save
181
+ * rejects, the store keeps its previous state.
118
182
  */
119
183
  public async writeKey<TKey extends keyof T>(keyArg: TKey, valueArg: T[TKey]) {
120
- const easyStore = await this.getEasyStore();
121
- easyStore.data[keyArg] = valueArg;
122
- await easyStore.save();
184
+ const value = copyInPersistedForm(valueArg);
185
+ await this.runExclusive(async () => {
186
+ const easyStore = await this.getEasyStore();
187
+ const nextData: Partial<T> = { ...easyStore.data };
188
+ nextData[keyArg] = value;
189
+ await this.commit(easyStore, nextData);
190
+ });
123
191
  }
124
192
 
193
+ /**
194
+ * deletes a key. When the save rejects, the store keeps its previous state.
195
+ */
125
196
  public async deleteKey(keyArg: keyof T) {
126
- const easyStore = await this.getEasyStore();
127
- delete easyStore.data[keyArg];
128
- await easyStore.save();
197
+ await this.runExclusive(async () => {
198
+ const easyStore = await this.getEasyStore();
199
+ const nextData: Partial<T> = { ...easyStore.data };
200
+ delete nextData[keyArg];
201
+ await this.commit(easyStore, nextData);
202
+ });
129
203
  }
130
204
 
131
205
  /**
132
206
  * merges all keyValue pairs from the object argument into the store.
133
207
  * Existing keys that are not present in `keyValueObject` are preserved.
134
208
  * To overwrite the entire store and drop missing keys, use `replace()`.
209
+ * The store keeps a copy, so mutating `keyValueObject` afterwards does not
210
+ * change the store. When the save rejects, the store keeps its previous state.
135
211
  */
136
212
  public async writeAll(keyValueObject: Partial<T>) {
137
- const easyStore = await this.getEasyStore();
138
- easyStore.data = { ...easyStore.data, ...keyValueObject };
139
- await easyStore.save();
213
+ const values = copyInPersistedForm(keyValueObject);
214
+ await this.runExclusive(async () => {
215
+ const easyStore = await this.getEasyStore();
216
+ await this.commit(easyStore, { ...easyStore.data, ...values });
217
+ });
140
218
  }
141
219
 
142
220
  /**
143
221
  * atomically replaces the entire store with the given object.
144
222
  * Unlike `writeAll` (which merges), `replace` clears any keys not
145
223
  * present in `keyValueObject`. Useful when you need to drop a key.
224
+ * The store keeps a copy, so mutating `keyValueObject` afterwards does not
225
+ * change the store. When the save rejects, the store keeps its previous state.
146
226
  */
147
227
  public async replace(keyValueObject: Partial<T>) {
148
- const easyStore = await this.getEasyStore();
149
- easyStore.data = { ...keyValueObject };
150
- await easyStore.save();
228
+ const values = copyInPersistedForm(keyValueObject);
229
+ await this.runExclusive(async () => {
230
+ const easyStore = await this.getEasyStore();
231
+ await this.commit(easyStore, values);
232
+ });
151
233
  }
152
234
 
153
235
  /**
154
- * wipes a key value store from disk
236
+ * wipes a key value store from disk. When the save rejects, the store keeps
237
+ * its previous state.
155
238
  */
156
239
  public async wipe() {
157
- const easyStore = await this.getEasyStore();
158
- easyStore.data = {};
159
- await easyStore.save();
240
+ await this.runExclusive(async () => {
241
+ const easyStore = await this.getEasyStore();
242
+ await this.commit(easyStore, {});
243
+ });
160
244
  }
161
245
 
162
246
  public async cleanUpEphemeral() {
@@ -46,6 +46,12 @@ export class SmartdataOperationBudget {
46
46
  }
47
47
  }
48
48
 
49
+ /** Whether the deadline itself has passed; a caller's signal does not count. */
50
+ public expired(): boolean {
51
+ if (performance.now() >= this.expiresAt) this.controller.abort();
52
+ return this.controller.signal.aborted;
53
+ }
54
+
49
55
  public remainingMs(): number {
50
56
  this.check();
51
57
  return this.cleanupOptions().timeoutMS;