@bongos/core 1.21.126 → 1.21.128

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.
Files changed (35) hide show
  1. package/.bongos-core.json +73 -33
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +6 -0
  8. package/docs/adr/0354-a-builder-hosts-one-project-free-and-more-takes-the-fleet-permission.md +1 -1
  9. package/docs/api/openapi.json +147 -2
  10. package/docs/api-reference.md +5 -2
  11. package/docs/module-api-changelog.md +4 -0
  12. package/modules/onboarding/howto-state.js +108 -0
  13. package/modules/onboarding/migrations/onboarding_001_howto_reads.sql +33 -0
  14. package/modules/onboarding/module.json +1 -1
  15. package/modules/onboarding/routes/onboarding.js +48 -1
  16. package/package-lock.json +2 -2
  17. package/package.json +1 -1
  18. package/release-notes.json +16 -0
  19. package/scripts/gds/exec-path-guard.js +1 -1
  20. package/scripts/gds/fitness.js +1 -0
  21. package/scripts/gds/module-error-report.js +148 -0
  22. package/scripts/gds/module.js +1 -0
  23. package/scripts/gds/provision-caps.js +63 -0
  24. package/scripts/gds/provision-process.js +3 -0
  25. package/scripts/gds/provision-units.js +3 -0
  26. package/scripts/gds/provision.js +3 -1
  27. package/src/module-api.js +1 -1
  28. package/src/module-loader/error-tally.js +188 -0
  29. package/src/module-loader/loader.js +52 -4
  30. package/src/platform-server.js +7 -0
  31. package/tests/howto_state.mjs +178 -0
  32. package/tests/module_error_counts.mjs +265 -0
  33. package/tests/own_scope_guard.mjs +6 -0
  34. package/tests/provision.mjs +2 -0
  35. package/tests/provision_resource_caps.mjs +117 -0
