f61ec6b391
Seven orders of measurement-first repair. The served grade does not move. A0/A1 — the unordered page walk returned the right COUNT and the wrong ROWS: 410-617 of 2,490 duplicated with an equal number never returned, while rows.length matched the server exactly. safePaginate orders on a real unique key, verifies the tuple at runtime, and THROWS on a query error instead of treating it as end-of-data. Both hits PROVES are withdrawn: they were drawn through that reader, and defense_by_direction's distinct-n was likely below the gate floor all along. A2/A2b — rolled across every reader: 11 FAIL -> 0. Composite keys pulled from pg_index (the context tables are dated-composite and had no single unique column). The unordered helper is deleted, not parked. A3 — ledgerService and retentionService defaulted the SAME env var to DIFFERENT versions, so no ledger row ever carried the marker eligibility requires. One source now. model_snapshots settlement moved onto the cron: 15,484 -> 28,894 settled, repaired-champion 0 -> 7,556. A4 — hitsFactorContext takes an as-of cutoff. Refusal over reconstruction: no row at-or-before the date means the factor does not apply, never the nearest row. Live path unchanged, proven 400/400 on real rows. A5 — factor_inputs freezes what the factor READ, never the multiplier, so an audit can recompute and check. It also recorded the finding: the three hits factors have NEVER fired. prop.opponent and prop.opposing_pitcher are read by the resolver and written by nothing. A6/A7 — matchupKeys resolves those keys from the posted lineup plus the schedule's probable pitchers, and fires the factors into a SHADOW freeze: 248 fires on 308 props, 245 of which would move the grade. The served forecast is untouched. specs/a8-shadow-factor-gate.md pre-registers the test that decides whether they ever go live. Nothing is turned on. CALIBRATION_DEPLOYED stays []. Both verdicts stay withdrawn. 4,772 tests / 371 suites green, web build exit 0, read-integrity harness 34/34. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
295 lines
13 KiB
JavaScript
295 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* safePaginate — spec: specs/read-integrity-harness.md §7.
|
|
* Fake query builders throughout; nothing here touches a network.
|
|
*/
|
|
|
|
const { paginate } = require('../../src/utils/safePaginate');
|
|
|
|
/**
|
|
* A fake PostgREST table. `rows` may be mutated between pages to simulate
|
|
* concurrent writes. Honours order+range the way Postgres does WITH an ORDER BY.
|
|
*/
|
|
function fakeTable(rows, opts = {}) {
|
|
const state = { rows, orderCalls: 0, rangeCalls: 0 };
|
|
state.make = () => {
|
|
let orderKey = null; let asc = true;
|
|
const q = {
|
|
order(k, o) { orderKey = k; asc = !o || o.ascending !== false; state.orderCalls += 1; return q; },
|
|
async range(from, to) {
|
|
state.rangeCalls += 1;
|
|
if (opts.errorOnRange && opts.errorOnRange(from)) return { data: null, error: { message: 'connection reset' } };
|
|
if (opts.beforeRange) opts.beforeRange(from, state);
|
|
// Unordered simulation: return physical order (the production defect).
|
|
let out = [...state.rows];
|
|
if (orderKey) {
|
|
out.sort((a, b) => (a[orderKey] < b[orderKey] ? -1 : a[orderKey] > b[orderKey] ? 1 : 0));
|
|
if (!asc) out.reverse();
|
|
}
|
|
return { data: out.slice(from, to + 1), error: null };
|
|
},
|
|
};
|
|
return q;
|
|
};
|
|
return state;
|
|
}
|
|
|
|
const row = (id) => ({ id, v: `r${id}` });
|
|
const ids = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => row(a + i));
|
|
|
|
describe('safePaginate — (a) returns the full set, in order', () => {
|
|
it('walks every page and returns each row exactly once, ascending by key', async () => {
|
|
const t = fakeTable(ids(1, 2490));
|
|
const out = await paginate(t.make, { pageSize: 1000 });
|
|
expect(out).toHaveLength(2490);
|
|
expect(new Set(out.map((r) => r.id)).size).toBe(2490);
|
|
expect(out.map((r) => r.id)).toEqual(ids(1, 2490).map((r) => r.id)); // in order
|
|
expect(t.rangeCalls).toBe(3);
|
|
});
|
|
|
|
it('always applies the order — every page is ordered, not just the first', async () => {
|
|
const t = fakeTable(ids(1, 2490));
|
|
await paginate(t.make, { pageSize: 1000 });
|
|
expect(t.orderCalls).toBe(t.rangeCalls);
|
|
});
|
|
|
|
it('orders by the configured key, and can descend', async () => {
|
|
const t = fakeTable(ids(1, 5));
|
|
const out = await paginate(t.make, { pageSize: 10, ascending: false });
|
|
expect(out.map((r) => r.id)).toEqual([5, 4, 3, 2, 1]);
|
|
});
|
|
|
|
it('returns [] for an empty table without throwing', async () => {
|
|
const out = await paginate(fakeTable([]).make, { pageSize: 10 });
|
|
expect(out).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('safePaginate — (b) a mid-walk error THROWS, never a silent end', () => {
|
|
it('throws when page 2 fails instead of returning page 1 as the whole set', async () => {
|
|
// THE :117 DEFECT: `if (error || !data) break` would return 1,000 rows here
|
|
// and the caller would fit a map on them believing it had 2,490.
|
|
const t = fakeTable(ids(1, 2490), { errorOnRange: (from) => from === 1000 });
|
|
await expect(paginate(t.make, { pageSize: 1000, label: 'cal' }))
|
|
.rejects.toThrow(/read failed at range 1000-1999 — connection reset/);
|
|
});
|
|
|
|
it('tags the error so a caller can tell a read failure from thin history', async () => {
|
|
const t = fakeTable(ids(1, 10), { errorOnRange: (from) => from === 0 });
|
|
await expect(paginate(t.make, { pageSize: 5 })).rejects.toMatchObject({ code: 'READ_FAILED' });
|
|
});
|
|
|
|
it('names itself in the message', async () => {
|
|
const t = fakeTable(ids(1, 10), { errorOnRange: () => true });
|
|
await expect(paginate(t.make, { pageSize: 5, label: 'fromLedger(hits)' }))
|
|
.rejects.toThrow(/^fromLedger\(hits\):/);
|
|
});
|
|
|
|
it('refuses a runaway walk rather than reporting a truncated read as complete', async () => {
|
|
// Fresh unique ids every page, so the runaway guard is what fires — not the
|
|
// duplicate guard.
|
|
let n = 0;
|
|
const t = {
|
|
make: () => ({
|
|
order() { return this; },
|
|
async range() { const page = ids(n * 10 + 1, n * 10 + 10); n += 1; return { data: page, error: null }; },
|
|
}),
|
|
};
|
|
await expect(paginate(t.make, { pageSize: 10, maxPages: 3 })).rejects.toThrow(/maxPages/);
|
|
});
|
|
});
|
|
|
|
describe('safePaginate — (c) set-identity vs an ordered full-table control', () => {
|
|
const control = (rows) => [...rows].sort((a, b) => a.id - b.id).map((r) => r.id);
|
|
|
|
it('is set-identical to the control on a static table', async () => {
|
|
const rows = ids(1, 4321);
|
|
const out = await paginate(fakeTable(rows).make, { pageSize: 1000 });
|
|
expect(out.map((r) => r.id)).toEqual(control(rows));
|
|
});
|
|
|
|
it('is set-identical when the physical order is shuffled under it', async () => {
|
|
// The planner returning rows in an arbitrary physical order is exactly the
|
|
// production condition. A stable ORDER BY makes it irrelevant.
|
|
const rows = ids(1, 3000);
|
|
const t = fakeTable([...rows].reverse());
|
|
const out = await paginate(t.make, { pageSize: 1000 });
|
|
expect(out.map((r) => r.id)).toEqual(control(rows));
|
|
});
|
|
|
|
it('THROWS on a duplicate key rather than returning a corrupted set', async () => {
|
|
// A non-unique ordering key: ties may come back in any order, so pages
|
|
// overlap. An .order() clause on such a column is not a fix.
|
|
const dup = [{ id: 1 }, { id: 2 }, { id: 2 }, { id: 3 }];
|
|
await expect(paginate(fakeTable(dup).make, { pageSize: 2 }))
|
|
.rejects.toThrow(/duplicate key \(id\)=2/);
|
|
});
|
|
});
|
|
|
|
describe('safePaginate — concurrent append (the regression this fix exists for)', () => {
|
|
it('returns every pre-existing row exactly once while rows are APPENDED mid-walk', async () => {
|
|
// The snapshot cron appends to ledger_entries at 14/19/22/1/3 UTC. With a
|
|
// stable ascending key, appended rows land after the cursor: nothing already
|
|
// returned can be returned again, and nothing pending can be skipped.
|
|
const original = ids(1, 2500);
|
|
let next = 10000;
|
|
const t = fakeTable([...original], {
|
|
beforeRange: (from, state) => {
|
|
if (from > 0) state.rows.push(row(next++)); // a write lands mid-walk
|
|
},
|
|
});
|
|
const out = await paginate(t.make, { pageSize: 1000 });
|
|
const seen = out.map((r) => r.id);
|
|
|
|
expect(new Set(seen).size).toBe(seen.length); // no duplicates
|
|
for (const r of original) expect(seen).toContain(r.id); // nothing dropped
|
|
});
|
|
|
|
it('the OLD unordered walk fails that same simulation — the test has teeth', async () => {
|
|
// Same table, paged over PHYSICAL order with no ORDER BY. An UPDATE in
|
|
// Postgres writes a new tuple version at the end of the heap, so a row that
|
|
// was already returned moves AFTER the cursor (returned twice) and every row
|
|
// behind it shifts back by one (one falls across the page boundary unseen).
|
|
// This is the production shape, and it is what the fix removes.
|
|
const original = ids(1, 2500);
|
|
const rows = [...original];
|
|
const unorderedWalk = async () => {
|
|
const out = [];
|
|
for (let from = 0; ; from += 1000) {
|
|
if (from > 0) rows.push(rows.splice(0, 1)[0]); // a row is re-written mid-walk
|
|
const page = rows.slice(from, from + 1000);
|
|
if (page.length === 0) break;
|
|
out.push(...page);
|
|
if (page.length < 1000) break;
|
|
}
|
|
return out;
|
|
};
|
|
const seen = (await unorderedWalk()).map((r) => r.id);
|
|
expect(new Set(seen).size).toBeLessThan(seen.length); // duplicates appear
|
|
const missing = original.filter((r) => !seen.includes(r.id));
|
|
expect(missing.length).toBeGreaterThan(0); // and rows are lost
|
|
});
|
|
});
|
|
|
|
// ── FIX A2b — COMPOSITE KEYS ────────────────────────────────────────────────
|
|
const { normalizeKey } = require('../../src/utils/safePaginate');
|
|
const { uniqueKeyFor } = require('../../src/utils/tableKeys');
|
|
|
|
/** A fake table keyed by a composite tuple, ordered the way Postgres would. */
|
|
function fakeComposite(rows, opts = {}) {
|
|
const state = { rows, orderCols: [], rangeCalls: 0 };
|
|
state.make = () => {
|
|
const cols = [];
|
|
const q = {
|
|
order(c) { cols.push(c); return q; },
|
|
async range(from, to) {
|
|
state.rangeCalls += 1;
|
|
state.orderCols = [...cols];
|
|
if (opts.errorOnRange && opts.errorOnRange(from)) return { data: null, error: { message: 'reset' } };
|
|
const out = [...state.rows].sort((a, b) => {
|
|
for (const c of cols) {
|
|
if (a[c] < b[c]) return -1;
|
|
if (a[c] > b[c]) return 1;
|
|
}
|
|
return 0;
|
|
});
|
|
return { data: out.slice(from, to + 1), error: null };
|
|
},
|
|
};
|
|
return q;
|
|
};
|
|
return state;
|
|
}
|
|
|
|
const KEY = ['as_of_date', 'sport', 'season', 'player_key'];
|
|
const composite = (n) => Array.from({ length: n }, (_, i) => ({
|
|
as_of_date: `2026-08-${String((i % 9) + 1).padStart(2, '0')}`,
|
|
sport: 'mlb', season: 2026, player_key: `p${i}`, v: i,
|
|
}));
|
|
|
|
describe('safePaginate — composite keys (A2b)', () => {
|
|
it('normalizeKey treats a string and a one-column list identically', () => {
|
|
expect(normalizeKey('id')).toEqual(['id']);
|
|
expect(normalizeKey(['id'])).toEqual(['id']);
|
|
expect(normalizeKey(undefined)).toEqual(['id']);
|
|
expect(normalizeKey(KEY)).toEqual(KEY);
|
|
});
|
|
|
|
it('rejects an empty key rather than silently defaulting to id', () => {
|
|
expect(() => normalizeKey([])).toThrow(/must be a column name/);
|
|
expect(() => normalizeKey([null])).toThrow(/must be a column name/);
|
|
});
|
|
|
|
it('returns the full set exactly once, ordered by the whole tuple', async () => {
|
|
const t = fakeComposite(composite(2500));
|
|
const rows = await paginate(t.make, { key: KEY, pageSize: 1000 });
|
|
expect(rows).toHaveLength(2500);
|
|
expect(new Set(rows.map((r) => r.player_key)).size).toBe(2500);
|
|
// sorted by as_of_date first
|
|
for (let i = 1; i < rows.length; i += 1) {
|
|
expect(rows[i].as_of_date >= rows[i - 1].as_of_date).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('orders EVERY column of the key, in the declared order', async () => {
|
|
const t = fakeComposite(composite(10));
|
|
await paginate(t.make, { key: KEY, pageSize: 100 });
|
|
expect(t.orderCols).toEqual(KEY);
|
|
});
|
|
|
|
it('a mid-walk error still THROWS with a composite key', async () => {
|
|
const t = fakeComposite(composite(2500), { errorOnRange: (f) => f === 1000 });
|
|
await expect(paginate(t.make, { key: KEY, pageSize: 1000, label: 'ctx' }))
|
|
.rejects.toThrow(/ctx: read failed at range 1000-1999/);
|
|
});
|
|
|
|
it('THROWS on a duplicate TUPLE, naming every column', async () => {
|
|
const dup = [
|
|
{ as_of_date: '2026-08-01', sport: 'mlb', season: 2026, player_key: 'a' },
|
|
{ as_of_date: '2026-08-01', sport: 'mlb', season: 2026, player_key: 'a' },
|
|
];
|
|
await expect(paginate(fakeComposite(dup).make, { key: KEY, pageSize: 10 }))
|
|
.rejects.toThrow(/duplicate key \(as_of_date,sport,season,player_key\)/);
|
|
});
|
|
|
|
it('does NOT throw when only a PREFIX of the tuple repeats', async () => {
|
|
// Same day, same sport, different player — a legitimate row, not a duplicate.
|
|
const rows = [
|
|
{ as_of_date: '2026-08-01', sport: 'mlb', season: 2026, player_key: 'a' },
|
|
{ as_of_date: '2026-08-01', sport: 'mlb', season: 2026, player_key: 'b' },
|
|
];
|
|
await expect(paginate(fakeComposite(rows).make, { key: KEY, pageSize: 10 }))
|
|
.resolves.toHaveLength(2);
|
|
});
|
|
|
|
it('the single-key path is unchanged — backward compatible', async () => {
|
|
const t = fakeTable(ids(1, 2490));
|
|
const out = await paginate(t.make, { pageSize: 1000 }); // no key => 'id'
|
|
expect(out).toHaveLength(2490);
|
|
expect(new Set(out.map((r) => r.id)).size).toBe(2490);
|
|
});
|
|
});
|
|
|
|
describe('tableKeys — the constraints come from the schema, not a guess', () => {
|
|
it('gives the real composite key for each context table', () => {
|
|
expect(uniqueKeyFor('batter_spray')).toEqual(['as_of_date', 'sport', 'season', 'source_id']);
|
|
expect(uniqueKeyFor('team_defense')).toEqual(['as_of_date', 'sport', 'season', 'team']);
|
|
expect(uniqueKeyFor('statcast_aggregates')).toEqual(['sport', 'season', 'source_id', 'role']);
|
|
expect(uniqueKeyFor('platoon_splits')).toEqual(['as_of_date', 'sport', 'season', 'player_key']);
|
|
expect(uniqueKeyFor('park_dimensions')).toEqual(['as_of_date', 'sport', 'venue_id']);
|
|
expect(uniqueKeyFor('hitter_opportunity')).toEqual(['as_of_date', 'sport', 'season', 'player_key']);
|
|
expect(uniqueKeyFor('lineup_context')).toEqual(['as_of_date', 'sport', 'game_pk', 'player_key']);
|
|
});
|
|
|
|
it('keeps the single-column keys single', () => {
|
|
expect(uniqueKeyFor('ledger_entries')).toEqual(['id']);
|
|
expect(uniqueKeyFor('model_snapshots')).toEqual(['id']);
|
|
expect(uniqueKeyFor('game_context')).toEqual(['game_id']);
|
|
});
|
|
|
|
it('THROWS for an unknown table rather than defaulting to id', () => {
|
|
expect(() => uniqueKeyFor('some_new_table')).toThrow(/no unique key recorded/);
|
|
});
|
|
});
|