@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
@@ -38,7 +38,6 @@ import {
38
38
  MAX_POST_SUMMARY_LENGTH,
39
39
  } from "./transformers.ts";
40
40
  import {
41
- buildCommunityObjectAddressing,
42
41
  type CreatePostBody,
43
42
  isRecord,
44
43
  type MentionFailure,
@@ -277,20 +276,12 @@ export async function checkCommunityPostPermission(
277
276
  // Reply handling
278
277
  // ---------------------------------------------------------------------------
279
278
 
280
- export const REPLY_TARGET_NOT_FOUND = "REPLY_TARGET_NOT_FOUND";
281
-
282
- // `.batch` lives only on the concrete D1/libsql subclasses, not the Database
283
- // union; reach it through a narrow structural cast (matching the other routes).
284
- type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
285
-
286
279
  /**
287
- * Insert the post object, increment author post count, and handle reply-chain
288
- * updates (parent reply count bump + notification to local parent author).
289
- *
290
- * Throws Error(REPLY_TARGET_NOT_FOUND) if in_reply_to references a missing post.
291
- * Returns the parentAuthor apId (or null if not a reply).
280
+ * Prepare every local statement owned by post creation without executing it.
281
+ * The route composes these statements with the outbound Create Activity and
282
+ * durable fanout intent in one D1 batch.
292
283
  */
293
- export async function insertPostAndHandleReply(
284
+ export function preparePostInsertStatements(
294
285
  db: Database,
295
286
  params: {
296
287
  apId: string;
@@ -301,34 +292,15 @@ export async function insertPostAndHandleReply(
301
292
  inReplyTo: string | null;
302
293
  visibility: string;
303
294
  communityId: string | null;
304
- community: CommunityTarget | null;
295
+ to: string[];
296
+ cc: string[];
297
+ audience: string[];
298
+ tags: PostTag[];
299
+ parentAuthor: string | null;
305
300
  baseUrl: string;
306
301
  now: string;
307
302
  },
308
- ): Promise<string | null> {
309
- let parentAuthor: string | null = null;
310
-
311
- // Community-scoped posts are ADDRESSED to the community Group actor: the
312
- // community (and its followers collection) goes into to/audience. A non-"[]"
313
- // audienceJson is exactly what excludes the post from the public/home feed,
314
- // so reach is the community — not the open public timeline.
315
- const addressing = buildCommunityObjectAddressing(
316
- params.visibility,
317
- params.community,
318
- );
319
-
320
- // Look up + validate the reply parent BEFORE the write batch — we need its
321
- // author both as the replyCount target and for the reply notification.
322
- if (params.inReplyTo) {
323
- const parentPost = await db
324
- .select({ attributedTo: objects.attributedTo })
325
- .from(objects)
326
- .where(eq(objects.apId, params.inReplyTo))
327
- .get();
328
- if (!parentPost) throw new Error(REPLY_TARGET_NOT_FOUND);
329
- parentAuthor = parentPost.attributedTo;
330
- }
331
-
303
+ ): [D1Statement, ...D1Statement[]] {
332
304
  // Co-commit the object insert + author postCount++ + parent replyCount recompute
333
305
  // in ONE batch (mirrors the federated handleCreate): a crash between separate
334
306
  // autocommits would otherwise leave the object inserted with an un-bumped
@@ -346,16 +318,17 @@ export async function insertPostAndHandleReply(
346
318
  inReplyTo: params.inReplyTo,
347
319
  visibility: params.visibility,
348
320
  communityApId: params.communityId,
349
- toJson: JSON.stringify(addressing.to),
350
- ccJson: JSON.stringify(addressing.cc),
351
- audienceJson: JSON.stringify(addressing.audience),
321
+ toJson: JSON.stringify(params.to),
322
+ ccJson: JSON.stringify(params.cc),
323
+ audienceJson: JSON.stringify(params.audience),
324
+ tagsJson: JSON.stringify(params.tags),
352
325
  published: params.now,
353
326
  isLocal: 1,
354
- });
327
+ }) as D1Statement;
355
328
  const bumpPostCount = db
356
329
  .update(actors)
357
330
  .set({ postCount: sql`${actors.postCount} + 1` })
358
- .where(and(eq(actors.apId, params.actorApId), objectAbsent));
331
+ .where(and(eq(actors.apId, params.actorApId), objectAbsent)) as D1Statement;
359
332
 
360
333
  // Direct (DM) posts do NOT count toward postCount: the dedicated DM send path
361
334
  // (createDmNote) never bumps it, and the generic DELETE skips the decrement for
@@ -364,56 +337,51 @@ export async function insertPostAndHandleReply(
364
337
  // postCount over-counts permanently.
365
338
  const countStmts = params.visibility === "direct" ? [] : [bumpPostCount];
366
339
 
340
+ const statements: D1Statement[] = [...countStmts, insertObject];
367
341
  if (params.inReplyTo) {
368
342
  const parentId = params.inReplyTo;
369
- await (db as unknown as Batchable).batch([
370
- ...countStmts,
371
- insertObject,
343
+ statements.push(
372
344
  db
373
345
  .update(objects)
374
346
  .set({
375
347
  replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
376
348
  })
377
- .where(eq(objects.apId, parentId)),
378
- ] as Parameters<Batchable["batch"]>[0]);
379
- } else {
380
- await (db as unknown as Batchable).batch([
381
- ...countStmts,
382
- insertObject,
383
- ] as Parameters<Batchable["batch"]>[0]);
349
+ .where(eq(objects.apId, parentId)) as D1Statement,
350
+ );
384
351
  }
385
352
 
386
- if (params.inReplyTo && parentAuthor) {
353
+ if (params.inReplyTo && params.parentAuthor) {
387
354
  if (
388
- parentAuthor !== params.actorApId &&
389
- isLocal(parentAuthor, params.baseUrl)
355
+ params.parentAuthor !== params.actorApId &&
356
+ isLocal(params.parentAuthor, params.baseUrl)
390
357
  ) {
391
358
  const replyActivityId = activityApId(params.baseUrl, generateId());
392
- await db.insert(activities).values({
393
- apId: replyActivityId,
394
- type: "Create",
395
- actorApId: params.actorApId,
396
- objectApId: params.apId,
397
- rawJson: JSON.stringify({
398
- "@context": "https://www.w3.org/ns/activitystreams",
399
- id: replyActivityId,
359
+ statements.push(
360
+ db.insert(activities).values({
361
+ apId: replyActivityId,
400
362
  type: "Create",
401
- actor: params.actorApId,
402
- object: params.apId,
403
- }),
404
- createdAt: params.now,
405
- });
406
-
407
- await db.insert(inboxTable).values({
408
- actorApId: parentAuthor,
409
- activityApId: replyActivityId,
410
- read: 0,
411
- createdAt: params.now,
412
- });
363
+ actorApId: params.actorApId,
364
+ objectApId: params.apId,
365
+ rawJson: JSON.stringify({
366
+ "@context": "https://www.w3.org/ns/activitystreams",
367
+ id: replyActivityId,
368
+ type: "Create",
369
+ actor: params.actorApId,
370
+ object: params.apId,
371
+ }),
372
+ createdAt: params.now,
373
+ }) as D1Statement,
374
+ db.insert(inboxTable).values({
375
+ actorApId: params.parentAuthor,
376
+ activityApId: replyActivityId,
377
+ read: 0,
378
+ createdAt: params.now,
379
+ }) as D1Statement,
380
+ );
413
381
  }
414
382
  }
415
383
 
416
- return parentAuthor;
384
+ return statements as [D1Statement, ...D1Statement[]];
417
385
  }
418
386
 
419
387
  // ---------------------------------------------------------------------------
@@ -497,15 +465,12 @@ async function resolveMentionActorRows(
497
465
  return { localActors, cachedActors };
498
466
  }
499
467
 
500
- export async function processMentions(
468
+ export async function resolvePostMentions(
501
469
  db: Database,
502
470
  params: {
503
471
  content: string;
504
- postApId: string;
505
472
  actorApId: string;
506
- parentAuthor: string | null;
507
473
  baseUrl: string;
508
- now: string;
509
474
  },
510
475
  ): Promise<ProcessMentionsResult> {
511
476
  const mentions = extractMentions(params.content);
@@ -527,38 +492,14 @@ export async function processMentions(
527
492
  });
528
493
  }
529
494
 
530
- // Persist the computed tag array onto the object row so the served object at
531
- // `GET /ap/objects/:id` emits the same `tag` the Create carries. The object
532
- // was already inserted by `insertPostAndHandleReply`, so this is an UPDATE.
533
- // Only write when there is at least one tag (the column defaults to "[]").
534
- const persistTags = async () => {
535
- if (tags.length === 0) return;
536
- try {
537
- await db
538
- .update(objects)
539
- .set({ tagsJson: JSON.stringify(tags) })
540
- .where(eq(objects.apId, params.postApId));
541
- } catch (e) {
542
- log.error("Failed to persist object tags", {
543
- event: "posts.mention.tags_persist_failed",
544
- postApId: params.postApId,
545
- error: e,
546
- });
547
- }
548
- };
549
-
550
- const emptyResult: ProcessMentionsResult = {
495
+ const result: ProcessMentionsResult = {
551
496
  failures: mentionFailures,
552
497
  tags,
553
498
  mentionedActorApIds,
554
499
  remoteMentionedActorApIds,
555
500
  };
556
501
 
557
- // No mentions to resolve still persist any Hashtag tags before returning.
558
- if (mentions.length === 0) {
559
- await persistTags();
560
- return emptyResult;
561
- }
502
+ if (mentions.length === 0) return result;
562
503
 
563
504
  const localMentions = mentions.filter((m) => !m.includes("@"));
564
505
  const remoteMentions = mentions.filter((m) => m.includes("@"));
@@ -584,21 +525,6 @@ export async function processMentions(
584
525
  }
585
526
  }
586
527
 
587
- const activitiesToCreate: Array<{
588
- apId: string;
589
- type: string;
590
- actorApId: string;
591
- objectApId: string;
592
- rawJson: string;
593
- createdAt: string;
594
- }> = [];
595
- const inboxEntriesToCreate: Array<{
596
- actorApId: string;
597
- activityApId: string;
598
- read: number;
599
- createdAt: string;
600
- }> = [];
601
-
602
528
  for (const mention of mentions) {
603
529
  try {
604
530
  const mentionedActorApId = mention.includes("@")
@@ -625,15 +551,51 @@ export async function processMentions(
625
551
  mentionedActorApIds.push(mentionedActorApId);
626
552
  if (remote) remoteMentionedActorApIds.push(mentionedActorApId);
627
553
  }
554
+ } catch (e) {
555
+ log.error("Failed to process mention", {
556
+ event: "posts.mention.processing_failed",
557
+ mention,
558
+ error: e,
559
+ });
560
+ mentionFailures.push({
561
+ mention,
562
+ stage: "resolve",
563
+ reason: "mention_processing_failed",
564
+ });
565
+ }
566
+ }
628
567
 
629
- // Local notification fan-in only. The parent author is already notified
630
- // by the reply path, and remote actors are reached by federated delivery
631
- // (no local inbox row), so skip both here.
632
- if (params.parentAuthor === mentionedActorApId) continue;
633
- if (remote) continue;
568
+ return result;
569
+ }
634
570
 
635
- const mentionActivityId = activityApId(params.baseUrl, generateId());
636
- activitiesToCreate.push({
571
+ /**
572
+ * Persist the local-notification projections for an already resolved post.
573
+ * The canonical Note, tags, Activity, and fanout intent are committed by the
574
+ * caller first; notification failures are reported without invalidating that
575
+ * durable federation mutation.
576
+ */
577
+ export async function persistPostMentionProjections(
578
+ db: Database,
579
+ params: {
580
+ result: ProcessMentionsResult;
581
+ postApId: string;
582
+ actorApId: string;
583
+ parentAuthor: string | null;
584
+ baseUrl: string;
585
+ now: string;
586
+ },
587
+ ): Promise<MentionFailure[]> {
588
+ const failures: MentionFailure[] = [];
589
+ const remoteActors = new Set(params.result.remoteMentionedActorApIds);
590
+ const localNotificationTargets = params.result.mentionedActorApIds.filter(
591
+ (actorApId) =>
592
+ actorApId !== params.parentAuthor && !remoteActors.has(actorApId),
593
+ );
594
+
595
+ const activitiesToCreate = localNotificationTargets.map((actorApId) => {
596
+ const mentionActivityId = activityApId(params.baseUrl, generateId());
597
+ return {
598
+ activity: {
637
599
  apId: mentionActivityId,
638
600
  type: "Create",
639
601
  actorApId: params.actorApId,
@@ -646,27 +608,15 @@ export async function processMentions(
646
608
  object: params.postApId,
647
609
  }),
648
610
  createdAt: params.now,
649
- });
650
-
651
- inboxEntriesToCreate.push({
652
- actorApId: mentionedActorApId,
611
+ },
612
+ inbox: {
613
+ actorApId,
653
614
  activityApId: mentionActivityId,
654
615
  read: 0,
655
616
  createdAt: params.now,
656
- });
657
- } catch (e) {
658
- log.error("Failed to process mention", {
659
- event: "posts.mention.processing_failed",
660
- mention,
661
- error: e,
662
- });
663
- mentionFailures.push({
664
- mention,
665
- stage: "resolve",
666
- reason: "mention_processing_failed",
667
- });
668
- }
669
- }
617
+ },
618
+ };
619
+ });
670
620
 
