Skip to content

Downgrading a Catodon instance to Sharkey

Running a server 23 min read Source on codefloe

On this page

This is a runbook, not a script. It is deliberately not automated: this procedure is run approximately never, and a script that silently rots as upstream Sharkey drifts is more dangerous than a documented, re-verified procedure that an operator reads before running.

Verified against Sharkey develop @ 9be347ef0662 (2026-09-20). See the maintenance caveat at the end before relying on this against a newer upstream.

What does NOT survive this downgrade

Read this before you do anything. Some of it is permanent.

Federated forum communities — PERMANENTLY GONE

Catodon renders a isGroup actor as an ActivityPub Group (ApRendererService.ts:1322 and :1410). Sharkey renders the very same actor, at the very same id URI, as Person or Service. The moment you switch to Sharkey, every remote server that has cached your forum actors (Lemmy, PieFed, NodeBB, or any other AP-forum-aware server) will fetch or be pushed an actor whose type changed out from under a stable id. There is no SQL on your side that fixes this — the damage is in their database, not yours. Migrating back to Catodon later does not repair it either; the remote cache poisoning already happened.

Optional mitigation, still destructive but at least consistent: before switching, send Delete activities for your forum actors so remote servers purge them cleanly, instead of later choking on (or silently mis-rendering) an actor that changed type underneath a live id. Decide this before you flip the switch — you cannot send a clean Delete after the actor has already started masquerading as a Person.

In-memory store (Valkey 9 data directory) — ONE-WAY, MOVE ASIDE BEFORE DOWNGRADE

