@lossless.org/client 1.20.2 → 1.21.0

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