@takosjp/yurucommu-core 3.0.3 → 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.
Files changed (40) hide show
  1. package/README.en.md +92 -0
  2. package/README.md +56 -47
  3. package/migrations/0019_notification_push_delivery.sql +103 -0
  4. package/migrations/README.md +7 -6
  5. package/package.json +11 -5
  6. package/packages/api/package.json +1 -1
  7. package/packages/api/src/lib/api/browser-push.ts +545 -0
  8. package/packages/api/src/lib/api/communities.ts +25 -2
  9. package/packages/api/src/lib/api/dm.ts +14 -2
  10. package/packages/api/src/lib/api/normalize.ts +15 -4
  11. package/packages/api/src/lib/api/notification-target.ts +106 -0
  12. package/packages/api/src/lib/api/notifications.ts +53 -1
  13. package/packages/api/src/lib/api/push-config.ts +132 -0
  14. package/packages/api/src/lib/api.ts +3 -0
  15. package/packages/api/src/social-server.ts +9 -0
  16. package/packages/api/src/types/index.ts +48 -0
  17. package/src/backend/index.ts +67 -3
  18. package/src/backend/lib/attachments.ts +52 -0
  19. package/src/backend/lib/delivery/queue.ts +73 -0
  20. package/src/backend/lib/delivery/types.ts +15 -1
  21. package/src/backend/lib/notification-eligibility.ts +150 -0
  22. package/src/backend/lib/notification-push.ts +1213 -0
  23. package/src/backend/lib/notification-pusher-contract.ts +340 -0
  24. package/src/backend/lib/oauth-providers.ts +7 -6
  25. package/src/backend/lib/session-actor.ts +16 -1
  26. package/src/backend/lib/unread-counts.ts +79 -0
  27. package/src/backend/middleware/csrf.ts +11 -0
  28. package/src/backend/routes/account-teardown.ts +13 -0
  29. package/src/backend/routes/auth-helpers.ts +10 -7
  30. package/src/backend/routes/auth.ts +124 -2
  31. package/src/backend/routes/communities/messages.ts +123 -9
  32. package/src/backend/routes/dm/contacts.ts +6 -44
  33. package/src/backend/routes/dm/messages.ts +51 -4
  34. package/src/backend/routes/notification-pushers.ts +93 -0
  35. package/src/backend/routes/notifications.ts +76 -69
  36. package/src/backend/routes/posts/transformers.ts +8 -10
  37. package/src/backend/server.ts +6 -0
  38. package/src/backend/types.ts +12 -0
  39. package/src/db/index.ts +11 -10
  40. package/src/db/schema/mobile.ts +99 -1
@@ -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
@@ -233,6 +232,47 @@ function encodeNotifCursor(row: { created_at: string; id: string }): string {
233
232
  return `${row.created_at}${NOTIF_CURSOR_SEP}${row.id}`;
234
233
  }
235
234
 
235
+ function notificationTarget(
236
+ type: string | null,
237
+ activityActorApId: string,
238
+ objectApId: string | null,
239
+ objectType: string | null,
240
+ ): {
241
+ target_kind: "post" | "story" | "profile" | "notifications";
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.
246
+ target_url: string;
247
+ } {
248
+ if (type === "follow" || type === "follow_request") {
249
+ return {
250
+ target_kind: "profile",
251
+ target_id: activityActorApId,
252
+ target_url: `/profile/${encodeURIComponent(activityActorApId)}`,
253
+ };
254
+ }
255
+ if (objectApId && objectType === "Story") {
256
+ return {
257
+ target_kind: "story",
258
+ target_id: objectApId,
259
+ target_url: `/?story=${encodeURIComponent(objectApId)}`,
260
+ };
261
+ }
262
+ if (objectApId) {
263
+ return {
264
+ target_kind: "post",
265
+ target_id: objectApId,
266
+ target_url: `/post/${encodeURIComponent(objectApId)}`,
267
+ };
268
+ }
269
+ return {
270
+ target_kind: "notifications",
271
+ target_id: null,
272
+ target_url: "/notifications",
273
+ };
274
+ }
275
+
236
276
  // ---------------------------------------------------------------------------
237
277
  // Routes
238
278
  // ---------------------------------------------------------------------------
