@toa.io/extensions.cadence 1.0.0-alpha.292 → 1.0.0-alpha.293

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/extensions.cadence",
3
- "version": "1.0.0-alpha.292",
3
+ "version": "1.0.0-alpha.293",
4
4
  "type": "module",
5
5
  "description": "Toa Schedule",
6
6
  "author": "temich <tema.gurtovoy@gmail.com>",
@@ -17,15 +17,23 @@
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },
20
+ "files": [
21
+ "transpiled",
22
+ "components",
23
+ "readme.md",
24
+ "!**/tsconfig.tsbuildinfo",
25
+ "!**/*.test.*",
26
+ "!**/.env*"
27
+ ],
20
28
  "dependencies": {
21
- "@toa.io/core": "1.0.0-alpha.292",
22
- "@toa.io/definitions": "1.0.0-alpha.292",
29
+ "@toa.io/core": "1.0.0-alpha.293",
30
+ "@toa.io/definitions": "1.0.0-alpha.293",
23
31
  "@toa.io/generic": "1.0.0-alpha.292",
24
- "@toa.io/schemas": "1.0.0-alpha.292",
25
- "openspan": "1.0.0-alpha.286"
32
+ "@toa.io/schemas": "1.0.0-alpha.293",
33
+ "openspan": "1.0.0-alpha.293"
26
34
  },
27
35
  "scripts": {
28
36
  "transpile": "tsc"
29
37
  },
30
- "gitHead": "0f6aa3ad9dbec86bb632a7975ea61d1dde1d8fc5"
38
+ "gitHead": "1e05b3ab64758324c58c425f1f8e52e9f35f1da8"
31
39
  }
