@bongos/core 1.19.1058 → 1.19.1060

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.
@@ -0,0 +1,219 @@
1
+ // tests/copy_desk_page_claim.mjs — claim a page, and ask for one, the WHOLE path
2
+ // (task 1004317 / BV2.TW06, ADR 0341 D2, D5, D6).
3
+ //
4
+ // The done-when: an artist claims a page; a second artist is refused with the
5
+ // holder named; a non-artist can ask but not claim.
6
+ //
7
+ // Driven through the real copy-desk routes, the real lifecycle port
8
+ // (webClaimPageTweak, and the real createTask under it), the real flag SQL and
9
+ // the real error envelope, against a STATEFUL fake pool: a small in-memory
10
+ // tasks / claims / builders / copy_desk_flags ledger that answers the SQL those
11
+ // paths send. The fake models the page's advisory lock as a real mutex, so the
12
+ // "two artists at once" case is a race the lock has to win, not two calls in a
13
+ // row.
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { makeRunner } from './helpers.mjs';
21
+ import { installPageLedger } from './fixtures/page-ledger.mjs';
22
+
23
+ process.env.NODE_ENV = 'test';
24
+ const require = createRequire(import.meta.url);
25
+ const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
26
+ const api = require('../src/module-api.js');
27
+
28
+ // --- the ledger: the shared stateful fake (tests/fixtures/page-ledger.mjs) ---
29
+ const { db, seen } = installPageLedger(api, {
30
+ builders: [
31
+ { id: '7', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
32
+ { id: '8', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['engineer', 'artist'] },
33
+ { id: '9', github_login: 'carol', display_name: 'Carol', preferred_disciplines: ['engineer'] },
34
+ ],
35
+ });
36
+ const ALICE = '7';
37
+ const BOB = '8';
38
+ const CAROL = '9';
39
+
40
+ api.requireBuilder = (req, res, next) => {
41
+ req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
42
+ next();
43
+ };
44
+
45
+ const seams = require('../src/module-seams.js');
46
+ const lifecyclePort = require('../modules/lifecycle/lifecycle.js');
47
+ if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', lifecyclePort);
48
+
49
+ const pageData = require('../modules/copy-desk/page-data.js');
50
+ const express = require('express');
51
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
52
+ const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
53
+
54
+ const app = express();
55
+ app.use(express.json());
56
+ app.use(attachFail);
57
+ app.use(buildRouter());
58
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
59
+ const base = `http://127.0.0.1:${server.address().port}`;
60
+
61
+ async function post(p, builder, body = {}) {
62
+ const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', 'x-builder': builder }, body: JSON.stringify(body) });
63
+ return { status: r.status, body: await r.json() };
64
+ }
65
+ const errCode = (b) => (b && b.error && b.error.code) || null;
66
+ const errDetail = (b) => (b && b.error && b.error.details) || {};
67
+
68
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-claim-'));
69
+ fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
70
+ fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
71
+
72
+ const { test, summary } = makeRunner();
73
+ const STUDIO = '/copy-desk/pages/builders:studio';
74
+
75
+ try {
76
+ pageData._setRoot(tmp);
77
+
78
+ await test('an artist claims a page: one round is created and web-claimed, in the building version', async () => {
79
+ const { status, body } = await post(`${STUDIO}/claim`, ALICE);
80
+ assert.equal(status, 201, JSON.stringify(body));
81
+ assert.equal(body.outcome, 'created');
82
+ assert.equal(body.round.state, 'writing');
83
+ assert.equal(body.round.round, 1);
84
+ assert.equal(body.round.source_ref, 'page-tweak/builders:studio/r1');
85
+ const t = db.tasks[0];
86
+ assert.equal(db.tasks.length, 1);
87
+ assert.deepEqual(
88
+ [t.source, t.source_ref, t.title, t.kind, t.discipline, t.credits_reward, t.version_id, t.goal_id, t.status],
89
+ ['page-tweak', 'page-tweak/builders:studio/r1', 'Tweak: Studio (builders:studio)', 'feature', 'artist', 30, 'V9', '77', 'active']
90
+ );
91
+ assert.deepEqual(t.touches, F.STUDIO_FILES, 'touches are the inventory page files');
92
+ const c = db.claims[0];
93
+ assert.deepEqual([c.builder_id, c.worktree_name, c.creator_session_id], [ALICE, null, null], 'a web claim: no worktree, no session');
94
+ });
95
+
96
+ await test('the holder claiming again is Resume: the same round, nothing new', async () => {
97
+ const { status, body } = await post(`${STUDIO}/claim`, ALICE);
98
+ assert.equal(status, 200);
99
+ assert.equal(body.outcome, 'resumed');
100
+ assert.equal(body.round.task_id, db.tasks[0].id);
101
+ assert.equal(db.tasks.length, 1);
102
+ assert.equal(db.claims.length, 1);
103
+ });
104
+
105
+ await test('a second artist is refused page_held, with the holder named', async () => {
106
+ const { status, body } = await post(`${STUDIO}/claim`, BOB);
107
+ assert.equal(status, 409);
108
+ assert.equal(errCode(body), 'page_held');
109
+ assert.deepEqual(errDetail(body).holder, { id: ALICE, login: 'alice', name: 'Alice' });
110
+ assert.match(errDetail(body).what_this_means, /Alice is writing this page/);
111
+ assert.equal(db.tasks.length, 1);
112
+ assert.equal(db.claims.length, 1);
113
+ });
114
+
115
+ await test('a non-artist is refused not_an_artist and offered the ask, and nothing is written', async () => {
116
+ const before = seen.length;
117
+ const { status, body } = await post('/copy-desk/pages/landing:index/claim', CAROL);
118
+ assert.equal(status, 403);
119
+ assert.equal(errCode(body), 'not_an_artist');
120
+ assert.ok(errDetail(body).your_options.some((o) => /asks/.test(o)));
121
+ assert.equal(seen.slice(before).some((q) => /^INSERT/.test(q)), false);
122
+ });
123
+
124
+ await test('a non-artist CAN ask: a page-scoped flag row, and a second ask returns the first', async () => {
125
+ const first = await post('/copy-desk/pages/landing:index/asks', CAROL, { reason: 'The hero says nothing about what this is.' });
126
+ assert.equal(first.status, 201, JSON.stringify(first.body));
127
+ assert.equal(first.body.created, true);
128
+ assert.deepEqual([first.body.ask.scope, first.body.ask.page_id, first.body.ask.surface], ['page', 'landing:index', 'landing:index']);
129
+ assert.equal(first.body.askers, 1);
130
+ const again = await post('/copy-desk/pages/landing:index/asks', CAROL, { reason: 'Asking a second time, same page.' });
131
+ assert.equal(again.status, 200);
132
+ assert.equal(again.body.created, false);
133
+ assert.equal(again.body.ask.id, first.body.ask.id);
134
+ const other = await post('/copy-desk/pages/landing:index/asks', BOB, { reason: 'Agreed, the hero needs a line.' });
135
+ assert.equal(other.status, 201);
136
+ assert.equal(other.body.askers, 2, 'distinct askers');
137
+ // One builder may ask for several pages of one surface (the reason surface = page id).
138
+ const second = await post('/copy-desk/pages/landing:projects/asks', CAROL, { reason: 'The project cards read the same.' });
139
+ assert.equal(second.status, 201);
140
+ });
141
+
142
+ await test('an ask keeps the reason floor; an unknown page is unknown_page; the body is strict', async () => {
143
+ const thin = await post('/copy-desk/pages/landing:index/asks', BOB, { reason: 'fix' });
144
+ assert.equal(errCode(thin.body), 'reason_too_thin');
145
+ const unknown = await post('/copy-desk/pages/landing:nope/claim', ALICE);
146
+ assert.equal(unknown.status, 404);
147
+ assert.equal(errCode(unknown.body), 'unknown_page');
148
+ const smuggled = await post('/copy-desk/pages/landing:index/claim', ALICE, { builder_id: BOB });
149
+ assert.equal(errCode(smuggled.body), 'validation_failed');
150
+ });
151
+
152
+ await test('two artists claiming one page at once: exactly one round, the other named as held', async () => {
153
+ const [a, b] = await Promise.all([
154
+ post('/copy-desk/pages/status:explore/claim', ALICE),
155
+ post('/copy-desk/pages/status:explore/claim', BOB),
156
+ ]);
157
+ const statuses = [a.status, b.status].sort();
158
+ assert.deepEqual(statuses, [201, 409]);
159
+ const loser = a.status === 409 ? a : b;
160
+ assert.equal(errCode(loser.body), 'page_held');
161
+ assert.equal(db.tasks.filter((t) => t.source_ref.startsWith('page-tweak/status:explore/')).length, 1);
162
+ });
163
+
164
+ await test('an unheld draft is taken over; a submitted round is page_in_flight', async () => {
165
+ // Alice releases her studio round without submitting: it is a draft.
166
+ const studio = db.tasks[0];
167
+ db.claims.find((c) => c.task_id === studio.id).released_at = new Date().toISOString();
168
+ studio.status = 'ready';
169
+ studio.description += '\n\n' + require('../modules/copy-desk/pages.js').composeDraftBlock({ page_id: 'builders:studio', reading_hash: 'h', saved_at: 'x', lines: [] });
170
+ const taken = await post(`${STUDIO}/claim`, BOB);
171
+ assert.equal(taken.status, 200, JSON.stringify(taken.body));
172
+ assert.equal(taken.body.outcome, 'claimed');
173
+ assert.equal(taken.body.round.task_id, studio.id, 'the same round: the draft stays');
174
+ assert.match(studio.description, /```page-tweak-draft/);
175
+
176
+ // Bob submits: the claim is released and a batch is frozen.
177
+ db.claims.find((c) => c.task_id === studio.id && c.released_at == null).released_at = new Date().toISOString();
178
+ studio.status = 'ready';
179
+ studio.description += '\n\n```page-tweak\n{"schema":1}\n```';
180
+ const refused = await post(`${STUDIO}/claim`, ALICE);
181
+ assert.equal(refused.status, 409);
182
+ assert.equal(errCode(refused.body), 'page_in_flight');
183
+ });
184
+
185
+ await test('after a round ships, the next claim opens round 2, and the open asks resolve', async () => {
186
+ const studio = db.tasks[0];
187
+ studio.status = 'shipped';
188
+ studio.shipped_by = BOB;
189
+ studio.shipped_at = new Date(Date.now() + 1000).toISOString();
190
+ await post('/copy-desk/pages/builders:studio/asks', CAROL, { reason: 'Studio greeting reads cold.' });
191
+ // The ship event: the port's getTask is the one read it needs.
192
+ const resolved = await buildRouter.resolveAsksOnShip({ id: studio.id }, {
193
+ lifecycle: { getTask: async () => ({ ...studio }) },
194
+ });
195
+ assert.equal(resolved.length, 1);
196
+ const ask = db.flags.find((x) => x.page_id === 'builders:studio');
197
+ assert.deepEqual([ask.status, ask.resolved_task_id, ask.resolved_by], ['resolved', studio.id, BOB]);
198
+ // Not a page tweak: nothing is touched.
199
+ assert.deepEqual(await buildRouter.resolveAsksOnShip({ id: '1' }, { lifecycle: { getTask: async () => ({ id: '1', source: 'copy-proposal', status: 'shipped' }) } }), []);
200
+
201
+ const next = await post(`${STUDIO}/claim`, ALICE);
202
+ assert.equal(next.status, 201);
203
+ assert.equal(next.body.round.source_ref, 'page-tweak/builders:studio/r2');
204
+ });
205
+
206
+ await test('nothing building: a new round is no_open_version, and nothing is created', async () => {
207
+ db.building = false;
208
+ const before = db.tasks.length;
209
+ const r = await post('/copy-desk/pages/landing:projects/claim', ALICE);
210
+ assert.equal(r.status, 503);
211
+ assert.equal(errCode(r.body), 'no_open_version');
212
+ assert.equal(db.tasks.length, before);
213
+ db.building = true;
214
+ });
215
+ } finally {
216
+ server.close();
217
+ pageData._setRoot(null);
218
+ }
219
+ summary();
@@ -0,0 +1,172 @@
1
+ // tests/copy_desk_page_next.mjs — "recommend the next page" and "take next",
2
+ // the WHOLE path (task 1004318 / BV2.TW07, ADR 0341 D5, D8, D11).
3
+ //
4
+ // The done-when: the recommendation follows the order on a fixture, skips held
5
+ // pages, and "take next" claims the recommended page in one call.
6
+ //
7
+ // modules/copy-desk/tests/copy_page_next.mjs proves the order on hand-shaped
8
+ // rows. This drives the path those rows travel: GET /copy-desk/next through
9
+ // the real route, the real lifecycle read (listPageTweakTasks) and the real
10
+ // page-ask summary SQL, then POST the recommendation's own `take` path through
11
+ // the real page claim (webClaimPageTweak, createTask), all against one stateful
12
+ // fake ledger (tests/fixtures/page-ledger.mjs). So a claim made by "take next"
13
+ // is what the next read sees, with no hand-built rows in between.
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { makeRunner } from './helpers.mjs';
21
+ import { installPageLedger } from './fixtures/page-ledger.mjs';
22
+
23
+ process.env.NODE_ENV = 'test';
24
+ const require = createRequire(import.meta.url);
25
+ const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
26
+ const pages = require('../modules/copy-desk/pages.js');
27
+ const api = require('../src/module-api.js');
28
+
29
+ const { db } = installPageLedger(api, {
30
+ builders: [
31
+ { id: '7', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
32
+ { id: '8', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['artist'] },
33
+ { id: '9', github_login: 'carol', display_name: 'Carol', preferred_disciplines: ['engineer'] },
34
+ ],
35
+ });
36
+ const ALICE = '7';
37
+ const BOB = '8';
38
+ const CAROL = '9';
39
+
40
+ api.requireBuilder = (req, res, next) => {
41
+ req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'thetes' };
42
+ next();
43
+ };
44
+
45
+ const seams = require('../src/module-seams.js');
46
+ if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', require('../modules/lifecycle/lifecycle.js'));
47
+
48
+ const pageData = require('../modules/copy-desk/page-data.js');
49
+ const express = require('express');
50
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
51
+ const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
52
+
53
+ const app = express();
54
+ app.use(express.json());
55
+ app.use(attachFail);
56
+ app.use(buildRouter());
57
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
58
+ const base = `http://127.0.0.1:${server.address().port}`;
59
+
60
+ async function call(method, p, builder, body) {
61
+ const r = await fetch(base + p, {
62
+ method, headers: { 'content-type': 'application/json', 'x-builder': builder },
63
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
64
+ });
65
+ return { status: r.status, body: await r.json() };
66
+ }
67
+ const getNext = (builder) => call('GET', '/copy-desk/next', builder);
68
+ const errCode = (b) => (b && b.error && b.error.code) || null;
69
+ const order = (b) => [b.recommended, ...b.alternatives].filter(Boolean).map((c) => c.page_id);
70
+
71
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-next-'));
72
+ fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
73
+ fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
74
+ fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(F.READINGS));
75
+
76
+ // The world before anyone asks: builders:studio was tweaked once by alice
77
+ // (her web claim, released at submit), and has drifted since ("Welcome home"
78
+ // became "Welcome back"). Every other page is untweaked.
79
+ db.tasks.push({
80
+ id: '4001', status: 'shipped', title: 'Tweak: Studio (builders:studio)', source: 'page-tweak',
81
+ source_ref: pages.roundSourceRef('builders:studio', 1), touches: F.STUDIO_FILES,
82
+ shipped_at: '2026-09-20T10:00:00.000Z',
83
+ description: F.describe({
84
+ pageId: 'builders:studio', submittedAt: '2026-09-19T10:00:00Z',
85
+ lines: [{ key: 'L0002', section: 'greeting', before: 'Welcome', after: 'Welcome home' }],
86
+ appliedAt: '2026-09-19T18:00:00Z', linesAfter: ['Welcome home', 'Resume'],
87
+ }),
88
+ });
89
+ db.claims.push({ id: '8001', task_id: '4001', builder_id: ALICE, worktree_name: null, creator_session_id: null, claimed_at: '2026-09-18T10:00:00.000Z', released_at: '2026-09-19T10:00:00.000Z' });
90
+ db.grades.set('4001', true);
91
+
92
+ const { test, summary } = makeRunner();
93
+
94
+ try {
95
+ pageData._setRoot(tmp);
96
+
97
+ await test('anyone asks; the recommendation reads asked, then changed, then untweaked', async () => {
98
+ assert.equal((await call('POST', '/copy-desk/pages/landing:projects/asks', CAROL, { reason: 'The project cards all read the same.' })).status, 201);
99
+ assert.equal((await call('POST', '/copy-desk/pages/landing:projects/asks', BOB, { reason: 'Agreed, the cards need a line each.' })).status, 201);
100
+ assert.equal((await call('POST', '/copy-desk/pages/status:explore/asks', CAROL, { reason: 'Explore does not say what it shows.' })).status, 201);
101
+
102
+ const { status, body } = await getNext(ALICE);
103
+ assert.equal(status, 200, JSON.stringify(body));
104
+ assert.deepEqual(order(body), ['landing:projects', 'status:explore', 'builders:studio', 'landing:index']);
105
+ assert.equal(body.recommended.why, '2 people asked for it');
106
+ assert.equal(body.recommended.why_line, 'landing · 2 people asked for it · untweaked');
107
+ assert.equal(body.alternatives[1].why, '1 line changed since your last pass', 'the round artist is the web claim, alice');
108
+ assert.equal(body.asked_pages, 2);
109
+ assert.equal(body.resume, null);
110
+ assert.deepEqual(body.skipped, []);
111
+ assert.equal(body.readings.ok, true);
112
+ });
113
+
114
+ await test('"take next" claims the recommended page in one call', async () => {
115
+ const { body: next } = await getNext(ALICE);
116
+ const before = db.tasks.length;
117
+ const take = await call(next.recommended.take.method, next.recommended.take.path, ALICE, {});
118
+ assert.equal(take.status, 201, JSON.stringify(take.body));
119
+ assert.equal(take.body.outcome, 'created');
120
+ assert.equal(take.body.page_id, next.recommended.page_id);
121
+ assert.equal(take.body.round.source_ref, 'page-tweak/landing:projects/r1');
122
+ assert.equal(db.tasks.length, before + 1);
123
+ const claim = db.claims.find((c) => c.task_id === take.body.round.task_id && c.released_at == null);
124
+ assert.deepEqual([claim.builder_id, claim.worktree_name, claim.creator_session_id], [ALICE, null, null], 'a web claim');
125
+ });
126
+
127
+ await test('the held page is Resume for its holder, and skipped (holder named) for everyone else', async () => {
128
+ const mine = (await getNext(ALICE)).body;
129
+ assert.equal(mine.resume.page_id, 'landing:projects');
130
+ assert.ok(!order(mine).includes('landing:projects'));
131
+ assert.equal(mine.recommended.page_id, 'status:explore');
132
+
133
+ const bobs = (await getNext(BOB)).body;
134
+ assert.equal(bobs.resume, null);
135
+ assert.ok(!order(bobs).includes('landing:projects'), 'never a page someone else holds, however many asked');
136
+ assert.deepEqual(bobs.skipped, [{ page_id: 'landing:projects', reason: 'page_held', state: 'writing', holder: { id: ALICE, login: 'alice' } }]);
137
+ assert.equal(bobs.recommended.page_id, 'status:explore');
138
+ assert.equal(bobs.alternatives[0].why, '1 line changed since the last pass', 'bob did not make that pass');
139
+ });
140
+
141
+ await test('take next for a second artist gets the next page, not the held one', async () => {
142
+ const { body: next } = await getNext(BOB);
143
+ const take = await call('POST', next.recommended.take.path, BOB, {});
144
+ assert.equal(take.status, 201, JSON.stringify(take.body));
145
+ assert.equal(take.body.page_id, 'status:explore');
146
+ const after = (await getNext(CAROL)).body;
147
+ assert.deepEqual(after.skipped.map((s) => s.page_id).sort(), ['landing:projects', 'status:explore']);
148
+ assert.deepEqual(order(after), ['builders:studio', 'landing:index']);
149
+ });
150
+
151
+ await test('a submitted round is in flight: skipped, and the claim agrees', async () => {
152
+ const round = db.tasks.find((t) => t.source_ref === 'page-tweak/status:explore/r1');
153
+ db.claims.find((c) => c.task_id === round.id && c.released_at == null).released_at = new Date().toISOString();
154
+ round.status = 'ready';
155
+ round.description += '\n\n' + pages.composeBatchBlock({ page_id: 'status:explore', reading_hash: 'h', submitted_at: 'x', lines: [{ key: 'L0001', section: 'hero', before: 'Explore', after: 'Look around', placement: 'placed', file: 'x', line: 1, string_id: 'a' }] });
156
+ const next = (await getNext(ALICE)).body;
157
+ assert.deepEqual(next.skipped, [{ page_id: 'status:explore', reason: 'page_in_flight', state: 'submitted' }]);
158
+ const refused = await call('POST', '/copy-desk/pages/status:explore/claim', ALICE, {});
159
+ assert.equal(errCode(refused.body), 'page_in_flight');
160
+ });
161
+
162
+ await test('no page inventory: a named 503, never a crash', async () => {
163
+ pageData._setRoot(fs.mkdtempSync(path.join(os.tmpdir(), 'page-next-empty-')));
164
+ const r = await getNext(ALICE);
165
+ assert.equal(r.status, 503);
166
+ assert.equal(errCode(r.body), 'page_inventory_missing');
167
+ });
168
+ } finally {
169
+ server.close();
170
+ pageData._setRoot(null);
171
+ }
172
+ summary();
@@ -0,0 +1,175 @@
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 and tests/copy_desk_page_next.mjs
15
+ // (a fixture, not a test: the runner discovers tests/*.mjs only).
16
+
17
+ import { strict as assert } from 'node:assert';
18
+
19
+ const flat = (sql) => String(sql).replace(/\s+/g, ' ').trim();
20
+
21
+ export function installPageLedger(api, { builders }) {
22
+ const db = {
23
+ builders: new Map(builders.map((b) => [String(b.id), { status: 'active', ...b, id: String(b.id) }])),
24
+ tasks: [],
25
+ claims: [],
26
+ flags: [],
27
+ grades: new Map(),
28
+ seq: { task: 5000, claim: 9000, flag: 100 },
29
+ building: true,
30
+ };
31
+ const seen = [];
32
+
33
+ // The advisory lock, as a mutex per key: a second locker waits until the
34
+ // first commits or rolls back.
35
+ const locks = new Map();
36
+ async function lock(key, owner) {
37
+ while (locks.has(key)) await locks.get(key).done;
38
+ let release;
39
+ const done = new Promise((r) => { release = r; });
40
+ locks.set(key, { owner, done, release });
41
+ }
42
+ function unlockAll(owner) {
43
+ for (const [k, v] of [...locks]) if (v.owner === owner) { locks.delete(k); v.release(); }
44
+ }
45
+
46
+ const activeClaim = (taskId) => db.claims.find((x) => String(x.task_id) === String(taskId) && x.released_at == null) || null;
47
+ const isWeb = (c) => c.worktree_name == null && c.creator_session_id == null;
48
+
49
+ // listPageTweakTasks' row, column for column (modules/lifecycle/page-tweak-reads.js).
50
+ function pageTweakRow(t) {
51
+ const ac = activeClaim(t.id);
52
+ const web = db.claims.filter((c) => String(c.task_id) === String(t.id) && isWeb(c)).slice(-1)[0] || null;
53
+ const hb = ac ? db.builders.get(String(ac.builder_id)) : null;
54
+ const ab = web ? db.builders.get(String(web.builder_id)) : null;
55
+ return {
56
+ id: t.id, status: t.status, title: t.title, description: t.description, source_ref: t.source_ref, touches: t.touches,
57
+ shipped_at: t.shipped_at || null, created_at: t.created_at || null, updated_at: t.updated_at || null,
58
+ grade_passed: db.grades.has(String(t.id)) ? db.grades.get(String(t.id)) : null,
59
+ holder_id: ac ? ac.builder_id : null, holder_login: hb ? hb.github_login : null,
60
+ holder_worktree: ac ? ac.worktree_name != null : null,
61
+ artist_id: web ? web.builder_id : null, artist_login: ab ? ab.github_login : null,
62
+ };
63
+ }
64
+
65
+ async function answer(q, p, owner) {
66
+ seen.push(q);
67
+ await new Promise((r) => setImmediate(r));
68
+ if (/^(BEGIN|COMMIT|ROLLBACK)$/.test(q)) {
69
+ if (q !== 'BEGIN') unlockAll(owner);
70
+ return { rows: [] };
71
+ }
72
+ if (/pg_advisory_xact_lock\(hashtext\(\$1\)\)/.test(q)) {
73
+ assert.ok(owner, 'the page lock must be taken on the transaction client');
74
+ await lock(p[0], owner);
75
+ return { rows: [] };
76
+ }
77
+ if (/^SELECT id, status, github_login, display_name FROM builders WHERE id = \$1/.test(q)) {
78
+ const b = db.builders.get(String(p[0]));
79
+ return { rows: b ? [b] : [] };
80
+ }
81
+ if (/^SELECT preferred_disciplines FROM builders WHERE id = \$1/.test(q)) {
82
+ const b = db.builders.get(String(p[0]));
83
+ return { rows: b ? [{ preferred_disciplines: b.preferred_disciplines }] : [] };
84
+ }
85
+ if (/FROM tasks WHERE source = \$1 AND left\(source_ref, length\(\$2\)\) = \$2 ORDER BY id ASC FOR UPDATE/.test(q)) {
86
+ return { rows: db.tasks.filter((t) => t.source === p[0] && String(t.source_ref).startsWith(p[1])).map((t) => ({ ...t })) };
87
+ }
88
+ 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)) {
89
+ const c = activeClaim(p[0]);
90
+ if (!c) return { rows: [] };
91
+ const b = db.builders.get(String(c.builder_id));
92
+ return { rows: [{ ...c, github_login: b.github_login, display_name: b.display_name }] };
93
+ }
94
+ 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)) {
95
+ if (activeClaim(p[0])) {
96
+ throw Object.assign(new Error('duplicate key'), { code: '23505', constraint: 'uniq_active_claim_per_task' });
97
+ }
98
+ 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 };
99
+ db.claims.push(c);
100
+ return { rows: [{ ...c }] };
101
+ }
102
+ if (/^UPDATE tasks SET status = 'active', updated_at = now\(\) WHERE id = \$1/.test(q)) {
103
+ db.tasks.find((t) => String(t.id) === String(p[0])).status = 'active';
104
+ return { rows: [] };
105
+ }
106
+ // createTask's reads: the catch-all goal of the version, and its scope.
107
+ if (/FROM goals g JOIN versions v ON v\.id = g\.version_id WHERE g\.version_id = \$1 AND g\.title = \$2/.test(q)) {
108
+ return { rows: [{ id: '77', version_status: 'building' }] };
109
+ }
110
+ if (/^SELECT scope_modules FROM goals WHERE id = \$1/.test(q)) return { rows: [{ scope_modules: [] }] };
111
+ if (/^INSERT INTO tasks \(version_id, title, description, status, touches,/.test(q)) {
112
+ assert.ok(owner, 'the round must be created inside the claim transaction');
113
+ const t = {
114
+ id: String(++db.seq.task), version_id: p[0], title: p[1], description: p[2], status: p[3], touches: p[4],
115
+ 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],
116
+ };
117
+ db.tasks.push(t);
118
+ return { rows: [{ ...t }] };
119
+ }
120
+ if (/^SELECT id, name, status, scope_doc_path, started_at, shipped_at, done_when FROM versions/.test(q)) {
121
+ return { rows: db.building ? [{ id: 'V9', name: 'V9', status: 'building' }] : [{ id: 'V8', name: 'V8', status: 'shipped' }] };
122
+ }
123
+ // The page reads (listPageTweakTasks), computed from the ledger's state.
124
+ if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
125
+ assert.match(q, /WHERE t\.source = 'page-tweak'/);
126
+ return { rows: db.tasks.filter((t) => t.source === 'page-tweak').map(pageTweakRow) };
127
+ }
128
+ // The flag SQL (the page ask).
129
+ 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)) {
130
+ 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) };
131
+ }
132
+ if (/^INSERT INTO copy_desk_flags/.test(q)) {
133
+ const [scope, surface, stringId, reason, kind, , , , , by, pageId] = p;
134
+ // The CHECKs of copy_desk_002, so the fake refuses what Postgres would.
135
+ assert.equal((scope === 'page'), (pageId != null), 'copy_desk_flags_target_chk');
136
+ if (scope === 'page') assert.equal(surface, pageId, 'copy_desk_flags_page_surface_chk');
137
+ const dup = db.flags.find((x) => x.status === 'open' && String(x.flagged_by) === String(by) && x.surface === surface && (x.string_id || '') === (stringId || ''));
138
+ if (dup) throw Object.assign(new Error('dup'), { code: '23505' });
139
+ 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' };
140
+ db.flags.push(row);
141
+ return { rows: [{ ...row }] };
142
+ }
143
+ if (/COUNT\(DISTINCT flagged_by\)::int AS askers FROM copy_desk_flags WHERE scope = 'page' AND page_id = \$1 AND status = 'open'/.test(q)) {
144
+ 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 }] };
145
+ }
146
+ // flags.pageAskSummary: open asks grouped by page.
147
+ 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)) {
148
+ const by = new Map();
149
+ for (const x of db.flags) {
150
+ if (x.scope !== 'page' || x.status !== 'open') continue;
151
+ const g = by.get(x.page_id) || { page_id: x.page_id, who: new Set(), oldest: null };
152
+ g.who.add(String(x.flagged_by));
153
+ if (!g.oldest || new Date(x.flagged_at) < new Date(g.oldest)) g.oldest = x.flagged_at;
154
+ by.set(x.page_id, g);
155
+ }
156
+ return { rows: [...by.values()].map((g) => ({ page_id: g.page_id, askers: g.who.size, oldest_ask_at: g.oldest })) };
157
+ }
158
+ if (/^UPDATE copy_desk_flags SET status = 'resolved', resolved_task_id = \$2, resolved_by = \$3/.test(q)) {
159
+ 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]));
160
+ 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] });
161
+ return { rows: hit.map((x) => ({ id: x.id })) };
162
+ }
163
+ throw new Error(`unexpected SQL: ${q.slice(0, 160)}`);
164
+ }
165
+
166
+ let clientSeq = 0;
167
+ api.pool = {
168
+ query: (sql, params = []) => answer(flat(sql), params, null),
169
+ async connect() {
170
+ const owner = ++clientSeq;
171
+ return { query: (sql, params = []) => answer(flat(sql), params, owner), release() { unlockAll(owner); } };
172
+ },
173
+ };
174
+ return { db, seen };
175
+ }