@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.
- package/.bongos-core.json +73 -33
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +6 -0
- package/docs/adr/0354-a-builder-hosts-one-project-free-and-more-takes-the-fleet-permission.md +1 -1
- package/docs/api/openapi.json +147 -2
- package/docs/api-reference.md +5 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/onboarding/howto-state.js +108 -0
- package/modules/onboarding/migrations/onboarding_001_howto_reads.sql +33 -0
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/onboarding.js +48 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/exec-path-guard.js +1 -1
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/module-error-report.js +148 -0
- package/scripts/gds/module.js +1 -0
- package/scripts/gds/provision-caps.js +63 -0
- package/scripts/gds/provision-process.js +3 -0
- package/scripts/gds/provision-units.js +3 -0
- package/scripts/gds/provision.js +3 -1
- package/src/module-api.js +1 -1
- package/src/module-loader/error-tally.js +188 -0
- package/src/module-loader/loader.js +52 -4
- package/src/platform-server.js +7 -0
- package/tests/howto_state.mjs +178 -0
- package/tests/module_error_counts.mjs +265 -0
- package/tests/own_scope_guard.mjs +6 -0
- package/tests/provision.mjs +2 -0
- 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
|
-
|
|
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) =>
|
|
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,
|
package/src/platform-server.js
CHANGED
|
@@ -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);
|