@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
package/docs/architecture.md
CHANGED
|
@@ -262,6 +262,17 @@ passkey_challenges(challenge PK, purpose, builder_id NULL, created_at, expires_a
|
|
|
262
262
|
-- single-use (DELETEd on consume), TTL swept; builder_id NULL for sign-in
|
|
263
263
|
```
|
|
264
264
|
|
|
265
|
+
Store sub-categories (migration `core_280`, task 1004355, [ADR 0342](adr/0342-module-categories-are-eight-parents-with-approved-sub-categories.md) D3 — an author proposes one at publish, a Metic+ person approves, renames or merges it; `src/bongos/module-subcategories.js`):
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
store_subcategories(id, parent one of 8, sub_key, label, status proposed|approved|merged,
|
|
269
|
+
proposed_by, decided_by, decided_at, merged_into FK self, created_at)
|
|
270
|
+
-- UNIQUE (parent, sub_key); merged ⇔ merged_into set; proposed ⇔ undecided.
|
|
271
|
+
store_module_categories(module_key PK FK store_modules, parent, subcategory_id FK NULL, updated_at)
|
|
272
|
+
-- where a module sits now; a merge moves rows here. Only an approved
|
|
273
|
+
-- sub-category is live for the cap and ranking.
|
|
274
|
+
```
|
|
275
|
+
|
|
265
276
|
Module release channels (migration `core_276`, task 1003815, [ADR 0344](adr/0344-a-tester-is-a-project-that-opts-in-once.md) — which channel a published version is on; the data only, the tester-first publish, promotion gate and channel-aware install are their own tasks):
|
|
266
277
|
|
|
267
278
|
```
|
|
@@ -3219,5 +3219,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
3219
3219
|
so MINOR. The browser twin is window.OTB.sleep.
|
|
3220
3220
|
1.23.1 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3221
3221
|
landed since 1.23.0 with no explicit bump. run 40270556091. (task 1002620)
|
|
3222
|
+
1.23.2 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3223
|
+
landed since 1.23.1 with no explicit bump. run 40399986777. (task 1002620)
|
|
3224
|
+
1.23.3 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3225
|
+
landed since 1.23.2 with no explicit bump. run 40402796050. (task 1002620)
|
|
3222
3226
|
---------------------------------------------------------------------------
|
|
3223
3227
|
```
|
package/docs/modules-contract.md
CHANGED
|
@@ -398,7 +398,9 @@ Run `bongos upgrade` to confirm coreVersion compatibility, then `node scripts/bo
|
|
|
398
398
|
- **What a module is strong and weak at** (task 1003799, ADR 0343 D4). Each catalog entry also carries `parts` (the current result of Tests, Clean installs, Reliability, Tester feedback and How-to) and a reading of them: `strengths` (a part scored **80 or more**), `weaknesses` (a part scored **below the floor**) and `unmeasured` (a part with no score yet, which is never counted as weak). Each strength and weakness has a one-sentence `detail` saying what the number rests on. Security is not among them: every listed version has passed it. The reading is `readParts` in `src/bongos/module-listing.js`; the hall's Modules tab shows it in its "In the store" group, so two modules with the same overall score can be told apart.
|
|
399
399
|
- **Sorting by a part** (task 1003800). `GET /store/modules?sort=<part>` orders the listed modules by one part a buyer cares about: `tests`, `install`, `reliability`, `tester_feedback` or `docs` (the response lists them as `sort_parts`, and says which one it applied as `sort`). Modules with a score for that part come first, highest first; a tie goes to the higher overall score, then the module key. A module with no score for that part goes last, so missing data never wins or loses a place on its own. New modules are not sorted: they are never ranked. Any other `sort` value is refused with a 400. Without `sort`, the listed modules are in the ranking below; New stays in key order. The rule is `sortByPart` in `src/bongos/module-listing.js`.
|
|
400
400
|
- **The ranking, and a maximum price** (task 1003808, owner decision 2026-10-23). Without `sort`, listed modules are ranked **quality first**: highest overall score first; between equal scores, free modules first, then the lowest cash price, then the lowest credit price, then the module key. Price never buys a better place, it only orders modules the score could not tell apart (and it is never an input to the score, ADR 0343 D6). The response states the rule as `ranking`, and each entry carries its current `price` (`cents`, `credits`, `free`). A buyer may add `?max_cents=` and/or `?max_credits=` (whole numbers, 0 or more): modules that cost more are left out of both lists, free modules always stay, and a priced module stays when it is offered at or under a cap the buyer set. The applied caps come back as `max_price`. The rules are `rankListed` and `withinMaxPrice` in `src/bongos/module-listing.js`.
|
|
401
|
-
- **
|
|
401
|
+
- **The cap: at most ten per sub-category** (task 1003809, [ADR 0342](adr/0342-module-categories-are-eight-parents-with-approved-sub-categories.md) D2). Above the floor, at most **ten** modules are listed in each approved sub-category: the first ten in the ranking above, so a buyer's `sort` or maximum price reorders or narrows those ten but never lets the eleventh in. The cap is never applied to a parent. A module whose sub-category is only proposed is listed under its parent and counts toward no cap, and a New module is not ranked, so it neither takes a slot nor is held by one. The catalog returns the cap as `cap`. The rule is `applyCap` in `src/bongos/module-listing.js`.
|
|
402
|
+
- **Why a module is not listed** (task 1003810). `GET /store/modules/:key/listing` says where one module stands, judging the same newest version the catalog does: `listed` (true or false), a `reason` (`not_assessed`, `security_not_passed`, `no_score`, `below_floor`, `delisted`, `no_version`, `over_cap`, `new` or `at_or_above_floor`), `why` in a sentence and `next_step` saying what would change it. A failed Security gate is told apart from one that has not finished, and names what it found to the module's author only (to anyone else it would point at a weakness not fixed yet); below the floor names the score, the floor and the weakest parts. It carries the same `parts`, `strengths`, `weaknesses` and `unmeasured` as the catalog, plus `security`. Any signed-in builder can read it. Past the cap it is `over_cap`, which names the score, the module's place in its sub-category and the cap, so an author can tell being eleventh apart from being below the floor. Both responses carry the module's `category` (`parent`, `parent_title`, and `sub` and `label` once its sub-category is approved). The rule is `explainPlacement` in `src/bongos/module-listing.js`.
|
|
403
|
+
- **Sub-categories are approved before they count** (task 1004355, ADR 0342 D3). A module names `category: { parent, sub }` in `module.json`; `GET /store/subcategories` lists the approved ones under each of the eight parents. A `sub` the store has not seen under that parent is **proposed** at publish (labelled from its key) and the publish answer's `category` says so (`status: "proposed"`, `live: false`): until a Metic+ person decides, the module sits under its parent only and counts toward no cap. The review queue (`GET /store/subcategories/queue`, atom `module.subcategory.review`, metic + archon via `government_033`) shows each proposal with the modules that named it and its near-duplicate names; `POST /store/subcategories/:id/approve { label?, sub_key? }` makes it live, renamed first if asked (a rename onto an existing key is refused — merge instead), and `POST /store/subcategories/:id/merge { into }` folds it into an approved one and moves its modules there. A later publish naming a merged key lands on its target. Where a module sits is `store_module_categories` (core_278), apart from the manifest; `readModuleCategories` in `src/bongos/module-subcategories.js` is the read the cap uses.
|
|
402
404
|
- **Testing new versions first** (task 1003802, [ADR 0344](adr/0344-a-tester-is-a-project-that-opts-in-once.md) D1). A project can choose to get each new version of its modules before everyone else. It is the project that opts in, never one builder for someone else's project, and never by default. `GET /modules/testing` returns `{ tester, general_only, updated_at, updated_by }` to anyone signed in. `POST /modules/testing { tester: true|false }` switches it, and `POST /modules/:key/general-only { general_only: true|false }` keeps one module on the versions everyone gets while the rest are tested. Both writes take the `module.enable` permission, the same one that turns modules on and off. Both are reversible: switching testing off puts the project back on general versions from its next update, and a module's "general only" mark is kept if testing is switched off and on again. The choice is stored in the project's own settings (`modules.tester`, `modules.general_only`); anything that cannot be read counts as not testing. `testsVersionsOf` in `src/bongos/module-testing.js` is the one answer to "does this project get the tester version of this module", which installs and updates use (task 1003805). The hall's Modules tab has the switch in its "Testing new versions" group, and the general-only control in each module's details.
|
|
403
405
|
|
|
404
406
|
### 7. Install from the store
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
-- core_280_store_subcategories.sql — the store's sub-categories and the queue that
|
|
2
|
+
-- approves them (task 1004355, ADR 0342 D3).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT IT IS. A module sits under one of eight fixed parents (ADR 0342 D1) and, once
|
|
5
|
+
-- approved, one narrow sub-category: the comparable set the cap of ten and the
|
|
6
|
+
-- ranking count within (D2). An author proposes a sub-category when publishing; it
|
|
7
|
+
-- is NOT live until a Metic+ person approves it (optionally renaming it) or merges
|
|
8
|
+
-- it into an existing one. Until then the module is listed under its parent only.
|
|
9
|
+
--
|
|
10
|
+
-- store_subcategories one row per (parent, sub_key). status proposed |
|
|
11
|
+
-- approved | merged. A merged row points at the
|
|
12
|
+
-- sub-category it was merged into, so a later publish
|
|
13
|
+
-- naming the old key lands on the right one.
|
|
14
|
+
-- store_module_categories where each store module sits now: its parent and the
|
|
15
|
+
-- sub-category it named (proposed or approved). A merge
|
|
16
|
+
-- moves every module of the merged row here. Kept apart
|
|
17
|
+
-- from the published manifest, which never changes.
|
|
18
|
+
--
|
|
19
|
+
-- The parent list matches CATEGORY_PARENTS in src/module-loader/manifest-schema.js;
|
|
20
|
+
-- changing it needs a new ADR (D1).
|
|
21
|
+
--
|
|
22
|
+
-- Additive only (two new tables), so forward-safe. Idempotent: safe to re-run.
|
|
23
|
+
-- Approved by the owner on 2026-10-15.
|
|
24
|
+
|
|
25
|
+
BEGIN;
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS store_subcategories (
|
|
28
|
+
id bigserial PRIMARY KEY,
|
|
29
|
+
parent text NOT NULL
|
|
30
|
+
CHECK (parent IN ('work', 'people', 'quality', 'money', 'communication', 'pages', 'hosting', 'knowledge')),
|
|
31
|
+
sub_key text NOT NULL
|
|
32
|
+
CHECK (sub_key ~ '^[a-z][a-z0-9-]*$'),
|
|
33
|
+
label text NOT NULL CHECK (length(label) BETWEEN 1 AND 80),
|
|
34
|
+
status text NOT NULL DEFAULT 'proposed'
|
|
35
|
+
CHECK (status IN ('proposed', 'approved', 'merged')),
|
|
36
|
+
proposed_by bigint REFERENCES builders(id),
|
|
37
|
+
decided_by bigint REFERENCES builders(id),
|
|
38
|
+
decided_at timestamptz,
|
|
39
|
+
merged_into bigint REFERENCES store_subcategories(id),
|
|
40
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
41
|
+
UNIQUE (parent, sub_key),
|
|
42
|
+
CHECK ((status = 'merged') = (merged_into IS NOT NULL)),
|
|
43
|
+
CHECK ((status = 'proposed') = (decided_at IS NULL))
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_store_subcategories_status ON store_subcategories (status);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS store_module_categories (
|
|
49
|
+
module_key text PRIMARY KEY REFERENCES store_modules(module_key),
|
|
50
|
+
parent text NOT NULL
|
|
51
|
+
CHECK (parent IN ('work', 'people', 'quality', 'money', 'communication', 'pages', 'hosting', 'knowledge')),
|
|
52
|
+
subcategory_id bigint REFERENCES store_subcategories(id),
|
|
53
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_store_module_categories_sub ON store_module_categories (subcategory_id);
|
|
57
|
+
|
|
58
|
+
COMMIT;
|
|
@@ -235,6 +235,9 @@ const PERMISSIONS = [
|
|
|
235
235
|
// Re-run a store version's automatic assessment (task 1003796, ADR 0359). Never a
|
|
236
236
|
// hand-set score: the owner ruled a person may ask for a re-check, not change the number.
|
|
237
237
|
{ key: 'module.assessment.rerun', system: false, floor: 'metic', guards: 'POST /store/modules/:key/versions/:version/reassess' },
|
|
238
|
+
// Review the store's proposed sub-categories (task 1004355, ADR 0342 D3): approve,
|
|
239
|
+
// rename then approve, or merge into an existing one (a merge moves its modules).
|
|
240
|
+
{ key: 'module.subcategory.review', system: false, floor: 'metic', guards: 'GET /store/subcategories/queue, POST /store/subcategories/:id/approve, POST /store/subcategories/:id/merge' },
|
|
238
241
|
// Take a module out of the store (task 1003797, ADR 0338 D2). Stops new installs,
|
|
239
242
|
// versions and downloads; never reaches an instance that already holds it.
|
|
240
243
|
{ key: 'module.delist', system: false, floor: 'metic', guards: 'POST /store/modules/:key/delist, POST /store/modules/:key/versions/:version/release (a version the promotion gate held on tester — task 1003804)' },
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- government_034_module_subcategory_review.sql — grant the new
|
|
2
|
+
-- `module.subcategory.review` atom to Metic and Archon (task 1004355, ADR 0342 D3).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT IT IS. An author proposes a store sub-category when publishing; a Metic+
|
|
5
|
+
-- person approves it (optionally renaming it) or merges it into an existing one.
|
|
6
|
+
-- Merging moves every module that named it. Approving is what makes a set of
|
|
7
|
+
-- modules "comparable" for the cap of ten, so it is Metic+, not every builder.
|
|
8
|
+
--
|
|
9
|
+
-- WHY THIS FILE EXISTS. RANK_SEED is derived from the catalog's floors, and the
|
|
10
|
+
-- drift guard (tests/government_seed.mjs) compares it against the union of the
|
|
11
|
+
-- run-once seed plus every later grant migration, per rank. A metic-floor key is
|
|
12
|
+
-- held by metic AND archon, so both are granted here.
|
|
13
|
+
--
|
|
14
|
+
-- Additive only: one new permission key granted to two ranks; nothing is revoked.
|
|
15
|
+
-- Approved by the owner on 2026-10-15 (with the sub-category table, core_280).
|
|
16
|
+
-- Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
|
|
17
|
+
--
|
|
18
|
+
-- The `SELECT '<rank>', unnest(ARRAY[…])` shape is load-bearing: the drift guard
|
|
19
|
+
-- scans for it literally.
|
|
20
|
+
|
|
21
|
+
BEGIN;
|
|
22
|
+
|
|
23
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
24
|
+
SELECT 'metic', unnest(ARRAY[
|
|
25
|
+
'module.subcategory.review'
|
|
26
|
+
])
|
|
27
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
28
|
+
|
|
29
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
30
|
+
SELECT 'archon', unnest(ARRAY[
|
|
31
|
+
'module.subcategory.review'
|
|
32
|
+
])
|
|
33
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
34
|
+
|
|
35
|
+
COMMIT;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.3",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.23.
|
|
9
|
+
"version": "1.23.3",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.3",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -10389,5 +10389,17 @@
|
|
|
10389
10389
|
"id": "1004893",
|
|
10390
10390
|
"text": "The ~30 copy-pasted \"wait a moment\" helpers across the codebase now all use one shared helper (one for the browser, one for the server), and a fitness check stops new copies from creeping back in. Nothing users see changes."
|
|
10391
10391
|
}
|
|
10392
|
+
],
|
|
10393
|
+
"1.23.2": [
|
|
10394
|
+
{
|
|
10395
|
+
"id": "1004355",
|
|
10396
|
+
"text": "Module authors can now propose a new store sub-category when they publish, and Metic+ reviewers approve, rename or merge it, with look-alike names shown side by side so duplicates don't split the rankings."
|
|
10397
|
+
}
|
|
10398
|
+
],
|
|
10399
|
+
"1.23.3": [
|
|
10400
|
+
{
|
|
10401
|
+
"id": "1003809",
|
|
10402
|
+
"text": "The module store now shows at most ten modules in each sub-category, and a module that lands eleventh tells its author why."
|
|
10403
|
+
}
|
|
10392
10404
|
]
|
|
10393
10405
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// A delisted module (ADR 0338 D2) is never in the catalog, whatever its score.
|
|
18
18
|
|
|
19
19
|
const { parseVersion, cmp } = require('../module-loader/semver');
|
|
20
|
+
const { readModuleCategories } = require('./module-subcategories');
|
|
20
21
|
|
|
21
22
|
// The quality floor, 0-100. It lives here and nowhere else: it is part of Bongos
|
|
22
23
|
// itself, not a live setting, so no author, builder or instance can move it.
|
|
@@ -106,9 +107,9 @@ function readParts(signals = [], { floor = QUALITY_FLOOR } = {}) {
|
|
|
106
107
|
// last, in key order: a missing part is never a zero, and never a first place.
|
|
107
108
|
//
|
|
108
109
|
// Only `listed` is sorted. A "New" version is never ranked (owner decision,
|
|
109
|
-
// 2026-10-01), so `new` keeps key order whatever the buyer picks.
|
|
110
|
-
//
|
|
111
|
-
//
|
|
110
|
+
// 2026-10-01), so `new` keeps key order whatever the buyer picks. The cap of ten
|
|
111
|
+
// per approved sub-category (applyCap) is drawn from the default ranking before any
|
|
112
|
+
// sort, so a buyer's sort never changes who is listed.
|
|
112
113
|
const SORT_PARTS = PARTS.map(({ part, title }) => ({ part, title }));
|
|
113
114
|
|
|
114
115
|
// `entries` carry `parts` as readParts builds them. Returns a new array. Pure.
|
|
@@ -174,6 +175,44 @@ function rankListed(entries) {
|
|
|
174
175
|
});
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// The cap: at most ten listed per sub-category (task 1003809, ADR 0342 D2)
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
//
|
|
182
|
+
// The owner's rule: fifteen Stripe modules all scoring above 94% still show only the
|
|
183
|
+
// top ten. The comparable set is an APPROVED sub-category, never a parent (D2): a
|
|
184
|
+
// module whose sub-category is only proposed is listed under its parent and counts
|
|
185
|
+
// toward no cap (D3). The ten are the first ten in the default ranking (RANKING), so
|
|
186
|
+
// a buyer's sort or maximum price reorders or narrows the ten, never changes who they
|
|
187
|
+
// are. A New version is not ranked, so it neither takes a slot nor is held by one.
|
|
188
|
+
const CAP_PER_SUBCATEGORY = 10;
|
|
189
|
+
|
|
190
|
+
// `ranked` in rankListed order; `cats` a Map of key -> readModuleCategories entry.
|
|
191
|
+
// Returns { kept, held }: `kept` keeps the order, `held` maps each key past the cap
|
|
192
|
+
// to { rank, cap, label } (its place in its sub-category). Pure.
|
|
193
|
+
function applyCap(ranked, cats, cap = CAP_PER_SUBCATEGORY) {
|
|
194
|
+
const seen = new Map();
|
|
195
|
+
const kept = [];
|
|
196
|
+
const held = new Map();
|
|
197
|
+
for (const e of ranked) {
|
|
198
|
+
const c = cats.get(e.key);
|
|
199
|
+
if (!c || !c.live) { kept.push(e); continue; }
|
|
200
|
+
const set = `${c.parent}/${c.sub}`;
|
|
201
|
+
const rank = (seen.get(set) || 0) + 1;
|
|
202
|
+
seen.set(set, rank);
|
|
203
|
+
if (rank <= cap) kept.push(e);
|
|
204
|
+
else held.set(e.key, { rank, cap, label: c.label || c.sub });
|
|
205
|
+
}
|
|
206
|
+
return { kept, held };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Where a module sits, as the catalog shows it: its parent, and its sub-category
|
|
210
|
+
// only once approved. A proposed one is not shown: it is not live yet (D3).
|
|
211
|
+
function categoryOf(c) {
|
|
212
|
+
if (!c) return null;
|
|
213
|
+
return { parent: c.parent, parent_title: c.parent_title, sub: c.live ? c.sub : null, label: c.live ? c.label : null };
|
|
214
|
+
}
|
|
215
|
+
|
|
177
216
|
// The newest version row per module key, in semver order (a version that does not
|
|
178
217
|
// parse is skipped, as latestVersions does in module-store.js).
|
|
179
218
|
function newestPerModule(rows) {
|
|
@@ -188,19 +227,28 @@ function newestPerModule(rows) {
|
|
|
188
227
|
return [...out.values()];
|
|
189
228
|
}
|
|
190
229
|
|
|
191
|
-
// The catalog: { floor, ranking, sort, sort_parts, max_price, listed: [...], new: [...] },
|
|
192
|
-
// each entry { key, title, version, published_at, price, overall, is_new,
|
|
193
|
-
// strengths, weaknesses, unmeasured } (the last four from readParts). Excluded
|
|
194
|
-
// versions are left out entirely: a module below the bar is not listed at all,
|
|
195
|
-
//
|
|
196
|
-
// `sort` names a part, which orders it by
|
|
197
|
-
// stays in key order.
|
|
198
|
-
async function listCatalog(
|
|
230
|
+
// The catalog: { floor, cap, ranking, sort, sort_parts, max_price, listed: [...], new: [...] },
|
|
231
|
+
// each entry { key, title, version, published_at, price, category, overall, is_new,
|
|
232
|
+
// parts, strengths, weaknesses, unmeasured } (the last four from readParts). Excluded
|
|
233
|
+
// versions are left out entirely: a module below the bar is not listed at all, nor
|
|
234
|
+
// is one past the cap of its sub-category, nor one over the buyer's `maxPrice`.
|
|
235
|
+
// `listed` is in rankListed order unless `sort` names a part, which orders it by
|
|
236
|
+
// sortByPart; `new` is never ranked and stays in key order.
|
|
237
|
+
async function listCatalog(opts = {}) {
|
|
238
|
+
return (await buildCatalog(opts)).catalog;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// listCatalog's work, plus `held`: the keys the cap left out, with their place
|
|
242
|
+
// (applyCap). readListing reads `held` to tell an author they are eleventh, and
|
|
243
|
+
// passes `keys` — one sub-category's modules — so one module's check reads only
|
|
244
|
+
// the set it competes in, never the whole store. The cap counts within a
|
|
245
|
+
// sub-category, so that subset ranks exactly as it does in the full catalog.
|
|
246
|
+
async function buildCatalog({ db, floor = QUALITY_FLOOR, sort = null, maxPrice = null, keys = null } = {}) {
|
|
199
247
|
const pool = db || require('./pool').pool;
|
|
200
248
|
const { rows: versions } = await pool.query(
|
|
201
249
|
`SELECT m.module_key, m.title, v.id AS version_id, v.version, v.published_at
|
|
202
250
|
FROM store_modules m JOIN store_module_versions v ON v.module_key = m.module_key
|
|
203
|
-
WHERE m.status = 'listed'
|
|
251
|
+
WHERE m.status = 'listed'${keys ? ' AND m.module_key = ANY($1)' : ''}`, keys ? [keys] : []);
|
|
204
252
|
const newest = newestPerModule(versions);
|
|
205
253
|
const scores = new Map();
|
|
206
254
|
if (newest.length) {
|
|
@@ -237,21 +285,26 @@ async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null, maxPrice =
|
|
|
237
285
|
}
|
|
238
286
|
const max = maxPrice && (maxPrice.cents != null || maxPrice.credits != null)
|
|
239
287
|
? { cents: maxPrice.cents ?? null, credits: maxPrice.credits ?? null } : null;
|
|
240
|
-
const
|
|
288
|
+
const cats = await readModuleCategories(newest.map((r) => r.module_key), { db: pool });
|
|
289
|
+
const out = { floor, cap: CAP_PER_SUBCATEGORY, ranking: RANKING, sort: sort || null, sort_parts: SORT_PARTS, max_price: max, listed: [], new: [] };
|
|
241
290
|
for (const r of newest.sort((a, b) => a.module_key.localeCompare(b.module_key))) {
|
|
242
291
|
const score = scores.get(String(r.version_id)) || null;
|
|
243
292
|
const { placement } = placeVersion(score, floor);
|
|
244
293
|
if (placement === 'excluded') continue;
|
|
245
|
-
const price = priceOf(prices.get(r.module_key));
|
|
246
|
-
if (!withinMaxPrice(price, max)) continue;
|
|
247
294
|
out[placement].push({
|
|
248
|
-
key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
|
|
295
|
+
key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
|
|
296
|
+
price: priceOf(prices.get(r.module_key)), category: categoryOf(cats.get(r.module_key)),
|
|
249
297
|
overall: placement === 'new' ? null : score.overall, is_new: placement === 'new',
|
|
250
298
|
...readParts(signals.get(String(r.version_id)) || [], { floor }),
|
|
251
299
|
});
|
|
252
300
|
}
|
|
253
|
-
|
|
254
|
-
|
|
301
|
+
// The cap first, over the default ranking; then the buyer's filter and order.
|
|
302
|
+
const { kept, held } = applyCap(rankListed(out.listed), cats);
|
|
303
|
+
const affordable = (e) => withinMaxPrice(e.price, max);
|
|
304
|
+
out.listed = kept.filter(affordable);
|
|
305
|
+
out.new = out.new.filter(affordable);
|
|
306
|
+
if (sort) out.listed = sortByPart(out.listed, sort);
|
|
307
|
+
return { catalog: out, held };
|
|
255
308
|
}
|
|
256
309
|
|
|
257
310
|
// ---------------------------------------------------------------------------
|
|
@@ -263,9 +316,10 @@ async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null, maxPrice =
|
|
|
263
316
|
// names the reason in a sentence, says what would change it, and carries the same
|
|
264
317
|
// parts reading the catalog shows (the weakest parts first). The reasons are
|
|
265
318
|
// placeVersion's, plus two the catalog drops before it gets that far: `delisted`
|
|
266
|
-
// and `no_version
|
|
267
|
-
//
|
|
268
|
-
|
|
319
|
+
// and `no_version`, and `over_cap`: above the floor but past the top ten of its
|
|
320
|
+
// sub-category (task 1003809). From outside, being eleventh looks the same as being
|
|
321
|
+
// below the floor, so the author is told which it is.
|
|
322
|
+
function explainPlacement({ placement, reason }, { overall = null, floor = QUALITY_FLOOR, security = null, weaknesses = [], held = null } = {}) {
|
|
269
323
|
const weakest = weaknesses.slice(0, 3).map((w) => `${w.title} (${w.score})`).join(', ');
|
|
270
324
|
const say = (why, nextStep) => ({ listed: placement !== 'excluded', placement, reason, why, next_step: nextStep });
|
|
271
325
|
switch (reason) {
|
|
@@ -291,6 +345,9 @@ function explainPlacement({ placement, reason }, { overall = null, floor = QUALI
|
|
|
291
345
|
case 'below_floor':
|
|
292
346
|
return say(`This version scored ${overall} out of 100. The store lists modules that score at least ${floor}.${weakest ? ` Its weakest parts: ${weakest}.` : ''}`,
|
|
293
347
|
'Improve its weakest parts and publish a new version. The newest version is the one judged.');
|
|
348
|
+
case 'over_cap':
|
|
349
|
+
return say(`This version scored ${overall} out of 100, above the floor of ${floor}, but it ranks ${held.rank} in ${held.label}. The store lists the top ${held.cap} in each sub-category.`,
|
|
350
|
+
`Score higher than the module in place ${held.cap} and publish a new version. Between equal scores, the cheaper module ranks higher.`);
|
|
294
351
|
case 'new':
|
|
295
352
|
return say('This version is listed with a New label. It is not ranked until it has real install and reliability data.',
|
|
296
353
|
'Nothing to do: it is ranked once projects have installed and used it.');
|
|
@@ -301,7 +358,7 @@ function explainPlacement({ placement, reason }, { overall = null, floor = QUALI
|
|
|
301
358
|
}
|
|
302
359
|
|
|
303
360
|
// Where one module stands in the catalog, and why: null for a key the store does not
|
|
304
|
-
// know, else { module_key, title, status, version, version_id, floor, overall,
|
|
361
|
+
// know, else { module_key, title, status, category, version, version_id, floor, overall,
|
|
305
362
|
// listed, placement, reason, why, next_step, security, parts, strengths, weaknesses,
|
|
306
363
|
// unmeasured }. Judges the same version the catalog does: the newest published one.
|
|
307
364
|
// What the Security gate found is told only to the module's author (`viewerId`): to
|
|
@@ -312,7 +369,8 @@ async function readListing(key, { db, floor = QUALITY_FLOOR, viewerId = null } =
|
|
|
312
369
|
'SELECT module_key, title, status, author_id FROM store_modules WHERE module_key = $1', [key]);
|
|
313
370
|
const mod = mods[0];
|
|
314
371
|
if (!mod) return null;
|
|
315
|
-
const
|
|
372
|
+
const cat = (await readModuleCategories([key], { db: pool })).get(key);
|
|
373
|
+
const head = { module_key: mod.module_key, title: mod.title, status: mod.status, floor, category: categoryOf(cat) };
|
|
316
374
|
const { rows: versions } = await pool.query(
|
|
317
375
|
'SELECT module_key, id AS version_id, version, published_at FROM store_module_versions WHERE module_key = $1', [key]);
|
|
318
376
|
const newest = newestPerModule(versions)[0];
|
|
@@ -337,16 +395,30 @@ async function readListing(key, { db, floor = QUALITY_FLOOR, viewerId = null } =
|
|
|
337
395
|
const isAuthor = viewerId != null && String(mod.author_id) === String(viewerId);
|
|
338
396
|
const security = sec ? { outcome: sec.outcome, reason: (isAuthor && sec.reason) || null } : null;
|
|
339
397
|
const reading = readParts(signals, { floor });
|
|
340
|
-
|
|
398
|
+
let placed = placeVersion(score, floor);
|
|
399
|
+
// Above the floor in a live sub-category, the cap decides, over the same ranking
|
|
400
|
+
// the catalog uses, so the two never disagree. Only that sub-category is read:
|
|
401
|
+
// `self` is found by store_module_categories' primary key (module_key) and `peer`
|
|
402
|
+
// by idx_store_module_categories_sub (subcategory_id), both in core_280.
|
|
403
|
+
let held = null;
|
|
404
|
+
if (placed.placement === 'listed' && cat && cat.live) {
|
|
405
|
+
const { rows: peers } = await pool.query(
|
|
406
|
+
`SELECT peer.module_key
|
|
407
|
+
FROM store_module_categories self
|
|
408
|
+
JOIN store_module_categories peer ON peer.subcategory_id = self.subcategory_id
|
|
409
|
+
WHERE self.module_key = $1`, [key]);
|
|
410
|
+
held = (await buildCatalog({ db: pool, floor, keys: peers.map((p) => p.module_key) })).held.get(key) || null;
|
|
411
|
+
if (held) placed = { placement: 'excluded', reason: 'over_cap' };
|
|
412
|
+
}
|
|
341
413
|
const overall = score && placed.placement !== 'new' ? score.overall : null;
|
|
342
414
|
return {
|
|
343
415
|
...head, version: newest.version, version_id: newest.version_id, overall,
|
|
344
|
-
...explainPlacement(placed, { overall, floor, security, weaknesses: reading.weaknesses }),
|
|
416
|
+
...explainPlacement(placed, { overall, floor, security, weaknesses: reading.weaknesses, held }),
|
|
345
417
|
security, ...reading,
|
|
346
418
|
};
|
|
347
419
|
}
|
|
348
420
|
|
|
349
421
|
module.exports = {
|
|
350
|
-
QUALITY_FLOOR, STRONG_AT, SORT_PARTS, RANKING, placeVersion, readParts,
|
|
351
|
-
priceOf, withinMaxPrice, rankListed, listCatalog, explainPlacement, readListing,
|
|
422
|
+
QUALITY_FLOOR, STRONG_AT, SORT_PARTS, RANKING, CAP_PER_SUBCATEGORY, placeVersion, readParts,
|
|
423
|
+
sortByPart, priceOf, withinMaxPrice, rankListed, applyCap, listCatalog, explainPlacement, readListing,
|
|
352
424
|
};
|
|
@@ -25,6 +25,7 @@ const path = require('node:path');
|
|
|
25
25
|
const crypto = require('node:crypto');
|
|
26
26
|
const { parseVersion, cmp } = require('../module-loader/semver');
|
|
27
27
|
const { KEY_RE } = require('../module-loader/manifest-schema');
|
|
28
|
+
const { recordPublishCategory } = require('./module-subcategories');
|
|
28
29
|
|
|
29
30
|
// Where the store lives: <INSTANCE ROOT>/var/module-store (task 1004399).
|
|
30
31
|
//
|
|
@@ -172,11 +173,11 @@ function relativeArtifactPath(file, { dir = storeDir() } = {}) {
|
|
|
172
173
|
const PUBLISH_CHANNEL = 'tester';
|
|
173
174
|
|
|
174
175
|
// One transaction. `db` is a pg Pool (anything with connect()). Returns
|
|
175
|
-
// { ok: true, version: row, created: bool, channel } or { ok: false, code, message, status }.
|
|
176
|
+
// { ok: true, version: row, created: bool, channel, category } or { ok: false, code, message, status }.
|
|
176
177
|
async function publishVersion(input, { db, beforeCommit } = {}) {
|
|
177
178
|
const {
|
|
178
179
|
key, title, description, version, coreVersion, manifest,
|
|
179
|
-
treeSha256, tarballSha256, bytes, artifactPath: relPath, builderId,
|
|
180
|
+
treeSha256, tarballSha256, bytes, artifactPath: relPath, builderId, category,
|
|
180
181
|
} = input;
|
|
181
182
|
const pool = db || require('./pool').pool;
|
|
182
183
|
const client = await pool.connect();
|
|
@@ -229,11 +230,15 @@ async function publishVersion(input, { db, beforeCommit } = {}) {
|
|
|
229
230
|
`INSERT INTO store_module_version_channels (version_id, channel, reason, set_by)
|
|
230
231
|
VALUES ($1, $2, $3, $4)`,
|
|
231
232
|
[row.id, PUBLISH_CHANNEL, 'new_version', builderId]);
|
|
233
|
+
// Where the module sits, on the same transaction (task 1004355, ADR 0342 D3): a
|
|
234
|
+
// sub-category the store has not seen is proposed here and is not live until a
|
|
235
|
+
// Metic+ person approves it. A version naming no category leaves it where it was.
|
|
236
|
+
const placed = category ? await recordPublishCategory(client, { key, category, builderId }) : null;
|
|
232
237
|
// Put the tarball in place while the row is still uncommitted: if this throws,
|
|
233
238
|
// the catch below rolls the row back, so the two can never disagree.
|
|
234
239
|
if (beforeCommit) await beforeCommit();
|
|
235
240
|
await client.query('COMMIT');
|
|
236
|
-
return { ok: true, created, version: row, channel: PUBLISH_CHANNEL };
|
|
241
|
+
return { ok: true, created, version: row, channel: PUBLISH_CHANNEL, category: placed };
|
|
237
242
|
} catch (e) {
|
|
238
243
|
try { await client.query('ROLLBACK'); } catch { /* the connection may be gone */ }
|
|
239
244
|
// Two publishes of the same version racing past the check: the UNIQUE wins.
|