@bongos/core 1.19.940 → 1.19.942

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/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.940",
6
- "core_contract": "1.19.940",
7
- "source_commit": "44e10510143c0775561f28fe1e8d3b55fdbc3190",
5
+ "core_version": "1.19.942",
6
+ "core_contract": "1.19.942",
7
+ "source_commit": "1e45821468069b9d8f26ed2f7dbf1bbf647ea4b8",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-21T19:54:33.696Z",
9
+ "built_at": "2026-09-21T21:46:27.727Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 511,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2339,
14
+ "functional_verbatim": 2340,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2876,
20
- "tree_sha256": "a7edb7e2d7278cda02f1dc7d69eb957b0702345c2f059ead3049463b6fb17ff7",
19
+ "file_count": 2877,
20
+ "tree_sha256": "5e3c476a60b0631f79c68b199ba47f437ab6295e2518dfc86e7104e8fe0e9bfe",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2677,7 +2677,7 @@
2677
2677
  {
2678
2678
  "path": "docs/module-api-changelog.md",
2679
2679
  "mode": "0000644",
2680
- "sha256": "b0dcdad0ab90dc948a111f4bb26a39ffa028bd17907ad7ffbadbc3ec424d54c6"
2680
+ "sha256": "cf5f371ef80387f8344682a639e736c8cc3240debb4ee97106dfc2525e402e97"
2681
2681
  },
