comq 0.9.0 → 0.10.1
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/.github/dependabot.yml +2 -2
- package/.github/workflows/codeql.yml +1 -1
- package/.github/workflows/release.yaml +14 -15
- package/docs/reply-stream-topology-dark.jpg +0 -0
- package/docs/reply-stream-topology-light.jpg +0 -0
- package/examples/readme.md +19 -3
- package/examples/streams/consumer.js +36 -0
- package/examples/streams/producer.js +44 -0
- package/features/backpressure.feature +2 -2
- package/features/readme.md +2 -2
- package/features/rpc.streams.feature +81 -0
- package/features/rpc.streams.shards.feature +37 -0
- package/features/steps/.test/context.test.js +1 -1
- package/features/steps/.test/events.test.js +1 -1
- package/features/steps/.types/context.d.ts +9 -1
- package/features/steps/broker.js +2 -0
- package/features/steps/common.js +12 -0
- package/features/steps/context.js +9 -2
- package/features/steps/rpc.js +235 -0
- package/lerna.json +1 -2
- package/package.json +3 -2
- package/readme.md +136 -46
- package/source/.io/ReplyPipe.js +158 -0
- package/source/.io/ReplyStream.js +139 -0
- package/source/.io/const.js +10 -0
- package/source/.io/{replies.js → createReplyEmitter.js} +3 -6
- package/source/.io/index.js +5 -1
- package/source/attributes/failsafe.js +34 -0
- package/source/attributes/failsafe.test.js +133 -0
- package/source/attributes/index.js +13 -0
- package/source/attributes/lazy.js +79 -0
- package/source/attributes/lazy.test.js +299 -0
- package/source/attributes/memo.js +13 -0
- package/source/attributes/memo.test.js +36 -0
- package/source/attributes/recall.js +42 -0
- package/source/attributes/recall.test.js +92 -0
- package/source/attributes/track.js +32 -0
- package/source/attributes/track.test.js +133 -0
- package/source/channel.js +31 -18
- package/source/connect.js +2 -2
- package/source/connection.js +4 -11
- package/source/emitter.js +13 -0
- package/source/encode.js +1 -1
- package/source/events.js +2 -2
- package/source/io.js +187 -50
- package/source/shards/channel.js +13 -10
- package/source/shards/connection.js +2 -2
- package/test/channel.test.js +3 -1
- package/test/encode.test.js +2 -2
- package/test/encodings.js +1 -1
- package/test/io.diagnostics.test.js +2 -2
- package/test/io.request.test.js +4 -23
- package/test/shards/channel.test.js +1 -1
- package/test/shards/connection.test.js +1 -1
- package/types/channel.d.ts +13 -11
- package/types/connection.d.ts +3 -3
- package/types/diagnostic.d.ts +4 -3
- package/types/encoding.d.ts +2 -2
- package/types/index.d.ts +4 -4
- package/types/io.d.ts +33 -40
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('node:events')
|
|
4
|
+
const { control, HEARTBEAT_INTERVAL } = require('./const')
|
|
5
|
+
|
|
6
|
+
/** @typedef {(message: any, properties?: comq.amqp.options.Publish) => Promise<void>} Reply */
|
|
7
|
+
|
|
8
|
+
class ReplyPipe extends EventEmitter {
|
|
9
|
+
#index = -1
|
|
10
|
+
#interrupted = false
|
|
11
|
+
#heartbeatInterval = global['COMQ_TESTING_HEARTBEAT_INTERVAL'] || HEARTBEAT_INTERVAL
|
|
12
|
+
|
|
13
|
+
/** @type {ReturnType<setInterval> | null} */
|
|
14
|
+
#interval = null
|
|
15
|
+
|
|
16
|
+
/** @type {string} */
|
|
17
|
+
#replyTo
|
|
18
|
+
|
|
19
|
+
/** @type {Record<string, comq.amqp.options.Publish>} */
|
|
20
|
+
#properties
|
|
21
|
+
|
|
22
|
+
/** @type {import('node:stream').Readable} */
|
|
23
|
+
#stream
|
|
24
|
+
|
|
25
|
+
/** @type {comq.Channel} */
|
|
26
|
+
#channel
|
|
27
|
+
|
|
28
|
+
/** @type {comq.ReplyEmitter} */
|
|
29
|
+
#feedback
|
|
30
|
+
|
|
31
|
+
/** @type {Reply} */
|
|
32
|
+
#reply
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {comq.amqp.Message} request
|
|
36
|
+
* @param {stream.Readable} stream
|
|
37
|
+
* @param {comq.Channel} channel
|
|
38
|
+
* @param {comq.ReplyEmitter} feedback
|
|
39
|
+
* @param {Reply} reply
|
|
40
|
+
*/
|
|
41
|
+
constructor (request, stream, channel, feedback, reply) {
|
|
42
|
+
super()
|
|
43
|
+
|
|
44
|
+
const { correlationId, replyTo } = request.properties
|
|
45
|
+
|
|
46
|
+
this.#stream = stream
|
|
47
|
+
this.#channel = channel
|
|
48
|
+
this.#feedback = feedback
|
|
49
|
+
this.#reply = reply
|
|
50
|
+
this.#replyTo = replyTo
|
|
51
|
+
|
|
52
|
+
this.#properties = {
|
|
53
|
+
chunk: { correlationId, ...CHUNK },
|
|
54
|
+
control: { correlationId, replyTo: feedback.queue, ...CONTROL }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
channel.diagnose('return', this.#onReturn)
|
|
58
|
+
feedback.on(correlationId, this.#control)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async pipe () {
|
|
62
|
+
await this.#transmit(control.ok, this.#properties.control)
|
|
63
|
+
|
|
64
|
+
this.#stream.on('data', this.#onData)
|
|
65
|
+
this.#stream.on('close', this.#close)
|
|
66
|
+
this.#heartbeat()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
destroy () {
|
|
70
|
+
this.#close()
|
|
71
|
+
this.#stream.destroy()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async #transmit (data, properties) {
|
|
75
|
+
this.#index++
|
|
76
|
+
|
|
77
|
+
const ok = await this.#reply(data,
|
|
78
|
+
{ ...properties, headers: { index: this.#index } })
|
|
79
|
+
|
|
80
|
+
if (!ok) this.#interrupt()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#heartbeat () {
|
|
84
|
+
if (this.#interval !== null) clearInterval(this.#interval)
|
|
85
|
+
|
|
86
|
+
this.#interval = setInterval(
|
|
87
|
+
() => this.#transmit(control.heartbeat, this.#properties.control),
|
|
88
|
+
this.#heartbeatInterval
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#interrupt () {
|
|
93
|
+
this.#interrupted = true
|
|
94
|
+
this.destroy()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#clear () {
|
|
98
|
+
clearInterval(this.#interval)
|
|
99
|
+
|
|
100
|
+
this.#stream.removeAllListeners()
|
|
101
|
+
this.#channel.forget('return', this.#onReturn)
|
|
102
|
+
this.#feedback.off(this.#properties.control.correlationId, this.#control)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#close = () => {
|
|
106
|
+
this.emit('close')
|
|
107
|
+
this.#clear()
|
|
108
|
+
|
|
109
|
+
if (!this.#interrupted) {
|
|
110
|
+
void this.#transmit(control.end, this.#properties.control)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#onData = async (chunk) => {
|
|
115
|
+
await this.#transmit(chunk, this.#properties.chunk)
|
|
116
|
+
|
|
117
|
+
this.#heartbeat()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#onReturn = (message) => {
|
|
121
|
+
if (message.fields.routingKey === this.#replyTo)
|
|
122
|
+
this.#interrupt()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#control = (message) => {
|
|
126
|
+
switch (message) {
|
|
127
|
+
case control.end:
|
|
128
|
+
this.#interrupt()
|
|
129
|
+
break
|
|
130
|
+
default:
|
|
131
|
+
throw new Error(`Unknown control message: ${message}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {comq.amqp.Message} request
|
|
137
|
+
* @param {stream.Readable} stream
|
|
138
|
+
* @param {comq.Channel} channel
|
|
139
|
+
* @param {comq.ReplyEmitter} control
|
|
140
|
+
* @param {Reply} reply
|
|
141
|
+
* @return {Promise<comq.Destroyable>}
|
|
142
|
+
*/
|
|
143
|
+
static async create (request, stream, channel, control, reply) {
|
|
144
|
+
const pipe = new ReplyPipe(request, stream, channel, control, reply)
|
|
145
|
+
|
|
146
|
+
await pipe.pipe()
|
|
147
|
+
|
|
148
|
+
return /** @type {comq.Destroyable} */ pipe
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @type {comq.amqp.options.Publish} */
|
|
153
|
+
const CHUNK = { mandatory: true }
|
|
154
|
+
|
|
155
|
+
/** @type {comq.amqp.options.Publish} */
|
|
156
|
+
const CONTROL = { type: 'control', mandatory: true }
|
|
157
|
+
|
|
158
|
+
exports.ReplyPipe = ReplyPipe
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Readable } = require('node:stream')
|
|
4
|
+
const { promex } = require('@toa.io/generic')
|
|
5
|
+
const { IDLE_INTERVAL, control } = require('./const')
|
|
6
|
+
|
|
7
|
+
class ReplyStream extends Readable {
|
|
8
|
+
confirmation = promex()
|
|
9
|
+
|
|
10
|
+
/** @type {ReturnType<setTimeout> | null} */
|
|
11
|
+
#timeout = null
|
|
12
|
+
|
|
13
|
+
/** @type {comq.ReplyEmitter} */
|
|
14
|
+
#emitter
|
|
15
|
+
|
|
16
|
+
/** @type {string} */
|
|
17
|
+
#correlationId
|
|
18
|
+
|
|
19
|
+
#control
|
|
20
|
+
|
|
21
|
+
#reply
|
|
22
|
+
|
|
23
|
+
#idleInterval
|
|
24
|
+
|
|
25
|
+
#index = 0
|
|
26
|
+
|
|
27
|
+
#buffered = 0
|
|
28
|
+
|
|
29
|
+
/** @type {Record<number, unknown>} */
|
|
30
|
+
#queue = {}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {comq.Request} request
|
|
34
|
+
* @param {any} reply
|
|
35
|
+
*/
|
|
36
|
+
constructor (request, reply) {
|
|
37
|
+
super({ objectMode: true })
|
|
38
|
+
|
|
39
|
+
this.#emitter = request.emitter
|
|
40
|
+
this.#correlationId = request.properties.correlationId
|
|
41
|
+
this.#idleInterval = global['COMQ_TESTING_IDLE_INTERVAL'] || IDLE_INTERVAL
|
|
42
|
+
this.#reply = reply
|
|
43
|
+
|
|
44
|
+
this.#emitter.on(this.#correlationId, this.arrange.bind(this))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_destroy (error, callback) {
|
|
48
|
+
this._clear()
|
|
49
|
+
this.push(null)
|
|
50
|
+
|
|
51
|
+
void this.#reply(this.#control, control.end)
|
|
52
|
+
super._destroy(error, callback)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_read (_) {}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {unknown} payload
|
|
59
|
+
* @param {comq.amqp.Properties} properties
|
|
60
|
+
*/
|
|
61
|
+
arrange (payload, properties) {
|
|
62
|
+
if (properties.headers.index !== this.#index) {
|
|
63
|
+
this._buffer(payload, properties)
|
|
64
|
+
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this._add(payload, properties)
|
|
69
|
+
|
|
70
|
+
if (this.#buffered > 0) {
|
|
71
|
+
let message
|
|
72
|
+
|
|
73
|
+
while ((message = this.#queue[this.#index])) {
|
|
74
|
+
delete this.#queue[this.#index]
|
|
75
|
+
this._add(message.payload, message.properties)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.#buffered = Object.keys(this.#queue).length
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {unknown} payload
|
|
84
|
+
* @param {comq.amqp.Properties} properties
|
|
85
|
+
* @private
|
|
86
|
+
*/
|
|
87
|
+
_add (payload, properties) {
|
|
88
|
+
this._heartbeat()
|
|
89
|
+
this.#index++
|
|
90
|
+
|
|
91
|
+
if (properties.type === 'control')
|
|
92
|
+
this._control(payload, properties)
|
|
93
|
+
else if (!this.push(payload))
|
|
94
|
+
this._clear()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
_buffer (payload, properties) {
|
|
98
|
+
if (this.#buffered > MAX_BUFFER_SIZE) this.destroy()
|
|
99
|
+
|
|
100
|
+
this.#buffered++
|
|
101
|
+
this.#queue[properties.headers.index] = { payload, properties }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} message
|
|
106
|
+
* @param {comq.amqp.Properties} properties
|
|
107
|
+
* @private
|
|
108
|
+
*/
|
|
109
|
+
_control (message, properties) {
|
|
110
|
+
switch (message) {
|
|
111
|
+
case control.ok:
|
|
112
|
+
this.#control = { properties }
|
|
113
|
+
this.confirmation.resolve()
|
|
114
|
+
break
|
|
115
|
+
case control.heartbeat:
|
|
116
|
+
break
|
|
117
|
+
case control.end:
|
|
118
|
+
this.push(null)
|
|
119
|
+
break
|
|
120
|
+
default:
|
|
121
|
+
throw new Error(`Unknown reply stream control message: '${message}'`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_heartbeat () {
|
|
126
|
+
if (this.#timeout !== null) clearTimeout(this.#timeout)
|
|
127
|
+
|
|
128
|
+
this.#timeout = setTimeout(() => this.destroy(), this.#idleInterval)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_clear () {
|
|
132
|
+
clearTimeout(this.#timeout)
|
|
133
|
+
this.#emitter.removeAllListeners(this.#correlationId)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const MAX_BUFFER_SIZE = 1000
|
|
138
|
+
|
|
139
|
+
exports.ReplyStream = ReplyStream
|
|
@@ -2,24 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
const { randomBytes } = require('node:crypto')
|
|
4
4
|
const { EventEmitter } = require('node:events')
|
|
5
|
-
|
|
6
5
|
const { concat } = require('./concat')
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* @param {string} label
|
|
10
9
|
* @returns {comq.ReplyEmitter}
|
|
11
10
|
*/
|
|
12
|
-
|
|
11
|
+
function createReplyEmitter (label) {
|
|
13
12
|
const id = randomBytes(8).toString('hex')
|
|
14
13
|
const queue = concat(label, id)
|
|
15
14
|
const emitter = new EventEmitter()
|
|
16
|
-
const once = (name, callback) => emitter.once(name, callback)
|
|
17
|
-
const emit = (name, value) => emitter.emit(name, value)
|
|
18
|
-
const clear = () => emitter.removeAllListeners()
|
|
19
15
|
|
|
20
16
|
emitter.setMaxListeners(0)
|
|
17
|
+
emitter.queue = queue
|
|
21
18
|
|
|
22
|
-
return
|
|
19
|
+
return /** @type {comq.ReplyEmitter} */ emitter
|
|
23
20
|
}
|
|
24
21
|
|
|
25
22
|
exports.createReplyEmitter = createReplyEmitter
|
package/source/.io/index.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { concat } = require('./concat')
|
|
4
|
-
const { createReplyEmitter } = require('./
|
|
4
|
+
const { createReplyEmitter } = require('./createReplyEmitter')
|
|
5
|
+
const { ReplyStream } = require('./ReplyStream')
|
|
6
|
+
const { ReplyPipe } = require('./ReplyPipe')
|
|
5
7
|
|
|
6
8
|
exports.concat = concat
|
|
7
9
|
exports.createReplyEmitter = createReplyEmitter
|
|
10
|
+
exports.ReplyStream = ReplyStream
|
|
11
|
+
exports.ReplyPipe = ReplyPipe
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {object} context
|
|
5
|
+
* @param {(exception: Error) => Promise<unknown>} recover
|
|
6
|
+
* @param {(...args: unknown[]) => Promise<unknown>} method
|
|
7
|
+
*/
|
|
8
|
+
function failsafe (context, recover, method = undefined) {
|
|
9
|
+
// two arguments passed
|
|
10
|
+
if (method === undefined) {
|
|
11
|
+
method = recover
|
|
12
|
+
recover = undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return async function call (...args) {
|
|
16
|
+
if (call[DISABLED] === true) return await method.apply(context, args)
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
return await method.apply(context, args)
|
|
20
|
+
} catch (exception) {
|
|
21
|
+
if (recover !== undefined && await recover.call(context, exception) === false) throw exception
|
|
22
|
+
|
|
23
|
+
return call.apply(this, args)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
failsafe.disable = (...methods) => {
|
|
29
|
+
for (const method of methods) method[DISABLED] = true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DISABLED = Symbol('disabled')
|
|
33
|
+
|
|
34
|
+
exports.failsafe = failsafe
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { generate } = require('randomstring')
|
|
4
|
+
const { failsafe } = require('./failsafe')
|
|
5
|
+
|
|
6
|
+
it('should be', async () => {
|
|
7
|
+
expect(failsafe).toBeDefined()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
jest.clearAllMocks()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const fn = /** @type {jest.MockedFunction} */
|
|
15
|
+
jest.fn(async () => generate())
|
|
16
|
+
const recover = /** @type {jest.MockedFunction<(...args: any[]) => Promise<any>>} */
|
|
17
|
+
jest.fn(async () => true)
|
|
18
|
+
|
|
19
|
+
class FailsafeTest {
|
|
20
|
+
doWithRecovery = failsafe(this, this.#recover, async (...args) => {
|
|
21
|
+
return fn(...args)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
doWithoutRecovery = failsafe(this, (...args) => {
|
|
25
|
+
return fn(...args)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
async #recover (exception) {
|
|
29
|
+
return recover(exception)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @type {FailsafeTest} */
|
|
34
|
+
let instance
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
jest.clearAllMocks()
|
|
38
|
+
|
|
39
|
+
instance = new FailsafeTest()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe.each([
|
|
43
|
+
['with', 'doWithRecovery'],
|
|
44
|
+
['without', 'doWithoutRecovery']
|
|
45
|
+
])('%s recovery', (_, method) => {
|
|
46
|
+
it('should run fn', async () => {
|
|
47
|
+
await instance[method]()
|
|
48
|
+
|
|
49
|
+
expect(fn).toHaveBeenCalled()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should return value', async () => {
|
|
53
|
+
const value = await instance[method]()
|
|
54
|
+
|
|
55
|
+
expect(value).toStrictEqual(await fn.mock.results[0].value)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should pass arguments', async () => {
|
|
59
|
+
const args = [generate(), generate()]
|
|
60
|
+
|
|
61
|
+
await instance[method](...args)
|
|
62
|
+
|
|
63
|
+
expect(fn).toHaveBeenCalledWith(...args)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should call again', async () => {
|
|
67
|
+
fn.mockImplementationOnce(async () => { throw new Error() })
|
|
68
|
+
|
|
69
|
+
await instance[method]()
|
|
70
|
+
|
|
71
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('recovery function', () => {
|
|
76
|
+
it('should recover on exception', async () => {
|
|
77
|
+
fn.mockImplementationOnce(async () => { throw new Error() })
|
|
78
|
+
|
|
79
|
+
const value = await instance.doWithRecovery()
|
|
80
|
+
|
|
81
|
+
expect(value).toStrictEqual(await fn.mock.results[1].value)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should throw on recovery failure', async () => {
|
|
85
|
+
const exception = generate()
|
|
86
|
+
|
|
87
|
+
fn.mockImplementationOnce(async () => { throw exception })
|
|
88
|
+
recover.mockImplementationOnce(async () => false)
|
|
89
|
+
|
|
90
|
+
await expect(instance.doWithRecovery()).rejects.toStrictEqual(exception)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should pass exception to recover', async () => {
|
|
94
|
+
const exception = generate()
|
|
95
|
+
|
|
96
|
+
fn.mockImplementationOnce(async () => { throw exception })
|
|
97
|
+
|
|
98
|
+
await instance.doWithRecovery()
|
|
99
|
+
|
|
100
|
+
expect(recover).toHaveBeenCalledWith(exception)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should call recover within context', async () => {
|
|
104
|
+
class Test {
|
|
105
|
+
foo = 1
|
|
106
|
+
|
|
107
|
+
do = failsafe(this, this.recover, fn)
|
|
108
|
+
|
|
109
|
+
recover () {
|
|
110
|
+
expect(this.foo).toStrictEqual(1)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const instance = new Test()
|
|
115
|
+
|
|
116
|
+
fn.mockImplementationOnce(() => { throw new Error() })
|
|
117
|
+
|
|
118
|
+
instance.do()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('disable', () => {
|
|
123
|
+
it('should throw exception', async () => {
|
|
124
|
+
const exception = generate()
|
|
125
|
+
|
|
126
|
+
failsafe.disable(instance.doWithRecovery, instance.doWithoutRecovery)
|
|
127
|
+
|
|
128
|
+
fn.mockImplementation(async () => { throw exception })
|
|
129
|
+
|
|
130
|
+
await expect(instance.doWithRecovery()).rejects.toStrictEqual(exception)
|
|
131
|
+
await expect(instance.doWithoutRecovery()).rejects.toStrictEqual(exception)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { memo } = require('./memo')
|
|
4
|
+
const { failsafe } = require('./failsafe')
|
|
5
|
+
const { lazy } = require('./lazy')
|
|
6
|
+
const { track } = require('./track')
|
|
7
|
+
const { recall } = require('./recall')
|
|
8
|
+
|
|
9
|
+
exports.memo = memo
|
|
10
|
+
exports.failsafe = failsafe
|
|
11
|
+
exports.lazy = lazy
|
|
12
|
+
exports.track = track
|
|
13
|
+
exports.recall = recall
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
function lazy (context, initializers, method) {
|
|
4
|
+
if (context[LOCK] === undefined) context[LOCK] = Symbol('methods locking key')
|
|
5
|
+
|
|
6
|
+
if (!Array.isArray(initializers)) initializers = [initializers]
|
|
7
|
+
|
|
8
|
+
return async function (...args) {
|
|
9
|
+
const override = await call(context, initializers, args)
|
|
10
|
+
|
|
11
|
+
if (override !== undefined) args.splice(0, override.length, ...override)
|
|
12
|
+
|
|
13
|
+
return method.apply(this, args)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} context
|
|
19
|
+
* @param {Function[]} initializers
|
|
20
|
+
* @param {any[]} args
|
|
21
|
+
* @returns {Promise<any[]>}
|
|
22
|
+
*/
|
|
23
|
+
async function call (context, initializers, args) {
|
|
24
|
+
let override
|
|
25
|
+
|
|
26
|
+
for (const init of initializers) {
|
|
27
|
+
const result = await resolve(context, init, args)
|
|
28
|
+
|
|
29
|
+
if (result !== undefined) override = result
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return override
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} context
|
|
37
|
+
* @param {Function} init
|
|
38
|
+
* @param {any[]} args
|
|
39
|
+
* @returns {Promise}
|
|
40
|
+
*/
|
|
41
|
+
function resolve (context, init, args) {
|
|
42
|
+
const key = context[LOCK]
|
|
43
|
+
const expected = args.slice(0, init.length)
|
|
44
|
+
|
|
45
|
+
return lock(context, init, expected, key)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} context
|
|
50
|
+
* @param {(...args: unknown[]) => Promise<unknown>} init
|
|
51
|
+
* @param {unknown[]} args
|
|
52
|
+
* @param {Symbol} key
|
|
53
|
+
* @returns {Promise<unknown>}
|
|
54
|
+
*/
|
|
55
|
+
function lock (context, init, args, key) {
|
|
56
|
+
if (init[key] === undefined) init[key] = []
|
|
57
|
+
|
|
58
|
+
const locks = init[key]
|
|
59
|
+
const found = locks.find((lock) => lock.args.reduce((match, argument, i) => match && argument === args[i], true))
|
|
60
|
+
|
|
61
|
+
if (found !== undefined) return found.promise
|
|
62
|
+
|
|
63
|
+
const promise = init.apply(context, args)
|
|
64
|
+
const lock = { args, promise }
|
|
65
|
+
|
|
66
|
+
locks.push(lock)
|
|
67
|
+
|
|
68
|
+
return promise
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function reset (context) {
|
|
72
|
+
context[LOCK] = context[LOCK] = Symbol('methods locking key')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const LOCK = Symbol('context locking key')
|
|
76
|
+
|
|
77
|
+
lazy.reset = reset
|
|
78
|
+
|
|
79
|
+
exports.lazy = lazy
|