@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
@@ -0,0 +1,456 @@
1
+ import { and, asc, eq, inArray, lte, or, sql } from "drizzle-orm";
2
+
3
+ import {
4
+ affectedRowCount,
5
+ deliveryResolutions,
6
+ insertMany,
7
+ runBatch,
8
+ type D1Statement,
9
+ type Database,
10
+ } from "../../../db/index.ts";
11
+ import type { Env } from "../../types.ts";
12
+ import type { IQueueProducer } from "../../runtime/queue.ts";
13
+ import type { DeliveryQueueMessageV1 } from "./types.ts";
14
+ import { sha256Hex } from "./transformers.ts";
15
+
16
+ const OUTBOX_SCAN_LIMIT = 50;
17
+ const STALE_RESOLUTION_MS = 2 * 60 * 1000;
18
+ const TERMINAL_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
19
+ // Keep the follow-up DELETE (ids + statuses + cutoff) under D1's 100-bind cap.
20
+ const TERMINAL_PURGE_LIMIT = 50;
21
+ export const MAX_RESOLVE_ATTEMPTS = 8;
22
+
23
+ const TERMINAL_STATUSES = ["resolved", "failed", "discarded"] as const;
24
+
25
+ export type DeliveryResolutionIntent = {
26
+ readonly activityId: string;
27
+ readonly recipientActorApId: string;
28
+ };
29
+
30
+ export type DeliveryResolutionClaim =
31
+ | { readonly state: "missing" }
32
+ | { readonly state: "terminal" }
33
+ | { readonly state: "deferred"; readonly delaySeconds: number }
34
+ | { readonly state: "busy"; readonly delaySeconds: number }
35
+ | {
36
+ readonly state: "claimed";
37
+ readonly id: string;
38
+ readonly processingToken: string;
39
+ readonly attempts: number;
40
+ };
41
+
42
+ export async function computeDeliveryResolutionId(
43
+ activityId: string,
44
+ recipientActorApId: string,
45
+ ): Promise<string> {
46
+ return sha256Hex(`resolve_actor|${activityId}|${recipientActorApId}`);
47
+ }
48
+
49
+ export function buildResolveActorMessage(
50
+ activityId: string,
51
+ recipientActorApId: string,
52
+ attempts = 0,
53
+ ): DeliveryQueueMessageV1 {
54
+ return {
55
+ version: 1,
56
+ type: "resolve_actor",
57
+ activityId,
58
+ recipientActorApId,
59
+ ...(attempts > 0 ? { attempts } : {}),
60
+ scheduledAt: new Date().toISOString(),
61
+ };
62
+ }
63
+
64
+ async function materializeIntents(
65
+ intents: readonly DeliveryResolutionIntent[],
66
+ ) {
67
+ const unique = new Map<string, DeliveryResolutionIntent>();
68
+ for (const intent of intents) {
69
+ unique.set(`${intent.activityId}\0${intent.recipientActorApId}`, intent);
70
+ }
71
+ return await Promise.all(
72
+ [...unique.values()].map(async (intent) => ({
73
+ id: await computeDeliveryResolutionId(
74
+ intent.activityId,
75
+ intent.recipientActorApId,
76
+ ),
77
+ ...intent,
78
+ })),
79
+ );
80
+ }
81
+
82
+ export type PreparedDeliveryResolutionJobs = {
83
+ readonly rows: ReadonlyArray<
84
+ DeliveryResolutionIntent & { readonly id: string }
85
+ >;
86
+ readonly statements: readonly D1Statement[];
87
+ };
88
+
89
+ /** Build durable first-hop intent inserts without executing them. */
90
+ export async function prepareDeliveryResolutionJobs(
91
+ db: Database,
92
+ intents: readonly DeliveryResolutionIntent[],
93
+ ): Promise<PreparedDeliveryResolutionJobs> {
94
+ const rows = await materializeIntents(intents);
95
+ if (rows.length === 0) return { rows, statements: [] };
96
+ const now = new Date().toISOString();
97
+ const statements = insertMany(
98
+ db,
99
+ deliveryResolutions,
100
+ rows.map((row) => ({
101
+ id: row.id,
102
+ activityApId: row.activityId,
103
+ recipientActorApId: row.recipientActorApId,
104
+ status: "pending",
105
+ attempts: 0,
106
+ nextAttemptAt: now,
107
+ createdAt: now,
108
+ updatedAt: now,
109
+ })),
110
+ { conflict: "ignore" },
111
+ );
112
+ return { rows, statements };
113
+ }
114
+
115
+ /** Persist intent before attempting any Queue RPC. */
116
+ export async function persistDeliveryResolutionJobs(
117
+ db: Database,
118
+ intents: readonly DeliveryResolutionIntent[],
119
+ ): Promise<ReadonlyArray<DeliveryResolutionIntent & { readonly id: string }>> {
120
+ const prepared = await prepareDeliveryResolutionJobs(db, intents);
121
+ if (prepared.statements.length > 0) {
122
+ await runBatch(
123
+ db,
124
+ prepared.statements as [
125
+ (typeof prepared.statements)[number],
126
+ ...(typeof prepared.statements)[number][],
127
+ ],
128
+ );
129
+ }
130
+ return prepared.rows;
131
+ }
132
+
133
+ async function sendRows(
134
+ db: Database,
135
+ queue: IQueueProducer<DeliveryQueueMessageV1>,
136
+ rows: readonly {
137
+ readonly id: string;
138
+ readonly activityApId: string;
139
+ readonly recipientActorApId: string;
140
+ readonly attempts: number;
141
+ }[],
142
+ now: string,
143
+ ): Promise<number> {
144
+ if (rows.length === 0) return 0;
145
+ await queue.sendBatch(
146
+ rows.map((row) => ({
147
+ body: buildResolveActorMessage(
148
+ row.activityApId,
149
+ row.recipientActorApId,
150
+ row.attempts,
151
+ ),
152
+ })),
153
+ );
154
+ await db
155
+ .update(deliveryResolutions)
156
+ .set({ status: "queued", processingToken: null, updatedAt: now })
157
+ .where(
158
+ and(
159
+ inArray(
160
+ deliveryResolutions.id,
161
+ rows.map((row) => row.id),
162
+ ),
163
+ inArray(deliveryResolutions.status, ["pending", "retry_wait"]),
164
+ ),
165
+ );
166
+ return rows.length;
167
+ }
168
+
169
+ /** Persist a bounded recipient set, then publish only currently-due rows. */
170
+ export async function enqueueDeliveryResolutionJobs(
171
+ db: Database,
172
+ queue: IQueueProducer<DeliveryQueueMessageV1> | undefined,
173
+ intents: readonly DeliveryResolutionIntent[],
174
+ options: { readonly reopenTerminal?: boolean } = {},
175
+ ): Promise<number> {
176
+ const materialized = await persistDeliveryResolutionJobs(db, intents);
177
+ if (!queue || materialized.length === 0) return 0;
178
+ const now = new Date().toISOString();
179
+ let sent = 0;
180
+ for (
181
+ let offset = 0;
182
+ offset < materialized.length;
183
+ offset += OUTBOX_SCAN_LIMIT
184
+ ) {
185
+ const ids = materialized
186
+ .slice(offset, offset + OUTBOX_SCAN_LIMIT)
187
+ .map((row) => row.id);
188
+ if (options.reopenTerminal) {
189
+ // A 404/410 endpoint invalidation is a new resolution generation for the
190
+ // same (Activity, actor) pair. The deterministic row may already be
191
+ // terminal from the original successful resolution, so reactivate it
192
+ // before publishing the replacement wakeup.
193
+ await db
194
+ .update(deliveryResolutions)
195
+ .set({
196
+ status: "pending",
197
+ processingToken: null,
198
+ attempts: 0,
199
+ nextAttemptAt: now,
200
+ lastError: null,
201
+ updatedAt: now,
202
+ resolvedAt: null,
203
+ })
204
+ .where(
205
+ and(
206
+ inArray(deliveryResolutions.id, ids),
207
+ inArray(deliveryResolutions.status, [...TERMINAL_STATUSES]),
208
+ ),
209
+ );
210
+ }
211
+ const due = await db
212
+ .select({
213
+ id: deliveryResolutions.id,
214
+ activityApId: deliveryResolutions.activityApId,
215
+ recipientActorApId: deliveryResolutions.recipientActorApId,
216
+ attempts: deliveryResolutions.attempts,
217
+ })
218
+ .from(deliveryResolutions)
219
+ .where(
220
+ and(
221
+ inArray(deliveryResolutions.id, ids),
222
+ inArray(deliveryResolutions.status, ["pending", "retry_wait"]),
223
+ lte(deliveryResolutions.nextAttemptAt, now),
224
+ ),
225
+ );
226
+ sent += await sendRows(db, queue, due, now);
227
+ }
228
+ return sent;
229
+ }
230
+
231
+ /** Recover due or stale first-hop rows. Safe after requests, queue batches, and cron. */
232
+ export async function enqueuePendingDeliveryResolutionJobs(
233
+ env: Env,
234
+ nowDate = new Date(),
235
+ ): Promise<number> {
236
+ const db = env.DB_INSTANCE;
237
+ const now = nowDate.toISOString();
238
+ await purgeTerminalDeliveryResolutionJobs(db, nowDate);
239
+ if (!env.DELIVERY_QUEUE || !env.DELIVERY_DLQ) return 0;
240
+
241
+ const staleBefore = new Date(
242
+ nowDate.getTime() - STALE_RESOLUTION_MS,
243
+ ).toISOString();
244
+ await db
245
+ .update(deliveryResolutions)
246
+ .set({ status: "pending", processingToken: null, updatedAt: now })
247
+ .where(
248
+ and(
249
+ inArray(deliveryResolutions.status, ["queued", "processing"]),
250
+ lte(deliveryResolutions.updatedAt, staleBefore),
251
+ ),
252
+ );
253
+
254
+ const rows = await db
255
+ .select({
256
+ id: deliveryResolutions.id,
257
+ activityApId: deliveryResolutions.activityApId,
258
+ recipientActorApId: deliveryResolutions.recipientActorApId,
259
+ attempts: deliveryResolutions.attempts,
260
+ })
261
+ .from(deliveryResolutions)
262
+ .where(
263
+ and(
264
+ inArray(deliveryResolutions.status, ["pending", "retry_wait"]),
265
+ lte(deliveryResolutions.nextAttemptAt, now),
266
+ ),
267
+ )
268
+ .orderBy(asc(deliveryResolutions.createdAt))
269
+ .limit(OUTBOX_SCAN_LIMIT);
270
+ return await sendRows(db, env.DELIVERY_QUEUE, rows, now);
271
+ }
272
+
273
+ export async function claimDeliveryResolutionJob(
274
+ db: Database,
275
+ activityId: string,
276
+ recipientActorApId: string,
277
+ nowDate = new Date(),
278
+ ): Promise<DeliveryResolutionClaim> {
279
+ const id = await computeDeliveryResolutionId(activityId, recipientActorApId);
280
+ const row = await db
281
+ .select({
282
+ status: deliveryResolutions.status,
283
+ attempts: deliveryResolutions.attempts,
284
+ nextAttemptAt: deliveryResolutions.nextAttemptAt,
285
+ updatedAt: deliveryResolutions.updatedAt,
286
+ })
287
+ .from(deliveryResolutions)
288
+ .where(eq(deliveryResolutions.id, id))
289
+ .get();
290
+ if (!row) return { state: "missing" };
291
+ if ((TERMINAL_STATUSES as readonly string[]).includes(row.status)) {
292
+ return { state: "terminal" };
293
+ }
294
+
295
+ const nowMs = nowDate.getTime();
296
+ const nextMs = Date.parse(row.nextAttemptAt);
297
+ if (
298
+ row.status === "retry_wait" &&
299
+ Number.isFinite(nextMs) &&
300
+ nextMs > nowMs
301
+ ) {
302
+ return {
303
+ state: "deferred",
304
+ delaySeconds: Math.max(1, Math.ceil((nextMs - nowMs) / 1000)),
305
+ };
306
+ }
307
+
308
+ const now = nowDate.toISOString();
309
+ const staleBefore = new Date(nowMs - STALE_RESOLUTION_MS).toISOString();
310
+ const processingToken = crypto.randomUUID();
311
+ const claim = await db
312
+ .update(deliveryResolutions)
313
+ .set({ status: "processing", processingToken, updatedAt: now })
314
+ .where(
315
+ and(
316
+ eq(deliveryResolutions.id, id),
317
+ or(
318
+ inArray(deliveryResolutions.status, ["pending", "queued"]),
319
+ and(
320
+ eq(deliveryResolutions.status, "retry_wait"),
321
+ lte(deliveryResolutions.nextAttemptAt, now),
322
+ ),
323
+ and(
324
+ eq(deliveryResolutions.status, "processing"),
325
+ lte(deliveryResolutions.updatedAt, staleBefore),
326
+ ),
327
+ ),
328
+ ),
329
+ );
330
+ if (affectedRowCount(claim) === 0) {
331
+ return { state: "busy", delaySeconds: 60 };
332
+ }
333
+ return { state: "claimed", id, processingToken, attempts: row.attempts };
334
+ }
335
+
336
+ export async function retryDeliveryResolutionJob(
337
+ db: Database,
338
+ claim: Extract<DeliveryResolutionClaim, { state: "claimed" }>,
339
+ error: unknown,
340
+ delaySeconds = 60,
341
+ ): Promise<{ readonly owned: boolean; readonly terminal: boolean }> {
342
+ const attempts = claim.attempts + 1;
343
+ const terminal = attempts >= MAX_RESOLVE_ATTEMPTS;
344
+ const now = new Date();
345
+ const result = await db
346
+ .update(deliveryResolutions)
347
+ .set({
348
+ status: terminal ? "failed" : "retry_wait",
349
+ processingToken: null,
350
+ attempts,
351
+ nextAttemptAt: new Date(
352
+ now.getTime() + delaySeconds * 1000,
353
+ ).toISOString(),
354
+ lastError: error instanceof Error ? error.message : String(error),
355
+ updatedAt: now.toISOString(),
356
+ resolvedAt: terminal ? now.toISOString() : null,
357
+ })
358
+ .where(
359
+ and(
360
+ eq(deliveryResolutions.id, claim.id),
361
+ eq(deliveryResolutions.processingToken, claim.processingToken),
362
+ ),
363
+ );
364
+ return { owned: affectedRowCount(result) > 0, terminal };
365
+ }
366
+
367
+ /**
368
+ * Release a claim after a local authority dependency fails without charging
369
+ * the remote actor's bounded resolution-attempt budget. The durable row stays
370
+ * retryable, while the processing token prevents an expired worker from
371
+ * overwriting a newer claim.
372
+ */
373
+ export async function deferDeliveryResolutionJob(
374
+ db: Database,
375
+ claim: Extract<DeliveryResolutionClaim, { state: "claimed" }>,
376
+ error: unknown,
377
+ delaySeconds = 60,
378
+ ): Promise<boolean> {
379
+ const now = new Date();
380
+ const result = await db
381
+ .update(deliveryResolutions)
382
+ .set({
383
+ status: "retry_wait",
384
+ processingToken: null,
385
+ nextAttemptAt: new Date(
386
+ now.getTime() + delaySeconds * 1000,
387
+ ).toISOString(),
388
+ lastError: error instanceof Error ? error.message : String(error),
389
+ updatedAt: now.toISOString(),
390
+ resolvedAt: null,
391
+ })
392
+ .where(
393
+ and(
394
+ eq(deliveryResolutions.id, claim.id),
395
+ eq(deliveryResolutions.processingToken, claim.processingToken),
396
+ ),
397
+ );
398
+ return affectedRowCount(result) > 0;
399
+ }
400
+
401
+ export async function completeDeliveryResolutionJob(
402
+ db: Database,
403
+ claim: Extract<DeliveryResolutionClaim, { state: "claimed" }>,
404
+ status: "resolved" | "discarded",
405
+ reason?: string,
406
+ ): Promise<boolean> {
407
+ const now = new Date().toISOString();
408
+ const result = await db
409
+ .update(deliveryResolutions)
410
+ .set({
411
+ status,
412
+ processingToken: null,
413
+ lastError: reason ?? null,
414
+ updatedAt: now,
415
+ resolvedAt: now,
416
+ })
417
+ .where(
418
+ and(
419
+ eq(deliveryResolutions.id, claim.id),
420
+ eq(deliveryResolutions.processingToken, claim.processingToken),
421
+ ),
422
+ );
423
+ return affectedRowCount(result) > 0;
424
+ }
425
+
426
+ export async function purgeTerminalDeliveryResolutionJobs(
427
+ db: Database,
428
+ nowDate = new Date(),
429
+ ): Promise<number> {
430
+ const cutoff = new Date(
431
+ nowDate.getTime() - TERMINAL_RETENTION_MS,
432
+ ).toISOString();
433
+ const rows = await db
434
+ .select({ id: deliveryResolutions.id })
435
+ .from(deliveryResolutions)
436
+ .where(
437
+ and(
438
+ inArray(deliveryResolutions.status, [...TERMINAL_STATUSES]),
439
+ lte(deliveryResolutions.updatedAt, cutoff),
440
+ ),
441
+ )
442
+ .orderBy(asc(deliveryResolutions.updatedAt))
443
+ .limit(TERMINAL_PURGE_LIMIT);
444
+ if (rows.length === 0) return 0;
445
+ const result = await db.delete(deliveryResolutions).where(
446
+ and(
447
+ inArray(
448
+ deliveryResolutions.id,
449
+ rows.map((row) => row.id),
450
+ ),
451
+ inArray(deliveryResolutions.status, [...TERMINAL_STATUSES]),
452
+ lte(deliveryResolutions.updatedAt, cutoff),
453
+ ),
454
+ );
455
+ return affectedRowCount(result);
456
+ }
@@ -1,6 +1,16 @@
1
1
  export const DELIVERY_QUEUE_MESSAGE_VERSION = 1 as const;
