@takosjp/yurucommu-core 3.2.0 → 3.2.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.
@@ -6,13 +6,10 @@ import {
6
6
  count,
7
7
  desc,
8
8
  eq,
9
- exists,
10
9
  inArray,
11
10
  isNotNull,
12
11
  isNull,
13
12
  lt,
14
- ne,
15
- notExists,
16
13
  or,
17
14
  type SQL,
18
15
  } from "drizzle-orm";
@@ -34,7 +31,10 @@ import { batchLoadActorInfo } from "./communities/membership-shared.ts";
34
31
  import { requireActor } from "./actors-helpers.ts";
35
32
  import { communityReadableApIds } from "../lib/community-visibility.ts";
36
33
  import { chunkForInClause } from "../lib/chunk.ts";
37
- import { excludeBlockedMutedAuthors } from "../lib/feed-exclude.ts";
34
+ import {
35
+ NOTIFICATION_ACTIVITY_TYPES,
36
+ notificationEligibilityWhere,
37
+ } from "../lib/notification-eligibility.ts";
38
38
 
39
39
  const notifications = new Hono<{ Bindings: Env; Variables: Variables }>();
40
40
 
@@ -53,7 +53,6 @@ const ARCHIVE_CREATE_BATCH_SIZE = 30;
53
53
  // the rest drains on later runs.
54
54
  const ARCHIVE_CLEANUP_BATCH = 200;
55
55
  const ARCHIVE_ALL_CAP = 1000;
56
- const NOTIFICATION_ACTIVITY_TYPES = ["Follow", "Like", "Announce", "Create"];
57
56
 
