@toa.io/boot 1.0.0-alpha.306 → 1.0.0-alpha.308

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.308](https://github.com/toa-io/toa/compare/v1.0.0-alpha.307...v1.0.0-alpha.308) (2026-09-15)
7
+
8
+ **Note:** Version bump only for package @toa.io/boot
9
+
10
+
11
+
12
+
13
+
14
+ # [1.0.0-alpha.307](https://github.com/toa-io/toa/compare/v1.0.0-alpha.306...v1.0.0-alpha.307) (2026-09-15)
15
+
16
+ **Note:** Version bump only for package @toa.io/boot
17
+
18
+
19
+
20
+
21
+
6
22
  # [1.0.0-alpha.306](https://github.com/toa-io/toa/compare/v1.0.0-alpha.305...v1.0.0-alpha.306) (2026-09-14)
7
23
 
8
24
  * refactor(core)!: name the quiesce hooks pause and unpause ([6e4e5f5](https://github.com/toa-io/toa/commit/6e4e5f55d43f03ea95ca8bbdaae57b949564dfa2))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/boot",
3
- "version": "1.0.0-alpha.306",
3
+ "version": "1.0.0-alpha.308",
4
4
  "type": "module",
5
5
  "description": "Toa Boot",
6
6
  "author": "temich <tema.gurtovoy@gmail.com>",
@@ -21,15 +21,15 @@
21
21
  "test": "echo \"Error: run tests from root\" && exit 1"
22
22
  },
23
23
  "dependencies": {
24
- "@toa.io/atomicity": "1.0.0-alpha.306",
25
- "@toa.io/core": "1.0.0-alpha.306",
26
- "@toa.io/definitions": "1.0.0-alpha.306",
24
+ "@toa.io/atomicity": "1.0.0-alpha.308",
25
+ "@toa.io/core": "1.0.0-alpha.308",
26
+ "@toa.io/definitions": "1.0.0-alpha.308",
27
27
  "@toa.io/generic": "1.0.0-alpha.304",
28
- "@toa.io/norm": "1.0.0-alpha.306",
28
+ "@toa.io/norm": "1.0.0-alpha.308",
29
29
  "@toa.io/schemas": "1.0.0-alpha.304",
30
30
  "clone-deep": "4.0.1",
31
31
  "dotenv": "17.4.2",
32
32
  "openspan": "1.0.0-alpha.305"
33
33
  },
34
- "gitHead": "0693d180a8053a7d07dca879dac2a3c4784d6d45"
34
+ "gitHead": "66eb0d54032f60e6ead9e13fd9e21fb60c611277"
35
35
  }
@@ -24,104 +24,88 @@ export async function composition(paths, options) {
24
24
  components: manifests.map((manifest) => manifest.locator.id)
25
25
  })
26
26
 
27
+ // what this process composes it knows, and the map it was given says the same
28
+ await boot.map.compose(manifests)
29
+
27
30
  const tenants = await span(
28
31
  'create tenants',
29
32
  async () => await Promise.all(manifests.map(boot.extensions.tenants))
30
33
  )
31
34
 
