@lossless.org/client 1.22.1 → 1.23.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.bootstraprecord.js +4 -4
- package/dist_ts/nosqldb/classes.connection.d.ts +3 -0
- package/dist_ts/nosqldb/classes.connection.js +3 -2
- package/dist_ts/nosqldb/classes.db.d.ts +31 -0
- package/dist_ts/nosqldb/classes.db.js +45 -2
- package/dist_ts/nosqldb/classes.doc.js +5 -5
- package/dist_ts/nosqldb/classes.exactpersistence.d.ts +3 -2
- package/dist_ts/nosqldb/classes.exactpersistence.js +8 -7
- package/dist_ts/nosqldb/classes.readtracker.d.ts +6 -4
- package/dist_ts/nosqldb/classes.readtracker.js +42 -10
- package/dist_ts/nosqldb/classes.session.d.ts +7 -6
- package/dist_ts/nosqldb/classes.session.js +91 -23
- package/dist_ts/nosqldb/classes.writelog.d.ts +129 -0
- package/dist_ts/nosqldb/classes.writelog.js +497 -0
- package/dist_ts/nosqldb/classes.writeobserver.d.ts +13 -0
- package/dist_ts/nosqldb/classes.writeobserver.js +47 -25
- package/dist_ts/nosqldb/classes.writerevisions.d.ts +18 -7
- package/dist_ts/nosqldb/classes.writerevisions.js +179 -30
- package/dist_ts/nosqldb/engineidentity.d.ts +6 -0
- package/dist_ts/nosqldb/engineidentity.js +14 -1
- package/package.json +2 -2
- package/readme.md +11 -10
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.bootstraprecord.ts +3 -3
- package/ts/nosqldb/classes.connection.ts +5 -1
- package/ts/nosqldb/classes.db.ts +68 -0
- package/ts/nosqldb/classes.doc.ts +4 -4
- package/ts/nosqldb/classes.exactpersistence.ts +8 -7
- package/ts/nosqldb/classes.readtracker.ts +41 -7
- package/ts/nosqldb/classes.session.ts +107 -26
- package/ts/nosqldb/classes.writelog.ts +701 -0
- package/ts/nosqldb/classes.writeobserver.ts +62 -28
- package/ts/nosqldb/classes.writerevisions.ts +224 -32
- package/ts/nosqldb/engineidentity.ts +16 -0
package/readme.md
CHANGED
|
@@ -119,13 +119,14 @@ Family imports preserve the established SmartData and SmartBucket names and cons
|
|
|
119
119
|
| Grouped totals over nested paths | `getGroupedTotals()` / `getGroupedTotalsPage()` group by and sum nested paths declared through `atomicPaths` or `arrayPaths`, and `unwind: path` groups by each element of one declared array |
|
|
120
120
|
| Grouped totals over array elements | with `unwind: 'lines'`, `groupBy` and `sums` name declared fields of the unwound element — `groupBy: ['lines.accountNumber', 'lines.side']`, `sums: ['lines.amountCents']` — so per-account debit and credit totals need no raw aggregation; undeclared element fields and an `unwind` that holds no `groupBy` or `sums` path stay refused with `invalid_argument` |
|
|
121
121
|
| Grouped totals element filter | `elementFilter: { 'lines.accountNumber': '1200' }` beside `unwind: 'lines'` counts and sums only the matching elements — one account's balance per side — with the atomic-filter operators on the unwound array and its declared element paths; any other path or operator is refused with `invalid_argument` |
|
|
122
|
-
| Grouped totals paging | rows come ordered by group key; `getGroupedTotalsPage()` returns `nextCursor` while `hasMore`, `cursor` continues after it, and `iterateGroupedTotals()` reads every group page by page — exact across pages for null, number, string and boolean group values (proven on NoSQLDB 8.0.2, 10.
|
|
122
|
+
| Grouped totals paging | rows come ordered by group key; `getGroupedTotalsPage()` returns `nextCursor` while `hasMore`, `cursor` continues after it, and `iterateGroupedTotals()` reads every group page by page — exact across pages for null, number, string and boolean group values (proven on NoSQLDB 8.0.2, 10.9.0 and MongoDB; ObjectId and date values are ordered by BSON type order but not yet covered by the paging tests), refused with `unsupported_operation` for other values, and a cursor from another query is refused with `invalid_argument` |
|
|
123
123
|
| Query-plan inspection | `Model.inspectQueryPlan(db, { filter, sort?, projection?, limit?, timeoutMS?, signal? })` — the winning plan's stages (`stage`, `depth`, `indexName`, `direction`) and `nReturned`/`totalKeysExamined`/`totalDocsExamined`, read-only and bounded, so a consumer proves a model query uses its intended index without raw driver access |
|
|
124
124
|
| Per-instance model binding | `bindDocToManager(manager, Model)` returns a subclass of a model declared with `@managed()` and no manager argument, named like the model and bound to that manager, so several databases use the same models at once; any other binding is refused with `invalid_configuration` |
|
|
125
125
|
| Subclasses need their own binding | a subclass of a bound model that declares decorators or a persistence policy of its own and no `@Collection()`, `@managed()` or `defineCollectionModel()` binding is refused with `invalid_configuration` on every read and write, so it never writes through the parent's schema; an undecorated subclass keeps the inherited binding |
|
|
126
126
|
| Observing this process's writes | `db.observeWrites({ fields, listener })` — one frozen report per acknowledged model write, `{ collection, kind, documents, anchors }` with the requested top-level fields of the written documents and of the selector's equality anchors; writes inside a `db.createSession()` transaction are reported after the commit, never after a rollback; works on NoSQLDB, where `watch()` is unsupported |
|
|
127
127
|
| Following this process's reads | `db.trackReads(fn)` resolves `{ result, reads }`; `reads.changed` turns true once a write this process makes through the database's models could have changed what a model read of `fn` returned, judged by the read's query and the ids it returned, and a write it cannot place counts as one that did, so a cache keyed by `reads` rebuilds only what a write touched; works on NoSQLDB |
|
|
128
|
-
| Following every process's writes | with `new SmartdataDb({ ..., writeRevisions: { partitionField: 'organizationId' } })` in every writer, the awaited `reads.isCurrent()`
|
|
128
|
+
| Following every process's writes | with `new SmartdataDb({ ..., writeRevisions: { partitionField: 'organizationId' } })` in every writer, the awaited `reads.isCurrent()` counts changes per collection and partition in the write's transaction; confirmed writes of this database that leave `reads.changed` false receive exact revision allowances, while other writers and unknown outcomes stay conservative; a revision snapshot overlapping a local commit that touches its keys is unknowable; `db.invalidateTrackedReads()` states raw writes and migrations; each write inserts its own counter entry, so concurrent writers of one partition do not conflict on it, and every process of a database upgrades together; needs an engine identified as NoSQLDB, elsewhere `isCurrent()` is always false |
|
|
129
|
+
| Following committed writes in commit order | with `new SmartdataDb({ ..., writeLog: { fields: ['id', 'organizationId'] } })` in every writer, `db.followWrites({ fields, onWrite, onGap })` delivers each committed transaction once with its write reports: this instance's own commits at once (`origin: 'local'`), every other commit from the engine's commit journal in commit order (`origin: 'journal'`, with its `stamp`), within 250 ms while active and a second when idle; rollbacks and aborted retry attempts deliver nothing, raw writes name their collections in `unplaced`, and a follower that falls out of the journal's 15-minute retention or meets a restarted memory engine calls `onGap` and continues from the head; needs NoSQLDB 10.9.0, elsewhere the option is inert and following is refused with `unsupported_operation` |
|
|
129
130
|
| 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 |
|
|
130
131
|
|
|
131
132
|
`docs/source/smartdata/readme.md` documents each one with its refusals.
|
|
@@ -147,24 +148,24 @@ A bucket lookup sends one HeadBucket for the named bucket, never ListBuckets, so
|
|
|
147
148
|
|
|
148
149
|
### Document APIs that need a MongoDB backend
|
|
149
150
|
|
|
150
|
-
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.
|
|
151
|
+
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.9.0 (see [Verification](#verification)); the two releases part on the grouped-totals and guarded-`$expr` rows below — 10.9.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, on when a transaction learns of a write conflict, and on whether `$elemMatch` applies query operators to scalar array elements.
|
|
151
152
|
|
|
152
153
|
`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.
|
|
153
154
|
|
|
154
155
|
| Document API | On the NoSQLDB engine | On MongoDB |
|
|
155
156
|
| --- | --- | --- |
|
|
156
|
-
| `getGroupedTotals()` / `getGroupedTotalsPage()` / `iterateGroupedTotals()` with one `groupBy` field | exact, including `sums`, declared nested paths, `unwind` of a declared array and declared fields of its element, `elementFilter`, group key order and `cursor` paging, on 8.0.2 and 10.
|
|
157
|
+
| `getGroupedTotals()` / `getGroupedTotalsPage()` / `iterateGroupedTotals()` with one `groupBy` field | exact, including `sums`, declared nested paths, `unwind` of a declared array and declared fields of its element, `elementFilter`, group key order and `cursor` paging, on 8.0.2 and 10.9.0 alike | exact |
|
|
157
158
|
| the same with **two** `groupBy` fields | exact, `sums`, nested paths, `unwind`, two element fields of one unwound array (`lines.accountNumber` with `lines.side`), `elementFilter` and `cursor` paging 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; the client wraps each key member in `$ifNull`, because MongoDB leaves a missing member out of the key and would otherwise report it as a second null group |
|
|
158
159
|
| 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 |
|
|
159
160
|
| `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. `db.observeWrites()` reports the process's own model writes on both backends | supported on replica sets |
|
|
160
|
-
| 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.
|
|
161
|
+
| 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.9.0; `toleratedIndexNames` accepts it and inspection reports its filter | not expressible, but a migration may own one; inspection reports its filter |
|
|
161
162
|
| 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 |
|
|
162
|
-
| write conflicts between transactions, and writes outside a transaction to a document a transaction holds | 10.
|
|
163
|
+
| write conflicts between transactions, and writes outside a transaction to a document a transaction holds | 10.9.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 |
|
|
163
164
|
| value-form `$elemMatch` in read filters (`{ scores: { $elemMatch: { $gte: 80, $lt: 90 } } }`, `{ labels: { $not: { $elemMatch: { $nin: runnerLabels } } } }`) | exact on 10.7.1 and newer: 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 newer 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 |
|
|
164
|
-
| `Model.inspectQueryPlan()` | explains on 10.
|
|
165
|
+
| `Model.inspectQueryPlan()` | explains on 10.9.0: the summary names the winning plan's stages and its execution totals. 8.0.2 answers `explain` with `CommandNotFound` (59), and the call rejects with `unsupported_operation` ("The connected engine does not explain queries.") rather than returning a plan it cannot know. Stage shapes are the engine's own: on 10.9.0 a limited sort on an unindexed field is `LIMIT` over `SORT` over `COLLSCAN`, an equality on a unique single-field index is `FETCH` over `IXSCAN`, and an `$or` whose branches each have an index is a `COLLSCAN` | explains: the same limited sort is `SORT` over `COLLSCAN`, the limit folded into the sort; the equality is `EXPRESS_IXSCAN` on the unique index; the `$or` is `SUBPLAN` over `FETCH` over `OR` over one `IXSCAN` per branch |
|
|
165
166
|
| 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 |
|
|
166
167
|
|
|
167
|
-
`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.
|
|
168
|
+
`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.9.0. The same boundary pins the rest of the engine's `$group` stage: a non-document accumulator is refused with `TypeMismatch` (14) on 10.9.0 while 8.0.2 answers it, and a `$cond` inside a composite key is evaluated per document on 10.9.0 while 8.0.2 answers its single literal row. It also pins whether a migration can create a partial index — 10.9.0 creates it and lists the filter, 8.0.2 refuses with 72 — and a collection validator — 10.9.0 creates and enforces it against model inserts and updates, 8.0.2 refuses with 72 — which writer a conflict sends back: on 10.9.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 — and what `$elemMatch` answers through `getInstances()`: 10.9.0 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.9.0 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.
|
|
168
169
|
|
|
169
170
|
## Relational SQL
|
|
170
171
|
|
|
@@ -244,14 +245,14 @@ Qualified engines:
|
|
|
244
245
|
|
|
245
246
|
| Component | Qualified versions |
|
|
246
247
|
| --- | --- |
|
|
247
|
-
| NoSQLDB engine | **8.0.2 and 10.
|
|
248
|
+
| NoSQLDB engine | **8.0.2 and 10.9.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.9.0 |
|
|
248
249
|
| MongoDB | 8.0.26 replica sets |
|
|
249
250
|
| ObjectStorage | 10.3.1 |
|
|
250
251
|
| SQLDB | 0.2.3 |
|
|
251
252
|
| MariaDB | 11.8 |
|
|
252
253
|
| ClickHouse | 25.8 |
|
|
253
254
|
|
|
254
|
-
Both engine versions run the whole document suite. They part on asserted boundaries — 10.
|
|
255
|
+
Both engine versions run the whole document suite. They part on asserted boundaries — 10.9.0 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.
|
|
255
256
|
|
|
256
257
|
## License and Legal Information
|
|
257
258
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
reportSmartdataWrite, reportSmartdataWriteFailure, runWithOrdinarySmartdataSession,
|
|
11
11
|
} from './classes.session.js';
|
|
12
12
|
import { runSmartdataWrite } from './classes.readtracker.js';
|
|
13
|
-
import {
|
|
13
|
+
import { requiresSmartdataWriteTransactions } from './classes.writelog.js';
|
|
14
14
|
import {
|
|
15
15
|
collectionModelSchemaResolverSymbol, collectionTopologyDbInspectionSymbol,
|
|
16
16
|
getExpectedCollectionTopologyForSchema, inspectCollectionTopologySnapshot,
|
|
@@ -92,8 +92,8 @@ export const insertBootstrapRecord = async (
|
|
|
92
92
|
if (topology.status !== 'absent' && topology.status !== 'exact') return 'invalid_configuration';
|
|
93
93
|
const insertOptions = budget.operationOptions();
|
|
94
94
|
dispatched = true;
|
|
95
|
-
if (
|
|
96
|
-
// The insert runs in a transaction of its own that commits its write revisions with it.
|
|
95
|
+
if (requiresSmartdataWriteTransactions(dbArg)) {
|
|
96
|
+
// The insert runs in a transaction of its own that commits its write revisions and write-log entry with it.
|
|
97
97
|
try {
|
|
98
98
|
const inserted = await runWithOrdinarySmartdataSession(undefined, dbArg, {
|
|
99
99
|
ordinaryWrite: true, prepared: true, ...insertOptions,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SmartdataDb } from './classes.db.js';
|
|
2
|
+
import type { ISmartdataWriteLogOptions } from './classes.writelog.js';
|
|
2
3
|
import type { ISmartdataWriteRevisionsOptions } from './classes.writerevisions.js';
|
|
3
4
|
import {
|
|
4
5
|
composeSmartdataCapabilities,
|
|
@@ -17,6 +18,8 @@ export interface INoSqlConnectionOptions {
|
|
|
17
18
|
connectionLimit?: number;
|
|
18
19
|
/** See `ISmartdataDbOptions.writeRevisions`. */
|
|
19
20
|
writeRevisions?: ISmartdataWriteRevisionsOptions;
|
|
21
|
+
/** See `ISmartdataDbOptions.writeLog`. */
|
|
22
|
+
writeLog?: ISmartdataWriteLogOptions;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export class NoSqlConnection extends SmartdataDb {
|
|
@@ -28,7 +31,8 @@ export class NoSqlConnection extends SmartdataDb {
|
|
|
28
31
|
super({ mongoDbUrl: options.url, mongoDbName: options.database,
|
|
29
32
|
mongoDbUser: options.username, mongoDbPass: options.password,
|
|
30
33
|
...(options.connectionLimit === undefined ? {} : { maxPoolSize: options.connectionLimit }),
|
|
31
|
-
...(options.writeRevisions === undefined ? {} : { writeRevisions: options.writeRevisions })
|
|
34
|
+
...(options.writeRevisions === undefined ? {} : { writeRevisions: options.writeRevisions }),
|
|
35
|
+
...(options.writeLog === undefined ? {} : { writeLog: options.writeLog }) });
|
|
32
36
|
this.backend = options.backend;
|
|
33
37
|
this.declaredCapabilities = composeSmartdataCapabilities(this.backend,
|
|
34
38
|
smartdataCompositeGrouping(this.engineIdentity));
|
package/ts/nosqldb/classes.db.ts
CHANGED
|
@@ -34,8 +34,19 @@ import {
|
|
|
34
34
|
configureSmartdataWriteRevisions,
|
|
35
35
|
ensureSmartdataWriteRevisions,
|
|
36
36
|
isSmartdataWriteRevisionsActive,
|
|
37
|
+
stopSmartdataWriteRevisionCompactions,
|
|
37
38
|
type ISmartdataWriteRevisionsOptions,
|
|
38
39
|
} from './classes.writerevisions.js';
|
|
40
|
+
import {
|
|
41
|
+
configureSmartdataWriteLog,
|
|
42
|
+
ensureSmartdataWriteLog,
|
|
43
|
+
followSmartdataWrites,
|
|
44
|
+
isSmartdataWriteLogActive,
|
|
45
|
+
stopSmartdataWriteFollowers,
|
|
46
|
+
type ISmartdataWriteFollower,
|
|
47
|
+
type ISmartdataWriteFollowerOptions,
|
|
48
|
+
type ISmartdataWriteLogOptions,
|
|
49
|
+
} from './classes.writelog.js';
|
|
39
50
|
import {
|
|
40
51
|
requireOperationSignal,
|
|
41
52
|
requireOperationTimeoutMS,
|
|
@@ -50,6 +61,15 @@ export type {
|
|
|
50
61
|
TSmartdataTransactionCommitOutcome,
|
|
51
62
|
} from './classes.session.js';
|
|
52
63
|
export type { ISmartdataWriteRevisionsOptions } from './classes.writerevisions.js';
|
|
64
|
+
export type {
|
|
65
|
+
ISmartdataCommitStamp,
|
|
66
|
+
ISmartdataFollowedWrite,
|
|
67
|
+
ISmartdataWriteFollower,
|
|
68
|
+
ISmartdataWriteFollowerOptions,
|
|
69
|
+
ISmartdataWriteGap,
|
|
70
|
+
ISmartdataWriteLogOptions,
|
|
71
|
+
TSmartdataWriteGapReason,
|
|
72
|
+
} from './classes.writelog.js';
|
|
53
73
|
export type {
|
|
54
74
|
ISmartdataWriteObserverOptions,
|
|
55
75
|
ISmartdataWriteReport,
|
|
@@ -110,6 +130,13 @@ export interface ISmartdataDbOptions {
|
|
|
110
130
|
* once, by the constructor.
|
|
111
131
|
*/
|
|
112
132
|
writeRevisions?: ISmartdataWriteRevisionsOptions;
|
|
133
|
+
/**
|
|
134
|
+
* Keeps a write log, so `followWrites()` delivers the model writes of every
|
|
135
|
+
* process in commit order. Every process that writes the database must
|
|
136
|
+
* enable it with the fields its followers follow. Needs NoSQLDB 10.9.0 or
|
|
137
|
+
* newer; elsewhere it is inert. Read once, by the constructor.
|
|
138
|
+
*/
|
|
139
|
+
writeLog?: ISmartdataWriteLogOptions;
|
|
113
140
|
}
|
|
114
141
|
|
|
115
142
|
export interface ISmartdataInvalidateTrackedReadsOptions {
|
|
@@ -246,6 +273,7 @@ export class SmartdataDb {
|
|
|
246
273
|
|
|
247
274
|
constructor(smartdataOptions: ISmartdataDbOptions) {
|
|
248
275
|
configureSmartdataWriteRevisions(this, smartdataOptions.writeRevisions);
|
|
276
|
+
configureSmartdataWriteLog(this, smartdataOptions.writeLog);
|
|
249
277
|
this.smartdataOptions = smartdataOptions;
|
|
250
278
|
this.status = 'initial';
|
|
251
279
|
void this.statusConnectedDeferred.promise.catch(() => {});
|
|
@@ -342,6 +370,10 @@ export class SmartdataDb {
|
|
|
342
370
|
await ensureSmartdataWriteRevisions(candidateDb, budget?.operationOptions());
|
|
343
371
|
budget?.check();
|
|
344
372
|
}
|
|
373
|
+
if (isSmartdataWriteLogActive(this)) {
|
|
374
|
+
await ensureSmartdataWriteLog(candidateDb, budget?.operationOptions());
|
|
375
|
+
budget?.check();
|
|
376
|
+
}
|
|
345
377
|
this.mongoDbClient = candidateClient;
|
|
346
378
|
this.mongoDb = candidateDb;
|
|
347
379
|
candidateClient = undefined;
|
|
@@ -399,6 +431,17 @@ export class SmartdataDb {
|
|
|
399
431
|
const mongoDbClient = this.pendingClient ?? this.mongoDbClient;
|
|
400
432
|
try {
|
|
401
433
|
const closeErrors: unknown[] = [];
|
|
434
|
+
try {
|
|
435
|
+
// Followers and compactions abort at once; their reads end with the client otherwise.
|
|
436
|
+
const backgroundStop = Promise.all([
|
|
437
|
+
stopSmartdataWriteFollowers(this),
|
|
438
|
+
stopSmartdataWriteRevisionCompactions(this),
|
|
439
|
+
]);
|
|
440
|
+
if (budget) await budget.waitFor(backgroundStop);
|
|
441
|
+
await backgroundStop;
|
|
442
|
+
} catch (errorArg) {
|
|
443
|
+
closeErrors.push(errorArg);
|
|
444
|
+
}
|
|
402
445
|
try {
|
|
403
446
|
const invalidation = invalidateSmartdataSessions(this, budget?.cleanupOptions());
|
|
404
447
|
// A session whose cleanup outlives the deadline stays owned and invalidated
|
|
@@ -582,6 +625,31 @@ export class SmartdataDb {
|
|
|
582
625
|
return addSmartdataWriteObserver(this, optionsArg);
|
|
583
626
|
}
|
|
584
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Follows the model writes of every process that writes this database with
|
|
630
|
+
* the `writeLog` option, from now on, in commit order: `onWrite` receives
|
|
631
|
+
* each committed transaction once, with the reports of its writes. A
|
|
632
|
+
* transaction of this instance is delivered as soon as its commit returned
|
|
633
|
+
* (`origin: 'local'`); every other one once the follower reads it from the
|
|
634
|
+
* engine's commit journal (`origin: 'journal'`, with its `stamp`), within
|
|
635
|
+
* 250 ms of a commit while the database is active and within a second when
|
|
636
|
+
* idle. A transaction of this instance whose commit outcome was unknown here
|
|
637
|
+
* is delivered from the journal if it committed. A commit the log cannot
|
|
638
|
+
* describe — a raw driver write, a migration, a writer without the option —
|
|
639
|
+
* names its collections in `unplaced`.
|
|
640
|
+
*
|
|
641
|
+
* The journal keeps a commit for 15 minutes, and at most its newest 65,536
|
|
642
|
+
* commits. A follower that falls further
|
|
643
|
+
* behind, or whose engine restarted on a new timeline, calls `onGap` and
|
|
644
|
+
* continues from the journal's head; state derived from earlier deliveries
|
|
645
|
+
* must then be rebuilt from the database. A failed journal read is logged
|
|
646
|
+
* and retried. Needs a connected database on NoSQLDB 10.9.0 or newer; MongoDB
|
|
647
|
+
* has no commit journal and is refused. `close()` stops every follower.
|
|
648
|
+
*/
|
|
649
|
+
public async followWrites(optionsArg: ISmartdataWriteFollowerOptions): Promise<ISmartdataWriteFollower> {
|
|
650
|
+
return followSmartdataWrites(this, optionsArg);
|
|
651
|
+
}
|
|
652
|
+
|
|
585
653
|
/**
|
|
586
654
|
* Runs `fnArg` and records every model read it makes on this database:
|
|
587
655
|
* `findOne`, `findAll`, `getCursor`, counts, pages and the exact reads.
|
|
@@ -30,7 +30,7 @@ import { SmartdataDbWatcher } from './classes.watcher.js';
|
|
|
30
30
|
import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
|
|
31
31
|
import { executeAtomicDelete, executeAtomicDeleteMany } from './classes.atomicdelete.js';
|
|
32
32
|
import { requireOperationSignal } from './classes.operationdeadline.js';
|
|
33
|
-
import {
|
|
33
|
+
import { requiresSmartdataWriteTransactions } from './classes.writelog.js';
|
|
34
34
|
import {
|
|
35
35
|
executeAtomicFindOneAndReplace, executeAtomicFindOneAndUpdate,
|
|
36
36
|
} from './classes.atomicfindoneandupdate.js';
|
|
@@ -4677,9 +4677,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
4677
4677
|
}
|
|
4678
4678
|
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
4679
4679
|
const inTransaction = isOrdinarySessionInTransaction(opts?.session);
|
|
4680
|
-
// Outside a transaction, a database with write revisions
|
|
4681
|
-
// in one of its own, so a failed batch inserted nothing.
|
|
4682
|
-
const insertsWhole = !inTransaction &&
|
|
4680
|
+
// Outside a transaction, a database with write revisions or a write log
|
|
4681
|
+
// runs the batch in one of its own, so a failed batch inserted nothing.
|
|
4682
|
+
const insertsWhole = !inTransaction && requiresSmartdataWriteTransactions(collection.smartdataDb);
|
|
4683
4683
|
const markInsertedDocuments = (
|
|
4684
4684
|
insertedIdsArg: Record<number, unknown> | undefined,
|
|
4685
4685
|
): void => {
|
|
@@ -9,11 +9,11 @@ import { registerCollectionReconnectHandler } from './classes.collectionlifecycl
|
|
|
9
9
|
import {
|
|
10
10
|
SmartdataSession,
|
|
11
11
|
acquireSmartdataSession,
|
|
12
|
-
|
|
12
|
+
needsSmartdataWriteTransaction,
|
|
13
13
|
readPreviousWriteRevisions,
|
|
14
14
|
reportSmartdataCollectionWrite,
|
|
15
15
|
reportSmartdataWriteFailure,
|
|
16
|
-
|
|
16
|
+
runSmartdataWriteTransaction,
|
|
17
17
|
settleSmartdataOperationError,
|
|
18
18
|
resolveSmartdataOperationDeadline,
|
|
19
19
|
trackSmartdataRead,
|
|
@@ -1159,8 +1159,8 @@ export class SmartdataExactCollection<
|
|
|
1159
1159
|
deadlineArg: SmartdataOperationDeadline,
|
|
1160
1160
|
) => Promise<TResult>,
|
|
1161
1161
|
): Promise<TResult> {
|
|
1162
|
-
if (
|
|
1163
|
-
return this.
|
|
1162
|
+
if (needsSmartdataWriteTransaction(optionsArg.session, this.collection.smartdataDb)) {
|
|
1163
|
+
return this.runTransactionalWriteOperation(optionsArg, operationArg);
|
|
1164
1164
|
}
|
|
1165
1165
|
let started = false;
|
|
1166
1166
|
try {
|
|
@@ -1180,13 +1180,14 @@ export class SmartdataExactCollection<
|
|
|
1180
1180
|
}
|
|
1181
1181
|
|
|
1182
1182
|
/**
|
|
1183
|
-
* Runs one exact write in a transaction that carries the write's revisions
|
|
1183
|
+
* Runs one exact write in a transaction that carries the write's revisions
|
|
1184
|
+
* and write-log entry:
|
|
1184
1185
|
* on the caller's owned session, which needs the collection prepared, or on
|
|
1185
1186
|
* a session of its own, which prepares the collection first. The
|
|
1186
1187
|
* transaction's commit is the write's outcome: one whose commit outcome is
|
|
1187
1188
|
* unknown is `ambiguous_write`.
|
|
1188
1189
|
*/
|
|
1189
|
-
private async
|
|
1190
|
+
private async runTransactionalWriteOperation<TResult>(
|
|
1190
1191
|
optionsArg: IExactBoundedOptions,
|
|
1191
1192
|
operationArg: (
|
|
1192
1193
|
sessionArg: plugins.mongodb.ClientSession | undefined,
|
|
@@ -1198,7 +1199,7 @@ export class SmartdataExactCollection<
|
|
|
1198
1199
|
signal?.throwIfAborted();
|
|
1199
1200
|
if (optionsArg.session) this.requirePreparedForSession();
|
|
1200
1201
|
try {
|
|
1201
|
-
return await
|
|
1202
|
+
return await runSmartdataWriteTransaction(optionsArg.session, this.collection.smartdataDb, {
|
|
1202
1203
|
...(optionsArg.session ? { prepared: true } : { prepare: () => this.initialize() }),
|
|
1203
1204
|
collectionName: this.collection.collectionName,
|
|
1204
1205
|
...(timeoutMS === undefined ? {} : { timeoutMS }),
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
readWriteRevisionSnapshot,
|
|
11
11
|
revisionIdOfValue,
|
|
12
12
|
sharedRevisionId,
|
|
13
|
+
writeRevisionBumpsOfKey,
|
|
13
14
|
writeRevisionKeysOfRead,
|
|
14
15
|
} from './classes.writerevisions.js';
|
|
15
16
|
|
|
@@ -32,13 +33,14 @@ export class SmartdataReadSet {
|
|
|
32
33
|
/**
|
|
33
34
|
* Whether no write of any process could have changed what the reads
|
|
34
35
|
* returned: `changed` is false, and the write revisions the reads depend on
|
|
35
|
-
*
|
|
36
|
+
* equal their baseline plus confirmed unrelated own writes. Requires the database's
|
|
36
37
|
* `writeRevisions` option and an engine with transactions; without them,
|
|
37
38
|
* and whenever the revisions cannot be read in time, it is false.
|
|
38
39
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* Confirmed writes of this database that leave `changed` false are accounted
|
|
41
|
+
* for by their exact revision bumps. Other writers and unknown commit
|
|
42
|
+
* outcomes stay conservative; overlapping local commits make a revision
|
|
43
|
+
* snapshot unknowable rather than granting an ambiguous allowance.
|
|
42
44
|
*/
|
|
43
45
|
public async isCurrent(): Promise<boolean> {
|
|
44
46
|
const state = readSetStates.get(this);
|
|
@@ -57,9 +59,16 @@ export class SmartdataReadSet {
|
|
|
57
59
|
if (count === 0) return true;
|
|
58
60
|
if (values.some((valueArg) => valueArg === undefined)) return false;
|
|
59
61
|
const keys = [...revisions.snapshots.keys()];
|
|
62
|
+
const allowances = keys.map((keyArg) => revisions.ownBumps.get(keyArg) ?? 0);
|
|
60
63
|
const current = await readWriteRevisionSnapshot(state.db, keys);
|
|
61
|
-
if (state.changed) return false;
|
|
62
|
-
return keys.every((keyArg, indexArg) =>
|
|
64
|
+
if (state.changed || revisions.lost) return false;
|
|
65
|
+
return keys.every((keyArg, indexArg) => {
|
|
66
|
+
const allowance = allowances[indexArg];
|
|
67
|
+
if ((revisions.ownBumps.get(keyArg) ?? 0) !== allowance) return false;
|
|
68
|
+
if (allowance === 0) return current.get(keyArg) === values[indexArg];
|
|
69
|
+
const expected = Number(values[indexArg]) + allowance;
|
|
70
|
+
return Number.isSafeInteger(expected) && current.get(keyArg) === String(expected);
|
|
71
|
+
});
|
|
63
72
|
} catch {
|
|
64
73
|
return false;
|
|
65
74
|
}
|
|
@@ -165,6 +174,7 @@ interface IReadSetState {
|
|
|
165
174
|
interface IReadSetRevisions {
|
|
166
175
|
/** Per snapshot key, its value before the first read that depends on it; `undefined` when unknown. */
|
|
167
176
|
snapshots: Map<string, Promise<string | undefined>>;
|
|
177
|
+
ownBumps: Map<string, number>;
|
|
168
178
|
/** A revision could not be read, so the set can never be current. */
|
|
169
179
|
lost: boolean;
|
|
170
180
|
}
|
|
@@ -204,6 +214,7 @@ const changeReadSet = (stateArg: IReadSetState): void => {
|
|
|
204
214
|
if (stateArg.changed) return;
|
|
205
215
|
stateArg.changed = true;
|
|
206
216
|
stateArg.reads.clear();
|
|
217
|
+
stateArg.revisions?.ownBumps.clear();
|
|
207
218
|
readSetsByDb.get(stateArg.db)?.delete(stateArg);
|
|
208
219
|
};
|
|
209
220
|
|
|
@@ -887,7 +898,9 @@ export const trackSmartdataReads = async <TResult>(
|
|
|
887
898
|
}
|
|
888
899
|
const reads = new SmartdataReadSet();
|
|
889
900
|
const state: IReadSetState = { db: dbArg, changed: false, finished: false, reads: new Map() };
|
|
890
|
-
if (isSmartdataWriteRevisionsActive(dbArg))
|
|
901
|
+
if (isSmartdataWriteRevisionsActive(dbArg)) {
|
|
902
|
+
state.revisions = { snapshots: new Map(), ownBumps: new Map(), lost: false };
|
|
903
|
+
}
|
|
891
904
|
readSetStates.set(reads, state);
|
|
892
905
|
let states = readSetsByDb.get(dbArg);
|
|
893
906
|
if (!states) {
|
|
@@ -915,6 +928,27 @@ export const hasSmartdataReadScope = (): boolean => readScope.getStore() !== und
|
|
|
915
928
|
export const hasSmartdataReadSets = (dbArg: SmartdataDb): boolean =>
|
|
916
929
|
(readSetsByDb.get(dbArg)?.size ?? 0) > 0;
|
|
917
930
|
|
|
931
|
+
export const accountSmartdataOwnWriteRevisions = (
|
|
932
|
+
dbArg: SmartdataDb,
|
|
933
|
+
idsArg: ReadonlySet<string>,
|
|
934
|
+
): void => {
|
|
935
|
+
if (idsArg.size === 0) return;
|
|
936
|
+
for (const state of readSetsByDb.get(dbArg) ?? []) {
|
|
937
|
+
const revisions = state.revisions;
|
|
938
|
+
if (!revisions || revisions.lost) continue;
|
|
939
|
+
for (const key of revisions.snapshots.keys()) {
|
|
940
|
+
const bumps = writeRevisionBumpsOfKey(key, idsArg);
|
|
941
|
+
if (bumps === 0) continue;
|
|
942
|
+
const allowance = (revisions.ownBumps.get(key) ?? 0) + bumps;
|
|
943
|
+
if (!Number.isSafeInteger(allowance)) {
|
|
944
|
+
revisions.lost = true;
|
|
945
|
+
break;
|
|
946
|
+
}
|
|
947
|
+
revisions.ownBumps.set(key, allowance);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
|
|
918
952
|
/** @internal Marks every open read set of the database changed, as its `close()` does. */
|
|
919
953
|
export const changeSmartdataReadSetsOfDb = (dbArg: SmartdataDb): void => {
|
|
920
954
|
// A reconnected database may hold recreated collections.
|