@lossless.org/client 1.21.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/nosqldb/classes.atomicdelete.js +8 -4
  3. package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +19 -4
  4. package/dist_ts/nosqldb/classes.atomicupdate.js +16 -5
  5. package/dist_ts/nosqldb/classes.bootstraprecord.js +31 -5
  6. package/dist_ts/nosqldb/classes.collection.js +17 -14
  7. package/dist_ts/nosqldb/classes.collectiontopology.d.ts +8 -1
  8. package/dist_ts/nosqldb/classes.collectiontopology.js +10 -2
  9. package/dist_ts/nosqldb/classes.connection.d.ts +3 -0
  10. package/dist_ts/nosqldb/classes.connection.js +3 -2
  11. package/dist_ts/nosqldb/classes.db.d.ts +27 -2
  12. package/dist_ts/nosqldb/classes.db.js +42 -3
  13. package/dist_ts/nosqldb/classes.doc.d.ts +3 -1
  14. package/dist_ts/nosqldb/classes.doc.js +15 -9
  15. package/dist_ts/nosqldb/classes.exactpersistence.d.ts +13 -2
  16. package/dist_ts/nosqldb/classes.exactpersistence.js +64 -17
  17. package/dist_ts/nosqldb/classes.operationdeadline.d.ts +5 -0
  18. package/dist_ts/nosqldb/classes.operationdeadline.js +8 -1
  19. package/dist_ts/nosqldb/classes.readtracker.d.ts +28 -0
  20. package/dist_ts/nosqldb/classes.readtracker.js +128 -5
  21. package/dist_ts/nosqldb/classes.session.d.ts +34 -5
  22. package/dist_ts/nosqldb/classes.session.js +138 -9
  23. package/dist_ts/nosqldb/classes.storedinspection.js +7 -4
  24. package/dist_ts/nosqldb/classes.writeobserver.d.ts +5 -0
  25. package/dist_ts/nosqldb/classes.writeobserver.js +1 -1
  26. package/dist_ts/nosqldb/classes.writerevisions.d.ts +81 -0
  27. package/dist_ts/nosqldb/classes.writerevisions.js +246 -0
  28. package/package.json +4 -4
  29. package/readme.md +1 -0
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/nosqldb/classes.atomicdelete.ts +11 -2
  32. package/ts/nosqldb/classes.atomicfindoneandupdate.ts +25 -2
  33. package/ts/nosqldb/classes.atomicupdate.ts +25 -3
  34. package/ts/nosqldb/classes.bootstraprecord.ts +29 -4
  35. package/ts/nosqldb/classes.collection.ts +20 -12
  36. package/ts/nosqldb/classes.collectiontopology.ts +15 -1
  37. package/ts/nosqldb/classes.connection.ts +5 -1
  38. package/ts/nosqldb/classes.db.ts +65 -2
  39. package/ts/nosqldb/classes.doc.ts +16 -8
  40. package/ts/nosqldb/classes.exactpersistence.ts +83 -21
  41. package/ts/nosqldb/classes.operationdeadline.ts +8 -0
  42. package/ts/nosqldb/classes.readtracker.ts +147 -5
  43. package/ts/nosqldb/classes.session.ts +197 -11
  44. package/ts/nosqldb/classes.storedinspection.ts +6 -3
  45. package/ts/nosqldb/classes.writeobserver.ts +5 -0
  46. package/ts/nosqldb/classes.writerevisions.ts +328 -0
