@bongos/core 1.23.1 → 1.23.3

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.
@@ -0,0 +1,316 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/module-subcategories.js — the store's sub-categories and the queue that
4
+ // approves them (task 1004355, ADR 0342 D3; tables: core_280).
5
+ //
6
+ // A module sits under one of eight fixed parents and, once approved, one narrow
7
+ // sub-category: the comparable set the cap of ten and the ranking count within (D2).
8
+ // The author names a sub-category in module.json (`category: { parent, sub }`); a
9
+ // key the store has never seen under that parent is PROPOSED at publish and is not
10
+ // live until a Metic+ person approves it (optionally renaming it) or merges it into
11
+ // an existing one (`module.subcategory.review`). Until then the module is listed
12
+ // under its parent only and counts toward no cap.
13
+ //
14
+ // Where a module sits lives in store_module_categories, apart from the published
15
+ // manifest (which never changes), so a merge can move modules without touching a
16
+ // version. A merged row keeps a pointer to where it went, so a later publish naming
17
+ // the old key lands on the right sub-category instead of proposing it again.
18
+
19
+ const { CATEGORY_PARENTS } = require('../module-loader/manifest-schema');
20
+
21
+ // How many pending proposals one queue read returns, oldest first. Each is compared
22
+ // against every live sub-category for near-duplicates, so the read is bounded by
23
+ // this times the live count; the rest wait for the next page of decisions.
24
+ const QUEUE_LIMIT = 100;
25
+
26
+ // The label a proposal starts with: its key in words ("stripe-payments" → "Stripe
27
+ // payments"). The approver can rename it; the author never types a free-text label.
28
+ function labelFromKey(subKey) {
29
+ const words = String(subKey || '').split('-').filter(Boolean).join(' ');
30
+ return words ? words[0].toUpperCase() + words.slice(1) : '';
31
+ }
32
+
33
+ // A label as a sub key: lowercase kebab-case, the shape manifest-schema checks.
34
+ function subKeyFromLabel(label) {
35
+ return String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^[^a-z]+/, '');
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Near-duplicates — the point of the queue (ADR 0342 D3)
40
+ // ---------------------------------------------------------------------------
41
+ //
42
+ // "Stripe", "Stripe payments" and "Payments (Stripe)" must sit beside each other in
43
+ // front of the approver. Two names are near when their significant words overlap by
44
+ // at least half (Jaccard over word sets, a plural "s" dropped) or, as whole strings,
45
+ // are within two edits of each other (a typo: "stirpe"). Filler words never count.
46
+ const FILLER = new Set(['and', 'the', 'of', 'for', 'a', 'an', 'to', 'with', 'in', 'on']);
47
+
48
+ function nameWords(name) {
49
+ return new Set(String(name || '').toLowerCase().split(/[^a-z0-9]+/)
50
+ .filter((w) => w && !FILLER.has(w))
51
+ .map((w) => (w.length > 3 && w.endsWith('s') ? w.slice(0, -1) : w)));
52
+ }
53
+
54
+ function editDistance(a, b) {
55
+ if (Math.abs(a.length - b.length) > 2) return 3;
56
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
57
+ for (let i = 1; i <= a.length; i++) {
58
+ const cur = [i];
59
+ for (let j = 1; j <= b.length; j++) {
60
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
61
+ }
62
+ prev = cur;
63
+ }
64
+ return prev[b.length];
65
+ }
66
+
67
+ function isNear(a, b) {
68
+ return wordsNear(nameWords(a), nameWords(b));
69
+ }
70
+
71
+ // Two word sets are near when they share at least half their words (Jaccard), or,
72
+ // joined, are within two edits of each other.
73
+ function wordsNear(wa, wb) {
74
+ if (!wa.size || !wb.size) return false;
75
+ let shared = 0;
76
+ for (const w of wa) if (wb.has(w)) shared++;
77
+ if (shared / (wa.size + wb.size - shared) >= 0.5) return true;
78
+ const ja = [...wa].sort().join('');
79
+ const jb = [...wb].sort().join('');
80
+ return ja.length >= 4 && jb.length >= 4 && editDistance(ja, jb) <= 2;
81
+ }
82
+
83
+ // For each of `pending`, every other live (proposed or approved) sub-category whose
84
+ // key or label is near its own, across all parents (an author may have picked the
85
+ // wrong parent). Word sets are built once per row, not once per comparison. Pure.
86
+ // Rows carry id, parent, sub_key, label, status.
87
+ function nearDuplicatesFor(pending, rows) {
88
+ const live = (rows || []).filter((o) => o.status !== 'merged')
89
+ .map((o) => ({ o, names: [nameWords(o.sub_key), nameWords(o.label)] }));
90
+ const byId = new Map(live.map((x) => [String(x.o.id), x]));
91
+ return new Map(pending.map((row) => {
92
+ const mine = byId.get(String(row.id))?.names || [nameWords(row.sub_key), nameWords(row.label)];
93
+ const near = live
94
+ .filter((x) => String(x.o.id) !== String(row.id) && mine.some((m) => x.names.some((n) => wordsNear(m, n))))
95
+ .map(({ o }) => ({ id: o.id, parent: o.parent, sub_key: o.sub_key, label: o.label, status: o.status }));
96
+ return [String(row.id), near];
97
+ }));
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Publish: record where the module sits, proposing a new sub-category
102
+ // ---------------------------------------------------------------------------
103
+
104
+ // Runs on the publish transaction's client (module-store.js publishVersion), so a
105
+ // version never lands without its category or the other way round. `category` is
106
+ // module.json's block, already shape-checked; absent → nothing changes (a version
107
+ // that names no category leaves the module where it was). Returns what the author
108
+ // is told, or null.
109
+ async function recordPublishCategory(client, { key, category, builderId }) {
110
+ if (!category || !category.parent) return null;
111
+ const { parent, sub } = category;
112
+ if (!sub) {
113
+ await upsertModuleCategory(client, key, parent, null);
114
+ return { parent, sub: null, status: null, live: false };
115
+ }
116
+ await client.query(
117
+ `INSERT INTO store_subcategories (parent, sub_key, label, proposed_by)
118
+ VALUES ($1, $2, $3, $4)
119
+ ON CONFLICT (parent, sub_key) DO NOTHING`,
120
+ [parent, sub, labelFromKey(sub), builderId]);
121
+ let row = await subcategoryByKey(client, parent, sub);
122
+ // One step is enough: a merge re-points every earlier merge at its new target
123
+ // (mergeSubcategory), so merged_into always names a live row.
124
+ if (row && row.status === 'merged') row = await subcategoryById(client, row.merged_into);
125
+ if (!row || row.status === 'merged') {
126
+ await upsertModuleCategory(client, key, parent, null);
127
+ return { parent, sub: null, status: null, live: false };
128
+ }
129
+ await upsertModuleCategory(client, key, row.parent, row.id);
130
+ return {
131
+ parent: row.parent, sub: row.sub_key, label: row.label, status: row.status,
132
+ live: row.status === 'approved', named: sub,
133
+ };
134
+ }
135
+
136
+ async function upsertModuleCategory(client, key, parent, subcategoryId) {
137
+ await client.query(
138
+ `INSERT INTO store_module_categories (module_key, parent, subcategory_id)
139
+ VALUES ($1, $2, $3)
140
+ ON CONFLICT (module_key) DO UPDATE
141
+ SET parent = EXCLUDED.parent, subcategory_id = EXCLUDED.subcategory_id, updated_at = now()`,
142
+ [key, parent, subcategoryId]);
143
+ }
144
+
145
+ const SUB_COLUMNS = 'id, parent, sub_key, label, status, proposed_by, decided_by, decided_at, merged_into, created_at';
146
+
147
+ async function subcategoryByKey(exec, parent, subKey) {
148
+ const { rows: [row] } = await exec.query(
149
+ `SELECT ${SUB_COLUMNS} FROM store_subcategories WHERE parent = $1 AND sub_key = $2`, [parent, subKey]);
150
+ return row || null;
151
+ }
152
+
153
+ async function subcategoryById(exec, id, { lock = false } = {}) {
154
+ const { rows: [row] } = await exec.query(
155
+ `SELECT ${SUB_COLUMNS} FROM store_subcategories WHERE id = $1${lock ? ' FOR UPDATE' : ''}`, [id]);
156
+ return row || null;
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Reads
161
+ // ---------------------------------------------------------------------------
162
+
163
+ // The approved sub-categories, grouped under the eight parents: what an author picks
164
+ // from. Any builder may read it.
165
+ async function listSubcategories({ db } = {}) {
166
+ const pool = db || require('./pool').pool;
167
+ const { rows } = await pool.query(
168
+ `SELECT id, parent, sub_key, label FROM store_subcategories
169
+ WHERE status = 'approved' ORDER BY parent, label`);
170
+ return {
171
+ parents: Object.entries(CATEGORY_PARENTS).map(([parent, title]) => ({
172
+ parent, title,
173
+ subcategories: rows.filter((r) => r.parent === parent).map((r) => ({ id: r.id, sub_key: r.sub_key, label: r.label })),
174
+ })),
175
+ };
176
+ }
177
+
178
+ // The Metic+ queue: every pending proposal, oldest first, with the modules that
179
+ // named it and the near-duplicate names beside it.
180
+ async function reviewQueue({ db } = {}) {
181
+ const pool = db || require('./pool').pool;
182
+ const { rows } = await pool.query(
183
+ `SELECT ${SUB_COLUMNS} FROM store_subcategories WHERE status <> 'merged' ORDER BY created_at, id`);
184
+ const proposed = rows.filter((r) => r.status === 'proposed');
185
+ const pending = proposed.slice(0, QUEUE_LIMIT);
186
+ const modules = new Map();
187
+ if (pending.length) {
188
+ const { rows: mods } = await pool.query(
189
+ `SELECT module_key, subcategory_id FROM store_module_categories
190
+ WHERE subcategory_id = ANY($1) ORDER BY module_key`, [pending.map((r) => r.id)]);
191
+ for (const m of mods) {
192
+ const k = String(m.subcategory_id);
193
+ if (!modules.has(k)) modules.set(k, []);
194
+ modules.get(k).push(m.module_key);
195
+ }
196
+ }
197
+ const near = nearDuplicatesFor(pending, rows);
198
+ return {
199
+ total: proposed.length,
200
+ more: proposed.length > pending.length,
201
+ proposals: pending.map((r) => ({
202
+ id: r.id, parent: r.parent, parent_title: CATEGORY_PARENTS[r.parent] || r.parent,
203
+ sub_key: r.sub_key, label: r.label, proposed_by: r.proposed_by, proposed_at: r.created_at,
204
+ modules: modules.get(String(r.id)) || [],
205
+ near: near.get(String(r.id)) || [],
206
+ })),
207
+ };
208
+ }
209
+
210
+ // Where each of `keys` sits now: Map key → { parent, parent_title, sub, label,
211
+ // status, live }. `live` is true only under an approved sub-category, the only kind
212
+ // the cap and ranking count within (ADR 0342 D2, D3). A key with no row is absent.
213
+ async function readModuleCategories(keys, { db } = {}) {
214
+ const out = new Map();
215
+ if (!keys || !keys.length) return out;
216
+ const pool = db || require('./pool').pool;
217
+ const { rows } = await pool.query(
218
+ `SELECT c.module_key, c.parent, s.sub_key, s.label, s.status
219
+ FROM store_module_categories c
220
+ LEFT JOIN store_subcategories s ON s.id = c.subcategory_id
221
+ WHERE c.module_key = ANY($1)`, [keys]);
222
+ for (const r of rows) {
223
+ out.set(r.module_key, {
224
+ parent: r.parent, parent_title: CATEGORY_PARENTS[r.parent] || r.parent,
225
+ sub: r.sub_key || null, label: r.label || null, status: r.status || null,
226
+ live: r.status === 'approved',
227
+ });
228
+ }
229
+ return out;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Decisions — approve (optionally renamed) and merge
234
+ // ---------------------------------------------------------------------------
235
+
236
+ async function inTx(db, fn) {
237
+ const pool = db || require('./pool').pool;
238
+ const client = await pool.connect();
239
+ try {
240
+ await client.query('BEGIN');
241
+ const out = await fn(client);
242
+ await client.query(out && out.ok === false ? 'ROLLBACK' : 'COMMIT');
243
+ return out;
244
+ } catch (e) {
245
+ try { await client.query('ROLLBACK'); } catch { /* the connection may be gone */ }
246
+ throw e;
247
+ } finally {
248
+ client.release();
249
+ }
250
+ }
251
+
252
+ const refuse = (status, code, message) => ({ ok: false, status, code, message });
253
+
254
+ // Approve a proposal, optionally under a new label and/or key. A rename onto a key
255
+ // that already exists under the same parent is refused: that is a merge.
256
+ async function approveSubcategory(id, { label = null, subKey = null, builderId }, { db } = {}) {
257
+ return inTx(db, async (client) => {
258
+ const row = await subcategoryById(client, id, { lock: true });
259
+ if (!row) return refuse(404, 'subcategory_not_found', `No sub-category ${id}.`);
260
+ if (row.status !== 'proposed') return refuse(409, 'subcategory_decided', `"${row.label}" is already ${row.status}.`);
261
+ const newLabel = label != null ? String(label).trim() : row.label;
262
+ if (!newLabel || newLabel.length > 80) return refuse(400, 'bad_label', 'A label is 1 to 80 characters.');
263
+ const newKey = subKey != null ? String(subKey) : (label != null ? subKeyFromLabel(newLabel) : row.sub_key);
264
+ if (!/^[a-z][a-z0-9-]*$/.test(newKey)) return refuse(400, 'bad_sub_key', 'A sub-category key is lowercase kebab-case.');
265
+ if (newKey !== row.sub_key) {
266
+ const clash = await subcategoryByKey(client, row.parent, newKey);
267
+ if (clash) return refuse(409, 'subcategory_exists', `"${newKey}" already exists under ${row.parent} — merge into it instead (POST /store/subcategories/${row.id}/merge { into: ${clash.id} }).`);
268
+ }
269
+ const renamed = newKey !== row.sub_key || newLabel !== row.label;
270
+ const { rows: [done] } = await client.query(
271
+ `UPDATE store_subcategories
272
+ SET status = 'approved', label = $2, sub_key = $3, decided_by = $4, decided_at = now()
273
+ WHERE id = $1 RETURNING ${SUB_COLUMNS}`,
274
+ [row.id, newLabel, newKey, builderId]);
275
+ return { ok: true, subcategory: done, renamed };
276
+ });
277
+ }
278
+
279
+ // Merge a sub-category (proposed, or an approved duplicate) into an approved one.
280
+ // Every module that sat under it moves to the target, parent included, and any
281
+ // earlier merge into it is re-pointed so a chain stays one step.
282
+ async function mergeSubcategory(id, { into, builderId }, { db } = {}) {
283
+ return inTx(db, async (client) => {
284
+ if (String(id) === String(into)) return refuse(400, 'merge_into_self', 'A sub-category cannot be merged into itself.');
285
+ // Both rows locked in one statement, in id order, so two opposite merges at once
286
+ // wait on each other instead of deadlocking.
287
+ const { rows: locked } = await client.query(
288
+ `SELECT ${SUB_COLUMNS} FROM store_subcategories WHERE id = ANY($1) ORDER BY id FOR UPDATE`,
289
+ [[String(id), String(into)]]);
290
+ const row = locked.find((x) => String(x.id) === String(BigInt(id)));
291
+ const target = locked.find((x) => String(x.id) === String(BigInt(into)));
292
+ if (!row) return refuse(404, 'subcategory_not_found', `No sub-category ${id}.`);
293
+ if (row.status === 'merged') return refuse(409, 'subcategory_decided', `"${row.label}" is already merged.`);
294
+ if (!target) return refuse(404, 'subcategory_not_found', `No sub-category ${into} to merge into.`);
295
+ // Compared by the rows found, not the raw ids: "05" and "5" are the same row.
296
+ if (String(target.id) === String(row.id)) return refuse(400, 'merge_into_self', 'A sub-category cannot be merged into itself.');
297
+ if (target.status !== 'approved') return refuse(409, 'merge_target_not_approved', `Merge into an approved sub-category; "${target.label}" is ${target.status}.`);
298
+ await client.query(
299
+ `UPDATE store_subcategories
300
+ SET status = 'merged', merged_into = $2, decided_by = $3, decided_at = now()
301
+ WHERE id = $1`,
302
+ [row.id, target.id, builderId]);
303
+ await client.query('UPDATE store_subcategories SET merged_into = $2 WHERE merged_into = $1', [row.id, target.id]);
304
+ const { rows: moved } = await client.query(
305
+ `UPDATE store_module_categories SET subcategory_id = $2, parent = $3, updated_at = now()
306
+ WHERE subcategory_id = $1 RETURNING module_key`,
307
+ [row.id, target.id, target.parent]);
308
+ return { ok: true, merged: { id: row.id, sub_key: row.sub_key, label: row.label }, into: target, moved: moved.map((m) => m.module_key).sort() };
309
+ });
310
+ }
311
+
312
+ module.exports = {
313
+ isNear,
314
+ recordPublishCategory, listSubcategories, reviewQueue, readModuleCategories,
315
+ approveSubcategory, mergeSubcategory,
316
+ };
@@ -242,6 +242,11 @@ const EXPECTED_RANKS = {
242
242
  // Re-running a store version's assessment (task 1003796, ADR 0359) spends a Docs
243
243
  // grader call and re-opens the Security gate, so it is pinned to its own atom.
244
244
  'POST /store/modules/:key/versions/:version/reassess': 'perm:module.assessment.rerun',
245
+ // Approving, renaming or merging a store sub-category (task 1004355, ADR 0342 D3)
246
+ // decides which modules compete for the same ten slots, so it is pinned to its own atom.
247
+ 'GET /store/subcategories/queue': 'perm:module.subcategory.review',
248
+ 'POST /store/subcategories/:id/approve': 'perm:module.subcategory.review',
249
+ 'POST /store/subcategories/:id/merge': 'perm:module.subcategory.review',
245
250
  // Delisting a store module (task 1003797, ADR 0338 D2) stops every new install of
246
251
  // it for every instance, so it is pinned to its own atom.
247
252
  'POST /store/modules/:key/delist': 'perm:module.delist',
@@ -111,6 +111,7 @@ 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, readListing, SORT_PARTS } = require('../module-listing');
114
+ const { listSubcategories, reviewQueue, approveSubcategory, mergeSubcategory } = require('../module-subcategories');
114
115
  const { readTesting, setTester, setGeneralOnly, testsVersionsOf } = require('../module-testing');
115
116
  const { readStanding, releaseHeld } = require('../module-promotion');
116
117
 
@@ -522,6 +523,7 @@ module.exports = function buildModulesRouter() {
522
523
  bytes: v.bytes,
523
524
  artifactPath: relativeArtifactPath(staged.final),
524
525
  builderId: req.builder.id,
526
+ category: v.moduleJson.category,
525
527
  }, { beforeCommit: () => commitArtifact(staged) });
526
528
  } finally {
527
529
  await removeArtifact(staged.tmp);
@@ -529,7 +531,7 @@ module.exports = function buildModulesRouter() {
529
531
  if (!result.ok) return res.fail(result.code, { status: result.status, message: result.message });
530
532
 
531
533
  console.log(`[gds] module "${key}" ${v.version} published to the store by builder ${req.builder.id}`);
532
- res.status(201).json({ ok: true, created_module: result.created, version: result.version, channel: result.channel });
534
+ res.status(201).json({ ok: true, created_module: result.created, version: result.version, channel: result.channel, category: result.category || null });
533
535
  // A new version is a new thing to judge (task 1003794, ADR 0343): queue its
534
536
  // assessment after answering, since the Security gate asks npm about its
535
537
  // dependencies and can take a minute. The queue runs one at a time and is
@@ -891,6 +893,58 @@ module.exports = function buildModulesRouter() {
891
893
  res.json(listing);
892
894
  }));
893
895
 
896
+ // GET /api/bongos/store/subcategories — the approved sub-categories under each of
897
+ // the eight parents (task 1004355, ADR 0342 D3): what an author names in
898
+ // module.json's `category.sub`. A key not on this list is proposed at publish.
899
+ //
900
+ // rank: any-builder — public store metadata; nothing changed.
901
+ router.get('/store/subcategories', auth.requireBuilder,
902
+ asyncHandler('GET /store/subcategories', async (_req, res) => {
903
+ res.set('Cache-Control', 'private, max-age=60'); // changes only when a reviewer decides
904
+ res.json(await listSubcategories());
905
+ }));
906
+
907
+ // GET /api/bongos/store/subcategories/queue — the proposals waiting on a Metic+
908
+ // decision, oldest first, each with the modules that named it and the
909
+ // near-duplicate names beside it ("Stripe" next to "Stripe payments"), since
910
+ // stopping near-duplicates is the point of the queue (ADR 0342 D3).
911
+ router.get('/store/subcategories/queue', auth.requireBuilder, auth.requirePermission('module.subcategory.review'),
912
+ asyncHandler('GET /store/subcategories/queue', async (_req, res) => {
913
+ res.set('Cache-Control', 'no-store');
914
+ res.json(await reviewQueue());
915
+ }));
916
+
917
+ // POST /api/bongos/store/subcategories/:id/approve { label?, sub_key? } — make a
918
+ // proposal live, optionally renamed first. A rename onto a key that already
919
+ // exists under the parent is refused with the merge to run instead.
920
+ router.post('/store/subcategories/:id/approve', auth.requireBuilder, auth.requirePermission('module.subcategory.review'),
921
+ asyncHandler('POST /store/subcategories/:id/approve', async (req, res) => {
922
+ if (validateOrRespond(req, res, { label: { type: 'string', maxLength: 80 }, sub_key: { type: 'string', maxLength: 80 } })) return;
923
+ const id = String(req.params.id);
924
+ if (!/^\d{1,18}$/.test(id)) return res.fail('bad_request', { status: 400, message: 'A sub-category id is a whole number.' });
925
+ const r = await approveSubcategory(id, { label: req.body?.label ?? null, subKey: req.body?.sub_key ?? null, builderId: req.builder.id });
926
+ if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
927
+ log.info({ id, builderId: req.builder.id }, `sub-category ${r.subcategory.parent}/${r.subcategory.sub_key} approved`);
928
+ res.json(r);
929
+ }));
930
+
931
+ // POST /api/bongos/store/subcategories/:id/merge { into } — fold a proposal (or an
932
+ // approved duplicate) into an approved sub-category. Every module under it moves
933
+ // to the target.
934
+ router.post('/store/subcategories/:id/merge', auth.requireBuilder, auth.requirePermission('module.subcategory.review'),
935
+ asyncHandler('POST /store/subcategories/:id/merge', async (req, res) => {
936
+ if (validateOrRespond(req, res, { into: {} })) return; // bigint as number or string; validated below
937
+ const id = String(req.params.id);
938
+ const into = req.body?.into == null ? '' : String(req.body.into);
939
+ if (!/^\d{1,18}$/.test(id) || !/^\d{1,18}$/.test(into)) {
940
+ return res.fail('bad_request', { status: 400, message: 'Name the sub-category to merge into by id, in into.' });
941
+ }
942
+ const r = await mergeSubcategory(id, { into, builderId: req.builder.id });
943
+ if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
944
+ log.info({ id, into, moved: r.moved.length, builderId: req.builder.id }, `sub-category ${r.merged.sub_key} merged into ${r.into.sub_key}`);
945
+ res.json(r);
946
+ }));
947
+
894
948
  router.post('/store/modules/:key/acquired', auth.requireBuilder,
895
949
  asyncHandler('POST /store/modules/:key/acquired', async (req, res) => {
896
950
  const { key } = req.params;
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.23.1'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.23.3'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -55,8 +55,9 @@
55
55
  // // never a silent pass.
56
56
  // "category": { // OPTIONAL. Where the module is browsed and ranked (ADR 0342).
57
57
  // "parent": "money", // REQUIRED in-block: one of the eight CATEGORY_PARENTS keys.
58
- // "sub": "stripe-payments" // OPTIONAL. An approved sub-category key — the comparable set
59
- // }, // the store's cap of ten counts within; absent while pending.
58
+ // "sub": "stripe-payments" // OPTIONAL. A sub-category key — the comparable set the store's
59
+ // }, // cap of ten counts within. One the store has not approved is
60
+ // // PROPOSED at publish and is not live until Metic+ approves it.
60
61
  // "howto": { // OPTIONAL. Extras beside the module's HOWTO.md (ADR 0347 D3).
61
62
  // "artifactUrl": "https://claude.ai/…" // A Claude page with the same how-to, shown as an extra,
62
63
  // }, // never graded and never required — the file is the how-to.
@@ -354,7 +355,7 @@ function validateManifest(obj) {
354
355
  if (!isStr(c.parent)) E(`category.parent: required — one of ${parents.join(', ')}`);
355
356
  else if (!parents.includes(c.parent)) E(`category.parent: "${c.parent}" is not one of ${parents.join(', ')} (ADR 0342 D1)`);
356
357
  if ('sub' in c && (!isStr(c.sub) || !KEY_RE.test(c.sub))) {
357
- E('category.sub: must be a sub-category key (lowercase kebab-case) when present — leave it out while a new one is pending approval');
358
+ E('category.sub: must be a sub-category key (lowercase kebab-case) when present — a new one is proposed at publish');
358
359
  }
359
360
  }
360
361
  }
@@ -357,7 +357,10 @@ test('liveIncludes reads inclusion by release when the live commit is not in thi
357
357
  assert.equal(liveIncludes({ coreVersion: olderV }, newer, 'HEAD'), false, 'an older release does not');
358
358
  assert.equal(liveIncludes({ coreVersion: olderV }, older, 'HEAD'), true);
359
359
  // A commit between the two bumps is carried by the NEWER one only.
360
- const between = gitOut(['rev-list', '--max-count=1', '--no-merges', `${older}..${newer}~1`]);
360
+ // --ancestry-path: a commit on a side branch merged in between the two bumps is
361
+ // in the range but is not descended from the older bump, so the older release's
362
+ // position says nothing about it (a release commit merged in from main did this).
363
+ const between = gitOut(['rev-list', '--max-count=1', '--no-merges', '--ancestry-path', `${older}..${newer}~1`]);
361
364
  if (between) {
362
365
  assert.equal(liveIncludes({ coreVersion: olderV }, between, 'HEAD'), false, 'not yet: the older release predates it');
363
366
  assert.equal(liveIncludes({ coreVersion: newerV }, between, 'HEAD'), true, 'the next bump carries it');
@@ -6,7 +6,7 @@
6
6
  // same bug in the task-visuals store.
7
7
  //
8
8
  // This builds a real consumer tree in a temp dir: a fake installed core package
9
- // carrying the REAL src/bongos/module-store.js, with its three core requires
9
+ // carrying the REAL src/bongos/module-store.js, with its four core requires
10
10
  // re-exporting the real files, then proves, through that installed copy:
11
11
  // 1. the store is the instance's var/module-store, not inside the package;
12
12
  // 2. a published tarball survives the package being deleted and reinstalled,
@@ -52,6 +52,7 @@ function installCore() {
52
52
  fs.writeFileSync(path.join(CORE, 'src', 'module-loader', 'semver.js'), reexport(REAL('module-loader', 'semver.js')));
53
53
  fs.writeFileSync(path.join(CORE, 'src', 'module-loader', 'manifest-schema.js'), reexport(REAL('module-loader', 'manifest-schema.js')));
54
54
  fs.writeFileSync(path.join(CORE, 'src', 'instance-config.js'), reexport(REAL('instance-config.js')));
55
+ fs.writeFileSync(path.join(CORE, 'src', 'bongos', 'module-subcategories.js'), reexport(REAL('bongos', 'module-subcategories.js')));
55
56
  }
56
57
  // A fresh require of the INSTALLED copy (a restarted server).
57
58
  function loadInstalled() {