@takosjp/yurucommu-core 3.2.0 → 3.3.0

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 (45) hide show
  1. package/migrations/0019_notification_push_delivery.sql +10 -7
  2. package/migrations/0020_call_sessions.sql +23 -0
  3. package/package.json +6 -2
  4. package/packages/api/package.json +1 -1
  5. package/packages/api/src/index.ts +1 -0
  6. package/packages/api/src/lib/api/browser-push.ts +11 -0
  7. package/packages/api/src/lib/api/normalize.ts +15 -4
  8. package/packages/api/src/lib/api/notification-target.ts +106 -0
  9. package/packages/api/src/lib/api/push-config.ts +132 -0
  10. package/packages/api/src/lib/api.ts +2 -0
  11. package/packages/api/src/lib/rtc-client.ts +542 -0
  12. package/packages/api/src/social-server.ts +7 -0
  13. package/packages/api/src/types/call.ts +306 -0
  14. package/packages/api/src/types/index.ts +16 -4
  15. package/src/backend/index.ts +65 -10
  16. package/src/backend/lib/attachments.ts +52 -0
  17. package/src/backend/lib/delivery/queue.ts +55 -0
  18. package/src/backend/lib/notification-eligibility.ts +150 -0
  19. package/src/backend/lib/notification-push.ts +159 -146
  20. package/src/backend/lib/rtc/call-store.ts +101 -0
  21. package/src/backend/lib/rtc/provider.ts +135 -0
  22. package/src/backend/lib/rtc/signal-transport.ts +125 -0
  23. package/src/backend/lib/session-actor.ts +16 -1
  24. package/src/backend/lib/unread-counts.ts +79 -0
  25. package/src/backend/middleware/csrf.ts +11 -0
  26. package/src/backend/public.ts +3 -0
  27. package/src/backend/routes/account-teardown.ts +13 -0
  28. package/src/backend/routes/activitypub.ts +5 -1
  29. package/src/backend/routes/auth-helpers.ts +10 -7
  30. package/src/backend/routes/auth.ts +131 -3
  31. package/src/backend/routes/communities/messages.ts +16 -33
  32. package/src/backend/routes/dm/contacts.ts +6 -44
  33. package/src/backend/routes/dm/messages.ts +5 -39
  34. package/src/backend/routes/notifications.ts +27 -70
  35. package/src/backend/routes/posts/transformers.ts +8 -10
  36. package/src/backend/routes/rtc/index.ts +147 -0
  37. package/src/backend/runtime/call-hub-core.ts +470 -0
  38. package/src/backend/runtime/call-hub-port.ts +78 -0
  39. package/src/backend/runtime/call-signaling-do.ts +242 -0
  40. package/src/backend/runtime/signaling-hub.ts +187 -0
  41. package/src/backend/types.ts +28 -0
  42. package/src/db/index.ts +11 -10
  43. package/src/db/schema/calls.ts +46 -0
  44. package/src/db/schema/index.ts +1 -0
  45. package/src/db/schema/mobile.ts +7 -9
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Shared social-notification eligibility predicates.
3
+ *
4
+ * SINGLE owner of the WHERE fragments that decide whether an inbox row is a
5
+ * user-facing notification. Consumed by ALL of:
6
+ * - GET /api/notifications (list),
7
+ * - GET /api/notifications/unread/count (badge),
8
+ * - the push outbox processor (delivery-time re-check + push unread badge)
9
+ * so a change to eligibility (a new activity type, a new suppression rule)
10
+ * cannot silently drift between the list, the badge, and push delivery — the
11
+ * dispersion that past audits repeatedly flagged for visibility logic.
12
+ */
13
+
14
+ import {
15
+ and,
16
+ eq,
17
+ exists,
18
+ inArray,
19
+ isNull,
20
+ ne,
21
+ notExists,
22
+ or,
23
+ type SQL,
24
+ } from "drizzle-orm";
25
+
26
+ import {
27
+ activities,
28
+ dmArchivedConversations,
29
+ inbox,
30
+ notificationArchived,
31
+ objects,
32
+ type Database,
33
+ } from "../../db/index.ts";
34
+ import { excludeBlockedMutedAuthors } from "./feed-exclude.ts";
35
+
36
+ /**
37
+ * The activity types that surface as notifications. Any addition must hold for
38
+ * the list, the unread badge, AND push delivery — they all read this constant.
39
+ */
40
+ export const NOTIFICATION_ACTIVITY_TYPES = [
41
+ "Follow",
42
+ "Like",
43
+ "Announce",
44
+ "Create",
45
+ ] as const;
46
+
47
+ export interface NotificationEligibilityOptions {
48
+ /**
49
+ * How direct-visibility (DM) Creates are treated:
50
+ * - "exclude" (list/badge): a DM surfaces in the DM view, never as a
51
+ * notification row, so drop it entirely.
52
+ * - "unless-dm-archived" (push): a direct Create IS push-eligible (routed to
53
+ * Yurume), unless the recipient archived the conversation — archiving is a
54
+ * delivery preference, not merely a presentation filter.
55
+ */
56
+ readonly direct: "exclude" | "unless-dm-archived";
57
+ /**
58
+ * Archive partition. "exclude" (default: badge/push and the default list
59
+ * view) hides archived rows; "only" (the list's archived view) shows ONLY
60
+ * archived rows.
61
+ */
62
+ readonly archived?: "exclude" | "only";
63
+ /**
64
+ * Restrict to a subset of NOTIFICATION_ACTIVITY_TYPES (the list's type
65
+ * filter). Defaults to the full set; callers must not widen it.
66
+ */
67
+ readonly activityTypes?: readonly string[];
68
+ }
69
+
70
+ /**
71
+ * Eligibility conditions for a query shaped as
72
+ * `inbox JOIN activities LEFT JOIN objects` scoped to `actorApId`'s inbox.
73
+ * Returns the shared conditions only; callers add their own paging/read/type
74
+ * filters on top.
75
+ */
76
+ export function notificationEligibilityWhere(
77
+ db: Database,
78
+ actorApId: string,
79
+ options: NotificationEligibilityOptions,
80
+ ): SQL[] {
81
+ // Never notify an actor about their own activity.
82
+ const notSelf = ne(activities.actorApId, actorApId);
83
+
84
+ // Only user-facing activity types.
85
+ const userFacingType = inArray(activities.type, [
86
+ ...(options.activityTypes ?? NOTIFICATION_ACTIVITY_TYPES),
87
+ ]);
88
+
89
+ // Archive partition. Archived notifications are hidden from the default
90
+ // list, excluded from the badge, and must not push; the list's archived view
91
+ // inverts the predicate.
92
+ const archivedSubquery = db
93
+ .select({ activityApId: notificationArchived.activityApId })
94
+ .from(notificationArchived)
95
+ .where(
96
+ and(
97
+ eq(notificationArchived.actorApId, inbox.actorApId),
98
+ eq(notificationArchived.activityApId, inbox.activityApId),
99
+ ),
100
+ );
101
+ const notArchived =
102
+ options.archived === "only"
103
+ ? exists(archivedSubquery)
104
+ : notExists(archivedSubquery);
105
+
106
+ // Direct (DM) handling. The object join is LEFT (Follow's object is an
107
+ // actor, not an objects row), so NULL visibility must be kept.
108
+ let directCondition: SQL;
109
+ if (options.direct === "exclude") {
110
+ directCondition = or(
111
+ isNull(objects.visibility),
112
+ ne(objects.visibility, "direct"),
113
+ )!;
114
+ } else {
115
+ const archivedDmSubquery = db
116
+ .select({ conversationId: dmArchivedConversations.conversationId })
117
+ .from(dmArchivedConversations)
118
+ .where(
119
+ and(
120
+ eq(dmArchivedConversations.actorApId, actorApId),
121
+ eq(dmArchivedConversations.conversationId, objects.conversation),
122
+ ),
123
+ );
124
+ directCondition = or(
125
+ isNull(objects.visibility),
126
+ ne(objects.visibility, "direct"),
127
+ notExists(archivedDmSubquery),
128
+ )!;
129
+ }
130
+
131
+ const conditions: SQL[] = [
132
+ notSelf,
133
+ userFacingType,
134
+ notArchived,
135
+ directCondition,
136
+ ];
137
+
138
+ // Suppress notifications whose actor the recipient has blocked or muted.
139
+ // This is the read-time choke point: mutes are read-only everywhere, and not
140
+ // every notify WRITE path block-checks, so gating here covers like/repost/
141
+ // follow/reply/mention (local AND federated) for both blocks and mutes.
142
+ const blockMute = excludeBlockedMutedAuthors(
143
+ db,
144
+ actorApId,
145
+ activities.actorApId,
146
+ );
147
+ if (blockMute) conditions.push(blockMute);
148
+
149
+ return conditions;
150
+ }
@@ -1,25 +1,11 @@
1
1
  import type { Message } from "@cloudflare/workers-types";
