diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh index 88dde49..961a587 100644 --- a/scripts/backup-db.sh +++ b/scripts/backup-db.sh @@ -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 diff --git a/src/services/gradeSlateService.js b/src/services/gradeSlateService.js index f995752..042a48f 100644 --- a/src/services/gradeSlateService.js +++ b/src/services/gradeSlateService.js @@ -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)); diff --git a/src/services/intelligence/analyzeViaEngine1.js b/src/services/intelligence/analyzeViaEngine1.js index 76486a1..b026611 100644 --- a/src/services/intelligence/analyzeViaEngine1.js +++ b/src/services/intelligence/analyzeViaEngine1.js @@ -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) diff --git a/src/services/retentionService.js b/src/services/retentionService.js new file mode 100644 index 0000000..6420940 --- /dev/null +++ b/src/services/retentionService.js @@ -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 }, +}; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 35e8be7..4c73321 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -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 }; diff --git a/supabase/migrations/025_model_snapshots.sql b/supabase/migrations/025_model_snapshots.sql new file mode 100644 index 0000000..e144059 --- /dev/null +++ b/supabase/migrations/025_model_snapshots.sql @@ -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; diff --git a/tests/unit/retentionService.test.js b/tests/unit/retentionService.test.js new file mode 100644 index 0000000..24d040e --- /dev/null +++ b/tests/unit/retentionService.test.js @@ -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' }); + }); +});