@bongos/core 1.19.1009 → 1.19.1011

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ // modules/ideas/routes/sky-mine.js — GET /sky/mine, the caller's whole trail in
4
+ // one read (task 1004230, sky part 2 of 5; ADR 0331).
5
+ //
6
+ // ITS OWN FILE, not more of inbox.js, for the reason ratify-goal.js gives: inbox
7
+ // is INTAKE, and this is a read of what a person's intake became. The read and
8
+ // its cap live in ../sky-mine.js so a test drives them with no express.
9
+ //
10
+ // The path is `/sky/mine` beside the lifecycle module's `/sky`: two modules, one
11
+ // surface to the page. Express matches whole paths, so neither swallows the
12
+ // other whichever mounts first — and no `:id` route exists under `/sky` to bind
13
+ // the literal `mine` (the `/inbox/:id` trap this module's CLAUDE.md records).
14
+ //
15
+ // MODULE BOUNDARY: reaches core only through `../../../src/module-api`.
16
+
17
+ const express = require('express');
18
+ const api = require('../../../src/module-api');
19
+ const skyMine = require('../sky-mine');
20
+
21
+ module.exports = function buildSkyMineRouter() {
22
+ const router = express.Router();
23
+
24
+ // rank: any-builder — GET /sky/mine, every thought the SIGNED-IN builder filed
25
+ // with its state, size and descent, capped at 1000 (task 1004230). The builder
26
+ // is the caller: there is no id in the path, so nobody can read a trail as
27
+ // somebody else through this route. The viewer's rank rides into the read so
28
+ // the skill-friction gate holds here as it does on the trail. Deliberately NOT
29
+ // pinned in route-rank-check (the /inbox/sparks precedent: a read with no floor
30
+ // above sign-in has no downgrade to catch).
31
+ router.get('/sky/mine', api.requireBuilder, api.asyncHandler('GET /sky/mine', skyMine.skyMineHandler(), { errorCode: 'sky_mine_failed' }));
32
+
33
+ return router;
34
+ };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ // modules/ideas/sky-mine.js — THE CALLER'S WHOLE TRAIL IN ONE READ (task
4
+ // 1004230, sky part 2 of 5; goal 1000095; ADR 0331).
5
+ //
6
+ // WHY. The ideator's sky draws every thought a person filed at once — its state,
7
+ // its size, its descent — and the trail route (`GET /inbox/by-builder/:id`) pages
8
+ // that at a hundred rows. The prototype walked the pages itself; the page should
9
+ // not. This read walks them server-side and hands back every row, the same rows
10
+ // the room shows, so the sky and the room can never disagree about a thought.
11
+ //
12
+ // THE SAME ROWS, LITERALLY. Each page is `listIdeaTrailForBuilder` — the trail's
13
+ // own projection — and the three attachers run over the whole set afterwards in
14
+ // the trail route's order: the descent (the walk), then size (what the walk
15
+ // paid), then state (which of the five objects — last, because it takes its
16
+ // share off the size just left on the row). Nothing is derived here that the
17
+ // room does not derive; ADR 0322 says the five states are derived ONCE, and this
18
+ // file keeps that true by calling the one derivation rather than adding a second.
19
+ //
20
+ // THE CAP. A thousand rows. The trail's own page cap is a hundred, so this is at
21
+ // most ten reads; a filer past a thousand thoughts gets the newest thousand and a
22
+ // `truncated: true` the page can say out loud, never a silent slice. Ten reads
23
+ // for the sky is still one request from the page, which is the point.
24
+ //
25
+ // WHOSE. The builder is the signed-in caller, from the request, never a URL
26
+ // parameter — the room reads `/me` for the same reason: a trail is a person's,
27
+ // and the route that lets you name a person is the other one.
28
+
29
+ const inbox = require('./inbox');
30
+ const { attachDescentToTrail } = require('./trail-descent');
31
+ const { attachSizeToTrail } = require('./trail-size');
32
+ const { attachStateToTrail } = require('./idea-state');
33
+
34
+ const SKY_MINE_CAP = 1000;
35
+
36
+ // readSkyMine — page the trail to the cap, then attach the three facts once
37
+ // over the whole set. `deps` lets a test stand in for each read and each
38
+ // attacher; production passes none and gets the trail's own.
39
+ async function readSkyMine({ builderId, viewerRank = null } = {}, deps = {}) {
40
+ const list = deps.listIdeaTrailForBuilder || inbox.listIdeaTrailForBuilder;
41
+ const count = deps.countIdeaTrailForBuilder || inbox.countIdeaTrailForBuilder;
42
+ const descent = deps.attachDescentToTrail || attachDescentToTrail;
43
+ const size = deps.attachSizeToTrail || attachSizeToTrail;
44
+ const state = deps.attachStateToTrail || attachStateToTrail;
45
+ const cap = Number.isInteger(deps.cap) && deps.cap > 0 ? deps.cap : SKY_MINE_CAP;
46
+ const pageSize = inbox.IDEA_TRAIL_MAX_LIMIT;
47
+
48
+ const ideas = [];
49
+ const total = await count(builderId, { viewerRank });
50
+ for (let offset = 0; offset < Math.min(total, cap); offset += pageSize) {
51
+ const limit = Math.min(pageSize, cap - offset);
52
+ const page = await list(builderId, { limit, offset, viewerRank });
53
+ ideas.push(...page);
54
+ if (page.length < limit) break;
55
+ }
56
+
57
+ await descent(ideas);
58
+ await size(ideas);
59
+ await state(ideas);
60
+
61
+ return { builder_id: builderId, ideas, total, cap, truncated: total > ideas.length };
62
+ }
63
+
64
+ // skyMineHandler — GET /sky/mine. A factory so a test drives it with a stub
65
+ // read and no database. Identity is the authenticated builder's, always.
66
+ function skyMineHandler({ read = readSkyMine } = {}) {
67
+ return async function getSkyMine(req, res) {
68
+ const builderId = Number(req.builder.id);
69
+ const viewerRank = req.builder && req.builder.rank;
70
+ const out = await read({ builderId, viewerRank });
71
+ return res.json(out);
72
+ };
73
+ }
74
+
75
+ module.exports = { readSkyMine, skyMineHandler, SKY_MINE_CAP };
@@ -27,7 +27,8 @@
27
27
  "rot",
