@bongos/core 1.19.1029 → 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 +16 -16
- package/docs/module-api-changelog.md +2 -0
- 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/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-25T21:
|
|
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",
|
|
@@ -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",
|
|
@@ -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",
|
|
@@ -2537,5 +2537,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2537
2537
|
landed since 1.19.1027 with no explicit bump. run 36188894725. (task 1002620)
|
|
2538
2538
|
1.19.1029 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2539
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)
|
|
2540
2542
|
---------------------------------------------------------------------------
|
|
2541
2543
|
```
|
|
@@ -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 {
|
|
@@ -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,
|