@toa.io/boot 1.0.0-alpha.34 → 1.0.0-alpha.341

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 (62) hide show
  1. package/CHANGELOG.md +475 -0
  2. package/package.json +11 -8
  3. package/src/atom.js +41 -0
  4. package/src/atomicity.js +11 -0
  5. package/src/bindings/broadcast.js +3 -6
  6. package/src/bindings/constants.js +1 -3
  7. package/src/bindings/consume.js +18 -6
  8. package/src/bindings/emit.js +3 -6
  9. package/src/bindings/factory.js +5 -9
  10. package/src/bindings/inbound.js +11 -0
  11. package/src/bindings/index.js +7 -13
  12. package/src/bindings/outbound.js +10 -0
  13. package/src/bindings/produce.js +62 -17
  14. package/src/bindings/receive.js +3 -6
  15. package/src/bridge.js +22 -16
  16. package/src/call.js +29 -9
  17. package/src/cascade.js +16 -10
  18. package/src/component.js +101 -32
  19. package/src/composition.js +115 -15
  20. package/src/context.js +7 -8
  21. package/src/contract.js +11 -12
  22. package/src/emission.js +21 -14
  23. package/src/env.js +19 -12
  24. package/src/events.js +27 -0
  25. package/src/extensions/aspects.js +4 -8
  26. package/src/extensions/component.js +4 -8
  27. package/src/extensions/context.js +4 -8
  28. package/src/extensions/destinations.js +27 -0
  29. package/src/extensions/emitter.js +7 -9
  30. package/src/extensions/index.js +10 -19
  31. package/src/extensions/instances.js +2 -4
  32. package/src/extensions/load.js +7 -7
  33. package/src/extensions/manage.js +15 -0
  34. package/src/extensions/receiver.js +5 -9
  35. package/src/extensions/resolve.js +62 -13
  36. package/src/extensions/storage.js +6 -9
  37. package/src/extensions/tenants.js +5 -9
  38. package/src/guards.js +20 -0
  39. package/src/host.js +29 -0
  40. package/src/inbox.js +12 -0
  41. package/src/index.js +29 -34
  42. package/src/manifest.js +80 -36
  43. package/src/manifest.test.js +91 -8
  44. package/src/map.js +108 -0
  45. package/src/operation.js +29 -14
  46. package/src/outbox.js +22 -0
  47. package/src/rc.js +5 -0
  48. package/src/receivers.js +53 -23
  49. package/src/regional.js +18 -0
  50. package/src/regional.test.js +28 -0
  51. package/src/remote.js +41 -22
  52. package/src/rendering.js +50 -0
  53. package/src/span.js +19 -0
  54. package/src/storage.js +50 -16
  55. package/src/storage.test.js +57 -0
  56. package/src/workload.js +283 -0
  57. package/src/workload.test.js +323 -0
  58. package/types/bindings.d.ts +6 -2
  59. package/types/index.d.ts +62 -10
  60. package/types/manifest.d.ts +5 -4
  61. package/src/discovery.js +0 -43
  62. package/src/exposition.js +0 -17
package/src/manifest.js CHANGED
@@ -1,40 +1,21 @@
1
- 'use strict'
1
+ import clone from 'clone-deep'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { basename, dirname, join } from 'node:path'
5
+ import { merge } from '@toa.io/generic'
6
+ import { component as load, revive, NORMALIZED } from '@toa.io/norm'
7
+ import { shipped } from '@toa.io/definitions/digest'
8
+ import { Locator } from '@toa.io/core'
2
9
 
3
- const clone = require('clone-deep')
4
- const { merge } = require('@toa.io/generic')
5
- const { component: load } = require('@toa.io/norm')
6
- const { Locator } = require('@toa.io/core')
10
+ import { span } from './span.js'
7
11
 
