@bongos/core 1.19.1060 → 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 +40 -25
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +162 -3
- package/docs/api-reference.md +4 -2
- package/docs/module-api-changelog.md +2 -0
- package/modules/copy-desk/pages.js +200 -0
- package/modules/copy-desk/routes/copy-desk.js +308 -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/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 +6 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_submit.mjs +289 -0
- package/tests/fixtures/page-ledger.mjs +33 -2
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// tests/copy_desk_page_submit.mjs — save a page draft, submit it, and split the
|
|
2
|
+
// unplaced lines out, the WHOLE path (task 1004319 / BV2.TW08, ADR 0341 D4, D7).
|
|
3
|
+
//
|
|
4
|
+
// The done-when: draft save, submit and the unplaced split work end to end.
|
|
5
|
+
//
|
|
6
|
+
// Driven through the real copy-desk routes, the real lifecycle port (the page
|
|
7
|
+
// claim, updateHeldTaskDescription, submitPageTweak, and the real createTask
|
|
8
|
+
// under it) and the real page reads, against the shared stateful fake ledger
|
|
9
|
+
// (tests/fixtures/page-ledger.mjs), whose page lock and task row lock are real
|
|
10
|
+
// mutexes. The reading is a file on disk, read through the real page-data.js.
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { makeRunner } from './helpers.mjs';
|
|
18
|
+
import { installPageLedger } from './fixtures/page-ledger.mjs';
|
|
19
|
+
|
|
20
|
+
process.env.NODE_ENV = 'test';
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
23
|
+
const pages = require('../modules/copy-desk/pages.js');
|
|
24
|
+
const api = require('../src/module-api.js');
|
|
25
|
+
|
|
26
|
+
const { db, seen } = installPageLedger(api, {
|
|
27
|
+
builders: [
|
|
28
|
+
{ id: '7', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
|
|
29
|
+
{ id: '8', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['artist'] },
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
const ALICE = '7';
|
|
33
|
+
const BOB = '8';
|
|
34
|
+
|
|
35
|
+
api.requireBuilder = (req, res, next) => {
|
|
36
|
+
req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
|
|
37
|
+
next();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const seams = require('../src/module-seams.js');
|
|
41
|
+
const lifecyclePort = require('../modules/lifecycle/lifecycle.js');
|
|
42
|
+
if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', lifecyclePort);
|
|
43
|
+
|
|
44
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
45
|
+
const express = require('express');
|
|
46
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
47
|
+
const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
|
|
48
|
+
|
|
49
|
+
const app = express();
|
|
50
|
+
app.use(express.json());
|
|
51
|
+
app.use(attachFail);
|
|
52
|
+
app.use(buildRouter());
|
|
53
|
+
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
|
54
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
55
|
+
|
|
56
|
+
async function call(method, p, builder, body = {}) {
|
|
57
|
+
const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json', 'x-builder': builder }, body: method === 'GET' ? undefined : JSON.stringify(body) });
|
|
58
|
+
return { status: r.status, body: await r.json() };
|
|
59
|
+
}
|
|
60
|
+
const errCode = (b) => (b && b.error && b.error.code) || null;
|
|
61
|
+
const errDetail = (b) => (b && b.error && b.error.details) || {};
|
|
62
|
+
|
|
63
|
+
// The studio reading, in page-reader.js's shape: a shared line with its
|
|
64
|
+
// registry row, a shared line without one, placed lines, one with {…} holes,
|
|
65
|
+
// and an unplaced line.
|
|
66
|
+
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
67
|
+
const STUDIO_LINES = [
|
|
68
|
+
L('L0001', 'top bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
|
|
69
|
+
L('L0002', 'top bar', 'Ctrl K', 'shared'),
|
|
70
|
+
L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
|
|
71
|
+
L('L0004', 'greeting', '{…} pages tweaked', 'placed', { file: 'modules/hall-ui/public/studio.js', line: 88, string_id: 'st0004' }),
|
|
72
|
+
L('L0005', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
73
|
+
];
|
|
74
|
+
const INDEX_LINES = [
|
|
75
|
+
L('L0001', 'hero', 'Build here', 'placed', { file: 'modules/public-landing/public/index.html', line: 3, string_id: 'ix0001' }),
|
|
76
|
+
L('L0002', 'hero', 'Start a project', 'placed', { file: 'modules/public-landing/public/index.html', line: 4, string_id: 'ix0002' }),
|
|
77
|
+
];
|
|
78
|
+
const readingsWith = (studio, index) => ({
|
|
79
|
+
schema: 1,
|
|
80
|
+
pages: [
|
|
81
|
+
{ id: 'builders:studio', reading_hash: studio.hash, lines: studio.lines },
|
|
82
|
+
{ id: 'landing:index', reading_hash: index.hash, lines: index.lines },
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-submit-'));
|
|
87
|
+
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
|
88
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
|
|
89
|
+
function writeReadings(r) {
|
|
90
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(r));
|
|
91
|
+
pageData._setRoot(tmp); // drop the (mtime, size) cache
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { test, summary } = makeRunner();
|
|
95
|
+
const STUDIO = '/copy-desk/pages/builders:studio';
|
|
96
|
+
const INDEX = '/copy-desk/pages/landing:index';
|
|
97
|
+
const studioTask = () => db.tasks.find((t) => t.source_ref === 'page-tweak/builders:studio/r1');
|
|
98
|
+
const DRAFT = {
|
|
99
|
+
reading_hash: 'studio-h1',
|
|
100
|
+
lines: [
|
|
101
|
+
{ key: 'L0003', after: 'Welcome home' },
|
|
102
|
+
{ key: 'L0004', after: '{…} pages rewritten so far' },
|
|
103
|
+
{ key: 'L0005', after: 'Nothing is waiting for you' },
|
|
104
|
+
{ key: 'L0002', after: 'Control K' },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
writeReadings(readingsWith({ hash: 'studio-h1', lines: STUDIO_LINES }, { hash: 'index-h1', lines: INDEX_LINES }));
|
|
110
|
+
|
|
111
|
+
await test('no round yet: a draft save is no_open_round, and nothing is written', async () => {
|
|
112
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
113
|
+
assert.equal(r.status, 409);
|
|
114
|
+
assert.equal(errCode(r.body), 'no_open_round');
|
|
115
|
+
assert.equal(db.tasks.length, 0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await test('the holder saves a draft: it rests on the round task, resolved from the reading', async () => {
|
|
119
|
+
assert.equal((await call('POST', `${STUDIO}/claim`, ALICE)).status, 201);
|
|
120
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
121
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
122
|
+
assert.equal(r.body.outcome, 'saved');
|
|
123
|
+
assert.deepEqual(r.body.counter, { changed: 4, total: 5 });
|
|
124
|
+
const parsed = pages.parseDraftBlock(studioTask().description);
|
|
125
|
+
assert.equal(parsed.ok, true);
|
|
126
|
+
assert.deepEqual(parsed.draft.lines.map((l) => [l.key, l.before, l.after, l.placement]), [
|
|
127
|
+
['L0002', 'Ctrl K', 'Control K', 'shared'],
|
|
128
|
+
['L0003', 'Welcome back', 'Welcome home', 'placed'],
|
|
129
|
+
['L0004', '{…} pages tweaked', '{…} pages rewritten so far', 'placed'],
|
|
130
|
+
['L0005', 'Your desk is quiet today', 'Nothing is waiting for you', 'unplaced'],
|
|
131
|
+
]);
|
|
132
|
+
assert.match(studioTask().description, /^Page tweak round 1/, 'the round body stays; the block is added below it');
|
|
133
|
+
assert.equal(studioTask().status, 'active');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await test('the same autosave again writes nothing: unchanged, and the task is not touched', async () => {
|
|
137
|
+
const writes = db.writes;
|
|
138
|
+
const stamp = studioTask().updated_at;
|
|
139
|
+
const before = seen.length;
|
|
140
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
141
|
+
assert.equal(r.status, 200);
|
|
142
|
+
assert.equal(r.body.outcome, 'unchanged');
|
|
143
|
+
assert.equal(db.writes, writes);
|
|
144
|
+
assert.equal(studioTask().updated_at, stamp);
|
|
145
|
+
assert.equal(seen.slice(before).some((q) => /^UPDATE tasks/.test(q)), false);
|
|
146
|
+
// The save reads this page's rounds only, never the whole instance's.
|
|
147
|
+
assert.equal(db.pageScopes[db.pageScopes.length - 1], 'page-tweak/builders:studio/');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await test('a whole-draft replace: a line left out of the next save leaves the draft', async () => {
|
|
151
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, { ...DRAFT, lines: DRAFT.lines.slice(0, 3) });
|
|
152
|
+
assert.equal(r.body.outcome, 'saved');
|
|
153
|
+
assert.deepEqual(pages.parseDraftBlock(studioTask().description).draft.lines.map((l) => l.key), ['L0003', 'L0004', 'L0005']);
|
|
154
|
+
const back = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
155
|
+
assert.equal(back.body.outcome, 'saved');
|
|
156
|
+
assert.equal((studioTask().description.match(/```page-tweak-draft/g) || []).length, 1, 'replaced in place, never a second block');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await test('another artist cannot save it: page_not_held, naming the holder', async () => {
|
|
160
|
+
const r = await call('PUT', `${STUDIO}/draft`, BOB, DRAFT);
|
|
161
|
+
assert.equal(r.status, 409);
|
|
162
|
+
assert.equal(errCode(r.body), 'page_not_held');
|
|
163
|
+
assert.deepEqual(errDetail(r.body).holder, { id: ALICE, login: 'alice', name: 'Alice' });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await test('a draft built on an older reading is refused reading_moved, and nothing is written', async () => {
|
|
167
|
+
const writes = db.writes;
|
|
168
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, { ...DRAFT, reading_hash: 'studio-h0' });
|
|
169
|
+
assert.equal(r.status, 409);
|
|
170
|
+
assert.equal(errCode(r.body), 'reading_moved');
|
|
171
|
+
assert.equal(errDetail(r.body).reading_hash, 'studio-h1');
|
|
172
|
+
assert.equal(db.writes, writes);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test('the body is strict: a target field at the top level is refused', async () => {
|
|
176
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, { ...DRAFT, file: 'modules/hall-ui/public/studio.html' });
|
|
177
|
+
assert.equal(errCode(r.body), 'validation_failed');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await test('another artist cannot submit it', async () => {
|
|
181
|
+
const r = await call('POST', `${STUDIO}/submit`, BOB);
|
|
182
|
+
assert.equal(r.status, 409);
|
|
183
|
+
assert.equal(errCode(r.body), 'page_not_held');
|
|
184
|
+
assert.equal(pages.blockPresence(studioTask().description).batch, false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await test('submit freezes the batch, files an engineer task per unplaced line, and releases the claim', async () => {
|
|
188
|
+
const r = await call('POST', `${STUDIO}/submit`, ALICE);
|
|
189
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
190
|
+
assert.equal(r.body.outcome, 'submitted');
|
|
191
|
+
assert.deepEqual(r.body.round, { task_id: studioTask().id, state: 'submitted', status: 'ready' });
|
|
192
|
+
assert.equal(r.body.batch.line_count, 2);
|
|
193
|
+
assert.deepEqual(r.body.next, { path: '/copy-desk/next' });
|
|
194
|
+
|
|
195
|
+
// The batch: placed lines with their registry rows, from the reading.
|
|
196
|
+
const b = pages.parseBatchBlock(studioTask().description);
|
|
197
|
+
assert.equal(b.ok, true);
|
|
198
|
+
assert.deepEqual(b.batch.lines.map((l) => [l.key, l.file, l.line, l.string_id, l.before, l.after]), [
|
|
199
|
+
['L0003', 'modules/hall-ui/public/studio.html', 12, 'st0003', 'Welcome back', 'Welcome home'],
|
|
200
|
+
['L0004', 'modules/hall-ui/public/studio.js', 88, 'st0004', '{…} pages tweaked', '{…} pages rewritten so far'],
|
|
201
|
+
]);
|
|
202
|
+
assert.equal(b.batch.reading_hash, 'studio-h1');
|
|
203
|
+
|
|
204
|
+
// The unplaced split: the unplaced line, and the shared line with no row.
|
|
205
|
+
const eng = db.tasks.filter((t) => t.source === 'page-tweak-unplaced');
|
|
206
|
+
assert.deepEqual(eng.map((t) => t.source_ref).sort(), [
|
|
207
|
+
`page-tweak-unplaced/${studioTask().id}/L0002`,
|
|
208
|
+
`page-tweak-unplaced/${studioTask().id}/L0005`,
|
|
209
|
+
]);
|
|
210
|
+
for (const t of eng) {
|
|
211
|
+
assert.deepEqual([t.status, t.discipline, t.kind, t.version_id, t.goal_id, t.created_by], ['backlog', 'engineer', 'feature', 'V9', '77', ALICE]);
|
|
212
|
+
assert.match(t.description, /builders:studio/);
|
|
213
|
+
}
|
|
214
|
+
const quiet = eng.find((t) => t.source_ref.endsWith('/L0005'));
|
|
215
|
+
assert.match(quiet.description, /"Your desk is quiet today"/);
|
|
216
|
+
assert.match(quiet.description, /"Nothing is waiting for you"/);
|
|
217
|
+
assert.deepEqual([...b.batch.unplaced_tasks].sort(), eng.map((t) => t.id).sort(), 'the batch names the engineer tasks');
|
|
218
|
+
assert.deepEqual(r.body.unplaced_tasks.map((u) => u.created), [true, true]);
|
|
219
|
+
|
|
220
|
+
// The claim is released and the round is ready: the /tweak queue.
|
|
221
|
+
assert.equal(studioTask().status, 'ready');
|
|
222
|
+
const claim = db.claims.find((c) => c.task_id === studioTask().id);
|
|
223
|
+
assert.ok(claim.released_at, 'the web claim is released');
|
|
224
|
+
assert.equal(claim.outcome, 'partial');
|
|
225
|
+
// The draft stays beside the batch (ADR 0341 D7: it may be amended later).
|
|
226
|
+
assert.equal(pages.parseDraftBlock(studioTask().description).ok, true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await test('the page reads submitted, and nobody can claim it back into writing', async () => {
|
|
230
|
+
const page = await call('GET', STUDIO, BOB);
|
|
231
|
+
assert.equal(page.status, 200);
|
|
232
|
+
assert.equal(page.body.open_round.state, 'submitted');
|
|
233
|
+
const claim = await call('POST', `${STUDIO}/claim`, BOB);
|
|
234
|
+
assert.equal(errCode(claim.body), 'page_in_flight');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await test('a repeated submit is already_submitted: no second batch, no second engineer task', async () => {
|
|
238
|
+
const tasks = db.tasks.length;
|
|
239
|
+
const r = await call('POST', `${STUDIO}/submit`, ALICE);
|
|
240
|
+
assert.equal(r.status, 200);
|
|
241
|
+
assert.equal(r.body.outcome, 'already_submitted');
|
|
242
|
+
assert.equal(r.body.unplaced_tasks.length, 2);
|
|
243
|
+
assert.equal(db.tasks.length, tasks);
|
|
244
|
+
assert.equal((studioTask().description.match(/```page-tweak\n/g) || []).length, 1);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await test('after submit the draft is frozen: a save is page_in_flight', async () => {
|
|
248
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
249
|
+
assert.equal(r.status, 409);
|
|
250
|
+
assert.equal(errCode(r.body), 'page_in_flight');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await test('a line whose reading moved is refused target_gone at submit; the round stays with its artist', async () => {
|
|
254
|
+
assert.equal((await call('POST', `${INDEX}/claim`, BOB)).status, 201);
|
|
255
|
+
const saved = await call('PUT', `${INDEX}/draft`, BOB, { reading_hash: 'index-h1', lines: [{ key: 'L0001', after: 'Build it here' }, { key: 'L0002', after: 'Start yours' }] });
|
|
256
|
+
assert.equal(saved.body.outcome, 'saved');
|
|
257
|
+
// A ship rewords the hero line: the page no longer shows "Build here".
|
|
258
|
+
writeReadings(readingsWith({ hash: 'studio-h1', lines: STUDIO_LINES }, {
|
|
259
|
+
hash: 'index-h2',
|
|
260
|
+
lines: [L('L0001', 'hero', 'Build anything here', 'placed', { file: 'x', line: 3, string_id: 'ix0009' }), INDEX_LINES[1]],
|
|
261
|
+
}));
|
|
262
|
+
const writes = db.writes;
|
|
263
|
+
const r = await call('POST', `${INDEX}/submit`, BOB);
|
|
264
|
+
assert.equal(r.status, 409);
|
|
265
|
+
assert.equal(errCode(r.body), 'lines_refused');
|
|
266
|
+
assert.deepEqual(errDetail(r.body).refused, [{ key: 'L0001', section: 'hero', before: 'Build here', code: 'target_gone' }]);
|
|
267
|
+
const t = db.tasks.find((x) => x.source_ref === 'page-tweak/landing:index/r1');
|
|
268
|
+
assert.equal(t.status, 'active');
|
|
269
|
+
assert.equal(pages.blockPresence(t.description).batch, false);
|
|
270
|
+
assert.equal(db.writes, writes);
|
|
271
|
+
assert.ok(db.claims.find((c) => c.task_id === t.id && c.released_at == null), 'the claim is still held');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await test('two submits at once: one freezes, the other is the same double-click, and one engineer task per line', async () => {
|
|
275
|
+
// Bob drops the gone line on the current reading, adds an unplaced-free
|
|
276
|
+
// rewrite, and double-submits.
|
|
277
|
+
const t = db.tasks.find((x) => x.source_ref === 'page-tweak/landing:index/r1');
|
|
278
|
+
const saved = await call('PUT', `${INDEX}/draft`, BOB, { reading_hash: 'index-h2', lines: [{ key: 'L0002', after: 'Start yours' }] });
|
|
279
|
+
assert.equal(saved.body.outcome, 'saved');
|
|
280
|
+
const [a, b] = await Promise.all([call('POST', `${INDEX}/submit`, BOB), call('POST', `${INDEX}/submit`, BOB)]);
|
|
281
|
+
assert.deepEqual([a.body.outcome, b.body.outcome].sort(), ['already_submitted', 'submitted'], JSON.stringify([a.body, b.body]));
|
|
282
|
+
assert.equal((t.description.match(/```page-tweak\n/g) || []).length, 1);
|
|
283
|
+
assert.equal(t.status, 'ready');
|
|
284
|
+
});
|
|
285
|
+
} finally {
|
|
286
|
+
server.close();
|
|
287
|
+
pageData._setRoot(null);
|
|
288
|
+
}
|
|
289
|
+
summary();
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// An unknown statement THROWS: a route that sends SQL this ledger does not
|
|
12
12
|
// model fails loudly instead of reading an empty answer.
|
|
13
13
|
//
|
|
14
|
-
// Shared by tests/copy_desk_page_claim.mjs
|
|
14
|
+
// Shared by tests/copy_desk_page_claim.mjs, tests/copy_desk_page_next.mjs and
|
|
15
|
+
// tests/copy_desk_page_submit.mjs
|
|
15
16
|
// (a fixture, not a test: the runner discovers tests/*.mjs only).
|
|
16
17
|
|
|
17
18
|
import { strict as assert } from 'node:assert';
|
|
@@ -120,10 +121,40 @@ export function installPageLedger(api, { builders }) {
|
|
|
120
121
|
if (/^SELECT id, name, status, scope_doc_path, started_at, shipped_at, done_when FROM versions/.test(q)) {
|
|
121
122
|
return { rows: db.building ? [{ id: 'V9', name: 'V9', status: 'building' }] : [{ id: 'V8', name: 'V8', status: 'shipped' }] };
|
|
122
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
|
+
}
|
|
123
150
|
// The page reads (listPageTweakTasks), computed from the ledger's state.
|
|
124
151
|
if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
|
|
125
152
|
assert.match(q, /WHERE t\.source = 'page-tweak'/);
|
|
126
|
-
|
|
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) };
|
|
127
158
|
}
|
|
128
159
|
// The flag SQL (the page ask).
|
|
129
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)) {
|