@@ -267,37 +307,22 @@ notifications.get("/", async (c) => {
267
307
  ? typeToActivityType[typeFilter]
268
308
  : NOTIFICATION_ACTIVITY_TYPES;
269
309
 
270
- // Archive partition pushed INTO SQL as a correlated EXISTS / NOT EXISTS, not a
271
- // post-query filter. Filtering archived rows out in the result loop made two
272
- // bugs: (1) `has_more` under-reporteda page whose limit+1 probe rows were
273
- // mostly the wrong archive state returned < limit items yet there were older
274
- // pages, so the client stopped loading; (2) every archived id for the actor
275
- // was loaded into an unbounded in-memory Set per request. As an SQL predicate
276
- // the limit+1 probe counts only rows that actually belong on the page.
277
- const archivedCorrelation = and(
278
- eq(notificationArchived.actorApId, inboxTable.actorApId),
279
- eq(notificationArchived.activityApId, inboxTable.activityApId),
280
- );
281
- const archivedSubquery = db
282
- .select({ activityApId: notificationArchived.activityApId })
283
- .from(notificationArchived)
284
- .where(archivedCorrelation);
285
- const archiveCondition = showArchived
286
- ? exists(archivedSubquery)
287
- : notExists(archivedSubquery);
288
-
289
- // Build inbox query with JOIN to activities. A direct (DM) Note is delivered
290
- // as a `Create` inbox row just like a mention, so without excluding it every
291
- // DM would double-surface here as a "mention" (with its body) AND inflate the
292
- // unread badge independently of the DM view. LEFT JOIN the object and drop
293
- // direct-visibility Creates; the join is LEFT because Follow's object is an
294
- // 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).
295
319
  const conditions = [
296
320
  eq(inboxTable.actorApId, actor.ap_id),
297
- ne(activities.actorApId, actor.ap_id),
298
- inArray(activities.type, activityTypes),
299
- or(isNull(objects.visibility), ne(objects.visibility, "direct"))!,
300
- archiveCondition,
321
+ ...notificationEligibilityWhere(db, actor.ap_id, {
322
+ direct: "exclude",
323
+ archived: showArchived ? "only" : "exclude",
324
+ activityTypes,
325
+ }),
301
326
  ];
302
327
  // reply vs mention both map to a Create; the split is whether the Create's
303
328
  // object is a reply (`inReplyTo` set). Pushed into SQL too so a type-filtered
@@ -310,17 +335,6 @@ notifications.get("/", async (c) => {
310
335
  if (before) {
311
336
  conditions.push(notifCursorPredicate(decodeNotifCursor(before)));
312
337
  }
313
- // Suppress notifications whose actor the recipient has blocked or muted. This
314
- // is the read-time choke point: mutes are read-only everywhere, and not every
315
- // notify WRITE path block-checks, so gating here covers like/repost/follow/
316
- // reply/mention (local AND federated) for both blocks and mutes. Keyed on the
317
- // activity actor (subquery-scoped → D1-param-safe).
318
- const listBlockMute = excludeBlockedMutedAuthors(
319
- db,
320
- actor.ap_id,
321
- activities.actorApId,
322
- );
323
- if (listBlockMute) conditions.push(listBlockMute);
324
338
 
325
339
  const inboxEntries = await db
326
340
  .select({
@@ -358,6 +372,7 @@ notifications.get("/", async (c) => {
358
372
  ? db
359
373
  .select({
360
374
  apId: objects.apId,
375
+ type: objects.type,
361
376
  content: objects.content,
362
377
  inReplyTo: objects.inReplyTo,
363
378
  audienceJson: objects.audienceJson,
@@ -438,6 +453,7 @@ notifications.get("/", async (c) => {
438
453
  objectRows.map((o) => [
439
454
  o.apId,
440
455
  {
456
+ type: o.type,
441
457
  content: readableObjectIds.has(o.apId) ? o.content : "",
442
458
  inReplyTo: o.inReplyTo,
443
459
  },
@@ -464,6 +480,9 @@ notifications.get("/", async (c) => {
464
480
  icon_url: string | null;
465
481
  };
466
482
  object_content: string;
483
+ target_kind: "post" | "story" | "profile" | "notifications";
484
+ target_id: string | null;
485
+ target_url: string;
467
486
  }> = [];
468
487
 
469
488
  for (const entry of inboxEntries) {
@@ -484,6 +503,12 @@ notifications.get("/", async (c) => {
484
503
  followStatus,
485
504
  );
486
505
  const actorInfo = actorMap.get(entry.activityActorApId);
506
+ const target = notificationTarget(
507
+ notifType,
508
+ entry.activityActorApId,
509
+ entry.activityObjectApId,
510
+ objectData?.type ?? null,
511
+ );
487
512
 
488
513
  notifications_list.push({
489
514
  id: entry.activityApId,
@@ -499,6 +524,7 @@ notifications.get("/", async (c) => {
499
524
  icon_url: actorInfo?.iconUrl ?? null,
500
525
  },
501
526
  object_content: objectData?.content ?? "",
527
+ ...target,
502
528
  });
503
529
  }
504
530
 
@@ -528,23 +554,10 @@ notifications.get("/unread/count", async (c) => {
528
554
  const db = c.get("db");
529
555
  await maybeCleanupArchivedNotifications(db, actor.ap_id);
530
556
 
531
- // Mirror the list query's DM exclusion (see GET /): a direct Note's Create
532
- // inbox row must not count toward the notification badge — the DM has its own
533
- // unread badge, and marking it read never clears this inbox row.
534
- // Mirror the default list view's archive exclusion: an archived notification
535
- // is hidden from the inbox list, so it must NOT count toward the badge either —
536
- // otherwise archiving an UNREAD notification (read stays 0) leaves a phantom
537
- // count the client can never clear (its mark-read sweep only touches rows the
538
- // inbox view returns, which no longer include the archived one).
539
- const archivedSubquery = db
540
- .select({ activityApId: notificationArchived.activityApId })
541
- .from(notificationArchived)
542
- .where(
543
- and(
544
- eq(notificationArchived.actorApId, inboxTable.actorApId),
545
- eq(notificationArchived.activityApId, inboxTable.activityApId),
546
- ),
547
- );
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.
548
561
  const result = await db
549
562
  .select({ count: count() })
550
563
  .from(inboxTable)
@@ -554,13 +567,7 @@ notifications.get("/unread/count", async (c) => {
554
567
  and(
555
568
  eq(inboxTable.actorApId, actor.ap_id),
556
569
  eq(inboxTable.read, 0),
557
- ne(activities.actorApId, actor.ap_id),
558
- inArray(activities.type, NOTIFICATION_ACTIVITY_TYPES),
559
- or(isNull(objects.visibility), ne(objects.visibility, "direct"))!,
560
- notExists(archivedSubquery),
561
- // Mirror the list query: don't count notifications from blocked/muted
562
- // actors toward the unread badge.
563
- excludeBlockedMutedAuthors(db, actor.ap_id, activities.actorApId),
570
+ ...notificationEligibilityWhere(db, actor.ap_id, { direct: "exclude" }),
564
571
  ),
565
572
  )
566
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
@@ -96,6 +96,12 @@ const ENV_PASSTHROUGH_KEYS = [
96
96
  "DELIVERY_DLQ_NAME",
97
97
  "YURUCOMMU_ENABLE_LOCAL_SUBSTRATE_REMOTE_FETCHES",
98
98
  "YURUCOMMU_ENABLE_LOCAL_DELIVERY_QUEUE",
99
+ "YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_ALLOWED_HOSTS",
100
+ "YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_URL",
101
+ "YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TOKEN",
102
+ "YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TIMEOUT_MS",
103
+ "YURUCOMMU_NOTIFICATION_PUSH_ALLOW_INSECURE_LOOPBACK",
104
+ "YURUCOMMU_NOTIFICATION_PUSH_WEB_PUSH_PUBLIC_KEY",
99
105
  ] as const;
100
106
 
101
107
  function isTruthyEnv(value: string | undefined): boolean {
@@ -67,6 +67,18 @@ export interface EnvVars {
67
67
  // app falls back to the YURUCOMMU_VERSION default constant.
68
68
  YURUCOMMU_SOFTWARE_VERSION?: string;
69
69
 
70
+ // Product-neutral notification pusher gateway. Public gateway registration
71
+ // is fail-closed until its hostname is explicitly allowlisted. The bearer is
72
+ // attached only when data.url exactly matches the canonical URL.
73
+ YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_ALLOWED_HOSTS?: string;
74
+ YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_URL?: string;
75
+ YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TOKEN?: string;
76
+ YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TIMEOUT_MS?: string;
77
+ YURUCOMMU_NOTIFICATION_PUSH_ALLOW_INSECURE_LOOPBACK?: string;
78
+ // Public VAPID application-server key exposed to authenticated browser
79
+ // clients. The corresponding private key remains gateway-owned.
80
+ YURUCOMMU_NOTIFICATION_PUSH_WEB_PUSH_PUBLIC_KEY?: string;
81
+
70
82
  // CSRF allowed origins (comma-separated). APP_URL の origin に加えて
71
83
  // 受け付ける追加 origin (= dev hostname (`https://yurucommu.test`) を
72
84
  // production-equivalent な strict CSRF check 経由で踏むため)。 未設定なら
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;
@@ -2,7 +2,13 @@
2
2
  * Mobile client tables.
3
3
  */
4
4
 
5
- import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
5
+ import {
6
+ index,
7
+ integer,
8
+ sqliteTable,
9
+ text,
10
+ uniqueIndex,
11
+ } from "drizzle-orm/sqlite-core";
6
12
  import { nowIsoUtc } from "./date-utils.ts";
7
13
  import { actors } from "./actors.ts";
8
14
 
@@ -35,3 +41,95 @@ export const mobilePushRegistrations = sqliteTable(
35
41
  index("mobile_push_registrations_last_seen_idx").on(t.lastSeenAt),
36
42
  ],
37
43
  );
44
+
45
+ /**
46
+ * Product-neutral notification pushers shared by yurucommu-family clients.
47
+ *
48
+ * `pushkey` is an opaque downstream-provider identifier. Provider credentials
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.
54
+ */
55
+ export const notificationPushers = sqliteTable(
56
+ "notification_pushers",
57
+ {
58
+ id: text("id").primaryKey(),
59
+ actorApId: text("actor_ap_id").notNull(),
60
+ product: text("product").notNull(),
61
+ scope: text("scope"),
62
+ kind: text("kind").notNull().default("http"),
63
+ appId: text("app_id").notNull(),
64
+ pushkey: text("pushkey").notNull(),
65
+ pushkeyHash: text("pushkey_hash").notNull(),
66
+ appDisplayName: text("app_display_name"),
67
+ deviceDisplayName: text("device_display_name"),
68
+ profileTag: text("profile_tag"),
69
+ lang: text("lang"),
70
+ dataJson: text("data_json").notNull().default("{}"),
71
+ gatewayUrl: text("gateway_url").notNull(),
72
+ createdAt: text("created_at").notNull().$defaultFn(nowIsoUtc),
73
+ updatedAt: text("updated_at")
74
+ .notNull()
75
+ .$defaultFn(nowIsoUtc)
76
+ .$onUpdateFn(nowIsoUtc),
77
+ lastSeenAt: text("last_seen_at").notNull().$defaultFn(nowIsoUtc),
78
+ },
79
+ (t) => [
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.
83
+ uniqueIndex("notification_pushers_device_idx").on(
84
+ t.product,
85
+ t.appId,
86
+ t.pushkeyHash,
87
+ ),
88
+ index("notification_pushers_last_seen_idx").on(t.lastSeenAt),
89
+ ],
90
+ );
91
+
92
+ /**
93
+ * Durable outbox for push delivery. A migration-owned trigger writes one row
94
+ * whenever an unread inbox entry is created. Queue messages contain only `id`.
95
+ */
96
+ export const notificationPushJobs = sqliteTable(
97
+ "notification_push_jobs",
98
+ {
99
+ id: text("id").primaryKey(),
100
+ actorApId: text("actor_ap_id").notNull(),
101
+ activityApId: text("activity_ap_id").notNull(),
102
+ // Explicit for notification sources that do not create an inbox row (for
103
+ // example community talk). NULL means infer social/direct from the object.
104
+ product: text("product"),
105
+ status: text("status").notNull().default("pending"),
106
+ // Per-claim fencing token. Every processing-state mutation must match this
107
+ // value so an expired worker cannot overwrite a reclaimed job.
108
+ processingToken: text("processing_token"),
109
+ attempts: integer("attempts").notNull().default(0),
110
+ pendingPusherIdsJson: text("pending_pusher_ids_json"),
111
+ nextAttemptAt: text("next_attempt_at").notNull().$defaultFn(nowIsoUtc),
112
+ lastError: text("last_error"),
113
+ createdAt: text("created_at").notNull().$defaultFn(nowIsoUtc),
114
+ updatedAt: text("updated_at")
115
+ .notNull()
116
+ .$defaultFn(nowIsoUtc)
117
+ .$onUpdateFn(nowIsoUtc),
118
+ deliveredAt: text("delivered_at"),
119
+ },
120
+ (t) => [
121
+ uniqueIndex("notification_push_jobs_actor_activity_idx").on(
122
+ t.actorApId,
123
+ t.activityApId,
124
+ ),
125
+ index("notification_push_jobs_status_next_idx").on(
126
+ t.status,
127
+ t.nextAttemptAt,
128
+ ),
129
+ index("notification_push_jobs_terminal_retention_idx").on(
130
+ t.status,
131
+ t.updatedAt,
132
+ ),
133
+ index("notification_push_jobs_actor_idx").on(t.actorApId),
134
+ ],
135
+ );