fix(compact): wipe evolu_timestamp + clear first/lastTimestamp atomically#10
Conversation
…mp atomically `/admin/compact-owner` and `/self/compact-owner` previously deleted `evolu_message` rows + zeroed `evolu_usage.storedBytes` but left two pieces of state behind: 1. The whole `evolu_timestamp` table for that owner. This is the merkle/fingerprint structure Evolu's negentropy reconciliation uses to decide what messages to exchange. With message rows gone but timestamp rows still present, the relay reports fingerprints for timestamps whose underlying payloads no longer exist — peers' subsequent per-row pushes get rejected as "you already have it" and silently disappear instead of refilling the log. 2. `evolu_usage.firstTimestamp` and `lastTimestamp`, which still pointed at deleted message rows. `getOwnerUsage` falls back to these on the next write, leaving stale bookkeeping. Verified in production 2026-05-06 on a 6.5K-message owner: an HMAC- authed `/self/compact-owner` returned `deletedMessages=6481, afterStoredBytes=0` (correct), but `evolu_timestamp` retained 6786 rows. Every subsequent client `forceResendCurrentProfile` reported `Push committed` locally with 444 ops planned, yet only the legacy profileData blob (1 message) ever landed on the relay. The 443 per-row deltas got stranded by the stale fingerprints. Manual `DELETE FROM evolu_timestamp WHERE ownerId = ?` immediately unstuck the owner; full state replicated on the next push. Both deletes + the usage update now run inside the same DB transaction so a partial-failure scenario can't leave the owner in the same wedged state. Test extended to seed `evolu_timestamp` rows + non-null first/lastTimestamp in the fixture and assert all three are cleaned post-compact (regression guard). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR fixes a production bug where compacting an owner's data left
Confidence Score: 5/5Safe to merge — well-scoped atomicity fix backed by unit and integration tests, with no auth or data-flow changes. The extraction into compactOwner() is a straightforward refactor: the SQL executed is identical to the original except for two provably missing statements. The transaction boundary is unchanged, both call-sites pass a fresh DB handle with busy_timeout already set, and the response contract is preserved. Tests cover the regression scenario, idempotency, and cross-owner isolation. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant C as Client
participant EP as /compact-owner endpoint
participant CO as compactOwner()
participant DB as SQLite
C->>EP: POST compact request (auth)
EP->>DB: Open fresh write handle
EP->>CO: compactOwner(db, ownerId)
CO->>DB: BEGIN TRANSACTION
CO->>DB: SELECT storedBytes (before)
CO->>DB: SELECT COUNT(*) FROM evolu_message
CO->>DB: DELETE FROM evolu_message
CO->>DB: DELETE FROM evolu_timestamp (NEW)
CO->>DB: UPDATE evolu_usage firstTimestamp=NULL lastTimestamp=NULL (NEW)
CO->>DB: SELECT storedBytes (after)
CO->>DB: COMMIT
CO-->>EP: CompactOwnerResult
EP->>C: 200 JSON response
EP->>DB: db.close()
Reviews (2): Last reviewed commit: "refactor: extract compactOwner helper so..." | Re-trigger Greptile |
…action Greptile flagged on PR #10 that admin-server.ts had no integration test for the new evolu_timestamp + first/lastTimestamp cleanup steps — correct: the two endpoints had identical-but-duplicated transactions, so a future tweak that landed on one path could silently regress the other. Move the transaction into `src/lib/compact-owner.ts` exporting a single `compactOwner(db, ownerIdBytes)` function. Both /admin/compact- owner and /self/compact-owner now call it. There's no longer per-endpoint compact code to drift. Added direct unit coverage at `test/compact-owner.test.mjs`: - deletes evolu_message rows - deletes evolu_timestamp rows (the production-wedge regression guard) - zeroes storedBytes + clears first/lastTimestamp - idempotent on already-empty owner - other owners' state untouched (defence-in-depth against a bad WHERE clause in a future refactor) Net: admin-server.ts -51 lines, self-server.ts -36 lines, +1 helper file, +1 unit test file. 26 tests pass (5 new + 21 existing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Addressed Greptile's drift concern by extracting the shared transaction:
Both endpoints now exercise the same code path, so future tweaks can't silently regress one and not the other. 26/26 tests green. |
Summary
Both
/admin/compact-ownerand/self/compact-ownerpreviously left the owner'sevolu_timestamptable populated andevolu_usage.firstTimestamp/lastTimestamppointing at deleted messages. The merkle/fingerprint mismatch this creates strands every subsequent client push — Evolu's negentropy reconciliation reports "I already have those timestamps" based on the stale fingerprint table, and peers' fresh per-row deltas get silently rejected.This patch performs all three cleanups (
DELETE FROM evolu_message,DELETE FROM evolu_timestamp,UPDATE evolu_usage SET storedBytes=0, firstTimestamp=NULL, lastTimestamp=NULL) inside the existing transaction in both endpoints, so the owner's storage is genuinely empty after compact (matches the contract).Production reproduction (2026-05-06)
/self/compact-owner{ deletedMessages: 6481, beforeStoredBytes: 52,348,941, afterStoredBytes: 0 }✓forceResendCurrentProfilereportedPush committedlocally with 444 delta ops planned + applied, but only the legacy profileData blob (1 message) actually landed on the relay. The 443 per-row deltas evaporated.DELETE FROM evolu_timestamp WHERE ownerId = ?immediately unstuck the owner; on the next push all 444 ops materialized as 2720 small + 101 large messages.What changed
src/lib/admin-server.tsandsrc/lib/self-server.ts: extend the existingdb.transaction(() => {...})to also DELETE fromevolu_timestampand clear first/lastTimestamp onevolu_usage.test/self-server.integration.test.mjs:evolu_timestamprows + non-null first/lastTimestamp alongside the 5evolu_messagerowsAll 21 self-server tests pass (12 unit + 9 integration).
Test plan
npm testgreen locally (21/21)/self/compact-owner+ verifyevolu_timestampis empty for that owner🤖 Generated with Claude Code