@toa.io/boot 1.0.0-alpha.32 → 1.0.0-alpha.321
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 +313 -0
- package/package.json +11 -8
- package/src/atom.js +41 -0
- package/src/atomicity.js +11 -0
- package/src/bindings/broadcast.js +3 -6
- package/src/bindings/constants.js +1 -3
- package/src/bindings/consume.js +18 -6
- package/src/bindings/emit.js +3 -6
- package/src/bindings/factory.js +5 -9
- package/src/bindings/inbound.js +11 -0
- package/src/bindings/index.js +7 -13
- package/src/bindings/outbound.js +10 -0
- package/src/bindings/produce.js +56 -17
- package/src/bindings/receive.js +3 -6
- package/src/bridge.js +22 -16
- package/src/call.js +28 -9
- package/src/cascade.js +16 -10
- package/src/component.js +101 -32
- package/src/composition.js +106 -15
- package/src/context.js +7 -8
- package/src/contract.js +11 -12
- package/src/emission.js +21 -14
- package/src/env.js +19 -12
- package/src/events.js +27 -0
- package/src/extensions/aspects.js +4 -8
- package/src/extensions/component.js +4 -8
- package/src/extensions/context.js +4 -8
- package/src/extensions/destinations.js +27 -0
- package/src/extensions/emitter.js +7 -9
- package/src/extensions/index.js +10 -19
- package/src/extensions/instances.js +2 -4
- package/src/extensions/load.js +7 -7
- package/src/extensions/manage.js +15 -0
- package/src/extensions/receiver.js +5 -9
- package/src/extensions/resolve.js +62 -13
- package/src/extensions/storage.js +6 -9
- package/src/extensions/tenants.js +5 -9
- package/src/guards.js +20 -0
- package/src/host.js +29 -0
- package/src/inbox.js +12 -0
- package/src/index.js +29 -34
- package/src/manifest.js +78 -36
- package/src/manifest.test.js +85 -8
- package/src/map.js +108 -0
- package/src/operation.js +26 -14
- package/src/outbox.js +22 -0
- package/src/rc.js +5 -0
- package/src/receivers.js +53 -23
- package/src/regional.js +18 -0
- package/src/regional.test.js +28 -0
- package/src/remote.js +41 -22
- package/src/rendering.js +50 -0
- package/src/span.js +19 -0
- package/src/storage.js +50 -16
- package/src/storage.test.js +57 -0
- package/src/workload.js +281 -0
- package/src/workload.test.js +321 -0
- package/types/bindings.d.ts +6 -2
- package/types/index.d.ts +62 -10
- package/types/manifest.d.ts +5 -4
- package/src/discovery.js +0 -43
- package/src/exposition.js +0 -17
package/src/manifest.test.js
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
1
|
-
|
|
1
|
+
import { it, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
2
3
|
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
component: () => mockComponent()
|
|
7
|
-
}))
|
|
9
|
+
const NORMALIZED = 'manifest.toa.json'
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
let normalised = 0
|
|
12
|
+
|
|
13
|
+
const digest = { labels: ['identity-basic'], manifests: [{ name: 'basic', namespace: 'identity' }] }
|
|
14
|
+
|
|
15
|
+
mock.module('@toa.io/norm', {
|
|
16
|
+
namedExports: {
|
|
17
|
+
component: () => {
|
|
18
|
+
normalised++
|
|
19
|
+
|
|
20
|
+
return mockComponent()
|
|
21
|
+
},
|
|
22
|
+
definition: async (reference) => ({
|
|
23
|
+
module: reference === '@toa.io/extensions.exposition' ? { components: () => digest } : {}
|
|
24
|
+
}),
|
|
25
|
+
revive: (declared, path) => ({ ...declared, path }),
|
|
26
|
+
NORMALIZED
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const { manifest } = await import('./manifest.js')
|
|
10
31
|
|
|
11
32
|
const path = generate()
|
|
12
33
|
|
|
@@ -15,9 +36,65 @@ it('should not modify options', async () => {
|
|
|
15
36
|
|
|
16
37
|
await manifest(path, options)
|
|
17
38
|
|
|
18
|
-
|
|
39
|
+
assert.deepStrictEqual(options.extensions.length, 2)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should normalize a component the build did not', async () => {
|
|
43
|
+
const before = normalised
|
|
44
|
+
|
|
45
|
+
await manifest(path)
|
|
46
|
+
|
|
47
|
+
assert.strictEqual(normalised, before + 1)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should read what the build wrote, and not normalize', async () => {
|
|
51
|
+
const root = await mkdtemp(join(tmpdir(), 'toa-manifest-test'))
|
|
52
|
+
const declared = { name: 'tasks', namespace: 'todos', operations: { compute: {} } }
|
|
53
|
+
|
|
54
|
+
await writeFile(join(root, NORMALIZED), JSON.stringify(declared))
|
|
55
|
+
|
|
56
|
+
const before = normalised
|
|
57
|
+
const read = await manifest(root)
|
|
58
|
+
|
|
59
|
+
assert.strictEqual(normalised, before, 'the component was normalized')
|
|
60
|
+
assert.strictEqual(read.path, root)
|
|
61
|
+
assert.deepStrictEqual(read.operations, declared.operations)
|
|
62
|
+
assert.strictEqual(read.locator.id, 'todos.tasks')
|
|
63
|
+
})
|
|
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)
|
|
19
96
|
})
|
|
20
97
|
|
|
21
|
-
function mockComponent
|
|
98
|
+
function mockComponent() {
|
|
22
99
|
return { name: generate(), namespace: generate() }
|
|
23
100
|
}
|
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,31 @@
|
|
|
1
|
-
|
|
1
|
+
import {
|
|
2
|
+
Transition,
|
|
3
|
+
Observation,
|
|
4
|
+
Assignment,
|
|
5
|
+
Operation,
|
|
6
|
+
Query,
|
|
7
|
+
Effect,
|
|
8
|
+
Unmanaged
|
|
9
|
+
} from '@toa.io/core'
|
|
2
10
|
|
|
3
|
-
|
|
11
|
+
import * as boot from './index.js'
|
|
4
12
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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({ input, stream: definition.stream }, manifest.entity)
|
|
12
25
|
const contracts = { reply, request }
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
: new Query(manifest.entity.
|
|
26
|
+
|
|
27
|
+
const query =
|
|
28
|
+
manifest.entity === undefined ? undefined : new Query(manifest.entity.properties)
|
|
16
29
|
|
|
17
30
|
const Type = TYPES[definition.type]
|
|
18
31
|
|
|
@@ -24,7 +37,6 @@ const TYPES = {
|
|
|
24
37
|
observation: Observation,
|
|
25
38
|
assignment: Assignment,
|
|
26
39
|
computation: Operation,
|
|
27
|
-
effect:
|
|
40
|
+
effect: Effect,
|
|
41
|
+
unmanaged: Unmanaged
|
|
28
42
|
}
|
|
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
package/src/receivers.js
CHANGED
|
@@ -1,34 +1,52 @@
|
|
|
1
|
-
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { Receiver, Locator } from '@toa.io/core'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
import * as boot from './index.js'
|
|
5
|
+
import * as extensions from './extensions/index.js'
|
|
4
6
|
|
|
5
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
68
|
+
async function resolveBinding(locator, label) {
|
|
49
69
|
const event = label.split('.').pop()
|
|
50
|
-
const
|
|
51
|
-
|
|
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
|
package/src/regional.js
ADDED
|
@@ -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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
43
|
+
return Object.fromEntries(entries.map(([key], index) => [key, values[index]]))
|
|
23
44
|
}
|
|
24
|
-
|
|
25
|
-
exports.remote = remote
|
package/src/rendering.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Event, Rendering } from '@toa.io/core'
|
|
2
|
+
|
|
3
|
+
import * as boot from './index.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gives every destination that names the events it renders a `Rendering` of them, made with the
|
|
7
|
+
* component's own bridges. Every event of the manifest may be named, including those nothing
|
|
8
|
+
* consumes and are therefore not published: a destination writes somewhere of its own.
|
|
9
|
+
*
|
|
10
|
+
* @param {toa.norm.Component} manifest
|
|
11
|
+
* @param {import('@toa.io/core/types').outbox.Destination[]} destinations
|
|
12
|
+
* @param {import('@toa.io/core').Context} context
|
|
13
|
+
* @returns {Promise<void>}
|
|
14
|
+
*/
|
|
15
|
+
export const rendering = async (manifest, destinations, context) => {
|
|
16
|
+
for (const destination of destinations) {
|
|
17
|
+
if (destination.renders === undefined) continue
|
|
18
|
+
|
|
19
|
+
const events = new Map()
|
|
20
|
+
|
|
21
|
+
for (const label of destination.renders) {
|
|
22
|
+
const definition = manifest.events?.[label]
|
|
23
|
+
|
|
24
|
+
if (definition === undefined)
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Component '${manifest.locator.id}' has no event '${label}' to render for ` +
|
|
27
|
+
`'${destination.name}'`
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const bridge = await boot.bridge.event(
|
|
31
|
+
definition.bridge,
|
|
32
|
+
definition.path,
|
|
33
|
+
label,
|
|
34
|
+
context
|
|
35
|
+
)
|
|
36
|
+
const event = new Event(
|
|
37
|
+
{ ...definition, label: `${manifest.locator.id}.${label}` },
|
|
38
|
+
null,
|
|
39
|
+
bridge
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
events.set(label, event)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const rendering = new Rendering(events)
|
|
46
|
+
|
|
47
|
+
destination.rendering = rendering
|
|
48
|
+
destination.depends(rendering)
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/span.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { console, traces } from 'openspan'
|
|
2
|
+
import { environment } from '@toa.io/generic'
|
|
3
|
+
|
|
4
|
+
const enabled = environment.get('TOA_BOOT_TRACE') === '1'
|
|
5
|
+
|
|
6
|
+
// Tracing is off until an exporter is configured, and the CLI opens its own span before
|
|
7
|
+
// any extension gets to configure one — so asking for a boot trace turns the console
|
|
8
|
+
// exporter on here. Extensions keep it: see `development()` in the telemetry factory.
|
|
9
|
+
if (enabled) traces({ exporters: { console: {} } })
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates a span if boot tracing is enabled (TOA_BOOT_TRACE=1), otherwise just runs the task
|
|
13
|
+
*
|
|
14
|
+
* @param {string | object} options
|
|
15
|
+
* @param {() => Promise<any>} task
|
|
16
|
+
* @returns {Promise<any>}
|
|
17
|
+
*/
|
|
18
|
+
export const span = async (options, task) =>
|
|
19
|
+
enabled ? console.span(options, task) : task()
|
package/src/storage.js
CHANGED
|
@@ -1,26 +1,60 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import * as extensions from './extensions/index.js'
|
|
4
|
+
|
|
5
|
+
// import.meta.resolve takes no paths, and the connector is resolved against the
|
|
6
|
+
// component that names it
|
|
7
|
+
const require = createRequire(import.meta.url)
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {toa.norm.Component} manifest
|
|
11
|
+
* @param {boolean} outbox whether this component publishes anything, and so needs a place to
|
|
12
|
+
* commit it with the entity
|
|
13
|
+
* @param {boolean} inbox whether any of its operations declares `once`, and so needs a place to
|
|
14
|
+
* record the call it commits
|
|
15
|
+
*/
|
|
16
|
+
export const storage = async (manifest, outbox, inbox) => {
|
|
7
17
|
if (manifest.entity === undefined) return
|
|
8
18
|
|
|
9
|
-
const Factory = load(manifest)
|
|
19
|
+
const Factory = await load(manifest)
|
|
10
20
|
|
|
11
|
-
/** @type {toa.core.storages.Factory} */
|
|
21
|
+
/** @type {import('@toa.io/core/types').storages.Factory} */
|
|
12
22
|
const factory = new Factory()
|
|
13
|
-
const storage = factory.storage(manifest.locator, manifest.entity)
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
const storage = factory.storage(manifest.locator, manifest.entity, { outbox, inbox })
|
|
24
|
+
|
|
25
|
+
// a component whose structure nothing will make must not start with the structure it lacks;
|
|
26
|
+
// what it inherits was written for descendants that store, and one that stores nothing has
|
|
27
|
+
// nothing to convert
|
|
28
|
+
const own = manifest.entity.migrations?.some(
|
|
29
|
+
(migration) => migration.prototype === undefined
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if (own === true && storage.migrates !== true)
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Component '${manifest.locator.id}' declares migrations, ` +
|
|
35
|
+
`which storage '${manifest.entity.storage}' does not apply`
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* Refused here rather than degraded: a storage that cannot record a call would run every
|
|
40
|
+
* duplicate of it, which is what declaring `once` asked not to happen. Whether the deployment
|
|
41
|
+
* can commit one is the storage's own to refuse, once it is connected.
|
|
42
|
+
*/
|
|
43
|
+
if (inbox && storage.claims !== true)
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Component '${manifest.locator.id}' declares 'once', ` +
|
|
46
|
+
`which storage '${manifest.entity.storage}' does not provide`
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return extensions.storage(storage, manifest.locator)
|
|
16
50
|
}
|
|
17
51
|
|
|
18
|
-
function load
|
|
52
|
+
async function load(component) {
|
|
19
53
|
const reference = component.entity.storage
|
|
20
|
-
const path = require.resolve(reference, {
|
|
21
|
-
|
|
54
|
+
const path = require.resolve(reference, {
|
|
55
|
+
paths: [component.path, import.meta.dirname]
|
|
56
|
+
})
|
|
57
|
+
const { Factory } = await import(pathToFileURL(path).href)
|
|
22
58
|
|
|
23
59
|
return Factory
|
|
24
60
|
}
|
|
25
|
-
|
|
26
|
-
exports.storage = storage
|