@takosjp/yurucommu-core 3.3.0 → 3.4.1

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 (52) hide show
  1. package/migrations/0022_inbound_dispatch_claims.sql +17 -0
  2. package/package.json +3 -2
  3. package/packages/api/package.json +1 -1
  4. package/packages/api/src/index.ts +1 -0
  5. package/packages/api/src/lib/api/notifications.ts +1 -0
  6. package/packages/api/src/lib/api/posts.ts +1 -0
  7. package/packages/api/src/lib/rtc-client.ts +1 -3
  8. package/packages/api/src/types/call.ts +2 -10
  9. package/packages/api/src/types/index.ts +3 -0
  10. package/packages/api/src/types/realtime.ts +139 -0
  11. package/src/backend/index.ts +54 -12
  12. package/src/backend/lib/delivery/queue-batching.ts +53 -36
  13. package/src/backend/lib/delivery/queue-delivery.ts +3 -3
  14. package/src/backend/lib/delivery/queue.ts +17 -9
  15. package/src/backend/lib/delivery/types.ts +12 -0
  16. package/src/backend/lib/notification-push.ts +2 -2
  17. package/src/backend/lib/oauth-providers.ts +9 -0
  18. package/src/backend/lib/strip-image-metadata.ts +50 -30
  19. package/src/backend/lib/unread-counts.ts +63 -2
  20. package/src/backend/middleware/bearer-auth.ts +24 -9
  21. package/src/backend/public.ts +25 -1
  22. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
  23. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
  24. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
  25. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
  26. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
  27. package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
  28. package/src/backend/routes/activitypub/inbox-types.ts +8 -0
  29. package/src/backend/routes/activitypub/inbox.ts +410 -205
  30. package/src/backend/routes/activitypub/outbox.ts +0 -0
  31. package/src/backend/routes/auth.ts +2 -1
  32. package/src/backend/routes/communities/messages.ts +53 -17
  33. package/src/backend/routes/dm/messages.ts +47 -7
  34. package/src/backend/routes/dm/read-archive.ts +27 -0
  35. package/src/backend/routes/dm/typing.ts +16 -0
  36. package/src/backend/routes/notifications.ts +25 -0
  37. package/src/backend/routes/posts/post-helpers.ts +42 -23
  38. package/src/backend/routes/realtime/index.ts +67 -0
  39. package/src/backend/routes/rtc/index.ts +5 -1
  40. package/src/backend/runtime/call-hub-core.ts +13 -3
  41. package/src/backend/runtime/cloudflare.ts +63 -2
  42. package/src/backend/runtime/managed-relational.ts +197 -0
  43. package/src/backend/runtime/managed-runtime.ts +631 -0
  44. package/src/backend/runtime/queue.ts +40 -0
  45. package/src/backend/runtime/realtime-hub.ts +257 -0
  46. package/src/backend/runtime/realtime-stream-do.ts +323 -0
  47. package/src/backend/server.ts +15 -18
  48. package/src/backend/types.ts +13 -2
  49. package/src/db/d1-write.ts +270 -0
  50. package/src/db/index.ts +17 -0
  51. package/src/db/schema/federation.ts +19 -0
  52. package/src/db/schema/index.ts +1 -0
@@ -0,0 +1,17 @@
1
+ -- Fence concurrent/retried ActivityPub inbox dispatches.
2
+ --
3
+ -- `activities.processed = 0` says an activity is retryable, but it does not say
4
+ -- whether another Worker is actively dispatching it. Keeping the lease in a
5
+ -- separate table preserves the public activities ledger shape and lets a
6
+ -- crashed owner expire without allowing an old owner to commit over a newer
7
+ -- retry.
8
+
9
+ CREATE TABLE IF NOT EXISTS inbound_activity_claims (
10
+ activity_ap_id TEXT PRIMARY KEY,
11
+ processing_token TEXT,
12
+ lease_expires_at TEXT,
13
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
14
+ );
15
+
16
+ CREATE INDEX IF NOT EXISTS inbound_activity_claims_lease_idx
17
+ ON inbound_activity_claims(lease_expires_at);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -59,7 +59,7 @@
59
59
  "start": "bun src/backend/server.ts",
