@toa.io/extensions.configuration 1.0.0-alpha.29 → 1.0.0-alpha.291

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 (140) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/components/configuration.values/manifest.toa.yaml +151 -0
  3. package/components/configuration.values/migrations/0002-indexes.yaml +7 -0
  4. package/components/configuration.values/operations/create.js +30 -0
  5. package/components/configuration.values/operations/fetch.js +13 -0
  6. package/components/configuration.values/operations/get.js +19 -0
  7. package/components/configuration.values/operations/lib/map.js +22 -0
  8. package/components/configuration.values/operations/lib/resolve.js +24 -0
  9. package/components/configuration.values/operations/list.js +11 -0
  10. package/components/configuration.values/source/create.ts +54 -0
  11. package/components/configuration.values/source/fetch.ts +26 -0
  12. package/components/configuration.values/source/get.ts +32 -0
  13. package/components/configuration.values/source/lib/map.test.ts +42 -0
  14. package/components/configuration.values/source/lib/map.ts +36 -0
  15. package/components/configuration.values/source/lib/resolve.ts +57 -0
  16. package/components/configuration.values/source/list.ts +21 -0
  17. package/components/configuration.values/tsconfig.json +10 -0
  18. package/components/configuration.values/tsconfig.tsbuildinfo +1 -0
  19. package/components/configuration.values/types/index.d.ts +3 -0
  20. package/components/configuration.values/types/toa.d.ts +68 -0
  21. package/cucumber.mjs +8 -0
  22. package/features/site/_app/immutable/asset.js +1 -0
  23. package/features/site/favicon.ico +0 -0
  24. package/features/site/index.html +10 -0
  25. package/features/steps/UI.ts +98 -0
  26. package/features/steps/tsconfig.json +9 -0
  27. package/features/ui.feature +93 -0
  28. package/notes/consistency.md +10 -9
  29. package/package.json +11 -10
  30. package/readme.md +165 -51
  31. package/schemas/annotation.cos.yaml +29 -1
  32. package/schemas/manifest.cos.yaml +9 -2
  33. package/source/Aspect.test.ts +114 -10
  34. package/source/Aspect.ts +76 -8
  35. package/source/Client.test.ts +125 -0
  36. package/source/Client.ts +251 -0
  37. package/source/Composition.ts +57 -0
  38. package/source/Factory.ts +29 -7
  39. package/source/Secret.test.ts +32 -0
  40. package/source/Secret.ts +32 -0
  41. package/source/UI.ts +213 -0
  42. package/source/configuration.test.ts +105 -43
  43. package/source/configuration.ts +36 -21
  44. package/source/const.ts +30 -0
  45. package/source/context.ts +13 -0
  46. package/source/deployment.test.ts +103 -12
  47. package/source/deployment.ts +105 -31
  48. package/source/epoch.test.ts +35 -0
  49. package/source/epoch.ts +22 -0
  50. package/source/index.ts +10 -3
  51. package/source/manifest.test.ts +16 -7
  52. package/source/manifest.ts +3 -5
  53. package/source/schemas.ts +1 -1
  54. package/transpiled/Aspect.d.ts +19 -4
  55. package/transpiled/Aspect.js +62 -9
  56. package/transpiled/Aspect.js.map +1 -1
  57. package/transpiled/Client.d.ts +61 -0
  58. package/transpiled/Client.js +175 -0
  59. package/transpiled/Client.js.map +1 -0
  60. package/transpiled/Composition.d.ts +14 -0
  61. package/transpiled/Composition.js +38 -0
  62. package/transpiled/Composition.js.map +1 -0
  63. package/transpiled/Factory.d.ts +9 -2
  64. package/transpiled/Factory.js +20 -9
  65. package/transpiled/Factory.js.map +1 -1
  66. package/transpiled/Secret.d.ts +15 -0
  67. package/transpiled/Secret.js +26 -0
  68. package/transpiled/Secret.js.map +1 -0
  69. package/transpiled/UI.d.ts +25 -0
  70. package/transpiled/UI.js +173 -0
  71. package/transpiled/UI.js.map +1 -0
  72. package/transpiled/configuration.d.ts +7 -2
  73. package/transpiled/configuration.js +32 -43
  74. package/transpiled/configuration.js.map +1 -1
  75. package/transpiled/const.d.ts +18 -0
  76. package/transpiled/const.js +22 -0
  77. package/transpiled/const.js.map +1 -0
  78. package/transpiled/context.d.ts +4 -0
  79. package/transpiled/context.js +9 -0
  80. package/transpiled/context.js.map +1 -0
  81. package/transpiled/deployment.d.ts +12 -3
  82. package/transpiled/deployment.js +76 -57
  83. package/transpiled/deployment.js.map +1 -1
  84. package/transpiled/epoch.d.ts +2 -0
  85. package/transpiled/epoch.js +19 -0
  86. package/transpiled/epoch.js.map +1 -0
  87. package/transpiled/index.d.ts +6 -3
  88. package/transpiled/index.js +8 -9
  89. package/transpiled/index.js.map +1 -1
  90. package/transpiled/manifest.d.ts +1 -1
  91. package/transpiled/manifest.js +2 -31
  92. package/transpiled/manifest.js.map +1 -1
  93. package/transpiled/schemas.js +5 -31
  94. package/transpiled/schemas.js.map +1 -1
  95. package/tsconfig.json +5 -1
  96. package/tsconfig.tsbuildinfo +1 -0
  97. package/ui/dist/_app/env.js +1 -0
  98. package/ui/dist/_app/immutable/assets/0.CNS8Cqhc.css +2 -0
  99. package/ui/dist/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  100. package/ui/dist/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  101. package/ui/dist/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  102. package/ui/dist/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  103. package/ui/dist/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  104. package/ui/dist/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  105. package/ui/dist/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  106. package/ui/dist/_app/immutable/assets/ui.BghF56l_.css +1 -0
  107. package/ui/dist/_app/immutable/chunks/3Kbn_1Vv.js +1 -0
  108. package/ui/dist/_app/immutable/chunks/BV5cDXGB.js +50 -0
  109. package/ui/dist/_app/immutable/chunks/B_CH3nFk.js +1 -0
  110. package/ui/dist/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  111. package/ui/dist/_app/immutable/chunks/CNZAv2Es.js +8 -0
  112. package/ui/dist/_app/immutable/chunks/CcD_ISxM.js +3 -0
  113. package/ui/dist/_app/immutable/chunks/vl8APhvf.js +6 -0
  114. package/ui/dist/_app/immutable/chunks/xihTtKlq.js +1 -0
  115. package/ui/dist/_app/immutable/entry/app.CK-db-gz.js +2 -0
  116. package/ui/dist/_app/immutable/entry/start.Bs7xr9Ne.js +1 -0
  117. package/ui/dist/_app/immutable/nodes/0.vPwVABRs.js +1 -0
  118. package/ui/dist/_app/immutable/nodes/1.DS63PME5.js +1 -0
  119. package/ui/dist/_app/immutable/nodes/2.KRpWYBfz.js +1 -0
  120. package/ui/dist/_app/immutable/nodes/3.DCxORePS.js +1 -0
  121. package/ui/dist/_app/immutable/nodes/4.D8lluH3u.js +1 -0
  122. package/ui/dist/_app/version.json +1 -0
  123. package/ui/dist/apple-touch-icon.png +0 -0
  124. package/ui/dist/favicon-96x96.png +0 -0
  125. package/ui/dist/favicon.ico +0 -0
  126. package/ui/dist/index.html +57 -0
  127. package/ui/dist/robots.txt +3 -0
  128. package/transpiled/Aspect.test.d.ts +0 -1
  129. package/transpiled/Aspect.test.js +0 -15
  130. package/transpiled/Aspect.test.js.map +0 -1
  131. package/transpiled/configuration.test.d.ts +0 -1
  132. package/transpiled/configuration.test.js +0 -64
  133. package/transpiled/configuration.test.js.map +0 -1
  134. package/transpiled/deployment.test.d.ts +0 -1
  135. package/transpiled/deployment.test.js +0 -18
  136. package/transpiled/deployment.test.js.map +0 -1
  137. package/transpiled/manifest.test.d.ts +0 -1
  138. package/transpiled/manifest.test.js +0 -14
  139. package/transpiled/manifest.test.js.map +0 -1
  140. package/transpiled/tsconfig.tsbuildinfo +0 -1
