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
120 lines
5.6 KiB
JavaScript
120 lines
5.6 KiB
JavaScript
// ── Admin · Cliloc table ───────────────────────────────────────────────────
|
|
//
|
|
// Operating the cliloc import: which source the table comes from, whether it has
|
|
// drifted from what is loaded, and a reimport after a client patch
|
|
// (docs/link/v8.md §9, docs/website/CLILOCS.md).
|
|
//
|
|
// The policy lives in the model. This controller does three things and no more:
|
|
// it validates input, it maps a refresh RESULT onto an HTTP status, and it
|
|
// records the action in the admin activity log.
|
|
//
|
|
// **A refresh result is not an exception.** `shardClilocs.refresh()` reports
|
|
// `unavailable` / `failed` rather than throwing, because the boot path must never
|
|
// be stopped by a bad source. That contract is preserved here, and protocol 8
|
|
// widened the set of things it covers: a shard that is down, an asset plane the
|
|
// operator has switched off, a client with no cliloc file, a client patched
|
|
// halfway through the import — plus everything the file pipeline could already
|
|
// report. Each is a 200 carrying `status: 'unavailable'` and a reason naming what
|
|
// to fix, not a 500 that says only "something broke".
|
|
//
|
|
// **Import matters more than it used to.** On the bridge, boot deliberately does
|
|
// not call the shard, so this endpoint is the only thing that refreshes the
|
|
// table — the operator presses it after patching their client.
|
|
|
|
const clilocs = require('../../model/shardClilocs/shardClilocs.model')
|
|
const market = require('../../model/shardMarket/shardMarket.model')
|
|
const { activity } = require('../../core')
|
|
|
|
const log = require('../../core').logger('admin-shard-clilocs')
|
|
|
|
// GET /admin/shard/clilocs — what is loaded, what the file looks like, whether
|
|
// they disagree. There is no public counterpart: the cliloc table is never
|
|
// served as a table, only applied to names the site already returns.
|
|
async function getStatus(req, res) {
|
|
try {
|
|
return res.json(await clilocs.status())
|
|
} catch (err) {
|
|
log.error('getStatus', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// POST /admin/shard/clilocs/import — reload after a client patch or a change to
|
|
// the shard's own overlay files, without a restart.
|
|
//
|
|
// `force` reimports even when the source hashes match what is loaded (the escape
|
|
// hatch for "the database is wrong but the files are not").
|
|
//
|
|
// `approve` accepts a refresh in which a previously-loaded source has VANISHED.
|
|
// That is refused by default because an unmounted volume and a deliberate
|
|
// deletion look identical from the server — the lighter cousin of the atlas's
|
|
// approve/reject flow, and the reason it can be a flag here rather than a
|
|
// pending table is that nothing is stored to approve: the import re-reads the
|
|
// files at approval time by construction.
|
|
async function importClilocs(req, res) {
|
|
try {
|
|
const force = !!req.body?.force
|
|
const approve = !!req.body?.approve
|
|
const result = await clilocs.refresh({ force, approve })
|
|
|
|
// The marketplace denormalizes resolved item names into
|
|
// shard_vendor_items.display_name, and the shard's market sweep will NOT
|
|
// re-send an unchanged shop just because the site learned what its items are
|
|
// called — so without this pass, an operator who imports clilocs after the
|
|
// first sweep keeps seeing item ids until every shop happens to change.
|
|
// Awaited (rather than fired and forgotten) so the panel's "imported" is
|
|
// honest about the names being live; the pass is a bounded walk of one table
|
|
// and never throws.
|
|
if (result.status === 'imported') await market.refreshDisplayNames()
|
|
|
|
await activity.log({
|
|
req,
|
|
action: 'shard.clilocs.import',
|
|
detail: {
|
|
force,
|
|
approve,
|
|
status: result.status,
|
|
// Which pipeline actually ran. Worth having in the audit log for the
|
|
// same reason it is in the status: an operator debugging a stale table
|
|
// needs to know whether the site asked the shard or read a file.
|
|
source: result.source ?? null,
|
|
count: result.count ?? null,
|
|
missingSources: result.missingSources ?? result.acceptedMissing ?? null,
|
|
},
|
|
})
|
|
return res.json(result)
|
|
} catch (err) {
|
|
log.error('importClilocs', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// PUT /admin/shard/clilocs/path — point the site at a different cliloc path.
|
|
//
|
|
// On an install with uo-link configured this selects where `custom/` OVERLAYS are
|
|
// read from; the base table comes from the shard either way. Without a shard link
|
|
// it is also where the converted base file is looked for.
|
|
//
|
|
// Persisted as a setting, which wins over the UO_CLIENT_PATH env default so an
|
|
// operator can move the mount without a redeploy. Blank clears it, which turns
|
|
// overlay resolution off (the loaded table keeps serving) — a legitimate thing to
|
|
// want, so it is allowed rather than validated away.
|
|
//
|
|
// Deliberately does NOT import as a side effect, for the same reason the atlas
|
|
// path does not: changing where the table reads from and reloading it are
|
|
// separate decisions. The response carries the refreshed status so the panel can
|
|
// offer the import immediately.
|
|
async function setPath(req, res) {
|
|
try {
|
|
const value = String(req.body?.path ?? '').trim()
|
|
await clilocs.setClientPath(value, req.user?.id ?? null)
|
|
await activity.log({ req, action: 'shard.clilocs.path', detail: { path: value } })
|
|
return res.json(await clilocs.status())
|
|
} catch (err) {
|
|
log.error('setClilocPath', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
module.exports = { getStatus, importClilocs, setPath }
|