@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.
- package/.bongos-core.json +51 -31
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0342-module-categories-are-eight-parents-with-approved-sub-categories.md +4 -2
- package/docs/api/openapi.json +210 -4
- package/docs/api-reference.md +6 -2
- package/docs/architecture.md +11 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +3 -1
- package/migrations/core_280_store_subcategories.sql +58 -0
- package/modules/government/catalog.js +3 -0
- package/modules/government/migrations/government_034_module_subcategory_review.sql +35 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/module-listing.js +99 -27
- package/src/bongos/module-store.js +8 -3
- package/src/bongos/module-subcategories.js +316 -0
- package/src/bongos/route-rank-check.js +5 -0
- package/src/bongos/routes/modules.js +55 -1
- package/src/module-api.js +1 -1
- package/src/module-loader/manifest-schema.js +4 -3
- package/tests/deploy_divergence_line.mjs +4 -1
- package/tests/module_store_instance_root.mjs +2 -1
- package/tests/module_store_listing.mjs +132 -3
- package/tests/module_store_subcategories.mjs +364 -0
|
@@ -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.
|
|
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.
|
|
59
|
-
// }, //
|
|
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 —
|
|
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
|
-
|
|
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
|
|
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() {
|