@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,8 +19,8 @@
19
19
  * the remote `handleDelete` inbox path so neither can orphan rows.
20
20
  */
21
21
 
22
- import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
23
- import type { Database } from "../../../db/index.ts";
22
+ import { and, asc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
23
+ import type { D1Statement, Database } from "../../../db/index.ts";
24
24
  import type { IObjectStorage } from "../../runtime/types.ts";
25
25
  import {
26
26
  activities,
@@ -28,15 +28,24 @@ import {
28
28
  announces,
29
29
  bookmarks,
30
30
  communities,
31
- inbox as inboxTable,
31
+ D1_MAX_BATCH_STATEMENTS,
32
32
  likes,
33
33
  mediaUploads,
34
34
  objectRecipients,
35
35
  objects,
36
+ runBatch,
36
37
  storyShares,
37
38
  storyViews,
38
39
  storyVotes,
39
40
  } from "../../../db/index.ts";
41
+ import { activityProjectionDeleteStatements } from "../../lib/activity-delete-cascade.ts";
42
+ import { chunkForInClause, D1_IN_CHUNK } from "../../lib/chunk.ts";
43
+
44
+ type CascadeObject = {
45
+ apId: string;
46
+ attributedTo: string;
47
+ attachmentsJson: string;
48
+ };
40
49
 
41
50
  /**
42
51
  * Reap the `media_uploads` rows attached to a single object.
@@ -51,8 +60,8 @@ import {
51
60
  * `attachmentMatches` semantics. There is no engine-level CASCADE for this edge,
52
61
  * so without this the upload rows orphan on every runtime.
53
62
  *
54
- * Returns silently when the object row is already gone (caller may delete the
55
- * object before or after calling this) or has no attachments.
63
+ * The caller supplies a still-present object snapshot; this returns silently
64
+ * when that snapshot has no attachments.
56
65
  *
57
66
  * When a `media` object-store binding is provided, the backing R2 blobs for the
58
67
  * reaped uploads are best-effort deleted by `r2_key` (mirroring the
@@ -67,22 +76,19 @@ import {
67
76
  * dropping to zero. The DB-row delete is unconditional (the reaped rows belong
68
77
  * to this object's reap set regardless).
69
78
  */
70
- async function deleteAttachedMediaUploads(
79
+ async function deleteAttachedMediaUploadsForObject(
71
80
  db: Database,
72
- objectApId: string,
81
+ obj: CascadeObject,
82
+ removedObjectApIds: ReadonlySet<string>,
73
83
  media?: IObjectStorage,
74
- ): Promise<string[]> {
75
- const obj = await db
76
- .select({
77
- attributedTo: objects.attributedTo,
78
- attachmentsJson: objects.attachmentsJson,
79
- })
80
- .from(objects)
81
- .where(eq(objects.apId, objectApId))
82
- .get();
83
-
84
- // No object row (already deleted) or no attachment payload: nothing to reap.
85
- if (!obj || !obj.attachmentsJson || obj.attachmentsJson === "[]") return [];
84
+ ): Promise<{
85
+ mediaKeys: string[];
86
+ mediaUploadIds: string[];
87
+ }> {
88
+ // No attachment payload: nothing to reap.
89
+ if (!obj.attachmentsJson || obj.attachmentsJson === "[]") {
90
+ return { mediaKeys: [], mediaUploadIds: [] };
91
+ }
86
92
 
87
93
  const attachmentsJson = obj.attachmentsJson;
88
94
 
@@ -110,42 +116,47 @@ async function deleteAttachedMediaUploads(
110
116
  attachmentsJson.includes(mediaUrlForKey(m.r2Key)),
111
117
  );
112
118
 
113
- if (orphaned.length === 0) return [];
119
+ if (orphaned.length === 0) {
120
+ return { mediaKeys: [], mediaUploadIds: [] };
121
+ }
114
122
 
115
- // Before any R2 purge, find which of these `r2_key`s are still referenced by
116
- // ANOTHER (different `ap_id`), still-present object of the same author. Such
117
- // a key must NOT have its blob deleted another object still shows it. We
118
- // run this read while the rows are still present so an OTHER object whose
119
- // `attachments_json` happens to point back at this object's reap set is
120
- // honoured; the lookup is scoped to the author's own objects (indexed) and
121
- // excludes the object being reaped.
123
+ // Before any R2 purge, find which keys are still referenced by an object of
124
+ // the same author OUTSIDE the complete set being removed. This matters for a
125
+ // batch containing two posts that share one blob: checking only "another
126
+ // object" would make each target keep the other target's key, then leak the
127
+ // row and blob after both objects disappear.
122
128
  const stillReferencedKeys = new Set<string>();
123
129
  if (media) {
124
- // For each orphaned key, ask SQL (indexed by attributedTo) whether ANOTHER
125
- // present object of this author still references it instead of loading
126
- // EVERY other object's attachmentsJson into memory and substring-scanning
127
- // them all (O(objects × keys), unbounded for a prolific author). `orphaned`
128
- // is only this object's own attachments (a handful), so this is a small,
129
- // bounded set of indexed lookups. Uses instr() (literal substring), NOT
130
- // `LIKE '%<key>%'`: a 73-char `uploads/<64-hex>.png` key in a `%...%` pattern
131
- // exceeds D1's LIKE pattern-complexity limit (SQLITE_ERROR 7500, ~50 chars).
132
- // Mirrors media.ts findReferencingObject.
130
+ // Page only matching AP-IDs and stop at the first survivor. A NOT IN list
131
+ // cannot safely carry an unbounded removal set through D1's 100-parameter
132
+ // ceiling; keyset pages keep every query constant-sized without loading a
133
+ // prolific author's complete object history. instr() is literal and avoids
134
+ // D1's long-LIKE complexity failure.
133
135
  for (const m of orphaned) {
134
- const ref = await db
135
- .select({ apId: objects.apId })
136
- .from(objects)
137
- .where(
138
- and(
139
- eq(objects.attributedTo, obj.attributedTo),
140
- ne(objects.apId, objectApId),
141
- or(
142
- sql`instr(${objects.attachmentsJson}, ${m.r2Key}) > 0`,
143
- sql`instr(${objects.attachmentsJson}, ${mediaUrlForKey(m.r2Key)}) > 0`,
144
- ),
136
+ let cursor: string | undefined;
137
+ while (true) {
138
+ const referenceMatch = and(
139
+ eq(objects.attributedTo, obj.attributedTo),
140
+ or(
141
+ sql`instr(${objects.attachmentsJson}, ${m.r2Key}) > 0`,
142
+ sql`instr(${objects.attachmentsJson}, ${mediaUrlForKey(m.r2Key)}) > 0`,
145
143
  ),
146
- )
147
- .get();
148
- if (ref) stillReferencedKeys.add(m.r2Key);
144
+ cursor ? gt(objects.apId, cursor) : undefined,
145
+ );
146
+ const refs = await db
147
+ .select({ apId: objects.apId })
148
+ .from(objects)
149
+ .where(referenceMatch)
150
+ .orderBy(asc(objects.apId))
151
+ .limit(D1_IN_CHUNK);
152
+ if (refs.some((ref) => !removedObjectApIds.has(ref.apId))) {
153
+ stillReferencedKeys.add(m.r2Key);
154
+ break;
155
+ }
156
+ if (refs.length < D1_IN_CHUNK) break;
157
+ cursor = refs.at(-1)?.apId;
158
+ if (!cursor) break;
159
+ }
149
160
  }
150
161
  }
151
162
 
@@ -158,10 +169,6 @@ async function deleteAttachedMediaUploads(
158
169
  const idsToDelete = media
159
170
  ? orphaned.filter((m) => !stillReferencedKeys.has(m.r2Key)).map((m) => m.id)
160
171
  : orphaned.map((m) => m.id);
161
- if (idsToDelete.length > 0) {
162
- await db.delete(mediaUploads).where(inArray(mediaUploads.id, idsToDelete));
163
- }
164
-
165
172
  // Return the keys whose reference count has now dropped to zero. The caller
166
173
  // purges them via purgeMediaBlobs AFTER it deletes the objects row, so the
167
174
  // IRREVERSIBLE R2 delete is the trailing step: if the objects-row delete fails
@@ -169,9 +176,12 @@ async function deleteAttachedMediaUploads(
169
176
  // surviving with a permanently-deleted blob (a broken image with no recovery).
170
177
  // Keys still embedded in another present object's `attachments_json` are kept
171
178
  // (blob + media_uploads row) so shared media isn't lost.
172
- return media
173
- ? orphaned.map((m) => m.r2Key).filter((k) => !stillReferencedKeys.has(k))
174
- : [];
179
+ return {
180
+ mediaKeys: media
181
+ ? orphaned.map((m) => m.r2Key).filter((k) => !stillReferencedKeys.has(k))
182
+ : [],
183
+ mediaUploadIds: idsToDelete,
184
+ };
175
185
  }
176
186
 
177
187
  /**
@@ -286,45 +296,199 @@ export async function reapReplacedMediaUrl(
286
296
  * a `media` binding is passed, the backing R2 blobs are best-effort deleted too
287
297
  * so storage does not leak; pass `c.env.MEDIA` from the request context.
288
298
  *
289
- * Does not touch the `objects` row or `activities` (SET NULL, not CASCADE).
299
+ * Does not touch the `objects` row or `activities` (SET NULL, not CASCADE),
300
+ * but cancels every durable notification/delivery projection of Activities
301
+ * that target the object.
290
302
  */
291
303
  export async function deleteObjectCascade(
292
304
  db: Database,
293
305
  objectApId: string,
294
306
  media?: IObjectStorage,
295
307
  ): Promise<string[]> {
296
- // Reap the media_uploads rows + child rows while the object row (and its
297
- // attachments_json) is still readable. Returns the R2 keys whose blobs are now
298
- // unreferenced — the caller MUST purge them via purgeMediaBlobs AFTER it has
299
- // deleted the objects row, so the irreversible R2 delete is the trailing step.
300
- const mediaKeys = await deleteAttachedMediaUploads(db, objectApId, media);
301
- await db.delete(likes).where(eq(likes.objectApId, objectApId));
302
- await db.delete(announces).where(eq(announces.objectApId, objectApId));
303
- await db.delete(bookmarks).where(eq(bookmarks.objectApId, objectApId));
304
- await db
305
- .delete(objectRecipients)
306
- .where(eq(objectRecipients.objectApId, objectApId));
307
- await db.delete(storyViews).where(eq(storyViews.storyApId, objectApId));
308
- await db.delete(storyVotes).where(eq(storyVotes.storyApId, objectApId));
309
- await db.delete(storyShares).where(eq(storyShares.storyApId, objectApId));
308
+ return await deleteObjectsCascade(db, [objectApId], media);
309
+ }
310
+
311
+ /**
312
+ * Prepare one local object's complete child/projection cleanup without writing
313
+ * it. Local delete owners compose this with counters, the outbound Delete,
314
+ * durable fanout intent, and final object removal in one D1 batch.
315
+ */
316
+ export async function prepareObjectDeleteCascade(
317
+ db: Database,
318
+ objectApId: string,
319
+ media?: IObjectStorage,
320
+ ): Promise<{
321
+ mediaKeys: string[];
322
+ statements: readonly [D1Statement, ...D1Statement[]];
323
+ }> {
324
+ const obj = await db
325
+ .select({
326
+ apId: objects.apId,
327
+ attributedTo: objects.attributedTo,
328
+ attachmentsJson: objects.attachmentsJson,
329
+ })
330
+ .from(objects)
331
+ .where(eq(objects.apId, objectApId))
332
+ .get();
333
+
334
+ const mediaPlan = obj
335
+ ? await deleteAttachedMediaUploadsForObject(
336
+ db,
337
+ obj,
338
+ new Set([objectApId]),
339
+ media,
340
+ )
341
+ : { mediaKeys: [], mediaUploadIds: [] };
310
342
 
311
- // Reap NOTIFICATION inbox rows that pointed at this object (a Like / Announce /
312
- // reply-Create that notified a local user). After the object row is gone the
313
- // notifications query's leftJoin(objects) yields NULL, so these would otherwise
314
- // render as dangling notifications (blank content, dead link) AND keep inflating
315
- // the unread badge (the inbox row stays read=0). Delete the INBOX rows only, via
316
- // a subquery (D1-param-safe) — NOT the `activities` rows, which may include the
317
- // outbound federation Delete that must survive to be delivered.
318
- await db
319
- .delete(inboxTable)
320
- .where(
321
- inArray(
322
- inboxTable.activityApId,
323
- db
324
- .select({ id: activities.apId })
325
- .from(activities)
326
- .where(eq(activities.objectApId, objectApId)),
343
+ const mediaDeleteStatements = chunkForInClause(mediaPlan.mediaUploadIds).map(
344
+ (ids) =>
345
+ db
346
+ .delete(mediaUploads)
347
+ .where(inArray(mediaUploads.id, ids)) as D1Statement,
348
+ );
349
+
350
+ return {
351
+ mediaKeys: [...new Set(mediaPlan.mediaKeys)],
352
+ statements: [
353
+ ...mediaDeleteStatements,
354
+ db.delete(likes).where(eq(likes.objectApId, objectApId)) as D1Statement,
355
+ db
356
+ .delete(announces)
357
+ .where(eq(announces.objectApId, objectApId)) as D1Statement,
358
+ db
359
+ .delete(bookmarks)
360
+ .where(eq(bookmarks.objectApId, objectApId)) as D1Statement,
361
+ db
362
+ .delete(objectRecipients)
363
+ .where(eq(objectRecipients.objectApId, objectApId)) as D1Statement,
364
+ db
365
+ .delete(storyViews)
366
+ .where(eq(storyViews.storyApId, objectApId)) as D1Statement,
367
+ db
368
+ .delete(storyVotes)
369
+ .where(eq(storyVotes.storyApId, objectApId)) as D1Statement,
370
+ db
371
+ .delete(storyShares)
372
+ .where(eq(storyShares.storyApId, objectApId)) as D1Statement,
373
+ ...activityProjectionDeleteStatements(
374
+ db,
375
+ eq(activities.objectApId, objectApId),
327
376
  ),
377
+ ] as [D1Statement, ...D1Statement[]],
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Reap every child row for a set of objects without deleting the object rows.
383
+ * This is the set-shaped counterpart to {@link deleteObjectCascade}; callers
384
+ * still own the final object delete and trailing {@link purgeMediaBlobs}.
385
+ *
386
+ * The old bulk callers invoked the singular helper once per object. On D1 that
387
+ * meant one attachment read plus eight serial delete round-trips per post: five
388
+ * empty posts took about ten seconds in a real workerd probe and larger domain
389
+ * purges could outlive the request. Here each <=90-id D1-safe chunk issues one
390
+ * atomic twelve-statement batch, so latency scales by chunks rather than posts.
391
+ */
392
+ export async function deleteObjectsCascade(
393
+ db: Database,
394
+ objectApIds: string[],
395
+ media?: IObjectStorage,
396
+ ): Promise<string[]> {
397
+ const uniqueApIds = [...new Set(objectApIds)];
398
+ if (uniqueApIds.length === 0) return [];
399
+
400
+ const cascadeObjects: CascadeObject[] = [];
401
+ for (const chunk of chunkForInClause(uniqueApIds)) {
402
+ cascadeObjects.push(
403
+ ...(await db
404
+ .select({
405
+ apId: objects.apId,
406
+ attributedTo: objects.attributedTo,
407
+ attachmentsJson: objects.attachmentsJson,
408
+ })
409
+ .from(objects)
410
+ .where(inArray(objects.apId, chunk))),
328
411
  );
329
- return mediaKeys;
412
+ }
413
+ if (cascadeObjects.length === 0) return [];
414
+ const existingApIds = cascadeObjects.map((obj) => obj.apId);
415
+ const removedObjectApIds = new Set(existingApIds);
416
+
417
+ // Remote attachments are normally ordinary remote URLs with no local
418
+ // media_uploads rows. Discover the small set of authors that actually own a
419
+ // managed upload before entering the per-object media GC, otherwise every
420
+ // image post would reintroduce one serial indexed read during defederation.
421
+ const objectsWithAttachments = cascadeObjects.filter(
422
+ (obj) => obj.attachmentsJson && obj.attachmentsJson !== "[]",
423
+ );
424
+ const authorsWithUploads = new Set<string>();
425
+ if (objectsWithAttachments.length > 0) {
426
+ const authors = [
427
+ ...new Set(objectsWithAttachments.map((obj) => obj.attributedTo)),
428
+ ];
429
+ for (const chunk of chunkForInClause(authors)) {
430
+ const rows = await db
431
+ .selectDistinct({ uploaderApId: mediaUploads.uploaderApId })
432
+ .from(mediaUploads)
433
+ .where(inArray(mediaUploads.uploaderApId, chunk));
434
+ for (const row of rows) authorsWithUploads.add(row.uploaderApId);
435
+ }
436
+ }
437
+
438
+ const mediaKeys = new Set<string>();
439
+ const mediaUploadIds = new Set<string>();
440
+ for (const obj of objectsWithAttachments) {
441
+ if (!authorsWithUploads.has(obj.attributedTo)) continue;
442
+ const plan = await deleteAttachedMediaUploadsForObject(
443
+ db,
444
+ obj,
445
+ removedObjectApIds,
446
+ media,
447
+ );
448
+ for (const key of plan.mediaKeys) mediaKeys.add(key);
449
+ for (const id of plan.mediaUploadIds) mediaUploadIds.add(id);
450
+ }
451
+ const mediaStatements = chunkForInClause([...mediaUploadIds]).map(
452
+ (ids) =>
453
+ db
454
+ .delete(mediaUploads)
455
+ .where(inArray(mediaUploads.id, ids)) as D1Statement,
456
+ );
457
+ for (
458
+ let offset = 0;
459
+ offset < mediaStatements.length;
460
+ offset += D1_MAX_BATCH_STATEMENTS
461
+ ) {
462
+ const page = mediaStatements.slice(
463
+ offset,
464
+ offset + D1_MAX_BATCH_STATEMENTS,
465
+ );
466
+ await runBatch(db, page as [D1Statement, ...D1Statement[]]);
467
+ }
468
+
469
+ for (const chunk of chunkForInClause(existingApIds)) {
470
+ // Cancel every notification/delivery projection of retained Activities
471
+ // that target these objects. A queued Create/Update must not be delivered
472
+ // after its object has disappeared, and an in-flight/terminal push job or
473
+ // claim must not retain that deleted payload. Keep the Activity ledger rows
474
+ // themselves; callers may need history/Undo, and the new outbound Delete
475
+ // Activity is created only after this cascade so it remains unaffected.
476
+ await runBatch(db, [
477
+ db.delete(likes).where(inArray(likes.objectApId, chunk)),
478
+ db.delete(announces).where(inArray(announces.objectApId, chunk)),
479
+ db.delete(bookmarks).where(inArray(bookmarks.objectApId, chunk)),
480
+ db
481
+ .delete(objectRecipients)
482
+ .where(inArray(objectRecipients.objectApId, chunk)),
483
+ db.delete(storyViews).where(inArray(storyViews.storyApId, chunk)),
484
+ db.delete(storyVotes).where(inArray(storyVotes.storyApId, chunk)),
485
+ db.delete(storyShares).where(inArray(storyShares.storyApId, chunk)),
486
+ ...activityProjectionDeleteStatements(
487
+ db,
488
+ inArray(activities.objectApId, chunk),
489
+ ),
490
+ ]);
491
+ }
492
+
493
+ return [...mediaKeys];
330
494
  }