@lossless.org/client 1.20.2 → 1.21.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 +3 -3
- package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +6 -3
- package/dist_ts/nosqldb/classes.atomicupdate.js +4 -4
- package/dist_ts/nosqldb/classes.bootstraprecord.js +10 -5
- package/dist_ts/nosqldb/classes.collection.js +53 -16
- package/dist_ts/nosqldb/classes.collectiontopology.d.ts +8 -1
- package/dist_ts/nosqldb/classes.collectiontopology.js +10 -2
- package/dist_ts/nosqldb/classes.cursor.d.ts +4 -1
- package/dist_ts/nosqldb/classes.cursor.js +14 -3
- package/dist_ts/nosqldb/classes.db.d.ts +22 -0
- package/dist_ts/nosqldb/classes.db.js +25 -1
- package/dist_ts/nosqldb/classes.doc.js +35 -5
- package/dist_ts/nosqldb/classes.exactpersistence.d.ts +6 -0
- package/dist_ts/nosqldb/classes.exactpersistence.js +70 -13
- package/dist_ts/nosqldb/classes.readtracker.d.ts +89 -0
- package/dist_ts/nosqldb/classes.readtracker.js +876 -0
- package/dist_ts/nosqldb/classes.session.d.ts +30 -7
- package/dist_ts/nosqldb/classes.session.js +119 -26
- package/dist_ts/nosqldb/classes.storedinspection.js +12 -4
- package/dist_ts/nosqldb/classes.testdatabase.js +16 -9
- package/dist_ts/nosqldb/classes.writeobserver.d.ts +11 -0
- package/dist_ts/nosqldb/classes.writeobserver.js +1 -1
- package/package.json +1 -1
- package/readme.md +1 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.atomicdelete.ts +16 -2
- package/ts/nosqldb/classes.atomicfindoneandupdate.ts +5 -0
- package/ts/nosqldb/classes.atomicupdate.ts +10 -1
- package/ts/nosqldb/classes.bootstraprecord.ts +7 -4
- package/ts/nosqldb/classes.collection.ts +56 -12
- package/ts/nosqldb/classes.collectiontopology.ts +15 -1
- package/ts/nosqldb/classes.cursor.ts +14 -1
- package/ts/nosqldb/classes.db.ts +32 -0
- package/ts/nosqldb/classes.doc.ts +32 -2
- package/ts/nosqldb/classes.exactpersistence.ts +93 -19
- package/ts/nosqldb/classes.readtracker.ts +1031 -0
- package/ts/nosqldb/classes.session.ts +166 -30
- package/ts/nosqldb/classes.storedinspection.ts +15 -3
- package/ts/nosqldb/classes.testdatabase.ts +14 -8
- package/ts/nosqldb/classes.writeobserver.ts +11 -0
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
|
|
3
|
+
import { collectionTopologyDbInspectionSymbol } from './classes.collectiontopology.js';
|
|
4
|
+
import type { SmartdataDb } from './classes.db.js';
|
|
5
|
+
import { readBoundedInspectionCursor } from './classes.namespaceinspection.js';
|
|
6
|
+
import { SmartdataPersistenceError } from './classes.persistence.js';
|
|
7
|
+
import type { ISmartdataWrite } from './classes.writeobserver.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The reads one `db.trackReads()` call made. `changed` turns true once a write
|
|
11
|
+
* of this process may have changed what any of them returned, and stays true.
|
|
12
|
+
*/
|
|
13
|
+
export class SmartdataReadSet {
|
|
14
|
+
/**
|
|
15
|
+
* Whether a write this process made through the database's models since the
|
|
16
|
+
* reads ran could have changed any of their results. Also true after
|
|
17
|
+
* `close()`, after the database closed, and for a set that tracked a read
|
|
18
|
+
* it cannot follow.
|
|
19
|
+
*/
|
|
20
|
+
public get changed(): boolean {
|
|
21
|
+
const state = readSetStates.get(this);
|
|
22
|
+
return !state || state.changed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Stops following writes and frees the recorded reads; `changed` is true from then on. */
|
|
26
|
+
public close(): void {
|
|
27
|
+
const state = readSetStates.get(this);
|
|
28
|
+
if (state) changeReadSet(state);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What `db.trackReads()` resolves with: the callback's result and the reads it made. */
|
|
33
|
+
export interface ISmartdataTrackedReads<TResult> {
|
|
34
|
+
readonly result: TResult;
|
|
35
|
+
readonly reads: SmartdataReadSet;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @internal What one model read asks for, stated before it is issued. */
|
|
39
|
+
export interface ISmartdataReadSpec {
|
|
40
|
+
collection: string;
|
|
41
|
+
/**
|
|
42
|
+
* `documents` returns the matching documents, `count` counts them, and
|
|
43
|
+
* `collection` depends on the whole collection: every write to it changes it.
|
|
44
|
+
*/
|
|
45
|
+
kind: 'documents' | 'count' | 'collection';
|
|
46
|
+
/** The filter as sent to the database. */
|
|
47
|
+
filter?: unknown;
|
|
48
|
+
sort?: unknown;
|
|
49
|
+
hint?: unknown;
|
|
50
|
+
/**
|
|
51
|
+
* The result depends on the matching documents' contents beyond which ones
|
|
52
|
+
* were returned (skipped pages, distinct values, grouped totals), so the
|
|
53
|
+
* returned documents never settle it.
|
|
54
|
+
*/
|
|
55
|
+
matches?: boolean;
|
|
56
|
+
/** The read returns at most a limit of the matching documents. */
|
|
57
|
+
limited?: boolean;
|
|
58
|
+
/** The model's identity fields, by which a write names a document without its `_id`. */
|
|
59
|
+
identityFields?: readonly string[];
|
|
60
|
+
/** The identity fields a sort by orders every document; see `getOrderingIdentityFields`. */
|
|
61
|
+
orderingIdentityFields?: readonly string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @internal Records what one read returned. */
|
|
65
|
+
export interface ISmartdataReadRecorder {
|
|
66
|
+
/** Records one raw document the read returned. */
|
|
67
|
+
record(documentArg: unknown): void;
|
|
68
|
+
/** The read returned everything it will return. */
|
|
69
|
+
settle(): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Values an image or a stored path can hold beside real values.
|
|
73
|
+
const MISSING = Symbol('missing');
|
|
74
|
+
const UNKNOWN = Symbol('unknown');
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What is known about one document: values by path, layered over `base`. A
|
|
78
|
+
* complete image knows every top-level field, so a field it lacks is missing.
|
|
79
|
+
*/
|
|
80
|
+
interface IDocumentImage {
|
|
81
|
+
values: ReadonlyMap<string, unknown>;
|
|
82
|
+
complete: boolean;
|
|
83
|
+
base?: IDocumentImage;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `true`, `false`, or `undefined` when the known values do not decide. */
|
|
87
|
+
type TMatch = boolean | undefined;
|
|
88
|
+
type TPredicate = (imageArg: IDocumentImage | undefined) => TMatch;
|
|
89
|
+
type TCondition = (valueArg: unknown) => TMatch;
|
|
90
|
+
|
|
91
|
+
interface IReadObservation {
|
|
92
|
+
kind: ISmartdataReadSpec['kind'];
|
|
93
|
+
predicate: TPredicate;
|
|
94
|
+
/** The paths the filter and sort read; `all` when they cannot be named. */
|
|
95
|
+
refs: ReadonlySet<string> | 'all';
|
|
96
|
+
identityFields?: readonly string[];
|
|
97
|
+
settles: boolean;
|
|
98
|
+
settled: boolean;
|
|
99
|
+
/** The sequence number at settling; a write that started earlier is not concurrent. */
|
|
100
|
+
settledAt: number;
|
|
101
|
+
/** The `_id` keys of the returned documents; `null` when one cannot be keyed. */
|
|
102
|
+
byId: Set<string> | null;
|
|
103
|
+
/** The identity keys of the returned documents; `null` when one cannot be keyed. */
|
|
104
|
+
byIdentity: Set<string> | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface IReadSetState {
|
|
108
|
+
db: SmartdataDb;
|
|
109
|
+
changed: boolean;
|
|
110
|
+
/** The callback returned, so its read collections have been checked. */
|
|
111
|
+
finished: boolean;
|
|
112
|
+
reads: Map<string, IReadObservation[]>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What one write did to one document, as far as it is known. */
|
|
116
|
+
interface IReadChange {
|
|
117
|
+
kind: ISmartdataWrite['kind'] | 'unknown';
|
|
118
|
+
before?: IDocumentImage;
|
|
119
|
+
after?: IDocumentImage;
|
|
120
|
+
touched: ReadonlySet<string> | 'all';
|
|
121
|
+
idKey?: string;
|
|
122
|
+
identityKey?: string;
|
|
123
|
+
startedAt: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const readSetStates = new WeakMap<SmartdataReadSet, IReadSetState>();
|
|
127
|
+
const readSetsByDb = new WeakMap<SmartdataDb, Set<IReadSetState>>();
|
|
128
|
+
/** Databases a read set was ever opened on; writes to others skip all capture work. */
|
|
129
|
+
const trackedDbs = new WeakSet<SmartdataDb>();
|
|
130
|
+
const droppedReadSets = new FinalizationRegistry<IReadSetState>((stateArg) => {
|
|
131
|
+
changeReadSet(stateArg);
|
|
132
|
+
});
|
|
133
|
+
const readScope = new plugins.nodeAsyncHooks.AsyncLocalStorage<readonly IReadSetState[]>();
|
|
134
|
+
/**
|
|
135
|
+
* Per database, whether the predicates can follow a collection's reads: only a
|
|
136
|
+
* plain collection without a default collation compares like they do.
|
|
137
|
+
*/
|
|
138
|
+
const followableCollections = new WeakMap<SmartdataDb, Map<string, boolean>>();
|
|
139
|
+
const writeScope = new plugins.nodeAsyncHooks.AsyncLocalStorage<{
|
|
140
|
+
db: SmartdataDb;
|
|
141
|
+
startedAt: number;
|
|
142
|
+
}>();
|
|
143
|
+
/** Orders read settling against write starts. */
|
|
144
|
+
let sequence = 0;
|
|
145
|
+
|
|
146
|
+
const changeReadSet = (stateArg: IReadSetState): void => {
|
|
147
|
+
if (stateArg.changed) return;
|
|
148
|
+
stateArg.changed = true;
|
|
149
|
+
stateArg.reads.clear();
|
|
150
|
+
readSetsByDb.get(stateArg.db)?.delete(stateArg);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const isPlainRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
|
|
154
|
+
if (typeof valueArg !== 'object' || valueArg === null || Array.isArray(valueArg)) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
const prototype = Object.getPrototypeOf(valueArg);
|
|
158
|
+
return prototype === Object.prototype || prototype === null;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const isObjectIdLike = (
|
|
162
|
+
valueArg: unknown,
|
|
163
|
+
): valueArg is { toHexString(): string } =>
|
|
164
|
+
typeof valueArg === 'object'
|
|
165
|
+
&& valueArg !== null
|
|
166
|
+
&& (valueArg as { _bsontype?: unknown })._bsontype === 'ObjectId'
|
|
167
|
+
&& typeof (valueArg as { toHexString?: unknown }).toHexString === 'function';
|
|
168
|
+
|
|
169
|
+
const isFilterPrimitive = (valueArg: unknown): boolean =>
|
|
170
|
+
valueArg === null
|
|
171
|
+
|| typeof valueArg === 'boolean'
|
|
172
|
+
|| (typeof valueArg === 'number' && Number.isFinite(valueArg))
|
|
173
|
+
|| (typeof valueArg === 'string' && valueArg.isWellFormed());
|
|
174
|
+
|
|
175
|
+
/** A stable key for an `_id`, or `undefined` for a value that has none. */
|
|
176
|
+
const documentIdKeyOf = (valueArg: unknown): string | undefined => {
|
|
177
|
+
if (typeof valueArg === 'string') return `s${valueArg}`;
|
|
178
|
+
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) return `n${valueArg}`;
|
|
179
|
+
if (typeof valueArg === 'boolean') return `b${valueArg}`;
|
|
180
|
+
if (isObjectIdLike(valueArg)) return `o${valueArg.toHexString()}`;
|
|
181
|
+
return undefined;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** A stable key for a document's identity values, or `undefined` unless all are concrete primitives. */
|
|
185
|
+
const identityKeyOf = (valuesArg: readonly unknown[]): string | undefined =>
|
|
186
|
+
valuesArg.every((valueArg) => valueArg !== null && isFilterPrimitive(valueArg))
|
|
187
|
+
? JSON.stringify(valuesArg)
|
|
188
|
+
: undefined;
|
|
189
|
+
|
|
190
|
+
// --- Document images --------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/** The image of a whole stored document, or `undefined` when its shape cannot be followed. */
|
|
193
|
+
const completeImage = (documentArg: unknown): IDocumentImage | undefined => {
|
|
194
|
+
if (!isPlainRecord(documentArg) || 'toBSON' in documentArg) return undefined;
|
|
195
|
+
const values = new Map<string, unknown>();
|
|
196
|
+
for (const key of Object.keys(documentArg)) {
|
|
197
|
+
if (key.length === 0 || key.includes('.') || key.startsWith('$')) return undefined;
|
|
198
|
+
values.set(key, documentArg[key]);
|
|
199
|
+
}
|
|
200
|
+
return { values, complete: true };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** Follows `segmentsArg` below a value; arrays and foreign objects are not followed. */
|
|
204
|
+
const descendValue = (valueArg: unknown, segmentsArg: readonly string[]): unknown => {
|
|
205
|
+
let value = valueArg;
|
|
206
|
+
for (const segment of segmentsArg) {
|
|
207
|
+
if (value === MISSING || value === UNKNOWN) return value;
|
|
208
|
+
if (value === undefined) return UNKNOWN;
|
|
209
|
+
if (typeof value !== 'object' || value === null) return MISSING;
|
|
210
|
+
if (!isPlainRecord(value) || 'toBSON' in value) return UNKNOWN;
|
|
211
|
+
value = Object.hasOwn(value, segment) ? value[segment] : MISSING;
|
|
212
|
+
}
|
|
213
|
+
if (value === undefined) return UNKNOWN;
|
|
214
|
+
if (isPlainRecord(value) && 'toBSON' in value) return UNKNOWN;
|
|
215
|
+
return value;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** The value at a dotted path of an image: a value, `MISSING`, or `UNKNOWN`. */
|
|
219
|
+
const lookupPath = (imageArg: IDocumentImage | undefined, pathArg: string): unknown => {
|
|
220
|
+
if (!imageArg) return UNKNOWN;
|
|
221
|
+
const segments = pathArg.split('.');
|
|
222
|
+
for (let length = segments.length; length > 0; length--) {
|
|
223
|
+
const key = length === segments.length ? pathArg : segments.slice(0, length).join('.');
|
|
224
|
+
if (imageArg.values.has(key)) {
|
|
225
|
+
return descendValue(imageArg.values.get(key), segments.slice(length));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const prefix = `${pathArg}.`;
|
|
229
|
+
for (const key of imageArg.values.keys()) {
|
|
230
|
+
// The image changed part of the value, so the rest of it is not known here.
|
|
231
|
+
if (key.startsWith(prefix)) return UNKNOWN;
|
|
232
|
+
}
|
|
233
|
+
if (imageArg.base) return lookupPath(imageArg.base, pathArg);
|
|
234
|
+
if (imageArg.complete) return pathArg === '_id' ? UNKNOWN : MISSING;
|
|
235
|
+
return UNKNOWN;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** A deep copy of plain records and arrays, so a buffered change keeps the values of its write. */
|
|
239
|
+
const snapshotValue = (valueArg: unknown): unknown => {
|
|
240
|
+
if (Array.isArray(valueArg)) return valueArg.map(snapshotValue);
|
|
241
|
+
if (isPlainRecord(valueArg) && !('toBSON' in valueArg)) {
|
|
242
|
+
const copy: Record<string, unknown> = {};
|
|
243
|
+
for (const key of Object.keys(valueArg)) copy[key] = snapshotValue(valueArg[key]);
|
|
244
|
+
return copy;
|
|
245
|
+
}
|
|
246
|
+
return valueArg;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// --- Filters ----------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
interface IRefs {
|
|
252
|
+
paths: Set<string>;
|
|
253
|
+
all: boolean;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const unknownPredicate: TPredicate = () => undefined;
|
|
257
|
+
const unknownCondition: TCondition = () => undefined;
|
|
258
|
+
|
|
259
|
+
const allOf = <TArg>(partsArg: Array<(arg: TArg) => TMatch>) =>
|
|
260
|
+
(arg: TArg): TMatch => {
|
|
261
|
+
let result: TMatch = true;
|
|
262
|
+
for (const part of partsArg) {
|
|
263
|
+
const match = part(arg);
|
|
264
|
+
if (match === false) return false;
|
|
265
|
+
if (match === undefined) result = undefined;
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const anyOf = <TArg>(partsArg: Array<(arg: TArg) => TMatch>) =>
|
|
271
|
+
(arg: TArg): TMatch => {
|
|
272
|
+
let result: TMatch = false;
|
|
273
|
+
for (const part of partsArg) {
|
|
274
|
+
const match = part(arg);
|
|
275
|
+
if (match === true) return true;
|
|
276
|
+
if (match === undefined) result = undefined;
|
|
277
|
+
}
|
|
278
|
+
return result;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const negate = <TArg>(partArg: (arg: TArg) => TMatch) =>
|
|
282
|
+
(arg: TArg): TMatch => {
|
|
283
|
+
const match = partArg(arg);
|
|
284
|
+
return match === undefined ? undefined : !match;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Whether a stored value is one the supported operators decide on. Arrays
|
|
289
|
+
* match element-wise, and other BSON types compare across numeric types, so
|
|
290
|
+
* both stay undecided.
|
|
291
|
+
*/
|
|
292
|
+
const isDecidableValue = (valueArg: unknown): boolean =>
|
|
293
|
+
valueArg === MISSING
|
|
294
|
+
|| isFilterPrimitive(valueArg)
|
|
295
|
+
|| isObjectIdLike(valueArg)
|
|
296
|
+
|| isPlainRecord(valueArg);
|
|
297
|
+
|
|
298
|
+
const decided = (conditionArg: TCondition): TCondition =>
|
|
299
|
+
(valueArg) => isDecidableValue(valueArg) ? conditionArg(valueArg) : undefined;
|
|
300
|
+
|
|
301
|
+
/** Equality with a filter primitive or ObjectId; `null` also matches a missing field. */
|
|
302
|
+
const equals = (operandArg: unknown): TCondition => decided((valueArg) => {
|
|
303
|
+
if (valueArg === MISSING || valueArg === null) return operandArg === null;
|
|
304
|
+
if (isObjectIdLike(operandArg)) {
|
|
305
|
+
return isObjectIdLike(valueArg) && valueArg.toHexString() === operandArg.toHexString();
|
|
306
|
+
}
|
|
307
|
+
if (isObjectIdLike(valueArg)) return false;
|
|
308
|
+
if (isPlainRecord(valueArg)) return false;
|
|
309
|
+
return valueArg === operandArg;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const isEqualityOperand = (valueArg: unknown): boolean =>
|
|
313
|
+
isFilterPrimitive(valueArg) || isObjectIdLike(valueArg);
|
|
314
|
+
|
|
315
|
+
/** Strings without surrogates order the same by UTF-16 units and by UTF-8 bytes. */
|
|
316
|
+
const isPlainOrderString = (valueArg: string): boolean => !/[\uD800-]/.test(valueArg);
|
|
317
|
+
|
|
318
|
+
const compares = (
|
|
319
|
+
operatorArg: '$gt' | '$gte' | '$lt' | '$lte',
|
|
320
|
+
operandArg: unknown,
|
|
321
|
+
): TCondition => {
|
|
322
|
+
if (
|
|
323
|
+
!(typeof operandArg === 'number' && Number.isFinite(operandArg))
|
|
324
|
+
&& !(typeof operandArg === 'string' && isPlainOrderString(operandArg))
|
|
325
|
+
) {
|
|
326
|
+
return unknownCondition;
|
|
327
|
+
}
|
|
328
|
+
return decided((valueArg) => {
|
|
329
|
+
if (valueArg === MISSING) return false;
|
|
330
|
+
// A range only matches values of its operand's BSON type.
|
|
331
|
+
if (typeof valueArg !== typeof operandArg) return false;
|
|
332
|
+
if (typeof valueArg === 'string' && !isPlainOrderString(valueArg)) return undefined;
|
|
333
|
+
const value = valueArg as number | string;
|
|
334
|
+
const operand = operandArg as number | string;
|
|
335
|
+
switch (operatorArg) {
|
|
336
|
+
case '$gt': return value > operand;
|
|
337
|
+
case '$gte': return value >= operand;
|
|
338
|
+
case '$lt': return value < operand;
|
|
339
|
+
case '$lte': return value <= operand;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const isOperatorObject = (valueArg: unknown): valueArg is Record<string, unknown> =>
|
|
345
|
+
isPlainRecord(valueArg)
|
|
346
|
+
&& Object.keys(valueArg).length > 0
|
|
347
|
+
&& Object.keys(valueArg).every((keyArg) => keyArg.startsWith('$'));
|
|
348
|
+
|
|
349
|
+
const compileCondition = (conditionArg: unknown): TCondition => {
|
|
350
|
+
if (isEqualityOperand(conditionArg)) return equals(conditionArg);
|
|
351
|
+
if (!isOperatorObject(conditionArg)) return unknownCondition;
|
|
352
|
+
const parts: TCondition[] = [];
|
|
353
|
+
for (const operator of Object.keys(conditionArg)) {
|
|
354
|
+
const operand = conditionArg[operator];
|
|
355
|
+
switch (operator) {
|
|
356
|
+
case '$eq':
|
|
357
|
+
parts.push(isEqualityOperand(operand) ? equals(operand) : unknownCondition);
|
|
358
|
+
break;
|
|
359
|
+
case '$ne':
|
|
360
|
+
parts.push(isEqualityOperand(operand) ? negate(equals(operand)) : unknownCondition);
|
|
361
|
+
break;
|
|
362
|
+
case '$in':
|
|
363
|
+
case '$nin': {
|
|
364
|
+
const listed = Array.isArray(operand) && operand.every(isEqualityOperand)
|
|
365
|
+
? anyOf(operand.map(equals))
|
|
366
|
+
: unknownCondition;
|
|
367
|
+
parts.push(operator === '$in' ? listed : negate(listed));
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
case '$gt':
|
|
371
|
+
case '$gte':
|
|
372
|
+
case '$lt':
|
|
373
|
+
case '$lte':
|
|
374
|
+
parts.push(compares(operator, operand));
|
|
375
|
+
break;
|
|
376
|
+
case '$exists':
|
|
377
|
+
parts.push(typeof operand === 'boolean'
|
|
378
|
+
? (valueArg) => valueArg === UNKNOWN
|
|
379
|
+
? undefined
|
|
380
|
+
: (valueArg !== MISSING) === operand
|
|
381
|
+
: unknownCondition);
|
|
382
|
+
break;
|
|
383
|
+
case '$not':
|
|
384
|
+
parts.push(isOperatorObject(operand) ? negate(compileCondition(operand)) : unknownCondition);
|
|
385
|
+
break;
|
|
386
|
+
default:
|
|
387
|
+
parts.push(unknownCondition);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return allOf(parts);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The part of a path a write or filter can be followed on: up to the first
|
|
395
|
+
* positional or numeric segment, which may address array elements.
|
|
396
|
+
*/
|
|
397
|
+
const followablePath = (pathArg: string): { path: string; whole: boolean } | undefined => {
|
|
398
|
+
const segments = pathArg.split('.');
|
|
399
|
+
if (segments.some((segmentArg) => segmentArg.length === 0)) return undefined;
|
|
400
|
+
const cut = segments.findIndex((segmentArg) =>
|
|
401
|
+
segmentArg.startsWith('$') || /^\d+$/.test(segmentArg));
|
|
402
|
+
if (cut === 0) return undefined;
|
|
403
|
+
return cut === -1
|
|
404
|
+
? { path: pathArg, whole: true }
|
|
405
|
+
: { path: segments.slice(0, cut).join('.'), whole: false };
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const compileFilter = (filterArg: unknown, refsArg: IRefs): TPredicate => {
|
|
409
|
+
if (!isPlainRecord(filterArg)) {
|
|
410
|
+
refsArg.all = true;
|
|
411
|
+
return unknownPredicate;
|
|
412
|
+
}
|
|
413
|
+
const parts: TPredicate[] = [];
|
|
414
|
+
for (const key of Object.keys(filterArg)) {
|
|
415
|
+
const value = filterArg[key];
|
|
416
|
+
if (key === '$and' || key === '$or' || key === '$nor') {
|
|
417
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every(isPlainRecord)) {
|
|
418
|
+
refsArg.all = true;
|
|
419
|
+
parts.push(unknownPredicate);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const branches = value.map((branchArg) => compileFilter(branchArg, refsArg));
|
|
423
|
+
parts.push(key === '$and'
|
|
424
|
+
? allOf(branches)
|
|
425
|
+
: key === '$or' ? anyOf(branches) : negate(anyOf(branches)));
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (key === '$comment') continue;
|
|
429
|
+
const followable = key.startsWith('$') ? undefined : followablePath(key);
|
|
430
|
+
if (!followable) {
|
|
431
|
+
refsArg.all = true;
|
|
432
|
+
parts.push(unknownPredicate);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
refsArg.paths.add(followable.path);
|
|
436
|
+
if (!followable.whole) {
|
|
437
|
+
parts.push(unknownPredicate);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const condition = compileCondition(value);
|
|
441
|
+
parts.push((imageArg) => condition(lookupPath(imageArg, key)));
|
|
442
|
+
}
|
|
443
|
+
return allOf(parts);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const addSortRefs = (sortArg: unknown, refsArg: IRefs): void => {
|
|
447
|
+
if (sortArg === undefined) return;
|
|
448
|
+
if (!isPlainRecord(sortArg)) {
|
|
449
|
+
refsArg.all = true;
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
for (const key of Object.keys(sortArg)) {
|
|
453
|
+
const followable = key.startsWith('$') ? undefined : followablePath(key);
|
|
454
|
+
const direction = sortArg[key];
|
|
455
|
+
if (!followable || (direction !== 1 && direction !== -1)) {
|
|
456
|
+
refsArg.all = true;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
refsArg.paths.add(followable.path);
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
// --- Writes -----------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
interface IUpdateEffect {
|
|
466
|
+
touched: Set<string> | 'all';
|
|
467
|
+
/** The values the update leaves at the touched paths. */
|
|
468
|
+
sets: Map<string, unknown>;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const updatesWithUnknownResult = new Set([
|
|
472
|
+
'$inc', '$mul', '$min', '$max', '$currentDate',
|
|
473
|
+
'$push', '$pull', '$pullAll', '$addToSet', '$pop', '$bit',
|
|
474
|
+
]);
|
|
475
|
+
|
|
476
|
+
const analyzeUpdate = (updateArg: unknown, insertedArg: boolean): IUpdateEffect => {
|
|
477
|
+
const everything: IUpdateEffect = { touched: 'all', sets: new Map() };
|
|
478
|
+
if (!isPlainRecord(updateArg)) return everything;
|
|
479
|
+
const operators = Object.keys(updateArg);
|
|
480
|
+
if (operators.length === 0 || operators.some((operatorArg) => !operatorArg.startsWith('$'))) {
|
|
481
|
+
return everything;
|
|
482
|
+
}
|
|
483
|
+
const touched = new Set<string>();
|
|
484
|
+
const sets = new Map<string, unknown>();
|
|
485
|
+
const touch = (pathArg: string, valueArg: unknown): boolean => {
|
|
486
|
+
const followable = followablePath(pathArg);
|
|
487
|
+
if (!followable) return false;
|
|
488
|
+
const value = !followable.whole || sets.has(followable.path) ? UNKNOWN : valueArg;
|
|
489
|
+
touched.add(followable.path);
|
|
490
|
+
sets.set(followable.path, value);
|
|
491
|
+
return true;
|
|
492
|
+
};
|
|
493
|
+
for (const operator of operators) {
|
|
494
|
+
const fields = updateArg[operator];
|
|
495
|
+
if (!isPlainRecord(fields)) return everything;
|
|
496
|
+
for (const path of Object.keys(fields)) {
|
|
497
|
+
let followed = true;
|
|
498
|
+
switch (operator) {
|
|
499
|
+
case '$set':
|
|
500
|
+
followed = touch(path, fields[path]);
|
|
501
|
+
break;
|
|
502
|
+
case '$setOnInsert':
|
|
503
|
+
if (insertedArg) followed = touch(path, fields[path]);
|
|
504
|
+
break;
|
|
505
|
+
case '$unset':
|
|
506
|
+
followed = touch(path, MISSING);
|
|
507
|
+
break;
|
|
508
|
+
case '$rename':
|
|
509
|
+
followed = typeof fields[path] === 'string'
|
|
510
|
+
&& touch(path, UNKNOWN)
|
|
511
|
+
&& touch(fields[path] as string, UNKNOWN);
|
|
512
|
+
break;
|
|
513
|
+
default:
|
|
514
|
+
if (!updatesWithUnknownResult.has(operator)) return everything;
|
|
515
|
+
followed = touch(path, UNKNOWN);
|
|
516
|
+
}
|
|
517
|
+
if (!followed) return everything;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return { touched, sets };
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The top-level equality conditions of a selector, including inside `$and`.
|
|
525
|
+
* `null` also matches a missing field, and a non-null value also matches an
|
|
526
|
+
* array holding it, so only an `_id` (never an array) states a stored value.
|
|
527
|
+
*/
|
|
528
|
+
const collectEqualities = (
|
|
529
|
+
selectorArg: unknown,
|
|
530
|
+
equalitiesArg: Map<string, unknown>,
|
|
531
|
+
): void => {
|
|
532
|
+
if (!isPlainRecord(selectorArg)) return;
|
|
533
|
+
for (const key of Object.keys(selectorArg)) {
|
|
534
|
+
const condition = selectorArg[key];
|
|
535
|
+
if (key === '$and') {
|
|
536
|
+
if (Array.isArray(condition)) {
|
|
537
|
+
for (const conjunct of condition) collectEqualities(conjunct, equalitiesArg);
|
|
538
|
+
}
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (key.startsWith('$') || key.includes('.')) continue;
|
|
542
|
+
let value: unknown = UNKNOWN;
|
|
543
|
+
if (isEqualityOperand(condition)) {
|
|
544
|
+
value = condition;
|
|
545
|
+
} else if (
|
|
546
|
+
isPlainRecord(condition)
|
|
547
|
+
&& Object.keys(condition).length === 1
|
|
548
|
+
&& Object.hasOwn(condition, '$eq')
|
|
549
|
+
&& isEqualityOperand(condition.$eq)
|
|
550
|
+
) {
|
|
551
|
+
value = condition.$eq;
|
|
552
|
+
}
|
|
553
|
+
if (value === UNKNOWN || value === null) continue;
|
|
554
|
+
if (!equalitiesArg.has(key)) equalitiesArg.set(key, value);
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
const documentIdImage = (selectorArg: unknown): IDocumentImage => {
|
|
559
|
+
const equalities = new Map<string, unknown>();
|
|
560
|
+
collectEqualities(selectorArg, equalities);
|
|
561
|
+
const values = new Map<string, unknown>();
|
|
562
|
+
if (documentIdKeyOf(equalities.get('_id')) !== undefined) values.set('_id', equalities.get('_id'));
|
|
563
|
+
return { values, complete: false };
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const selectorIdentityKey = (
|
|
567
|
+
selectorArg: unknown,
|
|
568
|
+
identityFieldsArg: readonly string[] | undefined,
|
|
569
|
+
): string | undefined => {
|
|
570
|
+
if (!identityFieldsArg?.length) return undefined;
|
|
571
|
+
const equalities = new Map<string, unknown>();
|
|
572
|
+
collectEqualities(selectorArg, equalities);
|
|
573
|
+
if (!identityFieldsArg.every((fieldArg) => equalities.has(fieldArg))) return undefined;
|
|
574
|
+
return identityKeyOf(identityFieldsArg.map((fieldArg) => equalities.get(fieldArg)));
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const imageIdentityKey = (
|
|
578
|
+
imageArg: IDocumentImage | undefined,
|
|
579
|
+
identityFieldsArg: readonly string[] | undefined,
|
|
580
|
+
): string | undefined => {
|
|
581
|
+
if (!imageArg || !identityFieldsArg?.length) return undefined;
|
|
582
|
+
return identityKeyOf(identityFieldsArg.map((fieldArg) => lookupPath(imageArg, fieldArg)));
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The document an upsert inserted: the selector's plain top-level equalities
|
|
587
|
+
* under the update's values. A selector of only such equalities states every
|
|
588
|
+
* field; any other condition leaves its field unknown.
|
|
589
|
+
*/
|
|
590
|
+
const upsertedImage = (selectorArg: unknown, effectArg: IUpdateEffect): IDocumentImage | undefined => {
|
|
591
|
+
if (effectArg.touched === 'all' || !isPlainRecord(selectorArg)) return undefined;
|
|
592
|
+
const values = new Map<string, unknown>();
|
|
593
|
+
let complete = true;
|
|
594
|
+
for (const key of Object.keys(selectorArg)) {
|
|
595
|
+
const condition = selectorArg[key];
|
|
596
|
+
if (key.startsWith('$') || key.includes('.')) {
|
|
597
|
+
complete = false;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
values.set(key, isEqualityOperand(condition) && condition !== null ? condition : UNKNOWN);
|
|
601
|
+
}
|
|
602
|
+
return { values: effectArg.sets, complete: false, base: { values, complete } };
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const withDocumentKeys = (
|
|
606
|
+
changeArg: IReadChange,
|
|
607
|
+
writeArg: ISmartdataWrite,
|
|
608
|
+
): IReadChange => {
|
|
609
|
+
changeArg.idKey = documentIdKeyOf(lookupPath(changeArg.before, '_id'))
|
|
610
|
+
?? documentIdKeyOf(lookupPath(changeArg.after, '_id'));
|
|
611
|
+
changeArg.identityKey = imageIdentityKey(changeArg.before, writeArg.identityFields)
|
|
612
|
+
?? selectorIdentityKey(writeArg.selector, writeArg.identityFields);
|
|
613
|
+
return changeArg;
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
/** What one acknowledged write did to each document it wrote, as far as its report tells. */
|
|
617
|
+
const describeWrite = (writeArg: ISmartdataWrite, startedAtArg: number): IReadChange[] => {
|
|
618
|
+
const { kind, images, documents } = writeArg;
|
|
619
|
+
const unknown: IReadChange[] = [{ kind: 'unknown', touched: 'all', startedAt: startedAtArg }];
|
|
620
|
+
const change = (fieldsArg: Omit<IReadChange, 'kind' | 'startedAt'>): IReadChange =>
|
|
621
|
+
withDocumentKeys({ kind, startedAt: startedAtArg, ...fieldsArg }, writeArg);
|
|
622
|
+
const documentIdOf = (imageArg: IDocumentImage | undefined): IDocumentImage | undefined => {
|
|
623
|
+
const id = lookupPath(imageArg, '_id');
|
|
624
|
+
return documentIdKeyOf(id) === undefined
|
|
625
|
+
? undefined
|
|
626
|
+
: { values: new Map([['_id', id]]), complete: false };
|
|
627
|
+
};
|
|
628
|
+
switch (kind) {
|
|
629
|
+
case 'insert':
|
|
630
|
+
if (images === 'after' && documents) {
|
|
631
|
+
return documents.map((documentArg) =>
|
|
632
|
+
change({ after: completeImage(documentArg), touched: 'all' }));
|
|
633
|
+
}
|
|
634
|
+
if (images === 'none' && writeArg.update !== undefined) {
|
|
635
|
+
return [change({
|
|
636
|
+
after: upsertedImage(writeArg.selector, analyzeUpdate(writeArg.update, true)),
|
|
637
|
+
touched: 'all',
|
|
638
|
+
})];
|
|
639
|
+
}
|
|
640
|
+
return images === undefined ? unknown : [change({ touched: 'all' })];
|
|
641
|
+
case 'update': {
|
|
642
|
+
const effect = analyzeUpdate(writeArg.update, false);
|
|
643
|
+
if (images === 'after' && documents?.length === 1) {
|
|
644
|
+
const after = completeImage(documents[0]);
|
|
645
|
+
return [change({ before: documentIdOf(after), after, touched: effect.touched })];
|
|
646
|
+
}
|
|
647
|
+
if (images === 'before' && documents?.length === 1) {
|
|
648
|
+
const before = completeImage(documents[0]);
|
|
649
|
+
return [change({
|
|
650
|
+
before,
|
|
651
|
+
after: effect.touched === 'all' || !before
|
|
652
|
+
? undefined
|
|
653
|
+
: { values: effect.sets, complete: false, base: before },
|
|
654
|
+
touched: effect.touched,
|
|
655
|
+
})];
|
|
656
|
+
}
|
|
657
|
+
if (images === 'none') {
|
|
658
|
+
const before = documentIdImage(writeArg.selector);
|
|
659
|
+
return [change({
|
|
660
|
+
before,
|
|
661
|
+
after: effect.touched === 'all'
|
|
662
|
+
? undefined
|
|
663
|
+
: { values: effect.sets, complete: false, base: before },
|
|
664
|
+
touched: effect.touched,
|
|
665
|
+
})];
|
|
666
|
+
}
|
|
667
|
+
return unknown;
|
|
668
|
+
}
|
|
669
|
+
case 'replace': {
|
|
670
|
+
if (images === 'beforeAndAfter' && documents?.length === 2) {
|
|
671
|
+
const before = completeImage(documents[0]);
|
|
672
|
+
let after = completeImage(documents[1]);
|
|
673
|
+
if (after && !after.values.has('_id') && before?.values.has('_id')) {
|
|
674
|
+
after = { ...after, values: new Map([...after.values, ['_id', before.values.get('_id')]]) };
|
|
675
|
+
}
|
|
676
|
+
return [change({ before, after, touched: 'all' })];
|
|
677
|
+
}
|
|
678
|
+
if (images === 'after' && documents?.length) {
|
|
679
|
+
// Documents before the last are what the caller expected, not what was stored.
|
|
680
|
+
const after = completeImage(documents[documents.length - 1]);
|
|
681
|
+
return [change({ before: documentIdOf(after), after, touched: 'all' })];
|
|
682
|
+
}
|
|
683
|
+
return unknown;
|
|
684
|
+
}
|
|
685
|
+
case 'delete':
|
|
686
|
+
if (images === 'before' && documents) {
|
|
687
|
+
return documents.map((documentArg) =>
|
|
688
|
+
change({ before: completeImage(documentArg), touched: 'all' }));
|
|
689
|
+
}
|
|
690
|
+
if (images === 'none') {
|
|
691
|
+
return [change({ before: documentIdImage(writeArg.selector), touched: 'all' })];
|
|
692
|
+
}
|
|
693
|
+
return unknown;
|
|
694
|
+
}
|
|
695
|
+
return unknown;
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
// --- Evaluation -------------------------------------------------------------
|
|
699
|
+
|
|
700
|
+
const pathsOverlap = (leftArg: string, rightArg: string): boolean =>
|
|
701
|
+
leftArg === rightArg
|
|
702
|
+
|| leftArg.startsWith(`${rightArg}.`)
|
|
703
|
+
|| rightArg.startsWith(`${leftArg}.`);
|
|
704
|
+
|
|
705
|
+
const refsOverlap = (
|
|
706
|
+
refsArg: ReadonlySet<string> | 'all',
|
|
707
|
+
touchedArg: ReadonlySet<string> | 'all',
|
|
708
|
+
): boolean => {
|
|
709
|
+
if (refsArg === 'all' || touchedArg === 'all') return true;
|
|
710
|
+
for (const ref of refsArg) {
|
|
711
|
+
for (const path of touchedArg) {
|
|
712
|
+
if (pathsOverlap(ref, path)) return true;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return false;
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Whether the change could alter what the read returned. Anything that is not
|
|
720
|
+
* known to leave the result alone counts as a change.
|
|
721
|
+
*/
|
|
722
|
+
const changeAffectsRead = (readArg: IReadObservation, changeArg: IReadChange): boolean => {
|
|
723
|
+
if (changeArg.kind === 'unknown' || readArg.kind === 'collection') return true;
|
|
724
|
+
const matchesBefore = () => readArg.predicate(changeArg.before);
|
|
725
|
+
const matchesAfter = () => readArg.predicate(changeArg.after);
|
|
726
|
+
// A write that started before the read settled may be part of what it returned.
|
|
727
|
+
const concurrent = !readArg.settled || readArg.settledAt > changeArg.startedAt;
|
|
728
|
+
if (readArg.kind === 'count') {
|
|
729
|
+
if (changeArg.kind === 'insert') return matchesAfter() !== false;
|
|
730
|
+
if (changeArg.kind === 'delete') return matchesBefore() !== false;
|
|
731
|
+
if (!refsOverlap(readArg.refs, changeArg.touched)) return false;
|
|
732
|
+
const before = matchesBefore();
|
|
733
|
+
const after = matchesAfter();
|
|
734
|
+
if (concurrent) return !(before === false && after === false);
|
|
735
|
+
return before === undefined || after === undefined || before !== after;
|
|
736
|
+
}
|
|
737
|
+
if (changeArg.kind === 'insert') return matchesAfter() !== false;
|
|
738
|
+
let returned: boolean | undefined;
|
|
739
|
+
if (readArg.settled && !concurrent) {
|
|
740
|
+
if (changeArg.idKey !== undefined && readArg.byId) {
|
|
741
|
+
returned = readArg.byId.has(changeArg.idKey);
|
|
742
|
+
} else if (changeArg.identityKey !== undefined && readArg.byIdentity) {
|
|
743
|
+
returned = readArg.byIdentity.has(changeArg.identityKey);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (returned === true) return true;
|
|
747
|
+
if (returned === undefined && matchesBefore() !== false) return true;
|
|
748
|
+
// The document was not returned: only one that matches afterwards can enter.
|
|
749
|
+
if (changeArg.kind === 'delete') return false;
|
|
750
|
+
if (matchesAfter() === false) return false;
|
|
751
|
+
return refsOverlap(readArg.refs, changeArg.touched);
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const applyChanges = (
|
|
755
|
+
dbArg: SmartdataDb,
|
|
756
|
+
collectionArg: string,
|
|
757
|
+
changesArg: readonly IReadChange[],
|
|
758
|
+
): void => {
|
|
759
|
+
const states = readSetsByDb.get(dbArg);
|
|
760
|
+
if (!states) return;
|
|
761
|
+
for (const state of [...states]) {
|
|
762
|
+
const reads = state.reads.get(collectionArg);
|
|
763
|
+
if (!reads) continue;
|
|
764
|
+
if (reads.some((readArg) => changesArg.some((changeArg) => changeAffectsRead(readArg, changeArg)))) {
|
|
765
|
+
changeReadSet(state);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
/** How long the check of one read collection may take. */
|
|
771
|
+
const collectionCheckTimeoutMs = 5_000;
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Whether the reads of a collection can be followed, or `undefined` when it
|
|
775
|
+
* does not exist: a view, another namespace type and a collection with a
|
|
776
|
+
* default collation compare in ways the predicates do not.
|
|
777
|
+
*/
|
|
778
|
+
const isFollowableCollection = async (
|
|
779
|
+
dbArg: SmartdataDb,
|
|
780
|
+
collectionArg: string,
|
|
781
|
+
): Promise<boolean | undefined> => {
|
|
782
|
+
const signal = AbortSignal.timeout(collectionCheckTimeoutMs);
|
|
783
|
+
const expiresAt = performance.now() + collectionCheckTimeoutMs;
|
|
784
|
+
const cleanupOptions = () => ({ timeoutMS: Math.max(1, Math.ceil(expiresAt - performance.now())) });
|
|
785
|
+
const [namespace] = await dbArg[collectionTopologyDbInspectionSymbol]((mongoDbArg) =>
|
|
786
|
+
readBoundedInspectionCursor(mongoDbArg.listCollections(
|
|
787
|
+
{ name: collectionArg },
|
|
788
|
+
{ nameOnly: false, batchSize: 1, ...cleanupOptions(), signal },
|
|
789
|
+
), 1, signal, cleanupOptions));
|
|
790
|
+
if (!namespace) return undefined;
|
|
791
|
+
const options = (namespace as { options?: unknown }).options;
|
|
792
|
+
return namespace.type === 'collection'
|
|
793
|
+
&& (options === undefined || (isPlainRecord(options) && options.collation === undefined));
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Checks each collection the set read, once per database. A collection that
|
|
798
|
+
* cannot be followed, does not exist or cannot be checked changes the set.
|
|
799
|
+
*/
|
|
800
|
+
const checkReadCollections = async (stateArg: IReadSetState): Promise<void> => {
|
|
801
|
+
let known = followableCollections.get(stateArg.db);
|
|
802
|
+
if (!known) {
|
|
803
|
+
known = new Map();
|
|
804
|
+
followableCollections.set(stateArg.db, known);
|
|
805
|
+
}
|
|
806
|
+
const collections = [...stateArg.reads.keys()];
|
|
807
|
+
try {
|
|
808
|
+
await Promise.all(collections.filter((collectionArg) => !known.has(collectionArg))
|
|
809
|
+
.map(async (collectionArg) => {
|
|
810
|
+
const followable = await isFollowableCollection(stateArg.db, collectionArg);
|
|
811
|
+
if (followable !== undefined) known.set(collectionArg, followable);
|
|
812
|
+
}));
|
|
813
|
+
} catch {
|
|
814
|
+
changeReadSet(stateArg);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (collections.some((collectionArg) => known.get(collectionArg) !== true)) changeReadSet(stateArg);
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
// --- Internal API -----------------------------------------------------------
|
|
821
|
+
|
|
822
|
+
/** @internal Runs `fnArg` with a new read set open; see `SmartdataDb.trackReads`. */
|
|
823
|
+
export const trackSmartdataReads = async <TResult>(
|
|
824
|
+
dbArg: SmartdataDb,
|
|
825
|
+
fnArg: () => TResult | Promise<TResult>,
|
|
826
|
+
): Promise<ISmartdataTrackedReads<TResult>> => {
|
|
827
|
+
if (typeof fnArg !== 'function') {
|
|
828
|
+
throw new SmartdataPersistenceError('invalid_argument',
|
|
829
|
+
'db.trackReads() requires a callback function.');
|
|
830
|
+
}
|
|
831
|
+
const reads = new SmartdataReadSet();
|
|
832
|
+
const state: IReadSetState = { db: dbArg, changed: false, finished: false, reads: new Map() };
|
|
833
|
+
readSetStates.set(reads, state);
|
|
834
|
+
let states = readSetsByDb.get(dbArg);
|
|
835
|
+
if (!states) {
|
|
836
|
+
states = new Set();
|
|
837
|
+
readSetsByDb.set(dbArg, states);
|
|
838
|
+
}
|
|
839
|
+
states.add(state);
|
|
840
|
+
trackedDbs.add(dbArg);
|
|
841
|
+
droppedReadSets.register(reads, state);
|
|
842
|
+
try {
|
|
843
|
+
const result = await readScope.run([...(readScope.getStore() ?? []), state], fnArg);
|
|
844
|
+
state.finished = true;
|
|
845
|
+
if (!state.changed) await checkReadCollections(state);
|
|
846
|
+
return { result, reads };
|
|
847
|
+
} catch (errorArg) {
|
|
848
|
+
reads.close();
|
|
849
|
+
throw errorArg;
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
/** @internal Whether a read of the current async context is tracked. */
|
|
854
|
+
export const hasSmartdataReadScope = (): boolean => readScope.getStore() !== undefined;
|
|
855
|
+
|
|
856
|
+
/** @internal Whether any read set of the database is open. */
|
|
857
|
+
export const hasSmartdataReadSets = (dbArg: SmartdataDb): boolean =>
|
|
858
|
+
(readSetsByDb.get(dbArg)?.size ?? 0) > 0;
|
|
859
|
+
|
|
860
|
+
/** @internal Marks every open read set of the database changed, as its `close()` does. */
|
|
861
|
+
export const changeSmartdataReadSetsOfDb = (dbArg: SmartdataDb): void => {
|
|
862
|
+
// A reconnected database may hold recreated collections.
|
|
863
|
+
followableCollections.delete(dbArg);
|
|
864
|
+
for (const state of [...(readSetsByDb.get(dbArg) ?? [])]) changeReadSet(state);
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Whether a limited read keeps the documents its filter and sort decide: at
|
|
869
|
+
* most one document matches an equality on `_id` or an identity field, and a
|
|
870
|
+
* sort by `_id` or by an identity field whose index holds every document
|
|
871
|
+
* orders them all.
|
|
872
|
+
*/
|
|
873
|
+
const limitFollowsQuery = (specArg: ISmartdataReadSpec): boolean => {
|
|
874
|
+
const filter = specArg.filter;
|
|
875
|
+
const sort = specArg.sort;
|
|
876
|
+
const pins = (valueArg: unknown): boolean =>
|
|
877
|
+
(isFilterPrimitive(valueArg) && valueArg !== null)
|
|
878
|
+
|| isObjectIdLike(valueArg)
|
|
879
|
+
|| (isPlainRecord(valueArg) && Object.keys(valueArg).length === 1 && Object.hasOwn(valueArg, '$eq') && pins(valueArg.$eq));
|
|
880
|
+
return ['_id', ...(specArg.identityFields ?? [])].some((fieldArg) =>
|
|
881
|
+
isPlainRecord(filter) && Object.hasOwn(filter, fieldArg) && pins(filter[fieldArg]))
|
|
882
|
+
|| ['_id', ...(specArg.orderingIdentityFields ?? [])].some((fieldArg) =>
|
|
883
|
+
isPlainRecord(sort) && Object.hasOwn(sort, fieldArg));
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* @internal Registers one read with every open read set of the current async
|
|
888
|
+
* context, before the read is issued. A read inside a transaction sees writes
|
|
889
|
+
* that may never commit, and a read of another database or of a collection the
|
|
890
|
+
* predicates cannot follow is not followed, so each marks the sets changed
|
|
891
|
+
* instead.
|
|
892
|
+
*/
|
|
893
|
+
export const observeSmartdataRead = (
|
|
894
|
+
dbArg: SmartdataDb,
|
|
895
|
+
specArg: ISmartdataReadSpec,
|
|
896
|
+
inTransactionArg: boolean,
|
|
897
|
+
): ISmartdataReadRecorder | undefined => {
|
|
898
|
+
const states = readScope.getStore()?.filter((stateArg) => !stateArg.changed);
|
|
899
|
+
if (!states?.length) return undefined;
|
|
900
|
+
const followed: IReadSetState[] = [];
|
|
901
|
+
const followable = followableCollections.get(dbArg)?.get(specArg.collection);
|
|
902
|
+
for (const state of states) {
|
|
903
|
+
// A read after the callback returned was never checked with the others.
|
|
904
|
+
if (
|
|
905
|
+
inTransactionArg
|
|
906
|
+
|| state.db !== dbArg
|
|
907
|
+
|| followable === false
|
|
908
|
+
|| (state.finished && followable !== true)
|
|
909
|
+
) {
|
|
910
|
+
changeReadSet(state);
|
|
911
|
+
} else {
|
|
912
|
+
followed.push(state);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (followed.length === 0) return undefined;
|
|
916
|
+
const refs: IRefs = { paths: new Set(), all: false };
|
|
917
|
+
let predicate = specArg.kind === 'collection'
|
|
918
|
+
? unknownPredicate
|
|
919
|
+
: compileFilter(specArg.filter ?? {}, refs);
|
|
920
|
+
addSortRefs(specArg.sort, refs);
|
|
921
|
+
if (specArg.limited && !limitFollowsQuery(specArg)) {
|
|
922
|
+
// Which matching documents the limit keeps follows the query plan, which a write to any field can reorder.
|
|
923
|
+
refs.all = true;
|
|
924
|
+
}
|
|
925
|
+
if (specArg.hint !== undefined) {
|
|
926
|
+
// A hinted sparse or partial index can leave out matching documents.
|
|
927
|
+
const filterPredicate = predicate;
|
|
928
|
+
predicate = (imageArg) => filterPredicate(imageArg) === false ? false : undefined;
|
|
929
|
+
refs.all = true;
|
|
930
|
+
}
|
|
931
|
+
const observation: IReadObservation = {
|
|
932
|
+
kind: specArg.kind,
|
|
933
|
+
predicate,
|
|
934
|
+
refs: refs.all ? 'all' : refs.paths,
|
|
935
|
+
identityFields: specArg.identityFields?.length ? [...specArg.identityFields] : undefined,
|
|
936
|
+
settles: specArg.matches !== true,
|
|
937
|
+
settled: false,
|
|
938
|
+
settledAt: 0,
|
|
939
|
+
byId: new Set(),
|
|
940
|
+
byIdentity: specArg.identityFields?.length ? new Set() : null,
|
|
941
|
+
};
|
|
942
|
+
for (const state of followed) {
|
|
943
|
+
let reads = state.reads.get(specArg.collection);
|
|
944
|
+
if (!reads) {
|
|
945
|
+
reads = [];
|
|
946
|
+
state.reads.set(specArg.collection, reads);
|
|
947
|
+
}
|
|
948
|
+
reads.push(observation);
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
record: (documentArg) => {
|
|
952
|
+
if (observation.settled) return;
|
|
953
|
+
if (!isPlainRecord(documentArg)) {
|
|
954
|
+
observation.byId = null;
|
|
955
|
+
observation.byIdentity = null;
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const idKey = documentIdKeyOf(documentArg._id);
|
|
959
|
+
if (idKey === undefined) observation.byId = null;
|
|
960
|
+
else observation.byId?.add(idKey);
|
|
961
|
+
if (observation.byIdentity && observation.identityFields) {
|
|
962
|
+
const identityKey = identityKeyOf(observation.identityFields.map((fieldArg) =>
|
|
963
|
+
Object.hasOwn(documentArg, fieldArg) ? documentArg[fieldArg] : undefined));
|
|
964
|
+
if (identityKey === undefined) observation.byIdentity = null;
|
|
965
|
+
else observation.byIdentity.add(identityKey);
|
|
966
|
+
}
|
|
967
|
+
},
|
|
968
|
+
settle: () => {
|
|
969
|
+
if (!observation.settles || observation.settled) return;
|
|
970
|
+
observation.settled = true;
|
|
971
|
+
observation.settledAt = ++sequence;
|
|
972
|
+
},
|
|
973
|
+
};
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* @internal Runs one model write. A write inside a read scope marks its sets
|
|
978
|
+
* changed; any other write records when it started, so its report can tell
|
|
979
|
+
* which reads ran concurrently with it.
|
|
980
|
+
*/
|
|
981
|
+
export const runSmartdataWrite = <TResult>(
|
|
982
|
+
dbArg: SmartdataDb,
|
|
983
|
+
operationArg: () => Promise<TResult>,
|
|
984
|
+
): Promise<TResult> => {
|
|
985
|
+
for (const state of readScope.getStore() ?? []) changeReadSet(state);
|
|
986
|
+
if (!trackedDbs.has(dbArg)) return operationArg();
|
|
987
|
+
return writeScope.run({ db: dbArg, startedAt: ++sequence }, operationArg);
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* @internal Captures how one acknowledged write changes the open read sets of
|
|
992
|
+
* its database, and returns the change to apply, or `undefined` when no set
|
|
993
|
+
* can be affected. A `deferred` change is applied after a transaction
|
|
994
|
+
* commits; it copies the written values now and also reaches sets opened in
|
|
995
|
+
* the meantime.
|
|
996
|
+
*/
|
|
997
|
+
export const captureSmartdataReadChange = (
|
|
998
|
+
dbArg: SmartdataDb,
|
|
999
|
+
writeArg: ISmartdataWrite,
|
|
1000
|
+
deferredArg: boolean,
|
|
1001
|
+
): (() => void) | undefined => {
|
|
1002
|
+
if (!trackedDbs.has(dbArg)) {
|
|
1003
|
+
return deferredArg ? captureSmartdataCollectionChange(dbArg, writeArg.collection, true) : undefined;
|
|
1004
|
+
}
|
|
1005
|
+
if (!deferredArg && !hasSmartdataReadSets(dbArg)) return undefined;
|
|
1006
|
+
const scope = writeScope.getStore();
|
|
1007
|
+
const startedAt = scope?.db === dbArg ? scope.startedAt : 0;
|
|
1008
|
+
const write = deferredArg
|
|
1009
|
+
? {
|
|
1010
|
+
...writeArg,
|
|
1011
|
+
documents: writeArg.documents?.map((documentArg) => snapshotValue(documentArg) as object) ?? null,
|
|
1012
|
+
selector: snapshotValue(writeArg.selector),
|
|
1013
|
+
update: snapshotValue(writeArg.update),
|
|
1014
|
+
}
|
|
1015
|
+
: writeArg;
|
|
1016
|
+
const changes = describeWrite(write, startedAt);
|
|
1017
|
+
return () => applyChanges(dbArg, writeArg.collection, changes);
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* @internal Captures a write to the collection whose effect is not known — a
|
|
1022
|
+
* failed write may still have applied — so every read of it counts as changed.
|
|
1023
|
+
*/
|
|
1024
|
+
export const captureSmartdataCollectionChange = (
|
|
1025
|
+
dbArg: SmartdataDb,
|
|
1026
|
+
collectionArg: string,
|
|
1027
|
+
deferredArg: boolean,
|
|
1028
|
+
): (() => void) | undefined => {
|
|
1029
|
+
if (!deferredArg && !hasSmartdataReadSets(dbArg)) return undefined;
|
|
1030
|
+
return () => applyChanges(dbArg, collectionArg, [{ kind: 'unknown', touched: 'all', startedAt: 0 }]);
|
|
1031
|
+
};
|