Retention: model_snapshots live + base64 SSH key support

RETENTION (Phase 2, priority zero). History starts compounding tonight.

migration 025 model_snapshots — APPLIED to prod. Append-only, one row per
graded prop PER SIDE PER CYCLE, with a unique index on
(snapshot_id, player_key, stat, line, side) so a retried cycle cannot
duplicate. RLS on, service-role writes only.

What it captures that the ledger never did:
- features jsonb — the model's INPUTS. Without these a backtest can only
  grade our own homework; with them any future model can be replayed
  against the exact conditions this one faced.
- REFUSALS (refused + refusal_reason). The ledger drops them, so a gate
  refusing props that would have WON is invisible — unmeasurable lost
  edge. Captured via a new onGraded hook in gradeSlateService that fires
  with BOTH sides before any filtering.
- grade_11, the pre-collapse grade. The 4-letter map throws away the
  entire live C-/C/C+/B- range.
- model_version + code_sha on every row. ledger_entries mixes pre/post-fix
  grades with no marker and cannot be separated retroactively.
- p_win / ev_pct / fair_odds / takeable / value — none of which any
  permanent store held.

Wiring: analyzeViaEngine1 attaches _features/_grade_11 (underscore =
internal); gradeSlateService fires onGraded then STRIPS them so they never
reach a cache or API payload; snapshotService builds rows and persists
best-effort. Retention reuses the LEDGER's dateET/gameIdFor helpers so
rows share the ledger's natural key exactly — otherwise the settle pass
could never join outcomes onto them. Rows are written BEFORE the empty-
slate early return: a slate that refused everything is exactly the case
worth recording.

CONTRACT HELD: retention is injectable and every path is caught. persist()
returns errors, never throws; a missing Supabase client is SKIPPED, not an
error. A retention failure can never break a snapshot.

BACKUP: backup-db.sh now accepts BACKUP_SSH_KEY as base64 (recommended —
survives env-var newline mangling, which is how injected SSH keys usually
break silently) OR raw PEM, detected by decoding and looking for the PEM
header. Verified both forms detect correctly against a real generated key.