2
- import {
3
- and,
4
- asc,
5
- eq,
6
- exists,
7
- inArray,
8
- isNull,
9
- lte,
10
- ne,
11
- notExists,
12
- or,
13
- sql,
14
- } from "drizzle-orm";
2
+ import { and, asc, eq, exists, inArray, lte, ne, sql } from "drizzle-orm";
15
3
 
16
4
  import {
17
5
  activities,
18
6
  affectedRowCount,
19
7
  communityMembers,
20
- dmArchivedConversations,
21
8
  inbox,
22
- notificationArchived,
23
9
  notificationPushers,
24
10
  notificationPushJobs,
25
11
  objectRecipients,
@@ -41,6 +27,11 @@ import {
41
27
  type DeliveryQueueMessageV1,
42
28
  } from "./delivery/types.ts";
43
29
  import { excludeBlockedMutedAuthors } from "./feed-exclude.ts";
30
+ import {
31
+ NOTIFICATION_ACTIVITY_TYPES,
32
+ notificationEligibilityWhere,
33
+ } from "./notification-eligibility.ts";
34
+ import { yurumeUnreadCounts } from "./unread-counts.ts";
44
35
  import { logger } from "./logger.ts";
45
36
  import { generateId } from "./oauth-utils.ts";
46
37
 
@@ -53,17 +44,21 @@ export const MAX_NOTIFICATION_PUSH_ATTEMPTS = 5;
53
44
  export const NOTIFICATION_PUSHER_RETENTION_DAYS = 90;
54
45
  export const NOTIFICATION_PUSH_JOB_RETENTION_DAYS = 90;
55
46
  export const MAX_NOTIFICATION_PUSH_JOB_PURGE = 50;
47
+ export const MAX_NOTIFICATION_PUSHER_PURGE = 50;
56
48
 
57
49
  const DEFAULT_GATEWAY_TIMEOUT_MS = 10_000;
58
50
  const MAX_GATEWAY_RESPONSE_BYTES = 64 * 1024;
59
51
  const MAX_QUEUE_SCAN = 50;
60
52
  const STALE_PROCESSING_MS = 2 * 60 * 1000;
61
- const SOCIAL_NOTIFICATION_ACTIVITY_TYPES = [
62
- "Follow",
63
- "Like",
64
- "Announce",
65
- "Create",
66
- ] as const;
53
+ // A 'queued'/'processing' row whose queue message is GONE (auto-dead-lettered
54
+ // after max retries with the raw body, dropped after queue retention, consumer
55
+ // down long enough) is otherwise unreachable: the sweep sends only
56
+ // pending/retry_wait and message-based recovery needs a message. Reclaim such
57
+ // rows to 'pending' once they are far staler than any live in-flight state —
58
+ // a live processing owner refreshes its lease before every gateway call
59
+ // (≤ ~40s between touches) and a queued message is consumed or dead-lettered
60
+ // within minutes.
61
+ const STALE_INFLIGHT_RECLAIM_MS = 15 * 60 * 1000;
67
62
 
68
63
  export interface NotificationPusherRegistrationResponse {
69
64
  readonly id: string;
@@ -330,22 +325,58 @@ export function buildNotificationPushMessage(
330
325
  export async function enqueuePendingNotificationPushJobs(
331
326
  env: Env,
332
327
  ): Promise<number> {
333
- // Terminal rows are retained long enough to preserve the deterministic job
328
+ const db = env.DB_INSTANCE;
329
+ // Expired rows are retained long enough to preserve the deterministic job
334
330
  // idempotency window, then removed opportunistically in a bounded batch.
335
331
  // This runs even without a Queue binding so disabled push delivery cannot
336
- // turn the outbox ledger into unbounded storage.
337
- await purgeExpiredNotificationPushJobs(env.DB_INSTANCE);
332
+ // turn the outbox ledger into unbounded storage. Stale pushers are purged
333
+ // here too (bounded), NOT inside per-job processing.
334
+ await purgeExpiredNotificationPushJobs(db);
335
+ await purgeExpiredNotificationPushers(db);
338
336
  if (!env.DELIVERY_QUEUE) return 0;
339
337
  const now = new Date().toISOString();
340
- const rows = await env.DB_INSTANCE.select({ id: notificationPushJobs.id })
338
+
339
+ // Reclaim in-flight rows whose queue message is gone (see
340
+ // STALE_INFLIGHT_RECLAIM_MS). The reclaim consumes one attempt so a
341
+ // crash-looping job terminates at the same MAX_NOTIFICATION_PUSH_ATTEMPTS
342
+ // budget instead of ping-ponging forever.
343
+ const staleCutoff = new Date(
344
+ Date.now() - STALE_INFLIGHT_RECLAIM_MS,
345
+ ).toISOString();
346
+ await db
347
+ .update(notificationPushJobs)
348
+ .set({
349
+ status: sql`CASE WHEN ${notificationPushJobs.attempts} + 1 >= ${MAX_NOTIFICATION_PUSH_ATTEMPTS} THEN 'failed' ELSE 'pending' END`,
350
+ attempts: sql`${notificationPushJobs.attempts} + 1`,
351
+ processingToken: null,
352
+ lastError: sql`COALESCE(${notificationPushJobs.lastError}, 'reclaimed stale in-flight push job')`,
353
+ updatedAt: now,
354
+ })
355
+ .where(
356
+ and(
357
+ inArray(notificationPushJobs.status, ["queued", "processing"]),
358
+ lte(notificationPushJobs.updatedAt, staleCutoff),
359
+ ),
360
+ );
361
+
362
+ // The trigger creates a job for EVERY unread inbox insert, including
363
+ // recipients with zero pushers. Only enqueue jobs that can actually deliver;
364
+ // pusher-less rows stay pending (no queue traffic, no processing cycle) and
365
+ // age out through the retention purge above.
366
+ const actorHasPusher = exists(
367
+ db
368
+ .select({ id: notificationPushers.id })
369
+ .from(notificationPushers)
370
+ .where(eq(notificationPushers.actorApId, notificationPushJobs.actorApId)),
371
+ );
372
+ const rows = await db
373
+ .select({ id: notificationPushJobs.id })
341
374
  .from(notificationPushJobs)
342
375
  .where(
343
376
  and(
344
- or(
345
- eq(notificationPushJobs.status, "pending"),
346
- eq(notificationPushJobs.status, "retry_wait"),
347
- ),
377
+ inArray(notificationPushJobs.status, ["pending", "retry_wait"]),
348
378
  lte(notificationPushJobs.nextAttemptAt, now),
379
+ actorHasPusher,
349
380
  ),
350
381
  )
351
382
  .orderBy(asc(notificationPushJobs.createdAt))
@@ -355,7 +386,8 @@ export async function enqueuePendingNotificationPushJobs(
355
386
  await env.DELIVERY_QUEUE.sendBatch(
356
387
  rows.map((row) => ({ body: buildNotificationPushMessage(row.id) })),
357
388
  );
358
- await env.DB_INSTANCE.update(notificationPushJobs)
389
+ await db
390
+ .update(notificationPushJobs)
359
391
  .set({ status: "queued", processingToken: null, updatedAt: now })
360
392
  .where(
361
393
  and(
@@ -363,16 +395,82 @@ export async function enqueuePendingNotificationPushJobs(
363
395
  notificationPushJobs.id,
364
396
  rows.map((row) => row.id),
365
397
  ),
366
- or(
367
- eq(notificationPushJobs.status, "pending"),
368
- eq(notificationPushJobs.status, "retry_wait"),
369
- ),
398
+ inArray(notificationPushJobs.status, ["pending", "retry_wait"]),
370
399
  ),
371
400
  );
372
401
  return rows.length;
373
402
  }
374
403
 
375
- /** Remove at most one bounded batch of expired terminal delivery jobs. */
404
+ /**
405
+ * Reset a dead-lettered push job so the durable outbox can retry it. Called by
406
+ * the DLQ consumer when a `notification_push` message exhausted its Cloudflare
407
+ * Queue retries with the RAW body (automatic dead-lettering) — without this,
408
+ * the job row would be stranded in 'queued'/'processing' forever. Consumes one
409
+ * attempt so a permanently failing job still terminates at the attempts budget.
410
+ */
411
+ export async function recoverDeadLetteredNotificationPushJob(
412
+ db: Database,
413
+ jobId: string,
414
+ ): Promise<boolean> {
415
+ const now = new Date().toISOString();
416
+ const recovered = await db
417
+ .update(notificationPushJobs)
418
+ .set({
419
+ status: sql`CASE WHEN ${notificationPushJobs.attempts} + 1 >= ${MAX_NOTIFICATION_PUSH_ATTEMPTS} THEN 'failed' ELSE 'retry_wait' END`,
420
+ attempts: sql`${notificationPushJobs.attempts} + 1`,
421
+ processingToken: null,
422
+ nextAttemptAt: now,
423
+ lastError: sql`COALESCE(${notificationPushJobs.lastError}, 'queue message dead-lettered')`,
424
+ updatedAt: now,
425
+ })
426
+ .where(
427
+ and(
428
+ eq(notificationPushJobs.id, jobId),
429
+ inArray(notificationPushJobs.status, [
430
+ "pending",
431
+ "queued",
432
+ "processing",
433
+ "retry_wait",
434
+ ]),
435
+ ),
436
+ );
437
+ return affectedRowCount(recovered) > 0;
438
+ }
439
+
440
+ /** Remove at most one bounded batch of pushers idle past the retention window. */
441
+ export async function purgeExpiredNotificationPushers(
442
+ db: Database,
443
+ now = new Date(),
444
+ ): Promise<number> {
445
+ const cutoff = new Date(
446
+ now.getTime() - NOTIFICATION_PUSHER_RETENTION_DAYS * 86_400_000,
447
+ ).toISOString();
448
+ const stale = await db
449
+ .select({ id: notificationPushers.id })
450
+ .from(notificationPushers)
451
+ .where(lte(notificationPushers.lastSeenAt, cutoff))
452
+ .orderBy(asc(notificationPushers.lastSeenAt), asc(notificationPushers.id))
453
+ .limit(MAX_NOTIFICATION_PUSHER_PURGE);
454
+ if (stale.length === 0) return 0;
455
+ const deleted = await db.delete(notificationPushers).where(
456
+ and(
457
+ inArray(
458
+ notificationPushers.id,
459
+ stale.map((row) => row.id),
460
+ ),
461
+ lte(notificationPushers.lastSeenAt, cutoff),
462
+ ),
463
+ );
464
+ return affectedRowCount(deleted);
465
+ }
466
+
467
+ /**
468
+ * Remove at most one bounded batch of jobs past the retention window. ANY
469
+ * status qualifies: terminal rows have served their idempotency window, a
470
+ * pending row that old belongs to a pusher-less recipient (never enqueued —
471
+ * see the sweep's actorHasPusher filter), and no live in-flight state survives
472
+ * 90 days (stale queued/processing rows are reclaimed within minutes).
473
+ */
376
474
  export async function purgeExpiredNotificationPushJobs(
377
475
  db: Database,
378
476
  now = new Date(),
@@ -380,16 +478,10 @@ export async function purgeExpiredNotificationPushJobs(
380
478
  const cutoff = new Date(
381
479
  now.getTime() - NOTIFICATION_PUSH_JOB_RETENTION_DAYS * 86_400_000,
382
480
  ).toISOString();
383
- const terminalStatuses = ["delivered", "failed"] as const;
384
481
  const expired = await db
385
482
  .select({ id: notificationPushJobs.id })
386
483
  .from(notificationPushJobs)
387
- .where(
388
- and(
389
- inArray(notificationPushJobs.status, terminalStatuses),
390
- lte(notificationPushJobs.updatedAt, cutoff),
391
- ),
392
- )
484
+ .where(lte(notificationPushJobs.updatedAt, cutoff))
393
485
  .orderBy(asc(notificationPushJobs.updatedAt), asc(notificationPushJobs.id))
394
486
  .limit(MAX_NOTIFICATION_PUSH_JOB_PURGE);
395
487
  if (expired.length === 0) return 0;
@@ -400,7 +492,6 @@ export async function purgeExpiredNotificationPushJobs(
400
492
  notificationPushJobs.id,
401
493
  expired.map((row) => row.id),
402
494
  ),
403
- inArray(notificationPushJobs.status, terminalStatuses),
404
495
  lte(notificationPushJobs.updatedAt, cutoff),
405
496
  ),
406
497
  );
@@ -470,12 +561,17 @@ export async function processNotificationPushJob(
470
561
  eq(notificationPushJobs.id, job.id),
471
562
  eq(notificationPushJobs.status, job.status),
472
563
  eq(notificationPushJobs.updatedAt, job.updatedAt),
564
+ // Honor the backoff schedule: a duplicate message that survived a
565
+ // double-enqueue race must not claim a retry_wait row before its
566
+ // nextAttemptAt, which would bypass exponential backoff / Retry-After
567
+ // and hammer a gateway that is actively rate-limiting us.
568
+ lte(notificationPushJobs.nextAttemptAt, now),
473
569
  ),
474
570
  );
475
571
  if (affectedRowCount(claimed) === 0) {
476
- // Another Queue delivery owns this job now. Do not ack the competing
477
- // message permanently: a retry lets it observe the terminal row or recover
478
- // if that owner crashes after claiming.
572
+ // Another Queue delivery owns this job now, or it is not yet due. Do not
573
+ // ack the competing message permanently: a retry lets it observe the
574
+ // terminal row, become due, or recover if that owner crashes after claiming.
479
575
  message.retry({ delaySeconds: 30 });
480
576
  return;
481
577
  }
@@ -503,12 +599,10 @@ export async function processNotificationPushJob(
503
599
  (event.visibility === "direct" ? "yurume" : "yurucommu");
504
600
  let pendingIds = parsePendingIds(job.pendingPusherIdsJson);
505
601
  if (pendingIds === null) {
506
- const cutoff = new Date(
507
- Date.now() - NOTIFICATION_PUSHER_RETENTION_DAYS * 86_400_000,
508
- ).toISOString();
509
- await db
510
- .delete(notificationPushers)
511
- .where(lte(notificationPushers.lastSeenAt, cutoff));
602
+ // Retention purge of idle pushers is a bounded sweep in
603
+ // enqueuePendingNotificationPushJobs, NOT an unbounded all-actor DELETE
604
+ // on this hot per-job path. Stale rows simply resolve to zero deliveries
605
+ // here and get reaped by the sweep.
512
606
  pendingIds = (
513
607
  await db
514
608
  .select({ id: notificationPushers.id })
@@ -900,7 +994,7 @@ async function loadPushEvent(
900
994
  and(
901
995
  eq(activities.apId, activityApId),
902
996
  ne(activities.actorApId, actorApId),
903
- inArray(activities.type, SOCIAL_NOTIFICATION_ACTIVITY_TYPES),
997
+ inArray(activities.type, [...NOTIFICATION_ACTIVITY_TYPES]),
904
998
  exists(currentCommunityMembership),
905
999
  excludeBlockedMutedAuthors(db, actorApId, activities.actorApId),
906
1000
  ),
@@ -910,27 +1004,9 @@ async function loadPushEvent(
910
1004
 
911
1005
  // The inbox trigger intentionally captures every unread insert so it cannot
912
1006
  // lose a notification in a route-specific crash window. Eligibility is
913
- // therefore re-checked here immediately before external delivery. This
914
- // mirrors the notification list/count choke point: only user-facing activity
915
- // types, never self events, never archived rows, and never blocked/muted
916
- // senders. Direct Creates remain eligible and are routed to Yurume below.
917
- const archivedCorrelation = and(
918
- eq(notificationArchived.actorApId, inbox.actorApId),
919
- eq(notificationArchived.activityApId, inbox.activityApId),
920
- );
921
- const archivedSubquery = db
922
- .select({ activityApId: notificationArchived.activityApId })
923
- .from(notificationArchived)
924
- .where(archivedCorrelation);
925
- const archivedDmSubquery = db
926
- .select({ conversationId: dmArchivedConversations.conversationId })
927
- .from(dmArchivedConversations)
928
- .where(
929
- and(
930
- eq(dmArchivedConversations.actorApId, actorApId),
931
- eq(dmArchivedConversations.conversationId, objects.conversation),
932
- ),
933
- );
1007
+ // therefore re-checked here immediately before external delivery, using the
1008
+ // SAME shared predicate builder as the notification list/count. Direct
1009
+ // Creates remain eligible (archived DMs excepted) and route to Yurume below.
934
1010
  return selectEvent()
935
1011
  .from(inbox)
936
1012
  .innerJoin(activities, eq(inbox.activityApId, activities.apId))
@@ -939,18 +1015,9 @@ async function loadPushEvent(
939
1015
  and(
940
1016
  eq(inbox.actorApId, actorApId),
941
1017
  eq(inbox.activityApId, activityApId),
942
- ne(activities.actorApId, actorApId),
943
- inArray(activities.type, SOCIAL_NOTIFICATION_ACTIVITY_TYPES),
944
- notExists(archivedSubquery),
945
- // Archiving a Yurume conversation is a current delivery preference,
946
- // not merely a presentation filter. A queued direct event must stop at
947
- // this choke point if the recipient archived it after enqueue.
948
- or(
949
- isNull(objects.visibility),
950
- ne(objects.visibility, "direct"),
951
- notExists(archivedDmSubquery),
952
- )!,
953
- excludeBlockedMutedAuthors(db, actorApId, activities.actorApId),
1018
+ ...notificationEligibilityWhere(db, actorApId, {
1019
+ direct: "unless-dm-archived",
1020
+ }),
954
1021
  ),
955
1022
  )
956
1023
  .get();
@@ -963,63 +1030,13 @@ async function unreadCountForProduct(
963
1030
  product: SocialNotificationProduct,
964
1031
  ): Promise<number> {
965
1032
  if (product === "yurume") {
966
- // Keep parity with GET /api/dm/unread/count: direct messages use their
967
- // per-conversation read baseline, while community talk uses the later of
968
- // the membership join time and the per-community read baseline.
969
- const dmRow = await db.get<{ c: number }>(sql`
970
- SELECT COUNT(*) AS c
971
- FROM objects o
972
- JOIN object_recipients orp
973
- ON orp.object_ap_id = o.ap_id
974
- AND orp.recipient_ap_id = ${actorApId}
975
- AND orp.type = 'to'
976
- LEFT JOIN dm_read_status r
977
- ON r.conversation_id = o.conversation
978
- AND r.actor_ap_id = ${actorApId}
979
- WHERE o.visibility = 'direct'
980
- AND o.type = 'Note'
981
- AND o.conversation IS NOT NULL
982
- AND o.attributed_to != ${actorApId}
983
- AND o.published > COALESCE(r.last_read_at, '1970-01-01T00:00:00Z')
984
- AND o.conversation NOT IN (
985
- SELECT conversation_id FROM dm_archived_conversations
986
- WHERE actor_ap_id = ${actorApId}
987
- )
988
- `);
989
- const communityRow = await db.get<{ c: number }>(sql`
990
- SELECT COUNT(*) AS c
991
- FROM community_members cm
992
- JOIN object_recipients orp
993
- ON orp.recipient_ap_id = cm.community_ap_id
994
- AND orp.type = 'audience'
995
- JOIN objects o
996
- ON o.ap_id = orp.object_ap_id
997
- AND o.type = 'Note'
998
- AND o.community_ap_id IS NULL
999
- AND o.attributed_to != ${actorApId}
1000
- LEFT JOIN dm_community_read_status r
1001
- ON r.community_ap_id = cm.community_ap_id
1002
- AND r.actor_ap_id = ${actorApId}
1003
- WHERE cm.actor_ap_id = ${actorApId}
1004
- AND o.published > COALESCE(
1005
- r.last_read_at,
1006
- cm.joined_at,
1007
- '1970-01-01T00:00:00Z'
1008
- )
1009
- `);
1010
- return Number(dmRow?.c ?? 0) + Number(communityRow?.c ?? 0);
1033
+ // Reuse the SAME helper as GET /api/dm/unread/count so the badge a push
1034
+ // sets can never drift from the badge the client computes on open.
1035
+ return (await yurumeUnreadCounts(db, actorApId)).total;
1011
1036
  }
1012
1037
 
1013
- // Keep parity with the default social notification list/unread count. A
1014
- // Yurume DM row must never inflate the Yurucommu app badge.
1015
- const archivedCorrelation = and(
1016
- eq(notificationArchived.actorApId, inbox.actorApId),
1017
- eq(notificationArchived.activityApId, inbox.activityApId),
1018
- );
1019
- const archivedSubquery = db
1020
- .select({ activityApId: notificationArchived.activityApId })
1021
- .from(notificationArchived)
1022
- .where(archivedCorrelation);
1038
+ // Reuse the SAME eligibility predicate as the notification list/badge. A
1039
+ // Yurume DM row must never inflate the Yurucommu app badge (direct: exclude).
1023
1040
  const row = await db
1024
1041
  .select({ count: sql<number>`COUNT(*)` })
1025
1042
  .from(inbox)
@@ -1029,11 +1046,7 @@ async function unreadCountForProduct(
1029
1046
  and(
1030
1047
  eq(inbox.actorApId, actorApId),
1031
1048
  eq(inbox.read, 0),
1032
- ne(activities.actorApId, actorApId),
1033
- inArray(activities.type, SOCIAL_NOTIFICATION_ACTIVITY_TYPES),
1034
- or(isNull(objects.visibility), ne(objects.visibility, "direct"))!,
1035
- notExists(archivedSubquery),
1036
- excludeBlockedMutedAuthors(db, actorApId, activities.actorApId),
1049
+ ...notificationEligibilityWhere(db, actorApId, { direct: "exclude" }),
1037
1050
  ),
1038
1051
  )
1039
1052
  .get();
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Durable persistence for call sessions (history / missed-call / current state).
3
+ * Ephemeral SDP/ICE never touches this — only the call lifecycle does.
4
+ */
5
+
6
+ import { and, desc, eq } from "drizzle-orm";
7
+ import type { Database } from "../../../db/index.ts";
8
+ import { callSessions, nowIso } from "../../../db/index.ts";
9
+ import type {
10
+ CallSessionSummary,
11
+ CallDirection,
12
+ CallState,
13
+ } from "../../../../packages/api/src/types/call.ts";
14
+ import { isTerminalCallState } from "../../../../packages/api/src/types/call.ts";
15
+ import type { CallRecord } from "../../runtime/call-hub-core.ts";
16
+
17
+ /** Insert or update the durable row for a call transition. */
18
+ export async function upsertCallSession(
19
+ db: Database,
20
+ localActorApId: string,
21
+ call: CallRecord,
22
+ ): Promise<void> {
23
+ const now = nowIso();
24
+ const terminal = isTerminalCallState(call.state);
25
+ const connectedAt = call.connectedAt
26
+ ? new Date(call.connectedAt).toISOString()
27
+ : null;
28
+ const sfuFocus = call.sfuFocus ? JSON.stringify(call.sfuFocus) : null;
29
+ await db
30
+ .insert(callSessions)
31
+ .values({
32
+ id: call.callId,
33
+ localActorApId,
34
+ peerActorApId: call.peerApId,
35
+ direction: call.direction,
36
+ state: call.state,
37
+ mediaAudio: call.media.audio ? 1 : 0,
38
+ mediaVideo: call.media.video ? 1 : 0,
39
+ sfuFocus,
40
+ peerSignalEndpoint: call.peerSignalEndpoint ?? null,
41
+ connectedAt,
42
+ endedAt: terminal ? now : null,
43
+ })
44
+ .onConflictDoUpdate({
45
+ target: callSessions.id,
46
+ set: {
47
+ state: call.state,
48
+ sfuFocus,
49
+ peerSignalEndpoint: call.peerSignalEndpoint ?? null,
50
+ connectedAt,
51
+ endedAt: terminal ? now : null,
52
+ updatedAt: now,
53
+ },
54
+ });
55
+ }
56
+
57
+ function toSummary(row: typeof callSessions.$inferSelect): CallSessionSummary {
58
+ return {
59
+ id: row.id,
60
+ peer: row.peerActorApId,
61
+ direction: row.direction as CallDirection,
62
+ state: row.state as CallState,
63
+ media: { audio: row.mediaAudio === 1, video: row.mediaVideo === 1 },
64
+ createdAt: row.createdAt,
65
+ connectedAt: row.connectedAt,
66
+ endedAt: row.endedAt,
67
+ endReason: row.endReason,
68
+ };
69
+ }
70
+
71
+ export async function listCallSessions(
72
+ db: Database,
73
+ localActorApId: string,
74
+ limit = 50,
75
+ ): Promise<CallSessionSummary[]> {
76
+ const rows = await db
77
+ .select()
78
+ .from(callSessions)
79
+ .where(eq(callSessions.localActorApId, localActorApId))
80
+ .orderBy(desc(callSessions.createdAt))
81
+ .limit(Math.min(Math.max(limit, 1), 200));
82
+ return rows.map(toSummary);
83
+ }
84
+
85
+ export async function getCallSession(
86
+ db: Database,
87
+ localActorApId: string,
88
+ callId: string,
89
+ ): Promise<CallSessionSummary | null> {
90
+ const row = await db
91
+ .select()
92
+ .from(callSessions)
93
+ .where(
94
+ and(
95
+ eq(callSessions.id, callId),
96
+ eq(callSessions.localActorApId, localActorApId),
97
+ ),
98
+ )
99
+ .get();
100
+ return row ? toSummary(row) : null;
101
+ }