@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.
@@ -1,15 +1,15 @@
1
- 'use strict'
2
-
3
- const { rename } = require('./rename')
1
+ import { rename } from './rename.js'
4
2
 
5
3
  /**
6
- * @param {toa.core.storages.ast.Node} node
4
+ * @param {import('@toa.io/core/types').storages.Node} node
5
+ * @param {string[]} dates properties held as BSON dates
7
6
  * @returns {import('mongodb').Filter}
8
7
  */
9
- const criteria = (node) => {
10
- if (TYPES[node.type] === undefined) throw new Error(`AST parse error: unknown node type '${node.type}'`)
8
+ export const criteria = (node, dates = NONE) => {
9
+ if (TYPES[node.type] === undefined)
10
+ throw new Error(`AST parse error: unknown node type '${node.type}'`)
11
11
 
12
- return TYPES[node.type](node)
12
+ return TYPES[node.type](node, dates)
13
13
  }
14
14
 
15
15
  const OPERATORS = {
@@ -33,24 +33,33 @@ const OPERATORS = {
33
33
 
34
34
  const TYPES = {}
35
35
 
36
- TYPES.LOGIC = (expression) => {
37
- const left = criteria(expression.left)
38
- const right = criteria(expression.right)
36
+ TYPES.LOGIC = (expression, dates) => {
37
+ const left = criteria(expression.left, dates)
38
+ const right = criteria(expression.right, dates)
39
39
 
40
40
  return { [OPERATORS.LOGIC[expression.operator]]: [left, right] }
41
41
  }
42
42
 
43
- TYPES.COMPARISON = (expression) => {
44
- const left = criteria(expression.left)
45
- const right = criteria(expression.right)
43
+ TYPES.COMPARISON = (expression, dates) => {
44
+ const left = criteria(expression.left, dates)
45
+ const right = criteria(expression.right, dates)
46
46
  const operator = OPERATORS.COMPARISON[expression.operator]
47
47
 
48
- if (operator === undefined) throw new Error(`AST parse error: unknown operator '${expression.operator}'`)
48
+ if (operator === undefined)
49
+ throw new Error(`AST parse error: unknown operator '${expression.operator}'`)
50
+
51
+ // the record holds a date where the criterion carries the string the entity declares, and a
52
+ // string compared against a date is a criterion that quietly matches nothing
53
+ const value = dates.includes(left) ? date(right) : right
49
54
 
50
- return { [left]: { [operator]: right } }
55
+ return { [left]: { [operator]: value } }
51
56
  }
52
57
 
53
58
  TYPES.SELECTOR = (expression) => rename(expression.selector)
54
59
  TYPES.VALUE = (expression) => expression.value
55
60
 
56
- exports.criteria = criteria
61
+ /** `=in=` and `=out=` carry a list, and every other operator one value. */
62
+ const date = (value) =>
63
+ Array.isArray(value) ? value.map(date) : value === null ? null : new Date(value)
64
+
65
+ const NONE = []
@@ -1,14 +1,20 @@
1
- 'use strict'
1
+ import { rename } from './rename.js'
2
2
 
3
- const { rename } = require('./rename')
4
-
5
- const options = (options) => {
3
+ export const options = (options) => {
6
4
  const result = {}
7
5
 
8
- if (options.omit) { result.skip = options.omit }
9
- if (options.limit) { result.limit = options.limit }
10
- if (options.sort) { result.sort = sort(options.sort) }
11
- if (options.projection) { result.projection = projection(options.projection) }
6
+ if (options.omit) {
7
+ result.skip = options.omit
8
+ }
9
+ if (options.limit) {
10
+ result.limit = options.limit
11
+ }
12
+ if (options.sort) {
13
+ result.sort = sort(options.sort)
14
+ }
15
+ if (options.projection) {
16
+ result.projection = projection(options.projection)
17
+ }
12
18
 
13
19
  return result
14
20
  }
@@ -20,7 +26,9 @@ const sort = (sort) => {
20
26
  const projection = (projection) => {
21
27
  const result = {}
22
28
 
23
- for (const property of projection) { result[rename(property)] = 1 }
29
+ for (const property of projection) {
30
+ result[rename(property)] = 1
31
+ }
24
32
 
25
33
  return result
26
34
  }
@@ -29,5 +37,3 @@ const DIRECTIONS = {
29
37
  asc: 1,
30
38
  desc: -1
31
39
  }
32
-
33
- exports.options = options
@@ -1,9 +1,5 @@
1
- 'use strict'
2
-
3
- const rename = (name) => {
1
+ export const rename = (name) => {
4
2
  return RENAME[name] || name
5
3
  }
6
4
 
7
5
  const RENAME = { id: '_id' }
8
-
9
- exports.rename = rename
package/src/translate.js CHANGED
@@ -1,28 +1,27 @@
1
- 'use strict'
1
+ import * as _criteria from './translate/criteria.js'
2
+ import * as _options from './translate/options.js'
2
3
 
3
- const parse = { ...require('./translate/criteria'), ...require('./translate/options') }
4
+ const parse = { ..._criteria, ..._options }
4
5
 
5
6
  /**
6
- * @param {toa.core.storages.Query} query
7
+ * @param {import('@toa.io/core/types').storages.Query} query
8
+ * @param {string[]} [dates] properties held as BSON dates, so a criterion against one is one too
7
9
  * @returns {{criteria: Object, options: Object}}
8
10
  */
9
- const translate = (query) => {
11
+ export const translate = (query, dates) => {
10
12
  const result = {
11
- criteria: query?.criteria === undefined ? {} : parse.criteria(query.criteria),
12
- options: query?.options === undefined ? {} : parse.options(query.options)
13
+ criteria: query?.criteria === undefined ? {} : parse.criteria(query.criteria, dates),
14
+ options: query?.options === undefined ? {} : parse.options(query.options),
15
+ sample: query?.options?.sample
13
16
  }
14
17
 
15
- if (query?.id !== undefined) {
16
- result.criteria._id = query.id
17
- }
18
+ if (query?.id !== undefined) result.criteria._id = query.id
18
19
 
19
- if (query?.version !== undefined) {
20
- result.criteria._version = query.version
21
- }
20
+ if (query?.ids !== undefined) result.criteria._id = { $in: query.ids }
22
21
 
23
- result.criteria._deleted = null
22
+ if (query?.version !== undefined) result.criteria.VERSION = query.version
23
+
24
+ if (query?.search !== undefined) result.criteria.$text = { $search: query.search }
24
25
 
25
26
  return result
26
27
  }
27
-
28
- exports.translate = translate
@@ -0,0 +1,388 @@
1
+ import { describe, it, beforeEach, afterEach, mock } from 'node:test'
2
+ import { console } from 'openspan'
3
+ import assert from 'node:assert/strict'
4
+
5
+ import { Migrations, STATE } from '../src/migrations.js'
6
+
7
+ let collection
8
+ let state
9
+ let db
10
+ let rows
11
+
12
+ /** whatever `insertOne` has accepted, keyed as MongoDB would key it */
13
+ function stateCollection() {
14
+ return {
15
+ insertOne: mock.fn(async (row) => {
16
+ if (rows.has(row._id)) throw Object.assign(new Error('duplicate'), { code: 11000 })
17
+
18
+ rows.set(row._id, { ...row })
19
+
20
+ return { insertedId: row._id }
21
+ }),
22
+ findOne: mock.fn(async ({ _id }) => rows.get(_id) ?? null),
23
+ updateOne: mock.fn(async (criteria, { $set }) => {
24
+ const row = rows.get(criteria._id)
25
+
26
+ if (row === undefined) return { modifiedCount: 0 }
27
+
28
+ for (const [key, value] of Object.entries(criteria))
29
+ if (key !== '_id' && String(row[key]) !== String(value))
30
+ return { modifiedCount: 0 }
31
+
32
+ Object.assign(row, $set)
33
+
34
+ return { modifiedCount: 1 }
35
+ })
36
+ }
37
+ }
38
+
39
+ beforeEach(() => {
40
+ rows = new Map()
41
+
42
+ collection = {
43
+ collectionName: 'test_one',
44
+ createIndex: mock.fn(async () => undefined),
45
+ dropIndex: mock.fn(async () => undefined),
46
+ updateMany: mock.fn(async () => ({ modifiedCount: 2 })),
47
+ deleteMany: mock.fn(async () => ({ deletedCount: 1 }))
48
+ }
49
+
50
+ state = stateCollection()
51
+ db = { collection: mock.fn((name) => (name === STATE ? state : null)) }
52
+ })
53
+
54
+ const run = (list) => new Migrations(db, collection, list).run()
55
+
56
+ describe('steps', () => {
57
+ it('should create an index', async () => {
58
+ await run([
59
+ {
60
+ id: '0001',
61
+ steps: [
62
+ {
63
+ index: {
64
+ name: 'index_a',
65
+ keys: { a: 'asc', b: 'desc', c: 'hash', d: 'text' }
66
+ }
67
+ }
68
+ ]
69
+ }
70
+ ])
71
+
72
+ assert.deepEqual(collection.createIndex.mock.calls[0].arguments, [
73
+ { a: 1, b: -1, c: 'hashed', d: 'text' },
74
+ { name: 'index_a' }
75
+ ])
76
+ })
77
+
78
+ it('should translate index options', async () => {
79
+ await run([
80
+ {
81
+ id: '0001',
82
+ steps: [
83
+ {
84
+ index: {
85
+ name: 'unique_a',
86
+ keys: { a: 'asc' },
87
+ unique: true,
88
+ sparse: true,
89
+ partial: { a: { $exists: true } },
90
+ ttl: 60
91
+ }
92
+ }
93
+ ]
94
+ }
95
+ ])
96
+
97
+ assert.deepEqual(collection.createIndex.mock.calls[0].arguments[1], {
98
+ name: 'unique_a',
99
+ unique: true,
100
+ sparse: true,
101
+ partialFilterExpression: { a: { $exists: true } },
102
+ expireAfterSeconds: 60
103
+ })
104
+ })
105
+
106
+ it('should refuse an unknown index option', async () => {
107
+ await assert.rejects(
108
+ run([
109
+ {
110
+ id: '0001',
111
+ steps: [{ index: { name: 'a', keys: { a: 'asc' }, backgorund: true } }]
112
+ }
113
+ ]),
114
+ /unknown index option 'backgorund'/
115
+ )
116
+ })
117
+
118
+ it('should refuse an index without keys', async () => {
119
+ await assert.rejects(
120
+ run([{ id: '0001', steps: [{ index: { name: 'a' } }] }]),
121
+ /without a name or keys/
122
+ )
123
+ })
124
+
125
+ it('should recreate an index whose declaration changed', async () => {
126
+ let conflicted = false
127
+
128
+ collection.createIndex = mock.fn(async () => {
129
+ if (conflicted) return undefined
130
+
131
+ conflicted = true
132
+
133
+ throw Object.assign(new Error('conflict'), { code: 85 })
134
+ })
135
+
136
+ await run([
137
+ { id: '0001', steps: [{ index: { name: 'index_a', keys: { a: 'asc' } } }] }
138
+ ])
139
+
140
+ assert.deepEqual(collection.dropIndex.mock.calls[0].arguments, ['index_a'])
141
+ assert.equal(collection.createIndex.mock.callCount(), 2)
142
+ assert.equal(rows.get('test_one:0001').state, 'done')
143
+ })
144
+
145
+ it('should not swallow a creation error that is not a conflict', async () => {
146
+ collection.createIndex = mock.fn(async () => {
147
+ throw Object.assign(new Error('nope'), { code: 13 })
148
+ })
149
+
150
+ await assert.rejects(
151
+ run([{ id: '0001', steps: [{ index: { name: 'a', keys: { a: 'asc' } } }] }]),
152
+ /nope/
153
+ )
154
+ })
155
+
156
+ it('should drop an index', async () => {
157
+ await run([{ id: '0001', steps: [{ dropIndex: { name: 'index_a' } }] }])
158
+
159
+ assert.deepEqual(collection.dropIndex.mock.calls[0].arguments, ['index_a'])
160
+ })
161
+
162
+ it('should ignore dropping an index that is not there', async () => {
163
+ collection.dropIndex = mock.fn(async () => {
164
+ throw Object.assign(new Error('missing'), { code: 27 })
165
+ })
166
+
167
+ await run([{ id: '0001', steps: [{ dropIndex: { name: 'index_a' } }] }])
168
+
169
+ assert.equal(rows.get('test_one:0001').state, 'done')
170
+ })
171
+
172
+ it('should update', async () => {
173
+ await run([
174
+ {
175
+ id: '0001',
176
+ steps: [{ update: { filter: { a: 1 }, update: { $set: { b: 2 } } } }]
177
+ }
178
+ ])
179
+
180
+ assert.deepEqual(collection.updateMany.mock.calls[0].arguments, [
181
+ { a: 1 },
182
+ { $set: { b: 2 } }
183
+ ])
184
+ })
185
+
186
+ it('should update every record when no filter is given', async () => {
187
+ await run([{ id: '0001', steps: [{ update: { update: { $set: { b: 2 } } } }] }])
188
+
189
+ assert.deepEqual(collection.updateMany.mock.calls[0].arguments[0], {})
190
+ })
191
+
192
+ it('should delete', async () => {
193
+ await run([{ id: '0001', steps: [{ delete: { filter: { a: 1 } } }] }])
194
+
195
+ assert.deepEqual(collection.deleteMany.mock.calls[0].arguments, [{ a: 1 }])
196
+ })
197
+
198
+ it('should refuse deleting without a filter', async () => {
199
+ await assert.rejects(
200
+ run([{ id: '0001', steps: [{ delete: {} }] }]),
201
+ /deletes without a filter/
202
+ )
203
+ })
204
+
205
+ it('should refuse a step that is not a verb', async () => {
206
+ await assert.rejects(
207
+ run([{ id: '0001', steps: [{ truncate: {} }] }]),
208
+ /has a step that is not one of/
209
+ )
210
+ })
211
+
212
+ it('should refuse a step naming two verbs', async () => {
213
+ await assert.rejects(
214
+ run([{ id: '0001', steps: [{ update: {}, delete: {} }] }]),
215
+ /has a step that is not one of/
216
+ )
217
+ })
218
+
219
+ it('should apply steps in order', async () => {
220
+ const order = []
221
+
222
+ collection.createIndex = mock.fn(async () => order.push('index'))
223
+ collection.updateMany = mock.fn(async () => {
224
+ order.push('update')
225
+ return { modifiedCount: 0 }
226
+ })
227
+
228
+ await run([
229
+ {
230
+ id: '0001',
231
+ steps: [
232
+ { update: { update: {} } },
233
+ { index: { name: 'a', keys: { a: 'asc' } } },
234
+ { update: { update: {} } }
235
+ ]
236
+ }
237
+ ])
238
+
239
+ assert.deepEqual(order, ['update', 'index', 'update'])
240
+ })
241
+ })
242
+
243
+ describe('state', () => {
244
+ it('should apply migrations in order', async () => {
245
+ const order = []
246
+
247
+ collection.updateMany = mock.fn(async (filter) => {
248
+ order.push(filter.n)
249
+ return { modifiedCount: 0 }
250
+ })
251
+
252
+ await run([
253
+ { id: '0001', steps: [{ update: { filter: { n: 1 }, update: {} } }] },
254
+ { id: '0002', steps: [{ update: { filter: { n: 2 }, update: {} } }] }
255
+ ])
256
+
257
+ assert.deepEqual(order, [1, 2])
258
+ assert.deepEqual([...rows.keys()], ['test_one:0001', 'test_one:0002'])
259
+ })
260
+
261
+ it('should name a row after the collection and the migration', async () => {
262
+ await run([{ id: '0001-indexes', steps: [] }])
263
+
264
+ assert.equal(rows.has('test_one:0001-indexes'), true)
265
+ })
266
+
267
+ it('should not apply a migration that is done', async () => {
268
+ rows.set('test_one:0001', { _id: 'test_one:0001', state: 'done' })
269
+
270
+ await run([{ id: '0001', steps: [{ update: { update: {} } }] }])
271
+
272
+ assert.equal(collection.updateMany.mock.callCount(), 0)
273
+ })
274
+
275
+ it('should leave the row running where a step throws', async () => {
276
+ collection.updateMany = mock.fn(async () => {
277
+ throw new Error('nope')
278
+ })
279
+
280
+ await assert.rejects(
281
+ run([{ id: '0001', steps: [{ update: { update: {} } }] }]),
282
+ /nope/
283
+ )
284
+
285
+ assert.equal(rows.get('test_one:0001').state, 'running')
286
+ })
287
+
288
+ it('should wait for a claim whose owner is alive', async () => {
289
+ rows.set('test_one:0001', {
290
+ _id: 'test_one:0001',
291
+ state: 'running',
292
+ owner: 'other/1',
293
+ heartbeat: new Date()
294
+ })
295
+
296
+ const pending = run([{ id: '0001', steps: [{ update: { update: {} } }] }])
297
+ const settled = await Promise.race([
298
+ pending.then(() => 'done'),
299
+ new Promise((resolve) => setTimeout(() => resolve('waiting'), 1500))
300
+ ])
301
+
302
+ assert.equal(settled, 'waiting')
303
+ assert.equal(collection.updateMany.mock.callCount(), 0)
304
+
305
+ // let it finish, so the test does not leave a promise polling
306
+ rows.get('test_one:0001').state = 'done'
307
+
308
+ await pending
309
+ })
310
+
311
+ it('should take over a claim whose owner stopped', async () => {
312
+ rows.set('test_one:0001', {
313
+ _id: 'test_one:0001',
314
+ state: 'running',
315
+ owner: 'dead/1',
316
+ heartbeat: new Date(Date.now() - 60_000)
317
+ })
318
+
319
+ await run([{ id: '0001', steps: [{ update: { update: {} } }] }])
320
+
321
+ assert.equal(collection.updateMany.mock.callCount(), 1)
322
+ assert.equal(rows.get('test_one:0001').state, 'done')
323
+ assert.notEqual(rows.get('test_one:0001').owner, 'dead/1')
324
+ })
325
+
326
+ it('should apply a migration once across concurrent replicas', async () => {
327
+ const list = [{ id: '0001', steps: [{ update: { update: {} } }] }]
328
+
329
+ await Promise.all(Array.from({ length: 5 }, () => run(list)))
330
+
331
+ assert.equal(collection.updateMany.mock.callCount(), 1)
332
+ assert.equal(rows.size, 1)
333
+ })
334
+ })
335
+
336
+ describe('logging', () => {
337
+ let info
338
+ let debug
339
+
340
+ const messages = (spy) => spy.mock.calls.map((call) => call.arguments[0])
341
+
342
+ beforeEach(() => {
343
+ info = mock.method(console, 'info', () => undefined)
344
+ debug = mock.method(console, 'debug', () => undefined)
345
+ })
346
+
347
+ afterEach(() => {
348
+ info.mock.restore()
349
+ debug.mock.restore()
350
+ })
351
+
352
+ it('should report a migration it applies', async () => {
353
+ await run([{ id: '0001', steps: [{ update: { update: {} } }] }])
354
+
355
+ assert.ok(messages(info).includes('Applying migration'))
356
+ assert.ok(messages(info).includes('Migration applied'))
357
+ })
358
+
359
+ it('should say at debug that a migration was applied already', async () => {
360
+ const list = [{ id: '0001', steps: [{ update: { update: {} } }] }]
361
+
362
+ await run(list)
363
+
364
+ info.mock.resetCalls()
365
+ debug.mock.resetCalls()
366
+
367
+ await run(list)
368
+
369
+ assert.deepStrictEqual(messages(info), [], 'the ordinary start has nothing to report')
370
+ assert.ok(messages(debug).includes('Migration was applied already'))
371
+ })
372
+
373
+ it('should say how many of the declared ones it applied', async () => {
374
+ await run([
375
+ { id: '0001', steps: [{ update: { update: {} } }] },
376
+ { id: '0002', steps: [{ update: { update: {} } }] }
377
+ ])
378
+
379
+ const [message, attributes] = debug.mock.calls.at(-1).arguments
380
+
381
+ assert.strictEqual(message, 'Migrations checked')
382
+ assert.deepStrictEqual(attributes, {
383
+ collection: 'test_one',
384
+ declared: 2,
385
+ applied: 2
386
+ })
387
+ })
388
+ })
@@ -1,6 +1,4 @@
1
- 'use strict'
2
-
3
- const ast = {
1
+ export const ast = {
4
2
  left: {
5
3
  type: 'SELECTOR',
6
4
  selector: 'id'
@@ -13,9 +11,6 @@ const ast = {
13
11
  }
14
12
  }
15
13
 
16
- const criteria = {
14
+ export const criteria = {
17
15
  _id: { $eq: 100500 }
18
16
  }
19
-
20
- exports.ast = ast
21
- exports.criteria = criteria
@@ -1,10 +1,11 @@
1
- 'use strict'
1
+ import { it } from 'node:test'
2
+ import assert from 'node:assert/strict'
2
3
 
3
- const fixtures = require('./criteria.fixtures')
4
- const { criteria } = require('../../src/translate/criteria')
4
+ import * as fixtures from './criteria.fixtures.js'
5
+ import { criteria } from '../../src/translate/criteria.js'
5
6
 
6
7
  it('should translate', () => {
7
8
  const result = criteria(fixtures.ast)
8
9
 
9
- expect(result).toStrictEqual(fixtures.criteria)
10
+ assert.deepStrictEqual(result, fixtures.criteria)
10
11
  })