60
60
  "dev": "bun src/backend/server.ts",
61
61
  "dev:server": "bun src/backend/server.ts",
62
- "check": "tsc --noEmit && bun run check:no-opentofu-artifacts",
62
+ "check": "bun run fmt:check && tsc --noEmit && bun run check:no-opentofu-artifacts && bun run test",
63
63
  "check:no-opentofu-artifacts": "bun scripts/check-no-opentofu-artifacts.mjs",
64
64
  "test": "bun run build:api && bun test test/ src/backend/ packages/api/src/ scripts/check-publish-version-discipline.test.ts scripts/publish-package-resumable.test.ts && bun run check:release-contents",
65
65
  "test:backend": "bun test src/backend/",
@@ -82,6 +82,7 @@
82
82
  "dependencies": {
83
83
  "@aws-sdk/client-s3": "^3.971.0",
84
84
  "@libsql/client": "^0.17.0",
85
+ "@takosjp/takosumi-contract": "^1.0.0",
85
86
  "better-sqlite3": "^12.4.1",
86
87
  "drizzle-orm": "^0.45.1",
87
88
  "fdir": "^6.5.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-api",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "Typed client SDK and public API contract for yurucommu-server clients.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  export * from "./lib/api.ts";
2
2
  export * from "./lib/transport.ts";
3
3
  export * from "./lib/rtc-client.ts";
4
+ export * from "./lib/realtime-client.ts";
4
5
  export * from "./social-server.ts";
5
6
  export * from "./types/index.ts";
@@ -39,6 +39,7 @@ export async function fetchNotifications(options?: {
39
39
 
40
40
  export async function fetchUnreadCount(): Promise<number> {
41
41
  const res = await apiFetch("/api/notifications/unread/count");
42
+ await assertOk(res, "Failed to load unread notification count");
42
43
  const data = (await res.json()) as { count?: number };
43
44
  return data.count || 0;
44
45
  }
@@ -169,6 +169,7 @@ export async function fetchBookmarks(options?: {
169
169
  if (options?.before) params.set("before", options.before);
170
170
  const query = params.toString() ? `?${params}` : "";
171
171
  const res = await apiFetch(`/api/bookmarks${query}`);
172
+ await assertOk(res, "Failed to load bookmarks");
172
173
  const data = (await res.json()) as PostListResponse & {
173
174
  next_cursor?: string | null;
174
175
  has_more?: boolean;
@@ -313,9 +313,7 @@ export class CallClient {
313
313
  }
314
314
  }
315
315
 
316
- private onRinging(
317
- frame: Extract<HubToClientFrame, { t: "ringing" }>,
318
- ): void {
316
+ private onRinging(frame: Extract<HubToClientFrame, { t: "ringing" }>): void {
319
317
  if (this.call) {
320
318
  // Already busy — auto-decline the second ring.
321
319
  this.send({ t: "reject", callId: frame.callId, reason: "busy" });
@@ -27,13 +27,7 @@ export interface CallMediaKind {
27
27
 
28
28
  /** Cross-instance signaling message kinds (Matrix-VoIP inspired). */
29
29
  export type RtcSignalType =
30
- | "offer"
31
- | "answer"
32
- | "candidate"
33
- | "accept"
34
- | "reject"
35
- | "hangup"
36
- | "cancel";
30
+ "offer" | "answer" | "candidate" | "accept" | "reject" | "hangup" | "cancel";
37
31
 
38
32
  /**
39
33
  * Selected SFU focus for a group call. `null`/absent means pure P2P (1:1).
@@ -286,9 +280,7 @@ export function parseRtcSignalEnvelope(
286
280
  candidates: parseCandidates(input.candidates),
287
281
  sfuFocus: parseSfuFocus(input.sfuFocus),
288
282
  reason:
289
- typeof input.reason === "string"
290
- ? input.reason.slice(0, 200)
291
- : undefined,
283
+ typeof input.reason === "string" ? input.reason.slice(0, 200) : undefined,
292
284
  ts,
293
285
  ttlMs,
294
286
  };
@@ -1,6 +1,9 @@
1
1
  // Call signaling wire contract + browser<->hub frames (voice + video).
2
2
  export * from "./call.ts";
3
3
 
4
+ // Realtime stream wire contract (per-user event feed + control frames).
5
+ export * from "./realtime.ts";
6
+
4
7
  // ===== Yurucommu AP-Native Types =====
5
8
 
6
9
  // Actor represents a user (Person) in ActivityPub
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Realtime stream wire contract (browser <-> per-user RealtimeStreamDO).
3
+ *
4
+ * One authenticated WebSocket per user carries every live update the client
5
+ * used to poll for: talk messages, typing, read receipts, contact-list
6
+ * changes, new notifications, and the authoritative unread counters. The
7
+ * server pushes `RealtimeEvent` envelopes; the client sends only the small
8
+ * control frames below (writes stay on the REST API).
9
+ *
10
+ * Event ids are a per-user monotonic sequence assigned by the Durable Object.
11
+ * A reconnecting client offers its last seen id in `hello`; the DO replays the
12
+ * gap from its ring buffer, or answers `resync` when the gap is older than the
13
+ * buffer so the client re-fetches via the normal REST reads.
14
+ */
15
+
16
+ export type RealtimeEventType =
17
+ | "talk.message"
18
+ | "talk.typing"
19
+ | "talk.read"
20
+ | "talk.contacts_changed"
21
+ | "notification.new"
22
+ | "unread";
23
+
24
+ export interface RealtimeEvent {
25
+ /** Per-user monotonic sequence number (assigned by the stream DO). */
26
+ id: number;
27
+ type: RealtimeEventType;
28
+ data: Record<string, unknown>;
29
+ }
30
+
31
+ /** `talk.message` payload. `other_ap_id` is from the RECEIVING user's view. */
32
+ export interface TalkMessageEventData {
33
+ kind: "dm" | "community";
34
+ /** DM: the counterpart actor (per-recipient). */
35
+ other_ap_id?: string;
36
+ /** Community chat: the community actor. */
37
+ community_ap_id?: string;
38
+ conversation_id?: string;
39
+ message: {
40
+ id: string;
41
+ sender: {
42
+ ap_id: string;
43
+ username: string;
44
+ preferred_username: string | null;
45
+ name: string | null;
46
+ icon_url: string | null;
47
+ };
48
+ content: string | null;
49
+ attachments?: unknown[];
50
+ created_at: string | null;
51
+ };
52
+ }
53
+
54
+ export interface TalkTypingEventData {
55
+ other_ap_id: string;
56
+ is_typing: boolean;
57
+ typed_at: string;
58
+ }
59
+
60
+ export interface TalkReadEventData {
61
+ other_ap_id: string;
62
+ conversation_id: string;
63
+ last_read_at: string;
64
+ }
65
+
66
+ /** Authoritative unread counters (server-computed; never client-derived). */
67
+ export interface UnreadEventData {
68
+ dm: number;
69
+ community: number;
70
+ talk_total: number;
71
+ notifications: number;
72
+ }
73
+
74
+ // --- Client -> server frames -------------------------------------------------
75
+
76
+ export type RealtimeClientFrame =
77
+ { t: "hello"; lastEventId?: number } | { t: "ping" } | { t: "pong" };
78
+
79
+ // --- Server -> client frames -------------------------------------------------
80
+
81
+ export type RealtimeServerFrame =
82
+ | { t: "hello_ok"; lastEventId: number }
83
+ | { t: "event"; event: RealtimeEvent }
84
+ /** The requested replay gap is older than the buffer: re-fetch via REST. */
85
+ | { t: "resync" }
86
+ | { t: "ping" }
87
+ | { t: "pong" };
88
+
89
+ export function parseRealtimeClientFrame(
90
+ raw: unknown,
91
+ ): RealtimeClientFrame | null {
92
+ if (!raw || typeof raw !== "object") return null;
93
+ const frame = raw as { t?: unknown; lastEventId?: unknown };
94
+ if (frame.t === "ping" || frame.t === "pong") return { t: frame.t };
95
+ if (frame.t === "hello") {
96
+ const lastEventId =
97
+ typeof frame.lastEventId === "number" &&
98
+ Number.isFinite(frame.lastEventId) &&
99
+ frame.lastEventId >= 0
100
+ ? Math.floor(frame.lastEventId)
101
+ : undefined;
102
+ return { t: "hello", lastEventId };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ export function parseRealtimeServerFrame(
108
+ raw: unknown,
109
+ ): RealtimeServerFrame | null {
110
+ if (!raw || typeof raw !== "object") return null;
111
+ const frame = raw as { t?: unknown; event?: unknown; lastEventId?: unknown };
112
+ if (frame.t === "ping" || frame.t === "pong" || frame.t === "resync") {
113
+ return { t: frame.t };
114
+ }
115
+ if (frame.t === "hello_ok" && typeof frame.lastEventId === "number") {
116
+ return { t: "hello_ok", lastEventId: frame.lastEventId };
117
+ }
118
+ if (frame.t === "event" && frame.event && typeof frame.event === "object") {
119
+ const event = frame.event as {
120
+ id?: unknown;
121
+ type?: unknown;
122
+ data?: unknown;
123
+ };
124
+ if (typeof event.id === "number" && typeof event.type === "string") {
125
+ return {
126
+ t: "event",
127
+ event: {
128
+ id: event.id,
129
+ type: event.type as RealtimeEventType,
130
+ data:
131
+ event.data && typeof event.data === "object"
132
+ ? (event.data as Record<string, unknown>)
133
+ : {},
134
+ },
135
+ };
136
+ }
137
+ }
138
+ return null;
139
+ }
@@ -4,8 +4,12 @@ import { NOTIFICATION_PUSHER_REGISTRATION_PATH } from "./lib/notification-pusher
4
4
  import type { Env, EnvVars, Variables } from "./types.ts";
5
5
  import { extractActorFromSession } from "./lib/session-actor.ts";
6
6
  import { isBackendPath } from "./lib/backend-paths.ts";
7
- import { wrapCloudflareBindings } from "./runtime/cloudflare.ts";
8
7
  import {
8
+ wrapCloudflareBindings,
9
+ wrapCloudflareMessageBatch,
10
+ } from "./runtime/cloudflare.ts";
11
+ import {
12
+ getMobileOidcAudience,
9
13
  getOidcClientCredentials,
10
14
  getOidcIssuerUrl,
11
15
  } from "./lib/oauth-providers.ts";
@@ -30,6 +34,8 @@ import { appsApiRoutes, appsServeRoutes } from "./routes/apps.ts";
30
34
  import mobileRoutes from "./routes/mobile.ts";
31
35
  import notificationPusherRoutes from "./routes/notification-pushers.ts";
32
36
  import rtcRoutes from "./routes/rtc/index.ts";
37
+ import realtimeRoutes from "./routes/realtime/index.ts";
38
+ import { sweepRealtimeNotifications } from "./runtime/realtime-hub.ts";
33
39
 
34
40
  import { rateLimit, RateLimitConfigs } from "./middleware/rate-limit.ts";
35
41
  import { csrfProtection } from "./middleware/csrf.ts";
@@ -42,7 +48,15 @@ import { logger } from "./lib/logger.ts";
42
48
 
43
49
  const log = logger.child({ component: "backend.index" });
44
50
  let lastNotificationPushRecoverySweep = 0;
45
- import type { MessageBatch } from "@cloudflare/workers-types";
51
+ import type { IQueueBatch } from "./runtime/queue.ts";
52
+ import type {
53
+ D1Database,
54
+ Fetcher,
55
+ KVNamespace,
56
+ MessageBatch,
57
+ Queue,
58
+ R2Bucket,
59
+ } from "@cloudflare/workers-types";
46
60
  import type {
47
61
  DeliveryDlqMessageV1,
48
62
  DeliveryQueueMessageV1,
@@ -72,6 +86,14 @@ export interface YurucommuBackendPluginV1 {
72
86
  export interface CreateYurucommuBackendAppOptionsV1 {
73
87
  plugins?: YurucommuBackendPluginV1[];
74
88
  discovery?: YurucommuBackendDiscoveryOptionsV1;
89
+ /**
90
+ * Browser capture capabilities required by this product shell. Both default
91
+ * to denied; a product must opt in to each capability it actually uses.
92
+ */
93
+ browserMedia?: {
94
+ camera?: boolean;
95
+ microphone?: boolean;
96
+ };
75
97
  }
76
98
 
77
99
  export interface YurucommuBackendDiscoveryClientV1 {
@@ -341,7 +363,7 @@ function mountReadinessRoutes(
341
363
  ) => {
342
364
  const appUrl = normalizeOrigin(c.env.APP_URL, c.req.url);
343
365
  const issuer = getOidcIssuerUrl(c.env) ?? appUrl;
344
- const { clientId } = getOidcClientCredentials(c.env);
366
+ const clientId = getMobileOidcAudience(c.env);
345
367
  return c.json(
346
368
  buildSocialServerDiscovery(appUrl, issuer, discovery, {
347
369
  oidcClientId: getOidcIssuerUrl(c.env)
@@ -502,7 +524,12 @@ function applyBodyLimits(app: YurucommuApp): void {
502
524
  });
503
525
  }
504
526
 
505
- function applyGlobalMiddleware(app: YurucommuApp): void {
527
+ function applyGlobalMiddleware(
528
+ app: YurucommuApp,
529
+ browserMedia: NonNullable<
530
+ CreateYurucommuBackendAppOptionsV1["browserMedia"]
531
+ > = {},
532
+ ): void {
506
533
  app.onError(createErrorMiddleware());
507
534
 
508
535
  app.use("*", async (c, next) => {
@@ -560,11 +587,14 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
560
587
  setSecurityHeader("X-Content-Type-Options", "nosniff");
561
588
  setSecurityHeader("X-Frame-Options", "DENY");
562
589
  setSecurityHeader("Referrer-Policy", "strict-origin-when-cross-origin");
563
- // Allow the app's OWN origin to use camera + microphone (WebRTC calls);
564
- // still deny geolocation and deny camera/mic to any cross-origin frame.
590
+ // Browser capture is product-specific and denied by default. Even when a
591
+ // capability is enabled, only this origin may use it; cross-origin frames
592
+ // and geolocation remain denied.
593
+ const camera = browserMedia.camera === true ? "(self)" : "()";
594
+ const microphone = browserMedia.microphone === true ? "(self)" : "()";
565
595
  setSecurityHeader(
566
596
  "Permissions-Policy",
567
- "camera=(self), microphone=(self), geolocation=()",
597
+ `camera=${camera}, microphone=${microphone}, geolocation=()`,
568
598
  );
569
599
  // HSTS: once a client has reached this host over HTTPS, keep it on HTTPS
570
600
  // (defeats SSL-strip / downgrade). Sent unconditionally — browsers ignore it
@@ -600,6 +630,9 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
600
630
  error,
601
631
  });
602
632
  }
633
+ // Same choke point feeds the realtime stream: the push-jobs the inbox
634
+ // trigger wrote tell us exactly which users gained a notification.
635
+ await sweepRealtimeNotifications(c.env);
603
636
  })();
604
637
  // Prefer to run the sweep AFTER the response is sent (waitUntil) so its
605
638
  // 2-4 D1 round-trips never add latency to the request. `executionCtx`
@@ -780,6 +813,8 @@ function mountCoreRoutes(app: YurucommuApp): void {
780
813
  app.route("/api/moderation", moderationRoutes);
781
814
  app.route("/api/apps", appsApiRoutes);
782
815
  app.route("/hosted", appsServeRoutes);
816
+ // Realtime stream: capability probe + WS ticket + per-user socket upgrade.
817
+ app.route("/api/realtime", realtimeRoutes);
783
818
  // Call feature: /api/rtc/* (session) + /ap/rtc/signal (server-to-server).
784
819
  app.route("/", rtcRoutes);
785
820
  app.route("/", activitypubRoutes);
@@ -894,7 +929,7 @@ export function createYurucommuBackendApp(
894
929
  // /healthz and /readyz stay reachable even when payload validation
895
930
  // misbehaves.
896
931
  applyBodyLimits(app);
897
- applyGlobalMiddleware(app);
932
+ applyGlobalMiddleware(app, options.browserMedia);
898
933
  for (const plugin of plugins) {
899
934
  plugin.setup?.(pluginContext);
900
935
  }
@@ -916,7 +951,7 @@ const app = createYurucommuBackendApp();
916
951
  export const backendApp = app;
917
952
 
918
953
  export async function handleYurucommuQueueBatch(
919
- batch: MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
954
+ batch: IQueueBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
920
955
  env: Env,
921
956
  ): Promise<void> {
922
957
  const deliveryQueueName = env.DELIVERY_QUEUE_NAME ?? "yurucommu-delivery";
@@ -924,13 +959,13 @@ export async function handleYurucommuQueueBatch(
924
959
 
925
960
  if (batch.queue === deliveryQueueName) {
926
961
  return handleDeliveryQueueBatch(
927
- batch as MessageBatch<DeliveryQueueMessageV1>,
962
+ batch as IQueueBatch<DeliveryQueueMessageV1>,
928
963
  env,
929
964
  );
930
965
  }
931
966
  if (batch.queue === deliveryDlqName) {
932
967
  return handleDeliveryDlqBatch(
933
- batch as MessageBatch<DeliveryDlqMessageV1>,
968
+ batch as IQueueBatch<DeliveryDlqMessageV1>,
934
969
  env,
935
970
  );
936
971
  }
@@ -953,6 +988,10 @@ type WorkerBindings = EnvVars & {
953
988
  // spreads it through untouched (it is not DB/MEDIA/KV/ASSETS) so app code and
954
989
  // the rtc routes read it as c.env.CALL_SIGNALING.
955
990
  CALL_SIGNALING?: DurableObjectNamespace;
991
+ // Per-user realtime event stream Durable Object namespace. Same pass-through
992
+ // as CALL_SIGNALING; optional — when unbound the realtime routes answer 503
993
+ // and clients fall back to polling.
994
+ REALTIME_STREAM?: DurableObjectNamespace;
956
995
  };
957
996
 
958
997
  export default {
@@ -968,6 +1007,9 @@ export default {
968
1007
  batch: MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
969
1008
  bindings: WorkerBindings,
970
1009
  ): Promise<void> {
971
- return handleYurucommuQueueBatch(batch, wrapCloudflareBindings(bindings));
1010
+ return handleYurucommuQueueBatch(
1011
+ wrapCloudflareMessageBatch(batch),
1012
+ wrapCloudflareBindings(bindings),
1013
+ );
972
1014
  },
973
1015
  };
@@ -3,8 +3,8 @@
3
3
  * and batch dispatch of delivery messages.
4
4
  */
5
5
 
6
- import type { Message, MessageBatch } from "@cloudflare/workers-types";
7
6
  import type { Env } from "../../types.ts";
7
+ import type { IQueueMessage } from "../../runtime/queue.ts";
8
8
  import type { Database } from "../../../db/index.ts";
9
9
  import { and, eq, or, sql } from "drizzle-orm";
10
10
  import {
@@ -117,12 +117,17 @@ export async function enqueueFollowerEndpointDeliveries(
117
117
  baseUrl: string,
118
118
  activityId: string,
119
119
  followeeApId: string,
120
- ): Promise<{ processed: number; capped: boolean }> {
120
+ startCursor: string | null = null,
121
+ ): Promise<{
122
+ processed: number;
123
+ capped: boolean;
124
+ nextCursor: string | null;
125
+ }> {
121
126
  // Page through accepted followers with a keyset cursor instead of loading
122
127
  // every row into memory at once. Each page is planned and dispatched in
123
128
  // ≤100-message chunks before the next page is read, bounding both memory
124
129
  // and per-call batch size.
125
- let cursor: string | null = null;
130
+ let cursor: string | null = startCursor;
126
131
  let processed = 0;
127
132
  let capped = false;
128
133
 
@@ -184,7 +189,7 @@ export async function enqueueFollowerEndpointDeliveries(
184
189
  }
185
190
  }
186
191
 
187
- return { processed, capped };
192
+ return { processed, capped, nextCursor: capped ? cursor : null };
188
193
  }
189
194
 
190
195
  /**
@@ -215,21 +220,27 @@ export async function snapshotAndEnqueueFollowerDeliveries(
215
220
  return;
216
221
  }
217
222
 
218
- const { processed, capped } = await enqueueFollowerEndpointDeliveries(
219
- db,
220
- queue,
221
- env.APP_URL,
222
- activityId,
223
- followeeApId,
224
- );
223
+ let cursor: string | null = null;
224
+ let processed = 0;
225
+ do {
226
+ const page = await enqueueFollowerEndpointDeliveries(
227
+ db,
228
+ queue,
229
+ env.APP_URL,
230
+ activityId,
231
+ followeeApId,
232
+ cursor,
233
+ );
234
+ processed += page.processed;
235
+ cursor = page.nextCursor;
236
+ } while (cursor !== null);
225
237
 
226
- if (capped) {
227
- log.warn("Follower snapshot delivery capped at max followers", {
228
- event: "delivery.fanout.snapshot_capped",
238
+ if (processed > FANOUT_MAX_FOLLOWERS) {
239
+ log.info("Follower snapshot continued across bounded pages", {
240
+ event: "delivery.fanout.snapshot_continued",
229
241
  followee: followeeApId,
230
242
  activityId,
231
243
  processed,
232
- max: FANOUT_MAX_FOLLOWERS,
233
244
  });
234
245
  }
235
246
  }
@@ -238,32 +249,38 @@ export async function processFanoutFollowers(
238
249
  db: Database,
239
250
  env: Env,
240
251
  msg: DeliveryFanoutFollowersMessageV1,
241
- message: Message<DeliveryQueueMessageV1>,
252
+ message: IQueueMessage<DeliveryQueueMessageV1>,
242
253
  ): Promise<void> {
243
254
  if (!requireQueue(env, "fanout", message)) return;
244
255
  const queueEnv = env as QueueEnv;
245
256
 
246
- const { processed, capped } = await enqueueFollowerEndpointDeliveries(
247
- db,
248
- queueEnv.DELIVERY_QUEUE,
249
- env.APP_URL,
250
- msg.activityId,
251
- msg.followeeApId,
252
- );
257
+ const { processed, capped, nextCursor } =
258
+ await enqueueFollowerEndpointDeliveries(
259
+ db,
260
+ queueEnv.DELIVERY_QUEUE,
261
+ env.APP_URL,
262
+ msg.activityId,
263
+ msg.followeeApId,
264
+ msg.cursor ?? null,
265
+ );
253
266
 
254
- if (capped) {
255
- // Extremely large follower sets are capped per invocation to keep the
256
- // Worker within CPU/time limits. Endpoint-deduped delivery jobs are
257
- // idempotent (computeDeliveryJobId + upsertDeliveryJob), and the planner
258
- // re-enqueues any still-unknown recipients on the next fanout, so capped
259
- // followers are re-planned on the actor's next delivery rather than lost
260
- // silently.
261
- log.warn("Fanout capped at max followers for one invocation", {
262
- event: "delivery.fanout.capped",
267
+ if (capped && nextCursor !== null) {
268
+ // Send the continuation before ACK. If this send fails, Cloudflare retries
269
+ // the current message; deterministic endpoint job ids make that safe.
270
+ await sendQueueMessage(env, {
271
+ version: DELIVERY_QUEUE_MESSAGE_VERSION,
272
+ type: "fanout_followers",
273
+ activityId: msg.activityId,
274
+ followeeApId: msg.followeeApId,
275
+ cursor: nextCursor,
276
+ scheduledAt: nowIso(),
277
+ });
278
+ log.info("Follower fanout continued from stable cursor", {
279
+ event: "delivery.fanout.continued",
263
280
  followee: msg.followeeApId,
264
281
  activityId: msg.activityId,
265
282
  processed,
266
- max: FANOUT_MAX_FOLLOWERS,
283
+ cursor: nextCursor,
267
284
  });
268
285
  }
269
286
 
@@ -288,7 +305,7 @@ export async function processFanoutCommunity(
288
305
  db: Database,
289
306
  env: Env,
290
307
  msg: DeliveryFanoutCommunityMessageV1,
291
- message: Message<DeliveryQueueMessageV1>,
308
+ message: IQueueMessage<DeliveryQueueMessageV1>,
292
309
  ): Promise<void> {
293
310
  const baseUrl = env.APP_URL;
294
311
 
@@ -456,7 +473,7 @@ export async function processResolveActor(
456
473
  db: Database,
457
474
  env: Env,
458
475
  msg: DeliveryResolveActorMessageV1,
459
- message: Message<DeliveryQueueMessageV1>,
476
+ message: IQueueMessage<DeliveryQueueMessageV1>,
460
477
  ): Promise<void> {
461
478
  if (!requireQueue(env, "resolve_actor", message)) return;
462
479
 
@@ -554,7 +571,7 @@ export async function processReconcileJob(
554
571
  db: Database,
555
572
  env: Env,
556
573
  msg: DeliveryReconcileJobMessageV1,
557
- message: Message<DeliveryQueueMessageV1>,
574
+ message: IQueueMessage<DeliveryQueueMessageV1>,
558
575
  ): Promise<void> {
559
576
  if (!requireQueue(env, "reconcile", message)) return;
560
577
 
@@ -3,7 +3,7 @@
3
3
  * Includes signing, circuit breaker checks, retry logic, and dead-letter handling.
4
4
  */
5
5
 
6
- import type { Message } from "@cloudflare/workers-types";
6
+ import type { IQueueMessage } from "../../runtime/queue.ts";
7
7
  import type { Env } from "../../types.ts";
8
8
  import type { Database } from "../../../db/index.ts";
9
9
  import { and, eq, lt, notInArray, or, sql } from "drizzle-orm";
@@ -186,7 +186,7 @@ async function failJob(
186
186
  db: Database,
187
187
  jobId: string,
188
188
  error: string,
189
- message: Message<DeliveryQueueMessageV1>,
189
+ message: IQueueMessage<DeliveryQueueMessageV1>,
190
190
  ): Promise<void> {
191
191
  await db
192
192
  .update(deliveryQueue)
@@ -286,7 +286,7 @@ export async function processDeliverEndpoint(
286
286
  db: Database,
287
287
  env: Env,
288
288
  msg: DeliveryDeliverEndpointMessageV1,
289
- message: Message<DeliveryQueueMessageV1>,
289
+ message: IQueueMessage<DeliveryQueueMessageV1>,
290
290
  bulkhead: Bulkhead,
291
291
  ): Promise<void> {
292
292
  if (!requireQueue(env, "deliver_endpoint", message)) return;