671
621
  if (activitiesToCreate.length > 0) {
672
622
  // A mention notification is one invariant: its activity and inbox edge
@@ -681,17 +631,21 @@ export async function processMentions(
681
631
  offset < activitiesToCreate.length;
682
632
  offset += MENTION_NOTIFICATION_PAGE_SIZE
683
633
  ) {
684
- const activityPage = activitiesToCreate.slice(
685
- offset,
686
- offset + MENTION_NOTIFICATION_PAGE_SIZE,
687
- );
688
- const inboxPage = inboxEntriesToCreate.slice(
634
+ const page = activitiesToCreate.slice(
689
635
  offset,
690
636
  offset + MENTION_NOTIFICATION_PAGE_SIZE,
691
637
  );
692
638
  const statements = [
693
- ...insertMany(db, activities, activityPage),
694
- ...insertMany(db, inboxTable, inboxPage),
639
+ ...insertMany(
640
+ db,
641
+ activities,
642
+ page.map((entry) => entry.activity),
643
+ ),
644
+ ...insertMany(
645
+ db,
646
+ inboxTable,
647
+ page.map((entry) => entry.inbox),
648
+ ),
695
649
  ];
696
650
  await runBatch(db, statements as [D1Statement, ...D1Statement[]]);
697
651
  }
@@ -700,7 +654,7 @@ export async function processMentions(
700
654
  event: "posts.mention.notification_persist_failed",
701
655
  error: e,
702
656
  });
703
- mentionFailures.push(
657
+ failures.push(
704
658
  {
705
659
  mention: "__batch__",
706
660
  stage: "persist_activity",
@@ -715,19 +669,15 @@ export async function processMentions(
715
669
  }
716
670
  }
717
671
 
718
- // Persist Mention + Hashtag tags onto the object row (see persistTags above).
719
- await persistTags();
720
-
721
- return emptyResult;
672
+ return failures;
722
673
  }
723
674
 
724
675
  /**
725
676
  * Derive the AS2 `tag` array (Hashtag + Mention) for a post's content WITHOUT
726
677
  * any notification / activity side effects. The EDIT path uses this so an
727
678
  * edited post's served object + Update(Note) carry the SAME tags a fresh post
728
- * would — re-running the full `processMentions` on every edit would re-notify
729
- * every mention. Mirrors processMentions' tag-building (kept as a separate,
730
- * side-effect-free function so the create/federation path stays untouched).
679
+ * would — re-running notification projection on every edit would re-notify
680
+ * every mention. Mirrors resolvePostMentions' tag-building.
731
681
  */
732
682
  export async function deriveContentTags(
733
683
  db: Database,
@@ -7,7 +7,7 @@
7
7
  * - Addressing logic for ActivityPub delivery
8
8
  */
9
9
 
10
- import type { Database } from "../../../db/index.ts";
10
+ import type { D1Statement, Database } from "../../../db/index.ts";
11
11
  import {
12
12
  activities,
13
13
  actorCache,
@@ -15,8 +15,9 @@ import {
15
15
  communities,
16
16
  likes,
17
17
  objects,
18
+ runBatch,
18
19
  } from "../../../db/index.ts";
19
- import { and, eq, inArray, or } from "drizzle-orm";
20
+ import { and, eq, inArray, isNull, or } from "drizzle-orm";
20
21
  import type { Env } from "../../types.ts";
21
22
  import {
22
23
  activityApId,
@@ -31,6 +32,7 @@ import {
31
32
  } from "../../lib/delivery/queue.ts";
32
33
  import { isRecord, parseJsonObject } from "../../lib/parse-helpers.ts";
33
34
  import { logger } from "../../lib/logger.ts";
35
+ import { prepareDeliveryFanoutJob } from "../../lib/delivery/fanout-outbox.ts";
34
36
 
35
37
  const log = logger.child({ component: "posts.queries" });
36
38
 
@@ -398,32 +400,71 @@ export async function persistActivity(
398
400
  }
399
401
 
400
402
  /** Persist an outbound ActivityPub activity and enqueue federation fanout. */
401
- export async function persistAndFanout(
403
+ export type PreparedActivityFanout = {
404
+ readonly statements: readonly [D1Statement, ...D1Statement[]];
405
+ /** Best-effort Queue wakeup; the durable intent is already in statements. */
406
+ publish(): Promise<void>;
407
+ };
408
+
409
+ /**
410
+ * Prepare an outbound Activity + durable follower-fanout intent without
411
+ * executing either statement. Callers that also mutate local state can place
412
+ * these statements in the SAME D1 batch and avoid a local-only partial commit.
413
+ */
414
+ export async function preparePersistAndFanout(
402
415
  db: Database,
403
416
  env: Env,
404
417
  activity: { id: string; type: string; actor: string; [key: string]: unknown },
405
418
  objectApIdValue: string,
406
- ): Promise<void> {
407
- await db.insert(activities).values({
408
- apId: activity.id,
409
- type: activity.type,
410
- actorApId: activity.actor,
411
- objectApId: objectApIdValue,
412
- rawJson: JSON.stringify(activity),
413
- direction: "outbound",
419
+ ): Promise<PreparedActivityFanout> {
420
+ const preparedFanout = await prepareDeliveryFanoutJob(db, {
421
+ kind: "followers",
422
+ activityId: activity.id,
423
+ targetApId: activity.actor,
414
424
  });
425
+ return {
426
+ statements: [
427
+ db.insert(activities).values({
428
+ apId: activity.id,
429
+ type: activity.type,
430
+ actorApId: activity.actor,
431
+ objectApId: objectApIdValue,
432
+ rawJson: JSON.stringify(activity),
433
+ direction: "outbound",
434
+ }) as D1Statement,
435
+ preparedFanout.statement,
436
+ ],
437
+ async publish() {
438
+ try {
439
+ await enqueueFanoutToFollowers(env, activity.id, activity.actor);
440
+ } catch (err) {
441
+ log.error("Failed to enqueue federation fanout", {
442
+ event: "posts.fanout.enqueue_failed",
443
+ activityType: activity.type,
444
+ activityId: activity.id,
445
+ actor: activity.actor,
446
+ error: err,
447
+ });
448
+ }
449
+ },
450
+ };
451
+ }
415
452
 
416
- try {
417
- await enqueueFanoutToFollowers(env, activity.id, activity.actor);
418
- } catch (err) {
419
- log.error("Failed to enqueue federation fanout", {
420
- event: "posts.fanout.enqueue_failed",
421
- activityType: activity.type,
422
- activityId: activity.id,
423
- actor: activity.actor,
424
- error: err,
425
- });
426
- }
453
+ /** Persist an outbound ActivityPub activity and enqueue federation fanout. */
454
+ export async function persistAndFanout(
455
+ db: Database,
456
+ env: Env,
457
+ activity: { id: string; type: string; actor: string; [key: string]: unknown },
458
+ objectApIdValue: string,
459
+ ): Promise<void> {
460
+ const prepared = await preparePersistAndFanout(
461
+ db,
462
+ env,
463
+ activity,
464
+ objectApIdValue,
465
+ );
466
+ await runBatch(db, prepared.statements);
467
+ await prepared.publish();
427
468
  }
428
469
 
429
470
  /**
@@ -431,22 +472,13 @@ export async function persistAndFanout(
431
472
  * (members + community followers) instead of the author's personal followers.
432
473
  * Used for community-scoped posts so reach == community.
433
474
  */
434
- export async function persistAndFanoutToCommunity(
475
+ export async function preparePersistAndFanoutToCommunity(
435
476
  db: Database,
436
477
  env: Env,
437
478
  activity: { id: string; type: string; actor: string; [key: string]: unknown },
438
479
  objectApIdValue: string,
439
480
  communityApId: string,
440
- ): Promise<void> {
441
- await db.insert(activities).values({
442
- apId: activity.id,
443
- type: activity.type,
444
- actorApId: activity.actor,
445
- objectApId: objectApIdValue,
446
- rawJson: JSON.stringify(activity),
447
- direction: "outbound",
448
- });
449
-
481
+ ): Promise<PreparedActivityFanout> {
450
482
  // Announce-relay: for a new post (Create), the GROUP re-broadcasts it to its
451
483
  // followers as its OWN Announce (Lemmy/Mobilizon convention) so the post is
452
484
  // attributed to the community and reaches remote followers whose server
@@ -467,12 +499,35 @@ export async function persistAndFanoutToCommunity(
467
499
  const row = await db
468
500
  .select({ visibility: communities.visibility })
469
501
  .from(communities)
470
- .where(eq(communities.apId, communityApId))
502
+ .where(
503
+ and(eq(communities.apId, communityApId), isNull(communities.deletedAt)),
504
+ )
471
505
  .get();
472
- isPublicCommunity = row?.visibility === "public";
506
+ if (!row) {
507
+ log.warn("Skipped fanout for a retired community", {
508
+ event: "posts.fanout.community_retired",
509
+ activityId: activity.id,
510
+ communityApId,
511
+ });
512
+ return {
513
+ statements: [
514
+ db.insert(activities).values({
515
+ apId: activity.id,
516
+ type: activity.type,
517
+ actorApId: activity.actor,
518
+ objectApId: objectApIdValue,
519
+ rawJson: JSON.stringify(activity),
520
+ direction: "outbound",
521
+ }) as D1Statement,
522
+ ],
523
+ async publish() {},
524
+ };
525
+ }
526
+ isPublicCommunity = row.visibility === "public";
473
527
  }
474
528
 
475
529
  let announceActivityId: string | undefined;
530
+ let announceStatement: D1Statement | undefined;
476
531
  if (activity.type === "Create" && isPublicCommunity) {
477
532
  const baseUrl = env.APP_URL;
478
533
  announceActivityId = activityApId(baseUrl, generateId());
@@ -486,33 +541,74 @@ export async function persistAndFanoutToCommunity(
486
541
  cc: [PUBLIC_URL],
487
542
  published: new Date().toISOString(),
488
543
  };
489
- await db.insert(activities).values({
544
+ announceStatement = db.insert(activities).values({
490
545
  apId: announceActivityId,
491
546
  type: "Announce",
492
547
  actorApId: communityApId,
493
548
  objectApId: objectApIdValue,
494
549
  rawJson: JSON.stringify(announce),
495
550
  direction: "outbound",
496
- });
551
+ }) as D1Statement;
497
552
  }
498
553
 
499
- try {
500
- await enqueueFanoutToCommunity(
501
- env,
502
- activity.id,
503
- communityApId,
504
- announceActivityId,
505
- );
506
- } catch (err) {
507
- log.error("Failed to enqueue community federation fanout", {
508
- event: "posts.fanout.community_enqueue_failed",
509
- activityType: activity.type,
510
- activityId: activity.id,
511
- actor: activity.actor,
512
- communityApId,
513
- error: err,
514
- });
515
- }
554
+ const preparedFanout = await prepareDeliveryFanoutJob(db, {
555
+ kind: "community",
556
+ activityId: activity.id,
557
+ targetApId: communityApId,
558
+ ...(announceActivityId ? { announceActivityId } : {}),
559
+ });
560
+ const statements: D1Statement[] = [
561
+ db.insert(activities).values({
562
+ apId: activity.id,
563
+ type: activity.type,
564
+ actorApId: activity.actor,
565
+ objectApId: objectApIdValue,
566
+ rawJson: JSON.stringify(activity),
567
+ direction: "outbound",
568
+ }) as D1Statement,
569
+ ];
570
+ if (announceStatement) statements.push(announceStatement);
571
+ statements.push(preparedFanout.statement);
572
+ return {
573
+ statements: statements as [D1Statement, ...D1Statement[]],
574
+ async publish() {
575
+ try {
576
+ await enqueueFanoutToCommunity(
577
+ env,
578
+ activity.id,
579
+ communityApId,
580
+ announceActivityId,
581
+ );
582
+ } catch (err) {
583
+ log.error("Failed to enqueue community federation fanout", {
584
+ event: "posts.fanout.community_enqueue_failed",
585
+ activityType: activity.type,
586
+ activityId: activity.id,
587
+ actor: activity.actor,
588
+ communityApId,
589
+ error: err,
590
+ });
591
+ }
592
+ },
593
+ };
594
+ }
595
+
596
+ export async function persistAndFanoutToCommunity(
597
+ db: Database,
598
+ env: Env,
599
+ activity: { id: string; type: string; actor: string; [key: string]: unknown },
600
+ objectApIdValue: string,
601
+ communityApId: string,
602
+ ): Promise<void> {
603
+ const prepared = await preparePersistAndFanoutToCommunity(
604
+ db,
605
+ env,
606
+ activity,
607
+ objectApIdValue,
608
+ communityApId,
609
+ );
610
+ await runBatch(db, prepared.statements);
611
+ await prepared.publish();
516
612
  }
517
613
 
518
614
  /** Batch-load cached authors for posts without a local author join. */