@toa.io/storages.mongodb 1.0.0-alpha.319 → 1.0.0-alpha.320
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 +7 -0
- package/package.json +4 -4
- package/readme.md +3 -0
- package/src/streams.js +67 -21
- package/src/translate/options.js +3 -0
- package/test/storage.test.js +34 -5
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [1.0.0-alpha.320](https://github.com/toa-io/toa/compare/v1.0.0-alpha.319...v1.0.0-alpha.320) (2026-09-28)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* page a stream by CREATED, and stop it after its first page ([038dc52](https://github.com/toa-io/toa/commit/038dc52f2db1a4dd58c6ec58a00de8914b3d38a3))
|
|
11
|
+
|
|
12
|
+
|
|
6
13
|
# [1.0.0-alpha.319](https://github.com/toa-io/toa/compare/v1.0.0-alpha.318...v1.0.0-alpha.319) (2026-09-27)
|
|
7
14
|
|
|
8
15
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toa.io/storages.mongodb",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.320",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Toa MongoDB Storage Connector",
|
|
6
6
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@toa.io/conveyor": "1.0.0-alpha.311",
|
|
24
|
-
"@toa.io/core": "1.0.0-alpha.
|
|
25
|
-
"@toa.io/definitions": "1.0.0-alpha.
|
|
24
|
+
"@toa.io/core": "1.0.0-alpha.320",
|
|
25
|
+
"@toa.io/definitions": "1.0.0-alpha.320",
|
|
26
26
|
"@toa.io/generic": "1.0.0-alpha.311",
|
|
27
27
|
"@toa.io/pointer": "1.0.0-alpha.311",
|
|
28
28
|
"mongodb": "7.6.0",
|
|
29
29
|
"openspan": "1.0.0-alpha.305"
|
|
30
30
|
},
|
|
31
|
-
"gitHead": "
|
|
31
|
+
"gitHead": "5ef92ab939a596e4ac820965c59c015820eba7b2"
|
|
32
32
|
}
|
package/readme.md
CHANGED
|
@@ -33,3 +33,6 @@ A [stream](/documentation/collections.md) ends with a token of changes where two
|
|
|
33
33
|
|
|
34
34
|
A token lasts as long as the oplog of the replica set holds the point it names — its window, which
|
|
35
35
|
the size of the oplog and the rate of writes decide.
|
|
36
|
+
|
|
37
|
+
A read from a token scans the oplog from the point it names: it costs what the whole replica set
|
|
38
|
+
wrote since, whatever of it concerns the collection.
|
package/src/streams.js
CHANGED
|
@@ -10,12 +10,12 @@ import { match } from './match.js'
|
|
|
10
10
|
* A token is a position in the history MongoDB keeps of the writes committed to the collection —
|
|
11
11
|
* a change stream's resume token — so what it continues from is the order writes committed in.
|
|
12
12
|
* A first read takes the position before it reads anything: what committed before it is in what
|
|
13
|
-
* the read finds, and what committed after it is in the next read. A
|
|
13
|
+
* the read finds, and what committed after it is in the next read. A window is read after the last
|
|
14
14
|
* `_id` of the one before it, ordered by `_id`, which never changes, so an entry stays on one side
|
|
15
|
-
* of a
|
|
15
|
+
* of a window boundary for the whole read.
|
|
16
16
|
*
|
|
17
17
|
* Where MongoDB keeps no such history — a standalone server, or a collection that keeps no images
|
|
18
|
-
* of a record before a change —
|
|
18
|
+
* of a record before a change — windows are still read, and a complete read ends with a `null`
|
|
19
19
|
* token: there is nothing to continue from.
|
|
20
20
|
*/
|
|
21
21
|
export class Streams {
|
|
@@ -45,16 +45,16 @@ export class Streams {
|
|
|
45
45
|
*/
|
|
46
46
|
async stream(translated, given) {
|
|
47
47
|
const { criteria, options } = translated
|
|
48
|
-
const hash = digest(criteria)
|
|
48
|
+
const hash = digest(criteria, options.sort)
|
|
49
49
|
|
|
50
50
|
if (given === undefined) return await this.#first(criteria, options, hash)
|
|
51
51
|
|
|
52
52
|
const token = decode(given)
|
|
53
53
|
|
|
54
54
|
if (token.h !== hash)
|
|
55
|
-
throw new exceptions.QuerySyntaxException('The token was issued for other criteria')
|
|
55
|
+
throw new exceptions.QuerySyntaxException('The token was issued for other criteria or order')
|
|
56
56
|
|
|
57
|
-
if (token.id !== undefined) return await this.#
|
|
57
|
+
if (token.id !== undefined) return await this.#window(criteria, options, hash, token)
|
|
58
58
|
if (token.p === null || !this.#history) throw lost()
|
|
59
59
|
|
|
60
60
|
return await this.#changes(criteria, options, hash, token)
|
|
@@ -74,7 +74,7 @@ export class Streams {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
async #
|
|
77
|
+
async #window(criteria, options, hash, token) {
|
|
78
78
|
if (token.p !== null && !this.#history) throw lost()
|
|
79
79
|
|
|
80
80
|
const session = this.#client().startSession({ causalConsistency: true })
|
|
@@ -84,7 +84,7 @@ export class Streams {
|
|
|
84
84
|
const position = token.p === null ? null : { p: token.p, t: token.t }
|
|
85
85
|
|
|
86
86
|
try {
|
|
87
|
-
return await this.#read(criteria, options, hash, session, position, token.id)
|
|
87
|
+
return await this.#read(criteria, options, hash, session, position, { id: token.id, c: token.c })
|
|
88
88
|
} catch (exception) {
|
|
89
89
|
await session.endSession()
|
|
90
90
|
|
|
@@ -94,7 +94,7 @@ export class Streams {
|
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* The position: the resume token of a change stream opened with a batch of none. Its
|
|
97
|
-
* operation time is where a
|
|
97
|
+
* operation time is where a window is read at, so that a member of the replica set behind it
|
|
98
98
|
* waits until it has replicated it.
|
|
99
99
|
*/
|
|
100
100
|
async #position(session) {
|
|
@@ -114,16 +114,21 @@ export class Streams {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
* The collection, or a
|
|
118
|
-
*
|
|
117
|
+
* The collection, or a window of it. A read in windows is ordered by what an entry never changes —
|
|
118
|
+
* `_id`, or `CREATED` and then `_id` — and continues after the last entry it read; one that does
|
|
119
|
+
* not is read in the order the query states. A read told to stop ends its window with the position
|
|
120
|
+
* changes continue from.
|
|
119
121
|
*/
|
|
120
122
|
async #read(criteria, options, hash, session, position, after) {
|
|
121
123
|
const limit = options.limit
|
|
122
|
-
const
|
|
124
|
+
const order = limit === undefined && after === undefined ? null : ordered(options.sort)
|
|
125
|
+
const filter = after === undefined ? criteria : { $and: [criteria, beyond(order, after)] }
|
|
123
126
|
|
|
124
127
|
const read = { ...options, session, readConcern: { level: 'majority' } }
|
|
125
128
|
|
|
126
|
-
|
|
129
|
+
delete read.stop
|
|
130
|
+
|
|
131
|
+
if (order !== null) read.sort = order
|
|
127
132
|
|
|
128
133
|
const cursor = this.#collection.find(filter, read)
|
|
129
134
|
|
|
@@ -139,14 +144,18 @@ export class Streams {
|
|
|
139
144
|
try {
|
|
140
145
|
for await (const record of cursor) {
|
|
141
146
|
count++
|
|
142
|
-
last = record._id
|
|
147
|
+
last = { id: record._id, c: record.CREATED instanceof Date ? record.CREATED.getTime() : undefined }
|
|
143
148
|
|
|
144
149
|
yield parts.entry(from(record))
|
|
145
150
|
}
|
|
146
151
|
|
|
147
|
-
if (limit !== undefined && count === limit)
|
|
148
|
-
|
|
149
|
-
|
|
152
|
+
if (limit !== undefined && count === limit && options.stop !== true) {
|
|
153
|
+
const window = { v: VERSION, p: position?.p ?? null, t: position?.t ?? null, id: last.id, h: hash }
|
|
154
|
+
|
|
155
|
+
if (order?.[0]?.[0] === 'CREATED') window.c = last.c
|
|
156
|
+
|
|
157
|
+
yield parts.token(encode(window))
|
|
158
|
+
} else yield parts.token(position === null ? null : encode({ v: VERSION, p: position.p, h: hash }))
|
|
150
159
|
} finally {
|
|
151
160
|
await cursor.close()
|
|
152
161
|
await session.endSession()
|
|
@@ -267,11 +276,44 @@ function lost() {
|
|
|
267
276
|
}
|
|
268
277
|
|
|
269
278
|
/**
|
|
270
|
-
* The criteria a token was issued for: a read that continues from it under other
|
|
271
|
-
* answer the changes to one collection as if they were those of another
|
|
279
|
+
* The criteria and the order a token was issued for: a read that continues from it under other
|
|
280
|
+
* criteria would answer the changes to one collection as if they were those of another, and one in
|
|
281
|
+
* another order would window from a place that order does not have.
|
|
282
|
+
*/
|
|
283
|
+
function digest(criteria, sort) {
|
|
284
|
+
return createHash('sha1')
|
|
285
|
+
.update(JSON.stringify([criteria, sort ?? null]))
|
|
286
|
+
.digest('base64url')
|
|
287
|
+
.slice(0, 16)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The order of a read in windows: `_id` ascending unless the query orders by `_id` or `CREATED`,
|
|
292
|
+
* which an entry never changes, and `_id` after `CREATED`, which entries may share. An order by
|
|
293
|
+
* anything else could move an entry across a window boundary mid-read, and is refused.
|
|
272
294
|
*/
|
|
273
|
-
function
|
|
274
|
-
|
|
295
|
+
function ordered(sort) {
|
|
296
|
+
if (sort === undefined || sort.length === 0) return [['_id', 1]]
|
|
297
|
+
|
|
298
|
+
for (const [property] of sort)
|
|
299
|
+
if (!UNMOVING.includes(property))
|
|
300
|
+
throw new exceptions.QuerySyntaxException(`A stream read in windows is not ordered by '${property}', which changes`)
|
|
301
|
+
|
|
302
|
+
const [[first, direction]] = sort
|
|
303
|
+
|
|
304
|
+
return first === '_id' ? [['_id', direction]] : [['CREATED', direction], ['_id', direction]]
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** What comes after the last entry a window read, in its order. */
|
|
308
|
+
function beyond(order, after) {
|
|
309
|
+
const [[first, direction]] = order
|
|
310
|
+
const past = direction === 1 ? '$gt' : '$lt'
|
|
311
|
+
|
|
312
|
+
if (first === '_id') return { _id: { [past]: after.id } }
|
|
313
|
+
|
|
314
|
+
const c = new Date(after.c)
|
|
315
|
+
|
|
316
|
+
return { $or: [{ CREATED: { [past]: c } }, { CREATED: c, _id: { [past]: after.id } }] }
|
|
275
317
|
}
|
|
276
318
|
|
|
277
319
|
function encode(token) {
|
|
@@ -295,6 +337,7 @@ function decode(given) {
|
|
|
295
337
|
typeof token.h === 'string' &&
|
|
296
338
|
(typeof token.p === 'string' || token.p === null) &&
|
|
297
339
|
(token.id === undefined || typeof token.id === 'string') &&
|
|
340
|
+
(token.c === undefined || typeof token.c === 'number') &&
|
|
298
341
|
(token.id === undefined || token.t === null || typeof token.t === 'string')
|
|
299
342
|
|
|
300
343
|
if (!valid) throw lost()
|
|
@@ -307,6 +350,9 @@ const VERSION = 1
|
|
|
307
350
|
|
|
308
351
|
const LOGICAL = ['$and', '$or', '$nor']
|
|
309
352
|
|
|
353
|
+
/** what an entry never changes, and so what a read in windows may be ordered by */
|
|
354
|
+
const UNMOVING = ['_id', 'CREATED']
|
|
355
|
+
|
|
310
356
|
/** what ends a change stream: the collection it follows is gone or renamed */
|
|
311
357
|
const ENDINGS = ['drop', 'rename', 'dropDatabase', 'invalidate']
|
|
312
358
|
|
package/src/translate/options.js
CHANGED
package/test/storage.test.js
CHANGED
|
@@ -28,11 +28,13 @@ beforeEach(async () => {
|
|
|
28
28
|
await storage.open()
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
function cursor() {
|
|
31
|
+
function cursor(records = []) {
|
|
32
32
|
return {
|
|
33
|
-
hasNext: async () =>
|
|
33
|
+
hasNext: async () => records.length > 0,
|
|
34
34
|
close: async () => undefined,
|
|
35
|
-
async *[Symbol.asyncIterator]() {
|
|
35
|
+
async *[Symbol.asyncIterator]() {
|
|
36
|
+
yield* records
|
|
37
|
+
}
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -133,12 +135,39 @@ describe('stream', () => {
|
|
|
133
135
|
assert.deepStrictEqual(parts, [{ token: null }])
|
|
134
136
|
})
|
|
135
137
|
|
|
136
|
-
it('should read a
|
|
138
|
+
it('should read a window ordered by id', async () => {
|
|
137
139
|
await storage.stream({ options: { limit: 2 } })
|
|
138
140
|
|
|
139
|
-
assert.deepStrictEqual(read()[1].sort,
|
|
141
|
+
assert.deepStrictEqual(read()[1].sort, [['_id', 1]])
|
|
140
142
|
assert.equal(read()[1].limit, 2)
|
|
141
143
|
})
|
|
144
|
+
|
|
145
|
+
it('should read a window newest first, and by id where entries share a time', async () => {
|
|
146
|
+
await storage.stream({ options: { limit: 2, sort: [['CREATED', 'desc']] } })
|
|
147
|
+
|
|
148
|
+
assert.deepStrictEqual(read()[1].sort, [['CREATED', -1], ['_id', -1]])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should refuse a window ordered by what an entry changes', async () => {
|
|
152
|
+
await assert.rejects(
|
|
153
|
+
storage.stream({ options: { limit: 2, sort: [['title', 'asc']] } }),
|
|
154
|
+
(error) => error.code === 221
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('should end a window it was told to stop at with no window token', async () => {
|
|
159
|
+
const found = [
|
|
160
|
+
{ _id: 'a', CREATED: new Date(2), VERSION: 1 },
|
|
161
|
+
{ _id: 'b', CREATED: new Date(1), VERSION: 1 }
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
collection.find.mock.mockImplementationOnce(() => cursor(found))
|
|
165
|
+
|
|
166
|
+
const stream = await storage.stream({ options: { limit: 2, stop: true } })
|
|
167
|
+
const parts = await stream.toArray()
|
|
168
|
+
|
|
169
|
+
assert.deepStrictEqual(parts.at(-1), { token: null })
|
|
170
|
+
})
|
|
142
171
|
})
|
|
143
172
|
|
|
144
173
|
describe('converge', () => {
|