The base cliloc table now comes over the bridge. `clilocBridge.js` walks
`GET /cliloc` page by page and the model merges the `custom/` overlays over it —
overlays stay on disk because ServUO has no server-side notion of a custom
cliloc, so there is nothing on the shard to ask for.
**The shard wins whenever uo-link is configured and enabled**, with no mode
setting: there is no version of "which source?" an operator benefits from
answering. A file on disk remains the source only where there is no shard link,
plus a one-off explicit `path` — deprecated, not removed, and unchanged.
**Boot no longer imports on the bridge.** The file path could hash 5 MB locally
and skip in 14 ms; a shard round trip in the boot sequence would be spent
answering "no" on every restart but the one after a client patch — and patching a
client is an operator action, so importing became one. Admin → Shard → Import.
Whatever table is loaded keeps serving until then.
Three checks in the walk, each for a way a shard can hand back a table that looks
complete:
* only `cut: 'end'` finishes it — a short page can equally be a spent budget,
and a truncated table renders some items named and some not, which is exactly
what NO table looks like;
* the cursor must advance, or the walk stops rather than spinning;
* every page echoes the source's size and mtime, so a client patched mid-import
is refused outright rather than stitched from two files.
**The base is exempt from the vanished-source rule**, which is an upgrade detail
rather than a preference: an install that used the file pipeline carries its base
file's label in the stored fingerprint, and on the bridge that label is *supposed*
to disappear. Counting it as vanished would demand an approval for a change the
upgrade itself made. Overlays keep the rule in full.
**The protocol pin moves 7 → 8** — the third declaration site, and the one
nothing enforces. Phase 1 moved the sidecar and the overlay together because the
installer refuses a mismatched bundle; this one has to be moved by hand, in the
phase that first calls a protocol-8 route. The schema block above it is the
record of what forgetting costs: two phases of every REST call answered 409.
Verified against a live shard, sidecar and site: 12 pages, 67,496 rows imported
in 1.68 s, the operator's three-row overlay overriding stock strings on top of
it, and the next import correctly `unchanged`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
129 lines
6.4 KiB
JavaScript
129 lines
6.4 KiB
JavaScript
// ── onBoot / onShutdown ────────────────────────────────────────────────────
|
|
//
|
|
// The eight UO call sites that used to sit in core's `server.js`. `register()`
|
|
// runs with no database (MODULE_API.md §2.2); everything here runs with one.
|
|
//
|
|
// Core dispatches `onBoot` after `ensureSchema` and the schema-fragment replay,
|
|
// and **before the HTTP listener binds** — so the tables these functions touch
|
|
// exist, and nothing is served until the warm-up finishes. That ordering is the
|
|
// contract's promise rather than an accident, and it is why `onBoot` has no
|
|
// timeout: a module that must not serve traffic until a cache is warm only gets
|
|
// that guarantee if the listener is still closed.
|
|
//
|
|
// **One behavioural change, and it is deliberate.** In core, `uoLinkSocket.start()`
|
|
// and the sidecar health probe ran AFTER the listener bound; here they run before
|
|
// it. `start()` returns as soon as the reconnecting client is armed, so that part
|
|
// is free — but the probe is a real HTTP call to the sidecar, and an unreachable
|
|
// sidecar must not hold the site closed. It is therefore fired and NOT awaited,
|
|
// with its own catch. Reporting whether the bridge is up is diagnostics; being up
|
|
// is not a precondition for serving a page, and the site is required to degrade
|
|
// gracefully when the shard is down.
|
|
//
|
|
// Everything here is best-effort by the same rule. A module whose `onBoot`
|
|
// throws is marked `startup_failed` and its routes answer 503 (§4.4), which is
|
|
// the right outcome for a broken module — but "the operator has not configured a
|
|
// ServUO path" is not a broken module, and neither is "the shard is offline".
|
|
|
|
const core = require('./core')
|
|
|
|
const uoLinkSocket = require('./utils/uoLinkSocket')
|
|
const uoLinkClient = require('./utils/uoLinkClient')
|
|
const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model')
|
|
const shardBroadcast = require('./utils/shardBroadcast')
|
|
const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
|
|
const shardClilocs = require('./model/shardClilocs/shardClilocs.model')
|
|
const shardMarket = require('./model/shardMarket/shardMarket.model')
|
|
|
|
/**
|
|
* Best-effort startup probe of the uo-link sidecar.
|
|
*
|
|
* Logs whether it is reachable and warns loudly on a protocol mismatch —
|
|
* fail-fast visibility rather than silently mis-parsing a newer wire format.
|
|
* Never throws, and is never awaited by `onBoot`.
|
|
*/
|
|
async function checkUoLink() {
|
|
const log = core.logger('boot')
|
|
const config = await uoLinkConfig.getSafe()
|
|
if (!config.enabled) return
|
|
const health = await uoLinkClient.health()
|
|
if (!health.ok) {
|
|
log.warn('uo-link is enabled but the sidecar is unreachable at startup', {
|
|
baseUrl: config.baseUrl,
|
|
error: health.error || `status ${health.status}`,
|
|
})
|
|
return
|
|
}
|
|
if (health.data && health.data.protocol && health.data.protocol !== config.protocol) {
|
|
log.error('uo-link PROTOCOL MISMATCH — pinned vs sidecar', {
|
|
pinned: config.protocol,
|
|
sidecar: health.data.protocol,
|
|
})
|
|
} else {
|
|
log.info('uo-link sidecar reachable', {
|
|
pluginConnected: health.data && health.data.plugin_connected,
|
|
protocol: health.data && health.data.protocol,
|
|
})
|
|
}
|
|
}
|
|
|
|
async function onBoot() {
|
|
const log = core.logger('boot')
|
|
|
|
// Re-derive the spawn atlas from the shard's own ServUO tree. The shard's maps
|
|
// change over its lifetime — facets get added, replaced or renamed — so the
|
|
// atlas is rebuilt on every boot rather than shipped as a snapshot that would
|
|
// silently go stale. Hash-gated, so an unchanged tree costs one read pass and
|
|
// no database write.
|
|
//
|
|
// Best-effort by contract: no configured path, an unreadable mount or a
|
|
// malformed file must never stop the site coming up. A refresh that would
|
|
// REMOVE a facet is staged for admin approval instead of being applied.
|
|
await shardAtlas.refreshOnBoot()
|
|
|
|
// Refresh the cliloc table (UO's id → display-string map).
|
|
//
|
|
// **On an install with uo-link configured this imports nothing** — protocol 8
|
|
// moved the base table to the shard, and asking for it would put a sidecar
|
|
// round trip in the boot sequence to answer a question whose answer is "no"
|
|
// except after a client patch. That is an operator action, so importing is an
|
|
// operator action: Admin → Shard (docs/link/v8.md §9).
|
|
//
|
|
// Without a shard link it is the old file pipeline, unchanged: hash-gated so an
|
|
// unchanged file costs one read, and best-effort so a missing or wrong-format
|
|
// file never stops the site coming up — it just means item names render as ids,
|
|
// which is what they did before the table existed.
|
|
const clilocResult = await shardClilocs.refreshOnBoot()
|
|
|
|
// A cliloc import changes what item names RESOLVE to, and the marketplace
|
|
// stores those names denormalized (shard_vendor_items.display_name) so it can
|
|
// index and search them. The shard's market sweep will not re-send an unchanged
|
|
// shop just because the site learned what its items are called, so the backfill
|
|
// has to be pulled rather than waited for. Only after an actual import — the
|
|
// common boot is hash-gated to a no-op and must stay one.
|
|
if (clilocResult && clilocResult.status === 'imported') await shardMarket.refreshDisplayNames()
|
|
|
|
// Start the uo-link WebSocket ingest client. Self-guards: it only actually
|
|
// connects when the admin has enabled the integration and saved a token, so
|
|
// this is a no-op on shards that haven't configured the sidecar.
|
|
try {
|
|
await uoLinkSocket.start()
|
|
} catch (err) {
|
|
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
|
|
}
|
|
|
|
// Deliberately not awaited — see the header. An unreachable sidecar would
|
|
// otherwise hold the listener closed for the length of an HTTP timeout.
|
|
checkUoLink().catch((err) => log.warn('uo-link startup probe failed', { error: err.message }))
|
|
}
|
|
|
|
async function onShutdown() {
|
|
// Core runs this FIRST in its signal handler, while everything it handed over
|
|
// still works — the pool is open, the push dispatcher is up, the SSE fan-out
|
|
// is live. It is the only chance to close cleanly, and it is budgeted, so a
|
|
// hook that will not let go costs five seconds rather than the whole shutdown.
|
|
uoLinkSocket.stop() // close the uo-link WS ingest client
|
|
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
|
|
}
|
|
|
|
module.exports = { onBoot, onShutdown, checkUoLink }
|