@lossless.org/client 1.21.0 → 1.22.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.atomicdelete.js +8 -4
- package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +19 -4
- package/dist_ts/nosqldb/classes.atomicupdate.js +16 -5
- package/dist_ts/nosqldb/classes.bootstraprecord.js +31 -5
- package/dist_ts/nosqldb/classes.collection.js +17 -14
- package/dist_ts/nosqldb/classes.collectiontopology.d.ts +8 -1
- package/dist_ts/nosqldb/classes.collectiontopology.js +10 -2
- 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 +27 -2
- package/dist_ts/nosqldb/classes.db.js +42 -3
- package/dist_ts/nosqldb/classes.doc.d.ts +3 -1
- package/dist_ts/nosqldb/classes.doc.js +15 -9
- package/dist_ts/nosqldb/classes.exactpersistence.d.ts +13 -2
- package/dist_ts/nosqldb/classes.exactpersistence.js +64 -17
- package/dist_ts/nosqldb/classes.operationdeadline.d.ts +5 -0
- package/dist_ts/nosqldb/classes.operationdeadline.js +8 -1
- package/dist_ts/nosqldb/classes.readtracker.d.ts +28 -0
- package/dist_ts/nosqldb/classes.readtracker.js +128 -5
- package/dist_ts/nosqldb/classes.session.d.ts +34 -5
- package/dist_ts/nosqldb/classes.session.js +138 -9
- package/dist_ts/nosqldb/classes.storedinspection.js +7 -4
- package/dist_ts/nosqldb/classes.writeobserver.d.ts +5 -0
- package/dist_ts/nosqldb/classes.writeobserver.js +1 -1
- package/dist_ts/nosqldb/classes.writerevisions.d.ts +81 -0
- package/dist_ts/nosqldb/classes.writerevisions.js +246 -0
- package/package.json +4 -4
- package/readme.md +1 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.atomicdelete.ts +11 -2
- package/ts/nosqldb/classes.atomicfindoneandupdate.ts +25 -2
- package/ts/nosqldb/classes.atomicupdate.ts +25 -3
- package/ts/nosqldb/classes.bootstraprecord.ts +29 -4
- package/ts/nosqldb/classes.collection.ts +20 -12
- package/ts/nosqldb/classes.collectiontopology.ts +15 -1
- package/ts/nosqldb/classes.connection.ts +5 -1
- package/ts/nosqldb/classes.db.ts +65 -2
- package/ts/nosqldb/classes.doc.ts +16 -8
- package/ts/nosqldb/classes.exactpersistence.ts +83 -21
- package/ts/nosqldb/classes.operationdeadline.ts +8 -0
- package/ts/nosqldb/classes.readtracker.ts +147 -5
- package/ts/nosqldb/classes.session.ts +197 -11
- package/ts/nosqldb/classes.storedinspection.ts +6 -3
- package/ts/nosqldb/classes.writeobserver.ts +5 -0
- package/ts/nosqldb/classes.writerevisions.ts +328 -0
|
@@ -5,6 +5,13 @@ import type { SmartdataDb } from './classes.db.js';
|
|
|
5
5
|
import { readBoundedInspectionCursor } from './classes.namespaceinspection.js';
|
|
6
6
|
import { SmartdataPersistenceError } from './classes.persistence.js';
|
|
7
7
|
import type { ISmartdataWrite } from './classes.writeobserver.js';
|
|
8
|
+
import {
|
|
9
|
+
isSmartdataWriteRevisionsActive,
|
|
10
|
+
readWriteRevisionSnapshot,
|
|
11
|
+
revisionIdOfValue,
|
|
12
|
+
sharedRevisionId,
|
|
13
|
+
writeRevisionKeysOfRead,
|
|
14
|
+
} from './classes.writerevisions.js';
|
|
8
15
|
|
|
9
16
|
/**
|
|
10
17
|
* The reads one `db.trackReads()` call made. `changed` turns true once a write
|
|
@@ -22,6 +29,42 @@ export class SmartdataReadSet {
|
|
|
22
29
|
return !state || state.changed;
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Whether no write of any process could have changed what the reads
|
|
34
|
+
* 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
|
+
* `writeRevisions` option and an engine with transactions; without them,
|
|
37
|
+
* and whenever the revisions cannot be read in time, it is false.
|
|
38
|
+
*
|
|
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.
|
|
42
|
+
*/
|
|
43
|
+
public async isCurrent(): Promise<boolean> {
|
|
44
|
+
const state = readSetStates.get(this);
|
|
45
|
+
const revisions = state?.revisions;
|
|
46
|
+
if (!state || state.changed || !revisions || revisions.lost) return false;
|
|
47
|
+
if (!isSmartdataWriteRevisionsActive(state.db)) return false;
|
|
48
|
+
try {
|
|
49
|
+
// Reads still starting add snapshots; wait until the set has them all.
|
|
50
|
+
let count: number;
|
|
51
|
+
let values: (string | undefined)[];
|
|
52
|
+
do {
|
|
53
|
+
count = revisions.snapshots.size;
|
|
54
|
+
values = await Promise.all(revisions.snapshots.values());
|
|
55
|
+
if (state.changed || revisions.lost) return false;
|
|
56
|
+
} while (revisions.snapshots.size !== count);
|
|
57
|
+
if (count === 0) return true;
|
|
58
|
+
if (values.some((valueArg) => valueArg === undefined)) return false;
|
|
59
|
+
const keys = [...revisions.snapshots.keys()];
|
|
60
|
+
const current = await readWriteRevisionSnapshot(state.db, keys);
|
|
61
|
+
if (state.changed) return false;
|
|
62
|
+
return keys.every((keyArg, indexArg) => current.get(keyArg) === values[indexArg]);
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
25
68
|
/** Stops following writes and frees the recorded reads; `changed` is true from then on. */
|
|
26
69
|
public close(): void {
|
|
27
70
|
const state = readSetStates.get(this);
|
|
@@ -57,6 +100,8 @@ export interface ISmartdataReadSpec {
|
|
|
57
100
|
limited?: boolean;
|
|
58
101
|
/** The model's identity fields, by which a write names a document without its `_id`. */
|
|
59
102
|
identityFields?: readonly string[];
|
|
103
|
+
/** The identity fields a sort by orders every document; see `getOrderingIdentityFields`. */
|
|
104
|
+
orderingIdentityFields?: readonly string[];
|
|
60
105
|
}
|
|
61
106
|
|
|
62
107
|
/** @internal Records what one read returned. */
|
|
@@ -65,6 +110,11 @@ export interface ISmartdataReadRecorder {
|
|
|
65
110
|
record(documentArg: unknown): void;
|
|
66
111
|
/** The read returned everything it will return. */
|
|
67
112
|
settle(): void;
|
|
113
|
+
/**
|
|
114
|
+
* Settles once the write revisions the read depends on were read; the read
|
|
115
|
+
* is issued after it. `undefined` when there is nothing to wait for.
|
|
116
|
+
*/
|
|
117
|
+
ready?: Promise<void>;
|
|
68
118
|
}
|
|
69
119
|
|
|
70
120
|
// Values an image or a stored path can hold beside real values.
|
|
@@ -108,6 +158,15 @@ interface IReadSetState {
|
|
|
108
158
|
/** The callback returned, so its read collections have been checked. */
|
|
109
159
|
finished: boolean;
|
|
110
160
|
reads: Map<string, IReadObservation[]>;
|
|
161
|
+
/** The write revisions the reads depend on; only while write revisions are active. */
|
|
162
|
+
revisions?: IReadSetRevisions;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface IReadSetRevisions {
|
|
166
|
+
/** Per snapshot key, its value before the first read that depends on it; `undefined` when unknown. */
|
|
167
|
+
snapshots: Map<string, Promise<string | undefined>>;
|
|
168
|
+
/** A revision could not be read, so the set can never be current. */
|
|
169
|
+
lost: boolean;
|
|
111
170
|
}
|
|
112
171
|
|
|
113
172
|
/** What one write did to one document, as far as it is known. */
|
|
@@ -828,6 +887,7 @@ export const trackSmartdataReads = async <TResult>(
|
|
|
828
887
|
}
|
|
829
888
|
const reads = new SmartdataReadSet();
|
|
830
889
|
const state: IReadSetState = { db: dbArg, changed: false, finished: false, reads: new Map() };
|
|
890
|
+
if (isSmartdataWriteRevisionsActive(dbArg)) state.revisions = { snapshots: new Map(), lost: false };
|
|
831
891
|
readSetStates.set(reads, state);
|
|
832
892
|
let states = readSetsByDb.get(dbArg);
|
|
833
893
|
if (!states) {
|
|
@@ -865,19 +925,59 @@ export const changeSmartdataReadSetsOfDb = (dbArg: SmartdataDb): void => {
|
|
|
865
925
|
/**
|
|
866
926
|
* Whether a limited read keeps the documents its filter and sort decide: at
|
|
867
927
|
* most one document matches an equality on `_id` or an identity field, and a
|
|
868
|
-
* sort by
|
|
928
|
+
* sort by `_id` or by an identity field whose index holds every document
|
|
929
|
+
* orders them all.
|
|
869
930
|
*/
|
|
870
931
|
const limitFollowsQuery = (specArg: ISmartdataReadSpec): boolean => {
|
|
871
|
-
const uniqueFields = ['_id', ...(specArg.identityFields ?? [])];
|
|
872
932
|
const filter = specArg.filter;
|
|
873
933
|
const sort = specArg.sort;
|
|
874
934
|
const pins = (valueArg: unknown): boolean =>
|
|
875
935
|
(isFilterPrimitive(valueArg) && valueArg !== null)
|
|
876
936
|
|| isObjectIdLike(valueArg)
|
|
877
937
|
|| (isPlainRecord(valueArg) && Object.keys(valueArg).length === 1 && Object.hasOwn(valueArg, '$eq') && pins(valueArg.$eq));
|
|
878
|
-
return
|
|
879
|
-
|
|
880
|
-
|| (
|
|
938
|
+
return ['_id', ...(specArg.identityFields ?? [])].some((fieldArg) =>
|
|
939
|
+
isPlainRecord(filter) && Object.hasOwn(filter, fieldArg) && pins(filter[fieldArg]))
|
|
940
|
+
|| ['_id', ...(specArg.orderingIdentityFields ?? [])].some((fieldArg) =>
|
|
941
|
+
isPlainRecord(sort) && Object.hasOwn(sort, fieldArg));
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Reads the write revisions a read depends on that its sets have no snapshot
|
|
946
|
+
* of yet, once for all of them. The earliest snapshot of a key stays, so a set
|
|
947
|
+
* compares against the state before its first read of it.
|
|
948
|
+
*/
|
|
949
|
+
const snapshotWriteRevisions = (
|
|
950
|
+
dbArg: SmartdataDb,
|
|
951
|
+
specArg: ISmartdataReadSpec,
|
|
952
|
+
statesArg: readonly IReadSetState[],
|
|
953
|
+
): Promise<void> | undefined => {
|
|
954
|
+
const states = statesArg.filter((stateArg) => stateArg.revisions && !stateArg.revisions.lost);
|
|
955
|
+
if (states.length === 0) return undefined;
|
|
956
|
+
if (!isSmartdataWriteRevisionsActive(dbArg)) {
|
|
957
|
+
for (const state of states) state.revisions!.lost = true;
|
|
958
|
+
return undefined;
|
|
959
|
+
}
|
|
960
|
+
const keys = writeRevisionKeysOfRead(dbArg, specArg.collection, specArg.kind, specArg.filter);
|
|
961
|
+
const missing = keys.filter((keyArg) =>
|
|
962
|
+
states.some((stateArg) => !stateArg.revisions!.snapshots.has(keyArg)));
|
|
963
|
+
if (missing.length === 0) return undefined;
|
|
964
|
+
const snapshot = (async () => readWriteRevisionSnapshot(dbArg, missing))()
|
|
965
|
+
.catch(() => undefined);
|
|
966
|
+
const pending: Promise<unknown>[] = [];
|
|
967
|
+
for (const state of states) {
|
|
968
|
+
const revisions = state.revisions!;
|
|
969
|
+
for (const key of missing) {
|
|
970
|
+
if (revisions.snapshots.has(key)) continue;
|
|
971
|
+
const value = snapshot.then((snapshotArg) => {
|
|
972
|
+
const known = snapshotArg?.get(key);
|
|
973
|
+
if (known === undefined) revisions.lost = true;
|
|
974
|
+
return known;
|
|
975
|
+
});
|
|
976
|
+
revisions.snapshots.set(key, value);
|
|
977
|
+
pending.push(value);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return Promise.all(pending).then(() => undefined);
|
|
881
981
|
};
|
|
882
982
|
|
|
883
983
|
/**
|
|
@@ -945,6 +1045,7 @@ export const observeSmartdataRead = (
|
|
|
945
1045
|
reads.push(observation);
|
|
946
1046
|
}
|
|
947
1047
|
return {
|
|
1048
|
+
ready: snapshotWriteRevisions(dbArg, specArg, followed),
|
|
948
1049
|
record: (documentArg) => {
|
|
949
1050
|
if (observation.settled) return;
|
|
950
1051
|
if (!isPlainRecord(documentArg)) {
|
|
@@ -1026,3 +1127,44 @@ export const captureSmartdataCollectionChange = (
|
|
|
1026
1127
|
if (!deferredArg && !hasSmartdataReadSets(dbArg)) return undefined;
|
|
1027
1128
|
return () => applyChanges(dbArg, collectionArg, [{ kind: 'unknown', touched: 'all', startedAt: 0 }]);
|
|
1028
1129
|
};
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* @internal The write-revision counters one acknowledged write bumps: the
|
|
1133
|
+
* partition of each document state it names before and after, and the shared
|
|
1134
|
+
* counter for each state it cannot place. A state before the write that the
|
|
1135
|
+
* report does not carry was read ahead of the write when
|
|
1136
|
+
* `previousPartitionsRead` is set; a field the write left alone keeps the
|
|
1137
|
+
* value it has afterwards.
|
|
1138
|
+
*/
|
|
1139
|
+
export const writeRevisionIdsOfWrite = (
|
|
1140
|
+
writeArg: ISmartdataWrite,
|
|
1141
|
+
fieldArg: string,
|
|
1142
|
+
): Set<string> => {
|
|
1143
|
+
const collection = writeArg.collection;
|
|
1144
|
+
const ids = new Set<string>();
|
|
1145
|
+
const add = (valueArg: unknown) => {
|
|
1146
|
+
ids.add(valueArg === UNKNOWN
|
|
1147
|
+
? sharedRevisionId(collection)
|
|
1148
|
+
: revisionIdOfValue(collection, valueArg === MISSING ? undefined : valueArg));
|
|
1149
|
+
};
|
|
1150
|
+
for (const change of describeWrite(writeArg, 0)) {
|
|
1151
|
+
if (change.kind === 'unknown') {
|
|
1152
|
+
ids.add(sharedRevisionId(collection));
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
const fieldTouched = change.touched === 'all'
|
|
1156
|
+
|| [...change.touched].some((pathArg) => pathsOverlap(pathArg, fieldArg));
|
|
1157
|
+
const after = change.kind === 'delete' ? undefined : lookupPath(change.after, fieldArg);
|
|
1158
|
+
if (change.kind !== 'delete' && (after !== UNKNOWN || fieldTouched || change.kind === 'insert')) {
|
|
1159
|
+
add(after);
|
|
1160
|
+
}
|
|
1161
|
+
if (change.kind === 'insert') continue;
|
|
1162
|
+
const before = lookupPath(change.before, fieldArg);
|
|
1163
|
+
if (before !== UNKNOWN) {
|
|
1164
|
+
add(before);
|
|
1165
|
+
} else if (!writeArg.previousPartitionsRead && (fieldTouched || after === UNKNOWN)) {
|
|
1166
|
+
add(UNKNOWN);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return ids;
|
|
1170
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
|
|
3
3
|
import type { SmartdataCollection } from './classes.collection.js';
|
|
4
|
+
import { getOrderingIdentityFields } from './classes.collectiontopology.js';
|
|
4
5
|
import type { SmartdataDb } from './classes.db.js';
|
|
5
6
|
import {
|
|
6
7
|
SmartdataOperationDeadline,
|
|
@@ -29,9 +30,17 @@ import {
|
|
|
29
30
|
hasSmartdataReadSets,
|
|
30
31
|
observeSmartdataRead,
|
|
31
32
|
runSmartdataWrite,
|
|
33
|
+
writeRevisionIdsOfWrite,
|
|
32
34
|
type ISmartdataReadRecorder,
|
|
33
35
|
type ISmartdataReadSpec,
|
|
34
36
|
} from './classes.readtracker.js';
|
|
37
|
+
import {
|
|
38
|
+
flushSmartdataWriteRevisions,
|
|
39
|
+
isSmartdataWriteRevisionsActive,
|
|
40
|
+
readPreviousRevisionIds,
|
|
41
|
+
sharedRevisionId,
|
|
42
|
+
writeRevisionsPartitionField,
|
|
43
|
+
} from './classes.writerevisions.js';
|
|
35
44
|
|
|
36
45
|
interface ISmartdataSessionBinding {
|
|
37
46
|
session: SmartdataSession;
|
|
@@ -59,6 +68,8 @@ interface ISmartdataSessionBinding {
|
|
|
59
68
|
* when the commit outcome is unknown.
|
|
60
69
|
*/
|
|
61
70
|
transactionWrites: ISmartdataTransactionWrite[];
|
|
71
|
+
/** The write-revision counters the current attempt's writes bump right before its commit. */
|
|
72
|
+
revisionBumps: Set<string>;
|
|
62
73
|
operationSettled?: Promise<void>;
|
|
63
74
|
resolveOperationSettled?: () => void;
|
|
64
75
|
invalidated: boolean;
|
|
@@ -135,6 +146,8 @@ const createTransactionSessionEndedError = (
|
|
|
135
146
|
);
|
|
136
147
|
|
|
137
148
|
const bindings = new WeakMap<SmartdataSession, ISmartdataSessionBinding>();
|
|
149
|
+
/** The binding of each owned session's driver session, for writes that only hold the latter. */
|
|
150
|
+
const bindingsByRawSession = new WeakMap<plugins.mongodb.ClientSession, ISmartdataSessionBinding>();
|
|
138
151
|
const bindingsByOwner = new WeakMap<SmartdataDb, Set<ISmartdataSessionBinding>>();
|
|
139
152
|
const smartdataSessionInvalidationAggregateBrand = Symbol(
|
|
140
153
|
'smartdataSessionInvalidationAggregate',
|
|
@@ -155,6 +168,11 @@ interface ISmartdataSessionAcquireOptions {
|
|
|
155
168
|
interface IOrdinarySessionOperationOptions {
|
|
156
169
|
ordinaryWrite?: boolean;
|
|
157
170
|
prepared?: boolean;
|
|
171
|
+
/**
|
|
172
|
+
* Prepares the collection. A write of a database with write revisions that
|
|
173
|
+
* runs in a transaction of its own prepares first, outside it.
|
|
174
|
+
*/
|
|
175
|
+
prepare?: () => Promise<void>;
|
|
158
176
|
/** Names the collection whose preparation a refusal must ask for. */
|
|
159
177
|
collectionName?: string;
|
|
160
178
|
/** The caller's validated client deadline for the call's database work. */
|
|
@@ -185,6 +203,16 @@ const transactionContext = new plugins.nodeAsyncHooks.AsyncLocalStorage<
|
|
|
185
203
|
ITransactionContext
|
|
186
204
|
>();
|
|
187
205
|
|
|
206
|
+
/**
|
|
207
|
+
* The session of the transaction a write of a database with write revisions
|
|
208
|
+
* runs in when its caller passed none, so the write's reports and reads
|
|
209
|
+
* belong to that transaction.
|
|
210
|
+
*/
|
|
211
|
+
const revisionTransactionScope = new plugins.nodeAsyncHooks.AsyncLocalStorage<{
|
|
212
|
+
owner: SmartdataDb;
|
|
213
|
+
session: SmartdataSession;
|
|
214
|
+
}>();
|
|
215
|
+
|
|
188
216
|
const resolveBinding = (
|
|
189
217
|
sessionArg: SmartdataSession,
|
|
190
218
|
ownerArg?: SmartdataDb,
|
|
@@ -341,6 +369,7 @@ export class SmartdataSession {
|
|
|
341
369
|
binding.transactionFailure = undefined;
|
|
342
370
|
binding.transactionCommitReached = false;
|
|
343
371
|
binding.transactionWrites = [];
|
|
372
|
+
binding.revisionBumps = new Set();
|
|
344
373
|
callbackError = undefined;
|
|
345
374
|
attemptInterruption = undefined;
|
|
346
375
|
try {
|
|
@@ -392,6 +421,16 @@ export class SmartdataSession {
|
|
|
392
421
|
}
|
|
393
422
|
// An abort that arrived while the callback ran rolls the attempt back.
|
|
394
423
|
signal?.throwIfAborted();
|
|
424
|
+
// The writes' revisions commit with them. A failure fails the attempt
|
|
425
|
+
// before its commit, and a transient one is retried with it.
|
|
426
|
+
if (binding.revisionBumps.size > 0) {
|
|
427
|
+
await flushSmartdataWriteRevisions(
|
|
428
|
+
binding.client.db(binding.owner.smartdataOptions.mongoDbName),
|
|
429
|
+
binding.rawSession,
|
|
430
|
+
binding.revisionBumps,
|
|
431
|
+
signal,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
395
434
|
// The driver commits next; a deadline that expires or a session that
|
|
396
435
|
// ends from here on leaves the commit outcome unknown.
|
|
397
436
|
binding.transactionCommitReached = true;
|
|
@@ -489,6 +528,7 @@ export class SmartdataSession {
|
|
|
489
528
|
binding.transactionCommitReached = false;
|
|
490
529
|
binding.transactionEnded = undefined;
|
|
491
530
|
binding.transactionWrites = [];
|
|
531
|
+
binding.revisionBumps = new Set();
|
|
492
532
|
}
|
|
493
533
|
if (transactionEnded) {
|
|
494
534
|
const outcome = commitOutcomeOfTransactionEnd(transactionEnded);
|
|
@@ -537,9 +577,11 @@ export const createSmartdataSession = (ownerArg: SmartdataDb): SmartdataSession
|
|
|
537
577
|
transactionUsesOrdinaryWrite: false,
|
|
538
578
|
transactionCommitReached: false,
|
|
539
579
|
transactionWrites: [],
|
|
580
|
+
revisionBumps: new Set(),
|
|
540
581
|
invalidated: false,
|
|
541
582
|
};
|
|
542
583
|
bindings.set(session, binding);
|
|
584
|
+
bindingsByRawSession.set(binding.rawSession, binding);
|
|
543
585
|
let ownerBindings = bindingsByOwner.get(ownerArg);
|
|
544
586
|
if (!ownerBindings) {
|
|
545
587
|
ownerBindings = new Set();
|
|
@@ -779,12 +821,90 @@ export const settleSmartdataOperationError = (
|
|
|
779
821
|
return error;
|
|
780
822
|
};
|
|
781
823
|
|
|
824
|
+
/**
|
|
825
|
+
* The session of the transaction a write of a database with write revisions
|
|
826
|
+
* runs in on its caller's behalf, while that transaction runs.
|
|
827
|
+
*/
|
|
828
|
+
const revisionTransactionSessionOf = (ownerArg: SmartdataDb): SmartdataSession | undefined => {
|
|
829
|
+
const scope = revisionTransactionScope.getStore();
|
|
830
|
+
return scope?.owner === ownerArg && bindings.get(scope.session)?.transactionActive
|
|
831
|
+
? scope.session
|
|
832
|
+
: undefined;
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Whether a write must start a transaction of its own to carry its write
|
|
837
|
+
* revisions: the database keeps them and the write runs outside an owned
|
|
838
|
+
* transaction. A raw driver session cannot carry them and is refused.
|
|
839
|
+
*/
|
|
840
|
+
export const needsSmartdataRevisionTransaction = (
|
|
841
|
+
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
842
|
+
ownerArg: SmartdataDb,
|
|
843
|
+
): boolean => {
|
|
844
|
+
if (!isSmartdataWriteRevisionsActive(ownerArg)) return false;
|
|
845
|
+
if (sessionArg !== undefined && !isSmartdataSession(sessionArg)) {
|
|
846
|
+
throw new SmartdataPersistenceError(
|
|
847
|
+
'unsupported_operation',
|
|
848
|
+
'Writes of a database with write revisions run on sessions of db.createSession(), not raw driver sessions.',
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
return sessionArg === undefined || !resolveBinding(sessionArg, ownerArg).transactionActive;
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const transactionOptionsOf = (
|
|
855
|
+
timeoutMSArg: number | undefined,
|
|
856
|
+
signalArg: AbortSignal | undefined,
|
|
857
|
+
): ISmartdataTransactionOptions => ({
|
|
858
|
+
...(timeoutMSArg === undefined ? {} : { timeoutMS: timeoutMSArg }),
|
|
859
|
+
...(signalArg === undefined ? {} : { signal: signalArg }),
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Runs one write of a database with write revisions in a transaction, so its
|
|
864
|
+
* revisions commit with it: on the caller's owned session, or on a session of
|
|
865
|
+
* its own that ends with the call. The call's deadline and signal bound the
|
|
866
|
+
* whole transaction; the collection is prepared before it starts, since its
|
|
867
|
+
* DDL cannot run inside.
|
|
868
|
+
*/
|
|
869
|
+
export const runSmartdataRevisionTransaction = async <TResult>(
|
|
870
|
+
sessionArg: SmartdataSession | undefined,
|
|
871
|
+
ownerArg: SmartdataDb,
|
|
872
|
+
optionsArg: Pick<
|
|
873
|
+
IOrdinarySessionOperationOptions,
|
|
874
|
+
'prepared' | 'prepare' | 'collectionName' | 'timeoutMS' | 'defaultTimeoutMS' | 'signal'
|
|
875
|
+
>,
|
|
876
|
+
writeArg: (sessionArg: SmartdataSession) => Promise<TResult>,
|
|
877
|
+
): Promise<TResult> => {
|
|
878
|
+
const timeoutMS = optionsArg.timeoutMS ?? optionsArg.defaultTimeoutMS;
|
|
879
|
+
if (sessionArg) {
|
|
880
|
+
if (!optionsArg.prepared) refuseUnpreparedCollection(optionsArg, 'with an owned SmartData session');
|
|
881
|
+
return await sessionArg.withTransaction(writeArg, transactionOptionsOf(timeoutMS, optionsArg.signal)) as TResult;
|
|
882
|
+
}
|
|
883
|
+
const deadline = new SmartdataOperationDeadline(timeoutMS, optionsArg.signal);
|
|
884
|
+
if (!optionsArg.prepared) {
|
|
885
|
+
if (!optionsArg.prepare) refuseUnpreparedCollection(optionsArg, 'inside a transaction');
|
|
886
|
+
await deadline.join(optionsArg.prepare!());
|
|
887
|
+
}
|
|
888
|
+
const transactionOptions = transactionOptionsOf(deadline.remainingTimeoutMS(), optionsArg.signal);
|
|
889
|
+
const session = createSmartdataSession(ownerArg);
|
|
890
|
+
try {
|
|
891
|
+
return await revisionTransactionScope.run(
|
|
892
|
+
{ owner: ownerArg, session },
|
|
893
|
+
() => session.withTransaction(writeArg, transactionOptions),
|
|
894
|
+
) as TResult;
|
|
895
|
+
} finally {
|
|
896
|
+
// A session whose end fails stays owned and invalidated; the database's close() ends it again.
|
|
897
|
+
await session.close().catch(() => {});
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
|
|
782
901
|
/**
|
|
783
902
|
* Runs one model operation on the ordinary session boundary: the owned
|
|
784
903
|
* session lease, the operation's client deadline and signal, and the mapping
|
|
785
904
|
* of an expired driver deadline to the `timeout` persistence error. A write
|
|
786
905
|
* that fails once started may still have applied, so it changes every open
|
|
787
|
-
* read set that read its collection.
|
|
906
|
+
* read set that read its collection. A write of a database with write
|
|
907
|
+
* revisions runs in an owned transaction; outside one it starts its own.
|
|
788
908
|
*/
|
|
789
909
|
export const runWithOrdinarySmartdataSession = async <TResult>(
|
|
790
910
|
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
@@ -796,6 +916,18 @@ export const runWithOrdinarySmartdataSession = async <TResult>(
|
|
|
796
916
|
) => Promise<TResult>,
|
|
797
917
|
): Promise<TResult> => {
|
|
798
918
|
optionsArg.signal?.throwIfAborted();
|
|
919
|
+
if (optionsArg.ordinaryWrite && needsSmartdataRevisionTransaction(sessionArg, ownerArg)) {
|
|
920
|
+
return runSmartdataRevisionTransaction(
|
|
921
|
+
sessionArg as SmartdataSession | undefined,
|
|
922
|
+
ownerArg,
|
|
923
|
+
optionsArg,
|
|
924
|
+
(transactionSessionArg) => runWithOrdinarySmartdataSession(transactionSessionArg, ownerArg, {
|
|
925
|
+
ordinaryWrite: true,
|
|
926
|
+
prepared: true,
|
|
927
|
+
...(optionsArg.collectionName === undefined ? {} : { collectionName: optionsArg.collectionName }),
|
|
928
|
+
}, operationArg),
|
|
929
|
+
);
|
|
930
|
+
}
|
|
799
931
|
const lease = leaseOrdinarySmartdataSession(sessionArg, ownerArg, optionsArg);
|
|
800
932
|
let deadline: SmartdataOperationDeadline | undefined;
|
|
801
933
|
let writeStarted = false;
|
|
@@ -833,6 +965,41 @@ const bufferTransactionWrite = (
|
|
|
833
965
|
}
|
|
834
966
|
};
|
|
835
967
|
|
|
968
|
+
/**
|
|
969
|
+
* Adds write-revision counters to the current attempt of an owned transaction.
|
|
970
|
+
* A write that settles after its attempt ended rolls back with that attempt.
|
|
971
|
+
*/
|
|
972
|
+
const bumpTransactionRevisions = (
|
|
973
|
+
bindingArg: ISmartdataSessionBinding,
|
|
974
|
+
idsArg: Iterable<string>,
|
|
975
|
+
): void => {
|
|
976
|
+
const attempt = transactionContext.getStore()?.attempt;
|
|
977
|
+
if (attempt === undefined || attempt !== bindingArg.transactionAttempt) return;
|
|
978
|
+
for (const id of idsArg) bindingArg.revisionBumps.add(id);
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Reads, in the write's transaction, the write-revision partitions of the
|
|
983
|
+
* documents a write by selector is about to change, and returns whether it
|
|
984
|
+
* did; the write's report then states `previousPartitionsRead`. `false` when
|
|
985
|
+
* the database keeps no revisions or the write runs outside an owned
|
|
986
|
+
* transaction.
|
|
987
|
+
*/
|
|
988
|
+
export const readPreviousWriteRevisions = async (
|
|
989
|
+
ownerArg: SmartdataDb,
|
|
990
|
+
collectionArg: plugins.mongodb.Collection<any>,
|
|
991
|
+
rawSessionArg: plugins.mongodb.ClientSession | undefined,
|
|
992
|
+
selectorArg: unknown,
|
|
993
|
+
deadlineArg: SmartdataOperationDeadline,
|
|
994
|
+
): Promise<boolean> => {
|
|
995
|
+
if (!rawSessionArg || !isSmartdataWriteRevisionsActive(ownerArg)) return false;
|
|
996
|
+
const binding = bindingsByRawSession.get(rawSessionArg);
|
|
997
|
+
if (!binding?.transactionActive || binding.owner !== ownerArg) return false;
|
|
998
|
+
const ids = await readPreviousRevisionIds(ownerArg, collectionArg, rawSessionArg, selectorArg, deadlineArg);
|
|
999
|
+
bumpTransactionRevisions(binding, ids);
|
|
1000
|
+
return true;
|
|
1001
|
+
};
|
|
1002
|
+
|
|
836
1003
|
/**
|
|
837
1004
|
* Reports one acknowledged write to the owner's write observers and open read
|
|
838
1005
|
* sets: at once, or, inside an owned transaction, after it committed. A
|
|
@@ -845,6 +1012,7 @@ export const reportSmartdataWrite = (
|
|
|
845
1012
|
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
846
1013
|
writeArg: ISmartdataWrite,
|
|
847
1014
|
): void => {
|
|
1015
|
+
sessionArg ??= revisionTransactionSessionOf(ownerArg);
|
|
848
1016
|
if (!isSmartdataSession(sessionArg) && sessionArg?.inTransaction()) {
|
|
849
1017
|
// A raw driver transaction passed its lease while nobody observed or
|
|
850
1018
|
// tracked, and whether it commits is never known here.
|
|
@@ -853,6 +1021,12 @@ export const reportSmartdataWrite = (
|
|
|
853
1021
|
}
|
|
854
1022
|
const binding = isSmartdataSession(sessionArg) ? bindings.get(sessionArg) : undefined;
|
|
855
1023
|
const buffered = binding?.transactionActive === true;
|
|
1024
|
+
if (binding && buffered) {
|
|
1025
|
+
const field = writeRevisionsPartitionField(ownerArg);
|
|
1026
|
+
if (field !== undefined && isSmartdataWriteRevisionsActive(ownerArg)) {
|
|
1027
|
+
bumpTransactionRevisions(binding, writeRevisionIdsOfWrite(writeArg, field));
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
856
1030
|
const changeReads = captureSmartdataReadChange(ownerArg, writeArg, buffered);
|
|
857
1031
|
const deliver = captureSmartdataWrite(ownerArg, writeArg);
|
|
858
1032
|
if (!changeReads && !deliver) return;
|
|
@@ -874,8 +1048,12 @@ export const reportSmartdataWriteFailure = (
|
|
|
874
1048
|
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
875
1049
|
collectionNameArg: string,
|
|
876
1050
|
): void => {
|
|
1051
|
+
sessionArg ??= revisionTransactionSessionOf(ownerArg);
|
|
877
1052
|
const binding = isSmartdataSession(sessionArg) ? bindings.get(sessionArg) : undefined;
|
|
878
1053
|
const buffered = binding?.transactionActive === true;
|
|
1054
|
+
if (binding && buffered && isSmartdataWriteRevisionsActive(ownerArg)) {
|
|
1055
|
+
bumpTransactionRevisions(binding, [sharedRevisionId(collectionNameArg)]);
|
|
1056
|
+
}
|
|
879
1057
|
const changeReads = captureSmartdataCollectionChange(ownerArg, collectionNameArg, buffered);
|
|
880
1058
|
if (!changeReads) return;
|
|
881
1059
|
if (binding && buffered) {
|
|
@@ -897,7 +1075,7 @@ export const reportSmartdataCollectionWrite = (
|
|
|
897
1075
|
kindArg: TSmartdataWriteKind,
|
|
898
1076
|
documentsArg: readonly object[] | null,
|
|
899
1077
|
selectorArg?: unknown,
|
|
900
|
-
readImagesArg?: Pick<ISmartdataWrite, 'images' | 'update'>,
|
|
1078
|
+
readImagesArg?: Pick<ISmartdataWrite, 'images' | 'update' | 'previousPartitionsRead'>,
|
|
901
1079
|
): void => {
|
|
902
1080
|
const schema = collectionArg.getBoundModelSchema();
|
|
903
1081
|
reportSmartdataWrite(collectionArg.smartdataDb, sessionArg, {
|
|
@@ -922,41 +1100,49 @@ const identityFieldsOfSchema = (
|
|
|
922
1100
|
/**
|
|
923
1101
|
* Registers one read of a model collection with the open read sets of the
|
|
924
1102
|
* current async context, right before it is issued; `undefined` when none is
|
|
925
|
-
* open. See `observeSmartdataRead`.
|
|
1103
|
+
* open. See `observeSmartdataRead`. Settles once the write revisions the read
|
|
1104
|
+
* depends on were read, within `deadlineArg`; issue the read after it.
|
|
926
1105
|
*/
|
|
927
1106
|
export const trackSmartdataRead = (
|
|
928
1107
|
collectionArg: SmartdataCollection<any>,
|
|
929
1108
|
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
930
|
-
specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields'>,
|
|
931
|
-
|
|
1109
|
+
specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields' | 'orderingIdentityFields'>,
|
|
1110
|
+
deadlineArg?: SmartdataOperationDeadline,
|
|
1111
|
+
): Promise<ISmartdataReadRecorder | undefined> => trackSmartdataModelRead(
|
|
932
1112
|
collectionArg.smartdataDb,
|
|
933
1113
|
collectionArg.collectionName,
|
|
934
1114
|
collectionArg.getBoundModelSchema(),
|
|
935
1115
|
sessionArg,
|
|
936
1116
|
specArg,
|
|
1117
|
+
deadlineArg,
|
|
937
1118
|
);
|
|
938
1119
|
|
|
939
1120
|
/** `trackSmartdataRead` for a read that names its model by schema rather than collection. */
|
|
940
|
-
export const trackSmartdataModelRead = (
|
|
1121
|
+
export const trackSmartdataModelRead = async (
|
|
941
1122
|
dbArg: SmartdataDb,
|
|
942
1123
|
collectionNameArg: string,
|
|
943
1124
|
schemaArg: ReturnType<SmartdataCollection<any>['getBoundModelSchema']>,
|
|
944
1125
|
sessionArg: TSmartdataOrdinarySession | undefined,
|
|
945
|
-
specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields'>,
|
|
946
|
-
|
|
1126
|
+
specArg: Omit<ISmartdataReadSpec, 'collection' | 'identityFields' | 'orderingIdentityFields'>,
|
|
1127
|
+
deadlineArg?: SmartdataOperationDeadline,
|
|
1128
|
+
): Promise<ISmartdataReadRecorder | undefined> => {
|
|
947
1129
|
if (!hasSmartdataReadScope()) return undefined;
|
|
948
1130
|
let inTransaction: boolean;
|
|
949
1131
|
try {
|
|
950
|
-
inTransaction = isOrdinarySessionInTransaction(sessionArg);
|
|
1132
|
+
inTransaction = isOrdinarySessionInTransaction(sessionArg ?? revisionTransactionSessionOf(dbArg));
|
|
951
1133
|
} catch {
|
|
952
1134
|
// An ended session's read fails; a set must not count it as followed.
|
|
953
1135
|
inTransaction = true;
|
|
954
1136
|
}
|
|
955
|
-
|
|
1137
|
+
const identityFields = identityFieldsOfSchema(schemaArg);
|
|
1138
|
+
const recorder = observeSmartdataRead(dbArg, {
|
|
956
1139
|
...specArg,
|
|
957
1140
|
collection: collectionNameArg,
|
|
958
|
-
identityFields
|
|
1141
|
+
identityFields,
|
|
1142
|
+
orderingIdentityFields: schemaArg && identityFields ? getOrderingIdentityFields(schemaArg) : undefined,
|
|
959
1143
|
}, inTransaction);
|
|
1144
|
+
if (recorder?.ready) await (deadlineArg ? deadlineArg.join(recorder.ready) : recorder.ready);
|
|
1145
|
+
return recorder;
|
|
960
1146
|
};
|
|
961
1147
|
|
|
962
1148
|
/**
|
|
@@ -3,6 +3,7 @@ import type { SmartdataDb } from './classes.db.js';
|
|
|
3
3
|
import { convertFilterForMongoDb } from './classes.doc.js';
|
|
4
4
|
import { SmartdataPersistenceError } from './classes.persistence.js';
|
|
5
5
|
import { runCursorOperation } from './classes.cursoroperation.js';
|
|
6
|
+
import { SmartdataOperationDeadline } from './classes.operationdeadline.js';
|
|
6
7
|
import {
|
|
7
8
|
acquireSmartdataSession, isSmartdataSession, trackSmartdataModelRead, type SmartdataSession,
|
|
8
9
|
} from './classes.session.js';
|
|
@@ -81,12 +82,14 @@ const readStoredDocuments = async (
|
|
|
81
82
|
let page: { documents: plugins.mongodb.Document[]; hasMore: boolean };
|
|
82
83
|
try {
|
|
83
84
|
page = await dbArg[collectionTopologyDbInspectionSymbol](async (mongoDbArg) => {
|
|
84
|
-
const
|
|
85
|
+
const deadline = new SmartdataOperationDeadline(timeoutMS, opts.signal);
|
|
86
|
+
const tracked = await trackSmartdataModelRead(dbArg, schema.collectionName, schema, opts.session, {
|
|
85
87
|
kind: 'documents', filter, sort: { _id: 1 }, matches: skip > 0, limited: true,
|
|
86
|
-
});
|
|
88
|
+
}, deadline);
|
|
87
89
|
const cursor = mongoDbArg.collection(schema.collectionName).find(filter, {
|
|
88
90
|
sort: { _id: 1 }, skip, limit: limit + 1, batchSize: 1,
|
|
89
|
-
timeoutMS, timeoutMode: 'cursorLifetime', signal: opts.signal,
|
|
91
|
+
timeoutMS: deadline.remainingTimeoutMS(), timeoutMode: 'cursorLifetime', signal: opts.signal,
|
|
92
|
+
session: lease?.rawSession,
|
|
90
93
|
});
|
|
91
94
|
return runCursorOperation(cursor, async (cursorArg) => {
|
|
92
95
|
const documents: plugins.mongodb.Document[] = [];
|
|
@@ -62,6 +62,11 @@ export interface ISmartdataWrite {
|
|
|
62
62
|
update?: unknown;
|
|
63
63
|
/** The model's identity fields, by which a selector names a document without its `_id`. */
|
|
64
64
|
identityFields?: readonly string[];
|
|
65
|
+
/**
|
|
66
|
+
* The write-revision partitions of the documents the selector matched were
|
|
67
|
+
* read in the write's transaction before it ran.
|
|
68
|
+
*/
|
|
69
|
+
previousPartitionsRead?: true;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
interface IWriteObserver {
|