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

@@ -0,0 +1,303 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const uoLinkClient = require('../utils/uoLinkClient')
const bridge = require('../utils/clilocBridge')
// The walk over `GET /cliloc`, driven against a stubbed sidecar client.
//
// Everything asserted here is a way the shard can be wrong that leaves the
// website holding a table it believes is complete. That is the failure worth
// testing, because it is invisible downstream: a truncated cliloc table renders
// some items with names and some with ids, which is exactly what NO table looks
// like. None of these are hypothetical shapes — each corresponds to a field the
// paging envelope carries specifically so this side can tell the difference
// (docs/link/v8.md §3.4).
const saved = {}
function stub({ sources, pages }) {
saved.getAssetSources = uoLinkClient.getAssetSources
saved.getClilocTable = uoLinkClient.getClilocTable
const calls = []
uoLinkClient.getAssetSources = async () => sources
uoLinkClient.getClilocTable = async ({ lang, cursor } = {}) => {
calls.push({ lang, cursor: cursor ?? null })
const next = pages.shift()
if (!next) throw new Error('the walk asked for more pages than the test supplied')
return next
}
return calls
}
function restore() {
if (saved.getAssetSources) uoLinkClient.getAssetSources = saved.getAssetSources
if (saved.getClilocTable) uoLinkClient.getClilocTable = saved.getClilocTable
}
const ok = (data) => ({ ok: true, status: 200, data })
/** One page of rows, with the source fingerprint every page echoes. */
const page = (rows, extra = {}) =>
ok({
kind: 'cliloc.table.ok',
lang: 'enu',
file: 'cliloc.enu',
size: 4989921,
mtime: 1757462400000,
total: 3,
rows,
more: false,
cut: 'end',
...extra,
})
const sourcesReply = (file = {}) =>
ok({
kind: 'assets.sources.ok',
extractorVersion: 1,
imaging: { ok: true },
hashing: false,
complete: true,
files: [
{ name: 'cliloc.enu', path: '/uo/cliloc.enu', size: 4989921, mtime: 1757462400000, sha256: 'abc', ...file },
{ name: 'art.mul', path: '/uo/art.mul', size: 148000000, mtime: 1, sha256: null },
],
})
// ── Stage 1: the fingerprint ───────────────────────────────────────────────
test('fingerprint picks the cliloc file out of the client manifest', async (t) => {
stub({ sources: sourcesReply(), pages: [] })
t.after(restore)
const fp = await bridge.fingerprint()
assert.equal(fp.file, 'cliloc.enu')
assert.equal(fp.size, 4989921)
assert.equal(fp.sha256, 'abc')
assert.equal(fp.extractorVersion, 1)
})
test('a client with no cliloc file is NO_SOURCE, not a crash', async (t) => {
stub({
sources: ok({ extractorVersion: 1, files: [{ name: 'art.mul', size: 1, mtime: 1 }] }),
pages: [],
})
t.after(restore)
await assert.rejects(bridge.fingerprint(), (err) => {
assert.equal(err.code, 'NO_SOURCE')
return true
})
})
test('the asset plane being switched off reads as a refusal, not a bug', async (t) => {
stub({
sources: { ok: false, status: 403, data: { reason: 'asset extraction is disabled on this shard' } },
pages: [],
})
t.after(restore)
await assert.rejects(bridge.fingerprint(), (err) => {
assert.equal(err.code, 'DISABLED')
return true
})
})
// A hash that has not been computed yet is the shard's ordinary first answer:
// hashing the 343 MB of art and animation it also serves cannot fit in a 10 s
// reply, so it happens off the request path. Treating a null hash as a CHANGE
// would make the panel show drift forever on a shard nobody has imported from.
test('a missing hash falls back to (size, mtime) rather than reading as drift', () => {
const before = { size: 10, mtime: 20, sha256: null, extractorVersion: 1 }
const after = { size: 10, mtime: 20, sha256: null, extractorVersion: 1 }
assert.equal(bridge.sameSource(before, after), true)
assert.equal(bridge.sameSource(before, { ...after, mtime: 21 }), false)
})
test('a hash on both sides beats size and mtime, which a patched-in-place file can preserve', () => {
const a = { size: 10, mtime: 20, sha256: 'aaa', extractorVersion: 1 }
assert.equal(bridge.sameSource(a, { ...a, sha256: 'bbb' }), false)
assert.equal(bridge.sameSource(a, { ...a, size: 11, mtime: 99 }), true)
})
test('the extractor version is part of the fingerprint, so a corrected reader drifts', () => {
const a = { size: 10, mtime: 20, sha256: 'aaa', extractorVersion: 1 }
assert.equal(bridge.sameSource(a, { ...a, extractorVersion: 2 }), false)
})
// ── Stage 2: the walk ──────────────────────────────────────────────────────
test('a one-page table comes back whole', async (t) => {
const calls = stub({
sources: sourcesReply(),
pages: [page([{ n: 3, f: 0, t: 'c' }, { n: 1, f: 2, t: 'a' }])],
})
t.after(restore)
const { entries, source } = await bridge.readCliloc()
assert.deepEqual(entries, [
{ number: 3, flag: 0, text: 'c' },
{ number: 1, flag: 2, text: 'a' },
])
assert.equal(source.pages, 1)
assert.equal(source.received, 2)
assert.equal(source.reported, 3)
assert.deepEqual(calls, [{ lang: 'enu', cursor: null }])
})
test('pages are walked by echoing the cursor back until more is false', async (t) => {
const calls = stub({
sources: sourcesReply(),
pages: [
page([{ n: 1, f: 0, t: 'a' }], { more: true, cursor: 'n:1', cut: 'budget' }),
page([{ n: 2, f: 0, t: 'b' }], { more: true, cursor: 'n:2', cut: 'budget' }),
page([{ n: 3, f: 0, t: 'c' }]),
],
})
t.after(restore)
const { entries, source } = await bridge.readCliloc()
assert.equal(entries.length, 3)
assert.equal(source.pages, 3)
assert.deepEqual(
calls.map((c) => c.cursor),
[null, 'n:1', 'n:2'],
)
})
// `cut` is the field that is easy to omit and expensive not to have. A short
// page means the source ended, the byte budget was spent, or the family hit its
// own limit — and only the first means finished.
test('a last page that did not end the table is refused, not imported', async (t) => {
stub({
sources: sourcesReply(),
pages: [page([{ n: 1, f: 0, t: 'a' }], { more: false, cut: 'limit' })],
})
t.after(restore)
await assert.rejects(bridge.readCliloc(), (err) => {
assert.equal(err.code, 'INCOMPLETE')
return true
})
})
test('a shard that does not advance its cursor is stopped rather than spun on', async (t) => {
stub({
sources: sourcesReply(),
pages: [
page([{ n: 1, f: 0, t: 'a' }], { more: true, cursor: 'n:1', cut: 'budget' }),
page([{ n: 2, f: 0, t: 'b' }], { more: true, cursor: 'n:1', cut: 'budget' }),
],
})
t.after(restore)
await assert.rejects(bridge.readCliloc(), (err) => {
assert.equal(err.code, 'STUCK')
return true
})
})
test('more:true with no cursor at all is the same refusal', async (t) => {
stub({
sources: sourcesReply(),
pages: [page([{ n: 1, f: 0, t: 'a' }], { more: true, cut: 'budget' })],
})
t.after(restore)
await assert.rejects(bridge.readCliloc(), (err) => {
assert.equal(err.code, 'STUCK')
return true
})
})
// The one failure a count cannot catch: an operator patches their client while
// the import is walking it. Half of what arrived is from a file that no longer
// exists, and nothing later can tell which half.
test('a client patched mid-walk aborts the whole import', async (t) => {
stub({
sources: sourcesReply(),
pages: [
page([{ n: 1, f: 0, t: 'a' }], { more: true, cursor: 'n:1', cut: 'budget' }),
page([{ n: 2, f: 0, t: 'b' }], { size: 5000000, mtime: 1757470000000 }),
],
})
t.after(restore)
await assert.rejects(bridge.readCliloc(), (err) => {
assert.equal(err.code, 'SOURCE_CHANGED')
return true
})
})
// 425 is flow control and the ORDINARY answer during an import — the shard's
// asset plane serves one request at a time on purpose — so it is retried rather
// than failed. (The backoff is real time, so this exercises one retry only.)
test('a busy shard is retried, because the work is happening', async (t) => {
saved.getAssetSources = uoLinkClient.getAssetSources
saved.getClilocTable = uoLinkClient.getClilocTable
t.after(restore)
let attempts = 0
uoLinkClient.getAssetSources = async () => sourcesReply()
uoLinkClient.getClilocTable = async () => {
attempts++
if (attempts === 1) return { ok: false, status: 425, data: { kind: 'bridge.busy' } }
return page([{ n: 1, f: 0, t: 'a' }])
}
const { entries } = await bridge.readCliloc()
assert.equal(attempts, 2)
assert.equal(entries.length, 1)
})
test('a page with no rows array is malformed, not an empty table', async (t) => {
stub({ sources: sourcesReply(), pages: [ok({ kind: 'cliloc.table.ok', more: false, cut: 'end' })] })
t.after(restore)
await assert.rejects(bridge.readCliloc(), (err) => {
assert.equal(err.code, 'MALFORMED')
return true
})
})
test('a shard that never ends the table is bounded by the page cap', async (t) => {
saved.getAssetSources = uoLinkClient.getAssetSources
saved.getClilocTable = uoLinkClient.getClilocTable
t.after(restore)
let n = 0
uoLinkClient.getAssetSources = async () => sourcesReply()
uoLinkClient.getClilocTable = async () => {
n++
return page([{ n, f: 0, t: 'x' }], { more: true, cursor: `n:${n}`, cut: 'budget' })
}
await assert.rejects(bridge.readCliloc(), (err) => {
assert.equal(err.code, 'TOO_LARGE')
return true
})
assert.equal(n, bridge.MAX_PAGES)
})
test('rows with an unusable id are dropped rather than stored as NaN', async (t) => {
stub({
sources: sourcesReply(),
pages: [page([{ n: 'nonsense', f: 0, t: 'a' }, { n: 7, f: 0, t: 'b' }])],
})
t.after(restore)
const { entries } = await bridge.readCliloc()
assert.deepEqual(entries, [{ number: 7, flag: 0, text: 'b' }])
})