8
- /**
9
- * @type {toa.boot.Manifest}
10
- */
11
- const manifest = async (path, options = {}) => {
12
+ export const manifest = async (path, options = {}) => {
12
13
  options = merge(clone(options), DEFAULTS)
13
14
 
14
- const manifest = await load(path)
15
-
16
- if (options?.bindings !== undefined) {
17
- if ('operations' in manifest) {
18
- for (const operation of Object.values(manifest.operations)) {
19
- operation.bindings = options.bindings
20
- }
21
- }
22
-
23
- const check = (binding) => require(binding).properties?.async === true
24
- const asyncBinding = options.bindings.find(check)
25
-
26
- if (asyncBinding === undefined) throw new Error('Bindings override must contain at least one async binding')
27
-
28
- if ('events' in manifest) {
29
- for (const event of Object.values(manifest.events)) event.binding = asyncBinding
30
- }
31
-
32
- if ('receivers' in manifest) {
33
- for (const receiver of Object.values(manifest.receivers)) {
34
- if (receiver.source === undefined) receiver.binding = asyncBinding
35
- }
36
- }
37
- }
15
+ const manifest = await span(
16
+ { name: `manifest ${basename(path)}`, attributes: { path } },
17
+ async () => await read(path)
18
+ )
38
19
 
39
20
  if (manifest.extensions === undefined) manifest.extensions = {}
40
21
 
@@ -44,13 +25,76 @@ const manifest = async (path, options = {}) => {
44
25
  }
45
26
  }
46
27
 
47
- if ('storage' in options && 'entity' in manifest) manifest.entity.storage = options.storage
28
+ if ('storage' in options && 'entity' in manifest)
29
+ manifest.entity.storage = options.storage
48
30
 
49
31
  manifest.locator = new Locator(manifest.name, manifest.namespace)
50
32
 
51
33
  return manifest
52
34
  }
53
35
 
54
- const DEFAULTS = {}
36
+ /**
37
+ * A manifest already normalised, where there is one, and the sources read where there is not.
38
+ *
39
+ * A component of an application's own comes with the manifest its build wrote beside it. A
40
+ * component an extension ships comes with none — its package is published once, long before
41
+ * any image is built — but `@toa.io/definitions` carries the digest of those manifests, which
42
+ * a deploy has read for as long as it has been unable to install the extension. A service runs
43
+ * them from the package, so it reads the same digest rather than reading their sources again.
44
+ *
45
+ * Only a workspace normalises, then, which is the one place a source can have changed since.
46
+ *
47
+ * @param {string} path
48
+ */
49
+ async function read(path) {
50
+ const file = join(path, NORMALIZED)
51
+
52
+ if (existsSync(file)) return revive(JSON.parse(await readFile(file, 'utf8')), path)
53
+
54
+ return (await digested(path)) ?? (await load(path))
55
+ }
55
56
 
