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:
301
server/model/shardMarket/shardMarket.db.js
Normal file
301
server/model/shardMarket/shardMarket.db.js
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user