58
57
  /**
59
58
  * Tracks the last cleanup timestamp per actor so cleanup is throttled to one
@@ -241,6 +240,9 @@ function notificationTarget(
241
240
  ): {
242
241
  target_kind: "post" | "story" | "profile" | "notifications";
243
242
  target_id: string | null;
243
+ // Same-origin in-app path shaped for the yurucommu web client's routing.
244
+ // Other clients (e.g. yurume's distinct IA) must treat target_kind/target_id
245
+ // as authoritative and build their own path, not follow target_url blindly.
244
246
  target_url: string;
245
247
  } {
246
248
  if (type === "follow" || type === "follow_request") {
@@ -305,37 +307,22 @@ notifications.get("/", async (c) => {
305
307
  ? typeToActivityType[typeFilter]
306
308
  : NOTIFICATION_ACTIVITY_TYPES;
307
309
 
308
- // Archive partition pushed INTO SQL as a correlated EXISTS / NOT EXISTS, not a
309
- // post-query filter. Filtering archived rows out in the result loop made two
310
- // bugs: (1) `has_more` under-reporteda page whose limit+1 probe rows were
311
- // mostly the wrong archive state returned < limit items yet there were older
312
- // pages, so the client stopped loading; (2) every archived id for the actor
313
- // was loaded into an unbounded in-memory Set per request. As an SQL predicate
314
- // the limit+1 probe counts only rows that actually belong on the page.
315
- const archivedCorrelation = and(
316
- eq(notificationArchived.actorApId, inboxTable.actorApId),
317
- eq(notificationArchived.activityApId, inboxTable.activityApId),
318
- );
319
- const archivedSubquery = db
320
- .select({ activityApId: notificationArchived.activityApId })
321
- .from(notificationArchived)
322
- .where(archivedCorrelation);
323
- const archiveCondition = showArchived
324
- ? exists(archivedSubquery)
325
- : notExists(archivedSubquery);
326
-
327
- // Build inbox query with JOIN to activities. A direct (DM) Note is delivered
328
- // as a `Create` inbox row just like a mention, so without excluding it every
329
- // DM would double-surface here as a "mention" (with its body) AND inflate the
330
- // unread badge independently of the DM view. LEFT JOIN the object and drop
331
- // direct-visibility Creates; the join is LEFT because Follow's object is an
332
- // actor (no `objects` row) → NULL visibility must be kept.
310
+ // Build inbox query with JOIN to activities. Shared eligibility predicate
311
+ // (lib/notification-eligibility.ts) the SAME builder used by the unread
312
+ // badge and push deliverysupplies: not-self, user-facing types, the
313
+ // archive partition (pushed into SQL as a correlated EXISTS/NOT EXISTS so the
314
+ // limit+1 probe counts only rows that belong on the page, not a post-query
315
+ // filter), the direct-DM exclusion (a DM's `Create` inbox row must not
316
+ // double-surface here as a mention), and block/mute suppression. Direct
317
+ // Creates are dropped via LEFT JOIN + NULL-visibility keep (Follow's object
318
+ // is an actor, no `objects` row).
333
319
  const conditions = [
334
320
  eq(inboxTable.actorApId, actor.ap_id),
335
- ne(activities.actorApId, actor.ap_id),
336
- inArray(activities.type, activityTypes),
337
- or(isNull(objects.visibility), ne(objects.visibility, "direct"))!,
338
- archiveCondition,
321
+ ...notificationEligibilityWhere(db, actor.ap_id, {
322
+ direct: "exclude",
323
+ archived: showArchived ? "only" : "exclude",
324
+ activityTypes,
325
+ }),
339
326
  ];
340
327
  // reply vs mention both map to a Create; the split is whether the Create's
341
328
  // object is a reply (`inReplyTo` set). Pushed into SQL too so a type-filtered
@@ -348,17 +335,6 @@ notifications.get("/", async (c) => {
348
335
  if (before) {
349
336
  conditions.push(notifCursorPredicate(decodeNotifCursor(before)));
350
337
  }
351
- // Suppress notifications whose actor the recipient has blocked or muted. This
352
- // is the read-time choke point: mutes are read-only everywhere, and not every
353
- // notify WRITE path block-checks, so gating here covers like/repost/follow/
354
- // reply/mention (local AND federated) for both blocks and mutes. Keyed on the
355
- // activity actor (subquery-scoped → D1-param-safe).
356
- const listBlockMute = excludeBlockedMutedAuthors(
357
- db,
358
- actor.ap_id,
359
- activities.actorApId,
360
- );
361
- if (listBlockMute) conditions.push(listBlockMute);
362
338
 
363
339
  const inboxEntries = await db
364
340
  .select({
@@ -504,7 +480,7 @@ notifications.get("/", async (c) => {
504
480
  icon_url: string | null;
505
481
  };
506
482
  object_content: string;
507
- target_kind: "post" | "story" | "profile" | "community" | "notifications";
483
+ target_kind: "post" | "story" | "profile" | "notifications";
508
484
  target_id: string | null;
509
485
  target_url: string;
510
486
  }> = [];
@@ -578,23 +554,10 @@ notifications.get("/unread/count", async (c) => {
578
554
  const db = c.get("db");
579
555
  await maybeCleanupArchivedNotifications(db, actor.ap_id);
580
556
 
581
- // Mirror the list query's DM exclusion (see GET /): a direct Note's Create
582
- // inbox row must not count toward the notification badge — the DM has its own
583
- // unread badge, and marking it read never clears this inbox row.
584
- // Mirror the default list view's archive exclusion: an archived notification
585
- // is hidden from the inbox list, so it must NOT count toward the badge either —
586
- // otherwise archiving an UNREAD notification (read stays 0) leaves a phantom
587
- // count the client can never clear (its mark-read sweep only touches rows the
588
- // inbox view returns, which no longer include the archived one).
589
- const archivedSubquery = db
590
- .select({ activityApId: notificationArchived.activityApId })
591
- .from(notificationArchived)
592
- .where(
593
- and(
594
- eq(notificationArchived.actorApId, inboxTable.actorApId),
595
- eq(notificationArchived.activityApId, inboxTable.activityApId),
596
- ),
597
- );
557
+ // SAME shared eligibility builder as the list and push delivery: not-self,
558
+ // user-facing types, archive exclusion (an archived UNREAD notification must
559
+ // not leave a phantom count the client can never clear), the direct-DM
560
+ // exclusion (a DM has its own badge), and block/mute suppression.
598
561
  const result = await db
599
562
  .select({ count: count() })
600
563
  .from(inboxTable)
@@ -604,13 +567,7 @@ notifications.get("/unread/count", async (c) => {
604
567
  and(
605
568
  eq(inboxTable.actorApId, actor.ap_id),
606
569
  eq(inboxTable.read, 0),
607
- ne(activities.actorApId, actor.ap_id),
608
- inArray(activities.type, NOTIFICATION_ACTIVITY_TYPES),
609
- or(isNull(objects.visibility), ne(objects.visibility, "direct"))!,
610
- notExists(archivedSubquery),
611
- // Mirror the list query: don't count notifications from blocked/muted
612
- // actors toward the unread badge.
613
- excludeBlockedMutedAuthors(db, actor.ap_id, activities.actorApId),
570
+ ...notificationEligibilityWhere(db, actor.ap_id, { direct: "exclude" }),
614
571
  ),
615
572
  )
616
573
  .get();
@@ -2,12 +2,14 @@ import { formatUsername, safeJsonParse } from "../../federation-helpers.ts";
2
2
 
3
3
  export const MAX_POST_CONTENT_LENGTH = 5000;
4
4
  export const MAX_POST_SUMMARY_LENGTH = 500;
5
- // Bound the attachments payload. PostAttachment is an open-ended record, so cap
6
- // both the COUNT and the serialized SIZE — the size cap bounds row/federated-doc
7
- // bloat regardless of internal shape (key count / field length). 16 KiB is ample
8
- // for MAX_ATTACHMENTS media descriptors with alt text + blurhash.
9
- export const MAX_ATTACHMENTS = 8;
10
- export const MAX_ATTACHMENTS_JSON_LENGTH = 16 * 1024;
5
+ // Attachment bounds live in lib/attachments.ts (shared with the DM and
6
+ // community-chat validators); re-exported here for the existing post-route and
7
+ // inbound-handler importers.
8
+ export {
9
+ boundAttachmentsJson,
10
+ MAX_ATTACHMENTS,
11
+ MAX_ATTACHMENTS_JSON_LENGTH,
12
+ } from "../../lib/attachments.ts";
11
13
 
12
14
  /** Truncate a string to `max` characters (no-op when already within bounds). */
