Skip to content

feat(scan): failure recovery for large scans + unified error logging system - #31

Draft
RenJeka wants to merge 33 commits into
mainfrom
claude/olx-scan-failure-recovery-tmci0q
Draft

RenJeka wants to merge 33 commits into
mainfrom
claude/olx-scan-failure-recovery-tmci0q

Conversation

@RenJeka

@RenJeka RenJeka commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Частина 1 — Стійкість великих сканів (7faa734)

Проблема

Великий глибокий скан (сотні запитів, ~година) інколи падав майже наприкінці, і все зібране втрачалося — у БД не потрапляло нічого. Причини:

  1. All-or-nothing запис: upsertListings викликався один раз у finalizeScanResult, після завершення всього фетчу — весь скан жив у пам'яті.
  2. Цикл варіантів (синонімів) не рятував зібране: збій одного пізнього варіанта (напр. анти-бот 403 після сотень запитів — детермінований, не ретраїться — і HTML-fallback теж впав) валив увесь скан разом із даними попередніх варіантів (fetchAllQueries, runDeepScanFromPlan).
  3. Бісекція цін без захисту: викид посеред bisectPriceRange валив весь варіант.
  4. Побічно знайдено: runDeepScanFromPlan викликав scanFromPlan без deep: true → noSplit-варіанти пагінувалися лише на 3 сторінки.

Рішення

  • ScanPersister (server/src/scanner/scanPersister.ts): зібране upsert-иться в БД ітераціями по ходу скану через новий колбек FetchOptions.onListings — після кожної сторінки deep-скану, кожного цінового бакету і кожного варіанта запиту. Дедуп уже збережених olx_id (чесні found/new_count, без подвійних Turso-записів); проміжний flushSafe ковтає транзієнтні збої БД, фінальний flush кидає чесно. Звичайний (≤3 запити) скан пише один раз, як і раніше.
  • Порятунок часткових даних: збій пізнього варіанта або посеред бісекції (SplitPlan.probeWarning) завершує скан частковим успіхом (warning, вікно покриття пропускається), а не помилкою.
  • Фікс deep: true у runDeepScanFromPlan → повна допагінація noSplit-варіантів.
  • Чанкування db.batch (≤500 statements) в upsertListings.

Частина 2 — Система логування помилок (739d85d)

Проблема

Логування було фрагментарне: вбудований pino Fastify писав JSON лише в stdout (зникає після рестарту), scan_runs.error зберігав тільки фінальний текст без етапу, 2 console.error йшли повз логер, ~28 «тихих» best-effort catch були повністю невидимі.

Рішення

  • Єдиний сервіс server/src/logger.ts на базі pino (той самий інстанс у Fastify через loggerInstance; у dev — pino-pretty, вмикається лише якщо пакет резолвиться): logError/logWarn(scope, stage, …) → stdout + таблиця app_logs. Кожен запис несе scope (модуль) і stage (крок data flow: bisect ₴0–5000, variant «x» 2/4, POST /api/…). У БД — лише warn+error (бережемо Turso writes); запис fire-and-forget, без рекурсії. Retention 14 днів.
  • Глобальні перехоплювачі: app.setErrorHandler (5xx → журнал з методом+URL), unhandledRejection/uncaughtException.
  • Інструментовано раніше невидимі місця: центральний catch скану (withScanRun, з searchId/runId), транзієнтні ретраї GraphQL (ранній сигнал «OLX відбиває»), порятунок бісекції/бакетів/варіантів, best-effort facet, verify-проби, невдалі спроби OpenRouter.
  • Перегляд: GET /api/logs (фільтри level/scope) + DELETE /api/logs + діалог «Журнал» у хедері фронта (фільтри, розгортання stack/details, авто-оновлення 5с).
  • Залежності: pino (версія з дерева fastify) як пряма залежність server, pino-pretty (devDep).

