@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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/nosqldb/classes.db.d.ts +19 -2
- package/dist_ts/nosqldb/classes.db.js +45 -12
- package/dist_ts/nosqldb/classes.doc.d.ts +12 -2
- package/dist_ts/nosqldb/classes.doc.js +1 -1
- package/dist_ts/nosqldb/classes.easystore.d.ts +34 -5
- package/dist_ts/nosqldb/classes.easystore.js +98 -24
- package/dist_ts/nosqldb/classes.operationbudget.d.ts +2 -0
- package/dist_ts/nosqldb/classes.operationbudget.js +7 -1
- package/dist_ts/nosqldb/classes.session.d.ts +11 -1
- package/dist_ts/nosqldb/classes.session.js +88 -14
- package/package.json +2 -2
- package/readme.md +11 -7
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.db.ts +63 -11
- package/ts/nosqldb/classes.doc.ts +19 -2
- package/ts/nosqldb/classes.easystore.ts +108 -24
- package/ts/nosqldb/classes.operationbudget.ts +6 -0
- package/ts/nosqldb/classes.session.ts +111 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lossless.org/client",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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.
|
|
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.
|
|
133
|
-
| collection validators (`validator`, `validationLevel`, `validationAction`) | no client API; a migration owns a validator through the driver. 10.7.0
|
|
134
|
-
| write conflicts between transactions, and writes outside a transaction to a document a transaction holds | 10.7.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/nosqldb/classes.db.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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)
|
|
372
|
-
|
|
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> =
|
|
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> ?
|
|
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
|
-
*
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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;
|