@takosjp/yurucommu-core 3.4.3 → 3.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/migrations/0023_delivery_resolution_outbox.sql +33 -0
  2. package/migrations/0024_delivery_fanout_outbox.sql +35 -0
  3. package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
  4. package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
  5. package/migrations/0027_remote_actor_tombstones.sql +12 -0
  6. package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
  7. package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
  8. package/package.json +2 -1
  9. package/packages/api/package.json +1 -1
  10. package/packages/api/src/lib/api/communities.ts +2 -0
  11. package/packages/api/src/lib/api/fetch.ts +69 -13
  12. package/packages/api/src/lib/api/normalize.ts +32 -10
  13. package/packages/api/src/lib/api/notifications.ts +4 -1
  14. package/packages/api/src/lib/rtc-client.ts +127 -29
  15. package/src/backend/federation-helpers.ts +1 -0
  16. package/src/backend/index.ts +16 -3
  17. package/src/backend/lib/account-migration.ts +340 -0
  18. package/src/backend/lib/activity-delete-cascade.ts +193 -0
  19. package/src/backend/lib/activitypub-actor-cache.ts +615 -48
  20. package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
  21. package/src/backend/lib/activitypub-actor-identity.ts +39 -0
  22. package/src/backend/lib/activitypub-validators.ts +62 -4
  23. package/src/backend/lib/ap-ids.ts +36 -6
  24. package/src/backend/lib/ap-verify.ts +7 -17
  25. package/src/backend/lib/blocklist-purge.ts +676 -42
  26. package/src/backend/lib/blocklist.ts +278 -39
  27. package/src/backend/lib/community-visibility.ts +47 -33
  28. package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
  29. package/src/backend/lib/delivery/planner.ts +16 -12
  30. package/src/backend/lib/delivery/queue-batching.ts +389 -145
  31. package/src/backend/lib/delivery/queue-delivery.ts +47 -25
  32. package/src/backend/lib/delivery/queue.ts +479 -73
  33. package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
  34. package/src/backend/lib/delivery/types.ts +31 -7
  35. package/src/backend/lib/feed-exclude.ts +40 -28
  36. package/src/backend/lib/follow-edge-mutations.ts +217 -0
  37. package/src/backend/lib/notification-eligibility.ts +15 -9
  38. package/src/backend/lib/notification-push.ts +2 -2
  39. package/src/backend/lib/oidc-id-token.ts +66 -0
  40. package/src/backend/lib/personal-actor-moderation.ts +239 -0
  41. package/src/backend/lib/post-visibility.ts +79 -16
  42. package/src/backend/lib/remote-activity-id.ts +61 -0
  43. package/src/backend/lib/unread-counts.ts +3 -0
  44. package/src/backend/retention.ts +38 -1
  45. package/src/backend/routes/account-teardown.ts +422 -156
  46. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  47. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  48. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  51. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  52. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  53. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  54. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  55. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  56. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  57. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  58. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  59. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  60. package/src/backend/routes/activitypub/inbox.ts +81 -105
  61. package/src/backend/routes/activitypub/outbox.ts +0 -0
  62. package/src/backend/routes/activitypub.ts +6 -5
  63. package/src/backend/routes/actors-helpers.ts +34 -8
  64. package/src/backend/routes/actors.ts +328 -164
  65. package/src/backend/routes/auth-helpers.ts +57 -9
  66. package/src/backend/routes/auth.ts +10 -2
  67. package/src/backend/routes/communities/membership-invites.ts +4 -1
  68. package/src/backend/routes/communities/membership-members.ts +201 -73
  69. package/src/backend/routes/communities/membership-requests.ts +141 -76
  70. package/src/backend/routes/communities/membership-shared.ts +228 -21
  71. package/src/backend/routes/communities/messages.ts +9 -2
  72. package/src/backend/routes/communities/routes.ts +48 -13
  73. package/src/backend/routes/dm/contacts.ts +29 -41
  74. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  75. package/src/backend/routes/dm/messages.ts +36 -42
  76. package/src/backend/routes/dm/read-archive.ts +4 -2
  77. package/src/backend/routes/dm/requests.ts +62 -54
  78. package/src/backend/routes/follow-helpers.ts +200 -60
  79. package/src/backend/routes/follow.ts +146 -140
  80. package/src/backend/routes/media.ts +20 -94
  81. package/src/backend/routes/moderation.ts +72 -4
  82. package/src/backend/routes/notes.ts +3 -4
  83. package/src/backend/routes/notifications.ts +209 -108
  84. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  85. package/src/backend/routes/posts/federation.ts +373 -0
  86. package/src/backend/routes/posts/interactions.ts +160 -184
  87. package/src/backend/routes/posts/like-mutation.ts +240 -0
  88. package/src/backend/routes/posts/post-helpers.ts +112 -162
  89. package/src/backend/routes/posts/queries.ts +150 -54
  90. package/src/backend/routes/posts/routes.ts +169 -329
  91. package/src/backend/routes/posts/transformers.ts +61 -6
  92. package/src/backend/routes/recommendations.ts +37 -44
  93. package/src/backend/routes/rtc/index.ts +26 -6
  94. package/src/backend/routes/search.ts +39 -55
  95. package/src/backend/routes/stories/interactions.ts +22 -6
  96. package/src/backend/routes/stories/query-helpers.ts +28 -41
  97. package/src/backend/routes/stories/routes.ts +125 -108
  98. package/src/backend/routes/takos-tools/dm.ts +17 -17
  99. package/src/backend/routes/takos-tools/posts.ts +189 -106
  100. package/src/backend/routes/takos-tools/search.ts +13 -4
  101. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  102. package/src/backend/routes/takos-tools-response.ts +6 -2
  103. package/src/backend/routes/timeline.ts +5 -5
  104. package/src/backend/runtime/call-signaling-do.ts +35 -4
  105. package/src/backend/runtime/one-time-ticket.ts +116 -0
  106. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  107. package/src/backend/runtime/signaling-hub.ts +36 -3
  108. package/src/backend/server.ts +95 -80
  109. package/src/db/d1-write.ts +67 -43
  110. package/src/db/schema/federation.ts +42 -1
  111. package/src/db/schema/messaging.ts +110 -2
  112. package/src/db/schema.ts +1 -1
