@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.
@@ -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) return refuse(404, 'no_versions', `"${key}" has no published version yet.`);
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
- // Returns { [key]: { version, core_version, status } }.
325
- async function latestVersions(keys, { db } = {}) {
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: every version is on the general channel until the channel table
671
- // (task 1003815) and "install honours the channel" (task 1003805) land; those
672
- // add the channel check to step 1 and step 2, beside the entitlement check.
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: 'general',
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.168'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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
- [/part IN \('install', 'reliability'\)/, () => ({ rows: current })],
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: [{ part: 'reliability', outcome: 'no_data', score: null, sample_size: 0 }],
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