@bongos/core 1.19.623 → 1.19.624
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 +30 -20
- package/docs/copy-inventory.md +76 -76
- package/docs/copy-registry.json +81 -81
- package/docs/module-api-changelog.md +2 -0
- package/modules/builder-settings/builder-needs.js +41 -4
- package/modules/economy/credits.js +38 -0
- package/modules/economy/reward.js +5 -0
- package/modules/hall-ui/public/hall-render.js +26 -7
- package/modules/sessions/db.js +65 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-session-earnings.js +123 -0
- package/scripts/gds/run-unit-tests.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/builder_needs.mjs +122 -0
- package/tests/session_earnings_mirror_db.mjs +250 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// tests/session_earnings_mirror_db.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1003634 — the session earnings mirror, against a REAL Postgres.
|
|
4
|
+
//
|
|
5
|
+
// WHAT IS BEING PROVEN, and why a fake cannot prove it. The defect was never in
|
|
6
|
+
// JavaScript arithmetic; it was in which credit_log rows a WHERE clause can
|
|
7
|
+
// reach. credit_log pays a builder by two different keys — the per-task streams
|
|
8
|
+
// key on `task_id` and leave session_id NULL, the cost-plus session reward
|
|
9
|
+
// (ADR 0054) keys on `session_id` and leaves task_id NULL — and the mirror
|
|
10
|
+
// summed only the first. A stubbed client would answer whatever the stub was
|
|
11
|
+
// written to answer, which is exactly the assumption under test. So the SQL has
|
|
12
|
+
// to meet a real planner over real columns.
|
|
13
|
+
//
|
|
14
|
+
// NON-DESTRUCTIVE BY CONSTRUCTION: every fixture and credit row is written inside
|
|
15
|
+
// ONE transaction that is ALWAYS rolled back. It never TRUNCATEs, never commits,
|
|
16
|
+
// and is safe to point at a dev database holding real rows. (The precedent and
|
|
17
|
+
// the schema-driven insertRow helper are tests/idea_credit_streams_db.mjs's.)
|
|
18
|
+
//
|
|
19
|
+
// Real-DB (self-skips without Postgres; belongs in the INTEGRATION set).
|
|
20
|
+
//
|
|
21
|
+
// Run: DATABASE_URL=postgres://... node tests/session_earnings_mirror_db.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const { Pool } = require('pg');
|
|
28
|
+
const credits = require('../modules/economy/credits.js');
|
|
29
|
+
|
|
30
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
31
|
+
|
|
32
|
+
let passed = 0;
|
|
33
|
+
let failed = 0;
|
|
34
|
+
// Each test runs inside its own SAVEPOINT: a failure rolls back to it, so one
|
|
35
|
+
// broken assertion cannot abort the transaction and cascade into every test
|
|
36
|
+
// after it.
|
|
37
|
+
let client;
|
|
38
|
+
async function test(name, fn) {
|
|
39
|
+
await client.query('SAVEPOINT t');
|
|
40
|
+
try {
|
|
41
|
+
await fn();
|
|
42
|
+
passed++; console.log(` ok ${name}`);
|
|
43
|
+
await client.query('RELEASE SAVEPOINT t');
|
|
44
|
+
} catch (err) {
|
|
45
|
+
failed++; console.error(` FAIL ${name}\n ${err.message}`);
|
|
46
|
+
await client.query('ROLLBACK TO SAVEPOINT t');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const schemaCache = new Map();
|
|
51
|
+
async function columnsOf(client, table) {
|
|
52
|
+
if (schemaCache.has(table)) return schemaCache.get(table);
|
|
53
|
+
const { rows } = await client.query(
|
|
54
|
+
`SELECT column_name, data_type, is_nullable, column_default, is_identity
|
|
55
|
+
FROM information_schema.columns
|
|
56
|
+
WHERE table_schema = 'public' AND table_name = $1`,
|
|
57
|
+
[table]
|
|
58
|
+
);
|
|
59
|
+
schemaCache.set(table, rows);
|
|
60
|
+
return rows;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function placeholderFor(dataType, columnName) {
|
|
64
|
+
switch (dataType) {
|
|
65
|
+
case 'integer': case 'bigint': case 'smallint':
|
|
66
|
+
case 'numeric': case 'real': case 'double precision':
|
|
67
|
+
return 0;
|
|
68
|
+
case 'boolean': return false;
|
|
69
|
+
case 'timestamp with time zone': case 'timestamp without time zone': case 'date':
|
|
70
|
+
return new Date();
|
|
71
|
+
case 'json': case 'jsonb': return {};
|
|
72
|
+
case 'ARRAY': return [];
|
|
73
|
+
default: return `sem-${columnName}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Identifiers are interpolated because SQL cannot parameterise them. Both are
|
|
78
|
+
// trusted by construction: `table` is a literal from this file, and the column
|
|
79
|
+
// names come from information_schema for that table — never from input.
|
|
80
|
+
async function insertRow(client, table, explicit = {}) {
|
|
81
|
+
const cols = await columnsOf(client, table);
|
|
82
|
+
if (cols.length === 0) throw new Error(`table ${table} not found — is this database migrated?`);
|
|
83
|
+
const values = { ...explicit };
|
|
84
|
+
for (const c of cols) {
|
|
85
|
+
if (c.column_name in values) continue;
|
|
86
|
+
if (c.is_nullable === 'YES') continue;
|
|
87
|
+
if (c.column_default !== null) continue;
|
|
88
|
+
if (c.is_identity === 'YES') continue;
|
|
89
|
+
values[c.column_name] = placeholderFor(c.data_type, c.column_name);
|
|
90
|
+
}
|
|
91
|
+
const names = Object.keys(values);
|
|
92
|
+
const params = names.map((_, i) => `$${i + 1}`);
|
|
93
|
+
const { rows } = await client.query(
|
|
94
|
+
`INSERT INTO ${table} (${names.map((n) => `"${n}"`).join(', ')})
|
|
95
|
+
VALUES (${params.join(', ')}) RETURNING *`,
|
|
96
|
+
names.map((n) => values[n])
|
|
97
|
+
);
|
|
98
|
+
return rows[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The query the mirror used BEFORE this fix — sumCreditsForTasks's predicate,
|
|
102
|
+
// inlined so the bug itself is asserted rather than described. It cannot use
|
|
103
|
+
// credits.sumCreditsForTasks() directly: that reads the module's own pool and so
|
|
104
|
+
// cannot see this transaction's uncommitted rows.
|
|
105
|
+
async function taskKeyedSumOnly(client, builderId, taskIds) {
|
|
106
|
+
const { rows } = await client.query(
|
|
107
|
+
`SELECT COALESCE(SUM(delta), 0)::int AS total
|
|
108
|
+
FROM credit_log WHERE builder_id = $1 AND task_id = ANY($2::bigint[])`,
|
|
109
|
+
[builderId, taskIds]
|
|
110
|
+
);
|
|
111
|
+
return rows[0].total;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// One builder, one shipped task, one session_record linking them.
|
|
115
|
+
async function fixture(client) {
|
|
116
|
+
const tag = `sem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
117
|
+
const builder = await insertRow(client, 'builders', {
|
|
118
|
+
github_id: `${tag}-gh`,
|
|
119
|
+
github_login: tag,
|
|
120
|
+
display_name: tag,
|
|
121
|
+
total_credits: 0,
|
|
122
|
+
});
|
|
123
|
+
const version = await insertRow(client, 'versions', { id: `${tag}-v`, status: 'building' });
|
|
124
|
+
const task = await insertRow(client, 'tasks', {
|
|
125
|
+
title: `${tag} task`,
|
|
126
|
+
status: 'shipped',
|
|
127
|
+
version_id: version.id,
|
|
128
|
+
});
|
|
129
|
+
const sessionId = `${tag}-session`;
|
|
130
|
+
await insertRow(client, 'session_records', {
|
|
131
|
+
session_id: sessionId,
|
|
132
|
+
builder_id: builder.id,
|
|
133
|
+
task_ids: [task.id],
|
|
134
|
+
drachmae_earned: 0,
|
|
135
|
+
total_tokens: 7823777,
|
|
136
|
+
});
|
|
137
|
+
return { builder, task, sessionId };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Book a cost-plus session reward the way credits.js does: keyed on session_id,
|
|
141
|
+
// with task_id deliberately NULL.
|
|
142
|
+
function sessionReward(client, builderId, sessionId, delta, basis) {
|
|
143
|
+
return client.query(
|
|
144
|
+
`INSERT INTO credit_log (builder_id, task_id, delta, reason, description, session_id, reward_basis)
|
|
145
|
+
VALUES ($1, NULL, $2, 'session.token_reward', $3, $4, $5)`,
|
|
146
|
+
[builderId, delta, `$${delta} test`, sessionId, basis]
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function main() {
|
|
151
|
+
try {
|
|
152
|
+
client = await pool.connect();
|
|
153
|
+
} catch {
|
|
154
|
+
console.log('session_earnings_mirror_db: no Postgres (DATABASE_URL unset/unreachable) — skipping');
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await client.query('BEGIN');
|
|
159
|
+
try {
|
|
160
|
+
// 1. THE BUG, executable. A session paid purely on cost-plus reads as zero
|
|
161
|
+
// through the old predicate — the exact state a builder reported as
|
|
162
|
+
// missing pay while the ledger showed them paid.
|
|
163
|
+
await test('the task-keyed sum alone cannot see a cost-plus session reward (the bug)', async () => {
|
|
164
|
+
const { builder, task, sessionId } = await fixture(client);
|
|
165
|
+
await sessionReward(client, builder.id, sessionId, 7, 581);
|
|
166
|
+
|
|
167
|
+
assert.equal(await taskKeyedSumOnly(client, builder.id, [task.id]), 0,
|
|
168
|
+
'the old predicate should report 0 — this is the defect being fixed');
|
|
169
|
+
assert.equal(
|
|
170
|
+
await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId), 7,
|
|
171
|
+
'the two-key sum must find the 7 drachmae actually paid');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// 2. Neither key may be lost when both streams pay the same session.
|
|
175
|
+
await test('both attribution keys are summed together', async () => {
|
|
176
|
+
const { builder, task, sessionId } = await fixture(client);
|
|
177
|
+
await sessionReward(client, builder.id, sessionId, 7, 581);
|
|
178
|
+
await client.query(
|
|
179
|
+
`INSERT INTO credit_log (builder_id, task_id, delta, reason, session_id)
|
|
180
|
+
VALUES ($1, $2, 60, 'task.ship', NULL)`,
|
|
181
|
+
[builder.id, task.id]
|
|
182
|
+
);
|
|
183
|
+
assert.equal(
|
|
184
|
+
await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId), 67,
|
|
185
|
+
'a session paid on both streams must total both');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// 3. The OR is one predicate over one scan. A row carrying BOTH keys must be
|
|
189
|
+
// summed once — if it were summed per-key, every fix would over-report.
|
|
190
|
+
await test('a row carrying both keys is counted once, not twice', async () => {
|
|
191
|
+
const { builder, task, sessionId } = await fixture(client);
|
|
192
|
+
await client.query(
|
|
193
|
+
`INSERT INTO credit_log (builder_id, task_id, delta, reason, session_id)
|
|
194
|
+
VALUES ($1, $2, 5, 'idea.credit.task_ship:1:2', $3)`,
|
|
195
|
+
[builder.id, task.id, sessionId]
|
|
196
|
+
);
|
|
197
|
+
assert.equal(
|
|
198
|
+
await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId), 5,
|
|
199
|
+
'the doubly-keyed row must contribute 5, not 10');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// 4. The figure is SET from ledger state, so re-deriving is a no-op. This is
|
|
203
|
+
// what lets every pay-on-land trigger call the sync and lets a re-attempt
|
|
204
|
+
// repair a stale row instead of inflating a correct one.
|
|
205
|
+
await test('re-deriving is idempotent across a top-up', async () => {
|
|
206
|
+
const { builder, task, sessionId } = await fixture(client);
|
|
207
|
+
await sessionReward(client, builder.id, sessionId, 18, 1496);
|
|
208
|
+
const first = await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId);
|
|
209
|
+
const again = await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId);
|
|
210
|
+
assert.equal(first, 18);
|
|
211
|
+
assert.equal(again, 18, 'the same ledger state must yield the same figure');
|
|
212
|
+
|
|
213
|
+
// A second ship in the same session tops the reward up at a higher basis.
|
|
214
|
+
await sessionReward(client, builder.id, sessionId, 35, 4403);
|
|
215
|
+
assert.equal(
|
|
216
|
+
await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId), 53,
|
|
217
|
+
'a top-up must be reflected in full, not added to a stale total');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// 5. Degenerate inputs must not become an unfiltered scan of the ledger.
|
|
221
|
+
await test('no keys means zero, never an unscoped sum', async () => {
|
|
222
|
+
const { builder, sessionId } = await fixture(client);
|
|
223
|
+
await sessionReward(client, builder.id, sessionId, 9, 750);
|
|
224
|
+
assert.equal(await credits.sumSessionEarnings(client, builder.id, [], null), 0);
|
|
225
|
+
assert.equal(await credits.sumSessionEarnings(client, builder.id, null, null), 0);
|
|
226
|
+
// A session key alone is enough — the cost-plus-only case.
|
|
227
|
+
assert.equal(await credits.sumSessionEarnings(client, builder.id, [], sessionId), 9);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// 6. Another builder's rows are never reachable, whichever key matches.
|
|
231
|
+
await test('the sum stays scoped to one builder', async () => {
|
|
232
|
+
const a = await fixture(client);
|
|
233
|
+
const b = await fixture(client);
|
|
234
|
+
await sessionReward(client, a.builder.id, a.sessionId, 7, 581);
|
|
235
|
+
await sessionReward(client, b.builder.id, b.sessionId, 99, 8250);
|
|
236
|
+
assert.equal(
|
|
237
|
+
await credits.sumSessionEarnings(client, a.builder.id, [a.task.id, b.task.id], a.sessionId), 7,
|
|
238
|
+
"another builder's credits must not leak in via a shared task id");
|
|
239
|
+
});
|
|
240
|
+
} finally {
|
|
241
|
+
await client.query('ROLLBACK');
|
|
242
|
+
client.release();
|
|
243
|
+
await pool.end();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log(`\nsession_earnings_mirror_db: ${passed} passed, ${failed} failed`);
|
|
247
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|