@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.
Files changed (35) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/nosqldb/classes.bootstraprecord.js +4 -4
  3. package/dist_ts/nosqldb/classes.connection.d.ts +3 -0
  4. package/dist_ts/nosqldb/classes.connection.js +3 -2
  5. package/dist_ts/nosqldb/classes.db.d.ts +31 -0
  6. package/dist_ts/nosqldb/classes.db.js +45 -2
  7. package/dist_ts/nosqldb/classes.doc.js +5 -5
  8. package/dist_ts/nosqldb/classes.exactpersistence.d.ts +3 -2
  9. package/dist_ts/nosqldb/classes.exactpersistence.js +8 -7
  10. package/dist_ts/nosqldb/classes.readtracker.d.ts +6 -4
  11. package/dist_ts/nosqldb/classes.readtracker.js +42 -10
  12. package/dist_ts/nosqldb/classes.session.d.ts +7 -6
  13. package/dist_ts/nosqldb/classes.session.js +91 -23
  14. package/dist_ts/nosqldb/classes.writelog.d.ts +129 -0
  15. package/dist_ts/nosqldb/classes.writelog.js +497 -0
  16. package/dist_ts/nosqldb/classes.writeobserver.d.ts +13 -0
  17. package/dist_ts/nosqldb/classes.writeobserver.js +47 -25
  18. package/dist_ts/nosqldb/classes.writerevisions.d.ts +18 -7
  19. package/dist_ts/nosqldb/classes.writerevisions.js +179 -30
  20. package/dist_ts/nosqldb/engineidentity.d.ts +6 -0
  21. package/dist_ts/nosqldb/engineidentity.js +14 -1
  22. package/package.json +2 -2
  23. package/readme.md +11 -10
  24. package/ts/00_commitinfo_data.ts +1 -1
  25. package/ts/nosqldb/classes.bootstraprecord.ts +3 -3
  26. package/ts/nosqldb/classes.connection.ts +5 -1
  27. package/ts/nosqldb/classes.db.ts +68 -0
  28. package/ts/nosqldb/classes.doc.ts +4 -4
  29. package/ts/nosqldb/classes.exactpersistence.ts +8 -7
  30. package/ts/nosqldb/classes.readtracker.ts +41 -7
  31. package/ts/nosqldb/classes.session.ts +107 -26
  32. package/ts/nosqldb/classes.writelog.ts +701 -0
  33. package/ts/nosqldb/classes.writeobserver.ts +62 -28
  34. package/ts/nosqldb/classes.writerevisions.ts +224 -32
  35. 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.7.2 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` |
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()` is true while no model write of any process could have changed the reads, counted per collection and partition value in the write's transaction, so one organization's writes keep another's sets current; `db.invalidateTrackedReads()` states raw writes and migrations; needs an engine identified as NoSQLDB, elsewhere `isCurrent()` is always false |
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.7.2 (see [Verification](#verification)); the two releases part on the grouped-totals and guarded-`$expr` rows below — 10.7.2 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
+ 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.7.2 alike | exact |
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.7.2; `toleratedIndexNames` accepts it and inspection reports its filter | not expressible, but a migration may own one; inspection reports its filter |
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.7.2 (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
+ | 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.7.2: 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.7.2 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
+ | `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.7.2. The same boundary pins the rest of the engine's `$group` stage: a non-document accumulator is refused with `TypeMismatch` (14) on 10.7.2 while 8.0.2 answers it, and a `$cond` inside a composite key is evaluated per document on 10.7.2 while 8.0.2 answers its single literal row. It also pins whether a migration can create a partial index — 10.7.2 creates it and lists the filter, 8.0.2 refuses with 72 — and a collection validator — 10.7.2 creates and enforces it against model inserts and updates, 8.0.2 refuses with 72 — which writer a conflict sends back: on 10.7.2 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.2 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.2 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
+ `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.7.2** — 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.2 |
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.7.2 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
+ 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
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@lossless.org/client',
6
- version: '1.22.1',
6
+ version: '1.23.0',
7
7
  description: 'One typed client for NoSQLDB, MongoDB, SQLDB, MariaDB, ClickHouse and S3 object storage.'
8
8
  }
@@ -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 { isSmartdataWriteRevisionsActive } from './classes.writerevisions.js';
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 (isSmartdataWriteRevisionsActive(dbArg)) {
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));
@@ -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 { isSmartdataWriteRevisionsActive } from './classes.writerevisions.js';
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 runs the batch
4681
- // in one of its own, so a failed batch inserted nothing.
4682
- const insertsWhole = !inTransaction && isSmartdataWriteRevisionsActive(collection.smartdataDb);
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
- needsSmartdataRevisionTransaction,
12
+ needsSmartdataWriteTransaction,
13
13
  readPreviousWriteRevisions,
14
14
  reportSmartdataCollectionWrite,
15
15
  reportSmartdataWriteFailure,
16
- runSmartdataRevisionTransaction,
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 (needsSmartdataRevisionTransaction(optionsArg.session, this.collection.smartdataDb)) {
1163
- return this.runRevisionWriteOperation(optionsArg, operationArg);
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 runRevisionWriteOperation<TResult>(
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 runSmartdataRevisionTransaction(optionsArg.session, this.collection.smartdataDb, {
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
- * are what they were before the reads ran. Requires the database's
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
- * The revisions count writes per collection and partition, not per read,
40
- * so a write of this process that left `changed` false also makes it false
41
- * when it wrote to a partition or collection the reads depend on.
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) => current.get(keyArg) === values[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)) state.revisions = { snapshots: new Map(), lost: false };
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.