// ── Core's own event actions ─────────────────────────────────────────────── // // EVENTS.md §F, and Phase 1 of EVENTS_PLAN.md. The twin of config/coreTriggers.js // and registered through the same staging area a module will use in Phase 7 — // which is the entire reason these three exist this early. A registry whose first // real registrant is a module is a registry that has already drifted, and §F's // claim that core is "an event engine that can announce, wait, cue a human and // publish results" with NO module installed is only true if core declares the // verbs that do it. // // **Three actions, and between them they cover the three things an event can do // that name no game noun at all**: tell people something, let time pass, and ask // a human to go and do something. A deployment with no game module installed has // a working event system made of exactly these. // // **Phase 8 added a fourth, and it is the odd one out on purpose.** `core.lease` // names no game noun either — it borrows a value some module declared — but // unlike the other three it genuinely changes the world, so it is `risk: 'change'` // and therefore default-off, admin-only and cap-checked like any module verb. // It is CORE's rather than each module's because §F puts the duration bound and // the two-events-one-target conflict check on core's side of the seam: a lease // verb per module would be that bound re-implemented once per module, advisory // everywhere, and wrong in the first one that forgot it. // // **Phase 10 added the last two, and they are the integrations** (EVENTS.md // §J). `core.announce.post` sends an ARTICLE rather than a line — it links a // post an editor already wrote and queues it through `announce_jobs`, so the // town crier and Discord arrive as already-registered legs with their retry and // their classification rather than as a second delivery pipeline. And // `core.results.publish` is what makes §F's "publish results" literal: it ranks // the run's participants and stamps the table published. Both name a game noun // nowhere, which is why they are core's; six actions is now the whole of what an // event can do on a deployment with no game module installed at all. // // **Phase 2 gave all three real bodies**, and between them they exercise every // shape §F's envelope can take: `core.announce` does work and finishes, // `core.wait` finishes while deferring what follows it, and `core.cue` succeeds // without finishing at all. The runner learns nothing about any of them by id — // each says what it needs in the envelope, through the same two members Phase 7 // hands to a module. // // **This file must not touch the database.** It is required from `registerCore()`, // which runs under `routeManifest.js` and `swagger.js` against a dead pool // (MODULE_API.md §2.2). Nothing below runs at require time; the announce leg is // looked up inside `perform()`, per call, which is also what makes a leg // registered by a module that booted later reachable at all. `core.lease` is the // one action here that reaches a table, and it requires the model INSIDE // `perform()` for the same reason — a top-level require would make this file // build a pool during route-manifest generation. const registries = require('../modules/registries') // `event_run_resources.ref` is VARCHAR(190). A targeted lease composes its ref // from the lease id and the target, so this is the one place a caller can push a // ref past the column — and the ledger's own rule applies: refuse, never // truncate, because a truncated ref is a restore pointed at another object. const MAX_LEASE_REF = 190 /** * Turn the `value` param's text into whatever the named lease says it holds. * * The range check is here too, and it is REQUIRED on the numeric types for the * reason §F gives: unlike a cap, a bad lease value is in force the moment it is * applied, so "0.5 to 5" is not advice. */ function coerceLeaseValue(lease, raw) { const text = String(raw === undefined || raw === null ? '' : raw).trim() if (lease.type === 'string') { // A string lease with a declared value set is bounded here, at authoring // time, exactly as a numeric one is by its range (Phase 12b). Without it the // only check on the value is the game side's, and that refusal arrives // unattended, mid-run, from a step nobody is watching. if (lease.values && !lease.values.includes(text)) { return { ok: false, error: `${lease.label} accepts ${lease.values.join(', ')}, and "${raw}" is none of them`, } } return { ok: true, value: text } } if (lease.type === 'bool') { if (['true', '1', 'yes', 'on'].includes(text.toLowerCase())) return { ok: true, value: true } if (['false', '0', 'no', 'off'].includes(text.toLowerCase())) return { ok: true, value: false } return { ok: false, error: `"${raw}" is not a yes or no value for ${lease.label}` } } const n = Number(text) if (text === '' || !Number.isFinite(n)) { return { ok: false, error: `"${raw}" is not a number, and ${lease.label} holds one` } } if (lease.type === 'int' && !Number.isInteger(n)) { return { ok: false, error: `${lease.label} holds a whole number, and "${raw}" is not one` } } if (n < lease.min || n > lease.max) { return { ok: false, error: `${lease.label} accepts ${lease.min} to ${lease.max}, and "${raw}" is outside that` } } return { ok: true, value: n } } const ACTIONS = [ { id: 'core.announce', label: 'Announce', description: 'Publish a line of text to an announce leg — Discord, the in-game town crier, or any leg a module has registered.', // Nothing in the world changes and nothing is created: a message goes out. // That is what makes the default `on_failure` for this step `retry -> skip` // (§L) rather than `pause`, and it is the honest class even though the // message itself cannot be unsent. risk: 'notify', // A sent announcement is gone. `none` rather than `ledger` is not an // omission — there is no undo to write, and declaring `ledger` would put a // row in the cleanup ledger that teardown could never resolve. reversible: 'none', version: 1, params: [ { // A leg id, checked against the announce-leg registry at dispatch rather // than here: legs are registered by modules, and this file is evaluated // before any module has registered anything. // // **`source` is what moves that check earlier** (Phase 7). The dispatch // check stays — a module can boot between authoring and the run — but // until now a typo here was caught mid-run and nowhere else, which is the // defect Phase 6's walk hit: an announce leg "site" no module registers, // found by a dry run rather than by the form that accepted it. name: 'leg', type: 'string', required: true, example: 'discord', source: 'core.options.legs', description: 'The announce leg to publish on. Registered legs only.', }, { name: 'title', type: 'string', required: false, example: 'The gates of Britain open at dusk', description: 'Optional heading, for legs that render one.', }, { name: 'body', type: 'string', required: true, example: 'A caravan has been sighted on the road east of Cove.', description: 'The announcement itself. Plain text.', }, ], /** * Publish through the announce leg the step names. * * **The legs are reused rather than reimplemented** (§J, "reuse the legs"): * `discord` is core's and `towncrier` is module-uo's, both already registered, * both already carrying a `classify()` that knows what their transport's * failures mean. An event announcement that went out by some other path would * be a second delivery mechanism with its own bugs. * * A leg's `dispatch()` takes a POST — that is the shape the news path gave it * — so an event announcement is presented as one. `excerpt` is the body * because it is the field every leg renders as prose, and `image_url` is null * because an event announcement has no article behind it to illustrate. * Widening the leg contract to carry a second payload shape is a * MODULE_API change, and Phase 7 is where those are made. * * The leg id is checked HERE rather than at authoring time, and that is not * laxness: legs are registered by modules, and a spec is validated in a * process that may have booted before the module that owns the leg. */ async perform({ params, verify }) { const registered = registries.announceLeg(params.leg) if (!registered) { // Terminal, not transient. A leg nobody registers will not appear // between two attempts sixty seconds apart, and the honest cause — a // module removed, or a typo the authoring form could not catch — is a // thing a human fixes. return { ok: false, retry: false, error: `no module registers the announce leg "${params.leg}"` } } // A dry run reports what it WOULD do and sends nothing (§I). Answering // before the dispatch rather than inside the leg is what keeps that true // for legs written by people who never read this file. if (verify) return { ok: true } const result = await registered.dispatch({ title: params.title || null, excerpt: params.body, image_url: null, }) // The leg's own classification, not a second opinion. `retry` vs // `terminal` for a Discord webhook is a judgement `discordAnnounce.classify` // already makes, and making it twice is how the two drift. const { outcome, error } = registered.classify(result) if (outcome === 'done') return { ok: true } return { ok: false, retry: outcome === 'retry', error: error || `announce leg "${params.leg}" refused` } }, }, { id: 'core.wait', label: 'Wait', description: 'Let a fixed amount of time pass before the next step of this phase runs.', // `inspect` rather than `notify`: nothing is sent and nobody is told. It is // the weakest class the closed set has for an action that is not a broadcast. risk: 'inspect', reversible: 'none', version: 1, params: [ { name: 'seconds', type: 'int', required: true, example: 300, description: 'How long to wait. The runner sets the next step due_at from this.', }, ], // A wait is a genuine no-op at dispatch, and it stayed one: the delay is the // NEXT step's `due_at`, which the runner owns, not something this function // sleeps through. A `perform` that slept would hold a step's claim for the // duration and turn a five-minute pause into a five-minute lease — and the // reclaim would then re-dispatch it, so a long enough wait would never end. // // `holdFor` is an ordinary envelope member (org lead, 2026-09-02), which is // why the runner can honour this without knowing what `core.wait` is. async perform({ params, verify }) { if (verify) return { ok: true } return { ok: true, holdFor: params.seconds } }, }, { id: 'core.cue', label: 'Cue a human', description: 'Post an instruction for staff and wait for someone to confirm it was done before the run advances.', // The action itself only posts an instruction. Whatever the human then does // is outside this system entirely, which is precisely why the cue exists: // it is how an event uses a capability no module has automated. risk: 'notify', reversible: 'none', version: 1, params: [ { name: 'instruction', type: 'string', required: true, example: 'Open the north gate and read the herald script in Britain bank.', description: 'What the staff member is being asked to do.', }, { name: 'assignee', type: 'string', required: false, example: 'Event Team', description: 'Who the cue is addressed to. A label, not an account.', }, ], /** * Post the instruction and PARK. The step does not complete here. * * `await: 'human'` is the envelope member that says so (org lead, * 2026-09-02), and the runner's answer to it is to leave the step `running` * with a NULL lease — genuinely in flight, nothing holding it, so the stale * reclaim passes it by and a cue posted on Friday is still waiting on Monday. * The step ends when someone presses confirm, which is Phase 3's control. * * **Nothing is delivered from here in Phase 2, and that is visible rather * than pretended.** The instruction is carried by the step's own params and * shown on the run console; routing it to Discord or to a staff inbox is * Phase 10's integration work, through the engagement triggers that own every * other notification on this platform. An action that grew its own delivery * path would be the second one. */ async perform({ verify }) { if (verify) return { ok: true } return { ok: true, await: 'human' } }, }, { id: 'core.lease', label: 'Borrow a value', description: 'Hold a module-declared value at a new setting for a bounded time, and put the old one back at teardown.', // The world changes and it changes back, so `change` rather than // `irreversible` — and `change`'s default `on_failure` is `pause`, which is // the right stop for a run that failed halfway through altering the world. risk: 'change', // The one action core ships in this class. `override` is what tells the // cleanup sweep to restore through the LEASE registry rather than through an // action's `revert()`, which is why this action needs no `revert()` of its own // and why the registry refuses one on it. reversible: 'override', version: 1, params: [ { name: 'lease', type: 'string', required: true, example: 'uo.rate.skillgain', source: 'core.options.leases', description: 'Which declared value to borrow.', }, { // **A string, and the coercion is here rather than in the type system.** // A param declares ONE type; a lease declares its own, and they are four // different ones. Typing this `float` would make a boolean lease // unauthorable and a string lease nonsense, so the field takes text and // this action turns it into whatever the named lease said it holds — the // one place that knows both halves. name: 'value', type: 'string', required: true, example: '3.0', description: 'What to hold it at, in whatever type the lease declares.', }, { // **Optional here, required by the LEASE** (Phase 12b), and the two are // not the same statement. A param's `required` is a property of the // action, and this action serves both a config key (which has no target) // and an object property (which cannot be named without one) — so the // field is declared optional and `perform` refuses a targeted lease with // nothing in it, in the lease's own words. // // It carries no `source` for the same reason: the values behind it are // the chosen LEASE's, and a param declares one source for all time. The // lease's own `target.source` is what the authoring form reads once the // author has picked a lease, which is the only moment the right list is // knowable. name: 'target', type: 'string', required: false, example: '003f11b8-9bfa-4587-991e-ca263004efe6', description: 'Which one, for a value that exists on many things. Leave empty otherwise.', }, { name: 'minutes', type: 'int', required: true, example: 120, description: 'How long to hold it. Core refuses more than the lease allows.', }, ], // What a lease costs is the LEASE's business to bound, not a budget's: // `maxDurationMs` and the numeric range are declared beside the callables and // enforced below. A cap dimension here would be core inventing an accounting // unit for something a module already bounds — and `registerEventBudgets` // refuses a dimension nobody declared, which is exactly the rule that would // then bite core's own action. /** * Read the baseline, reserve the target, apply the value. * * **This is rule 1 in its strongest form.** Unlike a spawn, a lease's target * is knowable before the dispatch — it is the lease id the step names — so * the ledger row is written with its real `kind` and `ref` BEFORE anything * touches the world, and the two-events-one-target refusal comes from the * unique index at that moment rather than from a check that read and then * wrote. A second run asking for a lease another run holds comes back * `refused`, in the same words a cap breach uses and for the same reason: * nothing is broken, the deployment already has that value spoken for. * * The order is read then reserve then apply, and a failure at each stage * undoes the one before it: a reservation whose `apply` refuses is released * here rather than left for the sweep, because there is nothing out there to * give back and a shard that is merely down must not lock a lease out for the * length of a retry cycle. */ async perform({ runId, stepId, params, verify }) { // eslint-disable-next-line global-require const resourcesDb = require('../model/events/eventRunResources.db') const lease = registries.eventLease(params.lease) if (!lease) { return { ok: false, retry: false, error: `no module registers the lease "${params.lease}"` } } const coerced = coerceLeaseValue(lease, params.value) if (!coerced.ok) return { ok: false, retry: false, error: coerced.error } // **The target is checked before anything else about the world is read** // (Phase 12b), because both of its failures are authoring mistakes rather // than outages: a targeted lease with no target names nothing, and a target // on a lease that has none is an author who has confused two fields. Both // are `retry: false` — the second attempt has the same params. const targetRaw = params.target === undefined || params.target === null ? '' : String(params.target).trim() if (lease.target && !targetRaw) { return { ok: false, retry: false, error: `${lease.label} needs a ${lease.target.label.toLowerCase()}` } } if (!lease.target && targetRaw) { return { ok: false, retry: false, error: `${lease.label} is a single value and takes no target` } } const target = lease.target ? targetRaw : null const ref = registries.leaseRef(lease.id, target) // Refused rather than truncated, on the ledger's own rule for a resource // ref: a truncated ref is a restore pointed at the wrong object. if (ref.length > MAX_LEASE_REF) { return { ok: false, retry: false, error: `that target is too long to record (${ref.length} of ${MAX_LEASE_REF})` } } const minutes = Number(params.minutes) if (!Number.isFinite(minutes) || minutes <= 0) { return { ok: false, retry: false, error: `"${params.minutes}" is not a number of minutes` } } const ms = Math.round(minutes * 60_000) if (ms > lease.maxDurationMs) { return { ok: false, retry: false, error: `${lease.label} may be held for at most ${Math.floor(lease.maxDurationMs / 60_000)} minutes, not ${minutes}`, } } // **The dry run stops here, and it has still checked everything worth // checking**: the lease exists, the value is in range and the duration is // allowed. What it deliberately does not do is reserve the target — a // verify that took a lease would be a dry run that changed something, and // it would then refuse the real run that followed it. // // It also does not check that the TARGET exists, and that is the same // rule rather than an exception: asking the game side whether a spawner is // there is a live read the shard may be down for, and a dry run that fails // because a shard is restarting would make `verified_at` a property of the // moment rather than of the version (§K). if (verify) return { ok: true } const baseline = await lease.read({ target }) if (!baseline || baseline.ok !== true) { return { ok: false, error: baseline && baseline.error ? String(baseline.error) : `could not read the current value of ${lease.label}`, } } const until = new Date(Date.now() + ms) const reserved = await resourcesDb.reserve({ runId, stepId, owner: lease.owner || 'core', kind: 'override', // **The ref carries the target, and that is what makes the unique index // right rather than merely present.** Reserved under the lease id alone, // an event turning up one spawner would lock every other run out of every // other spawner — a conflict check that refuses correct work is as wrong // as one that permits a collision, and on a shard with 6,707 spawners it // is the failure an operator would actually meet. ref, payload: { target: ref, leaseTarget: target, baseline: baseline.value, applied: coerced.value, until: until.toISOString(), }, leaseUntil: until, }) if (!reserved.ok) { const heldBy = reserved.holder ? ` (run ${reserved.holder.run_id})` : '' return { ok: false, retry: false, error: `${lease.label} is already leased by another run${heldBy}`, } } // **`until` goes down the wire** (§F). The module passes it to its sidecar // and the game side restores baseline when it passes, without being asked // again — the fail-safe that makes an unattended, scheduled world change // defensible, because the worst case is a world back at baseline early // rather than one stuck changed indefinitely. let applied try { applied = await lease.apply(coerced.value, until, { target }) } catch (err) { applied = { ok: false, error: err.message } } if (!applied || applied.ok !== true) { await resourcesDb.markReverted(reserved.id) return { ok: false, error: applied && applied.error ? String(applied.error) : `${lease.label} refused the new value` } } await resourcesDb.confirm(reserved.id) // **The run now owes the world something, and something has to say so.** // The generic path marks a run dirty when it records a module's reported // resources; this action reserves its own row and never goes through it, so // a run whose only resource was a lease would have kept `cleanup_status = // 'not_required'` and never been swept. Found by the live walk, and the // cleanup leg's own scan was widened to make the class impossible rather // than only this instance. // eslint-disable-next-line global-require await require('../events/ledger').markRunDirty(runId) return { ok: true } }, /** * Which of this run's leases the game side still has a record of (Phase 11b). * * **A lease row had no reconcile path at all until this existed**, and nothing * failed to say so. `cleanup.js` resolves a resource to the action of the step * that made it, and for a lease that action is `core.lease` — a CORE action, on * a path a module cannot register anything on. So every `override` row came * back `unanswered` for the life of the run, and a lease the shard had quietly * dropped (a restart reverts every config lease, by design) stayed in the * ledger as live until teardown went looking for a baseline nobody was holding. * * The question asked is deliberately NOT "is the value still what we applied". * That is drift, and drift is teardown's verdict to deliver through `restore` * so the row lands as `drifted` with the current value beside it. A reconcile * that inferred absence from a changed value would orphan the row first and * throw that away — the operator would be told the lease vanished rather than * that somebody moved it. * * A lease with no `inForce()` is reported in force, which is core's posture * everywhere else in this file: "I could not ask" must never be recorded as * "it is gone". */ async reconcile({ resources }) { const inForce = [] for (const row of resources || []) { if (row.kind !== 'override') continue // Resolved through the ref parser rather than by a bare map lookup: a // targeted row's ref is `#` and `eventLease` would miss it, // which would silently report every property lease still in force. const found = registries.eventLeaseForRef(row.ref) const lease = found && found.lease if (!lease || typeof lease.inForce !== 'function') { inForce.push(row.ref) continue } let answer try { answer = await lease.inForce({ ref: row.ref, target: found.target, payload: row.payload || null }) } catch (err) { answer = null } // Only an explicit `held: false` takes a row out. A module that threw, timed // out, or answered something unrecognisable has not said the lease is gone. if (answer && answer.ok === true && answer.held === false) continue inForce.push(row.ref) } return { ok: true, inForce } }, }, { id: 'core.announce.post', label: 'Announce a post', description: 'Send an existing news post out on every registered announce leg — Discord, the in-game town crier — as this run\'s announcement.', // Nothing in the world changes and nothing is created; a message goes out. // Same class as `core.announce` and for the same reason. risk: 'notify', // The job is queued, the legs deliver, and none of it can be unsent. A // `ledger` here would put a row in the cleanup ledger that teardown could // never resolve. reversible: 'none', version: 1, // **`core.announce` sends a line; this sends an ARTICLE**, and that is the // whole difference between them (EVENTS.md §J, "News"). Events does not // write posts — `ctx.posts` is read-only to modules and the CMS is core's — // so an event that wants prose, an image and a permanent page links a post // an editor already wrote. What this action adds over `core.announce` is // therefore not a second transport but a second SHAPE: every leg's // `dispatch()` takes a post, and this is the one that hands it a real one. params: [ { name: 'postId', type: 'int', required: true, example: 412, source: 'core.options.posts', description: 'The published post to announce. Any category.', }, ], /** * Queue the post on every registered leg, as this run's announcement. * * **The refusals are all `retry: false`**, and each is a thing a human has to * fix: a post id that names nothing, or a draft. Neither will have changed * sixty seconds later, and retrying would spend two more attempts before * saying the same thing. * * **What it does NOT wait for is delivery.** `enqueueForRun` writes the job * and the legs and returns; `announceWorker` drains them on its own tick with * its own backoff. So this step is `done` when the announcement is queued, * not when Discord has it — which is honest, because a leg that fails after * six attempts over two hours is not something a step could usefully have * stayed open for, and the post admin panel is where that failure is already * surfaced. */ async perform({ runId, params, verify }) { /* eslint-disable global-require */ const posts = require('../model/posts/posts.model') const announceJobs = require('../model/announceJobs/announceJobs.model') /* eslint-enable global-require */ const postId = Number(params.postId) if (!Number.isInteger(postId) || postId < 1) { return { ok: false, retry: false, error: `"${params.postId}" is not a post id` } } const post = await posts.getById(postId) if (!post) return { ok: false, retry: false, error: `no post with id ${postId}` } if (!post.published) { // A draft has no public page for a town-crier line to point at, and // announcing one would publish its title to a shard before an editor // meant to. Refused rather than published on the author's behalf: // publishing is the CMS's decision and this action is not it. return { ok: false, retry: false, error: `"${post.title}" is not published` } } // The dry run has now checked everything worth checking — the post exists // and is published — and queues nothing. Checked BEFORE the legs are read, // because a deployment with no leg registered is a real state and a verify // that reported it as a failure would refuse a plan that is fine. if (verify) return { ok: true } await announceJobs.enqueueForRun(postId, runId) return { ok: true } }, }, { id: 'core.results.publish', label: 'Publish the results', description: 'Rank this run\'s participants by score and publish the results table.', // Nothing in the game world changes and nobody is messaged: a table core // already holds becomes readable. `inspect` is the weakest class the closed // set has and it is the honest one — which also means this action is // default-ON like `core.wait`, and an author can place it without an admin // first visiting the switchboard. risk: 'inspect', // **`none`, and it is worth saying why a publication is not reversible.** // Nothing is created that core would have to come back for; un-publishing is // an admin decision about a table, not a teardown obligation, and a `ledger` // row here would make every completed event carry an outstanding resource // for ever. reversible: 'none', version: 1, // No params. What is published is this run's participants, which is the only // set there is — a param naming which run would be a way to publish someone // else's results from inside your own event. params: [], /** * Rank, stamp, and say how many. * * **Idempotent by construction**, which is what makes it safe as an ordinary * retried step: ranking is a total order over `(score, joined_at, id)`, so * running it twice over an unchanged table writes the same numbers, and the * stamp simply moves. A late participant added by a second collect step and * a re-publish afterwards renumbers deliberately — that is the operator * asking for exactly that. * * **A run with no participants publishes an empty table rather than * failing.** "Nobody was recorded" is a true and renderable result, and it is * the state of every run until a module can source attendance at all (Phase * 12). Failing here would make an event whose module reports nothing look * broken on the console for a reason that has nothing to do with the event. */ async perform({ runId, verify }) { /* eslint-disable global-require */ const participantsDb = require('../model/events/eventRunParticipants.db') const runsDb = require('../model/events/eventRuns.db') /* eslint-enable global-require */ if (verify) return { ok: true } await participantsDb.rankRun(runId) await runsDb.markResultsPublished(runId) return { ok: true } }, }, ] // ── Core's own param option sources (§F, Phase 7) ────────────────── // // One, and it is core's half of the seam it hands a module on the same boot: a // param's `source` names a registered option source, core asks it for values, and // the authoring form renders a dropdown instead of a text box. // // **The legs are already a registry with labels in it**, so this costs nothing // new — which is what makes it the right first exercise. `resolve()` is called // per request rather than read once, for the same reason `core.announce` looks a // leg up inside `perform()`: a leg registered by a module that booted after this // file was evaluated must still appear, and a module uninstalled since must stop // appearing. const OPTION_SOURCES = [ { id: 'core.options.legs', label: 'Announce legs', description: 'Every delivery leg registered on this deployment right now.', async resolve() { return registries.announceLegs().map((l) => ({ value: l.leg, label: l.label || l.leg })) }, }, { id: 'core.options.leases', label: 'Borrowable values', description: 'Every value a module has declared this deployment may lease.', async resolve() { return registries .allEventLeases() .map((l) => ({ value: l.id, label: l.label, group: l.id.split('.')[0] })) }, }, { id: 'core.options.posts', label: 'Published posts', description: 'Every published post an event may announce, newest first.', /** * **The one option source in core that reaches a table**, and the reason it * is allowed to is the rule §F draws about WHEN: a source resolves on its own * request (`GET /admin/events/catalog/options/:sourceId`), which is a live * request on a booted server, not at `register()` time under a dead pool. * * Grouped by category so the dropdown separates news from the newsletter * rather than presenting one long list in which the two are indistinguishable * — a `group` is what the form renders as an optgroup, and it costs a column * that is already selected. */ async resolve() { // eslint-disable-next-line global-require const postsDb = require('../model/posts/posts.db') const rows = await postsDb.listPublishedForOptions(200) return rows.map((p) => ({ value: p.id, label: p.title, group: p.category })) }, }, ] module.exports = { ACTIONS, OPTION_SOURCES }