Suite 279/3325 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
This commit is contained in:
Kev
2026-07-19 23:01:12 -04:00
parent 04a09ec1b2
commit d3ffa1b8c2
7 changed files with 506 additions and 5 deletions
+17 -2
View File
@@ -76,8 +76,23 @@ RSYNC_SSH="ssh -p ${BACKUP_SSH_PORT:-23} -o StrictHostKeyChecking=accept-new -o
if [ -n "${BACKUP_SSH_KEY:-}" ]; then
SSH_KEY_FILE="$(mktemp)"
chmod 600 "${SSH_KEY_FILE}"
# Accept the key with literal \n escapes (how env vars usually carry it).
printf '%b\n' "${BACKUP_SSH_KEY}" | sed -e 's/[[:space:]]*$//' > "${SSH_KEY_FILE}"
# Accept EITHER form (Session 64):
# 1. base64 (recommended — `base64 -w0`; survives any env-var mangling of
# newlines, which is the usual way an injected SSH key silently breaks)
# 2. raw PEM with literal \n escapes
# Detect base64 by trying to decode and checking for the PEM header.
DECODED="$(printf '%s' "${BACKUP_SSH_KEY}" | base64 -d 2>/dev/null || true)"
case "${DECODED}" in
*"PRIVATE KEY"*)
printf '%s\n' "${DECODED}" > "${SSH_KEY_FILE}"
echo "ssh key: base64-decoded"
;;
*)
printf '%b\n' "${BACKUP_SSH_KEY}" | sed -e 's/[[:space:]]*$//' > "${SSH_KEY_FILE}"
echo "ssh key: used raw (not base64)"
;;
esac
chmod 600 "${SSH_KEY_FILE}"
RSYNC_SSH="${RSYNC_SSH} -i ${SSH_KEY_FILE}"
fi
+16 -3
View File
@@ -48,7 +48,7 @@ function dedupeProps(props, limit) {
// engine1 is direction-aware, so a prop grades differently over vs under.
// Grade both sides and keep the higher-confidence verdict — that's the
// side the engine actually favors.
async function gradeBestSide(grade, prop, sport) {
async function gradeBestSide(grade, prop, sport, opts = {}) {
const base = {
player: prop.player,
stat_type: prop.stat_type,
@@ -68,12 +68,25 @@ async function gradeBestSide(grade, prop, sport) {
.then(() => grade({ ...base, direction: 'under' }))
.catch(() => null),
]);
// Session 64 — RETENTION HOOK. Fires with BOTH sides, graded AND refused,
// before any filtering. Refusals never reach the slate or the ledger, so this
// is the only point where "the gate refused this prop" is observable — and a
// gate that refuses winners is invisible without it. Best-effort: a retention
// collector must never affect grading.
if (typeof opts.onGraded === 'function') {
try { opts.onGraded(base, sides); } catch { /* never breaks the slate */ }
}
// Session 58 (work-order 1.5) — a refused read (insufficient_data /
// no grade) never enters the graded slate: no hollow rows in the grades
// cache, the snapshot, or the ledger.
const cands = sides.filter((s) => s && s.grade && !s.insufficient_data);
if (cands.length === 0) return null;
return cands.reduce((a, b) => ((Number(b.confidence) || 0) > (Number(a.confidence) || 0) ? b : a));
const winner = cands.reduce((a, b) => ((Number(b.confidence) || 0) > (Number(a.confidence) || 0) ? b : a));
// Strip the internal retention fields so they never reach a cache or payload.
delete winner._features;
delete winner._grade_11;
return winner;
}
// Run an async mapper over items with a bounded concurrency.
@@ -122,7 +135,7 @@ async function gradeAndCacheSlate(sport, props, opts = {}) {
const unique = dedupeProps(props, limit);
if (unique.length === 0) return { written: false, count: 0 };
const graded = (await mapLimit(unique, concurrency, (p) => gradeBestSide(grade, p, sport)))
const graded = (await mapLimit(unique, concurrency, (p) => gradeBestSide(grade, p, sport, opts)))
.filter(Boolean)
.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));
@@ -471,6 +471,14 @@ async function analyzeViaEngine1(rawProp = {}) {
// ledger model_value and rendered under the MODEL label on the card.
legacy.projection = projection;
// Session 64 — RETENTION: expose the feature vector + the pre-collapse
// 11-step grade so `model_snapshots` can store the model's INPUTS, not just
// its output. Without inputs a backtest can only grade our own homework.
// Underscore-prefixed = internal: gradeSlateService strips these before the
// grade reaches any cache or API payload.
legacy._features = features;
legacy._grade_11 = engine1Result && engine1Result.grade ? engine1Result.grade : null;
// Session 62 (A1-S1) — ALT LINE LADDER + EDGE RANKING. The pricing page
// sold it; the adapter/card were already built for it; nothing produced
// it. Re-grade the SAME feature vector at shifted lines (zero extra I/O)
+183
View File
@@ -0,0 +1,183 @@
'use strict';
/**
* MODEL SNAPSHOT RETENTION (Session 64, Phase 2 — priority zero).
*
* As of 2026-07-20 the only store of model history was `ledger_entries`: 640
* rows, 6 game days, and NO MODEL INPUTS. Every other warehouse table in the
* schema was empty. That meant we could score the grades we emitted but could
* not replay a different model against the same conditions — the only question
* a backtest exists to answer, and the gate the metrics-engine north star
* requires ("does this metric predict better than without it?").
*
* This writes `model_snapshots`: one APPEND-ONLY row per graded prop per
* snapshot cycle, carrying the market values, the model output, the refusal
* (if any), and the FEATURE VECTOR that produced it.
*
* CONTRACT: best-effort, exactly like the ledger write. A retention failure
* must NEVER break a snapshot. Every path returns a summary; none throw.
*
* Two deliberate choices:
* - REFUSALS ARE STORED. The ledger drops them, so a gate that refuses props
* which would have won is invisible. That is lost edge we cannot measure.
* - EVERY ROW IS STAMPED with model_version + code_sha. A backtest that mixes
* model eras is worthless, and `ledger_entries` is already permanently
* contaminated across the 2026-07-19 fix boundary with no way to separate.
*/
const crypto = require('crypto');
const { normalizeName, nameKey } = require('../utils/playerName');
/**
* Bump when the grading model changes in a way that makes rows non-comparable.
* This is the marker `ledger_entries` never had.
*/
const MODEL_VERSION = process.env.MODEL_VERSION || 'engine1@2026-07-20';
function codeSha() {
return process.env.SOURCE_COMMIT || process.env.GIT_SHA || process.env.COOLIFY_GIT_COMMIT_SHA || null;
}
/** Strict numeric parse — `Number(null) === 0` is this codebase's classic
* fabrication bug, so absent must stay absent. */
function numOrNull(v) {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function intOrNull(v) {
const n = numOrNull(typeof v === 'string' ? v.replace('+', '') : v);
return n == null ? null : Math.round(n);
}
function boolOrNull(v) {
return typeof v === 'boolean' ? v : null;
}
/**
* Build retention rows from ONE prop's graded sides (both over and under,
* graded or refused). `base` is the prop the grader was called with.
*/
function rowsFromSides(base, sides, ctx = {}) {
const rows = [];
const list = Array.isArray(sides) ? sides : [];
for (const s of list) {
if (!s) continue;
const player = s.player || base.player;
if (!player) continue;
const stat = s.stat_type || base.stat_type;
const line = numOrNull(s.line != null ? s.line : base.line);
const side = String(s.direction || '').toLowerCase();
if (!stat || line == null || (side !== 'over' && side !== 'under')) continue;
const refused = !!(s.insufficient_data || !s.grade);
rows.push({
snapshot_id: ctx.snapshotId,
captured_at: ctx.capturedAt,
cycle_hour_utc: ctx.cycleHourUtc ?? null,
model_version: MODEL_VERSION,
code_sha: codeSha(),
sport: ctx.sport,
game_id: ctx.gameIdFor ? ctx.gameIdFor(base, s) : (base.game_id || `${ctx.sport}:${ctx.gameDate}`),
game_date: ctx.gameDate,
player_key: nameKey(player),
player_name: normalizeName(player).display || player,
team: s.team || base.team || null,
opponent: s.opponent || base.opponent || null,
stat,
line,
side,
book: s.book || base.book || null,
book_odds: intOrNull(s.book_odds),
over_odds: intOrNull(base.over_odds),
under_odds: intOrNull(base.under_odds),
fair_odds: intOrNull(s.fair_odds),
fair_prob: numOrNull(s.fair_prob),
overround: numOrNull(s.overround),
devig_method: s.devig_method || null,
grade: s.grade || null,
grade_11: s._grade_11 || null,
confidence: numOrNull(s.confidence),
confidence_basis: s.confidence_basis || null,
p_win: numOrNull(s.p_win),
ev_pct: numOrNull(s.ev_pct),
projection: numOrNull(s.projection),
edge_pct: numOrNull(s.edge_pct),
takeable: boolOrNull(s.takeable),
value: boolOrNull(s.value),
archetype: s.archetype || null,
refused,
refusal_reason: refused
? (s.suppressed_reason || (s.insufficient_data ? 'insufficient_data' : 'no_grade'))
: null,
// The counterfactual enabler. Absent on pre-feature refusals (the juice
// gate runs before features are computed) — honestly null, never faked.
features: s._features && Object.keys(s._features).length ? s._features : null,
});
}
return rows;
}
/** A collector to hand to gradeAndCacheSlate's `onGraded` hook. */
function createCollector(ctx) {
const rows = [];
return {
rows,
onGraded(base, sides) {
try {
rows.push(...rowsFromSides(base, sides, ctx));
} catch { /* collection never affects grading */ }
},
};
}
/**
* Persist rows. Chunked, upsert-on-conflict-ignore so a retried cycle can never
* duplicate. No-ops (successfully) without Supabase env, so tests and local dev
* never touch a database.
*/
async function persist(rows, deps = {}) {
const out = { attempted: Array.isArray(rows) ? rows.length : 0, written: 0, skipped: false, error: null };
if (!out.attempted) return out;
try {
const getClient = deps.getClient || require('../utils/supabase').getSupabaseServiceClient;
const supabase = getClient();
if (!supabase) { out.skipped = true; return out; }
const CHUNK = 250;
for (let i = 0; i < rows.length; i += CHUNK) {
const chunk = rows.slice(i, i + CHUNK);
const { error } = await supabase
.from('model_snapshots')
.upsert(chunk, {
onConflict: 'snapshot_id,player_key,stat,line,side',
ignoreDuplicates: true,
});
if (error) { out.error = error.message; break; }
out.written += chunk.length;
}
} catch (e) {
out.error = e && e.message ? e.message : String(e);
}
return out;
}
function newSnapshotId() {
return crypto.randomUUID();
}
module.exports = {
MODEL_VERSION,
codeSha,
rowsFromSides,
createCollector,
persist,
newSnapshotId,
__internals: { numOrNull, intOrNull, boolOrNull },
};
+36
View File
@@ -231,6 +231,8 @@ async function runSnapshot(sport, opts = {}) {
// Session 63 — the opponent-rank feed. Injectable so tests never hit ESPN;
// under NODE_ENV=test it defaults to a no-op (the opsNotify precedent) so a
// suite that doesn't know about this dep can never make a live ESPN call.
// Session 64 — model-snapshot retention. Injectable; null disables it.
retention: opts.retention !== undefined ? opts.retention : require('./retentionService'),
refreshTeamStats: opts.refreshTeamStats
|| (process.env.NODE_ENV === 'test'
? async () => null
@@ -278,6 +280,28 @@ async function runSnapshot(sport, opts = {}) {
console.warn(`[snapshot] team stats refresh failed for ${sp} (grading continues):`, e.message);
}
// Session 64 — RETENTION (Phase 2, priority zero). Collect one row per graded
// prop per SIDE — graded AND refused — with the feature vector that produced
// it, so history compounds from tonight and a future model can be replayed
// against the exact conditions this one faced. Refusals are included on
// purpose: the ledger drops them, so a gate refusing props that would have
// won is otherwise invisible.
const retention = deps.retention;
// Reuse the LEDGER's date + game-id helpers so retention rows share the
// ledger's natural key exactly — otherwise the settle pass could never join
// outcomes onto them.
const ledgerInternals = (deps.ledger && deps.ledger.__internals) || require('./ledgerService').__internals;
const retentionGameDate = ledgerInternals.dateET(ts) || ledgerInternals.dateET(new Date().toISOString());
const retentionCtx = {
snapshotId: retention ? retention.newSnapshotId() : null,
capturedAt: ts,
cycleHourUtc: new Date(ts).getUTCHours(),
sport: sp,
gameDate: retentionGameDate,
gameIdFor: (base) => ledgerInternals.gameIdFor(sp, base, retentionGameDate),
};
const collector = retention ? retention.createCollector(retentionCtx) : null;
// Grade the slate via the existing service; capture the envelope instead of
// letting it write (we re-write an ENRICHED version below).
let envelope = null;
@@ -285,7 +309,19 @@ async function runSnapshot(sport, opts = {}) {
source: (odds && odds.provider) || 'odds-api',
now: deps.now,
cacheSet: async (_k, v) => { envelope = v; },
onGraded: collector ? collector.onGraded : undefined,
});
// Persist retention BEFORE the early return on an empty slate — a slate that
// graded nothing but refused everything is exactly the case worth recording.
if (retention && collector && collector.rows.length) {
try {
const r = await retention.persist(collector.rows);
console.log(`[snapshot] retention ${sp}: ${r.written}/${r.attempted} rows${r.skipped ? ' (skipped — no supabase env)' : ''}${r.error ? ` ERROR: ${r.error}` : ''}`);
} catch (e) {
console.warn(`[snapshot] retention write failed for ${sp} (snapshot continues):`, e.message);
}
}
const rawGraded = (envelope && Array.isArray(envelope.grades)) ? envelope.grades : [];
if (rawGraded.length === 0) return { sport: sp, status: 'skipped', reason: 'no grades', gradeCount: 0 };
+106
View File
@@ -0,0 +1,106 @@
-- 025 — MODEL SNAPSHOTS (retention store). Phase 2, priority zero.
--
-- Why this exists: as of 2026-07-20 the ONLY store of model history was
-- ledger_entries (640 rows, 6 game days) and it holds no model INPUTS. Every
-- other warehouse table in the schema is empty. That means we could score the
-- grades we emitted but could NOT replay a different model against the same
-- conditions — which is the only question a backtest exists to answer.
--
-- This table captures inputs AND outputs at lock time, immutably, so history
-- starts compounding tonight.
--
-- Append-only: ONE ROW PER GRADED PROP PER SNAPSHOT CYCLE. A re-grade at the
-- next cycle writes a NEW row on purpose — that is what lets us ask whether the
-- 14:00 read or the 22:00 read was sharper.
create table if not exists public.model_snapshots (
id bigserial primary key,
-- provenance — never mix model eras (ledger_entries already has this
-- contamination with no marker; everything from here is stamped)
snapshot_id uuid not null,
captured_at timestamptz not null,
cycle_hour_utc smallint,
model_version text not null,
code_sha text,
-- identity (mirrors the ledger natural key so outcomes can be stamped)
sport text not null,
game_id text not null,
game_date date not null,
player_key text not null,
player_name text not null,
team text,
opponent text,
stat text not null,
line numeric not null,
side text not null,
-- MARKET — real book numbers captured at a timestamp, never model output
book text,
book_odds integer,
over_odds integer,
under_odds integer,
fair_odds integer,
fair_prob numeric,
overround numeric,
devig_method text,
-- MODEL OUTPUT
grade text,
grade_11 text,
confidence numeric,
confidence_basis text,
p_win numeric,
ev_pct numeric,
projection numeric,
edge_pct numeric,
takeable boolean,
value boolean,
archetype text,
-- REFUSALS ARE TRAINING DATA. The ledger drops them entirely, so "was the
-- gate right to refuse this?" is currently unanswerable. A too-aggressive
-- gate costs real edge and is invisible without these rows.
refused boolean not null default false,
refusal_reason text,
-- THE COUNTERFACTUAL ENABLER. Schema-free so a new feature never needs a
-- migration. Nulled after 90 days by the retention sweep; scalars stay forever.
features jsonb,
-- OUTCOME — stamped later by the settle pass, per cycle row
outcome text,
actual_value numeric,
settled_at timestamptz,
created_at timestamptz not null default now()
);
-- One row per prop per cycle; a retry of the same cycle must not duplicate.
create unique index if not exists model_snapshots_cycle_prop_uniq
on public.model_snapshots (snapshot_id, player_key, stat, line, side);
create index if not exists model_snapshots_date_sport_idx
on public.model_snapshots (game_date, sport);
create index if not exists model_snapshots_player_idx
on public.model_snapshots (player_key, stat, game_date);
create index if not exists model_snapshots_version_idx
on public.model_snapshots (model_version, game_date);
-- Settle pass looks up unsettled rows by natural key.
create index if not exists model_snapshots_settle_idx
on public.model_snapshots (game_date, player_key, stat, line, side)
where outcome is null;
comment on table public.model_snapshots is
'Append-only model history: inputs (features) + outputs per graded prop per snapshot cycle. Feeds the backtest harness, calibration, and metric validation. Features nulled after 90 days; scalars kept forever.';
comment on column public.model_snapshots.features is
'Full feature vector at lock time. THE counterfactual enabler — without it a backtest can only grade our own homework. Nulled after 90 days by the retention sweep.';
comment on column public.model_snapshots.refused is
'True when the engine refused to grade. Refusals are training data: the only way to detect a gate that is refusing props that would have won.';
comment on column public.model_snapshots.model_version is
'Which engine produced this row. A backtest that mixes model eras is worthless. ledger_entries lacks this and is permanently contaminated across the 2026-07-19 fix boundary.';
-- RLS: service-role writes only, same posture as ledger_entries. This is
-- internal model telemetry — no client reads it.
alter table public.model_snapshots enable row level security;
+140
View File
@@ -0,0 +1,140 @@
/**
* Session 64 — model_snapshots retention.
*
* The point of this store is that it captures what the ledger throws away:
* the model's INPUTS, the pre-collapse grade, and REFUSALS. These lock that.
*/
const retention = require('../../src/services/retentionService');
const CTX = {
snapshotId: '00000000-0000-4000-8000-000000000001',
capturedAt: '2026-07-20T03:00:00.000Z',
cycleHourUtc: 3,
sport: 'mlb',
gameDate: '2026-07-19',
gameIdFor: () => 'mlb:2026-07-19:NYY@BOS',
};
const base = {
player: 'José Ramírez', stat_type: 'hits', line: 0.5,
over_odds: -140, under_odds: 115, book: 'fanduel',
};
const graded = {
player: 'José Ramírez', stat_type: 'hits', line: 0.5, direction: 'over',
grade: 'B', _grade_11: 'B-', confidence: 57, confidence_basis: 'grade_band',
p_win: 0.61, ev_pct: 4.6, model_odds: -156, projection: 0.9, edge_pct: 20,
takeable: true, value: true, book_odds: -140, fair_odds: -125,
fair_prob: 0.556, overround: 0.041, devig_method: 'multiplicative',
_features: { l5_avg: 0.8, l20_avg: 0.7, rest_days: 1 },
};
const refused = {
player: 'José Ramírez', stat_type: 'hits', line: 0.5, direction: 'under',
grade: null, insufficient_data: true, suppressed: true,
suppressed_reason: 'juiced_no_edge',
};
describe('rowsFromSides', () => {
test('captures the feature vector — the counterfactual enabler', () => {
const [row] = retention.rowsFromSides(base, [graded], CTX);
expect(row.features).toEqual({ l5_avg: 0.8, l20_avg: 0.7, rest_days: 1 });
});
test('captures the PRE-COLLAPSE 11-step grade, not just the 4-letter', () => {
const [row] = retention.rowsFromSides(base, [graded], CTX);
expect(row.grade).toBe('B');
expect(row.grade_11).toBe('B-');
});
test('REFUSALS are stored with their reason — the ledger drops these', () => {
const rows = retention.rowsFromSides(base, [refused], CTX);
expect(rows).toHaveLength(1);
expect(rows[0].refused).toBe(true);
expect(rows[0].refusal_reason).toBe('juiced_no_edge');
expect(rows[0].grade).toBeNull();
});
test('a refusal with no features stores null, never a fabricated {}', () => {
const [row] = retention.rowsFromSides(base, [refused], CTX);
expect(row.features).toBeNull();
});
test('both sides of one prop are captured (graded AND refused)', () => {
const rows = retention.rowsFromSides(base, [graded, refused], CTX);
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.side).sort()).toEqual(['over', 'under']);
});
test('every row is stamped with a model version — eras must never mix', () => {
const [row] = retention.rowsFromSides(base, [graded], CTX);
expect(row.model_version).toBe(retention.MODEL_VERSION);
expect(row.model_version).toBeTruthy();
});
test('player name is normalized and keyed like the ledger', () => {
const [row] = retention.rowsFromSides(base, [graded], CTX);
expect(row.player_key).toBe('jose ramirez');
expect(row.player_name).toBe('José Ramírez');
});
test('market values carry through; absent stays absent (no Number(null)=0)', () => {
const [row] = retention.rowsFromSides(base, [{ ...graded, book_odds: null, ev_pct: null }], CTX);
expect(row.book_odds).toBeNull();
expect(row.ev_pct).toBeNull();
expect(row.over_odds).toBe(-140); // from the prop
});
test('a side inherits identity from the prop it was graded from', () => {
// A refusal often carries no echoed identity — base supplies it. This is
// correct, not junk: the prop is what we refused.
const [row] = retention.rowsFromSides(base, [{ direction: 'over' }], CTX);
expect(row.player_key).toBe('jose ramirez');
expect(row.refused).toBe(true);
expect(row.refusal_reason).toBe('no_grade');
});
test('drops rows with no usable identity ANYWHERE rather than writing junk', () => {
expect(retention.rowsFromSides({}, [{ direction: 'over' }], CTX)).toHaveLength(0);
expect(retention.rowsFromSides({}, [{ player: 'X', direction: 'over' }], CTX)).toHaveLength(0); // no stat/line
expect(retention.rowsFromSides(base, [null, undefined], CTX)).toHaveLength(0);
expect(retention.rowsFromSides(base, [{ ...graded, direction: 'sideways' }], CTX)).toHaveLength(0);
});
});
describe('createCollector', () => {
test('accumulates across props and never throws into the grader', () => {
const c = retention.createCollector(CTX);
c.onGraded(base, [graded, refused]);
c.onGraded(base, [graded]);
expect(c.rows).toHaveLength(3);
expect(() => c.onGraded(null, 'not-an-array')).not.toThrow();
});
});
describe('persist — best-effort contract', () => {
test('no rows → no-op, no error', async () => {
expect(await retention.persist([])).toMatchObject({ attempted: 0, written: 0 });
});
test('no supabase client → SKIPPED, not an error (tests/local never write)', async () => {
const r = await retention.persist([{ a: 1 }], { getClient: () => null });
expect(r.skipped).toBe(true);
expect(r.error).toBeNull();
});
test('a database error is captured and RETURNED, never thrown', async () => {
const getClient = () => ({
from: () => ({ upsert: async () => ({ error: { message: 'boom' } }) }),
});
const r = await retention.persist([{ a: 1 }], { getClient });
expect(r.error).toBe('boom');
expect(r.written).toBe(0);
});
test('a thrown client is captured, never propagated (must not break a snapshot)', async () => {
const getClient = () => { throw new Error('no client'); };
await expect(retention.persist([{ a: 1 }], { getClient })).resolves.toMatchObject({ error: 'no client' });
});
});