@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +313 -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 +56 -17
  14. package/src/bindings/receive.js +3 -6
  15. package/src/bridge.js +22 -16
  16. package/src/call.js +28 -9
  17. package/src/cascade.js +16 -10
  18. package/src/component.js +101 -32
  19. package/src/composition.js +106 -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 +78 -36
  43. package/src/manifest.test.js +85 -8
  44. package/src/map.js +108 -0
  45. package/src/operation.js +26 -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 +281 -0
  57. package/src/workload.test.js +321 -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
@@ -1,12 +1,33 @@
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 = { 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
- expect(options.extensions.length).toStrictEqual(2)
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
- '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({ input, stream: definition.stream }, manifest.entity)
12
25
  const contracts = { reply, request }
13
- const query = manifest.entity === undefined
14
- ? undefined
15
- : new Query(manifest.entity.schema.properties)
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: Operation
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
@@ -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
@@ -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
- 'use strict'
2
-
3
- const { join } = require('node:path')
4
- const extensions = require('./extensions')
5
-
6
- const storage = (manifest) => {
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
- return extensions.storage(storage)
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 (component) {
52
+ async function load(component) {
19
53
  const reference = component.entity.storage
20
- const path = require.resolve(reference, { paths: [component.path, __dirname] })
21
- const { Factory } = require(path)
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