Catalog: a build that learns a platform fetches the firms an older one skipped
A firm on a platform the build didn't know was skipped, yet the cache was
stamped current, so after updating to a build that knows the platform the
sync sent ?since= and the master answered "unchanged": the firm (Vest, on
machines that synced before 96fdd9c) stayed missing until an unrelated
catalog edit. The parser version goes to 3, so those caches fetch in full
once, and a payload now names the platforms it skipped (skippedPlatforms):
a cache whose skipped platform this build knows is no longer current. Kept
rule ids still report from such a cache. scripts/check-catalog.mjs syncs
against a stand-in master on a local port.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
beddbdcf6c
commit
952e4412c4
@@ -0,0 +1,107 @@
|
||||
// Checks for the firm catalog sync in lib/firm-catalog.ts, against a stand-in
|
||||
// master on a local port and a throwaway database — never the live DB or the
|
||||
// real master.
|
||||
//
|
||||
// npm run check
|
||||
import { after, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
|
||||
// Resolve the extensionless local imports used by Next, without a build.
|
||||
function resolveTypes(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
||||
return nextResolve(`${specifier}.ts`, context);
|
||||
}
|
||||
}
|
||||
registerHooks({ resolve: resolveTypes });
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-catalog-'));
|
||||
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
const { getSetting, setSetting } = await import('../lib/db.ts');
|
||||
const { syncCatalog, getCachedCatalog, getKeptRuleIdsForFirm, CATALOG_PARSER_VERSION } = await import('../lib/firm-catalog.ts');
|
||||
|
||||
const firm = (id, name, platform) => ({
|
||||
id, name, platform, keptAccountRules: [], updatedAt: '2026-10-26T19:00:00.000Z',
|
||||
accountTypes: [{
|
||||
id: `${id}-50k`, firmId: id, label: '50k', prefix: `${id.toUpperCase()}-`, updatedAt: '2026-10-26T19:00:00.000Z',
|
||||
accountSize: 50000, profitTarget: 3000, consistency: 0, minDayPnL: 0, minTradingDays: 0, maxLoss: 3000,
|
||||
maxPositionSize: 0, withdrawalStages: [],
|
||||
}],
|
||||
});
|
||||
const apex = firm('apex', 'Apex', 'tradovate');
|
||||
const vest = firm('vest', 'VestMarkets', 'vest');
|
||||
|
||||
// The master's catalog, answering ?since= as app/api/firms/catalog does, and
|
||||
// the `since` each sync sent (null = a full fetch).
|
||||
let served = { version: 41, updatedAt: '2026-10-26T19:20:00.000Z', firms: [apex, vest] };
|
||||
const sinceSent = [];
|
||||
const server = createServer((req, res) => {
|
||||
const since = new URL(req.url, 'http://master').searchParams.get('since');
|
||||
sinceSent.push(since);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(since !== null && Number(since) === served.version
|
||||
? { version: served.version, updatedAt: served.updatedAt, unchanged: true }
|
||||
: served));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
after(() => server.close());
|
||||
const master = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
/** This machine's cache as some build stored it, read afresh as after a restart. */
|
||||
function storedCache(payload) {
|
||||
setSetting('master_dashboard_url', master);
|
||||
setSetting('catalog_source_url', master);
|
||||
setSetting('catalog_version', String(payload.version));
|
||||
setSetting('catalog_payload', JSON.stringify(payload));
|
||||
globalThis.__firmCatalog = undefined;
|
||||
sinceSent.length = 0;
|
||||
}
|
||||
|
||||
const names = () => getCachedCatalog().map((f) => f.name);
|
||||
|
||||
test('a cache a pre-Vest build stored is fetched in full by a build that knows Vest', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
// Parser 2 skipped the Vest firm and said nothing of it; the master's
|
||||
// catalog hasn't changed since, so ?since= would answer "unchanged".
|
||||
storedCache({ version: 41, updatedAt: served.updatedAt, firms: [apex], parser: 2 });
|
||||
assert.equal((await syncCatalog()).ok, true);
|
||||
assert.deepEqual(sinceSent, [null]);
|
||||
assert.deepEqual(names(), ['Apex', 'VestMarkets']);
|
||||
// Current from then on: the next sync asks ?since= again.
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, [null, '41']);
|
||||
});
|
||||
|
||||
test('a skipped platform is named in the cache, and a build that knows it fetches in full', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// The master adds a firm on a platform this build doesn't know.
|
||||
served = { version: 42, updatedAt: '2026-10-27T15:00:00.000Z', firms: [apex, vest, firm('px', 'PX Firm', 'projectx')] };
|
||||
storedCache({ version: 41, updatedAt: '2026-10-26T19:20:00.000Z', firms: [apex, vest], parser: CATALOG_PARSER_VERSION });
|
||||
await syncCatalog();
|
||||
assert.deepEqual(names(), ['Apex', 'VestMarkets']);
|
||||
assert.deepEqual(JSON.parse(getSetting('catalog_payload')).skippedPlatforms, ['projectx']);
|
||||
// While this build still doesn't know it, syncs stay incremental, and the
|
||||
// cache still vouches for the kept rule sets of the firms it holds.
|
||||
globalThis.__firmCatalog = undefined;
|
||||
sinceSent.length = 0;
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, ['42']);
|
||||
assert.deepEqual(getKeptRuleIdsForFirm('apex'), []);
|
||||
// A build that knows the platform it skipped (Vest here) fetches in full.
|
||||
storedCache({ version: 42, updatedAt: served.updatedAt, firms: [apex], parser: CATALOG_PARSER_VERSION, skippedPlatforms: ['vest'] });
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, [null]);
|
||||
assert.ok(names().includes('VestMarkets'));
|
||||
});
|
||||
Reference in New Issue
Block a user