feat(server): port the UO models, utils and schema fragment

The data half of the extraction: 8 model directories, 13 utils, the shard
stream catalog and the 27-table schema fragment with its purge.

server/core.js is what makes the port a one-line import change per file rather
than a signature change per function. Ported code requires its dependencies at
file scope -- `const { query } = require('../../core')` -- which runs before
register() has been called and before any ctx exists. So every member is a
stable function that resolves ctx when CALLED, and nothing may be destructured
off ctx at init either, because core is free to hand over a getter.

Two helpers are vendored rather than taken from ctx, and the line between them
is the point. utils/excerpt.js is core's deriveExcerpt -- nine lines of pure
text handling. Core's sanitiser next to it was NOT copied: a second copy of a
security control diverges silently the moment either is fixed. announceLinks.js
vendors legError and articleUrl the same way, but baseUrl could not be: core's
reads APP_BASE_URL, and §2.7 forbids a module reading core's environment, so it
comes off ctx.site.baseUrl.

The schema fragment is core's 27 shard_*/uo_link_* statements, verbs CREATE,
ALTER and UPDATE only, every CREATE TABLE guarded. Two of its tables carry a
foreign key INTO users, which is allowed and is why the replay order matters --
core's schema is in place before this runs. The reverse never occurs and must
not: it would make core unable to boot without a module installed.

One real port bug caught by the integration run, not by tests: the atlas art
map resolved `../../../db/data`, which pointed at core's tree when this file
lived there and points outside server/ now. A path that happens to resolve is
exactly what survives a green suite, because the absent-file branch returns {}
and looks like the normal case.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:06:26 -05:00
committed by Claude
parent 47809854ef
commit fe3251a543
40 changed files with 7967 additions and 3 deletions

View File

