feat(assets): the panel that operates the client-file imports (Phase 8)
Some checks failed
PR Checks / frozen-manifest (pull_request) Successful in 1m3s
PR Checks / server-tests (pull_request) Successful in 8m4s
PR Checks / client-build (pull_request) Failing after 14m21s

Admin -> Client Files: one page over the three things that come out of the
operator's UO client -- creature portraits, item and land pictures, and the
cliloc table. One page rather than three because they are one job: same client
install, same bridge, and all of them change at the same moment, when the
operator patches that client. Boot never asks the shard for any of it, so these
buttons are the only thing that imports.

The cliloc pair had had no UI at all since phase 2. On a bridged install, where
boot deliberately stopped calling the shard, that meant `curl` was the only way
to load 67,496 names.

Update and Re-import everything are section 6's two stages as two buttons rather
than one button and a checkbox, because they cost wildly different things. A
vanished key is reviewed in the page and not in a table -- an asset import only
happens because someone pressed a button here, so the review is already in front
of the person who caused it -- and it shows each key's PICTURE, since
`body/820/a23` names nothing a human recognises. `shard_asset_meta` gained a
`last` block (what the import did, who ran it) so the panel can answer "did last
week's import do anything" without scrolling core's whole activity log.

The live walk against a real shard imported 1,095 portraits in 3.5 s, warmed 313
item pictures in 0.6 s and reloaded 67,496 cliloc rows in 1.7 s -- and found two
DELETIONS that predate this phase and that no test could see, because only a
screen showing the numbers together makes them visible:

  * The body import diffed its manifest against every family's rows. Phase 5 put
    item and land art in the same table, and a body manifest never mentions
    them, so all 313 item pictures were staged for deletion with a sentence
    saying the shard had stopped offering them.
  * An approved vanish unlinked the sprite and kept the row. The catalogue went
    on counting a picture that was gone, the atlas could point a creature page at
    a missing file, and the next forced import offered the same key for review
    again -- reporting "nothing was changed" about a file it had deleted.

Both fixed here, with the removals now inside `saveAssets`'s own transaction.
The same whole-table read made the panel announce a 1,408-row creature catalogue
on an install holding 1,095 portraits and 313 item pictures.

Protocol stays 8 and EXTRACTOR_VERSION stays 3: nothing on the wire changed.

Refs: docs/link/v8.md sections 12.2, 14, 16 (phase 8)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-14 08:10:16 -05:00
parent 7dbdaa14ad
commit 675e879b48
13 changed files with 1451 additions and 54 deletions

View File

