@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.
- 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 +7 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +159 -2
- package/docs/api-reference.md +5 -2
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/flags.js +97 -9
- package/modules/copy-desk/migrations/copy_desk_002_page_asks.sql +62 -0
- package/modules/copy-desk/page-status.js +177 -3
- package/modules/copy-desk/pages.js +34 -0
- package/modules/copy-desk/routes/copy-desk.js +253 -14
- package/modules/copy-desk/tests/copy_flags.mjs +52 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +47 -11
- package/modules/copy-desk/tests/copy_page_next.mjs +184 -0
- package/modules/lifecycle/lifecycle.js +7 -0
- package/modules/lifecycle/page-tweak-claim.js +184 -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 +56 -0
- package/tests/copy_desk_page_claim.mjs +219 -0
- package/tests/copy_desk_page_next.mjs +172 -0
- package/tests/fixtures/page-ledger.mjs +175 -0
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
// POST /copy-desk/proposals — propose new wording. Creates a TASK carrying
|
|
11
11
|
// the patch; writes no file and stores no copy.
|
|
12
12
|
//
|
|
13
|
+
// Tweak Mode (ADR 0341) adds the page reads (TW05) and two page writes (TW06,
|
|
14
|
+
// task 1004317): POST /copy-desk/pages/:pageId/claim opens or takes a page's
|
|
15
|
+
// tweak round as a TASK with a web claim, and POST /copy-desk/pages/:pageId/asks
|
|
16
|
+
// files a page ask, a flag row whose target is a page id. Neither stores copy.
|
|
17
|
+
//
|
|
13
18
|
// THE RANK SHAPE, AND WHY IT IS ASYMMETRIC:
|
|
14
19
|
//
|
|
15
20
|
// FILING is open to every rank, deliberately. "Sourced by everyone else" is the
|
|
@@ -31,7 +36,7 @@
|
|
|
31
36
|
// produce a TASK carrying a patch, never a write. A flag is a work request; a
|
|
32
37
|
// proposal is a work request with a suggested fix attached. Neither reaches a
|
|
33
38
|
// reader. modules/copy-desk/tests/copy_no_cms.mjs pins that by asserting this
|
|
34
|
-
// router's write surface stays exactly
|
|
39
|
+
// router's write surface stays exactly the listed verbs, that none of them
|
|
35
40
|
// touches the filesystem, and that the one place a proposed wording is allowed
|
|
36
41
|
// to rest is the GDS ledger.
|
|
37
42
|
//
|
|
@@ -66,7 +71,7 @@ const log = api.logger('copy-desk');
|
|
|
66
71
|
// lookups, the scope/string_id coupling — live in flags.normalize*, so the DB
|
|
67
72
|
// constraint, the pure validator and this schema each say one thing once.
|
|
68
73
|
const FLAG_BODY_SCHEMA = Object.freeze({
|
|
69
|
-
scope: { type: 'string', enum: [...flags.
|
|
74
|
+
scope: { type: 'string', enum: [...flags.FLAG_SCOPES] },
|
|
70
75
|
surface: { required: true, type: 'string', maxLength: 128, minLength: 1 },
|
|
71
76
|
string_id: { type: 'string', maxLength: 64 },
|
|
72
77
|
reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
|
|
@@ -90,6 +95,14 @@ const PROPOSAL_BODY_SCHEMA = Object.freeze({
|
|
|
90
95
|
line: { type: 'integer', min: 1 },
|
|
91
96
|
flag_id: { type: 'integer', min: 1 },
|
|
92
97
|
});
|
|
98
|
+
// The page claim (task 1004317 / TW06) takes nothing: who claims is the
|
|
99
|
+
// session and what is claimed is the path. Strict, so a caller cannot smuggle
|
|
100
|
+
// a builder, a version or a round in.
|
|
101
|
+
const CLAIM_BODY_SCHEMA = Object.freeze({});
|
|
102
|
+
// The page ask: a reason and nothing else. The target is the path's page id.
|
|
103
|
+
const ASK_BODY_SCHEMA = Object.freeze({
|
|
104
|
+
reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
|
|
105
|
+
});
|
|
93
106
|
|
|
94
107
|
// Load the registry or answer the named "no registry" state. Returns null when
|
|
95
108
|
// it has already responded, so every handler is `if (!reg) return;`.
|
|
@@ -140,6 +153,51 @@ function readingsState() {
|
|
|
140
153
|
: { data: null, info: { ok: false, code: loaded.code, run: loaded.detail.run } };
|
|
141
154
|
}
|
|
142
155
|
|
|
156
|
+
// Resolve :pageId against the inventory, or answer `unknown_page` (ADR 0341
|
|
157
|
+
// D1). Returns null when it has already responded.
|
|
158
|
+
function pageOrFail(req, res) {
|
|
159
|
+
const pageId = String(req.params.pageId || '');
|
|
160
|
+
if (!pages.PAGE_ID_RE.test(pageId)) {
|
|
161
|
+
res.fail('unknown_page', 404, { page_id: pageId, what_this_means: 'A page id is <surface>:<page>, for example landing:index.' });
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const inv = inventoryOrFail(res);
|
|
165
|
+
if (!inv) return null;
|
|
166
|
+
const page = (inv.data.pages || []).find((p) => p && p.id === pageId);
|
|
167
|
+
if (!page) {
|
|
168
|
+
res.fail('unknown_page', 404, { page_id: pageId, what_this_means: 'The page inventory has no page with that id.' });
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return { page, pageId, inv };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// An open page ask is RESOLVED when the page's next tweak ships (ADR 0341 D6).
|
|
175
|
+
// Runs on the kernel's task.shipped event, after the ship has committed, so it
|
|
176
|
+
// can never fail the ship: every error is logged and swallowed. The event
|
|
177
|
+
// payload carries no source, so the task is read back through the port.
|
|
178
|
+
async function resolveAsksOnShip(shipped, deps = {}) {
|
|
179
|
+
try {
|
|
180
|
+
const lifecycle = deps.lifecycle || (api.resolveOptional && api.resolveOptional('lifecycle'));
|
|
181
|
+
const flagsLib = deps.flags || flags;
|
|
182
|
+
if (!shipped || shipped.id == null || !lifecycle || typeof lifecycle.getTask !== 'function') return [];
|
|
183
|
+
const task = await lifecycle.getTask(shipped.id);
|
|
184
|
+
if (!task || task.source !== pages.SOURCE || task.status !== 'shipped') return [];
|
|
185
|
+
const ref = pages.parseRoundRef(task.source_ref);
|
|
186
|
+
if (!ref) return [];
|
|
187
|
+
const resolvedBy = task.shipped_by != null ? task.shipped_by : shipped.shipped_by;
|
|
188
|
+
// copy_desk_flags_closed_chk: a closed flag names who closed it. With no
|
|
189
|
+
// shipper on record the asks stay open rather than be closed by nobody.
|
|
190
|
+
if (resolvedBy == null) return [];
|
|
191
|
+
return await flagsLib.resolvePageAsks({
|
|
192
|
+
pageId: ref.page_id, taskId: task.id, resolvedBy, shippedAt: task.shipped_at || new Date().toISOString(),
|
|
193
|
+
});
|
|
194
|
+
} catch (err) {
|
|
195
|
+
log.warn({ err, task_id: shipped && shipped.id }, 'resolving page asks on ship failed (non-blocking)');
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
let _askListenerOff = null;
|
|
200
|
+
|
|
143
201
|
// The ledger reads come through the lifecycle port. copy-desk consumes
|
|
144
202
|
// lifecycle, so the port is always there on a booted instance; the check is for
|
|
145
203
|
// a core whose lifecycle predates these reads.
|
|
@@ -173,6 +231,11 @@ async function tallyCreditRows(builderId) {
|
|
|
173
231
|
module.exports = function buildCopyDeskRouter() {
|
|
174
232
|
const router = express.Router();
|
|
175
233
|
|
|
234
|
+
// Page asks resolve when the page's tweak ships (ADR 0341 D6). Re-registered
|
|
235
|
+
// on every router build (tests rebuild it), never stacked.
|
|
236
|
+
if (_askListenerOff) _askListenerOff();
|
|
237
|
+
_askListenerOff = api.on('task.shipped', (task) => resolveAsksOnShip(task));
|
|
238
|
+
|
|
176
239
|
// -------------------------------------------------------------------------
|
|
177
240
|
// GET /copy-desk/queue — the artist's working list.
|
|
178
241
|
//
|
|
@@ -203,8 +266,11 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
203
266
|
// re-surfaces every already-dismissed duplicate as untouched work.
|
|
204
267
|
// composeQueue owns that rule; this just feeds it both sets. When the
|
|
205
268
|
// caller asked for everything the two are the same query, so it runs once.
|
|
206
|
-
|
|
207
|
-
|
|
269
|
+
// Page asks (scope 'page', task 1004317) are the recommendation's signal,
|
|
270
|
+
// not rows in this string queue, so the queue reads the two flag scopes.
|
|
271
|
+
const scopes = flags.FLAG_SCOPES;
|
|
272
|
+
const shown = await flags.listFlags({ status, surface, scopes });
|
|
273
|
+
const allFlags = status === 'all' ? shown : await flags.listFlags({ status: 'all', surface, scopes });
|
|
208
274
|
|
|
209
275
|
const queue = registry.composeQueue({
|
|
210
276
|
registry: loaded.registry,
|
|
@@ -548,16 +614,9 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
548
614
|
// -------------------------------------------------------------------------
|
|
549
615
|
router.get('/copy-desk/pages/:pageId', api.requireBuilder, async (req, res) => {
|
|
550
616
|
try {
|
|
551
|
-
const
|
|
552
|
-
if (!
|
|
553
|
-
|
|
554
|
-
}
|
|
555
|
-
const inv = inventoryOrFail(res);
|
|
556
|
-
if (!inv) return;
|
|
557
|
-
const page = (inv.data.pages || []).find((p) => p && p.id === pageId);
|
|
558
|
-
if (!page) {
|
|
559
|
-
return res.fail('unknown_page', 404, { page_id: pageId, what_this_means: 'The page inventory has no page with that id.' });
|
|
560
|
-
}
|
|
617
|
+
const found = pageOrFail(req, res);
|
|
618
|
+
if (!found) return;
|
|
619
|
+
const { page, pageId } = found;
|
|
561
620
|
const lifecycle = pageReadsOrFail(res);
|
|
562
621
|
if (!lifecycle) return;
|
|
563
622
|
const readings = readingsState();
|
|
@@ -604,5 +663,185 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
604
663
|
}
|
|
605
664
|
});
|
|
606
665
|
|
|
666
|
+
// -------------------------------------------------------------------------
|
|
667
|
+
// GET /copy-desk/next — the Tweak/CopyWrite Flow's recommendation (task
|
|
668
|
+
// 1004318 / BV2.TW07, ADR 0341 D8): the page the caller should tweak next
|
|
669
|
+
// and why ("2 people asked for it"), plus the next few for "pick another".
|
|
670
|
+
//
|
|
671
|
+
// Order: asked for (most distinct askers, then the oldest ask), then changed
|
|
672
|
+
// since the last tweak (most changed lines), then untweaked (landing,
|
|
673
|
+
// builders, status, then inventory order); tweaked pages with no drift last.
|
|
674
|
+
// A page another builder holds, or whose round is in flight, is never
|
|
675
|
+
// recommended (it is listed in `skipped`). The caller's own held page is
|
|
676
|
+
// `resume`, not a recommendation.
|
|
677
|
+
//
|
|
678
|
+
// "Take next" is POST /copy-desk/pages/:pageId/claim on `recommended.page_id`
|
|
679
|
+
// (each candidate carries its `take`), one call. The availability rule here
|
|
680
|
+
// is the claim's own, so that call is not refused for a reason this read knew.
|
|
681
|
+
//
|
|
682
|
+
// rank: any signed-in builder. The read is the caller's own and needs no
|
|
683
|
+
// craft; the claim it points at is where the artist gate lives.
|
|
684
|
+
// -------------------------------------------------------------------------
|
|
685
|
+
router.get('/copy-desk/next', api.requireBuilder, async (req, res) => {
|
|
686
|
+
try {
|
|
687
|
+
const inv = inventoryOrFail(res);
|
|
688
|
+
if (!inv) return;
|
|
689
|
+
const lifecycle = pageReadsOrFail(res);
|
|
690
|
+
if (!lifecycle) return;
|
|
691
|
+
const readings = readingsState();
|
|
692
|
+
const [tasks, asks] = await Promise.all([
|
|
693
|
+
lifecycle.listPageTweakTasks(),
|
|
694
|
+
flags.pageAskSummary(),
|
|
695
|
+
]);
|
|
696
|
+
const out = pageStatus.composeNext({ inventory: inv.data, readings: readings.data, tasks, asks, builderId: req.builder.id });
|
|
697
|
+
out.inventory = { at: inv.at };
|
|
698
|
+
out.readings = readings.info;
|
|
699
|
+
res.set('Cache-Control', 'no-store');
|
|
700
|
+
res.json(out);
|
|
701
|
+
} catch (err) {
|
|
702
|
+
log.error({ err }, 'GET /copy-desk/next failed');
|
|
703
|
+
if (!res.headersSent) res.fail('copy_next_failed', 500);
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// -------------------------------------------------------------------------
|
|
708
|
+
// POST /copy-desk/pages/:pageId/claim — claim a page's tweak (task 1004317 /
|
|
709
|
+
// BV2.TW06, ADR 0341 D2 and D5). Also Resume and "Take next".
|
|
710
|
+
//
|
|
711
|
+
// Body: {} (nothing — who claims is the session, what is claimed is the path).
|
|
712
|
+
// rank: any signed-in builder; the gate is the CRAFT, read server-side
|
|
713
|
+
// (builders.preferred_disciplines includes artist), never the request.
|
|
714
|
+
//
|
|
715
|
+
// One call finds the page's open round or creates it (source_ref
|
|
716
|
+
// page-tweak/<page_id>/r<n>, 30 credits, the building version's catch-all
|
|
717
|
+
// goal) and WEB-claims it: no worktree, no session. The lifecycle port runs it
|
|
718
|
+
// in one transaction under an advisory lock on the page, so two artists cannot
|
|
719
|
+
// both open a round.
|
|
720
|
+
//
|
|
721
|
+
// 201 created a new round, held by the caller
|
|
722
|
+
// 200 claimed an unheld round still being written (a draft released
|
|
723
|
+
// without submitting) taken over; the draft stays
|
|
724
|
+
// 200 resumed the caller already holds it
|
|
725
|
+
// 409 page_held another builder holds it; the holder is named
|
|
726
|
+
// 409 page_in_flight the round is past writing (submitted, applying,
|
|
727
|
+
// waiting for the artist, landing)
|
|
728
|
+
// 403 not_an_artist the caller's crafts do not include artist; they are
|
|
729
|
+
// offered the ask
|
|
730
|
+
// -------------------------------------------------------------------------
|
|
731
|
+
router.post('/copy-desk/pages/:pageId/claim', api.requireBuilder, async (req, res) => {
|
|
732
|
+
try {
|
|
733
|
+
if (validateOrRespond(req, res, CLAIM_BODY_SCHEMA)) return;
|
|
734
|
+
const found = pageOrFail(req, res);
|
|
735
|
+
if (!found) return;
|
|
736
|
+
const { page, pageId } = found;
|
|
737
|
+
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
738
|
+
if (!lifecycle || typeof lifecycle.webClaimPageTweak !== 'function') {
|
|
739
|
+
return res.fail('lifecycle_unavailable', 503, {
|
|
740
|
+
what_this_means: 'A page claim opens a task in the ledger, and this instance cannot. Nothing was recorded.',
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
// Where a NEW round lands: the version being built, as a copy proposal
|
|
744
|
+
// lands (no fallback to another version; see resolveTargetVersion).
|
|
745
|
+
const building = proposals.resolveTargetVersion(await lifecycle.listVersions());
|
|
746
|
+
const r = await lifecycle.webClaimPageTweak({
|
|
747
|
+
pageId,
|
|
748
|
+
builderId: req.builder.id,
|
|
749
|
+
versionId: building ? building.id : null,
|
|
750
|
+
round: {
|
|
751
|
+
title: () => pages.roundTaskTitle(page),
|
|
752
|
+
description: (n) => pages.roundTaskBody(page, n),
|
|
753
|
+
sourceRef: (n) => pages.roundSourceRef(pageId, n),
|
|
754
|
+
touches: Array.isArray(page.files) ? page.files : [],
|
|
755
|
+
creditsReward: pages.ROUND_CREDITS,
|
|
756
|
+
canTakeOver: pages.roundOpenForWriting,
|
|
757
|
+
},
|
|
758
|
+
});
|
|
759
|
+
const round = r.task ? { task_id: String(r.task.id), round: r.round, source_ref: r.task.source_ref, status: r.task.status } : null;
|
|
760
|
+
switch (r.outcome) {
|
|
761
|
+
case 'created':
|
|
762
|
+
case 'claimed':
|
|
763
|
+
case 'resumed':
|
|
764
|
+
return res.status(r.outcome === 'created' ? 201 : 200).json({
|
|
765
|
+
ok: true, outcome: r.outcome, page_id: pageId, round: { ...round, state: 'writing' },
|
|
766
|
+
claim: r.claim ? { id: String(r.claim.id), claimed_at: r.claim.claimed_at } : null,
|
|
767
|
+
});
|
|
768
|
+
case 'held':
|
|
769
|
+
return res.fail('page_held', 409, {
|
|
770
|
+
page_id: pageId, round, holder: r.holder,
|
|
771
|
+
what_this_means: r.holder_is_writing
|
|
772
|
+
? `${r.holder.name || r.holder.login || 'Another artist'} is writing this page now. Ask for it instead, or take another.`
|
|
773
|
+
: `${r.holder.name || r.holder.login || 'Another builder'} is applying this page's submitted words. It comes back when it ships or is sent back.`,
|
|
774
|
+
});
|
|
775
|
+
case 'in_flight':
|
|
776
|
+
return res.fail('page_in_flight', 409, {
|
|
777
|
+
page_id: pageId, round,
|
|
778
|
+
what_this_means: 'This page\'s round has been submitted and is on its way to the site. It can be claimed again once it ships or is sent back.',
|
|
779
|
+
});
|
|
780
|
+
case 'not_an_artist':
|
|
781
|
+
return res.fail('not_an_artist', 403, {
|
|
782
|
+
page_id: pageId,
|
|
783
|
+
what_this_means: 'Only a builder whose crafts include artist can claim a page. Anyone can ask for one.',
|
|
784
|
+
your_options: [`POST /copy-desk/pages/${pageId}/asks with a one-line reason`, 'Add artist to your crafts in Settings'],
|
|
785
|
+
});
|
|
786
|
+
case 'builder_inactive':
|
|
787
|
+
return res.fail('builder_inactive', 403);
|
|
788
|
+
case 'no_open_version':
|
|
789
|
+
return res.fail('no_open_version', 503, {
|
|
790
|
+
what_this_means: 'No version is currently being built, so there is nowhere for a new page round to land. Nothing was recorded.',
|
|
791
|
+
});
|
|
792
|
+
default:
|
|
793
|
+
throw new Error(`webClaimPageTweak: unknown outcome ${r.outcome}`);
|
|
794
|
+
}
|
|
795
|
+
} catch (err) {
|
|
796
|
+
log.error({ err }, 'POST /copy-desk/pages/:pageId/claim failed');
|
|
797
|
+
if (!res.headersSent) res.fail('copy_page_claim_failed', 500);
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// -------------------------------------------------------------------------
|
|
802
|
+
// POST /copy-desk/pages/:pageId/asks — ask for a page (task 1004317 / TW06,
|
|
803
|
+
// ADR 0341 D6).
|
|
804
|
+
//
|
|
805
|
+
// Body: { reason } — one line, the flag's own floor (10 characters).
|
|
806
|
+
// rank: any signed-in builder, any craft. A page ask is a copy_desk_flags row
|
|
807
|
+
// with scope 'page'; it carries a judgement and a page id, never text.
|
|
808
|
+
//
|
|
809
|
+
// 201 a new ask · 200 the caller's open ask on this page already (the first
|
|
810
|
+
// one comes back; a double ask is not a second signal)
|
|
811
|
+
// Both carry `askers`: distinct builders with an open ask on the page.
|
|
812
|
+
// -------------------------------------------------------------------------
|
|
813
|
+
router.post('/copy-desk/pages/:pageId/asks', api.requireBuilder, async (req, res) => {
|
|
814
|
+
try {
|
|
815
|
+
if (validateOrRespond(req, res, ASK_BODY_SCHEMA)) return;
|
|
816
|
+
const found = pageOrFail(req, res);
|
|
817
|
+
if (!found) return;
|
|
818
|
+
const { pageId } = found;
|
|
819
|
+
const norm = flags.normalizePageAsk(req.body, pageId);
|
|
820
|
+
if (!norm.ok) return res.fail(norm.code, 400, norm.detail);
|
|
821
|
+
|
|
822
|
+
let ask = await flags.findOpenPageAsk(pageId, req.builder.id);
|
|
823
|
+
let created = false;
|
|
824
|
+
if (!ask) {
|
|
825
|
+
try {
|
|
826
|
+
ask = await flags.insertFlag(norm.value, req.builder.id);
|
|
827
|
+
created = true;
|
|
828
|
+
} catch (err) {
|
|
829
|
+
// 23505: a concurrent ask by the same builder won the one-open-per-
|
|
830
|
+
// (builder, page) index. Same answer as the read above.
|
|
831
|
+
if (!(err && err.code === '23505')) throw err;
|
|
832
|
+
ask = await flags.findOpenPageAsk(pageId, req.builder.id);
|
|
833
|
+
if (!ask) throw err;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const askers = await flags.countPageAskers(pageId);
|
|
837
|
+
res.status(created ? 201 : 200).json({ ok: true, created, ask, page_id: pageId, askers });
|
|
838
|
+
} catch (err) {
|
|
839
|
+
log.error({ err }, 'POST /copy-desk/pages/:pageId/asks failed');
|
|
840
|
+
if (!res.headersSent) res.fail('copy_page_ask_failed', 500);
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
607
844
|
return router;
|
|
608
845
|
};
|
|
846
|
+
|
|
847
|
+
module.exports.resolveAsksOnShip = resolveAsksOnShip;
|
|
@@ -188,15 +188,65 @@ await test('the JS enums match the migration CHECK constraints exactly', async (
|
|
|
188
188
|
const url = await import('node:url');
|
|
189
189
|
const path = await import('node:path');
|
|
190
190
|
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
191
|
-
|
|
191
|
+
// Every migration, oldest first: a later one may widen a CHECK (TW06's
|
|
192
|
+
// copy_desk_002 widens scope), and the LAST definition is the one in force.
|
|
193
|
+
const dir = path.join(here, '..', 'migrations');
|
|
194
|
+
const sql = fs.readdirSync(dir).filter((x) => x.endsWith('.sql')).sort()
|
|
195
|
+
.map((x) => fs.readFileSync(path.join(dir, x), 'utf8')).join('\n');
|
|
192
196
|
for (const [name, list] of [['kind', flags.KINDS], ['scope', flags.SCOPES], ['status', flags.STATUSES]]) {
|
|
193
|
-
const
|
|
197
|
+
const all = [...sql.matchAll(new RegExp(`CHECK \\(${name} IN \\(([^)]*)\\)\\)`, 'g'))];
|
|
198
|
+
const m = all[all.length - 1];
|
|
194
199
|
assert.ok(m, `migration has no CHECK for ${name}`);
|
|
195
200
|
const inSql = m[1].split(',').map((s) => s.trim().replace(/'/g, ''));
|
|
196
201
|
assert.deepEqual(inSql.sort(), [...list].sort(), `${name} enum drifted between flags.js and the migration`);
|
|
197
202
|
}
|
|
198
203
|
});
|
|
199
204
|
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Page asks (task 1004317 / TW06, ADR 0341 D6).
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
console.log('copy-desk: page asks');
|
|
210
|
+
|
|
211
|
+
await test('the flag route refuses scope page and points at the ask route', () => {
|
|
212
|
+
const r = ok({ scope: 'page', surface: 'landing', reason: GOOD_REASON });
|
|
213
|
+
assert.equal(r.ok, false);
|
|
214
|
+
assert.equal(r.code, 'bad_scope');
|
|
215
|
+
assert.deepEqual([...r.detail.allowed], ['string', 'surface']);
|
|
216
|
+
assert.ok(/asks/.test(r.detail.why));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await test('a page ask carries the flag reason floor', () => {
|
|
220
|
+
const thin = flags.normalizePageAsk({ reason: ' do it ' }, 'landing:index');
|
|
221
|
+
assert.equal(thin.ok, false);
|
|
222
|
+
assert.equal(thin.code, 'reason_too_thin');
|
|
223
|
+
const long = flags.normalizePageAsk({ reason: 'x'.repeat(flags.MAX_REASON_CHARS + 1) }, 'landing:index');
|
|
224
|
+
assert.equal(long.code, 'reason_too_long');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('a page ask is scope page, points at the page twice, and holds no text', () => {
|
|
228
|
+
const r = flags.normalizePageAsk({ reason: GOOD_REASON }, 'builders:studio');
|
|
229
|
+
assert.equal(r.ok, true);
|
|
230
|
+
assert.equal(r.value.scope, 'page');
|
|
231
|
+
assert.equal(r.value.page_id, 'builders:studio');
|
|
232
|
+
// surface = page id: what makes 001's one-open-per-target index mean one
|
|
233
|
+
// open ask per (builder, page), and what copy_desk_flags_page_surface_chk pins.
|
|
234
|
+
assert.equal(r.value.surface, 'builders:studio');
|
|
235
|
+
assert.equal(r.value.string_id, null);
|
|
236
|
+
assert.equal(r.value.text_at_flag, null);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await test('the migration pins the page target both ways and ties surface to the page', async () => {
|
|
240
|
+
const fs = await import('node:fs');
|
|
241
|
+
const url = await import('node:url');
|
|
242
|
+
const path = await import('node:path');
|
|
243
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
244
|
+
const sql = fs.readFileSync(path.join(here, '..', 'migrations', 'copy_desk_002_page_asks.sql'), 'utf8');
|
|
245
|
+
assert.ok(/\(scope = 'page'\) = \(page_id IS NOT NULL\)/.test(sql), 'page_id present exactly for scope page');
|
|
246
|
+
assert.ok(/\(scope = 'string'\) = \(string_id IS NOT NULL\)/.test(sql), 'the string half of the target rule survives the widening');
|
|
247
|
+
assert.ok(/scope <> 'page' OR surface = page_id/.test(sql), 'a page ask keeps surface = page_id');
|
|
248
|
+
});
|
|
249
|
+
|
|
200
250
|
// ---------------------------------------------------------------------------
|
|
201
251
|
// Closing a flag.
|
|
202
252
|
// ---------------------------------------------------------------------------
|
|
@@ -67,7 +67,7 @@ const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'pa
|
|
|
67
67
|
|
|
68
68
|
console.log('copy-desk: NO LIVE CMS');
|
|
69
69
|
|
|
70
|
-
await test('the write surface is exactly the
|
|
70
|
+
await test('the write surface is exactly the verbs the module defines', () => {
|
|
71
71
|
const src = stripComments(read('routes/copy-desk.js'));
|
|
72
72
|
const found = [...src.matchAll(/router\.(post|put|patch|delete)\(\s*'([^']+)'/g)]
|
|
73
73
|
.map((m) => `${m[1].toUpperCase()} ${m[2]}`)
|
|
@@ -75,12 +75,21 @@ await test('the write surface is exactly the three verbs the module defines', ()
|
|
|
75
75
|
assert.deepEqual(found, [
|
|
76
76
|
'POST /copy-desk/flags',
|
|
77
77
|
'POST /copy-desk/flags/:id/close',
|
|
78
|
+
// TW06 (task 1004317, ADR 0341 D5, D6, D11): the page claim opens or takes a
|
|
79
|
+
// TASK and a web claim through the lifecycle port; the page ask writes one
|
|
80
|
+
// flag row whose target is a page id. Neither stores wording.
|
|
81
|
+
'POST /copy-desk/pages/:pageId/asks',
|
|
82
|
+
'POST /copy-desk/pages/:pageId/claim',
|
|
78
83
|
'POST /copy-desk/proposals',
|
|
79
84
|
], 'a new write route on this module must be a deliberate, reviewed change to this list');
|
|
80
85
|
});
|
|
81
86
|
|
|
87
|
+
// Every migration this module ships, oldest first (the list is pinned below).
|
|
88
|
+
const MIGRATION_FILES = ['copy_desk_001_flags.sql', 'copy_desk_002_page_asks.sql'];
|
|
89
|
+
const allMigrationSql = () => MIGRATION_FILES.map((f) => stripComments(read(`migrations/${f}`))).join('\n');
|
|
90
|
+
|
|
82
91
|
await test('no column on copy_desk_flags could hold replacement text', () => {
|
|
83
|
-
const sql =
|
|
92
|
+
const sql = allMigrationSql();
|
|
84
93
|
// The names a "just store the suggested wording" column would plausibly take.
|
|
85
94
|
const forbidden = /\b(new_text|proposed_text|suggested_text|replacement|replacement_text|new_copy|proposed_copy|override_text|value_text)\b/i;
|
|
86
95
|
const hit = forbidden.exec(sql);
|
|
@@ -88,22 +97,26 @@ await test('no column on copy_desk_flags could hold replacement text', () => {
|
|
|
88
97
|
`copy_desk_flags must carry a judgement and a pointer, never replacement copy (found "${hit && hit[0]}")`);
|
|
89
98
|
});
|
|
90
99
|
|
|
91
|
-
await test('
|
|
100
|
+
await test('the module has exactly its two migrations — a proposal and a page round are tasks, not rows here', () => {
|
|
92
101
|
// The load-bearing one. R03's obvious-but-wrong implementation is a
|
|
93
102
|
// `copy_desk_proposals` table, which is the forbidden column with extra steps:
|
|
94
103
|
// once the wording lives in this module's schema, one plausible read of it
|
|
95
104
|
// turns the module into the copy source. The proposal lives on the TASK.
|
|
96
105
|
const migrations = fs.readdirSync(path.join(MODULE_DIR, 'migrations')).sort();
|
|
97
|
-
|
|
98
|
-
|
|
106
|
+
// TW06 (task 1004317) added the second, deliberately: copy_desk_002_page_asks
|
|
107
|
+
// widens the flag to a page scope and adds one POINTER column, page_id. It
|
|
108
|
+
// stores no wording; a page round, like a proposal, is a task.
|
|
109
|
+
assert.deepEqual(migrations, MIGRATION_FILES,
|
|
110
|
+
'another migration in this module needs a hard look at whether it stores copy');
|
|
99
111
|
});
|
|
100
112
|
|
|
101
113
|
await test('the only text column is the historical snapshot, and it is never the live source', () => {
|
|
102
|
-
const sql =
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
const sql = allMigrationSql();
|
|
115
|
+
const created = [...sql.matchAll(/^\s{2}(\w+)\s+text\b/gm)].map((m) => m[1]);
|
|
116
|
+
const added = [...sql.matchAll(/ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s+text\b/gi)].map((m) => m[1]);
|
|
117
|
+
assert.deepEqual([...created, ...added].sort(), [
|
|
118
|
+
'file_at_flag', 'kind', 'page_id', 'reason', 'resolution_note', 'scope', 'status', 'string_id', 'surface', 'text_at_flag',
|
|
119
|
+
], 'a new text column here needs a deliberate look at whether it is copy (page_id, TW06, is a page pointer)');
|
|
107
120
|
|
|
108
121
|
// And the live read path is the registry, not the row: annotateFlag fills
|
|
109
122
|
// `current` from the registry index every time.
|
|
@@ -140,7 +153,11 @@ await test('the proposal domain holds no path to a renderer — it composes text
|
|
|
140
153
|
|
|
141
154
|
await test('the proposal route creates a TASK and does not write a string anywhere', () => {
|
|
142
155
|
const src = stripComments(read('routes/copy-desk.js'));
|
|
143
|
-
|
|
156
|
+
// Bounded to THIS handler: the slice ends where the next route begins, so a
|
|
157
|
+
// later route (the page ask does call insertFlag) is not read as this one.
|
|
158
|
+
const start = src.indexOf("router.post('/copy-desk/proposals'");
|
|
159
|
+
const next = src.indexOf('router.', start + 1);
|
|
160
|
+
const route = src.slice(start, next === -1 ? undefined : next);
|
|
144
161
|
assert.ok(/lifecycle\.createTask\(/.test(route),
|
|
145
162
|
'the proposal path must land in the ledger via lifecycle.createTask');
|
|
146
163
|
// No INSERT/UPDATE of this module's own tables on the proposal path — the
|
|
@@ -149,6 +166,25 @@ await test('the proposal route creates a TASK and does not write a string anywhe
|
|
|
149
166
|
'the proposal path must not write a row in this module — the task IS the record');
|
|
150
167
|
});
|
|
151
168
|
|
|
169
|
+
await test('the page claim creates a TASK through the port and writes no row here; the ask writes a pointer', () => {
|
|
170
|
+
const src = stripComments(read('routes/copy-desk.js'));
|
|
171
|
+
const handler = (path) => {
|
|
172
|
+
const start = src.indexOf(`router.post('${path}'`);
|
|
173
|
+
assert.ok(start >= 0, `${path} is mounted`);
|
|
174
|
+
const next = src.indexOf('router.', start + 1);
|
|
175
|
+
return src.slice(start, next === -1 ? undefined : next);
|
|
176
|
+
};
|
|
177
|
+
const claim = handler('/copy-desk/pages/:pageId/claim');
|
|
178
|
+
assert.ok(/lifecycle\.webClaimPageTweak\(/.test(claim), 'the page claim must land in the ledger via the lifecycle port');
|
|
179
|
+
assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE |description:\s*req\.body/i.test(claim), false,
|
|
180
|
+
'the page claim must write no row in this module and take no wording from the request');
|
|
181
|
+
const ask = handler('/copy-desk/pages/:pageId/asks');
|
|
182
|
+
assert.ok(/normalizePageAsk\(/.test(ask) && /insertFlag\(/.test(ask), 'the ask files through the validated flag write');
|
|
183
|
+
// The round's description is composed by pages.js from the INVENTORY page,
|
|
184
|
+
// never from anything a caller sent.
|
|
185
|
+
assert.ok(/roundTaskBody\(page, n\)/.test(claim), 'the round body comes from pages.roundTaskBody over the inventory page');
|
|
186
|
+
});
|
|
187
|
+
|
|
152
188
|
await test('the module manifest declares no seam another module could write copy through', () => {
|
|
153
189
|
const manifest = JSON.parse(read('module.json'));
|
|
154
190
|
assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');
|