2
2
 
3
- export type DeliveryFanoutFollowersMessageV1 = {
3
+ /**
4
+ * Bounded generation for a MAIN-queue message that exhausted Cloudflare's
5
+ * retries, reached the DLQ as its raw body, and was sent back to the MAIN
6
+ * queue. Optional keeps existing v1 producers and in-flight messages wire-
7
+ * compatible; absent means no prior redrive.
8
+ */
9
+ export type DeliveryAutoDlqStateV1 = {
10
+ autoDlqAttempt?: number;
11
+ };
12
+
13
+ export type DeliveryFanoutFollowersMessageV1 = DeliveryAutoDlqStateV1 & {
4
14
  version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
5
15
  type: "fanout_followers";
6
16
  activityId: string;
@@ -15,7 +25,7 @@ export type DeliveryFanoutFollowersMessageV1 = {
15
25
  * followers) rather than the author's personal follower graph. Used for
16
26
  * community-scoped posts so reach == community, not author-followers.
17
27
  */
18
- export type DeliveryFanoutCommunityMessageV1 = {
28
+ export type DeliveryFanoutCommunityMessageV1 = DeliveryAutoDlqStateV1 & {
19
29
  version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
20
30
  type: "fanout_community";
21
31
  activityId: string;
@@ -33,7 +43,7 @@ export type DeliveryFanoutCommunityMessageV1 = {
33
43
  scheduledAt: string; // ISO8601 UTC
34
44
  };
35
45
 
36
- export type DeliveryResolveActorMessageV1 = {
46
+ export type DeliveryResolveActorMessageV1 = DeliveryAutoDlqStateV1 & {
37
47
  version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
38
48
  type: "resolve_actor";
39
49
  activityId: string;
@@ -46,7 +56,7 @@ export type DeliveryResolveActorMessageV1 = {
46
56
  scheduledAt: string; // ISO8601 UTC
47
57
  };
48
58
 
49
- export type DeliveryDeliverEndpointMessageV1 = {
59
+ export type DeliveryDeliverEndpointMessageV1 = DeliveryAutoDlqStateV1 & {
50
60
  version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
51
61
  type: "deliver_endpoint";
52
62
  jobId: string;
@@ -58,7 +68,7 @@ export type DeliveryDeliverEndpointMessageV1 = {
58
68
  scheduledAt: string; // ISO8601 UTC
59
69
  };
60
70
 
61
- export type DeliveryReconcileJobMessageV1 = {
71
+ export type DeliveryReconcileJobMessageV1 = DeliveryAutoDlqStateV1 & {
62
72
  version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
63
73
  type: "reconcile_job";
64
74
  jobId: string;
@@ -70,7 +80,7 @@ export type DeliveryReconcileJobMessageV1 = {
70
80
  * Product notification delivery. The queue carries only the durable outbox id;
71
81
  * device pushkeys, gateway URLs, and notification content remain in the DB.
72
82
  */
73
- export type DeliveryNotificationPushMessageV1 = {
83
+ export type DeliveryNotificationPushMessageV1 = DeliveryAutoDlqStateV1 & {
74
84
  version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
75
85
  type: "notification_push";
76
86
  jobId: string;
@@ -106,6 +116,7 @@ export function isDeliveryQueueMessageV1(
106
116
  const v = value as Record<string, unknown>;
107
117
  if (v.version !== DELIVERY_QUEUE_MESSAGE_VERSION) return false;
108
118
  if (typeof v.type !== "string") return false;
119
+ if (!isOptionalNonNegativeInteger(v.autoDlqAttempt)) return false;
109
120
 
110
121
  switch (v.type) {
111
122
  case "fanout_followers":
@@ -130,10 +141,15 @@ export function isDeliveryQueueMessageV1(
130
141
  return (
131
142
  typeof v.activityId === "string" &&
132
143
  typeof v.recipientActorApId === "string" &&
144
+ isOptionalNonNegativeInteger(v.attempts) &&
133
145
  typeof v.scheduledAt === "string"
134
146
  );
135
147
  case "deliver_endpoint":
136
- return typeof v.jobId === "string" && typeof v.scheduledAt === "string";
148
+ return (
149
+ typeof v.jobId === "string" &&
150
+ isOptionalNonNegativeInteger(v.reconcileAttempt) &&
151
+ typeof v.scheduledAt === "string"
152
+ );
137
153
  case "reconcile_job":
138
154
  return (
139
155
  typeof v.jobId === "string" &&
@@ -147,6 +163,13 @@ export function isDeliveryQueueMessageV1(
147
163
  }
148
164
  }
149
165
 
166
+ function isOptionalNonNegativeInteger(value: unknown): boolean {
167
+ return (
168
+ value === undefined ||
169
+ (typeof value === "number" && Number.isInteger(value) && value >= 0)
170
+ );
171
+ }
172
+
150
173
  export function isDeliveryDlqMessageV1(
151
174
  value: unknown,
152
175
  ): value is DeliveryDlqMessageV1 {
@@ -160,6 +183,7 @@ export function isDeliveryDlqMessageV1(
160
183
  typeof v.endpoint === "string" &&
161
184
  typeof v.attempts === "number" &&
162
185
  (v.lastError === null || typeof v.lastError === "string") &&
186
+ isOptionalNonNegativeInteger(v.reconcileAttempt) &&
163
187
  typeof v.deadLetteredAt === "string"
164
188
  );
165
189
  }
@@ -1,48 +1,60 @@
1
- import { and, type AnyColumn, eq, notInArray, type SQL } from "drizzle-orm";
2
- import type { Database } from "../../db/index.ts";
1
+ import { and, type AnyColumn, sql, type SQL } from "drizzle-orm";
3
2
  import { blocks, mutes, objects } from "../../db/index.ts";
3
+ import { activityPubActorIdentitySetSql } from "./activitypub-actor-identity-sql.ts";
4
+ import { operatorActorNotBlockedSql } from "./blocklist.ts";
4
5
 
5
6
  /**
6
7
  * Predicate excluding posts authored by anyone the viewer has blocked or muted.
7
8
  *
8
- * Expressed as `objects.attributed_to NOT IN (SELECT blocked) AND ... NOT IN
9
- * (SELECT muted)` — i.e. `NOT IN (blocked muted)` using db.select
10
- * SUBQUERIES rather than materialising the id lists into an `inArray`. Each
11
- * subquery binds a single bound parameter (the viewer) regardless of how many
12
- * accounts the viewer has blocked/muted, so it never approaches Cloudflare D1's
13
- * 100-bound-parameter-per-query ceiling. (The previous `notInArray(attributedTo,
14
- * [...blocked, ...muted])` materialised up to ~2000 ids — fine on the libsql the
15
- * tests run on, but a "too many SQL variables" 500 on production D1 for a heavy
16
- * moderator.)
9
+ * Expressed as one uncorrelated `NOT IN` subquery over the union of raw +
10
+ * canonical block/mute identities. It binds only the viewer twice regardless
11
+ * of relation count, so it stays under Cloudflare D1's 100-parameter ceiling.
12
+ * The set CTE runs once per query rather than parsing actor URLs per feed row.
17
13
  *
18
14
  * Returns `undefined` for an empty viewer (anonymous) so callers can skip it.
19
15
  * Applying it unconditionally for a logged-in viewer is correct even with zero
20
- * blocks/mutes: `NOT IN (empty set)` is true for every row.
16
+ * blocks/mutes: `NOT IN (empty set)` is true.
21
17
  *
22
18
  * `column` defaults to the post author (`objects.attributedTo`) for feeds, but
23
19
  * can be any actor-id column — e.g. `activities.actorApId` so the notifications
24
20
  * list/count can suppress activity from blocked/muted accounts the same way.
25
21
  */
26
22
  export function excludeBlockedMutedAuthors(
27
- db: Database,
28
23
  viewerApId: string,
29
24
  column: AnyColumn = objects.attributedTo,
30
25
  ): SQL | undefined {
31
26
  if (!viewerApId) return undefined;
32
- return and(
33
- notInArray(
34
- column,
35
- db
36
- .select({ id: blocks.blockedApId })
37
- .from(blocks)
38
- .where(eq(blocks.blockerApId, viewerApId)),
39
- ),
40
- notInArray(
41
- column,
42
- db
43
- .select({ id: mutes.mutedApId })
44
- .from(mutes)
45
- .where(eq(mutes.muterApId, viewerApId)),
46
- ),
27
+ const excludedIds = activityPubActorIdentitySetSql(
28
+ sql`
29
+ SELECT ${blocks.blockedApId}
30
+ FROM ${blocks}
31
+ WHERE ${blocks.blockerApId} = ${viewerApId}
32
+ UNION ALL
33
+ SELECT ${mutes.mutedApId}
34
+ FROM ${mutes}
35
+ WHERE ${mutes.muterApId} = ${viewerApId}
36
+ `,
47
37
  );
38
+ return sql`${column} NOT IN (${excludedIds})`;
39
+ }
40
+
41
+ /**
42
+ * Global operator moderation plus the current viewer's personal block/mute
43
+ * filter for a projected actor identity. Unlike the personal-only helper this
44
+ * always returns a predicate: anonymous/global feeds must still hide retained
45
+ * content from an instance-defederated actor or domain.
46
+ *
47
+ * Apply this in SQL before cursor/offset/limit. Post-query filtering lets a run
48
+ * of blocked rows consume the page and can make later visible content
49
+ * unreachable or report incorrect pagination metadata.
50
+ */
51
+ export function excludeModeratedActors(
52
+ viewerApId: string,
53
+ column: AnyColumn = objects.attributedTo,
54
+ ): SQL {
55
+ const operatorVisible = operatorActorNotBlockedSql(sql`${column}`);
56
+ const personalVisible = excludeBlockedMutedAuthors(viewerApId, column);
57
+ return personalVisible
58
+ ? and(operatorVisible, personalVisible)!
59
+ : operatorVisible;
48
60
  }