Files
Module-uo/server/core.js
wtclaude 57419111e6 feat(events): UO wave 1 — the verbs that need no protocol change (Phase 9)
module-uo registers its first event actions: `uo.broadcast`,
`uo.towncrier.post` and `uo.news.post`, plus the `uo.broadcasts` budget
dimension and the three spawn-atlas option sources. The write plane they use
has existed since protocol 2.1; what is new is the declaration that lets the
event engine drive it unattended.

Three things the tree corrected about the plan:

- The plan's `on_failure: 'skip'` for `uo.broadcast` is already the default for
  `risk: 'notify'`, and `on_failure` is what happens AFTER the retries. The
  lever a module actually has is the failure envelope, so the action answers
  `retry: false` to everything — and every action declares `budgetMs: 15000`,
  because core's 10s default deadline fires before `uoLinkClient`'s 12s timeout
  and `classify()` answers `retry` for a timeout without asking the module.
  Without the budget the retry refusal is unreachable.
- `reconcile()` needs no protocol work. A shard restart wipes both the crier
  lines and an event's news article, so `perform()` stamps the shard `bootId`
  into the resource payload and `reconcile()` reports in force exactly the rows
  whose stamp still matches — correct for the module's own trigger and for
  core's boot sweep alike. `shardIngest` fires `ctx.events.reconcile()` on a
  changed `bootId`, after `recordStatus` so the comparison reads the new boot.
- Event articles post under `evt-<idempotencyKey>`, because `newsGump.js` uses
  the bare website post id and re-pushes that set on every reconnect.

`ci/core-ref.json` moves to a website `edge` sha for the length of this
workstream: `registerEventActions` exists only from MODULE_API 1.10.0, so under
the old `main` pin the module does not load at all. Verified locally — the
frozen-manifest rig passes against the new pin.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 07:23:03 -05:00

135 lines
6.2 KiB
JavaScript

// ── Everything this module reaches in core ─────────────────────────────────
//
// `ctx` arrives once, as an argument to `register()` (MODULE_API.md §2.3). The
// code below it — models, utils, controllers — is ordinary Node that requires
// its dependencies at file scope, the way it did when it lived in core. This
// file is what lets both be true.
//
// **The shape is a lazy accessor, not a stored reference, and that is the whole
// point.** A ported file writes
//
// const { query } = require('../../core')
//
// at require time, which is before `register()` has been called and therefore
// before any `ctx` exists. Handing out `ctx.db.query` there would hand out
// `undefined`, permanently, and the failure would surface much later as a
// TypeError inside a model. So every export here is a stable function that
// resolves `ctx` when it is CALLED. Require order stops mattering, and the port
// stays a one-line import change per file rather than a signature change per
// function.
//
// The other half of the same rule: nothing here may be destructured off `ctx`
// at init time either, for the same reason in the other direction — core is
// free to hand over a getter (`ctx.site.baseUrl` is one), and a value captured
// once is a value that cannot change.
//
// If `ctx` is missing, every accessor throws with the same message. That is
// deliberate: the only way to reach one before `register()` is a require cycle
// or a test that forgot to call `init`, and both want naming, not `undefined`.
let ctx = null
function need() {
if (!ctx) {
throw new Error('module-uo: core accessed before register() — see server/core.js')
}
return ctx
}
/** Called once, first thing in `register()`. */
function init(value) {
ctx = value
}
/** Test seam. Nothing in the module calls this; there is no de-registration. */
function _reset() {
ctx = null
}
// A logger that can be taken at require time and used after `register()`.
//
// Ported files write `const log = require('../core').logger('shard-ingest')` at
// file scope — the same shape as core's `require('./logger')('…')` — so the
// object returned has to exist before `ctx` does. It is a façade whose four
// methods each resolve the real logger on call. Core namespaces it with the
// module id, so these come out as `[uo:shard-ingest]`.
function logger(namespace) {
const call = (level) => (message, meta) => need().log(namespace)[level](message, meta)
return { error: call('error'), warn: call('warn'), info: call('info'), debug: call('debug') }
}
module.exports = {
init,
_reset,
logger,
// Shared server dependencies. Core owns exactly one express, as it owns
// exactly one React on the client, and for the same reason: a second copy in
// the process is a second Router prototype and a second set of instanceof
// checks. A module lives outside core's `server/`, so it could not resolve
// these for itself even if it were allowed to (§7.2).
get express() { return need().express },
get validator() { return need().validator },
// Database. `query` is the one every `*.db.js` uses; `pool` is for the
// streamed atlas import, which needs a connection it can hold.
query: (...args) => need().db.query(...args),
get pool() { return need().db.pool },
// Core state a module may read or append to, each narrowed to what is
// actually used (§2.3).
settings: {
get: (...args) => need().settings.get(...args),
set: (...args) => need().settings.set(...args),
getInstanceName: (...args) => need().settings.getInstanceName(...args),
},
activity: { log: (...args) => need().activity.log(...args) },
users: { getById: (...args) => need().users.getById(...args) },
posts: {
listAll: (...args) => need().posts.listAll(...args),
getById: (...args) => need().posts.getById(...args),
linkAnnounceJob: (...args) => need().posts.linkAnnounceJob(...args),
markAnnounced: (...args) => need().posts.markAnnounced(...args),
},
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
push: { publish: (...args) => need().push.publish(...args) },
// The engagement seam (MODULE_API 1.7.0, ENGAGEMENT.md §5.1). `emit` says an
// event this module DECLARED has happened; the engine decides whether anyone is
// told, on which channel, subject to which rule and preference. `inbox.push`
// writes an in-app item with no rule at all, for the cases that are not events.
//
// Both are fire-and-forget and return undefined by contract — a module calls
// them from inside a game-event handler and there is nothing it could correctly
// do with a storage failure of core's. `inbox.push` additionally does not report
// "the user has this switched off", because a module that could see that would
// be a module that could enumerate people's preferences one write at a time.
events: {
emit: (...args) => need().events.emit(...args),
// MODULE_API 1.10.0 (EVENTS.md F, Phase 8). "Ask every action of mine which
// of its ledgered resources the game still has." Core cannot know when to
// ask -- it has no concept of the game being up -- so the module says when,
// and `shardIngest` says it on a changed `bootId`. Fire-and-forget like
// `emit`, and for the same reason: core owns what happens next and there is
// nothing a game-event handler could correctly do with the answer.
reconcile: (...args) => need().events.reconcile(...args),
},
inbox: { push: (...args) => need().inbox.push(...args) },
secretBox: {
encrypt: (...args) => need().secretBox.encrypt(...args),
decrypt: (...args) => need().secretBox.decrypt(...args),
},
get uploads() { return need().uploads },
// Middleware. Taken as values rather than wrapped, because express stores the
// function reference at mount time — a wrapper would be what ends up in the
// stack, and `requireRole('admin')` returns a middleware rather than being
// one. Routers are built inside `register()`, so `ctx` is set by then.
get middleware() { return need().middleware },
// Deployment facts.
get baseUrl() { return need().site.baseUrl },
get moduleRoot() { return need().paths.moduleRoot },
get moduleId() { return need().moduleId },
}