2682
2682
  {
2683
2683
  "path": "docs/modules-contract.md",
@@ -2927,7 +2927,7 @@
2927
2927
  {
2928
2928
  "path": "docs/recipes/upgrading-the-core.md",
2929
2929
  "mode": "0000644",
2930
- "sha256": "147fe7408c7e86487c2cd99198074f3591c7b956c0debd80b7b0730803940571"
2930
+ "sha256": "0df57ea34114fb1ae81389458334b392d768fc83d9e82eb00fa917219189de87"
2931
2931
  },
2932
2932
  {
2933
2933
  "path": "docs/recipes/windows-builders.md",
@@ -8002,12 +8002,12 @@
8002
8002
  {
8003
8003
  "path": "package-lock.json",
8004
8004
  "mode": "0000644",
8005
- "sha256": "d319db2c2f341bea9b0f59dd6f2c826a68a155e620b3b6e8809ef8e35390b7bf"
8005
+ "sha256": "04da7afdf07b5eacb7b5d8e18f034a45e62c318eed546facfa4e65480f5a6292"
8006
8006
  },
8007
8007
  {
8008
8008
  "path": "package.json",
8009
8009
  "mode": "0000644",
8010
- "sha256": "0692c686bc978e5f8ada5fa1f563bf40bdc9fde2a548a8ca47f21e089010bbbb"
8010
+ "sha256": "79d643dab7e8bf794763c95cfa6f05200054d4aa2be271b61805954b1b82eb83"
8011
8011
  },
8012
8012
  {
8013
8013
  "path": "public-docs/index.html",
@@ -8442,7 +8442,7 @@
8442
8442
  {
8443
8443
  "path": "scripts/gds/deploy-divergence.js",
8444
8444
  "mode": "0000644",
8445
- "sha256": "8aae6c1a2cd978e53bce7d788ab5f4fe86ab357821f083d1b414df4f5742beac"
8445
+ "sha256": "bffaceec8b47b238f4a7911aee10a651b45cd75e0c5a67b2b8e3daa90e3cd54a"
8446
8446
  },
8447
8447
  {
8448
8448
  "path": "scripts/gds/deploy-main-audit-gate.sh",
@@ -9107,7 +9107,7 @@
9107
9107
  {
9108
9108
  "path": "scripts/gds/run-unit-tests.js",
9109
9109
  "mode": "0000644",
9110
- "sha256": "034d5678310329273360d7dd3e60cb0bb13e29fa6895c2c79338f80416b751bd"
9110
+ "sha256": "b536a889714c7f1ddc027b3f29602351d6942e1e52157469a5f82400490ee1d4"
9111
9111
  },
9112
9112
  {
9113
9113
  "path": "scripts/gds/runner-drift.js",
@@ -9617,7 +9617,7 @@
9617
9617
  {
9618
9618
  "path": "scripts/migrate.sh",
9619
9619
  "mode": "0000644",
9620
- "sha256": "669ba0cd4e0c5ad2c7809364e4ac17b24bfa3f8542fae5ba7ec05e9bcf77053f"
9620
+ "sha256": "6f927d08ea67e4a7081972fb78fd6131c9eee569730932621d1e1ee6334ddd3a"
9621
9621
  },
9622
9622
  {
9623
9623
  "path": "scripts/migrate_cost_ledger.sh",
@@ -9972,7 +9972,7 @@
9972
9972
  {
9973
9973
  "path": "src/bongos/schema-lag.js",
9974
9974
  "mode": "0000644",
9975
- "sha256": "bafe3453eff53cb80ef0eb3ca5894d7ff2fc4a2480e80595869d8d9838d06a42"
9975
+ "sha256": "b84a237b9fe9ce9f905ae0ca72e7f82eb08670397eb7e6c88c114c62afcf81a8"
9976
9976
  },
9977
9977
  {
9978
9978
  "path": "src/bongos/secret-box.js",
@@ -9987,7 +9987,7 @@
9987
9987
  {
9988
9988
  "path": "src/bongos/serve-internal.js",
9989
9989
  "mode": "0000644",
9990
- "sha256": "59aa0ee2be23c8c956357eb67ee58720bd2e378a71a9efb6a73ab966c7d4094f"
9990
+ "sha256": "989a1bef0e179efe32fc3edb11f7f46b1732920ca026b36a841c8e0e344a175a"
9991
9991
  },
9992
9992
  {
9993
9993
  "path": "src/bongos/uptime-poller.js",
@@ -10012,7 +10012,7 @@
10012
10012
  {
10013
10013
  "path": "src/module-api.js",
10014
10014
  "mode": "0000644",
10015
- "sha256": "d4d3c893fb318a929f2721a142191807d04b74c20ace3e77c1afc4d50f747c03"
10015
+ "sha256": "ea1889449a3dab8921a6d4ce4fc539c8c550aef3eedc14d22493a26cd6af1bcc"
10016
10016
  },
10017
10017
  {
10018
10018
  "path": "src/module-loader/catalog.js",
@@ -11062,7 +11062,7 @@
11062
11062
  {
11063
11063
  "path": "tests/deploy_divergence_line.mjs",
11064
11064
  "mode": "0000644",
11065
- "sha256": "7e87aa9005acbf2fff537a9b79f19b5901b304b5fb4472224b5f3abaf5fef15a"
11065
+ "sha256": "fd62561dcf5fb935116ec5933393e75cef25eaa807a6d39fc55d9c981e6fe289"
11066
11066
  },
11067
11067
  {
11068
11068
  "path": "tests/deploy_instance.mjs",
@@ -12509,6 +12509,11 @@
12509
12509
  "mode": "0000644",
12510
12510
  "sha256": "a1179788640c3959e21573817e386eece65067ecbe9bda6cd350e16118ac61f1"
12511
12511
  },
12512
+ {
12513
+ "path": "tests/migrate_module_overrides_db.mjs",
12514
+ "mode": "0000644",
12515
+ "sha256": "a998b59c0a6d28a0c0d1fe617059631cff052c68afe954265e92a0cca8ce04e4"
12516
+ },
12512
12517
  {
12513
12518
  "path": "tests/migrate_root_resolution.mjs",
12514
12519
  "mode": "0000644",
@@ -13452,12 +13457,12 @@
13452
13457
  {
13453
13458
  "path": "tests/schema_lag.mjs",
13454
13459
  "mode": "0000644",
13455
- "sha256": "90e5cdc6648856a027c67df41e39fee6699d7bae56b79bc41cefbb2d249c86a9"
13460
+ "sha256": "af0db545b71f9cc3b88ae0a9cd09e909d9956326dd0aba8bb02a330e2cb21c8b"
13456
13461
  },
13457
13462
  {
13458
13463
  "path": "tests/schema_lag_db.mjs",
13459
13464
  "mode": "0000644",
13460
- "sha256": "74f4f62025773a917b77120637fcfb3e5cd843aee4ca30c7ed2303fb613f18ec"
13465
+ "sha256": "5e3ada8619ea87eea3026a2ccf3069926d5825a06fe11b5e435020034b2eb7e1"
13461
13466
  },
13462
13467
  {
13463
13468
  "path": "tests/scope_map_guard.mjs",
@@ -2354,5 +2354,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2354
2354
  landed since 1.19.938 with no explicit bump. run 35641614084. (task 1002620)
2355
2355
  1.19.940 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2356
2356
  landed since 1.19.939 with no explicit bump. run 35647628385. (task 1002620)
2357
+ 1.19.941 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2358
+ landed since 1.19.940 with no explicit bump. run 35652433730. (task 1002620)
2359
+ 1.19.942 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2360
+ landed since 1.19.941 with no explicit bump. run 35659043986. (task 1002620)
2357
2361
  ---------------------------------------------------------------------------
2358
2362
  ```
@@ -167,7 +167,7 @@ running a go-live.
167
167
  - **Survey co-tenant git state as `lars`, never as root.** `ssh cloudbongos` lands you as root, where `git config --get remote.origin.url` on a `lars`-owned repo returns **nothing** — which reads exactly like "this instance has no remote" and is wrong. They do: `git@github-instances:example-owner/<slug>.git`. Prefix every probe with `sudo -u lars`.
168
168
  - **A scaffold's `migrate` script is not guaranteed.** 21 of the 22 co-tenants have `scripts.migrate`; `mercury` has **no `scripts` block at all** and is started directly by its systemd `ExecStart`, so the normal lane cannot complete on it. `--skip-migrate` gets the install through but silently skips every schema change in the window — check what that window carries before reaching for it, rather than after.
169
169
  - **An instance's database moves with its code, on every path** (task 1002002). `bongos upgrade` migrates (step 4) before it restarts; the control-plane runner behind the Deploy door drives it with `PGDATABASE` set to the target instance's own database; provisioning migrates at stand-up. A pinned co-tenant needs nothing extra, because its code only moves through `bongos upgrade`. `--skip-migrate` is the one way out of lockstep. Pinned by `tests/instance_db_migrated_on_code_move.mjs`.
170
- - **`/version` reports `schemaPending`** (task 1002002): how many core migrations the running code ships that this instance's database has not recorded — `null` when unknown (database unreachable, or a core older than the field). Above 0 is the 2026-07-04 outage shape, where the first query touching those tables fails. Fix: `migrate` against that instance DB with `PGDATABASE` set. The SessionStart `deploy-divergence` line alarms on it for the instance a checkout talks to; `--skip-migrate` above is the usual way to cause one.
170
+ - **`/version` reports `schemaPending`** (task 1002002): how many migrations the running code ships — core, plus each module this process has enabled (DB overrides included, task 1004136) — that this instance's database has not recorded — `null` when unknown (database unreachable, or a core older than the field). Above 0 is the 2026-07-04 outage shape, where the first query touching those tables fails. Fix: `migrate` against that instance DB with `PGDATABASE` set — it reads the same DB overrides, so a module switched on by the hall's Modules toggle gets its migrations too (`npm run migrate -- --print-modules` shows the set it will apply). The SessionStart `deploy-divergence` line alarms on it for the instance a checkout talks to; `--skip-migrate` above is the usual way to cause one.
171
171
  - **The health check polls the restart** (fixed in task [#1984](https://example.com/builders#/task/1984)). The service binds its port ~1s after `systemctl restart`, so `--health-url` now polls up to ~30s for a 2xx before reporting a warning — an immediate `fetch failed` no longer means a failed upgrade. (Pre-1984 cores fetched once and spuriously reported `fetch failed`; there, confirm with `is-active` + a manual `curl`.)
172
172
  - **The ledger auto-inserts via `PGDATABASE` too** (fixed in task [#1984](https://example.com/builders#/task/1984)). A consumer on `PGDATABASE` + peer auth now gets its `core_upgrades` row written automatically — the channel delegates the insert to `psql -d "$PGDATABASE"`, exactly like `migrate.sh`, so no manual `psql INSERT` is needed. (`DATABASE_URL` still works, via the `pg` driver.) Pre-1984 cores only auto-wrote with `DATABASE_URL`; on those, record the row manually.
173
173
  - **No deploy key on the droplet** → deploys/upgrades ride a `git bundle`, not `git pull` (idea 509).
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.940",
3
+ "version": "1.19.942",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.940",
9
+ "version": "1.19.942",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.940",
3
+ "version": "1.19.942",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -16,7 +16,7 @@
16
16
  // 2. UNDEPLOYED — the live instance's coreVersion is behind the CORE_VERSION
17
17
  // on main. A release exists; this instance has not moved its pin to it.
18
18
  // 3. SCHEMA BEHIND — live's /version reports schemaPending > 0: its database
19
- // lacks core migrations its running code ships (the 2026-07-04 502 outage
19
+ // lacks migrations its running code ships (the 2026-07-04 502 outage
20
20
  // shape; task 1002002). An instance too old to report it is not flagged.
21
21
  //
22
22
  // Usage:
@@ -267,7 +267,7 @@ function report(r, { quiet = false } = {}) {
267
267
  }
268
268
  if (r.schemaBehind) {
269
269
  const n = r.live.schemaPending;
270
- say(` ⚠ SCHEMA BEHIND — live's database is missing ${n} core migration${n === 1 ? '' : 's'} its running code ships.`);
270
+ say(` ⚠ SCHEMA BEHIND — live's database is missing ${n} migration${n === 1 ? '' : 's'} its running code ships.`);
271
271
  say(' The first query that touches them fails (the 2026-07-04 502 was this shape).');
272
272
  say(' Fix: run migrate against that instance DB with PGDATABASE set (see docs/recipes/upgrading-the-core.md).');
273
273
  }
@@ -319,7 +319,7 @@ function line(r) {
319
319
  const versionParts = parts.length;
320
320
  const pending = r.schemaBehind ? safeCount(r.live?.schemaPending) : null;
321
321
  if (pending) {
322
- parts.push(`its database is MISSING ${pending} core migration${pending === 1 ? '' : 's'} the running code ships, so the first query that touches ${pending === 1 ? 'it' : 'them'} fails`);
322
+ parts.push(`its database is MISSING ${pending} migration${pending === 1 ? '' : 's'} the running code ships, so the first query that touches ${pending === 1 ? 'it' : 'them'} fails`);
323
323
  }
324
324
  if (!parts.length) return null;
325
325
 
@@ -181,9 +181,12 @@ const INTEGRATION = new Set([
181
181
  'session_token_hash_db',
182
182
  // task 1002002: the no-false-alarm half of the schema-lag detector. The DB-free
183
183
  // sibling (schema_lag) covers the arithmetic against a stub; only a database that
184
- // migrate.sh has just run can show the expected core stems equal the recorded ones,
184
+ // migrate.sh has just run can show the expected stems (core + enabled modules) equal the recorded ones,
185
185
  // so a healthy instance reads zero rather than a permanent SCHEMA BEHIND.
186
186
  'schema_lag_db',
187
+ // task 1004137: migrate.sh's module set reads the hall's DB overrides — needs the
188
+ // override table and a database the real migrate.sh can connect to.
189
+ 'migrate_module_overrides_db',
187
190
  'scouting_rollup_fanout', 'session_records',
188
191
  // task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
189
192
  // SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
@@ -117,6 +117,46 @@ if [ "${1:-}" = "--print-db" ]; then
117
117
  exit 0
118
118
  fi
119
119
 
120
+ # The modules whose migrations the module loop below applies: the SAME set the server mounts routes
121
+ # for, DB overrides included (task 1004137). The server awaits refreshOverrides() before building its
122
+ # app; this read used to skip it, so a module switched on only by the hall's Modules toggle was
123
+ # "disabled — skipping" on every deploy while the next restart mounted its routes against tables that
124
+ # did not exist (the 2026-07-04 outage shape, per module).
125
+ # The overrides are read from the database this run migrates: PGDATABASE is pinned to $DB and
126
+ # DATABASE_URL blanked, because pool.js prefers DATABASE_URL and psql never reads it. FAILS OPEN to the
127
+ # config packs — refreshOverrides() swallows a DB error, and a require that throws (no `pg`) is caught
128
+ # here — so an unreadable override never fails a deploy; stderr is left visible so the fallback is not
129
+ # silent. CORE_ROOT rides in as argv, not interpolated, for the backslash reason above.
130
+ enabled_module_set() {
131
+ DATABASE_URL='' PGDATABASE="$DB" node -e "
132
+ const path = require('path');
133
+ const core = process.argv[1];
134
+ (async () => {
135
+ const { enabledModules } = require(path.join(core, 'src/modules'));
136
+ let pool;
137
+ try {
138
+ ({ pool } = require(path.join(core, 'src/bongos/pool')));
139
+ await require(path.join(core, 'src/bongos/module-overrides')).refreshOverrides();
140
+ } catch (e) {
141
+ process.stderr.write('migrate.sh: could not read DB module overrides, using the config packs: ' + e.message + '\n');
142
+ } finally {
143
+ if (pool) await pool.end().catch(() => {});
144
+ }
145
+ process.stdout.write(enabledModules().join(' '));
146
+ })().catch((e) => {
147
+ process.stderr.write('migrate.sh: could not read module config: ' + e.message + '\n');
148
+ });
149
+ " "$CORE_ROOT" || true
150
+ }
151
+
152
+ # Test/ops seam (task 1004137): --print-modules reports the module set this run would migrate, after
153
+ # reading the DB overrides, and exits before any schema work. tests/migrate_module_overrides_db.mjs
154
+ # asserts through it that a hall toggle reaches the module loop.
155
+ if [ "${1:-}" = "--print-modules" ]; then
156
+ printf '%s\n' "$(enabled_module_set)"
157
+ exit 0
158
+ fi
159
+
120
160
  # --- Instance-seed policy (task 1704 / ADR 0105) --------------------------
121
161
  # OTB/product scope-truth (versions, goals, done_when_criteria) lives in seed
122
162
  # migrations under migrations/instance/ (plus a few guarded blocks embedded in the
@@ -279,22 +319,15 @@ fi
279
319
  [ "$SEED_INSTANCE" = "on" ] || echo "instance seeds skipped (migrations/instance/) — non-owning / vanilla instance"
280
320
 
281
321
  # Module-owned migrations (modules/<key>/migrations/*.sql) — applied only when the module is
282
- # enabled. The enablement is read from the instance's config/modules.json (or the neutral
283
- # starter). The module CODE lives in the core package (require $CORE_ROOT/src/modules), but the
284
- # config is instance content (resolveInstanceRoot() falls back to cwd == INSTANCE_ROOT, set
285
- # above). The stem-key guarantee means moving a migration from migrations/game/ to
286
- # modules/game/migrations/ does NOT re-run it on prod — schema_migrations is keyed by filename
287
- # stem alone (ADR 0083 / BV1.R49, task 1418). A vanilla instance leaves game disabled via
288
- # config/modules.neutral.json.
289
- NODE_ENABLED_MODULES=$(node -e "
290
- try {
291
- const { enabledModules } = require('$CORE_ROOT/src/modules');
292
- process.stdout.write(enabledModules().join(' '));
293
- } catch(e) {
294
- process.stderr.write('migrate.sh: could not read module config: ' + e.message + '\n');
295
- process.stdout.write('');
296
- }
297
- " 2>/dev/null || true)
322
+ # enabled. The enablement is the instance's config/modules.json (or the neutral starter) plus the
323
+ # DB overrides (enabled_module_set above). The module CODE lives in the core package (require
324
+ # $CORE_ROOT/src/modules), but the config is instance content (resolveInstanceRoot() falls back to
325
+ # cwd == INSTANCE_ROOT, set above). Read HERE, after the core loop, so core_248's
326
+ # module_enablement_overrides table exists even on a from-scratch database. The stem-key guarantee
327
+ # means moving a migration from migrations/game/ to modules/game/migrations/ does NOT re-run it on
328
+ # prod — schema_migrations is keyed by filename stem alone (ADR 0083 / BV1.R49, task 1418). A
329
+ # vanilla instance leaves game disabled via config/modules.neutral.json.
330
+ NODE_ENABLED_MODULES=$(enabled_module_set)
298
331
 
299
332
  # Modules can be shipped by the CORE package or by the INSTANCE repo, so scan modules/ under
300
333
  # BOTH roots. Apply each module key ONCE — today the two roots coincide (so instance-root modules
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
- // src/bongos/schema-lag.js — does this instance's database carry every core migration the
3
- // running code ships? (task 1002002)
2
+ // src/bongos/schema-lag.js — does this instance's database carry every migration the running
3
+ // code ships? (task 1002002; modules added in task 1004136)
4
4
  //
5
5
  // WHY. On 2026-07-04 cloudbongos.com returned 502 on every /me: the deploy had moved the code
6
6
  // 22 core migrations past the instance's own database, and nothing said so — /healthz answered
@@ -8,11 +8,13 @@
8
8
  // moves an instance's core now migrates that instance's own DB first (bongos upgrade, the runner
9
9
  // behind the Deploy door, provisioning — pinned by tests/instance_db_migrated_on_code_move.mjs),
10
10
  // but a skipped or failed step would recreate that outage just as silently. This is the detector:
11
- // the core migration stems the installed package ships that schema_migrations does not record.
11
+ // the migration stems the running code expects that schema_migrations does not record.
12
12
  //
13
- // CORE ONLY, on purpose. migrate.sh applies every core migrations/*.sql on every instance, so a
14
- // missing core stem is always a real gap. Module and instance-seed migrations are per-instance
15
- // opt-ins, and counting them would alarm on a module that is simply off.
13
+ // WHAT IS EXPECTED is every migration the code this process actually serves depends on: every
14
+ // core migrations/*.sql, plus modules/<key>/migrations/*.sql for each module isModuleEnabled
15
+ // says is on — the same gate its routes mount behind, DB overrides included. A module that is
16
+ // off is expected nothing, so it can never alarm. Instance seeds (migrations/instance/) are an
17
+ // opt-in no running code depends on, so they are never counted.
16
18
  //
17
19
  // Surfaced as `schemaPending` on /version (a count, never the names) and logged at boot;
18
20
  // scripts/gds/deploy-divergence.js turns a non-zero count into the SessionStart alarm.
@@ -20,6 +22,8 @@
20
22
  const fs = require('node:fs');
21
23
  const path = require('node:path');
22
24
  const { resolveCoreRoot } = require('../instance-config');
25
+ const { isModuleEnabled } = require('../modules');
26
+ const { discovered } = require('../module-loader/loader');
23
27
  const { pool } = require('./pool');
24
28
  const log = require('./logger').logger.child({ src: 'schema-lag' });
25
29
 
@@ -28,14 +32,23 @@ const CORE_MIGRATIONS_DIR = path.join(resolveCoreRoot(), 'migrations');
28
32
  // query runs at most this often; between refreshes the last answer is served.
29
33
  const REFRESH_MS = 60 * 1000;
30
34
 
31
- /** The stems migrate.sh keys schema_migrations by, for the core's top-level *.sql files. */
32
- function coreMigrationStems(dir = CORE_MIGRATIONS_DIR) {
35
+ /** The stems migrate.sh keys schema_migrations by, for the *.sql files directly in `dir`. */
36
+ function migrationStems(dir = CORE_MIGRATIONS_DIR) {
33
37
  return fs.readdirSync(dir, { withFileTypes: true })
34
38
  .filter((e) => e.isFile() && e.name.endsWith('.sql'))
35
39
  .map((e) => e.name.slice(0, -'.sql'.length))
36
40
  .sort();
37
41
  }
38
42
 
43
+ /** Migration stems of every module this process serves, from the dir it was discovered in. */
44
+ function enabledModuleMigrationStems({ loaded = discovered().loaded, isEnabled = isModuleEnabled } = {}) {
45
+ return loaded
46
+ .filter((m) => isEnabled(m.key))
47
+ .map((m) => path.join(m.dir, 'migrations'))
48
+ .filter((dir) => fs.existsSync(dir))
49
+ .flatMap((dir) => migrationStems(dir));
50
+ }
51
+
39
52
  /** Shipped stems the database has not recorded. PURE. */
40
53
  function pendingStems(shipped, applied) {
41
54
  const have = new Set(applied);
@@ -49,25 +62,27 @@ let inflight = null;
49
62
  * Re-read schema_migrations and cache the gap. Never throws: an unreadable database or
50
63
  * migrations dir caches null — "unknown" — which no reader treats as an alarm.
51
64
  */
52
- async function refreshSchemaLag({ db = pool, dir = CORE_MIGRATIONS_DIR, now = Date.now } = {}) {
65
+ async function refreshSchemaLag({
66
+ db = pool, dir = CORE_MIGRATIONS_DIR, moduleStems = enabledModuleMigrationStems, now = Date.now,
67
+ } = {}) {
53
68
  const before = last.pending;
54
69
  let pending = null;
55
70
  try {
56
71
  const { rows } = await db.query('SELECT version FROM schema_migrations');
57
- pending = pendingStems(coreMigrationStems(dir), rows.map((r) => r.version));
72
+ pending = pendingStems([...migrationStems(dir), ...moduleStems()], rows.map((r) => r.version));
58
73
  } catch (err) {
59
- log.warn({ err: err && err.message }, 'could not compare schema_migrations with the core migrations');
74
+ log.warn({ err: err && err.message }, 'could not compare schema_migrations with the migrations the running code ships');
60
75
  }
61
76
  last = { pending, checkedAt: now() };
62
77
  if (pending && pending.length && (!before || before.length !== pending.length)) {
63
78
  log.error({ pending: pending.length, first: pending.slice(0, 5) },
64
- 'this database is missing core migrations the running code ships — run migrate against this instance DB before its next query touches them');
79
+ 'this database is missing migrations the running code ships (core, or an enabled module) — run migrate against this instance DB before its next query touches them');
65
80
  }
66
81
  return pending;
67
82
  }
68
83
 
69
84
  /**
70
- * The last-known count of missing core migrations, or null when unknown. Kicks a background
85
+ * The last-known count of missing migrations, or null when unknown. Kicks a background
71
86
  * refresh when the answer is older than REFRESH_MS, and never waits for it.
72
87
  */
73
88
  function schemaPending({ now = Date.now, refresh = refreshSchemaLag } = {}) {
@@ -77,4 +92,6 @@ function schemaPending({ now = Date.now, refresh = refreshSchemaLag } = {}) {
77
92
  return last.pending ? last.pending.length : null;
78
93
  }
79
94
 
80
- module.exports = { coreMigrationStems, pendingStems, refreshSchemaLag, schemaPending, REFRESH_MS };
95
+ module.exports = {
96
+ migrationStems, enabledModuleMigrationStems, pendingStems, refreshSchemaLag, schemaPending, REFRESH_MS,
97
+ };
@@ -583,7 +583,7 @@ function mountInternalSurfaces(app) {
583
583
  // the throwaway `updateCanary` proof marker was removed in phase E.)
584
584
  let coreVersion = null;
585
585
  try { coreVersion = require('../module-api').CORE_VERSION; } catch { /* fail-soft: version unknown */ }
586
- // schemaPending — core migrations this code ships that the instance DB has not applied
586
+ // schemaPending — migrations this code ships (core + enabled modules') that the instance DB has not applied
587
587
  // (null = unknown). Non-zero is the 2026-07-04 outage shape (task 1002002).
588
588
  res.json({ ...buildInfo(), coreVersion, schemaPending: schemaPending() });
589
589
  });
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.940'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.942'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -260,7 +260,7 @@ test('settings.json stays OUT of the published package', () => {
260
260
  });
261
261
 
262
262
  // ── schema behind (task 1002002) ────────────────────────────────────────────
263
- // /version's schemaPending: core migrations the running code ships that live's DB
263
+ // /version's schemaPending: migrations (core + enabled modules') the running code ships that live's DB
264
264
  // lacks. The 2026-07-04 502 was this shape, and nothing reported it.
265
265
 
266
266
  const schemaOnly = {
@@ -269,17 +269,17 @@ const schemaOnly = {
269
269
  behind: false, hasUnreleased: false, unreleased: { count: 0 }, schemaBehind: true,
270
270
  };
271
271
 
272
- test('a database missing core migrations is named on the one line, with its own advice', () => {
272
+ test('a database missing migrations is named on the one line, with its own advice', () => {
273
273
  const s = line(schemaOnly);
274
- assert.match(s, /MISSING 3 core migrations the running code ships/);
274
+ assert.match(s, /MISSING 3 migrations the running code ships/);
275
275
  assert.match(s, /Run migrate against that instance DB/);
276
276
  assert.doesNotMatch(s, /Merging to main deploys nothing/, 'the version advice is for version gaps only');
277
277
  assert.equal(s.split('\n').length, 1);
278
- assert.match(line({ ...schemaOnly, live: { ...schemaOnly.live, schemaPending: 1 } }), /MISSING 1 core migration the/);
278
+ assert.match(line({ ...schemaOnly, live: { ...schemaOnly.live, schemaPending: 1 } }), /MISSING 1 migration the/);
279
279
 
280
280
  const both = line({ ...schemaOnly, behind: true, live: { coreVersion: '1.19.56', schemaPending: 2 } });
281
281
  assert.match(both, /1\.19\.56/);
282
- assert.match(both, /MISSING 2 core migrations/);
282
+ assert.match(both, /MISSING 2 migrations/);
283
283
  assert.match(both, /Merging to main deploys nothing/);
284
284
  });
285
285
 
@@ -0,0 +1,91 @@
1
+ // tests/migrate_module_overrides_db.mjs — migrate.sh migrates a module the hall switched on (task 1004137).
2
+ //
3
+ // The hall's Modules toggle writes module_enablement_overrides, and the server reads that table
4
+ // before it mounts any routes. migrate.sh used to pick its module set in a fresh process that never
5
+ // read it, so a module on ONLY by override was skipped on every deploy while the next restart
6
+ // mounted its routes against tables that did not exist. This drives the real migrate.sh through its
7
+ // --print-modules seam (the same function its module loop calls) against a real database.
8
+ //
9
+ // Integration lane: needs Postgres plus the PG* env migrate.sh connects with (the lane sets both);
10
+ // self-skips when no Postgres is reachable. Leaves the override table as it found it.
11
+ //
12
+ // Run: DATABASE_URL=postgres://... node tests/migrate_module_overrides_db.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { execFileSync } from 'node:child_process';
16
+ import { createRequire } from 'node:module';
17
+ import { fileURLToPath } from 'node:url';
18
+ import path from 'node:path';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const { pool } = require('../src/bongos/pool.js');
22
+ const { moduleScopeKey } = require('../src/modules.js');
23
+
24
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const MIGRATE = path.join(REPO_ROOT, 'scripts', 'migrate.sh');
26
+ // Ships a migration, meters no spend and is off in the packs, so the override alone decides it.
27
+ const MODULE = 'discord';
28
+
29
+ try {
30
+ await pool.query('SELECT 1');
31
+ } catch (e) {
32
+ console.log(`migrate_module_overrides_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
33
+ await pool.end().catch(() => {});
34
+ process.exit(0);
35
+ }
36
+
37
+ const { rows: [{ db }] } = await pool.query('SELECT current_database() AS db');
38
+ const scope = moduleScopeKey();
39
+
40
+ function printModules(overrides = {}) {
41
+ const env = { ...process.env, PGDATABASE: db, ...overrides };
42
+ delete env.INIT_CWD;
43
+ const out = execFileSync('bash', [MIGRATE, '--print-modules'], {
44
+ cwd: REPO_ROOT, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
45
+ });
46
+ return out.trim().split(/\s+/).filter(Boolean);
47
+ }
48
+
49
+ const setOverride = (enabled) => pool.query(
50
+ `INSERT INTO module_enablement_overrides (scope, module_key, enabled) VALUES ($1, $2, $3)
51
+ ON CONFLICT (scope, module_key) DO UPDATE SET enabled = EXCLUDED.enabled`,
52
+ [scope, MODULE, enabled],
53
+ );
54
+
55
+ const { rows: prior } = await pool.query(
56
+ 'SELECT enabled, set_by, set_at FROM module_enablement_overrides WHERE scope = $1 AND module_key = $2',
57
+ [scope, MODULE],
58
+ );
59
+
60
+ try {
61
+ await pool.query('DELETE FROM module_enablement_overrides WHERE scope = $1 AND module_key = $2', [scope, MODULE]);
62
+ const packs = printModules();
63
+ assert.ok(packs.length > 0, 'with no override the pack set still resolves (not an empty read)');
64
+ assert.ok(!packs.includes(MODULE), `precondition: ${MODULE} is off in the packs, so only an override can turn it on`);
65
+ console.log(` ok no override: ${MODULE} is not migrated`);
66
+
67
+ await setOverride(true);
68
+ const toggled = printModules();
69
+ assert.ok(toggled.includes(MODULE), `a hall toggle ON must reach migrate's module set, got: ${toggled.join(' ')}`);
70
+ assert.deepEqual(toggled.filter((m) => m !== MODULE), packs, 'and changes nothing else');
71
+ console.log(` ok override on: ${MODULE} is migrated`);
72
+
73
+ // Fail-open: an override migrate cannot read falls back to the packs, never to "no modules".
74
+ // The override row is still ON here, so only the fallback can produce the pack set.
75
+ const unreadable = printModules({ PGHOST: '127.0.0.1', PGPORT: '1' });
76
+ assert.deepEqual(unreadable, packs, 'an unreachable override database resolves exactly the pack set');
77
+ console.log(' ok unreadable overrides fall back to the packs');
78
+ } finally {
79
+ if (prior.length) {
80
+ await pool.query(
81
+ `INSERT INTO module_enablement_overrides (scope, module_key, enabled, set_by, set_at)
82
+ VALUES ($1, $2, $3, $4, $5)
83
+ ON CONFLICT (scope, module_key)
84
+ DO UPDATE SET enabled = EXCLUDED.enabled, set_by = EXCLUDED.set_by, set_at = EXCLUDED.set_at`,
85
+ [scope, MODULE, prior[0].enabled, prior[0].set_by, prior[0].set_at],
86
+ );
87
+ } else {
88
+ await pool.query('DELETE FROM module_enablement_overrides WHERE scope = $1 AND module_key = $2', [scope, MODULE]);
89
+ }
90
+ await pool.end().catch(() => {});
91
+ }
@@ -1,4 +1,5 @@
1
- // tests/schema_lag.mjs — the schema-lag detector behind /version's schemaPending (task 1002002).
1
+ // tests/schema_lag.mjs — the schema-lag detector behind /version's schemaPending (task 1002002;
2
+ // enabled modules' migrations, task 1004136).
2
3
  //
3
4
  // DB-free: every database read goes through an injected stub. The real-SQL half — that a
4
5
  // database migrate.sh has just run reports ZERO pending, i.e. the stems this module expects
@@ -13,10 +14,23 @@ import os from 'node:os';
13
14
  import path from 'node:path';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  import {
16
- coreMigrationStems, pendingStems, refreshSchemaLag, schemaPending, REFRESH_MS,
17
+ migrationStems, enabledModuleMigrationStems, pendingStems, refreshSchemaLag, schemaPending, REFRESH_MS,
17
18
  } from '../src/bongos/schema-lag.js';
19
+ import { setModuleOverrides } from '../src/modules.js';
18
20
 
19
21
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const none = () => [];
23
+
24
+ /** A modules/ root: `on` and `off` each ship a migration; `bare` has no migrations/ dir. */
25
+ function tmpModules() {
26
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'schema-lag-mod-'));
27
+ for (const [key, files] of [['on', ['on_001_a.sql', 'on_002_b.sql']], ['off', ['off_001_a.sql']]]) {
28
+ fs.mkdirSync(path.join(root, key, 'migrations'), { recursive: true });
29
+ for (const f of files) fs.writeFileSync(path.join(root, key, 'migrations', f), '-- x\n');
30
+ }
31
+ fs.mkdirSync(path.join(root, 'bare'));
32
+ return ['on', 'off', 'bare'].map((key) => ({ key, dir: path.join(root, key) }));
33
+ }
20
34
 
21
35
  function tmpMigrations() {
22
36
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'schema-lag-'));
@@ -36,12 +50,12 @@ test('pendingStems lists shipped stems the database has not recorded', () => {
36
50
  assert.deepEqual(pendingStems(['a'], ['a']), []);
37
51
  });
38
52
 
39
- test('coreMigrationStems reads top-level .sql stems only, sorted', () => {
40
- assert.deepEqual(coreMigrationStems(tmpMigrations()), ['001_init', 'core_002_add', 'core_003_more']);
53
+ test('migrationStems reads top-level .sql stems only, sorted', () => {
54
+ assert.deepEqual(migrationStems(tmpMigrations()), ['001_init', 'core_002_add', 'core_003_more']);
41
55
  });
42
56
 
43
57
  test('the default directory is the installed core migrations', () => {
44
- const stems = coreMigrationStems();
58
+ const stems = migrationStems();
45
59
  const onDisk = fs.readdirSync(path.join(ROOT, 'migrations')).filter((f) => f.endsWith('.sql'));
46
60
  assert.equal(stems.length, onDisk.length);
47
61
  assert.ok(stems.includes('003_pms'), 'legacy bare stems are core migrations too');
@@ -49,19 +63,49 @@ test('the default directory is the installed core migrations', () => {
49
63
 
50
64
  test('a database missing core migrations reports them; a current one reports an empty list', async () => {
51
65
  const dir = tmpMigrations();
52
- assert.deepEqual(await refreshSchemaLag({ db: dbWith(['001_init']), dir }), ['core_002_add', 'core_003_more']);
53
- assert.deepEqual(await refreshSchemaLag({ db: dbWith(['001_init', 'core_002_add', 'core_003_more']), dir }), []);
66
+ assert.deepEqual(await refreshSchemaLag({ db: dbWith(['001_init']), dir, moduleStems: none }), ['core_002_add', 'core_003_more']);
67
+ assert.deepEqual(await refreshSchemaLag({ db: dbWith(['001_init', 'core_002_add', 'core_003_more']), dir, moduleStems: none }), []);
68
+ });
69
+
70
+ test('only enabled modules are expected to carry their migrations; a module that is off never alarms', () => {
71
+ const loaded = tmpModules();
72
+ const isEnabled = (key) => key !== 'off';
73
+ assert.deepEqual(enabledModuleMigrationStems({ loaded, isEnabled }), ['on_001_a', 'on_002_b'],
74
+ 'an enabled module with no migrations/ dir contributes nothing and does not throw');
75
+ assert.deepEqual(enabledModuleMigrationStems({ loaded, isEnabled: () => false }), []);
76
+ });
77
+
78
+ test('an enabled module whose migrations never ran is counted beside the core gap', async () => {
79
+ const dir = tmpMigrations();
80
+ const core = ['001_init', 'core_002_add', 'core_003_more'];
81
+ const moduleStems = () => enabledModuleMigrationStems({ loaded: tmpModules(), isEnabled: (key) => key === 'on' });
82
+ assert.deepEqual(await refreshSchemaLag({ db: dbWith([...core, 'on_001_a']), dir, moduleStems }), ['on_002_b']);
83
+ assert.deepEqual(await refreshSchemaLag({ db: dbWith([...core, 'on_001_a', 'on_002_b']), dir, moduleStems }), []);
84
+ });
85
+
86
+ test('the default expectation follows the server\'s own enablement, DB overrides included', () => {
87
+ // `discord` ships a migration and meters no spend, so the override alone decides it. migrate.sh
88
+ // reads the same override layer (tests/migrate_module_overrides_db.mjs, task 1004137).
89
+ const STEM = 'discord_001_board_messages';
90
+ try {
91
+ setModuleOverrides({ discord: true });
92
+ assert.ok(enabledModuleMigrationStems().includes(STEM), 'switched on by override: its migration is expected');
93
+ setModuleOverrides({ discord: false });
94
+ assert.ok(!enabledModuleMigrationStems().includes(STEM), 'switched off by override: nothing expected');
95
+ } finally {
96
+ setModuleOverrides({});
97
+ }
54
98
  });
55
99
 
56
100
  test('an unreadable database is unknown (null), never an alarm and never a throw', async () => {
57
101
  const down = { query: async () => { throw new Error('ECONNREFUSED'); } };
58
- assert.equal(await refreshSchemaLag({ db: down, dir: tmpMigrations() }), null);
102
+ assert.equal(await refreshSchemaLag({ db: down, dir: tmpMigrations(), moduleStems: none }), null);
59
103
  });
60
104
 
61
105
  test('schemaPending serves the cached count and refreshes at most once per window', async () => {
62
106
  const dir = tmpMigrations();
63
107
  const t0 = 1_000_000;
64
- await refreshSchemaLag({ db: dbWith(['001_init']), dir, now: () => t0 });
108
+ await refreshSchemaLag({ db: dbWith(['001_init']), dir, moduleStems: none, now: () => t0 });
65
109
 
66
110
  let calls = 0;
67
111
  const refresh = () => { calls += 1; return new Promise(() => {}); }; // never settles: stays in flight
@@ -29,7 +29,7 @@ try {
29
29
 
30
30
  try {
31
31
  const pending = await refreshSchemaLag();
32
- assert.deepEqual(pending, [], `a freshly migrated database must report no pending core migrations, got: ${JSON.stringify(pending)}`);
32
+ assert.deepEqual(pending, [], `a freshly migrated database must report no pending migrations (core or enabled module), got: ${JSON.stringify(pending)}`);
33
33
  assert.equal(schemaPending(), 0, 'and /version serves 0 — known-current, not unknown (null)');
34
34
  console.log(' ok a freshly migrated database reports schemaPending 0');
35
35
  } finally {