morphit/apps/indexer/scripts/orderbook-stream-smoke.ts

614 lines
24 KiB
TypeScript

/**
* Orderbook-stream pure helpers — tsx smoke runner.
*
* Covers buildWhereClauses (SQL fragment generation) and
* rowToWire (DB row → wire shape). Both are pure; testing
* them ensures the SSE endpoint's filter and wire format
* stay aligned with the REST endpoint.
*
* Usage (from apps/indexer):
* tsx scripts/orderbook-stream-smoke.ts
*/
import {
buildWhereClauses,
makeFetchSerializer,
rowToWire,
type OrderbookStreamRow
} from '../src/api/orderbookStreamHelpers.ts';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { parseExpectedSchema } from '../src/db/schemaDrift.ts';
const SMOKE_SRC = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
const readSmokeSrc = (p: string): string => readFileSync(resolve(SMOKE_SRC, p), 'utf8');
let failures = 0;
let scenarios = 0;
function scenario(name: string, fn: () => void): void {
scenarios++;
try {
fn();
console.log(`${name}`);
} catch (err) {
failures++;
console.log(`${name}`);
console.log(` ${err instanceof Error ? err.message : String(err)}`);
}
}
async function asyncScenario(name: string, fn: () => Promise<void>): Promise<void> {
scenarios++;
try {
await fn();
console.log(`${name}`);
} catch (err) {
failures++;
console.log(`${name}`);
console.log(` ${err instanceof Error ? err.message : String(err)}`);
}
}
/** Resolve after `n` macrotasks; lets the serializer's
* `await doFetch()` complete and the loop control logic
* to run. */
function tickN(n: number): Promise<void> {
let p = Promise.resolve();
for (let i = 0; i < n; i++) {
p = p.then(() => new Promise((resolve) => setTimeout(resolve, 0)));
}
return p;
}
function assertEqual(actual: unknown, expected: unknown, label: string): void {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a !== e) {
throw new Error(`${label}: expected ${e}, got ${a}`);
}
}
function assertTrue(cond: boolean, msg: string): void {
if (!cond) throw new Error(msg);
}
function makeRow(overrides: Partial<OrderbookStreamRow> = {}): OrderbookStreamRow {
return {
account: 'alice',
permlink: 'sell-btc-eur-2026-04',
side: 'sell',
asset: 'BTC',
fiat_currency: 'EUR',
amount_min: '50.00',
amount_max: '500.00',
price_model: 'mid+1%',
location_region: 'Berlin',
payment_methods: ['cash', 'sepa'],
// cp474 — REQUIRED by OrderbookStreamRow and previously absent from this
// fixture, so every makeRow() produced a shape the SQL never emits.
// `rowToWire` reads engagement_24h with no `??` default, so the wire
// payload silently dropped the key. null here = a crypto (non-barter)
// order, which is what this BTC row is.
accepted_assets: null,
specific_barter_title: null,
engagement_24h: 3,
terms: 'meet at café',
fee_method: 'blurt',
feedback_count: 5,
// cp473 — a DIFFERENT number from feedback_count on purpose. The card
// reads this; when the stream omitted it, every live orderbook card
// rendered "no trades".
trade_count: 9,
weighted_rating: '4.5',
// cp404 — reputation-score inputs + posting key now read by rowToWire.
last_feedback_at: new Date('2026-04-20T00:00:00Z'),
first_trade_complete_at: new Date('2026-02-01T00:00:00Z'),
posting_pubkey: 'BLT6vSMDaw3sLdJP7SjSxHCbtwLQoyTA2oc9dWDdmKZ2Jjw6Bh7d',
is_new_trader: false,
created_at: new Date('2026-04-01T10:00:00Z'),
updated_at: new Date('2026-04-26T12:00:00Z'),
expires_at: new Date('2026-05-01T00:00:00Z'),
...overrides
};
}
console.log('\n── Orderbook stream helpers ────────────────────────────');
// ─── buildWhereClauses ───────────────────────────────────────────
scenario('buildWhereClauses: empty filter → base clauses only', () => {
const { where, params } = buildWhereClauses({});
assertEqual(
where,
[
`o.status = 'live'`,
`o.fee_status IN ('verified', 'verified_by_attestation')`,
// BATCH19A-orderbook-1: filter past-expires_at orders
`(o.expires_at IS NULL OR o.expires_at > NOW())`
],
'where'
);
assertEqual(params, [], 'no params');
});
scenario('buildWhereClauses: operator account adds the block-exclusion clause', () => {
const { where, params } = buildWhereClauses({}, 0, 'morphit');
assertTrue(where.length === 4, 'base clauses + block exclusion');
assertTrue(
where[3]!.includes('operator_blocks') && where[3]!.includes("ob.state = 'blocked'"),
'block exclusion clause present'
);
assertEqual(params, ['morphit'], 'operator bound as a param');
});
scenario('buildWhereClauses: empty operator account skips the block clause', () => {
const { where } = buildWhereClauses({}, 0, '');
assertTrue(where.length === 3, 'no block clause when no operator supplied');
});
scenario('buildWhereClauses: asset filter matches traded OR pays-with OR accepted (REST parity)', () => {
const { where, params } = buildWhereClauses({ asset: 'BTC' });
assertTrue(where.length === 4, 'four clauses (3 base + asset)');
// v1.8.15 — broadened to match the REST orderbook.ts asset filter: an order
// INVOLVING the crypto (traded asset OR pay_<ticker> payment method OR
// barter-accepted via accepted_assets). Binds TWO params: asset + pay_<ticker>.
assertEqual(
where[3],
'(o.asset = $1 OR EXISTS (SELECT 1 FROM unnest(o.payment_methods) pm WHERE lower(pm) = $2) OR $1 = ANY(o.accepted_assets))',
'asset clause (broadened)'
);
assertEqual(params, ['BTC', 'pay_btc'], 'asset + pay_<ticker> params');
});
scenario('buildWhereClauses: side filter binds correctly (barter-aware)', () => {
const { where, params } = buildWhereClauses({ side: 'sell' });
// v1.8.16 (Ken) — barter orders store o.side as the GOODS direction, the
// inverse of the crypto direction, so the crypto-facing side filter emits a
// two-branch clause (shared cryptoFacingSideWhere) binding the requested side
// AND its opposite: "sell crypto" matches crypto SELL or barter BUY.
assertEqual(
where[3],
"((o.asset <> 'BARTER' AND o.side = $1) OR (o.asset = 'BARTER' AND o.side = $2))",
'side clause (barter-aware)'
);
assertEqual(params, ['sell', 'buy'], 'params (requested side + opposite)');
});
scenario('buildWhereClauses: fiat_currency filter binds correctly', () => {
const { where, params } = buildWhereClauses({ fiat_currency: 'USD' });
assertEqual(where[3], 'o.fiat_currency = ANY($1::text[])', 'fiat clause');
assertEqual(params, [['USD']], 'params');
});
scenario('buildWhereClauses: location_region uses ILIKE substring match', () => {
const { where, params } = buildWhereClauses({ location_region: 'Berl' });
assertTrue(
where[3]!.includes('ILIKE') && where[3]!.includes("ESCAPE '\\'"),
'ILIKE substring with escape'
);
// v1.8.15 — case-insensitive SUBSTRING (contains) match, was prefix region%,
// so "mzt" finds "Mazatlán MZT". Param is wrapped %...%.
assertEqual(params, ['%Berl%'], 'normalized + wrapped param');
});
scenario('buildWhereClauses: location_region NFC-normalizes input', () => {
// "é" decomposed (e + combining acute) NFC-normalizes to
// "é" precomposed. After normalization + escapeLike + wrapping in %...%.
const decomposed = 'Caf\u0065\u0301'; // "Café" decomposed
const { params } = buildWhereClauses({ location_region: decomposed });
const normalized = decomposed.normalize('NFC');
assertEqual(params, ['%' + normalized + '%'], 'normalized form, wrapped');
});
scenario('buildWhereClauses: payment_methods splits + lowercases tokens', () => {
const { where, params } = buildWhereClauses({
payment_methods: 'PayPal, SEPA,cash '
});
assertTrue(where[3]!.includes('EXISTS'), 'EXISTS subquery');
assertEqual(params, [['paypal', 'sepa', 'cash']], 'lowercased + trimmed');
});
scenario('buildWhereClauses: payment_methods drops oversized tokens', () => {
const { params } = buildWhereClauses({
payment_methods: 'cash,' + 'a'.repeat(50)
});
assertEqual(params, [['cash']], 'oversized dropped');
});
scenario('buildWhereClauses: payment_methods empty after filter → no EXISTS', () => {
const { where } = buildWhereClauses({
payment_methods: 'a'.repeat(50)
});
assertEqual(where.length, 3, 'no EXISTS clause (3 base only)');
});
scenario('buildWhereClauses: min_trades > 0 adds clause', () => {
const { where, params } = buildWhereClauses({ min_trades: 3 });
// cp473 — was pinned to `COALESCE(f.c, 0)`, i.e. this scenario ENCODED the
// bug: a filter named min_TRADES that actually counted REVIEWS, disagreeing
// with the REST endpoint the stream's snapshot then overwrote.
assertEqual(where[3], 'COALESCE(tc.c, 0) >= $1', 'min_trades clause');
assertEqual(params, [3], 'params');
});
scenario('buildWhereClauses: min_trades = 0 omitted', () => {
const { where } = buildWhereClauses({ min_trades: 0 });
assertEqual(where.length, 3, 'no min_trades clause (3 base only)');
});
scenario('buildWhereClauses: combined filter binds in order', () => {
const { where, params } = buildWhereClauses({
asset: 'BTC',
side: 'sell',
fiat_currency: 'EUR',
min_trades: 2
});
// v1.8.15 — the asset clause binds TWO params (asset + pay_<ticker>).
// v1.8.16 — the side clause now ALSO binds two (requested side + opposite,
// for the barter-inverse match), so every placeholder after side shifts up by
// one more: fiat $4→$5, min_trades $5→$6.
assertEqual(
where[3],
'(o.asset = $1 OR EXISTS (SELECT 1 FROM unnest(o.payment_methods) pm WHERE lower(pm) = $2) OR $1 = ANY(o.accepted_assets))',
'asset clause (broadened)'
);
assertEqual(
where[4],
"((o.asset <> 'BARTER' AND o.side = $3) OR (o.asset = 'BARTER' AND o.side = $4))",
'side clause (barter-aware, $3/$4)'
);
assertEqual(where[5], 'o.fiat_currency = ANY($5::text[])', 'fiat $5');
assertEqual(where[6], 'COALESCE(tc.c, 0) >= $6', 'min_trades $6');
assertEqual(params, ['BTC', 'pay_btc', 'sell', 'buy', ['EUR'], 2], 'params in order');
});
scenario('buildWhereClauses: startIndex offsets parameter numbering', () => {
// Caller has already bound $1, $2 (account, permlink) — tells
// builder to start at $3 for the filter params.
const { where, params } = buildWhereClauses({ asset: 'XMR' }, 2);
// v1.8.15 — broadened asset clause binds two params starting at the offset,
// so $3 (asset) and $4 (pay_<ticker>).
assertEqual(
where[3],
'(o.asset = $3 OR EXISTS (SELECT 1 FROM unnest(o.payment_methods) pm WHERE lower(pm) = $4) OR $3 = ANY(o.accepted_assets))',
'param numbering offset by startIndex'
);
assertEqual(params, ['XMR', 'pay_xmr'], 'asset + pay_<ticker> params');
});
// ─── v1.7.0: watch-one-order filter (ADR-0051) ───────────────────
//
// The order detail page subscribes with account+permlink so it gets a one-row
// snapshot and only that order's events, instead of every order the trader has
// live. The safety property being pinned is that this NARROWS — it must never
// let a row through that the unfiltered stream wouldn't already serve.
scenario('buildWhereClauses: account+permlink narrow to one order', () => {
const { where, params } = buildWhereClauses({ account: 'kentest3', permlink: 'sell-btc-1' });
assertTrue(where.includes('o.account = $1'), 'account predicate');
assertTrue(where.includes('o.permlink = $2'), 'permlink predicate');
assertEqual(params, ['kentest3', 'sell-btc-1'], 'both bound as params');
});
scenario('buildWhereClauses: the watch filter never drops a base predicate', () => {
// This is the whole safety argument. If account/permlink were ever built
// BEFORE the base clauses (or replaced them), the detail page could subscribe
// to an unpaid, cancelled, expired, or operator-blocked order and be told it
// was live. They must ADD to the same chokepoint every other path shares.
const { where } = buildWhereClauses({ account: 'kentest3', permlink: 'sell-btc-1' }, 0, 'morphit');
assertTrue(where.includes(`o.status = 'live'`), 'still live-only');
assertTrue(
where.includes(`o.fee_status IN ('verified', 'verified_by_attestation')`),
'still fee-verified-only — the gate that keeps unpaid orders unpublished'
);
assertTrue(
where.includes(`(o.expires_at IS NULL OR o.expires_at > NOW())`),
'still unexpired-only'
);
assertTrue(
where.some((w) => w.includes('operator_blocks')),
'still honours the operator block list'
);
});
scenario('buildWhereClauses: account alone is legal (all of a trader\'s live orders)', () => {
const { where, params } = buildWhereClauses({ account: 'kentest3' });
assertTrue(where.includes('o.account = $1'), 'account predicate');
assertTrue(!where.some((w) => w.startsWith('o.permlink')), 'no permlink predicate');
assertEqual(params, ['kentest3'], 'one param');
});
scenario('buildWhereClauses: the watch filter composes with the others', () => {
const { where, params } = buildWhereClauses({ account: 'kentest3', asset: 'BTC', side: 'sell' });
assertTrue(where.includes('o.account = $1'), 'account');
// v1.8.15 — asset clause broadened + binds two params ($2 asset, $3 pay_btc).
// v1.8.16 — side is the barter-aware two-branch clause binding $4 (requested)
// and $5 (opposite), so params gain the opposite side after 'sell'.
assertTrue(
where.includes(
'(o.asset = $2 OR EXISTS (SELECT 1 FROM unnest(o.payment_methods) pm WHERE lower(pm) = $3) OR $2 = ANY(o.accepted_assets))'
),
'asset (broadened)'
);
assertTrue(
where.includes(
"((o.asset <> 'BARTER' AND o.side = $4) OR (o.asset = 'BARTER' AND o.side = $5))"
),
'side (barter-aware)'
);
assertEqual(
params,
['kentest3', 'BTC', 'pay_btc', 'sell', 'buy'],
'param order matches placeholder order'
);
});
scenario('buildWhereClauses: watch filter respects startIndex (per-row lookup path)', () => {
// The per-row lookup binds account+permlink as $1,$2 and starts filter params
// at $3. An off-by-one here would silently bind the wrong value to the wrong
// column — a filter that matches the wrong order.
const { where, params } = buildWhereClauses({ account: 'kentest3' }, 2);
assertTrue(where.includes('o.account = $3'), 'placeholder offset by startIndex');
assertEqual(params, ['kentest3'], 'params unaffected by offset');
});
// ─── rowToWire ───────────────────────────────────────────────────
scenario('rowToWire: full row → full wire shape', () => {
const r = makeRow();
const w = rowToWire(r);
assertEqual(w.account, 'alice', 'account');
assertEqual(w.permlink, 'sell-btc-eur-2026-04', 'permlink');
assertEqual(w.side, 'sell', 'side');
assertEqual(w.asset, 'BTC', 'asset');
assertEqual(w.amount_min, 50, 'amount_min as number');
assertEqual(w.amount_max, 500, 'amount_max as number');
assertEqual(w.weighted_rating, 4.5, 'weighted_rating as number');
assertEqual(w.feedback_count, 5, 'feedback_count');
// cp473 — trade_count MUST cross the wire. The orderbook page treats this
// stream's snapshot as authoritative and replaces the REST rows with it, so
// a missing field here doesn't degrade the live path — it wipes the trade
// count off cards the REST fetch had already rendered correctly.
assertEqual(w.trade_count, 9, 'trade_count crosses the wire');
assertEqual(
w.trade_count !== w.feedback_count,
true,
'trades and ratings stay two different numbers'
);
assertEqual(w.created_at, '2026-04-01T10:00:00.000Z', 'created_at iso');
assertEqual(w.updated_at, '2026-04-26T12:00:00.000Z', 'updated_at iso');
assertEqual(w.expires_at, '2026-05-01T00:00:00.000Z', 'expires_at iso');
// cp404 — composite reputation score, earliest-trade ISO, posting key.
assertEqual(typeof w.reputation_score, 'number', 'reputation_score is a number');
assertEqual(w.first_trade_at, '2026-02-01T00:00:00.000Z', 'first_trade_at iso');
assertEqual(
w.posting_pubkey,
'BLT6vSMDaw3sLdJP7SjSxHCbtwLQoyTA2oc9dWDdmKZ2Jjw6Bh7d',
'posting_pubkey passthrough'
);
});
scenario('rowToWire: null reputation inputs → null score, null first_trade_at', () => {
const w = rowToWire(
makeRow({ weighted_rating: null, last_feedback_at: null, first_trade_complete_at: null })
);
assertEqual(w.reputation_score, null, 'null score when no rating');
assertEqual(w.first_trade_at, null, 'null first_trade_at when none');
});
scenario('rowToWire: null amounts and rating preserved', () => {
const r = makeRow({
amount_min: null,
amount_max: null,
weighted_rating: null
});
const w = rowToWire(r);
assertEqual(w.amount_min, null, 'amount_min null');
assertEqual(w.amount_max, null, 'amount_max null');
assertEqual(w.weighted_rating, null, 'weighted_rating null');
});
scenario('rowToWire: null expires_at preserved', () => {
const r = makeRow({ expires_at: null });
const w = rowToWire(r);
assertEqual(w.expires_at, null, 'expires_at null');
});
scenario('rowToWire: numeric strings coerced to JS numbers', () => {
// pg returns NUMERIC as string; the wire format coerces.
const r = makeRow({ amount_min: '0.01', amount_max: '99999.99' });
const w = rowToWire(r);
assertEqual(w.amount_min, 0.01, 'min coerced');
assertEqual(w.amount_max, 99999.99, 'max coerced');
});
scenario('rowToWire: payment_methods array passes through', () => {
const r = makeRow({ payment_methods: ['cash', 'wise', 'paypal'] });
const w = rowToWire(r);
assertEqual(w.payment_methods, ['cash', 'wise', 'paypal'], 'arr passed');
});
scenario('rowToWire: is_new_trader boolean passes through', () => {
const a = rowToWire(makeRow({ is_new_trader: true }));
const b = rowToWire(makeRow({ is_new_trader: false }));
assertEqual(a.is_new_trader, true, 'true');
assertEqual(b.is_new_trader, false, 'false');
});
scenario('rowToWire: null fee_method preserved', () => {
const r = makeRow({ fee_method: null });
const w = rowToWire(r);
assertEqual(w.fee_method, null, 'null fee_method');
});
// ─── makeFetchSerializer (F-6 audit fix) ─────────────────────────
await asyncScenario('serializer: single schedule triggers single fetch', async () => {
const calls: string[] = [];
let resolveOne: (() => void) | null = null;
const doFetch = (orderId: string): Promise<void> =>
new Promise<void>((resolve) => {
calls.push(orderId);
resolveOne = resolve;
});
const { schedule, state } = makeFetchSerializer(doFetch);
schedule('alice/perma');
assertEqual(state.get('alice/perma'), 'in-flight', 'state in-flight');
assertEqual(calls.length, 1, 'one fetch started');
resolveOne!();
await tickN(2);
assertEqual(state.has('alice/perma'), false, 'state cleared');
});
await asyncScenario(
'serializer: coincident schedules coalesce (no second fetch starts)',
async () => {
const calls: string[] = [];
let resolveOne: (() => void) | null = null;
const doFetch = (orderId: string): Promise<void> =>
new Promise<void>((resolve) => {
calls.push(orderId);
resolveOne = resolve;
});
const { schedule, state } = makeFetchSerializer(doFetch);
schedule('alice/perma');
schedule('alice/perma'); // 2nd while 1st in-flight
schedule('alice/perma'); // 3rd should be no-op (already dirty)
assertEqual(calls.length, 1, 'still one fetch in flight');
assertEqual(state.get('alice/perma'), 'in-flight-dirty', 'dirty flag');
// Resolve first fetch. Loop should immediately fire a second.
resolveOne!();
await tickN(2);
assertEqual(calls.length, 2, 'second fetch fired after dirty');
assertEqual(state.get('alice/perma'), 'in-flight', 'back to in-flight');
// Resolve second fetch. No more dirty → state clears.
resolveOne!();
await tickN(2);
assertEqual(state.has('alice/perma'), false, 'state cleared');
}
);
await asyncScenario('serializer: distinct orderIds run independently', async () => {
const inflight: Map<string, () => void> = new Map();
const doFetch = (orderId: string): Promise<void> =>
new Promise<void>((resolve) => {
inflight.set(orderId, resolve);
});
const { schedule, state } = makeFetchSerializer(doFetch);
schedule('alice/perma1');
schedule('bob/perma2');
assertEqual(inflight.size, 2, 'two distinct fetches');
assertEqual(state.get('alice/perma1'), 'in-flight', 'alice in-flight');
assertEqual(state.get('bob/perma2'), 'in-flight', 'bob in-flight');
inflight.get('alice/perma1')!();
await tickN(2);
assertEqual(state.has('alice/perma1'), false, 'alice cleared');
assertEqual(state.get('bob/perma2'), 'in-flight', 'bob still running');
inflight.get('bob/perma2')!();
await tickN(2);
assertEqual(state.has('bob/perma2'), false, 'bob cleared');
});
await asyncScenario('serializer: cancelled mid-loop exits cleanly', async () => {
const calls: string[] = [];
let resolveOne: (() => void) | null = null;
let cancelled = false;
const doFetch = (orderId: string): Promise<void> =>
new Promise<void>((resolve) => {
calls.push(orderId);
resolveOne = resolve;
});
const { schedule, state } = makeFetchSerializer(doFetch, () => cancelled);
schedule('alice/perma');
schedule('alice/perma'); // mark dirty so loop would refire
assertTrue(state.get('alice/perma') === 'in-flight-dirty', 'dirty');
cancelled = true;
resolveOne!();
await tickN(3);
// On cancellation, the loop's `while (!isCancelled())` exits
// after the current fetch resolves; no second fetch fires.
assertEqual(calls.length, 1, 'no second fetch after cancel');
assertEqual(state.has('alice/perma'), false, 'state cleared');
});
await asyncScenario('serializer: doFetch throwing does not break the loop', async () => {
let attempt = 0;
const doFetch = async (): Promise<void> => {
attempt++;
if (attempt === 1) throw new Error('first attempt fails');
// Second attempt resolves normally.
};
const { schedule, state } = makeFetchSerializer(doFetch);
schedule('alice/perma'); // attempt 1 (will throw)
schedule('alice/perma'); // marks dirty
await tickN(4);
assertEqual(attempt, 2, 'loop continued after throw');
assertEqual(state.has('alice/perma'), false, 'state cleared');
});
await asyncScenario('serializer: schedule after completion starts a fresh fetch', async () => {
let attempt = 0;
let resolveOne: (() => void) | null = null;
const doFetch = (): Promise<void> =>
new Promise<void>((resolve) => {
attempt++;
resolveOne = resolve;
});
const { schedule, state } = makeFetchSerializer(doFetch);
schedule('alice/perma');
resolveOne!();
await tickN(2);
assertEqual(state.has('alice/perma'), false, 'cleared after first');
schedule('alice/perma');
assertEqual(attempt, 2, 'fresh fetch started');
assertEqual(state.get('alice/perma'), 'in-flight', 'in-flight again');
resolveOne!();
await tickN(2);
assertEqual(state.has('alice/perma'), false, 'cleared');
});
// cp405 regression guard — the beta.44 outage: the orderbook query joined
// `accounts a` on `a.account`, but the accounts table keys on `name` (every
// OTHER table uses `account`, so the typo looked right). No smoke executes the
// SQL, so it shipped and 500'd every orderbook load. This asserts every `a.<col>`
// reference in BOTH orderbook query files is a real accounts column (inline
// columns from the drift parser + ALTER-added ones, which that parser omits).
scenario('orderbook query only references real accounts columns via alias a.', () => {
const schema = readSmokeSrc('db/schema.sql');
const accountsCols = new Set<string>(parseExpectedSchema(schema).get('accounts') ?? []);
const alterRe =
/ALTER\s+TABLE\s+accounts\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([a-z_][a-z0-9_]*)"?/gi;
let am: RegExpExecArray | null;
while ((am = alterRe.exec(schema)) !== null) accountsCols.add(am[1]!.toLowerCase());
if (accountsCols.size === 0) throw new Error('parsed 0 accounts columns — parser/schema drift');
if (!accountsCols.has('name')) throw new Error('expected accounts.name (PK) in parsed columns');
const bad: string[] = [];
for (const f of ['api/orderbook.ts', 'api/orderbookStream.ts']) {
const src = readSmokeSrc(f);
const refRe = /\ba\.([a-z_][a-z0-9_]*)/gi;
let rm: RegExpExecArray | null;
while ((rm = refRe.exec(src)) !== null) {
const col = rm[1]!.toLowerCase();
if (!accountsCols.has(col)) bad.push(`${f}: a.${col}`);
}
}
if (bad.length > 0) {
throw new Error(
`orderbook query references non-existent accounts column(s): ${bad.join('; ')}. ` +
`accounts keys on "name", not "account". Real columns: ${[...accountsCols].sort().join(', ')}`
);
}
});
console.log(`\n${'─'.repeat(54)}`);
if (failures === 0) {
console.log(`✓ all ${scenarios} scenarios passed`);
process.exit(0);
} else {
console.log(`${failures}/${scenarios} scenarios failed`);
process.exit(1);
}