package/CHANGELOG.md DELETED
@@ -1,44 +0,0 @@
1
- # Change Log
2
-
3
- All notable changes to this project will be documented in this file.
4
- See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
-
6
- # [1.0.0-alpha.292](https://github.com/toa-io/toa/compare/v1.0.0-alpha.291...v1.0.0-alpha.292) (2026-09-07)
7
-
8
- ### Features
9
-
10
- * **definitions:** what a package declares is read from one package ([7052341](https://github.com/toa-io/toa/commit/70523411b5d9e2b204c999aa02365173f3bb518d))
11
-
12
-
13
- # [1.0.0-alpha.291](https://github.com/toa-io/toa/compare/v1.0.0-alpha.290...v1.0.0-alpha.291) (2026-09-07)
14
-
15
- **Note:** Version bump only for package @toa.io/extensions.cadence
16
-
17
-
18
-
19
-
20
-
21
- # [1.0.0-alpha.289](https://github.com/toa-io/toa/compare/v1.0.0-alpha.288...v1.0.0-alpha.289) (2026-09-07)
22
-
23
- * A deploy moves only what changed, and a component sees none of the runtime's environment (#1073) ([f38e3db](https://github.com/toa-io/toa/commit/f38e3db533f24db866c57bfa1ef294eff1eaf499)), closes [#1073](https://github.com/toa-io/toa/issues/1073) [#1064](https://github.com/toa-io/toa/issues/1064) [#1066](https://github.com/toa-io/toa/issues/1066) [#1067](https://github.com/toa-io/toa/issues/1067) [#1068](https://github.com/toa-io/toa/issues/1068) [#1069](https://github.com/toa-io/toa/issues/1069) [#1071](https://github.com/toa-io/toa/issues/1071) [#1070](https://github.com/toa-io/toa/issues/1070) [#1072](https://github.com/toa-io/toa/issues/1072)
24
-
25
- ### BREAKING CHANGES
26
-
27
- * a component that read `process.env.TOA_*` reads `context` instead;
28
- `echo(input)` no longer substitutes from the environment; a bash operation sees no
29
- `TOA_*`; images no longer set `USER node` — see migrations/289.md.
30
-
31
-
32
- # [1.0.0-alpha.288](https://github.com/toa-io/toa/compare/v1.0.0-alpha.287...v1.0.0-alpha.288) (2026-09-06)
33
-
34
- **Note:** Version bump only for package @toa.io/extensions.cadence
35
-
36
-
37
-
38
-
39
-
40
- # [1.0.0-alpha.287](https://github.com/toa-io/toa/compare/v1.0.0-alpha.286...v1.0.0-alpha.287) (2026-09-06)
41
-
42
- ### Bug Fixes
43
-
44
- * **cadence:** the scan reads what is owed, and nothing that is done ([581610e](https://github.com/toa-io/toa/commit/581610e87ac54c42491c882d5749ff3307700d35))
package/source/Aspect.ts DELETED
@@ -1,73 +0,0 @@
1
- import { Connector } from '@toa.io/core'
2
- import type { Local } from './Local.js'
3
- import type { Options } from '@toa.io/definitions/extensions.cadence'
4
-
5
- interface Input {
6
- endpoint: string
7
- interval: number
8
- overdue: number | null
9
- request?: object
10
- }
11
-
12
- /**
13
- * `context.delay(endpoint, request, delay)` — a call to be made later, handed to the component
14
- * that keeps them.
15
- *
16
- * It is not transactional with the caller's state. A crash between committing a change and
17
- * handing over the call it should have scheduled loses the call, and there is no way for this
18
- * to know. Where the semantics allow it, hand the call over first.
19
- *
20
- */
21
- export class Aspect extends Connector {
22
- public readonly name = 'delay'
23
-
24
- private readonly metronome: Local
25
-
26
- public constructor(metronome: Local) {
27
- super()
28
-
29
- this.metronome = metronome
30
-
31
- this.depends(metronome)
32
- }
33
-
34
- // named rather than forwarded, so that the connector's lifecycle is not reachable from an
35
- // algorithm — the same reason the atom aspect names its own
36
- public async invoke(method: string, ...args: any[]): Promise<unknown> {
37
- switch (method) {
38
- case 'delay':
39
- return await this.delay(args[0], args[1], args[2])
40
- case 'cancel':
41
- return await this.cancel(args[0])
42
- default:
43
- throw new Error(`Delay aspect has no '${method}' method`)
44
- }
45
- }
46
-
47
- /** Answers the id the call was given, which is what cancels it. */
48
- private async delay(
49
- endpoint: string,
50
- request: object | null,
51
- options: Options
52
- ): Promise<unknown> {
53
- const input: Input = {
54
- endpoint,
55
- interval: options.interval,
56
- overdue: options.overdue
57
- }
58
-
59
- // a call that takes no request carries none, rather than a null one
60
- if (request !== null && request !== undefined) input.request = request
61
-
62
- return await this.metronome.invoke('delay', { input })
63
- }
64
-
65
- /**
66
- * Tombstones the row, which is the prototype's `terminate`. An id that was never issued has
67
- * nothing to terminate and raises; one whose call has already been made has a row still, and
68
- * terminating it again is not an error.
69
- */
70
- private async cancel(id: string): Promise<unknown> {
71
- return await this.metronome.invoke('terminate', { query: { id }, input: null })
72
- }
73
- }
@@ -1,47 +0,0 @@
1
- import { readdirSync } from 'node:fs'
2
- import { resolve } from 'node:path'
3
- import { Connector } from '@toa.io/core'
4
- import type { Dirent } from 'node:fs'
5
- import type { Host } from './Factory.js'
6
-
7
- /** The components this extension ships, which run inside its own service. */
8
- export function components(): Components {
9
- const labels: string[] = []
10
- const paths: string[] = []
11
-
12
- for (const entry of entries()) {
13
- labels.push(entry.name.replace('.', '-'))
14
- paths.push(resolve(ROOT, entry.name))
15
- }
16
-
17
- return { labels, paths }
18
- }
19
-
20
- export class Composition extends Connector {
21
- private readonly host: Host
22
-
23
- public constructor(host: Host) {
24
- super()
25
-
26
- this.host = host
27
- }
28
-
29
- protected override async open(): Promise<void> {
30
- const composition = await this.host.composition(components().paths)
31
-
32
- await composition.connect()
33
-
34
- this.depends(composition)
35
- }
36
- }
37
-
38
- function entries(): Dirent[] {
39
- return readdirSync(ROOT, { withFileTypes: true }).filter((entry) => entry.isDirectory())
40
- }
41
-
42
- interface Components {
43
- labels: string[]
44
- paths: string[]
45
- }
46
-
47
- const ROOT = resolve(import.meta.dirname, '../components/')
@@ -1,492 +0,0 @@
1
- import { it, beforeEach, afterEach, mock } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { console } from 'openspan'
4
-
5
- import { Dispatcher } from './Dispatcher.js'
6
- import { BATCH, LANES } from '@toa.io/definitions/extensions.cadence'
7
- import type { Local } from './Local.js'
8
- import type { atomicity } from '@toa.io/core/types'
9
-
10
- type Invoke = (endpoint: string, request: any) => Promise<unknown>
11
- type Slots = (total: number) => number[] | null
12
- type Listener = () => void
13
-
14
- const INTERVAL = 100
15
-
16
- let metronome: Local & { invoke: ReturnType<typeof mock.fn<Invoke>> }
17
- let target: Local & { invoke: ReturnType<typeof mock.fn<Invoke>> }
18
- let atom: atomicity.Atom & { slots: ReturnType<typeof mock.fn<Slots>> }
19
- let listeners: Listener[]
20
- let rows: Row[]
21
- let warn: ReturnType<typeof mock.method<typeof console, 'warn'>>
22
-
23
- interface Row {
24
- id: string
25
- lane: number
26
- due: number
27
- expires: number
28
- endpoint: string
29
- request: object | null
30
- }
31
-
32
- /** `expires` defaults to no bound at all, which is what `overdue: null` writes */
33
- const row = (
34
- id: string,
35
- due: number,
36
- lane = 0,
37
- expires = Number.MAX_SAFE_INTEGER
38
- ): Row => ({
39
- id,
40
- lane,
41
- due,
42
- expires,
43
- endpoint: 'tea.pots.transit',
44
- request: { input: { id } }
45
- })
46
-
47
- const connector = (properties: object): any => ({
48
- ...properties,
49
- link: mock.fn(),
50
- connect: async () => {},
51
- disconnect: async () => {}
52
- })
53
-
54
- const advance = async (ms: number): Promise<void> => {
55
- mock.timers.tick(ms)
56
-
57
- for (let i = 0; i < 30; i++) await Promise.resolve()
58
- }
59
-
60
- /** the query one scan made */
61
- const reads = (): any[] =>
62
- metronome.invoke.mock.calls
63
- .filter((call) => call.arguments[0] === 'enumerate')
64
- .map((call) => call.arguments[1].query)
65
-
66
- /** what has been settled, flattened */
67
- const settled = (): string[] =>
68
- metronome.invoke.mock.calls
69
- .filter((call) => call.arguments[0] === 'settle')
70
- .flatMap((call) => call.arguments[1].query.ids as string[])
71
-
72
- const range = (total: number): number[] => Array.from({ length: total }, (_, i) => i)
73
-
74
- const create = (): Dispatcher => new Dispatcher(metronome, () => target, atom)
75
-
76
- beforeEach(() => {
77
- process.env.TOA_CADENCE_DISCRETENESS = String(INTERVAL)
78
- mock.timers.enable({ apis: ['setTimeout', 'setInterval', 'Date'], now: 0 })
79
-
80
- rows = []
81
- listeners = []
82
- warn = mock.method(console, 'warn', () => undefined)
83
-
84
- metronome = connector({
85
- invoke: mock.fn<Invoke>(async (endpoint: string) =>
86
- endpoint === 'enumerate' ? rows : null
87
- )
88
- })
89
-
90
- target = connector({ invoke: mock.fn<Invoke>(async () => null) })
91
-
92
- atom = connector({
93
- slots: mock.fn<Slots>((total) => range(total)),
94
- onassigned: (listener: Listener) => {
95
- listeners.push(listener)
96
- listener()
97
-
98
- return () => {
99
- listeners = listeners.filter((one) => one !== listener)
100
- }
101
- }
102
- })
103
- })
104
-
105
- afterEach(() => {
106
- mock.timers.reset()
107
- warn.mock.restore()
108
- delete process.env.TOA_CADENCE_DISCRETENESS
109
- })
110
-
111
- it('should read two intervals ahead, in the lanes it owns', async () => {
112
- const dispatcher = create()
113
-
114
- await dispatcher.connect()
115
- await advance(INTERVAL)
116
-
117
- const [first] = reads()
118
-
119
- // the first scan is the one the subscription runs as it is added, at time zero. Two
120
- // intervals, so that consecutive passes overlap; and no bound on `expires`, because an
121
- // expired row is settled rather than left where nothing will ever read it again
122
- assert.strictEqual(
123
- first.criteria,
124
- `lane=in=(${range(LANES).join(',')});due<${2 * INTERVAL}`
125
- )
126
- assert.deepStrictEqual(first.sort, ['due:asc'])
127
- })
128
-
129
- it('should reach past the next pass, so a row on its horizon is armed before it is due', async () => {
130
- rows = [row('a', INTERVAL + 10)]
131
-
132
- const dispatcher = create()
133
-
134
- await dispatcher.connect()
135
-
136
- // gone from the store before the next pass, so nothing but the scan at time zero can have
137
- // armed it — which is what reaching two intervals rather than one is for
138
- rows = []
139
-
140
- await advance(INTERVAL)
141
-
142
- assert.strictEqual(target.invoke.mock.callCount(), 0, 'armed, not yet due')
143
-
144
- await advance(10)
145
-
146
- assert.strictEqual(target.invoke.mock.callCount(), 1, 'and called at its due time')
147
- })
148
-
149
- it('should settle an expired row without calling it', async () => {
150
- rows = [row('a', -10_000, 0, -5_000)]
151
-
152
- const dispatcher = create()
153
-
154
- await dispatcher.connect()
155
- await advance(0)
156
-
157
- assert.strictEqual(
158
- target.invoke.mock.callCount(),
159
- 0,
160
- 'past the bound its caller gave it'
161
- )
162
-
163
- rows = []
164
- await advance(INTERVAL)
165
-
166
- assert.deepStrictEqual(
167
- settled(),
168
- ['a'],
169
- 'and settled, or nothing would ever read it again'
170
- )
171
- })
172
-
173
- it('should settle what it has called even while it owns nothing', async () => {
174
- rows = [row('a', 10)]
175
-
176
- const dispatcher = create()
177
-
178
- await dispatcher.connect()
179
- await advance(10)
180
-
181
- assert.strictEqual(target.invoke.mock.callCount(), 1)
182
-
183
- // the lane is somebody else's now, and a row left live is one they call again
184
- rows = []
185
- atom.slots.mock.mockImplementation(() => null)
186
-
187
- await advance(INTERVAL)
188
-
189
- assert.deepStrictEqual(settled(), ['a'])
190
- })
191
-
192
- it('should say so when a scan fills its batch', async () => {
193
- rows = Array.from({ length: BATCH }, (_, i) => row(String(i), 10_000))
194
-
195
- const dispatcher = create()
196
-
197
- await dispatcher.connect()
198
- await advance(0)
199
-
200
- const warned = warn.mock.calls.map((call) => call.arguments[0] as string)
201
-
202
- assert.ok(
203
- warned.some((message) => message.includes('filled its batch')),
204
- 'the horizon is truncated and the rest arrive late'
205
- )
206
- })
207
-
208
- it('should say so when a pass is skipped because the one before it is still running', async () => {
209
- let release: () => void = () => {}
210
-
211
- metronome.invoke.mock.mockImplementation(async (endpoint: string) => {
212
- if (endpoint !== 'enumerate') return null
213
-
214
- await new Promise<void>((resolve) => {
215
- release = resolve
216
- })
217
-
218
- return []
219
- })
220
-
221
- const dispatcher = create()
222
-
223
- await dispatcher.connect()
224
- await advance(0) // the scan at time zero, which does not come back
225
-
226
- await advance(INTERVAL)
227
-
228
- const warned = warn.mock.calls.map((call) => call.arguments[0] as string)
229
-
230
- assert.ok(
231
- warned.some((message) => message.includes('has not returned')),
232
- 'a dispatcher that stops dispatching says so'
233
- )
234
-
235
- release()
236
- await advance(0)
237
- })
238
-
239
- it('should read nothing while it owns nothing', async () => {
240
- atom.slots.mock.mockImplementation(() => null)
241
-
242
- const dispatcher = create()
243
-
244
- await dispatcher.connect()
245
- await advance(INTERVAL * 3)
246
-
247
- assert.strictEqual(reads().length, 0)
248
- })
249
-
250
- it('should call at the due time and not before', async () => {
251
- rows = [row('a', 60)]
252
-
253
- const dispatcher = create()
254
-
255
- await dispatcher.connect()
256
- await advance(1)
257
-
258
- assert.strictEqual(target.invoke.mock.callCount(), 0, 'armed, not called')
259
-
260
- await advance(60)
261
-
262
- assert.strictEqual(target.invoke.mock.callCount(), 1)
263
-
264
- const [endpoint, request] = target.invoke.mock.calls[0].arguments
265
-
266
- assert.strictEqual(endpoint, 'transit')
267
- assert.deepStrictEqual(request, { input: { id: 'a' }, task: true })
268
- })
269
-
270
- it('should call one whose time has already passed at once', async () => {
271
- rows = [row('a', -10_000)]
272
-
273
- const dispatcher = create()
274
-
275
- await dispatcher.connect()
276
- await advance(0)
277
-
278
- assert.strictEqual(
279
- target.invoke.mock.callCount(),
280
- 1,
281
- 'a scan reads what is overdue along with what is coming'
282
- )
283
- })
284
-
285
- it('should arm a row once, however often a scan reads it', async () => {
286
- rows = [row('a', 10_000)]
287
-
288
- const dispatcher = create()
289
-
290
- await dispatcher.connect()
291
-
292
- // one interval at a time: a scan is one at a time, and a single jump would find the first
293
- // still running and skip the rest
294
- for (let cycle = 0; cycle < 4; cycle++) await advance(INTERVAL)
295
-
296
- assert.ok(reads().length >= 3, 'it kept scanning')
297
- assert.strictEqual(target.invoke.mock.callCount(), 0, 'and armed it only once')
298
- })
299
-
300
- it('should not arm a row again while its call is in flight', async () => {
301
- let release: () => void = () => {}
302
-
303
- rows = [row('a', 10)]
304
- target.invoke.mock.mockImplementation(
305
- async () =>
306
- await new Promise<void>((resolve) => {
307
- release = resolve
308
- })
309
- )
310
-
311
- const dispatcher = create()
312
-
313
- await dispatcher.connect()
314
- await advance(10) // the call goes out, and does not come back
315
-
316
- assert.strictEqual(target.invoke.mock.callCount(), 1)
317
-
318
- // a scan while it is in flight reads the row again, and settles what it thinks was called
319
- await advance(INTERVAL)
320
-
321
- // far enough for anything that scan armed to have fired
322
- await advance(INTERVAL)
323
-
324
- assert.strictEqual(
325
- target.invoke.mock.callCount(),
326
- 1,
327
- 'and does not send it a second time'
328
- )
329
-
330
- release()
331
- await advance(0)
332
- })
333
-
334
- it('should settle what it has called', async () => {
335
- rows = [row('a', 10)]
336
-
337
- const dispatcher = create()
338
-
339
- await dispatcher.connect()
340
- await advance(10)
341
-
342
- assert.strictEqual(target.invoke.mock.callCount(), 1)
343
-
344
- rows = []
345
- await advance(INTERVAL)
346
-
347
- assert.deepStrictEqual(settled(), ['a'])
348
- })
349
-
350
- it('should settle a row whose call failed, and keep dispatching', async () => {
351
- rows = [row('a', 10), row('b', 20)]
352
- target.invoke.mock.mockImplementationOnce(async () => {
353
- throw new Error('nope')
354
- })
355
-
356
- const dispatcher = create()
357
-
358
- await dispatcher.connect()
359
- await advance(20)
360
-
361
- assert.strictEqual(
362
- target.invoke.mock.callCount(),
363
- 2,
364
- 'the one after it is still called'
365
- )
366
-
367
- rows = []
368
- await advance(INTERVAL)
369
-
370
- assert.deepStrictEqual(
371
- settled().sort(),
372
- ['a', 'b'],
373
- 'and the one that failed is settled'
374
- )
375
- })
376
-
377
- it('should drop timers for lanes it no longer owns', async () => {
378
- rows = [row('a', 10_000, 7)]
379
-
380
- const dispatcher = create()
381
-
382
- await dispatcher.connect()
383
- await advance(INTERVAL)
384
-
385
- rows = []
386
- atom.slots.mock.mockImplementation(() => [0])
387
-
388
- for (const listener of listeners) listener()
389
-
390
- await advance(20_000)
391
-
392
- assert.strictEqual(target.invoke.mock.callCount(), 0, "lane 7 is somebody else's now")
393
- })
394
-
395
- it('should drop everything it holds when it owns nothing', async () => {
396
- rows = [row('a', 10_000, 7)]
397
-
398
- const dispatcher = create()
399
-
400
- await dispatcher.connect()
401
- await advance(INTERVAL)
402
-
403
- rows = []
404
- atom.slots.mock.mockImplementation(() => null)
405
-
406
- for (const listener of listeners) listener()
407
-
408
- await advance(20_000)
409
-
410
- assert.strictEqual(
411
- target.invoke.mock.callCount(),
412
- 0,
413
- 'whoever holds lane 7 now is the one to make that call'
414
- )
415
- })
416
-
417
- it('should scan at once when what it owns changes', async () => {
418
- atom.slots.mock.mockImplementation(() => null)
419
-
420
- const dispatcher = create()
421
-
422
- await dispatcher.connect()
423
- await advance(0)
424
-
425
- assert.strictEqual(reads().length, 0)
426
-
427
- atom.slots.mock.mockImplementation((total) => range(total))
428
-
429
- for (const listener of listeners) listener()
430
-
431
- await advance(0)
432
-
433
- assert.strictEqual(reads().length, 1, 'without waiting out an interval')
434
- })
435
-
436
- it('should give up a row a scan stops reading, so a cancellation still lands', async () => {
437
- rows = [row('a', INTERVAL + 50)]
438
-
439
- const dispatcher = create()
440
-
441
- await dispatcher.connect()
442
- await advance(1)
443
-
444
- // cancelled: the row is tombstoned, so the next scan does not read it
445
- rows = []
446
-
447
- await advance(INTERVAL) // the scan that finds it gone
448
- await advance(INTERVAL) // and past when it would have been called
449
-
450
- assert.strictEqual(
451
- target.invoke.mock.callCount(),
452
- 0,
453
- 'the timer was given up with the row'
454
- )
455
- })
456
-
457
- it('should keep a row the scan still reads', async () => {
458
- rows = [row('a', INTERVAL + 50)]
459
-
460
- const dispatcher = create()
461
-
462
- await dispatcher.connect()
463
- await advance(INTERVAL)
464
-
465
- assert.strictEqual(target.invoke.mock.callCount(), 0, 'not yet due')
466
-
467
- await advance(50)
468
-
469
- assert.strictEqual(target.invoke.mock.callCount(), 1)
470
- })
471
-
472
- it('should not give up a row when the batch was filled', async () => {
473
- const held = row('held', INTERVAL + 50)
474
-
475
- rows = [held, ...Array.from({ length: BATCH - 1 }, (_, i) => row('b' + i, 10_000))]
476
-
477
- const dispatcher = create()
478
-
479
- await dispatcher.connect()
480
- await advance(1)
481
-
482
- // a full batch is a read that was cut short, so absence from the next one is not a
483
- // cancellation — these are rows it never reached, not rows nobody owes
484
- rows = Array.from({ length: BATCH }, (_, i) => row('c' + i, 20_000))
485
-
486
- await advance(INTERVAL)
487
- await advance(50)
488
-
489
- const called = target.invoke.mock.calls.map((call) => call.arguments[1].input.id)
490
-
491
- assert.deepStrictEqual(called, ['held'], 'the row it armed is still called')
492
- })