@app-connect/core 1.7.36 → 1.7.37

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/index.js CHANGED
@@ -34,7 +34,7 @@ const { DebugTracer } = require('./lib/debugTracer');
34
34
  const s3ErrorLogReport = require('./lib/s3ErrorLogReport');
35
35
  const pluginCore = require('./handlers/plugin');
36
36
  const { sequelize } = require('./models/sequelize');
37
- const { ensureCallLogsHashedExtensionIdSchema } = require('./lib/migrateCallLogsSchema');
37
+ const { runCallLogsSchemaMigration } = require('./lib/migrateCallLogsSchema');
38
38
  const { handleDatabaseError } = require('./lib/errorHandler');
39
39
  const { updateAuthSession } = require('./lib/authSession');
40
40
  const managedAuthCore = require('./handlers/managedAuth');
@@ -73,7 +73,6 @@ async function initDB() {
73
73
  await UserModel.sync();
74
74
  await LlmSessionModel.sync();
75
75
  await CallLogModel.sync();
76
- await ensureCallLogsHashedExtensionIdSchema(sequelize);
77
76
  await MessageLogModel.sync();
78
77
  await AdminConfigModel.sync();
79
78
  await CacheModel.sync();
@@ -3125,6 +3124,16 @@ async function initializeCore(options = {}) {
3125
3124
 
3126
3125
  if (!skipDatabaseInit) {
3127
3126
  await initDB();
3127
+ // Run the heavy callLogs primary-key migration in the BACKGROUND, off the
3128
+ // readiness critical path. It must not gate coreInit: on a large production
3129
+ // table the index rebuild takes minutes, and blocking here would stall every
3130
+ // request (including the load-balancer health check) and fail the deploy.
3131
+ // The runner is self-guarding (advisory-locked across instances, idempotent,
3132
+ // never throws) and uses online DDL on Postgres so live traffic is unaffected.
3133
+ // Gated on the same flag as the schema sync so operators have one switch.
3134
+ if (!process.env.DISABLE_SYNC_DB_TABLE) {
3135
+ runCallLogsSchemaMigration(sequelize, logger);
3136
+ }
3128
3137
  }
3129
3138
  }
3130
3139
 
@@ -227,6 +227,191 @@ async function ensureCallLogsHashedExtensionIdSchema(sequelize) {
227
227
  }
228
228
  }
229
229
 
