@takosjp/yurucommu-core 3.4.1 → 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 (113) 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 +35 -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/public.ts +16 -0
  45. package/src/backend/retention.ts +115 -0
  46. package/src/backend/routes/account-teardown.ts +422 -156
  47. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  48. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  51. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  52. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  53. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  54. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  55. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  56. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  57. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  58. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  59. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  60. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  61. package/src/backend/routes/activitypub/inbox.ts +81 -105
  62. package/src/backend/routes/activitypub/outbox.ts +0 -0
  63. package/src/backend/routes/activitypub.ts +6 -5
  64. package/src/backend/routes/actors-helpers.ts +34 -8
  65. package/src/backend/routes/actors.ts +333 -169
  66. package/src/backend/routes/auth-helpers.ts +57 -9
  67. package/src/backend/routes/auth.ts +10 -2
  68. package/src/backend/routes/communities/membership-invites.ts +4 -1
  69. package/src/backend/routes/communities/membership-members.ts +201 -73
  70. package/src/backend/routes/communities/membership-requests.ts +141 -76
  71. package/src/backend/routes/communities/membership-shared.ts +228 -21
  72. package/src/backend/routes/communities/messages.ts +9 -2
  73. package/src/backend/routes/communities/routes.ts +48 -13
  74. package/src/backend/routes/dm/contacts.ts +29 -41
  75. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  76. package/src/backend/routes/dm/messages.ts +36 -42
  77. package/src/backend/routes/dm/read-archive.ts +4 -2
  78. package/src/backend/routes/dm/requests.ts +62 -54
  79. package/src/backend/routes/follow-helpers.ts +200 -60
  80. package/src/backend/routes/follow.ts +146 -140
  81. package/src/backend/routes/media.ts +20 -94
  82. package/src/backend/routes/moderation.ts +72 -4
  83. package/src/backend/routes/notes.ts +3 -4
  84. package/src/backend/routes/notifications.ts +209 -108
  85. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  86. package/src/backend/routes/posts/federation.ts +373 -0
  87. package/src/backend/routes/posts/interactions.ts +160 -184
  88. package/src/backend/routes/posts/like-mutation.ts +240 -0
  89. package/src/backend/routes/posts/post-helpers.ts +112 -162
  90. package/src/backend/routes/posts/queries.ts +150 -54
  91. package/src/backend/routes/posts/routes.ts +169 -329
  92. package/src/backend/routes/posts/transformers.ts +61 -6
  93. package/src/backend/routes/recommendations.ts +37 -44
  94. package/src/backend/routes/rtc/index.ts +26 -6
  95. package/src/backend/routes/search.ts +39 -55
  96. package/src/backend/routes/stories/interactions.ts +22 -6
  97. package/src/backend/routes/stories/query-helpers.ts +28 -41
  98. package/src/backend/routes/stories/routes.ts +130 -115
  99. package/src/backend/routes/takos-tools/dm.ts +17 -17
  100. package/src/backend/routes/takos-tools/posts.ts +189 -106
  101. package/src/backend/routes/takos-tools/search.ts +13 -4
  102. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  103. package/src/backend/routes/takos-tools-response.ts +6 -2
  104. package/src/backend/routes/timeline.ts +5 -5
  105. package/src/backend/runtime/call-signaling-do.ts +35 -4
  106. package/src/backend/runtime/one-time-ticket.ts +116 -0
  107. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  108. package/src/backend/runtime/signaling-hub.ts +36 -3
  109. package/src/backend/server.ts +95 -80
  110. package/src/db/d1-write.ts +67 -43
  111. package/src/db/schema/federation.ts +42 -1
  112. package/src/db/schema/messaging.ts +110 -2
  113. package/src/db/schema.ts +1 -1