@@ -30,11 +30,26 @@ async function batched(conn, sql, rows) {
// ── the manifest side ──────────────────────────────────────────────────────
/** Every asset row we hold, as a Map of key → row. */
async function allAssets() {
/**
* The asset rows we hold in one family, as a Map of key → row.
*
* **The family is required, and the reason is a deletion.** The import diffs what
* this returns against a manifest, and a manifest is always of ONE family (§14 —
* the reply carries a single catalogue id, so it could not be otherwise). Phase 5
* put item and land art in this table beside the body catalogue; read whole, the
* body import then sees every item picture as a key the shard has stopped
* offering and stages all of them for deletion. On a real install that is a few
* hundred pictures the operator is asked to approve the loss of, with a sentence
* that is entirely wrong about what happened.
*
* `null` reads every family, which nothing in the import path should ever want.
*/
async function allAssets(family = null) {
const rows = await query(
'SELECT asset_key, family, sha256, bytes, width, height, body, action, direction, file, catalog ' +
'FROM shard_assets',
'FROM shard_assets' +
(family ? ' WHERE family = ?' : ''),
family ? [family] : [],
)
const map = new Map()
@@ -68,8 +83,16 @@ async function allAssets() {
* `ON DUPLICATE KEY UPDATE` rather than delete-and-insert, because an unchanged
* key must keep the file it already points at — re-writing the file for every
* asset on every Update is exactly the cost the manifest diff exists to avoid.
*
* `remove` is the keys an operator has APPROVED the loss of (§6). They are
* deleted here, inside the same transaction, because a half-applied removal is
* the worst of the three outcomes: until phase 8 the import unlinked the sprite
* and left the row, so the catalogue still counted a picture that was gone, the
* atlas could point a creature at a deleted file, and the very next forced
* import staged the same key for review again — telling the operator nothing had
* changed, about a file it had already deleted.
*/
async function saveAssets(rows, meta) {
async function saveAssets(rows, meta, remove = []) {
const conn = await core.pool.getConnection()
try {
@@ -101,6 +124,16 @@ async function saveAssets(rows, meta) {
values,
)
if (remove.length > 0) {
for (let i = 0; i < remove.length; i += BATCH) {
const slice = remove.slice(i, i + BATCH)
await conn.query(
`DELETE FROM shard_assets WHERE asset_key IN (${slice.map(() => '?').join(',')})`,
slice,
)
}
}
if (meta) {
await conn.query(
'INSERT INTO shard_asset_meta (id, payload) VALUES (1, ?) ' +
@@ -188,6 +221,31 @@ async function countByFamily() {
return out
}
/**
* Record what the import that just finished actually did (§6, phase 8).
*
* **A second write, deliberately.** The interesting half of that summary — how
* many atlas creatures resolved to a body id, how many portraits were applied —
* does not exist when `saveAssets` commits: producing it takes another round trip
* to the shard, and widening the rows-and-meta transaction to cover a network
* call is how an import ends up holding a write lock for the length of a timeout.
*
* `JSON_SET` rather than a read-modify-write for the same reason the rest of this
* file is one statement per operation: the payload is the gate an Update compares
* against, and re-serialising it from the outside is how a concurrent import
* loses a field nobody notices for a month.
*
* It is cosmetic by design — nothing reads `last` to make a decision, the panel
* only renders it — so a failure here is logged and swallowed by the caller
* rather than failing an import that has already applied.
*/
async function recordLastImport(last) {
await query('UPDATE shard_asset_meta SET payload = JSON_SET(payload, ?, JSON_COMPACT(?)) WHERE id = 1', [
'$.last',
JSON.stringify(last),
])
}
async function getMeta() {
const rows = await query('SELECT payload, imported_at FROM shard_asset_meta WHERE id = 1')
if (rows.length === 0) return null
@@ -195,9 +253,23 @@ async function getMeta() {
return { ...payload, importedAt: rows[0].imported_at }
}
async function countAssets() {
/**
* How many assets we hold, optionally in one family.
*
* **The family argument is not optional in spirit.** Phase 5 put item and land
* art in this table beside the body catalogue, and they are counted differently
* by nature: the catalogue is a SET with a known size, while item art is however
* much of an unbounded space the site has happened to ask for. A whole-table
* count answers neither question — it reported the creature catalogue as 1,408
* rows on an install holding 1,095 portraits and 313 item pictures, which is a
* confident wrong number in the one place an operator checks whether the import
* worked.
*/
async function countAssets(family = null) {
const rows = await query(
'SELECT COUNT(*) AS n, SUM(file IS NOT NULL) AS stored FROM shard_assets',
'SELECT COUNT(*) AS n, SUM(file IS NOT NULL) AS stored FROM shard_assets' +
(family ? ' WHERE family = ?' : ''),
family ? [family] : [],
)
return { total: Number(rows[0]?.n) || 0, stored: Number(rows[0]?.stored) || 0 }
}
@@ -295,6 +367,7 @@ async function artBySlug() {
module.exports = {
allAssets,
saveAssets,
recordLastImport,
getMeta,
countAssets,
replaceBodies,

View File

@@ -176,8 +176,12 @@ function removeSprite(name) {
* an operator recovers from a deleted uploads directory — the database still
* holds the hashes, but the files behind them are gone). `approve` accepts a
* catalogue that no longer offers keys we hold.
*
* `by` is who pressed the button, carried through only so the panel can say what
* the last import did and who ran it without reading the audit log (phase 8). It
* decides nothing.
*/
async function importAssets({ force = false, approve = false } = {}) {
async function importAssets({ force = false, approve = false, by = null } = {}) {
if (!(await shardLinked())) {
return {
status: 'skipped',
@@ -207,7 +211,7 @@ async function importAssets({ force = false, approve = false } = {}) {
const meta = await db.getMeta().catch(() => null)
if (!force && bridge.sameSources(sources, meta?.sources)) {
const counts = await db.countAssets()
const counts = await db.countAssets(bridge.FAMILY)
const bodies = await db.countBodies()
return {
@@ -228,7 +232,10 @@ async function importAssets({ force = false, approve = false } = {}) {
return failure(err, 'asset manifest')
}
const held = await db.allAssets()
// The body family only. This diff decides what gets DELETED, and the manifest
// it is diffed against is of one family by construction — so reading the whole
// table here stages every item picture phase 5 warmed as a vanished key.
const held = await db.allAssets(bridge.FAMILY)
const offered = new Set(manifest.rows.map((r) => r.key))
// A key we hold that the shard no longer offers. An unmounted client volume and
@@ -243,7 +250,11 @@ async function importAssets({ force = false, approve = false } = {}) {
reason:
`${vanished.length} asset(s) this site holds are no longer offered by the shard; ` +
'nothing was changed',
vanished: vanished.slice(0, 50),
// Each one carries the picture it currently has, because the decision the
// operator is being asked for is "is it right that these disappear?" and a
// list of keys cannot be looked at. `body/820/a23` names nothing a human
// recognises; the horse it is a picture of does.
vanished: vanished.slice(0, 50).map((key) => ({ key, file: held.get(key)?.file ?? null })),
vanishedCount: vanished.length,
}
}
@@ -318,14 +329,21 @@ async function importAssets({ force = false, approve = false } = {}) {
}
try {
await db.saveAssets(rows, {
catalog: manifest.catalog,
extractorVersion: manifest.extractorVersion,
family: bridge.FAMILY,
playerBodies: manifest.playerBodies,
sources: { files: sources.files, extractorVersion: sources.extractorVersion },
count: rows.length,
})
await db.saveAssets(
rows,
{
catalog: manifest.catalog,
extractorVersion: manifest.extractorVersion,
family: bridge.FAMILY,
playerBodies: manifest.playerBodies,
sources: { files: sources.files, extractorVersion: sources.extractorVersion },
count: rows.length,
},
// The approved removals go in with the write. The sprite is already
// unlinked above; leaving the row behind would keep counting a picture
// that is gone and re-offer the same key for review on every import.
removed,
)
} catch (err) {
return { status: 'failed', reason: err.message }
}
@@ -333,6 +351,36 @@ async function importAssets({ force = false, approve = false } = {}) {
const bodies = await resolveAtlasBodies()
const art = await applyArt()
// What this run did, kept beside the catalogue it produced (phase 8). The admin
// panel renders it as "the last import", which is the question an operator has
// straight after pressing a button that takes a minute and prints nothing:
// what changed, and did the body pass find drift. Core's activity log records
// the same action, but it is one unfiltered list of every admin action on the
// site, so an import from three client patches ago is not findable there.
//
// Best-effort on purpose: the import has already applied, and losing a cosmetic
// summary must not turn a successful import into a failure.
const last = {
at: new Date().toISOString(),
by,
force,
approve,
assets: rows.length,
fetched: fetched.assets.size,
written,
removed: removed.length,
absent: fetched.missing.absent,
unsupported: fetched.missing.unsupported,
bodies: bodies.tally ?? null,
art: art.applied ?? 0,
}
try {
await db.recordLastImport(last)
} catch (err) {
log.warn('could not record the import summary', { error: err.message })
}
log.info('asset import applied', {
assets: rows.length,
fetched: fetched.assets.size,
@@ -460,12 +508,21 @@ async function applyArt() {
* state with a reason an operator can act on.
*/
async function getStatus() {
const counts = await db.countAssets().catch(() => ({ total: 0, stored: 0 }))
// The BODY family, not the whole table: item and land art live here too and
// are reported separately below, because they are a working set rather than a
// catalogue with a size (§11).
const counts = await db.countAssets(bridge.FAMILY).catch(() => ({ total: 0, stored: 0 }))
const bodies = await db.countBodies().catch(() => ({ total: 0, resolved: 0 }))
const meta = await db.getMeta().catch(() => null)
const families = await db.countByFamily().catch(() => ({}))
const status = {
// Is there a shard to ask at all? Stated rather than left to be inferred:
// the panel disables its import buttons on it, and the alternative — reading
// it out of `reason`'s wording, or out of `shard` being null, which is also
// what a shard that is merely DOWN looks like — is a sentence that decides
// behaviour.
linked: await shardLinked(),
loaded: {
assets: counts.total,
stored: counts.stored,
@@ -480,12 +537,18 @@ async function getStatus() {
// for and holds, which is the only number that means anything here.
items: families.static?.stored ?? 0,
land: families.land?.stored ?? 0,
// What the last import did, and who ran it (phase 8). Null on an install
// that has never imported, and on one whose last import predates this
// field — both of which render as "no import recorded" rather than as
// zeroes, because an import that fetched nothing is a real and different
// answer from one that never happened.
last: meta?.last ?? null,
},
shard: null,
drift: null,
}
if (!(await shardLinked())) {
if (!status.linked) {
status.reason = 'uo-link is not configured'
return status
}