28
28
  "project-settings",
29
29
  "artist-gate",
30
- "removal-proposals"
30
+ "removal-proposals",
31
+ "sky"
31
32
  ],
32
33
  "pollers": [
33
34
  "publish-reconciler"
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/routes/sky.js — GET /sky, the project sky in one read (task
4
+ // 1004230, sky part 2 of 5; ADR 0331, ADR 0227 §11).
5
+ //
6
+ // WHY ITS OWN ROUTE FILE. goals.js is the goal board's surface and already
7
+ // mounts the graph and the forest; the sky is a third view over the same rows,
8
+ // and the one line here is the mount. The data, the cache and the handler live
9
+ // in ../sky.js so a test drives them with no express and no database.
10
+ //
11
+ // The path is `/sky`, top-level, never `/goals/sky`: the ideas module contributes
12
+ // `/sky/mine` (the caller's own trail), and the two are one surface to the page
13
+ // even though they are two modules' reads. Express matches whole paths, so
14
+ // `/sky` cannot swallow `/sky/mine` whichever module mounts first.
15
+ //
16
+ // MODULE BOUNDARY: reaches core only through the published doorway
17
+ // `../../../src/module-api`; `../sky` is this module's own read.
18
+
19
+ const express = require('express');
20
+ const api = require('../../../src/module-api');
21
+ const sky = require('../sky');
22
+
23
+ const log = api.logger('lifecycle');
24
+
25
+ module.exports = function buildSkyRouter() {
26
+ const router = express.Router();
27
+
28
+ // rank: any-builder — GET /sky, the whole project sky (task 1004230, ADR 0331).
29
+ // The same gate as GET /goals/graph, for the same reason: the viewer decides
30
+ // which private goals come back with their prose, and that decision is made
31
+ // from the authenticated request inside the read. Deliberately NOT pinned in
32
+ // route-rank-check: a read with no floor above sign-in has no downgrade to
33
+ // catch (the /inbox/sparks precedent).
34
+ router.get('/sky', api.requireBuilder, sky.skyHandler({ log }));
35
+
36
+ return router;
37
+ };
@@ -0,0 +1,261 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/sky.js — ONE READ FOR THE WHOLE PROJECT SKY (task 1004230,
4
+ // sky part 2 of 5; goal 1000095; ADR 0331, ADR 0227 §11).
5
+ //
6
+ // WHY ONE READ. The prototype's snapshot (docs/design/mocks/sky/snapshot.js)
7
+ // needed about 120 requests to assemble what one page draws: the goal graph, a
8
+ // detail read per goal for its criteria, a progress rollup per version for the
9
+ // criterion→task join, and a task list per goal. The two sky pages (parts 3 and
10
+ // 4) draw all of it at once, so they get it at once — five queries, one JSON.
11
+ //
12
+ // WHAT IT IS NOT. Not a second goal graph: `goals` and `edges` come from
13
+ // goal-graph.js's own read, so the sky's wells are the map's nodes and the sky's
14
+ // routes are the map's edges by construction, never a copy that can drift. Not
15
+ // the refused whole-corpus task map either (ADR 0227 §1): tasks come back
16
+ // compact and grouped under their goal, and the page draws them only inside one
17
+ // goal past a zoom threshold — dust below it (§11).
18
+ //
19
+ // THE JOIN THAT BITES. A criterion's tasks live in `task_criteria`; goal detail
20
+ // returns only COUNTS of them, and the version progress rollup is the one place
21
+ // the ids are joined. Rather than call that rollup once per version, the join is
22
+ // read ONCE for every goal-bearing criterion, as the same flat (criterion, task)
23
+ // rows `criterionProgress` folds — so `tests/sky_read.mjs` can hand the same rows
24
+ // to `buildCriterionProgress` and to this shaper and pin that a task's criterion
25
+ // link is identical on both.
26
+ //
27
+ // VISIBILITY (ADR 0112's leak rule, as goal-graph.js applies it). A private goal
28
+ // the viewer may not read stays a well — its counts, its edges, its ring count
29
+ // and its task count are structure, not secrets — but every piece of PROSE on it
30
+ // comes back null: title, subtitle, category, each criterion's text, each task's
31
+ // title. The graph only had a title and a category to hide; the sky carries more
32
+ // prose per goal, and hiding the title while shipping the criteria under it would
33
+ // be the leak wearing a different field name.
34
+ //
35
+ // THE CACHE. Sixty seconds, in-process, keyed by what decides visibility (the
36
+ // Archon flag, else the viewer id) — a cached payload shaped for one viewer is
37
+ // never handed to another. The response says whether it was served from cache
38
+ // and when it was taken, so a page that just filed a thought can tell why the
39
+ // sky has not moved yet.
40
+
41
+ const api = require('../../src/module-api');
42
+ const { pool } = api;
43
+ const goalGraph = require('./goal-graph');
44
+ const goalCategory = require('./goal-category');
45
+ const { isPendingReview } = require('./done-when');
46
+ const { TERMINAL_STATUSES } = require('./task-status');
47
+
48
+ const SKY_CACHE_TTL_MS = 60 * 1000;
49
+
50
+ // skyRows — the raw reads. Returns { goals, edges, extras, criteria, tasks }:
51
+ // `goals` + `edges` exactly as goal-graph.js reads them; `extras` one row per
52
+ // goal with the two columns the graph does not carry (subtitle, created_at);
53
+ // `criteria` one FLAT row per (criterion, task) pair — a criterion with no task
54
+ // arrives once with null task_* (the LEFT JOIN, the criterionProgress shape);
55
+ // `tasks` one compact row per task under a goal. deps.pool honours the test seam.
56
+ async function skyRows({ versionId = null, viewerId = null, viewerIsArchon = false } = {}, deps = {}) {
57
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
58
+ const version = Array.isArray(versionId) ? (versionId[0] ?? null) : versionId;
59
+ const [graph, extras, criteria, tasks] = await Promise.all([
60
+ (deps.goalGraphRows || goalGraph.goalGraphRows)({ versionId: version, viewerId, viewerIsArchon }, deps),
61
+ activePool.query(
62
+ `SELECT g.id::text AS goal_id, g.subtitle, g.created_at
63
+ FROM goals g
64
+ WHERE ($1::text IS NULL OR g.version_id = $1)`,
65
+ [version]
66
+ ),
67
+ activePool.query(
68
+ // The criterion→task join, read once. Flat rows on purpose (see the header):
69
+ // the fold happens in shapeSky so a test can compare it with the rollup's.
70
+ `SELECT c.id::text AS id, c.goal_id::text AS goal_id, c.criterion_id, c.criterion_md,
71
+ c.satisfied, c.sort_order,
72
+ t.id::text AS task_id, t.status AS task_status
73
+ FROM done_when_criteria c
74
+ JOIN goals g ON g.id = c.goal_id
75
+ LEFT JOIN task_criteria tc ON tc.criterion_id = c.id
76
+ LEFT JOIN tasks t ON t.id = tc.task_id
77
+ WHERE c.goal_id IS NOT NULL
78
+ AND ($1::text IS NULL OR g.version_id = $1)
79
+ ORDER BY c.goal_id, c.sort_order, c.criterion_id, t.id`,
80
+ [version]
81
+ ),
82
+ activePool.query(
83
+ // Compact by construction: no description, no value summary, no notes.
84
+ // created_at is truncated to the DAY in SQL so the payload carries ten
85
+ // characters per task, not twenty-seven, and the clock draws by day anyway.
86
+ `SELECT t.id::text AS id, t.goal_id::text AS goal_id, t.title, t.status,
87
+ t.credits_reward, t.created_at::date::text AS created_on,
88
+ CASE WHEN t.source_ref ~ '^idea:[0-9]+$' THEN substring(t.source_ref from 6) END AS idea_id
89
+ FROM tasks t
90
+ JOIN goals g ON g.id = t.goal_id
91
+ WHERE ($1::text IS NULL OR g.version_id = $1)
92
+ ORDER BY t.goal_id, t.id`,
93
+ [version]
94
+ ),
95
+ ]);
96
+ return { goals: graph.goals, edges: graph.edges, extras: extras.rows, criteria: criteria.rows, tasks: tasks.rows };
97
+ }
98
+
99
+ // foldCriteria — the flat (criterion, task) rows into one entry per criterion
100
+ // with its task ids and the derived pending_review flag. The same fold
101
+ // buildCriterionProgress does, minus the cnum and the per-status counts the sky
102
+ // does not draw. Exported so the equivalence test can drive it on its own.
103
+ function foldCriteria(rows = []) {
104
+ const byId = new Map();
105
+ for (const row of rows) {
106
+ let c = byId.get(String(row.id));
107
+ if (!c) {
108
+ c = {
109
+ id: String(row.id),
110
+ goal_id: row.goal_id != null ? String(row.goal_id) : null,
111
+ criterion_id: row.criterion_id,
112
+ criterion_md: row.criterion_md,
113
+ satisfied: row.satisfied === true,
114
+ task_ids: [],
115
+ _unshipped: 0,
116
+ };
117
+ byId.set(c.id, c);
118
+ }
119
+ if (row.task_id != null) {
120
+ c.task_ids.push(String(row.task_id));
121
+ if (!TERMINAL_STATUSES.includes(row.task_status)) c._unshipped += 1;
122
+ }
123
+ }
124
+ return [...byId.values()].map((c) => {
125
+ const { _unshipped, ...rest } = c;
126
+ return {
127
+ ...rest,
128
+ pending_review: isPendingReview({ satisfied: c.satisfied, linked: c.task_ids.length, unshipped: _unshipped }),
129
+ };
130
+ });
131
+ }
132
+
133
+ // shapeSky — the wire shape. Pure, so a test pins it with no database.
134
+ //
135
+ // goals: [{ goal_id, title, subtitle, status, version_id, category, task_total,
136
+ // task_done, created_at, criteria: [{ id, criterion_id, criterion_md,
137
+ // satisfied, pending_review, task_ids }], tasks: [{ id, title, status,
138
+ // credits_reward, created_on, idea_id }] }]
139
+ // edges: [{ from, to, count }] — `from` waits on `to`, the graph's own
140
+ // counts: { goals, edges, criteria, tasks }
141
+ //
142
+ // A criterion or a task whose goal is not among the wells is DROPPED — the wells
143
+ // are the graph's nodes (goals carrying at least one task, in the version asked
144
+ // for), and a ring drawn around no well is a line pointing at nothing.
145
+ function shapeSky({ goals = [], edges = [], extras = [], criteria = [], tasks = [] } = {}, categoryById = new Map()) {
146
+ const graph = goalGraph.shapeGraph({ goals, edges }, categoryById);
147
+ const visibleById = new Map(goals.map((g) => [String(g.goal_id), g.visible !== false]));
148
+ const extraById = new Map(extras.map((x) => [String(x.goal_id), x]));
149
+ const wells = new Map(graph.nodes.map((n) => [n.goal_id, { ...n, criteria: [], tasks: [] }]));
150
+
151
+ let criteriaCount = 0;
152
+ for (const c of foldCriteria(criteria)) {
153
+ const well = c.goal_id != null ? wells.get(c.goal_id) : null;
154
+ if (!well) continue;
155
+ const visible = visibleById.get(c.goal_id) !== false;
156
+ const { goal_id, ...entry } = c;
157
+ well.criteria.push({ ...entry, criterion_md: visible ? entry.criterion_md : null });
158
+ criteriaCount += 1;
159
+ }
160
+
161
+ let taskCount = 0;
162
+ for (const t of tasks) {
163
+ const gid = t.goal_id != null ? String(t.goal_id) : null;
164
+ const well = gid != null ? wells.get(gid) : null;
165
+ if (!well) continue;
166
+ const visible = visibleById.get(gid) !== false;
167
+ well.tasks.push({
168
+ id: String(t.id),
169
+ title: visible ? t.title : null,
170
+ status: t.status,
171
+ credits_reward: t.credits_reward == null ? null : Number(t.credits_reward),
172
+ created_on: t.created_on || null,
173
+ idea_id: t.idea_id != null ? String(t.idea_id) : null,
174
+ });
175
+ taskCount += 1;
176
+ }
177
+
178
+ const out = [...wells.values()].map((w) => {
179
+ const visible = visibleById.get(w.goal_id) !== false;
180
+ const x = extraById.get(w.goal_id) || {};
181
+ const { depends_on, depended_on_by, ...node } = w;
182
+ return {
183
+ ...node,
184
+ subtitle: visible ? (x.subtitle ?? null) : null,
185
+ created_at: x.created_at instanceof Date ? x.created_at.toISOString() : (x.created_at ?? null),
186
+ };
187
+ });
188
+
189
+ return {
190
+ goals: out,
191
+ edges: graph.edges,
192
+ counts: { goals: out.length, edges: graph.edges.length, criteria: criteriaCount, tasks: taskCount },
193
+ };
194
+ }
195
+
196
+ // sky — read + shape. `categoryIndex` is injectable so the whole path runs in a
197
+ // test with a fake pool and no category table (the goal-graph.js precedent).
198
+ async function sky(opts = {}, deps = {}) {
199
+ const [rows, byId] = await Promise.all([
200
+ skyRows(opts, deps),
201
+ (deps.categoryIndex || goalCategory.categoryIndex)(deps),
202
+ ]);
203
+ return shapeSky(rows, byId);
204
+ }
205
+
206
+ // skyCache — the 60-second memo, keyed by what decides what the viewer may see.
207
+ // `now` is injectable so a test can move the clock instead of waiting a minute.
208
+ function skyCache({ ttlMs = SKY_CACHE_TTL_MS, now = Date.now } = {}) {
209
+ const entries = new Map();
210
+ return {
211
+ key({ viewerId, viewerIsArchon, versionId }) {
212
+ return `${viewerIsArchon === true ? 'archon' : `b${viewerId}`}|${versionId == null ? '' : String(versionId)}`;
213
+ },
214
+ get(key) {
215
+ const e = entries.get(key);
216
+ if (!e) return null;
217
+ if (now() - e.at > ttlMs) { entries.delete(key); return null; }
218
+ return e;
219
+ },
220
+ set(key, payload) {
221
+ const e = { at: now(), payload };
222
+ entries.set(key, e);
223
+ return e;
224
+ },
225
+ clear() { entries.clear(); },
226
+ ttlSeconds: Math.round(ttlMs / 1000),
227
+ };
228
+ }
229
+
230
+ // skyHandler — GET /sky. A factory, so a test drives it with a stub read, a
231
+ // stub cache clock and no database. Identity comes from the authenticated
232
+ // request, never the query (goal-graph.js's rule, for the same reason: a
233
+ // caller-supplied id would let anyone ask as somebody else).
234
+ function skyHandler({ read = sky, log = console, cache = skyCache() } = {}) {
235
+ return async function getSky(req, res) {
236
+ try {
237
+ const opts = {
238
+ versionId: (req.query && req.query.version) || null,
239
+ viewerId: req.builder.id,
240
+ viewerIsArchon: req.builder.rank === 'archon',
241
+ };
242
+ const key = cache.key(opts);
243
+ let entry = cache.get(key);
244
+ let hit = true;
245
+ if (!entry) {
246
+ hit = false;
247
+ entry = cache.set(key, await read(opts));
248
+ }
249
+ res.set('Cache-Control', 'private, max-age=60');
250
+ return res.json({
251
+ ...entry.payload,
252
+ cache: { hit, ttl_seconds: cache.ttlSeconds, taken_at: new Date(entry.at).toISOString() },
253
+ });
254
+ } catch (err) {
255
+ log.error('[gds] GET /sky', err);
256
+ return res.fail('sky_failed', { status: 500, message: 'internal error' });
257
+ }
258
+ };
259
+ }
260
+
261
+ module.exports = { sky, skyRows, shapeSky, foldCriteria, skyCache, skyHandler, SKY_CACHE_TTL_MS };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1009",
3
+ "version": "1.19.1011",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1009",
9
+ "version": "1.19.1011",
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.1009",
3
+ "version": "1.19.1011",
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
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1009'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1011'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,138 @@
1
+ // tests/sky_mine.mjs — GET /sky/mine, the caller's whole trail in one read (task
2
+ // 1004230, sky part 2 of 5; goal 1000095; ADR 0331, ADR 0322).
3
+ //
4
+ // What this file pins: that the rows are the trail's own (the same list read,
5
+ // the same three attachers, in the trail route's order — nothing derived here);
6
+ // that the cap holds at a thousand and says so; that the builder is the
7
+ // signed-in caller and never a URL parameter; and that the route is mounted
8
+ // beside, not under, the lifecycle module's /sky.
9
+ //
10
+ // DB-free: the read is driven with stub pages and stub attachers; the handler
11
+ // with a stub read; the mount is read from the router and the manifest.
12
+ //
13
+ // Run: node tests/sky_mine.mjs
14
+ process.env.NODE_ENV = 'test';
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { createRequire } from 'node:module';
20
+ import { makeRunner } from './helpers.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const { readSkyMine, skyMineHandler, SKY_MINE_CAP } = require(path.join(ROOT, 'modules/ideas/sky-mine.js'));
25
+ const inbox = require(path.join(ROOT, 'modules/ideas/inbox.js'));
26
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/ideas/module.json'), 'utf8'));
27
+ const LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/ideas/sky-mine.js'), 'utf8');
28
+ const ROUTE_SRC = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/sky-mine.js'), 'utf8');
29
+ const TRAIL_ROUTE_SRC = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/inbox.js'), 'utf8');
30
+
31
+ const { test, summary } = makeRunner();
32
+
33
+ // A stub trail of N ideas, paged exactly as listIdeaTrailForBuilder pages.
34
+ function stubTrail(n) {
35
+ const all = Array.from({ length: n }, (_, i) => ({ id: String(n - i), title: `Idea ${n - i}`, status: 'open' }));
36
+ const calls = { list: [], count: [], attach: [] };
37
+ const deps = {
38
+ listIdeaTrailForBuilder: async (builderId, { limit, offset, viewerRank }) => { calls.list.push({ builderId, limit, offset, viewerRank }); return all.slice(offset, offset + limit); },
39
+ countIdeaTrailForBuilder: async (builderId, { viewerRank }) => { calls.count.push({ builderId, viewerRank }); return n; },
40
+ attachDescentToTrail: async (ideas) => { calls.attach.push(['descent', ideas.length]); for (const i of ideas) i.descent_tasks = []; },
41
+ attachSizeToTrail: async (ideas) => { calls.attach.push(['size', ideas.length]); for (const i of ideas) i.size = { credits: 0, scale: 0 }; },
42
+ attachStateToTrail: async (ideas) => { calls.attach.push(['state', ideas.length]); for (const i of ideas) i.state = { object: 'protostar' }; },
43
+ };
44
+ return { deps, calls };
45
+ }
46
+
47
+ await test('the read pages the trail\'s own list at its own page size until the total, and returns every row', async () => {
48
+ const { deps, calls } = stubTrail(249);
49
+ const out = await readSkyMine({ builderId: 9, viewerRank: 'metic' }, deps);
50
+ assert.equal(out.ideas.length, 249);
51
+ assert.equal(out.total, 249);
52
+ assert.equal(out.truncated, false);
53
+ assert.equal(out.cap, SKY_MINE_CAP);
54
+ assert.equal(SKY_MINE_CAP, 1000);
55
+ assert.deepEqual(calls.list.map((c) => [c.limit, c.offset]), [[100, 0], [100, 100], [100, 200]], 'three pages of the trail\'s max page size');
56
+ assert.equal(calls.list[0].limit, inbox.IDEA_TRAIL_MAX_LIMIT, 'the page size is the trail\'s, not a number of its own');
57
+ for (const c of calls.list) { assert.equal(c.builderId, 9); assert.equal(c.viewerRank, 'metic', 'the viewer\'s rank rides into every page'); }
58
+ assert.equal(out.ideas[0].id, '249', 'newest first, as the trail orders');
59
+ });
60
+
61
+ await test('the three attachers run ONCE over the whole set, in the trail route\'s order: descent, then size, then state', async () => {
62
+ const { deps, calls } = stubTrail(150);
63
+ const out = await readSkyMine({ builderId: 9 }, deps);
64
+ assert.deepEqual(calls.attach, [['descent', 150], ['size', 150], ['state', 150]]);
65
+ assert.ok(out.ideas.every((i) => i.state && i.size && Array.isArray(i.descent_tasks)));
66
+ });
67
+
68
+ await test('the cap holds at 1000: a filer past it gets the newest thousand and truncated: true, never a silent slice', async () => {
69
+ const { deps, calls } = stubTrail(1234);
70
+ const out = await readSkyMine({ builderId: 9 }, deps);
71
+ assert.equal(out.ideas.length, 1000);
72
+ assert.equal(out.total, 1234);
73
+ assert.equal(out.truncated, true);
74
+ assert.equal(calls.list.length, 10, 'ten pages, not thirteen');
75
+ assert.equal(calls.list[9].offset, 900);
76
+ assert.equal(calls.list[9].limit, 100);
77
+ });
78
+
79
+ await test('an odd cap asks the last page for only what is left', async () => {
80
+ const { deps, calls } = stubTrail(500);
81
+ const out = await readSkyMine({ builderId: 9 }, { ...deps, cap: 250 });
82
+ assert.equal(out.ideas.length, 250);
83
+ assert.deepEqual(calls.list.map((c) => [c.limit, c.offset]), [[100, 0], [100, 100], [50, 200]]);
84
+ assert.equal(out.truncated, true);
85
+ });
86
+
87
+ await test('a short page ends the walk early (the count moved under us), and an empty trail reads nothing', async () => {
88
+ const { deps, calls } = stubTrail(0);
89
+ const out = await readSkyMine({ builderId: 9 }, deps);
90
+ assert.deepEqual(out.ideas, []);
91
+ assert.equal(calls.list.length, 0, 'no page is read for a total of zero');
92
+ assert.equal(out.truncated, false);
93
+
94
+ const { deps: d2, calls: c2 } = stubTrail(150);
95
+ d2.countIdeaTrailForBuilder = async () => 400; // the count claims more than the list has
96
+ const out2 = await readSkyMine({ builderId: 9 }, d2);
97
+ assert.equal(out2.ideas.length, 150);
98
+ assert.equal(c2.list.length, 2, 'stops on the short page rather than reading empties');
99
+ });
100
+
101
+ await test('nothing is derived here — the lib calls the trail\'s own read and the three attachers, and never touches the pool or idea-state internals', () => {
102
+ assert.match(LIB_SRC, /require\('\.\/inbox'\)/);
103
+ assert.match(LIB_SRC, /attachDescentToTrail/);
104
+ assert.match(LIB_SRC, /attachSizeToTrail/);
105
+ assert.match(LIB_SRC, /attachStateToTrail/);
106
+ assert.equal(/pool\.query|resolveIdeaState|SELECT /.test(LIB_SRC), false, 'no SQL and no second derivation (ADR 0322)');
107
+ // and the order matches the trail route's, read from its source
108
+ const order = (src) => ['attachDescentToTrail(', 'attachSizeToTrail(', 'attachStateToTrail('].map((n) => src.indexOf(n));
109
+ const trailOrder = order(TRAIL_ROUTE_SRC.slice(TRAIL_ROUTE_SRC.indexOf("'/inbox/by-builder/:id'")));
110
+ assert.ok(trailOrder[0] < trailOrder[1] && trailOrder[1] < trailOrder[2], 'the trail route runs descent, size, state');
111
+ const mine = LIB_SRC.slice(LIB_SRC.indexOf('await descent('));
112
+ assert.ok(mine.indexOf('await descent(') < mine.indexOf('await size(') && mine.indexOf('await size(') < mine.indexOf('await state('));
113
+ });
114
+
115
+ // ---- the handler ----------------------------------------------------------------------
116
+
117
+ await test('the builder is the signed-in caller — from the request, never a param or the query', async () => {
118
+ let seen = null;
119
+ const h = skyMineHandler({ read: async (o) => { seen = o; return { builder_id: o.builderId, ideas: [], total: 0, cap: 1000, truncated: false }; } });
120
+ let body = null;
121
+ await h({ builder: { id: '41', rank: 'thetes' }, params: { id: '999' }, query: { builder_id: '998' } }, { json(b) { body = b; } });
122
+ assert.deepEqual(seen, { builderId: 41, viewerRank: 'thetes' });
123
+ assert.equal(body.builder_id, 41);
124
+ });
125
+
126
+ // ---- the mount ------------------------------------------------------------------------
127
+
128
+ await test('the route is contributed by the ideas manifest at /sky/mine, gated to a signed-in builder, with no :id under /sky', () => {
129
+ assert.ok(manifest.contributes.routes.includes('sky-mine'), 'module.json contributes "sky-mine"');
130
+ assert.match(ROUTE_SRC, /router\.get\('\/sky\/mine',\s*api\.requireBuilder,/);
131
+ assert.equal(/\/sky\/:/.test(ROUTE_SRC), false, 'no parameterised /sky/:x that could bind the literal "mine"');
132
+ const factory = require(path.join(ROOT, 'modules/ideas/routes/sky-mine.js'));
133
+ const router = factory({});
134
+ const paths = router.stack.filter((l) => l.route).map((l) => l.route.path);
135
+ assert.deepEqual(paths, ['/sky/mine']);
136
+ });
137
+
138
+ summary();