@toa.io/extensions.cadence 1.0.0-alpha.284

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/components/cadence.metronome/manifest.toa.yaml +72 -0
  3. package/components/cadence.metronome/operations/delay.js +28 -0
  4. package/components/cadence.metronome/operations/settle.js +9 -0
  5. package/components/cadence.metronome/types/index.d.ts +3 -0
  6. package/components/cadence.metronome/types/toa.d.ts +45 -0
  7. package/package.json +28 -0
  8. package/readme.md +188 -0
  9. package/schemas/annotation.cos.yaml +9 -0
  10. package/schemas/declaration.cos.yaml +21 -0
  11. package/source/Aspect.ts +64 -0
  12. package/source/Composition.ts +47 -0
  13. package/source/Dispatcher.test.ts +271 -0
  14. package/source/Dispatcher.ts +221 -0
  15. package/source/Factory.ts +73 -0
  16. package/source/Local.ts +39 -0
  17. package/source/Pulse.test.ts +274 -0
  18. package/source/Pulse.ts +202 -0
  19. package/source/const.ts +23 -0
  20. package/source/context.ts +10 -0
  21. package/source/deployment.ts +33 -0
  22. package/source/index.ts +8 -0
  23. package/source/manifest.test.ts +58 -0
  24. package/source/manifest.ts +51 -0
  25. package/source/schemas.ts +10 -0
  26. package/source/types.ts +41 -0
  27. package/transpiled/Aspect.d.ts +26 -0
  28. package/transpiled/Aspect.js +46 -0
  29. package/transpiled/Aspect.js.map +1 -0
  30. package/transpiled/Composition.d.ts +14 -0
  31. package/transpiled/Composition.js +30 -0
  32. package/transpiled/Composition.js.map +1 -0
  33. package/transpiled/Dispatcher.d.ts +63 -0
  34. package/transpiled/Dispatcher.js +170 -0
  35. package/transpiled/Dispatcher.js.map +1 -0
  36. package/transpiled/Factory.d.ts +22 -0
  37. package/transpiled/Factory.js +56 -0
  38. package/transpiled/Factory.js.map +1 -0
  39. package/transpiled/Local.d.ts +18 -0
  40. package/transpiled/Local.js +29 -0
  41. package/transpiled/Local.js.map +1 -0
  42. package/transpiled/Pulse.d.ts +64 -0
  43. package/transpiled/Pulse.js +148 -0
  44. package/transpiled/Pulse.js.map +1 -0
  45. package/transpiled/const.d.ts +15 -0
  46. package/transpiled/const.js +19 -0
  47. package/transpiled/const.js.map +1 -0
  48. package/transpiled/context.d.ts +3 -0
  49. package/transpiled/context.js +9 -0
  50. package/transpiled/context.js.map +1 -0
  51. package/transpiled/deployment.d.ts +9 -0
  52. package/transpiled/deployment.js +26 -0
  53. package/transpiled/deployment.js.map +1 -0
  54. package/transpiled/index.d.ts +7 -0
  55. package/transpiled/index.js +7 -0
  56. package/transpiled/index.js.map +1 -0
  57. package/transpiled/manifest.d.ts +11 -0
  58. package/transpiled/manifest.js +39 -0
  59. package/transpiled/manifest.js.map +1 -0
  60. package/transpiled/schemas.d.ts +4 -0
  61. package/transpiled/schemas.js +7 -0
  62. package/transpiled/schemas.js.map +1 -0
  63. package/transpiled/types.d.ts +34 -0
  64. package/transpiled/types.js +2 -0
  65. package/transpiled/types.js.map +1 -0
  66. package/tsconfig.json +13 -0
