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>
302 lines
10 KiB
JavaScript
302 lines
10 KiB
JavaScript
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,
|
|
}
|