feat: automated pool archival and cleanup with data preservation - #260
Merged
Sendi0011 merged 7 commits intoAug 28, 2026
Merged
Conversation
Introduces the data model and the pure decision logic behind automated pool archival (issue JointSave-org#212). Archival is an off-chain visibility layer only — no pool metadata, member, or activity row is ever deleted, and the on-chain contract is untouched. Schema: - pools gains archived_at, archive_reason, plus the completed_at and emergency_withdrawn_at anchors the grace periods need (neither existed). - pools.status now admits 'emergency_withdrawn'. - New archive_log table records every archive/unarchive, automated or manual, so a pool that left discovery can always be traced back to the run that hid it. Public SELECT, service-role writes only, matching pool_activity. - Partial indexes over archived_at IS NULL keep the Explore and My Groups default queries off a full scan as archived rows accumulate. Rules (lib/archival.ts, framework-free so it runs under node --test): - completed + 7 day grace, emergency_withdrawn + 30 day grace, and inactive_90d, which requires silence *and* an empty balance. A quiet pool still holding member funds is deliberately never swept — that false positive would hide real money. - Paused pools are exempt: pausing is an admin decision the sweep must not undo. Unparseable or future timestamps fail closed. 30 unit tests cover each criterion, the grace boundaries, idempotency across daily runs, and the balance-derivation helpers.
GET /api/pools takes an `archived` param, default off, across all four list branches (explore, creator, member, fallback): omitted excludes archived pools, `true` includes them, `only` returns just the archived set for the My Groups "Archived" tab. The creator and explore branches filter in the query so the partial indexes do the work; the member branch filters after the join, since `pools` is an embedded relation PostgREST cannot filter without dropping the membership rows being paginated. PUT /api/pools/[id]/archive and .../unarchive give the pool creator manual control. Both are admin-only, both write an archive_log row, and both mirror into pool_activity so members see why a pool went quiet instead of finding it silently missing. Unarchive carries the previous reason onto its log row so a reversal records what it undid. Read-only is enforced server-side, not just in the UI: a shared guard blocks writes to archived pools in PATCH /api/pools, the deposit verifier, and pool chat, returning 409 with the archival reason. Hidden buttons alone would leave a stale tab or a direct request able to mutate an archived pool. GET paths are untouched — archived pools stay fully readable and exportable.
POST/GET /api/cron/archive-pools, scheduled at 02:00 UTC. Vercel Cron issues a GET, so GET is the real handler and POST delegates to it for manual runs. Applies the lib/archival.ts criteria, sets archived_at and archive_reason, writes an archive_log row per pool, and notifies each affected pool's admin with the reason in plain language plus how to get the pool back. Deletes nothing. Notes on the implementation: - One batched pool_activity read for the whole sweep rather than a query per pool, unlike snapshot-pool-metrics — this runs over the same table and the per-pool loop there is already the slowest cron in the project. - The update re-asserts `archived_at IS NULL`, so a manual archive landing between the scan and the write is not overwritten. - A run wanting to archive more than 200 pools stops and reports instead. At that volume a bad backfill or clock skew is likelier than a real cliff of dead pools, and the failure mode is an emptied Explore page. - archive_log and notification failures are collected, not thrown: neither should roll back an archival that already succeeded. - Every run heartbeats into cron_job_logs, so it shows up in /api/cron/health like the existing jobs.
Explore gets a "Show archived" switch, off by default, so the feed is active pools only until asked otherwise. My Groups splits into Active and Archived tabs. Both keep their state in the URL, so an archived view survives a refresh and a back navigation from a pool's history page, and both use their own query params so paging in one does not disturb the other. New components/shared/archived-pool-card.tsx renders the compact archived row: name, type, an Archived badge, the archival reason in plain language, completion date, member count, final TVL, and View History. It deliberately skips the on-chain read, health badge, and sparkline that PoolCard does — an archived pool's numbers are final, so a page of per-card RPC calls would buy nothing. That also keeps the archived list cheap no matter how long it grows. The archived tab fetches lazily, only once opened, so never looking at it costs nothing. Full en/es strings for every new surface.
An archived pool's detail page now leads with a banner saying it is archived, why, and that nothing was lost — a member arriving from a bookmark should not have to guess why the deposit button disappeared. Read-only is done by removing the whole actions column, the lending tab, and the yield dashboard rather than disabling each control: the surfaces that mutate simply do not mount, and the API refuses those writes independently. The read surfaces — details, members, activity, audit logs, export — are untouched, with the activity feed labelled historical. Admins get both directions: a confirmed "Archive pool" control on an active pool, and "Restore pool" in the banner, which is the escape hatch if the daily sweep ever hides something it should not have. 7 component tests cover the banner and its reason text, the absence of every mutating control, the historical activity label, and the archived card's fields and View History link. The not-archived case asserts Quick Actions *is* present, so the hidden-controls test is proving the gate rather than a mock that never mounted them.
docs/pool-archival.md covers the schema and why completed_at / emergency_withdrawn_at had to be added, the three automated criteria and the reasoning behind the balance check in the inactivity rule, the sweep's idempotency and 200-pool safety valve, the API surface including server-side read-only enforcement, the UI, and a rollback path. Also linked from CHANGELOG.md and supabase/README.md, since the archival job is a Vercel Cron route rather than an Edge Function and would otherwise not appear alongside the other scheduled jobs.
Resolves the conflict with the CCTP bridge feature (JointSave-org#253, PR JointSave-org#258). Only frontend/package.json conflicted: both branches appended a suite to test:unit. Kept both — lib/archival.test.ts and lib/cctp-bridge.test.ts — rather than taking a side, so neither feature's tests stop running. lib/supabase.ts, messages/en.json, and messages/es.json auto-merged cleanly; each side added its own keys in a different part of the file. Verified both features survived: archive_log/ArchiveReason and bridge_transactions are both in the Database type, and both message files carry the archival keys and all 36 bridge keys. Post-merge: 291 unit tests pass (273 + the bridge's 18), 128 component tests pass, lint clean, build compiles and registers the routes from both features.
Sendi0011
self-requested a review
August 28, 2026 09:21
10 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Implements automated pool archival and cleanup: completed, emergency-withdrawn, and genuinely dead pools are moved out of Explore and My Groups into a read-only Archived view, on a daily schedule, with manual admin control in both directions and a full audit trail.
Archival never deletes anything. An archived pool keeps every row it owned — metadata, members, activity, daily metrics, messages, disputes. Its detail page still renders, its activity feed is still readable, and CSV/PDF export still works. The on-chain contract is immutable and completely unaffected; this is a Supabase-side visibility flag and nothing more. Every archival is reversible by the pool admin in one click.
Closes #212
What's included
Database —
supabase/migrations/20260828000000_pool_archival.sqlpoolsgainsarchived_atandarchive_reason, pluscompleted_atandemergency_withdrawn_at. ACHECKkeepsarchived_atandarchive_reasonin lockstep, so no row can claim to be archived without saying why.pools.statusnow also admitsemergency_withdrawn.New
archive_logtable records one row per archive and per unarchive — pool, action, reason,triggered_by(cronor a wallet),automated, and a note. PublicSELECT, service-role writes only, matchingpool_activityanddisputes.Explore and My Groups both filter on
archived_at IS NULL, so the default queries are backed by partial indexes covering only the active set. Because those indexes never hold archived rows, the default views get faster as pools are archived rather than slower — which is the performance acceptance criterion.Archival rules —
frontend/lib/archival.tsPure and framework-free, so they run under the node test runner without a database.
completedstatus = 'completed',completed_atolder than 7 daysemergency_withdrawnstatus = 'emergency_withdrawn',emergency_withdrawn_atolder than 30 daysinactive_90dstatus = 'active', nopool_activityfor 90 days, and the pool holds no fundsadmin_archivedDaily sweep —
POST/GET /api/cron/archive-poolsScheduled in
vercel.jsonat 02:00 UTC. Vercel Cron issues aGET, soGETis the real handler andPOSTdelegates to it for manual runs and for the endpoint shape the issue specifies.archived_at IS NULLin itsWHERE, so a manual archive landing between the scan and the write is not overwritten.evaluateArchival, so a double run is a no-op.archive_logand notification failures are collected and returned, not thrown — neither should roll back an archival that already succeeded.cron_job_logs, so it shows up in/api/cron/healthlike the existing jobs. SameBearer ${CRON_SECRET}auth as the other crons.API
GET /api/pools?archived=true→ both ·only→ archived onlyPUT /api/pools/[id]/archive{ admin_address, reason?, note? }— pool creator onlyPUT /api/pools/[id]/unarchive{ admin_address, note? }— pool creator onlyThe
archivedparam applies to all four list branches (explore, creator, member, fallback). The creator and explore branches filter in the query so the partial indexes are used; the member branch filters after the join, becausepoolsthere is an embedded relation PostgREST cannot filter without dropping the membership rows being paginated.Both mutation endpoints write an
archive_logrow and mirror intopool_activity, so members see why a pool went quiet rather than finding it silently missing from their list. Unarchive carries the previous reason onto its log row so a reversal records what it undid.UI
components/shared/archived-pool-card.tsx— compact row with name, type, badge, reason, completion date, member count, final TVL, and "View History".Tab and toggle state live in the URL, so an archived view survives a refresh and a back navigation from a pool's history page. All strings translated in
messages/en.jsonandmessages/es.json.Design decisions worth reviewing
1. The issue assumed three columns that didn't exist.
poolshad nocompleted_atoremergency_withdrawn_at, andstatusonly allowedactive | completed | paused— but the criteria are written against all three. Without those anchors the grace periods are unimplementable: a pool would archive the instant its status flipped, and the 7-day review window in the issue would never exist. I added all three, and backfilled existing completed pools fromupdated_atso the first sweep gives them a grace period instead of archiving them all at once.2.
inactive_90drequires an empty balance, not just silence. This is the decision I'd most like a second opinion on. "No activity for 90 days" alone would archive a pool sitting quietly on real member deposits — and hiding a pool from the people whose money is in it is a trust problem, not a cleanup. So the rule additionally requires the pool to hold nothing: never funded, or fully withdrawn. The balance is derived from the activity feed (deposits minus withdrawals and payouts), reusing the same aggregation as/api/analyticsand the metrics cron, so all three agree on what "empty" means.Two more guards in the same spirit: paused pools are exempt (pausing is a deliberate admin decision the sweep must not quietly undo), and unparseable or future timestamps fail closed — a bad date keeps a pool visible rather than hiding it.
3. Read-only is enforced server-side, not just in the UI. Hiding the deposit button is presentation.
lib/server/archival-guard.tsblocks writes aimed at an archived pool at the API boundary, returning409with the archival reason, onPATCH /api/pools,POST /api/pools/deposit, andPOST /api/pools/messages. A stale tab, a bookmarked request, or a directcurltherefore cannot mutate an archived pool.GETpaths are deliberately untouched so archived pools stay fully readable and exportable.On the page itself, read-only is done by not mounting the actions column, lending tab, and yield dashboard, rather than disabling each control individually — there is no disabled button to re-enable in devtools. Details, members, activity, audit logs, and export are unchanged, with the activity feed labelled historical.
4. The archived card deliberately does less than
PoolCard. No on-chain read, no health badge, no sparkline. An archived pool's numbers are final, so per-card RPC calls would buy nothing, and the archived list is exactly where a page of them would be pure waste. It also keeps that list cheap however long it grows.Type of Change
How Has This Been Tested?
cargo test— not run: no smart-contract changes in this PR. Archival is entirely off-chain.pnpm buildsucceeds — compiles clean, and all three new routes register (/api/cron/archive-pools,/api/pools/[id]/archive,/api/pools/[id]/unarchive)pnpm lintpasses with 0 errorspnpm test:unit— 273 passing, including 30 new tests for the archival rulespnpm test:components— 128 passing (up from 121; 7 new), 20 filesprettier --checkclean on every file this PR touchesNew unit tests —
lib/archival.test.ts(30)Each criterion and both sides of every grace boundary (day 6 vs 7, day 29 vs 31, day 89 vs 90); the balance guard, including the case that matters most — a pool silent for 290 days but still holding funds is asserted not archived; paused pools exempt; idempotency across daily runs;
NaN, unparseable, and future timestamps failing closed; and the balance/latest-activity derivation helpers.New component tests —
__tests__/archived-pool.test.tsx(7)The archived banner and its reason text; the absence of every mutating control on an archived pool; the historical activity label; and the archived card's fields and View History link.
The not-archived case asserts Quick Actions is present — so the hidden-controls test is proving the archival gate rather than a mock that never mounted them in the first place.
Note on the existing typecheck baseline
tsc --noEmitreports 73 errors on this branch — and 73 onmain, unchanged. This PR introduces none, and none are in files it touches. They live inGroupClient.tsx,app/[locale]/join/[contractId]/page.tsx, andapp/api/admin/pools/route.ts;next.config.mjssetsignoreBuildErrors: true, so they don't block the build. Worth a separate issue, but out of scope here.Checklist
docs/pool-archival.md, plusCHANGELOG.mdand a pointer fromsupabase/README.md(the archival job is a Vercel Cron route rather than an Edge Function, so it would otherwise not appear alongside the other scheduled jobs)Deployment notes
supabase/migrations/20260828000000_pool_archival.sql. It is additive and nullable throughout; the only destructive step is dropping and rebuilding thepools.statusCHECK constraint to admitemergency_withdrawn.vercel.jsongains the 02:00 UTC cron entry. It needs the existingCRON_SECRET— no new environment variables.Rollback
Low-risk, because the feature is a visibility layer:
/api/cron/archive-poolsentry fromvercel.jsonto stop new archivals.UPDATE public.pools SET archived_at = NULL, archive_reason = NULL;restores every pool to discovery.archive_logretains the full history of what had been archived and why.The columns and table can be left in place — they are additive and nullable. Full detail in
docs/pool-archival.md.