230
+ // Dedicated name for the online-built unique index that becomes the new PK.
231
+ const IDENTITY_INDEX_NAME = 'callLogs_identity_pk';
232
+ // Stable application-wide key for the Postgres advisory lock that serializes the
233
+ // migration across concurrently-booting instances. Any fixed integer works as long
234
+ // as it is unique within the app; keep it constant so every instance agrees on it.
235
+ const CALL_LOGS_MIGRATION_LOCK_KEY = 4972001;
236
+
237
+ async function postgresPrimaryKeyNameOnConnection(client) {
238
+ const { rows } = await client.query(
239
+ `SELECT conname FROM pg_constraint
240
+ WHERE conrelid = '"${CALL_LOGS_TABLE}"'::regclass AND contype = 'p' LIMIT 1`,
241
+ );
242
+ return rows[0]?.conname ?? null;
243
+ }
244
+
245
+ async function postgresPkIncludesIdentityOnConnection(client) {
246
+ const { rows } = await client.query(
247
+ `SELECT kcu.column_name AS col
248
+ FROM information_schema.table_constraints tc
249
+ JOIN information_schema.key_column_usage kcu
250
+ ON kcu.constraint_name = tc.constraint_name
251
+ AND kcu.table_name = tc.table_name
252
+ WHERE tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY'`,
253
+ [CALL_LOGS_TABLE],
254
+ );
255
+ const cols = rows.map((r) => String(r.col).toLowerCase());
256
+ return CALL_LOG_IDENTITY_PK_COLUMNS.every((c) => cols.includes(c.toLowerCase()));
257
+ }
258
+
259
+ async function postgresColumnIsNullableOnConnection(client, column) {
260
+ const { rows } = await client.query(
261
+ `SELECT is_nullable FROM information_schema.columns
262
+ WHERE table_name = $1 AND column_name = $2`,
263
+ [CALL_LOGS_TABLE, column],
264
+ );
265
+ return rows[0]?.is_nullable === 'YES';
266
+ }
267
+
268
+ // 'absent' | 'valid' | 'invalid' — an INVALID index is the residue of a
269
+ // CONCURRENTLY build that was interrupted (process killed, connection dropped).
270
+ async function postgresIdentityIndexState(client) {
271
+ const { rows } = await client.query(
272
+ `SELECT (i.indisvalid AND i.indisready) AS ok
273
+ FROM pg_class c
274
+ JOIN pg_index i ON i.indexrelid = c.oid
275
+ WHERE c.relname = $1`,
276
+ [IDENTITY_INDEX_NAME],
277
+ );
278
+ if (rows.length === 0) return 'absent';
279
+ return rows[0].ok ? 'valid' : 'invalid';
280
+ }
281
+
282
+ /**
283
+ * Online (non-blocking) Postgres migration.
284
+ *
285
+ * Adds the hashedExtensionId column and rebuilds the primary key to the call-log
286
+ * identity WITHOUT holding an ACCESS EXCLUSIVE lock for the duration of the index
287
+ * build. This is the safe path for large production tables:
288
+ * - ADD COLUMN ... NOT NULL DEFAULT '' is metadata-only on PG 11+.
289
+ * - CREATE UNIQUE INDEX CONCURRENTLY builds the index while reads/writes continue.
290
+ * - ADD PRIMARY KEY USING INDEX attaches the prebuilt index with only a brief lock.
291
+ *
292
+ * Runs on a single dedicated pooled connection so the advisory lock and the
293
+ * (necessarily non-transactional) CONCURRENTLY statements share one backend session.
294
+ */
295
+ async function ensureCallLogsHashedExtensionIdSchemaPostgresOnline(sequelize, logger) {
296
+ // Cheap pre-check on a pooled connection; skip entirely once migrated.
297
+ if (await postgresCallLogsPkIncludesHashedExtension(sequelize)) {
298
+ return;
299
+ }
300
+
301
+ const connectionManager = sequelize.connectionManager;
302
+ const client = await connectionManager.getConnection({ type: 'write' });
303
+ let lockAcquired = false;
304
+ try {
305
+ const lockRes = await client.query(
306
+ `SELECT pg_try_advisory_lock(${CALL_LOGS_MIGRATION_LOCK_KEY}) AS locked`,
307
+ );
308
+ lockAcquired = lockRes.rows[0]?.locked === true;
309
+ if (!lockAcquired) {
310
+ logger?.info?.('[callLogs migration] another instance is migrating; skipping this run');
311
+ return;
312
+ }
313
+
314
+ // Re-check under the lock — another instance may have finished between the
315
+ // pre-check and acquiring the lock.
316
+ if (await postgresPkIncludesIdentityOnConnection(client)) {
317
+ return;
318
+ }
319
+
320
+ logger?.info?.('[callLogs migration] starting online migration (add column + rebuild primary key)');
321
+
322
+ // A server-side statement_timeout (common in RDS parameter groups) would abort
323
+ // the long CONCURRENTLY build and leave an INVALID index. Disable it for this
324
+ // session only; it is reset before the connection returns to the pool.
325
+ await client.query(`SET statement_timeout = 0`);
326
+
327
+ // 1. Add the column (metadata-only on PG 11+, safe to repeat).
328
+ await client.query(
329
+ `ALTER TABLE "${CALL_LOGS_TABLE}" ADD COLUMN IF NOT EXISTS "${HASHED_EXTENSION_ID_COLUMN}" varchar(255) NOT NULL DEFAULT ''`,
330
+ );
331
+
332
+ // 2. Primary-key columns must be NOT NULL. Only touch extensionNumber if needed.
333
+ if (await postgresColumnIsNullableOnConnection(client, 'extensionNumber')) {
334
+ await client.query(
335
+ `UPDATE "${CALL_LOGS_TABLE}" SET "extensionNumber" = '' WHERE "extensionNumber" IS NULL`,
336
+ );
337
+ await client.query(
338
+ `ALTER TABLE "${CALL_LOGS_TABLE}" ALTER COLUMN "extensionNumber" SET NOT NULL`,
339
+ );
340
+ }
341
+
342
+ // 3. Build the unique index without blocking traffic (cannot run in a transaction).
343
+ // Reuse an already-valid index from a prior run so a failed *attach* (step 4)
344
+ // never forces us to redo the expensive build; only rebuild if it is missing
345
+ // or was left INVALID by an interrupted run.
346
+ const indexState = await postgresIdentityIndexState(client);
347
+ if (indexState === 'invalid') {
348
+ await client.query(`DROP INDEX CONCURRENTLY IF EXISTS "${IDENTITY_INDEX_NAME}"`);
349
+ }
350
+ if (indexState !== 'valid') {
351
+ await client.query(
352
+ `CREATE UNIQUE INDEX CONCURRENTLY "${IDENTITY_INDEX_NAME}" ON "${CALL_LOGS_TABLE}" (${CALL_LOG_IDENTITY_PK_COLUMNS.map((c) => `"${c}"`).join(', ')})`,
353
+ );
354
+ }
355
+
356
+ // 4. Swap the primary key onto the prebuilt index (brief ACCESS EXCLUSIVE lock,
357
+ // no rebuild). Bound the lock wait so that if a long-running query holds the
358
+ // table we fail fast and retry next boot, instead of parking an exclusive-lock
359
+ // request at the head of the queue and stalling all traffic behind it.
360
+ await client.query(`SET lock_timeout = '5s'`);
361
+ const pkName = await postgresPrimaryKeyNameOnConnection(client);
362
+ if (pkName) {
363
+ await client.query(
364
+ `ALTER TABLE "${CALL_LOGS_TABLE}" DROP CONSTRAINT "${pkName}", ADD PRIMARY KEY USING INDEX "${IDENTITY_INDEX_NAME}"`,
365
+ );
366
+ } else {
367
+ await client.query(
368
+ `ALTER TABLE "${CALL_LOGS_TABLE}" ADD PRIMARY KEY USING INDEX "${IDENTITY_INDEX_NAME}"`,
369
+ );
370
+ }
371
+
372
+ logger?.info?.('[callLogs migration] completed');
373
+ } finally {
374
+ // Restore session defaults before the connection goes back to the pool, so no
375
+ // other query inherits statement_timeout=0 / the short lock_timeout.
376
+ try {
377
+ await client.query('RESET statement_timeout');
378
+ await client.query('RESET lock_timeout');
379
+ } catch (e) {
380
+ logger?.warn?.('[callLogs migration] failed to reset session settings', { message: e?.message });
381
+ }
382
+ if (lockAcquired) {
383
+ try {
384
+ await client.query(`SELECT pg_advisory_unlock(${CALL_LOGS_MIGRATION_LOCK_KEY})`);
385
+ } catch (e) {
386
+ logger?.warn?.('[callLogs migration] failed to release advisory lock', { message: e?.message });
387
+ }
388
+ }
389
+ connectionManager.releaseConnection(client);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Automatic entry point. Safe to call fire-and-forget off the startup critical path:
395
+ * it never throws (failures are logged and retried on the next boot) and, on Postgres,
396
+ * never blocks readiness or live traffic.
397
+ */
398
+ async function runCallLogsSchemaMigration(sequelize, logger) {
399
+ try {
400
+ const dialect = sequelize.getDialect();
401
+ if (dialect === 'postgres') {
402
+ await ensureCallLogsHashedExtensionIdSchemaPostgresOnline(sequelize, logger);
403
+ } else {
404
+ // sqlite / local / tests: tables are tiny, the transactional rebuild is instant.
405
+ await ensureCallLogsHashedExtensionIdSchema(sequelize);
406
+ }
407
+ } catch (e) {
408
+ logger?.error?.('[callLogs migration] failed; will retry on next start', {
409
+ message: e?.message,
410
+ stack: e?.stack,
411
+ });
412
+ }
413
+ }
414
+
230
415
  module.exports = {
231
416
  findColumnKey,
232
417
  migrateCallLogsExtensionNumberSqlite,
@@ -234,4 +419,6 @@ module.exports = {
234
419
  sqliteCallLogsPkIncludesExtension,
235
420
  sqliteCallLogsPkIncludesHashedExtension,
236
421
  ensureCallLogsHashedExtensionIdSchema,
422
+ ensureCallLogsHashedExtensionIdSchemaPostgresOnline,
423
+ runCallLogsSchemaMigration,
237
424
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-connect/core",
3
- "version": "1.7.36",
3
+ "version": "1.7.37",
4
4
  "description": "RingCentral App Connect Core",
5
5
  "main": "index.js",
6
6
  "repository": {
package/releaseNotes.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "1.7.36": {
2
+ "1.7.37": {
3
3
  "global": [
4
4
  {
5
5
  "type": "New",
@@ -9,6 +9,10 @@
9
9
  "type": "New",
10
10
  "description": "New option for auto log multi match resolver - Earliest created"
11
11
  },
12
+ {
13
+ "type": "Fix",
14
+ "description": "Shared SMS sending for company main number"
15
+ },
12
16
  {
13
17
  "type": "Fix",
14
18
  "description": "Server-side logging call log id inconsistency"