ti2 1.0.124 → 1.0.126

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.
@@ -0,0 +1,496 @@
1
+ const axios = require('axios');
2
+ const crypto = require('crypto');
3
+ const { Op } = require('sequelize');
4
+
5
+ const sqldb = require('../models');
6
+ const { removeJob } = require('../worker/queue');
7
+
8
+ const DEFAULT_PROVISIONING_TIMEOUT_MS = 5 * 60e3;
9
+
10
+ const identityWhere = ({ userId, integrationId, hint }) => ({
11
+ userId,
12
+ integrationId,
13
+ hint,
14
+ });
15
+
16
+ const lifecycleError = (status, message) => {
17
+ const error = new Error(message);
18
+ error.status = status;
19
+ return error;
20
+ };
21
+
22
+ const newRequestId = () => crypto.randomBytes(16).toString('hex');
23
+
24
+ const provisioningTimeoutMs = () => {
25
+ const configured = Number(process.env.INTEGRATION_PROVISIONING_TIMEOUT_MS);
26
+ return Number.isFinite(configured) && configured > 0
27
+ ? configured
28
+ : DEFAULT_PROVISIONING_TIMEOUT_MS;
29
+ };
30
+
31
+ const provisioningIsStale = lifecycle => {
32
+ const lastUpdatedAt = lifecycle.updatedAt || lifecycle.requestedAt;
33
+ const lastUpdatedMs = new Date(lastUpdatedAt).getTime();
34
+ return Number.isFinite(lastUpdatedMs)
35
+ && Date.now() - lastUpdatedMs >= provisioningTimeoutMs();
36
+ };
37
+
38
+ const catalogLifecycleEndpoint = () => {
39
+ const eventsUrl = process.env.ti2_events2url_eventsURL;
40
+ if (!eventsUrl) return null;
41
+ const endpoint = new URL(eventsUrl);
42
+ endpoint.pathname = '/productSync/integration-lifecycle';
43
+ endpoint.search = '';
44
+ endpoint.hash = '';
45
+ return endpoint.toString();
46
+ };
47
+
48
+ const callCatalogLifecycle = async ({
49
+ action,
50
+ userId,
51
+ integrationId,
52
+ hint,
53
+ generation,
54
+ }) => {
55
+ const endpoint = catalogLifecycleEndpoint();
56
+ if (!endpoint) {
57
+ return {
58
+ status: action === 'activate' ? 'active' : 'deleted',
59
+ generation,
60
+ skipped: true,
61
+ reason: 'catalog_lifecycle_not_configured',
62
+ };
63
+ }
64
+ try {
65
+ const response = await axios.post(
66
+ endpoint,
67
+ {
68
+ action,
69
+ companyId: userId,
70
+ integrationId,
71
+ hint,
72
+ generation,
73
+ },
74
+ {
75
+ headers: {
76
+ Authorization: process.env.ti2_events2url_authorization,
77
+ },
78
+ timeout: Number(process.env.INTEGRATION_LIFECYCLE_TIMEOUT_MS) || 30e3,
79
+ },
80
+ );
81
+ return response.data;
82
+ } catch (error) {
83
+ const responseData = error.response && error.response.data;
84
+ if (responseData && ['retry', 'superseded'].includes(responseData.status)) {
85
+ return responseData;
86
+ }
87
+ throw error;
88
+ }
89
+ };
90
+
91
+ const prepareActivation = async ({
92
+ userId,
93
+ integrationId,
94
+ hint,
95
+ transaction,
96
+ }) => {
97
+ const where = identityWhere({ userId, integrationId, hint });
98
+ let lifecycle = await sqldb.IntegrationLifecycle.findOne({
99
+ where,
100
+ transaction,
101
+ lock: transaction.LOCK.UPDATE,
102
+ });
103
+ if (lifecycle && ['deleting', 'external_cleanup', 'failed'].includes(lifecycle.status)) {
104
+ throw lifecycleError(409, 'Integration removal must finish before this integration can be saved again.');
105
+ }
106
+ if (!lifecycle) {
107
+ lifecycle = await sqldb.IntegrationLifecycle.create({
108
+ ...where,
109
+ generation: 1,
110
+ status: 'provisioning',
111
+ requestId: newRequestId(),
112
+ retryCount: 0,
113
+ requestedAt: new Date(),
114
+ artifacts: { requiresCatalogActivation: false },
115
+ }, { transaction });
116
+ } else if (lifecycle.status === 'complete') {
117
+ lifecycle.generation += 1;
118
+ lifecycle.status = 'provisioning';
119
+ lifecycle.requestId = newRequestId();
120
+ lifecycle.retryCount = 0;
121
+ lifecycle.requestedAt = new Date();
122
+ lifecycle.lastError = null;
123
+ lifecycle.artifacts = { requiresCatalogActivation: true };
124
+ await lifecycle.save({ transaction });
125
+ } else {
126
+ lifecycle.status = 'provisioning';
127
+ lifecycle.lastError = null;
128
+ if (!lifecycle.artifacts || typeof lifecycle.artifacts !== 'object') {
129
+ lifecycle.artifacts = { requiresCatalogActivation: false };
130
+ }
131
+ await lifecycle.save({ transaction });
132
+ }
133
+ return lifecycle;
134
+ };
135
+
136
+ const activateCatalog = async lifecycle => {
137
+ const result = await callCatalogLifecycle({
138
+ action: 'activate',
139
+ userId: lifecycle.userId,
140
+ integrationId: lifecycle.integrationId,
141
+ hint: lifecycle.hint,
142
+ generation: lifecycle.generation,
143
+ });
144
+ if (result && result.status === 'active') return result;
145
+ if (result && result.status === 'superseded') {
146
+ throw lifecycleError(409, 'A newer integration lifecycle already exists.');
147
+ }
148
+ if (result && result.status === 'retry') {
149
+ throw lifecycleError(503, 'Product catalog activation is not ready and can be retried.');
150
+ }
151
+ throw lifecycleError(502, 'Product catalog activation returned an unexpected response.');
152
+ };
153
+
154
+ const activationWhere = lifecycle => ({
155
+ ...identityWhere(lifecycle),
156
+ generation: lifecycle.generation,
157
+ status: 'provisioning',
158
+ });
159
+
160
+ const assertActivationOwnership = updatedRows => {
161
+ if (updatedRows !== 1) {
162
+ throw lifecycleError(409, 'Integration save was superseded by a newer lifecycle.');
163
+ }
164
+ };
165
+
166
+ const touchActivation = async lifecycle => {
167
+ const [updatedRows] = await sqldb.IntegrationLifecycle.update({
168
+ updatedAt: new Date(),
169
+ }, {
170
+ where: activationWhere(lifecycle),
171
+ });
172
+ if (updatedRows === 1) return;
173
+ // MySQL reports zero changed rows when a sub-second heartbeat rounds to the
174
+ // same stored timestamp, so confirm the fenced row before treating it as lost.
175
+ const current = await sqldb.IntegrationLifecycle.findOne({
176
+ where: activationWhere(lifecycle),
177
+ });
178
+ if (!current) assertActivationOwnership(updatedRows);
179
+ };
180
+
181
+ const completeActivation = async lifecycle => {
182
+ const [updatedRows] = await sqldb.IntegrationLifecycle.update({
183
+ status: 'active',
184
+ artifacts: null,
185
+ lastError: null,
186
+ }, {
187
+ where: activationWhere(lifecycle),
188
+ });
189
+ assertActivationOwnership(updatedRows);
190
+ };
191
+
192
+ const failActivation = (lifecycle, error) => sqldb.IntegrationLifecycle.update({
193
+ status: 'failed_activation',
194
+ lastError: error.message,
195
+ }, {
196
+ where: {
197
+ ...identityWhere(lifecycle),
198
+ generation: lifecycle.generation,
199
+ status: 'provisioning',
200
+ },
201
+ });
202
+
203
+ const beginDeletion = async ({
204
+ userId,
205
+ integrationId,
206
+ hint,
207
+ requestedBy,
208
+ }) => {
209
+ const where = identityWhere({ userId, integrationId, hint });
210
+ return sqldb.sequelize.transaction(async transaction => {
211
+ await sqldb.User.findOne({
212
+ where: { userId },
213
+ transaction,
214
+ lock: transaction.LOCK.UPDATE,
215
+ });
216
+ let lifecycle = await sqldb.IntegrationLifecycle.findOne({
217
+ where,
218
+ transaction,
219
+ lock: transaction.LOCK.UPDATE,
220
+ });
221
+ if (lifecycle && ['complete', 'external_cleanup'].includes(lifecycle.status)) {
222
+ return lifecycle;
223
+ }
224
+ if (
225
+ lifecycle
226
+ && lifecycle.status === 'provisioning'
227
+ && !provisioningIsStale(lifecycle)
228
+ ) {
229
+ throw lifecycleError(409, 'Integration save is still finishing. Retry removal shortly.');
230
+ }
231
+ if (!lifecycle) {
232
+ const credential = await sqldb.UserAppKey.findOne({
233
+ where,
234
+ transaction,
235
+ lock: transaction.LOCK.UPDATE,
236
+ });
237
+ if (!credential) throw lifecycleError(404, 'Integration credential not found.');
238
+ lifecycle = await sqldb.IntegrationLifecycle.create({
239
+ ...where,
240
+ generation: 1,
241
+ status: 'deleting',
242
+ requestId: newRequestId(),
243
+ retryCount: 0,
244
+ requestedBy,
245
+ requestedAt: new Date(),
246
+ }, { transaction });
247
+ return lifecycle;
248
+ }
249
+ if (
250
+ ['active', 'failed_activation'].includes(lifecycle.status)
251
+ || (lifecycle.status === 'provisioning' && provisioningIsStale(lifecycle))
252
+ ) {
253
+ // Deletion intentionally advances beyond a failed/stale activation. The catalog
254
+ // contract accepts a higher generation so an activation that committed remotely
255
+ // before TI2 crashed cannot resurrect the integration.
256
+ lifecycle.generation += 1;
257
+ lifecycle.requestId = newRequestId();
258
+ lifecycle.retryCount = 0;
259
+ lifecycle.requestedAt = new Date();
260
+ } else {
261
+ lifecycle.retryCount += 1;
262
+ }
263
+ lifecycle.status = 'deleting';
264
+ lifecycle.requestedBy = requestedBy;
265
+ lifecycle.lastError = null;
266
+ await lifecycle.save({ transaction });
267
+ return lifecycle;
268
+ });
269
+ };
270
+
271
+ const cleanupSchedules = async ({
272
+ userId,
273
+ integrationId,
274
+ hint,
275
+ }) => {
276
+ const where = {
277
+ userId,
278
+ hint,
279
+ pluginName: integrationId,
280
+ };
281
+ return sqldb.sequelize.transaction(async transaction => {
282
+ const schedules = await sqldb.CronJobs.findAll({
283
+ where,
284
+ transaction,
285
+ lock: transaction.LOCK.UPDATE,
286
+ });
287
+ /* eslint-disable no-await-in-loop */
288
+ // Sequential removal keeps each Bull deletion paired with its locked database row.
289
+ for (const schedule of schedules) { // eslint-disable-line no-restricted-syntax
290
+ const references = await sqldb.CronJobs.findAll({
291
+ where: { bullJobId: schedule.bullJobId },
292
+ transaction,
293
+ lock: transaction.LOCK.UPDATE,
294
+ });
295
+ if (references.length === 1) await removeJob(schedule.bullJobId);
296
+ await schedule.destroy({ transaction });
297
+ }
298
+ /* eslint-enable no-await-in-loop */
299
+ return schedules.length;
300
+ });
301
+ };
302
+
303
+ const cleanupCredentialAndSettings = ({ userId, integrationId, hint }) => (
304
+ sqldb.sequelize.transaction(async transaction => {
305
+ await sqldb.User.findOne({
306
+ where: { userId },
307
+ transaction,
308
+ lock: transaction.LOCK.UPDATE,
309
+ });
310
+ const credentials = await sqldb.UserAppKey.destroy({
311
+ where: identityWhere({ userId, integrationId, hint }),
312
+ transaction,
313
+ });
314
+ const remainingHints = await sqldb.UserAppKey.count({
315
+ where: { userId, integrationId },
316
+ transaction,
317
+ });
318
+ const settings = remainingHints === 0
319
+ ? await sqldb.UserIntegrationSettings.destroy({
320
+ where: { userId, integrationId },
321
+ transaction,
322
+ })
323
+ : 0;
324
+ return { credentials, settings };
325
+ })
326
+ );
327
+
328
+ const deleteIntegration = async ({
329
+ userId,
330
+ integrationId,
331
+ hint,
332
+ requestedBy,
333
+ deferCompletion = false,
334
+ }) => {
335
+ const lifecycle = await beginDeletion({
336
+ userId,
337
+ integrationId,
338
+ hint,
339
+ requestedBy,
340
+ });
341
+ if (['complete', 'external_cleanup'].includes(lifecycle.status)) {
342
+ return lifecycle.get({ plain: true });
343
+ }
344
+
345
+ const where = identityWhere({ userId, integrationId, hint });
346
+ let failureStage = 'schedule cleanup';
347
+ let observedArtifacts = lifecycle.artifacts || {};
348
+ try {
349
+ const cronJobs = await cleanupSchedules({ userId, integrationId, hint });
350
+ const previousArtifacts = { ...observedArtifacts };
351
+ delete previousArtifacts.failureStage;
352
+ observedArtifacts = {
353
+ ...previousArtifacts,
354
+ cronJobs: Math.max(previousArtifacts.cronJobs || 0, cronJobs),
355
+ };
356
+ await sqldb.IntegrationLifecycle.update({ artifacts: observedArtifacts }, {
357
+ where: {
358
+ ...where,
359
+ generation: lifecycle.generation,
360
+ status: { [Op.in]: ['deleting', 'failed'] },
361
+ },
362
+ });
363
+ failureStage = 'credential and settings cleanup';
364
+ const { credentials, settings } = await cleanupCredentialAndSettings({
365
+ userId,
366
+ integrationId,
367
+ hint,
368
+ });
369
+ observedArtifacts = {
370
+ ...observedArtifacts,
371
+ credentials: Math.max(observedArtifacts.credentials || 0, credentials),
372
+ settings: Math.max(observedArtifacts.settings || 0, settings),
373
+ };
374
+ await sqldb.IntegrationLifecycle.update({ artifacts: observedArtifacts }, {
375
+ where: {
376
+ ...where,
377
+ generation: lifecycle.generation,
378
+ status: { [Op.in]: ['deleting', 'failed'] },
379
+ },
380
+ });
381
+ failureStage = 'product catalog cleanup';
382
+ const catalog = await callCatalogLifecycle({
383
+ action: 'delete',
384
+ userId,
385
+ integrationId,
386
+ hint,
387
+ generation: lifecycle.generation,
388
+ });
389
+ if (catalog && catalog.status === 'superseded') {
390
+ throw lifecycleError(409, 'A newer product catalog lifecycle already exists.');
391
+ }
392
+ if (catalog && catalog.status === 'retry') {
393
+ throw lifecycleError(503, 'Product catalog cleanup is not ready and can be retried.');
394
+ }
395
+ if (!catalog || catalog.status !== 'deleted') {
396
+ throw lifecycleError(502, 'Product catalog cleanup returned an unexpected response.');
397
+ }
398
+ const artifacts = {
399
+ ...observedArtifacts,
400
+ catalog,
401
+ };
402
+ const status = deferCompletion ? 'external_cleanup' : 'complete';
403
+ const [updatedRows] = await sqldb.IntegrationLifecycle.update({
404
+ status,
405
+ artifacts,
406
+ lastError: null,
407
+ }, {
408
+ where: {
409
+ ...where,
410
+ generation: lifecycle.generation,
411
+ status: { [Op.in]: ['deleting', 'failed'] },
412
+ },
413
+ });
414
+ if (updatedRows === 0) {
415
+ const persisted = await sqldb.IntegrationLifecycle.findOne({ where });
416
+ if (
417
+ persisted
418
+ && persisted.generation === lifecycle.generation
419
+ && ['complete', 'external_cleanup'].includes(persisted.status)
420
+ ) {
421
+ return persisted.get({ plain: true });
422
+ }
423
+ throw lifecycleError(409, 'Integration cleanup state changed before completion.');
424
+ }
425
+ return {
426
+ ...where,
427
+ generation: lifecycle.generation,
428
+ requestId: lifecycle.requestId,
429
+ requestedAt: lifecycle.requestedAt,
430
+ retryCount: lifecycle.retryCount,
431
+ status,
432
+ artifacts,
433
+ };
434
+ } catch (error) {
435
+ await sqldb.IntegrationLifecycle.update({
436
+ status: 'failed',
437
+ artifacts: { ...observedArtifacts, failureStage },
438
+ lastError: error.message,
439
+ }, {
440
+ where: {
441
+ ...where,
442
+ generation: lifecycle.generation,
443
+ status: 'deleting',
444
+ },
445
+ });
446
+ if (error.status) throw error;
447
+ throw lifecycleError(
448
+ 503,
449
+ `Integration cleanup stopped at ${failureStage} and can be retried: ${error.message}`,
450
+ );
451
+ }
452
+ };
453
+
454
+ const completeDeletion = async ({
455
+ userId,
456
+ integrationId,
457
+ hint,
458
+ generation,
459
+ requestId,
460
+ externalArtifacts,
461
+ }) => sqldb.sequelize.transaction(async transaction => {
462
+ const where = identityWhere({ userId, integrationId, hint });
463
+ const lifecycle = await sqldb.IntegrationLifecycle.findOne({
464
+ where,
465
+ transaction,
466
+ lock: transaction.LOCK.UPDATE,
467
+ });
468
+ if (!lifecycle || lifecycle.generation !== generation) {
469
+ throw lifecycleError(409, 'Integration cleanup generation no longer matches.');
470
+ }
471
+ if (lifecycle.requestId !== requestId) {
472
+ throw lifecycleError(409, 'Integration cleanup request no longer owns this generation.');
473
+ }
474
+ if (lifecycle.status === 'complete') return lifecycle.get({ plain: true });
475
+ if (lifecycle.status !== 'external_cleanup') {
476
+ throw lifecycleError(409, 'Integration cleanup is not ready to finalize.');
477
+ }
478
+ lifecycle.status = 'complete';
479
+ lifecycle.artifacts = {
480
+ ...(lifecycle.artifacts || {}),
481
+ external: externalArtifacts || {},
482
+ };
483
+ lifecycle.lastError = null;
484
+ await lifecycle.save({ transaction });
485
+ return lifecycle.get({ plain: true });
486
+ });
487
+
488
+ module.exports = {
489
+ activateCatalog,
490
+ completeActivation,
491
+ completeDeletion,
492
+ deleteIntegration,
493
+ failActivation,
494
+ prepareActivation,
495
+ touchActivation,
496
+ };
@@ -0,0 +1,89 @@
1
+ /* globals beforeEach describe expect it jest */
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const mockQueue = {
6
+ getJob: jest.fn(),
7
+ getRepeatableJobs: jest.fn(),
8
+ on: jest.fn(),
9
+ removeRepeatableByKey: jest.fn(),
10
+ };
11
+ const mockRedis = { del: jest.fn() };
12
+
13
+ jest.mock('bull', () => jest.fn(() => mockQueue));
14
+ jest.mock('ioredis', () => jest.fn(() => mockRedis));
15
+
16
+ jest.resetModules();
17
+ const Queue = require('bull');
18
+ const { removeJob } = require('../queue');
19
+
20
+ const occurrenceId = ({
21
+ name, id, key, timestamp = 1700000000000,
22
+ }) => {
23
+ const namespace = crypto.createHash('md5').update(key).digest('hex');
24
+ const repeatHash = crypto.createHash('md5')
25
+ .update(`${name}${id ? `${id}:` : ':'}${namespace}`)
26
+ .digest('hex');
27
+ return `repeat:${repeatHash}:${timestamp}`;
28
+ };
29
+
30
+ describe('queue settings', () => {
31
+ it('keeps long-running jobs locked while the worker is active', () => {
32
+ expect(Queue).toHaveBeenCalledWith('work', expect.any(String), {
33
+ settings: {
34
+ lockDuration: 120e3,
35
+ lockRenewTime: 15e3,
36
+ },
37
+ });
38
+ });
39
+ });
40
+
41
+ describe('removeJob', () => {
42
+ beforeEach(() => {
43
+ jest.clearAllMocks();
44
+ mockQueue.getJob.mockResolvedValue(null);
45
+ });
46
+
47
+ it('removes only the repeat definition that owns the stored occurrence id', async () => {
48
+ const target = {
49
+ name: '__default__',
50
+ id: 'company-a-tourplan-desk-a',
51
+ key: '__default__:company-a-tourplan-desk-a:::0 9 * * *',
52
+ };
53
+ const sameCronOtherIntegration = {
54
+ name: '__default__',
55
+ id: 'company-a-tourplan-desk-b',
56
+ key: '__default__:company-a-tourplan-desk-b:::0 9 * * *',
57
+ };
58
+ mockQueue.getRepeatableJobs.mockResolvedValue([sameCronOtherIntegration, target]);
59
+ const jobId = occurrenceId(target);
60
+
61
+ await removeJob(jobId);
62
+
63
+ expect(mockQueue.removeRepeatableByKey).toHaveBeenCalledTimes(1);
64
+ expect(mockQueue.removeRepeatableByKey).toHaveBeenCalledWith(target.key);
65
+ expect(mockRedis.del).toHaveBeenCalledWith(jobId);
66
+ });
67
+
68
+ it('treats an already-removed repeat definition as an idempotent success', async () => {
69
+ mockQueue.getRepeatableJobs.mockResolvedValue([]);
70
+
71
+ await expect(removeJob('repeat:missing:1700000000000')).resolves.toBeUndefined();
72
+
73
+ expect(mockQueue.removeRepeatableByKey).not.toHaveBeenCalled();
74
+ expect(mockRedis.del).toHaveBeenCalledWith('repeat:missing:1700000000000');
75
+ });
76
+
77
+ it('removes a repeat definition referenced by a legacy custom job id', async () => {
78
+ const target = {
79
+ id: 'company-a-tourplan-desk-a',
80
+ key: '__default__:company-a-tourplan-desk-a:::0 9 * * *',
81
+ };
82
+ mockQueue.getRepeatableJobs.mockResolvedValue([target]);
83
+
84
+ await removeJob(target.id);
85
+
86
+ expect(mockQueue.removeRepeatableByKey).toHaveBeenCalledWith(target.key);
87
+ expect(mockRedis.del).toHaveBeenCalledWith(target.id);
88
+ });
89
+ });