Merge pull request 'fix(assets): a busy shard is not a broken one, and a column that reached no existing install (Phase 9a)' (#42) from feat/asset-bridge-p9 into edge

Reviewed-on: #42
This commit is contained in:
2026-09-14 22:26:29 +00:00
3 changed files with 57 additions and 4 deletions

View File

@@ -13,6 +13,7 @@ module that follows.
│ module-uo (>>> HERE <<<) │
│ shard status · spawn atlas · marketplace │
│ governors · cliloc · town crier · uo-link│
│ client files: portraits, item art, names │
└───────────────────────────────────────────┘
│ server half: routers, models, schema fragment
│ client half: prebuilt ESM chunk, SPA routes + nav
@@ -182,7 +183,7 @@ reaches the container.
|---|---|---|
| `UOLINK_BASE_URL` | — | Default sidecar base URL for a site with nothing saved yet. The admin panel's stored value wins. |
| `UOLINK_WS_URL` | — | Same, for the WebSocket URL. |
| `UOLINK_PROTOCOL` | `3` | Wire protocol this build speaks. Again only a fallback — set it lower only if you deliberately run an older sidecar. |
| `UOLINK_PROTOCOL` | `8` | Wire protocol this build speaks. Again only a fallback — set it lower only if you deliberately run an older sidecar. |
| `TOWNCRIER_DURATION_SEC` | `3600` | How long a published news post's in-game town-crier message stays up (≤ `86400`). |
**The sidecar's auth token is deliberately not here.** It is entered in Admin → Shard, encrypted at

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import api from '../../api.js'
import { ErrorState, Loading } from '../../core.js'
import Row from '../../components/DetailRow.jsx'
@@ -245,6 +245,28 @@ export default function ClientFiles() {
load()
}, [load])
// One automatic re-read when the shard answered BUSY (§3.2's single slot),
// and exactly one per mount.
//
// BUSY is not a fault and it is not sticky on the shard — it means something
// else held the asset slot for longer than the client's own 425 backoff, and
// the two things that hold it are both ordinary: an import the operator
// started, and the item-art warm pass refilling itself after a client patch.
// The panel does not poll, so without this the operator is left reading a
// refusal about a shard that was free again seconds later, until they think to
// reload. A second read clears the common case; if it is still busy, the
// sentence says to come back, because a page that retried forever would be
// holding the slot it is waiting for.
const retried = useRef(false)
useEffect(() => {
if (retried.current || busy) return
const stillBusy = assets?.code === 'BUSY' || clilocs?.code === 'BUSY'
if (!stillBusy) return
retried.current = true
const t = setTimeout(() => load({ quiet: true }), 4000)
return () => clearTimeout(t)
}, [assets, clilocs, busy, load])
// Every action shares this: run it, say what it said, then re-read status so
// the panel reflects the world rather than what we assumed happened.
async function run(section, table, fn) {
@@ -347,9 +369,22 @@ export default function ClientFiles() {
style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}
className="sans"
>
<strong style={{ color: 'var(--head)' }}>The shard is not answering for client files.</strong>
{/* BUSY is the one code here that is not a fault, and saying "the shard
is not answering" about it sends an operator to check a shard that is
working. The slot is held by something ordinary — an import running,
or the warm pass — and it frees itself. */}
<strong style={{ color: 'var(--head)' }}>
{assets.code === 'BUSY'
? 'The shard is busy with another client-file request.'
: 'The shard is not answering for client files.'}
</strong>
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
{assets.reason}
{assets.code === 'BUSY'
? 'The shard serves one of these at a time, so an import running now — or the' +
' item-picture pass refilling itself after a client patch — holds it until it is' +
' done. This page re-reads once on its own; if the counts below are still missing' +
' after that, reload in a moment.'
: assets.reason}
{assets.code === 'DISABLED' &&
' — set Bridge.AssetsEnabled on the shard to allow it to read its own client files.'}
</p>

View File

@@ -957,3 +957,20 @@ UPDATE uo_link_config SET protocol = 8
WHERE id = 1 AND protocol < 8
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_8_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_8_migrated', '1');
-- `shard_spawn_points.unique_id` for an install that already had the table
-- (Asset Bridge phase 9; the column itself is Events phase 12b).
--
-- The column was added to the CREATE TABLE above and nowhere else, so it reached
-- fresh installs and no existing one -- `CREATE TABLE IF NOT EXISTS` does not add
-- a column to a table that is already there, which is what every ALTER in this
-- file exists to do. `replaceAtlas` inserts `unique_id` unconditionally, so on an
-- upgraded install EVERY spawn-atlas import since v1.2.0 has failed outright with
-- `Unknown column 'unique_id' in 'INSERT INTO'` -- the bestiary, the spawn map and
-- the champion altars all frozen at whatever was last imported.
--
-- Found by the phase 9 acceptance walk, on a rig whose tables predate 12b: a fresh
-- install cannot reproduce it, and neither can a test whose schema is this file
-- applied to an empty database. That is the same blind spot the protocol-pin block
-- above records, two phases running.
ALTER TABLE shard_spawn_points ADD COLUMN IF NOT EXISTS unique_id VARCHAR(64) NULL;