@@ -0,0 +1,301 @@
const core = require('../../core')
const { query } = core
// Raw SQL for the player-vendor market index (Protocol 3.0 vendor.listing).
//
// Two tables, both INGEST-OWNED: `shard_vendors` (one row per shop) and
// `shard_vendor_items` (one row per priced listing). Nothing else in the codebase
// writes to either. No foreign keys, consistent with every other shard_* table.
// Insert batch size for one vendor's listings. A shop is capped at
// MarketMaxListings (250 by default) on the shard side, so in practice this is
// one batch — it exists for the operator who raised that cap.
const BATCH = 500
// LIKE wildcards in user input. `%` and `_` are not special to the parameterized
// query — they are special to LIKE itself — so a search for "50% off" would
// otherwise match everything containing "50" and a search for "_" would match
// every single-character name. Escaped with a backslash, which is MariaDB's
// default LIKE escape (no ESCAPE clause needed).
const likeTerm = (q) => `%${String(q).replace(/[\\%_]/g, (c) => `\\${c}`)}%`
/**
* Replace one vendor's whole row and listing set, in one transaction.
*
* Delete-then-insert rather than a diff, because the frame is AUTHORITATIVE for
* that vendor: the shard's sweep only emits a shop whose contents, prices or
* location moved, and when it does it sends the whole shop. Reconciling it item
* by item would be more code for the same result and would leave sold items
* behind on any path the reconciliation missed.
*
* All-or-nothing matters here for a specific reason: the two writes are "the
* shop" and "what is in it", and a failure between them leaves a shop advertising
* an inventory it no longer has (or none at all) — visibly wrong on the page, and
* indistinguishable from a genuinely empty shop.
*/
async function replaceVendor(vendor, items) {
const conn = await core.pool.getConnection()
try {
await conn.beginTransaction()
await conn.query(
`INSERT INTO shard_vendors
(serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
item_count, item_total, truncated, t)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), owner_serial = VALUES(owner_serial),
owner_name = VALUES(owner_name), map = VALUES(map), x = VALUES(x), y = VALUES(y),
z = VALUES(z), region = VALUES(region), house = VALUES(house),
item_count = VALUES(item_count), item_total = VALUES(item_total),
truncated = VALUES(truncated), t = VALUES(t),
-- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP:
-- MariaDB does not fire that when every column is written back
-- unchanged, and a shop that is re-published identically is still
-- FRESHLY CONFIRMED. Without this the staleness banner would age a
-- perfectly current shop forever.
updated_at = CURRENT_TIMESTAMP`,
[
vendor.serial,
vendor.shopName ?? null,
vendor.ownerSerial ?? null,
vendor.ownerName ?? null,
vendor.map ?? null,
Number.isFinite(vendor.x) ? vendor.x : null,
Number.isFinite(vendor.y) ? vendor.y : null,
Number.isFinite(vendor.z) ? vendor.z : null,
vendor.region ?? null,
vendor.house ?? null,
items.length,
Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
vendor.truncated ? 1 : 0,
Number.isFinite(vendor.t) ? vendor.t : null,
],
)
await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [vendor.serial])
const rows = items.map((i) => [
vendor.serial,
i.serial,
i.itemId,
i.hue,
i.amount,
i.price,
i.name,
i.cliloc,
i.displayName,
i.child ? 1 : 0,
])
for (let i = 0; i < rows.length; i += BATCH) {
await conn.batch(
`INSERT INTO shard_vendor_items
(vendor_serial, serial, item_id, hue, amount, price, name, cliloc, display_name, child)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
rows.slice(i, i + BATCH),
)
}
await conn.commit()
return { items: rows.length }
} catch (err) {
await conn.rollback().catch(() => {})
throw err
} finally {
conn.release()
}
}
/** Drop one vendor and its listings (vendor.listing.remove). */
async function removeVendor(serial) {
const conn = await core.pool.getConnection()
try {
await conn.beginTransaction()
await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [serial])
await conn.query('DELETE FROM shard_vendors WHERE serial = ?', [serial])
await conn.commit()
} catch (err) {
await conn.rollback().catch(() => {})
throw err
} finally {
conn.release()
}
}
// ── Search ─────────────────────────────────────────────────────────────────
//
// The unit of a search RESULT is a listing, not a vendor: "who sells a vanquishing
// kryss and for how much" is the question, and answering it per vendor would make
// the caller flatten the shops back out. The vendor's columns ride along on the
// join so a result row is self-contained.
function searchWhere({ q, minPrice, maxPrice, itemId, map, region }) {
const where = ['i.price > 0']
const params = []
if (q) {
// Both the resolved display name and the item's own literal, because an item
// with a player-set name (most of what is actually worth searching for on a
// player-run shard) may have a generic cliloc.
where.push('(i.display_name LIKE ? OR i.name LIKE ?)')
params.push(likeTerm(q), likeTerm(q))
}
if (Number.isFinite(minPrice)) {
where.push('i.price >= ?')
params.push(minPrice)
}
if (Number.isFinite(maxPrice)) {
where.push('i.price <= ?')
params.push(maxPrice)
}
if (Number.isFinite(itemId)) {
where.push('i.item_id = ?')
params.push(itemId)
}
if (map) {
where.push('v.map = ?')
params.push(map)
}
if (region) {
where.push('v.region = ?')
params.push(region)
}
return { sql: `WHERE ${where.join(' AND ')}`, params }
}
// Whitelisted, because this interpolates into the statement. `recent` sorts by
// the vendor's freshness, which is the only way to see what has just been listed
// on a shard whose sweep is minutes wide.
const SORTS = {
price_asc: 'i.price ASC, i.id ASC',
price_desc: 'i.price DESC, i.id ASC',
recent: 'v.updated_at DESC, i.id ASC',
}
async function searchListings({ q, minPrice, maxPrice, itemId, map, region, sort, limit, offset }) {
const { sql, params } = searchWhere({ q, minPrice, maxPrice, itemId, map, region })
const order = SORTS[sort] || SORTS.price_asc
const rows = await query(
`SELECT i.serial, i.item_id, i.hue, i.amount, i.price, i.name, i.cliloc, i.display_name, i.child,
v.serial AS vendor_serial, v.shop_name, v.owner_serial, v.owner_name,
v.map, v.x, v.y, v.z, v.region, v.house, v.updated_at
FROM shard_vendor_items i
JOIN shard_vendors v ON v.serial = i.vendor_serial
${sql}
ORDER BY ${order}
LIMIT ? OFFSET ?`,
[...params, limit, offset],
)
const counted = await query(
`SELECT COUNT(*) AS n
FROM shard_vendor_items i
JOIN shard_vendors v ON v.serial = i.vendor_serial
${sql}`,
params,
)
return { rows, total: Number(counted[0]?.n) || 0 }
}
async function getVendor(serial) {
const rows = await query(
`SELECT serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
item_count, item_total, truncated, t, updated_at
FROM shard_vendors WHERE serial = ?`,
[serial],
)
return rows[0] || null
}
async function listVendorItems(serial, { limit, offset }) {
return query(
`SELECT serial, item_id, hue, amount, price, name, cliloc, display_name, child
FROM shard_vendor_items
WHERE vendor_serial = ?
ORDER BY price ASC, id ASC
LIMIT ? OFFSET ?`,
[serial, limit, offset],
)
}
/**
* What the market page's header needs: how big the index is, and how stale it may
* be. `staleAt` is the OLDEST vendor row — the round-robin sweep means a shop can
* be a full cycle behind, and the page says so rather than implying live prices.
*/
async function meta() {
const rows = await query(
`SELECT COUNT(*) AS vendors, MIN(updated_at) AS stale_at, MAX(updated_at) AS fresh_at
FROM shard_vendors`,
)
const items = await query('SELECT COUNT(*) AS n FROM shard_vendor_items')
return {
vendors: Number(rows[0]?.vendors) || 0,
items: Number(items[0]?.n) || 0,
staleAt: rows[0]?.stale_at || null,
freshAt: rows[0]?.fresh_at || null,
}
}
/** The distinct facets and regions holding vendors — drives the page's filters. */
async function listPlaces() {
const maps = await query(
'SELECT DISTINCT map FROM shard_vendors WHERE map IS NOT NULL ORDER BY map',
)
const regions = await query(
'SELECT DISTINCT region FROM shard_vendors WHERE region IS NOT NULL ORDER BY region',
)
return { maps: maps.map((r) => r.map), regions: regions.map((r) => r.region) }
}
// ── Cliloc re-resolution ───────────────────────────────────────────────────
/**
* One page of listings whose name still needs resolving, for the bulk pass that
* runs after a cliloc import.
*
* Keyed on `id > after` rather than OFFSET: the pass updates the very rows it is
* scanning, and an OFFSET walk over a table being rewritten skips rows. Every
* row with a cliloc is re-read, not just the unresolved ones, because an import
* can also CHANGE a name — a shard overlay relabelling a stock item is the whole
* reason overlays exist.
*/
async function listResolvableItems(after, limit) {
return query(
`SELECT id, cliloc, name, display_name
FROM shard_vendor_items
WHERE cliloc IS NOT NULL AND cliloc > 0 AND id > ?
ORDER BY id
LIMIT ?`,
[after, limit],
)
}
/** Write back a batch of re-resolved display names. */
async function updateDisplayNames(pairs) {
if (pairs.length === 0) return 0
const conn = await core.pool.getConnection()
try {
await conn.batch('UPDATE shard_vendor_items SET display_name = ? WHERE id = ?', pairs)
return pairs.length
} finally {
conn.release()
}
}
module.exports = {
replaceVendor,
removeVendor,
searchListings,
getVendor,
listVendorItems,
meta,
listPlaces,
listResolvableItems,
updateDisplayNames,
likeTerm,
}