@bongos/core 1.21.168 → 1.21.170
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 +34 -29
- package/docs/adr/0344-a-tester-is-a-project-that-opts-in-once.md +12 -0
- package/docs/api/openapi.json +2 -2
- package/docs/architecture.md +2 -0
- package/docs/copy-inventory.md +22 -20
- package/docs/copy-registry.json +41 -23
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +2 -1
- package/docs/page-readings.json +1 -1
- package/modules/copy-desk/page-status.js +38 -1
- package/modules/hall-ui/public/approval-queue-lib.js +15 -1
- package/modules/hall-ui/public/approval-queue.css +9 -0
- package/modules/hall-ui/public/approval-queue.js +18 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/module-assess-installs.js +94 -9
- package/src/bongos/module-store.js +47 -9
- package/src/bongos/routes/modules.js +26 -10
- package/src/module-api.js +1 -1
- package/tests/hall_approval_queue.mjs +35 -0
- package/tests/module_assess_installs.mjs +72 -6
- package/tests/module_channel_install.mjs +112 -0
- package/tests/module_upgrade_updates.mjs +2 -2
|
@@ -256,12 +256,34 @@ function isFreePrice(priceRow) {
|
|
|
256
256
|
return priceRow.price_cents === 0 || priceRow.price_credits === 0;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
// The release channels a project is handed versions from (task 1003805, ADR 0344
|
|
260
|
+
// D1/D2): a project that tests this module gets tester AND general versions, every
|
|
261
|
+
// other project general only. 'withdrawn' is never in either list, so a withdrawn
|
|
262
|
+
// version is never handed out (a copy already installed keeps working, ADR 0338 D2).
|
|
263
|
+
function servedChannels(tester) {
|
|
264
|
+
return tester ? ['tester', 'general'] : ['general'];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A version's current channel is its newest store_module_version_channels row
|
|
268
|
+
// (core_276): a move is a new row, never an update. One probe per version into
|
|
269
|
+
// idx_store_module_version_channels_current. `idCol` is the outer query's version
|
|
270
|
+
// id column, and only these two literals are accepted, so no text reaches the SQL.
|
|
271
|
+
const VERSION_ID_COLS = new Set(['store_module_versions.id', 'v.id']);
|
|
272
|
+
function currentChannelSql(idCol) {
|
|
273
|
+
if (!VERSION_ID_COLS.has(idCol)) throw new Error(`currentChannelSql: unexpected column ${idCol}`);
|
|
274
|
+
return `(SELECT c.channel FROM store_module_version_channels c
|
|
275
|
+
WHERE c.version_id = ${idCol} ORDER BY c.set_at DESC, c.id DESC LIMIT 1)`;
|
|
276
|
+
}
|
|
277
|
+
|
|
259
278
|
// Resolve what an acquire of `key` (optionally at an exact `version`) would hand
|
|
260
279
|
// over. Refuses an unknown or delisted module (ADR 0338 D2: a delisted module takes
|
|
261
280
|
// no new acquisitions and serves no further versions) and an unknown version. With
|
|
262
281
|
// no version asked for, the newest published version (semver order, not text).
|
|
282
|
+
// `channels` (from servedChannels) limits it to versions currently on those
|
|
283
|
+
// channels — the newest one there, or a refusal for an exact version that is not;
|
|
284
|
+
// null skips the channel check (recording a version already placed).
|
|
263
285
|
// Returns { ok: true, module, version, price, free } or { ok: false, status, code, message }.
|
|
264
|
-
async function resolveAcquirable(key, { version = null, db } = {}) {
|
|
286
|
+
async function resolveAcquirable(key, { version = null, channels = null, db } = {}) {
|
|
265
287
|
const pool = db || require('./pool').pool;
|
|
266
288
|
const refuse = (status, code, message) => ({ ok: false, status, code, message });
|
|
267
289
|
const { rows: [mod] } = await pool.query(
|
|
@@ -270,16 +292,28 @@ async function resolveAcquirable(key, { version = null, db } = {}) {
|
|
|
270
292
|
if (mod.status === 'delisted') return refuse(409, 'module_delisted', `"${key}" is delisted: it can no longer be acquired or downloaded (ADR 0338 D2).`);
|
|
271
293
|
const { rows: versions } = await pool.query(
|
|
272
294
|
`SELECT id, module_key, version, core_version, tree_sha256, tarball_sha256, tarball_bytes,
|
|
273
|
-
artifact_path, published_at
|
|
295
|
+
artifact_path, published_at, ${currentChannelSql('store_module_versions.id')} AS channel
|
|
274
296
|
FROM store_module_versions WHERE module_key = $1`, [key]);
|
|
297
|
+
const served = (r) => !channels || channels.includes(r.channel);
|
|
275
298
|
let row;
|
|
276
299
|
if (version) {
|
|
277
300
|
row = versions.find((r) => r.version === version);
|
|
278
301
|
if (!row) return refuse(404, 'version_not_found', `"${key}" has no published version ${version}.`);
|
|
302
|
+
if (!served(row)) {
|
|
303
|
+
return refuse(409, 'version_not_on_channel', row.channel === 'withdrawn'
|
|
304
|
+
? `"${key}" ${version} was withdrawn: it is no longer handed out.`
|
|
305
|
+
: `"${key}" ${version} is still with testers, and this project takes general versions of it.`);
|
|
306
|
+
}
|
|
279
307
|
} else {
|
|
280
|
-
row = versions.filter((r) => parseVersion(r.version))
|
|
308
|
+
row = versions.filter((r) => parseVersion(r.version) && served(r))
|
|
281
309
|
.sort((a, b) => cmp(parseVersion(a.version), parseVersion(b.version))).pop();
|
|
282
|
-
if (!row)
|
|
310
|
+
if (!row) {
|
|
311
|
+
if (!versions.length) return refuse(404, 'no_versions', `"${key}" has no published version yet.`);
|
|
312
|
+
const testing = versions.some((r) => r.channel === 'tester');
|
|
313
|
+
return refuse(404, 'no_versions_on_channel', testing
|
|
314
|
+
? `"${key}" has no version on the channel this project takes yet: its newest is still with testers.`
|
|
315
|
+
: `"${key}" has no version this project can be handed: every version was withdrawn.`);
|
|
316
|
+
}
|
|
283
317
|
}
|
|
284
318
|
const { rows: [price] } = await pool.query(
|
|
285
319
|
`SELECT price_cents, price_credits, set_at FROM store_module_prices
|
|
@@ -321,21 +355,25 @@ async function publishedVersionSet(keys, { db } = {}) {
|
|
|
321
355
|
// takes no entitlement and refuses nothing, so a delisted module still answers,
|
|
322
356
|
// with its status, and the caller says it gets no further versions (ADR 0338 D2).
|
|
323
357
|
// A key the store does not know is simply absent. One query for the whole list.
|
|
324
|
-
//
|
|
325
|
-
|
|
358
|
+
// `channelsFor(key)` (servedChannels per module) counts only versions on the
|
|
359
|
+
// channels this project takes, so upgrade reports what update would hand over
|
|
360
|
+
// (task 1003805); a key with nothing on them is absent too.
|
|
361
|
+
// Returns { [key]: { version, core_version, status, channel } }.
|
|
362
|
+
async function latestVersions(keys, { db, channelsFor = null } = {}) {
|
|
326
363
|
if (!keys.length) return {};
|
|
327
364
|
const pool = db || require('./pool').pool;
|
|
328
365
|
const { rows } = await pool.query(
|
|
329
|
-
`SELECT v.module_key, v.version, v.core_version, m.status
|
|
366
|
+
`SELECT v.module_key, v.version, v.core_version, m.status, ${currentChannelSql('v.id')} AS channel
|
|
330
367
|
FROM store_module_versions v JOIN store_modules m ON m.module_key = v.module_key
|
|
331
368
|
WHERE v.module_key = ANY($1)`, [keys]);
|
|
332
369
|
const out = {};
|
|
333
370
|
for (const r of rows) {
|
|
334
371
|
const next = parseVersion(r.version);
|
|
335
372
|
if (!next) continue;
|
|
373
|
+
if (channelsFor && !channelsFor(r.module_key).includes(r.channel)) continue;
|
|
336
374
|
const have = out[r.module_key];
|
|
337
375
|
if (have && cmp(parseVersion(have.version), next) >= 0) continue;
|
|
338
|
-
out[r.module_key] = { version: r.version, core_version: r.core_version, status: r.status };
|
|
376
|
+
out[r.module_key] = { version: r.version, core_version: r.core_version, status: r.status, channel: r.channel };
|
|
339
377
|
}
|
|
340
378
|
return out;
|
|
341
379
|
}
|
|
@@ -383,6 +421,6 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
|
|
|
383
421
|
|
|
384
422
|
module.exports = {
|
|
385
423
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
386
|
-
isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
424
|
+
isFreePrice, servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
387
425
|
delistModule, delistedKeySet, storeDir, storeDirFor, migrateLegacyStore,
|
|
388
426
|
};
|
|
@@ -96,7 +96,7 @@ const moduleAssessVersion = require('../../../scripts/gds/module-assess-version'
|
|
|
96
96
|
const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } = require('../module-entitlements');
|
|
97
97
|
const {
|
|
98
98
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
99
|
-
resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
99
|
+
servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
100
100
|
delistModule, delistedKeySet, migrateLegacyStore,
|
|
101
101
|
} = require('../module-store');
|
|
102
102
|
const {
|
|
@@ -111,9 +111,20 @@ const log = require('../logger').logger.child({ src: 'modules' });
|
|
|
111
111
|
// as unused exports and tripped the dead-code ratchet that held this branch out of main.
|
|
112
112
|
const { listOverrides, setOverride } = require('../module-overrides');
|
|
113
113
|
const { listCatalog, SORT_PARTS } = require('../module-listing');
|
|
114
|
-
const { readTesting, setTester, setGeneralOnly } = require('../module-testing');
|
|
114
|
+
const { readTesting, setTester, setGeneralOnly, testsVersionsOf } = require('../module-testing');
|
|
115
115
|
const { readStanding, releaseHeld } = require('../module-promotion');
|
|
116
116
|
|
|
117
|
+
// The channels this project takes, per module (task 1003805, ADR 0344 D1/D2): a
|
|
118
|
+
// tester project gets tester and general versions, except of a module it marked
|
|
119
|
+
// "general only"; everyone else gets general. A choice that cannot be read reads as
|
|
120
|
+
// NOT a tester (module-testing.js), so it hands out general, never tester.
|
|
121
|
+
async function projectChannels() {
|
|
122
|
+
let testing = null;
|
|
123
|
+
try { testing = await readTesting(); }
|
|
124
|
+
catch (e) { log.warn({ err: e.message }, 'tester choice unreadable; serving general versions'); }
|
|
125
|
+
return (key) => servedChannels(testsVersionsOf(testing, key));
|
|
126
|
+
}
|
|
127
|
+
|
|
117
128
|
// The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
|
|
118
129
|
// module's pages, 'pollers' its background jobs. PURE.
|
|
119
130
|
function restartNote(waits, enabled) {
|
|
@@ -667,9 +678,12 @@ module.exports = function buildModulesRouter() {
|
|
|
667
678
|
// here changes what runs on this instance (install never enables; the CLI places
|
|
668
679
|
// files on the CALLER's checkout).
|
|
669
680
|
//
|
|
670
|
-
// Channels:
|
|
671
|
-
//
|
|
672
|
-
//
|
|
681
|
+
// Channels (task 1003805, ADR 0344): steps 1 and 2 hand out only versions on a
|
|
682
|
+
// channel this project takes (projectChannels) — with no version asked for, the
|
|
683
|
+
// newest there. Install and update share step 1, so both honour it. A project
|
|
684
|
+
// that stops testing keeps the tester version it has; its next update is the
|
|
685
|
+
// newest general one, and update only moves forward, so nothing rolls back.
|
|
686
|
+
// Step 3 records what was already placed, so it does not re-check the channel.
|
|
673
687
|
router.post('/store/modules/:key/acquire', auth.requireBuilder,
|
|
674
688
|
asyncHandler('POST /store/modules/:key/acquire', async (req, res) => {
|
|
675
689
|
const { key } = req.params;
|
|
@@ -681,7 +695,7 @@ module.exports = function buildModulesRouter() {
|
|
|
681
695
|
if (wanted != null && !parseVersion(String(wanted))) {
|
|
682
696
|
return res.fail('bad_version', { status: 400, message: 'A version is exact X.Y.Z.' });
|
|
683
697
|
}
|
|
684
|
-
const r = await resolveAcquirable(key, { version: wanted || null });
|
|
698
|
+
const r = await resolveAcquirable(key, { version: wanted || null, channels: (await projectChannels())(key) });
|
|
685
699
|
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
686
700
|
|
|
687
701
|
const holder = { moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) };
|
|
@@ -706,7 +720,7 @@ module.exports = function buildModulesRouter() {
|
|
|
706
720
|
tarball_sha256: v.tarball_sha256,
|
|
707
721
|
tree_sha256: v.tree_sha256,
|
|
708
722
|
tarball_bytes: Number(v.tarball_bytes),
|
|
709
|
-
channel:
|
|
723
|
+
channel: v.channel,
|
|
710
724
|
entitlement: held.entitlement,
|
|
711
725
|
});
|
|
712
726
|
}));
|
|
@@ -717,7 +731,7 @@ module.exports = function buildModulesRouter() {
|
|
|
717
731
|
if (!KEY_RE.test(key) || !parseVersion(version)) {
|
|
718
732
|
return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
|
|
719
733
|
}
|
|
720
|
-
const r = await resolveAcquirable(key, { version });
|
|
734
|
+
const r = await resolveAcquirable(key, { version, channels: (await projectChannels())(key) });
|
|
721
735
|
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
722
736
|
const held = await checkEntitlement({ moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) });
|
|
723
737
|
if (!held.entitled) {
|
|
@@ -801,7 +815,9 @@ module.exports = function buildModulesRouter() {
|
|
|
801
815
|
|
|
802
816
|
// The newest published version of each named module — what `bongos upgrade`
|
|
803
817
|
// compares an instance's installed store modules against to report the updates
|
|
804
|
-
// available (task 1003787). `?keys=a,b,c`, at most STORE_LATEST_MAX_KEYS.
|
|
818
|
+
// available (task 1003787). `?keys=a,b,c`, at most STORE_LATEST_MAX_KEYS. Only
|
|
819
|
+
// versions on a channel this project takes count (task 1003805), so it reports
|
|
820
|
+
// exactly what `bongos module update` would hand over.
|
|
805
821
|
//
|
|
806
822
|
// rank: any-builder — a read of public store metadata (version numbers and the
|
|
807
823
|
// core range each needs, the same facts the how-to page shows). It takes no
|
|
@@ -814,7 +830,7 @@ module.exports = function buildModulesRouter() {
|
|
|
814
830
|
if (!keys.length || keys.length > STORE_LATEST_MAX_KEYS || !keys.every((k) => KEY_RE.test(k))) {
|
|
815
831
|
return res.fail('bad_request', { status: 400, message: `keys is a comma-separated list of 1 to ${STORE_LATEST_MAX_KEYS} module keys (lowercase kebab-case).` });
|
|
816
832
|
}
|
|
817
|
-
const latest = await latestVersions(keys);
|
|
833
|
+
const latest = await latestVersions(keys, { channelsFor: await projectChannels() });
|
|
818
834
|
res.set('Cache-Control', 'no-store');
|
|
819
835
|
res.json({ latest });
|
|
820
836
|
}));
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.170'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -311,6 +311,41 @@ await test('empty, closed with Escape, and focus goes back where it was', async
|
|
|
311
311
|
assert.deepEqual(b.counts, [0]);
|
|
312
312
|
});
|
|
313
313
|
|
|
314
|
+
// task 1004765: a round SUBMITTED but never applied sat unseen for weeks. The
|
|
315
|
+
// server lists it apart (not_applied), the panel shows it below the reviewable
|
|
316
|
+
// page, marked, with how long it has waited, and with no verb; the door counts
|
|
317
|
+
// only what can be reviewed.
|
|
318
|
+
await test('submitted but not applied: listed apart, marked, how long, no verbs, not in the door count', async () => {
|
|
319
|
+
const NOW = Date.parse('2026-10-20T12:00:00Z');
|
|
320
|
+
const submitted = {
|
|
321
|
+
id: 60, status: 'ready', holder_id: null, artist_id: ME, artist_login: 'me', updated_at: '2026-09-29T08:00:00Z',
|
|
322
|
+
source_ref: pages.roundSourceRef('landing:projects', 2),
|
|
323
|
+
description: ['Page tweak round 2.', pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: '2026-09-29T08:00:00Z', lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All of them', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' }] })].join('\n\n'),
|
|
324
|
+
};
|
|
325
|
+
const tasks = [...TASKS.filter((t) => t.id !== 42), submitted];
|
|
326
|
+
const data = pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts: ['artist'], now: NOW });
|
|
327
|
+
assert.equal(data.count, 1, 'the door counts reviewable rounds only');
|
|
328
|
+
assert.deepEqual(data.queue.map((q) => q.task_id), ['41']);
|
|
329
|
+
assert.deepEqual(data.not_applied.map((r) => [r.page_id, r.task_id, r.state_word, r.waited_days, r.line_count]),
|
|
330
|
+
[['landing:projects', '60', 'Submitted, waiting to be applied', 21, 1]]);
|
|
331
|
+
|
|
332
|
+
const b = bootPanel({ answers: { [APPROVALS]: { ok: true, ...data }, [vis(41)]: slotsFor(41) } });
|
|
333
|
+
await b.panel.open(); await settle(); await settle();
|
|
334
|
+
assert.equal(text(b.q('.aq-pending__h')), 'Submitted, not applied yet (1)');
|
|
335
|
+
const row = b.q('.aq-pending__row');
|
|
336
|
+
assert.match(text(row), /Projects/);
|
|
337
|
+
assert.match(text(row), /not applied yet/);
|
|
338
|
+
assert.match(text(row), /Submitted, waiting to be applied · waiting 21 days\. The words are waiting to be applied/);
|
|
339
|
+
assert.equal(row.querySelectorAll('button').length, 0, 'no approve or send-back on a page nobody applied');
|
|
340
|
+
assert.deepEqual(b.counts, [1]);
|
|
341
|
+
|
|
342
|
+
const empty = bootPanel({ answers: { [APPROVALS]: { ok: true, ...pageStatus.composeApprovals({ inventory: INVENTORY, tasks: [submitted], builderId: ME, crafts: ['artist'], now: NOW }) } } });
|
|
343
|
+
await empty.panel.open(); await settle();
|
|
344
|
+
assert.equal(text(empty.q('.aq-title')), 'Nothing waiting', 'nothing to review');
|
|
345
|
+
assert.ok(empty.q('.aq-pending__row'), 'but the submitted page is still shown');
|
|
346
|
+
assert.deepEqual(empty.counts, [0]);
|
|
347
|
+
});
|
|
348
|
+
|
|
314
349
|
// ── the writes: only the host's, and the host's are the studio's allowlist ──
|
|
315
350
|
|
|
316
351
|
await test('the panel writes only through the writes it is handed; the studio hands it its own WRITE_ROUTES', () => {
|
|
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
20
20
|
|
|
21
21
|
const require = createRequire(import.meta.url);
|
|
22
22
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
-
const { MIN_HOLDERS, assessInstall, assessReliability, recordInstallSignals } = require('../scripts/gds/module-assess-installs.js');
|
|
23
|
+
const { MIN_HOLDERS, PRIOR_TESTERS, assessInstall, assessReliability, assessTesterFeedback, recordInstallSignals } = require('../scripts/gds/module-assess-installs.js');
|
|
24
24
|
const reports = require('../src/bongos/module-usage-reports.js');
|
|
25
25
|
const { ERROR_CODE_RE } = require('../src/bongos/module-usage-reports.js');
|
|
26
26
|
const { cmdInstall } = require('../scripts/gds/module.js');
|
|
@@ -146,11 +146,13 @@ test('the switch: off writes an opt-out row, on deletes it', async () => {
|
|
|
146
146
|
|
|
147
147
|
// ---- the re-score ----
|
|
148
148
|
|
|
149
|
-
function scoringDb({ latest, days, current = [] }) {
|
|
149
|
+
function scoringDb({ latest, days, current = [], testerInstalls = [], testerDays = [] }) {
|
|
150
150
|
return fakeDb([
|
|
151
|
-
[/FROM module_install_reports r/, () => ({ rows: latest })],
|
|
152
|
-
[/FROM module_runtime_error_counts r/, () => ({ rows: days })],
|
|
153
|
-
[/
|
|
151
|
+
[/FROM module_install_reports r\s/, () => ({ rows: latest })],
|
|
152
|
+
[/FROM module_runtime_error_counts r\s/, () => ({ rows: days })],
|
|
153
|
+
[/FROM module_install_reports ir/, () => ({ rows: testerInstalls })],
|
|
154
|
+
[/FROM module_runtime_error_counts ec/, () => ({ rows: testerDays })],
|
|
155
|
+
[/part IN \('install', 'reliability', 'tester_feedback'\)/, () => ({ rows: current })],
|
|
154
156
|
[/INSERT INTO module_assessment_signals/, () => ({ rows: [] })],
|
|
155
157
|
[/SELECT DISTINCT ON \(part\) id, part, outcome, score, sample_size/, () => ({ rows: [] })],
|
|
156
158
|
[/FROM module_assessment_scores/, () => ({ rows: [] })],
|
|
@@ -173,7 +175,10 @@ test('re-score: writes a signal row only for a part whose result moved, then re-
|
|
|
173
175
|
const latest = [{ ok: true }, { ok: true }, { ok: false, error_code: 'place_failed' }];
|
|
174
176
|
const db = scoringDb({
|
|
175
177
|
latest, days: [],
|
|
176
|
-
current: [
|
|
178
|
+
current: [
|
|
179
|
+
{ part: 'reliability', outcome: 'no_data', score: null, sample_size: 0 },
|
|
180
|
+
{ part: 'tester_feedback', outcome: 'no_data', score: null, sample_size: 0 },
|
|
181
|
+
],
|
|
177
182
|
});
|
|
178
183
|
const res = await recordInstallSignals(5, { db });
|
|
179
184
|
assert.deepEqual(res.written, ['install'], 'reliability is unchanged, so no new row');
|
|
@@ -184,12 +189,73 @@ test('re-score: writes a signal row only for a part whose result moved, then re-
|
|
|
184
189
|
const again = scoringDb({ latest, days: [], current: [
|
|
185
190
|
{ part: 'install', outcome: 'scored', score: 67, sample_size: 3 },
|
|
186
191
|
{ part: 'reliability', outcome: 'no_data', score: null, sample_size: 0 },
|
|
192
|
+
{ part: 'tester_feedback', outcome: 'no_data', score: null, sample_size: 0 },
|
|
187
193
|
] });
|
|
188
194
|
const res2 = await recordInstallSignals(5, { db: again });
|
|
189
195
|
assert.deepEqual(res2.written, []);
|
|
190
196
|
assert.ok(!again.calls.some((c) => /INSERT/.test(c.sql)), 'nothing moved, nothing written');
|
|
191
197
|
});
|
|
192
198
|
|
|
199
|
+
// ---- tester feedback (task 1003806, ADR 0344 D5) ----
|
|
200
|
+
|
|
201
|
+
const testers = (n, { failed = 0, erroring = 0 } = {}) => {
|
|
202
|
+
const installs = Array.from({ length: n }, (_, i) => ({ holder_kind: 'instance', holder_ref: `p${i}`, ok: i >= failed, error_code: i < failed ? 'place_failed' : null }));
|
|
203
|
+
const days = Array.from({ length: erroring }, (_, i) => ({ holder_kind: 'instance', holder_ref: `p${n - 1 - i}`, error_count: 2 }));
|
|
204
|
+
return { installs, days };
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
test('Tester feedback: no tester reports is no_data, and fewer than MIN_HOLDERS is pending', () => {
|
|
208
|
+
assert.equal(assessTesterFeedback({}).outcome, 'no_data');
|
|
209
|
+
const s = assessTesterFeedback(testers(MIN_HOLDERS - 1));
|
|
210
|
+
assert.equal(s.outcome, 'pending');
|
|
211
|
+
assert.equal(s.score, null);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('Tester feedback: a tester it went well for installed cleanly AND hit no runtime errors', () => {
|
|
215
|
+
const s = assessTesterFeedback(testers(20, { failed: 2, erroring: 3 }));
|
|
216
|
+
assert.equal(s.detail.went_well, 15);
|
|
217
|
+
assert.equal(s.detail.install_failed, 2);
|
|
218
|
+
assert.equal(s.detail.with_errors, 3);
|
|
219
|
+
assert.equal(s.detail.errors, 6);
|
|
220
|
+
assert.equal(s.score, Math.round((100 * (15 + PRIOR_TESTERS / 2)) / (20 + PRIOR_TESTERS)));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('Tester feedback: a tester who only sent error counts still counts once', () => {
|
|
224
|
+
const s = assessTesterFeedback({ installs: [], days: [
|
|
225
|
+
{ holder_kind: 'instance', holder_ref: 'a', error_count: 0 },
|
|
226
|
+
{ holder_kind: 'instance', holder_ref: 'a', error_count: 0 },
|
|
227
|
+
{ holder_kind: 'instance', holder_ref: 'b', error_count: 0 },
|
|
228
|
+
{ holder_kind: 'builder', holder_ref: 'a', error_count: 1 },
|
|
229
|
+
] });
|
|
230
|
+
assert.equal(s.sample_size, 3);
|
|
231
|
+
assert.equal(s.detail.went_well, 2);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('Tester feedback: sample size weighs — a few friendly testers cannot outrank many real ones', () => {
|
|
235
|
+
const few = assessTesterFeedback(testers(3)).score;
|
|
236
|
+
const many = assessTesterFeedback(testers(200, { failed: 10 })).score;
|
|
237
|
+
assert.ok(many > few, `200 testers at 95% (${many}) must beat 3 at 100% (${few})`);
|
|
238
|
+
assert.ok(assessTesterFeedback(testers(200)).score >= 95, 'a large clean sample still scores high');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('re-score: tester reports are read only inside the tester window, author excluded', async () => {
|
|
242
|
+
const t4 = testers(4);
|
|
243
|
+
const db = scoringDb({ latest: [], days: [], testerInstalls: t4.installs, testerDays: t4.days });
|
|
244
|
+
const res = await recordInstallSignals(5, { db });
|
|
245
|
+
assert.ok(res.written.includes('tester_feedback'));
|
|
246
|
+
assert.equal(res.signals.tester_feedback.score, Math.round((100 * (4 + PRIOR_TESTERS / 2)) / (4 + PRIOR_TESTERS)));
|
|
247
|
+
for (const [table, a, when] of [['module_install_reports', 'ir', /ir\.reported_at >= t\.start AND \(t\.stop IS NULL OR ir\.reported_at < t\.stop\)/],
|
|
248
|
+
['module_runtime_error_counts', 'ec', /ec\.day >= t\.start::date AND \(t\.stop IS NULL OR ec\.day <= t\.stop::date\)/]]) {
|
|
249
|
+
const sql = db.calls.find((c) => c.sql.includes(`FROM ${table} ${a}`)).sql;
|
|
250
|
+
assert.match(sql, /c1\.channel = 'tester'/, `${table}: the window opens on the tester channel`);
|
|
251
|
+
assert.match(sql, /c2\.channel <> 'tester' AND c2\.set_at > s\.start/, `${table}: and closes when it leaves it`);
|
|
252
|
+
assert.match(sql, when, `${table}: only reports inside the window`);
|
|
253
|
+
assert.ok(sql.includes(`NOT (${a}.holder_kind = 'builder' AND ${a}.holder_ref = m.author_id::text)`), `${table}: excludes the author`);
|
|
254
|
+
}
|
|
255
|
+
const insert = db.calls.find((c) => /INSERT INTO module_assessment_signals/.test(c.sql) && c.params[1] === 'tester_feedback');
|
|
256
|
+
assert.deepEqual(insert.params.slice(0, 5), [5, 'tester_feedback', 'scored', res.signals.tester_feedback.score, 4]);
|
|
257
|
+
});
|
|
258
|
+
|
|
193
259
|
// ---- the migration: there is nowhere to put anything but counts ----
|
|
194
260
|
|
|
195
261
|
test('core_273: three additive tables whose columns hold counts and codes only', () => {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// tests/module_channel_install.mjs — install and update honour the channel a
|
|
2
|
+
// project is on (task 1003805, ADR 0344 D1/D2, ADR 0338 D2).
|
|
3
|
+
//
|
|
4
|
+
// WHY. The channels exist (core_276) and new versions land on tester (task
|
|
5
|
+
// 1003803), but until acquire reads them, every project got the newest version
|
|
6
|
+
// whatever its channel. These pin the rule: a tester project gets the newest on
|
|
7
|
+
// tester or general, everyone else the newest on general, nobody a withdrawn one,
|
|
8
|
+
// and `bongos upgrade` reports the same version update would hand over. Run
|
|
9
|
+
// against a fake pg client; the routes are pinned in their source.
|
|
10
|
+
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const { servedChannels, resolveAcquirable, latestVersions } = require('../src/bongos/module-store.js');
|
|
21
|
+
const { testsVersionsOf } = require('../src/bongos/module-testing.js');
|
|
22
|
+
|
|
23
|
+
const row = (version, channel) => ({ id: 1, module_key: 'weather', version, channel, core_version: '^1.0.0', tarball_sha256: 'a'.repeat(64), tree_sha256: 'b'.repeat(64), tarball_bytes: 10, artifact_path: `weather/weather-${version}.tgz` });
|
|
24
|
+
|
|
25
|
+
function fakeDb(versions) {
|
|
26
|
+
return {
|
|
27
|
+
async query(sql) {
|
|
28
|
+
if (sql.includes('FROM store_modules ')) return { rows: [{ module_key: 'weather', status: 'listed' }] };
|
|
29
|
+
if (sql.includes('FROM store_module_versions')) return { rows: versions };
|
|
30
|
+
if (sql.includes('FROM store_module_prices')) return { rows: [] };
|
|
31
|
+
throw new Error(`unhandled: ${sql}`);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const SHELF = [row('1.0.0', 'general'), row('1.1.0', 'general'), row('1.2.0', 'tester'), row('2.0.0', 'withdrawn')];
|
|
36
|
+
const GENERAL = servedChannels(false);
|
|
37
|
+
const TESTER = servedChannels(true);
|
|
38
|
+
|
|
39
|
+
test('a tester project takes tester and general versions, everyone else general, nobody withdrawn', () => {
|
|
40
|
+
assert.deepEqual(TESTER, ['tester', 'general']);
|
|
41
|
+
assert.deepEqual(GENERAL, ['general']);
|
|
42
|
+
for (const c of [TESTER, GENERAL]) assert.ok(!c.includes('withdrawn'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('"general only" keeps a tester project on general for that one module', () => {
|
|
46
|
+
const testing = { tester: true, general_only: ['weather'] };
|
|
47
|
+
assert.deepEqual(servedChannels(testsVersionsOf(testing, 'weather')), GENERAL);
|
|
48
|
+
assert.deepEqual(servedChannels(testsVersionsOf(testing, 'clock')), TESTER);
|
|
49
|
+
assert.deepEqual(servedChannels(testsVersionsOf(null, 'clock')), GENERAL, 'an unreadable choice is not a tester');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('with no version asked for, each project gets the newest on its own channels', async () => {
|
|
53
|
+
const general = await resolveAcquirable('weather', { channels: GENERAL, db: fakeDb(SHELF) });
|
|
54
|
+
assert.equal(general.version.version, '1.1.0');
|
|
55
|
+
assert.equal(general.version.channel, 'general');
|
|
56
|
+
const tester = await resolveAcquirable('weather', { channels: TESTER, db: fakeDb(SHELF) });
|
|
57
|
+
assert.equal(tester.version.version, '1.2.0', 'the newer withdrawn 2.0.0 is never handed out');
|
|
58
|
+
assert.equal(tester.version.channel, 'tester');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('an exact version off the project\'s channels is refused, never handed over', async () => {
|
|
62
|
+
const early = await resolveAcquirable('weather', { version: '1.2.0', channels: GENERAL, db: fakeDb(SHELF) });
|
|
63
|
+
assert.equal(early.ok, false);
|
|
64
|
+
assert.equal(early.code, 'version_not_on_channel');
|
|
65
|
+
assert.match(early.message, /testers/);
|
|
66
|
+
const gone = await resolveAcquirable('weather', { version: '2.0.0', channels: TESTER, db: fakeDb(SHELF) });
|
|
67
|
+
assert.equal(gone.code, 'version_not_on_channel');
|
|
68
|
+
assert.match(gone.message, /withdrawn/);
|
|
69
|
+
assert.equal((await resolveAcquirable('weather', { version: '1.0.0', channels: GENERAL, db: fakeDb(SHELF) })).ok, true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a module whose only version is still with testers has nothing for a general project yet', async () => {
|
|
73
|
+
const r = await resolveAcquirable('weather', { channels: GENERAL, db: fakeDb([row('1.0.0', 'tester')]) });
|
|
74
|
+
assert.equal(r.code, 'no_versions_on_channel');
|
|
75
|
+
assert.equal(r.status, 404);
|
|
76
|
+
assert.match(r.message, /testers/);
|
|
77
|
+
const gone = await resolveAcquirable('weather', { channels: TESTER, db: fakeDb([row('1.0.0', 'withdrawn')]) });
|
|
78
|
+
assert.equal(gone.code, 'no_versions_on_channel');
|
|
79
|
+
assert.match(gone.message, /withdrawn/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('no channels means no channel check — recording a version already placed', async () => {
|
|
83
|
+
const r = await resolveAcquirable('weather', { version: '2.0.0', db: fakeDb(SHELF) });
|
|
84
|
+
assert.equal(r.ok, true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('upgrade reports the newest version on the channels this project takes', async () => {
|
|
88
|
+
const db = { async query() { return { rows: SHELF.map((r) => ({ ...r, status: 'listed' })) }; } };
|
|
89
|
+
const general = await latestVersions(['weather'], { db, channelsFor: () => GENERAL });
|
|
90
|
+
assert.equal(general.weather.version, '1.1.0');
|
|
91
|
+
const tester = await latestVersions(['weather'], { db, channelsFor: () => TESTER });
|
|
92
|
+
assert.equal(tester.weather.version, '1.2.0');
|
|
93
|
+
assert.equal(tester.weather.channel, 'tester');
|
|
94
|
+
const none = await latestVersions(['weather'], { db, channelsFor: () => ['nothing'] });
|
|
95
|
+
assert.deepEqual(none, {});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the acquire, download and latest routes ask the project\'s channels; recording does not', () => {
|
|
99
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
|
|
100
|
+
const route = (decl) => {
|
|
101
|
+
const start = src.indexOf(decl);
|
|
102
|
+
assert.ok(start !== -1, decl);
|
|
103
|
+
return src.slice(start, src.indexOf('}));', start));
|
|
104
|
+
};
|
|
105
|
+
const acquire = route("router.post('/store/modules/:key/acquire'");
|
|
106
|
+
assert.match(acquire, /resolveAcquirable\(key, \{ version: wanted \|\| null, channels: \(await projectChannels\(\)\)\(key\) \}\)/);
|
|
107
|
+
assert.match(acquire, /channel: v\.channel,/);
|
|
108
|
+
assert.doesNotMatch(acquire, /channel: 'general'/);
|
|
109
|
+
assert.match(route("router.get('/store/modules/:key/versions/:version/tarball'"), /channels: \(await projectChannels\(\)\)\(key\)/);
|
|
110
|
+
assert.match(route("router.get('/store/modules/latest'"), /latestVersions\(keys, \{ channelsFor: await projectChannels\(\) \}\)/);
|
|
111
|
+
assert.doesNotMatch(route("router.post('/store/modules/:key/acquired'"), /channels/);
|
|
112
|
+
});
|
|
@@ -24,7 +24,7 @@ const pool = {
|
|
|
24
24
|
return {
|
|
25
25
|
rows: state.versions
|
|
26
26
|
.filter((v) => params[0].includes(v.module_key) && state.modules[v.module_key])
|
|
27
|
-
.map((v) => ({ ...v, status: state.modules[v.module_key].status })),
|
|
27
|
+
.map((v) => ({ channel: 'general', ...v, status: state.modules[v.module_key].status })),
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
return { rows: [] };
|
|
@@ -236,7 +236,7 @@ test('the route is open to any signed-in builder: requireBuilder and no permissi
|
|
|
236
236
|
test('the route answers the newest version per key, in semver order', async () => {
|
|
237
237
|
const r = await ask('weather');
|
|
238
238
|
assert.equal(r.statusCode, 200, JSON.stringify(r.body));
|
|
239
|
-
assert.deepEqual(r.body.latest.weather, { version: '1.10.0', core_version: '^1.2.0', status: 'listed' });
|
|
239
|
+
assert.deepEqual(r.body.latest.weather, { version: '1.10.0', core_version: '^1.2.0', status: 'listed', channel: 'general' });
|
|
240
240
|
assert.equal(r.headers['Cache-Control'], 'no-store');
|
|
241
241
|
});
|
|
242
242
|
|