@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.
- package/README.en.md +92 -0
- package/README.md +56 -47
- package/migrations/0019_notification_push_delivery.sql +103 -0
- package/migrations/README.md +7 -6
- package/package.json +11 -5
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/browser-push.ts +545 -0
- package/packages/api/src/lib/api/communities.ts +25 -2
- package/packages/api/src/lib/api/dm.ts +14 -2
- package/packages/api/src/lib/api/normalize.ts +15 -4
- package/packages/api/src/lib/api/notification-target.ts +106 -0
- package/packages/api/src/lib/api/notifications.ts +53 -1
- package/packages/api/src/lib/api/push-config.ts +132 -0
- package/packages/api/src/lib/api.ts +3 -0
- package/packages/api/src/social-server.ts +9 -0
- package/packages/api/src/types/index.ts +48 -0
- package/src/backend/index.ts +67 -3
- package/src/backend/lib/attachments.ts +52 -0
- package/src/backend/lib/delivery/queue.ts +73 -0
- package/src/backend/lib/delivery/types.ts +15 -1
- package/src/backend/lib/notification-eligibility.ts +150 -0
- package/src/backend/lib/notification-push.ts +1213 -0
- package/src/backend/lib/notification-pusher-contract.ts +340 -0
- package/src/backend/lib/oauth-providers.ts +7 -6
- package/src/backend/lib/session-actor.ts +16 -1
- package/src/backend/lib/unread-counts.ts +79 -0
- package/src/backend/middleware/csrf.ts +11 -0
- package/src/backend/routes/account-teardown.ts +13 -0
- package/src/backend/routes/auth-helpers.ts +10 -7
- package/src/backend/routes/auth.ts +124 -2
- package/src/backend/routes/communities/messages.ts +123 -9
- package/src/backend/routes/dm/contacts.ts +6 -44
- package/src/backend/routes/dm/messages.ts +51 -4
- package/src/backend/routes/notification-pushers.ts +93 -0
- package/src/backend/routes/notifications.ts +76 -69
- package/src/backend/routes/posts/transformers.ts +8 -10
- package/src/backend/server.ts +6 -0
- package/src/backend/types.ts +12 -0
- package/src/db/index.ts +11 -10
- 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 {
|
|
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
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
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 delivery — supplies: 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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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/backend/server.ts
CHANGED
|
@@ -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 {
|
package/src/backend/types.ts
CHANGED
|
@@ -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
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
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;
|
package/src/db/schema/mobile.ts
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
* Mobile client tables.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
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
|
+
);
|