@toa.io/norm 1.0.0-alpha.282 → 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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
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.284](https://github.com/toa-io/toa/compare/v1.0.0-alpha.283...v1.0.0-alpha.284) (2026-09-05)
7
+
8
+ ### Features
9
+
10
+ * an operation states what it is ([b5e2f66](https://github.com/toa-io/toa/commit/b5e2f66c8bf67924e2eaaaa11f283dfb4d810981))
11
+ * **cadence:** calls a component makes to itself, and calls it puts off ([8bbde5f](https://github.com/toa-io/toa/commit/8bbde5fc89e8e5549d5aa2bfcddb1beec0871565))
12
+
13
+
14
+ # [1.0.0-alpha.283](https://github.com/toa-io/toa/compare/v1.0.0-alpha.282...v1.0.0-alpha.283) (2026-09-04)
15
+
16
+ ### Bug Fixes
17
+
18
+ * **exposition:** declare the errors the built-in components return ([27200c3](https://github.com/toa-io/toa/commit/27200c348ab09068d58c52c3e83208cb29cd7290))
19
+
20
+ ### Features
21
+
22
+ * **operations:** run extension services inside a composition ([154aced](https://github.com/toa-io/toa/commit/154aced047228a250ac3bab353e09432fcc5a34d))
23
+
24
+
6
25
  # [1.0.0-alpha.282](https://github.com/toa-io/toa/compare/v1.0.0-alpha.281...v1.0.0-alpha.282) (2026-09-03)
7
26
 
8
27
  **Note:** Version bump only for package @toa.io/norm
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/norm",
3
- "version": "1.0.0-alpha.282",
3
+ "version": "1.0.0-alpha.284",
4
4
  "type": "module",
5
5
  "description": "Toa declarations normalization and validation",
6
6
  "author": "temich <tema.gurtovoy@gmail.com>",
@@ -21,11 +21,11 @@
21
21
  "test": "echo \"Error: run tests from root\" && exit 1"
22
22
  },
23
23
  "dependencies": {
24
- "@toa.io/core": "1.0.0-alpha.282",
25
- "@toa.io/generic": "1.0.0-alpha.282",
26
- "@toa.io/schemas": "1.0.0-alpha.282",
24
+ "@toa.io/core": "1.0.0-alpha.284",
25
+ "@toa.io/generic": "1.0.0-alpha.283",
26
+ "@toa.io/schemas": "1.0.0-alpha.283",
27
27
  "fast-glob": "3.3.3",
28
28
  "js-yaml": "5.4.1"
29
29
  },
30
- "gitHead": "4007c814725dd015d3bbebb9a28e28a247645c4f"
30
+ "gitHead": "006755556879e91e8d68fa86753aa3b475bdf4bf"
31
31
  }
@@ -12,5 +12,6 @@ const SHORTCUTS = {
12
12
  state: '@toa.io/extensions.state',
13
13
  stash: '@toa.io/extensions.stash',
14
14
  storages: '@toa.io/extensions.storages',
15
+ cadence: '@toa.io/extensions.cadence',
15
16
  introspection: '@toa.io/extensions.introspection'
16
17
  }
@@ -148,15 +148,19 @@ properties:
148
148
  type: string
149
149
  bindings:
150
150
  $ref: "#/properties/bindings"
151
+ description:
152
+ # what the operation is, for whoever reads it: a generated type, an OPTIONS, a model
153
+ type: string
151
154
  input:
152
155
  $ref: https://json-schema.org/draft/2019-09/schema
153
156
  output:
154
157
  $ref: https://json-schema.org/draft/2019-09/schema
155
158
  default: {}
156
159
  errors:
160
+ # what a reply may carry as a code: the error schema allows either
157
161
  type: array
158
162
  items:
159
- type: string
163
+ type: [string, integer]
160
164
  query:
161
165
  type: boolean
162
166
  required: [type, scope, bindings]
@@ -4,7 +4,13 @@ import { load as loadDependency } from './load.js'
4
4
  export const extensions = async (context) => {
5
5
  const extensions = {}
6
6
  const components = context.components?.slice() ?? []
7
- const extracted = await extractExtensionComponents(components, extensions, context.annotations)
7
+
8
+ // an extension a composition runs may be referenced by no component of this context,
9
+ // and its own components must be extracted all the same
10
+ const declared = await extractDeclaredServices(context, extensions)
11
+
12
+ const extracted = declared.concat(
13
+ await extractExtensionComponents(components.concat(declared), extensions, context.annotations))
8
14
 
9
15
  components.push(...extracted)
10
16
 
@@ -21,6 +27,24 @@ export const extensions = async (context) => {
21
27
  return { extensions, components: extracted }
22
28
  }
23
29
 
30
+ async function extractDeclaredServices (context, extensions) {
31
+ const extracted = []
32
+
33
+ for (const composition of context.compositions ?? [])
34
+ for (const reference of composition.services ?? []) {
35
+ if (reference in extensions) continue
36
+
37
+ try {
38
+ extracted.push(...await extract(reference, extensions, context.annotations))
39
+ } catch (e) {
40
+ throw new Error(`Composition '${composition.name}' lists '${reference}', ` +
41
+ `which cannot be resolved: ${e.message}`, { cause: e })
42
+ }
43
+ }
44
+
45
+ return extracted
46
+ }
47
+
24
48
  async function extractExtensionComponents (components, extensions, annotations) {
25
49
 
26
50
  const extracted = []
@@ -31,20 +55,7 @@ async function extractExtensionComponents (components, extensions, annotations)
31
55
  for (const reference of Object.keys(component.extensions)) {
32
56
  if (reference in extensions) continue
33
57
 
34
- extensions[reference] = []
35
-
36
- const { metadata, module: mod } = await loadDependency(reference)
37
-
38
- if (mod.components === undefined) continue
39
-
40
- // the annotation decides whether an extension contributes components at all
41
- const annotation = annotations?.[metadata?.name ?? reference]
42
-
43
- for (const path of mod.components(annotation).paths) {
44
- const component = await load(path)
45
-
46
- extracted.push(component)
47
- }
58
+ extracted.push(...await extract(reference, extensions, annotations))
48
59
  }
49
60
  }
50
61
 
@@ -55,3 +66,28 @@ async function extractExtensionComponents (components, extensions, annotations)
55
66
 
56
67
  return extracted.concat(deeper)
57
68
  }
69
+
70
+ /**
71
+ * The components an extension contributes, if it contributes any.
72
+ *
73
+ * @param {string} reference
74
+ * @param {object} extensions
75
+ * @param {object} [annotations]
76
+ * @returns {Promise<Array<toa.norm.Component>>}
77
+ */
78
+ async function extract (reference, extensions, annotations) {
79
+ extensions[reference] = []
80
+
81
+ const { metadata, module: mod } = await loadDependency(reference)
82
+
83
+ if (mod.components === undefined) return []
84
+
85
+ // the annotation decides whether an extension contributes components at all
86
+ const annotation = annotations?.[metadata?.name ?? reference]
87
+ const extracted = []
88
+
89
+ for (const path of mod.components(annotation).paths)
90
+ extracted.push(await load(path))
91
+
92
+ return extracted
93
+ }
@@ -1,6 +1,6 @@
1
1
  import { load } from './load.js'
2
2
 
3
- export const resolve = async (references, annotations) => {
3
+ export const resolve = async (references, annotations = {}) => {
4
4
  const dependencies = {}
5
5
 
6
6
  for (const [dependency, components] of Object.entries(references)) {
@@ -8,7 +8,14 @@ export const dereference = (context) => {
8
8
 
9
9
  if (context.compositions !== undefined) {
10
10
  for (const composition of context.compositions) {
11
- composition.components = composition.components.map((id) => components[id])
11
+ composition.components = composition.components.map((id) => {
12
+ const component = components[id]
13
+
14
+ if (component === undefined)
15
+ throw new Error(`Composition '${composition.name}' lists an unknown component '${id}'.`)
16
+
17
+ return component
18
+ })
12
19
  }
13
20
  }
14
21
  }
@@ -6,4 +6,10 @@ import * as shortcuts from '../shortcuts.js'
6
6
  export const expand = (context) => {
7
7
  shortcuts.recognize(shortcuts.SHORTCUTS, context, 'annotations')
8
8
  shortcuts.recognize(shortcuts.SHORTCUTS, context.annotations)
9
+
10
+ // a composition names a service the way a manifest names an extension, and everything
11
+ // downstream matches it against a dependency, which is keyed by package reference
12
+ for (const composition of context.compositions ?? [])
13
+ if (composition.services !== undefined)
14
+ composition.services = composition.services.map(shortcuts.resolve)
9
15
  }
@@ -50,6 +50,7 @@ properties:
50
50
  minItems: 1
51
51
  items:
52
52
  type: object
53
+ additionalProperties: false
53
54
  properties:
54
55
  name:
55
56
  type: string
@@ -62,6 +63,16 @@ properties:
62
63
  items:
63
64
  type: string
64
65
  pattern: ^([a-zA-Z]+([_a-zA-Z0-9]*[a-zA-Z0-9]+)?)(\.([a-zA-Z]+([_a-zA-Z0-9]*[a-zA-Z0-9]+)?))$
66
+ services:
67
+ description: >
68
+ Extension services this composition runs in its own pod, named by shortcut or
69
+ package reference, rather than each as its own deployment. A service listed here
70
+ keeps its own Service and Ingress; what changes is the pod they select.
71
+ type: array
72
+ minItems: 1
73
+ items:
74
+ type: string
75
+ minLength: 1
65
76
  resources:
66
77
  $ref: '#/definitions/resources'
67
78
  required: [name, components]
package/src/shortcuts.js CHANGED
@@ -49,5 +49,6 @@ export const SHORTCUTS = {
49
49
  stash: '@toa.io/extensions.stash',
50
50
  storages: '@toa.io/extensions.storages',
51
51
  telemetry: '@toa.io/extensions.telemetry',
52
+ cadence: '@toa.io/extensions.cadence',
52
53
  introspection: '@toa.io/extensions.introspection'
53
54
  }
@@ -23,3 +23,12 @@ it('should not throw on empty compositions', () => {
23
23
 
24
24
  assert.doesNotThrow(() => dereference(context))
25
25
  })
26
+
27
+ it('should throw on an unknown component', () => {
28
+ const broken = clone(fixtures.context)
29
+
30
+ broken.compositions[1].components.push('nope.here')
31
+
32
+ assert.throws(() => dereference(broken),
33
+ { message: "Composition 'bar' lists an unknown component 'nope.here'." })
34
+ })
@@ -40,3 +40,28 @@ describe('annotations', () => {
40
40
  assert.notStrictEqual(context.annotations['@toa.io/storages.mongodb'], undefined)
41
41
  })
42
42
  })
43
+
44
+ describe('composition services', () => {
45
+ it('should resolve shortcuts', () => {
46
+ context.compositions[0].services = ['exposition', 'configuration']
47
+
48
+ expand(context)
49
+
50
+ assert.deepStrictEqual(context.compositions[0].services,
51
+ ['@toa.io/extensions.exposition', '@toa.io/extensions.configuration'])
52
+ })
53
+
54
+ it('should leave a package reference alone', () => {
55
+ context.compositions[0].services = ['@acme/extension']
56
+
57
+ expand(context)
58
+
59
+ assert.deepStrictEqual(context.compositions[0].services, ['@acme/extension'])
60
+ })
61
+
62
+ it('should not throw without compositions', () => {
63
+ delete context.compositions
64
+
65
+ assert.doesNotThrow(() => expand(context))
66
+ })
67
+ })
@@ -83,3 +83,26 @@ it('should allow mono replicas and resources', () => {
83
83
 
84
84
  assert.doesNotThrow(() => validate(context))
85
85
  })
86
+
87
+ describe('compositions', () => {
88
+ it('should allow services', () => {
89
+ context.compositions[0].services = ['@toa.io/extensions.exposition']
90
+
91
+ assert.doesNotThrow(() => validate(context))
92
+ })
93
+
94
+ it('should require services to be a non-empty array of strings', () => {
95
+ context.compositions[0].services = []
96
+ assert.throws(() => validate(context), (error) => /fewer than 1 items/.test(error.message))
97
+
98
+ context.compositions[0].services = 'exposition'
99
+ assert.throws(() => validate(context), (error) => /must be array/.test(error.message))
100
+ })
101
+
102
+ it('should reject an unknown property', () => {
103
+ context.compositions[0].compoments = ['a.b']
104
+
105
+ assert.throws(() => validate(context),
106
+ (error) => /Property compoments is not expected to be here/.test(error.message))
107
+ })
108
+ })
@@ -8,6 +8,7 @@ export type Operation = {
8
8
  type: operations.type
9
9
  scope?: operations.scope
10
10
  bindings?: string[]
11
+ description?: string
11
12
  input?: any
12
13
  output?: any
13
14
  error?: any
@@ -1,8 +1,10 @@
1
- import { Composition, Registry, Runtime } from '../context.js'
1
+ import { Registry, Runtime } from '../context.js'
2
2
 
3
+ /** A composition as declared, before its members are resolved. */
3
4
  interface Composition {
4
5
  name: string,
5
6
  components: string[]
7
+ services?: string[]
6
8
  }
7
9
 
8
10
  export interface Resources {
@@ -20,7 +20,8 @@ interface Registry{
20
20
 
21
21
  interface Composition{
22
22
  name: string,
23
- components: _component.Component[]
23
+ components: Manifest[]
24
+ services?: string[]
24
25
  }
25
26
 
26
27
  export interface Dependency<T = undefined>{
@@ -34,7 +35,7 @@ interface Context extends Declaration{
34
35
  environment?: string
35
36
  registry?: Registry
36
37
  compositions?: Composition[]
37
- components?: _component.Component[]
38
+ components?: Manifest[]
38
39
  dependencies?: Record<string, Dependency[]>
39
40
  }
40
41