Перевірка

  • tsc (server) + npm run build (server+web) — зелені.
  • ScanPersister на реальній файловій libSQL-БД: дедуп між флашами, чесні found/new_count, запис 1200 рядків чанками — PASS.
  • Logger на файловій БД: warn+error персистяться зі scope/stage/stack, info — ні; retention прибирає 20-денний запис — PASS.
  • E2E: сервер стартує, GET /health, GET/DELETE /api/logs працюють.
  • Плани з ручними test-cases: docs/plans/scan-failure-recovery.md, docs/plans/logging-system.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XtybAF4xeHxLbLyKoxN9ac

RenJeka and others added 30 commits June 23, 2026 16:03
…Turso)

Phase 0: swap the DB access mechanism only — business logic, schema, and the
OLX collection method are unchanged. libSQL is SQLite-compatible (single code
path: file: locally, Turso URL in prod), enabling Render + Turso deploy.

- db.ts: createClient + initDb (executeMultiple schema.sql) + thin async
  helpers dbGet/dbAll/dbRun; drop the historical migration scaffold
  (addColumnIfMissing/migrateListingsTable/backfill/PRAGMA/WAL) — schema.sql
  already holds every column.
- env.ts: load server/.env before db.ts reads TURSO_* (imported first in db.ts).
- Whole DB layer is async: every .get/.all/.run -> await dbGet/dbAll/dbRun;
  interactive db.transaction('write') for read->decide->write (upsert,
  statusEngine, analysis/relevance/aiPicks commit); db.batch('write') for pure
  write sets (cascade delete, sort swaps, filtered_out recompute, migratePostedAt).
- index.ts: host 0.0.0.0, CORS origin from WEB_ORIGIN, await initDb before listen.
- CLI entrypoints (scan.ts, migratePostedAt.ts) call initDb.
- .env.example: TURSO_DATABASE_URL/TURSO_AUTH_TOKEN/WEB_ORIGIN.
- docs: architecture.md/structure.md updated; render-turso-phase0.md marked done.

