@lossless.org/client 1.21.1 → 1.22.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/.smartconfig.json +1 -2
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/nosqldb/classes.atomicdelete.js +8 -4
- package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +19 -4
- package/dist_ts/nosqldb/classes.atomicupdate.js +16 -5
- package/dist_ts/nosqldb/classes.bootstraprecord.js +31 -5
- package/dist_ts/nosqldb/classes.collection.js +17 -14
- package/dist_ts/nosqldb/classes.connection.d.ts +3 -0
- package/dist_ts/nosqldb/classes.connection.js +3 -2
- package/dist_ts/nosqldb/classes.db.d.ts +27 -2
- package/dist_ts/nosqldb/classes.db.js +42 -3
- package/dist_ts/nosqldb/classes.doc.d.ts +3 -1
- package/dist_ts/nosqldb/classes.doc.js +15 -9
- package/dist_ts/nosqldb/classes.exactpersistence.d.ts +13 -2
- package/dist_ts/nosqldb/classes.exactpersistence.js +64 -17
- package/dist_ts/nosqldb/classes.operationdeadline.d.ts +5 -0
- package/dist_ts/nosqldb/classes.operationdeadline.js +8 -1
- package/dist_ts/nosqldb/classes.readtracker.d.ts +26 -0
- package/dist_ts/nosqldb/classes.readtracker.js +124 -1
- package/dist_ts/nosqldb/classes.session.d.ts +34 -5
- package/dist_ts/nosqldb/classes.session.js +134 -8
- package/dist_ts/nosqldb/classes.storedinspection.js +7 -4
- package/dist_ts/nosqldb/classes.writeobserver.d.ts +5 -0
- package/dist_ts/nosqldb/classes.writeobserver.js +1 -1
- package/dist_ts/nosqldb/classes.writerevisions.d.ts +81 -0
- package/dist_ts/nosqldb/classes.writerevisions.js +246 -0
- package/package.json +8 -5
- package/readme.md +10 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.atomicdelete.ts +11 -2
- package/ts/nosqldb/classes.atomicfindoneandupdate.ts +25 -2
- package/ts/nosqldb/classes.atomicupdate.ts +25 -3
- package/ts/nosqldb/classes.bootstraprecord.ts +29 -4
- package/ts/nosqldb/classes.collection.ts +20 -12
- package/ts/nosqldb/classes.connection.ts +5 -1
- package/ts/nosqldb/classes.db.ts +65 -2
- package/ts/nosqldb/classes.doc.ts +16 -8
- package/ts/nosqldb/classes.exactpersistence.ts +83 -21
- package/ts/nosqldb/classes.operationdeadline.ts +8 -0
- package/ts/nosqldb/classes.readtracker.ts +139 -0
- package/ts/nosqldb/classes.session.ts +191 -8
- package/ts/nosqldb/classes.storedinspection.ts +6 -3
- package/ts/nosqldb/classes.writeobserver.ts +5 -0
- 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
|
+
};
|