Files
Module-uo/server/utils/shardAnnounce.js
wtclaude fe3251a543 feat(server): port the UO models, utils and schema fragment
The data half of the extraction: 8 model directories, 13 utils, the shard
stream catalog and the 27-table schema fragment with its purge.

server/core.js is what makes the port a one-line import change per file rather
than a signature change per function. Ported code requires its dependencies at
file scope -- `const { query } = require('../../core')` -- which runs before
register() has been called and before any ctx exists. So every member is a
stable function that resolves ctx when CALLED, and nothing may be destructured
off ctx at init either, because core is free to hand over a getter.

Two helpers are vendored rather than taken from ctx, and the line between them
is the point. utils/excerpt.js is core's deriveExcerpt -- nine lines of pure
text handling. Core's sanitiser next to it was NOT copied: a second copy of a
security control diverges silently the moment either is fixed. announceLinks.js
vendors legError and articleUrl the same way, but baseUrl could not be: core's
reads APP_BASE_URL, and §2.7 forbids a module reading core's environment, so it
comes off ctx.site.baseUrl.

The schema fragment is core's 27 shard_*/uo_link_* statements, verbs CREATE,
ALTER and UPDATE only, every CREATE TABLE guarded. Two of its tables carry a
foreign key INTO users, which is allowed and is why the replay order matters --
core's schema is in place before this runs. The reverse never occurs and must
not: it would make core unable to boot without a module installed.

One real port bug caught by the integration run, not by tests: the atlas art
map resolved `../../../db/data`, which pointed at core's tree when this file
lived there and points outside server/ now. A path that happens to resolve is
exactly what survives a green suite, because the absent-file branch returns {}
and looks like the normal case.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:06:26 -05:00

78 lines
3.2 KiB
JavaScript

// ── The in-game town-crier announce leg ────────────────────────────────────
//
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8's third
// entangled file: utils/announceWorker.js is core's news dispatcher, but one of
// its two delivery legs goes to the shard through uoLinkClient.postTownCrier.
// PR 4 turned the legs into registrations, and this file is what module-uo will
// register in Phase 3 — it moves whole, with `'core'` becoming `'uo'` and the
// leg id staying `towncrier` (grandfathered in registries.js: the id is a stored
// value in announce_job_legs.leg).
const uoLinkClient = require('./uoLinkClient')
const { deriveExcerpt } = require('./excerpt')
const { articleUrl, baseUrl, legError } = require('./announceLinks')
const TOWNCRIER_DURATION_SEC = Number(process.env.TOWNCRIER_DURATION_SEC) || 3600
// Sidecar town-crier caps, mirrored from the admin route validation
// (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
// We pre-truncate to these so a published post never bounces with an error.
const MAX_LINES = 8
const MAX_LINE_LEN = 200
// Trim to a hard length, appending an ellipsis only when something was cut.
function clamp(value, max) {
const s = String(value == null ? '' : value)
.replace(/\s+/g, ' ')
.trim()
if (s.length <= max) return s
return `${s.slice(0, max - 1).trimEnd()}`
}
// Build the town-crier lines: title, a one-line excerpt, then the URL. Each line
// is clamped to the sidecar's per-line cap and the whole thing to the line-count
// cap. Falls back to a stripped body excerpt when the post has no excerpt.
function buildTownCrierText(post, { baseUrl: base } = {}) {
const title = clamp(post.title, MAX_LINE_LEN)
const excerptSource = post.excerpt || deriveExcerpt(post.body, MAX_LINE_LEN) || ''
const lines = [title]
const excerpt = clamp(excerptSource, MAX_LINE_LEN)
if (excerpt) lines.push(excerpt)
const url = clamp(articleUrl(base), MAX_LINE_LEN)
if (url) lines.push(url)
return lines.filter(Boolean).slice(0, MAX_LINES)
}
async function dispatch(post) {
const lines = buildTownCrierText(post, { baseUrl: baseUrl() })
// Stable id: re-posting `post-<id>` REPLACES the prior town-crier entry rather
// than stacking a duplicate, so a retry after a partial failure is safe.
return uoLinkClient.postTownCrier({
id: `post-${post.id}`,
lines,
durationSec: TOWNCRIER_DURATION_SEC,
})
}
function classify(result) {
if (result && result.ok) return { outcome: 'done' }
const status = result ? result.status : 0
// 400 = over the line/duration caps (a data problem — do NOT retry).
// 401 = token mismatch, 409 = protocol mismatch (both config problems).
if (status === 400 || status === 401 || status === 409) {
return { outcome: 'terminal', error: legError(result) }
}
// 503 (shard not connected), 504 (shard timeout), 0 (network/timeout / not
// configured yet), and any other 5xx are transient — retry.
return { outcome: 'retry', error: legError(result) }
}
const leg = {
leg: 'towncrier',
label: 'In-game town crier',
dispatch,
classify,
}
module.exports = { leg, dispatch, classify, buildTownCrierText, MAX_LINES, MAX_LINE_LEN }