@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 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.319",
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.319",
25
- "@toa.io/definitions": "1.0.0-alpha.319",
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": "ee4901f2bc12c33fc2098a62f2af0c29838a2c2c"
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 page is read after the last
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 page boundary for the whole read.
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 — pages are still read, and a complete read ends with a `null`
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.#page(criteria, options, hash, token)
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 #page(criteria, options, hash, token) {
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 page is read at, so that a member of the replica set behind it
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 page of it. A read that pages is ordered by `_id` and continues after the
118
- * last one; one that does not is read in the order the query states.
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 filter = after === undefined ? criteria : { $and: [criteria, { _id: { $gt: after } }] }
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
- if (limit !== undefined) read.sort = { _id: 1 }
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
- yield parts.token(encode({ v: VERSION, p: position?.p ?? null, t: position?.t ?? null, id: last, h: hash }))
149
- else yield parts.token(position === null ? null : encode({ v: VERSION, p: position.p, h: hash }))
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 criteria would
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 digest(criteria) {
274
- return createHash('sha1').update(JSON.stringify(criteria)).digest('base64url').slice(0, 16)
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
 
@@ -15,6 +15,9 @@ export const options = (options) => {
15
15
  if (options.projection) {
16
16
  result.projection = projection(options.projection)
17
17
  }
18
+ if (options.stop) {
19
+ result.stop = true
20
+ }
18
21
 
19
22
  return result
20
23
  }
@@ -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 () => false,
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 page ordered by id', async () => {
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, { _id: 1 })
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', () => {