@@ -0,0 +1,188 @@
1
+ // src/module-loader/error-tally.js — count each mounted module's runtime errors
2
+ // per day, so a store module's Reliability score has real installs behind it
3
+ // (task 1004637, follow-up to task 1003793; ADR 0343 D1).
4
+ //
5
+ // WHY. The store scores Reliability from a daily runtime-error count per module
6
+ // version, but nothing on an instance counted one: the loader console.errored a
7
+ // poller that failed to load and attributed nothing else to a module at all. This
8
+ // file is the instance-side tally the loader feeds; scripts/gds/module-error-report.js
9
+ // is the sender that turns it into reports.
10
+ //
11
+ // WHAT COUNTS. A request a module's router took and answered 500 (a thrown error or
12
+ // an unhandled failure — not a 4xx, which is the caller's mistake, and not a 503,
13
+ // which modules use to say "not set up here"); a poller that failed to load, threw
14
+ // or rejected on start; and anything a poller hands to the `reportError` the loader
15
+ // gives it. Only a module whose manifest carries an exact X.Y.Z version is counted,
16
+ // because the store files counts against an exact version.
17
+ //
18
+ // COUNTS ONLY (owner's privacy posture, 2026-10-01). The tally holds module key,
19
+ // version, day and a whole number — never an error's text, a path or a request.
20
+ //
21
+ // WHO SENDS (owner, 2026-10-15). A builder's sign-in on the box: the store's report
22
+ // routes take a builder, and no project-to-store credential exists (parked as
23
+ // task 1004740). So this file never talks to the network. It writes the tally to the
24
+ // instance config dir, and startDailySend() runs the sender as a CHILD process —
25
+ // cli-lib's session plumbing must never load in the server process.
26
+ //
27
+ // Counting is always in memory; nothing touches disk until start() is called, and
28
+ // only the server entrypoint calls it — so a test or tool that mounts modules never
29
+ // writes a file.
30
+
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+
34
+ const FILE_NAME = 'module-error-counts.json';
35
+ // Days kept on disk. One more than the store accepts (ERROR_DAY_WINDOW = 30), so a
36
+ // day the sender has not reached yet is never pruned before it can go.
37
+ const KEEP_DAYS = 31;
38
+ const FLUSH_DELAY_MS = 5_000;
39
+ const ROLL_CHECK_MS = 60 * 60 * 1000;
40
+ const SEND_EVERY_MS = 6 * 60 * 60 * 1000;
41
+ const FIRST_SEND_DELAY_MS = 10 * 60 * 1000;
42
+ const EXACT_VERSION = /^\d+\.\d+\.\d+$/;
43
+ // The sender, rooted at this file so what the server runs is answerable by reading
44
+ // this line (exec-path-guard, task 1003371).
45
+ const SENDER = path.join(__dirname, '..', '..', 'scripts', 'gds', 'module-error-report.js');
46
+
47
+ function utcDay(ms = Date.now()) { return new Date(ms).toISOString().slice(0, 10); }
48
+
49
+ let _now = Date.now;
50
+ let _days = {}; // day → { "key@version": count }
51
+ let _mounted = new Map(); // key → version, for every module seeded today and each new day
52
+ let _file = null; // set by start(); null = nothing written to disk
53
+ let _flushTimer = null;
54
+ let _timers = [];
55
+
56
+ function entryOf(key, version) { return `${key}@${version}`; }
57
+
58
+ // Make sure today has a 0 for every mounted module — a clean day is exactly what
59
+ // Reliability measures, so a module that ran and threw nothing must still appear.
60
+ function seedToday() {
61
+ const today = utcDay(_now());
62
+ const row = (_days[today] ||= {});
63
+ for (const [key, version] of _mounted) {
64
+ const e = entryOf(key, version);
65
+ if (!(e in row)) row[e] = 0;
66
+ }
67
+ return row;
68
+ }
69
+
70
+ // Register the modules this process runs. Call once the enabled set is known; a
71
+ // module without an exact version is ignored. Returns how many were registered.
72
+ function seed(modules) {
73
+ let n = 0;
74
+ for (const { key, manifest } of modules) {
75
+ const v = manifest && manifest.version;
76
+ if (typeof v !== 'string' || !EXACT_VERSION.test(v)) continue;
77
+ _mounted.set(key, v);
78
+ n += 1;
79
+ }
80
+ seedToday();
81
+ scheduleFlush();
82
+ return n;
83
+ }
84
+
85
+ // Count one runtime error against a mounted module. Unknown or unversioned keys
86
+ // are ignored, so a stray call can never invent a module.
87
+ function record(key) {
88
+ const version = _mounted.get(key);
89
+ if (!version) return false;
90
+ const row = seedToday();
91
+ row[entryOf(key, version)] += 1;
92
+ scheduleFlush();
93
+ return true;
94
+ }
95
+
96
+ function prune() {
97
+ const cutoff = utcDay(_now() - KEEP_DAYS * 86_400_000);
98
+ for (const d of Object.keys(_days)) if (d < cutoff) delete _days[d];
99
+ }
100
+
101
+ function scheduleFlush() {
102
+ if (!_file || _flushTimer) return;
103
+ _flushTimer = setTimeout(() => { _flushTimer = null; flush(); }, FLUSH_DELAY_MS);
104
+ if (_flushTimer.unref) _flushTimer.unref();
105
+ }
106
+
107
+ // Write the tally atomically (temp file, then rename), so the sender never reads
108
+ // half a file. Best-effort: a failed write costs a count, never the server.
109
+ function flush() {
110
+ if (!_file) return false;
111
+ prune();
112
+ try {
113
+ fs.mkdirSync(path.dirname(_file), { recursive: true });
114
+ const tmp = `${_file}.${process.pid}.tmp`;
115
+ fs.writeFileSync(tmp, JSON.stringify({ days: _days }, null, 2));
116
+ fs.renameSync(tmp, _file);
117
+ return true;
118
+ } catch (e) {
119
+ process.emitWarning(`module error tally: could not write ${_file}: ${e && e.message ? e.message : e}`);
120
+ return false;
121
+ }
122
+ }
123
+
124
+ // Read a tally file. Never throws: a missing or unreadable file is an empty tally.
125
+ function readTally(file) {
126
+ try {
127
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
128
+ return parsed && typeof parsed.days === 'object' && parsed.days ? parsed.days : {};
129
+ } catch { return {}; }
130
+ }
131
+
132
+ function defaultFile() {
133
+ return require('../instance-config').configPath(FILE_NAME);
134
+ }
135
+
136
+ // Persist from now on. Merges what is already on disk (a restart mid-day keeps the
137
+ // morning's count: the higher of the two wins per entry), seeds today, and checks
138
+ // hourly for a new day so a server that runs for a week still files a 0 each day.
139
+ function start({ file = defaultFile(), now } = {}) {
140
+ if (now) _now = now;
141
+ _file = file;
142
+ const onDisk = readTally(file);
143
+ for (const [day, row] of Object.entries(onDisk)) {
144
+ const mine = (_days[day] ||= {});
145
+ for (const [e, n] of Object.entries(row || {})) {
146
+ if (Number.isInteger(n) && n >= 0) mine[e] = Math.max(mine[e] || 0, n);
147
+ }
148
+ }
149
+ seedToday();
150
+ flush();
151
+ const roll = setInterval(() => { seedToday(); flush(); }, ROLL_CHECK_MS);
152
+ if (roll.unref) roll.unref();
153
+ _timers.push(roll);
154
+ return file;
155
+ }
156
+
157
+ // Run the sender now and then (scripts/gds/module-error-report.js) as a child
158
+ // process. It sends nothing without a builder sign-in on this box, and only days
159
+ // before today, so running it often is cheap and safe. Returns the timers.
160
+ function startDailySend({ spawnImpl } = {}) {
161
+ const { spawn } = require('node:child_process');
162
+ const run = spawnImpl || (() => {
163
+ const child = spawn(process.execPath, [SENDER, '--quiet'], { stdio: 'ignore', detached: false });
164
+ child.on('error', () => {});
165
+ if (child.unref) child.unref();
166
+ });
167
+ if (!fs.existsSync(SENDER)) return [];
168
+ const go = () => { try { run(SENDER); } catch { /* best-effort */ } };
169
+ const first = setTimeout(go, FIRST_SEND_DELAY_MS);
170
+ const every = setInterval(go, SEND_EVERY_MS);
171
+ for (const t of [first, every]) if (t.unref) t.unref();
172
+ _timers.push(first, every);
173
+ return [first, every];
174
+ }
175
+
176
+ // The in-memory tally, for tests and a /watch tile.
177
+ function snapshot() { return JSON.parse(JSON.stringify(_days)); }
178
+
179
+ function _reset() {
180
+ for (const t of _timers) { clearTimeout(t); clearInterval(t); }
181
+ if (_flushTimer) clearTimeout(_flushTimer);
182
+ _timers = []; _flushTimer = null; _days = {}; _mounted = new Map(); _file = null; _now = Date.now;
183
+ }
184
+
185
+ module.exports = {
186
+ FILE_NAME,
187
+ utcDay, seed, record, readTally, start, startDailySend, snapshot, _reset,
188
+ };
@@ -112,7 +112,7 @@ function discoverModules({ roots = DEFAULT_ROOTS, core = coreVersion() } = {}) {
112
112
  // Cached singleton for app code (lazy, like src/modules.js's modules()).
113
113
  let _cache = null;
114
114
  function discovered(opts) { return (_cache ||= discoverModules(opts)); }
115
- function _reset() { _cache = null; _startedPollers = []; _boot = { routes: null, pollers: null }; _bootShape = null; }
115
+ function _reset() { resetErrorTally(); _cache = null; _startedPollers = []; _boot = { routes: null, pollers: null }; _bootShape = null; }
116
116
 
117
117
  // The discovered manifests as MODULE_REGISTRY-shaped entries, so src/modules.js
118
118
  // can merge them with its built-in registry. Empty until a module exists.
@@ -197,10 +197,13 @@ function mountModuleRoutes(router, { isEnabled } = {}, opts) {
197
197
  for (const rk of routeKeys) {
198
198
  const factory = require(path.join(dir, 'routes', `${rk}.js`));
199
199
  const sub = typeof factory === 'function' ? factory() : factory;
200
- router.use(sub);
200
+ router.use(attributeErrors(key, sub));
201
201
  mounted += 1;
202
202
  }
203
203
  }
204
+ // Every enabled module gets a 0 for today, routes or not: a clean day is what the
205
+ // store's Reliability score measures (task 1004637).
206
+ seedErrorTally(enabled);
204
207
  return mounted;
205
208
  }