@@ -0,0 +1,328 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ import type { SmartdataDb } from './classes.db.js';
4
+ import { assertDataOptions } from './classes.operationbudget.js';
5
+ import type { SmartdataOperationDeadline } from './classes.operationdeadline.js';
6
+ import { SmartdataPersistenceError, isMongoDuplicateKeyError } from './classes.persistence.js';
7
+
8
+ /**
9
+ * Write revisions let a read set tell writes of other processes apart from
10
+ * no writes at all. Every model write bumps a counter per collection and
11
+ * partition in the same transaction, and a read set compares the counters it
12
+ * read before its reads with the current ones.
13
+ */
14
+ export interface ISmartdataWriteRevisionsOptions {
15
+ /**
16
+ * The top-level field whose string value splits a collection's counters, so
17
+ * writes of one partition never invalidate reads pinned to another. A
18
+ * document whose field holds anything but such a string shares one counter
19
+ * per collection with every write the partitions cannot place.
20
+ */
21
+ partitionField: string;
22
+ }
23
+
24
+ /** The collection that holds the counters. */
25
+ export const writeRevisionsCollectionName = '__smartdata_write_revisions';
26
+
27
+ interface IWriteRevisionDocument {
28
+ _id: string;
29
+ n: number;
30
+ generation?: string;
31
+ }
32
+
33
+ /** The counter every write revision belongs to; a new generation restarts all counters. */
34
+ const epochId = '$epoch';
35
+ const maximumPartitionLength = 512;
36
+ const maximumPinnedPartitions = 64;
37
+ /** Beyond this many documents, a write by selector bumps the shared counter instead. */
38
+ export const maximumPreReadDocuments = 1024;
39
+ const snapshotTimeoutMs = 5_000;
40
+
41
+ const partitionFields = new WeakMap<SmartdataDb, string>();
42
+
43
+ /** @internal Validates and records the option; called once by the database's constructor. */
44
+ export const configureSmartdataWriteRevisions = (dbArg: SmartdataDb, optionsArg: unknown): void => {
45
+ if (optionsArg === undefined) return;
46
+ const { partitionField } = assertDataOptions(optionsArg, ['partitionField']);
47
+ if (
48
+ typeof partitionField !== 'string'
49
+ || partitionField.length === 0
50
+ || !partitionField.isWellFormed()
51
+ || partitionField.includes('.')
52
+ || partitionField.startsWith('$')
53
+ || partitionField === '_id'
54
+ ) {
55
+ throw new SmartdataPersistenceError('invalid_argument',
56
+ 'Write revisions require a top-level partition field other than _id: no "." and no leading "$".');
57
+ }
58
+ partitionFields.set(dbArg, partitionField);
59
+ };
60
+
61
+ /** @internal The configured partition field, whether or not the engine can carry revisions. */
62
+ export const writeRevisionsPartitionField = (dbArg: SmartdataDb): string | undefined =>
63
+ partitionFields.get(dbArg);
64
+
65
+ /** @internal Write revisions are configured and the engine runs transactions. */
66
+ export const isSmartdataWriteRevisionsActive = (dbArg: SmartdataDb): boolean =>
67
+ partitionFields.has(dbArg) && dbArg.capabilities?.transactions === 'available';
68
+
69
+ /** A string the counters can name, or `undefined`. */
70
+ const partitionOf = (valueArg: unknown): string | undefined =>
71
+ typeof valueArg === 'string' && valueArg.length <= maximumPartitionLength && valueArg.isWellFormed()
72
+ ? valueArg
73
+ : undefined;
74
+
75
+ /** @internal The counter of one partition of a collection. */
76
+ export const partitionRevisionId = (collectionArg: string, partitionArg: string): string =>
77
+ `${collectionArg}$s${partitionArg}`;
78
+
79
+ /** @internal The counter of a collection's writes that no partition can place. */
80
+ export const sharedRevisionId = (collectionArg: string): string => `${collectionArg}$*`;
81
+
82
+ /**
83
+ * @internal The counter a stored value of the partition field belongs to:
84
+ * its partition's for a string the counters can name, the shared one otherwise.
85
+ */
86
+ export const revisionIdOfValue = (collectionArg: string, valueArg: unknown): string => {
87
+ const partition = partitionOf(valueArg);
88
+ return partition === undefined
89
+ ? sharedRevisionId(collectionArg)
90
+ : partitionRevisionId(collectionArg, partition);
91
+ };
92
+
93
+ const isPlainRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
94
+ if (typeof valueArg !== 'object' || valueArg === null || Array.isArray(valueArg)) return false;
95
+ const prototype = Object.getPrototypeOf(valueArg);
96
+ return prototype === Object.prototype || prototype === null;
97
+ };
98
+
99
+ /**
100
+ * The partitions a filter pins the field to: a string, `{ $eq: string }` or
101
+ * `{ $in: [...strings] }` beside other operators, at the top level or inside
102
+ * `$and`. The first pin found wins.
103
+ */
104
+ const pinnedPartitions = (filterArg: unknown, fieldArg: string): string[] | undefined => {
105
+ if (!isPlainRecord(filterArg)) return undefined;
106
+ if (Object.hasOwn(filterArg, fieldArg)) {
107
+ const condition = filterArg[fieldArg];
108
+ if (typeof condition === 'string') {
109
+ const partition = partitionOf(condition);
110
+ if (partition !== undefined) return [partition];
111
+ } else if (
112
+ isPlainRecord(condition)
113
+ && Object.keys(condition).length > 0
114
+ && Object.keys(condition).every((keyArg) => keyArg.startsWith('$'))
115
+ ) {
116
+ if (Object.hasOwn(condition, '$eq')) {
117
+ const partition = partitionOf(condition.$eq);
118
+ if (partition !== undefined) return [partition];
119
+ }
120
+ const values = condition.$in;
121
+ if (
122
+ Array.isArray(values)
123
+ && values.length > 0
124
+ && values.length <= maximumPinnedPartitions
125
+ && values.every((valueArg) => partitionOf(valueArg) !== undefined)
126
+ ) {
127
+ return [...new Set(values as string[])];
128
+ }
129
+ }
130
+ }
131
+ if (Array.isArray(filterArg.$and)) {
132
+ for (const conjunct of filterArg.$and) {
133
+ const partitions = pinnedPartitions(conjunct, fieldArg);
134
+ if (partitions) return partitions;
135
+ }
136
+ }
137
+ return undefined;
138
+ };
139
+
140
+ /*
141
+ * Snapshot keys: `e` for the epoch, `c<id>` for one counter and `r<collection>`
142
+ * for the sum of every counter of a collection.
143
+ */
144
+ const epochKey = 'e';
145
+ const counterKey = (idArg: string): string => `c${idArg}`;
146
+ const rangeKey = (collectionArg: string): string => `r${collectionArg}`;
147
+
148
+ /**
149
+ * @internal The snapshot keys one read depends on. A read the field pins
150
+ * depends on its partitions and the shared counter, which holds every
151
+ * document whose field holds no single string; any other read depends on the
152
+ * whole collection.
153
+ */
154
+ export const writeRevisionKeysOfRead = (
155
+ dbArg: SmartdataDb,
156
+ collectionArg: string,
157
+ kindArg: 'documents' | 'count' | 'collection',
158
+ filterArg: unknown,
159
+ ): string[] => {
160
+ const field = partitionFields.get(dbArg);
161
+ const partitions = field === undefined || kindArg === 'collection'
162
+ ? undefined
163
+ : pinnedPartitions(filterArg, field);
164
+ if (!partitions) return [epochKey, rangeKey(collectionArg)];
165
+ return [
166
+ epochKey,
167
+ counterKey(sharedRevisionId(collectionArg)),
168
+ ...partitions.map((partitionArg) => counterKey(partitionRevisionId(collectionArg, partitionArg))),
169
+ ];
170
+ };
171
+
172
+ const revisionsCollection = (mongoDbArg: plugins.mongodb.Db) =>
173
+ mongoDbArg.collection<IWriteRevisionDocument>(writeRevisionsCollectionName);
174
+
175
+ const connectedDb = (dbArg: SmartdataDb): plugins.mongodb.Db => {
176
+ if (dbArg.status !== 'connected' || !dbArg.mongoDb) {
177
+ throw new SmartdataPersistenceError('unsupported_operation',
178
+ 'Write revisions require a connected SmartdataDb.');
179
+ }
180
+ return dbArg.mongoDb;
181
+ };
182
+
183
+ /**
184
+ * @internal The current value of each snapshot key, or `undefined` for a key
185
+ * whose value cannot be known: a missing epoch means the counters restarted.
186
+ * Bounded by its own deadline.
187
+ */
188
+ export const readWriteRevisionSnapshot = async (
189
+ dbArg: SmartdataDb,
190
+ keysArg: readonly string[],
191
+ ): Promise<Map<string, string | undefined>> => {
192
+ const collection = revisionsCollection(connectedDb(dbArg));
193
+ const signal = AbortSignal.timeout(snapshotTimeoutMs);
194
+ const expiresAt = performance.now() + snapshotTimeoutMs;
195
+ const options = () => ({
196
+ timeoutMS: Math.max(1, Math.ceil(expiresAt - performance.now())),
197
+ timeoutMode: 'cursorLifetime' as const,
198
+ signal,
199
+ });
200
+ const counterIds = keysArg.flatMap((keyArg) => keyArg.startsWith('c') ? [keyArg.slice(1)] : []);
201
+ const ranges = keysArg.flatMap((keyArg) => keyArg.startsWith('r') ? [keyArg.slice(1)] : []);
202
+ const [counters, ...sums] = await Promise.all([
203
+ collection.find(
204
+ { _id: { $in: [epochId, ...counterIds] } },
205
+ { projection: { n: 1, generation: 1 }, ...options() },
206
+ ).toArray(),
207
+ ...ranges.map((collectionArg) => collection.aggregate<{ n: unknown }>([
208
+ { $match: { _id: { $gte: `${collectionArg}$`, $lt: `${collectionArg}%` } } },
209
+ { $group: { _id: null, n: { $sum: '$n' } } },
210
+ ], options()).toArray()),
211
+ ]);
212
+ const byId = new Map(counters.map((documentArg) => [documentArg._id, documentArg]));
213
+ const snapshot = new Map<string, string | undefined>();
214
+ const epoch = byId.get(epochId);
215
+ snapshot.set(epochKey, typeof epoch?.generation === 'string' && Number.isSafeInteger(epoch.n)
216
+ ? `${epoch.generation}:${epoch.n}`
217
+ : undefined);
218
+ for (const id of counterIds) {
219
+ const counter = byId.get(id);
220
+ snapshot.set(counterKey(id), counter === undefined
221
+ ? '0'
222
+ : Number.isSafeInteger(counter.n) ? String(counter.n) : undefined);
223
+ }
224
+ for (const [index, collectionArg] of ranges.entries()) {
225
+ const sum = sums[index].length === 0 ? 0 : sums[index][0].n;
226
+ snapshot.set(rangeKey(collectionArg), typeof sum === 'number' && Number.isSafeInteger(sum)
227
+ ? String(sum)
228
+ : undefined);
229
+ }
230
+ return snapshot;
231
+ };
232
+
233
+ /**
234
+ * @internal Creates the epoch of a database that has none, before `init()`
235
+ * publishes the connection. Another process creating it at the same time is
236
+ * fine.
237
+ */
238
+ export const ensureSmartdataWriteRevisions = async (
239
+ mongoDbArg: plugins.mongodb.Db,
240
+ optionsArg: { timeoutMS?: number; signal?: AbortSignal } = {},
241
+ ): Promise<void> => {
242
+ try {
243
+ await revisionsCollection(mongoDbArg).updateOne(
244
+ { _id: epochId },
245
+ { $setOnInsert: { n: 0, generation: plugins.nodeCrypto.randomUUID() } },
246
+ { upsert: true, ...optionsArg },
247
+ );
248
+ } catch (errorArg) {
249
+ if (!isMongoDuplicateKeyError(errorArg)) throw errorArg;
250
+ }
251
+ };
252
+
253
+ /**
254
+ * @internal Bumps the epoch, which every read set depends on, so every read
255
+ * set of every process stops being current. An epoch that went missing is
256
+ * recreated with a new generation.
257
+ */
258
+ export const bumpSmartdataWriteRevisionEpoch = async (
259
+ dbArg: SmartdataDb,
260
+ optionsArg: { timeoutMS?: number; signal?: AbortSignal },
261
+ ): Promise<void> => {
262
+ const collection = revisionsCollection(connectedDb(dbArg));
263
+ const bump = () => collection.updateOne(
264
+ { _id: epochId },
265
+ { $inc: { n: 1 }, $setOnInsert: { generation: plugins.nodeCrypto.randomUUID() } },
266
+ { upsert: true, ...optionsArg },
267
+ );
268
+ try {
269
+ await bump();
270
+ } catch (errorArg) {
271
+ // A concurrent upsert created the epoch first; the update now matches it.
272
+ if (!isMongoDuplicateKeyError(errorArg)) throw errorArg;
273
+ await bump();
274
+ }
275
+ };
276
+
277
+ /**
278
+ * @internal Bumps the counters a transaction's writes touched, inside that
279
+ * transaction, right before its commit. Sorted, so concurrent transactions
280
+ * take the counters in one order.
281
+ */
282
+ export const flushSmartdataWriteRevisions = async (
283
+ mongoDbArg: plugins.mongodb.Db,
284
+ rawSessionArg: plugins.mongodb.ClientSession,
285
+ idsArg: ReadonlySet<string>,
286
+ signalArg: AbortSignal | undefined,
287
+ ): Promise<void> => {
288
+ if (idsArg.size === 0) return;
289
+ await revisionsCollection(mongoDbArg).bulkWrite([...idsArg].sort().map((idArg) => ({
290
+ updateOne: { filter: { _id: idArg }, update: { $inc: { n: 1 } }, upsert: true },
291
+ })), {
292
+ ordered: true,
293
+ session: rawSessionArg,
294
+ ...(signalArg === undefined ? {} : { signal: signalArg }),
295
+ });
296
+ };
297
+
298
+ /**
299
+ * @internal The counters of the documents a write by selector is about to
300
+ * change, read in its transaction before it runs. Snapshot isolation makes
301
+ * them exactly the documents the write sees. More documents than the bound,
302
+ * or a value the counters cannot name, bump the shared counter.
303
+ */
304
+ export const readPreviousRevisionIds = async (
305
+ dbArg: SmartdataDb,
306
+ collectionArg: plugins.mongodb.Collection<any>,
307
+ rawSessionArg: plugins.mongodb.ClientSession,
308
+ selectorArg: unknown,
309
+ deadlineArg: SmartdataOperationDeadline,
310
+ ): Promise<Set<string>> => {
311
+ const field = partitionFields.get(dbArg)!;
312
+ const documents = await collectionArg.find(selectorArg as plugins.mongodb.Filter<any>, {
313
+ projection: { [field]: 1, _id: 0 },
314
+ limit: maximumPreReadDocuments + 1,
315
+ session: rawSessionArg,
316
+ ...deadlineArg.cursorOptions(),
317
+ }).toArray();
318
+ const ids = new Set<string>();
319
+ if (documents.length > maximumPreReadDocuments) {
320
+ ids.add(sharedRevisionId(collectionArg.collectionName));
321
+ return ids;
322
+ }
323
+ for (const document of documents) {
324
+ ids.add(revisionIdOfValue(collectionArg.collectionName,
325
+ Object.hasOwn(document, field) ? document[field] : undefined));
326
+ }
327
+ return ids;
328
+ };