@takosjp/yurucommu-core 3.0.0

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 (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,430 @@
1
+ import { and, asc, eq, gt, inArray, isNotNull, ne, or, sql } from "drizzle-orm";
2
+ import {
3
+ activities,
4
+ actors,
5
+ announces,
6
+ blocks,
7
+ bookmarks,
8
+ communities,
9
+ communityInvites,
10
+ communityJoinRequests,
11
+ communityMembers,
12
+ dmArchivedConversations,
13
+ dmCommunityReadStatus,
14
+ dmReadStatus,
15
+ dmTyping,
16
+ follows,
17
+ inbox,
18
+ likes,
19
+ mediaUploads,
20
+ mutes,
21
+ notificationArchived,
22
+ nowIso,
23
+ objectRecipients,
24
+ objects,
25
+ sessions,
26
+ storyShares,
27
+ storyViews,
28
+ storyVotes,
29
+ } from "../../db/index.ts";
30
+ import type { Database } from "../../db/index.ts";
31
+ import type { Env } from "../types.ts";
32
+ import type { IObjectStorage } from "../runtime/types.ts";
33
+ import { activityApId, generateId } from "../federation-helpers.ts";
34
+ import { snapshotAndEnqueueFollowerDeliveries } from "../lib/delivery/queue-batching.ts";
35
+ import { logger } from "../lib/logger.ts";
36
+
37
+ const log = logger.child({ component: "actors" });
38
+
39
+ /**
40
+ * Hard-delete an actor's media uploads and best-effort purge the backing R2
41
+ * objects. The DB rows are removed regardless of whether the object-store
42
+ * delete succeeds; account teardown is never blocked on storage availability.
43
+ * Shared by the owner teardown (routes/actors.ts POST /me/delete) and each
44
+ * sub-account teardown in {@link teardownActor}.
45
+ */
46
+ export async function purgeActorMediaUploads(
47
+ db: Database,
48
+ media: IObjectStorage | undefined,
49
+ apId: string,
50
+ ): Promise<void> {
51
+ const uploads = await db
52
+ .select({ r2Key: mediaUploads.r2Key })
53
+ .from(mediaUploads)
54
+ .where(eq(mediaUploads.uploaderApId, apId));
55
+ if (uploads.length === 0) return;
56
+ if (media) {
57
+ const keys = uploads.map((u) => u.r2Key);
58
+ // R2 caps a single delete() at 1000 keys; chunk the purge.
59
+ const R2_DELETE_BATCH = 1000;
60
+ try {
61
+ for (let i = 0; i < keys.length; i += R2_DELETE_BATCH) {
62
+ await media.delete(keys.slice(i, i + R2_DELETE_BATCH));
63
+ }
64
+ } catch (err) {
65
+ log.error("Failed to purge R2 objects for deleted account", {
66
+ event: "actors.account.delete_media_purge_failed",
67
+ actor: apId,
68
+ count: keys.length,
69
+ error: err,
70
+ });
71
+ }
72
+ }
73
+ await db.delete(mediaUploads).where(eq(mediaUploads.uploaderApId, apId));
74
+ }
75
+
76
+ /**
77
+ * Full per-actor teardown for account deletion: federate Delete(Actor),
78
+ * reconcile every counterparty's denormalized counters, delete all of the
79
+ * actor's edges / interactions / memberships / media / DM state, hand off
80
+ * sole-owned communities to an heir, hard-delete its authored objects, and
81
+ * tombstone+scrub the actor row.
82
+ *
83
+ * Applied to the deleting OWNER **and to each of its sub-accounts** (profiles
84
+ * minted via /accounts + /switch). A sub-account is a first-class actor that can
85
+ * post / follow / like / join+own communities, so without this its "deleted"
86
+ * content stays live in feeds, its edges keep counterparty counters inflated,
87
+ * its memberships keep community rosters/counts wrong (and a sole-owned
88
+ * community becomes permanently unmanageable), and remote followers never learn
89
+ * it is gone. This MUST mirror the owner teardown in routes/actors.ts
90
+ * (POST /me/delete) — keep the two in sync.
91
+ *
92
+ * `followersUrl` is the actor's followers collection (used as the Delete's cc).
93
+ */
94
+ export async function teardownActor(
95
+ db: Database,
96
+ env: Env,
97
+ baseUrl: string,
98
+ apId: string,
99
+ followersUrl: string,
100
+ ): Promise<void> {
101
+ // Federate the Delete BEFORE local teardown, snapshotting follower inboxes
102
+ // into delivery jobs while the follower graph + activity row still exist. The
103
+ // Delete activity row is preserved through teardown (excluded from the
104
+ // activities delete below) so the deliver_endpoint consumer can read its
105
+ // rawJson after the actor's other rows are gone.
106
+ const deleteActivityId = activityApId(baseUrl, generateId());
107
+ const deleteActivity = {
108
+ "@context": "https://www.w3.org/ns/activitystreams",
109
+ id: deleteActivityId,
110
+ type: "Delete",
111
+ actor: apId,
112
+ to: ["https://www.w3.org/ns/activitystreams#Public"],
113
+ cc: [followersUrl],
114
+ object: apId,
115
+ };
116
+ try {
117
+ await db.insert(activities).values({
118
+ apId: deleteActivityId,
119
+ type: "Delete",
120
+ actorApId: apId,
121
+ objectApId: apId,
122
+ rawJson: JSON.stringify(deleteActivity),
123
+ direction: "outbound",
124
+ });
125
+ await snapshotAndEnqueueFollowerDeliveries(db, env, deleteActivityId, apId);
126
+ } catch (err) {
127
+ // Federation is best-effort; never block local teardown on it.
128
+ log.error("Failed to enqueue account Delete federation", {
129
+ event: "actors.account.delete_federation_failed",
130
+ actor: apId,
131
+ error: err,
132
+ });
133
+ }
134
+
135
+ await db.delete(sessions).where(eq(sessions.memberId, apId));
136
+
137
+ // Reconcile counterparties' follower/following counts BEFORE dropping edges.
138
+ // Only ACCEPTED edges ever incremented a counter; gt(...,0) guards underflow.
139
+ await db
140
+ .update(actors)
141
+ .set({ followerCount: sql`${actors.followerCount} - 1` })
142
+ .where(
143
+ and(
144
+ inArray(
145
+ actors.apId,
146
+ db
147
+ .select({ id: follows.followingApId })
148
+ .from(follows)
149
+ .where(
150
+ and(
151
+ eq(follows.followerApId, apId),
152
+ eq(follows.status, "accepted"),
153
+ ),
154
+ ),
155
+ ),
156
+ gt(actors.followerCount, 0),
157
+ ),
158
+ );
159
+ await db
160
+ .update(actors)
161
+ .set({ followingCount: sql`${actors.followingCount} - 1` })
162
+ .where(
163
+ and(
164
+ inArray(
165
+ actors.apId,
166
+ db
167
+ .select({ id: follows.followerApId })
168
+ .from(follows)
169
+ .where(
170
+ and(
171
+ eq(follows.followingApId, apId),
172
+ eq(follows.status, "accepted"),
173
+ ),
174
+ ),
175
+ ),
176
+ gt(actors.followingCount, 0),
177
+ ),
178
+ );
179
+
180
+ await db
181
+ .delete(follows)
182
+ .where(or(eq(follows.followerApId, apId), eq(follows.followingApId, apId)));
183
+ await db
184
+ .delete(blocks)
185
+ .where(or(eq(blocks.blockerApId, apId), eq(blocks.blockedApId, apId)));
186
+ await db
187
+ .delete(mutes)
188
+ .where(or(eq(mutes.muterApId, apId), eq(mutes.mutedApId, apId)));
189
+
190
+ // Reconcile like/announce/share counters on OTHER actors' objects BEFORE
191
+ // deleting this actor's edges (griefable inflated counters otherwise).
192
+ await db
193
+ .update(objects)
194
+ .set({ likeCount: sql`${objects.likeCount} - 1` })
195
+ .where(
196
+ and(
197
+ inArray(
198
+ objects.apId,
199
+ db
200
+ .select({ id: likes.objectApId })
201
+ .from(likes)
202
+ .where(eq(likes.actorApId, apId)),
203
+ ),
204
+ gt(objects.likeCount, 0),
205
+ ),
206
+ );
207
+ await db
208
+ .update(objects)
209
+ .set({ announceCount: sql`${objects.announceCount} - 1` })
210
+ .where(
211
+ and(
212
+ inArray(
213
+ objects.apId,
214
+ db
215
+ .select({ id: announces.objectApId })
216
+ .from(announces)
217
+ .where(eq(announces.actorApId, apId)),
218
+ ),
219
+ gt(objects.announceCount, 0),
220
+ ),
221
+ );
222
+ await db
223
+ .update(objects)
224
+ .set({ shareCount: sql`${objects.shareCount} - 1` })
225
+ .where(
226
+ and(
227
+ inArray(
228
+ objects.apId,
229
+ db
230
+ .select({ id: storyShares.storyApId })
231
+ .from(storyShares)
232
+ .where(eq(storyShares.actorApId, apId)),
233
+ ),
234
+ gt(objects.shareCount, 0),
235
+ ),
236
+ );
237
+
238
+ await db.delete(likes).where(eq(likes.actorApId, apId));
239
+ await db.delete(bookmarks).where(eq(bookmarks.actorApId, apId));
240
+ await db.delete(announces).where(eq(announces.actorApId, apId));
241
+
242
+ await db.delete(inbox).where(eq(inbox.actorApId, apId));
243
+ await db
244
+ .delete(notificationArchived)
245
+ .where(eq(notificationArchived.actorApId, apId));
246
+
247
+ // Media: hard-delete the actor's uploads + best-effort purge backing R2.
248
+ await purgeActorMediaUploads(db, env.MEDIA, apId);
249
+
250
+ // Story interactions the actor performed on OTHER/remote stories.
251
+ await db.delete(storyVotes).where(eq(storyVotes.actorApId, apId));
252
+ await db.delete(storyViews).where(eq(storyViews.actorApId, apId));
253
+ await db.delete(storyShares).where(eq(storyShares.actorApId, apId));
254
+
255
+ // Per-actor DM status metadata (no FK cascade).
256
+ await db.delete(dmReadStatus).where(eq(dmReadStatus.actorApId, apId));
257
+ await db
258
+ .delete(dmCommunityReadStatus)
259
+ .where(eq(dmCommunityReadStatus.actorApId, apId));
260
+ await db
261
+ .delete(dmArchivedConversations)
262
+ .where(eq(dmArchivedConversations.actorApId, apId));
263
+ await db
264
+ .delete(dmTyping)
265
+ .where(or(eq(dmTyping.actorApId, apId), eq(dmTyping.recipientApId, apId)));
266
+
267
+ // Community membership lifecycle rows.
268
+ await db
269
+ .delete(communityJoinRequests)
270
+ .where(eq(communityJoinRequests.actorApId, apId));
271
+ await db
272
+ .delete(communityInvites)
273
+ .where(
274
+ or(
275
+ eq(communityInvites.invitedByApId, apId),
276
+ eq(communityInvites.usedByApId, apId),
277
+ eq(communityInvites.invitedApId, apId),
278
+ ),
279
+ );
280
+
281
+ const memberships = await db
282
+ .select({
283
+ communityApId: communityMembers.communityApId,
284
+ role: communityMembers.role,
285
+ })
286
+ .from(communityMembers)
287
+ .where(eq(communityMembers.actorApId, apId));
288
+ const communityApIds = memberships.map((m) => m.communityApId);
289
+
290
+ // Hand off sole-owned communities to the oldest remaining member.
291
+ for (const m of memberships) {
292
+ if (m.role !== "owner") continue;
293
+ const otherOwner = await db
294
+ .select({ actorApId: communityMembers.actorApId })
295
+ .from(communityMembers)
296
+ .where(
297
+ and(
298
+ eq(communityMembers.communityApId, m.communityApId),
299
+ eq(communityMembers.role, "owner"),
300
+ ne(communityMembers.actorApId, apId),
301
+ ),
302
+ )
303
+ .get();
304
+ if (otherOwner) continue;
305
+ const heir = await db
306
+ .select({ actorApId: communityMembers.actorApId })
307
+ .from(communityMembers)
308
+ .where(
309
+ and(
310
+ eq(communityMembers.communityApId, m.communityApId),
311
+ ne(communityMembers.actorApId, apId),
312
+ ),
313
+ )
314
+ .orderBy(asc(communityMembers.joinedAt))
315
+ .get();
316
+ if (heir) {
317
+ await db
318
+ .update(communityMembers)
319
+ .set({ role: "owner" })
320
+ .where(
321
+ and(
322
+ eq(communityMembers.communityApId, m.communityApId),
323
+ eq(communityMembers.actorApId, heir.actorApId),
324
+ ),
325
+ );
326
+ }
327
+ }
328
+
329
+ if (communityApIds.length > 0) {
330
+ await db
331
+ .update(communities)
332
+ .set({ memberCount: sql`${communities.memberCount} - 1` })
333
+ .where(
334
+ and(
335
+ inArray(
336
+ communities.apId,
337
+ db
338
+ .select({ id: communityMembers.communityApId })
339
+ .from(communityMembers)
340
+ .where(eq(communityMembers.actorApId, apId)),
341
+ ),
342
+ gt(communities.memberCount, 0),
343
+ ),
344
+ );
345
+ }
346
+ await db.delete(communityMembers).where(eq(communityMembers.actorApId, apId));
347
+
348
+ await db
349
+ .delete(objectRecipients)
350
+ .where(eq(objectRecipients.recipientApId, apId));
351
+ // Preserve the federation Delete activity (the delivery consumer needs its
352
+ // rawJson); all other activities by this actor go.
353
+ await db
354
+ .delete(activities)
355
+ .where(
356
+ and(
357
+ eq(activities.actorApId, apId),
358
+ ne(activities.apId, deleteActivityId),
359
+ ),
360
+ );
361
+
362
+ // Interactions on the actor's authored objects, via subqueries.
363
+ const authoredObjectIds = () =>
364
+ db
365
+ .select({ id: objects.apId })
366
+ .from(objects)
367
+ .where(eq(objects.attributedTo, apId));
368
+ await db.delete(likes).where(inArray(likes.objectApId, authoredObjectIds()));
369
+ await db
370
+ .delete(announces)
371
+ .where(inArray(announces.objectApId, authoredObjectIds()));
372
+ await db
373
+ .delete(bookmarks)
374
+ .where(inArray(bookmarks.objectApId, authoredObjectIds()));
375
+ await db
376
+ .delete(storyVotes)
377
+ .where(inArray(storyVotes.storyApId, authoredObjectIds()));
378
+ await db
379
+ .delete(storyViews)
380
+ .where(inArray(storyViews.storyApId, authoredObjectIds()));
381
+ await db
382
+ .delete(storyShares)
383
+ .where(inArray(storyShares.storyApId, authoredObjectIds()));
384
+ await db
385
+ .delete(objectRecipients)
386
+ .where(inArray(objectRecipients.objectApId, authoredObjectIds()));
387
+
388
+ // Recompute affected parents' replyCount as COUNT(*) of remaining replies.
389
+ await db
390
+ .update(objects)
391
+ .set({
392
+ replyCount: sql`(SELECT COUNT(*) FROM objects AS child WHERE child.in_reply_to = ${objects.apId} AND child.attributed_to <> ${apId})`,
393
+ })
394
+ .where(
395
+ inArray(
396
+ objects.apId,
397
+ db
398
+ .select({ id: objects.inReplyTo })
399
+ .from(objects)
400
+ .where(
401
+ and(eq(objects.attributedTo, apId), isNotNull(objects.inReplyTo)),
402
+ ),
403
+ ),
404
+ );
405
+
406
+ await db.delete(objects).where(eq(objects.attributedTo, apId));
407
+
408
+ // Tombstone + scrub the actor row (keep only the delivery signer's key
409
+ // material; free the unique handle by renaming it to a sentinel).
410
+ await db
411
+ .update(actors)
412
+ .set({
413
+ preferredUsername: `deleted-${generateId()}`,
414
+ name: null,
415
+ summary: null,
416
+ iconUrl: null,
417
+ headerUrl: null,
418
+ takosUserId: null,
419
+ followerCount: 0,
420
+ followingCount: 0,
421
+ postCount: 0,
422
+ fieldsJson: "[]",
423
+ alsoKnownAsJson: "[]",
424
+ movedTo: null,
425
+ ownerActorApId: null,
426
+ role: "member",
427
+ deletedAt: nowIso(),
428
+ })
429
+ .where(eq(actors.apId, apId));
430
+ }