206
209
 
@@ -352,19 +355,26 @@ function startModulePollers(deps = {}, { isEnabled } = {}, opts) {
352
355
  // here. A file that EXISTS but throws is a real error (the catch below).
353
356
  if (e && e.code === 'MODULE_NOT_FOUND' && String(e.message).includes(pollerPath)) continue;
354
357
  console.error(`[module-loader] poller ${key}/${pk} failed to load: ${e && e.message ? e.message : e}`);
358
+ recordModuleError(key);
355
359
  continue;
356
360
  }
357
361
  try {
358
362
  if (poller && typeof poller.start === 'function') {
359
- const r = poller.start(deps);
363
+ // reportError: how a poller counts a failure in its own runs against its
364
+ // module (task 1004637) — counts only, it takes no message.
365
+ const r = poller.start({ ...deps, reportError: () => recordModuleError(key) });
360
366
  if (r && typeof r.then === 'function') {
361
- r.catch((e) => console.error(`[module-loader] poller ${key}/${pk} start error: ${e && e.message ? e.message : e}`));
367
+ r.catch((e) => {
368
+ console.error(`[module-loader] poller ${key}/${pk} start error: ${e && e.message ? e.message : e}`);
369
+ recordModuleError(key);
370
+ });
362
371
  }
363
372
  started.push({ key, poller: pk, handle: poller });
364
373
  _boot.pollers.add(key);
365
374
  }
366
375
  } catch (e) {
367
376
  console.error(`[module-loader] poller ${key}/${pk} start threw: ${e && e.message ? e.message : e}`);
377
+ recordModuleError(key);
368
378
  }
