@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 +11 -2
- package/lib/migrateCallLogsSchema.js +187 -0
- package/package.json +1 -1
- package/releaseNotes.json +5 -1
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 {
|
|
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
package/releaseNotes.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"1.7.
|
|
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"
|