@@ -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
+ }
@@ -0,0 +1,193 @@
1
+ import { and, inArray, notExists, or, type SQL } from "drizzle-orm";
2
+
3
+ import {
4
+ activities,
5
+ deliveryFanouts,
6
+ deliveryQueue,
7
+ deliveryResolutions,
8
+ inboundActivityClaims,
9
+ inbox,
10
+ notificationArchived,
11
+ notificationPushJobs,
12
+ runBatch,
13
+ type Database,
14
+ type D1Statement,
15
+ } from "../../db/index.ts";
16
+
17
+ /**
18
+ * A write-time fence for deleting an Activity actor's signing material.
19
+ *
20
+ * Preliminary reads are not sufficient: a fanout/resolution worker can
21
+ * materialize an endpoint after the reaper checked delivery_queue and then
22
+ * mark its own outbox terminal before the reaper checks that table. Reuse this
23
+ * predicate on EVERY statement in the atomic cleanup batch so newly active
24
+ * work stops the remaining deletes before the Activity or signer disappears.
25
+ */
26
+ export function activityDeliveryDrainedGuard(
27
+ db: Database,
28
+ activityWhere: SQL,
29
+ ): SQL {
30
+ const targetActivityIds = () =>
31
+ db.select({ apId: activities.apId }).from(activities).where(activityWhere);
32
+
33
+ return and(
34
+ notExists(
35
+ db
36
+ .select({ id: deliveryQueue.id })
37
+ .from(deliveryQueue)
38
+ .where(
39
+ and(
40
+ inArray(deliveryQueue.activityApId, targetActivityIds()),
41
+ inArray(deliveryQueue.status, [
42
+ "pending",
43
+ "processing",
44
+ "retry_wait",
45
+ ]),
46
+ ),
47
+ ),
48
+ ),
49
+ notExists(
50
+ db
51
+ .select({ id: deliveryResolutions.id })
52
+ .from(deliveryResolutions)
53
+ .where(
54
+ and(
55
+ inArray(deliveryResolutions.activityApId, targetActivityIds()),
56
+ inArray(deliveryResolutions.status, [
57
+ "pending",
58
+ "queued",
59
+ "processing",
60
+ "retry_wait",
61
+ ]),
62
+ ),
63
+ ),
64
+ ),
65
+ notExists(
66
+ db
67
+ .select({ id: deliveryFanouts.id })
68
+ .from(deliveryFanouts)
69
+ .where(
70
+ and(
71
+ or(
72
+ inArray(deliveryFanouts.activityApId, targetActivityIds()),
73
+ inArray(
74
+ deliveryFanouts.announceActivityApId,
75
+ targetActivityIds(),
76
+ ),
77
+ ),
78
+ inArray(deliveryFanouts.status, ["pending", "published"]),
79
+ ),
80
+ ),
81
+ ),
82
+ )!;
83
+ }
84
+
85
+ /**
86
+ * Build the atomic cleanup unit for every durable projection of matching
87
+ * activities while retaining the activity ledger rows themselves.
88
+ *
89
+ * The inbox FK/trigger only removes push jobs in selected non-terminal states;
90
+ * delivery work, archived markers, in-flight/terminal push jobs, and inbound
91
+ * claim leases otherwise survive. This projection-only form is also used when
92
+ * an object is deleted but its historical Activity must remain available.
93
+ */
94
+ export function activityProjectionDeleteStatements(
95
+ db: Database,
96
+ activityWhere: SQL,
97
+ options: { readonly guard?: SQL } = {},
98
+ ): readonly [D1Statement, ...D1Statement[]] {
99
+ const targetActivityIds = () =>
100
+ db.select({ apId: activities.apId }).from(activities).where(activityWhere);
101
+ const guarded = (where: SQL): SQL =>
102
+ options.guard ? and(where, options.guard)! : where;
103
+
104
+ return [
105
+ db
106
+ .delete(notificationPushJobs)
107
+ .where(
108
+ guarded(
109
+ inArray(notificationPushJobs.activityApId, targetActivityIds()),
110
+ ),
111
+ ) as D1Statement,
112
+ db
113
+ .delete(notificationArchived)
114
+ .where(
115
+ guarded(
116
+ inArray(notificationArchived.activityApId, targetActivityIds()),
117
+ ),
118
+ ) as D1Statement,
119
+ db
120
+ .delete(inboundActivityClaims)
121
+ .where(
122
+ guarded(
123
+ inArray(inboundActivityClaims.activityApId, targetActivityIds()),
124
+ ),
125
+ ) as D1Statement,
126
+ db
127
+ .delete(deliveryQueue)
128
+ .where(
129
+ guarded(inArray(deliveryQueue.activityApId, targetActivityIds())),
130
+ ) as D1Statement,
131
+ db
132
+ .delete(deliveryResolutions)
133
+ .where(
134
+ guarded(inArray(deliveryResolutions.activityApId, targetActivityIds())),
135
+ ) as D1Statement,
136
+ db
137
+ .delete(deliveryFanouts)
138
+ .where(
139
+ guarded(inArray(deliveryFanouts.activityApId, targetActivityIds())),
140
+ ) as D1Statement,
141
+ // Keep the secondary Announce reference in its own statement. Combining
142
+ // both predicates with OR duplicates every bound value in D1, so a normal
143
+ // 90-id cascade would exceed the platform's 100-parameter ceiling.
144
+ db
145
+ .delete(deliveryFanouts)
146
+ .where(
147
+ guarded(
148
+ inArray(deliveryFanouts.announceActivityApId, targetActivityIds()),
149
+ ),
150
+ ) as D1Statement,
151
+ db
152
+ .delete(inbox)
153
+ .where(
154
+ guarded(inArray(inbox.activityApId, targetActivityIds())),
155
+ ) as D1Statement,
156
+ ];
157
+ }
158
+
159
+ /**
160
+ * Build one atomic hard-delete unit for activities and every durable
161
+ * projection keyed by their AP IDs.
162
+ *
163
+ * Keep the activities rows until the final statement so every preceding
164
+ * subquery sees the same target set. Semantic edges (`likes`, `announces`,
165
+ * `follows`) are intentionally caller-owned because removing them also
166
+ * requires domain-specific counter repair.
167
+ */
168
+ export function activityDeleteCascadeStatements(
169
+ db: Database,
170
+ activityWhere: SQL,
171
+ options: { readonly guard?: SQL } = {},
172
+ ): readonly [D1Statement, ...D1Statement[]] {
173
+ return [
174
+ ...activityProjectionDeleteStatements(db, activityWhere, options),
175
+ db
176
+ .delete(activities)
177
+ .where(
178
+ options.guard ? and(activityWhere, options.guard) : activityWhere,
179
+ ) as D1Statement,
180
+ ];
181
+ }
182
+
183
+ /**
184
+ * Atomically hard-delete matching activities and all of their projections.
185
+ * A failed final activity delete rolls the preceding cleanup back, so the
186
+ * caller can retry the same idempotent predicate without partial state.
187
+ */
188
+ export async function deleteActivitiesCascade(
189
+ db: Database,
190
+ activityWhere: SQL,
191
+ ): Promise<void> {
192
+ await runBatch(db, activityDeleteCascadeStatements(db, activityWhere));
193
+ }