Catodon ships with Valkey 9, and Sharkey ships with Redis 7. Valkey 8 changed the RDB magic string from REDIS to VALKEY (valkey-io/valkey#645), so Redis rejects a Valkey dump at the header rather than loading part of it: the failure is a clean refusal to start, not silent corruption. Point Sharkey's Redis at a Valkey directory and it will not come up. You must move the Valkey data directory aside before starting Sharkey.

What that costs: the main timeline caches fall back to a Postgres query and rate-limit counters start again, but four things do not come back, and Phase A below only addresses two of them.

  • Every account's notifications. There is no notification table: NotificationService keeps each account's notifications in a stream per user, notificationTimeline:<userId>, with the read state beside it. Moving the directory aside empties the notification list of every account, and nothing re-derives it. Nothing in Phase A helps, because there is nowhere to drain it to. Coming back to Catodon later does not restore them either, unless you still have the directory you moved aside and put it back.
  • Custom feeds timelines, which antennas/notes reads from antennaTimeline:<antennaId> with no database fallback. They come back blank and refill only from new matching notes.
  • Pending federation deliveries in the deliver queue are activities that never reached remote servers. Postgres does not re-derive them, so they are simply never sent, and failed deliveries sit as delayed retries, so a queue that looks idle may not be. Drain it in Phase A, below.
  • Buffered reaction counts, but only if you enabled reactions buffering (it is off by default). Those deltas live in the store until the daily bakeBufferedReactions job writes them to Postgres, so anything since the last run is lost and the counts stay low.

So "Postgres is unaffected" is true of the database itself, not of the state it ends up holding: undelivered activities stay undelivered and unbaked reaction counts stay wrong. Phase A0 below is how you avoid both.

Two different backups can exist here, and they are not interchangeable — which one you have depends on whether it was taken before or after the Valkey swap.

  • Taken before the swap, while Catodon still ran Redis. This is the one that helps a downgrade: a Redis 7.x dump restores straight into Sharkey's Redis 7. Whether it can still go forward into Valkey 9 depends on the version that wrote it — Redis 7.2.x can, Redis 7.4 cannot. If you were already running Valkey 8 yourself, it is the mirror case: it loads into Valkey 9, but Redis rejects it outright, because Valkey 8 changed the RDB magic to VALKEY.
  • Taken after Valkey 9 has run, such as the valkey.pre-downgrade directory from B0 below. Valkey 9 reads older dumps forward, but its own output does not go back: Valkey 8 will only attempt one on a best-effort basis and Redis will not read it at all. Treat this as restorable to another Valkey 9 and nothing else — its value is as a route back to Catodon, not as something Sharkey can use.

So keep the pre-swap backup if you still have it. It is the only one Sharkey can actually load.

Stranded in orphan tables (exportable to JSON, not migratable)

Sharkey's schema has no equivalent of these, so the data has nowhere to go on the other side. Export what you need as JSON before switching:

  • Blogs
  • RSS subscriptions
  • Note votes
  • Forum bans
  • Hashtag follows

Procedure

The steps below are split into two phases, and the phase order matters more than anything within a phase: everything in Phase A requires Catodon itself to still be up and serving its own schema — including the latest_note table's current 5-column primary key, which Catodon's SkLatestNote entity (packages/backend/src/models/LatestNote.ts:43) still declares and writes to on every note create/delete. If you drop latest_note.is_renote (Phase B, step 1) while Catodon is still running, every subsequent note write throws, because Catodon's own upsert still targets a column that no longer exists. Likewise, do not run pnpm migrate (i.e. start Sharkey against the database) until Catodon has been stopped and every Phase A step is done — Sharkey coming up mid-way through Phase A against a schema Catodon still expects is the same class of problem in the other direction.

So: finish all of Phase A with Catodon live, then stop Catodon, take a backup, run Phase B against the now-offline database, and only then start Sharkey.

Phase A — while Catodon is still running

A0. Drain the job queue and flush reaction buffers (REQUIRED)

The store is discarded in Phase B, so anything still queued is lost. Both of these need Catodon running — the admin dashboard and the workers go away the moment you stop it, and neither can be done afterwards.

Watch Control Panel -> Server health until the queues are clear. Read wait, active, delayed and prioritized together: wait alone shows empty while delivery retries are still pending.

If you enabled reactions buffering, switch it off in the control panel before you stop anything. That queues a bakeBufferedReactions job on the system queue, which writes the buffered deltas to Postgres — so wait for it the same way you waited for the queues above: it is done when no bakeBufferedReactions job is left in wait, active, delayed or prioritized. One sitting in failed means the deltas have not landed yet; read its error and retry it. The daily job also keeps running after buffering is disabled, so a flush that fails is retried rather than abandoned.

Check afterwards that <your.host>:reactionsBufferDeltas:* and <your.host>:reactionsBufferInFlight* are both empty. A surviving InFlight key is a bake that was interrupted, not a lost count — the next bake finishes it and applies it exactly once — but it does mean the flush is not complete, so let it run before moving on. See docs/install.md for the detail.

A1. Forum federation cleanup (OPTIONAL, do first if you're doing it at all)

See "Federated forum communities" above. If you've decided to send Delete activities for your forum actors before switching, do it now, while Catodon is still up to send them. There is no way to do this after Catodon is stopped, and no way to do it correctly after Sharkey starts (Sharkey doesn't know these actors were ever Groups).

A2. Moderator-removed notes (OPERATOR DECISION — no default)

Sharkey has no concept of note.removedAt. If you leave removed notes as-is in the database, Sharkey does not know they were removed: it will serve them back over the API and federate them over ActivityPub as ordinary, live objects — not as a Tombstone. Anything a moderator took down comes back, including out to remote servers.

Check how much this affects you:

SELECT count(*) FROM note WHERE "removedAt" IS NOT NULL;

Recommended: hard-delete these notes via Catodon's own moderation UI while Catodon is still running. That runs the normal reply/renote cascade and sends real Delete activities out over federation, same as any other deletion. This must happen before Catodon is stopped — the moderation UI needs a live Catodon instance behind it.

If you instead do it in raw SQL (in Phase B, after Catodon is stopped), no Delete federates — remote copies of those notes are left standing — and descendant replies/renotes become orphans referencing a row that no longer exists. This second part is harmless on Sharkey specifically: Sharkey's NoteDeleteService.findCascadingNotes walks replyId/renoteId relationships in application code, not through the database foreign keys Catodon dropped, so an orphaned descendant doesn't crash anything — it just becomes an unreachable reply thread fragment. But raw-SQL deletion doesn't require Catodon to be running, so if you go this route it can instead be done as part of Phase B.

A3. Export what can't migrate

Export the orphan-table data listed above (blogs, RSS subscriptions, note votes, forum bans, hashtag follows) to JSON now, while Catodon's own export tooling/queries can still read it against a schema it understands.

Phase B — Catodon stopped, schema surgery, then Sharkey starts

Stop Catodon (web and workers) and take a database backup before proceeding. Nothing below is meant to run against a live Catodon.

B0. Move the Valkey 9 data directory aside (REQUIRED)

Sharkey's Redis 7 cannot read a Valkey 9 dump and will refuse to start if it finds one, so the directory has to be out of the way before Sharkey comes up.

Move it rather than deleting it. It is your only copy of whatever was still queued, it is the one thing that lets you return to Catodon if the downgrade goes wrong, and rm -rf on a path you have not confirmed is a bad trade for the disk it saves:

# Confirm the path first — it is whatever your compose file mounts at /data
grep -A3 'valkey:' compose.yml

mv valkey valkey.pre-downgrade      # Redis creates a fresh directory on startup

Even assuming you did A0, this still costs every account's notification history and the custom feeds timelines, for the reasons given above; what A0 buys you is the queued jobs and any unbaked reaction deltas. Skip A0 and you lose those as well, on top.

Keep valkey.pre-downgrade until the downgrade has proven itself. It is restorable only to another Valkey 9 instance — not to Sharkey's Redis — so its value is as a route back to Catodon, not as something Sharkey can use.

B1. Fix the latest_note primary key (REQUIRED)

Sharkey's LatestNoteService.ts:65 does an upsert keyed on 4 columns (userId, isPublic, isReply, isQuote). Catodon's latest_note table has a 5 column primary key that also includes isRenote. Running Sharkey unmodified against Catodon's schema hits Postgres error 42P10 ("no unique or exclusion constraint matching the ON CONFLICT specification") — confirmed empirically.

A partial unique index on the 4 columns does not fix this: TypeORM emits a bare ON CONFLICT (col, col, col, col) with no WHERE / index predicate, so it never matches a partial index — also confirmed empirically. The primary key itself has to change shape.

Run this as a single transaction, not as loose statements. Outside an explicit BEGIN/COMMIT, psql autocommits each statement individually — if ADD CONSTRAINT fails partway through (e.g. the DELETE above didn't catch every collision), you're left with latest_note having neither its old 5-column primary key nor the new 4-column one, and no primary key at all is a state Sharkey's upsert can't work with either. Stop and investigate on the first error; do not retry statement-by-statement with the transaction already broken.

BEGIN;
DELETE FROM latest_note WHERE is_renote = true;
ALTER TABLE latest_note DROP CONSTRAINT "PK_08eac29bf34189d2eaf302d0c28";
ALTER TABLE latest_note DROP COLUMN is_renote;
ALTER TABLE latest_note ADD CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd"
  PRIMARY KEY (user_id, is_public, is_reply, is_quote);
DELETE FROM migrations WHERE name = 'AddLatestNotePureRenote1766500000000';
COMMIT;

The DELETE FROM latest_note WHERE is_renote = true is mandatory, not incidental: if a user has both an is_renote = true row and an is_renote = false row for the same (user_id, is_public, is_reply, is_quote) key, those two rows collide the instant you try to build the narrower 4-column primary key, and the ALTER TABLE ... ADD CONSTRAINT will fail. The data loss here is nil in practice: latest_note is a derived cache, not a source of truth, and current Sharkey has no code path that reads the is_renote-partitioned rows at all — it wasn't a Sharkey concept to begin with.

If you chose the raw-SQL route for moderator-removed notes in step A2 instead of doing it via Catodon's moderation UI, do that deletion now too — Catodon is already stopped, so there's no ordering hazard here, unlike with latest_note.

B2. Ads with no expiry

Sharkey's MetaEntityService.ts:45 filters ads with ads.expiresAt > :now. An ad with a NULL expiresAt fails that comparison in SQL and the ad silently stops showing — not an error, just a vanished ad.

BEGIN;
UPDATE ad SET "expiresAt" = 'infinity' WHERE "expiresAt" IS NULL;
ALTER TABLE ad ALTER COLUMN "expiresAt" SET NOT NULL;
COMMIT;

If SET NOT NULL fails (it will, if any row still has a NULL expiresAt — meaning the UPDATE above missed one), the transaction rolls back both statements together rather than leaving the column newly NOT NULL-constrained with a backfill that didn't fully apply.

B2a. Clips that were public (OPERATOR DECISION)

Catodon turned clips into private bookmark categories. Its migration made every clip private and recorded the ones that were public in clip."catodonWasPublic", a column Sharkey ignores. Sharkey shows a clip to everyone when "isPublic" is true, so the clips come back private unless you restore the flag:

UPDATE clip SET "isPublic" = true WHERE "catodonWasPublic";

Decide before running it. While the server ran Catodon, people filed posts into those clips as private categories, and the statement publishes all of them, the new ones included. Leaving the clips private and letting each owner make theirs public again in Sharkey is the cautious choice. Either way the column can then be dropped, or left: Sharkey never reads it.

ALTER TABLE clip DROP COLUMN "catodonWasPublic";

Two more things Catodon did to clips stay as they are, and need nothing from you. Every post that sat in a clip without being bookmarked got a bookmark for the clip's owner, and those bookmarks remain in Sharkey. And a base role that stored exactly 10 clips of 200 posts lost both values, which Sharkey reads as its own defaults, the same 10 and 200.

B2b. Drop Catodon's triggers on note (REQUIRED)

Catodon installs five triggers on note, a table Sharkey keeps writing. They are plain database objects, so they survive the switch and go on running under Sharkey, on every post it creates, edits or deletes:

  • note_reply_counters_insert and note_reply_counters_delete (packages/backend/src/misc/reply-counter-triggers.ts) keep two Catodon-only columns, note."threadRepliesTotal" and note."rankRepliesCount", and write the Catodon-only table note_tombstone.
  • note_hashtag_insert, note_hashtag_update and note_hashtag_delete (packages/backend/src/misc/note-hashtag-triggers.ts) keep the Catodon-only table note_hashtag, one row per hashtag of a public post.

Sharkey reads none of that, so left in place the triggers are work done for nothing. They are also a trap. Each one fails if the table or column it writes is gone, and a trigger that fails takes the statement that fired it down with it: drop note_tombstone or note_hashtag while clearing out Catodon-only tables, and from then on no post can be deleted, and no public post with a hashtag can be saved.

So drop the triggers, then their functions, before Sharkey starts:

DROP TRIGGER IF EXISTS "note_reply_counters_insert" ON "note";
DROP TRIGGER IF EXISTS "note_reply_counters_delete" ON "note";
DROP FUNCTION IF EXISTS note_reply_counters_insert();
DROP FUNCTION IF EXISTS note_reply_counters_delete();

DROP TRIGGER IF EXISTS "note_hashtag_insert" ON "note";
DROP TRIGGER IF EXISTS "note_hashtag_update" ON "note";
DROP TRIGGER IF EXISTS "note_hashtag_delete" ON "note";
DROP FUNCTION IF EXISTS note_hashtag_insert();
DROP FUNCTION IF EXISTS note_hashtag_update();
DROP FUNCTION IF EXISTS note_hashtag_delete();

Confirm nothing of Catodon's is left on the table:

SELECT tgname FROM pg_trigger
WHERE tgrelid = 'note'::regclass AND NOT tgisinternal;
-- must return no row named note_reply_counters_* or note_hashtag_*

Once the triggers are gone, what they kept goes stale: the two reply counters stop moving, and note_tombstone and note_hashtag stop following the posts. None of it is data that exists nowhere else. The counters and note_hashtag are derived from the posts, and note_tombstone only records where a deleted reply sat in a thread. The two tables can stay or be dropped; the two columns stay either way, unread.

If the switch happens while Catodon is still writing the reply counts an upgrade left it (docs/development.md, "Running the reply-counter migration on a large server"), a third Catodon-only table is there, note_reply_counter_backfill, holding the counts not yet written. Sharkey reads neither it nor the counters, so it can stay or be dropped as well.

B3. Confirm the compatibility-migration rows AND the schema they claim are both present

Before running Sharkey's pnpm migrate (i.e. before starting Sharkey for the first time), confirm these two rows exist in migrations (they should, as of any Catodon release that includes packages/backend/migration/1774625336796-add-user-deletedAt.js and packages/backend/migration/1770023088000-FixProfileImageUrls.js — see those files, and packages/backend/migration/1784800000000-ReconcileUpstreamCompatMigrations.js, for why they need to be there):

SELECT name FROM migrations
WHERE name IN ('AddUserDeletedAt1774625336796', 'FixProfileImageUrls1770023088000');
-- must return both rows

Do not stop at checking the rows — verify the underlying schema too. Those two rows are compatibility markers, not the source of truth: Catodon's ReconcileUpstreamCompatMigrations normally guarantees they only exist when the schema backs them up, but that guarantee comes from a later migration in the same run. Catodon runs migrations with -t each, so if some migration between the compatibility markers and ReconcileUpstreamCompatMigrations ever failed on this instance, the markers could have committed while the schema state they claim was never reached — and ReconcileUpstreamCompatMigrations would have caught that on a subsequent Catodon pnpm migrate, not on a database that's being taken straight to Sharkey without ever running Catodon's migrations again. So check the real thing directly:

SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = 'user'
  AND column_name IN ('deletedAt', 'avatarUrl', 'bannerUrl', 'backgroundUrl');
-- deletedAt must be present AND data_type = 'timestamp with time zone'
-- avatarUrl / bannerUrl / backgroundUrl must each satisfy:
--   data_type = 'text', OR
--   (data_type = 'character varying' AND character_maximum_length >= 1024)

There are four row/schema combinations for each of the two compatibility migrations (marker row present or absent, crossed with schema correct or not). Only one is safe to proceed on:

Marker row Schema correct Meaning Action
present yes Healthy. This is the expected state. Proceed.
absent yes Catodon's own migration ran, but the compat migration hasn't (e.g. this release predates it, or it was reverted alone). Harmless for this check, but confirm you're running a Catodon release that ships 1774625336796-add-user-deletedAt.js / 1770023088000-FixProfileImageUrls.js before proceeding — see the top of this section. Fix the Catodon release, then re-check.
absent no Neither has run. Normal pre-migration state on an instance that hasn't reached this point yet. Not yet ready to downgrade this piece — run Catodon's own pnpm migrate first.
present no Inconsistent. The compatibility marker claims the schema change happened, but it didn't (or didn't fully). This is exactly the state ReconcileUpstreamCompatMigrations is designed to catch and refuse to let pass — if it ran on this instance and didn't throw, something changed the schema afterward without going through Catodon's migrations. Do not just delete the marker row. The owning migration (AddUserDeletedAt1784500000000 / ProfileImageUrlText1780014000000) is very likely also still recorded as applied in migrations — TypeORM tracks each migration independently, so deleting only the compatibility marker does not make TypeORM re-run the owning migration, and the owning migration is the one that actually produces the schema, not the compatibility marker (which is a no-op by design — see its header comment). Deleting only the marker just lets the no-op re-insert the identical stale row on the next run, changing nothing. Instead, either (a) apply the missing schema change by hand with the same SQL the owning migration itself runs (read that migration's up() for the exact statement), or (b) delete both the compatibility marker row and the owning migration's row from migrations, then run Catodon's pnpm migrate so the owning migration executes for real.

This step is not optional bookkeeping. If a compatibility row is missing, or present without the schema to back it up, Sharkey's migration runner will try to apply a schema change that's already present (or claimed to be present) under a different (Catodon) migration name, hit a Postgres error, and — unlike Catodon's pnpm migrate -t each — abort and roll back the entire run as one transaction, because Sharkey's migrate has no -t flag at all. That takes all 21 pending upstream migrations down with it, not just the one that collided. (The "present without schema" case doesn't hit that specific collision — Sharkey sees the row and skips the migration entirely, so it never attempts the conflicting ALTER TABLE in the first place — but it leaves Sharkey running against a schema it believes is correct and isn't, which is worse: silent data-shape mismatches instead of a loud failure at migrate time.)

The result of that rollback is a Sharkey instance running against a schema that's missing roughly 14 columns its own entities declare, including (but not limited to): note.mandatoryCW, access_token.granteeIds, access_token.rank, announcement.confetti, announcement.forRoles, meta.about, note_edit.text, note_edit.userId, note_edit.visibility, user_profile.oneClickUnsubscribeToken, user.lastFetchedFeaturedAt, note_thread_muting.isPostMute, instance.mandatoryCW, meta.deliverSuspendedSoftware, user_pending.requestOriginIp. In that state, every note query 500s — the instance is effectively down, not degraded.

B4. Reclaim Catodon-only index space (OPTIONAL, after Sharkey is running)

This step is safe to skip. Sharkey does not create these indexes, and its current schema and queries do not depend on them; leaving them in place costs disk and makes the indexed tables more expensive to write. You can do this while Sharkey is serving.

This list covers only indexes on tables Sharkey keeps. It deliberately omits indexes on Catodon-only tables: those indexes disappear with their tables if you remove the orphan tables after exporting them.

It also deliberately keeps six Catodon-created indexes on shared tables. IDX_drive_file_remote_cache matches Sharkey's remote-drive usage aggregate. IDX_note_reaction_userId_id serves Sharkey's own users/reactions (a profile's Reactions tab), which lists one account's reactions newest first (reaction.userId filter, ordered by reaction.id, as of Sharkey develop @ 970f4ed4e02b); without it, that list can scan most of note_reaction for an account whose reactions are all old. IDX_note_favorite_noteId, IDX_user_note_pining_noteId and IDX_latest_note_noteId lead on the referencing columns of foreign keys to note, so they prevent ordinary note deletion and its cascades from scanning those tables; B1 changes the latest_note primary key but leaves its note_id foreign key in place. IDX_note_forumId supports the FK_note_forumId ON DELETE SET NULL action that remains in the downgraded schema. None of those six backs a Catodon-only query alone, so do not drop them as part of this cleanup.

First, see how much space your database would reclaim. A missing index has a SQL NULL size (shown as an empty field by default in psql) and is harmless:

WITH listed(index_name) AS (VALUES
  ('IDX_ad_displayMode'),
  ('IDX_drive_file_external_media_cache'),
  ('IDX_drive_file_isExternalMediaCache'),
  ('IDX_moderation_log_forumId'),
  ('IDX_note_forum_root_active'),
  ('IDX_note_forum_root_forumId_active'),
  ('IDX_note_forum_root_forumId_id'),
  ('IDX_note_forum_root_id'),
  ('IDX_note_global_hot_candidates'),
  ('IDX_note_global_root_active_sort'),
  ('IDX_note_lastReplyAt'),
  ('IDX_note_local_root_active_sort'),
  ('IDX_note_quoteAuthorizationUri'),
  ('IDX_note_removedAt'),
  ('IDX_note_text_trgm'),
  ('IDX_note_userId_specified'),
  ('IDX_note_user_root_active_sort'),
  ('IDX_page_embedded_note_ids'),
  ('IDX_user_forumLastPostedAt'),
  ('IDX_user_private_forum_actor'),
  ('IDX_user_profile_userHost_url')
), sizes AS (
  SELECT index_name,
    pg_relation_size(to_regclass(format('%I.%I', current_schema(), index_name))) AS bytes
  FROM listed
), rows AS (
  SELECT index_name, bytes, 0 AS sort FROM sizes
  UNION ALL
  SELECT 'TOTAL', sum(bytes), 1 FROM sizes
)
SELECT index_name, pg_size_pretty(bytes) AS size
FROM rows
ORDER BY sort, index_name;

Then run these from psql, connected to the downgraded database. Do not put them inside a BEGIN/COMMIT block: PostgreSQL refuses to run DROP INDEX CONCURRENTLY inside a transaction. Each statement is separate so one busy index does not roll back the others:

DROP INDEX CONCURRENTLY IF EXISTS "IDX_ad_displayMode";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_drive_file_external_media_cache";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_drive_file_isExternalMediaCache";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_moderation_log_forumId";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_forum_root_active";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_forum_root_forumId_active";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_forum_root_forumId_id";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_forum_root_id";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_global_hot_candidates";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_global_root_active_sort";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_lastReplyAt";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_local_root_active_sort";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_quoteAuthorizationUri";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_removedAt";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_text_trgm";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_userId_specified";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_note_user_root_active_sort";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_page_embedded_note_ids";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_user_forumLastPostedAt";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_user_private_forum_actor";
DROP INDEX CONCURRENTLY IF EXISTS "IDX_user_profile_userHost_url";

If you later re-upgrade this database to Catodon, pnpm migrate does not recreate these indexes: their migrations are still recorded as applied in the migrations table, and TypeORM decides what is pending by migration name, not by inspecting the schema. Either restore the pre-downgrade backup, or re-run the CREATE INDEX statements from these Catodon migrations: 1761800000000-add_index_drive_file_external_media_cache.js, 1762000000000-add_index_note_userId_specified.js, 1763100000000-add_drive_file_remote_cache_index.js, 1763800000000-add_note_last_reply_at.js, 1766200000000-add_user_forum_last_posted_at.js, 1766700500000-add_note_root_timeline_active_sort_indexes.js, 1778500100000-add_modlog_forum_id.js, 1778500300000-add_note_removal.js, 1778501600000-add_note_text_trgm_index.js, 1778501700000-add_note_global_hot_candidate_index.js, 1780009000000-AdImageAndPinnedDisplay.js, 1780017000000-QuoteAuthorizationUriIndex.js, 1787700000000-UserProfileUrlIndex.js, 1788500000000-RemoteNotesCleaning.js, 1789100000002-add_user_private_forum_actor_index.js, 1789100000003-add_note_forum_root_id_index.js, 1789100000004-add_note_forum_root_forum_id_index.js, 1789100000005-add_note_forum_active_indexes.js, and 1789590000000-add-note-user-root-active-sort-index.js. Re-run only the corresponding CREATE INDEX statements for the indexes above, rather than the migrations' complete up() methods or definitions reconstructed from the index names; several have predicates or expression keys that must match exactly.

Maintenance caveat

This procedure was verified against Sharkey develop @ 9be347ef0662 (2026-09-20). It reflects a comparison between that commit and Catodon's schema at the time this document was written. Upstream drift can and does introduce new collisions of exactly this kind — two independent ones (latest_note, and the migration-name collisions in step B3) accumulated in about four months. Re-verify against current upstream before relying on this document — diff Catodon's packages/backend/migration/ against a fresh checkout of activitypub.software/TransFem-org/Sharkey develop, and recheck the specific line numbers cited above, before running this against a production instance.