@toa.io/norm 1.0.0-alpha.292 → 1.0.0-alpha.294

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,22 @@
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.294](https://github.com/toa-io/toa/compare/v1.0.0-alpha.293...v1.0.0-alpha.294) (2026-09-07)
7
+
8
+ **Note:** Version bump only for package @toa.io/norm
9
+
10
+
11
+
12
+
13
+
14
+ # [1.0.0-alpha.293](https://github.com/toa-io/toa/compare/v1.0.0-alpha.292...v1.0.0-alpha.293) (2026-09-07)
15
+
16
+ **Note:** Version bump only for package @toa.io/norm
17
+
18
+
19
+
20
+
21
+
6
22
  # [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
23
 
8
24
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/norm",
3
- "version": "1.0.0-alpha.292",
3
+ "version": "1.0.0-alpha.294",
4
4
  "type": "module",
5
5
  "description": "Toa declarations normalization and validation",
6
6
  "author": "temich <tema.gurtovoy@gmail.com>",
@@ -21,13 +21,12 @@
21
21
  "test": "echo \"Error: run tests from root\" && exit 1"
22
22
  },
23
23
  "dependencies": {
24
- "@toa.io/core": "1.0.0-alpha.292",
25
- "@toa.io/definitions": "1.0.0-alpha.292",
24
+ "@toa.io/core": "1.0.0-alpha.293",
25
+ "@toa.io/definitions": "1.0.0-alpha.294",
26
26
  "@toa.io/generic": "1.0.0-alpha.292",
27
27
  "@toa.io/prototype": "1.0.0-alpha.289",
28
- "@toa.io/schemas": "1.0.0-alpha.292",
29
- "fast-glob": "3.3.3",
30
- "js-yaml": "5.4.1"
28
+ "@toa.io/schemas": "1.0.0-alpha.293",
29
+ "fast-glob": "3.3.3"
31
30
  },
32
- "gitHead": "0f6aa3ad9dbec86bb632a7975ea61d1dde1d8fc5"
31
+ "gitHead": "50b2b596a46a4d639d9f198f834d70c190f0a3c6"
33
32
  }
@@ -3,6 +3,5 @@ export { entity } from './entity.js'
3
3
  export { events } from './events.js'
4
4
  export { extensions } from './extensions.js'
5
5
  export { operations } from './operations.js'
6
- export { properties } from './properties.js'
7
6
  export { receivers } from './receivers.js'
8
7
  export { version } from './version.js'
@@ -5,7 +5,6 @@ import {
5
5
  events,
6
6
  receivers,
7
7
  extensions,
8
- properties,
9
8
  version
10
9
  } from './.expand/index.js'
11
10
 