package/readme.md CHANGED
@@ -11,8 +11,12 @@ namespace: dummies
11
11
 
12
12
  configuration:
13
13
  schema:
14
- foo: string
15
- bar: number
14
+ type: object
15
+ properties:
16
+ foo:
17
+ type: string
18
+ bar:
19
+ type: number
16
20
  defaults:
17
21
  foo: bar
18
22
  bar: 1
@@ -21,7 +25,7 @@ configuration:
21
25
  ### Use
22
26
 
23
27
  ```javascript
24
- function transition (input, entity, context) {
28
+ function transition(input, entity, context) {
25
29
  const { foo, bar } = context.configuration
26
30
 
27
31
  // ...
@@ -34,9 +38,9 @@ function transition (input, entity, context) {
34
38
  # context.toa.yaml
35
39
  configuration:
36
40
  dummies.dummy:
37
- foo: qux # override default value
41
+ foo: qux # override default value
38
42
  foo@staging: quux # deployment environment discriminator
39
- bar: $BAZ_VALUE # secret
43
+ bar: $BAZ_VALUE # secret
40
44
  ```
41
45
 
42
46
  ### Deploy secrets
@@ -45,22 +49,28 @@ configuration:
45
49
  $ toa conceal configuration BAZ_VALUE=$ecr3t
