@bongos/core 1.21.115 → 1.21.116

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.
@@ -33,7 +33,7 @@ const { parseSemver, compareSemver, isStable, listAvailableVersions } = require(
33
33
  const { checkArtistGate } = require('./upgrade-artist-gate');
34
34
  // The database side of a move: which command migrates, as which account, and whether that
35
35
  // account can reach the database (tasks 1004274, 1002712, 1004512).
36
- const { preflightDbIdentity, asAccount, ACCOUNT_RE } = require('./upgrade-migrate');
36
+ const { preflightDbIdentity, asAccount, ACCOUNT_RE, teeSpawn, migrateResult, migrateError } = require('./upgrade-migrate');
37
37
 
38
38
  const CORE_PKG = '@bongos/core';
39
39
  const ARTIFACT = 'bongos-core';
@@ -519,13 +519,13 @@ function npmInstall(instanceDir, run = spawnSync) {
519
519
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
520
520
  }
521
521
 
522
- function runMigrate(instanceDir, run = spawnSync, account = null) {
522
+ async function runMigrate(instanceDir, run = spawnSync, account = null) {
523
523
  const { cmd, args, env } = require('./upgrade-migrate').migratePlan(instanceDir); // its own migrate script, else the core's migrate.sh (task 1004274)
524
524
  // `account`: the project's own unix+Postgres identity when the move itself is running as
525
525
  // the account that owns the CHECKOUT instead (--migrate-as; upgrade-migrate.js, task 1004512).
526
526
  const q = asAccount(account, cmd, args, env ? { INIT_CWD: instanceDir } : {});
527
- const r = run(q.cmd, q.args, { cwd: instanceDir, stdio: 'inherit', ...(env ? { env } : {}) });
528
- return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
527
+ // TEED, not inherited: still streams live, and a failure names its migration + psql's ERROR line (upgrade-migrate.js, task 1004734)
528
+ return migrateResult(await (run === spawnSync ? teeSpawn : run)(q.cmd, q.args, { cwd: instanceDir, ...(env ? { env } : {}) }));
529
529
  }
530
530
 
531
531
  // WHICH database will migrate target? (task 1002755)
@@ -1224,9 +1224,9 @@ async function runUpgrade(opts, deps = {}) {
1224
1224
 
1225
1225
  // 4. migrate (additive core_* migrations).
1226
1226
  if (!opts.skipMigrate) {
1227
- const mig = runMigrate(instanceDir, run, opts.migrateAs || null);
1227
+ const mig = await runMigrate(instanceDir, run, opts.migrateAs || null);
1228
1228
  if (!mig.ok) {
1229
- const error = `migrate failed (${mig.reason || 'exit ' + mig.code})`;
1229
+ const error = migrateError(mig); // names the failing migration + its ERROR line (task 1004734)
1230
1230
  if (rollbackOnFailure && snapshot) {
1231
1231
  const rb = await doRollback('migrate failed');
1232
1232
  return { ok: false, error, fromVersion, toVersion: targetVersion, rolledBack: true, rollback: rb };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.115'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.116'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -1551,6 +1551,21 @@ await t('a real rollback says rolled back', async () => {
1551
1551
  assert.match(r.error, /failed and was rolled back/);
1552
1552
  });
1553
1553
 
1554
+ await t('a failed migrate reaches the stored failure with its migration and psql\'s ERROR line (task 1004734)', async () => {
1555
+ // upgrade.js's REAL migrate result → its real error sentence → its real outcome line, in the
1556
+ // frame its main() writes to stderr (pinned by "upgrade.js ends a failed run…" below).
1557
+ const run = () => ({ status: 3, stdout: 'apply core_998_ok\napply core_999_x\n',
1558
+ stderr: 'psql:migrations/core_999_x.sql:7: ERROR: check constraint "tasks_kind_check" of relation "tasks" is violated by some row\n' });
1559
+ const mig = await U.runMigrate(fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'mig-')), run);
1560
+ const res = { ok: false, error: require('../scripts/gds/upgrade-migrate.js').migrateError(mig), rolledBack: true, rollback: {} };
1561
+ const stderr = `\n✖ ${res.error}\n${require('../scripts/gds/upgrade-outcome.js').outcomeLine(res)}`;
1562
+ const r = await R.coreUpgradeInstance(inst(), upgradeThrows(stderr)(makeDeps()), intent());
1563
+ assert.deepEqual(r.failure, { class: 'unknown', reason: 'migrate_check_violation' });
1564
+ assert.match(r.error, /failed and was rolled back/);
1565
+ assert.match(r.error, /core_999_x/, 'the stored sentence names the migration');
1566
+ assert.match(r.error, /ERROR: +check constraint "tasks_kind_check" of relation "tasks" is violated by some row/, 'and psql\'s own error');
1567
+ });
1568
+
1554
1569
  await t('a FAILED rollback is its own outcome, and terminal — never retried unattended', async () => {
1555
1570
  const d = upgradeThrows('\n✖ health check failed after restart\nBONGOS_UPGRADE_OUTCOME {"stage":"rollback_failed","reason":"rollback_failed"}\n')(makeDeps());
1556
1571
  const r = await R.coreUpgradeInstance(inst(), d, intent());
@@ -32,7 +32,7 @@ const tenant = { slug: 'charter', db_name: 'charter_db', hosting_shape: 'co-tena
32
32
 
33
33
  test('bongos upgrade migrates before it restarts, and only --skip-migrate opts out', () => {
34
34
  const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/upgrade.js'), 'utf8');
35
- const migrate = src.indexOf('const mig = runMigrate(instanceDir, run, opts.migrateAs || null);');
35
+ const migrate = src.indexOf('const mig = await runMigrate(instanceDir, run, opts.migrateAs || null);'); // async since task 1004734 (teed output)
36
36
  const restart = src.lastIndexOf('restartService({ service: opts.service, restartCmd: opts.restartCmd }');
37
37
  assert.ok(migrate > 0, 'the upgrade flow runs migrate');
38
38
  assert.ok(restart > migrate, 'the restart that makes new code live comes AFTER migrate');
@@ -47,7 +47,7 @@ test('bongos upgrade migrates before it restarts, and only --skip-migrate opts o
47
47
  // provisioned since task 1003369, and the wrong one cannot connect at all: the project's
48
48
  // database is owned by its own Postgres login role with CONNECT revoked from PUBLIC.
49
49
  // Asserting the CALL SITE, not just the helper — the helper was never the gap.
50
- assert.match(src, /const mig = runMigrate\(instanceDir, run, opts\.migrateAs \|\| null\);/,
50
+ assert.match(src, /const mig = await runMigrate\(instanceDir, run, opts\.migrateAs \|\| null\);/,
51
51
  'the migrate step is handed --migrate-as, not left on whoever the move happens to be');
52
52
  assert.equal(`bash ${CORE_MIGRATE_SCRIPT}`, INSTANCE_MIGRATE_SCRIPT, 'migrate is the instance scaffold script, i.e. the core migrate.sh');
53
53
  });
@@ -23,6 +23,9 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, realpathSync, syml
23
23
  import { tmpdir } from 'node:os';
24
24
  import path from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
+ import { EventEmitter } from 'node:events';
27
+ // ESM named imports: knip credits a CJS export only when a test imports it this way.
28
+ import { migrateError, migrateFailureDetail, teeSpawn } from '../scripts/gds/upgrade-migrate.js';
26
29
 
27
30
  const require = createRequire(import.meta.url);
28
31
  const { migratePlan, CORE_MIGRATE_SCRIPT } = require('../scripts/gds/upgrade-migrate.js');
@@ -193,3 +196,81 @@ test('the real fallback command resolves the project as its instance root, despi
193
196
  const blind = rootsOf(execFileSync('bash', [CORE_MIGRATE_SCRIPT, '--print-roots'], { cwd: dir, env: base, encoding: 'utf8' }));
194
197
  assert.notEqual(blind.INSTANCE_ROOT, dir, 'without INIT_CWD pinned, a stray value decides which project is migrated');
195
198
  });
199
+
200
+ // ── a failed migrate names its migration and psql's ERROR line (task 1004734) ──────────
201
+ // Seen 2026-10-14: toast's move 1.21.20 → 1.21.115 rolled back and the hub kept only
202
+ // "migrate failed (exit 3)". Which file and which error were in the box's journal alone.
203
+ const FAKE_DSN = ['postgres://app', 'Sup3rS3cretPassw0rd@db.internal/prod'].join(':');
204
+ const MIGRATE_OUT = 'skip core_001_base (already applied)\napply core_998_ok\napply core_999_x\n';
205
+ const PSQL_ERR = `psql:node_modules/@bongos/core/migrations/core_999_x.sql:7: ERROR: check constraint "tasks_kind_check" of relation "tasks" is violated by some row ${FAKE_DSN}\n`;
206
+
207
+ function failingMigrateRun(calls, opts = {}) {
208
+ const base = recordingRun(calls, opts);
209
+ return (cmd, args = [], o = {}) => {
210
+ if ((cmd === 'bash' && args[0] === CORE_MIGRATE_SCRIPT) || (cmd === 'npm' && args[0] === 'run' && args[1] === 'migrate')) {
211
+ calls.push({ line: `${cmd} ${args.join(' ')}`.trim(), opts: o });
212
+ return { status: 3, stdout: MIGRATE_OUT, stderr: PSQL_ERR };
213
+ }
214
+ return base(cmd, args, o);
215
+ };
216
+ }
217
+
218
+ test('runMigrate: a failing migration comes back with its file name and psql\'s ERROR line', async () => {
219
+ const mig = await u.runMigrate(adoptedProject(), failingMigrateRun([]));
220
+ assert.equal(mig.ok, false);
221
+ assert.equal(mig.code, 3);
222
+ const error = migrateError(mig);
223
+ assert.match(error, /^migrate failed \(exit 3\)/, 'the prefix upgrade-outcome.js keys on is unchanged');
224
+ assert.match(error, /core_999_x/, 'names the migration that was being applied when it stopped');
225
+ assert.doesNotMatch(error, /core_998_ok/, 'and not an earlier one that applied cleanly');
226
+ assert.match(error, /ERROR: +check constraint "tasks_kind_check" of relation "tasks" is violated by some row/);
227
+ assert.doesNotMatch(error, /Sup3rS3cretPassw0rd/, 'a credential in the error never reaches the hub');
228
+ });
229
+
230
+ test('migrateFailureDetail scrubs secrets and caps the length', () => {
231
+ const d = migrateFailureDetail(`apply core_999_x\nERROR: bad value ${FAKE_DSN} ${'x'.repeat(5000)}\n`);
232
+ assert.doesNotMatch(d, /Sup3rS3cretPassw0rd/);
233
+ assert.ok(d.length <= 400, `capped (${d.length})`);
234
+ assert.match(d, /core_999_x/);
235
+ assert.equal(migrateFailureDetail('npm error code 1\n'), '', 'nothing recognisable adds nothing');
236
+ assert.match(migrateFailureDetail('psql:/x/migrations/core_5_y.sql:3: ERROR: boom\n'), /core_5_y.*ERROR: +boom/, 'psql\'s own file prefix stands in for a missing apply line');
237
+ });
238
+
239
+ test('a full upgrade whose migrate fails reports the migration, the error, and a CHECK-violation class', async () => {
240
+ const dir = adoptedProject({ installed: '1.19.312' });
241
+ const res = await u.runUpgrade(
242
+ { to: '1.19.1029', registry: true, instance: dir, service: 'mercury', healthCheck: false },
243
+ {
244
+ log: () => {}, err: () => {},
245
+ run: failingMigrateRun([], { dir, installTo: '1.19.1029' }),
246
+ materialize: () => ({ skills: 0 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
247
+ databaseUrl: '', pgDatabase: '',
248
+ },
249
+ );
250
+ assert.equal(res.ok, false);
251
+ assert.match(res.error, /^migrate failed \(exit 3\) at core_999_x: ERROR: +check constraint "tasks_kind_check"/);
252
+ const outcome = require('../scripts/gds/upgrade-outcome.js');
253
+ assert.equal(outcome.upgradeReason(res), 'migrate_check_violation');
254
+ assert.equal(outcome.classifyFailure('migrate_check_violation').class, 'unknown', 'existing rows break the new rule: a person decides, never an unattended retry');
255
+ });
256
+
257
+ test('teeSpawn streams the child\'s output live AND keeps it for the failure report', async () => {
258
+ const pipe = () => Object.assign(new EventEmitter(), { setEncoding() {} });
259
+ let child;
260
+ const spawnImpl = (cmd, args, opts) => {
261
+ child = Object.assign(new EventEmitter(), { stdout: pipe(), stderr: pipe(), opts });
262
+ return child;
263
+ };
264
+ const out = [], err = [];
265
+ const p = teeSpawn('bash', ['m.sh'], { cwd: '/x' }, { spawnImpl, out: { write: (c) => out.push(c) }, err: { write: (c) => err.push(c) } });
266
+ child.stdout.emit('data', 'apply core_999_x\n');
267
+ assert.deepEqual(out, ['apply core_999_x\n'], 'written through as it arrives, not after the child exits');
268
+ child.stderr.emit('data', 'ERROR: boom\n');
269
+ assert.deepEqual(err, ['ERROR: boom\n']);
270
+ child.emit('close', 3);
271
+ const r = await p;
272
+ assert.equal(r.status, 3);
273
+ assert.match(r.output, /apply {2}core_999_x[\s\S]*ERROR: {2}boom/);
274
+ assert.deepEqual(child.opts.stdio, ['inherit', 'pipe', 'pipe']);
275
+ assert.equal(child.opts.cwd, '/x');
276
+ });