@toa.io/storages.mongodb 1.0.0-alpha.31 → 1.0.0-alpha.310
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/CHANGELOG.md +203 -0
- package/package.json +10 -9
- package/readme.md +15 -0
- package/src/client.js +173 -39
- package/src/factory.js +6 -12
- package/src/inbox.js +85 -0
- package/src/index.js +1 -7
- package/src/indexes.js +84 -0
- package/src/measurements.js +39 -0
- package/src/migrations.js +343 -0
- package/src/outbox.js +131 -0
- package/src/record.js +88 -20
- package/src/storage.js +497 -130
- package/src/translate/criteria.js +25 -16
- package/src/translate/options.js +17 -11
- package/src/translate/rename.js +1 -5
- package/src/translate.js +14 -15
- package/test/migrations.test.js +388 -0
- package/test/query/criteria.fixtures.js +2 -7
- package/test/query/criteria.test.js +5 -4
- package/test/record.test.js +98 -25
- package/test/storage.test.js +217 -0
- package/test/translate.test.js +59 -7
- package/types/connection.d.ts +18 -12
- package/types/pointer.d.ts +4 -6
- package/types/record.d.ts +5 -7
- package/src/collection.js +0 -69
- package/src/deployment.js +0 -22
package/src/storage.js
CHANGED
|
@@ -1,210 +1,577 @@
|
|
|
1
|
-
|
|
1
|
+
import { Connector, exceptions } from '@toa.io/core'
|
|
2
|
+
import { console } from 'openspan'
|
|
3
|
+
import { translate } from './translate.js'
|
|
4
|
+
import { codec } from './record.js'
|
|
5
|
+
import { Inbox } from './inbox.js'
|
|
6
|
+
import { Outbox } from './outbox.js'
|
|
7
|
+
import { Migrations } from './migrations.js'
|
|
8
|
+
import { conflicted, query } from './measurements.js'
|
|
9
|
+
import { ReturnDocument } from 'mongodb'
|
|
10
|
+
|
|
11
|
+
export class Storage extends Connector {
|
|
12
|
+
#client
|
|
13
|
+
|
|
14
|
+
/** @type {import('mongodb').Collection} */
|
|
15
|
+
#collection
|
|
16
|
+
#entity
|
|
2
17
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
18
|
+
/**
|
|
19
|
+
* @type {Outbox | undefined} absent when nothing consumes this component's events, or when
|
|
20
|
+
* the deployment cannot run transactions
|
|
21
|
+
*/
|
|
22
|
+
#outbox
|
|
7
23
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
24
|
+
/**
|
|
25
|
+
* @type {Inbox | undefined} absent unless the component declares `once` somewhere; a
|
|
26
|
+
* deployment that cannot commit one with the entity does not boot, so this is present
|
|
27
|
+
* wherever it was asked for
|
|
28
|
+
*/
|
|
29
|
+
#inbox
|
|
13
30
|
|
|
14
|
-
|
|
15
|
-
#
|
|
16
|
-
|
|
31
|
+
/** @type {Migrations | undefined} absent where the component declares no migrations */
|
|
32
|
+
#migrations
|
|
33
|
+
|
|
34
|
+
/** @type {Map<string, object>} span options per driver method */
|
|
35
|
+
#spans = new Map()
|
|
36
|
+
|
|
37
|
+
/** how a record is written and read back, which depends on what the entity declares */
|
|
38
|
+
#to
|
|
39
|
+
#from
|
|
17
40
|
|
|
18
|
-
|
|
41
|
+
/** properties held as BSON dates, so that a criterion against one is one too */
|
|
42
|
+
#dates
|
|
43
|
+
|
|
44
|
+
constructor(client, entity) {
|
|
19
45
|
super()
|
|
20
46
|
|
|
21
|
-
this.#
|
|
47
|
+
this.#client = client
|
|
22
48
|
this.#entity = entity
|
|
23
49
|
|
|
24
|
-
|
|
50
|
+
const { to, from, dates } = codec(entity?.properties)
|
|
51
|
+
|
|
52
|
+
this.#to = to
|
|
53
|
+
this.#from = from
|
|
54
|
+
this.#dates = dates
|
|
55
|
+
|
|
56
|
+
this.depends(client)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get raw() {
|
|
60
|
+
return this.#collection
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The outbox is offered only where a row can be committed atomically with the entity.
|
|
65
|
+
* Without that it would be a second write with a window in front of it — worse than the
|
|
66
|
+
* inline emission it replaces — so the storage simply does not advertise it.
|
|
67
|
+
*/
|
|
68
|
+
get outbox() {
|
|
69
|
+
return this.#outbox
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get inbox() {
|
|
73
|
+
return this.#inbox
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** This storage can record a call, given a deployment that can commit one. */
|
|
77
|
+
get claims() {
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** This storage applies what a component's `migrations` directory declares. */
|
|
82
|
+
get migrates() {
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** This storage converges. */
|
|
87
|
+
get converges() {
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async open() {
|
|
92
|
+
this.#collection = this.#client.collection
|
|
93
|
+
|
|
94
|
+
if (this.#client.outbox !== undefined) this.#outbox = new Outbox(this.#client.outbox)
|
|
95
|
+
if (this.#client.inbox !== undefined) this.#inbox = new Inbox(this.#client.inbox)
|
|
96
|
+
|
|
97
|
+
this.#spans.clear()
|
|
98
|
+
|
|
99
|
+
if (this.#entity.migrations?.length > 0) {
|
|
100
|
+
this.#migrations = new Migrations(
|
|
101
|
+
this.#client.db,
|
|
102
|
+
this.#collection,
|
|
103
|
+
this.#entity.migrations
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
await this.#migrations.run()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await this.#outbox?.index()
|
|
110
|
+
await this.#inbox?.index()
|
|
25
111
|
}
|
|
26
112
|
|
|
27
|
-
async
|
|
28
|
-
|
|
113
|
+
async get(query) {
|
|
114
|
+
const { criteria, options } = translate(query, this.#dates)
|
|
115
|
+
|
|
116
|
+
// identity lookups must return deleted records, so that callers
|
|
117
|
+
// can tell a deleted entity from a missing one
|
|
118
|
+
if (query?.id === undefined && query?.options?.deleted !== true)
|
|
119
|
+
criteria.DELETED = null
|
|
120
|
+
|
|
121
|
+
const record = await this.command('findOne', { criteria, options }, () =>
|
|
122
|
+
this.#collection.findOne(criteria, options)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return this.#from(record)
|
|
29
126
|
}
|
|
30
127
|
|
|
31
|
-
async
|
|
32
|
-
const {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
} = translate(query)
|
|
128
|
+
async find(query) {
|
|
129
|
+
const { criteria, options, sample } = translate(query, this.#dates)
|
|
130
|
+
|
|
131
|
+
if (query?.options?.deleted !== true) criteria.DELETED = null
|
|
36
132
|
|
|
37
|
-
const
|
|
133
|
+
const recordset =
|
|
134
|
+
sample === undefined
|
|
135
|
+
? await this.command(
|
|
136
|
+
'find',
|
|
137
|
+
{ criteria, options },
|
|
138
|
+
async () => await this.#collection.find(criteria, options).toArray()
|
|
139
|
+
)
|
|
140
|
+
: await this.aggregate(criteria, options, sample)
|
|
38
141
|
|
|
39
|
-
return from(
|
|
142
|
+
return recordset.map((item) => this.#from(item))
|
|
40
143
|
}
|
|
41
144
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
options
|
|
46
|
-
} = translate(query)
|
|
145
|
+
/** @private */
|
|
146
|
+
async aggregate(criteria, options, sample) {
|
|
147
|
+
const pipeline = toPipeline(criteria, options, sample)
|
|
47
148
|
|
|
48
|
-
|
|
149
|
+
return await this.command(
|
|
150
|
+
'aggregate',
|
|
151
|
+
{ pipeline },
|
|
152
|
+
async () => await this.#collection.aggregate(pipeline).toArray()
|
|
153
|
+
)
|
|
154
|
+
}
|
|
49
155
|
|
|
50
|
-
|
|
156
|
+
async stream(query = undefined) {
|
|
157
|
+
const { criteria, options } = translate(query, this.#dates)
|
|
158
|
+
|
|
159
|
+
if (query?.options?.deleted !== true) criteria.DELETED = null
|
|
160
|
+
|
|
161
|
+
this.debug('find (stream)', { criteria, options })
|
|
162
|
+
|
|
163
|
+
return this.#collection.find(criteria, options).stream({ transform: this.#from })
|
|
51
164
|
}
|
|
52
165
|
|
|
53
|
-
async add
|
|
54
|
-
const record = to(entity)
|
|
55
|
-
|
|
166
|
+
async add(entity, session = undefined) {
|
|
167
|
+
const record = this.#to(entity)
|
|
168
|
+
|
|
169
|
+
const result = await this.command('insertOne', { record }, () =>
|
|
170
|
+
this.#collection.insertOne(record, { session })
|
|
171
|
+
)
|
|
56
172
|
|
|
57
173
|
return result.acknowledged
|
|
58
174
|
}
|
|
59
175
|
|
|
60
|
-
async set
|
|
176
|
+
async set(entity, session = undefined) {
|
|
61
177
|
const criteria = {
|
|
62
178
|
_id: entity.id,
|
|
63
|
-
|
|
179
|
+
VERSION: entity.VERSION - 1
|
|
64
180
|
}
|
|
65
|
-
|
|
181
|
+
|
|
182
|
+
const record = this.#to(entity)
|
|
183
|
+
|
|
184
|
+
const result = await this.command('findOneAndReplace', { criteria, record }, () =>
|
|
185
|
+
this.#collection.findOneAndReplace(criteria, record, { session })
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
// the version moved under this write: whoever asked either retries it or raises
|
|
189
|
+
if (result === null) conflicted(this.#collection.collectionName)
|
|
66
190
|
|
|
67
191
|
return result !== null
|
|
68
192
|
}
|
|
69
193
|
|
|
70
|
-
async store
|
|
194
|
+
async store(entity, row = undefined, call = undefined, attempt = 0) {
|
|
195
|
+
const writes = row !== undefined && this.#outbox !== undefined
|
|
196
|
+
const records = call !== undefined && this.#inbox !== undefined
|
|
197
|
+
|
|
198
|
+
let committed
|
|
199
|
+
|
|
71
200
|
try {
|
|
72
|
-
if (
|
|
73
|
-
return await this.add(entity)
|
|
74
|
-
|
|
75
|
-
return await this.set(entity)
|
|
201
|
+
if (!writes && !records) {
|
|
202
|
+
if (entity.VERSION === 1) return await this.add(entity)
|
|
203
|
+
else return await this.set(entity)
|
|
76
204
|
}
|
|
77
|
-
} catch (error) {
|
|
78
|
-
if (error.code === ERR_DUPLICATE_KEY) {
|
|
79
205
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
206
|
+
committed = await this.#client.transaction(async (session) => {
|
|
207
|
+
/*
|
|
208
|
+
* First, so that a call already made pays for nothing else: the duplicate key is the
|
|
209
|
+
* whole mechanism, and it takes the entity write down with it. Caught here rather than
|
|
210
|
+
* around the transaction, because here is where which key refused it is known — the
|
|
211
|
+
* driver's message is the only other thing that says, and it is worded differently by
|
|
212
|
+
* DocumentDB.
|
|
213
|
+
*/
|
|
214
|
+
if (records)
|
|
215
|
+
try {
|
|
216
|
+
await this.#inbox.insert(call, session)
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error?.code !== ERR_DUPLICATE_KEY) throw error
|
|
219
|
+
|
|
220
|
+
await session.abortTransaction()
|
|
221
|
+
|
|
222
|
+
return MADE
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const ok =
|
|
226
|
+
entity.VERSION === 1
|
|
227
|
+
? await this.add(entity, session)
|
|
228
|
+
: await this.set(entity, session)
|
|
229
|
+
|
|
230
|
+
// a lost compare-and-swap must take the row down with it, or a retried transition
|
|
231
|
+
// leaves a row for a write that never happened
|
|
232
|
+
if (!ok) {
|
|
233
|
+
await session.abortTransaction()
|
|
83
234
|
|
|
84
|
-
if (id) {
|
|
85
235
|
return false
|
|
86
|
-
} else {
|
|
87
|
-
throw new exceptions.DuplicateException()
|
|
88
236
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
237
|
+
|
|
238
|
+
if (writes) await this.#outbox.insert(row, session)
|
|
239
|
+
|
|
240
|
+
return true
|
|
241
|
+
})
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error('MongoDB error', error)
|
|
244
|
+
|
|
245
|
+
const retry = await retriable(error, attempt)
|
|
246
|
+
|
|
247
|
+
if (retry) return await this.store(entity, row, call, attempt + 1)
|
|
248
|
+
else return false
|
|
92
249
|
}
|
|
250
|
+
|
|
251
|
+
/*
|
|
252
|
+
* Outside the catch: the call has been made and what it changed is changed, which is not a
|
|
253
|
+
* driver failure and not worth another attempt. Raised rather than answered `false`, which
|
|
254
|
+
* means a lost compare-and-swap and *is* worth one.
|
|
255
|
+
*/
|
|
256
|
+
if (committed === MADE) throw new exceptions.DuplicateCallException(call.id)
|
|
257
|
+
|
|
258
|
+
return committed === true
|
|
93
259
|
}
|
|
94
260
|
|
|
95
|
-
async
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
261
|
+
async massStore(entities, rows = undefined, attempt = 0) {
|
|
262
|
+
if (entities.length === 0) return true
|
|
263
|
+
|
|
264
|
+
const operations = entities.map((entity) => {
|
|
265
|
+
const record = this.#to(entity)
|
|
266
|
+
|
|
267
|
+
if (entity.VERSION === 1) {
|
|
268
|
+
const { VERSION, ...rest } = record
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
// upsert in required when document is deleted
|
|
272
|
+
updateOne: {
|
|
273
|
+
filter: { _id: entity.id },
|
|
274
|
+
update: {
|
|
275
|
+
$set: {
|
|
276
|
+
...rest,
|
|
277
|
+
DELETED: null
|
|
278
|
+
},
|
|
279
|
+
$inc: { VERSION: 1 }
|
|
280
|
+
},
|
|
281
|
+
upsert: true
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} else
|
|
285
|
+
return {
|
|
286
|
+
replaceOne: {
|
|
287
|
+
filter: { _id: entity.id, VERSION: entity.VERSION - 1 },
|
|
288
|
+
replacement: record
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
})
|
|
100
292
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
293
|
+
try {
|
|
294
|
+
await this.#client.transaction(async (session) => {
|
|
295
|
+
await this.command(
|
|
296
|
+
'bulkWrite',
|
|
297
|
+
{ operations: operations.length },
|
|
298
|
+
async () => await this.#collection.bulkWrite(operations, { session })
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if (rows !== undefined && this.#outbox !== undefined)
|
|
302
|
+
await this.#outbox.insertMany(rows, session)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
return true
|
|
306
|
+
} catch (error) {
|
|
307
|
+
console.error('MongoDB error', error)
|
|
308
|
+
|
|
309
|
+
const retry = await retriable(error, attempt)
|
|
310
|
+
|
|
311
|
+
if (retry) return await this.massStore(entities, rows, attempt + 1)
|
|
312
|
+
else return false
|
|
104
313
|
}
|
|
314
|
+
}
|
|
105
315
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
316
|
+
/**
|
|
317
|
+
* The filter selects by `_id` alone, so it always matches what is there and upserts what is
|
|
318
|
+
* not, and the rule is the pipeline: either the incoming document replaces the stored one, or
|
|
319
|
+
* `$$ROOT` stays where it is and nothing changes. That keeps the three answers apart — a
|
|
320
|
+
* record never seen is an upsert, a superseded one is a modification, and a stale one is
|
|
321
|
+
* neither — where putting the rule in the filter would make absence and staleness one and the
|
|
322
|
+
* same miss, and then have the upsert collide on `_id` to say so.
|
|
323
|
+
*
|
|
324
|
+
* `$literal` because a value in a pipeline is an expression, so a property holding a string
|
|
325
|
+
* that begins with `$` would otherwise be read as a field path.
|
|
326
|
+
*
|
|
327
|
+
* `$ifNull` twice, and for two reasons. On the upsert path the pipeline runs over the base
|
|
328
|
+
* document the filter builds, which is `{ _id }`, so a missing `VERSION` reads as `0` — the
|
|
329
|
+
* version an entity holds before its first write. And a record that somehow reached here
|
|
330
|
+
* without a `REGION` reads as the first region, which is what the prototype's migration
|
|
331
|
+
* writes into one: without that, such a record would lose no tie and would beat every
|
|
332
|
+
* equal-version write from anywhere, silently and for good.
|
|
333
|
+
*/
|
|
334
|
+
async converge(record) {
|
|
335
|
+
const document = this.#to(record)
|
|
336
|
+
|
|
337
|
+
const supersedes = {
|
|
338
|
+
$or: [
|
|
339
|
+
{ $lt: [{ $ifNull: ['$VERSION', 0] }, document.VERSION] },
|
|
340
|
+
{
|
|
341
|
+
$and: [
|
|
342
|
+
{ $eq: ['$VERSION', document.VERSION] },
|
|
343
|
+
{ $gt: [{ $ifNull: ['$REGION', FIRST] }, document.REGION] }
|
|
344
|
+
]
|
|
345
|
+
}
|
|
346
|
+
]
|
|
109
347
|
}
|
|
110
348
|
|
|
111
|
-
|
|
349
|
+
const pipeline = [
|
|
350
|
+
{ $replaceWith: { $cond: [supersedes, { $literal: document }, '$$ROOT'] } }
|
|
351
|
+
]
|
|
112
352
|
|
|
113
|
-
const result = await this
|
|
353
|
+
const result = await this.command(
|
|
354
|
+
'updateOne',
|
|
355
|
+
{ criteria: { _id: document._id }, pipeline },
|
|
356
|
+
() => this.#collection.updateOne({ _id: document._id }, pipeline, { upsert: true })
|
|
357
|
+
)
|
|
114
358
|
|
|
115
|
-
return
|
|
359
|
+
return result.upsertedCount === 1 || result.modifiedCount === 1
|
|
116
360
|
}
|
|
117
361
|
|
|
118
|
-
async
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
if (this.#entity.unique !== undefined) {
|
|
122
|
-
for (const [name, fields] of Object.entries(this.#entity.unique)) {
|
|
123
|
-
const sparse = this.checkFields(fields)
|
|
124
|
-
const unique = await this.uniqueIndex(name, fields, sparse)
|
|
362
|
+
async upsert(query, changeset, row = undefined, call = undefined) {
|
|
363
|
+
const records = call !== undefined && this.#inbox !== undefined
|
|
364
|
+
const { criteria, options } = translate(query, this.#dates)
|
|
125
365
|
|
|
126
|
-
|
|
127
|
-
|
|
366
|
+
if (!('DELETED' in changeset) || changeset.DELETED === null) {
|
|
367
|
+
delete criteria.DELETED
|
|
368
|
+
changeset.DELETED = null
|
|
128
369
|
}
|
|
129
370
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
.map(([name, type]) => [name, INDEX_TYPES[type]]))
|
|
371
|
+
const update = {
|
|
372
|
+
$set: { ...changeset },
|
|
373
|
+
$inc: { VERSION: 1 }
|
|
374
|
+
}
|
|
135
375
|
|
|
136
|
-
|
|
376
|
+
// BEFORE, so that the filter is applied once and atomically and the pre-image comes back
|
|
377
|
+
// with it — an assignment is the one event whose images are the write's own
|
|
378
|
+
options.returnDocument = ReturnDocument.BEFORE
|
|
379
|
+
|
|
380
|
+
const apply = async (session) => {
|
|
381
|
+
const found = await this.command(
|
|
382
|
+
'findOneAndUpdate',
|
|
383
|
+
{ criteria, update, options },
|
|
384
|
+
() => this.#collection.findOneAndUpdate(criteria, update, { ...options, session })
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if (found === null) return null
|
|
388
|
+
|
|
389
|
+
const origin = this.#from(found)
|
|
390
|
+
|
|
391
|
+
/*
|
|
392
|
+
* The post-image is `update` applied to the pre-image, computed rather than read back.
|
|
393
|
+
* That is exact, not approximate: `$set` on top-level keys is a spread (entity property
|
|
394
|
+
* names cannot contain dots, so a changeset never carries a path), and `VERSION` is
|
|
395
|
+
* incremented by one. It is also a coupling — an operator added to `update` and not
|
|
396
|
+
* mirrored here diverges silently — which `features/events/outbox.feature` guards.
|
|
397
|
+
*/
|
|
398
|
+
const state = { ...origin, ...changeset, VERSION: origin.VERSION + 1 }
|
|
399
|
+
|
|
400
|
+
// an assignment's event is the write's own images, so they are filled in here whether
|
|
401
|
+
// or not the row is going to be committed
|
|
402
|
+
if (row !== undefined) row.event = { origin, state, ...row.event }
|
|
403
|
+
|
|
404
|
+
/*
|
|
405
|
+
* The reply is what a duplicate is answered with, and an assignment answers the post-image
|
|
406
|
+
* where its algorithm named nothing — which is this, computed here and nowhere else. Held
|
|
407
|
+
* to a copy: the driver may run this again, and the call is the caller's object.
|
|
408
|
+
*/
|
|
409
|
+
if (records) {
|
|
410
|
+
const reply =
|
|
411
|
+
call.reply?.output === undefined ? { ...call.reply, output: state } : call.reply
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
await this.#inbox.insert({ id: call.id, reply }, session)
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error?.code !== ERR_DUPLICATE_KEY) throw error
|
|
417
|
+
|
|
418
|
+
await session.abortTransaction()
|
|
419
|
+
|
|
420
|
+
return MADE
|
|
421
|
+
}
|
|
422
|
+
}
|
|
137
423
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
sparse
|
|
141
|
-
})
|
|
424
|
+
if (row !== undefined && this.#outbox !== undefined)
|
|
425
|
+
await this.#outbox.insert(row, session)
|
|
142
426
|
|
|
143
|
-
|
|
144
|
-
}
|
|
427
|
+
return state
|
|
145
428
|
}
|
|
146
429
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
async uniqueIndex (name, properties, sparse = false) {
|
|
151
|
-
const fields = properties.reduce((acc, property) => {
|
|
152
|
-
acc[property] = 1
|
|
153
|
-
return acc
|
|
154
|
-
}, {})
|
|
430
|
+
if ((row === undefined || this.#outbox === undefined) && !records)
|
|
431
|
+
return apply(undefined)
|
|
155
432
|
|
|
156
|
-
|
|
433
|
+
const result = await this.#client.transaction(apply)
|
|
157
434
|
|
|
158
|
-
|
|
159
|
-
name,
|
|
160
|
-
unique: true,
|
|
161
|
-
sparse
|
|
162
|
-
})
|
|
435
|
+
if (result === MADE) throw new exceptions.DuplicateCallException(call.id)
|
|
163
436
|
|
|
164
|
-
return
|
|
437
|
+
return result
|
|
165
438
|
}
|
|
166
439
|
|
|
167
|
-
async
|
|
168
|
-
|
|
169
|
-
const obsolete = current.filter((name) => !desired.includes(name))
|
|
440
|
+
async ensure(query, properties, state, row = undefined) {
|
|
441
|
+
let { criteria, options } = translate(query, this.#dates)
|
|
170
442
|
|
|
171
|
-
if (
|
|
172
|
-
console.info(`Remove obsolete indexes: [${obsolete.join(', ')}]`)
|
|
443
|
+
if (query === undefined) criteria = properties
|
|
173
444
|
|
|
174
|
-
|
|
445
|
+
const update = { $setOnInsert: this.#to(state) }
|
|
446
|
+
|
|
447
|
+
options.upsert = true
|
|
448
|
+
options.returnDocument = ReturnDocument.AFTER
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
const result =
|
|
452
|
+
row === undefined || this.#outbox === undefined
|
|
453
|
+
? await this.command('findOneAndUpdate', { criteria, update, options }, () =>
|
|
454
|
+
this.#collection.findOneAndUpdate(criteria, update, options)
|
|
455
|
+
)
|
|
456
|
+
: await this.#client.transaction(async (session) => {
|
|
457
|
+
const found = await this.command(
|
|
458
|
+
'findOneAndUpdate',
|
|
459
|
+
{ criteria, update, options },
|
|
460
|
+
() =>
|
|
461
|
+
this.#collection.findOneAndUpdate(criteria, update, {
|
|
462
|
+
...options,
|
|
463
|
+
session
|
|
464
|
+
})
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
// only an insert is an event; finding an existing record is not
|
|
468
|
+
if (found !== null && found._id === state.id)
|
|
469
|
+
await this.#outbox.insert(row, session)
|
|
470
|
+
|
|
471
|
+
return found
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
if (result.DELETED !== undefined && result.DELETED !== null) return null
|
|
475
|
+
else return this.#from(result)
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (error.code === ERR_DUPLICATE_KEY)
|
|
478
|
+
throw new exceptions.DuplicateException(this.#client.name)
|
|
479
|
+
else throw error
|
|
175
480
|
}
|
|
176
481
|
}
|
|
177
482
|
|
|
178
|
-
|
|
179
|
-
|
|
483
|
+
/**
|
|
484
|
+
* Names, logs and times a call into the driver. The driver's own command monitoring is
|
|
485
|
+
* off (see `client.js`), so this is where a query becomes a span.
|
|
486
|
+
*
|
|
487
|
+
* @private
|
|
488
|
+
*/
|
|
489
|
+
async command(method, attributes, task) {
|
|
490
|
+
this.debug(method, attributes)
|
|
180
491
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
throw new Error(`Index field '${field}' is not defined.`)
|
|
184
|
-
}
|
|
492
|
+
return console.span(this.span(method), task)
|
|
493
|
+
}
|
|
185
494
|
|
|
186
|
-
|
|
187
|
-
|
|
495
|
+
/**
|
|
496
|
+
* The span of a driver method is the same object every time: the collection is fixed
|
|
497
|
+
* for a storage, and nothing downstream writes to what it is given.
|
|
498
|
+
*
|
|
499
|
+
* @private
|
|
500
|
+
*/
|
|
501
|
+
span(method) {
|
|
502
|
+
let options = this.#spans.get(method)
|
|
503
|
+
|
|
504
|
+
if (options === undefined) {
|
|
505
|
+
const collection = this.#collection.collectionName
|
|
506
|
+
|
|
507
|
+
options = {
|
|
508
|
+
name: `${method} ${collection}`,
|
|
509
|
+
kind: 'client',
|
|
510
|
+
// https://opentelemetry.io/docs/specs/semconv/database/mongodb/
|
|
511
|
+
attributes: {
|
|
512
|
+
'db.system': 'mongodb',
|
|
513
|
+
'db.namespace': this.#collection.dbName,
|
|
514
|
+
'db.operation.name': method,
|
|
515
|
+
'db.collection.name': collection
|
|
516
|
+
},
|
|
517
|
+
measure: query(collection, method)
|
|
188
518
|
}
|
|
189
|
-
}
|
|
190
519
|
|
|
191
|
-
|
|
192
|
-
console.info(`Index fields [${optional.join(', ')}] are optional, creating sparse index.`)
|
|
193
|
-
|
|
194
|
-
return true
|
|
195
|
-
} else {
|
|
196
|
-
return false
|
|
520
|
+
this.#spans.set(method, options)
|
|
197
521
|
}
|
|
522
|
+
|
|
523
|
+
return options
|
|
198
524
|
}
|
|
199
525
|
|
|
526
|
+
debug(method, attributes) {
|
|
527
|
+
console.debug('MongoDB query', {
|
|
528
|
+
collection: this.#collection.collectionName,
|
|
529
|
+
method,
|
|
530
|
+
...attributes
|
|
531
|
+
})
|
|
532
|
+
}
|
|
200
533
|
}
|
|
201
534
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
535
|
+
function toPipeline(criteria, options, sample) {
|
|
536
|
+
const pipeline = []
|
|
537
|
+
|
|
538
|
+
if (criteria !== undefined) pipeline.push({ $match: criteria })
|
|
539
|
+
|
|
540
|
+
if (sample !== undefined) pipeline.push({ $sample: { size: sample } })
|
|
541
|
+
|
|
542
|
+
if (options?.sort !== undefined) pipeline.push({ $sort: options.sort })
|
|
543
|
+
|
|
544
|
+
if (options?.projection !== undefined) pipeline.push({ $project: options.projection })
|
|
545
|
+
|
|
546
|
+
return pipeline
|
|
206
547
|
}
|
|
207
548
|
|
|
549
|
+
/** the rank of the first region, which is what a record written before regions reads as */
|
|
550
|
+
const FIRST = 0
|
|
551
|
+
|
|
208
552
|
const ERR_DUPLICATE_KEY = 11000
|
|
209
553
|
|
|
210
|
-
|
|
554
|
+
/** what the transaction answers where the call it carries has already been made */
|
|
555
|
+
const MADE = Symbol('made')
|
|
556
|
+
|
|
557
|
+
async function retriable(error, attempt) {
|
|
558
|
+
if (error.code === ERR_DUPLICATE_KEY) {
|
|
559
|
+
const id =
|
|
560
|
+
error.keyPattern === undefined
|
|
561
|
+
? error.message.includes(' index: _id_ ') // AWS DocumentDB
|
|
562
|
+
: error.keyPattern._id === 1
|
|
563
|
+
|
|
564
|
+
if (id) return false
|
|
565
|
+
else throw new exceptions.DuplicateException()
|
|
566
|
+
} else if (error.cause?.code === 'ECONNREFUSED') {
|
|
567
|
+
if (attempt === LAST_ATTEMPT) throw error
|
|
568
|
+
|
|
569
|
+
const timeout = 1000 + 500 * attempt
|
|
570
|
+
|
|
571
|
+
await new Promise((resolve) => setTimeout(resolve, timeout))
|
|
572
|
+
|
|
573
|
+
return true
|
|
574
|
+
} else throw error
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const LAST_ATTEMPT = 9
|