@crowdin/app-project-module 2.8.0 → 2.9.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.
@@ -8,7 +8,7 @@ export declare class DrizzleStorage implements Storage {
8
8
  protected readonly schema: typeof POSTGRESQL_SCHEMA;
9
9
  protected readonly type: DB_TYPE;
10
10
  protected readonly migrationPromise: Promise<void>;
11
- protected migrated: boolean;
11
+ private _delayedMigrationPromise;
12
12
  protected readonly config: Config | UnauthorizedConfig;
13
13
  constructor({ config, type, delayedMigration, }: {
14
14
  config: Config | UnauthorizedConfig;
@@ -16,6 +16,8 @@ export declare class DrizzleStorage implements Storage {
16
16
  delayedMigration?: boolean;
17
17
  });
18
18
  migrateDelayed(): Promise<void>;
19
+ private _cascadeDelete;
20
+ private _executeMigration;
19
21
  saveCrowdinCredentials(credentials: CrowdinCredentials): Promise<void>;
20
22
  updateCrowdinCredentials(credentials: CrowdinCredentials): Promise<void>;
21
23
  getCrowdinCredentials(id: string): Promise<CrowdinCredentials | undefined>;
@@ -31,7 +31,6 @@ class DrizzleStorage {
31
31
  // schema: typeof D1_SCHEMA;
32
32
  constructor({ config, type, delayedMigration, }) {
33
33
  var _a, _b, _c, _d, _e;
34
- this.migrated = false;
35
34
  let db;
36
35
  if (type === 'd1') {
37
36
  db = (0, d1_1.initD1)(Object.assign(Object.assign({}, config.d1Config), { additionalSchema: (_a = config.customTables) === null || _a === void 0 ? void 0 : _a.schema }));
@@ -47,14 +46,14 @@ class DrizzleStorage {
47
46
  directoryPath: config.migrateToPostgreFromSQLite ? config.dbFolder : undefined,
48
47
  additionalMigrationFolder: (_c = config.customTables) === null || _c === void 0 ? void 0 : _c.migrationFolder,
49
48
  });
50
- this.migrated = true;
49
+ this._delayedMigrationPromise = Promise.resolve();
51
50
  }
52
51
  else {
53
52
  db = (0, sqlite_1.initSqlite)(config.dbFolder, (_d = config.customTables) === null || _d === void 0 ? void 0 : _d.schema);
54
53
  this.migrationPromise = delayedMigration
55
54
  ? Promise.resolve()
56
55
  : (0, sqlite_1.migrateSqlite)(db, (_e = config.customTables) === null || _e === void 0 ? void 0 : _e.migrationFolder);
57
- this.migrated = true;
56
+ this._delayedMigrationPromise = Promise.resolve();
58
57
  }
59
58
  // using cast here to avoid ts errors, since all drivers should have the same methods, just different clients
60
59
  this.db = db;
@@ -64,26 +63,55 @@ class DrizzleStorage {
64
63
  }
65
64
  migrateDelayed() {
66
65
  return __awaiter(this, void 0, void 0, function* () {
67
- var _a, _b, _c;
68
- if (!this.migrated) {
69
- if (this.type === 'd1') {
70
- yield (0, d1_1.migrateD1)(this.db, (_a = this.config.customTables) === null || _a === void 0 ? void 0 : _a.d1Migrations);
71
- }
72
- else if (this.type === 'postgresql') {
73
- yield (0, postgresql_1.migratePostgreSQL)({
74
- db: this.db,
75
- name: this.config.name,
76
- directoryPath: this.config.migrateToPostgreFromSQLite ? this.config.dbFolder : undefined,
77
- additionalMigrationFolder: (_b = this.config.customTables) === null || _b === void 0 ? void 0 : _b.migrationFolder,
78
- });
79
- }
80
- else {
81
- yield (0, sqlite_1.migrateSqlite)(this.db, (_c = this.config.customTables) === null || _c === void 0 ? void 0 : _c.migrationFolder);
82
- }
83
- this.migrated = true;
66
+ if (!this._delayedMigrationPromise) {
67
+ this._delayedMigrationPromise = this._executeMigration();
68
+ }
69
+ return this._delayedMigrationPromise;
70
+ });
71
+ }
72
+ // Each backend needs a different atomic delete strategy:
73
+ // - better-sqlite3: synchronous transaction (async callbacks are not awaited by the driver)
74
+ // - D1: batch API (Workers runtime does not support BEGIN/COMMIT via the JS binding)
75
+ // - PostgreSQL: standard async transaction
76
+ _cascadeDelete(pairs) {
77
+ return __awaiter(this, void 0, void 0, function* () {
78
+ if (this.type === 'sqlite') {
79
+ this.db.transaction((tx) => {
80
+ for (const { table, condition } of pairs) {
81
+ tx.delete(table).where(condition).run();
82
+ }
83
+ });
84
+ }
85
+ else if (this.type === 'd1') {
86
+ const d1Db = this.db;
87
+ yield d1Db.batch(pairs.map(({ table, condition }) => d1Db.delete(table).where(condition)));
88
+ }
89
+ else {
90
+ yield this.db.transaction((tx) => __awaiter(this, void 0, void 0, function* () {
91
+ for (const { table, condition } of pairs) {
92
+ yield tx.delete(table).where(condition);
93
+ }
94
+ }));
84
95
  }
85
96
  });
86
97
  }
98
+ _executeMigration() {
99
+ var _a, _b, _c;
100
+ if (this.type === 'd1') {
101
+ return (0, d1_1.migrateD1)(this.db, (_a = this.config.customTables) === null || _a === void 0 ? void 0 : _a.d1Migrations);
102
+ }
103
+ else if (this.type === 'postgresql') {
104
+ return (0, postgresql_1.migratePostgreSQL)({
105
+ db: this.db,
106
+ name: this.config.name,
107
+ directoryPath: this.config.migrateToPostgreFromSQLite ? this.config.dbFolder : undefined,
108
+ additionalMigrationFolder: (_b = this.config.customTables) === null || _b === void 0 ? void 0 : _b.migrationFolder,
109
+ });
110
+ }
111
+ else {
112
+ return (0, sqlite_1.migrateSqlite)(this.db, (_c = this.config.customTables) === null || _c === void 0 ? void 0 : _c.migrationFolder);
113
+ }
114
+ }
87
115
  saveCrowdinCredentials(credentials) {
88
116
  return __awaiter(this, void 0, void 0, function* () {
89
117
  yield this.migrationPromise;
@@ -168,22 +196,23 @@ class DrizzleStorage {
168
196
  deleteCrowdinCredentials(id) {
169
197
  return __awaiter(this, void 0, void 0, function* () {
170
198
  yield this.migrationPromise;
171
- yield this.db.delete(this.schema.crowdinCredentials).where((0, drizzle_orm_1.eq)(this.schema.crowdinCredentials.id, id));
172
- yield this.db
173
- .delete(this.schema.integrationCredentials)
174
- .where((0, drizzle_orm_1.eq)(this.schema.integrationCredentials.crowdin_id, id));
175
- yield this.db.delete(this.schema.syncSettings).where((0, drizzle_orm_1.eq)(this.schema.syncSettings.crowdin_id, id));
176
- yield this.db.delete(this.schema.appMetadata).where((0, drizzle_orm_1.eq)(this.schema.appMetadata.crowdin_id, id));
177
- yield this.db.delete(this.schema.filesSnapshot).where((0, drizzle_orm_1.eq)(this.schema.filesSnapshot.crowdin_id, id));
178
- yield this.db.delete(this.schema.webhooks).where((0, drizzle_orm_1.eq)(this.schema.webhooks.crowdin_id, id));
179
- yield this.db.delete(this.schema.userErrors).where((0, drizzle_orm_1.eq)(this.schema.userErrors.crowdin_id, id));
180
- yield this.db.delete(this.schema.integrationSettings).where((0, drizzle_orm_1.eq)(this.schema.integrationSettings.crowdin_id, id));
181
- yield this.db.delete(this.schema.job).where((0, drizzle_orm_1.eq)(this.schema.job.crowdin_id, id));
182
- yield this.db
183
- .delete(this.schema.translationFileCache)
184
- .where((0, drizzle_orm_1.eq)(this.schema.translationFileCache.crowdin_id, id));
185
- yield this.db.delete(this.schema.unsyncedFiles).where((0, drizzle_orm_1.eq)(this.schema.unsyncedFiles.crowdin_id, id));
186
- yield this.db.delete(this.schema.syncedData).where((0, drizzle_orm_1.eq)(this.schema.syncedData.crowdin_id, id));
199
+ yield this._cascadeDelete([
200
+ { table: this.schema.crowdinCredentials, condition: (0, drizzle_orm_1.eq)(this.schema.crowdinCredentials.id, id) },
201
+ {
202
+ table: this.schema.integrationCredentials,
203
+ condition: (0, drizzle_orm_1.eq)(this.schema.integrationCredentials.crowdin_id, id),
204
+ },
205
+ { table: this.schema.syncSettings, condition: (0, drizzle_orm_1.eq)(this.schema.syncSettings.crowdin_id, id) },
206
+ { table: this.schema.appMetadata, condition: (0, drizzle_orm_1.eq)(this.schema.appMetadata.crowdin_id, id) },
207
+ { table: this.schema.filesSnapshot, condition: (0, drizzle_orm_1.eq)(this.schema.filesSnapshot.crowdin_id, id) },
208
+ { table: this.schema.webhooks, condition: (0, drizzle_orm_1.eq)(this.schema.webhooks.crowdin_id, id) },
209
+ { table: this.schema.userErrors, condition: (0, drizzle_orm_1.eq)(this.schema.userErrors.crowdin_id, id) },
210
+ { table: this.schema.integrationSettings, condition: (0, drizzle_orm_1.eq)(this.schema.integrationSettings.crowdin_id, id) },
211
+ { table: this.schema.job, condition: (0, drizzle_orm_1.eq)(this.schema.job.crowdin_id, id) },
212
+ { table: this.schema.translationFileCache, condition: (0, drizzle_orm_1.eq)(this.schema.translationFileCache.crowdin_id, id) },
213
+ { table: this.schema.unsyncedFiles, condition: (0, drizzle_orm_1.eq)(this.schema.unsyncedFiles.crowdin_id, id) },
214
+ { table: this.schema.syncedData, condition: (0, drizzle_orm_1.eq)(this.schema.syncedData.crowdin_id, id) },
215
+ ]);
187
216
  });
188
217
  }
189
218
  saveIntegrationCredentials(id, credentials, crowdinId) {
@@ -248,41 +277,50 @@ class DrizzleStorage {
248
277
  deleteIntegrationCredentials(id) {
249
278
  return __awaiter(this, void 0, void 0, function* () {
250
279
  yield this.migrationPromise;
251
- yield this.db.delete(this.schema.integrationCredentials).where((0, drizzle_orm_1.eq)(this.schema.integrationCredentials.id, id));
252
- yield this.db.delete(this.schema.syncSettings).where((0, drizzle_orm_1.eq)(this.schema.syncSettings.integration_id, id));
253
- yield this.db.delete(this.schema.filesSnapshot).where((0, drizzle_orm_1.eq)(this.schema.filesSnapshot.integration_id, id));
254
- yield this.db.delete(this.schema.webhooks).where((0, drizzle_orm_1.eq)(this.schema.webhooks.integration_id, id));
255
- yield this.db.delete(this.schema.userErrors).where((0, drizzle_orm_1.eq)(this.schema.userErrors.integration_id, id));
256
- yield this.db
257
- .delete(this.schema.integrationSettings)
258
- .where((0, drizzle_orm_1.eq)(this.schema.integrationSettings.integration_id, id));
259
- yield this.db.delete(this.schema.job).where((0, drizzle_orm_1.eq)(this.schema.job.integration_id, id));
260
- yield this.db
261
- .delete(this.schema.translationFileCache)
262
- .where((0, drizzle_orm_1.eq)(this.schema.translationFileCache.integration_id, id));
263
- yield this.db.delete(this.schema.unsyncedFiles).where((0, drizzle_orm_1.eq)(this.schema.unsyncedFiles.integration_id, id));
264
- yield this.db.delete(this.schema.syncedData).where((0, drizzle_orm_1.eq)(this.schema.syncedData.integration_id, id));
280
+ yield this._cascadeDelete([
281
+ { table: this.schema.integrationCredentials, condition: (0, drizzle_orm_1.eq)(this.schema.integrationCredentials.id, id) },
282
+ { table: this.schema.syncSettings, condition: (0, drizzle_orm_1.eq)(this.schema.syncSettings.integration_id, id) },
283
+ { table: this.schema.filesSnapshot, condition: (0, drizzle_orm_1.eq)(this.schema.filesSnapshot.integration_id, id) },
284
+ { table: this.schema.webhooks, condition: (0, drizzle_orm_1.eq)(this.schema.webhooks.integration_id, id) },
285
+ { table: this.schema.userErrors, condition: (0, drizzle_orm_1.eq)(this.schema.userErrors.integration_id, id) },
286
+ {
287
+ table: this.schema.integrationSettings,
288
+ condition: (0, drizzle_orm_1.eq)(this.schema.integrationSettings.integration_id, id),
289
+ },
290
+ { table: this.schema.job, condition: (0, drizzle_orm_1.eq)(this.schema.job.integration_id, id) },
291
+ {
292
+ table: this.schema.translationFileCache,
293
+ condition: (0, drizzle_orm_1.eq)(this.schema.translationFileCache.integration_id, id),
294
+ },
295
+ { table: this.schema.unsyncedFiles, condition: (0, drizzle_orm_1.eq)(this.schema.unsyncedFiles.integration_id, id) },
296
+ { table: this.schema.syncedData, condition: (0, drizzle_orm_1.eq)(this.schema.syncedData.integration_id, id) },
297
+ ]);
265
298
  });
266
299
  }
267
300
  deleteAllIntegrationCredentials(crowdinId) {
268
301
  return __awaiter(this, void 0, void 0, function* () {
269
302
  yield this.migrationPromise;
270
- yield this.db
271
- .delete(this.schema.integrationCredentials)
272
- .where((0, drizzle_orm_1.eq)(this.schema.integrationCredentials.crowdin_id, crowdinId));
273
- yield this.db.delete(this.schema.syncSettings).where((0, drizzle_orm_1.eq)(this.schema.syncSettings.crowdin_id, crowdinId));
274
- yield this.db.delete(this.schema.filesSnapshot).where((0, drizzle_orm_1.eq)(this.schema.filesSnapshot.crowdin_id, crowdinId));
275
- yield this.db.delete(this.schema.webhooks).where((0, drizzle_orm_1.eq)(this.schema.webhooks.crowdin_id, crowdinId));
276
- yield this.db.delete(this.schema.userErrors).where((0, drizzle_orm_1.eq)(this.schema.userErrors.crowdin_id, crowdinId));
277
- yield this.db
278
- .delete(this.schema.integrationSettings)
279
- .where((0, drizzle_orm_1.eq)(this.schema.integrationSettings.crowdin_id, crowdinId));
280
- yield this.db.delete(this.schema.job).where((0, drizzle_orm_1.eq)(this.schema.job.crowdin_id, crowdinId));
281
- yield this.db
282
- .delete(this.schema.translationFileCache)
283
- .where((0, drizzle_orm_1.eq)(this.schema.translationFileCache.crowdin_id, crowdinId));
284
- yield this.db.delete(this.schema.unsyncedFiles).where((0, drizzle_orm_1.eq)(this.schema.unsyncedFiles.crowdin_id, crowdinId));
285
- yield this.db.delete(this.schema.syncedData).where((0, drizzle_orm_1.eq)(this.schema.syncedData.crowdin_id, crowdinId));
303
+ yield this._cascadeDelete([
304
+ {
305
+ table: this.schema.integrationCredentials,
306
+ condition: (0, drizzle_orm_1.eq)(this.schema.integrationCredentials.crowdin_id, crowdinId),
307
+ },
308
+ { table: this.schema.syncSettings, condition: (0, drizzle_orm_1.eq)(this.schema.syncSettings.crowdin_id, crowdinId) },
309
+ { table: this.schema.filesSnapshot, condition: (0, drizzle_orm_1.eq)(this.schema.filesSnapshot.crowdin_id, crowdinId) },
310
+ { table: this.schema.webhooks, condition: (0, drizzle_orm_1.eq)(this.schema.webhooks.crowdin_id, crowdinId) },
311
+ { table: this.schema.userErrors, condition: (0, drizzle_orm_1.eq)(this.schema.userErrors.crowdin_id, crowdinId) },
312
+ {
313
+ table: this.schema.integrationSettings,
314
+ condition: (0, drizzle_orm_1.eq)(this.schema.integrationSettings.crowdin_id, crowdinId),
315
+ },
316
+ { table: this.schema.job, condition: (0, drizzle_orm_1.eq)(this.schema.job.crowdin_id, crowdinId) },
317
+ {
318
+ table: this.schema.translationFileCache,
319
+ condition: (0, drizzle_orm_1.eq)(this.schema.translationFileCache.crowdin_id, crowdinId),
320
+ },
321
+ { table: this.schema.unsyncedFiles, condition: (0, drizzle_orm_1.eq)(this.schema.unsyncedFiles.crowdin_id, crowdinId) },
322
+ { table: this.schema.syncedData, condition: (0, drizzle_orm_1.eq)(this.schema.syncedData.crowdin_id, crowdinId) },
323
+ ]);
286
324
  });
287
325
  }
288
326
  saveMetadata(id, metadata, crowdinId) {
@@ -302,13 +302,16 @@ function prepareIntegrationCredentials(config, integration, integrationCredentia
302
302
  const performRefreshTokenRequest = (oauthLogin === null || oauthLogin === void 0 ? void 0 : oauthLogin.performRefreshTokenRequest) || (integrationLogin === null || integrationLogin === void 0 ? void 0 : integrationLogin.performRefreshTokenRequest);
303
303
  credentials.tokenProvider = {
304
304
  getToken: (...args_1) => __awaiter(this, [...args_1], void 0, function* (force = false) {
305
- var _a, _b, _c, _d;
306
305
  const { expireIn } = credentials;
307
306
  //2 min as an extra buffer
308
307
  const isExpired = !expireIn || expireIn - 120 < Date.now() / 1000;
309
308
  if (!force && !isExpired) {
310
309
  return credentials.accessToken;
311
310
  }
311
+ const existingRefresh = _integrationRefreshInFlight.get(integrationCredentials.id);
312
+ if (existingRefresh) {
313
+ return existingRefresh;
314
+ }
312
315
  (0, logger_1.log)(force
313
316
  ? 'Force refreshing integration credentials'
314
317
  : 'Integration credentials have expired during operation. Requesting a new credentials');
@@ -334,50 +337,62 @@ function prepareIntegrationCredentials(config, integration, integrationCredentia
334
337
  }
335
338
  return null;
336
339
  });
337
- let newCredentials;
338
- try {
339
- newCredentials = yield doRefresh(credentials);
340
- }
341
- catch (e) {
342
- // In-memory refresh token may be stale (rotated by another process/worker,
343
- // or the token itself expired). Try fetching fresh credentials from DB.
344
- (0, logger_1.log)('Token refresh failed. Attempting to fetch fresh credentials from the database');
345
- const freshStoredCredentials = yield (0, storage_1.getStorage)().getIntegrationCredentials(integrationCredentials.id);
346
- if (freshStoredCredentials) {
347
- const freshCredentials = JSON.parse((0, _1.decryptData)(config, freshStoredCredentials.credentials));
348
- if (freshCredentials.refreshToken &&
349
- freshCredentials.refreshToken !== credentials.refreshToken) {
350
- (0, logger_1.log)('Found a newer refresh token in the database, retrying token refresh');
351
- try {
352
- newCredentials = yield doRefresh(freshCredentials);
353
- credentials.refreshToken = freshCredentials.refreshToken;
340
+ const refreshPromise = (() => __awaiter(this, void 0, void 0, function* () {
341
+ var _a, _b, _c, _d;
342
+ let newCredentials;
343
+ try {
344
+ newCredentials = yield doRefresh(credentials);
345
+ }
346
+ catch (e) {
347
+ // In-memory refresh token may be stale (rotated by another process/worker,
348
+ // or the token itself expired). Try fetching fresh credentials from DB.
349
+ (0, logger_1.log)('Token refresh failed. Attempting to fetch fresh credentials from the database');
350
+ const freshStoredCredentials = yield (0, storage_1.getStorage)().getIntegrationCredentials(integrationCredentials.id);
351
+ if (freshStoredCredentials) {
352
+ const freshCredentials = JSON.parse((0, _1.decryptData)(config, freshStoredCredentials.credentials));
353
+ if (freshCredentials.refreshToken &&
354
+ freshCredentials.refreshToken !== credentials.refreshToken) {
355
+ (0, logger_1.log)('Found a newer refresh token in the database, retrying token refresh');
356
+ try {
357
+ newCredentials = yield doRefresh(freshCredentials);
358
+ credentials.refreshToken = freshCredentials.refreshToken;
359
+ }
360
+ catch (retryError) {
361
+ (0, logger_1.logError)(retryError);
362
+ throw retryError;
363
+ }
354
364
  }
355
- catch (retryError) {
356
- (0, logger_1.logError)(retryError);
357
- throw retryError;
365
+ else {
366
+ throw e;
358
367
  }
359
368
  }
360
369
  else {
361
370
  throw e;
362
371
  }
363
372
  }
364
- else {
365
- throw e;
373
+ if (!newCredentials) {
374
+ return credentials.accessToken;
366
375
  }
367
- }
368
- if (!newCredentials) {
376
+ credentials.accessToken = newCredentials[((_a = oauthLogin === null || oauthLogin === void 0 ? void 0 : oauthLogin.fieldsMapping) === null || _a === void 0 ? void 0 : _a.accessToken) || 'access_token'];
377
+ credentials.expireIn =
378
+ Number(newCredentials[((_b = oauthLogin === null || oauthLogin === void 0 ? void 0 : oauthLogin.fieldsMapping) === null || _b === void 0 ? void 0 : _b.expiresIn) || 'expires_in']) +
379
+ Date.now() / 1000;
380
+ if (newCredentials[((_c = oauthLogin === null || oauthLogin === void 0 ? void 0 : oauthLogin.fieldsMapping) === null || _c === void 0 ? void 0 : _c.refreshToken) || 'refresh_token']) {
381
+ credentials.refreshToken =
382
+ newCredentials[((_d = oauthLogin === null || oauthLogin === void 0 ? void 0 : oauthLogin.fieldsMapping) === null || _d === void 0 ? void 0 : _d.refreshToken) || 'refresh_token'];
383
+ }
384
+ (0, logger_1.log)('Saving updated integration credentials in the database');
385
+ yield (0, storage_1.getStorage)().updateIntegrationCredentials(integrationCredentials.id, (0, _1.encryptData)(config, JSON.stringify(credentials)));
369
386
  return credentials.accessToken;
387
+ }))();
388
+ if (_integrationRefreshInFlight.size < 1000) {
389
+ _integrationRefreshInFlight.set(integrationCredentials.id, refreshPromise);
390
+ refreshPromise.finally(() => _integrationRefreshInFlight.delete(integrationCredentials.id));
370
391
  }
371
- credentials.accessToken = newCredentials[((_a = oauthLogin === null || oauthLogin === void 0 ? void 0 : oauthLogin.fieldsMapping) === null || _a === void 0 ? void 0 : _a.accessToken) || 'access_token'];
372
- credentials.expireIn =
373
- Number(newCredentials[((_b = oauthLogin === null || oauthLogin === void 0 ? void 0 : oauthLogin.fieldsMapping) === null || _b === void 0 ? void 0 : _b.expiresIn) || 'expires_in']) + Date.now() / 1000;
374
- if (newCredentials[((_c = oauthLogin === null || oauthLogin === void 0 ? void 0 : oauthLogin.fieldsMapping) === null || _c === void 0 ? void 0 : _c.refreshToken) || 'refresh_token']) {
375
- credentials.refreshToken =
376
- newCredentials[((_d = oauthLogin === null || oauthLogin === void 0 ? void 0 : oauthLogin.fieldsMapping) === null || _d === void 0 ? void 0 : _d.refreshToken) || 'refresh_token'];
392
+ else {
393
+ (0, logger_1.log)('Warning: _integrationRefreshInFlight map reached 1000 entries; skipping deduplication for this refresh');
377
394
  }
378
- (0, logger_1.log)('Saving updated integration credentials in the database');
379
- yield (0, storage_1.getStorage)().updateIntegrationCredentials(integrationCredentials.id, (0, _1.encryptData)(config, JSON.stringify(credentials)));
380
- return credentials.accessToken;
395
+ return refreshPromise;
381
396
  }),
382
397
  refreshToken: () => {
383
398
  return credentials.tokenProvider.getToken(true);
@@ -389,30 +404,48 @@ function prepareIntegrationCredentials(config, integration, integrationCredentia
389
404
  return credentials;
390
405
  });
391
406
  }
407
+ // This map is used to deduplicate concurrent refresh requests for the same Crowdin credentials.
408
+ // Keyed by credentials.id. Assumes a given credentials.id is always used with the same config
409
+ // (i.e. clientId/clientSecret are 1:1 with the credential). Concurrent callers with the same id
410
+ // share one in-flight refresh and all receive the same result.
411
+ const _crowdinRefreshInFlight = new Map();
412
+ const _integrationRefreshInFlight = new Map();
392
413
  function refreshCrowdinCreds(_a) {
393
414
  return __awaiter(this, arguments, void 0, function* ({ config, credentials }) {
394
- (0, logger_1.log)('Crowdin credentials have expired. Requesting a new credentials');
395
- const newCredentials = yield refreshToken(config, credentials);
396
- (0, logger_1.log)('Saving updated crowdin credentials in the database');
397
- const newCrowdinCredentials = {
398
- id: credentials.id,
399
- appSecret: credentials.appSecret,
400
- domain: credentials.domain,
401
- userId: credentials.userId,
402
- agentId: credentials.agentId,
403
- organizationId: credentials.organizationId,
404
- baseUrl: credentials.baseUrl,
405
- refreshToken: newCredentials.refreshToken,
406
- accessToken: newCredentials.accessToken,
407
- expire: (new Date().getTime() / 1000 + newCredentials.expiresIn).toString(),
408
- type: credentials.type,
409
- };
410
- yield (0, storage_1.getStorage)().updateCrowdinCredentials(newCrowdinCredentials);
411
- const token = (0, _1.decryptData)(config, newCredentials.accessToken);
412
- return {
413
- credentials: newCrowdinCredentials,
414
- token,
415
- };
415
+ const existing = _crowdinRefreshInFlight.get(credentials.id);
416
+ if (existing) {
417
+ return existing;
418
+ }
419
+ const connectionPromise = (() => __awaiter(this, void 0, void 0, function* () {
420
+ (0, logger_1.log)('Crowdin credentials have expired. Requesting new credentials');
421
+ const newCredentials = yield refreshToken(config, credentials);
422
+ (0, logger_1.log)('Saving updated crowdin credentials in the database');
423
+ const newCrowdinCredentials = {
424
+ id: credentials.id,
425
+ appSecret: credentials.appSecret,
426
+ domain: credentials.domain,
427
+ userId: credentials.userId,
428
+ agentId: credentials.agentId,
429
+ organizationId: credentials.organizationId,
430
+ baseUrl: credentials.baseUrl,
431
+ refreshToken: newCredentials.refreshToken,
432
+ accessToken: newCredentials.accessToken,
433
+ expire: (Date.now() / 1000 + newCredentials.expiresIn).toString(),
434
+ type: credentials.type,
435
+ };
436
+ yield (0, storage_1.getStorage)().updateCrowdinCredentials(newCrowdinCredentials);
437
+ const token = (0, _1.decryptData)(config, newCredentials.accessToken);
438
+ return { credentials: newCrowdinCredentials, token };
439
+ }))();
440
+ if (_crowdinRefreshInFlight.size < 1000) {
441
+ _crowdinRefreshInFlight.set(credentials.id, connectionPromise);
442
+ connectionPromise.finally(() => _crowdinRefreshInFlight.delete(credentials.id));
443
+ }
444
+ else {
445
+ // should not really happen, but just in case, we don't want to block the refresh if the map is too big
446
+ (0, logger_1.log)('Warning: _crowdinRefreshInFlight map reached 1000 entries; skipping deduplication for this refresh');
447
+ }
448
+ return connectionPromise;
416
449
  });
417
450
  }
418
451
  function refreshToken(config, credentials) {
@@ -32,4 +32,5 @@ export declare function getFormattedDate({ date, userTimezone }: {
32
32
  }): string;
33
33
  export declare function kebabCase(str?: string): string;
34
34
  export declare function snakeCase(str?: string): string;
35
+ export declare function getEnvInt(name: string, defaultValue: number): number;
35
36
  export declare function uniqBy<T>(array: any, key: keyof T): T[];
package/out/util/index.js CHANGED
@@ -61,6 +61,7 @@ exports.validateEmail = validateEmail;
61
61
  exports.getFormattedDate = getFormattedDate;
62
62
  exports.kebabCase = kebabCase;
63
63
  exports.snakeCase = snakeCase;
64
+ exports.getEnvInt = getEnvInt;
64
65
  exports.uniqBy = uniqBy;
65
66
  const crypto = __importStar(require("crypto"));
66
67
  const storage_1 = require("../storage");
@@ -345,6 +346,11 @@ function kebabCase(str) {
345
346
  function snakeCase(str) {
346
347
  return kebabCase(str).replace(/-/g, '_');
347
348
  }
349
+ function getEnvInt(name, defaultValue) {
350
+ var _a;
351
+ const parsed = Number.parseInt((_a = process.env[name]) !== null && _a !== void 0 ? _a : '', 10);
352
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
353
+ }
348
354
  function uniqBy(array, key) {
349
355
  if (!Array.isArray(array)) {
350
356
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowdin/app-project-module",
3
- "version": "2.8.0",
3
+ "version": "2.9.1",
4
4
  "description": "Module that generates for you all common endpoints for serving standalone Crowdin App",
5
5
  "main": "out/index.js",
6
6
  "types": "out/index.d.ts",