fix: the 2026-09-25 review — backend, federation, queries, jobs, web client, operations - #2363
Merged
Merged
Conversation
A poll is stored with type `Question`, but the web client's delete route asked `StreamService::deleteLocalItem()` for a guarded delete of a `Note`. The guard matched no row and returned before the cascade, after the Delete had already been queued and the post counter decremented: the network was told the poll was gone, the author's count dropped, and the poll came back on reload, for good. The route now passes the post's own type, as the client API's status delete and the Pixelfed message delete already do. The inbound side had the same guard: `NoteInterface` handles both `Note` and `Question`, yet deleted only `Note`, so a remote author's Delete of their poll left the copy here. It now deletes a `Question` as a `Question`, and still refuses to remove any other type through this interface. The other callers were checked: the client API and Pixelfed pass the item's type, moderation takedowns and the account cascade pass none, and scheduled statuses are not stream rows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The post importer skips every source id it finds in `social_import_post`, and writes one row there per post it brings over, keyed on the local post. That table was not part of the post cascade in `StreamRequest::deleteRelatedTo()`, only of the account cascade, so once a user or a moderator deleted an imported post, re-running the same archive counted it as already imported and never wrote it again. The cascade now clears the import record with the post. The test checks the rule behind it rather than the one table: every table in the schema that keys rows on a single post by `stream_id_prim` is cleared. Rows orphaned before this change are left as they are; they only keep their own posts from being imported a second time. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Both follow routes, the client API's and the web client's, bumped `count_following` after every call to `FollowService::followAccount()`, but that call does nothing when the follow already exists, and it is the same call a Mastodon client makes to change `notify` or `reblogs` on an existing follow. Each such change added one to the counter. Unfollowing somebody not followed took one off in the same way. The cron's recount put the number back, so it wobbled rather than drifted. `followAccount()`/`followActor()` now say whether they made a follow, and `unfollowAccount()` whether it removed one, and the routes move the counter only then. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
`ReportService::reportFromFlag()` looked for the reported local account by calling `CacheActorService::getFromId()` on each id the Flag named, and that call fetches an actor it has not cached. A local account is always in the cache, so every fetch was for somebody else's URL: any signed fediverse actor could send one Flag naming a few hundred URLs on hosts of their choosing and have this instance make that many outbound GETs, one after the other, inside the inbox request. The ids are now resolved in one query against the cache alone, the way `BlockInterface` resolves a block target, and an incoming Flag keeps at most 50 of the ids it names. Mastodon's carry the account and a handful of statuses; the cap also bounds what is stored against the report. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
… a 4xx Every failure on the routes of `LocalController`, `NavigationController` and `OStatusController` went through `TNCDataResponse::fail()` with its default 500 and a warning with a stack trace: deleting a post that is gone or somebody else's, following a handle that does not resolve or a host that is down, following yourself or past the follow limit, asking for an account nobody holds, opening `/ostatus/follow/` for an unknown account, and every stale link to a document, which also wrote an error-level line per hit, so a crawler with old image links filled the log. `TNCDataResponse::failFor()` maps these the way `ApiController` already does for the client API: 404 for what does not exist, 422 for what cannot be done as asked, 403 for a remote refusal, 429 over the follow limit, 502 when another server failed. Those are not logged. What is left, a failure of this server's own, is still a 500 and still logged. The 401s of the client API map are left out: these routes run on the Nextcloud session, and a 401 reads as that session having ended. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Asking for a handle nobody holds used to answer 500, and the account store turned every failure into an app error, "Account lookup failed … The remote server may be unreachable", with an entry in the sidebar's error list, for a local name that simply does not exist. The route now answers 404 for that, and the store treats a 404 as the answer it is: it returns nothing and the profile page shows its own "User not found". Other failures are still reported as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
A peer whose Accept lists `application/ld+json; profile=...` first gets the responder registered for that type, and it answered `Content-Type: ld+json; profile=...` with no `application/`. GoToSocial asks in exactly that order and rejects any body whose type is not ActivityStreams, so every dereference of an actor, key or post on this server failed there, and with it every delivery from here that GoToSocial had to verify. Both custom responders also rebuilt the returned DataResponse as a fresh 200 JSONResponse, dropping its status and headers: an unknown actor, a deleted post or a refused secure-mode read reached every ActivityPub peer as a 200 with an error body. They now go through the framework's own json responder and only replace the Content-Type. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…efusing it A reply forwarded under AP 7.1.2 is signed by the forwarder's key, not its author's, and a reply written on a server that makes no Linked Data signatures (GoToSocial, Pleroma, Pixelfed, PeerTube, Lemmy, ...) carries nothing else to vouch for it. assertSignerSpeaksFor() refused it with a 401, which is final for Mastodon's queue, so such replies never reached this instance's copy of the thread. The body is still not trusted and never imported. A Create or Update of an object on its actor's own host is answered 202 and the object is queued by id as a new StreamQueue fetch item; the queue fetches it signed through CurlService (access list and local-network guard included), requires the id it was asked for and a post type, and hands it to the ordinary inbox path as a Create of its author with the URL's host as origin, so NoteInterface's author-on-host checks judge what the origin served. A stored copy is updated only when the fetched `updated` differs. Forwarded Delete and Announce are acknowledged and dropped; anything else signed by a third party is still a 401. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
… one
ActivityStreams lets every non-functional property be one value or a
list, and peers send `"attachment": {...}` and `"tag": {...}`. Read
through getArray(), a single object was iterated value by value, and
Stream::importAttachments() handed each string to AP::getItemFromData(),
which takes an array: a TypeError that `catch (Exception)` did not stop,
so the whole delivery answered 500 and Mastodon re-sent it for two days.
A single tag or profile field was silently lost the same way.
ACore::listOf() reads such a field as a list whatever its form, and is
used for `attachment`, `tag` (tags and emoji), `to`/`cc` and actor
fields. importAttachments() also skips an entry that is not an object.
At the import boundary, a TypeError from a model is turned into an
ActivityPubFormatException, which the inbox answers 400: the sender's
bytes are wrong and redelivering them will not help.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…nner list PeerTube publishes an account's `icon` and a channel's `image` as a list of Image objects, one per size. Person::import() handed the whole list to Image::import(), which found no `type` and dropped it, and `image.url` read nothing out of a list, so PeerTube accounts and channels arrived without an avatar or banner. The largest candidate is kept, the same rule PeerTubeService::thumbnail() applies to a video's poster; a single object is read as before, also when the cached copy is rebuilt from its source. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Undo{Announce} named its object by id only, while every other Undo this
app sends embeds the object. PeerTube's processUndoActivity dispatches on
`object.type` and logs an unknown object type for a string, so a video
un-boosted here stayed shared there. The Undo now embeds an Announce with
the stored boost's id, actor, object and audience; it is built fresh
rather than exported from the row, which carries the boosted post and
this app's bookkeeping.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
A Lemmy link post is a Page whose link is its only attachment,
`{type: "Link", href: ...}`, with no `url`. There is no model for a Link
attachment, so importAttachments() dropped it and the post arrived as
title and body without the link it exists for. The href of the first
http(s) Link attachment of a note-like object is now appended to the
content, unless the content already links it; the link preview is then
made from it like any other link in a post.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
AS_USERNAME and AS_ACCOUNT went through strip_tags(), which reads a bare `<` as the start of a tag and drops everything after it: every remote account called `Alice <3 cats` was shown as `Alice `. It is the bug the bio and content-warning paths were already fixed for, and the same withoutMarkup() is used here: only what really opens a tag is removed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Every local actor has an accepted `Loopback` row on itself in social_follow, which is how its own posts reach its home timeline. The followers and following page queries did not filter on type, so every actor was listed as its own follower and followee and each page held one id more than the collection's totalItems; Mastodon's follower sync and migration tools saw a phantom follower. Both queries now read Follow rows only, which also cleans the migration export and the other callers. The home timeline reads getHomeCollectionPrims(), which keeps the row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The `host` line of an inbound signature is rebuilt from the configured host so a captured request cannot be replayed against another instance, but getCloudHost() drops the port. A peer signs the Host it connected to, which on an install at `example.org:8443` includes the port, so every delivery there failed verification with a final 401. ConfigService::getCloudAuthority() answers host[:port], the port only when it is not the scheme's default, and is what the draft-cavage and RFC 9421 paths substitute now. The authority rule is the one HttpSignatureService already used when signing, moved to ConfigService::authorityOf() so both sides share it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
ActivityStreams names the public collection three ways: the full IRI, the compacted `as:Public` and a bare `Public`. The short two are not http URIs, so validateRecipients() dropped them as ids and a public post addressed that way was stored with no public recipient, which estimateVisibility() classifies as a direct message. Both are now canonicalised to the full IRI before validation, as Mastodon does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The actor document's `image` always said `mediaType: image/jpeg`,
whatever the banner was. Mastodon sniffs the file and does not care, but
GoToSocial stores the declared type, so every PNG or WebP banner was
described wrongly there. A remote banner now keeps the type its own
document gave; a local one is typed by its `/media/{uuid}.{subtype}`
address; when neither says, `mediaType` is left out rather than guessed.
Replacing the banner forgets the old type.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…okkeeping getPublicByAuthor() and the pinned-posts read join the author, which flags each row for complete details, and outboxPage() and featured() exported them that way: every item carried `source` (the whole stored document again, as a string under a key AS2 defines as an object), `actor_info` (a full actor document), `details`, `action`, `cache` and `publishedTime`. An outbox page was about five times the size of the same Notes fetched one by one and exposed internal state. Both now turn complete details off before export, as displayPost() does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The WebFinger subject echoed the query: `acct:@maya@host` came back as the subject `acct:@maya@host`, which a consumer splitting on `@` reads as a user with no name, and `acct:Maya@host` or a bare `maya@host` came back as typed. The subject is now always `acct:<preferredUsername>@<social address>`, as Mastodon answers. The host part of a local handle was also compared case-sensitively while the user part was not, so `acct:maya@HOST` was a 404; it is compared case-insensitively now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
PropertyValue.value is HTML on the wire: Mastodon renders it as such and verifies a remote field only from a link in it. A local value is stored as the user typed it and went out raw, so `<` and `&` reached every peer as markup and an address was plain text on Mastodon that could never be verified there. Local values are now escaped, and a value that is an address on its own becomes a link with rel="me", as Mastodon's own fields are. A remote actor's values are served as they arrived. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Every Update of a post went out as `<post>/activity#update`, so all edits of one post shared an activity id, which ActivityPub requires to be unique. Mastodon compares `updated` and copes; a peer that keeps a set of seen activity ids dropped every edit after the first. The id is now `<object>#updates/<n>` as Mastodon names its own, with `n` the post's `updated` time, or the current time in milliseconds for an object that has none (an actor, a poll whose counts moved). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
`object` may be a link as well as an embedded object, and a Create carrying only the id returned on `!hasObject()` without a trace. CreateInterface now queues the id as a fetch from its origin, through the queue a forwarded post takes, when it lives on the host the activity came from and its actor is on; the inbox drains it inline under the activity's token. What is stored is only what that server serves. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Two legal AS2 forms were lost on import. An `inReplyTo` embedding the
post it answers, `{id: ...}`, was read as a string and came out empty,
so the reply arrived outside its thread; it is now read as the embedded
post's id. An `alsoKnownAs` given as one string went through getArray(),
which json-decodes a string into nothing, so the actor had no aliases and
MoveInterface refused a Move whose target listed its old account that
way; it is read with listOf() now.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The Date of an inbound signature (and an RFC 9421 `created`) had to be within five minutes of this server's clock, and a miss is a final 401: a peer whose clock was six minutes off, or whose queue delivered a request it had signed a little earlier, lost every delivery here with nothing on its side to retry. Mastodon accepts twelve hours back and one hour ahead, and so does this now; the digest binding is unchanged. The five minutes were also the only thing standing against a captured request being sent again, so a window that wide needs a replay guard: once a delivery has been taken in, its signature is remembered in the distributed cache for as long as its date would be accepted, and the same signed request again is answered 200 without being processed. It is remembered only after success, so identical bytes resent after a failure are still processed; a peer's own retry signs anew anyway. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The three deprecated join helpers in CoreRequestBuilder -
leftJoinCacheActors(), leftJoinAccounts() and leftJoinFollowAsViewer(),
two of which make up leftJoinDetails() - compared LOWER(id) with LOWER(id).
None of those id columns has an index, and LOWER() would defeat one anyway,
so every row of the page was compared with every cached actor and, for the
viewer's relation, with every follow row: a block-nested-loop join per
request on the followers/following ActivityPub collections, the follow
requests list, favourited_by/reblogged_by, getFollowersByFollowId() and
every account search.
The indexed `_prim` columns exist for exactly this and the rest of the app
already joins on them (linkToCacheActors()). The prim is md5() of the id as
written, so the join is now an exact match where it used to fold case. An
id is a URI and two that differ in case are different resources; on devel
no follow or like row matches a cached actor by LOWER() without matching it
by prim (checked for actor_id and object_id of social_follow and actor_id
of social_action), and every page below returns the same rows before and
after. The unused $author branch of leftJoinCacheActors() is removed rather
than converted: nothing passes one.
EXPLAIN on devel (MariaDB 11.8), SQL printed from the builders:
followers page (getFollowersByActorId, 32 rows)
before: f ref social_f_ocr ... Using temporary; Using filesort
ca ALL (60) BNL join; as_follower_f ALL (336) BNL join;
as_followed_f ALL (336) BNL join
after: f ref social_f_ocr const, Using where
ca eq_ref UNIQ(id_prim) f.actor_id_prim
as_follower_f eq_ref social_f_oa_u ca.id_prim,const
as_followed_f eq_ref social_f_oa_u const,ca.id_prim
following page (getFollowingByActorId, 32 rows)
before: f ref social_f_aa ... temporary; filesort; ca/as_*_f ALL BNL
after: f ref social_f_aa ... filesort (over this account's follows);
ca, as_follower_f, as_followed_f eq_ref
getFollowersByFollowId (31 rows)
before: lja ALL BNL join after: lja eq_ref PRIMARY f.actor_id_prim
favourited_by (getActionsOnObject, 12 rows)
before: ca ALL BNL join, temporary after: ca eq_ref, no temporary
searchAccounts (2 rows)
before: as_follower_f ALL, as_followed_f ALL BNL join
after: both eq_ref social_f_oa_u (the ca scan itself is D12)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…ent nid
paginate() bounded and ordered every page on s.nid. For the three timelines
whose recipient join fixes the collection and the type - public (the public
collection, 'recipient'), notifications (the viewer, 'notif') and direct
(the viewer, 'dm') - MariaDB drives from those recipient rows anyway, so it
joined every one of them to its post to read s.nid and sorted the result in
a temporary table to keep twenty: a sort over every public post ever
written, and over every notification an account ever received, on the
reads clients poll most.
The recipient row carries the post's nid (StreamDestRequest::create() is
handed it on every write, and the Version1000Date20260917000001 backfill
filled the old rows), so paginate() takes the alias to page on and these
three pass `sd`. social_sd_atn (actor_id, type, nid) then serves the filter
and the order as one descending range. While the backfill flag is unset a
row may still carry 0, so they keep to s.nid until it is written, and a
zero is excluded as on the home timeline. On devel every recipient row's
nid equals its post's; the flag there is unset, so the after plans were
taken with it forced on, and all four pages return the same posts in the
same order before and after.
EXPLAIN on devel (MariaDB 11.8), page-selection query:
public local=true
before: sd ref social_sd_atn (const,const) rows 120
Using index condition; Using where; Using temporary; Using filesort
after: sd range social_sd_atn key_len 203 rows 120 Using where
public (all) same before/after as above
notifications
before: sd ref social_sd_at (const,const) rows 29 ... Using temporary;
Using filesort
after: sd range social_sd_atn key_len 203 rows 29 Using where
direct
before: sd ref social_sd_at (const,const) rows 3 ... Using temporary;
Using filesort
after: sd range social_sd_atn key_len 203 rows 3 Using where
(s joins eq_ref on sd.stream_id in both; the per-page hydration of the
chosen twenty is unchanged.)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
countNotificationsSince() answers the notification badge, which every
client polls every thirty seconds. It ran getStreamSelectSql() - SELECT
DISTINCT over every social_stream column - with LIMIT 100 and counted the
fetched rows in PHP, so each poll carried up to a hundred whole posts
through a temporary table to produce one number.
The rows are now chosen exactly as before (the notification type, the
viewer's `notif` recipient rows, the hidden-actor filter, archived posts
left out, cut at cap + 1) but projected to one column, and the database
counts them: SELECT COUNT(*) FROM (the page) with the page's parameters
bound on the outer query. The marker compares sd.nid once the backfill
flag says the recipient rows carry their nids (as the previous commit
does for the notifications page), which makes it a range over
social_sd_atn instead of a walk of social_stream by primary key.
EXPLAIN on devel (MariaDB 11.8), marker 1789688205063486330, 11 unread:
before: s range PRIMARY rows 98 Using where; Using temporary
sd eq_ref sat ... Distinct
(every column of every post newer than the marker, instance-wide)
after: <derived2> ALL rows 11
sd range social_sd_atn key_len 203 rows 11 Using index condition
s eq_ref on sd.stream_id
Same counts before and after: 29 since 0, 11 since the marker, 6 at cap 5.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The fast home query is `sd.actor_id IN (every followed collection) AND
sd.type = 'recipient' ORDER BY sd.nid DESC LIMIT 60`. Across several IN
ranges (actor_id, type, nid) cannot hand out rows in nid order, so the
database reads every index entry the predicate admits and sorts them: a page
cost everything the reader's follows ever posted, and newestNidFor(), the
home ETag a client polls every thirty seconds, did the same for LIMIT 1. The
collection list was unbounded too - getHomeCollectionPrims() without a
limit - although FollowsRequest::limitToHomeCollections() and its cap
existed for exactly that and had no caller: an account following 40k others
bound 40k parameters, past SQLite's 32,766.
Both are now bounded by the predicate:
- the collections come from limitToHomeCollections(): up to 500 named, past
that an EXISTS over social_follow. The reader's own follower collection is
read by a second, single-collection query and merged (mergeNidPages() now
takes the cut) instead of being passed as $also: OR'ed beside the EXISTS
it stopped MariaDB turning it into a semi-join, and the plan became a full
index scan of social_stream_dest (checked, below).
- the read is windowed by publication time. A nid is published_time * 1e9 +
random (Nid::publishedTimeOf() reads it back), so the window is a range on
the column the query orders by: the day before the cursor first, then a
week, a month, a year and finally no bound, widening only when the page
comes back short. The query is built once and each attempt rebinds one
parameter. It is exact: every row outside a window is further from the
cursor than every row inside it, so a window that fills the page holds the
rows the unbounded query returns, and the merge with the followed-hashtag
half keeps the guarantee getTimelineHome() relies on.
newestHomeNid() replaces newestNidFor(viewerCollections()) for the ETag with
the same collections and windows and a limit of one; viewerCollections() and
ApiController's FollowsRequest dependency had no other use and are gone.
docs/Architecture.md described the plan as "a descending index range per
followed collection, merged, stopping at the limit"; it now says what the
plan is and what bounds it.
EXPLAIN on devel (MariaDB 11.8, 32 followed collections, 790 recipient
rows); the returned page is the same 20 posts in the same order:
home page
before: sd range social_sd_atn rows 146 Using index; Using filesort
after: window 1 day: sd range social_sd_atn rows 32 Using index; Using filesort
window 1 week: sd range social_sd_atn rows 34 (page was short)
window 1 month: rows 140 (devel's data spans a few weeks)
own collection: sd range social_sd_atn rows 6 Using index (no sort)
home ETag
before: sd range social_sd_atn rows 146 Using index; Using filesort
after: sd range social_sd_atn rows 32 (the first window answers it)
past the cap (HOME_COLLECTIONS_IN_A_QUERY forced to 5 in the copy run on
devel)
EXISTS OR IN(own): sd index social_sd_atn rows 790 (every row) Using filesort
EXISTS alone: <subquery2> materialised from hf ref social_f_aatf,
sd ref social_sd_atn (hf.follow_id_prim, const) rows 6
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
social_stream_tag.hashtag held each tag as its author wrote it, while
social_hashtag and social_followed_tag hold FollowedTagsRequest::normalise()
forms. So the hashtag timeline compared LOWER(st.hashtag) = LOWER(:tag) and
the followed-tags join ft.hashtag = LOWER(ft_st.hashtag), and no index
answers a comparison over a function of the column: social_st_ht was never
used, and the tag was a filter over every tag row of every candidate post.
generateStreamTags() now writes normalise()d tags (deduplicated, empty ones
dropped), the hashtag timeline normalises its argument the same way and
compares st.hashtag exactly, and the followed-tags join compares the two
columns directly. The other readers of the column (related(), the featured
tags' counts) already compared it with a lowercased value and now find the
mixed-case rows they used to miss.
Version1000Date20260925000001 rewrites the existing rows: keyset-paged on
the primary key in batches of 5,000 (three parameters a row, inside
SQLite's ceiling), reading only rows with hashtag <> LOWER(hashtag) where
LOWER() folds Unicode and every row on SQLite, whose LOWER() is ASCII-only.
Two tags of one post that normalise to the same one would violate the
unique (stream_id, hashtag) index, so the later is deleted instead of
rewritten, whether its twin is in the same page or an earlier one. It sits
beside the squash, which every instance has already recorded as run.
EXPLAIN on devel (MariaDB 11.8), same posts returned before and after,
for `birding` and for `Birding`:
hashtag timeline
before: st range sh rows 75 (every tag row) Using where; Using index;
Using temporary; Using filesort
after: st ref social_st_ht const rows 3 Using index condition;
Using temporary; Using filesort (a sort of this tag's rows only)
home, followed-tags half
before: ft_st index sh rows 75 Using join buffer (flat, BNL join)
after: ft_st ref social_st_ht ft.hashtag rows 1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
searchAccounts() - the composer's mention picker on every keystroke,
/api/v2/search, /api/v1/accounts/search and unified search - matched
`account LIKE 'x%'` through iLike(), which on MySQL compares the column
COLLATE utf8mb4_general_ci; getFromAccount(), which SearchService calls
first for the exact handle, compared LOWER(account) = LOWER(?). account
has no index, and neither comparison could use one, so each keystroke was
two scans of every cached actor.
social_cache_actor gains account_lower, the handle through
CacheActorsRequest::lowerAccount() (mb_strtolower), indexed social_ca_al,
written by save() and update() wherever `account` is (and by the benchmark
seeder, which inserts rows directly). The search is a prefix LIKE on it,
compared as it stands, and the lookup by handle an equality on it.
Version1000Date20260925000002 adds the column and index and backfills the
cached rows keyset-paged on nid, 5,000 a statement. It sits beside the
squash, which instances have already recorded as run; the column is
registered in CoreRequestBuilder and docs/Architecture.md, and
IndexCoverageTest now pins the index.
On MySQL and MariaDB (utf8mb4_bin) a prefix LIKE on the plain column is an
index range. PostgreSQL and SQLite plan LIKE as a range only under a C
collation or case_sensitive_like, so there the search stays a scan (of one
short column); the lookup by handle is an index lookup on all three.
EXPLAIN on devel (MariaDB 11.8). The new column cannot be added there, so
the after plan is shown on social_cache_actor.host, which has the same
type, collation and a single-column index:
before, search: ca ALL rows 60 Using where
(account COLLATE utf8mb4_general_ci LIKE 'ni%')
before, lookup: ca ALL rows 60 (LOWER(account) = LOWER('nightsky'))
same predicate shape on an indexed column, with COLLATE:
ca index social_ca_host rows 60 (a full index scan)
after shape, as it stands:
host LIKE 'mastodon%' -> ca range social_ca_host rows 1
host = 'mastodon.social' -> ca ref social_ca_host const
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
While the first page was in flight the page was a black rectangle with a close button, and when the fetch failed `load()` only logged: the template's `reels.length === 0 && !loading` branch then said "No videos here yet." about a server error. An empty stack now shows a spinner while it loads, and an alert with "Try again" after a failed request (`failed`, reset on every attempt), as `TimelineList` does; "No videos here yet." is left for a feed that answered with nothing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…w scope Three small things on the reels page: - The wrapper carried an `aria-label` on a plain `<div>`, which assistive technology does not announce; it is now `role="region"`. - The arrow keys scrolled to the next slide with `behavior: 'smooth'` regardless of `prefers-reduced-motion`, which the rest of the app checks before every animated scroll. - `scope` (from `?scope=`) was read once in `mounted()`. The router reuses the view when only the query changes, so a changed scope now switches the feed and fetches its first page. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
`toggle()` replaced `this.sources` with the server's fresh rows and then called `check()` with the row it had been handed, whose `enabled` was still `false`. So the follow-up fetch went out with `dryRun: true`, and the line under the source said "Would block 12 and silence 3 of 200 servers." — which reads as applied, while nothing was until the daily job ran. The check now runs against the source as the server returned it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The six file inputs on the Migration page use Nextcloud's hidden-visually class, which moves them off screen but leaves them focusable and in the accessibility tree. A keyboard user tabbing through the page met six invisible "file upload" stops with no name between the buttons that open them. The button is the control the reader is meant to use and it carries the name, so the inputs are taken out of the tab order and the accessibility tree instead of being given a second, duplicate name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The four window choices are unbreakable labels in a flex row with `overflow: hidden`. At 400 px the row needs 448 px, so "Last 365 days" was cut to "Last 36" and could only be reached blind with the keyboard; it was the one element on the pages checked that overflowed a phone's width. The group now wraps inside the width it has, and the choices grow to fill each row so the second row is not a lone short button. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
App rendered `<ReactionPicker />` unconditionally. It is an async component, so rendering it at all fetched its chunk, which statically imports NcEmojiPicker -- the 935 KB emoji-picker chunk with its emoji data and NcColorPicker. Every page load, logged-out visitors included, paid for it although most readers never add a reaction. App now listens for the first REACTION_PICK itself and only then mounts the picker, handing it that first request as a prop: the picker was not listening yet when it was sent. From then on the picker takes requests over the bus as before, and App stops listening. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The profile asked `/accounts/familiar_followers` on every profile it drew. The route needs a login, so a visitor who is not logged in got a 401 in the console on every `/@user` page, for a line that could never say anything to them: they follow nobody here. The request is now left out when the page is served to a visitor. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Without `overwrite.cli.url` an administrator is handed the setup page by an early return in `NavigationController::navigate()`, before the admin checks are added to the page's data. The setup form reads `serverData.checks.success`, threw while rendering, and the administrator saw an empty content area instead of the form. Once the form was submitted, the main page read the same key. Both sides now hold: the setup path sends a well-formed `checks` (nothing probed yet, since there is no address to probe from, and the addresses as configured), and App only reads `checks` where there are some. The App test for this now uses the payload the server actually sends on that path instead of one that always carried `checks`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Post text draws each Unicode emoji as `img/twemoji/<codepoint>.svg`, copied at build time out of the `twemoji` package. That was pinned at 12.0.1 (March 2019, Unicode 12), and `twemoji` itself stopped at 14.0.2, so every emoji added since -- 🥹, 🫠, 🩷 and the rest -- had no file and drew as a broken image, in the reader's own posts and in everything arriving from Mastodon. `@twemoji/api`, the maintained continuation of the library, ships no pictures in its npm package (only the JavaScript; the SVGs are served from a CDN), so it cannot be the copy source. `@discordapp/twemoji` 16.0.1, Discord's fork, ships them in the same layout under `dist/svg/` (3,846 files, Unicode 16) under the same MIT / CC-BY-4.0 licences. It replaces `twemoji` as the devDependency and as the code point reference the tests compare against. The dependabot rule holding `twemoji` below 13 goes with the package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Whatever Twemoji the build copies stops at some Unicode version, and an emoji newer than that has no file under `img/twemoji/`. `Emoji.vue` had no error handler, so such an emoji drew as the browser's broken-image glyph. It now falls back to the character, in the reader's own emoji font, when the picture fails to load, and tries the picture again for the next emoji it is handed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…ed's file The first-run introduction is shown only for `?welcome=1`, which the account setup screen reloads with; the server's `firstrun` flag is always false. So an account made by `occ social:account:create`, every account older than the setup screen, and anybody who navigated away half way never saw it, and nothing opened it again. Settings now has an Introduction section whose button goes to the home timeline with `?welcome=1`. Closing the introduction takes `welcome` out of the address, so a reload does not bring it back. The follows step accepted only `.csv`, while the user guide tells Pixelfed users to upload their `pixelfed-following.json` there. The import route already reads JSON (Settings → Migration uses it), so the picker now offers `.json` too, and the text names both files. Its hidden file input is kept out of the tab order, as on the Migration page. Showing the introduction once per account however the account was made needs a server-side flag and is not part of this change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The same pattern as on the Migration page: the file input is moved off screen with hidden-visually but stays focusable, so a keyboard user met an unnamed "file upload" stop next to the "Choose a picture or a video" button that opens it. The composer's own attachment input already takes itself out with `tabindex="-1"` and `aria-hidden`; this one now does too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
`social-profilePage.js` is added to Nextcloud's core profile page for every user, and `ProfileStatusCard` imported `TimelineEntry` statically, so the entry carried the whole post renderer -- TimelinePost, PostMenu, MediaAttachment, GalleryCarousel, the hover card, polls, quotes -- at 422 KB, and a second copy of the components the app already has in its own chunk. The card now loads `TimelineEntry` with `defineAsyncComponent`; webpack puts it in the chunk the app shares, and the entry is 147 KB. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
`sass` compiles the components' SCSS during the webpack build and is imported by nothing that runs in the browser, but it was listed under `dependencies`. A test now checks that everything under `dependencies` is imported from `src/` or is a peer that such an import requires. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…n it does not The announcement card was drawn only from `choose()`, with the handle and address `mounted()` fetches. A reader who picked a network before that answer landed got a card saying `@you` with no address, which was never redrawn, and "Save the card" saved exactly that. A failed fetch was only logged, and "Copy the words" then copied an empty string and said "Copied". The card is now redrawn whenever the announcement changes; a failed fetch says so, with a way to ask again; and saving or copying waits until there is something to save or copy. Around it: "Copied" goes back to "Copy the words" after a moment, picking another network forgets the archive and the people found for the previous one, and the canvas is a `role="img"` so its label is announced. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
App.vue carried two `prefers-reduced-motion: reduce` blocks for the page transition; the second was a subset of the first, one of those copies that drift apart the first time one of them is edited. The first, which also covers the directional transitions and the view-transition pseudo-elements, stays. MigrationSettings' header comment still described it as a section of Settings whose heading lives in `Settings.vue`; it is the body of its own page now, and says so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…emcache Without memcache.local or memcache.distributed, createDistributed() hands the app a NullCache: every set() is dropped and every get() answers null. The inbox throttle therefore read a count of zero on every delivery and never refused one, and the LD-signature replay cache accepted the same signature as often as it was sent. Nothing told the administrator. DurableCache is the distributed cache when ICacheFactory::isAvailable() says there is one and a small table (social_durable_cache, new step Version1000Date20260925000010) when there is not, behind the get / set / inc / remove-with-TTL subset those callers need. Cron\Cache purges the expired rows. InboxLimiter and SignatureService's social.ldsig records use it; the delivery breaker and the HTTP-signature replay cache are left to their own changes. A new setup check, MemcacheConfigured, warns when there is no memcache and names what is kept in the database, what is off and what is recomputed on every request, with a section in docs/Admin.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
StatisticsService kept its fifteen-minute copy of the page in the distributed cache, which on an instance with no memcache is a NullCache. There the page, the most expensive read the app has, was counted again on every load whether the client asked for a fresh one or not: 1.2 s and 27 queries per request on a 400-post instance. It now keeps the page in DurableCache, which falls back to the social_durable_cache table. The setup check and the admin guide no longer list the statistics among what is recomputed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
GET /api/v1/directories took 3.4 to 5.3 s on every load: sources() found out each federated peer's software from its NodeInfo and fetched the fediverse.info server list right there, and the day and week it kept the answers for were in the distributed cache, which on an instance with no memcache keeps nothing. Even with a memcache the first load after every expiry paid for it. FediverseDirectoryService::refresh() now does that work from Cron\Cache, at most eight NodeInfo lookups a run, and writes what it learned to the directory_known app value. sources() only reads it, so neither the page nor the peer trends that share it ever ask a remote host to list the sources. An app value rather than a cache: the cron writes it and a web request reads it, and APCu, the memcache of most small instances, is not shared between the two. A fresh install lists no peers or discovered servers until the cron has run once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Cron\BlocklistSync is a TimedJob, and a TimedJob that takes no argument runs only once <background-jobs> in appinfo/info.xml lists it. It was never listed and nothing adds it to the job list, so a followed block list was read once, when an administrator pressed Check now, and never again, while docs/Admin.md promises a daily re-read. It is in info.xml now, which Nextcloud registers on the next app upgrade or enable. A test asserts that every TimedJob in lib/Cron is registered there and that the rest are the two QueuedJobs that are added on demand with an argument (ActorCleanup, DomainPurge), so the next one cannot be forgotten the same way. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…uest Every actor, inbox and post id is minted from social_url. It was set on the first page load that found it empty, from getAbsoluteURL() of that request's route, so from the host and scheme the request arrived with and not from cloud_url / overwrite.cli.url. An instance first opened through an internal hostname, or behind a proxy without overwritehost / overwriteprotocol, federated every id under that address for good while cloud_url was right, and the address check compared only cloud_url. The line also ran before cloud_url was set, on the fall-through for a non-admin. ConfigService::setSocialUrl() now derives it from cloud_url plus the app's route path (taken from /apps/ on, so web root and index.php come from cloud_url) and stores nothing while cloud_url is unset. occ social:reset derives it again after the flush, so --uri moves both. Existing values are left alone: they are inside every id already federated. Instead CloudAddressMatches now also compares the scheme, host and port of social_url with cloud_url and says how to get out. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Application.php loads the app's own vendor/autoload.php, and composer prepends it to the server's, so a dev-only package the server also carries is served in place of the server's own. With symfony/console 6.4 in require-dev, a dev checkout inside Nextcloud 36 (which ships 7.4 and calls Application::addCommand()) broke every occ call. That is why the interop workflow, the one job that runs the app against Nextcloud master, has been red since 2026-09-21. The interop job now installs with --no-dev before the server is set up and adds the dev dependencies only for the test runner. The dev requirement is widened to ^6.4 || ^7.4 (psalm, the only other package needing it, takes ^6 || ^7 || ^8) and the lock moves symfony/console alone to 7.4.19, the major Nextcloud 35 and 36 ship. A dev checkout next to Nextcloud 34, which ships 6.4, is now the mismatched one; a lock can only match one of them. A test pins the locked major and the order of the interop steps. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
…rose The review found the documents a reader acts on saying things the code no longer does, none of which DocumentationTest could see: - README, the app-store text and the user guide promised every Nextcloud group as a list; a group is a list only once an administrator chose it. - The same three said Mastodon apps cannot connect, and Mastodon-Compatibility.md said no rewrite or guidance ships (§3.1, §9 item 1). The rules are in contrib/webserver/, explained in Admin.md and checked by ClientApiAtRoot; all of them now say so, and that it is a web-server change. - The README called per-user domain blocks API-only (Blocking → Hidden servers exists); the user guide said there is no post translation (there is, through the server's provider) and that the handle is the user id (it is chosen on the setup screen and cannot be changed). - Mastodon-Compatibility §5.3 and item 28 said posts are never written back; PostImportService does, as new local posts. - The annual report was listed as a feature of the web client; it is API-only. - Counts of commands, setup checks, routes, router entries, admin cards, review switches, settings rows, background jobs and dashboard widgets were wrong nearly everywhere. They are gone from the prose; the router table in Architecture.md is complete again. The User Guide and Mastodon-Compatibility stamps now say which sections were re-checked against 0.26.60; Technical-Debt and Performance were not re-verified and keep theirs. DocumentationTest gains checks for the client-connection wording, the group-list promise and counted prose. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The Idempotency-Key record of POST /api/v1/statuses was kept in the plain distributed cache, which without a memcache forgets every write: a client that retries a post (Tusky and Ivory do) published it twice. It is in DurableCache now, beside the inbox throttle and the replay records. The memcache setup check no longer says the delivery breaker is off: it is kept in social_host_breaker whether or not there is a memcache. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Stream tag rows are stored in FollowedTagsRequest::normalise() form now, which drops a leading '#', so the trend counts come back keyed by the bare tag. The integration test wrote its fixtures as '#itest-steady' and looked the counts up by that key, and found nothing. A post never holds the '#' anyway: Note::fillHashtags() strips it when a post is read. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
karlitschek
force-pushed
the
fix/review-2026-09-25
branch
from
September 25, 2026 09:14
546fd76 to
fbae628
Compare
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.
Fixes from the 2026-09-25 review of master 1f45852 (report: /Volumes/frank/social-tests/fresh-look-2026-09-25/). One commit per finding, each with a regression test that fails without it.
Backend
Flagresolves from the cache and names at most 50 objects; session routes answer 404/422/429/502 for the caller's own mistakes instead of 500; an unknown account is "User not found", not "lookup failed".Federation
application/ld+json(GoToSocial refused every fetch) and keep their status (404/410/401 were 200s); forwarded Create/Update without an LD signature are fetched from their origin instead of refused; singleattachment/tagobjects read as lists (was a 500); PeerTube avatar lists,Undoof a boost embeds theAnnounce, Lemmy link posts keep the link, display names with<kept, followers/following without the self row, signatures verify on a non-default port, all three spellings of Public, banner media type; outbox/featured without internal bookkeeping; WebFinger subject and host case; profile fields as HTML with rel=me; one activity id per edit; Create by id fetched;inReplyToobject and singlealsoKnownAs; 12 h Date window with a replay record.Queries (each with before/after EXPLAIN in its commit)
LOWER()full scans); home timeline windowed by time and capped in collections; public/notifications/DM ordered on the recipient key; hashtags stored and compared normalised (+ backfill step); indexed account-handle search (MySQL/MariaDB prefix); keyset paging on the ActivityPub collections; unread count asCOUNT(*).Background jobs
Cron\Cachepages local actors; parallel delivery with a database-backed per-host breaker; feed and block-list bodies streamed with a byte cap; feed follows/imports fetched by the job, items pruned; feed and remote-actor refresh budgeted by time; trends in one pass; block lists applied with one write; sidebar routes answer ETag/304; a migration paging fix; the cost of the stream-rewriting upgrade steps documented.Web client
familiar_followerscall logged out; profile page no longer bundles the post renderer (422 KB → 147 KB);@discordapp/twemoji16 (Unicode 16) with a native fallback; first-run introduction reachable again from Settings and accepting the Pixelfed JSON;sassmoved to devDependencies.Operations and docs
DurableCache: inbox throttle, replay records, Idempotency-Key and statistics hold without a memcache; setup check for a missing memcache; Discover's directory list refreshed by cron instead of ten live requests per page load;Cron\BlocklistSyncregistered (with a test that every job is);social_urlderived fromcloud_url, and a setup check comparing them; interop CI installs with--no-dev,symfony/consoledev range^6.4 || ^7.4; docs corrected against the code, counts removed from prose.New migration steps:
Version1000Date20260925000001(hashtag backfill),…000002(account handle column),…000003(host breaker table),…000010(durable cache table).chore(release): 0.26.64 with the rebuilt bundle, on top of master 0.26.63 (#2361, #2362).Checks run locally
phpunit (6905), vitest (3220), eslint, stylelint, typecheck, psalm, php-cs-fixer — green on the tip, and phpunit on each of the 75 commits; vue-tsc within the baseline. Integration tests run in CI only.
AI disclosure
The code, tests and this index were produced with Claude Code (Claude Opus 5.5) and are awaiting the author's review.
Signed-off-byis added by the author.🤖 Generated with Claude Code