Verified locally against libSQL file:: build green; /health ok; live OLX scan
(GraphQL) -> upsert+dedup; coverage-window disable; manual PATCH override;
CLI scan; cascade-delete/recompute via batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AX5fZLCKjxyASaMAxMxxum
Beginner-friendly walkthrough: Turso DB setup, Render Web Service (backend) with
build/start commands and env vars, Render Static Site (frontend) with /api/*
rewrite (same-origin, no CORS), verification, free-tier cold-start notes, and
troubleshooting. No code changes — frontend keeps relative /api fetch; the
Render rewrite proxies it to the backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AX5fZLCKjxyASaMAxMxxum
Gate access to the dashboard behind Google login (allowlist of one email)
without turning the app multi-user — no users table, no user_id, no data
partitioning. Auth is a lock on the front door plus a session cookie.

Server (server/src/auth/):
- config.ts: env (GOOGLE_CLIENT_ID/ALLOWED_EMAILS/SESSION_SECRET), cookie
  flags (prod cross-site Secure+SameSite=None, local http Lax), fail-fast
  assertAuthConfigured, AUTH_DISABLED dev bypass.
- plugin.ts: fastify-plugin (non-encapsulated) registering @fastify/cookie
  and @fastify/jwt, verifyGoogleIdToken via google-auth-library, global
  onRequest lock on /api/* (skips /health, /api/auth/*, CORS preflight).
- routes.ts: POST /api/auth/google, GET /api/auth/me, POST /api/auth/logout.
- index.ts: import ./env first, assertAuthConfigured, CORS credentials,
  register auth before domain routes.

Frontend (web/src/auth/):
- useAuth.ts: useSession/useLogin/useLogout, reacts to global 401 event.
- AuthGate.tsx: login screen with GoogleLogin, wraps the app.
- base.ts: credentials: 'include', VITE_API_BASE prefix, 401 -> gate event.
- main.tsx: GoogleOAuthProvider. Header: logout button.

DB schema untouched. Docs + .env examples updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZmcZ6mmoQa5rv9gCa1KX
… cache tuning

Optimize database reads/writes for the Turso (libSQL) backend, where every
execute is a network round-trip.

- normalizer.upsertListings: replace the per-listing loop (EXISTS -> upsert ->
  read-back -> UPDATE filtered_out, ~4 round-trips/row) with one bulk SELECT of
  existing fields, in-memory COALESCE merge + filtered_out, and a single
  db.batch('write'). A scan of N listings now does ~2 round-trips instead of ~4N.
  filtered_out is folded into the UPSERT (no separate read/write).
- schema: add idx_listings_search_refresh (coverage window) and
  idx_listings_search_lastseen (verify P1).
- scanRunLifecycle: throttle scan-progress writes to >=1s (poll is 1.5s),
  avoiding dozens of redundant UPDATEs during deep scans.
- web: QueryClient defaults staleTime 60s + refetchOnWindowFocus false to stop
  refetching the full listings list on every window focus.
- docs/architecture.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHUDe59mPDD1miT5km3n3u
strict mode bug
  Render cannot reach www.googleapis.com cert endpoints (v1 PEM → 403,
  v3 JWK → non-200), so google-auth-library/jose-JWKS verification fails
  in production. Verify the ID token primarily via the tokeninfo endpoint
  on oauth2.googleapis.com (a reachable host; Google checks the signature,
  we validate aud/iss/email_verified), falling back to local jose+JWKS
  verification where www.googleapis.com is reachable (e.g. local dev).

  Якщо на Render увімкнено auto-deploy з гілки — після пушу він сам передеплоїться. Підтверди — і я запушу (або скажи, чи хочеш разом із рештою змін цієї сесії:
  оптимізація БД + фронтенд-фікси).
…sses

On a plain page reload these always-fetched (stats) / on-demand (filter-options)
endpoints scanned the search's listings multiple times. On Turso each pass bills
N rows read, so for ~400 listings a reload cost ~2600 reads.

- /stats: replace 4 separate scans (in_db COUNT, stale COUNT, verify P1 COUNT,
  verify P2 COUNT) with one aggregate using conditional SUM(CASE...). Reuse
  P1_CONDITION/P2_CONDITION (now exported from verifyScan.ts) so the verify
  predicate stays single-sourced. ~1600 -> ~400 reads.
- /filter-options: replace 4 scans (DISTINCT city, DISTINCT seller_name, pros,
  cons) with one SELECT and JS dedup. categories still from category_facet.
- SearchStats / FilterOptions contracts unchanged; frontend untouched.

Verified on a temp DB: aggregate stats and consolidated filter lists match the
old per-query results exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHUDe59mPDD1miT5km3n3u
…sses

On a plain page reload these always-fetched (stats) / on-demand (filter-options)
endpoints scanned the search's listings multiple times. On Turso each pass bills
N rows read, so for ~400 listings a reload cost ~2600 reads.

- /stats: replace 4 separate scans (in_db COUNT, stale COUNT, verify P1 COUNT,
  verify P2 COUNT) with one aggregate using conditional SUM(CASE...). Reuse
  P1_CONDITION/P2_CONDITION (now exported from verifyScan.ts) so the verify
  predicate stays single-sourced. ~1600 -> ~400 reads.
- /filter-options: replace 4 scans (DISTINCT city, DISTINCT seller_name, pros,
  cons) with one SELECT and JS dedup. categories still from category_facet.
- SearchStats / FilterOptions contracts unchanged; frontend untouched.

Verified on a temp DB: aggregate stats and consolidated filter lists match the
old per-query results exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHUDe59mPDD1miT5km3n3u
…q4p' into claude/turso-db-optimization-dv0q4p
…pass

  The /stats endpoint ran a separate SUM(CASE…) scan over all listings on every
  search-select to derive in_db/stale_count/verify_candidates — all derivable from
  the listings array the client already has. Now /stats returns only last_scan
  (~1 read) and the client computes aggregates via computeListingStats().

  - server: trim /api/searches/:id/stats to LastScanResponse (last_scan only)
  - web: add utils/searchStats.ts; useSearchStats merges last_scan + derived counts
    from the ['listings'] cache (deduped with the table — no extra request)
  - bonus: verify_candidates now updates live on status edits (same cache)
  - expected: /stats 410→~1 read, search-select 1228→~819 (−33%); verify after deploy

  docs: plan docs/plans/turso-stats-clientside.md, structure.md
…nder 401)

The blob-download helpers for the AI workflows (analysis package, relevance
package, AI Picks package, and the xlsx/json preview export) used a bare
fetch('/api/...') without the API_BASE prefix or credentials: 'include'. On
Render the web app and API are separate origins, so the SameSite=None session
cookie was not sent and the global JWT lock returned 401. Locally the Vite proxy
made it same-origin, hiding the bug.

Add a shared apiBlob() helper in api/base.ts that mirrors api() (API_BASE prefix,
credentials: 'include', conditional Content-Type, 401 -> re-login event) but
resolves to a Blob, and route all four download functions through it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECwRYEMkkETto1AWc6e7Jm
… instead of aborting the scan

  A single transient OLX GraphQL request failure (HTTP 429 after the analyze
  burst, a 5xx gateway blip, or an anti-bot 200 with a non-JSON body) aborted
  the entire deep scan, and the inline HTML fallback in runDeepScanFromPlan
  then swallowed the GraphQL cause, surfacing only the misleading "page renders
  via JS" error.

  - client.fetchPage: retry up to 3 attempts with backoff for transient
    conditions (network error, HTTP 429/5xx, non-JSON body); deterministic
    errors (4xx, ListingError, schema errors) still throw immediately.
  - runDeepScanFromPlan: wrap the HTML fallback in try/catch and throw a
    combined "graphql failed: …; html fallback failed: …" error, mirroring
    fetchWithFallback, so the real GraphQL cause is never masked.
  - fetchSearch / scanSingleBucket: when retries are exhausted mid-pagination
    and data is already collected, stop with a partial-success warning and
    move on (next bucket/variant) instead of throwing; only throw when no data
    was collected yet (offset=0) so the HTML fallback still gets a chance.
    Partial results skip the coverage window, so no false auto-disables.
Introduce a single shared "scope" control (AiScope: all/tab/selected/
candidates) used by the relevance filter, pros/cons wizard and AI Picks:

- add utils/aiScope.ts (getScopeIds/getScopeCounts/getDefaultScope) and
  hooks/analysis/useAiScope.ts, reusing listingVisibility predicates
- add components/analysis/ScopeSelector.tsx: 4 always-visible toggles
  (disabled instead of hidden); "candidates" set apart in amber + star,
  default only in AI Picks
- "Весь пошук" now means literally all rows (incl. filtered/irrelevant);
  "tab" label shows the active tab name with a live count
- AI Picks scopes are functional: client sends scope ids; server
  loadPickCandidates(id, ids?) and prompt/package.zip (GET->POST) +
  rank/import accept an optional ids pool
- update CLAUDE.md scope invariant, architecture.md, structure.md,
  ai-flow.md; add docs/plans/ai-scope-selector.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167F8cHBaaX9GJ9ccxw6Zqq
A deep scan adding +876 listings wrote ~6,500 Turso rows: every seen row
(new and unchanged) was re-upserted, and each write hit the table plus all
3 secondary indexes (last_seen_at/last_refresh_at/status are always in the
UPSERT SET clause), so each row cost 4 rows_written.

- normalizer: diff existing rows; only new/HTML/genuinely-changed GraphQL
  rows take the full UPSERT. Unchanged rows get a cheap batched last_seen_at/
  miss_count touch, throttled to once per day in SQL (fresh rows write nothing).
- schema/db: drop idx_listings_search_lastseen (rewritten on every scan since
  last_seen_at always changes); verify P1 falls back to scan+sort. DROP INDEX
  IF EXISTS migration in initDb for deployed DBs.
- statusEngine: replace per-candidate tx.execute loop with one db.batch; the
  non-disable branch updates only miss_count (no status-index churn).

Behavior (statuses, coverage window, verify, reactivation) unchanged;
verified end-to-end against a local libSQL file DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCxt8KYN4Tm9feNhsBRMdo
…ists

Empty TURSO_DATABASE_URL in .env bypassed the ?? fallback (empty string is
not nullish), passing url:'' to libSQL (URL_INVALID). Normalize blank env
vars to undefined and mkdir the parent dir for file: databases so a clean
clone / empty env starts against server/data/olx.db instead of crashing.
The AI Picks manual (ZIP) prompt told the agent to print the final JSON to
chat, unlike steps 1-2 which write output.json and forbid console output. It
also lacked the shared forbidden-list / fallback scaffolding.

- New manualZip.ts holds the shared prompt fragments (mechanicalIntro,
  packageContents, forbidden + FORBID_* items, resultLine, fallbackBlock,
  OUTPUT_FILE) so all three manual builders compose instead of duplicating.
- AI Picks: rewrite as scriptless map-reduce ON FILES — each chunk nominates
  into nominations/nominees-NNN.json (nothing to console), then the agent reads
  all nominee files and writes the final top-N to output.json itself. No .py:
  ranking is judgment, so no deterministic engine and no merge script needed;
  files still survive weak-model context loss. Adds forbidden-list + fallback.
- relevance.ts and prompts.ts refactored to source the shared skeleton from
  manualZip.ts; wording converged (map/reduce labels, single output.json/paste
  convention).
- docs/ai-flow.md rewritten as a high-level view of all three AI steps (common
  agent pattern, per-step differences, code pointers); structure/architecture
  refs updated.

Server prompts rendered and eyeballed; typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCxt8KYN4Tm9feNhsBRMdo
Weak agentic models fill an existing file more reliably than they create a
correctly-named one, and a set of empty slots doubles as a visible checklist.
So the AI Picks ZIP now ships an empty nominations/nominees-NNN.json ([]) per
chunk, and the prompt tells the agent to FILL each slot from its paired
candidates chunk (same NNN) instead of creating files. Still scriptless.

Since step 3 has no verify.py, the prompt adds a self-check before the reduce:
no slot may stay [] by mistake (empty is allowed only when a chunk genuinely
has no worthy candidate). Docs (ai-flow/structure/architecture) updated.

ZIP contents verified to include the nominee stubs; typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCxt8KYN4Tm9feNhsBRMdo
Symmetry with the AI Picks change: the AI Filter ZIP now ships an empty
classifications/result-NNN.json ([]) per chunk, and the prompt tells the agent
to FILL each slot from its paired descriptions chunk (same NNN) instead of
creating the folder and files. Weak agentic models fill an existing,
correctly-named file more reliably, and the empty slots act as a checklist.

No self-check needed here (unlike AI Picks): the shipped verify.py already
flags any unfilled slot as a missing id and sends the agent back to
re-classify. Docs (ai-flow/structure/architecture) updated.

Typecheck clean; archiver stub-append mechanics already verified for AI Picks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCxt8KYN4Tm9feNhsBRMdo
… scans

Large deep scans (hundreds of requests, ~1 hour) previously lived entirely
in memory and were written to the DB once at the very end - any exception
near the end (e.g. OLX anti-bot 403 on a late synonym variant after both
GraphQL and HTML fallback failed, or a failure mid price-bisection) lost
everything collected.

- Add ScanPersister: listings are upserted iteratively during the scan
  (per deep-scan page, per price bucket, per query variant) via new
  FetchOptions.onListings; already-persisted olx_ids are deduped so
  found/new_count stay correct and Turso writes are not duplicated.
  Intermediate flush failures are swallowed (retried on next flush);
  the final flush in finalizeScanResult still throws honestly.
- Salvage partial data instead of failing the whole scan: a late query
  variant crash (fetchAllQueries / runDeepScanFromPlan) or a mid-bisection
  crash (SplitScanner.bisectPriceRange -> SplitPlan.probeWarning) now ends
  the scan early as a partial success with a warning, keeping everything
  collected so far.
- Fix runDeepScanFromPlan calling scanFromPlan without deep:true - noSplit
  variants were paginated in normal mode (3 pages) instead of full depth.
- Chunk db.batch in upsertListings (<=500 statements) so a huge final
  batch cannot fail on payload size after a successful fetch.
- Docs: plan docs/plans/scan-failure-recovery.md, architecture/structure
  updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtybAF4xeHxLbLyKoxN9ac
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7376cf10-3776-4f8a-a428-99ef2ffc1b34

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/olx-scan-failure-recovery-tmci0q

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…I journal

Logging was fragmented: Fastify's built-in pino wrote JSON to stdout only
(lost on restart), scan_runs.error captured just the final scan message
without the failing stage, two console.error calls bypassed the logger,
and ~28 intentional best-effort catches were completely invisible.

- Add server/src/logger.ts as the single logging service: root pino
  (passed to Fastify via loggerInstance so HTTP and app logs share one
  stream; pino-pretty in dev, enabled only when resolvable),
  getLogger(scope) for info/debug, and logError/logWarn(scope, stage,...)
  that write to stdout AND persist into the new app_logs table. Every
  journal entry carries scope (module) and stage (data-flow step, e.g.
  "bisect UAH0-5000", "variant 2/4", "POST /api/..."). Only warn+error
  are persisted (info/debug stay stdout-only to protect Turso writes);
  journal writes are fire-and-forget and never recurse or break logic.
  Retention: entries older than 14 days are purged on startup.
- Global catchers: app.setErrorHandler (unhandled 5xx -> journal with
  method+URL), unhandledRejection / uncaughtException handlers.
- Instrument previously invisible failure points: central scan-run catch
  (withScanRun, with searchId/runId), GraphQL transient retries (early
  "OLX is blocking" signal), bisection/bucket/variant salvage paths,
  best-effort category facet, verify page probes, failed OpenRouter
  attempts; ScanPersister.flushSafe now logs via the service instead of
  console.error.
- Viewing: GET /api/logs (level/scope filters) + DELETE /api/logs, and a
  "Journal" dialog in the web header (filters, expandable stack/details,
  5s auto-refresh while open).
- Deps: pino (same version already inside Fastify's tree) as a direct
  server dependency, pino-pretty as devDep.
- Docs: plan docs/plans/logging-system.md, architecture/structure/CLAUDE
  updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtybAF4xeHxLbLyKoxN9ac
@RenJeka RenJeka changed the title feat(scan): incremental persistence + partial-data recovery for large scans feat(scan): failure recovery for large scans + unified error logging system Jul 15, 2026
…eport

Post-review cleanup of the two prior features:
- Extract variantFailureNote() into fetchOrchestrator (shared by
  fetchAllQueries and runDeepScanFromPlan) so the salvage warning text
  stays identical in both scan paths instead of being copy-pasted.
- Extract reportLoggerFailure() in logger.ts for the two identical
  "journal write failed" self-log sites; bind the retention interval as
  a SQL parameter instead of string-interpolating it.

No behavior change. Verified: server tsc + web build green; persister,
logger, and in-process setErrorHandler journaling smoke tests all pass
(uncaught 5xx -> app_logs with stage=method+URL and stack; 4xx skipped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtybAF4xeHxLbLyKoxN9ac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants