@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
@@ -1,4 +1,19 @@
1
- import { and, asc, eq, gt, inArray, isNotNull, ne, or, sql } from "drizzle-orm";
1
+ import {
2
+ and,
3
+ asc,
4
+ eq,
5
+ exists,
6
+ gt,
7
+ inArray,
8
+ isNotNull,
9
+ isNull,
10
+ ne,
11
+ not,
12
+ notExists,
13
+ or,
14
+ sql,
15
+ } from "drizzle-orm";
16
+ import type { BatchItem } from "drizzle-orm/batch";
2
17
  import {
3
18
  activities,
4
19
  actors,
@@ -6,6 +21,7 @@ import {
6
21
  blocks,
7
22
  bookmarks,
8
23
  communities,
24
+ communityBans,
9
25
  communityInvites,
10
26
  communityJoinRequests,
11
27
  communityMembers,
@@ -33,15 +49,32 @@ import type { Database } from "../../db/index.ts";
33
49
  import type { Env } from "../types.ts";
34
50
  import type { IObjectStorage } from "../runtime/types.ts";
35
51
  import { activityApId, generateId } from "../federation-helpers.ts";
52
+ import { chunkForInClause } from "../lib/chunk.ts";
36
53
  import { snapshotAndEnqueueFollowerDeliveries } from "../lib/delivery/queue-batching.ts";
37
54
  import { logger } from "../lib/logger.ts";
55
+ import { deleteActivitiesCascade } from "../lib/activity-delete-cascade.ts";
38
56
 
39
57
  const log = logger.child({ component: "actors" });
40
58
 
59
+ // D1 has no interactive transactions, but both the D1 and libsql drivers
60
+ // expose an atomic `db.batch([...])` surface. Keep follow-edge removal and its
61
+ // counter reconciliation in one batch so a retry can never observe counters
62
+ // updated while the edge is still present.
63
+ type BatchStatement = BatchItem<"sqlite">;
64
+ interface BatchableDb {
65
+ batch(
66
+ statements: readonly [BatchStatement, ...BatchStatement[]],
67
+ ): Promise<unknown>;
68
+ }
69
+
41
70
  /**
42
- * Hard-delete an actor's media uploads and best-effort purge the backing R2
43
- * objects. The DB rows are removed regardless of whether the object-store
44
- * delete succeeds; account teardown is never blocked on storage availability.
71
+ * Hard-delete an actor's media uploads after purging the backing R2 objects.
72
+ *
73
+ * The media row is the durable retry/GC identity (`r2_key`). When a configured
74
+ * object store rejects a delete, keep that row and propagate the error so the
75
+ * account route reports failure and a later retry can attempt the same key.
76
+ * Without a MEDIA binding there is no external delete to fail, so metadata can
77
+ * still be removed.
45
78
  * Shared by the owner teardown (routes/actors.ts POST /me/delete) and each
46
79
  * sub-account teardown in {@link teardownActor}.
47
80
  */
@@ -70,11 +103,128 @@ export async function purgeActorMediaUploads(
70
103
  count: keys.length,
71
104
  error: err,
72
105
  });
106
+ // Do not discard the only durable identity from which a later retry or
107
+ // GC pass can recover the object. The caller must return a non-success
108
+ // response while the metadata remains available for retry.
109
+ throw err;
73
110
  }
74
111
  }
75
112
  await db.delete(mediaUploads).where(eq(mediaUploads.uploaderApId, apId));
76
113
  }
77
114
 
115
+ /**
116
+ * Tombstone and scrub an actor after every owner/sub-account teardown step has
117
+ * succeeded. Keeping this finalization separate lets the owner route retain a
118
+ * live actor/session while a sub-account still needs a retry.
119
+ */
120
+ export async function finalizeActorDeletion(
121
+ db: Database,
122
+ apId: string,
123
+ ): Promise<void> {
124
+ await actorDeletionUpdate(db, apId);
125
+ }
126
+
127
+ function actorDeletionUpdate(db: Database, apId: string): BatchStatement {
128
+ return db
129
+ .update(actors)
130
+ .set({
131
+ preferredUsername: `deleted-${generateId()}`,
132
+ name: null,
133
+ summary: null,
134
+ iconUrl: null,
135
+ headerUrl: null,
136
+ takosUserId: null,
137
+ followerCount: 0,
138
+ followingCount: 0,
139
+ postCount: 0,
140
+ fieldsJson: "[]",
141
+ alsoKnownAsJson: "[]",
142
+ movedTo: null,
143
+ ownerActorApId: null,
144
+ role: "member",
145
+ deletedAt: nowIso(),
146
+ })
147
+ .where(eq(actors.apId, apId)) as unknown as BatchStatement;
148
+ }
149
+
150
+ /**
151
+ * Commit the owner's final tombstone and every owner/sub-account session
152
+ * deletion together. If either operation fails, D1/libsql rolls the whole
153
+ * batch back so the live owner session can retry the incomplete cascade.
154
+ */
155
+ export async function finalizeActorDeletionAndSessions(
156
+ db: Database,
157
+ apId: string,
158
+ sessionActorIds: string[],
159
+ ): Promise<void> {
160
+ const statements: BatchStatement[] = [actorDeletionUpdate(db, apId)];
161
+ for (const chunk of chunkForInClause(sessionActorIds)) {
162
+ statements.push(
163
+ db
164
+ .delete(sessions)
165
+ .where(inArray(sessions.memberId, chunk)) as unknown as BatchStatement,
166
+ );
167
+ }
168
+ await (db as unknown as BatchableDb).batch(
169
+ statements as [BatchStatement, ...BatchStatement[]],
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Persist one stable outbound Delete intent and snapshot every current remote
175
+ * follower before its authority edge can be removed. Both account actors and
176
+ * community Group actors use the same durable-delivery contract.
177
+ */
178
+ async function ensureDeleteFollowerSnapshot(
179
+ db: Database,
180
+ env: Env,
181
+ baseUrl: string,
182
+ subjectApId: string,
183
+ followersUrl: string,
184
+ ): Promise<string> {
185
+ const existingDelete = await db
186
+ .select({ apId: activities.apId })
187
+ .from(activities)
188
+ .where(
189
+ and(
190
+ eq(activities.actorApId, subjectApId),
191
+ eq(activities.type, "Delete"),
192
+ eq(activities.direction, "outbound"),
193
+ ),
194
+ )
195
+ .orderBy(asc(activities.createdAt))
196
+ .get();
197
+ const deleteActivityId =
198
+ existingDelete?.apId ?? activityApId(baseUrl, generateId());
199
+ const deleteActivity = {
200
+ "@context": "https://www.w3.org/ns/activitystreams",
201
+ id: deleteActivityId,
202
+ type: "Delete",
203
+ actor: subjectApId,
204
+ to: ["https://www.w3.org/ns/activitystreams#Public"],
205
+ cc: [followersUrl],
206
+ object: subjectApId,
207
+ };
208
+
209
+ if (!existingDelete) {
210
+ await db.insert(activities).values({
211
+ apId: deleteActivityId,
212
+ type: "Delete",
213
+ actorApId: subjectApId,
214
+ objectApId: subjectApId,
215
+ rawJson: JSON.stringify(deleteActivity),
216
+ direction: "outbound",
217
+ });
218
+ }
219
+ await snapshotAndEnqueueFollowerDeliveries(
220
+ db,
221
+ env,
222
+ deleteActivityId,
223
+ subjectApId,
224
+ );
225
+ return deleteActivityId;
226
+ }
227
+
78
228
  /**
79
229
  * Full per-actor teardown for account deletion: federate Delete(Actor),
80
230
  * reconcile every counterparty's denormalized counters, delete all of the
@@ -92,6 +242,8 @@ export async function purgeActorMediaUploads(
92
242
  * (POST /me/delete) — keep the two in sync.
93
243
  *
94
244
  * `followersUrl` is the actor's followers collection (used as the Delete's cc).
245
+ * `options.finalizeActor = false` leaves the actor live for a parent owner
246
+ * route to finalize after every sub-account has completed.
95
247
  */
96
248
  export async function teardownActor(
97
249
  db: Database,
@@ -99,89 +251,85 @@ export async function teardownActor(
99
251
  baseUrl: string,
100
252
  apId: string,
101
253
  followersUrl: string,
254
+ options: { finalizeActor?: boolean } = {},
102
255
  ): Promise<void> {
103
256
  // Federate the Delete BEFORE local teardown, snapshotting follower inboxes
104
257
  // into delivery jobs while the follower graph + activity row still exist. The
105
258
  // Delete activity row is preserved through teardown (excluded from the
106
259
  // activities delete below) so the deliver_endpoint consumer can read its
107
260
  // rawJson after the actor's other rows are gone.
108
- const deleteActivityId = activityApId(baseUrl, generateId());
109
- const deleteActivity = {
110
- "@context": "https://www.w3.org/ns/activitystreams",
111
- id: deleteActivityId,
112
- type: "Delete",
113
- actor: apId,
114
- to: ["https://www.w3.org/ns/activitystreams#Public"],
115
- cc: [followersUrl],
116
- object: apId,
117
- };
261
+ // Retry the same durable Delete intent when an earlier teardown attempt
262
+ // failed after snapshotting followers. Minting a fresh Activity here would
263
+ // see an already-erased follower graph, then the cleanup below could delete
264
+ // the original Activity and its only endpoint/resolution jobs.
118
265
  try {
119
- await db.insert(activities).values({
120
- apId: deleteActivityId,
121
- type: "Delete",
122
- actorApId: apId,
123
- objectApId: apId,
124
- rawJson: JSON.stringify(deleteActivity),
125
- direction: "outbound",
126
- });
127
- await snapshotAndEnqueueFollowerDeliveries(db, env, deleteActivityId, apId);
266
+ await ensureDeleteFollowerSnapshot(db, env, baseUrl, apId, followersUrl);
128
267
  } catch (err) {
129
- // Federation is best-effort; never block local teardown on it.
130
- log.error("Failed to enqueue account Delete federation", {
131
- event: "actors.account.delete_federation_failed",
268
+ // Queue publication is already best-effort inside the snapshot helper, but
269
+ // the durable Activity/outbox write is not. Continuing would erase the
270
+ // follower graph and make the missing remote Delete impossible to rebuild.
271
+ // Preserve the live actor/session as retry authority, matching media purge
272
+ // failures later in this same teardown.
273
+ log.error("Failed to persist account Delete follower snapshot", {
274
+ event: "actors.account.delete_snapshot_failed",
132
275
  actor: apId,
133
276
  error: err,
134
277
  });
278
+ throw err;
135
279
  }
136
280
 
137
- await db.delete(sessions).where(eq(sessions.memberId, apId));
138
-
139
281
  // Reconcile counterparties' follower/following counts BEFORE dropping edges.
140
282
  // Only ACCEPTED edges ever incremented a counter; gt(...,0) guards underflow.
141
- await db
142
- .update(actors)
143
- .set({ followerCount: sql`${actors.followerCount} - 1` })
144
- .where(
145
- and(
146
- inArray(
147
- actors.apId,
148
- db
149
- .select({ id: follows.followingApId })
150
- .from(follows)
151
- .where(
152
- and(
153
- eq(follows.followerApId, apId),
154
- eq(follows.status, "accepted"),
283
+ // Keep both updates and the edge delete in one atomic batch. If a later
284
+ // teardown step fails, a retry sees either the original edge+counts or the
285
+ // fully removed edge+reconciled counts — never a half-applied decrement.
286
+ await (db as unknown as BatchableDb).batch([
287
+ db
288
+ .update(actors)
289
+ .set({ followerCount: sql`${actors.followerCount} - 1` })
290
+ .where(
291
+ and(
292
+ inArray(
293
+ actors.apId,
294
+ db
295
+ .select({ id: follows.followingApId })
296
+ .from(follows)
297
+ .where(
298
+ and(
299
+ eq(follows.followerApId, apId),
300
+ eq(follows.status, "accepted"),
301
+ ),
155
302
  ),
156
- ),
303
+ ),
304
+ gt(actors.followerCount, 0),
157
305
  ),
158
- gt(actors.followerCount, 0),
159
306
  ),
160
- );
161
- await db
162
- .update(actors)
163
- .set({ followingCount: sql`${actors.followingCount} - 1` })
164
- .where(
165
- and(
166
- inArray(
167
- actors.apId,
168
- db
169
- .select({ id: follows.followerApId })
170
- .from(follows)
171
- .where(
172
- and(
173
- eq(follows.followingApId, apId),
174
- eq(follows.status, "accepted"),
307
+ db
308
+ .update(actors)
309
+ .set({ followingCount: sql`${actors.followingCount} - 1` })
310
+ .where(
311
+ and(
312
+ inArray(
313
+ actors.apId,
314
+ db
315
+ .select({ id: follows.followerApId })
316
+ .from(follows)
317
+ .where(
318
+ and(
319
+ eq(follows.followingApId, apId),
320
+ eq(follows.status, "accepted"),
321
+ ),
175
322
  ),
176
- ),
323
+ ),
324
+ gt(actors.followingCount, 0),
177
325
  ),
178
- gt(actors.followingCount, 0),
179
326
  ),
180
- );
181
-
182
- await db
183
- .delete(follows)
184
- .where(or(eq(follows.followerApId, apId), eq(follows.followingApId, apId)));
327
+ db
328
+ .delete(follows)
329
+ .where(
330
+ or(eq(follows.followerApId, apId), eq(follows.followingApId, apId)),
331
+ ),
332
+ ]);
185
333
  await db
186
334
  .delete(blocks)
187
335
  .where(or(eq(blocks.blockerApId, apId), eq(blocks.blockedApId, apId)));
@@ -189,57 +337,60 @@ export async function teardownActor(
189
337
  .delete(mutes)
190
338
  .where(or(eq(mutes.muterApId, apId), eq(mutes.mutedApId, apId)));
191
339
 
192
- // Reconcile like/announce/share counters on OTHER actors' objects BEFORE
193
- // deleting this actor's edges (griefable inflated counters otherwise).
194
- await db
195
- .update(objects)
196
- .set({ likeCount: sql`${objects.likeCount} - 1` })
197
- .where(
198
- and(
199
- inArray(
200
- objects.apId,
201
- db
202
- .select({ id: likes.objectApId })
203
- .from(likes)
204
- .where(eq(likes.actorApId, apId)),
340
+ // Reconcile like/announce/share counters on OTHER actors' objects together
341
+ // with deleting this actor's edges. If a delete fails after a counter update,
342
+ // a retry must not see the surviving edge and apply the same decrement again.
343
+ await (db as unknown as BatchableDb).batch([
344
+ db
345
+ .update(objects)
346
+ .set({ likeCount: sql`${objects.likeCount} - 1` })
347
+ .where(
348
+ and(
349
+ inArray(
350
+ objects.apId,
351
+ db
352
+ .select({ id: likes.objectApId })
353
+ .from(likes)
354
+ .where(eq(likes.actorApId, apId)),
355
+ ),
356
+ gt(objects.likeCount, 0),
205
357
  ),
206
- gt(objects.likeCount, 0),
207
358
  ),
208
- );
209
- await db
210
- .update(objects)
211
- .set({ announceCount: sql`${objects.announceCount} - 1` })
212
- .where(
213
- and(
214
- inArray(
215
- objects.apId,
216
- db
217
- .select({ id: announces.objectApId })
218
- .from(announces)
219
- .where(eq(announces.actorApId, apId)),
359
+ db
360
+ .update(objects)
361
+ .set({ announceCount: sql`${objects.announceCount} - 1` })
362
+ .where(
363
+ and(
364
+ inArray(
365
+ objects.apId,
366
+ db
367
+ .select({ id: announces.objectApId })
368
+ .from(announces)
369
+ .where(eq(announces.actorApId, apId)),
370
+ ),
371
+ gt(objects.announceCount, 0),
220
372
  ),
221
- gt(objects.announceCount, 0),
222
373
  ),
223
- );
224
- await db
225
- .update(objects)
226
- .set({ shareCount: sql`${objects.shareCount} - 1` })
227
- .where(
228
- and(
229
- inArray(
230
- objects.apId,
231
- db
232
- .select({ id: storyShares.storyApId })
233
- .from(storyShares)
234
- .where(eq(storyShares.actorApId, apId)),
374
+ db
375
+ .update(objects)
376
+ .set({ shareCount: sql`${objects.shareCount} - 1` })
377
+ .where(
378
+ and(
379
+ inArray(
380
+ objects.apId,
381
+ db
382
+ .select({ id: storyShares.storyApId })
383
+ .from(storyShares)
384
+ .where(eq(storyShares.actorApId, apId)),
385
+ ),
386
+ gt(objects.shareCount, 0),
235
387
  ),
236
- gt(objects.shareCount, 0),
237
388
  ),
238
- );
239
-
240
- await db.delete(likes).where(eq(likes.actorApId, apId));
241
- await db.delete(bookmarks).where(eq(bookmarks.actorApId, apId));
242
- await db.delete(announces).where(eq(announces.actorApId, apId));
389
+ db.delete(likes).where(eq(likes.actorApId, apId)),
390
+ db.delete(bookmarks).where(eq(bookmarks.actorApId, apId)),
391
+ db.delete(announces).where(eq(announces.actorApId, apId)),
392
+ db.delete(storyShares).where(eq(storyShares.actorApId, apId)),
393
+ ]);
243
394
 
244
395
  await db.delete(inbox).where(eq(inbox.actorApId, apId));
245
396
  await db
@@ -257,13 +408,13 @@ export async function teardownActor(
257
408
  .delete(notificationPushJobs)
258
409
  .where(eq(notificationPushJobs.actorApId, apId));
259
410
 
260
- // Media: hard-delete the actor's uploads + best-effort purge backing R2.
411
+ // Media: purge backing R2 first, then remove the durable upload metadata.
412
+ // A storage failure propagates and leaves the row for the retry path.
261
413
  await purgeActorMediaUploads(db, env.MEDIA, apId);
262
414
 
263
415
  // Story interactions the actor performed on OTHER/remote stories.
264
416
  await db.delete(storyVotes).where(eq(storyVotes.actorApId, apId));
265
417
  await db.delete(storyViews).where(eq(storyViews.actorApId, apId));
266
- await db.delete(storyShares).where(eq(storyShares.actorApId, apId));
267
418
 
268
419
  // Per-actor DM status metadata (no FK cascade).
269
420
  await db.delete(dmReadStatus).where(eq(dmReadStatus.actorApId, apId));
@@ -298,7 +449,7 @@ export async function teardownActor(
298
449
  })
299
450
  .from(communityMembers)
300
451
  .where(eq(communityMembers.actorApId, apId));
301
- const communityApIds = memberships.map((m) => m.communityApId);
452
+ const retirementCandidates: string[] = [];
302
453
 
303
454
  // Hand off sole-owned communities to the oldest remaining member.
304
455
  for (const m of memberships) {
@@ -336,41 +487,172 @@ export async function teardownActor(
336
487
  eq(communityMembers.actorApId, heir.actorApId),
337
488
  ),
338
489
  );
490
+ } else {
491
+ retirementCandidates.push(m.communityApId);
339
492
  }
340
493
  }
341
494
 
342
- if (communityApIds.length > 0) {
495
+ // A community with no successor cannot keep a live Group actor after its
496
+ // only owner disappears. Retire it first with an atomic last-member guard.
497
+ // Keeping the membership/follow graph at this stage gives a failed snapshot
498
+ // a durable retry path; once deletedAt is set, every join/write/read route is
499
+ // closed so no new heir can race in between retirement and cleanup.
500
+ const retiredCommunityApIds: string[] = [];
501
+ for (const communityApId of retirementCandidates) {
502
+ const otherMember = db
503
+ .select({ actorApId: communityMembers.actorApId })
504
+ .from(communityMembers)
505
+ .where(
506
+ and(
507
+ eq(communityMembers.communityApId, communityApId),
508
+ ne(communityMembers.actorApId, apId),
509
+ ),
510
+ )
511
+ .limit(1);
512
+ const deletingOwner = db
513
+ .select({ actorApId: communityMembers.actorApId })
514
+ .from(communityMembers)
515
+ .where(
516
+ and(
517
+ eq(communityMembers.communityApId, communityApId),
518
+ eq(communityMembers.actorApId, apId),
519
+ eq(communityMembers.role, "owner"),
520
+ ),
521
+ )
522
+ .limit(1);
343
523
  await db
344
524
  .update(communities)
345
- .set({ memberCount: sql`${communities.memberCount} - 1` })
525
+ .set({ deletedAt: nowIso() })
346
526
  .where(
347
527
  and(
348
- inArray(
349
- communities.apId,
350
- db
351
- .select({ id: communityMembers.communityApId })
352
- .from(communityMembers)
353
- .where(eq(communityMembers.actorApId, apId)),
354
- ),
355
- gt(communities.memberCount, 0),
528
+ eq(communities.apId, communityApId),
529
+ isNull(communities.deletedAt),
530
+ notExists(otherMember),
531
+ exists(deletingOwner),
356
532
  ),
357
533
  );
534
+
535
+ const retired = await db
536
+ .select({
537
+ deletedAt: communities.deletedAt,
538
+ followersUrl: communities.followersUrl,
539
+ })
540
+ .from(communities)
541
+ .where(eq(communities.apId, communityApId))
542
+ .get();
543
+ if (!retired?.deletedAt) continue;
544
+
545
+ try {
546
+ await ensureDeleteFollowerSnapshot(
547
+ db,
548
+ env,
549
+ baseUrl,
550
+ communityApId,
551
+ retired.followersUrl,
552
+ );
553
+ } catch (err) {
554
+ log.error("Failed to persist community Delete follower snapshot", {
555
+ event: "actors.account.community_delete_snapshot_failed",
556
+ actor: apId,
557
+ community: communityApId,
558
+ error: err,
559
+ });
560
+ throw err;
561
+ }
562
+ retiredCommunityApIds.push(communityApId);
563
+ }
564
+
565
+ if (memberships.length > 0) {
566
+ // Keep the denormalized count and its membership authority edge atomic.
567
+ // Otherwise a failed DELETE leaves the edge visible to a retry, which
568
+ // would decrement the same community for a second time.
569
+ const membershipCleanup: BatchStatement[] = [
570
+ db
571
+ .update(communities)
572
+ .set({ memberCount: sql`${communities.memberCount} - 1` })
573
+ .where(
574
+ and(
575
+ inArray(
576
+ communities.apId,
577
+ db
578
+ .select({ id: communityMembers.communityApId })
579
+ .from(communityMembers)
580
+ .where(eq(communityMembers.actorApId, apId)),
581
+ ),
582
+ gt(communities.memberCount, 0),
583
+ ),
584
+ ) as unknown as BatchStatement,
585
+ ];
586
+
587
+ // A retired Group keeps its row + signing keys while the durable Delete
588
+ // drains, but no membership, moderation/read state, or follow authority
589
+ // should remain. Chunk every IN predicate for D1's 100-bind limit and keep
590
+ // all retirement cleanup in the same batch as memberCount + membership.
591
+ // The follows DELETE binds each id twice (follower OR following), so use
592
+ // 45 rather than the normal 90 to stay below D1's 100-bind query ceiling.
593
+ for (const chunk of chunkForInClause(retiredCommunityApIds, 45)) {
594
+ membershipCleanup.push(
595
+ db
596
+ .delete(follows)
597
+ .where(
598
+ or(
599
+ inArray(follows.followerApId, chunk),
600
+ inArray(follows.followingApId, chunk),
601
+ ),
602
+ ) as unknown as BatchStatement,
603
+ db
604
+ .delete(communityJoinRequests)
605
+ .where(
606
+ inArray(communityJoinRequests.communityApId, chunk),
607
+ ) as unknown as BatchStatement,
608
+ db
609
+ .delete(communityInvites)
610
+ .where(
611
+ inArray(communityInvites.communityApId, chunk),
612
+ ) as unknown as BatchStatement,
613
+ db
614
+ .delete(communityBans)
615
+ .where(
616
+ inArray(communityBans.communityApId, chunk),
617
+ ) as unknown as BatchStatement,
618
+ db
619
+ .delete(dmCommunityReadStatus)
620
+ .where(
621
+ inArray(dmCommunityReadStatus.communityApId, chunk),
622
+ ) as unknown as BatchStatement,
623
+ );
624
+ }
625
+ membershipCleanup.push(
626
+ db
627
+ .delete(communityMembers)
628
+ .where(
629
+ eq(communityMembers.actorApId, apId),
630
+ ) as unknown as BatchStatement,
631
+ );
632
+ await (db as unknown as BatchableDb).batch(
633
+ membershipCleanup as [BatchStatement, ...BatchStatement[]],
634
+ );
358
635
  }
359
- await db.delete(communityMembers).where(eq(communityMembers.actorApId, apId));
360
636
 
361
637
  await db
362
638
  .delete(objectRecipients)
363
639
  .where(eq(objectRecipients.recipientApId, apId));
364
- // Preserve the federation Delete activity (the delivery consumer needs its
365
- // rawJson); all other activities by this actor go.
366
- await db
367
- .delete(activities)
368
- .where(
369
- and(
370
- eq(activities.actorApId, apId),
371
- ne(activities.apId, deleteActivityId),
640
+ // Preserve every outbound federation Delete activity (the delivery consumer
641
+ // needs rawJson). Current retries reuse the oldest one, while retaining all
642
+ // covers rows created by older builds without cancelling their delivery
643
+ // projections before they drain.
644
+ await deleteActivitiesCascade(
645
+ db,
646
+ and(
647
+ eq(activities.actorApId, apId),
648
+ not(
649
+ and(
650
+ eq(activities.type, "Delete"),
651
+ eq(activities.direction, "outbound"),
652
+ )!,
372
653
  ),
373
- );
654
+ )!,
655
+ );
374
656
 
375
657
  // Interactions on the actor's authored objects, via subqueries.
376
658
  const authoredObjectIds = () =>
@@ -418,26 +700,10 @@ export async function teardownActor(
418
700
 
419
701
  await db.delete(objects).where(eq(objects.attributedTo, apId));
420
702
 
421
- // Tombstone + scrub the actor row (keep only the delivery signer's key
422
- // material; free the unique handle by renaming it to a sentinel).
423
- await db
424
- .update(actors)
425
- .set({
426
- preferredUsername: `deleted-${generateId()}`,
427
- name: null,
428
- summary: null,
429
- iconUrl: null,
430
- headerUrl: null,
431
- takosUserId: null,
432
- followerCount: 0,
433
- followingCount: 0,
434
- postCount: 0,
435
- fieldsJson: "[]",
436
- alsoKnownAsJson: "[]",
437
- movedTo: null,
438
- ownerActorApId: null,
439
- role: "member",
440
- deletedAt: nowIso(),
441
- })
442
- .where(eq(actors.apId, apId));
703
+ // Tombstone + scrub the actor row only when the caller asks for immediate
704
+ // finalization. The owner route defers this final step until every
705
+ // sub-account has completed, retaining a live retry authority on failure.
706
+ if (options.finalizeActor !== false) {
707
+ await finalizeActorDeletion(db, apId);
708
+ }
443
709
  }