@bongos/core 1.19.249 → 1.19.250
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 +52 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +14 -0
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0178-the-copy-desk-flag-and-queue.md +131 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +257 -3
- package/docs/api-reference.md +10 -1
- package/docs/file-map.md +1 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +10 -0
- package/modules/hall-ui/public/copy-desk.css +119 -0
- package/modules/hall-ui/public/copy-desk.html +139 -0
- package/modules/hall-ui/public/copy-desk.js +352 -0
- package/modules/hall-ui/public/shell.js +9 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +11 -1
- package/src/bongos/module-scope-map.js +17 -0
- package/src/bongos/serve-internal.js +11 -1
- package/src/module-api.js +3 -1
- package/tests/copy_desk_flags_db.mjs +250 -0
- package/tests/hall_nav.mjs +7 -1
- package/tests/module_loader.mjs +5 -2
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// tests/copy_desk_flags_db.mjs — the copy_desk_flags schema + SQL against a REAL
|
|
2
|
+
// Postgres (task 1003113 / R02 of goal 1000074).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS SEPARATELY FROM modules/copy-desk/tests/. Those three files are
|
|
5
|
+
// DB-free by contract and cover the PURE layer — the validators, the queue
|
|
6
|
+
// composition, the no-CMS shape. They cannot cover the half of this feature that
|
|
7
|
+
// lives in the schema, and that half is where the load-bearing rules are:
|
|
8
|
+
//
|
|
9
|
+
// * the REASON FLOOR is a CHECK constraint, not just a route check. The whole
|
|
10
|
+
// argument for putting it there is that no future write path can get around
|
|
11
|
+
// it — which is a claim about Postgres, and can only be tested against
|
|
12
|
+
// Postgres.
|
|
13
|
+
// * the ONE-OPEN-FLAG-PER-(builder, target) rule is a PARTIAL unique index. Its
|
|
14
|
+
// partiality is the design (two builders flagging one sentence is signal; the
|
|
15
|
+
// same builder twice is a double-click; and a closed flag must not block a
|
|
16
|
+
// re-flag when the copy regresses). A fake client cannot exercise that.
|
|
17
|
+
// * the CLOSE IS GUARDED on `status='open'` inside the UPDATE so two people
|
|
18
|
+
// closing at once cannot both win. That is a race, and a race needs a real
|
|
19
|
+
// transaction engine to demonstrate.
|
|
20
|
+
// * the FK on resolved_task_id is what turns a typo'd task number into a named
|
|
21
|
+
// 400 instead of an opaque 500.
|
|
22
|
+
//
|
|
23
|
+
// It SELF-SKIPS (exit 0) when no Postgres is reachable, so it is safe on a bare
|
|
24
|
+
// worktree, and it is listed in run-unit-tests.js's INTEGRATION set so the
|
|
25
|
+
// default DB-free lane never runs it.
|
|
26
|
+
//
|
|
27
|
+
// Run: DATABASE_URL=postgres://... node tests/copy_desk_flags_db.mjs
|
|
28
|
+
//
|
|
29
|
+
// It creates its OWN minimal `builders`/`tasks` stand-ins if they are absent, so
|
|
30
|
+
// it works against a throwaway container as well as a full migrated database, and
|
|
31
|
+
// it cleans up every row it writes.
|
|
32
|
+
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
import { strict as assert } from 'node:assert';
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import url from 'node:url';
|
|
38
|
+
|
|
39
|
+
const require = createRequire(import.meta.url);
|
|
40
|
+
const { Pool } = require('pg');
|
|
41
|
+
|
|
42
|
+
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
|
|
43
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await pool.query('SELECT 1');
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.log(`copy_desk_flags_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
|
|
49
|
+
await pool.end().catch(() => {});
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let passed = 0;
|
|
54
|
+
let failed = 0;
|
|
55
|
+
async function test(name, fn) {
|
|
56
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
57
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- fixtures -------------------------------------------------------------
|
|
61
|
+
// Minimal stand-ins ONLY if the real tables are absent (throwaway container).
|
|
62
|
+
// Against a fully migrated DB these are no-ops and the real tables are used.
|
|
63
|
+
await pool.query(`
|
|
64
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
|
|
65
|
+
CREATE TABLE IF NOT EXISTS builders (id bigserial PRIMARY KEY, github_login text, display_name text);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS tasks (id bigserial PRIMARY KEY);
|
|
67
|
+
`);
|
|
68
|
+
|
|
69
|
+
// Two throwaway builders + one task, at ids far above any real row.
|
|
70
|
+
const A = 900000001;
|
|
71
|
+
const B = 900000002;
|
|
72
|
+
const T = 900000003;
|
|
73
|
+
await pool.query('INSERT INTO builders (id, github_login, display_name) VALUES ($1,$2,$3),($4,$5,$6) ON CONFLICT (id) DO NOTHING',
|
|
74
|
+
[A, 'cdtest-a', 'Copy Desk Test A', B, 'cdtest-b', 'Copy Desk Test B']);
|
|
75
|
+
await pool.query('INSERT INTO tasks (id) VALUES ($1) ON CONFLICT (id) DO NOTHING', [T]);
|
|
76
|
+
|
|
77
|
+
// Apply the migration verbatim — this also proves it is idempotent, since a
|
|
78
|
+
// migrated DB has already run it.
|
|
79
|
+
const MIGRATION = fs.readFileSync(path.join(ROOT, 'modules/copy-desk/migrations/copy_desk_001_flags.sql'), 'utf8');
|
|
80
|
+
await pool.query(MIGRATION);
|
|
81
|
+
|
|
82
|
+
// Every row this test writes carries a surface nothing else uses, so cleanup is
|
|
83
|
+
// exact and it can never delete a real flag.
|
|
84
|
+
const SURF = '__copy_desk_test__';
|
|
85
|
+
const clean = () => pool.query('DELETE FROM copy_desk_flags WHERE surface = $1', [SURF]);
|
|
86
|
+
await clean();
|
|
87
|
+
|
|
88
|
+
const REASON = 'Reads like a stack trace, not a sentence a person would say.';
|
|
89
|
+
// `string_id` uses an `in` check, not `??` — an explicit null is the POINT of
|
|
90
|
+
// several cases below, and `??` would silently substitute the default.
|
|
91
|
+
function ins(o = {}) {
|
|
92
|
+
return pool.query(
|
|
93
|
+
`INSERT INTO copy_desk_flags
|
|
94
|
+
(scope, surface, string_id, reason, kind, text_at_flag, file_at_flag, line_at_flag, registry_schema, flagged_by)
|
|
95
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
|
|
96
|
+
[o.scope ?? 'string', SURF, ('string_id' in o ? o.string_id : 'abc123'), o.reason ?? REASON,
|
|
97
|
+
o.kind ?? 'other', 'Could not save.', 'a.js', 1, 1, o.by ?? A]
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log('copy_desk_flags — the constraints hold in Postgres, not just in JS');
|
|
102
|
+
|
|
103
|
+
await test('the migration is idempotent (re-running is clean)', async () => {
|
|
104
|
+
await pool.query(MIGRATION);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await test('a well-formed flag inserts and defaults to open + unstamped', async () => {
|
|
108
|
+
const { rows } = await ins();
|
|
109
|
+
assert.equal(rows[0].status, 'open');
|
|
110
|
+
assert.equal(rows[0].resolved_at, null);
|
|
111
|
+
assert.equal(rows[0].resolved_by, null);
|
|
112
|
+
assert.ok(rows[0].flagged_at);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('THE REASON FLOOR fires in the DB, not only in the route', async () => {
|
|
116
|
+
await assert.rejects(() => ins({ reason: 'bad', string_id: 'r1' }), (e) => e.code === '23514');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await test('a whitespace-only reason is refused (btrim in the CHECK)', async () => {
|
|
120
|
+
await assert.rejects(() => ins({ reason: ' ', string_id: 'r2' }), (e) => e.code === '23514');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await test("scope 'string' with a NULL string_id is refused — it points nowhere", async () => {
|
|
124
|
+
await assert.rejects(() => ins({ scope: 'string', string_id: null }), (e) => e.code === '23514');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await test("scope 'surface' WITH a string_id is refused — it would be silently narrower than it claims", async () => {
|
|
128
|
+
await assert.rejects(() => ins({ scope: 'surface', string_id: 'x1' }), (e) => e.code === '23514');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await test("scope 'surface' with a NULL string_id inserts", async () => {
|
|
132
|
+
const { rows } = await ins({ scope: 'surface', string_id: null, by: B });
|
|
133
|
+
assert.equal(rows[0].scope, 'surface');
|
|
134
|
+
assert.equal(rows[0].string_id, null);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('an unknown kind is refused', async () => {
|
|
138
|
+
await assert.rejects(() => ins({ kind: 'vibes', string_id: 'k1' }), (e) => e.code === '23514');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('THE PARTIAL UNIQUE INDEX: the same builder on the same target twice → 23505', async () => {
|
|
142
|
+
await ins({ string_id: 'dup-target', by: A });
|
|
143
|
+
await assert.rejects(() => ins({ string_id: 'dup-target', by: A }), (e) => e.code === '23505');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await test('a DIFFERENT builder may flag the same target — that agreement is signal', async () => {
|
|
147
|
+
const { rows } = await ins({ string_id: 'dup-target', by: B });
|
|
148
|
+
assert.ok(rows[0].id);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('the guarded close stamps exactly one row', async () => {
|
|
152
|
+
const { rows: [f] } = await ins({ string_id: 'close-me', by: A });
|
|
153
|
+
const upd = await pool.query(
|
|
154
|
+
`UPDATE copy_desk_flags SET status=$2, resolution_note=$3, resolved_task_id=$4, resolved_by=$5, resolved_at=now()
|
|
155
|
+
WHERE id=$1 AND status='open' RETURNING *`,
|
|
156
|
+
[f.id, 'resolved', 'Rewritten in the ship below.', T, B]);
|
|
157
|
+
assert.equal(upd.rowCount, 1);
|
|
158
|
+
assert.equal(upd.rows[0].status, 'resolved');
|
|
159
|
+
assert.equal(String(upd.rows[0].resolved_task_id), String(T));
|
|
160
|
+
assert.ok(upd.rows[0].resolved_at);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await test('A SECOND CLOSE LOSES THE RACE — 0 rows, never an overwrite of the first verdict', async () => {
|
|
164
|
+
const { rows: [f] } = await ins({ string_id: 'race', by: A });
|
|
165
|
+
const first = await pool.query(
|
|
166
|
+
`UPDATE copy_desk_flags SET status='resolved', resolution_note='first verdict stands', resolved_by=$2, resolved_at=now()
|
|
167
|
+
WHERE id=$1 AND status='open' RETURNING *`, [f.id, A]);
|
|
168
|
+
const second = await pool.query(
|
|
169
|
+
`UPDATE copy_desk_flags SET status='dismissed', resolution_note='second must not win', resolved_by=$2, resolved_at=now()
|
|
170
|
+
WHERE id=$1 AND status='open' RETURNING *`, [f.id, B]);
|
|
171
|
+
assert.equal(first.rowCount, 1);
|
|
172
|
+
assert.equal(second.rowCount, 0);
|
|
173
|
+
const { rows } = await pool.query('SELECT resolution_note FROM copy_desk_flags WHERE id=$1', [f.id]);
|
|
174
|
+
assert.equal(rows[0].resolution_note, 'first verdict stands');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await test('after a close, the SAME builder may flag that target again (the index is partial)', async () => {
|
|
178
|
+
const { rows } = await ins({ string_id: 'close-me', by: A });
|
|
179
|
+
assert.ok(rows[0].id, 'a sentence that regresses must be re-flaggable by the person who caught it before');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await test('a closed row with no resolved_at is refused — "resolved by nobody at no time" is impossible', async () => {
|
|
183
|
+
const { rows: [f] } = await ins({ string_id: 'halfclosed', by: A });
|
|
184
|
+
await assert.rejects(
|
|
185
|
+
() => pool.query("UPDATE copy_desk_flags SET status='resolved' WHERE id=$1", [f.id]),
|
|
186
|
+
(e) => e.code === '23514');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await test('THE FK: a nonexistent resolved_task_id raises 23503 — the route\'s named 400', async () => {
|
|
190
|
+
const { rows: [f] } = await ins({ string_id: 'fk-test', by: A });
|
|
191
|
+
await assert.rejects(
|
|
192
|
+
() => pool.query(
|
|
193
|
+
`UPDATE copy_desk_flags SET status='resolved', resolution_note='names a task that does not exist',
|
|
194
|
+
resolved_by=$2, resolved_at=now(), resolved_task_id=999999999 WHERE id=$1`, [f.id, A]),
|
|
195
|
+
(e) => e.code === '23503');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
console.log("\nthe module's own SQL functions (flags.js), not hand-written queries");
|
|
199
|
+
|
|
200
|
+
// Point the doorway's shared pool at THIS connection so flags.js runs its real
|
|
201
|
+
// queries against the test database.
|
|
202
|
+
const api = require(path.join(ROOT, 'src/module-api.js'));
|
|
203
|
+
api.pool.query = (...a) => pool.query(...a);
|
|
204
|
+
const flagsLib = require(path.join(ROOT, 'modules/copy-desk/flags.js'));
|
|
205
|
+
|
|
206
|
+
await test('insertFlag writes and projects the full row shape', async () => {
|
|
207
|
+
const row = await flagsLib.insertFlag({
|
|
208
|
+
scope: 'string', surface: SURF, string_id: 'lib-1', reason: 'Says nothing a reader can act on.',
|
|
209
|
+
kind: 'unclear', text_at_flag: 'Enter.', file_at_flag: 'i.html', line_at_flag: 4, registry_schema: 1,
|
|
210
|
+
}, A);
|
|
211
|
+
assert.equal(row.status, 'open');
|
|
212
|
+
assert.equal(row.kind, 'unclear');
|
|
213
|
+
assert.equal(row.string_id, 'lib-1');
|
|
214
|
+
assert.ok('resolution_note' in row, 'the RETURNING projection matches the read projection');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await test('listFlags resolves the flagger join and sorts open ahead of closed', async () => {
|
|
218
|
+
const rows = await flagsLib.listFlags({ status: 'all', surface: SURF });
|
|
219
|
+
assert.ok(rows.length > 0);
|
|
220
|
+
assert.ok(rows.some((r) => r.flagged_by_login === 'cdtest-a'), 'the builders join resolves a display name');
|
|
221
|
+
const firstClosed = rows.findIndex((r) => r.status !== 'open');
|
|
222
|
+
if (firstClosed !== -1) {
|
|
223
|
+
assert.ok(rows.slice(firstClosed).every((r) => r.status !== 'open'), 'open rows sort ahead of closed ones');
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('listFlags honours the status + surface filters', async () => {
|
|
228
|
+
const open = await flagsLib.listFlags({ status: 'open', surface: SURF });
|
|
229
|
+
assert.ok(open.length > 0);
|
|
230
|
+
assert.ok(open.every((r) => r.status === 'open' && r.surface === SURF));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await test('closeFlag returns null on a second call — the route answers 409 from that', async () => {
|
|
234
|
+
const row = await flagsLib.insertFlag({
|
|
235
|
+
scope: 'string', surface: SURF, string_id: 'lib-close', reason: 'Off voice for the front door.',
|
|
236
|
+
kind: 'off-voice', text_at_flag: 'x', file_at_flag: 'i.html', line_at_flag: 9, registry_schema: 1,
|
|
237
|
+
}, B);
|
|
238
|
+
const first = await flagsLib.closeFlag(row.id, { status: 'dismissed', resolution_note: 'Deliberate house style.', resolved_task_id: null }, A);
|
|
239
|
+
assert.ok(first, 'the first close returns the row');
|
|
240
|
+
const second = await flagsLib.closeFlag(row.id, { status: 'resolved', resolution_note: 'must not win', resolved_task_id: null }, B);
|
|
241
|
+
assert.equal(second, null);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// --- cleanup --------------------------------------------------------------
|
|
245
|
+
await clean();
|
|
246
|
+
await pool.query('DELETE FROM builders WHERE id = ANY($1)', [[A, B]]).catch(() => {});
|
|
247
|
+
|
|
248
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
249
|
+
await pool.end();
|
|
250
|
+
process.exit(failed ? 1 : 0);
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -131,7 +131,13 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
131
131
|
// naming the top rank was wrong), then → 'Government' by ADR 0174: a header
|
|
132
132
|
// reading "Archon" over items a Metic can open would misdescribe the ladder.
|
|
133
133
|
assert.deepEqual(nav.map((g) => g.label), ['', 'You', 'Community', 'Docs', 'Government']);
|
|
134
|
-
|
|
134
|
+
// task 1003113 (R02 of goal 1000074): 'copy-desk' joins the unlabeled group,
|
|
135
|
+
// after Tasks. It belongs with the project's own work and not in Docs — it is a
|
|
136
|
+
// QUEUE somebody acts on, not reference material — and not in You or Community,
|
|
137
|
+
// whose subject is a person rather than the product. Deliberate addition to
|
|
138
|
+
// this oracle: the group is pinned exactly so a new tab in the hall's most
|
|
139
|
+
// valuable nav real estate is a reviewed decision, not a drift.
|
|
140
|
+
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'copy-desk'],
|
|
135
141
|
'the unlabeled group is the project itself (task 1002425 order)');
|
|
136
142
|
assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
|
|
137
143
|
// People (task 1002577): the /scouting directory graduated into the hall.
|
package/tests/module_loader.mjs
CHANGED
|
@@ -173,7 +173,10 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
|
|
|
173
173
|
// configurable roles/permissions module; and agents (ADR 0083 / goal 1000038) the
|
|
174
174
|
// declarative agent registry — the first module to ship default:false, which is
|
|
175
175
|
// why it is DISCOVERED here (discovery is enablement-independent) while staying
|
|
176
|
-
// off in enabledModules().
|
|
176
|
+
// off in enabledModules(). And copy-desk (task 1003113 / R02 of goal 1000074) —
|
|
177
|
+
// the copy half of the design contract: the flag + the artist's queue over
|
|
178
|
+
// user-facing text, default-on because every instance shows a person words.
|
|
179
|
+
// All are discovered; none has validation
|
|
177
180
|
// errors or coreVersion mismatches. Order is the readdir sort.
|
|
178
181
|
//
|
|
179
182
|
// NB this is the CORE package tree: game / art-pipeline / character-anim / demo are
|
|
@@ -197,7 +200,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
|
|
|
197
200
|
// That is precisely how the agents regression reached an open PR (task 1002485).
|
|
198
201
|
loader._reset();
|
|
199
202
|
const snap = loader.loaderSnapshot();
|
|
200
|
-
assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'platform-identity', 'provisioning', 'security', 'sessions', 'status-ui']);
|
|
203
|
+
assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'platform-identity', 'provisioning', 'security', 'sessions', 'status-ui']);
|
|
201
204
|
assert.deepEqual(snap.errors, []);
|
|
202
205
|
assert.deepEqual(snap.skipped, []);
|
|
203
206
|
loader._reset();
|