56
- exports.manifest = manifest
57
+ /**
58
+ * What the digest holds for a component of an extension's own, by the directory it is in.
59
+ *
60
+ * An extension ships its components in `components/` beside its manifest and nowhere else, so
61
+ * that is the whole of what is looked for: anything else — an application's own component, a
62
+ * fixture in a workspace — is read from its sources.
63
+ *
64
+ * @param {string} path
65
+ * @returns {Promise<object | undefined>}
66
+ */
67
+ async function digested(path) {
68
+ const directory = dirname(path)
69
+
70
+ if (basename(directory) !== COMPONENTS) return undefined
71
+
72
+ const root = dirname(directory)
73
+ const manifest = join(root, MANIFEST)
74
+
75
+ if (!existsSync(manifest)) return undefined
76
+
77
+ const { name } = JSON.parse(readFileSync(manifest, 'utf8'))
78
+
79
+ // the digest is of what Toa ships, and a package it does not define has none
80
+ if (name === undefined || !name.startsWith(SCOPE)) return undefined
81
+
82
+ // every component the extension ships, not those a definition deploys by default: what an
83
+ // annotation adds, such as the encounters of Chronicles, runs in the service all the same
84
+ const { labels, manifests } = shipped(name.slice(SCOPE.length)) ?? {}
85
+
86
+ if (manifests === undefined) return undefined
87
+
88
+ // a chart names a component's workload the way the digest labels it
89
+ const index = labels.indexOf(basename(path).replace('.', '-'))
90
+
91
+ return index === -1 ? undefined : revive(manifests[index], path)
92
+ }
93
+
94
+ const SCOPE = '@toa.io/'
95
+
96
+ const COMPONENTS = 'components'
97
+
98
+ const MANIFEST = 'package.json'
99
+
100
+ const DEFAULTS = {}
@@ -1,12 +1,39 @@
1
- 'use strict'
1
+ import { it, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
2
3
 
3
- const { generate } = require('randomstring')
4
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+ import { generate } from 'randomstring'
4
8
 
5
- jest.mock('@toa.io/norm', () => ({
6
- component: () => mockComponent()
7
- }))
9
+ const NORMALIZED = 'manifest.toa.json'
8
10
 
9
- const { manifest } = require('./manifest')
11
+ let normalised = 0
12
+
13
+ const digest = {
14
+ labels: ['identity-basic'],
15
+ manifests: [{ name: 'basic', namespace: 'identity' }]
16
+ }
17
+
18
+ mock.module('@toa.io/norm', {
19
+ namedExports: {
20
+ component: () => {
21
+ normalised++
22
+
23
+ return mockComponent()
24
+ },
25
+ revive: (declared, path) => ({ ...declared, path }),
26
+ NORMALIZED
27
+ }
28
+ })
29
+
30
+ mock.module('@toa.io/definitions/digest', {
31
+ namedExports: {
32
+ shipped: (suffix) => (suffix === 'extensions.exposition' ? digest : undefined)
33
+ }
34
+ })
35
+
36
+ const { manifest } = await import('./manifest.js')
10
37
 
11
38
  const path = generate()
12
39
 
@@ -15,9 +42,65 @@ it('should not modify options', async () => {
15
42
 
16
43
  await manifest(path, options)
17
44
 
18
- expect(options.extensions.length).toStrictEqual(2)
45
+ assert.deepStrictEqual(options.extensions.length, 2)
46
+ })
47
+
48
+ it('should normalize a component the build did not', async () => {
49
+ const before = normalised
50
+
51
+ await manifest(path)
52
+
53
+ assert.strictEqual(normalised, before + 1)
54
+ })
55
+
56
+ it('should read what the build wrote, and not normalize', async () => {
57
+ const root = await mkdtemp(join(tmpdir(), 'toa-manifest-test'))
58
+ const declared = { name: 'tasks', namespace: 'todos', operations: { compute: {} } }
59
+
60
+ await writeFile(join(root, NORMALIZED), JSON.stringify(declared))
61
+
62
+ const before = normalised
63
+ const read = await manifest(root)
64
+
65
+ assert.strictEqual(normalised, before, 'the component was normalized')
66
+ assert.strictEqual(read.path, root)
67
+ assert.deepStrictEqual(read.operations, declared.operations)
68
+ assert.strictEqual(read.locator.id, 'todos.tasks')
69
+ })
70
+
71
+ it('should read the digest for a component an extension ships, and not normalize', async () => {
72
+ const root = await mkdtemp(join(tmpdir(), 'toa-manifest-test'))
73
+ const component = join(root, 'components', 'identity.basic')
74
+
75
+ await mkdir(component, { recursive: true })
76
+ await writeFile(
77
+ join(root, 'package.json'),
78
+ JSON.stringify({ name: '@toa.io/extensions.exposition' })
79
+ )
80
+
81
+ const before = normalised
82
+ const read = await manifest(component)
83
+
84
+ assert.strictEqual(normalised, before, 'the component was normalized')
85
+ assert.strictEqual(read.path, component)
86
+ assert.strictEqual(read.locator.id, 'identity.basic')
87
+ })
88
+
89
+ it('should normalize a component that only looks like one an extension ships', async () => {
90
+ const root = await mkdtemp(join(tmpdir(), 'toa-manifest-test'))
91
+ const component = join(root, 'components', 'identity.basic')
92
+
93
+ await mkdir(component, { recursive: true })
94
+ // an application's own package, which the digest knows nothing about
95
+ await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'acme' }))
96
+
97
+ const before = normalised
98
+
99
+ await manifest(component)
100
+
101
+ assert.strictEqual(normalised, before + 1)
19
102
  })
