@bongos/core 1.20.19 → 1.20.20
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 +44 -19
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +5 -3
- package/docs/module-api-changelog.md +2 -0
- package/modules/platform-identity/guild-totals.js +217 -0
- package/modules/platform-identity/guilds.js +6 -2
- package/modules/platform-identity/migrations/platform_identity_027_guild_totals.sql +45 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/pollers/guild-totals.js +76 -0
- package/modules/platform-identity/routes/guilds-public.js +11 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/guild_totals.mjs +183 -0
- package/tests/guild_totals_db.mjs +292 -0
- package/tests/guilds_api.mjs +6 -1
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.20.
|
|
6
|
-
"core_contract": "1.20.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.20.20",
|
|
6
|
+
"core_contract": "1.20.20",
|
|
7
|
+
"source_commit": "81de3611c8c98f0a428be1780f94435846cd7bf2",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-30T14:
|
|
9
|
+
"built_at": "2026-09-30T14:55:04.182Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 557,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2636,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3219,
|
|
20
|
+
"tree_sha256": "9d08157c7a131c0010616436de1c1b4b0ecd19030def84afd3ed402813e39499",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -347,7 +347,7 @@
|
|
|
347
347
|
{
|
|
348
348
|
"path": "clients/bongos-client/index.d.ts",
|
|
349
349
|
"mode": "0000644",
|
|
350
|
-
"sha256": "
|
|
350
|
+
"sha256": "d9b3edd04125960791f6282252f5f31b2ace6abb90beeb19c483af1a422fbf53"
|
|
351
351
|
},
|
|
352
352
|
{
|
|
353
353
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -2247,7 +2247,7 @@
|
|
|
2247
2247
|
{
|
|
2248
2248
|
"path": "docs/api/openapi.json",
|
|
2249
2249
|
"mode": "0000644",
|
|
2250
|
-
"sha256": "
|
|
2250
|
+
"sha256": "4167b07fddd64f47f901082e130a12f307e4e2fab5ac9bc821e24a0c4f6f3d72"
|
|
2251
2251
|
},
|
|
2252
2252
|
{
|
|
2253
2253
|
"path": "docs/architecture.md",
|
|
@@ -2777,7 +2777,7 @@
|
|
|
2777
2777
|
{
|
|
2778
2778
|
"path": "docs/module-api-changelog.md",
|
|
2779
2779
|
"mode": "0000644",
|
|
2780
|
-
"sha256": "
|
|
2780
|
+
"sha256": "2b82bf65da0987ffc862ad6369e0cbcf8e6adef0a70915cf7a7a2c2289a69c85"
|
|
2781
2781
|
},
|
|
2782
2782
|
{
|
|
2783
2783
|
"path": "docs/modules-contract.md",
|
|
@@ -6864,10 +6864,15 @@
|
|
|
6864
6864
|
"mode": "0000644",
|
|
6865
6865
|
"sha256": "793e96feacef39ec52166dd7372c894a25679c7137c9c3c5e9df38ec323f1c4a"
|
|
6866
6866
|
},
|
|
6867
|
+
{
|
|
6868
|
+
"path": "modules/platform-identity/guild-totals.js",
|
|
6869
|
+
"mode": "0000644",
|
|
6870
|
+
"sha256": "8e07aed97641d68d826ffea3055d209b8fd9eb172e1d000f17e335f7296cc6d6"
|
|
6871
|
+
},
|
|
6867
6872
|
{
|
|
6868
6873
|
"path": "modules/platform-identity/guilds.js",
|
|
6869
6874
|
"mode": "0000644",
|
|
6870
|
-
"sha256": "
|
|
6875
|
+
"sha256": "e048e674b087eae9035da5b3449d8f4029ec329a42333980eb21ac3e6c13a297"
|
|
6871
6876
|
},
|
|
6872
6877
|
{
|
|
6873
6878
|
"path": "modules/platform-identity/hub-device.js",
|
|
@@ -7019,10 +7024,15 @@
|
|
|
7019
7024
|
"mode": "0000644",
|
|
7020
7025
|
"sha256": "9f8d582d626dec82b55b96272f9d6d7f6c1488ac28bd9a98ab673404afde0c8e"
|
|
7021
7026
|
},
|
|
7027
|
+
{
|
|
7028
|
+
"path": "modules/platform-identity/migrations/platform_identity_027_guild_totals.sql",
|
|
7029
|
+
"mode": "0000644",
|
|
7030
|
+
"sha256": "9b263f91ce6df19640ef8ae413f3eb40cc17589924fdf21256da7647b55631c1"
|
|
7031
|
+
},
|
|
7022
7032
|
{
|
|
7023
7033
|
"path": "modules/platform-identity/module.json",
|
|
7024
7034
|
"mode": "0000644",
|
|
7025
|
-
"sha256": "
|
|
7035
|
+
"sha256": "48c9d82d9ec647430a97ba19f403b22e7eba13e16ff553b6bbd08691a8918a7b"
|
|
7026
7036
|
},
|
|
7027
7037
|
{
|
|
7028
7038
|
"path": "modules/platform-identity/origin-containment.js",
|
|
@@ -7039,6 +7049,11 @@
|
|
|
7039
7049
|
"mode": "0000644",
|
|
7040
7050
|
"sha256": "b6eea95dbe66a3126fc2c73d93237cf38dd648a803e910eb88fbd5c780ccef62"
|
|
7041
7051
|
},
|
|
7052
|
+
{
|
|
7053
|
+
"path": "modules/platform-identity/pollers/guild-totals.js",
|
|
7054
|
+
"mode": "0000644",
|
|
7055
|
+
"sha256": "cfd421477fdcee4a8a92ed30fa1bb987e9f8a055862c8564de9a9bfe8eb5e089"
|
|
7056
|
+
},
|
|
7042
7057
|
{
|
|
7043
7058
|
"path": "modules/platform-identity/pollers/visibility-pull.js",
|
|
7044
7059
|
"mode": "0000644",
|
|
@@ -7092,7 +7107,7 @@
|
|
|
7092
7107
|
{
|
|
7093
7108
|
"path": "modules/platform-identity/routes/guilds-public.js",
|
|
7094
7109
|
"mode": "0000644",
|
|
7095
|
-
"sha256": "
|
|
7110
|
+
"sha256": "b6029f6f9d40fec18e42c0eaba75779ad7baa0c29df47bb7fe248a83cb783396"
|
|
7096
7111
|
},
|
|
7097
7112
|
{
|
|
7098
7113
|
"path": "modules/platform-identity/routes/guilds.js",
|
|
@@ -8872,12 +8887,12 @@
|
|
|
8872
8887
|
{
|
|
8873
8888
|
"path": "package-lock.json",
|
|
8874
8889
|
"mode": "0000644",
|
|
8875
|
-
"sha256": "
|
|
8890
|
+
"sha256": "830a8f9950a12b57c520a79198562f423818f1f1a98095d7122ddf4a6100f052"
|
|
8876
8891
|
},
|
|
8877
8892
|
{
|
|
8878
8893
|
"path": "package.json",
|
|
8879
8894
|
"mode": "0000644",
|
|
8880
|
-
"sha256": "
|
|
8895
|
+
"sha256": "3b0d575917863d61bca84ddc2ce68886c33bc0d4fae9825a7abb8e2a5da7eec8"
|
|
8881
8896
|
},
|
|
8882
8897
|
{
|
|
8883
8898
|
"path": "public-docs/index.html",
|
|
@@ -8897,7 +8912,7 @@
|
|
|
8897
8912
|
{
|
|
8898
8913
|
"path": "release-notes.json",
|
|
8899
8914
|
"mode": "0000644",
|
|
8900
|
-
"sha256": "
|
|
8915
|
+
"sha256": "47fee7ce9947c485d183f574d1564c43da3329822ffd470897609631fe95effc"
|
|
8901
8916
|
},
|
|
8902
8917
|
{
|
|
8903
8918
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -10057,7 +10072,7 @@
|
|
|
10057
10072
|
{
|
|
10058
10073
|
"path": "scripts/gds/run-unit-tests.js",
|
|
10059
10074
|
"mode": "0000644",
|
|
10060
|
-
"sha256": "
|
|
10075
|
+
"sha256": "b4b2358efdc538a35472258f5c0812bc3990e179612f663810cdcde6f1e0b143"
|
|
10061
10076
|
},
|
|
10062
10077
|
{
|
|
10063
10078
|
"path": "scripts/gds/runner-drift.js",
|
|
@@ -11012,7 +11027,7 @@
|
|
|
11012
11027
|
{
|
|
11013
11028
|
"path": "src/module-api.js",
|
|
11014
11029
|
"mode": "0000644",
|
|
11015
|
-
"sha256": "
|
|
11030
|
+
"sha256": "091a4bde841eaad09639727d4691f0c8a5555926e33cbffc49c7acc6b95870a6"
|
|
11016
11031
|
},
|
|
11017
11032
|
{
|
|
11018
11033
|
"path": "src/module-loader/catalog.js",
|
|
@@ -13064,10 +13079,20 @@
|
|
|
13064
13079
|
"mode": "0000644",
|
|
13065
13080
|
"sha256": "8298006ae35010d726bcac8a2d351a5aa932d2ee6d5b53ed7b8a5c095ca5e54f"
|
|
13066
13081
|
},
|
|
13082
|
+
{
|
|
13083
|
+
"path": "tests/guild_totals.mjs",
|
|
13084
|
+
"mode": "0000644",
|
|
13085
|
+
"sha256": "c488d1231e2d7b9233e7b857cf584c4c6df9c596b34c7e4a74c8d976f38d1ada"
|
|
13086
|
+
},
|
|
13087
|
+
{
|
|
13088
|
+
"path": "tests/guild_totals_db.mjs",
|
|
13089
|
+
"mode": "0000644",
|
|
13090
|
+
"sha256": "095f66255fa4755ff57db9aef07a5334f12a2f1720a71e9523eeb5d28356029a"
|
|
13091
|
+
},
|
|
13067
13092
|
{
|
|
13068
13093
|
"path": "tests/guilds_api.mjs",
|
|
13069
13094
|
"mode": "0000644",
|
|
13070
|
-
"sha256": "
|
|
13095
|
+
"sha256": "2476ef4a332fa6d82d9d40ca798012c5aef40a74b08c5ca8d8d40e1975223cbb"
|
|
13071
13096
|
},
|
|
13072
13097
|
{
|
|
13073
13098
|
"path": "tests/guilds_db.mjs",
|
|
@@ -74,7 +74,7 @@ export interface GetGovernmentRanksResponse { ranks: unknown }
|
|
|
74
74
|
export interface GetGradesByBuilderResponse { window_days: unknown; builders: unknown }
|
|
75
75
|
export interface GetGuildsSlugMembersResponse { guild: unknown; you: unknown; members: unknown; count: unknown }
|
|
76
76
|
export interface GetGuildsSlugRequestsResponse { incoming: unknown; outgoing: unknown; incoming_count: unknown; outgoing_count: unknown }
|
|
77
|
-
export interface GetGuildsSlugResponse { guild: unknown; members: unknown; size: unknown }
|
|
77
|
+
export interface GetGuildsSlugResponse { guild: unknown; members: unknown; size: unknown; totals: unknown }
|
|
78
78
|
export interface GetHealthzResponse { ok: boolean; auth_configured: unknown }
|
|
79
79
|
export interface GetHelpRequestsArchiveResponse { asked_of_you: unknown; asked_of_others: unknown; count: unknown; crafts: unknown }
|
|
80
80
|
export interface GetHelpRequestsForMeResponse { help_requests: unknown; count: unknown; crafts: unknown }
|
package/docs/api/openapi.json
CHANGED
|
@@ -8645,7 +8645,7 @@
|
|
|
8645
8645
|
"guilds"
|
|
8646
8646
|
],
|
|
8647
8647
|
"summary": "GET /guilds/:slug",
|
|
8648
|
-
"description": "a public guild's page data. The roster is the members who agreed to be shown AND whose own accounts are publicly visible (ADR 0336 D4); size is that roster's length. no-store: a hide or a visibility flip must beat every cache (ADR 0171 D5).\n\n**Rank:** `public` — No authentication — any caller.",
|
|
8648
|
+
"description": "a public guild's page data. The roster is the members who agreed to be shown AND whose own accounts are publicly visible (ADR 0336 D4); size is that roster's length; totals are the weekly band floors (ADR 0337 D4.4), null before the first refresh. no-store: a hide or a visibility flip must beat every cache (ADR 0171 D5).\n\n**Rank:** `public` — No authentication — any caller.",
|
|
8649
8649
|
"x-rank": "public",
|
|
8650
8650
|
"x-source": "modules/platform-identity/routes/guilds-public.js",
|
|
8651
8651
|
"parameters": [
|
|
@@ -22586,12 +22586,14 @@
|
|
|
22586
22586
|
"properties": {
|
|
22587
22587
|
"guild": {},
|
|
22588
22588
|
"members": {},
|
|
22589
|
-
"size": {}
|
|
22589
|
+
"size": {},
|
|
22590
|
+
"totals": {}
|
|
22590
22591
|
},
|
|
22591
22592
|
"required": [
|
|
22592
22593
|
"guild",
|
|
22593
22594
|
"members",
|
|
22594
|
-
"size"
|
|
22595
|
+
"size",
|
|
22596
|
+
"totals"
|
|
22595
22597
|
]
|
|
22596
22598
|
},
|
|
22597
22599
|
"GetHealthzResponse": {
|
|
@@ -2695,5 +2695,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2695
2695
|
landed since 1.20.17 with no explicit bump. run 36724081934. (task 1002620)
|
|
2696
2696
|
1.20.19 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2697
2697
|
landed since 1.20.18 with no explicit bump. run 36727774141. (task 1002620)
|
|
2698
|
+
1.20.20 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2699
|
+
landed since 1.20.19 with no explicit bump. run 36732445320. (task 1002620)
|
|
2698
2700
|
---------------------------------------------------------------------------
|
|
2699
2701
|
```
|
|
@@ -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
|
|
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,13 @@
|
|
|
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
|
|
7
|
-
// size. 404 for an unknown slug AND
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// 1002301's; this is
|
|
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.
|
|
12
13
|
//
|
|
13
14
|
// Reaches core only through the doorway (ADR 0083).
|
|
14
15
|
'use strict';
|
|
@@ -38,15 +39,16 @@ module.exports = function guildPublicRoutes() {
|
|
|
38
39
|
|
|
39
40
|
// rank: public — a public guild's page data. The roster is the members who
|
|
40
41
|
// agreed to be shown AND whose own accounts are publicly visible (ADR 0336 D4);
|
|
41
|
-
// size is that roster's length
|
|
42
|
-
//
|
|
42
|
+
// size is that roster's length; totals are the weekly band floors (ADR 0337
|
|
43
|
+
// D4.4), null before the first refresh. no-store: a hide or a visibility flip
|
|
44
|
+
// must beat every cache (ADR 0171 D5).
|
|
43
45
|
router.get('/guilds/:slug', guildReadRateLimit, async (req, res) => {
|
|
44
46
|
res.set('Cache-Control', 'no-store');
|
|
45
47
|
try {
|
|
46
48
|
const slug = guilds.lookupSlug(req.params.slug);
|
|
47
49
|
const out = slug ? await guilds.getPublicGuild(slug, { pool }) : null;
|
|
48
50
|
if (!out) return res.fail('guild_not_found', 404);
|
|
49
|
-
res.json({ guild: out.guild, members: out.members, size: out.size });
|
|
51
|
+
res.json({ guild: out.guild, members: out.members, size: out.size, totals: out.totals });
|
|
50
52
|
} catch (err) {
|
|
51
53
|
logError(`GET /guilds/:slug failed: ${err && err.message}`);
|
|
52
54
|
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.
|
|
3
|
+
"version": "1.20.20",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.20",
|
|
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.
|
|
3
|
+
"version": "1.20.20",
|
|
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/release-notes.json
CHANGED
|
@@ -8170,5 +8170,11 @@
|
|
|
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
|
+
}
|
|
8173
8179
|
]
|
|
8174
8180
|
}
|
|
@@ -178,6 +178,11 @@ 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',
|
|
181
186
|
// task 1003935: 'linkify_refs' was swept in with the three above without the
|
|
182
187
|
// no-DB check they were being fixed for. It touches no Postgres at all — pure
|
|
183
188
|
// 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.
|
|
78
|
+
const CORE_VERSION = '1.20.20'; // 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');
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// tests/guild_totals.mjs — a guild's collective record (R26, task 1002302; ADR
|
|
2
|
+
// 0337 D4.4, superseding ADR 0336 D5).
|
|
3
|
+
//
|
|
4
|
+
// DB-free. What it holds, each easy to lose in a later edit without anything else
|
|
5
|
+
// going red:
|
|
6
|
+
//
|
|
7
|
+
// 1. THE BAND SCALE. 1-2-5 from 10, the band floor published, 0 below the first
|
|
8
|
+
// band — exact numbers never reach a row.
|
|
9
|
+
// 2. THE NAMED CONSTANTS are the ADR's: k = 3, a 7-day refresh.
|
|
10
|
+
// 3. THE COMPUTATION COMPOSES THE SHARED PREDICATES (accountActiveSql for who may
|
|
11
|
+
// count at all, rollupVisibleSql for who is public) and never restates them;
|
|
12
|
+
// only public members' named public projects enter the project count.
|
|
13
|
+
// 4. THE WEEKLY GATE IS IN THE WRITE ITSELF, and the read is a bare SELECT.
|
|
14
|
+
// 5. THE POLLER IS REGISTERED and a failed sweep never throws.
|
|
15
|
+
//
|
|
16
|
+
// The rules that only a real planner can settle (who is counted, when k flips,
|
|
17
|
+
// the padding case, the upsert's age guard) are proved in tests/guild_totals_db.mjs.
|
|
18
|
+
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const gt = require('../modules/platform-identity/guild-totals.js');
|
|
26
|
+
const poller = require('../modules/platform-identity/pollers/guild-totals.js');
|
|
27
|
+
const { accountActiveSql, rollupVisibleSql } = require('../modules/platform-identity/account-visibility.js');
|
|
28
|
+
const { onMapSql } = require('../modules/platform-identity/projects-feed.js');
|
|
29
|
+
|
|
30
|
+
// A pool that records every statement and answers from a queue.
|
|
31
|
+
function recordingPool(responses = []) {
|
|
32
|
+
const calls = [];
|
|
33
|
+
const queue = [...responses];
|
|
34
|
+
return {
|
|
35
|
+
calls,
|
|
36
|
+
query(text, params) {
|
|
37
|
+
calls.push({ text, params });
|
|
38
|
+
const next = queue.shift();
|
|
39
|
+
if (next instanceof Error) return Promise.reject(next);
|
|
40
|
+
return Promise.resolve(next || { rows: [], rowCount: 0 });
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── 1. The band scale ──
|
|
46
|
+
|
|
47
|
+
test('bandFloor publishes the 1-2-5 band floor from 10, and 0 below it', () => {
|
|
48
|
+
const cases = [
|
|
49
|
+
[0, 0], [1, 0], [9, 0], [9.99, 0],
|
|
50
|
+
[10, 10], [19, 10], [20, 20], [49, 20], [50, 50], [99, 50],
|
|
51
|
+
[100, 100], [199, 100], [200, 200], [499, 200], [500, 500], [999, 500],
|
|
52
|
+
[1000, 1000], [2500, 2000], [7777, 5000], [12345, 10000],
|
|
53
|
+
];
|
|
54
|
+
for (const [n, want] of cases) assert.equal(gt.bandFloor(n), want, `bandFloor(${n})`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('bandFloor never publishes more than it was given, and fails closed on junk', () => {
|
|
58
|
+
for (const n of [-50, -1, NaN, undefined, null, 'abc', Infinity]) {
|
|
59
|
+
assert.equal(gt.bandFloor(n), 0, `bandFloor(${String(n)}) is 0`);
|
|
60
|
+
}
|
|
61
|
+
// Postgres hands sums back as strings; the scale reads them the same.
|
|
62
|
+
assert.equal(gt.bandFloor('1234'), 1000);
|
|
63
|
+
// The columns are integer: a floor never exceeds what they can hold.
|
|
64
|
+
assert.equal(gt.bandFloor(5e12), 2000000000);
|
|
65
|
+
for (let n = 0; n < 5000; n += 7) assert.ok(gt.bandFloor(n) <= n);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── 2. The named constants ──
|
|
69
|
+
|
|
70
|
+
test('the constants are the ADR\'s own numbers', () => {
|
|
71
|
+
assert.equal(gt.NON_PUBLIC_MIN_COUNTED, 3, 'k = 3 (ADR 0337 D4.4)');
|
|
72
|
+
assert.equal(gt.TOTALS_REFRESH_DAYS, 7, 'at most weekly');
|
|
73
|
+
assert.deepEqual(gt.TOTALS_BAND_MANTISSAS, [1, 2, 5]);
|
|
74
|
+
assert.equal(gt.TOTALS_FIRST_BAND, 10);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── 3. The computation ──
|
|
78
|
+
|
|
79
|
+
test('who counts is composed from the shared predicates, never restated', async () => {
|
|
80
|
+
const pool = recordingPool([{ rows: [] }]);
|
|
81
|
+
await gt.computeGuildSums(['7', '8'], { pool });
|
|
82
|
+
const { text, params } = pool.calls[0];
|
|
83
|
+
assert.ok(text.includes(accountActiveSql('pa')), 'an un-consented account never counts');
|
|
84
|
+
assert.ok(text.includes(`(gm.shown_publicly AND ${rollupVisibleSql('pa')}) AS is_public`),
|
|
85
|
+
'public = shown on this guild\'s page AND the account\'s own record is public');
|
|
86
|
+
assert.match(text, /AND gm\.counted_in_totals/, 'the per-guild switch is honoured');
|
|
87
|
+
assert.match(text, /WHERE m\.is_public OR k\.non_public >= \$2/,
|
|
88
|
+
'non-public numbers enter only at or above k');
|
|
89
|
+
assert.deepEqual(params, [['7', '8'], 3], 'k is bound from the named constant');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('only public members\' named public projects enter the project count', async () => {
|
|
93
|
+
const pool = recordingPool([{ rows: [] }]);
|
|
94
|
+
await gt.computeGuildSums(['7'], { pool });
|
|
95
|
+
const projs = pool.calls[0].text.split('projs AS')[1];
|
|
96
|
+
assert.ok(projs.includes(`WHERE c.is_public AND ${onMapSql('p')} AND p.visibility = 'public'`),
|
|
97
|
+
'ADR 0336 D5\'s union rule: never a non-public member\'s projects, never a non-public project');
|
|
98
|
+
assert.match(projs, /count\(DISTINCT p\.id\)/, 'a project two members share counts once');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('an empty batch asks the database nothing', async () => {
|
|
102
|
+
const pool = recordingPool();
|
|
103
|
+
assert.deepEqual(await gt.computeGuildSums([], { pool }), []);
|
|
104
|
+
assert.equal(pool.calls.length, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── 4. The weekly gate, and a read that only reads ──
|
|
108
|
+
|
|
109
|
+
test('refresh stores band floors only, and re-asserts the week in its own upsert', async () => {
|
|
110
|
+
const pool = recordingPool([
|
|
111
|
+
{ rows: [{ id: '7' }, { id: '8' }] },
|
|
112
|
+
{ rows: [
|
|
113
|
+
{ guild_id: '7', credits: '1234', works_shipped: '57', karma: '-3', projects: '2' },
|
|
114
|
+
{ guild_id: '8', credits: '0', works_shipped: '0', karma: '0', projects: '0' },
|
|
115
|
+
] },
|
|
116
|
+
{ rows: [], rowCount: 2 },
|
|
117
|
+
]);
|
|
118
|
+
const out = await gt.refreshStaleGuildTotals({ limit: 50 }, { pool });
|
|
119
|
+
assert.deepEqual(out, { scanned: 2, refreshed: 2 });
|
|
120
|
+
|
|
121
|
+
const [pick, , upsert] = pool.calls;
|
|
122
|
+
assert.match(pick.text, /t\.guild_id IS NULL OR t\.computed_at <= now\(\) - make_interval\(days => \$1::int\)/,
|
|
123
|
+
'due = never computed, or at least a week old');
|
|
124
|
+
assert.match(pick.text, /ORDER BY t\.computed_at ASC NULLS FIRST/, 'stalest first');
|
|
125
|
+
assert.deepEqual(pick.params, [7, 50]);
|
|
126
|
+
|
|
127
|
+
assert.match(upsert.text, /ON CONFLICT \(guild_id\) DO UPDATE[\s\S]*WHERE t\.computed_at <= now\(\) - make_interval\(days => \$6::int\)/,
|
|
128
|
+
'a racing sweep cannot refresh a guild twice in a week');
|
|
129
|
+
assert.match(upsert.text, /WHERE EXISTS \(SELECT 1 FROM platform_identity_guilds g WHERE g\.id = u\.guild_id\)/,
|
|
130
|
+
'a guild dissolved mid-sweep is skipped, not an FK failure for the batch');
|
|
131
|
+
assert.deepEqual(upsert.params, [['7', '8'], [1000, 0], [50, 0], [0, 0], [0, 0], 7],
|
|
132
|
+
'only band floors are written — 1234 credits is stored as 1000, never 1234');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('nothing due → no computation and no write', async () => {
|
|
136
|
+
const pool = recordingPool([{ rows: [] }]);
|
|
137
|
+
assert.deepEqual(await gt.refreshStaleGuildTotals({}, { pool }), { scanned: 0, refreshed: 0 });
|
|
138
|
+
assert.equal(pool.calls.length, 1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('the read is one SELECT of the stored record, and null before the first refresh', async () => {
|
|
142
|
+
const at = new Date('2026-09-28T00:00:00Z');
|
|
143
|
+
const pool = recordingPool([
|
|
144
|
+
{ rows: [{ credits: 1000, works_shipped: 50, karma: 0, projects: 2, computed_at: at }] },
|
|
145
|
+
{ rows: [] },
|
|
146
|
+
]);
|
|
147
|
+
assert.deepEqual(await gt.readGuildTotals('7', { pool }),
|
|
148
|
+
{ credits: 1000, works_shipped: 50, karma: 0, projects: 2, as_of: at });
|
|
149
|
+
assert.equal(await gt.readGuildTotals('8', { pool }), null);
|
|
150
|
+
for (const c of pool.calls) {
|
|
151
|
+
assert.match(c.text.trim(), /^SELECT credits, works_shipped, karma, projects, computed_at\s+FROM platform_identity_guild_totals\s+WHERE guild_id = \$1$/);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('the migration stores no exact sum and no member count', () => {
|
|
156
|
+
const sql = readFileSync(new URL('../modules/platform-identity/migrations/platform_identity_027_guild_totals.sql', import.meta.url), 'utf8');
|
|
157
|
+
const table = sql.split('CREATE TABLE IF NOT EXISTS platform_identity_guild_totals (')[1].split(');')[0];
|
|
158
|
+
const cols = table.split('\n').map((l) => l.trim().split(/\s+/)[0]).filter((c) => /^[a-z_]+$/.test(c));
|
|
159
|
+
assert.deepEqual(cols, ['guild_id', 'credits', 'works_shipped', 'karma', 'projects', 'computed_at'],
|
|
160
|
+
'a counted-member count, or a flag saying non-public numbers are in, would reveal how many non-public members a guild has');
|
|
161
|
+
assert.match(table, /REFERENCES platform_identity_guilds\(id\) ON DELETE CASCADE/, 'a dissolved guild takes its record with it');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── 5. The poller ──
|
|
165
|
+
|
|
166
|
+
test('the poller is registered on the module and is hourly, with the weekly limit per row', () => {
|
|
167
|
+
const manifest = JSON.parse(readFileSync(new URL('../modules/platform-identity/module.json', import.meta.url), 'utf8'));
|
|
168
|
+
assert.ok(manifest.contributes.pollers.includes('guild-totals'));
|
|
169
|
+
assert.equal(poller.SWEEP_INTERVAL_MS, 60 * 60 * 1000);
|
|
170
|
+
assert.equal(typeof poller.start, 'function');
|
|
171
|
+
assert.equal(typeof poller.stop, 'function');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('a failed sweep is logged and swallowed, never thrown', async () => {
|
|
175
|
+
const pool = recordingPool([new Error('db down')]);
|
|
176
|
+
assert.equal(await poller.sweepOnce({ pool }), null);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a sweep hands the injected pool through', async () => {
|
|
180
|
+
const pool = recordingPool([{ rows: [] }]);
|
|
181
|
+
assert.deepEqual(await poller.sweepOnce({ pool }), { scanned: 0, refreshed: 0 });
|
|
182
|
+
assert.equal(pool.calls.length, 1);
|
|
183
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// tests/guild_totals_db.mjs — a guild's collective record against REAL SQL
|
|
2
|
+
// (R26, task 1002302; ADR 0337 D4.4).
|
|
3
|
+
//
|
|
4
|
+
// Why a real database: who is counted is a join over three predicates and a
|
|
5
|
+
// FILTERed count, the k gate is a CTE comparison, and the weekly limit is a WHERE
|
|
6
|
+
// on an upsert's conflict branch — whose re-evaluation against the row a racing
|
|
7
|
+
// writer just committed only a real Postgres settles. The DB-free sibling,
|
|
8
|
+
// tests/guild_totals.mjs, holds the band scale and the statement shapes.
|
|
9
|
+
//
|
|
10
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
11
|
+
// INTEGRATION set so the default DB-free lane never runs it. Fixture ids are
|
|
12
|
+
// high-numbered and every guild slug starts r26-, so a shared DB is safe; all
|
|
13
|
+
// fixtures are deleted before and after.
|
|
14
|
+
//
|
|
15
|
+
// Run: DATABASE_URL=... node tests/guild_totals_db.mjs
|
|
16
|
+
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const { Pool } = require('pg');
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
28
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
29
|
+
|
|
30
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
31
|
+
try {
|
|
32
|
+
await pool.query('SELECT 1');
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.log(`guild_totals_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
35
|
+
await pool.end().catch(() => {});
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const gt = require('../modules/platform-identity/guild-totals.js');
|
|
40
|
+
const guilds = require('../modules/platform-identity/guilds.js');
|
|
41
|
+
|
|
42
|
+
// The cast. Credits are powers of two where it matters, so every exact sum below
|
|
43
|
+
// names exactly which members went into it.
|
|
44
|
+
const OWN = 9002302001; // public, shown — the guild's owner
|
|
45
|
+
const PUB = 9002302002; // public, shown
|
|
46
|
+
const HID1 = 9002302003; // hides their numbers
|
|
47
|
+
const HID2 = 9002302004; // hides their numbers
|
|
48
|
+
const HID3 = 9002302005; // hides their numbers
|
|
49
|
+
const PRIV = 9002302006; // private account
|
|
50
|
+
const UNSHOWN = 9002302007; // public account, not shown on this guild's page
|
|
51
|
+
const OFF = 9002302008; // public, shown, switched counting OFF
|
|
52
|
+
const PROV = 9002302009; // provisional — never consented
|
|
53
|
+
const FAKE1 = 9002302010; // the padding case: private, numbers the owner knows
|
|
54
|
+
const FAKE2 = 9002302011;
|
|
55
|
+
const TARGET = 9002302012; // hides their numbers
|
|
56
|
+
const ALL_IDS = [OWN, PUB, HID1, HID2, HID3, PRIV, UNSHOWN, OFF, PROV, FAKE1, FAKE2, TARGET];
|
|
57
|
+
|
|
58
|
+
const CLIENT_A = 'r26-client-a'; // → a public project on the map
|
|
59
|
+
const CLIENT_B = 'r26-client-b'; // → another public project on the map
|
|
60
|
+
const CLIENT_C = 'r26-client-c'; // → a PRIVATE project
|
|
61
|
+
const ORIGIN = { [CLIENT_A]: 'https://r26-a.example', [CLIENT_B]: 'https://r26-b.example', [CLIENT_C]: 'https://r26-c.example' };
|
|
62
|
+
|
|
63
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
64
|
+
async function cleanup() {
|
|
65
|
+
await q(`DELETE FROM platform_identity_guilds WHERE slug LIKE 'r26-%'`).catch(() => {});
|
|
66
|
+
await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
67
|
+
await q(`DELETE FROM platform_identity_projects WHERE origin LIKE 'https://r26-%'`).catch(() => {});
|
|
68
|
+
await q(`DELETE FROM platform_identity_sso_clients WHERE client_id LIKE 'r26-%'`).catch(() => {});
|
|
69
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let pass = 0;
|
|
73
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
74
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
75
|
+
|
|
76
|
+
async function account(id, handle, { visibility = 'public', hide = false, state = 'active' } = {}) {
|
|
77
|
+
await q(
|
|
78
|
+
`INSERT INTO platform_identity_accounts
|
|
79
|
+
(github_id, github_login, handle, display_name, profile_state, terms_accepted_at, account_visibility, hide_stats)
|
|
80
|
+
VALUES ($1, $2, $2, $2, $3, CASE WHEN $3 = 'active' THEN now() END, $4, $5)`,
|
|
81
|
+
[id, handle, state, visibility, hide],
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
async function activity(id, client, credits, ships = 0, karma = 0) {
|
|
85
|
+
await q(
|
|
86
|
+
`INSERT INTO platform_identity_builder_project_activity (client_id, github_id, credits, tasks_shipped, karma)
|
|
87
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
88
|
+
[client, id, credits, ships, karma],
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
async function guild(slug, visibility = 'public') {
|
|
92
|
+
const { rows } = await q(
|
|
93
|
+
`INSERT INTO platform_identity_guilds (slug, name, visibility) VALUES ($1, $1, $2) RETURNING id::text AS id`,
|
|
94
|
+
[slug, visibility],
|
|
95
|
+
);
|
|
96
|
+
return rows[0].id;
|
|
97
|
+
}
|
|
98
|
+
async function member(gid, id, { kind = 'member', shown = true, counted = true } = {}) {
|
|
99
|
+
await q(
|
|
100
|
+
`INSERT INTO platform_identity_guild_members (guild_id, github_id, membership_kind, shown_publicly, counted_in_totals)
|
|
101
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
102
|
+
[gid, id, kind, shown, counted],
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const sums = async (gid) => {
|
|
106
|
+
const [r] = await gt.computeGuildSums([gid], { pool });
|
|
107
|
+
return { credits: Number(r.credits), works_shipped: Number(r.works_shipped), karma: Number(r.karma), projects: Number(r.projects) };
|
|
108
|
+
};
|
|
109
|
+
const stored = async (gid) => (await q(
|
|
110
|
+
`SELECT credits, works_shipped, karma, projects, computed_at FROM platform_identity_guild_totals WHERE guild_id = $1`, [gid],
|
|
111
|
+
)).rows[0] || null;
|
|
112
|
+
const backdate = (gid, days) => q(
|
|
113
|
+
`UPDATE platform_identity_guild_totals SET computed_at = now() - make_interval(days => $2::int) WHERE guild_id = $1`, [gid, days],
|
|
114
|
+
);
|
|
115
|
+
// Only this file's guilds: a shared DB may hold other guilds the sweep would also pick.
|
|
116
|
+
const refresh = () => gt.refreshStaleGuildTotals({ limit: 500 }, { pool });
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// Every table the computation reads, all idempotent: accounts + consent, handle,
|
|
120
|
+
// privacy, terms; clients, projects, their visibility and the door stamp; the
|
|
121
|
+
// activity rollup; guilds; and this task's snapshot table.
|
|
122
|
+
for (const f of [
|
|
123
|
+
'platform_identity_001_tables.sql', 'platform_identity_004_projects.sql',
|
|
124
|
+
'platform_identity_005_builder_activity.sql', 'platform_identity_007_profile_consent.sql',
|
|
125
|
+
'platform_identity_008_handle.sql', 'platform_identity_011_project_visibility.sql',
|
|
126
|
+
'platform_identity_012_account_privacy.sql', 'platform_identity_019_terms_acceptance.sql',
|
|
127
|
+
'platform_identity_020_visibility_pull.sql', 'platform_identity_025_guilds.sql',
|
|
128
|
+
'platform_identity_027_guild_totals.sql',
|
|
129
|
+
]) await q(migrationSql(f));
|
|
130
|
+
await q(migrationSql('platform_identity_027_guild_totals.sql')); // re-runnable
|
|
131
|
+
await cleanup();
|
|
132
|
+
|
|
133
|
+
await account(OWN, 'r26own');
|
|
134
|
+
await account(PUB, 'r26pub');
|
|
135
|
+
await account(HID1, 'r26hid1', { hide: true });
|
|
136
|
+
await account(HID2, 'r26hid2', { hide: true });
|
|
137
|
+
await account(HID3, 'r26hid3', { hide: true });
|
|
138
|
+
await account(PRIV, 'r26priv', { visibility: 'private' });
|
|
139
|
+
await account(UNSHOWN, 'r26unshown');
|
|
140
|
+
await account(OFF, 'r26off');
|
|
141
|
+
await account(PROV, 'r26prov', { state: 'provisional' });
|
|
142
|
+
await account(FAKE1, 'r26fake1', { visibility: 'private' });
|
|
143
|
+
await account(FAKE2, 'r26fake2', { visibility: 'private' });
|
|
144
|
+
await account(TARGET, 'r26target', { hide: true });
|
|
145
|
+
|
|
146
|
+
for (const c of [CLIENT_A, CLIENT_B, CLIENT_C]) {
|
|
147
|
+
await q(`INSERT INTO platform_identity_sso_clients (client_id, secret_hash, name, origin) VALUES ($1, 'x', $1, $2)`, [c, ORIGIN[c]]);
|
|
148
|
+
}
|
|
149
|
+
await q(
|
|
150
|
+
`INSERT INTO platform_identity_projects (origin, name, description, visibility, visibility_source)
|
|
151
|
+
VALUES ($1, 'A', 'a public project', 'public', 'control-plane'),
|
|
152
|
+
($2, 'B', 'another public project', 'public', 'control-plane'),
|
|
153
|
+
($3, 'C', 'a private project', 'private', 'control-plane')`,
|
|
154
|
+
[ORIGIN[CLIENT_A], ORIGIN[CLIENT_B], ORIGIN[CLIENT_C]],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
await activity(OWN, CLIENT_A, 1, 1, 1);
|
|
158
|
+
await activity(OWN, CLIENT_C, 2, 1, 1); // a public member on a private project
|
|
159
|
+
await activity(PUB, CLIENT_A, 4, 1, 1); // shares project A with OWN
|
|
160
|
+
await activity(HID1, CLIENT_B, 8, 1, 1); // the only builder on project B
|
|
161
|
+
await activity(HID2, CLIENT_A, 16, 1, 1);
|
|
162
|
+
await activity(HID3, CLIENT_A, 32, 1, 1);
|
|
163
|
+
await activity(PRIV, CLIENT_A, 64, 1, 1);
|
|
164
|
+
await activity(UNSHOWN, CLIENT_A, 128, 1, 1);
|
|
165
|
+
await activity(OFF, CLIENT_A, 256, 1, 1);
|
|
166
|
+
await activity(PROV, CLIENT_A, 512, 1, 1);
|
|
167
|
+
|
|
168
|
+
// --- 1. who is counted, and the k gate (ADR 0337 D4.4) --------------------
|
|
169
|
+
const g1 = await guild('r26-one');
|
|
170
|
+
await member(g1, OWN, { kind: 'owner' });
|
|
171
|
+
await member(g1, PUB);
|
|
172
|
+
await member(g1, HID1);
|
|
173
|
+
await member(g1, HID2);
|
|
174
|
+
await member(g1, OFF, { counted: false });
|
|
175
|
+
await member(g1, PROV);
|
|
176
|
+
same((await sums(g1)).credits, 1 + 2 + 4,
|
|
177
|
+
'two counted non-public members (< k) → the totals add up the public members alone; OFF and PROV never count');
|
|
178
|
+
|
|
179
|
+
await member(g1, HID3);
|
|
180
|
+
same((await sums(g1)).credits, 1 + 2 + 4 + 8 + 16 + 32,
|
|
181
|
+
'a third counted non-public member reaches k = 3 → every counted member\'s numbers enter');
|
|
182
|
+
same((await sums(g1)).works_shipped, 6, 'works shipped sums over the same members\' rows (OWN has two)');
|
|
183
|
+
|
|
184
|
+
const g2 = await guild('r26-two');
|
|
185
|
+
await member(g2, OWN, { kind: 'owner' });
|
|
186
|
+
await member(g2, PRIV);
|
|
187
|
+
await member(g2, UNSHOWN, { shown: false });
|
|
188
|
+
await member(g2, HID1);
|
|
189
|
+
same((await sums(g2)).credits, 1 + 2 + 64 + 128 + 8,
|
|
190
|
+
'a private account and a member not shown on the page are non-public too, and count toward k');
|
|
191
|
+
await q(`UPDATE platform_identity_guild_members SET counted_in_totals = false WHERE guild_id = $1 AND github_id = $2`, [g2, PRIV]);
|
|
192
|
+
same((await sums(g2)).credits, 1 + 2,
|
|
193
|
+
'one member switching off drops them AND can drop the guild below k, taking every non-public number out');
|
|
194
|
+
|
|
195
|
+
// --- 2. projects: public members, named public projects, distinct ---------
|
|
196
|
+
same((await sums(g1)).projects, 1,
|
|
197
|
+
'project A once (OWN and PUB share it); never private project C; never HID1\'s project B, even with k met');
|
|
198
|
+
|
|
199
|
+
// --- 3. the stored record: band floors only, and a read that only reads ----
|
|
200
|
+
same(await guilds.getPublicGuild('r26-one', { pool }).then((r) => r.totals), null,
|
|
201
|
+
'before the first refresh the public read carries no totals — and computes none');
|
|
202
|
+
same(await stored(g1), null, 'a public read wrote nothing');
|
|
203
|
+
|
|
204
|
+
await refresh();
|
|
205
|
+
const first = await stored(g1);
|
|
206
|
+
same({ credits: first.credits, works_shipped: first.works_shipped, karma: first.karma, projects: first.projects },
|
|
207
|
+
{ credits: 50, works_shipped: 0, karma: 0, projects: 0 },
|
|
208
|
+
'63 credits is stored as its band floor 50; 6 works and 6 karma are under the first band (0)');
|
|
209
|
+
const pub = await guilds.getPublicGuild('r26-one', { pool });
|
|
210
|
+
same(pub.totals, { credits: 50, works_shipped: 0, karma: 0, projects: 0, as_of: first.computed_at },
|
|
211
|
+
'the public read publishes the stored band floors and when they were computed');
|
|
212
|
+
same(pub.size, 6,
|
|
213
|
+
'size stays the public roster\'s length: OWN, PUB, OFF and the three who hide their numbers (hide_stats hides numbers, never the name — ADR 0336 D4 §2); never PROV');
|
|
214
|
+
|
|
215
|
+
// --- 4. at most weekly -----------------------------------------------------
|
|
216
|
+
await activity(OWN, CLIENT_B, 1000);
|
|
217
|
+
for (let i = 0; i < 5; i++) await guilds.getPublicGuild('r26-one', { pool });
|
|
218
|
+
await refresh();
|
|
219
|
+
same((await stored(g1)).credits, 50,
|
|
220
|
+
'a change inside the week moves nothing: not five reads, not another sweep');
|
|
221
|
+
|
|
222
|
+
await backdate(g1, 6);
|
|
223
|
+
await refresh();
|
|
224
|
+
same((await stored(g1)).credits, 50, 'six days old is still inside the week');
|
|
225
|
+
|
|
226
|
+
await backdate(g1, 7);
|
|
227
|
+
const [a, b] = await Promise.all([refresh(), refresh()]);
|
|
228
|
+
same((await stored(g1)).credits, 1000, 'a week old → refreshed: 1063 credits is now the 1000 band');
|
|
229
|
+
const racedG1 = [a, b].filter((r) => r.refreshed > 0).length;
|
|
230
|
+
ok(racedG1 >= 1, 'at least one sweep refreshed the due guild');
|
|
231
|
+
const oneRow = await q(`SELECT count(*)::int AS n FROM platform_identity_guild_totals WHERE guild_id = $1`, [g1]);
|
|
232
|
+
same(oneRow.rows[0].n, 1, 'one row per guild');
|
|
233
|
+
|
|
234
|
+
// The upsert's own guard, without the pick step's help: a sweep that picked the
|
|
235
|
+
// guild while it was due, and lost the race to one that committed first, must
|
|
236
|
+
// update nothing. The wrapper plays the winning sweep between this one's pick
|
|
237
|
+
// and its write.
|
|
238
|
+
await backdate(g1, 7);
|
|
239
|
+
const losingPool = {
|
|
240
|
+
async query(text, params) {
|
|
241
|
+
const out = await pool.query(text, params);
|
|
242
|
+
if (/LEFT JOIN platform_identity_guild_totals t ON t\.guild_id = g\.id/.test(text)) {
|
|
243
|
+
await q(`UPDATE platform_identity_guild_totals SET credits = 20, computed_at = now() WHERE guild_id = $1`, [g1]);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
await gt.refreshStaleGuildTotals({ limit: 500 }, { pool: losingPool });
|
|
249
|
+
same((await stored(g1)).credits, 20,
|
|
250
|
+
'the age check lives in the write: the losing sweep leaves the winner\'s record alone');
|
|
251
|
+
|
|
252
|
+
// --- 5. unlisted, and dissolve --------------------------------------------
|
|
253
|
+
const g3 = await guild('r26-quiet', 'unlisted');
|
|
254
|
+
await member(g3, OWN, { kind: 'owner', shown: false });
|
|
255
|
+
await refresh();
|
|
256
|
+
ok(!!(await stored(g3)), 'an unlisted guild has a record too (ready for a flip to public)…');
|
|
257
|
+
same(await guilds.getPublicGuild('r26-quiet', { pool }), null, '…but no public read reaches it');
|
|
258
|
+
await q(`DELETE FROM platform_identity_guilds WHERE id = $1`, [g3]);
|
|
259
|
+
same(await stored(g3), null, 'dissolving a guild takes its record with it (FK cascade)');
|
|
260
|
+
|
|
261
|
+
// --- 6. the honest limit, DOCUMENTED, not fixed (ADR 0337 D4.4) -----------
|
|
262
|
+
// A guild owner who pads with two accounts whose numbers they already know
|
|
263
|
+
// makes a real non-public member the third, and the total then carries that
|
|
264
|
+
// member. Accepted by the owner on 2026-09-25: the notice on the join card and
|
|
265
|
+
// the per-guild switch are the defence, not this computation. If this block
|
|
266
|
+
// ever goes red because the padding stopped working, the rule changed — update
|
|
267
|
+
// ADR 0337 D4.4 before this test.
|
|
268
|
+
await activity(TARGET, CLIENT_A, 777);
|
|
269
|
+
const own = 1 + 2 + 1000; // OWN's three rows, after section 4's report
|
|
270
|
+
const pad = await guild('r26-pad');
|
|
271
|
+
await member(pad, OWN, { kind: 'owner' });
|
|
272
|
+
await member(pad, FAKE1);
|
|
273
|
+
await member(pad, FAKE2);
|
|
274
|
+
same((await sums(pad)).credits, own, 'the two fakes alone are below k');
|
|
275
|
+
await member(pad, TARGET);
|
|
276
|
+
same((await sums(pad)).credits, own + 777,
|
|
277
|
+
'PADDING WORKS (accepted): the target becomes the third non-public member and enters the sum');
|
|
278
|
+
await q(`UPDATE platform_identity_guild_members SET counted_in_totals = false WHERE guild_id = $1 AND github_id = $2`, [pad, TARGET]);
|
|
279
|
+
same((await sums(pad)).credits, own, 'the target\'s answer is the switch: off, and they are out');
|
|
280
|
+
|
|
281
|
+
// --- 7. karma below zero stays inside the column's floor ------------------
|
|
282
|
+
const neg = await guild('r26-neg');
|
|
283
|
+
await member(neg, PUB, { kind: 'owner' });
|
|
284
|
+
await q(`UPDATE platform_identity_builder_project_activity SET karma = -40 WHERE github_id = $1`, [PUB]);
|
|
285
|
+
await refresh();
|
|
286
|
+
same((await stored(neg)).karma, 0, 'a negative sum publishes as 0, never below the CHECK');
|
|
287
|
+
|
|
288
|
+
console.log(`\nguild_totals_db.mjs: ${pass} passed`);
|
|
289
|
+
} finally {
|
|
290
|
+
await cleanup();
|
|
291
|
+
await pool.end();
|
|
292
|
+
}
|
package/tests/guilds_api.mjs
CHANGED
|
@@ -263,8 +263,13 @@ test('public read: the roster is shown_publicly AND the shared existence predica
|
|
|
263
263
|
});
|
|
264
264
|
const res = await call('GET', '/api/bongos/guilds/Guild-A');
|
|
265
265
|
assert.equal(res.status, 200);
|
|
266
|
-
assert.deepEqual(Object.keys(res.json).sort(), ['guild', 'members', 'size']);
|
|
266
|
+
assert.deepEqual(Object.keys(res.json).sort(), ['guild', 'members', 'size', 'totals']);
|
|
267
267
|
assert.equal(res.json.size, 2);
|
|
268
|
+
assert.equal(res.json.totals, null, 'no stored record yet → no totals, never a computed stand-in');
|
|
269
|
+
assert.ok(calls.some((c) => /FROM platform_identity_guild_totals\s+WHERE guild_id = \$1/.test(c.text)),
|
|
270
|
+
'the totals are READ from the weekly snapshot');
|
|
271
|
+
assert.ok(calls.every((c) => !/INSERT|UPDATE|platform_identity_builder_project_activity/.test(c.text)),
|
|
272
|
+
'the public read never computes or writes the totals (ADR 0337 D4.4: never on a read)');
|
|
268
273
|
assert.equal(res.json.members.length, 2);
|
|
269
274
|
assert.ok(!('id' in res.json.guild), 'the row id stays internal');
|
|
270
275
|
assert.ok(!/github_id|github_login/.test(res.text), 'no account id or login crosses');
|