pg-boss 10.0.0-beta2 → 10.0.0-beta21
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/README.md +17 -20
- package/package.json +1 -3
- package/src/attorney.js +35 -36
- package/src/boss.js +13 -57
- package/src/contractor.js +18 -12
- package/src/db.js +12 -38
- package/src/index.js +92 -67
- package/src/manager.js +109 -164
- package/src/plans.js +404 -298
- package/src/timekeeper.js +51 -98
- package/types.d.ts +70 -67
package/src/manager.js
CHANGED
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
const assert = require('assert')
|
|
2
2
|
const EventEmitter = require('events')
|
|
3
3
|
const { randomUUID } = require('crypto')
|
|
4
|
-
const debounce = require('lodash.debounce')
|
|
5
4
|
const { serializeError: stringify } = require('serialize-error')
|
|
6
|
-
const pMap = require('p-map')
|
|
7
5
|
const { delay } = require('./tools')
|
|
8
6
|
const Attorney = require('./attorney')
|
|
9
7
|
const Worker = require('./worker')
|
|
10
8
|
const plans = require('./plans')
|
|
11
9
|
|
|
12
10
|
const { QUEUES: TIMEKEEPER_QUEUES } = require('./timekeeper')
|
|
13
|
-
const {
|
|
11
|
+
const { QUEUE_POLICIES } = plans
|
|
14
12
|
|
|
15
13
|
const INTERNAL_QUEUES = Object.values(TIMEKEEPER_QUEUES).reduce((acc, i) => ({ ...acc, [i]: i }), {})
|
|
16
14
|
|
|
17
|
-
const WIP_EVENT_INTERVAL = 2000
|
|
18
|
-
const WIP_DEBOUNCE_OPTIONS = { leading: true, trailing: true, maxWait: WIP_EVENT_INTERVAL }
|
|
19
|
-
|
|
20
15
|
const events = {
|
|
21
16
|
error: 'error',
|
|
22
17
|
wip: 'wip'
|
|
@@ -45,6 +40,7 @@ class Manager extends EventEmitter {
|
|
|
45
40
|
this.db = db
|
|
46
41
|
|
|
47
42
|
this.events = events
|
|
43
|
+
this.wipTs = Date.now()
|
|
48
44
|
this.workers = new Map()
|
|
49
45
|
|
|
50
46
|
this.nextJobCommand = plans.fetchNextJob(config.schema)
|
|
@@ -53,18 +49,27 @@ class Manager extends EventEmitter {
|
|
|
53
49
|
this.completeJobsCommand = plans.completeJobs(config.schema)
|
|
54
50
|
this.cancelJobsCommand = plans.cancelJobs(config.schema)
|
|
55
51
|
this.resumeJobsCommand = plans.resumeJobs(config.schema)
|
|
52
|
+
this.deleteJobsCommand = plans.deleteJobs(config.schema)
|
|
56
53
|
this.failJobsByIdCommand = plans.failJobsById(config.schema)
|
|
57
54
|
this.getJobByIdCommand = plans.getJobById(config.schema)
|
|
58
55
|
this.getArchivedJobByIdCommand = plans.getArchivedJobById(config.schema)
|
|
59
56
|
this.subscribeCommand = plans.subscribe(config.schema)
|
|
60
57
|
this.unsubscribeCommand = plans.unsubscribe(config.schema)
|
|
58
|
+
this.getQueuesCommand = plans.getQueues(config.schema)
|
|
59
|
+
this.getQueueByNameCommand = plans.getQueueByName(config.schema)
|
|
61
60
|
this.getQueuesForEventCommand = plans.getQueuesForEvent(config.schema)
|
|
61
|
+
this.createQueueCommand = plans.createQueue(config.schema)
|
|
62
|
+
this.updateQueueCommand = plans.updateQueue(config.schema)
|
|
63
|
+
this.purgeQueueCommand = plans.purgeQueue(config.schema)
|
|
64
|
+
this.deleteQueueCommand = plans.deleteQueue(config.schema)
|
|
65
|
+
this.clearStorageCommand = plans.clearStorage(config.schema)
|
|
62
66
|
|
|
63
67
|
// exported api to index
|
|
64
68
|
this.functions = [
|
|
65
69
|
this.complete,
|
|
66
70
|
this.cancel,
|
|
67
71
|
this.resume,
|
|
72
|
+
this.deleteJob,
|
|
68
73
|
this.fail,
|
|
69
74
|
this.fetch,
|
|
70
75
|
this.work,
|
|
@@ -80,15 +85,14 @@ class Manager extends EventEmitter {
|
|
|
80
85
|
this.sendAfter,
|
|
81
86
|
this.createQueue,
|
|
82
87
|
this.updateQueue,
|
|
83
|
-
this.getQueue,
|
|
84
88
|
this.deleteQueue,
|
|
85
89
|
this.purgeQueue,
|
|
86
90
|
this.getQueueSize,
|
|
91
|
+
this.getQueue,
|
|
92
|
+
this.getQueues,
|
|
87
93
|
this.clearStorage,
|
|
88
94
|
this.getJobById
|
|
89
95
|
]
|
|
90
|
-
|
|
91
|
-
this.emitWipThrottled = debounce(() => this.emit(events.wip, this.getWipData()), WIP_EVENT_INTERVAL, WIP_DEBOUNCE_OPTIONS)
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
start () {
|
|
@@ -133,7 +137,12 @@ class Manager extends EventEmitter {
|
|
|
133
137
|
|
|
134
138
|
emitWip (name) {
|
|
135
139
|
if (!INTERNAL_QUEUES[name]) {
|
|
136
|
-
|
|
140
|
+
const now = Date.now()
|
|
141
|
+
|
|
142
|
+
if (now - this.wipTs > 2000) {
|
|
143
|
+
this.emit(events.wip, this.getWipData())
|
|
144
|
+
this.wipTs = now
|
|
145
|
+
}
|
|
137
146
|
}
|
|
138
147
|
}
|
|
139
148
|
|
|
@@ -177,73 +186,35 @@ class Manager extends EventEmitter {
|
|
|
177
186
|
}
|
|
178
187
|
|
|
179
188
|
const {
|
|
180
|
-
|
|
189
|
+
pollingInterval: interval = this.config.pollingInterval,
|
|
181
190
|
batchSize,
|
|
182
|
-
teamSize = 1,
|
|
183
|
-
teamConcurrency = 1,
|
|
184
|
-
teamRefill: refill = false,
|
|
185
191
|
includeMetadata = false,
|
|
186
192
|
priority = true
|
|
187
193
|
} = options
|
|
188
194
|
|
|
189
195
|
const id = randomUUID({ disableEntropyCache: true })
|
|
190
196
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
let refillTeamPromise
|
|
194
|
-
let resolveRefillTeam
|
|
195
|
-
|
|
196
|
-
// Setup a promise that onFetch can await for when at least one
|
|
197
|
-
// job is finished and so the team is ready to be topped up
|
|
198
|
-
const createTeamRefillPromise = () => {
|
|
199
|
-
refillTeamPromise = new Promise((resolve) => { resolveRefillTeam = resolve })
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
createTeamRefillPromise()
|
|
203
|
-
|
|
204
|
-
const onRefill = () => {
|
|
205
|
-
queueSize--
|
|
206
|
-
resolveRefillTeam()
|
|
207
|
-
createTeamRefillPromise()
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const fetch = () => this.fetch(name, batchSize || (teamSize - queueSize), { includeMetadata, priority })
|
|
197
|
+
const fetch = () => this.fetch(name, { batchSize, includeMetadata, priority })
|
|
211
198
|
|
|
212
199
|
const onFetch = async (jobs) => {
|
|
200
|
+
if (!jobs.length) {
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
213
204
|
if (this.config.__test__throw_worker) {
|
|
214
205
|
throw new Error('__test__throw_worker')
|
|
215
206
|
}
|
|
216
207
|
|
|
217
208
|
this.emitWip(name)
|
|
218
209
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
queueSize += jobs.length || 1
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const allTeamPromise = pMap(jobs, job =>
|
|
231
|
-
resolveWithinSeconds(callback(job), job.expire_in_seconds)
|
|
232
|
-
.then(result => this.complete(name, job.id, result))
|
|
233
|
-
.catch(err => this.fail(name, job.id, err))
|
|
234
|
-
.then(() => refill ? onRefill() : null)
|
|
235
|
-
, { concurrency: teamConcurrency }
|
|
236
|
-
).catch(() => {}) // allow promises & non-promises to live together in harmony
|
|
237
|
-
|
|
238
|
-
if (refill) {
|
|
239
|
-
if (queueSize < teamSize) {
|
|
240
|
-
return
|
|
241
|
-
} else {
|
|
242
|
-
await refillTeamPromise
|
|
243
|
-
}
|
|
244
|
-
} else {
|
|
245
|
-
await allTeamPromise
|
|
246
|
-
}
|
|
210
|
+
const maxExpiration = jobs.reduce((acc, i) => Math.max(acc, i.expireInSeconds), 0)
|
|
211
|
+
const jobIds = jobs.map(job => job.id)
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const result = await resolveWithinSeconds(callback(jobs), maxExpiration)
|
|
215
|
+
this.complete(name, jobIds, jobIds.length === 1 ? result : undefined)
|
|
216
|
+
} catch (err) {
|
|
217
|
+
this.fail(name, jobIds, err)
|
|
247
218
|
}
|
|
248
219
|
|
|
249
220
|
this.emitWip(name)
|
|
@@ -317,13 +288,9 @@ class Manager extends EventEmitter {
|
|
|
317
288
|
async publish (event, ...args) {
|
|
318
289
|
assert(event, 'Missing required argument')
|
|
319
290
|
|
|
320
|
-
const
|
|
291
|
+
const { rows } = await this.db.executeSql(this.getQueuesForEventCommand, [event])
|
|
321
292
|
|
|
322
|
-
|
|
323
|
-
return []
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
return await Promise.all(result.rows.map(({ name }) => this.send(name, ...args)))
|
|
293
|
+
await Promise.allSettled(rows.map(({ name }) => this.send(name, ...args)))
|
|
327
294
|
}
|
|
328
295
|
|
|
329
296
|
async send (...args) {
|
|
@@ -406,10 +373,10 @@ class Manager extends EventEmitter {
|
|
|
406
373
|
]
|
|
407
374
|
|
|
408
375
|
const db = wrapper || this.db
|
|
409
|
-
const
|
|
376
|
+
const { rows } = await db.executeSql(this.insertJobCommand, values)
|
|
410
377
|
|
|
411
|
-
if (
|
|
412
|
-
return
|
|
378
|
+
if (rows.length === 1) {
|
|
379
|
+
return rows[0].id
|
|
413
380
|
}
|
|
414
381
|
|
|
415
382
|
if (!options.singletonNextSlot) {
|
|
@@ -461,25 +428,20 @@ class Manager extends EventEmitter {
|
|
|
461
428
|
return startAfter
|
|
462
429
|
}
|
|
463
430
|
|
|
464
|
-
async fetch (name,
|
|
465
|
-
|
|
431
|
+
async fetch (name, options = {}) {
|
|
432
|
+
Attorney.checkFetchArgs(name, options)
|
|
466
433
|
const db = options.db || this.db
|
|
467
434
|
const nextJobSql = this.nextJobCommand({ ...options })
|
|
468
|
-
const statementValues = [values.name, batchSize || 1]
|
|
469
435
|
|
|
470
436
|
let result
|
|
471
437
|
|
|
472
438
|
try {
|
|
473
|
-
result = await db.executeSql(nextJobSql,
|
|
439
|
+
result = await db.executeSql(nextJobSql, [name, options.batchSize])
|
|
474
440
|
} catch (err) {
|
|
475
441
|
// errors from fetchquery should only be unique constraint violations
|
|
476
442
|
}
|
|
477
443
|
|
|
478
|
-
|
|
479
|
-
return null
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
return result.rows.length === 1 && !batchSize ? result.rows[0] : result.rows
|
|
444
|
+
return result?.rows || []
|
|
483
445
|
}
|
|
484
446
|
|
|
485
447
|
mapCompletionIdArg (id, funcName) {
|
|
@@ -504,52 +466,62 @@ class Manager extends EventEmitter {
|
|
|
504
466
|
return stringify(result)
|
|
505
467
|
}
|
|
506
468
|
|
|
507
|
-
|
|
469
|
+
mapCommandResponse (ids, result) {
|
|
508
470
|
return {
|
|
509
471
|
jobs: ids,
|
|
510
472
|
requested: ids.length,
|
|
511
|
-
|
|
473
|
+
affected: result && result.rows ? parseInt(result.rows[0].count) : 0
|
|
512
474
|
}
|
|
513
475
|
}
|
|
514
476
|
|
|
515
477
|
async complete (name, id, data, options = {}) {
|
|
516
|
-
|
|
478
|
+
Attorney.assertQueueName(name)
|
|
517
479
|
const db = options.db || this.db
|
|
518
480
|
const ids = this.mapCompletionIdArg(id, 'complete')
|
|
519
481
|
const result = await db.executeSql(this.completeJobsCommand, [name, ids, this.mapCompletionDataArg(data)])
|
|
520
|
-
return this.
|
|
482
|
+
return this.mapCommandResponse(ids, result)
|
|
521
483
|
}
|
|
522
484
|
|
|
523
485
|
async fail (name, id, data, options = {}) {
|
|
524
|
-
|
|
486
|
+
Attorney.assertQueueName(name)
|
|
525
487
|
const db = options.db || this.db
|
|
526
488
|
const ids = this.mapCompletionIdArg(id, 'fail')
|
|
527
489
|
const result = await db.executeSql(this.failJobsByIdCommand, [name, ids, this.mapCompletionDataArg(data)])
|
|
528
|
-
return this.
|
|
490
|
+
return this.mapCommandResponse(ids, result)
|
|
529
491
|
}
|
|
530
492
|
|
|
531
493
|
async cancel (name, id, options = {}) {
|
|
532
|
-
|
|
494
|
+
Attorney.assertQueueName(name)
|
|
533
495
|
const db = options.db || this.db
|
|
534
496
|
const ids = this.mapCompletionIdArg(id, 'cancel')
|
|
535
497
|
const result = await db.executeSql(this.cancelJobsCommand, [name, ids])
|
|
536
|
-
return this.
|
|
498
|
+
return this.mapCommandResponse(ids, result)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async deleteJob (name, id, options = {}) {
|
|
502
|
+
Attorney.assertQueueName(name)
|
|
503
|
+
const db = options.db || this.db
|
|
504
|
+
const ids = this.mapCompletionIdArg(id, 'deleteJob')
|
|
505
|
+
const result = await db.executeSql(this.deleteJobsCommand, [name, ids])
|
|
506
|
+
return this.mapCommandResponse(ids, result)
|
|
537
507
|
}
|
|
538
508
|
|
|
539
509
|
async resume (name, id, options = {}) {
|
|
540
|
-
|
|
510
|
+
Attorney.assertQueueName(name)
|
|
541
511
|
const db = options.db || this.db
|
|
542
512
|
const ids = this.mapCompletionIdArg(id, 'resume')
|
|
543
513
|
const result = await db.executeSql(this.resumeJobsCommand, [name, ids])
|
|
544
|
-
return this.
|
|
514
|
+
return this.mapCommandResponse(ids, result)
|
|
545
515
|
}
|
|
546
516
|
|
|
547
517
|
async createQueue (name, options = {}) {
|
|
548
|
-
|
|
518
|
+
name = name || options.name
|
|
549
519
|
|
|
550
|
-
|
|
520
|
+
Attorney.assertQueueName(name)
|
|
551
521
|
|
|
552
|
-
|
|
522
|
+
const { policy = QUEUE_POLICIES.standard } = options
|
|
523
|
+
|
|
524
|
+
assert(policy in QUEUE_POLICIES, `${policy} is not a valid queue policy`)
|
|
553
525
|
|
|
554
526
|
const {
|
|
555
527
|
retryLimit,
|
|
@@ -560,14 +532,12 @@ class Manager extends EventEmitter {
|
|
|
560
532
|
deadLetter
|
|
561
533
|
} = Attorney.checkQueueArgs(name, options)
|
|
562
534
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const sql = plans.createQueue(this.config.schema, name)
|
|
535
|
+
if (deadLetter) {
|
|
536
|
+
Attorney.assertQueueName(deadLetter)
|
|
537
|
+
}
|
|
568
538
|
|
|
569
|
-
|
|
570
|
-
|
|
539
|
+
// todo: pull in defaults from constructor config
|
|
540
|
+
const data = {
|
|
571
541
|
policy,
|
|
572
542
|
retryLimit,
|
|
573
543
|
retryDelay,
|
|
@@ -575,13 +545,22 @@ class Manager extends EventEmitter {
|
|
|
575
545
|
expireInSeconds,
|
|
576
546
|
retentionMinutes,
|
|
577
547
|
deadLetter
|
|
578
|
-
|
|
548
|
+
}
|
|
579
549
|
|
|
580
|
-
await this.db.executeSql(
|
|
550
|
+
await this.db.executeSql(this.createQueueCommand, [name, data])
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async getQueues () {
|
|
554
|
+
const { rows } = await this.db.executeSql(this.getQueuesCommand)
|
|
555
|
+
return rows
|
|
581
556
|
}
|
|
582
557
|
|
|
583
558
|
async updateQueue (name, options = {}) {
|
|
584
|
-
|
|
559
|
+
Attorney.assertQueueName(name)
|
|
560
|
+
|
|
561
|
+
const { policy = QUEUE_POLICIES.standard } = options
|
|
562
|
+
|
|
563
|
+
assert(policy in QUEUE_POLICIES, `${policy} is not a valid queue policy`)
|
|
585
564
|
|
|
586
565
|
const {
|
|
587
566
|
retryLimit,
|
|
@@ -592,10 +571,9 @@ class Manager extends EventEmitter {
|
|
|
592
571
|
deadLetter
|
|
593
572
|
} = Attorney.checkQueueArgs(name, options)
|
|
594
573
|
|
|
595
|
-
const sql = plans.updateQueue(this.config.schema)
|
|
596
|
-
|
|
597
574
|
const params = [
|
|
598
575
|
name,
|
|
576
|
+
policy,
|
|
599
577
|
retryLimit,
|
|
600
578
|
retryDelay,
|
|
601
579
|
retryBackoff,
|
|
@@ -604,94 +582,61 @@ class Manager extends EventEmitter {
|
|
|
604
582
|
deadLetter
|
|
605
583
|
]
|
|
606
584
|
|
|
607
|
-
await this.db.executeSql(
|
|
585
|
+
await this.db.executeSql(this.updateQueueCommand, params)
|
|
608
586
|
}
|
|
609
587
|
|
|
610
588
|
async getQueue (name) {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
const sql = plans.getQueueByName(this.config.schema)
|
|
614
|
-
const result = await this.db.executeSql(sql, [name])
|
|
589
|
+
Attorney.assertQueueName(name)
|
|
615
590
|
|
|
616
|
-
|
|
617
|
-
return null
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
const {
|
|
621
|
-
policy,
|
|
622
|
-
retry_limit: retryLimit,
|
|
623
|
-
retry_delay: retryDelay,
|
|
624
|
-
retry_backoff: retryBackoff,
|
|
625
|
-
expire_seconds: expireInSeconds,
|
|
626
|
-
retention_minutes: retentionMinutes,
|
|
627
|
-
dead_letter: deadLetter
|
|
628
|
-
} = result.rows[0]
|
|
591
|
+
const { rows } = await this.db.executeSql(this.getQueueByNameCommand, [name])
|
|
629
592
|
|
|
630
|
-
return
|
|
631
|
-
name,
|
|
632
|
-
policy,
|
|
633
|
-
retryLimit,
|
|
634
|
-
retryDelay,
|
|
635
|
-
retryBackoff,
|
|
636
|
-
expireInSeconds,
|
|
637
|
-
retentionMinutes,
|
|
638
|
-
deadLetter
|
|
639
|
-
}
|
|
593
|
+
return rows[0] || null
|
|
640
594
|
}
|
|
641
595
|
|
|
642
596
|
async deleteQueue (name) {
|
|
643
|
-
|
|
597
|
+
Attorney.assertQueueName(name)
|
|
644
598
|
|
|
645
|
-
const
|
|
646
|
-
const result = await this.db.executeSql(queueSql, [name])
|
|
599
|
+
const { rows } = await this.db.executeSql(this.getQueueByNameCommand, [name])
|
|
647
600
|
|
|
648
|
-
if (
|
|
649
|
-
|
|
650
|
-
const sql = plans.dropJobTablePartition(this.config.schema, name)
|
|
651
|
-
await this.db.executeSql(sql)
|
|
601
|
+
if (rows.length === 1) {
|
|
602
|
+
await this.db.executeSql(this.deleteQueueCommand, [name])
|
|
652
603
|
}
|
|
653
|
-
|
|
654
|
-
const sql = plans.deleteQueueRecords(this.config.schema)
|
|
655
|
-
const result2 = await this.db.executeSql(sql, [name])
|
|
656
|
-
return result2?.rowCount || null
|
|
657
604
|
}
|
|
658
605
|
|
|
659
|
-
async purgeQueue (
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
await this.db.executeSql(sql, [queue])
|
|
606
|
+
async purgeQueue (name) {
|
|
607
|
+
Attorney.assertQueueName(name)
|
|
608
|
+
await this.db.executeSql(this.purgeQueueCommand, [name])
|
|
663
609
|
}
|
|
664
610
|
|
|
665
611
|
async clearStorage () {
|
|
666
|
-
|
|
667
|
-
await this.db.executeSql(sql)
|
|
612
|
+
await this.db.executeSql(this.clearStorageCommand)
|
|
668
613
|
}
|
|
669
614
|
|
|
670
|
-
async getQueueSize (
|
|
671
|
-
|
|
615
|
+
async getQueueSize (name, options) {
|
|
616
|
+
Attorney.assertQueueName(name)
|
|
672
617
|
|
|
673
618
|
const sql = plans.getQueueSize(this.config.schema, options)
|
|
674
619
|
|
|
675
|
-
const result = await this.db.executeSql(sql, [
|
|
620
|
+
const result = await this.db.executeSql(sql, [name])
|
|
676
621
|
|
|
677
622
|
return result ? parseFloat(result.rows[0].count) : null
|
|
678
623
|
}
|
|
679
624
|
|
|
680
|
-
async getJobById (
|
|
681
|
-
|
|
682
|
-
const result1 = await db.executeSql(this.getJobByIdCommand, [queue, id])
|
|
625
|
+
async getJobById (name, id, options = {}) {
|
|
626
|
+
Attorney.assertQueueName(name)
|
|
683
627
|
|
|
684
|
-
|
|
685
|
-
return result1.rows[0]
|
|
686
|
-
}
|
|
628
|
+
const db = options.db || this.db
|
|
687
629
|
|
|
688
|
-
const
|
|
630
|
+
const result1 = await db.executeSql(this.getJobByIdCommand, [name, id])
|
|
689
631
|
|
|
690
|
-
if (
|
|
691
|
-
return
|
|
632
|
+
if (result1?.rows?.length === 1) {
|
|
633
|
+
return result1.rows[0]
|
|
634
|
+
} else if (options.includeArchive) {
|
|
635
|
+
const result2 = await db.executeSql(this.getArchivedJobByIdCommand, [name, id])
|
|
636
|
+
return result2?.rows[0] || null
|
|
637
|
+
} else {
|
|
638
|
+
return null
|
|
692
639
|
}
|
|
693
|
-
|
|
694
|
-
return null
|
|
695
640
|
}
|
|
696
641
|
}
|
|
697
642
|
|