@bongos/core 1.19.361 → 1.19.363
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 +28 -18
- package/docs/copy-inventory.md +61 -61
- package/docs/copy-registry.json +70 -70
- package/migrations/core_225_search_chunks_updated_at_idx.sql +24 -0
- package/modules/hall-ui/public/government.js +35 -17
- package/modules/memory/search.js +45 -12
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/recall.js +11 -1
- package/scripts/gds/run-unit-tests.js +8 -1
- package/src/module-api.js +5 -1
- package/tests/hall_page_gate_map.mjs +5 -4
- package/tests/recall_corpus_health.mjs +38 -3
- package/tests/recall_freshness_plan_db.mjs +171 -0
|
@@ -25,14 +25,21 @@
|
|
|
25
25
|
// gated server-side on the `government.manage` atom (archon floor). On 401 →
|
|
26
26
|
// web sign-in.
|
|
27
27
|
//
|
|
28
|
-
// SINCE R15 (goal 1000069, ADR 0175) this page carries
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// the
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
28
|
+
// SINCE R15 (goal 1000069, ADR 0175) this page carries a three-section IA,
|
|
29
|
+
// because Government is the page that answers "how is this project governed"
|
|
30
|
+
// and administration is only one part of that answer. Task 1003269 reordered
|
|
31
|
+
// the tabs to Permissions · Board Room · Constitution (the administering Archon
|
|
32
|
+
// works from the first of those, and it used to sit last) and shortened the
|
|
33
|
+
// "Ranks & Permissions" label to "Permissions". Tabs are OTBKit.makeTabs with
|
|
34
|
+
// hash deep links, and the HASH IDS DID NOT CHANGE with that reorder
|
|
35
|
+
// (#constitution / #board-room / #ranks) — they are linked from Discord and
|
|
36
|
+
// docs.
|
|
37
|
+
//
|
|
38
|
+
// The Constitution and Board Room sections began as FRAMES (named here by
|
|
39
|
+
// section, not by position, so a reorder like 1003269's cannot make this
|
|
40
|
+
// comment lie): R18 filled Constitution with the live values + change history,
|
|
41
|
+
// R19 added the charter library, and R16/R17 filled the Board Room with open
|
|
42
|
+
// items and the vote. Until a frame is filled it states what belongs there
|
|
36
43
|
// and shows an honest kit empty state — never a fake or a hardcoded value that
|
|
37
44
|
// could drift from the real config.
|
|
38
45
|
|
|
@@ -412,19 +419,29 @@
|
|
|
412
419
|
|
|
413
420
|
// ---- the three-section IA (R15) -------------------------------------------
|
|
414
421
|
|
|
415
|
-
// Tab order
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
422
|
+
// Tab order puts Permissions FIRST (task 1003269, owner request): it is the
|
|
423
|
+
// surface an administering Archon actually works from, and it sat last. The
|
|
424
|
+
// label is "Permissions" — the words the owner uses for it — not the compound
|
|
425
|
+
// "Ranks & Permissions"; ranks are still what the panel lists, but permissions
|
|
426
|
+
// are the atom (ADR 0151) and the shorter label is the clearer one.
|
|
427
|
+
//
|
|
428
|
+
// THE HASH IDS ARE LOAD-BEARING AND MUST NOT CHANGE: the Discord board
|
|
429
|
+
// announcement deep-links ?item=<id>#board-room (board-broadcast.js, and the
|
|
430
|
+
// reader in loadBoard below), and #constitution/#ranks are linked from docs.
|
|
431
|
+
// Reorder and relabel freely; the ids are the contract.
|
|
432
|
+
//
|
|
433
|
+
// A non-archon still lands on the Constitution (the page's IA claim: this is
|
|
434
|
+
// the page that answers "how is this project governed"); the Archon still
|
|
435
|
+
// lands on their working surface, which is now also the first tab.
|
|
419
436
|
function wireTabs({ isArchon }) {
|
|
420
437
|
window.OTBKit.makeTabs({
|
|
421
438
|
mount: document.getElementById('gov-tabs'),
|
|
422
439
|
hash: true,
|
|
423
440
|
initial: isArchon ? 'ranks' : 'constitution',
|
|
424
441
|
tabs: [
|
|
425
|
-
{ id: '
|
|
442
|
+
{ id: 'ranks', label: 'Permissions', panel: 'gov-panel-ranks' },
|
|
426
443
|
{ id: 'board-room', label: 'Board Room', panel: 'gov-panel-board' },
|
|
427
|
-
{ id: '
|
|
444
|
+
{ id: 'constitution', label: 'Constitution', panel: 'gov-panel-constitution' },
|
|
428
445
|
],
|
|
429
446
|
});
|
|
430
447
|
}
|
|
@@ -843,15 +860,16 @@
|
|
|
843
860
|
}
|
|
844
861
|
|
|
845
862
|
// Since R16 the page shell admits page.view.government (metic+), and only the
|
|
846
|
-
//
|
|
847
|
-
// page to that one panel. Cosmetic
|
|
863
|
+
// Permissions tab (panel id gov-panel-ranks) is the archon admin surface — so
|
|
864
|
+
// the seal narrowed from the whole page to that one panel. Cosmetic
|
|
865
|
+
// defence-in-depth as ever: every
|
|
848
866
|
// /government/* CRUD endpoint still gates on government.manage server-side,
|
|
849
867
|
// and the board read carries its own atom.
|
|
850
868
|
function sealRanksPanel() {
|
|
851
869
|
const panel = document.getElementById('gov-panel-ranks');
|
|
852
870
|
const body = document.getElementById('gov-body');
|
|
853
871
|
// Replace the admin surface with the sealed note, inside its own tab only.
|
|
854
|
-
panel.innerHTML = '<p class="gov-sealed">
|
|
872
|
+
panel.innerHTML = '<p class="gov-sealed">Permissions is the Archon administration surface. The Constitution and the Board Room are yours to read.</p>';
|
|
855
873
|
body.hidden = false;
|
|
856
874
|
}
|
|
857
875
|
|
package/modules/memory/search.js
CHANGED
|
@@ -331,7 +331,8 @@ async function markChunkUsefulness(
|
|
|
331
331
|
// NOTE for anyone reading task 1003097's brief: it points at "migration 127
|
|
332
332
|
// search_ingest_runs" as an ingest ledger to consult. No such table exists in
|
|
333
333
|
// this repo (the search schema is migration 103, search_chunks alone), so
|
|
334
|
-
// freshness here is derived from
|
|
334
|
+
// freshness here is derived from updated_at on the chunks themselves — which is
|
|
335
|
+
// why it reports the last CONTENT CHANGE and not the last ingest run (1003145).
|
|
335
336
|
// Who may be told about content they cannot read. Metic+ is the boundary this
|
|
336
337
|
// repo already uses for the permission/methodology core (ADR 0043), and
|
|
337
338
|
// distinguishing "the index is empty" from "everything is above your rank"
|
|
@@ -350,7 +351,7 @@ const PRIVILEGED_DIAGNOSTIC_RANKS = ['metic', 'archon'];
|
|
|
350
351
|
// keeping in view:
|
|
351
352
|
//
|
|
352
353
|
// SCOPING (ADR 0016). The first cut returned unfiltered total/doc/db counts and
|
|
353
|
-
// a global
|
|
354
|
+
// a global freshness stamp to every caller — an oracle for the size,
|
|
354
355
|
// composition and freshness of content the caller may not read. This route
|
|
355
356
|
// family deliberately avoids that (fetchChunkBody answers one 404 for both
|
|
356
357
|
// "missing" and "not yours"). Now: the caller's own view is scoped with the
|
|
@@ -360,10 +361,23 @@ const PRIVILEGED_DIAGNOSTIC_RANKS = ['metic', 'archon'];
|
|
|
360
361
|
// COST. It also ran a full-table COUNT(*) — and during an actual outage EVERY
|
|
361
362
|
// search is a zero-result search, so that scan would run on every request from
|
|
362
363
|
// every builder for the duration. But nothing here needs a COUNT: the states
|
|
363
|
-
// turn on whether ANY row exists, not how many.
|
|
364
|
-
// first matching row, so this is two index-assisted probes rather than a scan,
|
|
365
|
-
// and it stays flat as the corpus grows. Dropping the exact numbers also
|
|
364
|
+
// turn on whether ANY row exists, not how many. Dropping the exact numbers also
|
|
366
365
|
// removes the last of the oracle.
|
|
366
|
+
//
|
|
367
|
+
// WHAT THIS QUERY COSTS (task 1003145). The comment here used to say "two
|
|
368
|
+
// index-assisted probes rather than a scan", counting only the EXISTS pair and
|
|
369
|
+
// forgetting the third subquery — which was a filtered MAX(updated_at), an
|
|
370
|
+
// AGGREGATE over the whole visible corpus, on a column migration 103 never
|
|
371
|
+
// indexed. Wrong, and load-bearing wrong: a future reader sizing this path would
|
|
372
|
+
// have trusted it. What runs now, all three bounded in the common case:
|
|
373
|
+
// * the two EXISTS probes stop at the first matching row;
|
|
374
|
+
// * the freshness probe is ORDER BY updated_at DESC LIMIT 1 over
|
|
375
|
+
// idx_search_chunks_updated_at (migration 225), so it walks the index
|
|
376
|
+
// newest-first and stops at the first row that passes the scope filter.
|
|
377
|
+
// The honest remaining worst case is a caller who can see NOTHING: no row passes
|
|
378
|
+
// the filter, so the visible-probe and the freshness probe each read to the end
|
|
379
|
+
// before they can answer. That is the `nothing_visible` state — one of the
|
|
380
|
+
// outages these three states exist to NAME, not a steady-state cost.
|
|
367
381
|
async function corpusHealth({ builderId, builderRank } = {}, { pool: p = pool, client = null } = {}) {
|
|
368
382
|
const allowedRanks = ranksAtOrBelow(builderRank);
|
|
369
383
|
const ownerId = Number.isInteger(builderId) ? builderId : (Number(builderId) || null);
|
|
@@ -379,10 +393,27 @@ async function corpusHealth({ builderId, builderRank } = {}, { pool: p = pool, c
|
|
|
379
393
|
-- for everyone else), and cheap either way because EXISTS stops at
|
|
380
394
|
-- the first row.
|
|
381
395
|
EXISTS (SELECT 1 FROM search_chunks) AS any_at_all,
|
|
382
|
-
|
|
396
|
+
-- FRESHNESS — and NOT "when the ingest last ran". Both ingesters
|
|
397
|
+
-- delta-gate their upsert (updated_at = now() fires only when a
|
|
398
|
+
-- chunk's content actually CHANGED — search-ingest-md.js,
|
|
399
|
+
-- search-ingest-db.js), so this is the newest CONTENT CHANGE the
|
|
400
|
+
-- caller can see, and a healthy nightly ingest over an unchanged
|
|
401
|
+
-- corpus leaves it perfectly still. Named for what it measures after
|
|
402
|
+
-- task 1003145: the old name, last_indexed_at, promised a liveness
|
|
403
|
+
-- signal it cannot deliver. Real ingest liveness would need an
|
|
404
|
+
-- ingest-run ledger, not a proxy on chunk mtimes.
|
|
405
|
+
--
|
|
406
|
+
-- ORDER BY … LIMIT 1 rather than MAX(): the same answer on a NOT NULL
|
|
407
|
+
-- column, but it ASKS for the bounded plan instead of depending on the
|
|
408
|
+
-- planner's MIN/MAX transform still firing. Scoped with the SAME
|
|
409
|
+
-- predicate as the search — an unscoped stamp was the freshness half
|
|
410
|
+
-- of the oracle described above.
|
|
411
|
+
(SELECT updated_at FROM search_chunks
|
|
383
412
|
WHERE min_rank = ANY($1::text[])
|
|
384
413
|
AND (owner_builder_id IS NULL OR owner_builder_id = $2::bigint)
|
|
385
|
-
|
|
414
|
+
ORDER BY updated_at DESC
|
|
415
|
+
LIMIT 1
|
|
416
|
+
) AS last_content_change_at
|
|
386
417
|
`;
|
|
387
418
|
const runner = client || p;
|
|
388
419
|
let rows;
|
|
@@ -392,13 +423,13 @@ async function corpusHealth({ builderId, builderRank } = {}, { pool: p = pool, c
|
|
|
392
423
|
// ENRICHMENT ONLY — it runs to EXPLAIN a zero result, so it must never turn
|
|
393
424
|
// a working search into an error. Nulls fail closed through
|
|
394
425
|
// classifyEmptyResult, which never reports health it cannot prove.
|
|
395
|
-
return { any_visible: null, any_at_all: null,
|
|
426
|
+
return { any_visible: null, any_at_all: null, last_content_change_at: null, privileged };
|
|
396
427
|
}
|
|
397
428
|
const r = rows[0] || {};
|
|
398
429
|
return {
|
|
399
430
|
any_visible: r.any_visible === true || r.any_visible === false ? r.any_visible : null,
|
|
400
431
|
any_at_all: r.any_at_all === true || r.any_at_all === false ? r.any_at_all : null,
|
|
401
|
-
|
|
432
|
+
last_content_change_at: r.last_content_change_at || null,
|
|
402
433
|
privileged,
|
|
403
434
|
};
|
|
404
435
|
}
|
|
@@ -409,9 +440,11 @@ async function corpusHealth({ builderId, builderRank } = {}, { pool: p = pool, c
|
|
|
409
440
|
function classifyEmptyResult(health) {
|
|
410
441
|
const anyVisible = health && typeof health.any_visible === 'boolean' ? health.any_visible : null;
|
|
411
442
|
const privileged = !!(health && health.privileged);
|
|
412
|
-
// Never echo the unscoped probe.
|
|
413
|
-
// caller's own view by the query
|
|
414
|
-
|
|
443
|
+
// Never echo the unscoped probe. last_content_change_at is already scoped to
|
|
444
|
+
// the caller's own view by the query, and is non-null ONLY when any_visible is
|
|
445
|
+
// true (the same predicate matches nothing in every other state), so in
|
|
446
|
+
// practice it rides along with no_match and is null on all three bad ones.
|
|
447
|
+
const safe = { last_content_change_at: (health && health.last_content_change_at) || null };
|
|
415
448
|
|
|
416
449
|
if (anyVisible === null) {
|
|
417
450
|
return {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.363",
|
|
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.363",
|
|
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.363",
|
|
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/scripts/gds/recall.js
CHANGED
|
@@ -93,7 +93,17 @@ function renderNoMatch({ q, corpus }) {
|
|
|
93
93
|
lines: [`No matches for "${q}". The index covers repo docs + (nightly) DB prose; try fewer / different terms, or it may not be indexed yet.`],
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
|
-
|
|
96
|
+
// "Corpus last changed", NOT "last indexed" — the server derives this from
|
|
97
|
+
// updated_at on the chunks, and both ingesters delta-gate their upsert, so it
|
|
98
|
+
// moves when CONTENT changes and stands still through a healthy ingest over an
|
|
99
|
+
// unchanged corpus. It was labelled "Last indexed:" until task 1003145, which
|
|
100
|
+
// read as an ingest-liveness signal it was never measuring.
|
|
101
|
+
//
|
|
102
|
+
// Both keys are read for the version-skew window: this CLI ships before the
|
|
103
|
+
// server pin moves, so an OLD server still answers with last_indexed_at. Same
|
|
104
|
+
// value either way — only the name was wrong.
|
|
105
|
+
const changedAt = corpus.last_content_change_at || corpus.last_indexed_at;
|
|
106
|
+
const stamp = changedAt ? ` Corpus last changed: ${changedAt}.` : '';
|
|
97
107
|
if (corpus.healthy) {
|
|
98
108
|
return { stream: 'out', exitCode: 0, lines: [`No matches for "${q}". ${corpus.message}${stamp}`] };
|
|
99
109
|
}
|
|
@@ -107,7 +107,14 @@ const INTEGRATION = new Set([
|
|
|
107
107
|
// never governs under the widened predicate.
|
|
108
108
|
'government_board_membership_db',
|
|
109
109
|
'government_fold_bench', 'government_live_proof', 'linkify_refs', 'memory_isolation',
|
|
110
|
-
'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent',
|
|
110
|
+
'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent',
|
|
111
|
+
// task 1003145: whether the PLANNER uses the migration 225 index for the recall
|
|
112
|
+
// freshness probe. The DB-free sibling (recall_corpus_health) pins the query
|
|
113
|
+
// shape and that the migration declares the index; only a real Postgres can
|
|
114
|
+
// answer whether the probe stops at the first visible row or reads the corpus —
|
|
115
|
+
// and an unverified claim about exactly that is the comment this task fixed.
|
|
116
|
+
'recall_freshness_plan_db',
|
|
117
|
+
'reward_mode_cost_plus_only',
|
|
111
118
|
'scouting_rollup_fanout', 'session_records',
|
|
112
119
|
// task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
|
|
113
120
|
// SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
|
package/src/module-api.js
CHANGED
|
@@ -1152,8 +1152,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1152
1152
|
// landed since 1.19.359 with no explicit bump. run 32881040669. (task 1002620)
|
|
1153
1153
|
// 1.19.361 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1154
1154
|
// landed since 1.19.360 with no explicit bump. run 32883230235. (task 1002620)
|
|
1155
|
+
// 1.19.362 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1156
|
+
// landed since 1.19.361 with no explicit bump. run 32886106098. (task 1002620)
|
|
1157
|
+
// 1.19.363 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1158
|
+
// landed since 1.19.362 with no explicit bump. run 32889324497. (task 1002620)
|
|
1155
1159
|
// ---------------------------------------------------------------------------
|
|
1156
|
-
const CORE_VERSION = '1.19.
|
|
1160
|
+
const CORE_VERSION = '1.19.363'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1157
1161
|
|
|
1158
1162
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1159
1163
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -68,10 +68,11 @@ const ORACLE = {
|
|
|
68
68
|
'/builders/scouting': 'requireProjectCuratePage',
|
|
69
69
|
|
|
70
70
|
// Metic+ since R16 (goal 1000069): the Government page is three rooms —
|
|
71
|
-
//
|
|
72
|
-
// archon admin surface, whose DATA routes all still
|
|
73
|
-
// individually. The shell follows the widest
|
|
74
|
-
// reads carry the vote atom, the manage
|
|
71
|
+
// Permissions · Board Room · Constitution (reordered, task 1003269) — and only
|
|
72
|
+
// the Permissions room is the archon admin surface, whose DATA routes all still
|
|
73
|
+
// gate on government.manage individually. The shell follows the widest
|
|
74
|
+
// legitimate audience; the board reads carry the vote atom, and the manage
|
|
75
|
+
// tab seals itself for non-archons.
|
|
75
76
|
'/builders/government': 'requireGovernmentPage',
|
|
76
77
|
|
|
77
78
|
// Deliberately UNGATED — the landing card, the rank ladder, the reward
|
|
@@ -122,13 +122,27 @@ test('an outage goes to STDERR with a non-zero exit', () => {
|
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
test('a genuine miss stays ordinary stdout with exit 0', () => {
|
|
125
|
-
const corpus = { ...classifyEmptyResult({ any_visible: true, any_at_all: true, privileged: false }),
|
|
125
|
+
const corpus = { ...classifyEmptyResult({ any_visible: true, any_at_all: true, privileged: false }), last_content_change_at: '2026-08-20T00:00:00.000Z' };
|
|
126
126
|
const out = renderNoMatch({ q: 'nonexistent gibberish', corpus });
|
|
127
127
|
assert.equal(out.stream, 'out');
|
|
128
128
|
assert.equal(out.exitCode, 0);
|
|
129
129
|
const text = out.lines.join('\n');
|
|
130
130
|
assert.doesNotMatch(text, /RECALL IS NOT ANSWERING/, 'never cry outage on an honest miss');
|
|
131
|
-
assert.match(text, /
|
|
131
|
+
assert.match(text, /Corpus last changed: 2026-08-20/, 'freshness is still surfaced');
|
|
132
|
+
// task 1003145: the stamp comes from chunk updated_at, and both ingesters
|
|
133
|
+
// delta-gate their upsert — so it moves on CONTENT CHANGE and stands perfectly
|
|
134
|
+
// still through a healthy ingest of an unchanged corpus. Labelling it "Last
|
|
135
|
+
// indexed" sold it as ingest liveness, the one thing it cannot report.
|
|
136
|
+
assert.doesNotMatch(text, /Last indexed/, 'the stamp must not claim to be an ingest-liveness signal');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('the stamp still renders for an OLD server sending the former key', () => {
|
|
140
|
+
// Same version-skew reason the no-diagnostic case below is covered: the CLI
|
|
141
|
+
// ships before the server pin moves, so for a while it talks to a server that
|
|
142
|
+
// still answers last_indexed_at. Same value, older name — read both.
|
|
143
|
+
const corpus = { ...classifyEmptyResult({ any_visible: true, any_at_all: true, privileged: false }), last_indexed_at: '2026-08-19T00:00:00.000Z' };
|
|
144
|
+
const text = renderNoMatch({ q: 'anything', corpus }).lines.join('\n');
|
|
145
|
+
assert.match(text, /Corpus last changed: 2026-08-19/, 'the older key still renders, under the true label');
|
|
132
146
|
});
|
|
133
147
|
|
|
134
148
|
test('a server that sends no diagnostic keeps the previous behaviour', () => {
|
|
@@ -178,7 +192,28 @@ test('corpusHealth probes EXISTENCE, scoped with the same predicate as the searc
|
|
|
178
192
|
assert.match(body, /EXISTS \(/, 'existence probes instead');
|
|
179
193
|
assert.match(body, /min_rank = ANY/, 'the visible probe is rank-scoped');
|
|
180
194
|
assert.match(body, /owner_builder_id IS NULL OR owner_builder_id/, 'with the search\'s own ownership predicate');
|
|
181
|
-
|
|
195
|
+
// task 1003145: the freshness probe used to be MAX(updated_at) — an AGGREGATE
|
|
196
|
+
// over the whole visible corpus, on an unindexed column, while the comment
|
|
197
|
+
// beside it claimed the path held no scan. ORDER BY … LIMIT 1 over
|
|
198
|
+
// idx_search_chunks_updated_at (migration 225) stops at the first row that
|
|
199
|
+
// passes the scope filter instead.
|
|
200
|
+
assert.doesNotMatch(body, /MAX\(updated_at\)/, 'freshness must not be an aggregate scan');
|
|
201
|
+
assert.match(body, /ORDER BY updated_at DESC\s+LIMIT 1/, 'it asks for the bounded plan explicitly');
|
|
202
|
+
const freshness = body.slice(body.indexOf('SELECT updated_at FROM search_chunks'));
|
|
203
|
+
assert.match(freshness.slice(0, freshness.indexOf('LIMIT 1')), /min_rank = ANY/,
|
|
204
|
+
'and the freshness probe carries the search’s scope predicate too');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('the freshness probe has an index to walk (migration 225)', () => {
|
|
208
|
+
// The LIMIT 1 only bounds anything if updated_at is ORDERED by an index —
|
|
209
|
+
// otherwise the planner sorts the visible corpus to answer it, which is the
|
|
210
|
+
// same scan wearing a new name. Migration 103 indexed tsv / body / source /
|
|
211
|
+
// owner and NOT updated_at, which is how the aggregate went unnoticed.
|
|
212
|
+
const dir = path.join(ROOT, 'migrations');
|
|
213
|
+
const sql = fs.readdirSync(dir).filter((f) => /search_chunks/.test(f))
|
|
214
|
+
.map((f) => fs.readFileSync(path.join(dir, f), 'utf8')).join('\n');
|
|
215
|
+
assert.match(sql, /CREATE INDEX IF NOT EXISTS idx_search_chunks_updated_at[\s\S]{0,80}\(updated_at DESC\)/,
|
|
216
|
+
'search_chunks must carry a descending index on updated_at');
|
|
182
217
|
});
|
|
183
218
|
|
|
184
219
|
test('the route hands the caller ONLY the classifier output', () => {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// tests/recall_freshness_plan_db.mjs — task 1003145, against a REAL Postgres.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS BESIDE tests/recall_corpus_health.mjs. That file is DB-free: it
|
|
4
|
+
// pins the SHAPE of the freshness probe (ORDER BY ... LIMIT 1, not an aggregate)
|
|
5
|
+
// and that migration 225 declares an index on updated_at. Neither of those is the
|
|
6
|
+
// claim that actually matters, which is a claim about the DATABASE: that the
|
|
7
|
+
// planner USES that index, so the probe stops at the first visible row instead of
|
|
8
|
+
// reading the visible corpus.
|
|
9
|
+
//
|
|
10
|
+
// That distinction IS the bug being fixed here. The old code said MAX(updated_at)
|
|
11
|
+
// under a comment promising "two index-assisted probes rather than a scan" — a
|
|
12
|
+
// statement about the plan that nothing checked, and that was false. Answering it
|
|
13
|
+
// with only a text tripwire on the SQL would repeat the mistake one level up: the
|
|
14
|
+
// shape can be right while the plan is still a scan (no index, a lost ANALYZE, an
|
|
15
|
+
// ORDER BY the index cannot serve). So this asks the planner directly.
|
|
16
|
+
//
|
|
17
|
+
// It also proves the REWRITE IS SEMANTICALLY IDENTICAL — ORDER BY updated_at DESC
|
|
18
|
+
// LIMIT 1 returns exactly what MAX(updated_at) returned under the same predicate —
|
|
19
|
+
// because the point was to make the probe cheaper, not to change the answer.
|
|
20
|
+
//
|
|
21
|
+
// The SQL under test is SLICED OUT OF modules/memory/search.js rather than copied,
|
|
22
|
+
// so this cannot keep passing against a query the module no longer runs.
|
|
23
|
+
//
|
|
24
|
+
// It SELF-SKIPS (exit 0) with no reachable Postgres, and is listed in
|
|
25
|
+
// run-unit-tests.js INTEGRATION set so the DB-free lane never runs it.
|
|
26
|
+
//
|
|
27
|
+
// Run: DATABASE_URL=postgres://... node tests/recall_freshness_plan_db.mjs
|
|
28
|
+
//
|
|
29
|
+
// It CREATES its own throwaway search_chunks and drops it again, so point it at a
|
|
30
|
+
// scratch database, never a real one.
|
|
31
|
+
|
|
32
|
+
import pg from 'pg';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import url from 'node:url';
|
|
36
|
+
import { strict as assert } from 'node:assert';
|
|
37
|
+
|
|
38
|
+
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
|
|
39
|
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await pool.query('SELECT 1');
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.log(`recall_freshness_plan_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
|
|
45
|
+
await pool.end().catch(() => {});
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Same posture as tests/claims_requested_worktree_db.mjs: this DROPs and CREATEs
|
|
50
|
+
// search_chunks, so refuse anything that looks like a real GDS database.
|
|
51
|
+
const { rows: realRows } = await pool.query(
|
|
52
|
+
`SELECT to_regclass('public.builders') IS NOT NULL AS has_builders`);
|
|
53
|
+
if (realRows[0].has_builders) {
|
|
54
|
+
console.log('recall_freshness_plan_db.mjs SKIPPED — this database has a `builders` table, so it is not a scratch DB. It DROPs and CREATEs search_chunks; point it at a throwaway Postgres.');
|
|
55
|
+
await pool.end().catch(() => {});
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let passed = 0;
|
|
60
|
+
let failed = 0;
|
|
61
|
+
async function test(name, fn) {
|
|
62
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
63
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- the query under test, taken from the module itself ----
|
|
67
|
+
|
|
68
|
+
const searchSrc = fs.readFileSync(path.join(ROOT, 'modules/memory/search.js'), 'utf8');
|
|
69
|
+
const OPEN = '(SELECT updated_at FROM search_chunks';
|
|
70
|
+
const start = searchSrc.indexOf(OPEN);
|
|
71
|
+
assert.ok(start > 0, 'the freshness subquery must be findable in modules/memory/search.js');
|
|
72
|
+
const end = searchSrc.indexOf(') AS last_content_change_at', start);
|
|
73
|
+
assert.ok(end > start, 'the freshness subquery must still be aliased last_content_change_at');
|
|
74
|
+
// Drop the wrapping parens so it stands alone as a statement.
|
|
75
|
+
const FRESHNESS_SQL = searchSrc.slice(start + 1, end);
|
|
76
|
+
assert.match(FRESHNESS_SQL, /ORDER BY updated_at DESC/, 'sanity: sliced the ordered form');
|
|
77
|
+
|
|
78
|
+
// The privileged caller — the case the task named, where the scope predicate
|
|
79
|
+
// matches nearly every row and the aggregate therefore scanned nearly everything.
|
|
80
|
+
const ALL_RANKS = ['xenos', 'thetes', 'metic', 'archon'];
|
|
81
|
+
const OWNER = 1;
|
|
82
|
+
|
|
83
|
+
async function explain(sql) {
|
|
84
|
+
const { rows } = await pool.query(`EXPLAIN (FORMAT JSON) ${sql}`, [ALL_RANKS, OWNER]);
|
|
85
|
+
return JSON.stringify(rows[0]['QUERY PLAN']);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- fixture ----
|
|
89
|
+
|
|
90
|
+
await pool.query('DROP TABLE IF EXISTS search_chunks');
|
|
91
|
+
// Only the columns this probe touches. The real table (migration 103) carries the
|
|
92
|
+
// tsvector, the trigram body and the rest; none of them participate in this plan.
|
|
93
|
+
await pool.query(`
|
|
94
|
+
CREATE TABLE search_chunks (
|
|
95
|
+
id bigserial PRIMARY KEY,
|
|
96
|
+
min_rank text NOT NULL DEFAULT 'xenos',
|
|
97
|
+
owner_builder_id bigint,
|
|
98
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
99
|
+
)`);
|
|
100
|
+
// Enough rows that a sequential scan plus a top-N sort is visibly the worse plan.
|
|
101
|
+
await pool.query(`
|
|
102
|
+
INSERT INTO search_chunks (min_rank, owner_builder_id, updated_at)
|
|
103
|
+
SELECT (ARRAY['xenos','thetes','metic','archon'])[1 + (i % 4)],
|
|
104
|
+
CASE WHEN i % 50 = 0 THEN 1 + (i % 7) ELSE NULL END,
|
|
105
|
+
now() - (i || ' minutes')::interval
|
|
106
|
+
FROM generate_series(1, 20000) AS i`);
|
|
107
|
+
await pool.query('ANALYZE search_chunks');
|
|
108
|
+
|
|
109
|
+
await test('WITHOUT the index the probe is the scan the old comment denied', async () => {
|
|
110
|
+
const plan = await explain(FRESHNESS_SQL);
|
|
111
|
+
assert.match(plan, /Seq Scan/, 'baseline: with no index on updated_at this reads the table');
|
|
112
|
+
assert.doesNotMatch(plan, /idx_search_chunks_updated_at/, 'and cannot be using an index that does not exist yet');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('migration 225 applies cleanly', async () => {
|
|
116
|
+
const sql = fs.readFileSync(path.join(ROOT, 'migrations/core_225_search_chunks_updated_at_idx.sql'), 'utf8');
|
|
117
|
+
await pool.query(sql);
|
|
118
|
+
await pool.query('ANALYZE search_chunks');
|
|
119
|
+
const { rows } = await pool.query(
|
|
120
|
+
`SELECT 1 FROM pg_indexes WHERE tablename = 'search_chunks' AND indexname = 'idx_search_chunks_updated_at'`);
|
|
121
|
+
assert.equal(rows.length, 1, 'the migration must create idx_search_chunks_updated_at');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await test('WITH the index the planner walks it and stops — no scan', async () => {
|
|
125
|
+
// THE CLAIM THE COMMENT MAKES. Everything else in this file is scaffolding for
|
|
126
|
+
// this assertion: the probe must reach its answer through the index, not by
|
|
127
|
+
// reading the visible corpus.
|
|
128
|
+
const plan = await explain(FRESHNESS_SQL);
|
|
129
|
+
assert.match(plan, /idx_search_chunks_updated_at/, 'the freshness probe must use the updated_at index');
|
|
130
|
+
assert.match(plan, /Index Scan/, 'and reach it by an index scan');
|
|
131
|
+
assert.doesNotMatch(plan, /Seq Scan/, 'no sequential scan may remain on this path');
|
|
132
|
+
assert.doesNotMatch(plan, /"Node Type":"Sort"/, 'and no sort — the index already supplies the order');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await test('the probe reads a bounded number of rows, not the corpus', async () => {
|
|
136
|
+
const { rows } = await pool.query(
|
|
137
|
+
`EXPLAIN (ANALYZE, FORMAT JSON) ${FRESHNESS_SQL}`, [ALL_RANKS, OWNER]);
|
|
138
|
+
const plan = rows[0]['QUERY PLAN'][0].Plan;
|
|
139
|
+
// A privileged caller matches nearly every row, so the first row the index hands
|
|
140
|
+
// back already passes the filter. That is the inversion this index buys: the
|
|
141
|
+
// worst case for the old aggregate is the best case for this plan.
|
|
142
|
+
assert.ok(plan['Actual Rows'] <= 1, `expected at most 1 row out, got ${plan['Actual Rows']}`);
|
|
143
|
+
const read = JSON.stringify(plan).match(/"Actual Rows": ?(\d+)/g).map((m) => Number(m.split(':')[1]));
|
|
144
|
+
assert.ok(Math.max(...read) < 100, `plan should touch a handful of rows, not 20000 (saw ${Math.max(...read)})`);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test('the rewrite returns exactly what MAX(updated_at) returned', async () => {
|
|
148
|
+
// The point was to make the probe cheaper, NOT to change the answer. Equivalent
|
|
149
|
+
// on a NOT NULL column, and this pins it rather than trusting the reasoning.
|
|
150
|
+
const { rows: viaOrder } = await pool.query(FRESHNESS_SQL, [ALL_RANKS, OWNER]);
|
|
151
|
+
const { rows: viaMax } = await pool.query(
|
|
152
|
+
`SELECT MAX(updated_at) AS updated_at FROM search_chunks
|
|
153
|
+
WHERE min_rank = ANY($1::text[])
|
|
154
|
+
AND (owner_builder_id IS NULL OR owner_builder_id = $2::bigint)`, [ALL_RANKS, OWNER]);
|
|
155
|
+
assert.equal(String(viaOrder[0].updated_at), String(viaMax[0].updated_at));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await test('a caller who can see NOTHING still gets null, not a wrong stamp', async () => {
|
|
159
|
+
// The scope predicate is load-bearing (ADR 0016): a freshness stamp that outran
|
|
160
|
+
// the caller scope would be the freshness half of the oracle this diagnostic was
|
|
161
|
+
// rewritten to remove. No visible row must mean no stamp.
|
|
162
|
+
const { rows } = await pool.query(FRESHNESS_SQL, [['nobody'], 999999]);
|
|
163
|
+
assert.equal(rows.length, 0, 'nothing visible must yield no row, hence a null stamp');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await pool.query('DROP TABLE IF EXISTS search_chunks');
|
|
167
|
+
await pool.end();
|
|
168
|
+
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log(`recall_freshness_plan_db: ${passed} passed, ${failed} failed`);
|
|
171
|
+
process.exit(failed === 0 ? 0 : 1);
|