@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.
- package/.bongos-core.json +59 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md +28 -0
- package/docs/adr/0331-the-sky-is-the-ideators-home.md +103 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +67 -1
- package/docs/api-reference.md +8 -1
- package/docs/design/ideator-surface-direction.md +4 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/ideas/module.json +1 -0
- package/modules/ideas/routes/sky-mine.js +34 -0
- package/modules/ideas/sky-mine.js +75 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/sky.js +37 -0
- package/modules/lifecycle/sky.js +261 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/sky_mine.mjs +138 -0
- package/tests/sky_read.mjs +265 -0
|
@@ -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 };
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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();
|