@@ -0,0 +1,274 @@
1
+ import { it, beforeEach, afterEach, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { Pulse } from './Pulse.js'
5
+ import type { Local } from './Local.js'
6
+ import type { Locator, Request, atomicity } from '@toa.io/core'
7
+
8
+ type Invoke = (endpoint: string, request: Request) => Promise<unknown>
9
+ type Slots = (total: number) => number[] | null
10
+
11
+ let local: Local & { invoke: ReturnType<typeof mock.fn<Invoke>> }
12
+ let atom: atomicity.Atom & { slots: ReturnType<typeof mock.fn<Slots>> }
13
+
14
+ const SECOND = 1000
15
+ const HOUR = 3600
16
+ const DAY = 24 * HOUR
17
+
18
+ const locator = { id: 'tea.pots' } as Locator
19
+
20
+ const range = (total: number): number[] => Array.from({ length: total }, (_, index) => index)
21
+
22
+ /** what a Pulse reaches for, with a lifecycle it can connect through */
23
+ const connector = (properties: object): any =>
24
+ ({ ...properties, link: mock.fn(), connect: async () => {}, disconnect: async () => {} })
25
+
26
+ const create = (cycle: number, intervals: number): Pulse =>
27
+ new Pulse({ locator, endpoint: 'sweep', cycle, intervals }, local, atom)
28
+
29
+ /** the timers the tick arms are the only ones, so a tick is a jump and its microtasks */
30
+ const advance = async (ms: number): Promise<void> => {
31
+ mock.timers.tick(ms)
32
+
33
+ for (let i = 0; i < 20; i++) await Promise.resolve()
34
+ }
35
+
36
+ /** which interval each call was told it was for */
37
+ const intervals = (): number[] =>
38
+ local.invoke.mock.calls.map((call) => (call.arguments[1].input as { i: number }).i)
39
+
40
+
41
+ beforeEach(() => {
42
+ mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 })
43
+
44
+ local = connector({ invoke: mock.fn<Invoke>(async () => undefined) })
45
+
46
+ // one replica of one: every slot of the cycle is its own
47
+ atom = connector({ slots: mock.fn<Slots>((total) => range(total)) })
48
+ })
49
+
50
+ afterEach(() => {
51
+ mock.timers.reset()
52
+ })
53
+
54
+ it('should split a cycle into intervals', () => {
55
+ const pulse = create(DAY, 24)
56
+
57
+ assert.strictEqual(pulse.index(0), 0)
58
+ assert.strictEqual(pulse.index(HOUR * SECOND - 1), 0)
59
+ assert.strictEqual(pulse.index(HOUR * SECOND), 1)
60
+ assert.strictEqual(pulse.index(23 * HOUR * SECOND), 23)
61
+ assert.strictEqual(pulse.index(DAY * SECOND), 0)
62
+ })
63
+
64
+ it('should give every interval of a cycle that does not divide evenly', () => {
65
+ const pulse = create(DAY, 7)
66
+ const seen = new Set<number>()
67
+
68
+ let now = 0
69
+
70
+ for (let step = 0; step < 7; step++) {
71
+ seen.add(pulse.index(now))
72
+ now = pulse.boundary(now)
73
+ }
74
+
75
+ assert.deepStrictEqual([...seen].sort((a, b) => a - b), [0, 1, 2, 3, 4, 5, 6])
76
+ assert.strictEqual(now, DAY * SECOND, 'seven boundaries land exactly on the next cycle')
77
+ })
78
+
79
+ it('should not drift across cycles', () => {
80
+ const pulse = create(DAY, 7)
81
+
82
+ let now = 0
83
+
84
+ for (let step = 0; step < 7 * 100; step++) now = pulse.boundary(now)
85
+
86
+ assert.strictEqual(now, 100 * DAY * SECOND)
87
+ })
88
+
89
+ it('should treat the interval it starts in as already handled', async () => {
90
+ const pulse = create(DAY, 24)
91
+
92
+ mock.timers.setTime(30 * 60 * SECOND) // half past the first hour
93
+
94
+ await pulse.connect()
95
+ await advance(0)
96
+
97
+ assert.strictEqual(local.invoke.mock.callCount(), 0)
98
+ })
99
+
100
+ it('should call at the boundary', async () => {
101
+ const pulse = create(DAY, 24)
102
+
103
+ mock.timers.setTime(30 * 60 * SECOND)
104
+
105
+ await pulse.connect()
106
+
107
+ await advance(30 * 60 * SECOND - 1)
108
+ assert.strictEqual(local.invoke.mock.callCount(), 0, 'not a millisecond early')
109
+
110
+ await advance(1)
111
+ assert.strictEqual(local.invoke.mock.callCount(), 1)
112
+ })
113
+
114
+ it('should tell the operation which interval it is', async () => {
115
+ const pulse = create(DAY, 24)
116
+
117
+ await pulse.connect()
118
+ await advance(HOUR * SECOND)
119
+
120
+ const [endpoint, request] = local.invoke.mock.calls[0].arguments
121
+
122
+ assert.strictEqual(endpoint, 'sweep')
123
+ assert.deepStrictEqual(request, { input: { n: 24, i: 1 } })
124
+ })
125
+
126
+ it('should call once per interval, and every interval of a cycle', async () => {
127
+ const pulse = create(DAY, 24)
128
+
129
+ await pulse.connect()
130
+
131
+ for (let hour = 0; hour < 24; hour++) await advance(HOUR * SECOND)
132
+
133
+ assert.deepStrictEqual(intervals(), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
134
+ 17, 18, 19, 20, 21, 22, 23, 0])
135
+ })
136
+
137
+ it('should keep calling a cycle it is not splitting', async () => {
138
+ const pulse = create(HOUR, 1)
139
+
140
+ await pulse.connect()
141
+
142
+ for (let cycle = 0; cycle < 3; cycle++) await advance(HOUR * SECOND)
143
+
144
+ assert.deepStrictEqual(intervals(), [0, 0, 0])
145
+ })
146
+
147
+ it('should not call while this replica owns nothing', async () => {
148
+ atom.slots.mock.mockImplementation(() => null)
149
+
150
+ const pulse = create(DAY, 24)
151
+
152
+ await pulse.connect()
153
+ await advance(HOUR * SECOND)
154
+
155
+ assert.strictEqual(local.invoke.mock.callCount(), 0)
156
+ })
157
+
158
+ it('should not call an interval it acquired after the boundary', async () => {
159
+ atom.slots.mock.mockImplementation(() => [])
160
+
161
+ const pulse = create(DAY, 24)
162
+
163
+ await pulse.connect()
164
+ await advance(HOUR * SECOND) // the boundary passes, and the interval is not this one's
165
+
166
+ atom.slots.mock.mockImplementation((total) => range(total))
167
+
168
+ await advance(30 * 60 * SECOND) // and half an hour later it is
169
+
170
+ assert.strictEqual(local.invoke.mock.callCount(), 0,
171
+ 'the replica that held it at the boundary is the only one that could have called')
172
+ })
173
+
174
+ it('should not call an interval another replica owns', async () => {
175
+ atom.slots.mock.mockImplementation((total) =>
176
+ range(total).filter((slot) => slot % 2 === 0))
177
+
178
+ const pulse = create(DAY, 24)
179
+
180
+ await pulse.connect()
181
+
182
+ for (let hour = 0; hour < 24; hour++) await advance(HOUR * SECOND)
183
+
184
+ const called = intervals()
185
+
186
+ assert.strictEqual(called.length, 12)
187
+ assert.ok(called.every((i) => i % 2 === 0))
188
+ })
189
+
190
+ it('should not start a call while the one before has not returned', async () => {
191
+ let release: () => void = () => {}
192
+
193
+ local.invoke.mock.mockImplementation(async () =>
194
+ await new Promise<void>((resolve) => { release = resolve }))
195
+
196
+ const pulse = create(DAY, 24)
197
+
198
+ await pulse.connect()
199
+
200
+ await advance(HOUR * SECOND)
201
+ await advance(HOUR * SECOND)
202
+
203
+ assert.strictEqual(local.invoke.mock.callCount(), 1)
204
+
205
+ release()
206
+ await advance(0) // the call settles, and only then is the pulse free again
207
+
208
+ await advance(HOUR * SECOND)
209
+
210
+ assert.strictEqual(local.invoke.mock.callCount(), 2)
211
+ })
212
+
213
+ it('should keep its cadence when a call raises', async () => {
214
+ local.invoke.mock.mockImplementationOnce(async () => { throw new Error('nope') })
215
+
216
+ const pulse = create(DAY, 24)
217
+
218
+ await pulse.connect()
219
+
220
+ await advance(HOUR * SECOND)
221
+ await advance(HOUR * SECOND)
222
+
223
+ assert.strictEqual(local.invoke.mock.callCount(), 2,
224
+ 'the interval that raised is skipped, and the next one is called')
225
+ })
226
+
227
+ it('should wait out a cycle longer than one timer in instalments', async () => {
228
+ const YEAR = 365 * DAY
229
+
230
+ const pulse = create(YEAR, 1)
231
+
232
+ await pulse.connect()
233
+
234
+ // more than `setTimeout` takes at once, and nothing is due yet
235
+ await advance(2 ** 31)
236
+ assert.strictEqual(local.invoke.mock.callCount(), 0)
237
+
238
+ await advance(YEAR * SECOND)
239
+ assert.strictEqual(local.invoke.mock.callCount(), 1)
240
+ })
241
+
242
+ it('should wait for a call in flight before closing', async () => {
243
+ let release: () => void = () => {}
244
+ let closed = false
245
+
246
+ local.invoke.mock.mockImplementation(async () =>
247
+ await new Promise<void>((resolve) => { release = resolve }))
248
+
249
+ const pulse = create(DAY, 24)
250
+
251
+ await pulse.connect()
252
+ await advance(HOUR * SECOND)
253
+
254
+ const closing = pulse.disconnect().then(() => { closed = true })
255
+
256
+ await advance(0)
257
+ assert.strictEqual(closed, false, 'the call is still running')
258
+
259
+ release()
260
+ await advance(0)
261
+ await closing
262
+
263
+ assert.strictEqual(closed, true)
264
+ })
265
+
266
+ it('should stop calling once closed', async () => {
267
+ const pulse = create(DAY, 24)
268
+
269
+ await pulse.connect()
270
+ await pulse.disconnect()
271
+ await advance(HOUR * SECOND)
272
+
273
+ assert.strictEqual(local.invoke.mock.callCount(), 0)
274
+ })
@@ -0,0 +1,202 @@
1
+ import { console } from 'openspan'
2
+ import { Connector } from '@toa.io/core'
3
+ import type { Local } from './Local.js'
4
+ import type { SpanOptions } from 'openspan'
5
+ import type { Locator, atomicity } from '@toa.io/core'
6
+
7
+ /**
8
+ * A recurring call to an operation of its own component, on the cadence its manifest states.
9
+ *
10
+ * Nothing is stored. The cycle is split into `n` intervals and the current one is a function of
11
+ * the clock, so every replica computes the same `i` without talking to any other; the one that
12
+ * owns `i` is the one that calls. An interval no replica was alive for is not made up
13
+ * afterwards — the operation is expected to select by what is still due rather than by where
14
+ * the cycle has reached.
15
+ */
16
+ export class Pulse extends Connector {
17
+ private readonly endpoint: string
18
+ private readonly label: string
19
+
20
+ /** milliseconds one whole cycle takes */
21
+ private readonly cycle: number
22
+
23
+ /** intervals the cycle is split into */
24
+ private readonly n: number
25
+
26
+ private readonly local: Local
27
+ private readonly atom: atomicity.Atom
28
+
29
+ /** span options, built once */
30
+ private readonly options: SpanOptions
31
+
32
+ /**
33
+ * The ordinal of the interval this process has already handled, so an early wake-up is not a
34
+ * second call. An ordinal and not an index: with one interval per cycle the index never
35
+ * changes, and comparing it would suppress every call after the first.
36
+ */
37
+ private fired = 0
38
+
39
+ private timer?: NodeJS.Timeout
40
+
41
+ /** the call in flight, awaited on close */
42
+ private firing?: Promise<unknown>
43
+
44
+ private closing = false
45
+
46
+ public constructor (definition: Definition, local: Local, atom: atomicity.Atom) {
47
+ super()
48
+
49
+ const { locator, endpoint, cycle, intervals } = definition
50
+
51
+ this.endpoint = endpoint
52
+ this.label = `${locator.id}.${endpoint}`
53
+ this.cycle = cycle * 1000
54
+ this.n = intervals
55
+
56
+ this.local = local
57
+ this.atom = atom
58
+
59
+ this.depends(local)
60
+ this.depends(atom)
61
+
62
+ this.options = {
63
+ name: `${this.label} pulse`,
64
+ kind: 'producer',
65
+ service: locator.id
66
+ }
67
+ }
68
+
69
+ /**
70
+ * How many intervals have passed since the epoch, `now` included. Against the cycle rather
71
+ * than by adding up gaps, so a cycle that does not divide evenly into its intervals neither
72
+ * drifts nor accumulates error.
73
+ *
74
+ * The arithmetic is exact rather than double: `now` is milliseconds since 1970 and `n` may be
75
+ * as large as the cycle has seconds, so their product leaves the range a double is exact in
76
+ * for a long cycle cut finely. It is evaluated once per interval, where this costs nothing.
77
+ */
78
+ public ordinal (now: number): number {
79
+ return Number(BigInt(now) * BigInt(this.n) / BigInt(this.cycle))
80
+ }
81
+
82
+ /** Which of the cycle's intervals `now` falls in. */
83
+ public index (now: number): number {
84
+ return this.ordinal(now) % this.n
85
+ }
86
+
87
+ /** When the interval `now` falls in gives way to the next one. */
88
+ public boundary (now: number): number {
89
+ const cycle = BigInt(this.cycle)
90
+ const n = BigInt(this.n)
91
+ const next = BigInt(this.ordinal(now) + 1)
92
+
93
+ // the earliest millisecond whose ordinal is the next one
94
+ return Number((next * cycle + n - 1n) / n)
95
+ }
96
+
97
+ protected override async open (): Promise<void> {
98
+ // whatever interval it is now, this replica was not there when it began, so it is not
99
+ // this one's to call — the first call is at the next boundary
100
+ this.fired = this.ordinal(Date.now())
101
+
102
+ this.arm()
103
+ }
104
+
105
+ protected override async close (): Promise<void> {
106
+ this.closing = true
107
+
108
+ clearTimeout(this.timer)
109
+
110
+ // a call still running holds the remote it was made through, which is torn down after
111
+ // this returns. Bounded, because an operation that never returns would outlive any
112
+ // grace period and the interval is over either way
113
+ if (this.firing !== undefined)
114
+ await Promise.race([this.firing, delay(DRAIN)])
115
+ }
116
+
117
+ private arm (): void {
118
+ const now = Date.now()
119
+
120
+ // a delay past this fires at once rather than late, so a long cycle is waited out in
121
+ // instalments; the wake-up finds the same interval and simply arms again
122
+ const wait = Math.min(this.boundary(now) - now, MAX_DELAY)
123
+
124
+ this.timer = setTimeout(() => this.tick(), wait)
125
+ this.timer.unref()
126
+ }
127
+
128
+ private tick (): void {
129
+ if (this.closing) return
130
+
131
+ const ordinal = this.ordinal(Date.now())
132
+
133
+ // a timer may wake a millisecond before the boundary it was set for, and a cycle longer
134
+ // than one timer wakes several times on the way — both are the interval already handled
135
+ if (ordinal !== this.fired) {
136
+ this.fired = ordinal
137
+
138
+ void this.fire(ordinal % this.n)
139
+ }
140
+
141
+ this.arm()
142
+ }
143
+
144
+ /**
145
+ * Nothing queues here. A call still running when the next boundary arrives means the work
146
+ * does not fit the gap, and starting a second one would only make that worse.
147
+ */
148
+ private async fire (i: number): Promise<void> {
149
+ if (this.firing !== undefined) {
150
+ console.warn('Pulse skipped: the previous call has not returned',
151
+ { pulse: this.label, interval: i })
152
+
153
+ return
154
+ }
155
+
156
+ const owned = this.atom.slots(this.n)
157
+
158
+ if (owned === null) {
159
+ console.warn('Pulse skipped: this replica owns nothing',
160
+ { pulse: this.label, interval: i })
161
+
162
+ return
163
+ }
164
+
165
+ if (!owned.includes(i)) return
166
+
167
+ /*
168
+ * A pulse calls and waits, so an operation that raised comes back as an exception here —
169
+ * the runtime carries it across and rethrows it in this process. What to do about one is a
170
+ * choice with no right answer, and asking would put the whole of it in front of anyone who
171
+ * only wanted an operation called every hour. So the interval is skipped: it is reported,
172
+ * and the next one is called as though it had not happened.
173
+ */
174
+ const firing = console.span(this.options,
175
+ async () => await this.local.invoke(this.endpoint, { input: { n: this.n, i } }))
176
+ .catch((error: unknown) => {
177
+ console.error('Pulse failed', { pulse: this.label, interval: i, error })
178
+ })
179
+ .finally(() => { this.firing = undefined })
180
+
181
+ this.firing = firing
182
+
183
+ await firing
184
+ }
185
+ }
186
+
187
+ export interface Definition {
188
+ locator: Locator
189
+ endpoint: string
190
+ cycle: number
191
+ intervals: number
192
+ }
193
+
194
+ async function delay (ms: number): Promise<void> {
195
+ await new Promise((resolve) => setTimeout(resolve, ms).unref())
196
+ }
197
+
198
+ /** `setTimeout` fires immediately past this, so a longer wait is taken in instalments */
199
+ const MAX_DELAY = 2 ** 31 - 1
200
+
201
+ /** how long a call in flight is waited for on close */
202
+ const DRAIN = 10_000
@@ -0,0 +1,23 @@
1
+ /** The component the extension ships to hold delayed calls. */
2
+ export const NAMESPACE = 'cadence'
3
+ export const COMPONENT = 'metronome'
4
+
5
+ /**
6
+ * Constant, never configuration: a row carries its lane, so lowering this would leave rows in
7
+ * lanes nobody reads any more. It is also the ceiling on replicas dispatching, and a power of
8
+ * two so that the common replica counts divide evenly. The outbox says the same of its own.
9
+ */
10
+ export const LANES = 128
11
+
12
+ /** seconds between passes over the calls waiting to be made, and how far ahead each reaches */
13
+ export const DISCRETENESS = 60
14
+
15
+ /** rows one scan brings back */
16
+ export const BATCH = 200
17
+
18
+ /** A number from the environment, where a deployment or a test suite states one. */
19
+ export function number (variable: string, fallback: number): number {
20
+ const value = Number(process.env[variable])
21
+
22
+ return Number.isNaN(value) || value <= 0 ? fallback : value
23
+ }
@@ -0,0 +1,10 @@
1
+ import type { Contribution } from '@toa.io/core'
2
+
3
+ /** What this extension puts on the context of a component that declares it. */
4
+ export function context (): Contribution {
5
+ return {
6
+ name: 'delay',
7
+ type: 'Delay',
8
+ imports: { '@toa.io/extensions.cadence': ['Delay'] }
9
+ }
10
+ }
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { components } from './Composition.js'
3
+ import { DISCRETENESS, NAMESPACE } from './const.js'
4
+ import * as schemas from './schemas.js'
5
+ import type { Annotation } from './types.js'
6
+ import type { Dependency, Service, Variable } from '@toa.io/operations'
7
+
8
+ /**
9
+ * The component that keeps delayed calls runs in a service of its own, the way the identity
10
+ * components run in the gateway. A composition may claim it and run it in its own pods instead.
11
+ */
12
+ export function deployment (_: unknown, annotation?: Annotation | null): Dependency {
13
+ schemas.annotation.validate<Annotation | null>(annotation ?? null, 'Invalid cadence annotation')
14
+
15
+ // stated in seconds and carried in milliseconds, so that what an application writes is what
16
+ // the rest of a manifest is written in
17
+ const discreteness = (annotation?.discreteness ?? DISCRETENESS) * 1000
18
+ const variables: Variable[] =
19
+ [{ name: 'TOA_CADENCE_DISCRETENESS', value: String(discreteness) }]
20
+
21
+ const service: Service = {
22
+ group: NAMESPACE,
23
+ name: 'metronome',
24
+ version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version,
25
+ components: components().labels,
26
+ variables
27
+ }
28
+
29
+ return { services: [service] }
30
+ }
31
+
32
+ /** The extension is deployed for the sake of `delay`, which nothing has to declare to use. */
33
+ export const standalone = true
@@ -0,0 +1,8 @@
1
+ export { Factory } from './Factory.js'
2
+ export { manifest } from './manifest.js'
3
+ export { components } from './Composition.js'
4
+ export { deployment, standalone } from './deployment.js'
5
+ export { context } from './context.js'
6
+ export { LANES } from './const.js'
7
+
8
+ export type { Declaration, Delay, Pulse } from './types.js'
@@ -0,0 +1,58 @@
1
+ import { it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { manifest } from './manifest.js'
5
+ import type { Manifest } from '@toa.io/norm'
6
+
7
+ const component = {
8
+ operations: {
9
+ sweep: { type: 'effect' },
10
+ gather: { type: 'transition' },
11
+ peek: { type: 'observation' }
12
+ }
13
+ } as unknown as Manifest
14
+
15
+ it('should normalize a declaration', () => {
16
+ const declaration = manifest({ sweep: { cycle: 86400, intervals: 24 } }, component)
17
+
18
+ assert.deepStrictEqual(declaration, { sweep: { cycle: 86400, intervals: 24 } })
19
+ })
20
+
21
+ it('should split a cycle into one interval by default', () => {
22
+ const declaration = manifest({ sweep: { cycle: 3600 } } as any, component)
23
+
24
+ assert.deepStrictEqual(declaration, { sweep: { cycle: 3600, intervals: 1 } })
25
+ })
26
+
27
+ it('should expand the shorthand', () => {
28
+ const declaration = manifest({ sweep: 3600 } as any, component)
29
+
30
+ assert.deepStrictEqual(declaration, { sweep: { cycle: 3600, intervals: 1 } })
31
+ })
32
+
33
+ it('should answer a component that only delays calls', () => {
34
+ assert.deepStrictEqual(manifest(null, component), {})
35
+ })
36
+
37
+ it('should refuse an undefined operation', () => {
38
+ assert.throws(() => manifest({ nothing: 3600 } as any, component),
39
+ /refers to undefined operation 'nothing'/)
40
+ })
41
+
42
+ it('should refuse an operation that produces no side effects', () => {
43
+ assert.throws(() => manifest({ peek: 3600 } as any, component),
44
+ /of the allowed types/)
45
+ })
46
+
47
+ it('should refuse an interval shorter than a second', () => {
48
+ assert.throws(() => manifest({ sweep: { cycle: 60, intervals: 61 } }, component),
49
+ /less than a second/)
50
+ })
51
+
52
+ it('should refuse a cycle of no time at all', () => {
53
+ assert.throws(() => manifest({ sweep: { cycle: 0, intervals: 1 } }, component))
54
+ })
55
+
56
+ it('should refuse what the declaration does not describe', () => {
57
+ assert.throws(() => manifest({ sweep: { cycle: 60, every: 3 } } as any, component))
58
+ })
@@ -0,0 +1,51 @@
1
+ import * as schemas from './schemas.js'
2
+ import type { Declaration } from './types.js'
3
+ import type { Manifest } from '@toa.io/norm'
4
+
5
+ /**
6
+ * The `cadence:` block of a component manifest: the operations called on a cadence, by the
7
+ * operation each one calls. Norm hands over the whole manifest, so what the declaration refers
8
+ * to is checked here rather than left to fail at boot.
9
+ *
10
+ * A component that declares nothing still has a declaration. `cadence: ~` is how one that only
11
+ * delays calls says so, and it has to produce a value or norm rejects the extension.
12
+ */
13
+ export function manifest (declaration: Declaration | null | undefined,
14
+ component: Manifest): Declaration {
15
+ const normalized: Record<string, unknown> = expand(declaration)
16
+
17
+ // `intervals` is defaulted by the schema, so the declaration is only whole after this
18
+ schemas.declaration.validate<Declaration>(normalized, 'Invalid cadence declaration')
19
+
20
+ for (const [endpoint, pulse] of Object.entries(normalized)) {
21
+ const operation = component.operations?.[endpoint]
22
+
23
+ if (operation === undefined)
24
+ throw new Error(`Pulse refers to undefined operation '${endpoint}'`)
25
+
26
+ if (!TYPES.has(operation.type))
27
+ throw new Error(`Pulse '${endpoint}' must refer to an operation of the allowed types: ` +
28
+ [...TYPES].join(', '))
29
+
30
+ if (pulse.intervals > pulse.cycle)
31
+ throw new Error(`Pulse '${endpoint}' splits a cycle of ${pulse.cycle} seconds into ` +
32
+ `${pulse.intervals} intervals, which is less than a second each`)
33
+ }
34
+
35
+ return normalized
36
+ }
37
+
38
+ /** A pulse whose cycle is not split has nothing to state but the cycle. */
39
+ function expand (declaration: Declaration | null | undefined): Record<string, unknown> {
40
+ if (declaration === null || declaration === undefined) return {}
41
+
42
+ return Object.fromEntries(Object.entries(declaration)
43
+ .map(([endpoint, pulse]) =>
44
+ [endpoint, typeof pulse === 'number' ? { cycle: pulse } : pulse]))
45
+ }
46
+
47
+ /**
48
+ * The types a receiver may refer to. An operation that produces no side effects has nothing to
49
+ * be called periodically for.
50
+ */
51
+ const TYPES = new Set(['transition', 'assignment', 'effect', 'unmanaged'])
@@ -0,0 +1,10 @@
1
+ import { resolve } from 'node:path'
2
+ import * as schemas from '@toa.io/schemas'
3
+ import type { Schema } from '@toa.io/schemas'
4
+ import type { Annotation, Declaration } from './types.js'
5
+
6
+ const path = resolve(import.meta.dirname, '../schemas')
7
+ const namespace = schemas.namespace(path)
8
+
9
+ export const declaration: Schema<Declaration> = namespace.schema('declaration')
10
+ export const annotation: Schema<Annotation> = namespace.schema('annotation')