feat(atlas): the spawn atlas reads the shard, not the shard's filesystem (Phase 7)
`spawnAtlasSource.js` gains a second backend behind its existing interface
(docs/link/v8.md 10). Where a shard is linked and enabled the tree arrives over
the sidecar; where there is none, a local ServUO tree is read exactly as before.
An explicit --servuo path is an instruction and overrules both.
The parsers do not move. spawnAtlasParse.js is still pure, still fs-free and
still CI-covered without a ServUO tree anywhere near it; `buildFromFiles` is now
where the parse starts, and both readers feed it the same shape.
treeBridge.js walks the manifest and then the chunks. Three of its checks are
not decoration -- each is a way this ends in a tree that LOOKS imported, and XML
is forgiving enough that a mis-assembled spawn file parses cleanly and simply
has fewer spawns in it:
- every chunk re-declares its address and carries the hash of its own
uncompressed bytes, and chunks are placed by declared index rather than
arrival order
- the whole file is hashed after reassembly against its manifest row
- the catalog must not move mid-walk, or the import is refused rather than
stitched out of two trees
Boot does not call the shard. The same answer 17.7 gave the cliloc table, and
the same reasoning: a local tree hashes in ~120 ms and skips, while a round trip
in the boot sequence would answer "no" on every restart that did not follow a
map edit. Editing spawn files is an operator action, so importing is one --
Admin -> Spawn Atlas -> Import. What that costs is real and is said out loud in
the panel, the CLI and the log: an install on the bridge has NO automatic
refresh at all.
Two things the live walk found that the unit tests could not:
- PARSER_VERSION 4 -> 5. The parse is order-sensitive in one place -- the
decoration index keeps the FIRST item id it sees for a type -- and the two
readers agreed on a stock tree by coincidence, since the filesystem reader
walks each directory with localeCompare while the shard sorts whole relative
paths. buildFromFiles now sorts by label, ordinally, once, whatever order
the files arrived in. Identical input, a different answer for a handful of
types: exactly what the version number exists to push through the hash gate.
The parity test asserted deepEqual, which ignores key order; it now asserts
serialised equality too.
- The source fingerprint is taken over RAW BYTES at both ends. Hashing decoded
text hashes a UTF-8 re-encoding -- identical for valid UTF-8, different for a
file that is not, because an undecodable byte becomes U+FFFD and never comes
back. One Latin-1 character in a creature name would have made the drift gate
report a change on every import, forever, with the tree untouched.
A 200 from assets.sources also stopped meaning "the client files are on offer":
a shard may now serve its configuration tree while declining to serve its UO
client. Both client-file readers check `assetsEnabled` and say DISABLED, instead
of reading an empty file list as "your client has no cliloc.enu" and sending an
operator to their client install for a setting that lives on their shard.
Measured end to end against a live shard and the real sidecar: 141 files,
11.9 MB, 158 chunks, 3 pages, 1.33 MB on the wire, 512 ms; every file
byte-identical to disk; and the atlas built over the bridge identical to the one
built off it -- 6,455 points, 800 creatures, 387 regions, 558 landmarks,
25 champions, 309 decoration types.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
156
server/test/atlasSourceSelection.test.js
Normal file
156
server/test/atlasSourceSelection.test.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// Which atlas source runs, and what boot does with the answer
|
||||
// (docs/link/v8.md §10, §17.7; docs/website/SPAWN_ATLAS.md).
|
||||
//
|
||||
// The model is the only place that decides between a local ServUO tree and the
|
||||
// shard bridge, so these drive it with the shard, the database and the
|
||||
// filesystem all stubbed. Nothing here reaches a real sidecar or a real tree.
|
||||
//
|
||||
// The rule under test is the one the cliloc pipeline settled first and this
|
||||
// inherits: the bridge wins whenever uo-link is configured and enabled, a local
|
||||
// path is what a site with no shard link uses, and an explicit path is an
|
||||
// instruction that overrules both.
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const atlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
const db = require('../model/shardAtlas/shardAtlas.db')
|
||||
const source = require('../utils/spawnAtlasSource')
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const { ctx } = require('./_setup')
|
||||
|
||||
const saved = {
|
||||
getMeta: db.getMeta,
|
||||
getPending: db.getPending,
|
||||
getFacets: db.getFacets,
|
||||
hashFrom: source.hashFrom,
|
||||
buildFrom: source.buildFrom,
|
||||
getSafe: uoLinkConfig.getSafe,
|
||||
settingsGet: ctx.settings.get,
|
||||
}
|
||||
|
||||
function restore() {
|
||||
db.getMeta = saved.getMeta
|
||||
db.getPending = saved.getPending
|
||||
db.getFacets = saved.getFacets
|
||||
source.hashFrom = saved.hashFrom
|
||||
source.buildFrom = saved.buildFrom
|
||||
uoLinkConfig.getSafe = saved.getSafe
|
||||
ctx.settings.get = saved.settingsGet
|
||||
}
|
||||
|
||||
/** Whatever source the model chose, captured rather than read. */
|
||||
function rig({ linked = true, treePath = '', meta = null } = {}) {
|
||||
const asked = { hash: [], build: [] }
|
||||
|
||||
uoLinkConfig.getSafe = async () => ({
|
||||
enabled: linked,
|
||||
baseUrl: linked ? 'http://127.0.0.1:8099' : null,
|
||||
})
|
||||
ctx.settings.get = async () => treePath
|
||||
|
||||
db.getMeta = async () => meta
|
||||
db.getPending = async () => null
|
||||
db.getFacets = async () => []
|
||||
|
||||
source.hashFrom = async (descriptor) => {
|
||||
asked.hash.push(descriptor)
|
||||
return { 'Data/Regions.xml': 'aa' }
|
||||
}
|
||||
source.buildFrom = async (descriptor) => {
|
||||
asked.build.push(descriptor)
|
||||
throw new Error('the test stops before a build')
|
||||
}
|
||||
|
||||
return asked
|
||||
}
|
||||
|
||||
test('a linked shard is the atlas source, and the configured path is not consulted', async () => {
|
||||
const asked = rig({ linked: true, treePath: '/srv/servuo' })
|
||||
|
||||
const status = await atlas.status()
|
||||
|
||||
assert.equal(status.source, 'bridge')
|
||||
assert.equal(status.path, 'the shard bridge')
|
||||
assert.equal(status.configured, true)
|
||||
assert.deepEqual(asked.hash[0], { kind: 'bridge', root: '' })
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('with no shard linked the configured tree is the source', async () => {
|
||||
const asked = rig({ linked: false, treePath: '/srv/servuo' })
|
||||
|
||||
const status = await atlas.status()
|
||||
|
||||
assert.equal(status.source, 'fs')
|
||||
assert.equal(status.path, '/srv/servuo')
|
||||
assert.deepEqual(asked.hash[0], { kind: 'fs', root: '/srv/servuo' })
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('an explicit path overrules the bridge — it is an instruction, not a default', async () => {
|
||||
const asked = rig({ linked: true, treePath: '/srv/servuo' })
|
||||
|
||||
await atlas.status({ path: '/tmp/other-tree' })
|
||||
|
||||
assert.deepEqual(asked.hash[0], { kind: 'fs', root: '/tmp/other-tree' })
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('no shard and no path is "nothing configured", not an error', async () => {
|
||||
rig({ linked: false, treePath: '' })
|
||||
|
||||
const status = await atlas.status()
|
||||
assert.equal(status.configured, false)
|
||||
|
||||
const result = await atlas.refresh()
|
||||
assert.equal(result.status, 'skipped')
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('boot does not call the shard; it says where the import lives instead', async () => {
|
||||
// §17.7's rule, and the reason it is not free: an install whose atlas comes
|
||||
// over the bridge has NO automatic refresh at all, so the skip has to be
|
||||
// deliberate and visible rather than a path that quietly does nothing.
|
||||
const asked = rig({ linked: true, treePath: '/srv/servuo' })
|
||||
|
||||
const result = await atlas.refreshOnBoot()
|
||||
|
||||
assert.equal(result.status, 'skipped')
|
||||
assert.equal(result.source, 'bridge')
|
||||
assert.equal(asked.hash.length, 0, 'boot made no shard call at all')
|
||||
assert.equal(asked.build.length, 0)
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('boot still refreshes by itself from a local tree', async () => {
|
||||
const asked = rig({ linked: false, treePath: '/srv/servuo' })
|
||||
|
||||
await atlas.refreshOnBoot()
|
||||
|
||||
assert.deepEqual(asked.hash[0], { kind: 'fs', root: '/srv/servuo' })
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a source that cannot be read is reported, with which end could not read it', async () => {
|
||||
rig({ linked: true, treePath: '' })
|
||||
|
||||
source.hashFrom = async () => {
|
||||
const { TreeBridgeError } = require('../utils/treeBridge')
|
||||
throw new TreeBridgeError('the shard is not serving its tree', 'DISABLED')
|
||||
}
|
||||
|
||||
const result = await atlas.refresh()
|
||||
|
||||
assert.equal(result.status, 'unavailable')
|
||||
assert.equal(result.source, 'bridge')
|
||||
assert.equal(result.code, 'DISABLED')
|
||||
|
||||
restore()
|
||||
})
|
||||
Reference in New Issue
Block a user