369
379
  }
370
380
  }
@@ -397,9 +407,47 @@ function loaderSnapshot(opts) {
397
407
  loaded: d.loaded.map((m) => m.key),
398
408
  skipped: d.skipped,
399
409
  errors: d.errors,
410
+ // per-day runtime-error counts per module@version, kept for the store (task 1004637)
411
+ errorCounts: snapshotErrorTally(),
400
412
  };
401
413
  }
402
414
 
415
+ // Which module took this request, and whether its 500 has been hooked yet.
416
+ const OWNER = Symbol('moduleOwner');
417
+ const HOOKED = Symbol('moduleErrorHooked');
418
+
419
+ // Wrap a module's router so a request it answers with a 500 counts against that
420
+ // module (task 1004637). The module is the owner from the moment the request enters
421
+ // its router until it falls through (the router calls next() with no error): a request
422
+ // the module never matched is not its fault, and the next module or the kernel takes
423
+ // it from there. An error the module passes on (next(err)) keeps it the owner, so the
424
+ // kernel's error handler answering 500 for it still counts here. Only a 500 counts:
425
+ // a 4xx is the caller's mistake and a 503 is a module saying it is not set up.
426
+ //
427
+ // A plain function rather than a nested express Router, so nothing about mounting
428
+ // changes for the kernel; `stack` is passed through so tooling that reads a mounted
429
+ // router's routes still sees the module's own.
430
+ function attributeErrors(key, sub) {
431
+ const scoped = function moduleScoped(req, res, next) {
432
+ const prev = req[OWNER];
433
+ req[OWNER] = key;
434
+ if (!req[HOOKED] && res && typeof res.on === 'function') {
435
+ req[HOOKED] = true;
436
+ res.on('finish', () => { if (res.statusCode === 500 && req[OWNER]) recordModuleError(req[OWNER]); });
437
+ }
438
+ sub(req, res, (err) => {
439
+ if (!err || typeof err === 'string') req[OWNER] = prev; // fell through ('route'/'router' too)
440
+ next(err);
441
+ });
442
+ };
443
+ if (sub && sub.stack) scoped.stack = sub.stack;
444
+ return scoped;
445
+ }
446
+
447
+ // Per-module runtime-error counts (task 1004637). Pure fs + timers, no network and no
448
+ // core requires at load, so it is cycle-safe here.
449
+ const { seed: seedErrorTally, record: recordModuleError, _reset: resetErrorTally, snapshot: snapshotErrorTally } = require('./error-tally');
450
+
403
451
  module.exports = {
404
452
  discoverModules,
405
453
  discovered,
@@ -47,6 +47,7 @@ const express = require('express');
47
47
  const { mountInternalSurfaces, isStatusHost, isBuildersHost, injectBrandingGlobal } = require('./bongos/serve-internal');
48
48
  const { isApiShapedPath, apiNotFoundBody } = require('./bongos/api-path-404');
49
49
  const moduleLoader = require('./module-loader/loader');
50
+ const { start: startErrorTally, startDailySend: startErrorReports } = require('./module-loader/error-tally');
50
51
  const { isModuleEnabled } = require('./modules');
51
52
  // Hoisted out of an inline require inside the boot IIFE below (task 1004052 strand walk).
52
53
  // knip does not follow an inline require(), so every export of module-overrides.js read as
@@ -413,6 +414,12 @@ if (require.main === module) {
413
414
  // process starts, and /version carries the count within one query of a restart.
414
415
  schemaPending();
415
416
  startBackgroundServices();
417
+ // Store modules' daily runtime-error counts (task 1004637): keep the tally on
418
+ // disk, and now and then run the sender, which goes out only with a builder
419
+ // sign-in on this box. Here, not in startBackgroundServices(), so a test that
420
+ // starts the pollers never writes the tally or spawns the sender.
421
+ try { startErrorTally(); startErrorReports(); }
422
+ catch (e) { logger.warn({ err: e.message }, 'module error tally did not start; module error counts are not kept'); }
416
423
  });