46
50
  ```
47
51
 
48
- ---
52
+ ### Change at runtime
49
53
 
50
- ## Problem Definition
54
+ ```http
55
+ POST /configuration/values/dummies.dummy/ HTTP/1.1
56
+ authorization: Token ...
57
+ content-type: application/yaml
51
58
 
52
- - Components should be runnable in different deployment environments.
53
- - Some algorithm's parameters should be deployed secretly.
54
- - Components should be reusable in different contexts.
59
+ configuration:
60
+ foo: quux
61
+ bar: 2
62
+ ```
63
+
64
+ ---
55
65
 
56
66
  ## Manifest
57
67
 
58
- Component's configuration is declared using `configuration` manifest,
59
- containing `schema` and optionnaly `defaults` properties.
68
+ Component's configuration is declared using the `configuration` manifest, containing `schema`
69
+ and optionally `defaults` properties.
60
70
 
61
71
  ### Schema
62
72
 
63
- Configuration schema is declared with [COS](/libraries/concise).
73
+ Configuration schema is declared with [JSON Schema](https://json-schema.org).
64
74
 
65
75
  ```yaml
66
76
  # manifest.toa.yaml
@@ -69,30 +79,18 @@ namespace: dummies
69
79
 
70
80
  configuration:
71
81
  schema:
72
- foo: string
73
- bar: number
74
- ```
75
-
76
- > Introducing non-backward compatible changes to a configuration schema will result in a loss of
77
- > compatibility with existing contexts and deployment environments.
78
- > Therefore, configuration schema changes are subject to component versioning.
79
-
80
- If `configuration` object doesn't contain property `schema`, then it is considered to be schema.
81
-
82
- ```yaml
83
- # manifest.toa.yaml
84
- name: dummy
85
- namespace: dummies
86
-
87
- configuration:
88
- foo: string
89
- bar: number
82
+ type: object
83
+ properties:
84
+ foo:
85
+ type: string
86
+ bar:
87
+ type: number
90
88
  ```
91
89
 
92
90
  ### Defaults
93
91
 
94
- The default configuration value can be provided using the `defaults` property, which should conform
95
- to the configuration schema.
92
+ The default configuration value can be provided using the `defaults` property, which should
93
+ conform to the configuration schema.
96
94
 
97
95
  ```yaml
98
96
  # manifest.toa.yaml
@@ -101,27 +99,22 @@ namespace: dummies
101
99
 
102
100
  configuration:
103
101
  schema:
104
- foo: string
105
- bar: number
102
+ type: object
103
+ properties:
104
+ foo:
105
+ type: string
106
+ bar:
107
+ type: number
106
108
  defaults:
107
109
  foo: hello
108
110
  bar: 0
109
111
  ```
110
112
 
111
- #### Schema defaults hint
113
+ ### Epoch
112
114
 
113
- The configuration schema itself can contain default primitive values using the COS syntax.
114
-
115
- ```yaml
116
- # manifest.toa.yaml
117
- name: dummy
118
- namespace: dummies
119
-
120
- configuration:
121
- schema:
122
- foo: hello
123
- bar: 0
124
- ```
115
+ The configuration epoch of a component is the SHA-256 of its configuration schema, as
116
+ canonical JSON. A configuration object belongs to the epoch of the schema it was validated
117
+ against. A schema change is a new epoch.
125
118
 
126
119
  ## Annotation
127
120
 
@@ -136,9 +129,26 @@ configuration:
136
129
  bar@staging: 2
137
130
  ```