20
103
 
21
- function mockComponent () {
104
+ function mockComponent() {
22
105
  return { name: generate(), namespace: generate() }
23
106
  }
package/src/map.js ADDED
@@ -0,0 +1,108 @@
1
+ import assert from 'node:assert'
2
+ import { gunzipSync } from 'node:zlib'
3
+ import { readFile } from 'node:fs/promises'
4
+ import * as core from '@toa.io/core'
5
+
6
+ /**
7
+ * What this process was given about the components it calls: the contract of the version each of
8
+ * them runs.
9
+ *
10
+ * Held as the file it was given rather than as what the file said, and read at every call for
11
+ * one: a deployment rewrites it under a running process, and a process may first call a peer
12
+ * long after that peer was replaced. A contract is read once per peer, so this is a read per
13
+ * peer.
14
+ *
15
+ * @type {string | Record<string, import('@toa.io/core').Contract> | undefined}
16
+ */
17
+ let source
18
+
19
+ /**
20
+ * What this process composes, which it knows from the sources it booted from. What the map
21
+ * states wins: two compositions of one locator run in one process while a rollout is staged,
22
+ * and the map is what says which of them a caller is held to.
23
+ *
24
+ * @type {Record<string, import('@toa.io/core').Contract>}
25
+ */
26
+ let composed = {}
27
+
28
+ /**
29
+ * @param {string | Record<string, import('@toa.io/core').Contract> | undefined} value the file, or the map itself
30
+ */
31
+ export const use = (value) => {
32
+ source = value
33
+ }
34
+
35
+ /**
36
+ * Forgets what this process composed, which is what a test does between one composition and
37
+ * the next: a component that is no longer composed is one nothing states.
38
+ */
39
+ export const forget = () => {
40
+ composed = {}
41
+ }
42
+
43
+ /**
44
+ * What this process composes, stated by the boot that composes it — and held to the map it was
45
+ * given a file of, which one `toa deploy` writes with the images it deploys: the two differ only
46
+ * where the map was not written again. What a test states instead is what that test means, and
47
+ * is not checked.
48
+ *
49
+ * @param {toa.norm.Component[]} manifests
50
+ * @returns {Promise<void>}
51
+ */
52
+ export const compose = async (manifests) => {
53
+ const file = typeof source === 'string' ? await read(source) : undefined
54
+
55
+ for (const manifest of manifests) {
56
+ const stated = file?.[manifest.locator.id]
57
+
58
+ assert.ok(
59
+ stated === undefined || stated.version === manifest.version,
60
+ `'${manifest.locator.id}' is composed at a version the component map does not state. ` +
61
+ 'Run `toa map`.'
62
+ )
63
+
64
+ composed[manifest.locator.id] = core.contract.component(manifest)
65
+ }
66
+ }
67
+
68
+ /**
69
+ * What a component provides: what the map states of it, or what this process composes.
70
+ *
71
+ * @param {string} id
72
+ * @returns {Promise<import('@toa.io/core').Contract | undefined>}
73
+ */
74
+ export const contract = async (id) => {
75
+ if (source === undefined) return composed[id]
76
+ if (typeof source !== 'string') return source[id] ?? composed[id]
77
+
78
+ return (await read(source))[id] ?? composed[id]
79
+ }
80
+
81
+ /**
82
+ * A map is read gzipped where it is deployed and plain where it is not, so what it is is told by
83
+ * its first two bytes rather than by its name.
84
+ *
85
+ * @param {string} path
86
+ * @returns {Promise<Record<string, import('@toa.io/core').Contract>>}
87
+ */
88
+ async function read(path) {
89
+ let contents
90
+
91
+ try {
92
+ contents = await readFile(path)
93
+ } catch (cause) {
94
+ throw new Error(`Cannot read the component map '${path}'`, { cause })
95
+ }
96
+
97
+ try {
98
+ if (contents[0] === 0x1f && contents[1] === 0x8b) contents = gunzipSync(contents)
99
+ } catch (cause) {
100
+ throw new Error(`The component map '${path}' is not readable gzip`, { cause })
101
+ }
102
+
103
+ try {
104
+ return JSON.parse(contents.toString('utf8'))
105
+ } catch (cause) {
106
+ throw new Error(`The component map '${path}' is not JSON`, { cause })
107
+ }
108
+ }
package/src/operation.js CHANGED
@@ -1,18 +1,34 @@
1
- 'use strict'
1
+ import {
2
+ Transition,
3
+ Observation,
4
+ Assignment,
5
+ Operation,
6
+ Query,
7
+ Effect,
8
+ Unmanaged
9
+ } from '@toa.io/core'
2
10
 
3
- const { Transition, Observation, Assignment, Operation, Query } = require('@toa.io/core')
11
+ import * as boot from './index.js'
4
12
 
5
- const boot = require('./index')
6
-
7
- const operation = (manifest, endpoint, definition, context, scope) => {
8
- const cascade = boot.cascade(manifest, endpoint, definition, context)
9
- const reply = boot.contract.reply(definition.output, definition.error)
13
+ export async function operation(
14
+ manifest,
15
+ endpoint,
16
+ definition,
17
+ context,
18
+ scope,
19
+ preflight
20
+ ) {
21
+ const cascade = await boot.cascade(manifest, endpoint, definition, context, preflight)
22
+ const reply = boot.contract.reply(definition.output, definition.errors)
10
23
  const input = definition.input
11
- const request = boot.contract.request({ input }, manifest.entity)
24
+ const request = boot.contract.request(
25
+ { input, stream: definition.stream },
26
+ manifest.entity
27
+ )
12
28
  const contracts = { reply, request }
13
- const query = manifest.entity === undefined
14
- ? undefined
15
- : new Query(manifest.entity.schema.properties)
29
+
30
+ const query =
31
+ manifest.entity === undefined ? undefined : new Query(manifest.entity.properties)
16
32
 
17
33
  const Type = TYPES[definition.type]
18
34
 
@@ -24,7 +40,6 @@ const TYPES = {
24
40
  observation: Observation,
25
41
  assignment: Assignment,
26
42
  computation: Operation,
27
- effect: Observation
43
+ effect: Effect,
44
+ unmanaged: Unmanaged
28
45
  }
29
-
30
- exports.operation = operation
package/src/outbox.js ADDED
@@ -0,0 +1,22 @@
1
+ import { Outbox } from '@toa.io/core'
2
+
3
+ import * as boot from './index.js'
4
+
5
+ /**
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.
10
+ *
11
+ * @param {toa.norm.Component} manifest
12
+ * @param {import('@toa.io/core/types').storages.Storage} [storage]
13
+ * @param {import('@toa.io/core').Emission} [emission]
14
+ * @param {import('@toa.io/core/types').outbox.Destination[]} [contributed]
15
+ */
16
+ export const outbox = (manifest, storage, emission, contributed = []) => {
17
+ const destinations = emission === undefined ? contributed : [emission, ...contributed]
18
+
19
+ if (destinations.length === 0) return
20
+
21
+ return new Outbox(destinations, storage, boot.atomicity(manifest.locator.id), {})
22
+ }
package/src/rc.js ADDED
@@ -0,0 +1,5 @@
1
+ import * as boot from './index.js'
2
+
3
+ export async function rc(manifest, context) {
4
+ return boot.bridge.rc(manifest.bridge, manifest.path, context)
5
+ }
package/src/receivers.js CHANGED
@@ -1,34 +1,52 @@
1
- 'use strict'
1
+ import assert from 'node:assert'
2
+ import { Receiver, Locator } from '@toa.io/core'
2
3
 
3
- const { Receiver, Locator } = require('@toa.io/core')
4
+ import * as boot from './index.js'
5
+ import * as extensions from './extensions/index.js'
4
6
 
5
- const boot = require('./index')
6
- const extensions = require('./extensions')
7
-
8
- const receivers = async (manifest, runtime) => {
7
+ export const receivers = async (manifest, component) => {
9
8
  if (manifest.receivers === undefined) return []
10
9
 
11
10
  const receivers = []
12
- const local = await boot.remote(manifest.locator, manifest)
11
+ const local = await boot.remote(manifest.locator, undefined, { contract: manifest })
13
12
 
14
13
  for (const [label, definition] of Object.entries(manifest.receivers)) {
15
- const bridge = definition.bridge !== undefined ? boot.bridge.receiver(definition.bridge, manifest.path, label) : undefined
16
- const receiver = new Receiver(definition, local, bridge)
17
- const decorator = extensions.receiver(receiver, manifest.locator)
18
-
19
14
  const locator = Locator.parse(label)
20
- const transport = definition.binding ?? await resolveBinding(locator, label)
21
15
  const source = definition.source ? Locator.parse(definition.source) : locator
22
- const binding = boot.bindings.receive(transport, source, label, manifest.locator.id, decorator)
16
+ const event = label.split('.').pop()
17
+ const destination = `${source.id}.${event}`
18
+
19
+ // the origin of the calls this receiver makes to its local operation
20
+ const origin = { namespace: source.namespace, component: source.name, event }
21
+
22
+ const bridge =
23
+ definition.bridge !== undefined
24
+ ? await boot.bridge.receiver(definition.bridge, manifest.path, label)
25
+ : undefined
26
+ const receiver = new Receiver(
27
+ { ...definition, label, destination, origin },
28
+ local,
29
+ bridge
30
+ )
31
+ const decorator = extensions.receiver(receiver, manifest.locator)
23
32
 
24
- binding.depends(runtime)
33
+ const transport = definition.binding ?? (await resolveBinding(locator, label))
34
+ const binding = await boot.bindings.receive(
35
+ transport,
36
+ source,
37
+ label,
38
+ manifest.locator.id,
39
+ decorator
40
+ )
41
+
42
+ binding.depends(component)
25
43
  receivers.push(binding)
26
44
  }
27
45
 
28
46
  return receivers
29
47
  }
30
48
 
31
- async function receive (label, group, callback) {
49
+ export async function receive(label, group, callback) {
32
50
  if (callback === undefined) {
33
51
  callback = group
34
52
  group = undefined
@@ -37,21 +55,33 @@ async function receive (label, group, callback) {
37
55
  const locator = Locator.parse(label)
38
56
  const transport = await resolveBinding(locator, label)
39
57
 
40
- return boot.bindings.receive(transport, locator, label, group, callback)
58
+ return await boot.bindings.receive(transport, locator, label, group, callback)
41
59
  }
42
60
 
43
61
  /**
44
- * @param {toa.core.Locator} locator
62
+ * Which transport carries an event, as the component whose event it is declares it.
63
+ *
64
+ * @param {import('@toa.io/core').Locator} locator
45
65
  * @param {string} label
46
66
  * @return {Promise<string>}
47
67
  */
48
- async function resolveBinding (locator, label) {
68
+ async function resolveBinding(locator, label) {
49
69
  const event = label.split('.').pop()
50
- const discovery = await boot.discovery.discovery()
51
- const { events } = await discovery.lookup(locator)
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
79
+
80
+ assert.ok(
81
+ events?.[event] !== undefined,
82
+ `'${locator.id}'${version === undefined ? '' : ` at version ${version}`} declares no ` +
83
+ `event '${event}', which is what '${label}' receives`
84
+ )
52
85
 
53
86
  return events[event].binding
54
87
  }
55
-
56
- exports.receivers = receivers
57
- exports.receive = receive
@@ -0,0 +1,18 @@
1
+ import { Regional } from '@toa.io/core'
2
+
3
+ /**
4
+ * Gives every destination that carries a change to other regions the others that write to
5
+ * something each region keeps for itself. Neither knows what the other is: the carrier is given
6
+ * what they export, and hands back what arrives.
7
+ *
8
+ * @param {import('@toa.io/core/types').outbox.Destination[]} destinations
9
+ * @returns {void}
10
+ */
11
+ export const regional = (destinations) => {
12
+ const regionals = destinations.filter((destination) => Regional.is(destination))
13
+
14
+ if (regionals.length === 0) return
15
+
16
+ for (const destination of destinations)
17
+ if (destination.carries === true) destination.regional = new Regional(regionals)
18
+ }
@@ -0,0 +1,28 @@
1
+ import { it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { regional } from './regional.js'
5
+
6
+ const regionalDestination = (name) => ({
7
+ name,
8
+ export: async () => [name],
9
+ import: async () => undefined
10
+ })
11
+
12
+ it('should give a carrier the regional destinations', async () => {
13
+ const carrier = { name: 'convergence', carries: true }
14
+ const events = { name: 'events' }
15
+
16
+ regional([events, carrier, regionalDestination('realtime')])
17
+
18
+ assert.deepEqual(await carrier.regional.export({}), { realtime: ['realtime'] })
19
+ assert.equal('regional' in events, false)
20
+ })
21
+
22
+ it('should give a carrier nothing where no destination is regional', () => {
23
+ const carrier = { name: 'convergence', carries: true }
24
+
25
+ regional([carrier, { name: 'events' }])
26
+
27
+ assert.equal(carrier.regional, undefined)
28
+ })
package/src/remote.js CHANGED
@@ -1,25 +1,44 @@
1
- 'use strict'
2
-
3
- const { Remote } = require('@toa.io/core')
4
- const { remap } = require('@toa.io/generic')
5
-
6
- const boot = require('./index')
7
-
8
- const remote = async (locator, manifest = undefined) => {
9
- const discovery = await boot.discovery.discovery()
10
-
11
- if (manifest === undefined) manifest = await discovery.lookup(locator)
12
-
13
- const calls = manifest.operations === undefined
14
- ? {}
15
- : remap(manifest.operations, (definition, endpoint) => boot.call(locator, endpoint, definition, manifest.entity))
16
-
17
- const remote = new Remote(locator, calls)
1
+ import { Remote, contract as coreContract, exceptions } from '@toa.io/core'
2
+ import { remap } from '@toa.io/generic'
3
+
4
+ import * as boot from './index.js'
5
+
6
+ /**
7
+ * @param {import('@toa.io/core').Locator} locator
8
+ * @param {import('@toa.io/core/types').Source} [source] the origin stamped on every call made through this remote
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.
12
+ */
13
+ export const remote = async (locator, source, options = {}) => {
14
+ const stated = options.contract ?? (await boot.map.contract(locator.id))
15
+
16
+ if (stated === undefined)
17
+ throw new exceptions.UnstatedException(
18
+ `Cannot call '${locator.id}': the component map states nothing of it. Run \`toa map\`.`
19
+ )
20
+
21
+ // a contract states once, or not at all, what the runtime gives every component; the calls
22
+ // are made from the whole of it
23
+ const contract = coreContract.restore(stated)
24
+
25
+ // a call binds its consumers, which are loaded rather than required
26
+ const calls =
27
+ contract.operations === undefined
28
+ ? {}
29
+ : await settle(
30
+ remap(contract.operations, (definition, endpoint) =>
31
+ boot.call(locator, endpoint, definition, contract.entity, source)
32
+ )
33
+ )
34
+
35
+ return new Remote(locator, calls)
36
+ }
18
37
 
19
- // ensure discovery shutdown
20
- remote.depends(discovery)
38
+ /** An object whose values are promises, as an object of what they resolve to. */
39
+ async function settle(object) {
40
+ const entries = Object.entries(object)
41
+ const values = await Promise.all(entries.map(([, promise]) => promise))
21
42
 
22
- return remote
43
+ return Object.fromEntries(entries.map(([key], index) => [key, values[index]]))
23
44
  }
24
-
25
- exports.remote = remote