@toa.io/core 1.0.0-alpha.27 → 1.0.0-alpha.270
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 +81 -0
- package/package.json +7 -9
- package/src/call.js +21 -1
- package/src/cascade.js +7 -5
- package/src/component.js +59 -15
- package/src/composition.js +1 -1
- package/src/connector.js +28 -17
- package/src/context.js +6 -0
- package/src/contract/contract.js +22 -0
- package/src/contract/reply.js +26 -9
- package/src/contract/request.js +19 -5
- package/src/contract/schemas/index.js +1 -0
- package/src/contract/schemas/query.yaml +14 -1
- package/src/contract/schemas/source.yaml +23 -0
- package/src/discovery.js +13 -7
- package/src/effect.js +19 -0
- package/src/entities/changeset.js +5 -8
- package/src/entities/entity.js +76 -24
- package/src/entities/factory.js +17 -6
- package/src/entities/newid.js +11 -0
- package/src/entities/set.js +13 -0
- package/src/event.js +19 -1
- package/src/exceptions.js +26 -19
- package/src/exposition.js +3 -2
- package/src/guard.js +17 -0
- package/src/index.js +6 -0
- package/src/observation.js +1 -9
- package/src/operation.js +38 -8
- package/src/query/options.js +3 -2
- package/src/query.js +32 -2
- package/src/receiver.js +73 -11
- package/src/remote.js +8 -7
- package/src/state.js +74 -35
- package/src/transition.js +11 -19
- package/src/transmission.js +12 -3
- package/src/unmanaged.js +11 -0
- package/test/component.test.js +4 -3
- package/test/connector.fixtures.js +8 -0
- package/test/connector.test.js +20 -0
- package/test/contract/conditions.test.js +5 -5
- package/test/contract/request.test.js +46 -7
- package/test/discovery.test.js +56 -0
- package/test/entities/entity.test.js +54 -40
- package/test/entities/factory.test.js +5 -3
- package/test/event.test.js +4 -4
- package/test/query.test.js +17 -0
- package/test/receiver.fixtures.js +1 -0
- package/test/state.test.js +13 -12
- package/types/bindings.d.ts +7 -5
- package/types/bridges.ts +3 -0
- package/types/component.d.ts +4 -1
- package/types/context.d.ts +3 -0
- package/types/entity.d.ts +2 -2
- package/types/extensions.d.ts +7 -4
- package/types/index.ts +2 -1
- package/types/message.d.ts +1 -0
- package/types/operations.d.ts +6 -0
- package/types/query.d.ts +2 -0
- package/types/remote.d.ts +18 -0
- package/types/request.d.ts +15 -0
- package/types/state.d.ts +2 -2
- package/types/storages.d.ts +11 -9
- package/src/contract/conditions.js +0 -21
package/src/receiver.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const { console, decode, run } = require('openspan')
|
|
3
4
|
const { add } = require('@toa.io/generic')
|
|
4
5
|
const { Connector } = require('./connector')
|
|
5
6
|
|
|
@@ -16,18 +17,28 @@ class Receiver extends Connector {
|
|
|
16
17
|
/** @type {string} */
|
|
17
18
|
#endpoint
|
|
18
19
|
|
|
19
|
-
/** @type {
|
|
20
|
+
/** @type {string} */
|
|
21
|
+
#label
|
|
22
|
+
|
|
23
|
+
/** @type {string} */
|
|
24
|
+
#destination
|
|
25
|
+
|
|
26
|
+
/** @type {unknown[]} */
|
|
27
|
+
#arguments
|
|
28
|
+
|
|
29
|
+
/** @type {toa.core.Source} */
|
|
30
|
+
#origin
|
|
31
|
+
|
|
20
32
|
#local
|
|
21
33
|
|
|
22
|
-
/** @type {toa.core.bridges.Receiver} */
|
|
23
34
|
#bridge
|
|
24
35
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
/** @type {object} */
|
|
37
|
+
#delivery
|
|
38
|
+
|
|
39
|
+
/** @type {object} */
|
|
40
|
+
#processing
|
|
41
|
+
|
|
31
42
|
constructor (definition, local, bridge) {
|
|
32
43
|
super()
|
|
33
44
|
|
|
@@ -36,17 +47,42 @@ class Receiver extends Connector {
|
|
|
36
47
|
this.#conditioned = conditioned
|
|
37
48
|
this.#adaptive = adaptive
|
|
38
49
|
this.#endpoint = operation
|
|
50
|
+
this.#label = definition.label ?? operation
|
|
51
|
+
this.#destination = definition.destination ?? this.#label
|
|
52
|
+
this.#arguments = definition.arguments
|
|
53
|
+
this.#origin = definition.origin
|
|
39
54
|
|
|
40
55
|
this.#local = local
|
|
41
56
|
this.#bridge = bridge
|
|
42
57
|
|
|
43
58
|
this.depends(local)
|
|
44
59
|
if (bridge !== undefined) this.depends(bridge)
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
* The delivery span is created on behalf of the messaging destination, so that
|
|
63
|
+
* each consumer forms its own complete producer/consumer pair, and service graphs
|
|
64
|
+
* display fan-out correctly: producer -> destination -> each consumer
|
|
65
|
+
* (Tempo pairs spans one-to-one, thus multiple consumers can not pair
|
|
66
|
+
* with a single producer span, see grafana/tempo#5408)
|
|
67
|
+
*/
|
|
68
|
+
this.#delivery = {
|
|
69
|
+
name: `${this.#label} deliver`,
|
|
70
|
+
kind: 'producer',
|
|
71
|
+
service: this.#destination,
|
|
72
|
+
attributes: { 'messaging.destination.name': this.#destination }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.#processing = {
|
|
76
|
+
name: `${this.#label} process`,
|
|
77
|
+
kind: 'consumer',
|
|
78
|
+
service: local.locator.id,
|
|
79
|
+
attributes: { 'messaging.destination.name': this.#destination }
|
|
80
|
+
}
|
|
45
81
|
}
|
|
46
82
|
|
|
47
83
|
/** @hot */
|
|
48
84
|
async receive (message) {
|
|
49
|
-
const { payload, ...extensions } = message
|
|
85
|
+
const { payload, telemetry, ...extensions } = message
|
|
50
86
|
|
|
51
87
|
if (this.#conditioned && await this.#bridge.condition(payload) === false) return
|
|
52
88
|
|
|
@@ -54,11 +90,37 @@ class Receiver extends Connector {
|
|
|
54
90
|
|
|
55
91
|
add(request, extensions)
|
|
56
92
|
|
|
57
|
-
|
|
93
|
+
// set after `add`, so that a message field can not spoof the origin
|
|
94
|
+
if (this.#origin !== undefined) request.source = this.#origin
|
|
95
|
+
|
|
96
|
+
// continue the trace from the producer span
|
|
97
|
+
const remote = telemetry === undefined ? null : decode(telemetry)
|
|
98
|
+
const task = () => this.#process(request)
|
|
99
|
+
|
|
100
|
+
if (remote === null)
|
|
101
|
+
await task()
|
|
102
|
+
else
|
|
103
|
+
await run(remote, task)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async #process (request) {
|
|
107
|
+
return console.span(this.#delivery, async () => console.span(this.#processing, async () => {
|
|
108
|
+
try {
|
|
109
|
+
await this.#local.invoke(this.#endpoint, request)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('Receiver error', {
|
|
112
|
+
component: this.#local.locator.id,
|
|
113
|
+
endpoint: this.#endpoint,
|
|
114
|
+
error
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
}))
|
|
58
120
|
}
|
|
59
121
|
|
|
60
122
|
async #request (payload) {
|
|
61
|
-
return this.#adaptive ? await this.#bridge.request(payload) : { input: payload }
|
|
123
|
+
return this.#adaptive ? await this.#bridge.request(payload, ...(this.#arguments ?? [])) : { input: payload }
|
|
62
124
|
}
|
|
63
125
|
}
|
|
64
126
|
|
package/src/remote.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
|
|
3
|
+
const assert = require('node:assert')
|
|
5
4
|
const { Component } = require('./component')
|
|
6
5
|
|
|
7
6
|
class Remote extends Component {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
kind = 'client'
|
|
8
|
+
|
|
9
|
+
explain (endpoint) {
|
|
10
|
+
if (!(endpoint in this.operations))
|
|
11
|
+
// `assert.fail`, not `assert.ok`: the message is built only when it is needed
|
|
12
|
+
assert.fail(`Endpoint '${endpoint}' is not provided by '${this.locator.id}'`)
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
console.info(`Remote '${this.locator.id}' disconnected`)
|
|
14
|
+
return this.operations[endpoint].explain()
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
17
|
|
package/src/state.js
CHANGED
|
@@ -1,73 +1,109 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const {
|
|
4
|
-
|
|
5
|
-
const {
|
|
6
|
-
StatePreconditionException,
|
|
7
|
-
StateNotFoundException
|
|
8
|
-
} = require('./exceptions')
|
|
3
|
+
const { StatePreconditionException, StateNotFoundException } = require('./exceptions')
|
|
9
4
|
|
|
10
5
|
class State {
|
|
6
|
+
storage
|
|
7
|
+
|
|
11
8
|
#associated
|
|
12
|
-
#
|
|
13
|
-
#entity
|
|
9
|
+
#entities
|
|
14
10
|
#emission
|
|
15
11
|
|
|
16
12
|
constructor (storage, entity, emission, associated) {
|
|
17
|
-
this
|
|
18
|
-
this.#
|
|
13
|
+
this.storage = storage
|
|
14
|
+
this.#entities = entity
|
|
19
15
|
this.#emission = emission
|
|
20
16
|
this.#associated = associated === true
|
|
21
17
|
}
|
|
22
18
|
|
|
23
19
|
init (id) {
|
|
24
|
-
return this.#
|
|
20
|
+
return this.#entities.init(id)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fit (values) {
|
|
24
|
+
return this.#entities.fit(values)
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
async object (query) {
|
|
28
|
-
const record = await this
|
|
27
|
+
async object (query, mutable = true) {
|
|
28
|
+
const record = await this.storage.get(query)
|
|
29
29
|
|
|
30
30
|
if (record === null) {
|
|
31
|
-
if (this.#associated && query.id !== undefined && query.version === undefined)
|
|
31
|
+
if (this.#associated && query.id !== undefined && query.criteria === undefined && query.version === undefined)
|
|
32
32
|
return this.init(query.id)
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
else if (query.version !== undefined)
|
|
34
|
+
throw new StatePreconditionException()
|
|
35
35
|
|
|
36
|
-
if (record === null) {
|
|
37
36
|
return null
|
|
38
|
-
} else
|
|
39
|
-
return this.#
|
|
40
|
-
}
|
|
37
|
+
} else
|
|
38
|
+
return this.#entities.object(record, mutable)
|
|
41
39
|
}
|
|
42
40
|
|
|
43
|
-
async objects (query) {
|
|
44
|
-
const recordset = await this
|
|
41
|
+
async objects (query, mutable = true) {
|
|
42
|
+
const recordset = await this.storage.find(query)
|
|
43
|
+
const missing = this.#associated && query.ids !== undefined && recordset.length < query.ids.length
|
|
44
|
+
const init = missing ? query.ids.filter((id) => !recordset.some((record) => record.id === id)) : undefined
|
|
45
|
+
|
|
46
|
+
return this.#entities.objects(recordset, init, mutable)
|
|
47
|
+
}
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
async stream (query) {
|
|
50
|
+
return this.storage.stream(query)
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
changeset (query) {
|
|
50
|
-
return this.#
|
|
54
|
+
return this.#entities.changeset(query)
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
none () {
|
|
54
58
|
return null
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
async
|
|
58
|
-
const
|
|
61
|
+
async ensure (query, properties, input) {
|
|
62
|
+
const object = this.#entities.init()
|
|
63
|
+
const blank = object.get()
|
|
59
64
|
|
|
60
|
-
|
|
65
|
+
Object.assign(blank, properties)
|
|
61
66
|
|
|
62
|
-
|
|
63
|
-
const object = state.get()
|
|
67
|
+
object.set(blank)
|
|
64
68
|
|
|
65
|
-
|
|
69
|
+
const record = await this.storage.ensure(query, properties, object.get())
|
|
66
70
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
if (record.id !== blank.id) // exists
|
|
72
|
+
return this.#entities.object(record, NOT_MUTABLE)
|
|
73
|
+
|
|
74
|
+
const event = object.event(input)
|
|
75
|
+
|
|
76
|
+
await this.#emission.emit(event)
|
|
77
|
+
|
|
78
|
+
return object
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async commit (state, input) {
|
|
82
|
+
if (state.constructor.name === 'EntitySet')
|
|
83
|
+
return this.massCommit(state, input)
|
|
84
|
+
|
|
85
|
+
const data = state.get()
|
|
86
|
+
const ok = await this.storage.store(data)
|
|
87
|
+
|
|
88
|
+
// #20
|
|
89
|
+
if (ok === true) {
|
|
90
|
+
const event = state.event(input)
|
|
91
|
+
|
|
92
|
+
await this.#emission.emit(event)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return ok
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async massCommit (state, input) {
|
|
99
|
+
const data = state.get()
|
|
100
|
+
const ok = await this.storage.massStore(data)
|
|
101
|
+
|
|
102
|
+
// #20
|
|
103
|
+
if (ok === true) {
|
|
104
|
+
const events = state.events(input)
|
|
105
|
+
|
|
106
|
+
await Promise.all(events.map((event) => this.#emission.emit(event)))
|
|
71
107
|
}
|
|
72
108
|
|
|
73
109
|
return ok
|
|
@@ -76,7 +112,7 @@ class State {
|
|
|
76
112
|
async apply (state) {
|
|
77
113
|
const changeset = state.export()
|
|
78
114
|
|
|
79
|
-
const result = await this
|
|
115
|
+
const result = await this.storage.upsert(state.query, changeset)
|
|
80
116
|
|
|
81
117
|
if (result === null) {
|
|
82
118
|
if (state.query.version !== undefined) {
|
|
@@ -96,4 +132,7 @@ class State {
|
|
|
96
132
|
}
|
|
97
133
|
}
|
|
98
134
|
|
|
135
|
+
/** an effect never commits what `ensure` hands it, see `Effect` */
|
|
136
|
+
const NOT_MUTABLE = false
|
|
137
|
+
|
|
99
138
|
exports.State = State
|
package/src/transition.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { retry } = require('@toa.io/generic')
|
|
4
|
-
|
|
5
4
|
const { Operation } = require('./operation')
|
|
6
|
-
const {
|
|
7
|
-
StateConcurrencyException,
|
|
8
|
-
StateNotFoundException
|
|
9
|
-
} = require('./exceptions')
|
|
5
|
+
const { StateConcurrencyException, StateNotFoundException } = require('./exceptions')
|
|
10
6
|
|
|
11
7
|
class Transition extends Operation {
|
|
8
|
+
/** a transition is the only operation that commits */
|
|
9
|
+
mutable = true
|
|
10
|
+
|
|
12
11
|
#concurrency
|
|
13
12
|
|
|
14
13
|
constructor (cascade, scope, contract, query, definition) {
|
|
@@ -26,33 +25,26 @@ class Transition extends Operation {
|
|
|
26
25
|
|
|
27
26
|
store.scope = request.query ? await this.query(request.query) : this.scope.init()
|
|
28
27
|
|
|
29
|
-
if (store.scope === null)
|
|
28
|
+
if (store.scope === null || (store.scope.deleted === true && request.query?.options?.deleted !== true))
|
|
30
29
|
throw new StateNotFoundException()
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
|
|
33
31
|
store.state = store.scope.get()
|
|
34
32
|
}
|
|
35
33
|
|
|
36
34
|
async commit (store) {
|
|
37
|
-
const {
|
|
38
|
-
scope,
|
|
39
|
-
state,
|
|
40
|
-
reply,
|
|
41
|
-
retry
|
|
42
|
-
} = store
|
|
35
|
+
const { scope, state, reply, retry } = store
|
|
43
36
|
|
|
44
37
|
if (reply.error !== undefined) return
|
|
45
38
|
|
|
46
39
|
scope.set(state)
|
|
47
40
|
|
|
48
|
-
const result = await this.scope.commit(scope)
|
|
41
|
+
const result = await this.scope.commit(scope, store.request.input)
|
|
49
42
|
|
|
50
43
|
if (result === false) {
|
|
51
|
-
if (this.#concurrency === 'retry')
|
|
44
|
+
if (this.#concurrency === 'retry')
|
|
52
45
|
return retry()
|
|
53
|
-
|
|
46
|
+
else
|
|
54
47
|
throw new StateConcurrencyException()
|
|
55
|
-
}
|
|
56
48
|
}
|
|
57
49
|
}
|
|
58
50
|
|
|
@@ -67,7 +59,7 @@ const RETRY = {
|
|
|
67
59
|
base: 10,
|
|
68
60
|
max: 5000,
|
|
69
61
|
dispersion: 1,
|
|
70
|
-
retries:
|
|
62
|
+
retries: 32
|
|
71
63
|
}
|
|
72
64
|
|
|
73
65
|
exports.Transition = Transition
|
package/src/transmission.js
CHANGED
|
@@ -18,13 +18,22 @@ class Transmission extends Connector {
|
|
|
18
18
|
let i = 0
|
|
19
19
|
|
|
20
20
|
while (reply === false && i < this.#bindings.length) {
|
|
21
|
-
|
|
21
|
+
const binding = this.#bindings[i]
|
|
22
|
+
|
|
22
23
|
i++
|
|
24
|
+
|
|
25
|
+
if (request?.task === true) {
|
|
26
|
+
if (binding.task === undefined)
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
await binding.task(request)
|
|
30
|
+
reply = null
|
|
31
|
+
} else
|
|
32
|
+
reply = await binding.request(request)
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
if (reply === false)
|
|
35
|
+
if (reply === false)
|
|
26
36
|
throw new TransmissionException(`All (${this.#bindings.length}) bindings rejected.`)
|
|
27
|
-
}
|
|
28
37
|
|
|
29
38
|
return reply
|
|
30
39
|
}
|
package/src/unmanaged.js
ADDED
package/test/component.test.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { Component } = require('../src/component')
|
|
4
4
|
const { codes } = require('../src/exceptions')
|
|
5
5
|
const fixtures = require('./component.fixtures')
|
|
6
|
+
const { AssertionError } = require('node:assert')
|
|
6
7
|
|
|
7
8
|
describe('Invocations', () => {
|
|
8
9
|
const name = ['foo', 'bar'][Math.floor(2 * Math.random())]
|
|
@@ -16,12 +17,12 @@ describe('Invocations', () => {
|
|
|
16
17
|
it('should invoke', async () => {
|
|
17
18
|
await component.invoke(name)
|
|
18
19
|
|
|
19
|
-
expect(invocation.invoke).
|
|
20
|
+
expect(invocation.invoke).toHaveBeenCalled()
|
|
20
21
|
})
|
|
21
22
|
|
|
22
23
|
it('should throw on unknown invocation name', async () => {
|
|
23
24
|
await expect(() => component.invoke('baz'))
|
|
24
|
-
.rejects.
|
|
25
|
+
.rejects.toThrow(AssertionError)
|
|
25
26
|
})
|
|
26
27
|
|
|
27
28
|
it('should invoke input and query', async () => {
|
|
@@ -29,7 +30,7 @@ describe('Invocations', () => {
|
|
|
29
30
|
const query = { test: Math.random() }
|
|
30
31
|
await component.invoke(name, { input, query })
|
|
31
32
|
|
|
32
|
-
expect(invocation.invoke).
|
|
33
|
+
expect(invocation.invoke).toHaveBeenCalledWith({ input, query })
|
|
33
34
|
})
|
|
34
35
|
|
|
35
36
|
it('should return io', async () => {
|
|
@@ -36,5 +36,13 @@ class FailingConnector extends Connector {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** Stands for a connector waiting for something that never arrives. */
|
|
40
|
+
class StuckConnector extends Connector {
|
|
41
|
+
async open () {
|
|
42
|
+
return new Promise(() => undefined)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
exports.TestConnector = TestConnector
|
|
40
47
|
exports.FailingConnector = FailingConnector
|
|
48
|
+
exports.StuckConnector = StuckConnector
|
package/test/connector.test.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const { timeout } = require('@toa.io/generic')
|
|
4
|
+
|
|
3
5
|
const fixtures = require('./connector.fixtures')
|
|
4
6
|
|
|
5
7
|
let sequence
|
|
@@ -105,6 +107,7 @@ describe('dependencies', () => {
|
|
|
105
107
|
a.depends(b).depends(c)
|
|
106
108
|
b.depends(d)
|
|
107
109
|
|
|
110
|
+
await a.connect()
|
|
108
111
|
await a.disconnect()
|
|
109
112
|
|
|
110
113
|
expect(sequence.indexOf('-a')).toBeLessThan(sequence.indexOf('-b'))
|
|
@@ -146,6 +149,23 @@ describe('dependencies', () => {
|
|
|
146
149
|
expect(() => a.depends({})).toThrow()
|
|
147
150
|
})
|
|
148
151
|
|
|
152
|
+
it('should disconnect while still connecting', async () => {
|
|
153
|
+
const stuck = new fixtures.StuckConnector()
|
|
154
|
+
|
|
155
|
+
a.depends(stuck).depends(b)
|
|
156
|
+
|
|
157
|
+
void a.connect()
|
|
158
|
+
|
|
159
|
+
while (b.connected !== true) await timeout(1)
|
|
160
|
+
|
|
161
|
+
// `a` never connects, and disconnecting must not wait for it to
|
|
162
|
+
expect(a.connected).toStrictEqual(false)
|
|
163
|
+
|
|
164
|
+
await a.disconnect()
|
|
165
|
+
|
|
166
|
+
expect(sequence).toEqual(['+b', '-b', '*b', '*a'])
|
|
167
|
+
})
|
|
168
|
+
|
|
149
169
|
describe('errors', () => {
|
|
150
170
|
let f
|
|
151
171
|
|
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
const { generate } = require('randomstring')
|
|
4
4
|
|
|
5
|
-
const {
|
|
5
|
+
const { Contract } = require('../../src/contract/contract')
|
|
6
6
|
const fixtures = require('./contract.fixtures')
|
|
7
7
|
|
|
8
|
-
let
|
|
8
|
+
let contract
|
|
9
9
|
|
|
10
10
|
beforeEach(() => {
|
|
11
|
-
|
|
11
|
+
contract = new Contract(fixtures.schema)
|
|
12
12
|
})
|
|
13
13
|
|
|
14
14
|
it('should fit value', () => {
|
|
15
15
|
const value = { foo: generate() }
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
contract.fit(value)
|
|
18
18
|
|
|
19
19
|
expect(fixtures.schema.fit).toHaveBeenCalledWith(value)
|
|
20
20
|
})
|
|
@@ -22,5 +22,5 @@ it('should fit value', () => {
|
|
|
22
22
|
it('should throw on invalid value', () => {
|
|
23
23
|
const value = { invalid: true }
|
|
24
24
|
|
|
25
|
-
expect(() =>
|
|
25
|
+
expect(() => contract.fit(value)).toThrow()
|
|
26
26
|
})
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
const clone = require('clone-deep')
|
|
4
4
|
const { generate } = require('randomstring')
|
|
5
5
|
|
|
6
|
-
jest.mock('../../src/contract/
|
|
6
|
+
jest.mock('../../src/contract/contract')
|
|
7
7
|
|
|
8
8
|
const { Request } = require('../../src/contract/request')
|
|
9
|
-
const {
|
|
9
|
+
const { Contract } = require('../../src/contract/contract')
|
|
10
10
|
const fixtures = require('./contract.fixtures')
|
|
11
11
|
|
|
12
12
|
let contract
|
|
@@ -14,14 +14,14 @@ let contract
|
|
|
14
14
|
beforeEach(() => {
|
|
15
15
|
jest.clearAllMocks()
|
|
16
16
|
|
|
17
|
-
contract = new Request(fixtures.schema)
|
|
17
|
+
contract = new Request(fixtures.schema, {})
|
|
18
18
|
})
|
|
19
19
|
|
|
20
20
|
const dummy = { schema: { properties: {} } }
|
|
21
21
|
|
|
22
22
|
it('should extend Conditions', () => {
|
|
23
|
-
expect(contract).toBeInstanceOf(
|
|
24
|
-
expect(
|
|
23
|
+
expect(contract).toBeInstanceOf(Contract)
|
|
24
|
+
expect(Contract).toHaveBeenCalledWith(fixtures.schema)
|
|
25
25
|
})
|
|
26
26
|
|
|
27
27
|
it('should fit request', () => {
|
|
@@ -29,7 +29,7 @@ it('should fit request', () => {
|
|
|
29
29
|
|
|
30
30
|
contract.fit(request)
|
|
31
31
|
|
|
32
|
-
expect(
|
|
32
|
+
expect(Contract.mock.instances[0].fit).toHaveBeenCalledWith(request)
|
|
33
33
|
})
|
|
34
34
|
|
|
35
35
|
describe('schema', () => {
|
|
@@ -59,7 +59,7 @@ describe('schema', () => {
|
|
|
59
59
|
|
|
60
60
|
it('should not contain query if declaration.query is false', () => {
|
|
61
61
|
schema.properties.query = { type: 'null' }
|
|
62
|
-
expect(Request.schema({ query: false }, dummy)).
|
|
62
|
+
expect(Request.schema({ query: false }, dummy)).toMatchObject(schema)
|
|
63
63
|
})
|
|
64
64
|
|
|
65
65
|
it('should require query if declaration.query is true', () => {
|
|
@@ -110,3 +110,42 @@ describe('schema', () => {
|
|
|
110
110
|
expect(objects.limit).toBeDefined()
|
|
111
111
|
})
|
|
112
112
|
})
|
|
113
|
+
|
|
114
|
+
describe('source', () => {
|
|
115
|
+
const schemas = require('@toa.io/schemas')
|
|
116
|
+
|
|
117
|
+
const compile = (definition, entity) =>
|
|
118
|
+
schemas.schema(Request.schema(definition, entity), { removeAdditional: true })
|
|
119
|
+
|
|
120
|
+
it('should declare source', () => {
|
|
121
|
+
const schema = Request.schema({}, dummy)
|
|
122
|
+
|
|
123
|
+
expect(schema.properties.source).toBeDefined()
|
|
124
|
+
expect(schema.properties.source.additionalProperties).toStrictEqual(false)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should pass known source variants', () => {
|
|
128
|
+
const schema = compile({}, undefined)
|
|
129
|
+
|
|
130
|
+
for (const source of [
|
|
131
|
+
{ namespace: 'a', component: 'b', operation: 'c' },
|
|
132
|
+
{ namespace: 'a', component: 'b', event: 'c' },
|
|
133
|
+
{ service: 'exposition' }
|
|
134
|
+
]) {
|
|
135
|
+
const request = { input: null, query: null, source }
|
|
136
|
+
|
|
137
|
+
expect(schema.fit(request)).toStrictEqual(null)
|
|
138
|
+
expect(request.source).toStrictEqual(source)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// `source` crosses the wire and keys the introspection map, so whatever
|
|
143
|
+
// a peer adds to it must not survive
|
|
144
|
+
it('should strip unknown source properties', () => {
|
|
145
|
+
const schema = compile({}, undefined)
|
|
146
|
+
const request = { input: null, query: null, source: { service: 'exposition', evil: 'x' } }
|
|
147
|
+
|
|
148
|
+
expect(schema.fit(request)).toStrictEqual(null)
|
|
149
|
+
expect(request.source).toStrictEqual({ service: 'exposition' })
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { console } = require('openspan')
|
|
4
|
+
const { Connector } = require('../src/connector')
|
|
5
|
+
const { Discovery } = require('../src/discovery')
|
|
6
|
+
|
|
7
|
+
class Lookup extends Connector {
|
|
8
|
+
invoke = jest.fn(async () => ({ operations: {} }))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** @type {Lookup} */
|
|
12
|
+
let lookup
|
|
13
|
+
|
|
14
|
+
/** @type {Discovery} */
|
|
15
|
+
let discovery
|
|
16
|
+
|
|
17
|
+
/** @type {jest.SpyInstance} */
|
|
18
|
+
let warn
|
|
19
|
+
|
|
20
|
+
const locator = { id: 'dummies.one' }
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
jest.useFakeTimers()
|
|
24
|
+
|
|
25
|
+
warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
26
|
+
lookup = new Lookup()
|
|
27
|
+
discovery = new Discovery(async () => lookup)
|
|
28
|
+
|
|
29
|
+
await discovery.connect()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
warn.mockRestore()
|
|
34
|
+
jest.useRealTimers()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should not warn if a lookup is answered', async () => {
|
|
38
|
+
await discovery.lookup(locator)
|
|
39
|
+
|
|
40
|
+
jest.advanceTimersByTime(60_000)
|
|
41
|
+
|
|
42
|
+
expect(warn).not.toHaveBeenCalled()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should keep warning while a lookup is unanswered', async () => {
|
|
46
|
+
lookup.invoke.mockImplementation(() => new Promise(() => undefined))
|
|
47
|
+
|
|
48
|
+
void discovery.lookup(locator)
|
|
49
|
+
|
|
50
|
+
await jest.advanceTimersByTimeAsync(12_000)
|
|
51
|
+
|
|
52
|
+
expect(warn).toHaveBeenCalledTimes(2)
|
|
53
|
+
|
|
54
|
+
expect(warn).toHaveBeenLastCalledWith('Waiting for lookup response',
|
|
55
|
+
{ component: locator.id, waiting: 10 })
|
|
56
|
+
})
|