138
131
 
132
+ The annotated object is deployed as the defaults of the component for its epoch, in place of
133
+ the manifest `defaults`.
134
+
135
+ Every key names a component except `resources`, which is the values service's own: it deploys
136
+ like any other and states what it may take. A component actually named `resources` is written
137
+ with its namespace, `default.resources` — the bare form is only shorthand for that.
138
+
139
+ ```yaml
140
+ # context.toa.yaml
141
+ configuration:
142
+ resources:
143
+ cpu: [200m, 1000m]
144
+ memory: [200Mi, 500Mi]
145
+ dummies.dummy:
146
+ foo: bye
147
+ ```
148
+
139
149
  ## Secrets
140
150
 
141
- Configuration annotation top-level values which are uppercase strings prefixed with `$` considered as secrets.
151
+ Configuration values which are uppercase strings prefixed with `$` are considered as secrets.
142
152
 
143
153
  ```yaml
144
154
  # context.toa.yaml
@@ -158,14 +168,118 @@ Deployed kubernetes secret's name is predefined as `configuration`.
158
168
  $ toa conceal configuration STRIPE_API_KEY=xxxxxxxx
159
169
  ```
160
170
 
171
+ A secret is substituted in the component's process, from the variable
172
+ `TOA_CONFIGURATION__<NAME>` deployed to it — a variable the component's code cannot read.
173
+ The values service holds and returns the reference.
174
+
175
+ In the component, a secret is a `Secret` object: `unwrap()` returns the string, while
176
+ `toString()`, JSON and `util.inspect` give `<REDACTED>`.
177
+
178
+ ```javascript
179
+ function transition(input, entity, context) {
180
+ const key = context.configuration.apiKey.unwrap()
181
+
182
+ // ...
183
+ }
184
+ ```
185
+
186
+ A value a component reads as a secret is given as a reference: a plain string in its place
187
+ has no `unwrap`.
188
+
189
+ ## Values
190
+
191
+ Configuration values are held by the `configuration.values` component, deployed as the
192
+ `configuration-values` service. The service is deployed with the variable
193
+ `TOA_CONFIGURATION_VALUES`: the epoch, the schema and the defaults of every component
194
+ declaring configuration.
195
+
196
+ ```json
197
+ {
198
+ "dummies.dummy": {
199
+ "epoch": "3f2a…",
200
+ "schema": { "type": "object", "properties": { "foo": { "type": "string" } } },
201
+ "defaults": { "foo": "bye" }
202
+ }
203
+ }
204
+ ```
205
+
206
+ Configuration objects are immutable. Creating a configuration is creating a new object for
207
+ the component's current epoch; the latest object for a component and an epoch is the last
208
+ created one. Each object records its `originator`.
209
+
210
+ The configuration of a component for an epoch is:
211
+
212
+ 1. The latest object created for the component and the epoch;
213
+ 2. Otherwise, the deployed defaults, if the epoch is the deployed one;
214
+ 3. Otherwise, none.
215
+
216
+ ### Operations
217
+
218
+ - `get({ component, epoch? })`: the configuration with the schema it is checked against, as
219
+ `{ configuration, schema, epoch }`, or `null` when there is none. The epoch is the deployed
220
+ one when omitted; an epoch the deployment does not know has no `schema`.
221
+ - `fetch([{ component, epoch }])`: the same for several pairs at once, as
222
+ `[{ component, epoch, configuration }]`.
223
+ - `list()`: every component's configuration for its deployed epoch, by component name, as
224
+ `[{ component, epoch, schema, configuration }]`.
225
+ - `create({ component, configuration, originator })`: a new object for the component's
226
+ deployed epoch. The configuration must satisfy the schema. Errors: `UNKNOWN_COMPONENT`,
227
+ `INVALID_CONFIGURATION`.
228
+
229
+ Creating a configuration publishes the `configuration.values.created` event with the
230
+ object as stored.
231
+
232
+ ### Resources
233
+
234
+ | Method | Path | Role |
235
+ | ------ | ----------------------------------- | ----------------------------- |
236
+ | `GET` | `/configuration/values/` | `system:configuration:get` |
237
+ | `GET` | `/configuration/values/:component/` | `system:configuration:get` |
238
+ | `POST` | `/configuration/values/:component/` | `system:configuration:create` |
239
+
240
+ `GET /configuration/values/` lists every component's configuration for its deployed epoch, by
241
+ component name, as `[{ component, epoch, schema, configuration }]`.
242
+
243
+ `GET /configuration/values/:component/` returns `{ configuration, schema, epoch }` for the
244
+ deployed epoch, `404` when there is none.
245
+
246
+ `POST` takes `{ configuration }`, records the Identity as the `originator`, and returns
247
+ `{ id, epoch }`. A configuration not satisfying the schema, or an unknown component, is
248
+ `422`.
249
+
250
+ ## UI
251
+
252
+ The values service serves a page listing the configured components and creating
253
+ configurations, mounted at `/.configuration` on port `8003`. Reading it needs the
254
+ `system:configuration:get` role, creating needs `system:configuration:create`.
255
+
256
+ The page is always published: unlike the introspection annotation, the configuration
257
+ annotation is the per-component values map and has nowhere to carry a switch.
258
+
161
259
  ## Aspect
