@bongos/core 1.19.1059 → 1.19.1061
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 +58 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +193 -3
- package/docs/api-reference.md +5 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/flags.js +14 -1
- package/modules/copy-desk/page-status.js +177 -3
- package/modules/copy-desk/pages.js +200 -0
- package/modules/copy-desk/routes/copy-desk.js +349 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +36 -1
- package/modules/copy-desk/tests/copy_page_draft.mjs +202 -0
- package/modules/copy-desk/tests/copy_page_next.mjs +184 -0
- package/modules/lifecycle/lifecycle.js +8 -0
- package/modules/lifecycle/page-tweak-reads.js +11 -2
- package/modules/lifecycle/page-tweak-write.js +192 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_flags_db.mjs +12 -0
- package/tests/copy_desk_page_claim.mjs +9 -124
- package/tests/copy_desk_page_next.mjs +172 -0
- package/tests/copy_desk_page_submit.mjs +289 -0
- package/tests/fixtures/page-ledger.mjs +206 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// tests/fixtures/page-ledger.mjs — a STATEFUL fake pool for Tweak Mode's
|
|
2
|
+
// whole-path tests: a small in-memory tasks / claims / builders /
|
|
3
|
+
// copy_desk_flags ledger that answers the SQL the real copy-desk routes and the
|
|
4
|
+
// real lifecycle port send (the page claim, task 1004317 / TW06; the page reads
|
|
5
|
+
// and the recommendation, task 1004318 / TW07).
|
|
6
|
+
//
|
|
7
|
+
// The page's advisory lock is modelled as a real mutex, so a "two artists at
|
|
8
|
+
// once" case is a race the lock has to win, not two calls in a row. Every
|
|
9
|
+
// statement yields first, so concurrent requests really interleave.
|
|
10
|
+
//
|
|
11
|
+
// An unknown statement THROWS: a route that sends SQL this ledger does not
|
|
12
|
+
// model fails loudly instead of reading an empty answer.
|
|
13
|
+
//
|
|
14
|
+
// Shared by tests/copy_desk_page_claim.mjs, tests/copy_desk_page_next.mjs and
|
|
15
|
+
// tests/copy_desk_page_submit.mjs
|
|
16
|
+
// (a fixture, not a test: the runner discovers tests/*.mjs only).
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
|
|
20
|
+
const flat = (sql) => String(sql).replace(/\s+/g, ' ').trim();
|
|
21
|
+
|
|
22
|
+
export function installPageLedger(api, { builders }) {
|
|
23
|
+
const db = {
|
|
24
|
+
builders: new Map(builders.map((b) => [String(b.id), { status: 'active', ...b, id: String(b.id) }])),
|
|
25
|
+
tasks: [],
|
|
26
|
+
claims: [],
|
|
27
|
+
flags: [],
|
|
28
|
+
grades: new Map(),
|
|
29
|
+
seq: { task: 5000, claim: 9000, flag: 100 },
|
|
30
|
+
building: true,
|
|
31
|
+
};
|
|
32
|
+
const seen = [];
|
|
33
|
+
|
|
34
|
+
// The advisory lock, as a mutex per key: a second locker waits until the
|
|
35
|
+
// first commits or rolls back.
|
|
36
|
+
const locks = new Map();
|
|
37
|
+
async function lock(key, owner) {
|
|
38
|
+
while (locks.has(key)) await locks.get(key).done;
|
|
39
|
+
let release;
|
|
40
|
+
const done = new Promise((r) => { release = r; });
|
|
41
|
+
locks.set(key, { owner, done, release });
|
|
42
|
+
}
|
|
43
|
+
function unlockAll(owner) {
|
|
44
|
+
for (const [k, v] of [...locks]) if (v.owner === owner) { locks.delete(k); v.release(); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const activeClaim = (taskId) => db.claims.find((x) => String(x.task_id) === String(taskId) && x.released_at == null) || null;
|
|
48
|
+
const isWeb = (c) => c.worktree_name == null && c.creator_session_id == null;
|
|
49
|
+
|
|
50
|
+
// listPageTweakTasks' row, column for column (modules/lifecycle/page-tweak-reads.js).
|
|
51
|
+
function pageTweakRow(t) {
|
|
52
|
+
const ac = activeClaim(t.id);
|
|
53
|
+
const web = db.claims.filter((c) => String(c.task_id) === String(t.id) && isWeb(c)).slice(-1)[0] || null;
|
|
54
|
+
const hb = ac ? db.builders.get(String(ac.builder_id)) : null;
|
|
55
|
+
const ab = web ? db.builders.get(String(web.builder_id)) : null;
|
|
56
|
+
return {
|
|
57
|
+
id: t.id, status: t.status, title: t.title, description: t.description, source_ref: t.source_ref, touches: t.touches,
|
|
58
|
+
shipped_at: t.shipped_at || null, created_at: t.created_at || null, updated_at: t.updated_at || null,
|
|
59
|
+
grade_passed: db.grades.has(String(t.id)) ? db.grades.get(String(t.id)) : null,
|
|
60
|
+
holder_id: ac ? ac.builder_id : null, holder_login: hb ? hb.github_login : null,
|
|
61
|
+
holder_worktree: ac ? ac.worktree_name != null : null,
|
|
62
|
+
artist_id: web ? web.builder_id : null, artist_login: ab ? ab.github_login : null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function answer(q, p, owner) {
|
|
67
|
+
seen.push(q);
|
|
68
|
+
await new Promise((r) => setImmediate(r));
|
|
69
|
+
if (/^(BEGIN|COMMIT|ROLLBACK)$/.test(q)) {
|
|
70
|
+
if (q !== 'BEGIN') unlockAll(owner);
|
|
71
|
+
return { rows: [] };
|
|
72
|
+
}
|
|
73
|
+
if (/pg_advisory_xact_lock\(hashtext\(\$1\)\)/.test(q)) {
|
|
74
|
+
assert.ok(owner, 'the page lock must be taken on the transaction client');
|
|
75
|
+
await lock(p[0], owner);
|
|
76
|
+
return { rows: [] };
|
|
77
|
+
}
|
|
78
|
+
if (/^SELECT id, status, github_login, display_name FROM builders WHERE id = \$1/.test(q)) {
|
|
79
|
+
const b = db.builders.get(String(p[0]));
|
|
80
|
+
return { rows: b ? [b] : [] };
|
|
81
|
+
}
|
|
82
|
+
if (/^SELECT preferred_disciplines FROM builders WHERE id = \$1/.test(q)) {
|
|
83
|
+
const b = db.builders.get(String(p[0]));
|
|
84
|
+
return { rows: b ? [{ preferred_disciplines: b.preferred_disciplines }] : [] };
|
|
85
|
+
}
|
|
86
|
+
if (/FROM tasks WHERE source = \$1 AND left\(source_ref, length\(\$2\)\) = \$2 ORDER BY id ASC FOR UPDATE/.test(q)) {
|
|
87
|
+
return { rows: db.tasks.filter((t) => t.source === p[0] && String(t.source_ref).startsWith(p[1])).map((t) => ({ ...t })) };
|
|
88
|
+
}
|
|
89
|
+
if (/FROM claims c JOIN builders b ON b\.id = c\.builder_id WHERE c\.task_id = \$1 AND c\.released_at IS NULL/.test(q)) {
|
|
90
|
+
const c = activeClaim(p[0]);
|
|
91
|
+
if (!c) return { rows: [] };
|
|
92
|
+
const b = db.builders.get(String(c.builder_id));
|
|
93
|
+
return { rows: [{ ...c, github_login: b.github_login, display_name: b.display_name }] };
|
|
94
|
+
}
|
|
95
|
+
if (/^INSERT INTO claims \(task_id, builder_id, worktree_name, requested_worktree_name, head_sha, creator_session_id\) VALUES \(\$1, \$2, NULL, NULL, NULL, NULL\)/.test(q)) {
|
|
96
|
+
if (activeClaim(p[0])) {
|
|
97
|
+
throw Object.assign(new Error('duplicate key'), { code: '23505', constraint: 'uniq_active_claim_per_task' });
|
|
98
|
+
}
|
|
99
|
+
const c = { id: String(++db.seq.claim), task_id: String(p[0]), builder_id: String(p[1]), worktree_name: null, creator_session_id: null, claimed_at: new Date().toISOString(), released_at: null };
|
|
100
|
+
db.claims.push(c);
|
|
101
|
+
return { rows: [{ ...c }] };
|
|
102
|
+
}
|
|
103
|
+
if (/^UPDATE tasks SET status = 'active', updated_at = now\(\) WHERE id = \$1/.test(q)) {
|
|
104
|
+
db.tasks.find((t) => String(t.id) === String(p[0])).status = 'active';
|
|
105
|
+
return { rows: [] };
|
|
106
|
+
}
|
|
107
|
+
// createTask's reads: the catch-all goal of the version, and its scope.
|
|
108
|
+
if (/FROM goals g JOIN versions v ON v\.id = g\.version_id WHERE g\.version_id = \$1 AND g\.title = \$2/.test(q)) {
|
|
109
|
+
return { rows: [{ id: '77', version_status: 'building' }] };
|
|
110
|
+
}
|
|
111
|
+
if (/^SELECT scope_modules FROM goals WHERE id = \$1/.test(q)) return { rows: [{ scope_modules: [] }] };
|
|
112
|
+
if (/^INSERT INTO tasks \(version_id, title, description, status, touches,/.test(q)) {
|
|
113
|
+
assert.ok(owner, 'the round must be created inside the claim transaction');
|
|
114
|
+
const t = {
|
|
115
|
+
id: String(++db.seq.task), version_id: p[0], title: p[1], description: p[2], status: p[3], touches: p[4],
|
|
116
|
+
credits_reward: p[10], source: p[11], source_ref: p[12], kind: p[14], discipline: p[15], requires_rank: p[20], goal_id: p[22], created_by: p[23],
|
|
117
|
+
};
|
|
118
|
+
db.tasks.push(t);
|
|
119
|
+
return { rows: [{ ...t }] };
|
|
120
|
+
}
|
|
121
|
+
if (/^SELECT id, name, status, scope_doc_path, started_at, shipped_at, done_when FROM versions/.test(q)) {
|
|
122
|
+
return { rows: db.building ? [{ id: 'V9', name: 'V9', status: 'building' }] : [{ id: 'V8', name: 'V8', status: 'shipped' }] };
|
|
123
|
+
}
|
|
124
|
+
// The draft save and the submit (task 1004319 / TW08,
|
|
125
|
+
// modules/lifecycle/page-tweak-write.js). The task row lock is a real
|
|
126
|
+
// mutex too, so a raced second submit waits for the first to commit.
|
|
127
|
+
if (/^SELECT id, status, title, description, source, source_ref, version_id, goal_id, touches FROM tasks WHERE id = \$1 FOR UPDATE$/.test(q)) {
|
|
128
|
+
assert.ok(owner, 'the task row must be locked on the transaction client');
|
|
129
|
+
await lock(`task:${p[0]}`, owner);
|
|
130
|
+
const t = db.tasks.find((x) => String(x.id) === String(p[0]));
|
|
131
|
+
return { rows: t ? [{ ...t }] : [] };
|
|
132
|
+
}
|
|
133
|
+
if (/^UPDATE tasks SET description = \$2, (status = 'ready', )?updated_at = now\(\) WHERE id = \$1 RETURNING id, status, description, source_ref, updated_at$/.test(q)) {
|
|
134
|
+
assert.ok(owner, 'a description write runs inside the transaction');
|
|
135
|
+
const t = db.tasks.find((x) => String(x.id) === String(p[0]));
|
|
136
|
+
t.description = p[1];
|
|
137
|
+
if (/status = 'ready'/.test(q)) t.status = 'ready';
|
|
138
|
+
t.updated_at = new Date().toISOString();
|
|
139
|
+
db.writes = (db.writes || 0) + 1;
|
|
140
|
+
return { rows: [{ id: t.id, status: t.status, description: t.description, source_ref: t.source_ref, updated_at: t.updated_at }] };
|
|
141
|
+
}
|
|
142
|
+
if (/^SELECT id, source_ref FROM tasks WHERE source = \$1 AND source_ref = ANY\(\$2::text\[\]\)$/.test(q)) {
|
|
143
|
+
return { rows: db.tasks.filter((t) => t.source === p[0] && p[1].includes(t.source_ref)).map((t) => ({ id: t.id, source_ref: t.source_ref })) };
|
|
144
|
+
}
|
|
145
|
+
if (/^UPDATE claims SET released_at = now\(\), outcome = \$2 WHERE id = \$1 AND released_at IS NULL$/.test(q)) {
|
|
146
|
+
const c = db.claims.find((x) => String(x.id) === String(p[0]) && x.released_at == null);
|
|
147
|
+
if (c) Object.assign(c, { released_at: new Date().toISOString(), outcome: p[1] });
|
|
148
|
+
return { rows: [] };
|
|
149
|
+
}
|
|
150
|
+
// The page reads (listPageTweakTasks), computed from the ledger's state.
|
|
151
|
+
if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
|
|
152
|
+
assert.match(q, /WHERE t\.source = 'page-tweak'/);
|
|
153
|
+
// The optional page scope (task 1004319): $1 is the round-ref prefix or null.
|
|
154
|
+
assert.match(q, /left\(t\.source_ref, length\(\$1::text\)\) = \$1::text/);
|
|
155
|
+
const prefix = p[0] == null ? null : String(p[0]);
|
|
156
|
+
(db.pageScopes = db.pageScopes || []).push(prefix);
|
|
157
|
+
return { rows: db.tasks.filter((t) => t.source === 'page-tweak' && (prefix == null || String(t.source_ref).startsWith(prefix))).map(pageTweakRow) };
|
|
158
|
+
}
|
|
159
|
+
// The flag SQL (the page ask).
|
|
160
|
+
if (/FROM copy_desk_flags f WHERE f\.scope = 'page' AND f\.page_id = \$1 AND f\.flagged_by = \$2 AND f\.status = 'open'/.test(q)) {
|
|
161
|
+
return { rows: db.flags.filter((x) => x.scope === 'page' && x.page_id === p[0] && String(x.flagged_by) === String(p[1]) && x.status === 'open').slice(0, 1) };
|
|
162
|
+
}
|
|
163
|
+
if (/^INSERT INTO copy_desk_flags/.test(q)) {
|
|
164
|
+
const [scope, surface, stringId, reason, kind, , , , , by, pageId] = p;
|
|
165
|
+
// The CHECKs of copy_desk_002, so the fake refuses what Postgres would.
|
|
166
|
+
assert.equal((scope === 'page'), (pageId != null), 'copy_desk_flags_target_chk');
|
|
167
|
+
if (scope === 'page') assert.equal(surface, pageId, 'copy_desk_flags_page_surface_chk');
|
|
168
|
+
const dup = db.flags.find((x) => x.status === 'open' && String(x.flagged_by) === String(by) && x.surface === surface && (x.string_id || '') === (stringId || ''));
|
|
169
|
+
if (dup) throw Object.assign(new Error('dup'), { code: '23505' });
|
|
170
|
+
const row = { id: String(++db.seq.flag), scope, surface, string_id: stringId, page_id: pageId, reason, kind, flagged_by: String(by), flagged_at: new Date().toISOString(), status: 'open' };
|
|
171
|
+
db.flags.push(row);
|
|
172
|
+
return { rows: [{ ...row }] };
|
|
173
|
+
}
|
|
174
|
+
if (/COUNT\(DISTINCT flagged_by\)::int AS askers FROM copy_desk_flags WHERE scope = 'page' AND page_id = \$1 AND status = 'open'/.test(q)) {
|
|
175
|
+
return { rows: [{ askers: new Set(db.flags.filter((x) => x.scope === 'page' && x.page_id === p[0] && x.status === 'open').map((x) => x.flagged_by)).size }] };
|
|
176
|
+
}
|
|
177
|
+
// flags.pageAskSummary: open asks grouped by page.
|
|
178
|
+
if (/^SELECT page_id, COUNT\(DISTINCT flagged_by\)::int AS askers, MIN\(flagged_at\) AS oldest_ask_at FROM copy_desk_flags WHERE scope = 'page' AND status = 'open' GROUP BY page_id$/.test(q)) {
|
|
179
|
+
const by = new Map();
|
|
180
|
+
for (const x of db.flags) {
|
|
181
|
+
if (x.scope !== 'page' || x.status !== 'open') continue;
|
|
182
|
+
const g = by.get(x.page_id) || { page_id: x.page_id, who: new Set(), oldest: null };
|
|
183
|
+
g.who.add(String(x.flagged_by));
|
|
184
|
+
if (!g.oldest || new Date(x.flagged_at) < new Date(g.oldest)) g.oldest = x.flagged_at;
|
|
185
|
+
by.set(x.page_id, g);
|
|
186
|
+
}
|
|
187
|
+
return { rows: [...by.values()].map((g) => ({ page_id: g.page_id, askers: g.who.size, oldest_ask_at: g.oldest })) };
|
|
188
|
+
}
|
|
189
|
+
if (/^UPDATE copy_desk_flags SET status = 'resolved', resolved_task_id = \$2, resolved_by = \$3/.test(q)) {
|
|
190
|
+
const hit = db.flags.filter((x) => x.scope === 'page' && x.page_id === p[0] && x.status === 'open' && new Date(x.flagged_at) <= new Date(p[3]));
|
|
191
|
+
for (const x of hit) Object.assign(x, { status: 'resolved', resolved_task_id: String(p[1]), resolved_by: String(p[2]), resolution_note: p[4] });
|
|
192
|
+
return { rows: hit.map((x) => ({ id: x.id })) };
|
|
193
|
+
}
|
|
194
|
+
throw new Error(`unexpected SQL: ${q.slice(0, 160)}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let clientSeq = 0;
|
|
198
|
+
api.pool = {
|
|
199
|
+
query: (sql, params = []) => answer(flat(sql), params, null),
|
|
200
|
+
async connect() {
|
|
201
|
+
const owner = ++clientSeq;
|
|
202
|
+
return { query: (sql, params = []) => answer(flat(sql), params, owner), release() { unlockAll(owner); } };
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
return { db, seen };
|
|
206
|
+
}
|