13
15
  export function truncate(s: string, max: number): string {
@@ -28,10 +30,6 @@ export function boundInboundSummary(summary: unknown): string | null {
28
30
  ? truncate(summary, MAX_POST_SUMMARY_LENGTH)
29
31
  : null;
30
32
  }
31
- /** Drop an oversized inbound attachments blob to "[]" rather than store it. */
32
- export function boundAttachmentsJson(json: string): string {
33
- return json.length > MAX_ATTACHMENTS_JSON_LENGTH ? "[]" : json;
34
- }
35
33
  // Capped at 90 (not 100): a page's object ids are re-queried via
36
34
  // `inArray(col, objectApIds)` for like/bookmark enrichment, and Cloudflare D1
37
35
  // allows at most 100 bound parameters per query. 90 leaves headroom for the
package/src/db/index.ts CHANGED
@@ -47,16 +47,17 @@ export async function getDbSQLite(databasePath: string): Promise<Database> {
47
47
  const { drizzle } = await import("drizzle-orm/libsql");
48
48
 
49
49
  const client = createClient({ url: `file:${databasePath}` });
50
- // Foreign keys are explicitly turned OFF so the libsql engine matches
51
- // Cloudflare D1, which ignores the FK constraints declared in the
52
- // migrations. NOTE: libsql (unlike bun:sqlite / stock SQLite, which default
53
- // OFF) defaults foreign_keys ON, so this must be set explicitly — simply
54
- // not enabling it is not enough. Remote actors are stored in actor_cache
55
- // (never in actors), yet objects.attributed_to / follows.* / likes.* /
56
- // announces.* FK-reference actors(ap_id); enforcement would make every
57
- // inbound federated activity from a remote actor violate the FK and fail to
58
- // insert. Referential cleanup is handled at the app level by
59
- // delete-cascade.ts, identically on D1.
50
+ // Foreign keys are explicitly turned OFF so the libsql engine matches the
51
+ // MIGRATED production schema. Cloudflare D1 ENFORCES declared FKs (the old
52
+ // "D1 ignores FK" assumption was wrong see migrations/0011, which exists
53
+ // precisely because enforcement broke inbound federation): remote actors
54
+ // live only in actor_cache, never in actors, so 0010/0011 REBUILT the
55
+ // affected tables to drop their actors FKs. Post-0011 the schema declares
56
+ // essentially no FKs and referential cleanup is handled at the app level by
57
+ // delete-cascade.ts / account-teardown.ts, identically on D1. NOTE: libsql
58
+ // (unlike bun:sqlite / stock SQLite, which default OFF) defaults
59
+ // foreign_keys ON, so this must be set explicitly — pre-0010 FKs still
60
+ // present in older local DB files must not diverge from D1 behavior.
60
61
  await client.execute("PRAGMA foreign_keys = OFF");
61
62
  sqliteDb = drizzle(client, { schema });
62
63
  return sqliteDb;
@@ -47,14 +47,16 @@ export const mobilePushRegistrations = sqliteTable(
47
47
  *
48
48
  * `pushkey` is an opaque downstream-provider identifier. Provider credentials
49
49
  * never live in this table; they stay in the configured stateless gateway.
50
+ *
51
+ * NO foreign key on actor_ap_id: D1 ENFORCES declared FKs (0010/0011 dropped
52
+ * the actors FKs for that reason) and cleanup is app-level
53
+ * (routes/account-teardown.ts), matching the rest of the schema.
50
54
  */
51
55
  export const notificationPushers = sqliteTable(
52
56
  "notification_pushers",
53
57
  {
54
58
  id: text("id").primaryKey(),
55
- actorApId: text("actor_ap_id")
56
- .notNull()
57
- .references(() => actors.apId),
59
+ actorApId: text("actor_ap_id").notNull(),
58
60
  product: text("product").notNull(),
59
61
  scope: text("scope"),
60
62
  kind: text("kind").notNull().default("http"),
@@ -75,13 +77,9 @@ export const notificationPushers = sqliteTable(
75
77
  lastSeenAt: text("last_seen_at").notNull().$defaultFn(nowIsoUtc),
76
78
  },
77
79
  (t) => [
78
- uniqueIndex("notification_pushers_actor_product_app_pushkey_idx").on(
79
- t.actorApId,
80
- t.product,
81
- t.appId,
82
- t.pushkeyHash,
83
- ),
84
80
  index("notification_pushers_actor_product_idx").on(t.actorApId, t.product),
81
+ // Device uniqueness — strictly stronger than any actor-scoped unique
82
+ // variant, so this is the ONLY unique index.
85
83
  uniqueIndex("notification_pushers_device_idx").on(
86
84
  t.product,
87
85
  t.appId,