162
260
 
163
261
  Component's configuration values are available as a well-known Aspect `configuration`.
164
262
 
165
263
  ```javascript
166
- function transition (input, entity, context) {
167
- const foo = context.configiuration.foo
264
+ function transition(input, entity, context) {
265
+ const foo = context.configuration.foo
168
266
 
169
267
  // ...
170
268
  }
171
269
  ```
270
+
271
+ On start, a component requests its configuration for its epoch from the values service and
272
+ waits until there is one, reporting every fifth attempt. The schema is applied, and secrets
273
+ are substituted. What is served is what was stored, whole: a `default` written into the schema
274
+ fills nothing, so a value every component is to have is declared in `defaults`. After a configuration is created, the running component receives the new
275
+ object and takes it when its `CREATED` is later than that of the value it holds.
276
+
277
+ ### Local override
278
+
279
+ When the variable `TOA_CONFIGURATION_<NAMESPACE>_<NAME>` is set, the component's
280
+ configuration is the variable's value with the manifest `defaults` filled in and the schema
281
+ applied, and the values service is not used.
282
+
283
+ ```shell
284
+ $ TOA_CONFIGURATION_DUMMIES_DUMMY='{"foo":"local"}' toa run components/dummy
285
+ ```
@@ -1 +1,29 @@
1
- <object>
1
+ # context.toa.yaml configuration annotation
2
+ #
3
+ # Every key names a component and holds that component's values, except `resources`, which
4
+ # is the values service's own: it deploys like any other and has to state what it may take.
5
+ # A component actually named `resources` is written with its namespace, `default.resources`
6
+ # — the bare form is only shorthand for that.
7
+ properties:
8
+ resources:
9
+ # `null` deploys the service without any, which every deployment has to state one way
10
+ # or the other — see `migrations/262.md`.
11
+ type: object
12
+ nullable: true
13
+ properties:
14
+ cpu:
15
+ type: array
16
+ items:
17
+ type: string
18
+ minItems: 2
19
+ maxItems: 2
20
+ memory:
21
+ type: array
22
+ items:
23
+ type: string
24
+ minItems: 2
25
+ maxItems: 2
26
+ patternProperties:
27
+ ^(?!resources$).+$:
28
+ type: object
29
+ type: object
@@ -1,2 +1,9 @@
1
- schema: object
2
- defaults?: object
1
+ properties:
2
+ schema:
3
+ type: object
4
+ defaults:
5
+ type: object
6
+ additionalProperties: false
7
+ type: object
8
+ required:
9
+ - schema
@@ -1,15 +1,119 @@
1
- import { Aspect } from './Aspect'
1
+ import { it, beforeEach, afterEach, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { isDeepStrictEqual } from 'node:util'
2
4
 
3
- it('should return value', async () => {
4
- const configuration = {
5
- foo: 'bar',
6
- bar: {
7
- baz: 'quux'
5
+ import { Connector, Locator } from '@toa.io/core'
6
+ import { generate } from 'randomstring'
7
+ import { Aspect } from './Aspect.js'
8
+ import type { Client, Listener } from './Client.js'
9
+ import type { Manifest } from './manifest.js'
10
+
11
+ class Fake extends Connector {
12
+ public readonly fetch = mock.fn(async () => ({
13
+ configuration: { foo: 'served' },
14
+ created: 5
15
+ }))
16
+ public readonly subscribe = mock.fn()
17
+ public readonly unsubscribe = mock.fn()
18
+ }
19
+
20
+ const manifest: Manifest = {
21
+ schema: {
22
+ type: 'object',
23
+ properties: {
24
+ foo: { type: 'string' },
25
+ bar: { type: 'object', properties: { baz: { type: 'string' } } }
8
26
  }
9
- }
27
+ },
28
+ defaults: { bar: { baz: 'quux' } }
29
+ }
30
+
31
+ let locator: Locator
32
+
33
+ beforeEach(() => {
34
+ locator = new Locator(generate(), generate())
35
+ })
36
+
37
+ afterEach(() => {
38
+ delete process.env['TOA_CONFIGURATION_' + locator.uppercase]
39
+ })
40
+
41
+ it('should be named', async () => {
42
+ assert.deepStrictEqual(new Aspect(locator, manifest, null).name, 'configuration')
43
+ })
44
+
45
+ it('should resolve locally without a client', async () => {
46
+ process.env['TOA_CONFIGURATION_' + locator.uppercase] = JSON.stringify({ foo: 'local' })
47
+
48
+ const aspect = new Aspect(locator, manifest, null)
49
+
50
+ await aspect.connect()
51
+
52
+ assert.deepStrictEqual(aspect.invoke(), { foo: 'local', bar: { baz: 'quux' } })
53
+ assert.deepStrictEqual(aspect.invoke(['foo']), 'local')
54
+ assert.deepStrictEqual(aspect.invoke(['bar', 'baz']), 'quux')
55
+ })
56
+
57
+ it('should fetch from the client and follow it', async () => {
58
+ const client = new Fake()
59
+ const aspect = new Aspect(locator, manifest, client as unknown as Client)
60
+
61
+ await aspect.connect()
62
+
63
+ assert.deepStrictEqual(client.connected, true)
64
+ assert.strictEqual(client.fetch.mock.callCount(), 1)
65
+
66
+ const [component, epoch] = client.fetch.mock.calls[0].arguments as unknown as [
67
+ string,
68
+ string
69
+ ]
70
+
71
+ assert.deepStrictEqual(component, locator.id)
72
+ assert.match(epoch, /^[a-f0-9]{64}$/)
73
+ // what the service serves is what was stored, and the manifest defaults are not applied to it
74
+ assert.deepStrictEqual(aspect.invoke(), { foo: 'served' })
75
+
76
+ assert.ok(
77
+ client.subscribe.mock.calls.some(
78
+ (call: any) =>
79
+ call.arguments.length === 3 &&
80
+ isDeepStrictEqual(call.arguments[0], component) &&
81
+ isDeepStrictEqual(call.arguments[1], epoch) &&
82
+ typeof call.arguments[2] === 'function'
83
+ )
84
+ )
85
+
86
+ const listener = client.subscribe.mock.calls[0].arguments[2] as Listener
87
+
88
+ listener({ configuration: { foo: 'updated' }, created: 6 })
89
+
90
+ assert.deepStrictEqual(aspect.invoke(['foo']), 'updated')
91
+
92
+ // what is not newer than the held value is left alone
93
+ listener({ configuration: { foo: 'stale' }, created: 6 })
94
+ listener({ configuration: { foo: 'older' }, created: 4 })
95
+
96
+ assert.deepStrictEqual(aspect.invoke(['foo']), 'updated')
97
+
98
+ // a value that does not fit keeps the previous one
99
+ listener({ configuration: { foo: { nested: true } }, created: 7 })
100
+
101
+ assert.deepStrictEqual(aspect.invoke(['foo']), 'updated')
102
+
103
+ // and the one after it still applies
104
+ listener({ configuration: { foo: 'latest' }, created: 8 })
105
+
106
+ assert.deepStrictEqual(aspect.invoke(['foo']), 'latest')
10
107
 
11
- const aspect = new Aspect(configuration)
108
+ await aspect.disconnect()
12
109
 
13
- expect(aspect.invoke(['foo'])).toStrictEqual(configuration.foo)
14
- expect(aspect.invoke(['bar', 'baz'])).toStrictEqual(configuration.bar.baz)
110
+ assert.ok(
111
+ client.unsubscribe.mock.calls.some(
112
+ (call: any) =>
113
+ call.arguments.length === 3 &&
114
+ isDeepStrictEqual(call.arguments[0], component) &&
115
+ isDeepStrictEqual(call.arguments[1], epoch) &&
116
+ isDeepStrictEqual(call.arguments[2], listener)
117
+ )
118
+ )
15
119
  })
package/source/Aspect.ts CHANGED
@@ -1,23 +1,91 @@
1
- import { Connector, type extensions } from '@toa.io/core'
1
+ import { console } from 'openspan'
2
+ import { Connector, type Locator } from '@toa.io/core'
3
+ import type { extensions } from '@toa.io/core/types'
4
+ import { fit, local, type Node } from './configuration.js'
5
+ import { epoch } from './epoch.js'
6
+ import type { Client, Value } from './Client.js'
7
+ import type { Manifest } from './manifest.js'
2
8
 
3
9
  export class Aspect extends Connector implements extensions.Aspect {
4
10
  public readonly name = 'configuration'
5
11
 
6
- private readonly value: object
12
+ private readonly locator: Locator
13
+ private readonly manifest: Manifest
14
+ private readonly client: Client | null
15
+ private readonly epoch: string
16
+ private value: Node = {}
17
+ private created = 0
7
18
 
8
- public constructor (value: object) {
19
+ /**
20
+ * Without a client the value is local: the variable, the defaults and the schema.
21
+ * With one, the value is what the service holds, and it follows the service.
22
+ */
23
+ public constructor(locator: Locator, manifest: Manifest, client: Client | null) {
9
24
  super()
10
25
 
11
- this.value = value
26
+ this.locator = locator
27
+ this.manifest = manifest
28
+ this.client = client
29
+ this.epoch = epoch(manifest.schema)
30
+
31
+ if (client !== null) this.depends(client)
12
32
  }
13
33
 
14
- public invoke (path: string[]): any {
34
+ public invoke(path?: string[]): any {
15
35
  let cursor: any = this.value
16
36
 
17
- if (path !== undefined)
18
- for (const segment of path)
19
- cursor = cursor[segment]
37
+ if (path !== undefined) for (const segment of path) cursor = cursor[segment]
20
38
 
21
39
  return cursor
22
40
  }
41
+
42
+ protected override async open(): Promise<void> {
43
+ if (this.client === null) {
44
+ this.value = local(this.locator, this.manifest)
45
+
46
+ return
47
+ }
48
+
49
+ const { configuration, created } = await this.client.fetch(
50
+ this.locator.id,
51
+ this.epoch
52
+ )
53
+
54
+ this.value = fit(configuration, this.manifest)
55
+ this.created = created
56
+ this.client.subscribe(this.locator.id, this.epoch, this.listener)
57
+
58
+ console.info('Configuration resolved', {
59
+ component: this.locator.id,
60
+ epoch: this.epoch,
61
+ created
62
+ })
63
+ }
64
+
65
+ protected override async close(): Promise<void> {
66
+ this.client?.unsubscribe(this.locator.id, this.epoch, this.listener)
67
+ }
68
+
69
+ private readonly listener = ({ configuration, created }: Value): void => {
70
+ // deliveries may repeat or cross: only what is newer than the held value replaces it
71
+ if (created <= this.created) return
72
+
73
+ try {
74
+ this.value = fit(configuration, this.manifest)
75
+ this.created = created
76
+
77
+ console.info('Configuration updated', {
78
+ component: this.locator.id,
79
+ epoch: this.epoch,
80
+ created
81
+ })
82
+ } catch (error) {
83
+ // the service validated it against the schema of its epoch, so the two schemas differ
84
+ console.error('Configuration value does not match the schema', {
85
+ component: this.locator.id,
86
+ epoch: this.epoch,
87
+ error
88
+ })
89
+ }
90
+ }
23
91
  }
@@ -0,0 +1,125 @@
1
+ import { it, beforeEach, afterEach, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { isDeepStrictEqual } from 'node:util'
4
+
5
+ import { Connector, type Locator, type Receiver } from '@toa.io/core'
6
+ import type { Message } from '@toa.io/core/types'
7
+ import { timeout } from '@toa.io/generic'
8
+ import { Client, type Fetched } from './Client.js'
9
+ import { EVENT } from './const.js'
10
+ import type { Host } from './Factory.js'
11
+
12
+ class Remote extends Connector {
13
+ public readonly invoke = mock.fn(
14
+ async (
15
+ _endpoint: string,
16
+ request: { input: Array<{ component: string; epoch: string }> }
17
+ ) =>
18
+ request.input.map((pair): Fetched => ({
19
+ ...pair,
20
+ configuration: this.values[pair.component] ?? null,
21
+ created: this.values[pair.component] === undefined ? 0 : 7
22
+ }))
23
+ )
24
+
25
+ public values: Record<string, object | null> = {}
26
+ }
27
+
28
+ let remote: Remote
29
+ let receiver: Receiver | null
30
+ let host: Host
31
+ let client: Client
32
+
33
+ beforeEach(() => {
34
+ remote = new Remote()
35
+ receiver = null
36
+
37
+ host = {
38
+ remote: mock.fn(async (locator: Locator) => {
39
+ assert.deepStrictEqual(locator.id, 'configuration.values')
40
+
41
+ return remote
42
+ }),
43
+ receive: mock.fn(async (label: string, consumer: Receiver) => {
44
+ assert.deepStrictEqual(label, EVENT)
45
+
46
+ receiver = consumer
47
+
48
+ return new Connector()
49
+ })
50
+ } as unknown as Host
51
+
52
+ client = new Client(host, { base: 10, max: 20, warn: 2 })
53
+ })
54
+
55
+ afterEach(async () => {
56
+ await client.disconnect()
57
+ })
58
+
59
+ it('should send the requests of one tick as one call', async () => {
60
+ remote.values = { 'a.one': { foo: 1 }, 'a.two': { foo: 2 } }
61
+
62
+ await client.connect()
63
+
64
+ const [one, two] = await Promise.all([
65
+ client.fetch('a.one', 'e1'),
66
+ client.fetch('a.two', 'e2')
67
+ ])
68
+
69
+ assert.deepStrictEqual(one, { configuration: { foo: 1 }, created: 7 })
70
+ assert.deepStrictEqual(two, { configuration: { foo: 2 }, created: 7 })
71
+ assert.strictEqual(remote.invoke.mock.callCount(), 1)
72
+ assert.deepStrictEqual(remote.invoke.mock.calls[0].arguments[1], {
73
+ input: [
74
+ { component: 'a.one', epoch: 'e1' },
75
+ { component: 'a.two', epoch: 'e2' }
76
+ ]
77
+ })
78
+ })
79
+
80
+ it('should keep asking until served', async () => {
81
+ await client.connect()
82
+
83
+ const fetching = client.fetch('a.one', 'e1')
84
+ const deadline = Date.now() + 1000
85
+
86
+ while (remote.invoke.mock.calls.length < 2 && Date.now() < deadline) await timeout(5)
87
+
88
+ assert.ok(remote.invoke.mock.calls.length >= 2)
89
+
90
+ remote.values = { 'a.one': { foo: 1 } }
91
+
92
+ assert.deepStrictEqual((await fetching).configuration, { foo: 1 })
93
+ })
94
+
95
+ it('should hand a created object to its subscribers', async () => {
96
+ await client.connect()
97
+
98
+ const listener = mock.fn()
99
+ const other = mock.fn()
100
+
101
+ client.subscribe('a.one', 'e1', listener)
102
+ client.subscribe('a.one', 'e0', other)
103
+
104
+ await receiver!.receive({
105
+ payload: { component: 'a.one', epoch: 'e1', configuration: { foo: 2 }, CREATED: 12 }
106
+ } satisfies Message)
107
+
108
+ assert.ok(
109
+ listener.mock.calls.some(
110
+ (call: any) =>
111
+ call.arguments.length === 1 &&
112
+ isDeepStrictEqual(call.arguments[0], { configuration: { foo: 2 }, created: 12 })
113
+ )
114
+ )
115
+ assert.strictEqual(other.mock.callCount(), 0)
116
+ assert.strictEqual(remote.invoke.mock.callCount(), 0)
117
+
118
+ client.unsubscribe('a.one', 'e1', listener)
119
+
120
+ await receiver!.receive({
121
+ payload: { component: 'a.one', epoch: 'e1', configuration: { foo: 3 }, CREATED: 13 }
122
+ } satisfies Message)
123
+
124
+ assert.strictEqual(listener.mock.callCount(), 1)
125
+ })