@bongos/core 1.19.540 → 1.19.542
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +34 -19
- package/.claude/skills/blocker-solve/SKILL.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/ideas/blockers.js +14 -8
- package/modules/platform-identity/project-catalog.js +29 -2
- package/modules/provisioning/catalog-bridge.js +67 -3
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/pollers/catalog-backfill.js +134 -0
- package/modules/provisioning/routes/provisioning.js +10 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/blocker_detail_hydration.mjs +128 -0
- package/tests/catalog_only_client.mjs +8 -4
- package/tests/project_catalog_backfill.mjs +402 -0
- package/tests/project_catalog_routes.mjs +49 -1
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.542",
|
|
6
|
+
"core_contract": "1.19.542",
|
|
7
|
+
"source_commit": "ec461e927a5fe4bb98053989f04853aa378878ee",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-04T17:03:11.236Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 441,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2002,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2467,
|
|
20
|
+
"tree_sha256": "15eb33889177142913c49676b48a92f947cc04ace14b1cc1b0055e773f996645",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"path": ".claude/skills/blocker-solve/SKILL.md",
|
|
29
29
|
"mode": "0000644",
|
|
30
|
-
"sha256": "
|
|
30
|
+
"sha256": "90de8b32fb4dac69d6e5c781d78489c36c40f00c4b07f062c0650cff08052203"
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"path": ".claude/skills/builder-backup/SKILL.md",
|
|
@@ -2647,7 +2647,7 @@
|
|
|
2647
2647
|
{
|
|
2648
2648
|
"path": "docs/module-api-changelog.md",
|
|
2649
2649
|
"mode": "0000644",
|
|
2650
|
-
"sha256": "
|
|
2650
|
+
"sha256": "f5af0c300e59c22fcfeaafba88b00bd39f65afb2f51529efb0981a898aebb766"
|
|
2651
2651
|
},
|
|
2652
2652
|
{
|
|
2653
2653
|
"path": "docs/modules-contract.md",
|
|
@@ -5237,7 +5237,7 @@
|
|
|
5237
5237
|
{
|
|
5238
5238
|
"path": "modules/ideas/blockers.js",
|
|
5239
5239
|
"mode": "0000644",
|
|
5240
|
-
"sha256": "
|
|
5240
|
+
"sha256": "d3feb342ef22f8a3952c249c7d09a2fa1c9d01ac45f4306f4084e2572df5c203"
|
|
5241
5241
|
},
|
|
5242
5242
|
{
|
|
5243
5243
|
"path": "modules/ideas/completeness.js",
|
|
@@ -5952,7 +5952,7 @@
|
|
|
5952
5952
|
{
|
|
5953
5953
|
"path": "modules/platform-identity/project-catalog.js",
|
|
5954
5954
|
"mode": "0000644",
|
|
5955
|
-
"sha256": "
|
|
5955
|
+
"sha256": "6021d893691000a164d7d16e6f24b5bd91215c6dcb252557f0ffcd5d1d467e32"
|
|
5956
5956
|
},
|
|
5957
5957
|
{
|
|
5958
5958
|
"path": "modules/platform-identity/project-stats.js",
|
|
@@ -6037,7 +6037,7 @@
|
|
|
6037
6037
|
{
|
|
6038
6038
|
"path": "modules/provisioning/catalog-bridge.js",
|
|
6039
6039
|
"mode": "0000644",
|
|
6040
|
-
"sha256": "
|
|
6040
|
+
"sha256": "dae214682f6115b3a4e11ac3bcd120460b07c2a517155ba0b7ec690f1db9f3ea"
|
|
6041
6041
|
},
|
|
6042
6042
|
{
|
|
6043
6043
|
"path": "modules/provisioning/migrations/provisioning_001_tables.sql",
|
|
@@ -6112,13 +6112,18 @@
|
|
|
6112
6112
|
{
|
|
6113
6113
|
"path": "modules/provisioning/module.json",
|
|
6114
6114
|
"mode": "0000644",
|
|
6115
|
-
"sha256": "
|
|
6115
|
+
"sha256": "6c19e18cd65ed87e3af53835af179bbe0a937e38456056fe49711c8b31b3e081"
|
|
6116
6116
|
},
|
|
6117
6117
|
{
|
|
6118
6118
|
"path": "modules/provisioning/onboard-plan.js",
|
|
6119
6119
|
"mode": "0000644",
|
|
6120
6120
|
"sha256": "31f14cbf3d649c25f1f17c508ae19be2f39d9e1991fde17cdce08cb15837ffa1"
|
|
6121
6121
|
},
|
|
6122
|
+
{
|
|
6123
|
+
"path": "modules/provisioning/pollers/catalog-backfill.js",
|
|
6124
|
+
"mode": "0000644",
|
|
6125
|
+
"sha256": "bf4a392ce4c545feb246b818ad2b37dd5e92050538d5202fb8980941d0ec52c9"
|
|
6126
|
+
},
|
|
6122
6127
|
{
|
|
6123
6128
|
"path": "modules/provisioning/pollers/liveness-sweep.js",
|
|
6124
6129
|
"mode": "0000644",
|
|
@@ -6147,7 +6152,7 @@
|
|
|
6147
6152
|
{
|
|
6148
6153
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
6149
6154
|
"mode": "0000644",
|
|
6150
|
-
"sha256": "
|
|
6155
|
+
"sha256": "c6cfd3a1d0b4f39c4d23d65e1587ec50276aa4180ab48c520b4c22fdb6cf0a6c"
|
|
6151
6156
|
},
|
|
6152
6157
|
{
|
|
6153
6158
|
"path": "modules/provisioning/starter-bundles.js",
|
|
@@ -7407,12 +7412,12 @@
|
|
|
7407
7412
|
{
|
|
7408
7413
|
"path": "package-lock.json",
|
|
7409
7414
|
"mode": "0000644",
|
|
7410
|
-
"sha256": "
|
|
7415
|
+
"sha256": "3292c34b3bdbd992383b7d539a5cc5e1734ac78aff6ca476db6d3e558a60db32"
|
|
7411
7416
|
},
|
|
7412
7417
|
{
|
|
7413
7418
|
"path": "package.json",
|
|
7414
7419
|
"mode": "0000644",
|
|
7415
|
-
"sha256": "
|
|
7420
|
+
"sha256": "0e83db7f6c2b6d6310d2f20810817be1e483f774d64def7852895c2f85eff047"
|
|
7416
7421
|
},
|
|
7417
7422
|
{
|
|
7418
7423
|
"path": "public-docs/index.html",
|
|
@@ -9097,7 +9102,7 @@
|
|
|
9097
9102
|
{
|
|
9098
9103
|
"path": "src/module-api.js",
|
|
9099
9104
|
"mode": "0000644",
|
|
9100
|
-
"sha256": "
|
|
9105
|
+
"sha256": "980c4fd7007ae4d60c95392644476ef440e9d0e0a1eaa3aa4f19f4f3bd5cb69c"
|
|
9101
9106
|
},
|
|
9102
9107
|
{
|
|
9103
9108
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9404,6 +9409,11 @@
|
|
|
9404
9409
|
"mode": "0000644",
|
|
9405
9410
|
"sha256": "445231e7fea9163e83ec0a76fce981415ed141760e34b165016a8039bba4da51"
|
|
9406
9411
|
},
|
|
9412
|
+
{
|
|
9413
|
+
"path": "tests/blocker_detail_hydration.mjs",
|
|
9414
|
+
"mode": "0000644",
|
|
9415
|
+
"sha256": "fac81eb7827820e9e9ad03d4024d8fd9372f6a346a6931ac0a77692805ae2d5c"
|
|
9416
|
+
},
|
|
9407
9417
|
{
|
|
9408
9418
|
"path": "tests/blocker_link.mjs",
|
|
9409
9419
|
"mode": "0000644",
|
|
@@ -9582,7 +9592,7 @@
|
|
|
9582
9592
|
{
|
|
9583
9593
|
"path": "tests/catalog_only_client.mjs",
|
|
9584
9594
|
"mode": "0000644",
|
|
9585
|
-
"sha256": "
|
|
9595
|
+
"sha256": "cf03bbcb60691dadd4f7c9f4faf4122df6dea099f9ce3137b514b416cab3a5e8"
|
|
9586
9596
|
},
|
|
9587
9597
|
{
|
|
9588
9598
|
"path": "tests/category_advisory_guard.mjs",
|
|
@@ -11319,6 +11329,11 @@
|
|
|
11319
11329
|
"mode": "0000644",
|
|
11320
11330
|
"sha256": "6a7a223a678b56333befaed017429e5b9d8a81e4405efae72a23355902517d9d"
|
|
11321
11331
|
},
|
|
11332
|
+
{
|
|
11333
|
+
"path": "tests/project_catalog_backfill.mjs",
|
|
11334
|
+
"mode": "0000644",
|
|
11335
|
+
"sha256": "079b8e54e94a6c1576a02735ee9ea9f27baaaa266115fa21eafa51ba7c17c565"
|
|
11336
|
+
},
|
|
11322
11337
|
{
|
|
11323
11338
|
"path": "tests/project_catalog_bridge.mjs",
|
|
11324
11339
|
"mode": "0000644",
|
|
@@ -11327,7 +11342,7 @@
|
|
|
11327
11342
|
{
|
|
11328
11343
|
"path": "tests/project_catalog_routes.mjs",
|
|
11329
11344
|
"mode": "0000644",
|
|
11330
|
-
"sha256": "
|
|
11345
|
+
"sha256": "e8f3b09a5c06aa1d760b7605d6c40ba7b818594286a1bd6bb9496f222b7fc8e8"
|
|
11331
11346
|
},
|
|
11332
11347
|
{
|
|
11333
11348
|
"path": "tests/project_door_ui.mjs",
|
|
@@ -41,7 +41,7 @@ Read the blocker fully, then enrich it:
|
|
|
41
41
|
node scripts/gds/api.js GET /api/gds/blockers/<id>
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
Fields: `title`, `body_md` (often already contains a numbered "Steps (owner): …" plan — lean on it), `source`, `source_ref`, `status`, plus `blocking_task_ids`
|
|
44
|
+
Fields: `title`, `body_md` (often already contains a numbered "Steps (owner): …" plan — lean on it), `source`, `source_ref`, `status`, plus `blocking_task_ids` (task 1002459 — the detail route now hydrates the same array the list view returns). These linked tasks are exactly what will auto-promote when you resolve.
|
|
45
45
|
|
|
46
46
|
- Read any linked task's notes (`GET /api/gds/tasks/<task-id>`) — the source task usually explains what "done" means.
|
|
47
47
|
- Pull project knowledge so you're not re-deriving: `node scripts/gds/recall.js "<the blocker's topic>"` (add `--kind doc` for ADRs/recipes). This surfaces the ADR, recipe, or past session that already documents how to do this thing.
|
|
@@ -1529,5 +1529,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1529
1529
|
landed since 1.19.538 with no explicit bump. run 33892948885. (task 1002620)
|
|
1530
1530
|
1.19.540 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1531
1531
|
landed since 1.19.539 with no explicit bump. run 33895602533. (task 1002620)
|
|
1532
|
+
1.19.541 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1533
|
+
landed since 1.19.540 with no explicit bump. run 33897974683. (task 1002620)
|
|
1534
|
+
1.19.542 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1535
|
+
landed since 1.19.541 with no explicit bump. run 33898437821. (task 1002620)
|
|
1532
1536
|
---------------------------------------------------------------------------
|
|
1533
1537
|
```
|
|
@@ -9,15 +9,20 @@
|
|
|
9
9
|
|
|
10
10
|
const { pool } = require('../../src/module-api');
|
|
11
11
|
|
|
12
|
+
// Shared by listOpenBlockers + getBlocker so the two reads can never drift
|
|
13
|
+
// (task 1002459 — getBlocker used to omit this column entirely, so a
|
|
14
|
+
// link-then-verify against the detail route read as a false negative).
|
|
15
|
+
const BLOCKING_TASK_IDS_SQL = `COALESCE(
|
|
16
|
+
(SELECT array_agg(tb.task_id ORDER BY tb.task_id)
|
|
17
|
+
FROM task_blockers tb WHERE tb.blocker_id = b.id),
|
|
18
|
+
ARRAY[]::bigint[]
|
|
19
|
+
) AS blocking_task_ids`;
|
|
20
|
+
|
|
12
21
|
async function listOpenBlockers() {
|
|
13
22
|
const { rows } = await pool.query(
|
|
14
23
|
`SELECT b.id, b.title, b.body_md, b.status, b.created_at,
|
|
15
24
|
b.source, b.source_ref,
|
|
16
|
-
|
|
17
|
-
(SELECT array_agg(tb.task_id ORDER BY tb.task_id)
|
|
18
|
-
FROM task_blockers tb WHERE tb.blocker_id = b.id),
|
|
19
|
-
ARRAY[]::bigint[]
|
|
20
|
-
) AS blocking_task_ids
|
|
25
|
+
${BLOCKING_TASK_IDS_SQL}
|
|
21
26
|
FROM blockers b
|
|
22
27
|
WHERE b.status = 'open'
|
|
23
28
|
ORDER BY b.id`
|
|
@@ -27,9 +32,10 @@ async function listOpenBlockers() {
|
|
|
27
32
|
|
|
28
33
|
async function getBlocker(id) {
|
|
29
34
|
const { rows } = await pool.query(
|
|
30
|
-
`SELECT id, title, body_md, status, created_at, resolved_at, resolved_by,
|
|
31
|
-
source, source_ref
|
|
32
|
-
|
|
35
|
+
`SELECT b.id, b.title, b.body_md, b.status, b.created_at, b.resolved_at, b.resolved_by,
|
|
36
|
+
b.resolution_note, b.source, b.source_ref,
|
|
37
|
+
${BLOCKING_TASK_IDS_SQL}
|
|
38
|
+
FROM blockers b WHERE b.id = $1`,
|
|
33
39
|
[id]
|
|
34
40
|
);
|
|
35
41
|
return rows[0] || null;
|
|
@@ -165,6 +165,29 @@ async function retireOwnedProject({ origin, ownerGithubId }, { pool = defaultPoo
|
|
|
165
165
|
return rows[0] || null;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// WHICH of these origins does the map already carry? The one question a BACKFILL has to ask
|
|
169
|
+
// and `publish` cannot answer (task 1003581). ADR 0246's projection is write-triggered, so an
|
|
170
|
+
// instance that predates it has no row until its owner happens to save something — and the
|
|
171
|
+
// only way to heal that is to re-project, which without this read would mean re-projecting
|
|
172
|
+
// EVERY instance at every boot and churning every row's updated_at forever.
|
|
173
|
+
//
|
|
174
|
+
// Deliberately UNFILTERED by owner and by status: "is there a row at this origin" is the
|
|
175
|
+
// question, so a hand-seeded row (owner NULL) or a hidden one counts as present and the
|
|
176
|
+
// backfill leaves it exactly where it is. Origins in, origins out — the catalog still learns
|
|
177
|
+
// no other module's schema, which is ADR 0246's reason for keeping the port narrow. PURE of
|
|
178
|
+
// side effects; one indexed lookup however many origins are asked about.
|
|
179
|
+
async function catalogedOrigins(origins, { pool = defaultPool } = {}) {
|
|
180
|
+
const list = [...new Set((Array.isArray(origins) ? origins : [origins])
|
|
181
|
+
.map((o) => String(o || '').replace(/\/+$/, ''))
|
|
182
|
+
.filter(Boolean))];
|
|
183
|
+
if (!list.length) return [];
|
|
184
|
+
const { rows } = await pool.query(
|
|
185
|
+
'SELECT origin FROM platform_identity_projects WHERE origin = ANY($1::text[])',
|
|
186
|
+
[list],
|
|
187
|
+
);
|
|
188
|
+
return rows.map((r) => r.origin);
|
|
189
|
+
}
|
|
190
|
+
|
|
168
191
|
// ONE control-plane project, projected into the catalog: the identity row first, then the map
|
|
169
192
|
// row, then (on a re-point) the address it left. Both writes are claim-gated, so `claimed`
|
|
170
193
|
// tells the caller whether the map actually carries this project — a false is a refusal, not
|
|
@@ -184,10 +207,13 @@ async function publishControlPlaneProject(entry, { retireOrigin = null, pool = d
|
|
|
184
207
|
// module.json. The `project.publishGate` precedent, and for the same reason: provisioning is
|
|
185
208
|
// the plane that OWNS these projects and may not import this module (the ADR 0083 one-way
|
|
186
209
|
// rule), while this module owns the catalog's tables. One definition, reached through the
|
|
187
|
-
// switchboard. `publish` is
|
|
188
|
-
//
|
|
210
|
+
// switchboard. `publish` writes; `catalogued` is the one READ a consumer needs — "which of
|
|
211
|
+
// these origins are already on the map" — added by the backfill (task 1003581) because
|
|
212
|
+
// healing history without it means re-writing every row at every boot. Both speak only in
|
|
213
|
+
// origins and the entry shape, so the catalog still never learns another module's schema.
|
|
189
214
|
const projectCatalogPort = Object.freeze({
|
|
190
215
|
publish: (entry, opts) => publishControlPlaneProject(entry, opts),
|
|
216
|
+
catalogued: (origins) => catalogedOrigins(origins),
|
|
191
217
|
});
|
|
192
218
|
|
|
193
219
|
module.exports = {
|
|
@@ -197,6 +223,7 @@ module.exports = {
|
|
|
197
223
|
upsertCatalogOnlyClient,
|
|
198
224
|
upsertOwnedProject,
|
|
199
225
|
retireOwnedProject,
|
|
226
|
+
catalogedOrigins,
|
|
200
227
|
publishControlPlaneProject,
|
|
201
228
|
projectCatalogPort,
|
|
202
229
|
};
|
|
@@ -44,18 +44,31 @@ const CATALOG_HIDDEN_STATES = new Set(['tearing_down', 'torn_down']);
|
|
|
44
44
|
// SAME row rather than orphaning it). The door comes from `effectiveSettings`, never the raw
|
|
45
45
|
// jsonb, so an off-vocabulary stored value resolves to today's behaviour exactly as it does
|
|
46
46
|
// on every other surface.
|
|
47
|
+
// The two halves of the projection that do NOT need the owner, split out so a caller that only
|
|
48
|
+
// wants the address can have it without a database read (task 1003581 — the backfill's presence
|
|
49
|
+
// check runs on every owner manage-page load, and resolving an owner just to build a string was
|
|
50
|
+
// a query per read forever). `catalogEntryForInstance` derives both from these, so there is
|
|
51
|
+
// still exactly ONE definition of each. Both PURE.
|
|
52
|
+
function catalogOriginForInstance(row) {
|
|
53
|
+
return row && row.slug && row.domain ? `https://${row.domain}` : null;
|
|
54
|
+
}
|
|
55
|
+
function catalogStatusForInstance(row) {
|
|
56
|
+
return CATALOG_HIDDEN_STATES.has(row && row.status) ? 'hidden' : 'active';
|
|
57
|
+
}
|
|
58
|
+
|
|
47
59
|
function catalogEntryForInstance(row, { ownerGithubId } = {}) {
|
|
48
|
-
|
|
60
|
+
const origin = catalogOriginForInstance(row);
|
|
61
|
+
if (!origin) return null;
|
|
49
62
|
const owner = Number(ownerGithubId);
|
|
50
63
|
if (!Number.isFinite(owner) || owner <= 0) return null;
|
|
51
64
|
return {
|
|
52
65
|
clientId: row.slug,
|
|
53
|
-
origin
|
|
66
|
+
origin,
|
|
54
67
|
name: row.slug,
|
|
55
68
|
description: row.description || null,
|
|
56
69
|
type: row.type || null,
|
|
57
70
|
visibility: effectiveSettings(row).visibility,
|
|
58
|
-
status:
|
|
71
|
+
status: catalogStatusForInstance(row),
|
|
59
72
|
ownerGithubId: owner,
|
|
60
73
|
};
|
|
61
74
|
}
|
|
@@ -98,8 +111,59 @@ async function publishInstanceToCatalog(db, row, { retireOrigin = null } = {}) {
|
|
|
98
111
|
}
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
// The origin a BACKFILL would put this instance's catalog row at, or null when it should not
|
|
115
|
+
// have one. PURE, and composed from the projection's own two halves rather than a second copy
|
|
116
|
+
// of its rules: no address means nothing to key a row on, and 'hidden' means the row would
|
|
117
|
+
// exist only to be invisible — writing that for a torn-down project that was NEVER catalogued
|
|
118
|
+
// is churn, not repair. A project the owner is still standing up ('requested' / 'provisioning'
|
|
119
|
+
// / 'error') IS backfillable — ADR 0246: the map is a directory of projects, not an uptime
|
|
120
|
+
// board. Deliberately says nothing about the OWNER, so the presence check it feeds costs no
|
|
121
|
+
// database read; a missing owner identity is `publishInstanceToCatalog`'s own refusal.
|
|
122
|
+
function backfillOriginForInstance(row) {
|
|
123
|
+
const origin = catalogOriginForInstance(row);
|
|
124
|
+
return origin && catalogStatusForInstance(row) === 'active' ? origin : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Project ONE instance ONLY IF the map does not already carry it (task 1003581).
|
|
128
|
+
//
|
|
129
|
+
// WHY IT EXISTS. ADR 0246 made the catalog row a projection re-taken at every write that can
|
|
130
|
+
// change it — but a write-triggered projection has no opinion about HISTORY. Every instance
|
|
131
|
+
// that existed before that ADR landed has no `platform_identity_projects` row and no
|
|
132
|
+
// `platform_identity_sso_clients` row, so `POST /projects/invite` answers 404 `unknown_project`
|
|
133
|
+
// and its owner cannot invite anyone into a project they own. This is the repair, and it is
|
|
134
|
+
// deliberately conditional: re-projecting an instance that is already on the map would churn
|
|
135
|
+
// its `updated_at` on every owner page-load and every boot.
|
|
136
|
+
//
|
|
137
|
+
// SAFE ON A ROW SOMEONE ELSE HOLDS. "Already catalogued" is asked of the ORIGIN alone, so a
|
|
138
|
+
// hand-seeded row (owner NULL, unclaimable by ADR 0246's claim rule 2) counts as present and
|
|
139
|
+
// is never written to. Best-effort like every other call site: it never throws, and it reports
|
|
140
|
+
// why it did nothing.
|
|
141
|
+
//
|
|
142
|
+
// THE ALREADY-HEALED CASE COSTS ONE QUERY — the presence read, and nothing else. That is why
|
|
143
|
+
// the order below is address → map → owner rather than owner first: this rides an owner's
|
|
144
|
+
// manage-page read forever, long after the one boot that repaired the instance, so the steady
|
|
145
|
+
// state has to be the cheap one. The owner lookup happens inside `publishInstanceToCatalog`,
|
|
146
|
+
// i.e. only when there is actually a row to write.
|
|
147
|
+
async function healInstanceCatalog(db, row) {
|
|
148
|
+
const catalog = api.resolveOptional('project.catalog');
|
|
149
|
+
if (!catalog || typeof catalog.catalogued !== 'function') return { healed: false, reason: 'no_catalog' };
|
|
150
|
+
const origin = backfillOriginForInstance(row);
|
|
151
|
+
if (!origin) return { healed: false, reason: 'not_backfillable' };
|
|
152
|
+
try {
|
|
153
|
+
const present = await catalog.catalogued([origin]);
|
|
154
|
+
if (present.includes(origin)) return { healed: false, reason: 'already_catalogued' };
|
|
155
|
+
const out = await publishInstanceToCatalog(db, row);
|
|
156
|
+
return { healed: !!out.published, reason: out.published ? null : out.reason };
|
|
157
|
+
} catch (err) {
|
|
158
|
+
log.error({ err: err.message, slug: row && row.slug }, 'catalog heal failed (non-fatal)');
|
|
159
|
+
return { healed: false, reason: 'error' };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
101
163
|
module.exports = {
|
|
102
164
|
CATALOG_HIDDEN_STATES,
|
|
103
165
|
catalogEntryForInstance,
|
|
166
|
+
backfillOriginForInstance,
|
|
104
167
|
publishInstanceToCatalog,
|
|
168
|
+
healInstanceCatalog,
|
|
105
169
|
};
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"contributes": {
|
|
13
13
|
"routes": ["provisioning"],
|
|
14
14
|
"migrations": true,
|
|
15
|
-
"pollers": ["liveness-sweep"]
|
|
15
|
+
"pollers": ["liveness-sweep", "catalog-backfill"]
|
|
16
16
|
},
|
|
17
17
|
"provides": ["provisioning.instanceState", "provisioning.projectPlanets", "provisioning.projectOwnedBy"],
|
|
18
18
|
"consumes": []
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/provisioning/pollers/catalog-backfill.js — THE ONE-SHOT REPAIR OF HISTORY behind
|
|
4
|
+
// ADR 0246's catalog projection (task 1003581). The loader's startModulePollers requires this
|
|
5
|
+
// file and calls start(deps) at boot for the ENABLED module only — the liveness-sweep shape.
|
|
6
|
+
//
|
|
7
|
+
// WHY. ADR 0246 made the hub catalog row a PROJECTION of the provisioning row, re-taken at
|
|
8
|
+
// every write that can change it. A write-triggered projection is correct going forward and
|
|
9
|
+
// says nothing about the past: every instance that existed before that ADR landed has no
|
|
10
|
+
// catalog row and gets one only if its owner happens to edit its detail, its settings or its
|
|
11
|
+
// address. Until then `POST /projects/invite` resolves nothing and answers 404
|
|
12
|
+
// `unknown_project`, so **an owner cannot invite anyone into a project they own** — which is
|
|
13
|
+
// what blocked the C6 / BV1.R30 proof on live. This sweep is how the fleet heals itself on the
|
|
14
|
+
// next core upgrade, with nobody hand-running SQL.
|
|
15
|
+
//
|
|
16
|
+
// ONE-SHOT, NOT A CADENCE. Ongoing correctness is already the four write call sites' job, plus
|
|
17
|
+
// the owner-read self-heal in routes/provisioning.js; a repeating sweep would find nothing on
|
|
18
|
+
// every tick after the first. It runs once, a few seconds after boot, and stops.
|
|
19
|
+
//
|
|
20
|
+
// IT IS THE SAME REPAIR THE OWNER READ DOES, asked in one question instead of N. Both paths
|
|
21
|
+
// share `backfillOriginForInstance` (what is projectable) and `publishInstanceToCatalog` (the
|
|
22
|
+
// write, with ADR 0246's two claim rules inside its own statements), so they cannot drift; only
|
|
23
|
+
// the shape of the presence question differs — the sweep hands the whole fleet's origins to
|
|
24
|
+
// `catalogued` at once. In particular the hand-seeded catalog rows (owner_github_id NULL) come
|
|
25
|
+
// back as PRESENT and are never written.
|
|
26
|
+
//
|
|
27
|
+
// SAFE TO RUN EVERY START: idempotent (an instance already on the map is skipped, not
|
|
28
|
+
// re-written), bounded (SWEEP_LIMIT), logged through the module logger, and it never throws —
|
|
29
|
+
// `healInstanceCatalog` swallows its own errors and the tick has its own catch, because a hub
|
|
30
|
+
// hiccup must never take the boot.
|
|
31
|
+
|
|
32
|
+
const api = require('../../../src/module-api');
|
|
33
|
+
const provisioning = require('../provisioning');
|
|
34
|
+
const bridge = require('../catalog-bridge');
|
|
35
|
+
|
|
36
|
+
const log = api.logger('provisioning');
|
|
37
|
+
|
|
38
|
+
// A few seconds after boot: late enough that the route mounts have registered the
|
|
39
|
+
// `project.catalog` port, early enough that an owner opening their manage page finds the
|
|
40
|
+
// repair already done.
|
|
41
|
+
const FIRST_SWEEP_DELAY_MS = Number(process.env.PROVISIONING_CATALOG_BACKFILL_MS) || 15_000;
|
|
42
|
+
// Runaway guard, not an expected limit — the whole point is that this is a finite backlog.
|
|
43
|
+
const SWEEP_LIMIT = 500;
|
|
44
|
+
// Keep the one-line account readable on a big fleet.
|
|
45
|
+
const MAX_NAMES_SHOWN = 8;
|
|
46
|
+
|
|
47
|
+
let timer = null;
|
|
48
|
+
let started = false;
|
|
49
|
+
|
|
50
|
+
// The per-sweep disposition line, PURE so its wording is unit-tested without a sweep:
|
|
51
|
+
// [catalog-backfill] 22 instances: 5 projected (mercury, lars-test) · 1 already · 16 skipped
|
|
52
|
+
function formatSweepLine({ considered = 0, projected = 0, already = 0, skipped = 0, refused = 0, names = [] } = {}) {
|
|
53
|
+
const shown = names.slice(0, MAX_NAMES_SHOWN).join(', ');
|
|
54
|
+
const more = names.length > MAX_NAMES_SHOWN ? `, +${names.length - MAX_NAMES_SHOWN} more` : '';
|
|
55
|
+
const detail = names.length ? ` (${shown}${more})` : '';
|
|
56
|
+
const refusedPart = refused ? ` · ${refused} refused` : '';
|
|
57
|
+
return `[catalog-backfill] ${considered} instances: ${projected} projected${detail}`
|
|
58
|
+
+ ` · ${already} already · ${skipped} skipped${refusedPart}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ONE pass over the fleet, projecting every instance the map does not already carry.
|
|
62
|
+
// Exported + dependency-injected (pool, log) so the tests drive it directly — the timer is
|
|
63
|
+
// only a schedule.
|
|
64
|
+
//
|
|
65
|
+
// THE PRESENCE QUESTION IS ASKED ONCE, for the whole fleet. Calling the per-instance heal in a
|
|
66
|
+
// loop would be a round trip per row — the N+1 shape — where `catalogued` was built to take a
|
|
67
|
+
// list. It shares the heal path's rules rather than its composition: `backfillOriginForInstance`
|
|
68
|
+
// decides what is projectable and `publishInstanceToCatalog` does the write, so the two paths
|
|
69
|
+
// cannot disagree about either. What remains per-row is one primary-key owner lookup inside the
|
|
70
|
+
// write, and only for a row actually being repaired — once in that instance's lifetime.
|
|
71
|
+
async function sweepOnce(deps = {}) {
|
|
72
|
+
const db = deps.pool || api.pool;
|
|
73
|
+
const emit = deps.log || ((line) => log.info(line));
|
|
74
|
+
// No hub, no map to be on — the `publishVerdict` precedent. Nothing to say, nothing to do.
|
|
75
|
+
const catalog = api.resolveOptional('project.catalog');
|
|
76
|
+
if (!catalog || typeof catalog.catalogued !== 'function') return { considered: 0, skipped: 0, reason: 'no_catalog' };
|
|
77
|
+
|
|
78
|
+
const rows = (await provisioning.listAllInstances(db)).slice(0, SWEEP_LIMIT);
|
|
79
|
+
const candidates = rows
|
|
80
|
+
.map((row) => ({ row, origin: bridge.backfillOriginForInstance(row) }))
|
|
81
|
+
.filter((c) => c.origin);
|
|
82
|
+
const out = {
|
|
83
|
+
considered: rows.length, projected: 0, already: 0,
|
|
84
|
+
skipped: rows.length - candidates.length, refused: 0, names: [],
|
|
85
|
+
};
|
|
86
|
+
if (!candidates.length) return out;
|
|
87
|
+
|
|
88
|
+
let present;
|
|
89
|
+
try {
|
|
90
|
+
present = new Set(await catalog.catalogued(candidates.map((c) => c.origin)));
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log.error({ err: err && err.message ? err.message : String(err) }, 'catalog backfill: the map could not be read');
|
|
93
|
+
return { ...out, reason: 'error' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const { row, origin } of candidates) {
|
|
97
|
+
if (present.has(origin)) { out.already++; continue; }
|
|
98
|
+
const result = await bridge.publishInstanceToCatalog(db, row);
|
|
99
|
+
if (result.published) { out.projected++; out.names.push(row.slug); }
|
|
100
|
+
else if (result.reason === 'origin_held' || result.reason === 'error') out.refused++;
|
|
101
|
+
else out.skipped++;
|
|
102
|
+
}
|
|
103
|
+
// A fleet with nothing to repair says nothing — a control plane that has already healed
|
|
104
|
+
// should not print an empty account at every restart.
|
|
105
|
+
if (out.projected || out.refused) emit(formatSweepLine(out));
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Arm the one-shot sweep. Idempotent (a second call is a no-op) and kill-switched by
|
|
110
|
+
// PROVISIONING_CATALOG_BACKFILL_DISABLED=1. The timer is unref'd so it never holds the
|
|
111
|
+
// process open, and the tick's own catch means a failure is a log line, never a dead boot.
|
|
112
|
+
function start(deps = {}) {
|
|
113
|
+
if (process.env.PROVISIONING_CATALOG_BACKFILL_DISABLED === '1') return;
|
|
114
|
+
if (started) return;
|
|
115
|
+
started = true;
|
|
116
|
+
timer = setTimeout(() => {
|
|
117
|
+
sweepOnce(deps).catch((e) => log.error({ err: e && e.message ? e.message : String(e) }, 'catalog backfill sweep crashed'));
|
|
118
|
+
}, FIRST_SWEEP_DELAY_MS);
|
|
119
|
+
if (timer.unref) timer.unref();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function stop() {
|
|
123
|
+
started = false;
|
|
124
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
start,
|
|
129
|
+
stop,
|
|
130
|
+
// exported for tests
|
|
131
|
+
sweepOnce,
|
|
132
|
+
formatSweepLine,
|
|
133
|
+
SWEEP_LIMIT,
|
|
134
|
+
};
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// POST /provisioning/instances — request a new instance
|
|
15
15
|
// GET /provisioning/instances — list the caller's instances
|
|
16
16
|
// GET /provisioning/instances/:id — read one of the caller's instances
|
|
17
|
+
// (also SELF-HEALS its catalog projection,
|
|
18
|
+
// task 1003581 — see the handler)
|
|
17
19
|
// POST /provisioning/instances/:id/teardown — request teardown of one's own
|
|
18
20
|
// Archon-scoped (requireRank('archon') — the fleet, the /boxes precedent):
|
|
19
21
|
// GET /provisioning/fleet — the full instance roster
|
|
@@ -653,6 +655,14 @@ module.exports = function provisioningRoutes() {
|
|
|
653
655
|
if (instanceAccessDenied(inst, req.builder)) {
|
|
654
656
|
return res.fail('instance_not_found', 404);
|
|
655
657
|
}
|
|
658
|
+
// SELF-HEAL THE CATALOG PROJECTION (task 1003581). ADR 0246's projection is
|
|
659
|
+
// write-triggered, so an instance older than it has no catalog row and its owner's
|
|
660
|
+
// invites 404 until they happen to save something. This own-scoped read — the manage
|
|
661
|
+
// page, past the access gate above — is the moment to close that. Conditional and
|
|
662
|
+
// best-effort: an instance already on the map costs ONE presence read and no write,
|
|
663
|
+
// which is the steady state this rides in forever, and it never throws, so it can
|
|
664
|
+
// never turn a read into a failure.
|
|
665
|
+
await catalogBridge.healInstanceCatalog(pool, inst).catch(() => {});
|
|
656
666
|
const open = await provisioning.getOpenIntent(pool, id);
|
|
657
667
|
// The GitHub-App leg's truth rides the same read (task 1003235), so the done
|
|
658
668
|
// panel recovers it from server state after a reload — not from sessionStorage.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.542",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.542",
|
|
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.19.
|
|
3
|
+
"version": "1.19.542",
|
|
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/src/module-api.js
CHANGED
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.542'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// tests/blocker_detail_hydration.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002459 — GET /blockers/:id was missing `blocking_task_ids`, the array
|
|
4
|
+
// the list route (GET /blockers) already returns for the same row. A caller
|
|
5
|
+
// doing the natural link-then-verify (link a task, then re-fetch the blocker
|
|
6
|
+
// by id to confirm) read that as a false negative — the ids were never gone,
|
|
7
|
+
// `getBlocker`'s SELECT just never asked for them.
|
|
8
|
+
//
|
|
9
|
+
// The fix hoists the array_agg fragment into one shared constant
|
|
10
|
+
// (BLOCKING_TASK_IDS_SQL) so `listOpenBlockers` and `getBlocker` cannot drift
|
|
11
|
+
// apart again the way they did here. This test pins that: both queries must
|
|
12
|
+
// carry the identical fragment, and the detail route's SELECT must not add a
|
|
13
|
+
// status filter the list route has (task_blockers rows persist after resolve,
|
|
14
|
+
// so a resolved blocker's field reads as "tasks it WAS blocking" — a
|
|
15
|
+
// deliberate widening, not a bug, per the task's decision note).
|
|
16
|
+
//
|
|
17
|
+
// DB-free: `modules/ideas/blockers.js` destructures `pool` off the doorway at
|
|
18
|
+
// require time, so patching `pool.query` on that same object — BEFORE
|
|
19
|
+
// requiring blockers.js — intercepts every query. No live Postgres needed.
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/blocker_detail_hydration.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
|
|
28
|
+
const api = require('../src/module-api.js');
|
|
29
|
+
|
|
30
|
+
let queries = [];
|
|
31
|
+
|
|
32
|
+
api.pool.query = async (sql, params = []) => {
|
|
33
|
+
queries.push({ sql: String(sql), params });
|
|
34
|
+
if (/FROM blockers b\s+WHERE b\.status = 'open'/.test(sql)) {
|
|
35
|
+
return {
|
|
36
|
+
rows: [
|
|
37
|
+
{ id: '1000085', title: 'the open one', body_md: '', status: 'open',
|
|
38
|
+
created_at: new Date(), source: 'manual', source_ref: null,
|
|
39
|
+
blocking_task_ids: ['1001838', '1001839'] },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (/FROM blockers b WHERE b\.id = \$1/.test(sql)) {
|
|
44
|
+
const id = String(params[0]);
|
|
45
|
+
if (id === '1000085') {
|
|
46
|
+
return {
|
|
47
|
+
rows: [
|
|
48
|
+
{ id: '1000085', title: 'the open one', body_md: '', status: 'open',
|
|
49
|
+
created_at: new Date(), resolved_at: null, resolved_by: null,
|
|
50
|
+
resolution_note: null, source: 'manual', source_ref: null,
|
|
51
|
+
blocking_task_ids: ['1001838', '1001839'] },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (id === '1000099') {
|
|
56
|
+
// A resolved blocker: task_blockers rows persist, so the field still
|
|
57
|
+
// reports the tasks it WAS blocking (see comment above).
|
|
58
|
+
return {
|
|
59
|
+
rows: [
|
|
60
|
+
{ id: '1000099', title: 'the resolved one', body_md: '', status: 'resolved',
|
|
61
|
+
created_at: new Date(), resolved_at: new Date(), resolved_by: '41',
|
|
62
|
+
resolution_note: 'done', source: 'manual', source_ref: null,
|
|
63
|
+
blocking_task_ids: ['1002000'] },
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { rows: [] };
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const blockers = require('../modules/ideas/blockers.js');
|
|
73
|
+
|
|
74
|
+
let passed = 0;
|
|
75
|
+
let failed = 0;
|
|
76
|
+
|
|
77
|
+
async function test(name, fn) {
|
|
78
|
+
queries = [];
|
|
79
|
+
try {
|
|
80
|
+
await fn();
|
|
81
|
+
passed += 1;
|
|
82
|
+
console.log(` ok ${name}`);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
failed += 1;
|
|
85
|
+
console.log(` FAIL ${name}\n ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function blockingTaskIdsFragment(sql) {
|
|
90
|
+
const match = sql.match(/COALESCE\(\s*\(SELECT array_agg\(tb\.task_id[\s\S]*?blocking_task_ids/);
|
|
91
|
+
assert.ok(match, `query does not carry a blocking_task_ids projection:\n${sql}`);
|
|
92
|
+
return match[0].replace(/\s+/g, ' ').trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await test('listOpenBlockers and getBlocker project blocking_task_ids from the identical fragment', async () => {
|
|
96
|
+
await blockers.listOpenBlockers();
|
|
97
|
+
const listSql = queries[queries.length - 1].sql;
|
|
98
|
+
|
|
99
|
+
queries = [];
|
|
100
|
+
await blockers.getBlocker(1000085);
|
|
101
|
+
const detailSql = queries[queries.length - 1].sql;
|
|
102
|
+
|
|
103
|
+
assert.equal(blockingTaskIdsFragment(detailSql), blockingTaskIdsFragment(listSql));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await test('getBlocker returns blocking_task_ids on the row — the gap this task closes', async () => {
|
|
107
|
+
const row = await blockers.getBlocker(1000085);
|
|
108
|
+
assert.deepEqual(row.blocking_task_ids, ['1001838', '1001839']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await test('getBlocker does not restrict by status — the list route is open-only, the detail route is not', async () => {
|
|
112
|
+
const detailSql = (await (async () => { await blockers.getBlocker(1000085); return queries[queries.length - 1].sql; })());
|
|
113
|
+
assert.ok(!/WHERE b\.status = 'open'/.test(detailSql), 'detail query must not gain the list route\'s open-only filter');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await test('a resolved blocker still reports the tasks it WAS blocking (task_blockers rows persist)', async () => {
|
|
117
|
+
const row = await blockers.getBlocker(1000099);
|
|
118
|
+
assert.equal(row.status, 'resolved');
|
|
119
|
+
assert.deepEqual(row.blocking_task_ids, ['1002000']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('getBlocker still returns null for an id with no row', async () => {
|
|
123
|
+
const row = await blockers.getBlocker(999999);
|
|
124
|
+
assert.equal(row, null);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
128
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -227,10 +227,14 @@ test('mounting the projects routes registers the `project.catalog` port', async
|
|
|
227
227
|
assert.ok(Object.isFrozen(catalog.projectCatalogPort), 'a consumer must not be able to re-point the port');
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
-
test('the port declares the catalog write and nothing else', () => {
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
230
|
+
test('the port declares the catalog write, one presence read, and nothing else', () => {
|
|
231
|
+
// Two verbs, and the reason for the ceiling is unchanged: a consumer maps its own row onto
|
|
232
|
+
// the entry shape and asks about ORIGINS, so the catalog never learns another module's
|
|
233
|
+
// schema and no module gets a general-purpose handle on these tables. `catalogued` arrived
|
|
234
|
+
// with the backfill (task 1003581) because healing history without a presence read means
|
|
235
|
+
// re-writing every catalog row at every boot. Anything wider than these two is a new
|
|
236
|
+
// decision, and this list is where it has to be made.
|
|
237
|
+
assert.deepEqual(Object.keys(catalog.projectCatalogPort).sort(), ['catalogued', 'publish']);
|
|
234
238
|
});
|
|
235
239
|
|
|
236
240
|
test('the module manifest DECLARES the port it registers', async () => {
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// tests/project_catalog_backfill.mjs — THE REPAIR OF HISTORY behind ADR 0246 (task 1003581).
|
|
2
|
+
//
|
|
3
|
+
// The bug: ADR 0246's catalog projection is WRITE-TRIGGERED. Its four call sites are all
|
|
4
|
+
// provisioning writes (create, the detail answers, the settings door, a re-pointed address),
|
|
5
|
+
// so every instance that existed BEFORE that ADR landed has no catalog row and gets one only
|
|
6
|
+
// if its owner happens to save something. Until then `POST /projects/invite` resolves nothing
|
|
7
|
+
// and answers 404 `unknown_project` — an owner cannot invite anyone into a project they own,
|
|
8
|
+
// which is what halted the C6 / BV1.R30 proof on live.
|
|
9
|
+
//
|
|
10
|
+
// What this file pins:
|
|
11
|
+
// 1. THE REPAIR — an instance the map does not carry is projected, through the SAME
|
|
12
|
+
// projection the write paths use.
|
|
13
|
+
// 2. IT IS IDEMPOTENT. An instance already on the map is skipped, not re-written: a heal
|
|
14
|
+
// that wrote unconditionally would churn every row's updated_at at every boot and on
|
|
15
|
+
// every owner page-load, which is the reason the port grew a READ at all.
|
|
16
|
+
// 3. IT REFUSES THE SAME THINGS THE PROJECTION REFUSES — no address, no owner identity, and
|
|
17
|
+
// a reclaimed instance whose row would exist only to be hidden.
|
|
18
|
+
// 4. THE HAND-SEEDED ROWS STAY UNCLAIMABLE (ADR 0246's two claim rules). An origin the map
|
|
19
|
+
// already holds is never written, whoever owns it — and if it somehow were, the upsert's
|
|
20
|
+
// own ON CONFLICT … WHERE refuses.
|
|
21
|
+
// 5. A STEALTH PROJECT REACHES THE MAP ANONYMOUSLY. The backfill puts rows on the map that
|
|
22
|
+
// were never there, so D2a has to hold for what it adds.
|
|
23
|
+
//
|
|
24
|
+
// DB-free: a recording fake hub standing in for the `project.catalog` port, a recording fake
|
|
25
|
+
// pool for the owner lookup, the real bridge/poller/catalog for everything else.
|
|
26
|
+
//
|
|
27
|
+
// Run: node --test --test-reporter=tap tests/project_catalog_backfill.mjs
|
|
28
|
+
|
|
29
|
+
import assert from 'node:assert/strict';
|
|
30
|
+
import { test, beforeEach } from 'node:test';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const api = require('../src/module-api.js');
|
|
35
|
+
const bridge = require('../modules/provisioning/catalog-bridge.js');
|
|
36
|
+
const backfill = require('../modules/provisioning/pollers/catalog-backfill.js');
|
|
37
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
38
|
+
const catalog = require('../modules/platform-identity/project-catalog.js');
|
|
39
|
+
const feed = require('../modules/platform-identity/projects-feed.js');
|
|
40
|
+
|
|
41
|
+
// A live standalone instance from before ADR 0246: fully projectable, and on the map only if
|
|
42
|
+
// something puts it there. Instance 18's shape — the one whose owner could not invite anyone.
|
|
43
|
+
const STANDALONE = Object.freeze({
|
|
44
|
+
id: 18, slug: 'spike-harbor', domain: 'spike-harbor.cloudbongos.test',
|
|
45
|
+
hosting_shape: 'standalone', status: 'active', owner_builder_id: 42,
|
|
46
|
+
description: 'A harbour for spikes.', type: 'research',
|
|
47
|
+
settings: { visibility: 'public' },
|
|
48
|
+
});
|
|
49
|
+
const ORIGIN = 'https://spike-harbor.cloudbongos.test';
|
|
50
|
+
|
|
51
|
+
// The owner lookup the projection makes, and nothing else. Records every statement so a test
|
|
52
|
+
// can assert what the repair did — and did not — ask the database.
|
|
53
|
+
function fakePool(githubIdByBuilder = { 42: 9001 }) {
|
|
54
|
+
const calls = [];
|
|
55
|
+
return {
|
|
56
|
+
calls,
|
|
57
|
+
query: async (text, params = []) => {
|
|
58
|
+
calls.push({ text: String(text), params });
|
|
59
|
+
if (/FROM builders/i.test(String(text))) {
|
|
60
|
+
const gid = githubIdByBuilder[String(params[0])];
|
|
61
|
+
return { rows: gid ? [{ github_id: gid }] : [] };
|
|
62
|
+
}
|
|
63
|
+
return { rows: [] };
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A recording fake pool with queued responses, for asserting a statement's SHAPE.
|
|
69
|
+
function mockPool(responses = []) {
|
|
70
|
+
const calls = [];
|
|
71
|
+
let i = 0;
|
|
72
|
+
return {
|
|
73
|
+
calls,
|
|
74
|
+
query: async (text, params) => {
|
|
75
|
+
calls.push({ text: String(text), params: params || [] });
|
|
76
|
+
return responses[i++] || { rows: [], rowCount: 0 };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The fake hub: `origins` is what the map already carries. Registered from INSIDE the first
|
|
82
|
+
// test rather than at import, because the port registry is process-global and single-provider
|
|
83
|
+
// — the "no hub at all" case can only be observed before anything registers one.
|
|
84
|
+
const hub = { origins: new Set(), publishes: [], reads: [], claimed: true, throws: false };
|
|
85
|
+
|
|
86
|
+
function registerHub() {
|
|
87
|
+
api.registerProvider('project.catalog', {
|
|
88
|
+
publish: async (entry, opts) => {
|
|
89
|
+
if (hub.throws) throw new Error('hub unreachable');
|
|
90
|
+
hub.publishes.push({ entry, opts });
|
|
91
|
+
if (hub.claimed) hub.origins.add(entry.origin);
|
|
92
|
+
return { claimed: hub.claimed };
|
|
93
|
+
},
|
|
94
|
+
catalogued: async (origins) => {
|
|
95
|
+
hub.reads.push(origins);
|
|
96
|
+
return origins.filter((o) => hub.origins.has(o));
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── 0. the seam, FIRST, for the reason above ──────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
test('with NO catalog provider the repair is an honest no-op, on both paths', async () => {
|
|
104
|
+
// platform-identity is hub-only (`default: false`). An instance running provisioning
|
|
105
|
+
// without it has no map to be repaired onto, and the honest answer is a reason, never an
|
|
106
|
+
// error — the `publishVerdict` precedent.
|
|
107
|
+
assert.equal(api.hasProvider('project.catalog'), false, 'nothing has registered one yet');
|
|
108
|
+
assert.deepEqual(await bridge.healInstanceCatalog(fakePool(), STANDALONE),
|
|
109
|
+
{ healed: false, reason: 'no_catalog' });
|
|
110
|
+
|
|
111
|
+
// And the boot sweep does not even list the fleet: a vanilla control plane pays nothing
|
|
112
|
+
// for a repair that has no destination.
|
|
113
|
+
const pool = fakePool();
|
|
114
|
+
const out = await backfill.sweepOnce({ pool });
|
|
115
|
+
assert.equal(out.reason, 'no_catalog');
|
|
116
|
+
assert.equal(out.considered, 0);
|
|
117
|
+
assert.equal(pool.calls.length, 0);
|
|
118
|
+
|
|
119
|
+
registerHub(); // every test below runs against the hub
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
hub.origins = new Set();
|
|
124
|
+
hub.publishes = [];
|
|
125
|
+
hub.reads = [];
|
|
126
|
+
hub.claimed = true;
|
|
127
|
+
hub.throws = false;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── 1. the repair, and 2. its idempotence ─────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
test('an instance the map does not carry is PROJECTED — the pre-ADR-0246 instance heals', async () => {
|
|
133
|
+
// Arrange — the map is empty, exactly as it was for every instance older than the ADR.
|
|
134
|
+
const pool = fakePool();
|
|
135
|
+
|
|
136
|
+
// Act
|
|
137
|
+
const out = await bridge.healInstanceCatalog(pool, STANDALONE);
|
|
138
|
+
|
|
139
|
+
// Assert — one publish, carrying the SAME projection the four write call sites hand over:
|
|
140
|
+
// slug as both name and client_id, the owner's github id for attribution.
|
|
141
|
+
assert.equal(out.healed, true);
|
|
142
|
+
assert.equal(hub.publishes.length, 1);
|
|
143
|
+
assert.equal(hub.publishes[0].entry.origin, ORIGIN);
|
|
144
|
+
assert.equal(hub.publishes[0].entry.clientId, 'spike-harbor');
|
|
145
|
+
assert.equal(hub.publishes[0].entry.ownerGithubId, 9001);
|
|
146
|
+
assert.equal(hub.publishes[0].entry.status, 'active');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('a SECOND heal writes NOTHING — the repair is idempotent, not a re-write', async () => {
|
|
150
|
+
// The property that lets this run on every owner page-load and at every boot. Without it
|
|
151
|
+
// the heal would churn every catalog row's updated_at forever.
|
|
152
|
+
const pool = fakePool();
|
|
153
|
+
await bridge.healInstanceCatalog(pool, STANDALONE);
|
|
154
|
+
assert.equal(hub.publishes.length, 1);
|
|
155
|
+
|
|
156
|
+
const second = await bridge.healInstanceCatalog(pool, STANDALONE);
|
|
157
|
+
assert.deepEqual(second.reason, 'already_catalogued');
|
|
158
|
+
assert.equal(second.healed, false);
|
|
159
|
+
assert.equal(hub.publishes.length, 1, 'the map was asked, and nothing was written');
|
|
160
|
+
assert.deepEqual(hub.reads.at(-1), [ORIGIN]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('the ALREADY-HEALED case costs one presence read and NO owner lookup', async () => {
|
|
164
|
+
// This is the steady state: every owner manage-page load, forever, long after the one boot
|
|
165
|
+
// that repaired the instance. Resolving the owner just to build an origin string would be a
|
|
166
|
+
// second query per read for the rest of the instance's life, so the order is address → map
|
|
167
|
+
// → owner, and the owner lookup lives inside the write.
|
|
168
|
+
hub.origins = new Set([ORIGIN]);
|
|
169
|
+
const pool = fakePool();
|
|
170
|
+
const out = await bridge.healInstanceCatalog(pool, STANDALONE);
|
|
171
|
+
assert.equal(out.reason, 'already_catalogued');
|
|
172
|
+
assert.equal(hub.reads.length, 1);
|
|
173
|
+
assert.deepEqual(pool.calls, [], 'the builders table is not touched to answer "is it there"');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('a hub whose port predates the presence read is a no-op, not a crash', async () => {
|
|
177
|
+
// The port is versioned only by its shape. An older platform-identity registers `publish`
|
|
178
|
+
// alone, and the heal must notice rather than call undefined — the same fail-closed reading
|
|
179
|
+
// `resolveOptional` gets when there is no hub at all.
|
|
180
|
+
const saved = api.resolveOptional('project.catalog').catalogued;
|
|
181
|
+
const port = api.resolveOptional('project.catalog');
|
|
182
|
+
delete port.catalogued;
|
|
183
|
+
try {
|
|
184
|
+
assert.deepEqual(await bridge.healInstanceCatalog(fakePool(), STANDALONE),
|
|
185
|
+
{ healed: false, reason: 'no_catalog' });
|
|
186
|
+
assert.equal((await backfill.sweepOnce({ pool: fakePool() })).reason, 'no_catalog');
|
|
187
|
+
assert.equal(hub.publishes.length, 0);
|
|
188
|
+
} finally {
|
|
189
|
+
port.catalogued = saved;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('a claim the hub REFUSES is reported, not pretended', async () => {
|
|
194
|
+
// ADR 0246 claim rule 2: an origin someone else's row already holds is refused. The heal
|
|
195
|
+
// must carry that through rather than answering "healed".
|
|
196
|
+
hub.claimed = false;
|
|
197
|
+
const out = await bridge.healInstanceCatalog(fakePool(), STANDALONE);
|
|
198
|
+
assert.equal(out.healed, false);
|
|
199
|
+
assert.equal(out.reason, 'origin_held');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a hub that throws is SWALLOWED — a repair may never break the read it rides on', async () => {
|
|
203
|
+
hub.throws = true;
|
|
204
|
+
const out = await bridge.healInstanceCatalog(fakePool(), STANDALONE);
|
|
205
|
+
assert.equal(out.healed, false);
|
|
206
|
+
assert.equal(out.reason, 'error');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── 3. the refusals — the same ones the projection already makes ──────────────────────────
|
|
210
|
+
|
|
211
|
+
test('a RECLAIMED instance is never backfilled — a hidden row is churn, not repair', async () => {
|
|
212
|
+
// The projection maps tearing_down/torn_down → 'hidden'. Creating a row for a project that
|
|
213
|
+
// was never on the map, purely so it can be invisible, repairs nothing.
|
|
214
|
+
for (const status of ['tearing_down', 'torn_down']) {
|
|
215
|
+
const out = await bridge.healInstanceCatalog(fakePool(), { ...STANDALONE, status });
|
|
216
|
+
assert.equal(out.reason, 'not_backfillable', `${status} must not be backfilled`);
|
|
217
|
+
}
|
|
218
|
+
assert.equal(hub.publishes.length, 0);
|
|
219
|
+
assert.equal(hub.reads.length, 0, 'the hub is not even asked about a row we would not write');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('an instance still standing up IS backfilled — the map is a directory, not an uptime board', async () => {
|
|
223
|
+
// ADR 0246: 'requested', 'provisioning' and 'error' are all "the owner is standing this up".
|
|
224
|
+
// Live instance 20 `walk-lantern` sits at 'error' with a domain and an owner, and its owner
|
|
225
|
+
// is as unable to invite anyone as instance 18's was.
|
|
226
|
+
for (const status of ['requested', 'provisioning', 'error']) {
|
|
227
|
+
hub.origins = new Set();
|
|
228
|
+
const out = await bridge.healInstanceCatalog(fakePool(), { ...STANDALONE, status });
|
|
229
|
+
assert.equal(out.healed, true, `${status} is a project that exists`);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('an instance with NO ADDRESS asks the hub nothing at all', async () => {
|
|
234
|
+
// The catalog is keyed on origin, so there is nothing to ask about. Answered from the row
|
|
235
|
+
// alone — no round trip is spent discovering what the projection already knows.
|
|
236
|
+
const pool = fakePool();
|
|
237
|
+
const out = await bridge.healInstanceCatalog(pool, { ...STANDALONE, domain: null });
|
|
238
|
+
assert.equal(out.reason, 'not_backfillable');
|
|
239
|
+
assert.equal(hub.reads.length, 0);
|
|
240
|
+
assert.equal(pool.calls.length, 0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('an owner with no github identity is refused by the WRITE, not written un-refreshable', async () => {
|
|
244
|
+
// The hub's claim rule compares owners, so a row stamped with a null owner could never be
|
|
245
|
+
// refreshed. It is `publishInstanceToCatalog`'s own refusal — which is also why the owner is
|
|
246
|
+
// looked up there and not before the presence check.
|
|
247
|
+
const out = await bridge.healInstanceCatalog(fakePool({}), STANDALONE);
|
|
248
|
+
assert.equal(out.healed, false);
|
|
249
|
+
assert.equal(out.reason, 'no_owner_identity');
|
|
250
|
+
assert.equal(hub.publishes.length, 0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ── 4. the hand-seeded rows stay exactly where they are ───────────────────────────────────
|
|
254
|
+
|
|
255
|
+
test('an origin the map ALREADY holds is left alone, whoever owns it', async () => {
|
|
256
|
+
// The three hand-seeded catalog rows carry owner_github_id NULL and no provisioning row.
|
|
257
|
+
// "Is there a row at this origin" is deliberately not an ownership question, so a backfill
|
|
258
|
+
// that happened to meet one skips it instead of trying — and ADR 0246's consequence that
|
|
259
|
+
// those rows are an owner data decision, never a code change, survives this task.
|
|
260
|
+
hub.origins = new Set([ORIGIN]);
|
|
261
|
+
const out = await bridge.healInstanceCatalog(fakePool(), STANDALONE);
|
|
262
|
+
assert.equal(out.reason, 'already_catalogued');
|
|
263
|
+
assert.equal(hub.publishes.length, 0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('the presence read filters on ORIGIN alone — never on owner, never on status', async () => {
|
|
267
|
+
// If it filtered on owner, a hand-seeded row (owner NULL) would read as ABSENT and the
|
|
268
|
+
// backfill would try to claim it at every boot. If it filtered on status, a hidden row
|
|
269
|
+
// would too. Both are the same mistake, and this is where it would be made.
|
|
270
|
+
const pool = mockPool([{ rows: [{ origin: ORIGIN }] }]);
|
|
271
|
+
const out = await catalog.catalogedOrigins([ORIGIN, `${ORIGIN}/`], { pool });
|
|
272
|
+
assert.deepEqual(out, [ORIGIN]);
|
|
273
|
+
const { text, params } = pool.calls[0];
|
|
274
|
+
assert.match(text, /WHERE origin = ANY\(\$1::text\[\]\)/);
|
|
275
|
+
assert.doesNotMatch(text, /owner_github_id/);
|
|
276
|
+
assert.doesNotMatch(text, /\bstatus\b/);
|
|
277
|
+
assert.deepEqual(params, [[ORIGIN]], 'a trailing slash is the same origin, asked once');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('an empty ask issues no statement', async () => {
|
|
281
|
+
const pool = mockPool();
|
|
282
|
+
assert.deepEqual(await catalog.catalogedOrigins([], { pool }), []);
|
|
283
|
+
assert.equal(pool.calls.length, 0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('the backfill writes through the SAME claim-gated upsert — a NULL owner can never match', async () => {
|
|
287
|
+
// The belt behind the read: even if the map answered "absent" for a row it holds, the
|
|
288
|
+
// upsert's own ON CONFLICT … WHERE is what refuses. `NULL = anything` is NULL, i.e. not
|
|
289
|
+
// true, so a hand-seeded row cannot be claimed by an instance at its address.
|
|
290
|
+
const pool = mockPool([{ rows: [] }]);
|
|
291
|
+
const out = await catalog.upsertOwnedProject({
|
|
292
|
+
clientId: 'spike-harbor', origin: ORIGIN, name: 'spike-harbor',
|
|
293
|
+
description: 'A harbour for spikes.', visibility: 'public', status: 'active', ownerGithubId: 9001,
|
|
294
|
+
}, { pool });
|
|
295
|
+
assert.match(pool.calls[0].text,
|
|
296
|
+
/WHERE\s+platform_identity_projects\.owner_github_id\s*=\s*EXCLUDED\.owner_github_id/);
|
|
297
|
+
assert.equal(out, null, 'a refused claim answers null, not a row');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ── 5. the boot sweep ─────────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
// The live fleet's shape in miniature: one repairable, one already on the map, one reclaimed,
|
|
303
|
+
// one with no address, one whose owner has no github identity.
|
|
304
|
+
const FLEET = Object.freeze([
|
|
305
|
+
{ ...STANDALONE },
|
|
306
|
+
{ ...STANDALONE, id: 31, slug: 'mercury', domain: 'mercury.cloudbongos.test' },
|
|
307
|
+
{ ...STANDALONE, id: 34, slug: 'testing', domain: 'testing.cloudbongos.test', status: 'torn_down' },
|
|
308
|
+
{ ...STANDALONE, id: 13, slug: 'charter', domain: null },
|
|
309
|
+
{ ...STANDALONE, id: 9, slug: 'test2', domain: 'test2.cloudbongos.test', owner_builder_id: 77 },
|
|
310
|
+
]);
|
|
311
|
+
|
|
312
|
+
provisioning.listAllInstances = async () => FLEET.map((r) => ({ ...r }));
|
|
313
|
+
|
|
314
|
+
test('the boot sweep projects exactly the instances the map does not carry', async () => {
|
|
315
|
+
// Arrange — mercury is already on the map, as instance 18 became on live the moment a
|
|
316
|
+
// sibling task wrote its detail.
|
|
317
|
+
hub.origins = new Set(['https://mercury.cloudbongos.test']);
|
|
318
|
+
|
|
319
|
+
// Act
|
|
320
|
+
const out = await backfill.sweepOnce({ pool: fakePool(), log: () => {} });
|
|
321
|
+
|
|
322
|
+
// Assert
|
|
323
|
+
assert.equal(out.considered, 5);
|
|
324
|
+
assert.equal(out.projected, 1, 'only spike-harbor is both repairable and absent');
|
|
325
|
+
assert.deepEqual(out.names, ['spike-harbor']);
|
|
326
|
+
assert.equal(out.already, 1, 'mercury');
|
|
327
|
+
assert.equal(out.skipped, 3, 'torn down · no address · no owner identity');
|
|
328
|
+
assert.equal(hub.publishes.length, 1);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('the sweep asks the map ONCE for the whole fleet — never a round trip per instance', async () => {
|
|
332
|
+
// `catalogued` takes a list because this is the caller it was built for. A per-row loop here
|
|
333
|
+
// would be a presence read per instance at every boot, which is the N+1 the batch exists to
|
|
334
|
+
// avoid — and the origins asked about are exactly the backfillable ones, no more.
|
|
335
|
+
await backfill.sweepOnce({ pool: fakePool(), log: () => {} });
|
|
336
|
+
assert.equal(hub.reads.length, 1);
|
|
337
|
+
assert.deepEqual(hub.reads[0], [
|
|
338
|
+
'https://spike-harbor.cloudbongos.test',
|
|
339
|
+
'https://mercury.cloudbongos.test',
|
|
340
|
+
'https://test2.cloudbongos.test',
|
|
341
|
+
]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('a fleet with nothing backfillable asks the map nothing', async () => {
|
|
345
|
+
const saved = provisioning.listAllInstances;
|
|
346
|
+
provisioning.listAllInstances = async () => [{ ...STANDALONE, status: 'torn_down' }];
|
|
347
|
+
try {
|
|
348
|
+
const out = await backfill.sweepOnce({ pool: fakePool(), log: () => {} });
|
|
349
|
+
assert.equal(out.considered, 1);
|
|
350
|
+
assert.equal(out.skipped, 1);
|
|
351
|
+
assert.equal(hub.reads.length, 0);
|
|
352
|
+
} finally {
|
|
353
|
+
provisioning.listAllInstances = saved;
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('a SECOND sweep writes nothing — safe to run at every single boot', async () => {
|
|
358
|
+
const deps = { pool: fakePool(), log: () => {} };
|
|
359
|
+
const first = await backfill.sweepOnce(deps);
|
|
360
|
+
assert.equal(first.projected, 2);
|
|
361
|
+
|
|
362
|
+
const second = await backfill.sweepOnce(deps);
|
|
363
|
+
assert.equal(second.projected, 0);
|
|
364
|
+
assert.equal(second.already, 2);
|
|
365
|
+
assert.equal(hub.publishes.length, 2, 'the two projected by the FIRST sweep, and no more');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('the sweep says nothing when there was nothing to repair', async () => {
|
|
369
|
+
// A control plane that has already healed must not print an account at every restart.
|
|
370
|
+
hub.origins = new Set(['https://mercury.cloudbongos.test', ORIGIN]);
|
|
371
|
+
const lines = [];
|
|
372
|
+
await backfill.sweepOnce({ pool: fakePool(), log: (l) => lines.push(l) });
|
|
373
|
+
assert.deepEqual(lines, []);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('the disposition line names what it repaired', async () => {
|
|
377
|
+
assert.equal(
|
|
378
|
+
backfill.formatSweepLine({ considered: 22, projected: 2, already: 1, skipped: 19, names: ['mercury', 'lars-test'] }),
|
|
379
|
+
'[catalog-backfill] 22 instances: 2 projected (mercury, lars-test) · 1 already · 19 skipped',
|
|
380
|
+
);
|
|
381
|
+
assert.match(backfill.formatSweepLine({ considered: 1, refused: 1 }), / · 1 refused$/);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// ── 6. what the repair puts on the map is still redactable (D2a) ──────────────────────────
|
|
385
|
+
|
|
386
|
+
test('a STEALTH instance the backfill adds is on the map ANONYMOUSLY — the row names nothing', async () => {
|
|
387
|
+
// The backfill's whole job is putting rows on the map that were never there, so the
|
|
388
|
+
// anonymisation has to hold for what it adds, not only for what the write paths added.
|
|
389
|
+
const pool = fakePool();
|
|
390
|
+
await bridge.healInstanceCatalog(pool, { ...STANDALONE, settings: { visibility: 'stealth' } });
|
|
391
|
+
const entry = catalog.normalizeEntry(hub.publishes[0].entry);
|
|
392
|
+
assert.equal(entry.visibility, 'stealth');
|
|
393
|
+
|
|
394
|
+
const wire = feed.feedShapeFor({
|
|
395
|
+
origin: entry.origin, name: entry.name, description: entry.description,
|
|
396
|
+
type: entry.type, visibility: entry.visibility, status: entry.status,
|
|
397
|
+
});
|
|
398
|
+
for (const field of ['origin', 'name', 'description', 'type']) {
|
|
399
|
+
assert.equal(wire[field], null, `${field} must not leave the server for a black hole`);
|
|
400
|
+
}
|
|
401
|
+
assert.deepEqual(Object.keys(wire).filter((k) => wire[k] !== null).sort(), ['orb', 'state']);
|
|
402
|
+
});
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
// …and one negative: a settings save that changes nothing must not write to the catalog
|
|
15
15
|
// either, for the same reason it does not restart the project.
|
|
16
16
|
//
|
|
17
|
+
// Task 1003581 adds the FIFTH call site, and it is a READ: `GET /provisioning/instances/:id`
|
|
18
|
+
// self-heals the projection, because a write-triggered projection says nothing about the
|
|
19
|
+
// instances that existed BEFORE ADR 0246 landed — their owners' invites 404 until they happen
|
|
20
|
+
// to save something. Two pins: the owner's own read reaches the heal, and a REFUSED read never
|
|
21
|
+
// does (the repair may only ever ride an own-scoped request).
|
|
22
|
+
//
|
|
17
23
|
// DB-free: the real router over loopback, data access and the bridge stubbed on the cached
|
|
18
24
|
// module objects (the provisioning_settings.mjs pattern).
|
|
19
25
|
//
|
|
@@ -40,6 +46,8 @@ const BASE_ROW = Object.freeze({
|
|
|
40
46
|
let instance = { ...BASE_ROW };
|
|
41
47
|
// Every catalog projection the routes ask for, in order.
|
|
42
48
|
let published = [];
|
|
49
|
+
// Every self-heal the READ path asks for (task 1003581).
|
|
50
|
+
let healed = [];
|
|
43
51
|
|
|
44
52
|
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
45
53
|
provisioning.getInstanceBySlug = async (_db, slug) => (instance && instance.slug === slug ? { ...instance } : null);
|
|
@@ -72,6 +80,10 @@ catalogBridge.publishInstanceToCatalog = async (_db, row, opts = {}) => {
|
|
|
72
80
|
published.push({ row: row ? { ...row } : null, ...opts });
|
|
73
81
|
return { published: true };
|
|
74
82
|
};
|
|
83
|
+
catalogBridge.healInstanceCatalog = async (_db, row) => {
|
|
84
|
+
healed.push(row ? { ...row } : null);
|
|
85
|
+
return { healed: true };
|
|
86
|
+
};
|
|
75
87
|
|
|
76
88
|
// Destructured off the doorway at route-module load — stub before requiring the router.
|
|
77
89
|
api.requireBuilder = (req, _res, next) => { req.builder = OWNER; next(); };
|
|
@@ -115,7 +127,7 @@ function call(method, path, body) {
|
|
|
115
127
|
});
|
|
116
128
|
}
|
|
117
129
|
|
|
118
|
-
beforeEach(() => { instance = { ...BASE_ROW }; published = []; });
|
|
130
|
+
beforeEach(() => { instance = { ...BASE_ROW }; published = []; healed = []; });
|
|
119
131
|
|
|
120
132
|
test('CREATE catalogues the project at birth, whatever its hosting shape', async () => {
|
|
121
133
|
// The wizard hard-codes hosting_shape 'standalone', which is exactly the shape
|
|
@@ -212,6 +224,42 @@ test('a catalog hiccup can never turn a saved change into a failure', async () =
|
|
|
212
224
|
}
|
|
213
225
|
});
|
|
214
226
|
|
|
227
|
+
test('the OWNER READ self-heals the projection, with the row it just read (task 1003581)', async () => {
|
|
228
|
+
// The repair for every instance older than ADR 0246: its owner opens the manage page and
|
|
229
|
+
// the project catalogues itself, instead of waiting for them to save something they had no
|
|
230
|
+
// reason to change. Conditional and best-effort — what it then does is pinned in
|
|
231
|
+
// tests/project_catalog_backfill.mjs.
|
|
232
|
+
const res = await call('GET', '/provisioning/instances/18');
|
|
233
|
+
assert.equal(res.status, 200);
|
|
234
|
+
assert.equal(healed.length, 1);
|
|
235
|
+
assert.equal(healed[0].slug, 'spike-harbor');
|
|
236
|
+
assert.equal(published.length, 0, 'a read is not a write — only the heal path runs');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('a REFUSED read heals NOTHING — the repair only ever rides an own-scoped request', async () => {
|
|
240
|
+
// instanceAccessDenied answers 404 for someone else's instance, and the heal must sit
|
|
241
|
+
// BEHIND that gate: an unauthenticated or wrong-owner read must never make the platform
|
|
242
|
+
// write a catalog row on a stranger's behalf.
|
|
243
|
+
instance = { ...BASE_ROW, owner_builder_id: 99 };
|
|
244
|
+
const res = await call('GET', '/provisioning/instances/18');
|
|
245
|
+
assert.equal(res.status, 404);
|
|
246
|
+
assert.equal(healed.length, 0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a heal that throws can never turn the manage read into a failure', async () => {
|
|
250
|
+
// Same rule as every other call site: the repair is best-effort, and the owner's page must
|
|
251
|
+
// load whether or not the hub answered.
|
|
252
|
+
const saved = catalogBridge.healInstanceCatalog;
|
|
253
|
+
catalogBridge.healInstanceCatalog = async () => { throw new Error('hub unreachable'); };
|
|
254
|
+
try {
|
|
255
|
+
const res = await call('GET', '/provisioning/instances/18');
|
|
256
|
+
assert.equal(res.status, 200);
|
|
257
|
+
assert.equal(res.body.instance.slug, 'spike-harbor');
|
|
258
|
+
} finally {
|
|
259
|
+
catalogBridge.healInstanceCatalog = saved;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
215
263
|
test('mounting the routes registers the `provisioning.projectOwnedBy` port (task 1003580)', async () => {
|
|
216
264
|
// The OTHER direction of the same seam: the catalog write travels provisioning → hub, and
|
|
217
265
|
// this ownership read travels hub → provisioning, so the hub's invite door can own-scope a
|