@bongos/core 1.20.19 → 1.20.21

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,217 @@
1
+ // modules/platform-identity/guild-totals.js — a guild's collective record: the
2
+ // rounded totals its public page publishes (task 1002302, R26; ADR 0337 D4.4,
3
+ // which supersedes ADR 0336 D5's read-time sum).
4
+ //
5
+ // THE RULE, next to the data so no reader can forget a half of it:
6
+ //
7
+ // * EVERY MEMBER COUNTS UNLESS THEY SWITCHED IT OFF. counted_in_totals is the
8
+ // per-membership switch R24 stores (task 1002300); the notice on the join
9
+ // cards is the consent. An account that has not consented at all (not
10
+ // accountActiveSql) is never counted, whatever its switch says.
11
+ // * A PUBLIC MEMBER is one whose numbers are already public on the guild's page:
12
+ // shown_publicly AND rollupVisibleSql. Everyone else counted is NON-PUBLIC — a
13
+ // private account, hidden numbers, or not agreeing to be shown.
14
+ // * A MINIMUM OF 3. Non-public members' numbers enter only while the guild has
15
+ // at least NON_PUBLIC_MIN_COUNTED counted non-public members. Below that the
16
+ // totals add up the public members alone.
17
+ // * ROUNDED, NEVER EXACT. Each total is published as its band floor on the
18
+ // 1-2-5 scale ("100+"), and only the band floor is ever stored. The exact sum
19
+ // lives in memory for as long as it takes to round it.
20
+ // * WEEKLY, NEVER ON A READ. A poller (pollers/guild-totals.js) refreshes a row
21
+ // at most once every TOTALS_REFRESH_DAYS, and the upsert re-checks that age in
22
+ // its own statement. readGuildTotals only SELECTs, so no read, however many,
23
+ // can move a number an observer is watching.
24
+ // * PROJECTS ARE THE PUBLIC MEMBERS' NAMED PUBLIC PROJECTS, never a non-public
25
+ // member's (ADR 0336 D5's union rule, which D4.4 keeps). Even a hidden
26
+ // member's public projects are withheld on their own profile, so they stay
27
+ // out of the count.
28
+ //
29
+ // WHAT IS DELIBERATELY ABSENT. No count of counted members, and nothing that says
30
+ // whether non-public numbers are in the total: either would reveal how many
31
+ // non-public members a guild has, which its public size (the roster's length,
32
+ // ADR 0336 D4) is built never to show.
33
+ //
34
+ // THE HONEST LIMIT, accepted by the owner (ADR 0337 D4.4, 2026-09-25). This stops
35
+ // a casual reader, not a determined guild owner: padding the guild with two
36
+ // accounts whose numbers the owner already knows makes a real non-public member
37
+ // the third, and comparing totals across refreshes then yields a range for that
38
+ // member. The notice and the per-guild switch answer it, not this file.
39
+ // tests/guild_totals_db.mjs documents the padding case; it does not "fix" it.
40
+ //
41
+ // NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). Descriptive only.
42
+ //
43
+ // Reaches core only through the doorway (ADR 0083).
44
+ 'use strict';
45
+
46
+ const api = require('../../src/module-api');
47
+ const { accountActiveSql, rollupVisibleSql } = require('./account-visibility');
48
+ const { onMapSql } = require('./projects-feed');
49
+
50
+ const { pool: defaultPool } = api;
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // The constants ADR 0337 D4.4 names. Exported so a route, a test and a doc read
54
+ // the same number.
55
+ // ---------------------------------------------------------------------------
56
+
57
+ // The 1-2-5 band scale: 10, 20, 50, 100, 200, 500, ... Below the first band a
58
+ // total publishes as 0, which a page renders as "under 10".
59
+ const TOTALS_BAND_MANTISSAS = [1, 2, 5];
60
+ const TOTALS_FIRST_BAND = 10;
61
+
62
+ // k: non-public members' numbers enter only while at least this many of them
63
+ // are counted.
64
+ const NON_PUBLIC_MIN_COUNTED = 3;
65
+
66
+ // A row is refreshed at most this often. Each before/after comparison costs an
67
+ // observer this long.
68
+ const TOTALS_REFRESH_DAYS = 7;
69
+
70
+ // The columns are integer; a band floor never exceeds what they can hold.
71
+ const INT4_MAX = 2147483647;
72
+
73
+ // bandFloor(n) → the largest band on the scale that is <= n, or 0 below the
74
+ // first band (and for a negative or non-numeric sum).
75
+ function bandFloor(value) {
76
+ const raw = Math.floor(Number(value));
77
+ if (!Number.isFinite(raw) || raw < TOTALS_FIRST_BAND) return 0;
78
+ const n = Math.min(raw, INT4_MAX);
79
+ let decade = TOTALS_FIRST_BAND;
80
+ while (decade * 10 <= n) decade *= 10;
81
+ let floor = decade;
82
+ for (const m of TOTALS_BAND_MANTISSAS) if (m * decade <= n) floor = m * decade;
83
+ return floor;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // The computation
88
+ // ---------------------------------------------------------------------------
89
+
90
+ // The exact sums for a batch of guilds, one row per id asked for (a guild with
91
+ // nobody counted sums to zero). Internal: its output is never stored or sent —
92
+ // only its band floors are.
93
+ async function computeGuildSums(guildIds, { pool = defaultPool } = {}) {
94
+ if (!guildIds.length) return [];
95
+ const { rows } = await pool.query(
96
+ `WITH m AS (
97
+ SELECT gm.guild_id, gm.github_id,
98
+ (gm.shown_publicly AND ${rollupVisibleSql('pa')}) AS is_public
99
+ FROM platform_identity_guild_members gm
100
+ JOIN platform_identity_accounts pa ON pa.github_id = gm.github_id
101
+ WHERE gm.guild_id = ANY($1::bigint[])
102
+ AND gm.counted_in_totals
103
+ AND ${accountActiveSql('pa')}
104
+ ),
105
+ k AS (
106
+ SELECT guild_id, count(*) FILTER (WHERE NOT is_public) AS non_public
107
+ FROM m GROUP BY guild_id
108
+ ),
109
+ counted AS (
110
+ SELECT m.guild_id, m.github_id, m.is_public
111
+ FROM m JOIN k ON k.guild_id = m.guild_id
112
+ WHERE m.is_public OR k.non_public >= $2
113
+ ),
114
+ sums AS (
115
+ SELECT c.guild_id,
116
+ COALESCE(sum(a.credits), 0)::bigint AS credits,
117
+ COALESCE(sum(a.tasks_shipped), 0)::bigint AS works_shipped,
118
+ COALESCE(sum(a.karma), 0)::bigint AS karma
119
+ FROM counted c
120
+ LEFT JOIN platform_identity_builder_project_activity a ON a.github_id = c.github_id
121
+ GROUP BY c.guild_id
122
+ ),
123
+ projs AS (
124
+ SELECT c.guild_id, count(DISTINCT p.id)::bigint AS projects
125
+ FROM counted c
126
+ JOIN platform_identity_builder_project_activity a ON a.github_id = c.github_id
127
+ JOIN platform_identity_sso_clients sc ON sc.client_id = a.client_id
128
+ JOIN platform_identity_projects p ON p.origin = sc.origin
129
+ WHERE c.is_public AND ${onMapSql('p')} AND p.visibility = 'public'
130
+ GROUP BY c.guild_id
131
+ )
132
+ SELECT g.id::text AS guild_id,
133
+ COALESCE(s.credits, 0)::text AS credits,
134
+ COALESCE(s.works_shipped, 0)::text AS works_shipped,
135
+ COALESCE(s.karma, 0)::text AS karma,
136
+ COALESCE(pr.projects, 0)::text AS projects
137
+ FROM unnest($1::bigint[]) AS g(id)
138
+ LEFT JOIN sums s ON s.guild_id = g.id
139
+ LEFT JOIN projs pr ON pr.guild_id = g.id`,
140
+ [guildIds, NON_PUBLIC_MIN_COUNTED],
141
+ );
142
+ return rows;
143
+ }
144
+
145
+ // Refresh every guild whose record is missing or at least TOTALS_REFRESH_DAYS
146
+ // old, stalest first, at most `limit` per call. The upsert re-asserts the age in
147
+ // its own WHERE, so a racing sweep cannot refresh a guild twice in a week, and
148
+ // it re-checks each guild still exists so one dissolved mid-sweep is skipped
149
+ // rather than failing the batch. Returns { scanned, refreshed }.
150
+ async function refreshStaleGuildTotals({ limit = 200 } = {}, { pool = defaultPool } = {}) {
151
+ const { rows: stale } = await pool.query(
152
+ `SELECT g.id::text AS id
153
+ FROM platform_identity_guilds g
154
+ LEFT JOIN platform_identity_guild_totals t ON t.guild_id = g.id
155
+ WHERE t.guild_id IS NULL OR t.computed_at <= now() - make_interval(days => $1::int)
156
+ ORDER BY t.computed_at ASC NULLS FIRST, g.id
157
+ LIMIT $2`,
158
+ [TOTALS_REFRESH_DAYS, Math.max(1, Number(limit) || 1)],
159
+ );
160
+ if (!stale.length) return { scanned: 0, refreshed: 0 };
161
+ const sums = await computeGuildSums(stale.map((r) => r.id), { pool });
162
+ const col = (k) => sums.map((r) => bandFloor(r[k]));
163
+ const { rowCount } = await pool.query(
164
+ `INSERT INTO platform_identity_guild_totals AS t
165
+ (guild_id, credits, works_shipped, karma, projects, computed_at)
166
+ SELECT u.guild_id, u.credits, u.works_shipped, u.karma, u.projects, now()
167
+ FROM unnest($1::bigint[], $2::int[], $3::int[], $4::int[], $5::int[])
168
+ AS u(guild_id, credits, works_shipped, karma, projects)
169
+ WHERE EXISTS (SELECT 1 FROM platform_identity_guilds g WHERE g.id = u.guild_id)
170
+ ON CONFLICT (guild_id) DO UPDATE
171
+ SET credits = EXCLUDED.credits,
172
+ works_shipped = EXCLUDED.works_shipped,
173
+ karma = EXCLUDED.karma,
174
+ projects = EXCLUDED.projects,
175
+ computed_at = EXCLUDED.computed_at
176
+ WHERE t.computed_at <= now() - make_interval(days => $6::int)`,
177
+ [sums.map((r) => r.guild_id), col('credits'), col('works_shipped'), col('karma'), col('projects'),
178
+ TOTALS_REFRESH_DAYS],
179
+ );
180
+ return { scanned: stale.length, refreshed: rowCount };
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // The read
185
+ // ---------------------------------------------------------------------------
186
+
187
+ // A guild's published record, or null before its first refresh. A SELECT and
188
+ // nothing else: the record moves only on the poller's clock, never on a read.
189
+ // The caller decides whether the guild may be read at all (getPublicGuild).
190
+ async function readGuildTotals(guildId, { pool = defaultPool } = {}) {
191
+ const { rows } = await pool.query(
192
+ `SELECT credits, works_shipped, karma, projects, computed_at
193
+ FROM platform_identity_guild_totals
194
+ WHERE guild_id = $1`,
195
+ [guildId],
196
+ );
197
+ const r = rows[0];
198
+ if (!r) return null;
199
+ return {
200
+ credits: r.credits,
201
+ works_shipped: r.works_shipped,
202
+ karma: r.karma,
203
+ projects: r.projects,
204
+ as_of: r.computed_at,
205
+ };
206
+ }
207
+
208
+ module.exports = {
209
+ TOTALS_BAND_MANTISSAS,
210
+ TOTALS_FIRST_BAND,
211
+ NON_PUBLIC_MIN_COUNTED,
212
+ TOTALS_REFRESH_DAYS,
213
+ bandFloor,
214
+ computeGuildSums,
215
+ refreshStaleGuildTotals,
216
+ readGuildTotals,
217
+ };
@@ -26,7 +26,7 @@
26
26
  // be seen by the guild, never by the world). Every count is the length of a
27
27
  // list the reader may see.
28
28
  // * counted_in_totals (ADR 0337 D4.4) is stored and switchable here; the
29
- // totals that read it are task 1002302's.
29
+ // totals that read it live in guild-totals.js (task 1002302).
30
30
  //
31
31
  // NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). A guild grants no rank,
32
32
  // credits, admission or access to any project.
@@ -40,6 +40,7 @@ const {
40
40
  } = require('./account-visibility');
41
41
  const conn = require('./connections');
42
42
  const { normalizeHandle } = require('./platform-identity');
43
+ const { readGuildTotals } = require('./guild-totals');
43
44
 
44
45
  const { pool: defaultPool } = api;
45
46
 
@@ -654,6 +655,8 @@ async function listGuildRequests({ guildId, ownerGithubId }, { pool = defaultPoo
654
655
  // vary by one. The roster is shown_publicly AND accountVisibleSql, ordered only
655
656
  // by what it publishes (ADR 0255), capped at LIST_MAX; `size` is the length of
656
657
  // the list returned, never a total beside it (D4: there is no "+N more").
658
+ // `totals` is the weekly rounded record (ADR 0337 D4.4), or null before the
659
+ // first refresh — read, never computed, here.
657
660
  async function getPublicGuild(slug, { pool = defaultPool } = {}) {
658
661
  const g = await pool.query(
659
662
  `SELECT id, slug, name, description, created_at
@@ -672,8 +675,9 @@ async function getPublicGuild(slug, { pool = defaultPool } = {}) {
672
675
  LIMIT $2`,
673
676
  [guild.id, LIST_MAX],
674
677
  );
678
+ const totals = await readGuildTotals(guild.id, { pool });
675
679
  const { id, ...shown } = guild;
676
- return { guild: shown, members: rows, size: rows.length };
680
+ return { guild: shown, members: rows, size: rows.length, totals };
677
681
  }
678
682
 
679
683
  module.exports = {
@@ -0,0 +1,45 @@
1
+ -- platform_identity_027_guild_totals.sql — a guild's collective record, as a
2
+ -- stored weekly snapshot (task 1002302, R26; ADR 0337 D4.4, superseding ADR
3
+ -- 0336 D5's read-time sum).
4
+ --
5
+ -- WHAT THIS IS. One row per guild: the totals its page publishes, and when they
6
+ -- were computed. guild-totals.js writes it from a poller and nothing else does;
7
+ -- the public read only SELECTs it. A guild with no row yet publishes no totals.
8
+ --
9
+ -- THE SHAPE CARRIES THE RULES, so no reader has to remember them:
10
+ -- * ONLY BAND FLOORS ARE STORED. Every column is already rounded down on the
11
+ -- 1-2-5 scale (guild-totals.js bandFloor). The exact sum exists only in
12
+ -- memory for the moment it takes to round it, so no read, dump or backup can
13
+ -- recover a number more precise than the page shows. The CHECKs hold the
14
+ -- floor at zero; the scale itself lives in the domain as a named constant.
15
+ -- * computed_at IS THE WEEKLY GATE. The writer's upsert refreshes a row only
16
+ -- when its computed_at is at least a week old, in the same statement, so two
17
+ -- racing sweeps cannot refresh a guild twice in a week.
18
+ -- * THE FK CASCADES, so a dissolved guild takes its record with it (the ADR
19
+ -- 0336 D2 no-tombstone reason).
20
+ --
21
+ -- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). No rank, credit or admission
22
+ -- path reads this table; it is descriptive only.
23
+ --
24
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). CREATE only, so the
25
+ -- previous release still runs against this schema. Idempotent.
26
+
27
+ BEGIN;
28
+
29
+ CREATE TABLE IF NOT EXISTS platform_identity_guild_totals (
30
+ guild_id bigint PRIMARY KEY REFERENCES platform_identity_guilds(id) ON DELETE CASCADE,
31
+ credits integer NOT NULL CHECK (credits >= 0),
32
+ works_shipped integer NOT NULL CHECK (works_shipped >= 0),
33
+ karma integer NOT NULL CHECK (karma >= 0),
34
+ projects integer NOT NULL CHECK (projects >= 0),
35
+ computed_at timestamptz NOT NULL DEFAULT now()
36
+ );
37
+
38
+ -- The sweep's working set: the stalest rows first.
39
+ CREATE INDEX IF NOT EXISTS platform_identity_guild_totals_computed_at
40
+ ON platform_identity_guild_totals (computed_at);
41
+
42
+ COMMENT ON TABLE platform_identity_guild_totals IS
43
+ 'A guild''s published collective record (ADR 0337 D4.4, task 1002302): band floors on the 1-2-5 scale, never exact sums, recomputed at most weekly by a poller and never on a read. Non-public members'' numbers enter only while the guild has at least 3 counted non-public members. Descriptive only, never authority (ADR 0016).';
44
+
45
+ COMMIT;
@@ -13,7 +13,7 @@
13
13
  "contributes": {
14
14
  "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search", "community-leaderboard", "guilds", "guilds-public"],
15
15
  "migrations": true,
16
- "pollers": ["code-sweep", "visibility-pull"]
16
+ "pollers": ["code-sweep", "visibility-pull", "guild-totals"]
17
17
  },
18
18
  "provides": ["platform-identity.hub", "project.publishGate", "project.catalog"],
19
19
  "consumes": []
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ // modules/platform-identity/pollers/guild-totals.js — the loader poller entry that
4
+ // refreshes guilds' collective records (R26, task 1002302; ADR 0337 D4.4). The
5
+ // loader's startModulePollers requires THIS file and calls start(deps) at boot /
6
+ // stop() at shutdown for the ENABLED module only (the hub) — non-blocking and
7
+ // failure-isolated, so a sweep hiccup never touches a read. Mirrors visibility-pull.js.
8
+ //
9
+ // WHY A POLLER. D4.4 says the totals are "recomputed at most once a week, never on a
10
+ // read": a read that recomputed would let an observer move the number they are
11
+ // watching by asking for it. So the only writer runs on its own clock, and the
12
+ // public read (getPublicGuild) only SELECTs what it last wrote.
13
+ //
14
+ // Proof: ../../../tests/guild_totals.mjs (DB-free), ../../../tests/guild_totals_db.mjs
15
+
16
+ const api = require('../../../src/module-api');
17
+ const guildTotals = require('../guild-totals');
18
+
19
+ const log = api.logger('platform-identity');
20
+
21
+ // Hourly. The weekly limit is enforced per row by refreshStaleGuildTotals, not by
22
+ // this cadence, so the interval only sets how late a due row may run — and how soon
23
+ // a new guild gets its first record.
24
+ const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
25
+
26
+ // Guilds per sweep. A ceiling rather than a page: refreshStaleGuildTotals takes the
27
+ // stalest first, so a backlog larger than this converges over a few sweeps.
28
+ const SWEEP_LIMIT = 200;
29
+
30
+ // Wait before the FIRST sweep so boot is never competing with it.
31
+ const FIRST_SWEEP_DELAY_MS = 60 * 1000;
32
+
33
+ let timer = null;
34
+ let firstTimer = null;
35
+
36
+ async function sweepOnce(deps = {}) {
37
+ try {
38
+ const out = await guildTotals.refreshStaleGuildTotals(
39
+ { limit: SWEEP_LIMIT },
40
+ deps.pool ? { pool: deps.pool } : {},
41
+ );
42
+ // One line per sweep, and only when something was refreshed.
43
+ if (out.refreshed > 0) {
44
+ log.info({ refreshed: out.refreshed, scanned: out.scanned }, 'guild-totals: record(s) refreshed');
45
+ }
46
+ return out;
47
+ } catch (err) {
48
+ // Best-effort: a sweep failure must never crash the process. The rows it did not
49
+ // refresh stay due, so the next sweep picks them up.
50
+ log.error({ err: err && err.message }, 'guild-totals sweep failed (non-fatal)');
51
+ return null;
52
+ }
53
+ }
54
+
55
+ module.exports = {
56
+ start: (deps = {}) => {
57
+ if (timer || firstTimer) return; // idempotent
58
+ firstTimer = setTimeout(() => {
59
+ firstTimer = null;
60
+ sweepOnce(deps);
61
+ timer = setInterval(() => { sweepOnce(deps); }, SWEEP_INTERVAL_MS);
62
+ if (timer.unref) timer.unref();
63
+ }, FIRST_SWEEP_DELAY_MS);
64
+ // unref so neither timer keeps the process alive on its own.
65
+ if (firstTimer.unref) firstTimer.unref();
66
+ },
67
+ stop: () => {
68
+ if (timer) { clearInterval(timer); timer = null; }
69
+ if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
70
+ },
71
+ // exported for tests
72
+ sweepOnce,
73
+ SWEEP_INTERVAL_MS,
74
+ SWEEP_LIMIT,
75
+ FIRST_SWEEP_DELAY_MS,
76
+ };
@@ -3,12 +3,17 @@
3
3
  // routes/guilds.js is own-scoped and held there by tests/own_scope_guard.mjs;
4
4
  // this one is deliberately not.
5
5
  //
6
- // GET /guilds/:slug — a PUBLIC guild's name, description, public roster and
7
- // size. 404 for an unknown slug AND for an unlisted guild, to everyone, its own
8
- // members included: the read takes no viewer, so it cannot vary by one (the
9
- // private-profile rule, ADR 0171 D4). Members reach an unlisted guild through
10
- // the own-scope GET /guilds/:slug/members. The page at /g/<slug> is task
11
- // 1002301's; this is its read.
6
+ // GET /guilds/:slug — a PUBLIC guild's name, description, public roster,
7
+ // size and rounded weekly totals (task 1002302). 404 for an unknown slug AND
8
+ // for an unlisted guild, to everyone, its own members included: the read
9
+ // takes no viewer, so it cannot vary by one (the private-profile rule, ADR
10
+ // 0171 D4). Members reach an unlisted guild through the own-scope
11
+ // GET /guilds/:slug/members. The page at /g/<slug> is task 1002301's; this is
12
+ // its read.
13
+ //
14
+ // GET /guilds — the guild MAP (task 1002306, R30; ADR 0336 D8): public guilds
15
+ // with a non-empty public roster, ranked by size or by the stored shipped band
16
+ // floor, slug tiebreak, paged. Who and how live in ../guild-map.js.
12
17
  //
13
18
  // Reaches core only through the doorway (ADR 0083).
14
19
  'use strict';
@@ -16,6 +21,7 @@
16
21
  const express = require('express');
17
22
  const api = require('../../../src/module-api');
18
23
  const guilds = require('../guilds');
24
+ const guildMap = require('../guild-map');
19
25
  const { createReadRateLimit } = require('../read-rate-limit');
20
26
 
21
27
  const { pool } = api;
@@ -33,20 +39,52 @@ function logError(msg) {
33
39
  // uncapped scraper could walk the slug space at line rate.
34
40
  const guildReadRateLimit = createReadRateLimit({ scope: 'guild-public-read', limit: 120 });
35
41
 
42
+ // The map's own budget: one read aggregates every public guild's roster, so it
43
+ // gets the community reads' tighter cap rather than the single-guild one.
44
+ const guildMapRateLimit = createReadRateLimit({ scope: 'guild-map-read', limit: 60 });
45
+
36
46
  module.exports = function guildPublicRoutes() {
37
47
  const router = express.Router();
38
48
 
49
+ // rank: public — the guild map (?order=size|shipped, default size;
50
+ // ?limit=&offset=). Viewer-independent: it reads no session. size is the
51
+ // public roster's length; shipped is R26's stored band floor (ADR 0337 D4.4),
52
+ // never an exact sum, null before a guild's first refresh. no-store: a hide or
53
+ // a visibility flip moves size on the very next read (ADR 0171 D5).
54
+ router.get('/guilds', guildMapRateLimit, async (req, res) => {
55
+ const order = req.query.order === undefined || req.query.order === ''
56
+ ? guildMap.DEFAULT_ORDER
57
+ : req.query.order;
58
+ if (!guildMap.isOrder(order)) {
59
+ return res.fail('guild_map_bad_order', { status: 400, message: `order must be one of: ${guildMap.ORDER_KEYS.join(', ')}` });
60
+ }
61
+ try {
62
+ const map = await guildMap.listGuildMap({ order, limit: req.query.limit, offset: req.query.offset }, { pool });
63
+ res.set('Cache-Control', 'no-store');
64
+ res.json({
65
+ order: map.order,
66
+ count: map.results.length,
67
+ results: map.results,
68
+ page: { limit: map.limit, offset: map.offset },
69
+ });
70
+ } catch (err) {
71
+ logError(`GET /guilds failed: ${err && err.message}`);
72
+ res.fail('guild_map_failed', { status: 500, message: 'internal error' });
73
+ }
74
+ });
75
+
39
76
  // rank: public — a public guild's page data. The roster is the members who
40
77
  // agreed to be shown AND whose own accounts are publicly visible (ADR 0336 D4);
41
- // size is that roster's length. no-store: a hide or a visibility flip must beat
42
- // every cache (ADR 0171 D5).
78
+ // size is that roster's length; totals are the weekly band floors (ADR 0337
79
+ // D4.4), null before the first refresh. no-store: a hide or a visibility flip
80
+ // must beat every cache (ADR 0171 D5).
43
81
  router.get('/guilds/:slug', guildReadRateLimit, async (req, res) => {
44
82
  res.set('Cache-Control', 'no-store');
45
83
  try {
46
84
  const slug = guilds.lookupSlug(req.params.slug);
47
85
  const out = slug ? await guilds.getPublicGuild(slug, { pool }) : null;
48
86
  if (!out) return res.fail('guild_not_found', 404);
49
- res.json({ guild: out.guild, members: out.members, size: out.size });
87
+ res.json({ guild: out.guild, members: out.members, size: out.size, totals: out.totals });
50
88
  } catch (err) {
51
89
  logError(`GET /guilds/:slug failed: ${err && err.message}`);
52
90
  res.fail('guild_read_failed', { status: 500, message: 'internal error' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.19",
3
+ "version": "1.20.21",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.19",
9
+ "version": "1.20.21",
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.20.19",
3
+ "version": "1.20.21",
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",
@@ -8170,5 +8170,17 @@
8170
8170
  "id": "1004268",
8171
8171
  "text": "Builders' activity numbers on the platform can no longer briefly go backwards when two updates arrive at the same time."
8172
8172
  }
8173
+ ],
8174
+ "1.20.20": [
8175
+ {
8176
+ "id": "1002302",
8177
+ "text": "Guilds now show a combined track record: rounded totals of their members' credits, shipped work and projects, refreshed once a week. Members who keep their numbers private still count, but only when at least three such members"
8178
+ }
8179
+ ],
8180
+ "1.20.21": [
8181
+ {
8182
+ "id": "1002306",
8183
+ "text": "The hub can now list guilds ranked by size or by how much their members have shipped, using the rounded weekly totals. Only public guilds with visible members appear, and private members are never counted."
8184
+ }
8173
8185
  ]
8174
8186
  }
@@ -178,6 +178,15 @@ const INTEGRATION = new Set([
178
178
  // proves the one-owner partial index, the expiry CTE drained before its insert,
179
179
  // the cascade on dissolve and the two-statement transfer.
180
180
  'guilds_db',
181
+ // task 1002302 (R26): a guild's weekly rounded totals against real SQL. Who is
182
+ // counted (three predicates and a FILTERed k count) and the upsert's week guard,
183
+ // re-evaluated against a racing writer's committed row, are planner facts; the
184
+ // DB-free sibling (guild_totals) pins the band scale and statement shapes.
185
+ 'guild_totals_db',
186
+ // task 1002306 (R30): the guild map against real SQL — the INNER join that drops
187
+ // an empty public roster, the capped roster count, and NULLS LAST on a guild
188
+ // with no weekly record yet. guilds_api §3b pins the route and statement shape.
189
+ 'guild_map_db',
181
190
  // task 1003935: 'linkify_refs' was swept in with the three above without the
182
191
  // no-DB check they were being fixed for. It touches no Postgres at all — pure
183
192
  // string work plus a filesystem walk — so the DB-free lane skipped it and the
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.19'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.21'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');