417
424
  })().catch((err) => {
418
425
  // THE AWAIT ABOVE MUST NOT SOFTEN A FATAL BOOT (task 1002857, caught by
@@ -0,0 +1,178 @@
1
+ // tests/howto_state.mjs — per-person read/unread state for the how-to steps (task 1004634)
2
+ //
3
+ // Two halves. The DB-free half drives howto-state.js against an in-memory pool that
4
+ // honours the three statements it issues, so the contract runs everywhere: unread by
5
+ // default, mark read, skip all, a read upgrades a skip (never the reverse), the first
6
+ // read time is kept, reset some or all, and an id the guide does not name is refused.
7
+ // The DB half applies the real migration and runs the same calls against Postgres; it
8
+ // skips when no Postgres is reachable (DATABASE_URL), like copy_desk_flags_db.mjs.
9
+ //
10
+ // Run: node tests/howto_state.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import url from 'node:url';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const hs = require('../modules/onboarding/howto-state.js');
20
+ const guide = require('../modules/onboarding/howto-guide.js');
21
+ const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
22
+
23
+ let passed = 0;
24
+ let failed = 0;
25
+ async function test(name, fn) {
26
+ try { await fn(); passed++; console.log(` ok ${name}`); }
27
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
28
+ }
29
+
30
+ // An in-memory stand-in for the three statements howto-state.js runs.
31
+ function fakePool() {
32
+ const rows = new Map(); // `${b}|${step}` → { builder_id, step_id, how, read_at }
33
+ let clock = 0;
34
+ return {
35
+ rows,
36
+ async query(sql, params) {
37
+ if (/^SELECT step_id/.test(sql)) {
38
+ return { rows: [...rows.values()].filter((r) => r.builder_id === params[0]) };
39
+ }
40
+ if (/^INSERT INTO onboarding_howto_reads/.test(sql)) {
41
+ const [b, ids, how] = params;
42
+ for (const s of ids) {
43
+ const k = `${b}|${s}`;
44
+ const cur = rows.get(k);
45
+ if (!cur) rows.set(k, { builder_id: b, step_id: s, how, read_at: new Date(1e12 + ++clock * 1000) });
46
+ else if (cur.how === 'skipped' && how === 'read') cur.how = 'read';
47
+ }
48
+ return { rows: [] };
49
+ }
50
+ if (/^DELETE FROM onboarding_howto_reads WHERE builder_id = \$1 AND/.test(sql)) {
51
+ for (const s of params[1]) rows.delete(`${params[0]}|${s}`);
52
+ return { rows: [] };
53
+ }
54
+ if (/^DELETE FROM onboarding_howto_reads WHERE builder_id = \$1$/.test(sql)) {
55
+ for (const [k, r] of rows) if (r.builder_id === params[0]) rows.delete(k);
56
+ return { rows: [] };
57
+ }
58
+ throw new Error(`unexpected SQL: ${sql}`);
59
+ },
60
+ };
61
+ }
62
+
63
+ async function contract(label, deps, A, B) {
64
+ const first = guide.STEPS[0].id;
65
+ const second = guide.STEPS[1].id;
66
+
67
+ await test(`${label}: a new person has every step unread, next is the first`, async () => {
68
+ const s = await hs.readState(A, deps);
69
+ assert.equal(s.total, guide.STEPS.length);
70
+ assert.equal(s.unread, s.total);
71
+ assert.equal(s.next_unread, first);
72
+ assert.ok(s.steps.every((x) => x.state === 'unread' && x.read_at === null));
73
+ });
74
+
75
+ await test(`${label}: mark one read → it is read, next moves on, other people untouched`, async () => {
76
+ const r = await hs.markRead(A, { steps: [first] }, deps);
77
+ assert.equal(r.ok, true);
78
+ assert.equal(r.state.read, 1);
79
+ assert.equal(r.state.next_unread, second);
80
+ assert.equal(r.state.steps[0].state, 'read');
81
+ assert.ok(r.state.steps[0].read_at);
82
+ const other = await hs.readState(B, deps);
83
+ assert.equal(other.read, 0, 'per person');
84
+ });
85
+
86
+ await test(`${label}: re-marking keeps the first read time (idempotent)`, async () => {
87
+ const before = (await hs.readState(A, deps)).steps[0].read_at;
88
+ await hs.markRead(A, { steps: [first] }, deps);
89
+ assert.equal((await hs.readState(A, deps)).steps[0].read_at, before);
90
+ });
91
+
92
+ await test(`${label}: an unknown step id is refused and nothing is stored`, async () => {
93
+ const r = await hs.markRead(A, { steps: [second, 'not_a_step'] }, deps);
94
+ assert.equal(r.ok, false);
95
+ assert.equal(r.reason, 'unknown_step');
96
+ assert.deepEqual(r.unknown, ['not_a_step']);
97
+ assert.equal((await hs.readState(A, deps)).read, 1);
98
+ assert.equal((await hs.markRead(A, { steps: [] }, deps)).reason, 'no_steps');
99
+ assert.equal((await hs.markRead(A, { steps: [second], how: 'seen' }, deps)).reason, 'bad_how');
100
+ });
101
+
102
+ await test(`${label}: skip all marks every other step skipped; nothing left unread`, async () => {
103
+ const r = await hs.markRead(A, { all: true, how: 'skipped' }, deps);
104
+ assert.equal(r.state.unread, 0);
105
+ assert.equal(r.state.next_unread, null);
106
+ assert.equal(r.state.read, 1, 'the step already read stays read');
107
+ assert.equal(r.state.skipped, guide.STEPS.length - 1);
108
+ });
109
+
110
+ await test(`${label}: reading a skipped step upgrades it; skipping a read one does not downgrade`, async () => {
111
+ await hs.markRead(A, { steps: [second] }, deps);
112
+ let s = await hs.readState(A, deps);
113
+ assert.equal(s.steps[1].state, 'read');
114
+ await hs.markRead(A, { steps: [second], how: 'skipped' }, deps);
115
+ s = await hs.readState(A, deps);
116
+ assert.equal(s.steps[1].state, 'read');
117
+ });
118
+
119
+ await test(`${label}: reset some steps, then reset everything`, async () => {
120
+ let r = await hs.reset(A, { steps: [first] }, deps);
121
+ assert.equal(r.state.steps[0].state, 'unread');
122
+ assert.equal(r.state.next_unread, first);
123
+ assert.equal((await hs.reset(A, { steps: ['nope_nope'] }, deps)).reason, 'unknown_step');
124
+ r = await hs.reset(A, {}, deps);
125
+ assert.equal(r.state.unread, guide.STEPS.length);
126
+ });
127
+ }
128
+
129
+ await test('shapeState ignores a stored mark for a step the guide no longer has', async () => {
130
+ const s = hs.shapeState([{ step_id: 'retired_step', how: 'read', read_at: new Date() }]);
131
+ assert.equal(s.read, 0);
132
+ assert.equal(s.total, guide.STEPS.length);
133
+ });
134
+
135
+ await test('the module declares its migration and the routes are mounted', async () => {
136
+ const mod = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/onboarding/module.json'), 'utf8'));
137
+ assert.equal(mod.contributes.migrations, true);
138
+ assert.ok(fs.existsSync(path.join(ROOT, 'modules/onboarding/migrations/onboarding_001_howto_reads.sql')));
139
+ const src = fs.readFileSync(path.join(ROOT, 'modules/onboarding/routes/onboarding.js'), 'utf8');
140
+ for (const r of ["router.get('/me/howto'", "router.post('/me/howto/read'", "router.post('/me/howto/reset'"]) assert.ok(src.includes(r), r);
141
+ });
142
+
143
+ await contract('in-memory', { pool: fakePool() }, 1, 2);
144
+
145
+ // ── the real database ────────────────────────────────────────────────────────
146
+ const { Pool } = require('pg');
147
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
148
+ let dbUp = true;
149
+ try { await pool.query('SELECT 1'); } catch (e) {
150
+ dbUp = false;
151
+ console.log(` (db half SKIPPED — no reachable Postgres: ${e.code || e.message}. Set DATABASE_URL to run.)`);
152
+ }
153
+ if (dbUp) {
154
+ await pool.query(`
155
+ CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
156
+ CREATE TABLE IF NOT EXISTS builders (id bigserial PRIMARY KEY, github_login text);
157
+ `);
158
+ await pool.query(fs.readFileSync(path.join(ROOT, 'modules/onboarding/migrations/onboarding_001_howto_reads.sql'), 'utf8'));
159
+ await pool.query(fs.readFileSync(path.join(ROOT, 'modules/onboarding/migrations/onboarding_001_howto_reads.sql'), 'utf8')); // idempotent
160
+ const A = 910000001;
161
+ const B = 910000002;
162
+ await pool.query('INSERT INTO builders (id, github_login) VALUES ($1, $2), ($3, $4) ON CONFLICT (id) DO NOTHING', [A, 'howto-a', B, 'howto-b']);
163
+ await pool.query('DELETE FROM onboarding_howto_reads WHERE builder_id IN ($1, $2)', [A, B]);
164
+ try {
165
+ await contract('postgres', { pool }, A, B);
166
+ await test('postgres: the CHECK refuses a malformed step id and a bad how', async () => {
167
+ await assert.rejects(pool.query("INSERT INTO onboarding_howto_reads (builder_id, step_id) VALUES ($1, 'Bad Id')", [A]));
168
+ await assert.rejects(pool.query("INSERT INTO onboarding_howto_reads (builder_id, step_id, how) VALUES ($1, 'welcome', 'seen')", [A]));
169
+ });
170
+ } finally {
171
+ await pool.query('DELETE FROM onboarding_howto_reads WHERE builder_id IN ($1, $2)', [A, B]).catch(() => {});
172
+ await pool.query('DELETE FROM builders WHERE id IN ($1, $2)', [A, B]).catch(() => {});
173
+ }
174
+ }
175
+ await pool.end().catch(() => {});
176
+
177
+ console.log(`\n${passed} passed, ${failed} failed`);
178
+ if (failed) process.exit(1);