feat(cliloc): import the table from the shard, not from a file someone converted (Phase 2)
All checks were successful
PR Checks / client-build (pull_request) Successful in 34s
PR Checks / frozen-manifest (pull_request) Successful in 53s
PR Checks / server-tests (pull_request) Successful in 8m18s

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
This commit is contained in:
2026-09-10 11:13:24 -05:00
parent c73d62e93a
commit 893a36618b
13 changed files with 1817 additions and 64 deletions

View File

@@ -562,7 +562,7 @@
"Admin · Shard"
],
"summary": "Cliloc table status: sources, drift, entry count (admin only)",
"description": "Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.",
"description": "Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether they have drifted from what is loaded. `source` says which pipeline is in use: `bridge` (the shard reads its own client — the normal case once uo-link is configured) or `file` (a converted file on disk, deprecated, kept for installs with no shard link). On the bridge, `shard` carries the client files size, mtime, hash and the shards extractor version, and `shard.hashing: true` means a null hash is “not computed yet”, not “changed”. The table is always a SET: the base plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any overlay that was loaded before and is now gone; an import refuses that without `approve`. A shard with no source at all is a supported state — item names simply render as ids.",
"responses": {
"200": {
"description": "Cliloc status",
@@ -603,8 +603,8 @@
"tags": [
"Admin · Shard"
],
"summary": "Re-import the cliloc table from its source files (admin only)",
"description": "Applies a client patch, or a change to the shards own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the clients own COMPRESSED Cliloc.enu — answers 200 with status \"unavailable\" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.",
"summary": "Re-import the cliloc table from its source (admin only)",
"description": "Applies a client patch, or a change to the shards own overlay files, without a restart. On the bridge this is the ONLY thing that imports — boot deliberately does not call the shard — so it is what an operator presses after patching their client. `force` reimports even when the sources are unchanged. `approve` accepts a refresh in which a previously-loaded overlay has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. Nothing here throws for an operator-visible problem: a shard that is down, an asset plane the operator has switched off, a client with no cliloc file, or a malformed overlay all answer 200 with status \"unavailable\" and a reason naming what to fix.",
"responses": {
"200": {
"description": "What happened",
@@ -655,8 +655,8 @@
"tags": [
"Admin · Shard"
],
"summary": "Set the cliloc source the site reads from (admin only)",
"description": "Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.",
"summary": "Set the cliloc path the site reads overlays (and any file base) from (admin only)",
"description": "On an install with uo-link configured this selects only where `custom/` overlays are read from — the base table comes from the shard. Without a shard link it is also where the converted base file is looked for, which is the deprecated pre-protocol-8 pipeline. Accepts either a file or a directory to search; overlays are read from a `custom/` directory beside it either way, so pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.",
"responses": {
"200": {
"description": "Cliloc status after the change",
@@ -692,7 +692,7 @@
"properties": {
"path": {
"type": "string",
"description": "Path to the converted cliloc file, or a directory containing one. Blank disables resolution."
"description": "Directory holding the custom/ overlays (and, with no shard link, a converted base file). Blank clears it."
}
}
}
@@ -7273,11 +7273,38 @@
},
"description": {
"type": "string",
"example": "Admin view of cliloc state: where the converted file is, whether it is readable, how many entries are loaded, and whether the file has drifted from them. `configured: false` is a supported state — item names then render as ids."
"example": "Admin view of cliloc state: which source the base table comes from, whether it can be read, how many entries are loaded, and whether anything has drifted from them. Nothing configured at all is a supported state — item names then render as ids."
},
"properties": {
"type": "object",
"properties": {
"source": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"bridge",
"file"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "`bridge`: the shard reads its own UO client (protocol 8, the normal case). `file`: a converted file on disk — the pre-protocol-8 pipeline, deprecated, kept for installs with no shard link."
},
"example": {
"type": "string",
"example": "bridge"
}
}
},
"configured": {
"type": "object",
"properties": {
@@ -7298,6 +7325,10 @@
"type": "string",
"example": "string"
},
"description": {
"type": "string",
"example": "On the bridge: where `custom/` overlays are read from. On a file source: the base path too."
},
"example": {
"type": "string",
"example": "/srv/uo-client"
@@ -7317,11 +7348,11 @@
},
"description": {
"type": "string",
"example": "The file actually resolved, when the path is a directory."
"example": "The base file in use — the shards own `cliloc.enu` on the bridge, the resolved local file otherwise."
},
"example": {
"type": "string",
"example": "/srv/uo-client/clilocs.tsv"
"example": "cliloc.enu"
}
}
},
@@ -7351,7 +7382,7 @@
},
"description": {
"type": "string",
"example": "Why the file cannot be used, when it cannot. Set (with code COMPRESSED) for a readable-but-unconverted client file."
"example": "Why the base cannot be used, when it cannot: a shard that is down or has assets switched off, or (on a file source) a missing or still-compressed file."
},
"example": {}
}
@@ -7378,7 +7409,13 @@
"NOT_FOUND",
"NO_FILE",
"UNREADABLE",
"COMPRESSED"
"COMPRESSED",
"DISABLED",
"NO_SOURCE",
"SHARD_DOWN",
"PROTOCOL",
"BUSY",
"UNAVAILABLE"
],
"items": {
"type": "string"
@@ -7386,6 +7423,118 @@
}
}
},
"shard": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Present on the bridge: the shards own cliloc file as it is right now. `hashing: true` with a null `sha256` means the hash has not been computed yet — “ask again”, not “changed”."
},
"properties": {
"type": "object",
"properties": {
"size": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 4989921
}
}
},
"mtime": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"description": {
"type": "string",
"example": "Unix milliseconds."
},
"example": {
"type": "number",
"example": 1757462400000
}
}
},
"sha256": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
}
}
},
"extractorVersion": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"description": {
"type": "string",
"example": "The version of the shards extraction code. A bump makes everything derived from it drift."
},
"example": {
"type": "number",
"example": 1
}
}
},
"hashing": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"complete": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Every client file has a hash."
},
"example": {
"type": "boolean",
"example": true
}
}
}
}
}
}
},
"drift": {
"type": "object",
"properties": {
@@ -7447,7 +7596,6 @@
"example": {
"type": "array",
"example": [
"clilocs.plain",
"custom/uomysticmoon.tsv"
],
"items": {
@@ -7504,6 +7652,7 @@
"enum": {
"type": "array",
"example": [
"shard",
"base",
"custom"
],
@@ -7511,6 +7660,10 @@
"type": "string"
}
},
"description": {
"type": "string",
"example": "`shard` is the table read over the bridge; `base` a converted file on disk."
},
"example": {
"type": "string",
"example": "custom"
@@ -7693,6 +7846,37 @@
}
}
},
"source": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"enum": {
"type": "array",
"example": [
"bridge",
"file"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "Which source this refresh read."
},
"example": {
"type": "string",
"example": "bridge"
}
}
},
"code": {
"type": "object",
"properties": {
@@ -7706,7 +7890,7 @@
},
"description": {
"type": "string",
"example": "Machine-readable cause. `COMPRESSED` means the client's own Cliloc.enu was supplied instead of a converted one."
"example": "Machine-readable cause. Bridge codes describe the shard (`DISABLED`: the operator switched the asset plane off; `NO_SOURCE`: its client has no cliloc file; `SHARD_DOWN`; `SOURCE_CHANGED`: the client was patched mid-import, so nothing was applied). File codes describe the path — `COMPRESSED` means the client's own Cliloc.enu was supplied instead of a converted one."
},
"enum": {
"type": "array",
@@ -7718,7 +7902,18 @@
"COMPRESSED",
"TRUNCATED",
"EMPTY",
"NOT_BUFFER"
"NOT_BUFFER",
"DISABLED",
"NO_SOURCE",
"SHARD_DOWN",
"PROTOCOL",
"BUSY",
"UNAVAILABLE",
"SOURCE_CHANGED",
"INCOMPLETE",
"STUCK",
"MALFORMED",
"TOO_LARGE"
],
"items": {
"type": "string"
@@ -7807,7 +8002,87 @@
},
"example": {
"type": "number",
"example": 55994
"example": 0
}
}
},
"pages": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Bridge only: how many pages the table arrived in (a stock English table is about eleven)."
},
"example": {
"type": "number",
"example": 11
}
}
},
"reported": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Bridge only: how many rows the shard said it holds."
},
"example": {
"type": "number",
"example": 67496
}
}
},
"received": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Bridge only: how many arrived. Disagreeing with `reported` means the walk is wrong."
},
"example": {
"type": "number",
"example": 67496
}
}
},
"overlayProblem": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "The base imported, but the overlay directory could not be read. Reported rather than fatal."
}
}
},
@@ -7855,6 +8130,7 @@
"enum": {
"type": "array",
"example": [
"shard",
"base",
"custom"
],