@bongos/core 1.19.1028 → 1.19.1030
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 +19 -19
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-goals.js +14 -2
- package/modules/lifecycle/routes/goals.js +1 -1
- package/modules/platform-identity/activity-snapshots.js +44 -28
- package/modules/platform-identity/platform-identity.js +20 -25
- package/modules/platform-identity/tests/activity-snapshots.mjs +69 -30
- package/modules/platform-identity/tests/platform-identity.mjs +44 -24
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/activity_snapshots_db.mjs +106 -37
- package/tests/goal_member_kinds.mjs +24 -1
- package/tests/hub_recent_ships.mjs +7 -4
- package/tests/sso_checkin_identity_spoof.mjs +1 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.1030",
|
|
6
|
+
"core_contract": "1.19.1030",
|
|
7
|
+
"source_commit": "198e316ae502b4929018d6c592d28fd6bfa3b81d",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-25T21:18:42.202Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 529,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 3014,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "980cdb45376b2b35067d51b269d2709e1e1922dcbfb541554877cbcb9ae0c9e1",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2757,7 +2757,7 @@
|
|
|
2757
2757
|
{
|
|
2758
2758
|
"path": "docs/module-api-changelog.md",
|
|
2759
2759
|
"mode": "0000644",
|
|
2760
|
-
"sha256": "
|
|
2760
|
+
"sha256": "bb67419c4dd3f2311a231615a96bd462cc6806b6e3a1967ea0614e3966ef9b0e"
|
|
2761
2761
|
},
|
|
2762
2762
|
{
|
|
2763
2763
|
"path": "docs/modules-contract.md",
|
|
@@ -5882,7 +5882,7 @@
|
|
|
5882
5882
|
{
|
|
5883
5883
|
"path": "modules/lifecycle/db-goals.js",
|
|
5884
5884
|
"mode": "0000644",
|
|
5885
|
-
"sha256": "
|
|
5885
|
+
"sha256": "e15bf5a3f2ec8ede9b5a8c90c3e00c472c229f351b809333beebfeb90dad2bcc"
|
|
5886
5886
|
},
|
|
5887
5887
|
{
|
|
5888
5888
|
"path": "modules/lifecycle/db-grade.js",
|
|
@@ -6192,7 +6192,7 @@
|
|
|
6192
6192
|
{
|
|
6193
6193
|
"path": "modules/lifecycle/routes/goals.js",
|
|
6194
6194
|
"mode": "0000644",
|
|
6195
|
-
"sha256": "
|
|
6195
|
+
"sha256": "520b63212462c762fdf23e40ffdb084c22f786616d3a5694b4dc182e334eeaa7"
|
|
6196
6196
|
},
|
|
6197
6197
|
{
|
|
6198
6198
|
"path": "modules/lifecycle/routes/help-requests.js",
|
|
@@ -6452,7 +6452,7 @@
|
|
|
6452
6452
|
{
|
|
6453
6453
|
"path": "modules/platform-identity/activity-snapshots.js",
|
|
6454
6454
|
"mode": "0000644",
|
|
6455
|
-
"sha256": "
|
|
6455
|
+
"sha256": "c483a9ef792ad0ac3600dc1eb227323c6f851e7cd33189cc418a4e0ef3443ed6"
|
|
6456
6456
|
},
|
|
6457
6457
|
{
|
|
6458
6458
|
"path": "modules/platform-identity/applicant-profile.js",
|
|
@@ -6652,7 +6652,7 @@
|
|
|
6652
6652
|
{
|
|
6653
6653
|
"path": "modules/platform-identity/platform-identity.js",
|
|
6654
6654
|
"mode": "0000644",
|
|
6655
|
-
"sha256": "
|
|
6655
|
+
"sha256": "0f60239ec8ae79e1cb87870d7454f6b03de84170d557a06fae93cc556757004a"
|
|
6656
6656
|
},
|
|
6657
6657
|
{
|
|
6658
6658
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -6767,12 +6767,12 @@
|
|
|
6767
6767
|
{
|
|
6768
6768
|
"path": "modules/platform-identity/tests/activity-snapshots.mjs",
|
|
6769
6769
|
"mode": "0000644",
|
|
6770
|
-
"sha256": "
|
|
6770
|
+
"sha256": "47e4970496e303b66007c3c8cee9ecf1b1a10fe8805d28c8ef44d545ed7f1b4f"
|
|
6771
6771
|
},
|
|
6772
6772
|
{
|
|
6773
6773
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
6774
6774
|
"mode": "0000644",
|
|
6775
|
-
"sha256": "
|
|
6775
|
+
"sha256": "6bbddd8d1b85f159ba1bade8df61552979e85e50dace2e1c9128de4b9ee8beb9"
|
|
6776
6776
|
},
|
|
6777
6777
|
{
|
|
6778
6778
|
"path": "modules/platform-identity/ui/.gitkeep",
|
|
@@ -8362,12 +8362,12 @@
|
|
|
8362
8362
|
{
|
|
8363
8363
|
"path": "package-lock.json",
|
|
8364
8364
|
"mode": "0000644",
|
|
8365
|
-
"sha256": "
|
|
8365
|
+
"sha256": "ab4b3ba1d87734173359f3ade610dc804a339ac3a518e7e5efebee2a1ff68cd0"
|
|
8366
8366
|
},
|
|
8367
8367
|
{
|
|
8368
8368
|
"path": "package.json",
|
|
8369
8369
|
"mode": "0000644",
|
|
8370
|
-
"sha256": "
|
|
8370
|
+
"sha256": "ddb2d7173af124e48c454d0ba5db240e96d35541495fe7cf414246b1df433cd7"
|
|
8371
8371
|
},
|
|
8372
8372
|
{
|
|
8373
8373
|
"path": "public-docs/index.html",
|
|
@@ -10412,7 +10412,7 @@
|
|
|
10412
10412
|
{
|
|
10413
10413
|
"path": "src/module-api.js",
|
|
10414
10414
|
"mode": "0000644",
|
|
10415
|
-
"sha256": "
|
|
10415
|
+
"sha256": "439c0db441cc17b1ce9da268d30703325667f137d05f4b810786b356c9d56cfd"
|
|
10416
10416
|
},
|
|
10417
10417
|
{
|
|
10418
10418
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10517,7 +10517,7 @@
|
|
|
10517
10517
|
{
|
|
10518
10518
|
"path": "tests/activity_snapshots_db.mjs",
|
|
10519
10519
|
"mode": "0000644",
|
|
10520
|
-
"sha256": "
|
|
10520
|
+
"sha256": "219569f3160eb22a89e92a217b854a194afba6ebc7032dc07eff2534efc4390a"
|
|
10521
10521
|
},
|
|
10522
10522
|
{
|
|
10523
10523
|
"path": "tests/admission_ceiling_gate.mjs",
|
|
@@ -11982,7 +11982,7 @@
|
|
|
11982
11982
|
{
|
|
11983
11983
|
"path": "tests/goal_member_kinds.mjs",
|
|
11984
11984
|
"mode": "0000644",
|
|
11985
|
-
"sha256": "
|
|
11985
|
+
"sha256": "5ae48afae42839d5b8ea6002b5d9c892d027be87eda2e60bded481437a3306a6"
|
|
11986
11986
|
},
|
|
11987
11987
|
{
|
|
11988
11988
|
"path": "tests/goal_membership_requests.mjs",
|
|
@@ -12562,7 +12562,7 @@
|
|
|
12562
12562
|
{
|
|
12563
12563
|
"path": "tests/hub_recent_ships.mjs",
|
|
12564
12564
|
"mode": "0000644",
|
|
12565
|
-
"sha256": "
|
|
12565
|
+
"sha256": "ad183edeb3b24bdd70de80aad370c5411d32257907baa36c65b70a8143d8ec53"
|
|
12566
12566
|
},
|
|
12567
12567
|
{
|
|
12568
12568
|
"path": "tests/idea_brightness.mjs",
|
|
@@ -14597,7 +14597,7 @@
|
|
|
14597
14597
|
{
|
|
14598
14598
|
"path": "tests/sso_checkin_identity_spoof.mjs",
|
|
14599
14599
|
"mode": "0000644",
|
|
14600
|
-
"sha256": "
|
|
14600
|
+
"sha256": "628db25b4f0c5dc157e4aa9bc9d136efd474e88769641061dfbeca6d3e013cc2"
|
|
14601
14601
|
},
|
|
14602
14602
|
{
|
|
14603
14603
|
"path": "tests/sso_checkin_legacy_field_gone.mjs",
|
|
@@ -2535,5 +2535,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2535
2535
|
landed since 1.19.1026 with no explicit bump. run 36187742785. (task 1002620)
|
|
2536
2536
|
1.19.1028 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2537
2537
|
landed since 1.19.1027 with no explicit bump. run 36188894725. (task 1002620)
|
|
2538
|
+
1.19.1029 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2539
|
+
landed since 1.19.1028 with no explicit bump. run 36189693139. (task 1002620)
|
|
2540
|
+
1.19.1030 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2541
|
+
landed since 1.19.1029 with no explicit bump. run 36190779629. (task 1002620)
|
|
2538
2542
|
---------------------------------------------------------------------------
|
|
2539
2543
|
```
|
|
@@ -942,8 +942,14 @@ async function setGoalMembershipKind({ goalId, builderId, membershipKind }, deps
|
|
|
942
942
|
// "drops to manager" — exactly the handoff semantics the route promises. The
|
|
943
943
|
// route gates WHO may call this (owner | Archon, target Metic+); this helper
|
|
944
944
|
// just performs the move. Returns the updated goal row, or null for unknown id.
|
|
945
|
+
//
|
|
946
|
+
// The new owner arriving as 'lead' is a membership arrival like any other, so it
|
|
947
|
+
// also closes that builder's still-pending ask/invite on this goal (task
|
|
948
|
+
// 1003661, the third path after addGoalMember — see its comment for why
|
|
949
|
+
// 'cancelled'). Same statement, so the cancel commits with the transfer or not
|
|
950
|
+
// at all; resolvedBy is the ACTOR handing the goal over.
|
|
945
951
|
|
|
946
|
-
async function transferGoalOwnership({ goalId, newOwnerId }, deps = {}) {
|
|
952
|
+
async function transferGoalOwnership({ goalId, newOwnerId, resolvedBy = null }, deps = {}) {
|
|
947
953
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
948
954
|
const { rows } = await activePool.query(
|
|
949
955
|
`WITH g AS (
|
|
@@ -955,9 +961,15 @@ async function transferGoalOwnership({ goalId, newOwnerId }, deps = {}) {
|
|
|
955
961
|
INSERT INTO goal_members (goal_id, builder_id, membership_kind)
|
|
956
962
|
SELECT g.id, $2, 'lead' FROM g
|
|
957
963
|
ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = 'lead'
|
|
964
|
+
), cancelled AS (
|
|
965
|
+
UPDATE goal_membership_requests
|
|
966
|
+
SET status = 'cancelled', resolved_by = $3, resolved_at = now(), updated_at = now()
|
|
967
|
+
WHERE goal_id = $1 AND builder_id = $2 AND status = 'pending'
|
|
968
|
+
AND EXISTS (SELECT 1 FROM g)
|
|
969
|
+
RETURNING id
|
|
958
970
|
)
|
|
959
971
|
SELECT * FROM g`,
|
|
960
|
-
[goalId, newOwnerId]
|
|
972
|
+
[goalId, newOwnerId, resolvedBy]
|
|
961
973
|
);
|
|
962
974
|
return rows[0] ?? null;
|
|
963
975
|
}
|
|
@@ -899,7 +899,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
899
899
|
if (decision.noop) {
|
|
900
900
|
return res.json({ ok: true, goal, changed: false });
|
|
901
901
|
}
|
|
902
|
-
const updated = await db.transferGoalOwnership({ goalId: id, newOwnerId });
|
|
902
|
+
const updated = await db.transferGoalOwnership({ goalId: id, newOwnerId, resolvedBy: req.builder.id });
|
|
903
903
|
if (!updated) {
|
|
904
904
|
return res.fail('transfer_conflict', { status: 409, message: 'The goal changed underneath this request — re-read it and retry.' });
|
|
905
905
|
}
|
|
@@ -11,37 +11,53 @@
|
|
|
11
11
|
// Compaction (daily granularity after 90 days) is a later, volume-triggered task
|
|
12
12
|
// — nothing here prunes. A disconnected/re-registered project's snapshots are
|
|
13
13
|
// likewise not purged here — platform_identity_builder_project_activity (the
|
|
14
|
-
// table this mirrors) has the identical gap today; the
|
|
15
|
-
// is where consent (rollupVisibleSql) is enforced
|
|
14
|
+
// table this mirrors) has the identical gap today; the leaderboard reader
|
|
15
|
+
// (community-leaderboard.js) is where consent (rollupVisibleSql) is enforced.
|
|
16
16
|
'use strict';
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const api = require('../../src/module-api');
|
|
19
19
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
20
|
+
// Snapshot the LIVE totals of one (project, builder) iff they differ from the
|
|
21
|
+
// last snapshot. Must run on the transaction that just upserted the live row:
|
|
22
|
+
// that upsert's row lock queues racing reports, so this sees every earlier
|
|
23
|
+
// snapshot and a live row no one else can change (task 1004244).
|
|
24
|
+
// Best-effort: a fault rolls back to the savepoint, is logged, and returns null,
|
|
25
|
+
// so the upsert still commits. If even that rollback fails the transaction is
|
|
26
|
+
// gone, and the error propagates rather than let COMMIT silently drop the upsert.
|
|
27
|
+
// reported_at is the write's own moment (clock_timestamp(); now() would be the
|
|
28
|
+
// transaction start, before any wait on the lock), never earlier than the last
|
|
29
|
+
// row's, so the index order (reported_at, id) always ends at the last write.
|
|
30
|
+
async function recordActivitySnapshot({ clientId, githubId }, { client }) {
|
|
31
|
+
await client.query('SAVEPOINT activity_snapshot');
|
|
32
|
+
try {
|
|
33
|
+
const { rows } = await client.query(
|
|
34
|
+
`WITH live AS (
|
|
35
|
+
SELECT credits, tasks_shipped, karma FROM platform_identity_builder_project_activity
|
|
36
|
+
WHERE client_id = $1 AND github_id = $2
|
|
37
|
+
), last AS (
|
|
38
|
+
SELECT credits, tasks_shipped, karma, reported_at FROM platform_identity_activity_snapshots
|
|
39
|
+
WHERE client_id = $1 AND github_id = $2
|
|
40
|
+
ORDER BY reported_at DESC, id DESC LIMIT 1
|
|
41
|
+
)
|
|
42
|
+
INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at)
|
|
43
|
+
SELECT $1, $2, live.credits, live.tasks_shipped, live.karma,
|
|
44
|
+
GREATEST(clock_timestamp(), (SELECT reported_at FROM last))
|
|
45
|
+
FROM live
|
|
46
|
+
WHERE NOT EXISTS (
|
|
47
|
+
SELECT 1 FROM last
|
|
48
|
+
WHERE last.credits = live.credits
|
|
49
|
+
AND last.tasks_shipped = live.tasks_shipped
|
|
50
|
+
AND last.karma = live.karma)
|
|
51
|
+
RETURNING id, reported_at`,
|
|
52
|
+
[clientId, githubId],
|
|
53
|
+
);
|
|
54
|
+
await client.query('RELEASE SAVEPOINT activity_snapshot');
|
|
55
|
+
return rows[0] || null;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
await client.query('ROLLBACK TO SAVEPOINT activity_snapshot');
|
|
58
|
+
api.logger('platform-identity').error({ err }, 'activity snapshot write failed (non-fatal)');
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
module.exports = { recordActivitySnapshot };
|
|
@@ -666,31 +666,26 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
|
|
|
666
666
|
.filter((d) => typeof d === 'string' && d.length > 0 && d.length <= MAX_DISCIPLINE_LENGTH)
|
|
667
667
|
.slice(0, MAX_ROLLUP_DISCIPLINES);
|
|
668
668
|
const ships = sanitizeRecentShips(recentShips, clientOrigin);
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
await recordActivitySnapshot({ clientId, githubId, credits: credits | 0, tasksShipped: tasksShipped | 0, karma: karma | 0 }, { pool });
|
|
690
|
-
} catch (err) {
|
|
691
|
-
api.logger('platform-identity').error(`activity snapshot write failed (non-fatal): ${err && err.message}`);
|
|
692
|
-
}
|
|
693
|
-
return rows[0];
|
|
669
|
+
// One transaction with the snapshot: the upsert's row lock queues racing reports (task 1004244).
|
|
670
|
+
return api.withTx(async (client) => {
|
|
671
|
+
const { rows } = await client.query(
|
|
672
|
+
`INSERT INTO platform_identity_builder_project_activity
|
|
673
|
+
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at)
|
|
674
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now())
|
|
675
|
+
ON CONFLICT (client_id, github_id) DO UPDATE SET
|
|
676
|
+
credits = EXCLUDED.credits, tasks_shipped = EXCLUDED.tasks_shipped, karma = EXCLUDED.karma,
|
|
677
|
+
rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
|
|
678
|
+
recent_ships = EXCLUDED.recent_ships,
|
|
679
|
+
last_active = now(), updated_at = now()
|
|
680
|
+
RETURNING id, client_id, github_id`,
|
|
681
|
+
// jsonb is bound as a JSON STRING: node-postgres serializes a JS array as a
|
|
682
|
+
// Postgres ARRAY literal, which the jsonb column would reject.
|
|
683
|
+
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships)],
|
|
684
|
+
);
|
|
685
|
+
// Windowed data plane (task 1002977) — best-effort, see activity-snapshots.js.
|
|
686
|
+
await recordActivitySnapshot({ clientId, githubId }, { client });
|
|
687
|
+
return rows[0];
|
|
688
|
+
}, { pool });
|
|
694
689
|
}
|
|
695
690
|
|
|
696
691
|
// The scouting rollup: one row PER BUILDER, aggregated across every project they
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// modules/platform-identity/tests/activity-snapshots.mjs
|
|
2
2
|
//
|
|
3
3
|
// DB-free unit suite for the windowed data plane's append-only snapshot history
|
|
4
|
-
// (community spec D2, task 1002977
|
|
5
|
-
// tests/platform-identity.mjs: proves the
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// (community spec D2, task 1002977; the live-row read, task 1004244).
|
|
5
|
+
// Recording-fake-client style, matching tests/platform-identity.mjs: proves the
|
|
6
|
+
// SHAPE — a SAVEPOINT on the caller's transaction, ONE INSERT that reads the
|
|
7
|
+
// totals from the live row (never from the caller) guarded by the last snapshot,
|
|
8
|
+
// RELEASE; on a fault, ROLLBACK TO SAVEPOINT, log the Error, return null. A fake
|
|
9
|
+
// cannot execute `WHERE NOT EXISTS` or a lock, so WHICH reports insert and how
|
|
10
|
+
// racing reports settle are proved against real Postgres in
|
|
11
|
+
// tests/activity_snapshots_db.mjs.
|
|
9
12
|
//
|
|
10
13
|
// Run standalone: node modules/platform-identity/tests/activity-snapshots.mjs
|
|
11
14
|
|
|
@@ -13,6 +16,14 @@ import { strict as assert } from 'node:assert';
|
|
|
13
16
|
import { createRequire } from 'node:module';
|
|
14
17
|
|
|
15
18
|
const require = createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
// Pin the doorway's logger so the test can read what a fault logs.
|
|
21
|
+
const logged = [];
|
|
22
|
+
const moduleApiPath = require.resolve('../../../src/module-api.js');
|
|
23
|
+
require.cache[moduleApiPath] = {
|
|
24
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
25
|
+
exports: { logger: () => ({ error: (obj, msg) => logged.push({ obj, msg }) }) },
|
|
26
|
+
};
|
|
16
27
|
const { recordActivitySnapshot } = require('../activity-snapshots.js');
|
|
17
28
|
|
|
18
29
|
let passed = 0, failed = 0;
|
|
@@ -21,47 +32,75 @@ async function ta(name, fn) {
|
|
|
21
32
|
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
22
33
|
}
|
|
23
34
|
|
|
24
|
-
// A recording fake
|
|
25
|
-
|
|
35
|
+
// A recording fake client: answers the INSERT with `insertRows`, or fails it;
|
|
36
|
+
// `failRollback` also fails the savepoint rollback (a dead connection).
|
|
37
|
+
function mockClient({ insertRows = [], failInsert = null, failRollback = null } = {}) {
|
|
26
38
|
const calls = [];
|
|
27
|
-
let i = 0;
|
|
28
39
|
return {
|
|
29
40
|
calls,
|
|
30
41
|
query: async (text, params) => {
|
|
31
42
|
calls.push({ text: String(text), params: params || [] });
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
if (/INSERT INTO platform_identity_activity_snapshots/.test(text)) {
|
|
44
|
+
if (failInsert) throw failInsert;
|
|
45
|
+
return { rows: insertRows };
|
|
46
|
+
}
|
|
47
|
+
if (/^ROLLBACK TO SAVEPOINT/.test(text) && failRollback) throw failRollback;
|
|
48
|
+
return { rows: [] };
|
|
34
49
|
},
|
|
35
50
|
};
|
|
36
51
|
}
|
|
37
52
|
|
|
38
|
-
|
|
53
|
+
const texts = (client) => client.calls.map((c) => c.text.replace(/\s+/g, ' ').trim());
|
|
54
|
+
|
|
55
|
+
console.log('recordActivitySnapshot (community spec D2, tasks 1002977 + 1004244):');
|
|
39
56
|
|
|
40
|
-
await ta('
|
|
41
|
-
const
|
|
42
|
-
const out = await recordActivitySnapshot({ clientId: 'c1', githubId: 42
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
assert.
|
|
46
|
-
assert.match(
|
|
47
|
-
assert.
|
|
48
|
-
assert.match(c.text, /INSERT INTO platform_identity_activity_snapshots/);
|
|
49
|
-
assert.match(c.text, /WHERE NOT EXISTS \(SELECT 1 FROM last WHERE credits = \$3 AND tasks_shipped = \$4 AND karma = \$5\)/);
|
|
50
|
-
assert.deepEqual(c.params, ['c1', 42, 100, 5, 3]);
|
|
57
|
+
await ta('on the caller\'s transaction: SAVEPOINT, one conditional INSERT, RELEASE', async () => {
|
|
58
|
+
const client = mockClient({ insertRows: [{ id: '1', reported_at: 'T1' }] });
|
|
59
|
+
const out = await recordActivitySnapshot({ clientId: 'c1', githubId: 42 }, { client });
|
|
60
|
+
const t = texts(client);
|
|
61
|
+
assert.equal(t.length, 3);
|
|
62
|
+
assert.equal(t[0], 'SAVEPOINT activity_snapshot');
|
|
63
|
+
assert.match(t[1], /^WITH live AS/);
|
|
64
|
+
assert.equal(t[2], 'RELEASE SAVEPOINT activity_snapshot');
|
|
51
65
|
assert.deepEqual(out, { id: '1', reported_at: 'T1' });
|
|
52
66
|
});
|
|
53
67
|
|
|
54
|
-
await ta('
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
assert.
|
|
68
|
+
await ta('the INSERT reads the totals from the LIVE row, never from the caller', async () => {
|
|
69
|
+
const client = mockClient();
|
|
70
|
+
await recordActivitySnapshot({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { client });
|
|
71
|
+
assert.deepEqual(client.calls[1].params, ['c1', 42], 'only the pair is bound — a captured total has nowhere to go');
|
|
72
|
+
const sql = texts(client)[1];
|
|
73
|
+
assert.match(sql, /WITH live AS \( SELECT credits, tasks_shipped, karma FROM platform_identity_builder_project_activity WHERE client_id = \$1 AND github_id = \$2 \)/);
|
|
74
|
+
assert.match(sql, /SELECT \$1, \$2, live\.credits, live\.tasks_shipped, live\.karma, GREATEST\(clock_timestamp\(\), \(SELECT reported_at FROM last\)\) FROM live/,
|
|
75
|
+
'stamped at the write, and never earlier than the last row');
|
|
76
|
+
assert.ok(!/\$3/.test(sql), 'no third parameter exists to carry a caller value');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await ta('skipped when the live totals equal the last snapshot, found in migration 022\'s index order', async () => {
|
|
80
|
+
const client = mockClient();
|
|
81
|
+
const out = await recordActivitySnapshot({ clientId: 'c1', githubId: 42 }, { client });
|
|
82
|
+
const sql = texts(client)[1];
|
|
83
|
+
assert.match(sql, /last AS \( SELECT credits, tasks_shipped, karma, reported_at FROM platform_identity_activity_snapshots WHERE client_id = \$1 AND github_id = \$2 ORDER BY reported_at DESC, id DESC LIMIT 1 \)/,
|
|
84
|
+
'(client_id, github_id, reported_at DESC, id DESC) serves this as an index-order LIMIT 1');
|
|
85
|
+
assert.match(sql, /WHERE NOT EXISTS \( SELECT 1 FROM last WHERE last\.credits = live\.credits AND last\.tasks_shipped = live\.tasks_shipped AND last\.karma = live\.karma\)/);
|
|
86
|
+
assert.equal(out, null, 'no row returned → null');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await ta('a fault rolls back to the savepoint, logs the Error itself, and returns null — the caller\'s upsert still commits', async () => {
|
|
90
|
+
logged.length = 0;
|
|
91
|
+
const fault = Object.assign(new Error('relation "platform_identity_activity_snapshots" does not exist'), { code: '42P01' });
|
|
92
|
+
const client = mockClient({ failInsert: fault });
|
|
93
|
+
const out = await recordActivitySnapshot({ clientId: 'c1', githubId: 42 }, { client });
|
|
58
94
|
assert.equal(out, null);
|
|
95
|
+
const t = texts(client);
|
|
96
|
+
assert.equal(t[t.length - 1], 'ROLLBACK TO SAVEPOINT activity_snapshot');
|
|
97
|
+
assert.equal(logged.length, 1);
|
|
98
|
+
assert.equal(logged[0].obj.err, fault, 'the Error object itself, so its stack and SQLSTATE reach the log');
|
|
59
99
|
});
|
|
60
100
|
|
|
61
|
-
await ta('
|
|
62
|
-
const
|
|
63
|
-
await recordActivitySnapshot({ clientId: 'c1', githubId: 42 }, {
|
|
64
|
-
assert.deepEqual(pool.calls[0].params, ['c1', 42, 0, 0, 0]);
|
|
101
|
+
await ta('if the savepoint rollback itself fails, the error propagates — COMMIT must not silently drop the upsert', async () => {
|
|
102
|
+
const client = mockClient({ failInsert: new Error('boom'), failRollback: new Error('connection terminated') });
|
|
103
|
+
await assert.rejects(() => recordActivitySnapshot({ clientId: 'c1', githubId: 42 }, { client }), /connection terminated/);
|
|
65
104
|
});
|
|
66
105
|
|
|
67
106
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
@@ -558,11 +558,26 @@ await ta('getAccountByLogin resolves case-insensitively, newest first', async ()
|
|
|
558
558
|
|
|
559
559
|
console.log('\ncross-project activity (§4 scouting data plane):');
|
|
560
560
|
|
|
561
|
+
// upsertActivity runs in a transaction, so a positional answer queue would be
|
|
562
|
+
// spent by BEGIN: answer by statement shape instead, and find the upsert by text.
|
|
563
|
+
const activityPool = ({ failSnapshot = false } = {}) => {
|
|
564
|
+
const calls = [];
|
|
565
|
+
const query = async (text, params) => {
|
|
566
|
+
calls.push({ text: String(text), params: params || [] });
|
|
567
|
+
if (failSnapshot && /INSERT INTO platform_identity_activity_snapshots/.test(text)) {
|
|
568
|
+
throw new Error('relation "platform_identity_activity_snapshots" does not exist');
|
|
569
|
+
}
|
|
570
|
+
if (/INSERT INTO platform_identity_builder_project_activity/.test(text)) return { rows: [{ id: '1' }] };
|
|
571
|
+
return { rows: [] };
|
|
572
|
+
};
|
|
573
|
+
return { calls, query, connect: async () => ({ query, release: () => {} }) };
|
|
574
|
+
};
|
|
575
|
+
const upsertCall = (pool) => pool.calls.find((c) => /INSERT INTO platform_identity_builder_project_activity/.test(c.text));
|
|
576
|
+
|
|
561
577
|
await ta('upsertActivity upserts per (client_id, github_id) with the talent snapshot', async () => {
|
|
562
|
-
const pool =
|
|
578
|
+
const pool = activityPool();
|
|
563
579
|
await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3, rank: 'metic', disciplines: ['engineer'] }, { pool });
|
|
564
|
-
const c = pool
|
|
565
|
-
assert.match(c.text, /INSERT INTO platform_identity_builder_project_activity/);
|
|
580
|
+
const c = upsertCall(pool);
|
|
566
581
|
assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
|
|
567
582
|
// recent_ships (task 1002803) rides the same row as a jsonb STRING param —
|
|
568
583
|
// an empty list when the sender reported none, never NULL. Its own cap,
|
|
@@ -571,7 +586,7 @@ await ta('upsertActivity upserts per (client_id, github_id) with the talent snap
|
|
|
571
586
|
});
|
|
572
587
|
|
|
573
588
|
await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-ladder rank → null, disciplines validated + capped', async () => {
|
|
574
|
-
const pool =
|
|
589
|
+
const pool = activityPool();
|
|
575
590
|
await P.upsertActivity({
|
|
576
591
|
clientId: 'c1', githubId: 42,
|
|
577
592
|
rank: 'Supreme Overlord', // not one of the four core rank keys → stored null
|
|
@@ -581,7 +596,7 @@ await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-la
|
|
|
581
596
|
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', // valid tail — capped to 8 total
|
|
582
597
|
],
|
|
583
598
|
}, { pool });
|
|
584
|
-
const c = pool
|
|
599
|
+
const c = upsertCall(pool);
|
|
585
600
|
assert.equal(c.params[5], null, 'an invented rank label is never stored');
|
|
586
601
|
const disc = c.params[6];
|
|
587
602
|
assert.ok(disc.length <= 8, `disciplines capped at 8 (got ${disc.length})`);
|
|
@@ -591,30 +606,35 @@ await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-la
|
|
|
591
606
|
});
|
|
592
607
|
|
|
593
608
|
await ta('upsertActivity keeps a legitimate core rank key', async () => {
|
|
594
|
-
const pool =
|
|
609
|
+
const pool = activityPool();
|
|
595
610
|
await P.upsertActivity({ clientId: 'c1', githubId: 42, rank: 'archon', disciplines: [] }, { pool });
|
|
596
|
-
assert.equal(pool.
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
await ta('upsertActivity also appends to the snapshot history (task 1002977, community spec D2) — deleting that call would leave this red', async () => {
|
|
600
|
-
const pool = mockPool([{ rows: [{ id: '1' }] }, { rows: [{ id: 's1', reported_at: 'T' }] }]);
|
|
601
|
-
await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
|
|
602
|
-
assert.equal(pool.calls.length, 2, 'the rollup upsert, then the snapshot statement');
|
|
603
|
-
assert.match(pool.calls[1].text, /INSERT INTO platform_identity_activity_snapshots/);
|
|
604
|
-
assert.deepEqual(pool.calls[1].params, ['c1', 42, 100, 5, 3]);
|
|
611
|
+
assert.equal(upsertCall(pool).params[5], 'archon');
|
|
605
612
|
});
|
|
606
613
|
|
|
607
|
-
await ta('upsertActivity
|
|
608
|
-
const pool =
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
614
|
+
await ta('upsertActivity writes the upsert and its snapshot in ONE transaction, the snapshot reading the live row (tasks 1002977 + 1004244)', async () => {
|
|
615
|
+
const pool = activityPool();
|
|
616
|
+
const out = await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
|
|
617
|
+
const t = pool.calls.map((c) => c.text.replace(/\s+/g, ' ').trim().split(' ').slice(0, 4).join(' '));
|
|
618
|
+
assert.deepEqual(t, [
|
|
619
|
+
'BEGIN',
|
|
620
|
+
'INSERT INTO platform_identity_builder_project_activity (client_id,',
|
|
621
|
+
'SAVEPOINT activity_snapshot',
|
|
622
|
+
'WITH live AS (',
|
|
623
|
+
'RELEASE SAVEPOINT activity_snapshot',
|
|
624
|
+
'COMMIT',
|
|
625
|
+
], 'the upsert holds the row lock until its snapshot is written; deleting the snapshot call leaves this red');
|
|
626
|
+
const snap = pool.calls.find((c) => /INSERT INTO platform_identity_activity_snapshots/.test(c.text));
|
|
627
|
+
assert.deepEqual(snap.params, ['c1', 42], 'the report\'s own totals are never handed to the snapshot');
|
|
628
|
+
assert.equal(out.id, '1');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
await ta('upsertActivity survives a snapshot-write failure (best-effort — the application-echo.js precedent): the savepoint rolls back alone and the upsert commits', async () => {
|
|
632
|
+
const pool = activityPool({ failSnapshot: true });
|
|
616
633
|
const out = await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
|
|
617
634
|
assert.equal(out.id, '1', 'the rollup upsert result still returns despite the snapshot write throwing');
|
|
635
|
+
const t = pool.calls.map((c) => c.text.trim());
|
|
636
|
+
assert.ok(t.includes('ROLLBACK TO SAVEPOINT activity_snapshot'));
|
|
637
|
+
assert.equal(t[t.length - 1], 'COMMIT', 'the transaction still commits the upsert');
|
|
618
638
|
});
|
|
619
639
|
|
|
620
640
|
await ta('getMembership reads one (client, builder) row — the rollup membership requirement', async () => {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1030",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1030",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1030",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1030'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// tests/activity_snapshots_db.mjs —
|
|
1
|
+
// tests/activity_snapshots_db.mjs — tasks 1002977 + 1004244 (community spec D2; real DB).
|
|
2
2
|
//
|
|
3
3
|
// Proves the windowed data plane's append-only history against real SQL. The
|
|
4
|
-
// skip-on-unchanged rule lives
|
|
5
|
-
//
|
|
6
|
-
// SHAPE (modules/platform-identity/tests/activity-snapshots.mjs); only
|
|
7
|
-
// Postgres can prove WHICH reports
|
|
8
|
-
//
|
|
4
|
+
// skip-on-unchanged rule lives inside one INSERT ... WHERE NOT EXISTS, and racing
|
|
5
|
+
// reports are serialised by the upsert's own row lock, so a fake pool can only
|
|
6
|
+
// prove the SHAPE (modules/platform-identity/tests/activity-snapshots.mjs); only
|
|
7
|
+
// a real Postgres can prove WHICH reports insert, that upsertActivity (the
|
|
8
|
+
// POST /sso/activity/rollup path) writes the upsert and its snapshot together,
|
|
9
|
+
// and that the newest snapshot always agrees with the live row.
|
|
9
10
|
//
|
|
10
11
|
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
11
12
|
// INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
|
|
@@ -37,58 +38,126 @@ try {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
// High-numbered fixture ids so a shared DB can never collide with real data.
|
|
40
|
-
const BUILDER = 90002977, OTHER_BUILDER = 90002978;
|
|
41
|
-
const IDS = [BUILDER, OTHER_BUILDER];
|
|
41
|
+
const BUILDER = 90002977, OTHER_BUILDER = 90002978, RACE_BUILDER = 90002979, FLIGHT_BUILDER = 90002980;
|
|
42
|
+
const IDS = [BUILDER, OTHER_BUILDER, RACE_BUILDER, FLIGHT_BUILDER];
|
|
42
43
|
const CLIENT = 't1002977-client';
|
|
43
44
|
const q = (sql, params) => pool.query(sql, params);
|
|
45
|
+
const report = (githubId, credits) =>
|
|
46
|
+
pi.upsertActivity({ clientId: CLIENT, githubId, credits, tasksShipped: 1, karma: 0 }, { pool });
|
|
44
47
|
|
|
45
48
|
async function cleanup() {
|
|
46
49
|
await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
47
50
|
await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
48
51
|
}
|
|
52
|
+
// The writer runs on its caller's transaction, right after the live row is set.
|
|
53
|
+
async function setLiveAndSnapshot(githubId, credits) {
|
|
54
|
+
const client = await pool.connect();
|
|
55
|
+
try {
|
|
56
|
+
await client.query('BEGIN');
|
|
57
|
+
await client.query(
|
|
58
|
+
`INSERT INTO platform_identity_builder_project_activity (client_id, github_id, credits, tasks_shipped, karma)
|
|
59
|
+
VALUES ($1, $2, $3, 5, 3)
|
|
60
|
+
ON CONFLICT (client_id, github_id) DO UPDATE SET credits = EXCLUDED.credits`,
|
|
61
|
+
[CLIENT, githubId, credits],
|
|
62
|
+
);
|
|
63
|
+
const row = await recordActivitySnapshot({ clientId: CLIENT, githubId }, { client });
|
|
64
|
+
await client.query('COMMIT');
|
|
65
|
+
return row;
|
|
66
|
+
} finally {
|
|
67
|
+
client.release();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function history(githubId) {
|
|
71
|
+
const { rows } = await q(
|
|
72
|
+
`SELECT credits FROM platform_identity_activity_snapshots
|
|
73
|
+
WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at, id`,
|
|
74
|
+
[CLIENT, githubId],
|
|
75
|
+
);
|
|
76
|
+
return rows.map((r) => r.credits);
|
|
77
|
+
}
|
|
78
|
+
// The newest snapshot (in the index order the writer and the leaderboard use) against the live row.
|
|
79
|
+
async function newestVsLive(githubId) {
|
|
80
|
+
const { rows } = await q(
|
|
81
|
+
`SELECT (SELECT credits FROM platform_identity_activity_snapshots
|
|
82
|
+
WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at DESC, id DESC LIMIT 1) AS newest,
|
|
83
|
+
(SELECT credits FROM platform_identity_builder_project_activity
|
|
84
|
+
WHERE client_id = $1 AND github_id = $2) AS live`,
|
|
85
|
+
[CLIENT, githubId],
|
|
86
|
+
);
|
|
87
|
+
return [rows[0].newest, rows[0].live];
|
|
88
|
+
}
|
|
49
89
|
|
|
50
90
|
let pass = 0;
|
|
51
91
|
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
92
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
52
93
|
|
|
53
94
|
try {
|
|
54
95
|
await q(migrationSql('platform_identity_005_builder_activity.sql'));
|
|
55
96
|
// upsertActivity's INSERT always names recent_ships (012_recent_ships adds the
|
|
56
|
-
// column); without it here,
|
|
97
|
+
// column); without it here, the upsertActivity steps fail 42703 on a fresh database.
|
|
57
98
|
await q(migrationSql('platform_identity_012_recent_ships.sql'));
|
|
58
99
|
await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
|
|
59
100
|
await cleanup();
|
|
60
101
|
|
|
61
|
-
// 1 —
|
|
62
|
-
|
|
63
|
-
ok(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const { rows: history } = await q(
|
|
72
|
-
`SELECT credits FROM platform_identity_activity_snapshots
|
|
73
|
-
WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at, id`,
|
|
102
|
+
// 1 — the writer snapshots the LIVE row, and only when it changed.
|
|
103
|
+
ok(await setLiveAndSnapshot(BUILDER, 100) !== null, 'a first-ever report inserts — nothing to be unchanged from');
|
|
104
|
+
ok(await setLiveAndSnapshot(BUILDER, 100) === null, 'an unchanged live row inserts nothing');
|
|
105
|
+
ok(await setLiveAndSnapshot(BUILDER, 150) !== null, 'a changed live row (credits differ) inserts a new row');
|
|
106
|
+
same(await history(BUILDER), [100, 150], 'exactly two rows, in write order — the unchanged report left no trace');
|
|
107
|
+
// A clock that stepped back: the last row carries a later stamp than "now".
|
|
108
|
+
await q(
|
|
109
|
+
`UPDATE platform_identity_activity_snapshots SET reported_at = clock_timestamp() + interval '1 hour'
|
|
110
|
+
WHERE client_id = $1 AND github_id = $2 AND credits = 150`,
|
|
74
111
|
[CLIENT, BUILDER],
|
|
75
112
|
);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
113
|
+
const later = await setLiveAndSnapshot(BUILDER, 175);
|
|
114
|
+
same((await newestVsLive(BUILDER))[0], 175, 'a write after the clock stepped back is still the newest in index order');
|
|
115
|
+
ok(later !== null, 'and it was written, stamped no earlier than the row before it');
|
|
79
116
|
|
|
80
|
-
// 2 —
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
117
|
+
// 2 — a report whose snapshot is still pending holds the row: its upsert is
|
|
118
|
+
// not visible yet, and a second report queues behind it. The prior state is the
|
|
119
|
+
// hard case the live-row read alone left open — the last snapshot (110) already
|
|
120
|
+
// equals the fresh report, while a stale report (100) is mid-flight. A table
|
|
121
|
+
// lock parks the stale report at its snapshot INSERT, deterministically.
|
|
122
|
+
await report(FLIGHT_BUILDER, 110);
|
|
123
|
+
const parker = await pool.connect();
|
|
124
|
+
let stale, fresh;
|
|
125
|
+
const settled = { stale: false, fresh: false };
|
|
126
|
+
try {
|
|
127
|
+
await parker.query('BEGIN');
|
|
128
|
+
await parker.query('LOCK TABLE platform_identity_activity_snapshots IN SHARE ROW EXCLUSIVE MODE');
|
|
129
|
+
stale = report(FLIGHT_BUILDER, 100).then(() => { settled.stale = true; });
|
|
130
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
131
|
+
same((await newestVsLive(FLIGHT_BUILDER))[1], 110,
|
|
132
|
+
"the stale report's upsert is invisible while its snapshot is pending — the two commit together");
|
|
133
|
+
fresh = report(FLIGHT_BUILDER, 110).then(() => { settled.fresh = true; });
|
|
134
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
135
|
+
ok(!settled.stale && !settled.fresh, 'and the fresh report queues behind it on the row lock');
|
|
136
|
+
await parker.query('COMMIT');
|
|
137
|
+
} finally {
|
|
138
|
+
parker.release();
|
|
139
|
+
}
|
|
140
|
+
await Promise.all([stale, fresh]);
|
|
141
|
+
same(await newestVsLive(FLIGHT_BUILDER), [110, 110],
|
|
142
|
+
'once both land, the newest snapshot agrees with the live row (the stale value is recorded, then superseded)');
|
|
143
|
+
same(await history(FLIGHT_BUILDER), [110, 100, 110], 'every change the live row went through is in the history');
|
|
144
|
+
|
|
145
|
+
// 3 — racing reports, end to end: however they interleave, once they settle
|
|
146
|
+
// the newest snapshot equals the live row.
|
|
147
|
+
for (let round = 0; round < 15; round++) {
|
|
148
|
+
const credits = [0, 1, 2, 3, 4].map((i) => 1000 + round * 10 + ((i * 7 + round) % 5));
|
|
149
|
+
await Promise.all(credits.map((c) => report(RACE_BUILDER, c)));
|
|
150
|
+
const [newest, live] = await newestVsLive(RACE_BUILDER);
|
|
151
|
+
assert.equal(newest, live, `round ${round}: newest snapshot ${newest} disagrees with the live row ${live}`);
|
|
152
|
+
}
|
|
153
|
+
pass++; console.log(' ok - 15 rounds of five racing reports: the newest snapshot always equals the live row');
|
|
154
|
+
|
|
155
|
+
// 4 — upsertActivity (the real POST /sso/activity/rollup path) really snapshots.
|
|
156
|
+
await report(OTHER_BUILDER, 10);
|
|
157
|
+
await report(OTHER_BUILDER, 10);
|
|
158
|
+
await report(OTHER_BUILDER, 20);
|
|
159
|
+
same(await history(OTHER_BUILDER), [10, 20],
|
|
160
|
+
'upsertActivity appends a snapshot per changed report — deleting the call inside it would leave this red');
|
|
92
161
|
|
|
93
162
|
console.log(`\nactivity_snapshots_db.mjs: ${pass} passed`);
|
|
94
163
|
} finally {
|
|
@@ -400,7 +400,7 @@ await test('transferGoalOwnership: created_by moves and the new owner upserts to
|
|
|
400
400
|
const out = await transferGoalOwnership({ goalId: 7, newOwnerId: 5 }, { pool });
|
|
401
401
|
assert.deepEqual(out, GOAL_ROW);
|
|
402
402
|
const upd = find(pool.queries, /UPDATE goals SET created_by = \$2/);
|
|
403
|
-
assert.deepEqual(upd.params, [7, 5]);
|
|
403
|
+
assert.deepEqual(upd.params, [7, 5, null], 'resolvedBy defaults to null for a non-route caller');
|
|
404
404
|
const sql = String(upd.sql).replace(/\s+/g, ' ');
|
|
405
405
|
assert.match(sql, /INSERT INTO goal_members \(goal_id, builder_id, membership_kind\)/,
|
|
406
406
|
'the new owner\'s membership rides in the same statement');
|
|
@@ -409,6 +409,29 @@ await test('transferGoalOwnership: created_by moves and the new owner upserts to
|
|
|
409
409
|
assert.match(sql, /updated_at = now\(\)/);
|
|
410
410
|
});
|
|
411
411
|
|
|
412
|
+
// task 1003661 — the third arrival path (after join / direct-add, task 1002888):
|
|
413
|
+
// handing a goal to a builder who still has a pending ask outstanding on it must
|
|
414
|
+
// close that ask in the SAME statement, or the stale row lingers in the inbox.
|
|
415
|
+
await test('transferGoalOwnership: cancels the new owner\'s pending ask in the same statement, attributed to the actor', async () => {
|
|
416
|
+
const pool = fakePool((s) => (/UPDATE goals SET created_by = \$2/.test(s) ? { rows: [GOAL_ROW] } : null));
|
|
417
|
+
await transferGoalOwnership({ goalId: 7, newOwnerId: 5, resolvedBy: 99 }, { pool });
|
|
418
|
+
assert.equal(pool.queries.length, 1, 'ONE statement — the cancel commits with the transfer or not at all');
|
|
419
|
+
const upd = find(pool.queries, /UPDATE goals SET created_by = \$2/);
|
|
420
|
+
assert.deepEqual(upd.params, [7, 5, 99], 'goal, new owner, actor');
|
|
421
|
+
const sql = String(upd.sql).replace(/\s+/g, ' ');
|
|
422
|
+
assert.match(sql, /UPDATE goal_membership_requests SET status = 'cancelled', resolved_by = \$3/);
|
|
423
|
+
assert.match(sql, /WHERE goal_id = \$1 AND builder_id = \$2 AND status = 'pending'/,
|
|
424
|
+
'scoped to this goal + new owner, pending rows only');
|
|
425
|
+
assert.doesNotMatch(sql, /status = 'accepted'/);
|
|
426
|
+
assert.match(sql, /AND EXISTS \(SELECT 1 FROM g\)/, 'an unknown goal (no g row) must cancel nothing');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
await test('routes: POST /goals/:id/transfer passes resolvedBy to transferGoalOwnership', async () => {
|
|
430
|
+
const { readFileSync } = await import('node:fs');
|
|
431
|
+
const src = readFileSync(new URL('../modules/lifecycle/routes/goals.js', import.meta.url), 'utf8');
|
|
432
|
+
assert.match(src, /db\.transferGoalOwnership\(\{[^}]*resolvedBy:\s*req\.builder\.id/);
|
|
433
|
+
});
|
|
434
|
+
|
|
412
435
|
await test('transferGoalOwnership: unknown goal → null', async () => {
|
|
413
436
|
const pool = fakePool(() => ({ rows: [] }));
|
|
414
437
|
assert.equal(await transferGoalOwnership({ goalId: 999, newOwnerId: 5 }, { pool }), null);
|
|
@@ -50,12 +50,15 @@ async function t(name, fn) {
|
|
|
50
50
|
// asserts the params the real query actually bound.
|
|
51
51
|
function mockPool(answer = () => ({ rows: [] })) {
|
|
52
52
|
const calls = [];
|
|
53
|
+
const query = async (text, params = []) => {
|
|
54
|
+
calls.push({ text: String(text), params });
|
|
55
|
+
return answer(String(text), params) || { rows: [] };
|
|
56
|
+
};
|
|
53
57
|
return {
|
|
54
58
|
calls,
|
|
55
|
-
query
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
},
|
|
59
|
+
query,
|
|
60
|
+
// upsertActivity runs its upsert + snapshot as one transaction on a pooled client.
|
|
61
|
+
connect: async () => ({ query, release: () => {} }),
|
|
59
62
|
find: (re) => calls.find((c) => re.test(c.text)),
|
|
60
63
|
};
|
|
61
64
|
}
|
|
@@ -73,6 +73,7 @@ require.cache[moduleApiPath] = {
|
|
|
73
73
|
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
74
74
|
exports: {
|
|
75
75
|
pool,
|
|
76
|
+
withTx: (fn) => fn(pool), // transactions are not under test; keep the queue positional
|
|
76
77
|
validateOrRespond: helpers.validateOrRespond,
|
|
77
78
|
resolveSession: async () => null, // the browser leg is not under test
|
|
78
79
|
logger: () => noopLog,
|