@lossless.org/client 1.19.0 → 1.20.1
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.atomicdelete.js +12 -4
- package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +11 -2
- package/dist_ts/nosqldb/classes.atomicupdate.d.ts +2 -0
- package/dist_ts/nosqldb/classes.atomicupdate.js +32 -5
- package/dist_ts/nosqldb/classes.bootstraprecord.js +7 -2
- package/dist_ts/nosqldb/classes.collection.js +52 -7
- package/dist_ts/nosqldb/classes.db.d.ts +19 -0
- package/dist_ts/nosqldb/classes.db.js +28 -21
- package/dist_ts/nosqldb/classes.doc.js +2 -2
- package/dist_ts/nosqldb/classes.exactpersistence.js +17 -2
- package/dist_ts/nosqldb/classes.persistence.d.ts +6 -0
- package/dist_ts/nosqldb/classes.persistence.js +16 -1
- package/dist_ts/nosqldb/classes.session.d.ts +11 -0
- package/dist_ts/nosqldb/classes.session.js +51 -1
- package/dist_ts/nosqldb/classes.writeobserver.d.ts +57 -0
- package/dist_ts/nosqldb/classes.writeobserver.js +139 -0
- package/dist_ts/nosqldb/connectionstring.d.ts +15 -0
- package/dist_ts/nosqldb/connectionstring.js +194 -0
- package/package.json +3 -3
- package/readme.md +3 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.atomicdelete.ts +11 -2
- package/ts/nosqldb/classes.atomicfindoneandupdate.ts +22 -0
- package/ts/nosqldb/classes.atomicupdate.ts +53 -3
- package/ts/nosqldb/classes.bootstraprecord.ts +7 -1
- package/ts/nosqldb/classes.collection.ts +63 -4
- package/ts/nosqldb/classes.db.ts +42 -22
- package/ts/nosqldb/classes.doc.ts +1 -1
- package/ts/nosqldb/classes.exactpersistence.ts +24 -0
- package/ts/nosqldb/classes.persistence.ts +16 -0
- package/ts/nosqldb/classes.session.ts +73 -0
- package/ts/nosqldb/classes.writeobserver.ts +224 -0
- package/ts/nosqldb/connectionstring.ts +243 -0
package/ts/nosqldb/classes.db.ts
CHANGED
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
type ISmartdataNamespaceInspectionOptions,
|
|
21
21
|
type ISmartdataNamespaceInspection,
|
|
22
22
|
} from './classes.namespaceinspection.js';
|
|
23
|
+
import {
|
|
24
|
+
addSmartdataWriteObserver,
|
|
25
|
+
type ISmartdataWriteObserverOptions,
|
|
26
|
+
} from './classes.writeobserver.js';
|
|
23
27
|
|
|
24
28
|
export { SmartdataSession } from './classes.session.js';
|
|
25
29
|
export type {
|
|
@@ -27,8 +31,16 @@ export type {
|
|
|
27
31
|
TSmartdataOrdinarySession,
|
|
28
32
|
TSmartdataTransactionCommitOutcome,
|
|
29
33
|
} from './classes.session.js';
|
|
34
|
+
export type {
|
|
35
|
+
ISmartdataWriteObserverOptions,
|
|
36
|
+
ISmartdataWriteReport,
|
|
37
|
+
TSmartdataObservedFields,
|
|
38
|
+
TSmartdataObservedValue,
|
|
39
|
+
TSmartdataWriteKind,
|
|
40
|
+
} from './classes.writeobserver.js';
|
|
30
41
|
|
|
31
42
|
import { logger } from './logging.js';
|
|
43
|
+
import { buildSmartdataConnectionUrl, redactSmartdataConnectionError } from './connectionstring.js';
|
|
32
44
|
import { assertDataOptions, SmartdataOperationBudget } from './classes.operationbudget.js';
|
|
33
45
|
import {
|
|
34
46
|
composeSmartdataCapabilities,
|
|
@@ -247,28 +259,11 @@ export class SmartdataDb {
|
|
|
247
259
|
try { budget?.check(); } catch (errorArg) { budget?.close(); throw errorArg; }
|
|
248
260
|
this.beginLifecycleOperation('init');
|
|
249
261
|
this.transitionLifecycle('connecting');
|
|
262
|
+
// Read once, so a failure is redacted with the credentials this attempt connected with.
|
|
263
|
+
const descriptor = this.smartdataOptions;
|
|
250
264
|
let candidateClient: plugins.mongodb.MongoClient | undefined;
|
|
251
265
|
try {
|
|
252
266
|
try {
|
|
253
|
-
// Safely encode credentials to handle special characters
|
|
254
|
-
const encodedUser = this.smartdataOptions.mongoDbUser
|
|
255
|
-
? encodeURIComponent(this.smartdataOptions.mongoDbUser)
|
|
256
|
-
: '';
|
|
257
|
-
const encodedPass = this.smartdataOptions.mongoDbPass
|
|
258
|
-
? encodeURIComponent(this.smartdataOptions.mongoDbPass)
|
|
259
|
-
: '';
|
|
260
|
-
|
|
261
|
-
const finalConnectionUrl = this.smartdataOptions.mongoDbUrl
|
|
262
|
-
.replace('<USERNAME>', encodedUser)
|
|
263
|
-
.replace('<username>', encodedUser)
|
|
264
|
-
.replace('<USER>', encodedUser)
|
|
265
|
-
.replace('<user>', encodedUser)
|
|
266
|
-
.replace('<PASSWORD>', encodedPass)
|
|
267
|
-
.replace('<password>', encodedPass)
|
|
268
|
-
.replace('<DBNAME>', this.smartdataOptions.mongoDbName || '')
|
|
269
|
-
.replace('<dbname>', this.smartdataOptions.mongoDbName || '');
|
|
270
|
-
|
|
271
|
-
const descriptor = this.smartdataOptions;
|
|
272
267
|
const clientOptions: plugins.mongodb.MongoClientOptions = {
|
|
273
268
|
maxPoolSize: descriptor.maxPoolSize ?? 100,
|
|
274
269
|
maxIdleTimeMS: descriptor.maxIdleTimeMS ?? 300000,
|
|
@@ -279,7 +274,8 @@ export class SmartdataDb {
|
|
|
279
274
|
retryWrites: true,
|
|
280
275
|
};
|
|
281
276
|
|
|
282
|
-
candidateClient = new plugins.mongodb.MongoClient(
|
|
277
|
+
candidateClient = new plugins.mongodb.MongoClient(
|
|
278
|
+
buildSmartdataConnectionUrl(descriptor), clientOptions);
|
|
283
279
|
this.pendingClient = candidateClient;
|
|
284
280
|
const connectingClient = candidateClient;
|
|
285
281
|
let cancellationCleanup: Promise<void> | undefined;
|
|
@@ -336,9 +332,13 @@ export class SmartdataDb {
|
|
|
336
332
|
? 'SmartData connection setup and cleanup failed; close must be retried.'
|
|
337
333
|
: 'SmartData connection setup failed or was cancelled.');
|
|
338
334
|
}
|
|
339
|
-
|
|
335
|
+
const setupError = new SmartdataPersistenceError('unsupported_operation',
|
|
336
|
+
'SmartData connection setup failed.',
|
|
337
|
+
{ cause: redactSmartdataConnectionError(error, descriptor) });
|
|
338
|
+
if (cleanupError) throw new AggregateError(
|
|
339
|
+
[setupError, redactSmartdataConnectionError(cleanupError, descriptor)],
|
|
340
340
|
'SmartData connection setup and cleanup failed; close must be retried.');
|
|
341
|
-
throw
|
|
341
|
+
throw setupError;
|
|
342
342
|
}
|
|
343
343
|
} finally {
|
|
344
344
|
this.endLifecycleOperation('init');
|
|
@@ -524,6 +524,26 @@ export class SmartdataDb {
|
|
|
524
524
|
return createSmartdataSession(this);
|
|
525
525
|
}
|
|
526
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Observes every acknowledged write that this process makes through the
|
|
529
|
+
* database's models: inserts, saves, deletes, atomic and exact writes. Each
|
|
530
|
+
* write is reported once, after the database acknowledged it; a write inside
|
|
531
|
+
* a `createSession()` transaction is reported after the commit, and not at
|
|
532
|
+
* all when the transaction rolls back or its outcome is unknown. A write
|
|
533
|
+
* that changed nothing (no match, nothing deleted) is not reported. Writes
|
|
534
|
+
* of other processes, raw driver access and migrations are not observed.
|
|
535
|
+
*
|
|
536
|
+
* Reports carry the values of the requested top-level `fields` only, from
|
|
537
|
+
* the written documents and from the selector's equality conditions. While
|
|
538
|
+
* an observer is registered, transactions on raw `startSession()` sessions
|
|
539
|
+
* are refused for model writes, since their outcome is not known here.
|
|
540
|
+
*
|
|
541
|
+
* Returns the function that stops the observer.
|
|
542
|
+
*/
|
|
543
|
+
public observeWrites(optionsArg: ISmartdataWriteObserverOptions): () => void {
|
|
544
|
+
return addSmartdataWriteObserver(this, optionsArg);
|
|
545
|
+
}
|
|
546
|
+
|
|
527
547
|
/**
|
|
528
548
|
* Creates a narrowly scoped MongoDB administration facade bound to this
|
|
529
549
|
* SmartData lifecycle. Applications never need direct driver access.
|
|
@@ -6851,7 +6851,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
6851
6851
|
$set: setObject,
|
|
6852
6852
|
...(Object.keys(unsetObject).length > 0 ? { $unset: unsetObject } : {}),
|
|
6853
6853
|
},
|
|
6854
|
-
{ session: options.session, timeoutMS: options.timeoutMS },
|
|
6854
|
+
{ session: options.session, timeoutMS: options.timeoutMS, observedDocument: saveableObject },
|
|
6855
6855
|
);
|
|
6856
6856
|
if (result.matchedCount === 0) {
|
|
6857
6857
|
// Nothing was written, so the instance keeps the update timestamp the
|
|
@@ -9,6 +9,7 @@ import { registerCollectionReconnectHandler } from './classes.collectionlifecycl
|
|
|
9
9
|
import type { SmartdataSession } from './classes.session.js';
|
|
10
10
|
import {
|
|
11
11
|
acquireSmartdataSession,
|
|
12
|
+
reportSmartdataCollectionWrite,
|
|
12
13
|
settleSmartdataOperationError,
|
|
13
14
|
resolveSmartdataOperationDeadline,
|
|
14
15
|
} from './classes.session.js';
|
|
@@ -1549,6 +1550,14 @@ export class SmartdataExactCollection<
|
|
|
1549
1550
|
}
|
|
1550
1551
|
}
|
|
1551
1552
|
|
|
1553
|
+
/** A reconciled body is this call's own insert only when it carries this call's `_id`. */
|
|
1554
|
+
const reportReconciledInsert = (storedArg: TStoredDocument<TPersisted>): void => {
|
|
1555
|
+
if (storedArg._id.equals(expectedStoredDocument._id)) {
|
|
1556
|
+
reportSmartdataCollectionWrite(
|
|
1557
|
+
this.collection, optionsArg.session, 'insert', [expectedStoredDocument],
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1552
1561
|
let result: plugins.mongodb.InsertOneResult;
|
|
1553
1562
|
const insertOptions = { session: sessionArg, ...deadlineArg.commandOptions() };
|
|
1554
1563
|
try {
|
|
@@ -1594,6 +1603,7 @@ export class SmartdataExactCollection<
|
|
|
1594
1603
|
}
|
|
1595
1604
|
if (reconciled) {
|
|
1596
1605
|
if (bytesEqual(reconciled.bodyBytes, persistedBytes)) {
|
|
1606
|
+
reportReconciledInsert(reconciled.document);
|
|
1597
1607
|
return { status: 'already_committed', document: reconciled.document };
|
|
1598
1608
|
}
|
|
1599
1609
|
if (isDuplicateKeyError(errorArg)) {
|
|
@@ -1633,6 +1643,7 @@ export class SmartdataExactCollection<
|
|
|
1633
1643
|
);
|
|
1634
1644
|
}
|
|
1635
1645
|
if (reconciled && bytesEqual(reconciled.bodyBytes, persistedBytes)) {
|
|
1646
|
+
reportReconciledInsert(reconciled.document);
|
|
1636
1647
|
return { status: 'already_committed', document: reconciled.document };
|
|
1637
1648
|
}
|
|
1638
1649
|
throw this.ambiguousWriteError(
|
|
@@ -1640,6 +1651,9 @@ export class SmartdataExactCollection<
|
|
|
1640
1651
|
result,
|
|
1641
1652
|
);
|
|
1642
1653
|
}
|
|
1654
|
+
reportSmartdataCollectionWrite(
|
|
1655
|
+
this.collection, optionsArg.session, 'insert', [expectedStoredDocument],
|
|
1656
|
+
);
|
|
1643
1657
|
return { status: 'inserted', document: expectedStoredDocument };
|
|
1644
1658
|
});
|
|
1645
1659
|
}
|
|
@@ -1827,6 +1841,11 @@ export class SmartdataExactCollection<
|
|
|
1827
1841
|
? { _id: current._id, _smartdataRevision: current._smartdataRevision }
|
|
1828
1842
|
: { _id: current._id, _smartdataRevision: { $exists: false } };
|
|
1829
1843
|
|
|
1844
|
+
const reportTransition = (): void => {
|
|
1845
|
+
reportSmartdataCollectionWrite(
|
|
1846
|
+
this.collection, sessionOptionsArg.session, 'replace', [current, expectedStoredDocument], selector,
|
|
1847
|
+
);
|
|
1848
|
+
};
|
|
1830
1849
|
const replaceOptions = {
|
|
1831
1850
|
returnDocument: 'after' as const,
|
|
1832
1851
|
includeResultMetadata: false as const,
|
|
@@ -1851,6 +1870,7 @@ export class SmartdataExactCollection<
|
|
|
1851
1870
|
'Exact transition returned a postimage different from the requested state.',
|
|
1852
1871
|
);
|
|
1853
1872
|
}
|
|
1873
|
+
reportTransition();
|
|
1854
1874
|
return { status: 'transitioned', document: expectedStoredDocument };
|
|
1855
1875
|
} catch (errorArg) {
|
|
1856
1876
|
if (isTransientTransactionError(errorArg, sessionArg)) {
|
|
@@ -1875,6 +1895,7 @@ export class SmartdataExactCollection<
|
|
|
1875
1895
|
}, { session: sessionArg, ...deadlineArg.commandOptions() });
|
|
1876
1896
|
if (reconciledRaw) {
|
|
1877
1897
|
if (this.encodesAs(reconciledRaw, expectedBytes)) {
|
|
1898
|
+
reportTransition();
|
|
1878
1899
|
return { status: 'transitioned', document: expectedStoredDocument };
|
|
1879
1900
|
}
|
|
1880
1901
|
// A malformed document fails the reconciliation itself.
|
|
@@ -1936,6 +1957,9 @@ export class SmartdataExactCollection<
|
|
|
1936
1957
|
result.acknowledged === true &&
|
|
1937
1958
|
result.deletedCount === 1
|
|
1938
1959
|
) {
|
|
1960
|
+
reportSmartdataCollectionWrite(
|
|
1961
|
+
this.collection, sessionOptionsArg.session, 'delete', [current], selector,
|
|
1962
|
+
);
|
|
1939
1963
|
return { status: 'deleted', deletedCount: 1 };
|
|
1940
1964
|
}
|
|
1941
1965
|
if (
|
|
@@ -78,6 +78,22 @@ export const isMongoTransientTransactionError = (errorArg: unknown): boolean =>
|
|
|
78
78
|
errorArg instanceof plugins.mongodb.MongoError
|
|
79
79
|
&& errorArg.hasErrorLabel(plugins.mongodb.MongoErrorLabel.TransientTransactionError);
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The failed operations of a bulk write that failed on write errors. Only then
|
|
83
|
+
* does the error's result state which operations applied: after a driver or
|
|
84
|
+
* write-concern error the outcome is unknown and this returns `undefined`.
|
|
85
|
+
*/
|
|
86
|
+
export const getMongoBulkWriteErrorIndexes = (
|
|
87
|
+
errorArg: unknown,
|
|
88
|
+
): ReadonlySet<number> | undefined => {
|
|
89
|
+
if (!(errorArg instanceof plugins.mongodb.MongoBulkWriteError)) return undefined;
|
|
90
|
+
if (errorArg.result?.getWriteConcernError() !== undefined) return undefined;
|
|
91
|
+
const writeErrors = [errorArg.writeErrors].flat();
|
|
92
|
+
return writeErrors.length > 0
|
|
93
|
+
? new Set(writeErrors.map((writeErrorArg) => writeErrorArg.index))
|
|
94
|
+
: undefined;
|
|
95
|
+
};
|
|
96
|
+
|
|
81
97
|
export const isMongoDuplicateKeyError = (
|
|
82
98
|
errorArg: unknown,
|
|
83
99
|
): errorArg is plugins.mongodb.MongoServerError => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
|
|
3
|
+
import type { SmartdataCollection } from './classes.collection.js';
|
|
3
4
|
import type { SmartdataDb } from './classes.db.js';
|
|
4
5
|
import {
|
|
5
6
|
SmartdataOperationDeadline,
|
|
@@ -15,6 +16,12 @@ import {
|
|
|
15
16
|
normalizeDeadlineError,
|
|
16
17
|
normalizeOrdinaryPersistenceError,
|
|
17
18
|
} from './classes.persistence.js';
|
|
19
|
+
import {
|
|
20
|
+
captureSmartdataWrite,
|
|
21
|
+
hasSmartdataWriteObservers,
|
|
22
|
+
type ISmartdataWrite,
|
|
23
|
+
type TSmartdataWriteKind,
|
|
24
|
+
} from './classes.writeobserver.js';
|
|
18
25
|
|
|
19
26
|
interface ISmartdataSessionBinding {
|
|
20
27
|
session: SmartdataSession;
|
|
@@ -36,6 +43,8 @@ interface ISmartdataSessionBinding {
|
|
|
36
43
|
transactionCommitReached: boolean;
|
|
37
44
|
/** Set when the session ended while a transaction ran, stating how far the transaction had got. */
|
|
38
45
|
transactionEnded?: TSmartdataTransactionEnd;
|
|
46
|
+
/** The write reports of the current attempt, delivered once the transaction committed. */
|
|
47
|
+
transactionWrites: Array<() => void>;
|
|
39
48
|
operationSettled?: Promise<void>;
|
|
40
49
|
resolveOperationSettled?: () => void;
|
|
41
50
|
invalidated: boolean;
|
|
@@ -307,6 +316,7 @@ export class SmartdataSession {
|
|
|
307
316
|
binding.transactionUsesOrdinaryWrite = false;
|
|
308
317
|
binding.transactionFailure = undefined;
|
|
309
318
|
binding.transactionCommitReached = false;
|
|
319
|
+
binding.transactionWrites = [];
|
|
310
320
|
callbackError = undefined;
|
|
311
321
|
attemptInterruption = undefined;
|
|
312
322
|
try {
|
|
@@ -435,11 +445,13 @@ export class SmartdataSession {
|
|
|
435
445
|
};
|
|
436
446
|
let result: TResult | undefined;
|
|
437
447
|
let transactionEnded: TSmartdataTransactionEnd | undefined;
|
|
448
|
+
let committedWrites: Array<() => void> = [];
|
|
438
449
|
try {
|
|
439
450
|
result = timeoutMS === undefined
|
|
440
451
|
? await binding.rawSession.withTransaction(runAttempt)
|
|
441
452
|
: await binding.rawSession.withTransaction(runAttempt, { timeoutMS });
|
|
442
453
|
transactionEnded = binding.transactionEnded;
|
|
454
|
+
committedWrites = binding.transactionWrites;
|
|
443
455
|
} catch (errorArg) {
|
|
444
456
|
const { error, outcome } = settleTransactionRejection(errorArg);
|
|
445
457
|
throw rejectTransaction(error, outcome);
|
|
@@ -451,6 +463,7 @@ export class SmartdataSession {
|
|
|
451
463
|
binding.transactionFailure = undefined;
|
|
452
464
|
binding.transactionCommitReached = false;
|
|
453
465
|
binding.transactionEnded = undefined;
|
|
466
|
+
binding.transactionWrites = [];
|
|
454
467
|
}
|
|
455
468
|
if (transactionEnded) {
|
|
456
469
|
throw rejectTransaction(
|
|
@@ -458,6 +471,9 @@ export class SmartdataSession {
|
|
|
458
471
|
commitOutcomeOfTransactionEnd(transactionEnded),
|
|
459
472
|
);
|
|
460
473
|
}
|
|
474
|
+
for (const deliverWrite of committedWrites) {
|
|
475
|
+
deliverWrite();
|
|
476
|
+
}
|
|
461
477
|
return result;
|
|
462
478
|
}
|
|
463
479
|
|
|
@@ -495,6 +511,7 @@ export const createSmartdataSession = (ownerArg: SmartdataDb): SmartdataSession
|
|
|
495
511
|
transactionCallbackOpen: false,
|
|
496
512
|
transactionUsesOrdinaryWrite: false,
|
|
497
513
|
transactionCommitReached: false,
|
|
514
|
+
transactionWrites: [],
|
|
498
515
|
invalidated: false,
|
|
499
516
|
};
|
|
500
517
|
bindings.set(session, binding);
|
|
@@ -656,6 +673,17 @@ export const leaseOrdinarySmartdataSession = (
|
|
|
656
673
|
if (!optionsArg.prepared && sessionArg?.inTransaction()) {
|
|
657
674
|
refuseUnpreparedCollection(optionsArg, 'inside a transaction');
|
|
658
675
|
}
|
|
676
|
+
if (
|
|
677
|
+
optionsArg.ordinaryWrite
|
|
678
|
+
&& sessionArg?.inTransaction()
|
|
679
|
+
&& hasSmartdataWriteObservers(ownerArg)
|
|
680
|
+
) {
|
|
681
|
+
// Only an owned transaction tells SmartData whether it committed.
|
|
682
|
+
throw new SmartdataPersistenceError(
|
|
683
|
+
'unsupported_operation',
|
|
684
|
+
'Writes of an observed database run in transactions of db.createSession(), not raw driver sessions.',
|
|
685
|
+
);
|
|
686
|
+
}
|
|
659
687
|
return { rawSession: sessionArg, release: () => {} };
|
|
660
688
|
}
|
|
661
689
|
if (!optionsArg.prepared) {
|
|
@@ -753,6 +781,51 @@ export const runWithOrdinarySmartdataSession = async <TResult>(
|
|
|
753
781
|
}
|
|
754
782
|
};
|
|
755
783
|
|
|
784
|
+
/**
|
|
785
|
+
* Reports one acknowledged write to the owner's write observers: at once, or,
|
|
786
|
+
* inside an owned transaction, after it committed. A transaction that rolls
|
|
787
|
+
* back, or whose commit outcome is unknown, reports nothing, and a retried
|
|
788
|
+
* attempt reports only its own writes.
|
|
789
|
+
*/
|
|
790
|
+
export const reportSmartdataWrite = (
|
|
791
|
+
ownerArg: SmartdataDb,
|
|
792
|
+
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
793
|
+
writeArg: ISmartdataWrite,
|
|
794
|
+
): void => {
|
|
795
|
+
// A raw driver transaction passed its lease while nobody observed, and
|
|
796
|
+
// whether it commits is never known here.
|
|
797
|
+
if (!isSmartdataSession(sessionArg) && sessionArg?.inTransaction()) return;
|
|
798
|
+
const deliver = captureSmartdataWrite(ownerArg, writeArg);
|
|
799
|
+
if (!deliver) return;
|
|
800
|
+
const binding = isSmartdataSession(sessionArg) ? bindings.get(sessionArg) : undefined;
|
|
801
|
+
if (binding?.transactionActive) {
|
|
802
|
+
// A write that settles after its attempt ended rolls back with that attempt.
|
|
803
|
+
const attempt = transactionContext.getStore()?.attempt;
|
|
804
|
+
if (attempt !== undefined && attempt === binding.transactionAttempt) {
|
|
805
|
+
binding.transactionWrites.push(deliver);
|
|
806
|
+
}
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
deliver();
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
/** Reports one acknowledged write of a model collection; see `reportSmartdataWrite`. */
|
|
813
|
+
export const reportSmartdataCollectionWrite = (
|
|
814
|
+
collectionArg: SmartdataCollection<any>,
|
|
815
|
+
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
816
|
+
kindArg: TSmartdataWriteKind,
|
|
817
|
+
documentsArg: readonly object[] | null,
|
|
818
|
+
selectorArg?: unknown,
|
|
819
|
+
): void => {
|
|
820
|
+
reportSmartdataWrite(collectionArg.smartdataDb, sessionArg, {
|
|
821
|
+
collection: collectionArg.collectionName,
|
|
822
|
+
kind: kindArg,
|
|
823
|
+
documents: documentsArg,
|
|
824
|
+
selector: selectorArg,
|
|
825
|
+
identityDocumentIdField: collectionArg.getBoundModelSchema()?.identityAsDocumentId,
|
|
826
|
+
});
|
|
827
|
+
};
|
|
828
|
+
|
|
756
829
|
/**
|
|
757
830
|
* Ends every session of the owner. `endOptionsArg.timeoutMS` is the driver's
|
|
758
831
|
* deadline for aborting a transaction a session still holds.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { SmartdataDb } from './classes.db.js';
|
|
2
|
+
import { assertDataOptions } from './classes.operationbudget.js';
|
|
3
|
+
import { SmartdataPersistenceError } from './classes.persistence.js';
|
|
4
|
+
import { logger } from './logging.js';
|
|
5
|
+
|
|
6
|
+
/** What a write did to its documents. An upsert that inserted is an `insert`. */
|
|
7
|
+
export type TSmartdataWriteKind = 'insert' | 'update' | 'replace' | 'delete';
|
|
8
|
+
|
|
9
|
+
/** The values a report carries; any other stored value is left out. */
|
|
10
|
+
export type TSmartdataObservedValue = string | number | boolean | null;
|
|
11
|
+
|
|
12
|
+
/** The observed fields of one document or selector, by field name. */
|
|
13
|
+
export type TSmartdataObservedFields = Readonly<Record<string, TSmartdataObservedValue>>;
|
|
14
|
+
|
|
15
|
+
/** One acknowledged write, reported to every write observer of its database. */
|
|
16
|
+
export interface ISmartdataWriteReport {
|
|
17
|
+
/** The collection the write went to. */
|
|
18
|
+
readonly collection: string;
|
|
19
|
+
readonly kind: TSmartdataWriteKind;
|
|
20
|
+
/**
|
|
21
|
+
* The observed fields of each document the write named or returned: the
|
|
22
|
+
* inserted documents, a model instance's save or delete, an exact write, the
|
|
23
|
+
* document a find-one-and-modify returned. `null` when the write addressed
|
|
24
|
+
* its documents by selector only; `anchors` then says what it pinned.
|
|
25
|
+
*/
|
|
26
|
+
readonly documents: readonly TSmartdataObservedFields[] | null;
|
|
27
|
+
/**
|
|
28
|
+
* The observed fields the write's selector pinned by top-level equality
|
|
29
|
+
* (`{ field: value }`, `{ field: { $eq: value } }`, or inside `$and`).
|
|
30
|
+
* Empty for an insert, which has no selector.
|
|
31
|
+
*/
|
|
32
|
+
readonly anchors: TSmartdataObservedFields;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ISmartdataWriteObserverOptions {
|
|
36
|
+
/** Top-level field names the reports carry: 1 to 64 distinct names, no `.` and no leading `$`. */
|
|
37
|
+
fields: readonly string[];
|
|
38
|
+
/**
|
|
39
|
+
* Called synchronously once per write. A listener that throws is logged and
|
|
40
|
+
* never fails the write; one that needs to do async work schedules it.
|
|
41
|
+
*/
|
|
42
|
+
listener: (reportArg: ISmartdataWriteReport) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @internal What a write path states about one acknowledged write. */
|
|
46
|
+
export interface ISmartdataWrite {
|
|
47
|
+
collection: string;
|
|
48
|
+
kind: TSmartdataWriteKind;
|
|
49
|
+
/** The documents as written or returned; `null` for a write by selector only. */
|
|
50
|
+
documents: readonly object[] | null;
|
|
51
|
+
selector?: unknown;
|
|
52
|
+
/** The model's `identityAsDocumentId` field, whose value a selector carries as `_id`. */
|
|
53
|
+
identityDocumentIdField?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface IWriteObserver {
|
|
57
|
+
fields: readonly string[];
|
|
58
|
+
listener: (reportArg: ISmartdataWriteReport) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const maximumObservedFields = 64;
|
|
62
|
+
const observersByDb = new WeakMap<SmartdataDb, Set<IWriteObserver>>();
|
|
63
|
+
|
|
64
|
+
const normalizeObserverOptions = (optionsArg: unknown): IWriteObserver => {
|
|
65
|
+
const { fields, listener } = assertDataOptions(optionsArg, ['fields', 'listener']);
|
|
66
|
+
if (typeof listener !== 'function') {
|
|
67
|
+
throw new SmartdataPersistenceError('invalid_argument',
|
|
68
|
+
'A SmartData write observer requires a listener function.');
|
|
69
|
+
}
|
|
70
|
+
if (
|
|
71
|
+
!Array.isArray(fields)
|
|
72
|
+
|| fields.length === 0
|
|
73
|
+
|| fields.length > maximumObservedFields
|
|
74
|
+
|| fields.some((fieldArg) =>
|
|
75
|
+
typeof fieldArg !== 'string'
|
|
76
|
+
|| fieldArg.length === 0
|
|
77
|
+
|| fieldArg.includes('.')
|
|
78
|
+
|| fieldArg.startsWith('$'))
|
|
79
|
+
|| new Set(fields).size !== fields.length
|
|
80
|
+
) {
|
|
81
|
+
throw new SmartdataPersistenceError('invalid_argument',
|
|
82
|
+
`A SmartData write observer requires 1 to ${maximumObservedFields} distinct top-level field names.`);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
fields: Object.freeze([...fields] as string[]),
|
|
86
|
+
listener: listener as IWriteObserver['listener'],
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** @internal Registers a write observer; the returned function removes it. */
|
|
91
|
+
export const addSmartdataWriteObserver = (
|
|
92
|
+
dbArg: SmartdataDb,
|
|
93
|
+
optionsArg: ISmartdataWriteObserverOptions,
|
|
94
|
+
): (() => void) => {
|
|
95
|
+
const observer = normalizeObserverOptions(optionsArg);
|
|
96
|
+
let observers = observersByDb.get(dbArg);
|
|
97
|
+
if (!observers) {
|
|
98
|
+
observers = new Set();
|
|
99
|
+
observersByDb.set(dbArg, observers);
|
|
100
|
+
}
|
|
101
|
+
observers.add(observer);
|
|
102
|
+
return () => {
|
|
103
|
+
observersByDb.get(dbArg)?.delete(observer);
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** @internal */
|
|
108
|
+
export const hasSmartdataWriteObservers = (dbArg: SmartdataDb): boolean =>
|
|
109
|
+
(observersByDb.get(dbArg)?.size ?? 0) > 0;
|
|
110
|
+
|
|
111
|
+
const observedValueOf = (valueArg: unknown): TSmartdataObservedValue | undefined =>
|
|
112
|
+
valueArg === null
|
|
113
|
+
|| typeof valueArg === 'string'
|
|
114
|
+
|| typeof valueArg === 'boolean'
|
|
115
|
+
|| (typeof valueArg === 'number' && Number.isFinite(valueArg))
|
|
116
|
+
? valueArg as TSmartdataObservedValue
|
|
117
|
+
: undefined;
|
|
118
|
+
|
|
119
|
+
const isPlainRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
|
|
120
|
+
if (typeof valueArg !== 'object' || valueArg === null || Array.isArray(valueArg)) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
const prototype = Object.getPrototypeOf(valueArg);
|
|
124
|
+
return prototype === Object.prototype || prototype === null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const pickDocumentFields = (
|
|
128
|
+
documentArg: object,
|
|
129
|
+
fieldsArg: readonly string[],
|
|
130
|
+
): Record<string, TSmartdataObservedValue> => {
|
|
131
|
+
const picked: Record<string, TSmartdataObservedValue> = {};
|
|
132
|
+
for (const field of fieldsArg) {
|
|
133
|
+
if (!Object.hasOwn(documentArg, field)) continue;
|
|
134
|
+
const value = observedValueOf((documentArg as Record<string, unknown>)[field]);
|
|
135
|
+
if (value !== undefined) picked[field] = value;
|
|
136
|
+
}
|
|
137
|
+
return picked;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A `null` condition also matches a missing field, so only concrete values
|
|
142
|
+
* anchor a selector.
|
|
143
|
+
*/
|
|
144
|
+
const collectSelectorAnchors = (
|
|
145
|
+
selectorArg: unknown,
|
|
146
|
+
fieldsArg: ReadonlySet<string>,
|
|
147
|
+
identityDocumentIdFieldArg: string | undefined,
|
|
148
|
+
anchorsArg: Record<string, TSmartdataObservedValue>,
|
|
149
|
+
): void => {
|
|
150
|
+
if (!isPlainRecord(selectorArg)) return;
|
|
151
|
+
for (const key of Object.keys(selectorArg)) {
|
|
152
|
+
const condition = selectorArg[key];
|
|
153
|
+
if (key === '$and') {
|
|
154
|
+
if (Array.isArray(condition)) {
|
|
155
|
+
for (const conjunct of condition) {
|
|
156
|
+
collectSelectorAnchors(conjunct, fieldsArg, identityDocumentIdFieldArg, anchorsArg);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
let value = observedValueOf(condition);
|
|
162
|
+
if (
|
|
163
|
+
value === undefined
|
|
164
|
+
&& isPlainRecord(condition)
|
|
165
|
+
&& Object.keys(condition).length === 1
|
|
166
|
+
&& Object.hasOwn(condition, '$eq')
|
|
167
|
+
) {
|
|
168
|
+
value = observedValueOf(condition.$eq);
|
|
169
|
+
}
|
|
170
|
+
if (value === undefined || value === null) continue;
|
|
171
|
+
const fields = key === '_id' && identityDocumentIdFieldArg
|
|
172
|
+
? [key, identityDocumentIdFieldArg]
|
|
173
|
+
: [key];
|
|
174
|
+
for (const field of fields) {
|
|
175
|
+
if (fieldsArg.has(field) && !Object.hasOwn(anchorsArg, field)) {
|
|
176
|
+
anchorsArg[field] = value;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const describeError = (errorArg: unknown): string =>
|
|
183
|
+
errorArg instanceof Error ? errorArg.message : String(errorArg);
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @internal Captures one acknowledged write for the observers registered now,
|
|
187
|
+
* and returns the delivery, or `undefined` when nobody observes the database.
|
|
188
|
+
* The observed values are copied at capture, so a delivery deferred to a
|
|
189
|
+
* transaction's commit reports the write as it was made. An observer removed
|
|
190
|
+
* before the delivery receives nothing.
|
|
191
|
+
*/
|
|
192
|
+
export const captureSmartdataWrite = (
|
|
193
|
+
dbArg: SmartdataDb,
|
|
194
|
+
writeArg: ISmartdataWrite,
|
|
195
|
+
): (() => void) | undefined => {
|
|
196
|
+
const registered = observersByDb.get(dbArg);
|
|
197
|
+
if (!registered || registered.size === 0) return undefined;
|
|
198
|
+
const observers = [...registered];
|
|
199
|
+
const fields = [...new Set(observers.flatMap((observerArg) => observerArg.fields))];
|
|
200
|
+
const documents = writeArg.documents?.map((documentArg) =>
|
|
201
|
+
pickDocumentFields(documentArg, fields)) ?? null;
|
|
202
|
+
const anchors: Record<string, TSmartdataObservedValue> = {};
|
|
203
|
+
collectSelectorAnchors(writeArg.selector, new Set(fields), writeArg.identityDocumentIdField, anchors);
|
|
204
|
+
return () => {
|
|
205
|
+
for (const observer of observers) {
|
|
206
|
+
if (!observersByDb.get(dbArg)?.has(observer)) continue;
|
|
207
|
+
const report: ISmartdataWriteReport = Object.freeze({
|
|
208
|
+
collection: writeArg.collection,
|
|
209
|
+
kind: writeArg.kind,
|
|
210
|
+
documents: documents === null
|
|
211
|
+
? null
|
|
212
|
+
: Object.freeze(documents.map((documentArg) =>
|
|
213
|
+
Object.freeze(pickDocumentFields(documentArg, observer.fields)))),
|
|
214
|
+
anchors: Object.freeze(pickDocumentFields(anchors, observer.fields)),
|
|
215
|
+
});
|
|
216
|
+
try {
|
|
217
|
+
observer.listener(report);
|
|
218
|
+
} catch (errorArg) {
|
|
219
|
+
logger.log('error',
|
|
220
|
+
`A SmartData write observer threw on a write to "${writeArg.collection}": ${describeError(errorArg)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
};
|