diff --git a/packages/overlays/matrix-synapse/patches/0001-Fast-auth-links.patch b/packages/overlays/matrix-synapse/patches/0001-Fast-auth-links.patch
index 0d92e4e..7b91953 100644
--- a/packages/overlays/matrix-synapse/patches/0001-Fast-auth-links.patch
+++ b/packages/overlays/matrix-synapse/patches/0001-Fast-auth-links.patch
@@ -1,7 +1,7 @@
From 1b82f35b613e96c56bf18015e33f34328ad73188 Mon Sep 17 00:00:00 2001
From: Rory& <root@rory.gay>
Date: Tue, 22 Jul 2025 05:07:01 +0200
-Subject: [PATCH 01/11] Fast auth links
+Subject: [PATCH 01/14] Fast auth links
---
synapse/storage/database.py | 43 +++++++++++++++++++
diff --git a/packages/overlays/matrix-synapse/patches/0002-Hotfix-ignore-rejected-events-in-delayed_events.patch b/packages/overlays/matrix-synapse/patches/0002-Hotfix-ignore-rejected-events-in-delayed_events.patch
index 3d5ea60..adc1b50 100644
--- a/packages/overlays/matrix-synapse/patches/0002-Hotfix-ignore-rejected-events-in-delayed_events.patch
+++ b/packages/overlays/matrix-synapse/patches/0002-Hotfix-ignore-rejected-events-in-delayed_events.patch
@@ -1,7 +1,7 @@
From 346fb5899fa42d4604b7bf0261c5e1774e6d2c04 Mon Sep 17 00:00:00 2001
From: Rory& <root@rory.gay>
Date: Sun, 20 Apr 2025 00:30:29 +0200
-Subject: [PATCH 02/11] Hotfix: ignore rejected events in delayed_events
+Subject: [PATCH 02/14] Hotfix: ignore rejected events in delayed_events
---
synapse/handlers/delayed_events.py | 7 ++++++-
diff --git a/packages/overlays/matrix-synapse/patches/0003-Add-too-much-logging-to-room-summary-over-federation.patch b/packages/overlays/matrix-synapse/patches/0003-Add-too-much-logging-to-room-summary-over-federation.patch
index 5f4e596..c5a71ec 100644
--- a/packages/overlays/matrix-synapse/patches/0003-Add-too-much-logging-to-room-summary-over-federation.patch
+++ b/packages/overlays/matrix-synapse/patches/0003-Add-too-much-logging-to-room-summary-over-federation.patch
@@ -1,7 +1,7 @@
From 929d1e329ec26d2e351591206a82c6e235660437 Mon Sep 17 00:00:00 2001
From: Rory& <root@rory.gay>
Date: Wed, 23 Apr 2025 17:53:52 +0200
-Subject: [PATCH 03/11] Add too much logging to room summary over federation
+Subject: [PATCH 03/14] Add too much logging to room summary over federation
Signed-off-by: Rory& <root@rory.gay>
---
diff --git a/packages/overlays/matrix-synapse/patches/0004-Log-entire-room-if-accessibility-check-fails.patch b/packages/overlays/matrix-synapse/patches/0004-Log-entire-room-if-accessibility-check-fails.patch
index 290f0da..04c00c1 100644
--- a/packages/overlays/matrix-synapse/patches/0004-Log-entire-room-if-accessibility-check-fails.patch
+++ b/packages/overlays/matrix-synapse/patches/0004-Log-entire-room-if-accessibility-check-fails.patch
@@ -1,7 +1,7 @@
From 0ce933278f77e272e2cc894229a1178e1b4fb552 Mon Sep 17 00:00:00 2001
From: Rory& <root@rory.gay>
Date: Wed, 23 Apr 2025 18:24:57 +0200
-Subject: [PATCH 04/11] Log entire room if accessibility check fails
+Subject: [PATCH 04/14] Log entire room if accessibility check fails
Signed-off-by: Rory& <root@rory.gay>
---
diff --git a/packages/overlays/matrix-synapse/patches/0005-Log-policy-server-rejected-events.patch b/packages/overlays/matrix-synapse/patches/0005-Log-policy-server-rejected-events.patch
index ae59e63..7c6b002 100644
--- a/packages/overlays/matrix-synapse/patches/0005-Log-policy-server-rejected-events.patch
+++ b/packages/overlays/matrix-synapse/patches/0005-Log-policy-server-rejected-events.patch
@@ -1,7 +1,7 @@
From 0b5d4c8104bf25f7bbb4e4e7db229742f04199b6 Mon Sep 17 00:00:00 2001
From: Rory& <root@rory.gay>
Date: Tue, 27 May 2025 05:21:46 +0200
-Subject: [PATCH 05/11] Log policy server rejected events
+Subject: [PATCH 05/14] Log policy server rejected events
---
synapse/handlers/room_policy.py | 7 +++++++
diff --git a/packages/overlays/matrix-synapse/patches/0006-Use-parse_boolean-for-unredacted-content.patch b/packages/overlays/matrix-synapse/patches/0006-Use-parse_boolean-for-unredacted-content.patch
index 1c2841c..45fbd2c 100644
--- a/packages/overlays/matrix-synapse/patches/0006-Use-parse_boolean-for-unredacted-content.patch
+++ b/packages/overlays/matrix-synapse/patches/0006-Use-parse_boolean-for-unredacted-content.patch
@@ -1,7 +1,7 @@
From 07d72fd39ea3044577322647d5ed1dd8cb6f77d9 Mon Sep 17 00:00:00 2001
From: Rory& <root@rory.gay>
Date: Tue, 27 May 2025 06:14:26 +0200
-Subject: [PATCH 06/11] Use parse_boolean for unredacted content
+Subject: [PATCH 06/14] Use parse_boolean for unredacted content
---
synapse/rest/client/room.py | 5 ++---
diff --git a/packages/overlays/matrix-synapse/patches/0007-Expose-tombstone-in-room-admin-api.patch b/packages/overlays/matrix-synapse/patches/0007-Expose-tombstone-in-room-admin-api.patch
index 719705e..f331512 100644
--- a/packages/overlays/matrix-synapse/patches/0007-Expose-tombstone-in-room-admin-api.patch
+++ b/packages/overlays/matrix-synapse/patches/0007-Expose-tombstone-in-room-admin-api.patch
@@ -1,7 +1,7 @@
From d3edb4aa9a225f521fdbc406c187fd40343b3963 Mon Sep 17 00:00:00 2001
From: Rory& <root@rory.gay>
Date: Tue, 27 May 2025 06:37:52 +0200
-Subject: [PATCH 07/11] Expose tombstone in room admin api
+Subject: [PATCH 07/14] Expose tombstone in room admin api
---
synapse/rest/admin/rooms.py | 5 ++++
diff --git a/packages/overlays/matrix-synapse/patches/0008-fix-Always-recheck-messages-pagination-data-if-a-bac.patch b/packages/overlays/matrix-synapse/patches/0008-fix-Always-recheck-messages-pagination-data-if-a-bac.patch
index 363204e..724c134 100644
--- a/packages/overlays/matrix-synapse/patches/0008-fix-Always-recheck-messages-pagination-data-if-a-bac.patch
+++ b/packages/overlays/matrix-synapse/patches/0008-fix-Always-recheck-messages-pagination-data-if-a-bac.patch
@@ -1,7 +1,7 @@
From afecddceaa6ece4cf797ce27e226a99acb8e8a6d Mon Sep 17 00:00:00 2001
From: Jason Little <j.little@famedly.com>
Date: Wed, 30 Apr 2025 09:29:42 -0500
-Subject: [PATCH 08/11] fix: Always recheck `/messages` pagination data if a
+Subject: [PATCH 08/14] fix: Always recheck `/messages` pagination data if a
backfill might have been needed (#28)
---
diff --git a/packages/overlays/matrix-synapse/patches/0009-Fix-pagination-with-large-gaps-of-rejected-events.patch b/packages/overlays/matrix-synapse/patches/0009-Fix-pagination-with-large-gaps-of-rejected-events.patch
index ebed62e..e249252 100644
--- a/packages/overlays/matrix-synapse/patches/0009-Fix-pagination-with-large-gaps-of-rejected-events.patch
+++ b/packages/overlays/matrix-synapse/patches/0009-Fix-pagination-with-large-gaps-of-rejected-events.patch
@@ -1,7 +1,7 @@
From 2f2dd65326b8a8dc6b7ac99dbe7476abb2163469 Mon Sep 17 00:00:00 2001
From: Nicolas Werner <nicolas.werner@hotmail.de>
Date: Sun, 8 Jun 2025 23:14:31 +0200
-Subject: [PATCH 09/11] Fix pagination with large gaps of rejected events
+Subject: [PATCH 09/14] Fix pagination with large gaps of rejected events
---
synapse/handlers/pagination.py | 13 +++++++++++--
diff --git a/packages/overlays/matrix-synapse/patches/0010-Fix-nix-flake.patch b/packages/overlays/matrix-synapse/patches/0010-Fix-nix-flake.patch
index 4df6090..a2bb1ed 100644
--- a/packages/overlays/matrix-synapse/patches/0010-Fix-nix-flake.patch
+++ b/packages/overlays/matrix-synapse/patches/0010-Fix-nix-flake.patch
@@ -1,7 +1,7 @@
From 448de6ea7bfe1c6073726f517988e5deeb510861 Mon Sep 17 00:00:00 2001
From: Rory& <root@rory.gay>
Date: Mon, 9 Jun 2025 17:38:34 +0200
-Subject: [PATCH 10/11] Fix nix flake
+Subject: [PATCH 10/14] Fix nix flake
---
flake.lock | 58 +++++++++++++++++++-----------------------------------
diff --git a/packages/overlays/matrix-synapse/patches/0011-Fix-gitignore-to-ignore-.venv.patch b/packages/overlays/matrix-synapse/patches/0011-Fix-gitignore-to-ignore-.venv.patch
index 82335db..f627abc 100644
--- a/packages/overlays/matrix-synapse/patches/0011-Fix-gitignore-to-ignore-.venv.patch
+++ b/packages/overlays/matrix-synapse/patches/0011-Fix-gitignore-to-ignore-.venv.patch
@@ -1,7 +1,7 @@
From e1b50954048039a23c538cd260644ccc63d82941 Mon Sep 17 00:00:00 2001
From: Rory& <root@rory.gay>
Date: Mon, 9 Jun 2025 17:46:10 +0200
-Subject: [PATCH 11/11] Fix gitignore to ignore .venv
+Subject: [PATCH 11/14] Fix gitignore to ignore .venv
---
.gitignore | 1 +
diff --git a/packages/overlays/matrix-synapse/patches/0012-Devenv-use-postgres-17.patch b/packages/overlays/matrix-synapse/patches/0012-Devenv-use-postgres-17.patch
new file mode 100644
index 0000000..0e78105
--- /dev/null
+++ b/packages/overlays/matrix-synapse/patches/0012-Devenv-use-postgres-17.patch
@@ -0,0 +1,24 @@
+From 8fefc1ece0f73ab4e4867cbb4cc1511dc7faa56f Mon Sep 17 00:00:00 2001
+From: Rory& <root@rory.gay>
+Date: Fri, 25 Jul 2025 08:25:28 +0200
+Subject: [PATCH 12/14] Devenv: use postgres 17
+
+---
+ flake.nix | 1 +
+ 1 file changed, 1 insertion(+)
+
+diff --git a/flake.nix b/flake.nix
+index 76b3c1a4b0..cc41490a41 100644
+--- a/flake.nix
++++ b/flake.nix
+@@ -157,6 +157,7 @@
+ # Postgres is needed to run Synapse with postgres support and
+ # to run certain unit tests that require postgres.
+ services.postgres.enable = true;
++ services.postgres.package = pkgs.postgresql_17;
+
+ # On the first invocation of `devenv up`, create a database for
+ # Synapse to store data in.
+--
+2.49.0
+
diff --git a/packages/overlays/matrix-synapse/patches/0013-RequestRatelimiter-expose-can_do_action.patch b/packages/overlays/matrix-synapse/patches/0013-RequestRatelimiter-expose-can_do_action.patch
new file mode 100644
index 0000000..2ad8e55
--- /dev/null
+++ b/packages/overlays/matrix-synapse/patches/0013-RequestRatelimiter-expose-can_do_action.patch
@@ -0,0 +1,95 @@
+From 4b62d4e914d8ff7e21bcfbbc6572f1f2a363e066 Mon Sep 17 00:00:00 2001
+From: Rory& <root@rory.gay>
+Date: Fri, 25 Jul 2025 08:26:15 +0200
+Subject: [PATCH 13/14] RequestRatelimiter: expose can_do_action
+
+---
+ synapse/api/ratelimiting.py | 75 +++++++++++++++++++++++++++++++++++++
+ 1 file changed, 75 insertions(+)
+
+diff --git a/synapse/api/ratelimiting.py b/synapse/api/ratelimiting.py
+index 509ef6b2c1..5f22089a6b 100644
+--- a/synapse/api/ratelimiting.py
++++ b/synapse/api/ratelimiting.py
+@@ -435,3 +435,78 @@ class RequestRatelimiter:
+ update=update,
+ n_actions=n_actions,
+ )
++
++ async def can_do_action(
++ self,
++ requester: Optional[Requester],
++ burst_count: Optional[int] = None,
++ update: bool = True,
++ is_admin_redaction: bool = False,
++ n_actions: int = 1,
++ ) -> Tuple[bool, float]:
++ """Can the entity (e.g. user or IP address) perform the action?
++
++ Checks if the user has ratelimiting disabled in the database by looking
++ for null/zero values in the `ratelimit_override` table. (Non-zero
++ values aren't honoured, as they're specific to the event sending
++ ratelimiter, rather than all ratelimiters)
++
++ Args:
++ requester: The requester that is doing the action, if any. Used to check
++ if the user has ratelimits disabled in the database.
++ key: An arbitrary key used to classify an action. Defaults to the
++ requester's user ID.
++ rate_hz: The long term number of actions that can be performed in a second.
++ Overrides the value set during instantiation if set.
++ burst_count: How many actions that can be performed before being limited.
++ Overrides the value set during instantiation if set.
++ update: Whether to count this check as performing the action. If the action
++ cannot be performed, the user's action count is not incremented at all.
++ n_actions: The number of times the user wants to do this action. If the user
++ cannot do all of the actions, the user's action count is not incremented
++ at all.
++ _time_now_s: The current time. Optional, defaults to the current time according
++ to self.clock. Only used by tests.
++
++ Returns:
++ A tuple containing:
++ * A bool indicating if they can perform the action now
++ * The reactor timestamp for when the action can be performed next.
++ -1 if rate_hz is less than or equal to zero
++ """
++ user_id = requester.user.to_string()
++
++ # The AS user itself is never rate limited.
++ app_service = self.store.get_app_service_by_user_id(user_id)
++ if app_service is not None:
++ return True, 0 # do not ratelimit app service senders
++
++ messages_per_second = self._rc_message.per_second
++ burst_count = self._rc_message.burst_count
++
++ # Check if there is a per user override in the DB.
++ override = await self.store.get_ratelimit_for_user(user_id)
++ if override:
++ # If overridden with a null Hz then ratelimiting has been entirely
++ # disabled for the user
++ if not override.messages_per_second:
++ return True, 0
++
++ messages_per_second = override.messages_per_second
++ burst_count = override.burst_count
++
++ if is_admin_redaction and self.admin_redaction_ratelimiter:
++ # If we have separate config for admin redactions, use a separate
++ # ratelimiter as to not have user_ids clash
++ return await self.admin_redaction_ratelimiter.can_do_action(
++ requester, update=update, n_actions=n_actions
++ )
++ else:
++ # Override rate and burst count per-user
++ return await self.request_ratelimiter.can_do_action(
++ requester,
++ rate_hz=messages_per_second,
++ burst_count=burst_count,
++ update=update,
++ n_actions=n_actions,
++ )
+--
+2.49.0
+
diff --git a/packages/overlays/matrix-synapse/patches/0014-Add-bulk-send-events-endpoint.patch b/packages/overlays/matrix-synapse/patches/0014-Add-bulk-send-events-endpoint.patch
new file mode 100644
index 0000000..fdd6030
--- /dev/null
+++ b/packages/overlays/matrix-synapse/patches/0014-Add-bulk-send-events-endpoint.patch
@@ -0,0 +1,187 @@
+From 452f38800dd00b8686543099d6a085f9b4210687 Mon Sep 17 00:00:00 2001
+From: Rory& <root@rory.gay>
+Date: Sat, 26 Jul 2025 09:50:56 +0200
+Subject: [PATCH 14/14] Add bulk send events endpoint
+
+---
+ synapse/rest/client/capabilities.py | 3 +
+ synapse/rest/client/room.py | 117 +++++++++++++++++++++++++++-
+ 2 files changed, 119 insertions(+), 1 deletion(-)
+
+diff --git a/synapse/rest/client/capabilities.py b/synapse/rest/client/capabilities.py
+index 8f3193fb47..7220b75006 100644
+--- a/synapse/rest/client/capabilities.py
++++ b/synapse/rest/client/capabilities.py
+@@ -74,6 +74,9 @@ class CapabilitiesRestServlet(RestServlet):
+ "m.get_login_token": {
+ "enabled": self.config.auth.login_via_existing_enabled,
+ },
++ "gay.rory.bulk_send_events": {
++ "enabled": True
++ }
+ }
+ }
+
+diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py
+index f61152c35b..19ba13dd64 100644
+--- a/synapse/rest/client/room.py
++++ b/synapse/rest/client/room.py
+@@ -23,10 +23,12 @@
+
+ import logging
+ import re
++import ijson
+ from enum import Enum
+ from http import HTTPStatus
+ from typing import TYPE_CHECKING, Awaitable, Dict, List, Optional, Tuple
+ from urllib import parse as urlparse
++from twisted.internet import defer
+
+ from prometheus_client.core import Histogram
+
+@@ -44,6 +46,7 @@ from synapse.api.errors import (
+ UnredactedContentDeletedError,
+ )
+ from synapse.api.filtering import Filter
++from synapse.events import EventBase
+ from synapse.events.utils import SerializeEventConfig, format_event_for_client_v2
+ from synapse.http.server import HttpServer
+ from synapse.http.servlet import (
+@@ -469,7 +472,6 @@ class RoomSendEventRestServlet(TransactionRestServlet):
+ txn_id,
+ )
+
+-
+ def _parse_request_delay(
+ request: SynapseRequest,
+ max_delay: Optional[int],
+@@ -1610,6 +1612,118 @@ class RoomSummaryRestServlet(ResolveRoomIdMixin, RestServlet):
+ remote_room_hosts,
+ )
+
++class RoomBulkSendEventRestServlet(ResolveRoomIdMixin, RestServlet):
++ """
++ Bulk send events to a room.
++
++ This endpoint allows sending multiple events to a room in a single request,
++ avoiding event linearisation issues.
++ """
++
++ PATTERNS = (
++ re.compile(
++ "^/_matrix/client/unstable/gay.rory.bulk_send_events"
++ "/rooms/(?P<room_identifier>[^/]*)/bulk_send_events$"
++ ),
++ )
++ CATEGORY = "Event sending requests"
++
++ def __init__(self, hs: "HomeServer"):
++ super().__init__(hs)
++ self._auth = hs.get_auth()
++ self._event_creation_handler = hs.get_event_creation_handler()
++ self._message_handler = hs.get_message_handler()
++
++ async def on_POST(
++ self, request: SynapseRequest, room_identifier: str
++ ) -> Tuple[int, JsonDict]:
++ logger.warning("bulk_send_events: Got bulk send events request")
++ requester = await self._auth.get_user_by_req(request, allow_guest=False)
++ room_id, remote_room_hosts = await self.resolve_room_id(room_identifier)
++
++ force_sync_interval = parse_integer(request, "force_sync_interval", default=250)
++
++ current_state_events = await self._message_handler.get_state_events(
++ room_id=room_id,
++ requester=requester,
++ )
++
++ state_map = {(event["type"], event.get("state_key", "")): event.get("event_id") for event in current_state_events}
++
++ events = ijson.items(
++ request.content,
++ "item"
++ )
++
++ i = 0
++ unpersisted_events = []
++
++ for event_data in events:
++ current_index = i
++ i += 1
++ logger.info("bulk_send_events: Processing event %d: %s", current_index, event_data)
++
++ event_dict: JsonDict = {
++ "type": event_data.get("type"),
++ "content": event_data.get("content", {}),
++ "room_id": room_id,
++ "sender": requester.user.to_string(),
++ }
++
++ if "state_key" in event_data:
++ event_dict["state_key"] = event_data["state_key"]
++
++ # Explicitly handle rate limits in order to avoid compounding effects
++ awaiting_ratelimit = False
++ ratelimit_hit = False
++ while awaiting_ratelimit:
++ can_do_action, ratelimit_expiry = await self._event_creation_handler.request_ratelimiter.can_do_action(requester, update=False)
++ if not can_do_action:
++ # can_do_action returns an absolute timestamp, convert it to a relative time
++ time_to_sleep = ratelimit_expiry - self._event_creation_handler.request_ratelimiter.clock.time()
++ logger.warning("bulk_send_events: Got rate limited in bulk sending events, waiting %ds", time_to_sleep)
++ await self._event_creation_handler.request_ratelimiter.clock.sleep(time_to_sleep)
++ ratelimit_hit = True
++ else:
++ awaiting_ratelimit = False
++ await self._event_creation_handler.request_ratelimiter.can_do_action(requester, update=True)
++
++ event, unpersisted_context = await self._event_creation_handler.create_event(
++ requester,
++ event_dict,
++ for_batch=True,
++ state_map=state_map,
++ )
++ context = await unpersisted_context.persist(event)
++
++ if event.is_state():
++ prev_event = await self._event_creation_handler.deduplicate_state_event(event, context)
++ if prev_event is not None:
++ logger.info(
++ "Not bothering to persist state event %s duplicated by %s",
++ event.event_id,
++ prev_event.event_id,
++ )
++ continue
++ else:
++ state_map[(event_dict["type"], event_dict["state_key"])] = event.event_id
++ logger.warning("bulk_send_events: Updated state_map!")
++
++ unpersisted_events.append((event, context))
++ logger.warning("bulk_send_events: Persisted event %d: %s", current_index, event)
++
++ if ratelimit_hit or len(unpersisted_events) >= force_sync_interval:
++ logger.warning("bulk_send_events: Hit rate limit or max batch size, sending %d events", len(unpersisted_events))
++ await self._event_creation_handler.handle_new_client_event(requester, unpersisted_events, ratelimit=False)
++ unpersisted_events = []
++
++ # Finalize any remaining unpersisted events
++ if(len(unpersisted_events) > 0):
++ await self._event_creation_handler.handle_new_client_event(requester, unpersisted_events, ratelimit=False)
++ unpersisted_events = []
++
++ return 200, {}
++
+
+ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
+ RoomStateEventRestServlet(hs).register(http_server)
+@@ -1619,6 +1733,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
+ JoinRoomAliasServlet(hs).register(http_server)
+ RoomMembershipRestServlet(hs).register(http_server)
+ RoomSendEventRestServlet(hs).register(http_server)
++ RoomBulkSendEventRestServlet(hs).register(http_server)
+ PublicRoomListRestServlet(hs).register(http_server)
+ RoomStateRestServlet(hs).register(http_server)
+ RoomRedactEventRestServlet(hs).register(http_server)
+--
+2.49.0
+
|