@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,184 @@
|
|
|
1
|
+
// modules/copy-desk/tests/copy_page_next.mjs — the recommendation, pure
|
|
2
|
+
// (task 1004318 / BV2.TW07, ADR 0341 D8).
|
|
3
|
+
//
|
|
4
|
+
// The done-when: the recommendation follows the order on a fixture and skips
|
|
5
|
+
// held pages. The fixture below has a page in every tier and every way a page
|
|
6
|
+
// can be left out:
|
|
7
|
+
//
|
|
8
|
+
// asked builders:work (2 askers), landing:privacy (1, the OLDER ask),
|
|
9
|
+
// builders:tasks (1, the newer ask)
|
|
10
|
+
// changed builders:goals (4 lines, alice's pass), status:board (1 line,
|
|
11
|
+
// bob's pass)
|
|
12
|
+
// untweaked landing:index, builders:ideas (an unheld draft, taken over),
|
|
13
|
+
// status:explore: surface order, though the inventory lists
|
|
14
|
+
// builders first
|
|
15
|
+
// tweaked landing:projects (twice, no drift): last
|
|
16
|
+
// skipped builders:studio (held by bob, and asked for), landing:about
|
|
17
|
+
// (submitted: in flight)
|
|
18
|
+
//
|
|
19
|
+
// "Take next" through the real claim is tests/copy_desk_page_next.mjs (root).
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const pages = require('../pages.js');
|
|
26
|
+
const status = require('../page-status.js');
|
|
27
|
+
const F = require('./fixtures/page-tweak.cjs');
|
|
28
|
+
|
|
29
|
+
let passed = 0;
|
|
30
|
+
let failed = 0;
|
|
31
|
+
async function test(name, fn) {
|
|
32
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
33
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- the fixture: F's four pages plus six more -----------------------------
|
|
37
|
+
const page = (id, title) => ({ id, surface: id.split(':')[0], title, path: `/${id.split(':')[1]}`, files: [`${id}.html`] });
|
|
38
|
+
const INVENTORY = {
|
|
39
|
+
schema: 1,
|
|
40
|
+
surfaces: F.INVENTORY.surfaces,
|
|
41
|
+
pages: [
|
|
42
|
+
...F.INVENTORY.pages,
|
|
43
|
+
page('builders:work', 'Work'),
|
|
44
|
+
page('builders:goals', 'Goals'),
|
|
45
|
+
page('builders:tasks', 'Tasks'),
|
|
46
|
+
page('builders:ideas', 'Ideas'),
|
|
47
|
+
page('landing:privacy', 'Privacy'),
|
|
48
|
+
page('landing:about', 'About'),
|
|
49
|
+
page('status:board', 'Board'),
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
const line = (key, text, placement = 'placed') => ({ key, section: 'body', text, placement });
|
|
53
|
+
const READINGS = {
|
|
54
|
+
schema: 1,
|
|
55
|
+
pages: [
|
|
56
|
+
...F.READINGS.pages,
|
|
57
|
+
{ id: 'builders:goals', lines: [line('L0001', 'Nav', 'shared'), line('L0002', 'A'), line('L0003', 'W'), line('L0004', 'X'), line('L0005', 'Y'), line('L0006', 'Z')] },
|
|
58
|
+
{ id: 'status:board', lines: [line('L0001', 'One'), line('L0002', 'Two, reworded')] },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
const shipped = (id, pageId, n, artist, linesAfter, at) => F.row({
|
|
62
|
+
id, status: 'shipped', source_ref: pages.roundSourceRef(pageId, n), shipped_at: new Date(at), grade_passed: true,
|
|
63
|
+
artist_id: artist.id, artist_login: artist.login,
|
|
64
|
+
description: F.describe({ pageId, submittedAt: at, lines: [{ key: 'L0002', section: 'body', before: 'b', after: 'a' }], appliedAt: at, linesAfter }),
|
|
65
|
+
});
|
|
66
|
+
const TASKS = [
|
|
67
|
+
...F.TASKS,
|
|
68
|
+
shipped('401', 'builders:goals', 1, F.ALICE, ['A', 'B', 'C', 'D', 'E'], '2026-09-10T10:00:00Z'),
|
|
69
|
+
shipped('402', 'status:board', 1, F.BOB, ['One', 'Two'], '2026-09-11T10:00:00Z'),
|
|
70
|
+
// An unheld draft: released without submitting. The claim takes it over.
|
|
71
|
+
F.row({
|
|
72
|
+
id: '403', status: 'ready', source_ref: pages.roundSourceRef('builders:ideas', 1),
|
|
73
|
+
description: F.describe({ pageId: 'builders:ideas', submittedAt: '2026-09-20T10:00:00Z', draft: true, lines: [] }),
|
|
74
|
+
}),
|
|
75
|
+
// Submitted: a frozen batch, no holder. In flight.
|
|
76
|
+
F.row({
|
|
77
|
+
id: '404', status: 'ready', source_ref: pages.roundSourceRef('landing:about', 1),
|
|
78
|
+
description: F.describe({ pageId: 'landing:about', submittedAt: '2026-09-21T10:00:00Z', lines: [{ key: 'L0002', section: 'body', before: 'x', after: 'y' }] }),
|
|
79
|
+
}),
|
|
80
|
+
];
|
|
81
|
+
const ASKS = [
|
|
82
|
+
{ page_id: 'builders:work', askers: 2, oldest_ask_at: new Date('2026-09-15T00:00:00Z') },
|
|
83
|
+
{ page_id: 'landing:privacy', askers: 1, oldest_ask_at: new Date('2026-09-01T00:00:00Z') },
|
|
84
|
+
{ page_id: 'builders:tasks', askers: 1, oldest_ask_at: new Date('2026-09-20T00:00:00Z') },
|
|
85
|
+
{ page_id: 'builders:studio', askers: 3, oldest_ask_at: new Date('2026-08-01T00:00:00Z') },
|
|
86
|
+
{ page_id: 'landing:gone', askers: 5, oldest_ask_at: new Date('2026-08-01T00:00:00Z') }, // not in the inventory
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const next = (builderId, over = {}) => status.composeNext({ inventory: INVENTORY, readings: READINGS, tasks: TASKS, asks: ASKS, builderId, alternatives: 99, ...over });
|
|
90
|
+
const order = (out) => [out.recommended, ...out.alternatives].filter(Boolean).map((c) => c.page_id);
|
|
91
|
+
|
|
92
|
+
const EXPECTED = [
|
|
93
|
+
'builders:work', 'landing:privacy', 'builders:tasks',
|
|
94
|
+
'builders:goals', 'status:board',
|
|
95
|
+
'landing:index', 'builders:ideas', 'status:explore',
|
|
96
|
+
'landing:projects',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
await test('the order on the fixture: asked, changed, untweaked (surface order), tweaked last', () => {
|
|
100
|
+
const out = next(F.ALICE.id);
|
|
101
|
+
assert.deepEqual(order(out), EXPECTED);
|
|
102
|
+
assert.deepEqual([...new Set([out.recommended, ...out.alternatives].map((c) => c.tier))], ['asked', 'changed', 'untweaked', 'tweaked']);
|
|
103
|
+
assert.equal(out.candidates, EXPECTED.length);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await test('held and in-flight pages are never recommended, and are named in skipped', () => {
|
|
107
|
+
const out = next(F.ALICE.id);
|
|
108
|
+
assert.ok(!order(out).includes('builders:studio'), 'held by bob, though three people asked for it');
|
|
109
|
+
assert.ok(!order(out).includes('landing:about'), 'submitted: in flight');
|
|
110
|
+
assert.deepEqual(out.skipped.map((s) => [s.page_id, s.reason]), [['builders:studio', 'page_held'], ['landing:about', 'page_in_flight']]);
|
|
111
|
+
assert.deepEqual(out.skipped[0].holder, { id: F.BOB.id, login: 'bob' });
|
|
112
|
+
assert.equal(out.skipped[1].state, 'submitted');
|
|
113
|
+
assert.equal(out.resume, null);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await test("the caller's own held page is Resume, not a recommendation", () => {
|
|
117
|
+
const out = next(F.BOB.id);
|
|
118
|
+
assert.equal(out.resume.page_id, 'builders:studio');
|
|
119
|
+
assert.equal(out.resume.task_id, '202');
|
|
120
|
+
assert.equal(out.resume.take.path, '/copy-desk/pages/builders:studio/claim');
|
|
121
|
+
assert.ok(!order(out).includes('builders:studio'));
|
|
122
|
+
assert.ok(!out.skipped.some((s) => s.page_id === 'builders:studio'));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await test("the caller's own CLI claim (applying) is in flight, not Resume", () => {
|
|
126
|
+
const tasks = TASKS.map((t) => (t.id === '202' ? { ...t, holder_worktree: true } : t));
|
|
127
|
+
const out = next(F.BOB.id, { tasks });
|
|
128
|
+
assert.equal(out.resume, null);
|
|
129
|
+
assert.deepEqual(out.skipped.find((s) => s.page_id === 'builders:studio').reason, 'page_in_flight');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('each candidate carries its why, in the mock\'s words', () => {
|
|
133
|
+
const out = next(F.ALICE.id);
|
|
134
|
+
const by = (id) => [out.recommended, ...out.alternatives].find((c) => c.page_id === id);
|
|
135
|
+
assert.equal(out.recommended.why, '2 people asked for it');
|
|
136
|
+
assert.equal(out.recommended.why_line, 'builders hall · 2 people asked for it · untweaked');
|
|
137
|
+
assert.equal(by('landing:privacy').why_line, 'landing · 1 person asked for it · untweaked');
|
|
138
|
+
assert.equal(by('builders:goals').why, '4 lines changed since your last pass', 'alice made the last pass');
|
|
139
|
+
assert.equal(by('builders:goals').changed_lines, 4, 'shared lines never count');
|
|
140
|
+
assert.equal(by('status:board').why, '1 line changed since the last pass', 'bob made it, not the caller');
|
|
141
|
+
assert.equal(by('status:explore').why_line, 'status · untweaked');
|
|
142
|
+
assert.equal(by('landing:projects').why, 'text tweaked 2x, nothing changed since');
|
|
143
|
+
assert.equal(by('builders:ideas').draft_round.task_id, '403', 'the draft the claim takes over is named');
|
|
144
|
+
assert.equal(out.recommended.take.path, '/copy-desk/pages/builders:work/claim');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test('ties: more askers first, then the oldest ask; more changed lines first', () => {
|
|
148
|
+
const asks = [
|
|
149
|
+
{ page_id: 'landing:privacy', askers: 1, oldest_ask_at: new Date('2026-09-25T00:00:00Z') },
|
|
150
|
+
{ page_id: 'builders:tasks', askers: 1, oldest_ask_at: new Date('2026-09-02T00:00:00Z') },
|
|
151
|
+
];
|
|
152
|
+
assert.deepEqual(order(next(F.ALICE.id, { asks })).slice(0, 2), ['builders:tasks', 'landing:privacy'], 'oldest ask wins the tie');
|
|
153
|
+
const more = [{ page_id: 'builders:tasks', askers: 3, oldest_ask_at: new Date('2026-09-26T00:00:00Z') }, ...asks];
|
|
154
|
+
assert.equal(next(F.ALICE.id, { asks: more }).recommended.page_id, 'builders:tasks');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await test('asked_pages counts every inventory page with an open ask, held ones too', () => {
|
|
158
|
+
assert.equal(next(F.ALICE.id).asked_pages, 4);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await test('pick another: the next few, four by default', () => {
|
|
162
|
+
const out = status.composeNext({ inventory: INVENTORY, readings: READINGS, tasks: TASKS, asks: ASKS, builderId: F.ALICE.id });
|
|
163
|
+
assert.equal(out.alternatives.length, status.NEXT_ALTERNATIVES);
|
|
164
|
+
assert.equal(status.NEXT_ALTERNATIVES, 4);
|
|
165
|
+
assert.deepEqual(order(out), EXPECTED.slice(0, 5));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await test('no readings: drift is unknown, so no page is "changed", and tweaked pages sort last', () => {
|
|
169
|
+
const out = next(F.ALICE.id, { readings: null });
|
|
170
|
+
assert.ok(![out.recommended, ...out.alternatives].some((c) => c.tier === 'changed'));
|
|
171
|
+
assert.deepEqual(order(out).slice(-3), ['landing:projects', 'builders:goals', 'status:board']);
|
|
172
|
+
assert.equal(out.alternatives.find((c) => c.page_id === 'builders:goals').why, 'text tweaked 1x');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test('nothing left to take: recommended is null, never a held page', () => {
|
|
176
|
+
const inventory = { ...INVENTORY, pages: INVENTORY.pages.filter((p) => ['builders:studio', 'landing:about'].includes(p.id)) };
|
|
177
|
+
const out = next(F.ALICE.id, { inventory });
|
|
178
|
+
assert.equal(out.recommended, null);
|
|
179
|
+
assert.deepEqual(out.alternatives, []);
|
|
180
|
+
assert.equal(out.skipped.length, 2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
console.log(`\ncopy_page_next: ${passed} passed, ${failed} failed`);
|
|
184
|
+
if (failed) process.exit(1);
|
|
@@ -26,6 +26,7 @@ const activity = require('./activity');
|
|
|
26
26
|
const mingle = require('./mingle');
|
|
27
27
|
const pageTweakReads = require('./page-tweak-reads');
|
|
28
28
|
const pageTweakClaim = require('./page-tweak-claim');
|
|
29
|
+
const pageTweakWrite = require('./page-tweak-write');
|
|
29
30
|
|
|
30
31
|
module.exports = {
|
|
31
32
|
// --- retired doorway leaks (ADR 0093 §3) ---------------------------------
|
|
@@ -118,6 +119,13 @@ module.exports = {
|
|
|
118
119
|
// it is page-shaped because the find-or-create has to share the claim's
|
|
119
120
|
// transaction. See modules/lifecycle/page-tweak-claim.js.
|
|
120
121
|
webClaimPageTweak: (...a) => pageTweakClaim.webClaimPageTweak(...a),
|
|
122
|
+
// The page draft's autosave and the submit (task 1004319 / BV2.TW08, ADR 0341
|
|
123
|
+
// D4, D11): a holder-guarded description write that skips an unchanged save,
|
|
124
|
+
// and the submit's one transaction (engineer tasks for unplaced lines, the
|
|
125
|
+
// frozen batch, the web claim released, the round back to ready). D11 names
|
|
126
|
+
// updateTaskDescription and releaseClaim; see modules/lifecycle/page-tweak-write.js.
|
|
127
|
+
updateHeldTaskDescription: (...a) => pageTweakWrite.updateHeldTaskDescription(...a),
|
|
128
|
+
submitPageTweak: (...a) => pageTweakWrite.submitPageTweak(...a),
|
|
121
129
|
listVersions: (...a) => db.listVersions(...a),
|
|
122
130
|
versionProgress: (...a) => db.versionProgress(...a),
|
|
123
131
|
recentShippedTasks: (...a) => db.recentShippedTasks(...a),
|
|
@@ -37,10 +37,17 @@ const api = require('../../src/module-api');
|
|
|
37
37
|
// per task), joined once, rather than a correlated subquery per task:
|
|
38
38
|
// claims has no full index on task_id, so a per-row LATERAL would scan it
|
|
39
39
|
// once per round.
|
|
40
|
-
|
|
40
|
+
//
|
|
41
|
+
// `pageId` narrows it to ONE page's rounds (task 1004319 / TW08): the draft
|
|
42
|
+
// autosave runs this on every debounced save, and a page's rounds are a handful
|
|
43
|
+
// while the instance's grow without bound. The prefix is matched with left(),
|
|
44
|
+
// not LIKE, the page claim's own rule (an `_` in an id is never a wildcard).
|
|
45
|
+
async function listPageTweakTasks({ pageId = null } = {}) {
|
|
46
|
+
const prefix = pageId ? `page-tweak/${pageId}/` : null;
|
|
41
47
|
const { rows } = await api.pool.query(
|
|
42
48
|
`WITH pt AS (
|
|
43
49
|
SELECT t.id FROM tasks t WHERE t.source = 'page-tweak'
|
|
50
|
+
AND ($1::text IS NULL OR left(t.source_ref, length($1::text)) = $1::text)
|
|
44
51
|
), web AS (
|
|
45
52
|
SELECT DISTINCT ON (c.task_id) c.task_id, c.builder_id
|
|
46
53
|
FROM claims c JOIN pt ON pt.id = c.task_id
|
|
@@ -60,7 +67,9 @@ async function listPageTweakTasks() {
|
|
|
60
67
|
LEFT JOIN web wc ON wc.task_id = t.id
|
|
61
68
|
LEFT JOIN builders ab ON ab.id = wc.builder_id
|
|
62
69
|
WHERE t.source = 'page-tweak'
|
|
63
|
-
|
|
70
|
+
AND ($1::text IS NULL OR left(t.source_ref, length($1::text)) = $1::text)
|
|
71
|
+
ORDER BY t.id ASC`,
|
|
72
|
+
[prefix]
|
|
64
73
|
);
|
|
65
74
|
return rows;
|
|
66
75
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// modules/lifecycle/page-tweak-write.js — the page draft save and the submit
|
|
2
|
+
// (task 1004319 / BV2.TW08, ADR 0341 D4, D7, D11).
|
|
3
|
+
//
|
|
4
|
+
// On the `lifecycle` port because tasks and claims are this module's tables and
|
|
5
|
+
// copy-desk may only cross the wall through a port (ADR 0083, ADR 0093 §2). What
|
|
6
|
+
// the draft and the batch SAY (the fenced blocks, the resolution against the page
|
|
7
|
+
// reading, the engineer task an unplaced line files) is copy-desk's and is handed
|
|
8
|
+
// in as a function; the transaction and the holder check are lifecycle's.
|
|
9
|
+
//
|
|
10
|
+
// BOTH ARE HOLDER-ONLY. The caller must hold the task's open WEB claim
|
|
11
|
+
// (worktree_name NULL and creator_session_id NULL, the claim the page claim
|
|
12
|
+
// takes). The check runs inside the transaction, after the task row is locked
|
|
13
|
+
// FOR UPDATE, the same lock claimTask and the page claim take, so a save cannot
|
|
14
|
+
// interleave with a claim or a release of the same round.
|
|
15
|
+
//
|
|
16
|
+
// AN AUTOSAVE THAT CHANGES NOTHING WRITES NOTHING. The editor autosaves often
|
|
17
|
+
// (it debounces; the server accepts a whole-draft replace). When the composed
|
|
18
|
+
// description equals the stored one, the transaction ends without an UPDATE, so
|
|
19
|
+
// `updated_at` and every reader that sorts by it stay where they were.
|
|
20
|
+
//
|
|
21
|
+
// ADR 0341 D11 names the port entries updateTaskDescription, webClaimTask and
|
|
22
|
+
// releaseClaim. They landed page-shaped, as TW06's webClaimPageTweak did: the
|
|
23
|
+
// description update is holder-guarded (updateHeldTaskDescription, so no caller
|
|
24
|
+
// can rewrite a task it does not hold through the port), and the release runs
|
|
25
|
+
// INSIDE the submit's transaction (submitPageTweak), because the batch, the
|
|
26
|
+
// engineer tasks and the release must commit together or not at all.
|
|
27
|
+
//
|
|
28
|
+
// Outcomes are returned, not thrown, so the route maps each to a named answer:
|
|
29
|
+
// saved | unchanged | submitted
|
|
30
|
+
// not_found no such task
|
|
31
|
+
// wrong_source the task is not of the source the caller named
|
|
32
|
+
// not_held the caller does not hold its open web claim (holder named)
|
|
33
|
+
// refused copy-desk's function refused (its refusal is passed back)
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const api = require('../../src/module-api');
|
|
38
|
+
const db = require('./db');
|
|
39
|
+
|
|
40
|
+
function activePoolFor(deps) {
|
|
41
|
+
return (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Lock the task, then read its open claim. Returns { task, claim } or an outcome.
|
|
45
|
+
async function lockHeld(client, { taskId, builderId, source }) {
|
|
46
|
+
const { rows } = await client.query(
|
|
47
|
+
`SELECT id, status, title, description, source, source_ref, version_id, goal_id, touches
|
|
48
|
+
FROM tasks WHERE id = $1 FOR UPDATE`,
|
|
49
|
+
[taskId]
|
|
50
|
+
);
|
|
51
|
+
const task = rows[0];
|
|
52
|
+
if (!task) return { outcome: 'not_found' };
|
|
53
|
+
if (source && task.source !== source) return { outcome: 'wrong_source', task };
|
|
54
|
+
const { rows: held } = await client.query(
|
|
55
|
+
`SELECT c.id, c.builder_id, c.worktree_name, c.creator_session_id, b.github_login, b.display_name
|
|
56
|
+
FROM claims c JOIN builders b ON b.id = c.builder_id
|
|
57
|
+
WHERE c.task_id = $1 AND c.released_at IS NULL
|
|
58
|
+
LIMIT 1`,
|
|
59
|
+
[taskId]
|
|
60
|
+
);
|
|
61
|
+
const claim = held[0] || null;
|
|
62
|
+
const web = !!claim && claim.worktree_name == null && claim.creator_session_id == null;
|
|
63
|
+
if (!claim || !web || String(claim.builder_id) !== String(builderId)) {
|
|
64
|
+
return {
|
|
65
|
+
outcome: 'not_held',
|
|
66
|
+
task,
|
|
67
|
+
holder: claim ? { id: String(claim.builder_id), login: claim.github_login || null, name: claim.display_name || null, writing: web } : null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { task, claim };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// updateHeldTaskDescription({ taskId, builderId, source, compose }, deps)
|
|
74
|
+
//
|
|
75
|
+
// compose(task) -> { ok: true, description } | { ok: false, code, detail }
|
|
76
|
+
//
|
|
77
|
+
// The page draft's autosave (PUT /copy-desk/pages/:pageId/draft). Writes the
|
|
78
|
+
// composed description when it differs from the stored one.
|
|
79
|
+
async function updateHeldTaskDescription({ taskId, builderId, source = null, compose }, deps = {}) {
|
|
80
|
+
const client = await activePoolFor(deps).connect();
|
|
81
|
+
try {
|
|
82
|
+
await client.query('BEGIN');
|
|
83
|
+
const held = await lockHeld(client, { taskId, builderId, source });
|
|
84
|
+
if (held.outcome) { await client.query('ROLLBACK'); return held; }
|
|
85
|
+
const r = compose(held.task);
|
|
86
|
+
if (!r || !r.ok) { await client.query('ROLLBACK'); return { outcome: 'refused', refusal: r, task: held.task }; }
|
|
87
|
+
if (r.description === held.task.description) {
|
|
88
|
+
await client.query('ROLLBACK');
|
|
89
|
+
return { outcome: 'unchanged', task: held.task };
|
|
90
|
+
}
|
|
91
|
+
const { rows } = await client.query(
|
|
92
|
+
`UPDATE tasks SET description = $2, updated_at = now() WHERE id = $1
|
|
93
|
+
RETURNING id, status, description, source_ref, updated_at`,
|
|
94
|
+
[taskId, r.description]
|
|
95
|
+
);
|
|
96
|
+
await client.query('COMMIT');
|
|
97
|
+
return { outcome: 'saved', task: rows[0] };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
100
|
+
throw err;
|
|
101
|
+
} finally {
|
|
102
|
+
client.release();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// submitPageTweak({ taskId, builderId, versionId, freeze }, deps)
|
|
107
|
+
//
|
|
108
|
+
// freeze(task) -> { ok: true, unplaced: [{ sourceRef, title, description, touches }],
|
|
109
|
+
// description(unplacedTaskIds) -> string }
|
|
110
|
+
// | { ok: false, code, detail }
|
|
111
|
+
//
|
|
112
|
+
// The submit (POST /copy-desk/pages/:pageId/submit), one transaction:
|
|
113
|
+
// 1. each unplaced rewrite files its engineer task (source page-tweak-unplaced,
|
|
114
|
+
// backlog, engineer), or finds the one its source ref already filed;
|
|
115
|
+
// 2. the description gains the frozen `page-tweak` block naming those tasks;
|
|
116
|
+
// 3. the artist's web claim is RELEASED (outcome 'partial': their part is done
|
|
117
|
+
// and the task goes on) and the task returns to `ready`, so the round reads
|
|
118
|
+
// `submitted` and sits in the /tweak queue (ADR 0341 D7).
|
|
119
|
+
// The released claim is the one the payee rule reads (ADR 0341 D10).
|
|
120
|
+
async function submitPageTweak({ taskId, builderId, versionId = null, freeze }, deps = {}) {
|
|
121
|
+
const createTask = deps.createTask || db.createTask;
|
|
122
|
+
const client = await activePoolFor(deps).connect();
|
|
123
|
+
try {
|
|
124
|
+
await client.query('BEGIN');
|
|
125
|
+
const held = await lockHeld(client, { taskId, builderId, source: 'page-tweak' });
|
|
126
|
+
if (held.outcome) { await client.query('ROLLBACK'); return held; }
|
|
127
|
+
const plan = freeze(held.task);
|
|
128
|
+
if (!plan || !plan.ok) { await client.query('ROLLBACK'); return { outcome: 'refused', refusal: plan, task: held.task }; }
|
|
129
|
+
|
|
130
|
+
// The engineer tasks an earlier submit of this round already filed, in ONE
|
|
131
|
+
// read (a resubmit after a send-back finds them). Only the missing ones go
|
|
132
|
+
// through createTask, the single create chokepoint, one at a time.
|
|
133
|
+
const wanted = plan.unplaced || [];
|
|
134
|
+
const existing = new Map();
|
|
135
|
+
if (wanted.length) {
|
|
136
|
+
const { rows: found } = await client.query(
|
|
137
|
+
'SELECT id, source_ref FROM tasks WHERE source = $1 AND source_ref = ANY($2::text[])',
|
|
138
|
+
[wanted[0].source, wanted.map((u) => u.sourceRef)]
|
|
139
|
+
);
|
|
140
|
+
for (const f of found) if (!existing.has(f.source_ref)) existing.set(f.source_ref, String(f.id));
|
|
141
|
+
}
|
|
142
|
+
const filed = [];
|
|
143
|
+
for (const u of wanted) {
|
|
144
|
+
if (existing.has(u.sourceRef)) { filed.push({ id: existing.get(u.sourceRef), key: u.key, created: false }); continue; }
|
|
145
|
+
const t = await createTask({
|
|
146
|
+
versionId: versionId != null ? versionId : held.task.version_id,
|
|
147
|
+
// The building version's catch-all goal, declared out loud (BV1.R11),
|
|
148
|
+
// exactly as the round itself and a copy proposal land.
|
|
149
|
+
allowCatchAll: true,
|
|
150
|
+
title: u.title,
|
|
151
|
+
description: u.description,
|
|
152
|
+
// BACKLOG (ADR 0341 D4): an engineer's triage decides when, and the
|
|
153
|
+
// page does not wait for it.
|
|
154
|
+
status: 'backlog',
|
|
155
|
+
kind: 'feature',
|
|
156
|
+
discipline: 'engineer',
|
|
157
|
+
priority: 4,
|
|
158
|
+
touches: u.touches || [],
|
|
159
|
+
source: u.source,
|
|
160
|
+
sourceRef: u.sourceRef,
|
|
161
|
+
createdBy: builderId,
|
|
162
|
+
}, { client });
|
|
163
|
+
filed.push({ id: String(t.id), key: u.key, created: true });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const description = plan.description(filed.map((f) => f.id));
|
|
167
|
+
const { rows } = await client.query(
|
|
168
|
+
`UPDATE tasks SET description = $2, status = 'ready', updated_at = now() WHERE id = $1
|
|
169
|
+
RETURNING id, status, description, source_ref, updated_at`,
|
|
170
|
+
[taskId, description]
|
|
171
|
+
);
|
|
172
|
+
await releaseClaim(client, held.claim.id);
|
|
173
|
+
await client.query('COMMIT');
|
|
174
|
+
return { outcome: 'submitted', task: rows[0], unplaced_tasks: filed, released_claim_id: String(held.claim.id) };
|
|
175
|
+
} catch (err) {
|
|
176
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
177
|
+
throw err;
|
|
178
|
+
} finally {
|
|
179
|
+
client.release();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Release one claim inside the caller's transaction. The task's status is the
|
|
184
|
+
// caller's to set (the submit sets `ready` with the same UPDATE as the batch).
|
|
185
|
+
async function releaseClaim(client, claimId, outcome = 'partial') {
|
|
186
|
+
await client.query(
|
|
187
|
+
'UPDATE claims SET released_at = now(), outcome = $2 WHERE id = $1 AND released_at IS NULL',
|
|
188
|
+
[claimId, outcome]
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { updateHeldTaskDescription, submitPageTweak };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1061",
|
|
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.1061",
|
|
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.1061",
|
|
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/release-notes.json
CHANGED
|
@@ -7655,5 +7655,17 @@
|
|
|
7655
7655
|
"id": "1004317",
|
|
7656
7656
|
"text": "An artist can now claim a whole page to rewrite in one step. If the page has no open round, one is created for them. If someone else is already on it, they are told who. Anyone, artist or not, can ask for a page to be worked o"
|
|
7657
7657
|
}
|
|
7658
|
+
],
|
|
7659
|
+
"1.19.1060": [
|
|
7660
|
+
{
|
|
7661
|
+
"id": "1004318",
|
|
7662
|
+
"text": "An artist can now ask \"what should I tweak next?\" and get one page back, with the reason in plain words, such as \"2 people asked for it\". Pages people asked for come first, then pages whose words changed since their last tweak"
|
|
7663
|
+
}
|
|
7664
|
+
],
|
|
7665
|
+
"1.19.1061": [
|
|
7666
|
+
{
|
|
7667
|
+
"id": "1004319",
|
|
7668
|
+
"text": "An artist's page rewrite is now saved as they type and can be submitted. The draft rests on the page's own tweak task, and saving the same words again changes nothing. Submitting freezes the rewrite for a /tweak session to app"
|
|
7669
|
+
}
|
|
7658
7670
|
]
|
|
7659
7671
|
}
|
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.1061'; // 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');
|
|
@@ -285,6 +285,18 @@ await test('resolvePageAsks closes the open asks filed before the ship, stamped
|
|
|
285
285
|
assert.equal(await flagsLib.countPageAskers(SURF), 0);
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
+
// The recommendation's read (task 1004318 / TW07): one grouped row per page
|
|
289
|
+
// with an open ask, distinct askers and the oldest open ask.
|
|
290
|
+
await test('pageAskSummary groups open asks by page: distinct askers, the oldest ask', async () => {
|
|
291
|
+
assert.equal((await flagsLib.pageAskSummary()).some((r) => r.page_id === SURF), false, 'resolved asks are not counted');
|
|
292
|
+
const first = (await pageIns({ by: B })).rows[0];
|
|
293
|
+
await pool.query("UPDATE copy_desk_flags SET flagged_at = now() - interval '2 days' WHERE id = $1", [first.id]);
|
|
294
|
+
await pageIns({ by: A });
|
|
295
|
+
const row = (await flagsLib.pageAskSummary()).find((r) => r.page_id === SURF);
|
|
296
|
+
assert.equal(row.askers, 2);
|
|
297
|
+
assert.equal(new Date(row.oldest_ask_at).getTime(), new Date((await pool.query('SELECT flagged_at FROM copy_desk_flags WHERE id = $1', [first.id])).rows[0].flagged_at).getTime());
|
|
298
|
+
});
|
|
299
|
+
|
|
288
300
|
// --- cleanup --------------------------------------------------------------
|
|
289
301
|
await clean();
|
|
290
302
|
await pool.query('DELETE FROM builders WHERE id = ANY($1)', [[A, B]]).catch(() => {});
|
|
@@ -18,140 +18,25 @@ import fs from 'node:fs';
|
|
|
18
18
|
import os from 'node:os';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { makeRunner } from './helpers.mjs';
|
|
21
|
+
import { installPageLedger } from './fixtures/page-ledger.mjs';
|
|
21
22
|
|
|
22
23
|
process.env.NODE_ENV = 'test';
|
|
23
24
|
const require = createRequire(import.meta.url);
|
|
24
25
|
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
25
26
|
const api = require('../src/module-api.js');
|
|
26
27
|
|
|
27
|
-
// --- the ledger
|
|
28
|
-
const db = {
|
|
29
|
-
builders:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
claims: [],
|
|
36
|
-
flags: [],
|
|
37
|
-
seq: { task: 5000, claim: 9000, flag: 100 },
|
|
38
|
-
building: true,
|
|
39
|
-
};
|
|
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
|
+
});
|
|
40
36
|
const ALICE = '7';
|
|
41
37
|
const BOB = '8';
|
|
42
38
|
const CAROL = '9';
|
|
43
39
|
|
|
44
|
-
// The advisory lock, as a mutex per key: a second locker waits until the first
|
|
45
|
-
// commits or rolls back. That is what makes the race case below a real race.
|
|
46
|
-
const locks = new Map();
|
|
47
|
-
async function lock(key, owner) {
|
|
48
|
-
while (locks.has(key)) await locks.get(key).done;
|
|
49
|
-
let release;
|
|
50
|
-
const done = new Promise((r) => { release = r; });
|
|
51
|
-
locks.set(key, { owner, done, release });
|
|
52
|
-
}
|
|
53
|
-
function unlockAll(owner) {
|
|
54
|
-
for (const [k, v] of [...locks]) if (v.owner === owner) { locks.delete(k); v.release(); }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const flat = (sql) => String(sql).replace(/\s+/g, ' ').trim();
|
|
58
|
-
const seen = [];
|
|
59
|
-
let clientSeq = 0;
|
|
60
|
-
|
|
61
|
-
async function answer(q, p, owner) {
|
|
62
|
-
seen.push(q);
|
|
63
|
-
// Yield on every statement, so two concurrent requests really interleave:
|
|
64
|
-
// without the page lock, both would read "no open round" and both create.
|
|
65
|
-
await new Promise((r) => setImmediate(r));
|
|
66
|
-
if (/^(BEGIN|COMMIT|ROLLBACK)$/.test(q)) {
|
|
67
|
-
if (q !== 'BEGIN') unlockAll(owner);
|
|
68
|
-
return { rows: [] };
|
|
69
|
-
}
|
|
70
|
-
if (/pg_advisory_xact_lock\(hashtext\(\$1\)\)/.test(q)) {
|
|
71
|
-
assert.ok(owner, 'the page lock must be taken on the transaction client');
|
|
72
|
-
await lock(p[0], owner);
|
|
73
|
-
return { rows: [] };
|
|
74
|
-
}
|
|
75
|
-
if (/^SELECT id, status, github_login, display_name FROM builders WHERE id = \$1/.test(q)) {
|
|
76
|
-
const b = db.builders.get(String(p[0]));
|
|
77
|
-
return { rows: b ? [b] : [] };
|
|
78
|
-
}
|
|
79
|
-
if (/^SELECT preferred_disciplines FROM builders WHERE id = \$1/.test(q)) {
|
|
80
|
-
const b = db.builders.get(String(p[0]));
|
|
81
|
-
return { rows: b ? [{ preferred_disciplines: b.preferred_disciplines }] : [] };
|
|
82
|
-
}
|
|
83
|
-
if (/FROM tasks WHERE source = \$1 AND left\(source_ref, length\(\$2\)\) = \$2 ORDER BY id ASC FOR UPDATE/.test(q)) {
|
|
84
|
-
return { rows: db.tasks.filter((t) => t.source === p[0] && String(t.source_ref).startsWith(p[1])).map((t) => ({ ...t })) };
|
|
85
|
-
}
|
|
86
|
-
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)) {
|
|
87
|
-
const c = db.claims.find((x) => String(x.task_id) === String(p[0]) && x.released_at == null);
|
|
88
|
-
if (!c) return { rows: [] };
|
|
89
|
-
const b = db.builders.get(String(c.builder_id));
|
|
90
|
-
return { rows: [{ ...c, github_login: b.github_login, display_name: b.display_name }] };
|
|
91
|
-
}
|
|
92
|
-
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)) {
|
|
93
|
-
if (db.claims.some((x) => String(x.task_id) === String(p[0]) && x.released_at == null)) {
|
|
94
|
-
throw Object.assign(new Error('duplicate key'), { code: '23505', constraint: 'uniq_active_claim_per_task' });
|
|
95
|
-
}
|
|
96
|
-
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 };
|
|
97
|
-
db.claims.push(c);
|
|
98
|
-
return { rows: [{ ...c }] };
|
|
99
|
-
}
|
|
100
|
-
if (/^UPDATE tasks SET status = 'active', updated_at = now\(\) WHERE id = \$1/.test(q)) {
|
|
101
|
-
db.tasks.find((t) => String(t.id) === String(p[0])).status = 'active';
|
|
102
|
-
return { rows: [] };
|
|
103
|
-
}
|
|
104
|
-
// createTask's reads: the catch-all goal of the version, and its scope.
|
|
105
|
-
if (/FROM goals g JOIN versions v ON v\.id = g\.version_id WHERE g\.version_id = \$1 AND g\.title = \$2/.test(q)) {
|
|
106
|
-
return { rows: [{ id: '77', version_status: 'building' }] };
|
|
107
|
-
}
|
|
108
|
-
if (/^SELECT scope_modules FROM goals WHERE id = \$1/.test(q)) return { rows: [{ scope_modules: [] }] };
|
|
109
|
-
if (/^INSERT INTO tasks \(version_id, title, description, status, touches,/.test(q)) {
|
|
110
|
-
assert.ok(owner, 'the round must be created inside the claim transaction');
|
|
111
|
-
const t = {
|
|
112
|
-
id: String(++db.seq.task), version_id: p[0], title: p[1], description: p[2], status: p[3], touches: p[4],
|
|
113
|
-
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],
|
|
114
|
-
};
|
|
115
|
-
db.tasks.push(t);
|
|
116
|
-
return { rows: [{ ...t }] };
|
|
117
|
-
}
|
|
118
|
-
if (/^SELECT id, name, status, scope_doc_path, started_at, shipped_at, done_when FROM versions/.test(q)) {
|
|
119
|
-
return { rows: db.building ? [{ id: 'V9', name: 'V9', status: 'building' }] : [{ id: 'V8', name: 'V8', status: 'shipped' }] };
|
|
120
|
-
}
|
|
121
|
-
// The flag SQL (the page ask).
|
|
122
|
-
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)) {
|
|
123
|
-
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) };
|
|
124
|
-
}
|
|
125
|
-
if (/^INSERT INTO copy_desk_flags/.test(q)) {
|
|
126
|
-
const [scope, surface, stringId, reason, kind, , , , , by, pageId] = p;
|
|
127
|
-
// The CHECKs of copy_desk_002, so the fake refuses what Postgres would.
|
|
128
|
-
assert.equal((scope === 'page'), (pageId != null), 'copy_desk_flags_target_chk');
|
|
129
|
-
if (scope === 'page') assert.equal(surface, pageId, 'copy_desk_flags_page_surface_chk');
|
|
130
|
-
const dup = db.flags.find((x) => x.status === 'open' && String(x.flagged_by) === String(by) && x.surface === surface && (x.string_id || '') === (stringId || ''));
|
|
131
|
-
if (dup) throw Object.assign(new Error('dup'), { code: '23505' });
|
|
132
|
-
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' };
|
|
133
|
-
db.flags.push(row);
|
|
134
|
-
return { rows: [{ ...row }] };
|
|
135
|
-
}
|
|
136
|
-
if (/COUNT\(DISTINCT flagged_by\)::int AS askers FROM copy_desk_flags WHERE scope = 'page' AND page_id = \$1 AND status = 'open'/.test(q)) {
|
|
137
|
-
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 }] };
|
|
138
|
-
}
|
|
139
|
-
if (/^UPDATE copy_desk_flags SET status = 'resolved', resolved_task_id = \$2, resolved_by = \$3/.test(q)) {
|
|
140
|
-
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]));
|
|
141
|
-
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] });
|
|
142
|
-
return { rows: hit.map((x) => ({ id: x.id })) };
|
|
143
|
-
}
|
|
144
|
-
throw new Error(`unexpected SQL: ${q.slice(0, 160)}`);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
api.pool = {
|
|
148
|
-
query: (sql, params = []) => answer(flat(sql), params, null),
|
|
149
|
-
async connect() {
|
|
150
|
-
const owner = ++clientSeq;
|
|
151
|
-
return { query: (sql, params = []) => answer(flat(sql), params, owner), release() { unlockAll(owner); } };
|
|
152
|
-
},
|
|
153
|
-
};
|
|
154
|
-
|
|
155
40
|
api.requireBuilder = (req, res, next) => {
|
|
156
41
|
req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
|
|
157
42
|
next();
|