@@ -19,6 +19,8 @@ import type {
19
19
  HubToClientFrame,
20
20
  IceServerConfig,
21
21
  } from "../types/call.ts";
22
+ import { apiPost } from "./api/fetch.ts";
23
+ import { getYurucommuApiTransport } from "./transport.ts";
22
24
 
23
25
  export type CallUiState =
24
26
  | "idle"
@@ -45,7 +47,7 @@ export interface CallClientEvents {
45
47
  }
46
48
 
47
49
  export interface CallClientOptions {
48
- /** Origin the app is served from (defaults to the page origin). */
50
+ /** Explicit server-origin override; otherwise the configured API transport owns it. */
49
51
  origin?: string;
50
52
  /** WebSocket reconnect backoff ceiling (ms). */
51
53
  maxBackoffMs?: number;
@@ -67,12 +69,15 @@ interface ActiveCall {
67
69
  }
68
70
 
69
71
  const CANDIDATE_FLUSH_MS = 200;
72
+ const MAX_PENDING_SOCKET_FRAMES = 64;
70
73
 
71
74
  export class CallClient {
72
75
  private ws: WebSocket | null = null;
73
76
  private wantConnected = false;
74
77
  private backoff = 500;
75
78
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
79
+ private openingSocket = false;
80
+ private readonly pendingFrames: ClientToHubFrame[] = [];
76
81
  private readonly listeners = new Map<keyof CallClientEvents, Set<Listener>>();
77
82
  private call: ActiveCall | null = null;
78
83
  private localStream: MediaStream | null = null;
@@ -114,16 +119,24 @@ export class CallClient {
114
119
  }
115
120
 
116
121
  // --- connection -----------------------------------------------------------
117
- private origin(): string {
118
- return (
119
- this.options.origin ??
120
- (typeof location !== "undefined" ? location.origin : "")
121
- );
122
+ private apiUrl(path: string): string {
123
+ const origin = this.options.origin;
124
+ return origin ? new URL(path, `${origin}/`).toString() : path;
122
125
  }
123
126
 
124
- private socketUrl(): string {
125
- const o = this.origin();
126
- return `${o.replace(/^http/, "ws")}/api/rtc/socket`;
127
+ private socketUrl(actorApId: string, ticket: string): string {
128
+ const transport = getYurucommuApiTransport();
129
+ const resolved = transport.resolveUrl(this.apiUrl("/api/rtc/socket"));
130
+ const base =
131
+ this.options.origin ??
132
+ (typeof location !== "undefined" ? location.origin : "http://localhost");
133
+ const url = new URL(resolved, base);
134
+ if (url.protocol === "http:") url.protocol = "ws:";
135
+ else if (url.protocol === "https:") url.protocol = "wss:";
136
+ else throw new Error("call socket URL must use HTTP(S)");
137
+ url.searchParams.set("actor", actorApId);
138
+ url.searchParams.set("ticket", ticket);
139
+ return url.toString();
127
140
  }
128
141
 
129
142
  connect(): void {
@@ -137,13 +150,46 @@ export class CallClient {
137
150
  this.reconnectTimer = null;
138
151
  this.ws?.close();
139
152
  this.ws = null;
153
+ this.pendingFrames.length = 0;
140
154
  }
141
155
 
142
156
  private openSocket(): void {
143
157
  if (this.ws && this.ws.readyState <= WebSocket.OPEN) return;
158
+ if (this.openingSocket) return;
159
+ this.openingSocket = true;
160
+ void this.openSocketWithTicket().finally(() => {
161
+ this.openingSocket = false;
162
+ });
163
+ }
164
+
165
+ private async openSocketWithTicket(): Promise<void> {
166
+ let actorApId: string;
167
+ let ticket: string;
168
+ try {
169
+ const response = await apiPost(this.apiUrl("/api/rtc/ticket"));
170
+ if (!response.ok) throw new Error(`ticket ${response.status}`);
171
+ const body = (await response.json()) as {
172
+ actor_ap_id?: unknown;
173
+ ticket?: unknown;
174
+ };
175
+ if (
176
+ typeof body.actor_ap_id !== "string" ||
177
+ typeof body.ticket !== "string"
178
+ ) {
179
+ throw new Error("bad ticket response");
180
+ }
181
+ actorApId = body.actor_ap_id;
182
+ ticket = body.ticket;
183
+ } catch (err) {
184
+ this.emit("error", "socket_ticket_failed", String(err));
185
+ this.scheduleReconnect();
186
+ return;
187
+ }
188
+ if (!this.wantConnected) return;
189
+
144
190
  let socket: WebSocket;
145
191
  try {
146
- socket = new WebSocket(this.socketUrl());
192
+ socket = new WebSocket(this.socketUrl(actorApId, ticket));
147
193
  } catch (err) {
148
194
  this.emit("error", "socket_open_failed", String(err));
149
195
  this.scheduleReconnect();
@@ -152,8 +198,22 @@ export class CallClient {
152
198
  this.ws = socket;
153
199
  socket.onopen = () => {
154
200
  this.backoff = 500;
155
- this.send({ t: "hello" });
156
- if (this.call) this.send({ t: "resume", callId: this.call.callId });
201
+ socket.send(JSON.stringify({ t: "hello" } satisfies ClientToHubFrame));
202
+ const pending = this.pendingFrames.splice(0);
203
+ const hasInitialInvite =
204
+ this.call !== null &&
205
+ pending.some(
206
+ (frame) => frame.t === "invite" && frame.callId === this.call?.callId,
207
+ );
208
+ if (this.call && !hasInitialInvite) {
209
+ socket.send(
210
+ JSON.stringify({
211
+ t: "resume",
212
+ callId: this.call.callId,
213
+ } satisfies ClientToHubFrame),
214
+ );
215
+ }
216
+ for (const frame of pending) socket.send(JSON.stringify(frame));
157
217
  };
158
218
  socket.onmessage = (ev) => {
159
219
  void this.onFrame(ev.data);
@@ -181,7 +241,14 @@ export class CallClient {
181
241
  private send(frame: ClientToHubFrame): void {
182
242
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
183
243
  this.ws.send(JSON.stringify(frame));
244
+ return;
184
245
  }
246
+ if (!this.wantConnected) return;
247
+ if (this.pendingFrames.length >= MAX_PENDING_SOCKET_FRAMES) {
248
+ this.emit("error", "socket_queue_full");
249
+ return;
250
+ }
251
+ this.pendingFrames.push(frame);
185
252
  }
186
253
 
187
254
  // --- public call control --------------------------------------------------
@@ -189,11 +256,9 @@ export class CallClient {
189
256
  /** Place an outgoing call. Fetches a callId + ICE, then rings the peer. */
190
257
  async startCall(peer: string, media: CallMediaKind): Promise<void> {
191
258
  if (this.call) throw new Error("already in a call");
192
- const res = await fetch(`${this.origin()}/api/rtc/calls`, {
193
- method: "POST",
194
- credentials: "include",
195
- headers: { "Content-Type": "application/json" },
196
- body: JSON.stringify({ to: peer, media }),
259
+ const res = await apiPost(this.apiUrl("/api/rtc/calls"), {
260
+ to: peer,
261
+ media,
197
262
  });
198
263
  if (!res.ok) {
199
264
  const code = res.status === 403 ? "blocked" : "start_failed";
@@ -204,18 +269,19 @@ export class CallClient {
204
269
  callId: string;
205
270
  iceServers: IceServerConfig[];
206
271
  };
207
- this.call = this.newCall(
272
+ const call = this.newCall(
208
273
  data.callId,
209
274
  peer,
210
275
  media,
211
276
  "caller",
212
277
  data.iceServers,
213
278
  );
279
+ this.call = call;
214
280
  this.setState("calling");
215
281
  this.connect();
216
282
  this.send({ t: "invite", callId: data.callId, to: peer, media });
217
- await this.setupMedia(this.call);
218
- await this.makeOffer(this.call);
283
+ if (!(await this.setupMedia(call))) return;
284
+ await this.makeOffer(call);
219
285
  }
220
286
 
221
287
  /** Accept the current incoming call. */
@@ -224,11 +290,14 @@ export class CallClient {
224
290
  if (!call || call.role !== "callee") return;
225
291
  this.setState("connecting");
226
292
  this.send({ t: "accept", callId: call.callId });
227
- await this.setupMedia(call);
293
+ if (!(await this.setupMedia(call))) return;
228
294
  // The offer was already applied on arrival; create + send the answer.
229
- if (call.pc && call.remoteDescriptionSet) {
230
- const answer = await call.pc.createAnswer();
231
- await call.pc.setLocalDescription(answer);
295
+ const pc = call.pc;
296
+ if (pc && call.remoteDescriptionSet && this.isActiveCall(call)) {
297
+ const answer = await pc.createAnswer();
298
+ if (!this.isActivePeer(call, pc)) return;
299
+ await pc.setLocalDescription(answer);
300
+ if (!this.isActivePeer(call, pc)) return;
232
301
  this.send({ t: "answer", callId: call.callId, sdp: answer.sdp ?? "" });
233
302
  }
234
303
  }
@@ -412,24 +481,45 @@ export class CallClient {
412
481
  }
413
482
 
414
483
  // --- media + peer connection ---------------------------------------------
415
- private async setupMedia(call: ActiveCall): Promise<void> {
484
+ private isActiveCall(call: ActiveCall): boolean {
485
+ return this.call === call;
486
+ }
487
+
488
+ private isActivePeer(call: ActiveCall, pc: RTCPeerConnection): boolean {
489
+ return this.isActiveCall(call) && call.pc === pc;
490
+ }
491
+
492
+ private async setupMedia(call: ActiveCall): Promise<boolean> {
493
+ if (!this.isActiveCall(call)) return false;
416
494
  if (!this.localStream) {
495
+ let stream: MediaStream;
417
496
  try {
418
- this.localStream = await navigator.mediaDevices.getUserMedia({
497
+ stream = await navigator.mediaDevices.getUserMedia({
419
498
  audio: call.media.audio,
420
499
  video: call.media.video,
421
500
  });
422
501
  } catch (err) {
502
+ if (!this.isActiveCall(call)) return false;
423
503
  this.emit("error", "media_denied", String(err));
424
504
  this.hangup("media_denied");
425
- return;
505
+ return false;
506
+ }
507
+ if (!this.isActiveCall(call)) {
508
+ // Permission prompts resolve asynchronously. If the user cancelled or
509
+ // another call replaced this one while the prompt was open, the newly
510
+ // acquired tracks have no owner and must be stopped immediately.
511
+ stream.getTracks().forEach((track) => track.stop());
512
+ return false;
426
513
  }
514
+ this.localStream = stream;
427
515
  this.emit("localstream", this.localStream);
428
516
  }
517
+ if (!this.isActiveCall(call)) return false;
429
518
  if (!call.pc) this.buildPeerConnection(call);
430
519
  for (const track of this.localStream.getTracks()) {
431
520
  call.pc?.addTrack(track, this.localStream);
432
521
  }
522
+ return this.isActiveCall(call);
433
523
  }
434
524
 
435
525
  private buildPeerConnection(call: ActiveCall): void {
@@ -441,13 +531,16 @@ export class CallClient {
441
531
  })),
442
532
  });
443
533
  pc.onicecandidate = (ev) => {
534
+ if (!this.isActivePeer(call, pc)) return;
444
535
  if (ev.candidate) this.bufferCandidate(call, ev.candidate.toJSON());
445
536
  else this.flushCandidates(call);
446
537
  };
447
538
  pc.ontrack = (ev) => {
539
+ if (!this.isActivePeer(call, pc)) return;
448
540
  this.emit("remotestream", ev.streams[0] ?? null);
449
541
  };
450
542
  pc.onconnectionstatechange = () => {
543
+ if (!this.isActivePeer(call, pc)) return;
451
544
  if (pc.connectionState === "connected") this.setState("connected");
452
545
  else if (
453
546
  pc.connectionState === "failed" ||
@@ -460,9 +553,14 @@ export class CallClient {
460
553
  }
461
554
 
462
555
  private async makeOffer(call: ActiveCall): Promise<void> {
556
+ if (!this.isActiveCall(call)) return;
463
557
  if (!call.pc) this.buildPeerConnection(call);
464
- const offer = await call.pc!.createOffer();
465
- await call.pc!.setLocalDescription(offer);
558
+ const pc = call.pc;
559
+ if (!pc) return;
560
+ const offer = await pc.createOffer();
561
+ if (!this.isActivePeer(call, pc)) return;
562
+ await pc.setLocalDescription(offer);
563
+ if (!this.isActivePeer(call, pc)) return;
466
564
  this.send({ t: "offer", callId: call.callId, sdp: offer.sdp ?? "" });
467
565
  }
468
566
 
@@ -11,6 +11,7 @@ export {
11
11
  activityApId,
12
12
  actorApId,
13
13
  communityApId,
14
+ formatPreferredUsername,
14
15
  formatUsername,
15
16
  generateId,
16
17
  getDomain,
@@ -47,7 +47,7 @@ import {
47
47
  import { logger } from "./lib/logger.ts";
48
48
 
49
49
  const log = logger.child({ component: "backend.index" });
50
- let lastNotificationPushRecoverySweep = 0;
50
+ let lastOutboxRecoverySweep = 0;
51
51
  import type { IQueueBatch } from "./runtime/queue.ts";
52
52
  import type {
53
53
  D1Database,
@@ -63,9 +63,12 @@ import type {
63
63
  DeliveryQueueMessageV1,
64
64
  } from "./lib/delivery/types.ts";
65
65
  import {
66
+ enqueuePendingDeliveryEndpointJobs,
66
67
  handleDeliveryDlqBatch,
67
68
  handleDeliveryQueueBatch,
68
69
  } from "./lib/delivery/queue.ts";
70
+ import { enqueuePendingDeliveryResolutionJobs } from "./lib/delivery/resolution-outbox.ts";
71
+ import { enqueuePendingDeliveryFanoutJobs } from "./lib/delivery/fanout-outbox.ts";
69
72
  import { enqueuePendingNotificationPushJobs } from "./lib/notification-push.ts";
70
73
  import { runYurucommuRetention } from "./retention.ts";
71
74
 
@@ -620,10 +623,20 @@ function applyGlobalMiddleware(
620
623
  const method = c.req.method.toUpperCase();
621
624
  const now = Date.now();
622
625
  const mutating = !["GET", "HEAD", "OPTIONS"].includes(method);
623
- const recoveryDue = now - lastNotificationPushRecoverySweep >= 60_000;
626
+ const recoveryDue = now - lastOutboxRecoverySweep >= 60_000;
624
627
  if (mutating || recoveryDue) {
625
- if (recoveryDue) lastNotificationPushRecoverySweep = now;
628
+ if (recoveryDue) lastOutboxRecoverySweep = now;
626
629
  const sweep = (async () => {
630
+ try {
631
+ await enqueuePendingDeliveryFanoutJobs(c.env);
632
+ await enqueuePendingDeliveryEndpointJobs(c.env);
633
+ await enqueuePendingDeliveryResolutionJobs(c.env);
634
+ } catch (error) {
635
+ log.error("Failed to enqueue durable federation outbox", {
636
+ event: "delivery.outbox.enqueue_failed",
637
+ error,
638
+ });
639
+ }
627
640
  try {
628
641
  await enqueuePendingNotificationPushJobs(c.env);
629
642
  } catch (error) {
@@ -9,6 +9,14 @@ import { signRequest } from "./ap-signing.ts";
9
9
  import type { RemoteFetchSigner } from "./activitypub-actor-cache.ts";
10
10
  import { parseWebFinger } from "./activitypub-validators.ts";
11
11
  import { isSafeRemoteUrl, normalizeRemoteDomain } from "./ssrf.ts";
12
+ import type { Env } from "../types.ts";
13
+ import type { Database, D1Statement } from "../../db/index.ts";
14
+ import { activities, actors, follows, runBatch } from "../../db/index.ts";
15
+ import { and, asc, eq, exists, gt, ne, notExists, sql } from "drizzle-orm";
16
+ import { alias } from "drizzle-orm/sqlite-core";
17
+ import { activityApId } from "./ap-ids.ts";
18
+ import { enqueueDeliveriesToActor } from "./delivery/queue.ts";
19
+ import { sha256Hex } from "./delivery/transformers.ts";
12
20
 
13
21
  const ALIAS_FETCH_TIMEOUT_MS = 15000;
14
22
 
@@ -104,3 +112,335 @@ export async function destinationDeclaresAlias(
104
112
  return false;
105
113
  }
106
114
  }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Inbound Move follow-graph rewrite
118
+ // ---------------------------------------------------------------------------
119
+
120
+ const MOVE_REFOLLOW_PAGE_SIZE = 100;
121
+
122
+ /**
123
+ * Stable namespace for the outbound Follow activities produced by one inbound
124
+ * Move. The inbox stamps a fixed-size, signer-bound internal activity id before
125
+ * dispatch; the old/new ids are mixed in as defense-in-depth and to keep direct
126
+ * handler tests deterministic when no inbox id is supplied.
127
+ */
128
+ export async function moveRefollowPrefix(args: {
129
+ readonly baseUrl: string;
130
+ readonly inboundActivityId?: string;
131
+ readonly oldActorApId: string;
132
+ readonly newActorApId: string;
133
+ }): Promise<string> {
134
+ const digest = await sha256Hex(
135
+ `${args.inboundActivityId ?? "synthetic-move"}\0${args.oldActorApId}\0${args.newActorApId}`,
136
+ );
137
+ return activityApId(args.baseUrl, `move-refollow-${digest}-`);
138
+ }
139
+
140
+ /**
141
+ * Re-enqueue every durable local re-Follow created for one Move. Returns the
142
+ * number of persisted activities found, so the handler can resume a graph
143
+ * rewrite that committed before a Queue send failed without re-fetching the
144
+ * destination alias or trying to reconstruct already-deleted old edges.
145
+ */
146
+ export async function enqueuePersistedMoveRefollows(args: {
147
+ readonly db: Database;
148
+ readonly env: Env;
149
+ readonly newActorApId: string;
150
+ readonly refollowPrefix: string;
151
+ }): Promise<number> {
152
+ let cursor: string | null = null;
153
+ let found = 0;
154
+
155
+ while (true) {
156
+ const page = await args.db
157
+ .select({ apId: activities.apId })
158
+ .from(activities)
159
+ .where(
160
+ and(
161
+ eq(activities.type, "Follow"),
162
+ eq(activities.objectApId, args.newActorApId),
163
+ eq(activities.direction, "outbound"),
164
+ // `LIKE prefix%` would hit D1's short LIKE-pattern complexity limit
165
+ // because the stable prefix contains a full URL + SHA-256 digest.
166
+ sql`instr(${activities.apId}, ${args.refollowPrefix}) = 1`,
167
+ cursor ? gt(activities.apId, cursor) : undefined,
168
+ ),
169
+ )
170
+ .orderBy(asc(activities.apId))
171
+ .limit(MOVE_REFOLLOW_PAGE_SIZE);
172
+
173
+ if (page.length === 0) break;
174
+ await enqueueDeliveriesToActor(
175
+ args.env,
176
+ page.map((row) => row.apId),
177
+ args.newActorApId,
178
+ );
179
+ found += page.length;
180
+ cursor = page[page.length - 1]!.apId;
181
+ if (page.length < MOVE_REFOLLOW_PAGE_SIZE) break;
182
+ }
183
+
184
+ return found;
185
+ }
186
+
187
+ /**
188
+ * Atomically rewrite every edge that names a moved remote actor.
189
+ *
190
+ * The statement count is fixed at eight regardless of graph size. Each INSERT
191
+ * reads the old graph with INSERT ... SELECT, so it binds only the handful of
192
+ * actor/prefix values rather than six parameters per edge. Both counter repairs
193
+ * are set-based UPDATEs. This is deliberately not implemented as insertMany()
194
+ * chunks: a large graph would eventually exceed the 50-statement atomic-batch
195
+ * ceiling, while splitting the delete/insert work across batches would expose
196
+ * a permanent half-migrated graph after a crash.
197
+ *
198
+ * Outbound local re-Follow activities are written in the same batch as their
199
+ * pending edges. If Queue delivery throws after commit, the stable prefix lets
200
+ * a retry call enqueuePersistedMoveRefollows() before touching the graph.
201
+ */
202
+ export async function rewriteMovedFollowGraph(args: {
203
+ readonly db: Database;
204
+ readonly oldActorApId: string;
205
+ readonly newActorApId: string;
206
+ readonly refollowPrefix: string;
207
+ }): Promise<void> {
208
+ const oldAsFollower = alias(follows, "move_old_as_follower");
209
+ const oldAsFollowee = alias(follows, "move_old_as_followee");
210
+ const existingFollowerTarget = alias(
211
+ follows,
212
+ "move_existing_follower_target",
213
+ );
214
+ const existingFollowingSource = alias(
215
+ follows,
216
+ "move_existing_following_source",
217
+ );
218
+ const localFollowerActor = alias(actors, "move_local_follower_actor");
219
+
220
+ // These aliases belong to the counter statements. Keep them separate from
221
+ // the INSERT aliases so each generated statement is unambiguous to SQLite.
222
+ const acceptedOldFollowee = alias(follows, "move_accepted_old_followee");
223
+ const acceptedOldFollower = alias(follows, "move_accepted_old_follower");
224
+ const duplicateNewFollower = alias(follows, "move_duplicate_new_follower");
225
+
226
+ const decrementLocalFollowers = args.db
227
+ .update(actors)
228
+ .set({ followingCount: sql`${actors.followingCount} - 1` })
229
+ .where(
230
+ and(
231
+ gt(actors.followingCount, 0),
232
+ exists(
233
+ args.db
234
+ .select({ one: sql`1` })
235
+ .from(acceptedOldFollowee)
236
+ .where(
237
+ and(
238
+ eq(acceptedOldFollowee.followingApId, args.oldActorApId),
239
+ eq(acceptedOldFollowee.followerApId, actors.apId),
240
+ eq(acceptedOldFollowee.status, "accepted"),
241
+ ),
242
+ ),
243
+ ),
244
+ ),
245
+ );
246
+
247
+ const decrementDroppedLocalFollowees = args.db
248
+ .update(actors)
249
+ .set({ followerCount: sql`${actors.followerCount} - 1` })
250
+ .where(
251
+ and(
252
+ gt(actors.followerCount, 0),
253
+ exists(
254
+ args.db
255
+ .select({ one: sql`1` })
256
+ .from(acceptedOldFollower)
257
+ .where(
258
+ and(
259
+ eq(acceptedOldFollower.followerApId, args.oldActorApId),
260
+ eq(acceptedOldFollower.followingApId, actors.apId),
261
+ eq(acceptedOldFollower.status, "accepted"),
262
+ ),
263
+ ),
264
+ ),
265
+ sql`(
266
+ ${actors.apId} = ${args.newActorApId}
267
+ OR ${exists(
268
+ args.db
269
+ .select({ one: sql`1` })
270
+ .from(duplicateNewFollower)
271
+ .where(
272
+ and(
273
+ eq(duplicateNewFollower.followerApId, args.newActorApId),
274
+ eq(duplicateNewFollower.followingApId, actors.apId),
275
+ ),
276
+ ),
277
+ )}
278
+ )`,
279
+ ),
280
+ );
281
+
282
+ const rewriteOldAsFollower = args.db
283
+ .insert(follows)
284
+ .select(
285
+ args.db
286
+ .select({
287
+ followerApId: sql<string>`${args.newActorApId}`.as("follower_ap_id"),
288
+ followingApId: oldAsFollower.followingApId,
289
+ status: oldAsFollower.status,
290
+ activityApId: oldAsFollower.activityApId,
291
+ createdAt: oldAsFollower.createdAt,
292
+ acceptedAt: oldAsFollower.acceptedAt,
293
+ })
294
+ .from(oldAsFollower)
295
+ .where(
296
+ and(
297
+ eq(oldAsFollower.followerApId, args.oldActorApId),
298
+ ne(oldAsFollower.followingApId, args.newActorApId),
299
+ notExists(
300
+ args.db
301
+ .select({ one: sql`1` })
302
+ .from(existingFollowerTarget)
303
+ .where(
304
+ and(
305
+ eq(existingFollowerTarget.followerApId, args.newActorApId),
306
+ eq(
307
+ existingFollowerTarget.followingApId,
308
+ oldAsFollower.followingApId,
309
+ ),
310
+ ),
311
+ ),
312
+ ),
313
+ ),
314
+ ),
315
+ )
316
+ .onConflictDoNothing();
317
+
318
+ const localFollowerExists = exists(
319
+ args.db
320
+ .select({ one: sql`1` })
321
+ .from(localFollowerActor)
322
+ .where(eq(localFollowerActor.apId, oldAsFollowee.followerApId)),
323
+ );
324
+ const targetEdgeDoesNotExist = notExists(
325
+ args.db
326
+ .select({ one: sql`1` })
327
+ .from(existingFollowingSource)
328
+ .where(
329
+ and(
330
+ eq(existingFollowingSource.followerApId, oldAsFollowee.followerApId),
331
+ eq(existingFollowingSource.followingApId, args.newActorApId),
332
+ ),
333
+ ),
334
+ );
335
+
336
+ const rewriteLocalFollowers = args.db
337
+ .insert(follows)
338
+ .select(
339
+ args.db
340
+ .select({
341
+ followerApId: oldAsFollowee.followerApId,
342
+ followingApId: sql<string>`${args.newActorApId}`.as(
343
+ "following_ap_id",
344
+ ),
345
+ status: sql<string>`'pending'`.as("status"),
346
+ activityApId:
347
+ sql<string>`${args.refollowPrefix} || lower(hex(randomblob(32)))`.as(
348
+ "activity_ap_id",
349
+ ),
350
+ createdAt: oldAsFollowee.createdAt,
351
+ acceptedAt: sql<null>`NULL`.as("accepted_at"),
352
+ })
353
+ .from(oldAsFollowee)
354
+ .where(
355
+ and(
356
+ eq(oldAsFollowee.followingApId, args.oldActorApId),
357
+ ne(oldAsFollowee.followerApId, args.newActorApId),
358
+ targetEdgeDoesNotExist,
359
+ localFollowerExists,
360
+ ),
361
+ ),
362
+ )
363
+ .onConflictDoNothing();
364
+
365
+ const rewriteRemoteFollowers = args.db
366
+ .insert(follows)
367
+ .select(
368
+ args.db
369
+ .select({
370
+ followerApId: oldAsFollowee.followerApId,
371
+ followingApId: sql<string>`${args.newActorApId}`.as(
372
+ "following_ap_id",
373
+ ),
374
+ status: oldAsFollowee.status,
375
+ activityApId: oldAsFollowee.activityApId,
376
+ createdAt: oldAsFollowee.createdAt,
377
+ acceptedAt: oldAsFollowee.acceptedAt,
378
+ })
379
+ .from(oldAsFollowee)
380
+ .where(
381
+ and(
382
+ eq(oldAsFollowee.followingApId, args.oldActorApId),
383
+ ne(oldAsFollowee.followerApId, args.newActorApId),
384
+ targetEdgeDoesNotExist,
385
+ notExists(
386
+ args.db
387
+ .select({ one: sql`1` })
388
+ .from(localFollowerActor)
389
+ .where(eq(localFollowerActor.apId, oldAsFollowee.followerApId)),
390
+ ),
391
+ ),
392
+ ),
393
+ )
394
+ .onConflictDoNothing();
395
+
396
+ const migratedLocalFollow = alias(follows, "move_migrated_local_follow");
397
+ const recordLocalRefollows = args.db
398
+ .insert(activities)
399
+ .select(
400
+ args.db
401
+ .select({
402
+ apId: migratedLocalFollow.activityApId,
403
+ type: sql<string>`'Follow'`.as("type"),
404
+ actorApId: migratedLocalFollow.followerApId,
405
+ objectApId: sql<string>`${args.newActorApId}`.as("object_ap_id"),
406
+ objectJson: sql<null>`NULL`.as("object_json"),
407
+ targetApId: sql<null>`NULL`.as("target_ap_id"),
408
+ rawJson: sql<string>`json_object(
409
+ '@context', 'https://www.w3.org/ns/activitystreams',
410
+ 'id', ${migratedLocalFollow.activityApId},
411
+ 'type', 'Follow',
412
+ 'actor', ${migratedLocalFollow.followerApId},
413
+ 'object', ${args.newActorApId}
414
+ )`.as("raw_json"),
415
+ direction: sql<string>`'outbound'`.as("direction"),
416
+ processed: sql<number>`0`.as("processed"),
417
+ createdAt: sql<string>`datetime('now')`.as("created_at"),
418
+ })
419
+ .from(migratedLocalFollow)
420
+ .where(
421
+ and(
422
+ eq(migratedLocalFollow.followingApId, args.newActorApId),
423
+ sql`instr(${migratedLocalFollow.activityApId}, ${args.refollowPrefix}) = 1`,
424
+ ),
425
+ ),
426
+ )
427
+ .onConflictDoNothing();
428
+
429
+ const deleteOldAsFollower = args.db
430
+ .delete(follows)
431
+ .where(eq(follows.followerApId, args.oldActorApId));
432
+ const deleteOldAsFollowee = args.db
433
+ .delete(follows)
434
+ .where(eq(follows.followingApId, args.oldActorApId));
435
+
436
+ await runBatch(args.db, [
437
+ decrementLocalFollowers,
438
+ decrementDroppedLocalFollowees,
439
+ rewriteOldAsFollower,
440
+ rewriteLocalFollowers,
441
+ rewriteRemoteFollowers,
442
+ recordLocalRefollows,
443
+ deleteOldAsFollower,
444
+ deleteOldAsFollowee,
445
+ ] as unknown as [D1Statement, ...D1Statement[]]);
446
+ }