32
- const expositions = await span(
33
- 'expose discovery',
34
- async () => await Promise.all(manifests.map(boot.discovery.expose))
35
+ const components = await span(
36
+ 'create components',
37
+ async () => await Promise.all(manifests.map(boot.component))
35
38
  )
36
39
 
37
- try {
38
- const components = await span(
39
- 'create components',
40
- async () => await Promise.all(manifests.map(boot.component))
41
- )
42
-
43
- const groups = await Promise.all(
44
- components.map(
45
- async (component, index) =>
46
- await boot.bindings.produce(component, manifests[index].operations)
47
- )
40
+ const groups = await Promise.all(
41
+ components.map(
42
+ async (component, index) =>
43
+ await boot.bindings.produce(component, manifests[index].operations)
48
44
  )
45
+ )
49
46
 
50
- const receivers = await span(
51
- 'create receivers',
52
- async () =>
53
- await Promise.all(
54
- components.map((component, index) =>
55
- boot.receivers(manifests[index], component)
56
- )
47
+ const receivers = await span(
48
+ 'create receivers',
49
+ async () =>
50
+ await Promise.all(
51
+ components.map((component, index) =>
52
+ boot.receivers(manifests[index], component)
57
53
  )
58
- )
59
-
60
- const producers = []
61
- const settles = []
62
- const readies = []
54
+ )
55
+ )
63
56
 
64
- for (let i = 0; i < components.length; i++) {
65
- const { local, other } = groups[i]
66
- const { settle, ready } = components[i]
67
- const serving = [...local, ...other]
57
+ const producers = []
58
+ const settles = []
59
+ const readies = []
68
60
 
69
- producers.push(...serving)
61
+ for (let i = 0; i < components.length; i++) {
62
+ const { local, other } = groups[i]
63
+ const { settle, ready } = components[i]
64
+ const serving = [...local, ...other]
70
65
 
71
- /*
72
- * A receiver turns an event into a call to an operation of its own component, and
73
- * that call is served by these — the loop producer first, the broker's if it has
74
- * already gone. They are siblings under the composition otherwise, so a receiver
75
- * still draining a delivery would be left calling something already torn down.
76
- */
77
- // one at a time: `depends` given an array links the group it makes rather than its
78
- // members, and it is the members whose teardown has to wait for this one
79
- for (const receiver of receivers[i])
80
- for (const producer of serving) receiver.depends(producer)
66
+ producers.push(...serving)
81
67
 
82
- /*
83
- * The broker producers serve the stateful endpoints under the process's name, and the
84
- * name is held once they are open — before then a call to it is refused. `ready` is where
85
- * a component hands the name out, so it waits for every producer and receiver of its own.
86
- */
87
- if (ready !== undefined) {
88
- for (const producer of serving) ready.depends(producer)
68
+ /*
69
+ * A receiver turns an event into a call to an operation of its own component, and
70
+ * that call is served by these — the loop producer first, the broker's if it has
71
+ * already gone. They are siblings under the composition otherwise, so a receiver
72
+ * still draining a delivery would be left calling something already torn down.
73
+ */
74
+ // one at a time: `depends` given an array links the group it makes rather than its
75
+ // members, and it is the members whose teardown has to wait for this one
76
+ for (const receiver of receivers[i])
77
+ for (const producer of serving) receiver.depends(producer)
89
78
 
90
- for (const receiver of receivers[i]) ready.depends(receiver)
79
+ /*
80
+ * The broker producers serve the stateful endpoints under the process's name, and the
81
+ * name is held once they are open — before then a call to it is refused. `ready` is where
82
+ * a component hands the name out, so it waits for every producer and receiver of its own.
83
+ */
84
+ if (ready !== undefined) {
85
+ for (const producer of serving) ready.depends(producer)
91
86
 
92
- readies.push(ready)
93
- }
87
+ for (const receiver of receivers[i]) ready.depends(receiver)
94
88
 
95
- if (settle === undefined) continue
89
+ readies.push(ready)
90
+ }
96
91
 
97
- if (local.length > 0) settle.depends(local)
92
+ if (settle === undefined) continue
98
93
 
99
- for (const producer of other) producer.depends(settle)
94
+ if (local.length > 0) settle.depends(local)
100
95
 
101
- for (const receiver of receivers[i]) receiver.depends(settle)
96
+ for (const producer of other) producer.depends(settle)
102
97
 
103
- settles.push(settle)
104
- }
98
+ for (const receiver of receivers[i]) receiver.depends(settle)
105
99
 
106
- const composition = new Composition(
107
- expositions.flat(),
108
- producers.concat(settles, readies),
109
- receivers.flat(),
110
- tenants.flat()
111
- )
100
+ settles.push(settle)
101
+ }
112
102
 
113
- /*
114
- * A lookup that is never answered holds a connection open, and whoever
115
- * awaits it has nothing to disconnect — the remote it would belong to does
116
- * not exist yet. The composition owns the process's discovery instead, so
117
- * an unanswered lookup cannot outlive it.
118
- */
119
- composition.depends(await boot.discovery.discovery())
103
+ const composition = new Composition(
104
+ producers.concat(settles, readies),
105
+ receivers.flat(),
106
+ tenants.flat()
107
+ )
120
108
 
121
- return boot.extensions.manage(composition)
122
- } catch (exception) {
123
- await Promise.all(expositions.flat().map((connector) => connector.disconnect(true)))
124
- throw exception
125
- }
109
+ return boot.extensions.manage(composition)
126
110
  })
127
111
  }
package/src/context.js CHANGED
@@ -3,7 +3,7 @@ import { Context, Locator } from '@toa.io/core'
3
3
  import * as boot from './index.js'
4
4
 