@@ -15,7 +14,6 @@ export async function expand(manifest) {
15
14
  operations(manifest)
16
15
  events(manifest)
17
16
  receivers(manifest)
18
- properties(manifest)
19
17
  extensions(manifest)
20
18
 
21
19
  await version(manifest)
@@ -303,7 +303,4 @@ properties:
303
303
  extensions:
304
304
  type: object
305
305
 
306
- properties:
307
- type: object
308
-
309
306
  additionalProperties: false
@@ -1,6 +1,6 @@
1
1
  import { definition } from '../../definition.js'
2
2
 
3
- export const resolve = async (references, annotations = {}) => {
3
+ export const resolve = async (references, annotations = {}, context = {}) => {
4
4
  const dependencies = {}
5
5
 
6
6
  for (const [dependency, components] of Object.entries(references)) {
@@ -19,6 +19,27 @@ export const resolve = async (references, annotations = {}) => {
19
19
  if (annotation !== undefined && module.annotation !== undefined) {
20
20
  annotations[id] = module.annotation(annotation, instances)
21
21
  }
22
+
23
+ // what a declaration costs to install, read from the definition rather than from the
24
+ // package: a deploy installs it, and nothing that declares nothing carries it. A component
25
+ // is asked for its own instance; a composition that runs the package's service is asked
26
+ // with none, because what a service brings is not a component's to declare
27
+ if (module.installs !== undefined) {
28
+ for (const instance of instances)
29
+ Object.assign(
30
+ (instance.component.packages ??= {}),
31
+ module.installs(instance, annotations[id])
32
+ )
33
+
34
+ const service = module.installs(undefined, annotations[id])
35
+
36
+ // mono runs every service in one process, so it takes every service's packages
37
+ Object.assign((context.packages ??= {}), service)
38
+
39
+ for (const composition of context.compositions ?? [])
40
+ if (composition.services?.includes(dependency) === true)
41
+ Object.assign((composition.packages ??= {}), service)
42
+ }
22
43
  }
23
44
 
24
45
  for (const dependency of Object.keys(annotations)) {
@@ -5,5 +5,5 @@ export const dependencies = async (context) => {
5
5
  const c = connectors(context, components)
6
6
  const references = { ...c, ...e }
7
7
 
8
- return resolve(references, context.annotations)
8
+ return resolve(references, context.annotations, context)
9
9
  }
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { context } from './context.js'
2
2
  export { component } from './component.js'
3
+ export { plain, revive, NORMALIZED } from './manifest.js'
3
4
  export { definition } from './definition.js'
4
5
  export * as shortcuts from './shortcuts.js'
5
6
  export * as entity from './entity.js'
@@ -0,0 +1,154 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, isAbsolute, join, parse, relative, sep } from 'node:path'
3
+
4
+ import { Locator } from '@toa.io/core'
5
+ import { find } from '@toa.io/generic'
6
+
7
+ /**
8
+ * A normalised manifest, as it is carried in a file beside the component it is of.
9
+ *
10
+ * A build normalises every component to tag its image; the process that runs it normalised them
11
+ * again at start, reading every operation module's source through its bridge to do it. What the
12
+ * build derived is written instead, and the two cannot disagree: the sources it was read from are
13
+ * what the image's tag digests, and the version of Toa that read them is in that tag as well.
14
+ *
15
+ * What cannot be carried is where things are. A workspace and an image put a component in one
16
+ * directory and Toa's own packages in another, so every path is written as what it is of rather
17
+ * than as where it was — the component itself, or the package that holds it — and is resolved
18
+ * again on the other side, the way norm resolved it.
19
+ *
20
+ * A key with no value is not carried: a file has no way to say `undefined`, and nothing reads
21
+ * one apart from a key that is not there.
22
+ */
23
+ export function plain(manifest) {
24
+ const { locator, packages, path, ...rest } = manifest
25
+
26
+ // what is carried is written, not the manifest the caller goes on using, and it is written
27
+ // as a file holds it: a key with no value does not survive and is not meant to
28
+ const declared = JSON.parse(JSON.stringify(rest))
29
+
30
+ walk(declared, (item) => {
31
+ item.path = carried(item.path, path)
32
+ })
33
+
34
+ local(declared, path)
35
+
36
+ return declared
37
+ }
38
+
39
+ /** What `plain` wrote, read back beside the component it is of. */
40
+ export function revive(declared, path) {
41
+ const manifest = { ...structuredClone(declared), path }
42
+
43
+ walk(manifest, (item) => {
44
+ item.path = resolved(item.path, path)
45
+ })
46
+
47
+ manifest.locator = new Locator(manifest.name, manifest.namespace)
48
+
49
+ return manifest
50
+ }
51
+
52
+ /**
53
+ * Everything in a manifest that says where a module of it is: what an inherited one declares is
54
+ * in the prototype it came from, and what the component declares is in the component.
55
+ */
56
+ function walk(manifest, rewrite) {
57
+ for (const property of ROOTED)
58
+ for (const item of Object.values(manifest[property] ?? {}))
59
+ if (item.path !== undefined) rewrite(item)
60
+
61
+ for (let prototype = manifest.prototype; prototype != null; prototype = prototype.prototype)
62
+ if (prototype.path !== undefined) rewrite(prototype)
63
+ }
64
+
65
+ /** Where it is written. A workspace has none: it is a build's output, read by a process. */
66
+ export const NORMALIZED = 'manifest.toa.json'
67
+
68
+ /** Where norm says a module is, beside what it says about it. */
69
+ const ROOTED = ['events', 'receivers', 'guards']
70
+
71
+ /** The component's own directory, which is where it is read back. */
72
+ const SELF = '.'
73
+
74
+ function carried(path, root) {
75
+ if (path === root) return SELF
76
+ if (path.startsWith(root + sep)) return SELF + '/' + relative(root, path).split(sep).join('/')
77
+
78
+ return specifier(path)
79
+ }
80
+
81
+ function resolved(path, root) {
82
+ if (path === SELF) return root
83
+ if (path.startsWith(SELF + '/')) return join(root, path.slice(2))
84
+
85
+ return find(path, root, MANIFEST)
86
+ }
87
+
88
+ /**
89
+ * A directory in a package, named the way a manifest would name it, so that it is found again
90
+ * wherever the package is installed. A prototype that is a directory of the application's own is
91
+ * refused here rather than in the container: an image carries components and nothing between
92
+ * them, so nothing would be there to find, whether it is normalised here or there.
93
+ */
94
+ function specifier(path) {
95
+ const root = above(path)
96
+
97
+ if (root !== undefined) {
98
+ const { name, private: hidden } = JSON.parse(
99
+ readFileSync(join(root, 'package.json'), 'utf8')
100
+ )
101
+
102
+ if (name !== undefined && hidden !== true)
103
+ return [name, ...relative(root, path).split(sep)].filter(Boolean).join('/')
104
+ }
105
+
106
+ throw new Error(
107
+ `'${path}' is in no package, so an image cannot carry what is in it: ` +
108
+ 'what a component inherits is reached by the reference of the package it is in.'
109
+ )
110
+ }
111
+
112
+ /** The directory of the package a path is in. */
113
+ function above(path) {
114
+ const { root } = parse(path)
115
+
116
+ let current = path
117
+
118
+ while (current !== root) {
119
+ if (existsSync(join(current, 'package.json'))) return current
120
+
121
+ current = dirname(current)
122
+ }
123
+
124
+ return undefined
125
+ }
126
+
127
+ /**
128
+ * Nothing that is carried may say where it was on the machine that read it. What is checked is
129
+ * what can be told apart from a value of the application's own — an HTTP route is `/accounts`
130
+ * and is a path to nobody — so: anything inside the component, and any directory a manifest
131
+ * declares a component or a prototype in. This is what catches a path norm starts writing
132
+ * somewhere this file does not know about, at the build rather than at the start of a container
133
+ * that cannot find it.
134
+ */
135
+ function local(value, root, at = '') {
136
+ if (typeof value === 'string') {
137
+ if (value === root || value.startsWith(root + sep) || component(value))
138
+ throw new Error(`'${at}' carries a path of the machine that read it: ${value}`)
139
+
140
+ return
141
+ }
142
+
143
+ if (value === null || typeof value !== 'object') return
144
+
145
+ for (const [key, item] of Object.entries(value))
146
+ local(item, root, at === '' ? key : `${at}.${key}`)
147
+ }
148
+
149
+ /** Whether a value is a directory a component or a prototype is declared in. */
150
+ function component(value) {
151
+ return isAbsolute(value) && existsSync(join(value, MANIFEST))
152
+ }
153
+
154
+ const MANIFEST = 'manifest.toa.yaml'
package/src/shortcuts.js CHANGED
@@ -41,7 +41,6 @@ export const SHORTCUTS = {
41
41
  node: '@toa.io/bridges.node',
42
42
  bash: '@toa.io/bridges.bash',
43
43
  mongodb: '@toa.io/storages.mongodb',
44
- queues: '@toa.io/storages.queues',
45
44
  exposition: '@toa.io/extensions.exposition',
46
45
  realtime: '@toa.io/extensions.realtime',
47
46
  configuration: '@toa.io/extensions.configuration',
@@ -30,14 +30,3 @@ it('should keep declared version', async () => {
30
30
 
31
31
  assert.deepStrictEqual(source.version, '1.0.0')
32
32
  })
33
-
34
- it('should recognize storages.queues', async () => {
35
- const queues = { foo: 'bar' }
36
-
37
- source.queues = clone(queues)
38
-
39
- await expand(source)
40
-
41
- assert.strictEqual(source.queues, undefined)
42
- assert.partialDeepStrictEqual(source.properties['@toa.io/storages.queues'], queues)
43
- })
@@ -0,0 +1,126 @@
1
+ import { it, describe } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { resolve } from 'node:path'
5
+
6
+ import { component } from '../src/component.js'
7
+ import { plain, revive } from '../src/manifest.js'
8
+
9
+ /** the prototype itself: an entity, operations and events, and no prototype of its own */
10
+ const PROTOTYPE = resolve(import.meta.dirname, '../../prototype')
11
+
12
+ /** a component on the default prototype, with an entity, events and receivers */
13
+ const COMPONENT = resolve(
14
+ import.meta.dirname,
15
+ '../../../features/steps/.workspace/components/collection/external.consumer'
16
+ )
17
+
18
+ /** a component whose prototype is a directory of the workspace's own */
19
+ const EXTENDED = resolve(
20
+ import.meta.dirname,
21
+ '../../../features/steps/.workspace/components/collection/configuration.extended'
22
+ )
23
+
24
+ const carry = (manifest) => JSON.parse(JSON.stringify(plain(manifest)))
25
+
26
+ describe('a manifest carried through a file', () => {
27
+ for (const [name, path] of [
28
+ ['the prototype', PROTOTYPE],
29
+ ['a component', COMPONENT]
30
+ ])
31
+ it(`comes back the same: ${name}`, async () => {
32
+ const manifest = await component(path)
33
+
34
+ assert.deepStrictEqual(revive(carry(manifest), path), manifest)
35
+ })
36
+
37
+ it('does not carry a key with no value', () => {
38
+ const carried = plain({ name: 'a', namespace: 'b', path: '/x', operations: { echo: { input: undefined } } })
39
+
40
+ assert.deepStrictEqual(carried.operations.echo, {})
41
+ })
42
+
43
+ it('leaves the manifest it was given alone', async () => {
44
+ const manifest = await component(COMPONENT)
45
+ const before = JSON.stringify(manifest)
46
+
47
+ plain(manifest)
48
+
49
+ assert.strictEqual(JSON.stringify(manifest), before)
50
+ })
51
+
52
+ it('is read beside the component wherever it is', async () => {
53
+ const carried = carry(await component(COMPONENT))
54
+ const revived = revive(carried, '/composition/external-consumer')
55
+
56
+ assert.strictEqual(revived.path, '/composition/external-consumer')
57
+
58
+ // what the component declares is in the component
59
+ assert.strictEqual(
60
+ revived.receivers['something_happened'].path,
61
+ '/composition/external-consumer'
62
+ )
63
+ })
64
+
65
+ it('reads what is inherited where the prototype is', async () => {
66
+ const prototype = (await component(COMPONENT)).prototype.path
67
+ const revived = revive(carry(await component(COMPONENT)), '/composition/external-consumer')
68
+
69
+ // an event of the prototype's is in the prototype, wherever the component is
70
+ assert.strictEqual(revived.events.created.path, prototype)
71
+ })
72
+
73
+ it('carries what is inherited by the package that holds it', async () => {
74
+ const carried = carry(await component(COMPONENT))
75
+
76
+ assert.strictEqual(carried.prototype.path, '@toa.io/prototype')
77
+ assert.strictEqual(carried.events.created.path, '@toa.io/prototype')
78
+ assert.strictEqual(carried.receivers['something_happened'].path, '.')
79
+ })
80
+ })
81
+
82
+ describe('what cannot be carried', () => {
83
+ it('leaves out where the manifest is and what only a deploy reads', async () => {
84
+ const manifest = await component(PROTOTYPE)
85
+
86
+ manifest.packages = { cloudinary: '2.11.0' }
87
+
88
+ const carried = plain(manifest)
89
+
90
+ assert.ok(!('locator' in carried))
91
+ assert.ok(!('path' in carried))
92
+ assert.ok(!('packages' in carried))
93
+ })
94
+
95
+ it('refuses a prototype an image would not have', async () => {
96
+ const manifest = await component(EXTENDED)
97
+
98
+ assert.throws(() => plain(manifest), /is in no package, so an image cannot carry what is in it/)
99
+ })
100
+
101
+ it('refuses a path inside the component it does not know about', () => {
102
+ assert.throws(
103
+ () => plain({ name: 'a', namespace: 'b', path: '/x', invented: { at: '/x/y' } }),
104
+ /carries a path of the machine that read it/
105
+ )
106
+ })
107
+
108
+ it('refuses a component directory it does not know about', async () => {
109
+ const manifest = await component(PROTOTYPE)
110
+
111
+ manifest.invented = { at: COMPONENT }
112
+
113
+ assert.throws(() => plain(manifest), /carries a path of the machine that read it/)
114
+ })
115
+
116
+ it('leaves a value of the application\'s own alone', () => {
117
+ const carried = plain({
118
+ name: 'a',
119
+ namespace: 'b',
120
+ path: '/x',
121
+ extensions: { exposition: { routes: [{ path: '/accounts' }] } }
122
+ })
123
+
124
+ assert.strictEqual(carried.extensions.exposition.routes[0].path, '/accounts')
125
+ })
126
+ })
@@ -65,10 +65,11 @@ type Declaration = {
65
65
  events?: Events
66
66
  receivers?: Record<string, Receiver>
67
67
  extensions?: Record<string, object>
68
- properties?: Record<string, object>
69
68
  }
70
69
 
71
70
  export type Manifest = Declaration & {
72
71
  locator: Locator
73
72
  path: string
73
+ /** what a deploy installs beside this component for what it declares, by package name */
74
+ packages?: Record<string, string>
74
75
  }
@@ -23,6 +23,8 @@ interface Composition {
23
23
  name: string
24
24
  components: Manifest[]
25
25
  services?: string[]
26
+ /** what a deploy installs for the services this composition runs, by package name */
27
+ packages?: Record<string, string>
26
28
  }
27
29
 
28
30
  export interface Dependency<T = undefined> {
@@ -38,6 +40,8 @@ interface Context extends Declaration {
38
40
  compositions?: Composition[]
39
41
  components?: Manifest[]
40
42
  dependencies?: Record<string, Dependency[]>
43
+ /** what a deploy installs for every service, which is what `mono` runs */
44
+ packages?: Record<string, string>
41
45
  }
42
46
 
43
47
  export function context(path: string, environment?: string): Promise<Context>
@@ -1,10 +0,0 @@
1
- import { recognize } from '../../shortcuts.js'
2
-
3
- export function properties(manifest) {
4
- recognize(SHORTCUTS, manifest, 'properties')
5
- recognize(SHORTCUTS, manifest.properties)
6
- }
7
-
8
- const SHORTCUTS = {
9
- queues: '@toa.io/storages.queues'
10
- }