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
notificationtable:NotificationServicekeeps 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/notesreads fromantennaTimeline:<antennaId>with no database fallback. They come back blank and refill only from new matching notes. - Pending federation deliveries in the
deliverqueue 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
bakeBufferedReactionsjob 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-downgradedirectory 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_insertandnote_reply_counters_delete(packages/backend/src/misc/reply-counter-triggers.ts) keep two Catodon-only columns,note."threadRepliesTotal"andnote."rankRepliesCount", and write the Catodon-only tablenote_tombstone.note_hashtag_insert,note_hashtag_updateandnote_hashtag_delete(packages/backend/src/misc/note-hashtag-triggers.ts) keep the Catodon-only tablenote_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.