5
5
  export const context = async (manifest) => {
6
- const local = await boot.remote(manifest.locator, undefined, { manifest })
6
+ const local = await boot.remote(manifest.locator, undefined, { contract: manifest })
7
7
  const aspects = boot.extensions.aspects(manifest)
8
8
 
9
9
  // a system aspect: it is there in every component, declared by none of them
package/src/host.js CHANGED
@@ -12,7 +12,7 @@ import * as boot from './index.js'
12
12
  */
13
13
  export const host = (workload) => ({
14
14
  // an extension knows a version only where something told it one; see `Remotes` in exposition
15
- remote: (locator, source, version) => boot.remote(locator, source, { version }),
15
+ remote: (locator, source, contract) => boot.remote(locator, source, { contract }),
16
16
  broadcast: boot.bindings.broadcast,
17
17
  composition: boot.composition,
18
18
  receive: boot.receive,
package/src/index.js CHANGED
@@ -4,7 +4,6 @@ export { load as env } from './env.js'
4
4
  export * as bindings from './bindings/index.js'
5
5
  export * as bridge from './bridge.js'
6
6
  export * as contract from './contract.js'
7
- export * as discovery from './discovery.js'
8
7
  export * as extensions from './extensions/index.js'
9
8
 
10
9
  export { call } from './call.js'
package/src/map.js CHANGED
@@ -1,40 +1,85 @@
1
+ import assert from 'node:assert'
1
2
  import { readFile } from 'node:fs/promises'
3
+ import * as core from '@toa.io/core'
2
4
 
3
5
  /**
4
- * Which version of a component this process asks what that component provides.
6
+ * What this process was given about the components it calls: the contract of the version each of
7
+ * them runs.
5
8
  *
6
- * Held as the file it was given rather than as what the file said, and read at every lookup: a
7
- * deployment rewrites it under a running process, and a process may first call a peer long after
8
- * that peer was replaced. A lookup happens once per peer, so this is a read per peer.
9
+ * Held as the file it was given rather than as what the file said, and read at every call for
10
+ * one: a deployment rewrites it under a running process, and a process may first call a peer
11
+ * long after that peer was replaced. A contract is read once per peer, so this is a read per
12
+ * peer.
9
13
  *
10
- * @type {string | Record<string, string> | undefined}
14
+ * @type {string | Record<string, import('@toa.io/core').Contract> | undefined}
11
15
  */
12
16
  let source
13
17
 
14
18
  /**
15
- * @param {string | Record<string, string> | undefined} value the file, or the map itself
19
+ * What this process composes, which it knows from the sources it booted from. What the map
20
+ * states wins: two compositions of one locator run in one process while a rollout is staged,
21
+ * and the map is what says which of them a caller is held to.
22
+ *
23
+ * @type {Record<string, import('@toa.io/core').Contract>}
24
+ */
25
+ let composed = {}
26
+
27
+ /**
28
+ * @param {string | Record<string, import('@toa.io/core').Contract> | undefined} value the file, or the map itself
16
29
  */
17
30
  export const use = (value) => {
18
31
  source = value
19
32
  }
20
33
 
21
34
  /**
22
- * The version of a component, where this process was given a map that names one. Absent, a
23
- * lookup goes to the name every version of that component serves.
35
+ * Forgets what this process composed, which is what a test does between one composition and
36
+ * the next: a component that is no longer composed is one nothing states.
37
+ */
38
+ export const forget = () => {
39
+ composed = {}
40
+ }
41
+
42
+ /**
43
+ * What this process composes, stated by the boot that composes it — and held to the map it was
44
+ * given a file of, which one `toa deploy` writes with the images it deploys: the two differ only
45
+ * where the map was not written again. What a test states instead is what that test means, and
46
+ * is not checked.
47
+ *
48
+ * @param {toa.norm.Component[]} manifests
49
+ * @returns {Promise<void>}
50
+ */
51
+ export const compose = async (manifests) => {
52
+ const file = typeof source === 'string' ? await read(source) : undefined
53
+
54
+ for (const manifest of manifests) {
55
+ const stated = file?.[manifest.locator.id]
56
+
57
+ assert.ok(
58
+ stated === undefined || stated.version === manifest.version,
59
+ `'${manifest.locator.id}' is composed at a version the component map does not state. ` +
60
+ 'Run `toa map`.'
61
+ )
62
+
63
+ composed[manifest.locator.id] = core.contract.component(manifest)
64
+ }
65
+ }
66
+
67
+ /**
68
+ * What a component provides: what the map states of it, or what this process composes.
24
69
  *
25
70
  * @param {string} id
26
- * @returns {Promise<string | undefined>}
71
+ * @returns {Promise<import('@toa.io/core').Contract | undefined>}
27
72
  */
28
- export const version = async (id) => {
29
- if (source === undefined) return undefined
30
- if (typeof source !== 'string') return source[id]
73
+ export const contract = async (id) => {
74
+ if (source === undefined) return composed[id]
75
+ if (typeof source !== 'string') return source[id] ?? composed[id]
31
76
 
32
- return (await read(source))[id]
77
+ return (await read(source))[id] ?? composed[id]
33
78
  }
34
79
 
35
80
  /**
36
81
  * @param {string} path
37
- * @returns {Promise<Record<string, string>>}
82
+ * @returns {Promise<Record<string, import('@toa.io/core').Contract>>}
38
83
  */
39
84
  async function read(path) {
40
85
  let contents
package/src/receivers.js CHANGED
@@ -8,7 +8,7 @@ export const receivers = async (manifest, component) => {
8
8
  if (manifest.receivers === undefined) return []
9
9
 
10
10
  const receivers = []
11
- const local = await boot.remote(manifest.locator, undefined, { manifest })
11
+ const local = await boot.remote(manifest.locator, undefined, { contract: manifest })
12
12
 
13
13
  for (const [label, definition] of Object.entries(manifest.receivers)) {
14
14
  const locator = Locator.parse(label)
@@ -67,9 +67,15 @@ export async function receive(label, group, callback) {
67
67
  */
68
68
  async function resolveBinding(locator, label) {
69
69
  const event = label.split('.').pop()
70
- const version = await boot.map.version(locator.id)
71
- const discovery = await boot.discovery.discovery()
72
- const { events } = await discovery.lookup(locator, version)
70
+ const contract = await boot.map.contract(locator.id)
71
+
72
+ assert.ok(
73
+ contract !== undefined,
74
+ `The component map states nothing of '${locator.id}', whose event '${label}' receives. ` +
75
+ 'Run `toa map`.'
76
+ )
77
+
78
+ const { events, version } = contract
73
79
 
74
80
  assert.ok(
75
81
  events?.[event] !== undefined,
package/src/remote.js CHANGED
@@ -1,5 +1,4 @@
1
- import { console } from 'openspan'
2
- import { Remote } from '@toa.io/core'
1
+ import { Remote, exceptions } from '@toa.io/core'
3
2
  import { remap } from '@toa.io/generic'
4
3
 
5
4
  import * as boot from './index.js'
@@ -7,40 +6,29 @@ import * as boot from './index.js'
7
6
  /**
8
7
  * @param {import('@toa.io/core').Locator} locator
9
8
  * @param {import('@toa.io/core/types').Source} [source] the origin stamped on every call made through this remote
10
- * @param {{ manifest?: toa.norm.Component, version?: string }} [options]
11
- * `manifest` skips discovery where it is already known; `version` is which version of the
12
- * component answers, where the caller knows one — the gateway, from the branch it merged.
13
- * Absent, the map this process was given says, and absent that too, whichever version answers.
9
+ * @param {{ contract?: import('@toa.io/core').Contract }} [options]
10
+ * `contract` is what the caller already has — a manifest of its own is one, and so is what a
11
+ * tenant announced — and absent it, what the map this process was given states.
14
12
  */
15
13
  export const remote = async (locator, source, options = {}) => {
16
- let { manifest } = options
17
- let discovery
14
+ const contract = options.contract ?? (await boot.map.contract(locator.id))
18
15
 
19
- if (manifest === undefined) {
20
- const version = options.version ?? (await boot.map.version(locator.id))
21
-
22
- console.debug('Lookup', { locator: locator.id, version })
23
-
24
- discovery = await boot.discovery.discovery()
25
- manifest = await discovery.lookup(locator, version)
26
- }
16
+ if (contract === undefined)
17
+ throw new exceptions.UnstatedException(
18
+ `Cannot call '${locator.id}': the component map states nothing of it. Run \`toa map\`.`
19
+ )
27
20
 
28
21
  // a call binds its consumers, which are loaded rather than required
29
22
  const calls =
30
- manifest.operations === undefined
23
+ contract.operations === undefined
31
24
  ? {}
32
25
  : await settle(
33
- remap(manifest.operations, (definition, endpoint) =>
34
- boot.call(locator, endpoint, definition, manifest.entity, source)
26
+ remap(contract.operations, (definition, endpoint) =>
27
+ boot.call(locator, endpoint, definition, contract.entity, source)
35
28
  )
36
29
  )
37
30
 
38
- const remote = new Remote(locator, calls)
39
-
40
- // ensure discovery shutdown
41
- if (discovery !== undefined) remote.depends(discovery)
42
-
43
- return remote
31
+ return new Remote(locator, calls)
44
32
  }
45
33
 
46
34
  /** An object whose values are promises, as an object of what they resolve to. */
package/types/index.d.ts CHANGED
@@ -1,9 +1,29 @@
1
1
  import type * as core from '@toa.io/core'
2
2
  import type * as types from '@toa.io/core/types'
3
+ import type * as norm from '@toa.io/norm/types'
3
4
  import * as composition from './composition.d.ts'
4
5
 
5
6
  export * as bindings from './bindings.d.ts'
6
7
 
8
+ export async function manifest(
9
+ path: string,
10
+ options?: composition.Options
11
+ ): Promise<norm.Manifest>
12
+
13
+ /** What this process was given about the components it calls. */
14
+ export namespace map {
15
+ /** the file to read it from, or the map itself */
16
+ function use(value: string | Record<string, core.Contract> | undefined): void
17
+
18
+ /** what this process composes, stated by the boot that composes it */
19
+ function compose(manifests: norm.Manifest[]): Promise<void>
20
+
21
+ /** forgets what this process composed */
22
+ function forget(): void
23
+
24
+ function contract(id: string): Promise<core.Contract | undefined>
25
+ }
26
+
7
27
  export async function composition(
8
28
  paths: string[],
9
29
  options?: composition.Options
package/src/discovery.js DELETED
@@ -1,75 +0,0 @@
1
- import { Discovery, Exposition } from '@toa.io/core'
2
-
3
- import * as boot from './index.js'
4
-
5
- let promise
6
- let instance = null
7
-
8
- /**
9
- * The process's discovery, for as long as it is connected.
10
- *
11
- * A disconnected one is replaced rather than reused. A lookup becomes a dependency of the
12
- * discovery that made it and nothing takes one back, so an instance carried across a
13
- * disconnection would reconnect every call it has ever made beside the ones the composition now
14
- * booting asks for — and every disconnection after that would walk all of them again.
15
- */
16
- export const discovery = async () => {
17
- while (true) {
18
- if (instance === null) {
19
- instance = new Discovery(lookup)
20
- promise = instance.connect()
21
- }
22
-
23
- const current = instance
24
-
25
- await promise
26
-
27
- if (current.connected) return current
28
-
29
- // whoever replaced it first wins, and the loop reads what they left
30
- if (instance === current) instance = null
31
- }
32
- }
33
-
34
- const lookup = async (locator, version) => {
35
- const call = await boot.call(locator, endpoint(version), { bindings: BINDINGS })
36
-
37
- await call.connect()
38
-
39
- return call
40
- }
41
-
42
- /**
43
- * Where a lookup of a named version of a component is answered. Absent a version, the name every
44
- * version of it answers on, which is what a caller with no map asks.
45
- *
46
- * @param {string} [version]
47
- * @returns {string}
48
- */
49
- export const endpoint = (version) =>
50
- version === undefined ? ENDPOINT : ENDPOINT + SEPARATOR + version
51
-
52
- /**
53
- * Both names, always: the versioned one is what a caller that knows which version it wants asks,
54
- * and the shared one is what everything else asks — a caller outside the deployment, a process of
55
- * a runtime that has never heard of this.
56
- */
57
- export const expose = async (manifest) => {
58
- const exposition = new Exposition(manifest.locator, manifest)
59
- const operations = {
60
- [ENDPOINT]: { bindings: BINDINGS },
61
- [endpoint(manifest.version)]: { bindings: BINDINGS }
62
- }
63
- const { local, other } = await boot.bindings.produce(exposition, operations)
64
- const producers = local.concat(other)
65
-
66
- await Promise.all(producers.map((producer) => producer.connect()))
67
-
68
- return producers
69
- }
70
-
71
- const BINDINGS = ['@toa.io/bindings.amqp']
72
- const ENDPOINT = '.lookup'
73
-
74
- /** the runtime's own, as `..tasks` and `..instances` are */
75
- const SEPARATOR = '..'