@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.
- package/migrations/0023_delivery_resolution_outbox.sql +33 -0
- package/migrations/0024_delivery_fanout_outbox.sql +35 -0
- package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
- package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
- package/migrations/0027_remote_actor_tombstones.sql +12 -0
- package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
- package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/communities.ts +2 -0
- package/packages/api/src/lib/api/fetch.ts +69 -13
- package/packages/api/src/lib/api/normalize.ts +32 -10
- package/packages/api/src/lib/api/notifications.ts +4 -1
- package/packages/api/src/lib/rtc-client.ts +127 -29
- package/src/backend/federation-helpers.ts +1 -0
- package/src/backend/index.ts +16 -3
- package/src/backend/lib/account-migration.ts +340 -0
- package/src/backend/lib/activity-delete-cascade.ts +193 -0
- package/src/backend/lib/activitypub-actor-cache.ts +615 -48
- package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
- package/src/backend/lib/activitypub-actor-identity.ts +39 -0
- package/src/backend/lib/activitypub-validators.ts +62 -4
- package/src/backend/lib/ap-ids.ts +36 -6
- package/src/backend/lib/ap-verify.ts +7 -17
- package/src/backend/lib/blocklist-purge.ts +676 -42
- package/src/backend/lib/blocklist.ts +278 -39
- package/src/backend/lib/community-visibility.ts +47 -33
- package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
- package/src/backend/lib/delivery/planner.ts +16 -12
- package/src/backend/lib/delivery/queue-batching.ts +389 -145
- package/src/backend/lib/delivery/queue-delivery.ts +47 -25
- package/src/backend/lib/delivery/queue.ts +479 -73
- package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
- package/src/backend/lib/delivery/types.ts +31 -7
- package/src/backend/lib/feed-exclude.ts +40 -28
- package/src/backend/lib/follow-edge-mutations.ts +217 -0
- package/src/backend/lib/notification-eligibility.ts +15 -9
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oidc-id-token.ts +66 -0
- package/src/backend/lib/personal-actor-moderation.ts +239 -0
- package/src/backend/lib/post-visibility.ts +79 -16
- package/src/backend/lib/remote-activity-id.ts +61 -0
- package/src/backend/lib/unread-counts.ts +3 -0
- package/src/backend/retention.ts +38 -1
- package/src/backend/routes/account-teardown.ts +422 -156
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
- package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
- package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
- package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
- package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
- package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
- package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
- package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
- package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
- package/src/backend/routes/activitypub/inbox-types.ts +14 -2
- package/src/backend/routes/activitypub/inbox.ts +81 -105
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub.ts +6 -5
- package/src/backend/routes/actors-helpers.ts +34 -8
- package/src/backend/routes/actors.ts +328 -164
- package/src/backend/routes/auth-helpers.ts +57 -9
- package/src/backend/routes/auth.ts +10 -2
- package/src/backend/routes/communities/membership-invites.ts +4 -1
- package/src/backend/routes/communities/membership-members.ts +201 -73
- package/src/backend/routes/communities/membership-requests.ts +141 -76
- package/src/backend/routes/communities/membership-shared.ts +228 -21
- package/src/backend/routes/communities/messages.ts +9 -2
- package/src/backend/routes/communities/routes.ts +48 -13
- package/src/backend/routes/dm/contacts.ts +29 -41
- package/src/backend/routes/dm/conversations-helpers.ts +9 -1
- package/src/backend/routes/dm/messages.ts +36 -42
- package/src/backend/routes/dm/read-archive.ts +4 -2
- package/src/backend/routes/dm/requests.ts +62 -54
- package/src/backend/routes/follow-helpers.ts +200 -60
- package/src/backend/routes/follow.ts +146 -140
- package/src/backend/routes/media.ts +20 -94
- package/src/backend/routes/moderation.ts +72 -4
- package/src/backend/routes/notes.ts +3 -4
- package/src/backend/routes/notifications.ts +209 -108
- package/src/backend/routes/posts/delete-cascade.ts +253 -89
- package/src/backend/routes/posts/federation.ts +373 -0
- package/src/backend/routes/posts/interactions.ts +160 -184
- package/src/backend/routes/posts/like-mutation.ts +240 -0
- package/src/backend/routes/posts/post-helpers.ts +112 -162
- package/src/backend/routes/posts/queries.ts +150 -54
- package/src/backend/routes/posts/routes.ts +169 -329
- package/src/backend/routes/posts/transformers.ts +61 -6
- package/src/backend/routes/recommendations.ts +37 -44
- package/src/backend/routes/rtc/index.ts +26 -6
- package/src/backend/routes/search.ts +39 -55
- package/src/backend/routes/stories/interactions.ts +22 -6
- package/src/backend/routes/stories/query-helpers.ts +28 -41
- package/src/backend/routes/stories/routes.ts +125 -108
- package/src/backend/routes/takos-tools/dm.ts +17 -17
- package/src/backend/routes/takos-tools/posts.ts +189 -106
- package/src/backend/routes/takos-tools/search.ts +13 -4
- package/src/backend/routes/takos-tools/timeline.ts +35 -27
- package/src/backend/routes/takos-tools-response.ts +6 -2
- package/src/backend/routes/timeline.ts +5 -5
- package/src/backend/runtime/call-signaling-do.ts +35 -4
- package/src/backend/runtime/one-time-ticket.ts +116 -0
- package/src/backend/runtime/realtime-stream-do.ts +5 -61
- package/src/backend/runtime/signaling-hub.ts +36 -3
- package/src/backend/server.ts +95 -80
- package/src/db/d1-write.ts +67 -43
- package/src/db/schema/federation.ts +42 -1
- package/src/db/schema/messaging.ts +110 -2
- 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
|
-
|
|
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
|
|
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,
|
|
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 `
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
}
|