@toa.io/boot 1.0.0-alpha.297 → 1.0.0-alpha.299

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,23 @@
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.299](https://github.com/toa-io/toa/compare/v1.0.0-alpha.298...v1.0.0-alpha.299) (2026-09-10)
7
+
8
+ ### Features
9
+
10
+ * **bindings.amqp:** a binding carries a channel, addressed by label ([17cf178](https://github.com/toa-io/toa/commit/17cf1782d1c47d033c02f99947b766eb9d45df75))
11
+ * **convergence:** deployments of one context converge ([8a70343](https://github.com/toa-io/toa/commit/8a70343cf6953aab58d4a25bac215508ff8d9a37))
12
+ * **core:** an outbox row is published to destinations ([cfb77b4](https://github.com/toa-io/toa/commit/cfb77b4826ff0f9560769a0996981e143d6525af))
13
+
14
+
15
+ # [1.0.0-alpha.298](https://github.com/toa-io/toa/compare/v1.0.0-alpha.297...v1.0.0-alpha.298) (2026-09-08)
16
+
17
+ **Note:** Version bump only for package @toa.io/boot
18
+
19
+
20
+
21
+
22
+
6
23
  # [1.0.0-alpha.297](https://github.com/toa-io/toa/compare/v1.0.0-alpha.296...v1.0.0-alpha.297) (2026-09-08)
7
24
 
8
25
  **Note:** Version bump only for package @toa.io/boot
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/boot",
3
- "version": "1.0.0-alpha.297",
3
+ "version": "1.0.0-alpha.299",
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.293",
25
- "@toa.io/core": "1.0.0-alpha.293",
26
- "@toa.io/definitions": "1.0.0-alpha.297",
24
+ "@toa.io/atomicity": "1.0.0-alpha.299",
25
+ "@toa.io/core": "1.0.0-alpha.299",
26
+ "@toa.io/definitions": "1.0.0-alpha.299",
27
27
  "@toa.io/generic": "1.0.0-alpha.292",
28
- "@toa.io/norm": "1.0.0-alpha.297",
29
- "@toa.io/schemas": "1.0.0-alpha.293",
28
+ "@toa.io/norm": "1.0.0-alpha.299",
29
+ "@toa.io/schemas": "1.0.0-alpha.299",
30
30
  "clone-deep": "4.0.1",
31
31
  "dotenv": "17.4.2",
32
32
  "openspan": "1.0.0-alpha.293"
33
33
  },
34
- "gitHead": "52959bba83817238725f50553d45afde1f231af1"
34
+ "gitHead": "2ba95b16f2cf06f82d6ff8d436761e843e5e4b43"
35
35
  }
@@ -0,0 +1,11 @@
1
+ import { factory } from './factory.js'
2
+
3
+ // eslint-disable-next-line max-params
4
+ export const inbound = async (binding, channel, uris, label, sink) => {
5
+ const instance = await factory(binding)
6
+
7
+ if (instance.inbound === undefined)
8
+ throw new Error(`Binding '${binding}' does not consume a channel`)
9
+
10
+ return instance.inbound(channel, uris, label, sink)
11
+ }
@@ -1,5 +1,7 @@
1
1
  export { broadcast } from './broadcast.js'
2
2
  export { consume } from './consume.js'
3
3
  export { emit } from './emit.js'
4
+ export { inbound } from './inbound.js'
5
+ export { outbound } from './outbound.js'
4
6
  export { produce } from './produce.js'
5
7
  export { receive } from './receive.js'
@@ -0,0 +1,10 @@
1
+ import { factory } from './factory.js'
2
+
3
+ export const outbound = async (binding, channel, uris) => {
4
+ const instance = await factory(binding)
5
+
6
+ if (instance.outbound === undefined)
7
+ throw new Error(`Binding '${binding}' does not publish a channel`)
8
+
9
+ return instance.outbound(channel, uris)
10
+ }
package/src/component.js CHANGED
@@ -19,12 +19,17 @@ const create = async (manifest, locator) => {
19
19
 
20
20
  await boot.extensions.load(manifest)
21
21
 
22
- // the storage is told whether there will be an outbox, so the events come first
22
+ // the storage is told whether there will be an outbox, so what it publishes to comes first:
23
+ // its own events, and whatever an extension adds
23
24
  const events = boot.events(manifest)
24
- const storage = await boot.storage(manifest, events !== undefined)
25
+ const destinations = await boot.extensions.destinations(manifest)
26
+ const storage = await boot.storage(
27
+ manifest,
28
+ events !== undefined || destinations.length > 0
29
+ )
25
30
  const context = await boot.context(manifest)
26
31
  const emission = await boot.emission(events, locator, context)
27
- const outbox = boot.outbox(manifest, storage, emission)
32
+ const outbox = boot.outbox(manifest, storage, emission, destinations)
28
33
 
29
34
  let state
30
35
 
@@ -0,0 +1,27 @@
1
+ import { instance } from './resolve.js'
2
+
3
+ /**
4
+ * Where a committed state change of this component goes, beside its own events. An extension
5
+ * that contributes one gives the component an outbox whether or not it declares an event, so
6
+ * this is read before the storage is made.
7
+ *
8
+ * @param {toa.norm.Component} manifest
9
+ * @returns {Promise<import('@toa.io/core/types').outbox.Destination[]>}
10
+ */
11
+ export const destinations = async (manifest) => {
12
+ const destinations = []
13
+
14
+ if (manifest.extensions === undefined) return destinations
15
+
16
+ for (const [name, declaration] of Object.entries(manifest.extensions)) {
17
+ const factory = instance(name, manifest.path)
18
+
19
+ if (factory.destination === undefined) continue
20
+
21
+ const destination = await factory.destination(manifest.locator, declaration, manifest)
22
+
23
+ if (destination !== undefined) destinations.push(destination)
24
+ }
25
+
26
+ return destinations
27
+ }
@@ -1,6 +1,7 @@
1
1
  export { aspects } from './aspects.js'
2
2
  export { component } from './component.js'
3
3
  export { context } from './context.js'
4
+ export { destinations } from './destinations.js'
4
5
  export { emitter } from './emitter.js'
5
6
  export { load } from './load.js'
6
7
  export { manage } from './manage.js'
@@ -2,13 +2,14 @@ import { instances } from './instances.js'
2
2
 
3
3
  /**
4
4
  * @param {import('@toa.io/core/types').storages.Storage} storage
5
+ * @param {import('@toa.io/core').Locator} locator whose storage it is
5
6
  * @returns {import('@toa.io/core/types').storages.Storage}
6
7
  */
7
- export const storage = (storage) => {
8
+ export const storage = (storage, locator) => {
8
9
  let decorated = storage
9
10
 
10
11
  for (const factory of Object.values(instances)) {
11
- if (factory.storage !== undefined) decorated = factory.storage(decorated)
12
+ if (factory.storage !== undefined) decorated = factory.storage(decorated, locator)
12
13
  }
13
14
 
14
15
  return decorated
package/src/host.js CHANGED
@@ -11,5 +11,7 @@ export const host = () => ({
11
11
  broadcast: boot.bindings.broadcast,
12
12
  composition: boot.composition,
13
13
  receive: boot.receive,
14
- atom: boot.atomicity
14
+ atom: boot.atomicity,
15
+ outbound: boot.bindings.outbound,
16
+ inbound: boot.bindings.inbound
15
17
  })
package/src/manifest.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import clone from 'clone-deep'
2
- import { existsSync } from 'node:fs'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { readFile } from 'node:fs/promises'
4
- import { basename, join } from 'node:path'
4
+ import { basename, dirname, join } from 'node:path'
5
5
  import { merge } from '@toa.io/generic'
6
- import { component as load, revive, NORMALIZED } from '@toa.io/norm'
6
+ import { component as load, definition, revive, NORMALIZED } from '@toa.io/norm'
7
7
  import { Locator } from '@toa.io/core'
8
8
 
9
9
  import { span } from './span.js'
@@ -33,17 +33,66 @@ export const manifest = async (path, options = {}) => {
33
33
  }
34
34
 
35
35
  /**
36
- * What the build wrote, where it wrote one. An image carries the manifest a build normalised;
37
- * a workspace has no such file and is read as it always was.
36
+ * A manifest already normalised, where there is one, and the sources read where there is not.
37
+ *
38
+ * A component of an application's own comes with the manifest its build wrote beside it. A
39
+ * component an extension ships comes with none — its package is published once, long before
40
+ * any image is built — but `@toa.io/definitions` carries the digest of those manifests, which
41
+ * a deploy has read for as long as it has been unable to install the extension. A service runs
42
+ * them from the package, so it reads the same digest rather than reading their sources again.
43
+ *
44
+ * Only a workspace normalises, then, which is the one place a source can have changed since.
38
45
  *
39
46
  * @param {string} path
40
47
  */
41
48
  async function read(path) {
42
49
  const file = join(path, NORMALIZED)
43
50
 
44
- if (!existsSync(file)) return await load(path)
51
+ if (existsSync(file)) return revive(JSON.parse(await readFile(file, 'utf8')), path)
45
52
 
46
- return revive(JSON.parse(await readFile(file, 'utf8')), path)
53
+ return (await digested(path)) ?? (await load(path))
47
54
  }
48
55
 
56
+ /**
57
+ * What the digest holds for a component of an extension's own, by the directory it is in.
58
+ *
59
+ * An extension ships its components in `components/` beside its manifest and nowhere else, so
60
+ * that is the whole of what is looked for: anything else — an application's own component, a
61
+ * fixture in a workspace — is read from its sources.
62
+ *
63
+ * @param {string} path
64
+ * @returns {Promise<object | undefined>}
65
+ */
66
+ async function digested(path) {
67
+ const directory = dirname(path)
68
+
69
+ if (basename(directory) !== COMPONENTS) return undefined
70
+
71
+ const root = dirname(directory)
72
+ const manifest = join(root, MANIFEST)
73
+
74
+ if (!existsSync(manifest)) return undefined
75
+
76
+ const { name } = JSON.parse(readFileSync(manifest, 'utf8'))
77
+
78
+ // the digest is of what Toa ships, and a package it does not define has none
79
+ if (name === undefined || !name.startsWith(SCOPE)) return undefined
80
+
81
+ const { module } = await definition(name)
82
+ const { labels, manifests } = module.components?.() ?? {}
83
+
84
+ if (manifests === undefined) return undefined
85
+
86
+ // a chart names a component's workload the way the digest labels it
87
+ const index = labels.indexOf(basename(path).replace('.', '-'))
88
+
89
+ return index === -1 ? undefined : revive(manifests[index], path)
90
+ }
91
+
92
+ const SCOPE = '@toa.io/'
93
+
94
+ const COMPONENTS = 'components'
95
+
96
+ const MANIFEST = 'package.json'
97
+
49
98
  const DEFAULTS = {}
@@ -1,7 +1,7 @@
1
1
  import { it, mock } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
 
4
- import { mkdtemp, writeFile } from 'node:fs/promises'
4
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
5
5
  import { join } from 'node:path'
6
6
  import { tmpdir } from 'node:os'
7
7
  import { generate } from 'randomstring'
@@ -10,6 +10,8 @@ const NORMALIZED = 'manifest.toa.json'
10
10
 
11
11
  let normalised = 0
12
12
 
13
+ const digest = { labels: ['identity-basic'], manifests: [{ name: 'basic', namespace: 'identity' }] }
14
+
13
15
  mock.module('@toa.io/norm', {
14
16
  namedExports: {
15
17
  component: () => {
@@ -17,7 +19,9 @@ mock.module('@toa.io/norm', {
17
19
 
18
20
  return mockComponent()
19
21
  },
20
- definition: async () => ({ module: {} }),
22
+ definition: async (reference) => ({
23
+ module: reference === '@toa.io/extensions.exposition' ? { components: () => digest } : {}
24
+ }),
21
25
  revive: (declared, path) => ({ ...declared, path }),
22
26
  NORMALIZED
23
27
  }
@@ -58,6 +62,39 @@ it('should read what the build wrote, and not normalize', async () => {
58
62
  assert.strictEqual(read.locator.id, 'todos.tasks')
59
63
  })
60
64
 
65
+ it('should read the digest for a component an extension ships, and not normalize', async () => {
66
+ const root = await mkdtemp(join(tmpdir(), 'toa-manifest-test'))
67
+ const component = join(root, 'components', 'identity.basic')
68
+
69
+ await mkdir(component, { recursive: true })
70
+ await writeFile(
71
+ join(root, 'package.json'),
72
+ JSON.stringify({ name: '@toa.io/extensions.exposition' })
73
+ )
74
+
75
+ const before = normalised
76
+ const read = await manifest(component)
77
+
78
+ assert.strictEqual(normalised, before, 'the component was normalized')
79
+ assert.strictEqual(read.path, component)
80
+ assert.strictEqual(read.locator.id, 'identity.basic')
81
+ })
82
+
83
+ it('should normalize a component that only looks like one an extension ships', async () => {
84
+ const root = await mkdtemp(join(tmpdir(), 'toa-manifest-test'))
85
+ const component = join(root, 'components', 'identity.basic')
86
+
87
+ await mkdir(component, { recursive: true })
88
+ // an application's own package, which the digest knows nothing about
89
+ await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'acme' }))
90
+
91
+ const before = normalised
92
+
93
+ await manifest(component)
94
+
95
+ assert.strictEqual(normalised, before + 1)
96
+ })
97
+
61
98
  function mockComponent() {
62
99
  return { name: generate(), namespace: generate() }
63
100
  }
package/src/outbox.js CHANGED
@@ -3,15 +3,20 @@ import { Outbox } from '@toa.io/core'
3
3
  import * as boot from './index.js'
4
4
 
5
5
  /**
6
- * A component with no declared events has nothing to publish and therefore no outbox — the
7
- * static check that keeps the common case free of a transaction.
6
+ * A component with nothing to publish to has no outbox — the static check that keeps the
7
+ * common case free of a transaction. Its own events are one destination; an extension may
8
+ * contribute others, and a component that declares no event at all has an outbox where one of
9
+ * those is there.
8
10
  *
9
11
  * @param {toa.norm.Component} manifest
10
12
  * @param {import('@toa.io/core/types').storages.Storage} [storage]
11
13
  * @param {import('@toa.io/core').Emission} [emission]
14
+ * @param {import('@toa.io/core/types').outbox.Destination[]} [contributed]
12
15
  */
13
- export const outbox = (manifest, storage, emission) => {
14
- if (emission === undefined) return
16
+ export const outbox = (manifest, storage, emission, contributed = []) => {
17
+ const destinations = emission === undefined ? contributed : [emission, ...contributed]
15
18
 
16
- return new Outbox(emission, storage, boot.atomicity(manifest.locator.id), {})
19
+ if (destinations.length === 0) return
20
+
21
+ return new Outbox(destinations, storage, boot.atomicity(manifest.locator.id), {})
17
22
  }
package/src/storage.js CHANGED
@@ -33,7 +33,7 @@ export const storage = async (manifest, outbox) => {
33
33
  `which storage '${manifest.entity.storage}' does not apply`
34
34
  )
35
35
 
36
- return extensions.storage(storage)
36
+ return extensions.storage(storage, manifest.locator)
37
37
  }
38
38
 
39
39
  async function load(component) {