@meith/db 0.16.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.
- package/LICENSE.md +165 -0
- package/migrations/0000_initial_schema.sql +538 -0
- package/migrations/0001_seed_usergroups.sql +111 -0
- package/migrations/0002_group_promotions.sql +25 -0
- package/migrations/0003_counter_maintenance.sql +34 -0
- package/migrations/0004_post_render_cache.sql +7 -0
- package/migrations/0005_reports.sql +51 -0
- package/migrations/0006_warnings.sql +91 -0
- package/migrations/0007_notifications.sql +44 -0
- package/migrations/0008_subscriptions.sql +48 -0
- package/migrations/0009_member_preferences.sql +13 -0
- package/migrations/0010_profile_fields.sql +53 -0
- package/migrations/0011_private_messages.sql +63 -0
- package/migrations/0012_user_relations.sql +18 -0
- package/migrations/0013_reputation.sql +45 -0
- package/migrations/0014_signatures.sql +16 -0
- package/migrations/0015_admin_sessions.sql +24 -0
- package/migrations/0016_attachments.sql +51 -0
- package/migrations/0017_avatars.sql +33 -0
- package/migrations/0018_mass_mail.sql +15 -0
- package/migrations/0019_word_filters.sql +8 -0
- package/migrations/0020_searches.sql +15 -0
- package/migrations/0021_presence_stats.sql +18 -0
- package/migrations/0022_api_tokens_webhooks.sql +58 -0
- package/migrations/0023_install_state.sql +6 -0
- package/migrations/0024_component_versions.sql +13 -0
- package/migrations/0025_import.sql +25 -0
- package/migrations/0026_polls.sql +40 -0
- package/migrations/0027_drafts.sql +13 -0
- package/migrations/0028_board_vocabulary.sql +32 -0
- package/migrations/0029_announcements.sql +16 -0
- package/migrations/0030_antispam.sql +18 -0
- package/migrations/0031_markdown.sql +24 -0
- package/migrations/0032_theme_enablement.sql +1 -0
- package/migrations/0033_group_identity.sql +5 -0
- package/migrations/0034_search_document_version.sql +4 -0
- package/migrations/0035_categories_hold_no_threads.sql +1 -0
- package/migrations/0036_expiring_group_grants.sql +6 -0
- package/migrations/0037_automatic_timezone.sql +3 -0
- package/migrations/0038_primary_group_grants.sql +3 -0
- package/migrations/0039_moderator_log_forum_scope.sql +1 -0
- package/migrations/0040_moderator_restore_grants.sql +1 -0
- package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
- package/migrations/0042_drop_placebo_delete_others.sql +2 -0
- package/migrations/0043_federated_identity.sql +28 -0
- package/migrations/0044_second_factor.sql +34 -0
- package/migrations/0045_drop_dead_task_due_index.sql +1 -0
- package/migrations/0046_member_locale.sql +1 -0
- package/migrations/0047_navigation_items.sql +34 -0
- package/migrations/0048_web_push.sql +16 -0
- package/migrations/0049_plugin_health.sql +7 -0
- package/migrations/0050_drop_forum_password_grants.sql +1 -0
- package/migrations/0051_glossy_barracuda.sql +1 -0
- package/migrations/0052_many_professor_monster.sql +2 -0
- package/migrations/0053_strange_maximus.sql +14 -0
- package/migrations/0054_legacy_import_sources.sql +25 -0
- package/migrations/0055_poll_parity.sql +14 -0
- package/migrations/0056_marketplace_catalog.sql +11 -0
- package/migrations/meta/0000_snapshot.json +4412 -0
- package/migrations/meta/0006_snapshot.json +5411 -0
- package/migrations/meta/0007_snapshot.json +5634 -0
- package/migrations/meta/0008_snapshot.json +5779 -0
- package/migrations/meta/0009_snapshot.json +5816 -0
- package/migrations/meta/0010_snapshot.json +6117 -0
- package/migrations/meta/0011_snapshot.json +6406 -0
- package/migrations/meta/0012_snapshot.json +6517 -0
- package/migrations/meta/0013_snapshot.json +6722 -0
- package/migrations/meta/0014_snapshot.json +6777 -0
- package/migrations/meta/0015_snapshot.json +6914 -0
- package/migrations/meta/0016_snapshot.json +7192 -0
- package/migrations/meta/0017_snapshot.json +7248 -0
- package/migrations/meta/0018_snapshot.json +7355 -0
- package/migrations/meta/0019_snapshot.json +7419 -0
- package/migrations/meta/0020_snapshot.json +7562 -0
- package/migrations/meta/0021_snapshot.json +7653 -0
- package/migrations/meta/0031_snapshot.json +8162 -0
- package/migrations/meta/0032_snapshot.json +8169 -0
- package/migrations/meta/0033_snapshot.json +8193 -0
- package/migrations/meta/0034_snapshot.json +8221 -0
- package/migrations/meta/0036_snapshot.json +8262 -0
- package/migrations/meta/0037_snapshot.json +8262 -0
- package/migrations/meta/0038_snapshot.json +8293 -0
- package/migrations/meta/0039_snapshot.json +8308 -0
- package/migrations/meta/0041_snapshot.json +8287 -0
- package/migrations/meta/0042_snapshot.json +8274 -0
- package/migrations/meta/0043_snapshot.json +8521 -0
- package/migrations/meta/0044_snapshot.json +8814 -0
- package/migrations/meta/0045_snapshot.json +8797 -0
- package/migrations/meta/0046_snapshot.json +8804 -0
- package/migrations/meta/0047_snapshot.json +9018 -0
- package/migrations/meta/0048_snapshot.json +9145 -0
- package/migrations/meta/0049_snapshot.json +9190 -0
- package/migrations/meta/0050_snapshot.json +9126 -0
- package/migrations/meta/0051_snapshot.json +9132 -0
- package/migrations/meta/0052_snapshot.json +9148 -0
- package/migrations/meta/0053_snapshot.json +9268 -0
- package/migrations/meta/0054_snapshot.json +9378 -0
- package/migrations/meta/0056_snapshot.json +9447 -0
- package/migrations/meta/_journal.json +405 -0
- package/package.json +64 -0
- package/src/account-repos.ts +580 -0
- package/src/actor-builder.ts +130 -0
- package/src/admin-repo.ts +85 -0
- package/src/admin-session-repo.ts +205 -0
- package/src/admin-undo-repo.ts +128 -0
- package/src/announcement-repo.ts +176 -0
- package/src/antispam-repo.ts +145 -0
- package/src/api-repo.ts +276 -0
- package/src/attachment-admin-repo.ts +161 -0
- package/src/attachment-repo.ts +240 -0
- package/src/authorization-source.ts +138 -0
- package/src/avatar-repo.ts +185 -0
- package/src/ban-repos.ts +146 -0
- package/src/client.ts +85 -0
- package/src/content-admin-repo.ts +298 -0
- package/src/content-counters.ts +152 -0
- package/src/counter-recount.ts +263 -0
- package/src/cursor.ts +16 -0
- package/src/denormalised-username.ts +34 -0
- package/src/discovery-repo.ts +140 -0
- package/src/draft-repo.ts +59 -0
- package/src/feed-repo.ts +219 -0
- package/src/forum-admin-repo.ts +310 -0
- package/src/forum-repo.ts +206 -0
- package/src/group-admin-repo.ts +256 -0
- package/src/group-identity-repo.ts +84 -0
- package/src/identity-link-repo.ts +145 -0
- package/src/import-repo.ts +123 -0
- package/src/import-sink.ts +1377 -0
- package/src/index.ts +316 -0
- package/src/inline-moderation.ts +344 -0
- package/src/install-repo.ts +67 -0
- package/src/latest-repo.ts +115 -0
- package/src/maintenance-repo.ts +54 -0
- package/src/marketplace-repo.ts +111 -0
- package/src/member-profile-repo.ts +82 -0
- package/src/member-settings-repo.ts +160 -0
- package/src/message-repo.ts +384 -0
- package/src/migrate-run.ts +18 -0
- package/src/migrate.ts +129 -0
- package/src/modcp-repo.ts +253 -0
- package/src/moderation-queue.ts +279 -0
- package/src/navigation-repo.ts +288 -0
- package/src/notification-repo.ts +402 -0
- package/src/orphan-keys.ts +19 -0
- package/src/outbox-repo.ts +78 -0
- package/src/permission-version.ts +24 -0
- package/src/permissions-map.ts +52 -0
- package/src/pglite.fixture.ts +87 -0
- package/src/plugin-data.ts +88 -0
- package/src/plugin-grants.ts +369 -0
- package/src/plugin-health-repo.ts +80 -0
- package/src/plugin-purge-repo.ts +91 -0
- package/src/plugin-users.ts +39 -0
- package/src/poll-repo.ts +334 -0
- package/src/post-repo.ts +261 -0
- package/src/post-writes.ts +227 -0
- package/src/presence-repo.ts +220 -0
- package/src/profile-field-repo.ts +182 -0
- package/src/promotion-repo.ts +152 -0
- package/src/read-state-repo.ts +81 -0
- package/src/relation-repo.ts +113 -0
- package/src/render-backfill.ts +110 -0
- package/src/render-signature.ts +33 -0
- package/src/report-repo.ts +349 -0
- package/src/reputation-repo.ts +295 -0
- package/src/result-rows.ts +13 -0
- package/src/row-values.ts +7 -0
- package/src/schema/content.ts +451 -0
- package/src/schema/identity.ts +699 -0
- package/src/schema/index.ts +10 -0
- package/src/schema/messages.ts +76 -0
- package/src/schema/permission-columns.ts +34 -0
- package/src/schema/platform.ts +376 -0
- package/src/schema/structure.ts +164 -0
- package/src/search-repo.ts +359 -0
- package/src/search-store.ts +113 -0
- package/src/seed-groups.ts +11 -0
- package/src/settings-repo.ts +32 -0
- package/src/signature-repo.ts +102 -0
- package/src/sql-lists.ts +17 -0
- package/src/staff-groups.ts +50 -0
- package/src/stats-repo.ts +151 -0
- package/src/subscription-repo.ts +350 -0
- package/src/system-health-repo.ts +130 -0
- package/src/task-repo.ts +104 -0
- package/src/theme-admin-repo.ts +172 -0
- package/src/theme-repo.ts +43 -0
- package/src/thread-audience.ts +45 -0
- package/src/thread-counters.ts +107 -0
- package/src/thread-repo.ts +255 -0
- package/src/thread-surgery.ts +282 -0
- package/src/thread-tools.ts +311 -0
- package/src/thread-views.ts +41 -0
- package/src/thread-writes.ts +356 -0
- package/src/two-factor-repo.ts +235 -0
- package/src/upgrade-repo.ts +66 -0
- package/src/user-admin-repo.ts +367 -0
- package/src/user-bulk-repo.ts +397 -0
- package/src/user-merge-map.ts +111 -0
- package/src/user-merge-repo.ts +304 -0
- package/src/visibility-counters.ts +190 -0
- package/src/visibility.ts +17 -0
- package/src/vocabulary-repo.ts +54 -0
- package/src/warning-repo.ts +328 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { ContentScope } from '@meith/core'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
import { toDate } from './row-values'
|
|
8
|
+
import { audienceIsEmpty, inAudience } from './thread-audience'
|
|
9
|
+
import { visibleIn } from './visibility'
|
|
10
|
+
|
|
11
|
+
export interface BoardTotals {
|
|
12
|
+
readonly threadCount: number
|
|
13
|
+
readonly postCount: number
|
|
14
|
+
readonly memberCount: number
|
|
15
|
+
readonly newestUserId: number | null
|
|
16
|
+
readonly newestUsername: string | null
|
|
17
|
+
readonly computedAt: Date | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TopPoster {
|
|
21
|
+
readonly userId: number
|
|
22
|
+
readonly username: string
|
|
23
|
+
readonly postCount: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TopThread {
|
|
27
|
+
readonly threadId: number
|
|
28
|
+
readonly title: string
|
|
29
|
+
readonly slug: string
|
|
30
|
+
readonly forumId: number
|
|
31
|
+
readonly forumTitle: string
|
|
32
|
+
readonly viewCount: number
|
|
33
|
+
readonly replyCount: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface StatsScope {
|
|
37
|
+
readonly forumIds: readonly number[]
|
|
38
|
+
readonly ownThreadsOnlyForumIds: readonly number[]
|
|
39
|
+
readonly viewerUserId: number | null
|
|
40
|
+
readonly content: ContentScope
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class PostgresStatsRepository {
|
|
44
|
+
constructor(private readonly db: Database) {}
|
|
45
|
+
|
|
46
|
+
async readTotals(): Promise<BoardTotals> {
|
|
47
|
+
const rows = resultRows(
|
|
48
|
+
await this.db.execute(sql`
|
|
49
|
+
select thread_count, post_count, member_count,
|
|
50
|
+
newest_user_id, newest_username, computed_at
|
|
51
|
+
from board_stats where id = 1
|
|
52
|
+
`),
|
|
53
|
+
) as Array<Record<string, unknown>>
|
|
54
|
+
|
|
55
|
+
const row = rows[0]
|
|
56
|
+
if (row === undefined) {
|
|
57
|
+
return {
|
|
58
|
+
threadCount: 0,
|
|
59
|
+
postCount: 0,
|
|
60
|
+
memberCount: 0,
|
|
61
|
+
newestUserId: null,
|
|
62
|
+
newestUsername: null,
|
|
63
|
+
computedAt: null,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
threadCount: Number(row.thread_count),
|
|
69
|
+
postCount: Number(row.post_count),
|
|
70
|
+
memberCount: Number(row.member_count),
|
|
71
|
+
newestUserId: row.newest_user_id === null ? null : Number(row.newest_user_id),
|
|
72
|
+
newestUsername: row.newest_username === null ? null : String(row.newest_username),
|
|
73
|
+
computedAt: row.computed_at === null ? null : toDate(row.computed_at),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async rollUp(now: Date): Promise<BoardTotals> {
|
|
78
|
+
await this.db.execute(sql`
|
|
79
|
+
update board_stats set
|
|
80
|
+
thread_count = coalesce((select sum(thread_count) from forums where parent_id is null), 0),
|
|
81
|
+
post_count = coalesce((select sum(post_count) from forums where parent_id is null), 0),
|
|
82
|
+
member_count = (select count(*) from users where state = 'active'),
|
|
83
|
+
newest_user_id = (select id from users where state = 'active'
|
|
84
|
+
order by created_at desc, id desc limit 1),
|
|
85
|
+
newest_username = (select username from users where state = 'active'
|
|
86
|
+
order by created_at desc, id desc limit 1),
|
|
87
|
+
computed_at = ${now},
|
|
88
|
+
updated_at = ${now}
|
|
89
|
+
where id = 1
|
|
90
|
+
`)
|
|
91
|
+
|
|
92
|
+
return this.readTotals()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async topPosters(limit: number): Promise<readonly TopPoster[]> {
|
|
96
|
+
const rows = resultRows(
|
|
97
|
+
await this.db.execute(sql`
|
|
98
|
+
select id, username, post_count
|
|
99
|
+
from users
|
|
100
|
+
where state = 'active' and post_count > 0
|
|
101
|
+
order by post_count desc, id
|
|
102
|
+
limit ${limit}
|
|
103
|
+
`),
|
|
104
|
+
) as Array<Record<string, unknown>>
|
|
105
|
+
|
|
106
|
+
return rows.map((row) => ({
|
|
107
|
+
userId: Number(row.id),
|
|
108
|
+
username: String(row.username),
|
|
109
|
+
postCount: Number(row.post_count),
|
|
110
|
+
}))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async mostViewed(limit: number, scope: StatsScope): Promise<readonly TopThread[]> {
|
|
114
|
+
return this.topThreads(sql`t.view_count desc, t.id`, limit, scope)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async mostReplied(limit: number, scope: StatsScope): Promise<readonly TopThread[]> {
|
|
118
|
+
return this.topThreads(sql`t.reply_count desc, t.id`, limit, scope)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private async topThreads(
|
|
122
|
+
order: ReturnType<typeof sql>,
|
|
123
|
+
limit: number,
|
|
124
|
+
scope: StatsScope,
|
|
125
|
+
): Promise<readonly TopThread[]> {
|
|
126
|
+
if (audienceIsEmpty(scope)) return []
|
|
127
|
+
|
|
128
|
+
const rows = resultRows(
|
|
129
|
+
await this.db.execute(sql`
|
|
130
|
+
select t.id, t.title, t.slug, t.forum_id, f.title as forum_title,
|
|
131
|
+
t.view_count, t.reply_count
|
|
132
|
+
from threads t
|
|
133
|
+
join forums f on f.id = t.forum_id
|
|
134
|
+
where ${inAudience(sql`t.forum_id`, sql`t.author_user_id`, scope)}
|
|
135
|
+
and ${visibleIn(sql`t.visibility`, scope.content)}
|
|
136
|
+
order by ${order}
|
|
137
|
+
limit ${limit}
|
|
138
|
+
`),
|
|
139
|
+
) as Array<Record<string, unknown>>
|
|
140
|
+
|
|
141
|
+
return rows.map((row) => ({
|
|
142
|
+
threadId: Number(row.id),
|
|
143
|
+
title: String(row.title),
|
|
144
|
+
slug: String(row.slug),
|
|
145
|
+
forumId: Number(row.forum_id),
|
|
146
|
+
forumTitle: String(row.forum_title),
|
|
147
|
+
viewCount: Number(row.view_count),
|
|
148
|
+
replyCount: Number(row.reply_count),
|
|
149
|
+
}))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { PUBLIC_CONTENT } from '@meith/core'
|
|
4
|
+
import type {
|
|
5
|
+
DigestCadence,
|
|
6
|
+
PendingForUser,
|
|
7
|
+
SubscriptionMode,
|
|
8
|
+
SubscriptionRepository,
|
|
9
|
+
SubscriptionRow,
|
|
10
|
+
SubscriptionTarget,
|
|
11
|
+
} from '@meith/subscriptions'
|
|
12
|
+
import { parseSubscriptionMode } from '@meith/subscriptions'
|
|
13
|
+
|
|
14
|
+
import type { Database } from './client'
|
|
15
|
+
import { resultRows } from './result-rows'
|
|
16
|
+
import { toDate } from './row-values'
|
|
17
|
+
import { idList } from './sql-lists'
|
|
18
|
+
import { visibleIn } from './visibility'
|
|
19
|
+
|
|
20
|
+
interface TableShape {
|
|
21
|
+
readonly table: ReturnType<typeof sql>
|
|
22
|
+
readonly column: ReturnType<typeof sql>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function shapeFor(target: SubscriptionTarget): TableShape {
|
|
26
|
+
return target === 'thread'
|
|
27
|
+
? { table: sql`thread_subscriptions`, column: sql`thread_id` }
|
|
28
|
+
: { table: sql`forum_subscriptions`, column: sql`forum_id` }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pendingPosts(
|
|
32
|
+
userId: number,
|
|
33
|
+
mode: SubscriptionMode | null,
|
|
34
|
+
visibleForumIds: readonly number[] | null,
|
|
35
|
+
): ReturnType<typeof sql> {
|
|
36
|
+
const byMode = mode === null ? sql`` : sql`and s.mode = ${mode}`
|
|
37
|
+
const visible =
|
|
38
|
+
visibleForumIds === null ? sql`` : sql`and t.forum_id in ${idList(visibleForumIds)}`
|
|
39
|
+
|
|
40
|
+
return sql`
|
|
41
|
+
select p.id as post_id, p.thread_id, t.title as thread_title, t.slug as thread_slug,
|
|
42
|
+
t.forum_id, u.username as author_username, p.created_at,
|
|
43
|
+
'thread'::text as target, s.thread_id as target_id
|
|
44
|
+
from thread_subscriptions s
|
|
45
|
+
join threads t on t.id = s.thread_id
|
|
46
|
+
join posts p on p.thread_id = s.thread_id and p.id > s.last_notified_post_id
|
|
47
|
+
left join users u on u.id = p.author_user_id
|
|
48
|
+
where s.user_id = ${userId} ${byMode}
|
|
49
|
+
and p.author_user_id is distinct from ${userId}
|
|
50
|
+
and ${visibleIn(sql`p.visibility`, PUBLIC_CONTENT)}
|
|
51
|
+
and ${visibleIn(sql`t.visibility`, PUBLIC_CONTENT)}
|
|
52
|
+
${visible}
|
|
53
|
+
|
|
54
|
+
union all
|
|
55
|
+
|
|
56
|
+
select p.id as post_id, p.thread_id, t.title as thread_title, t.slug as thread_slug,
|
|
57
|
+
t.forum_id, u.username as author_username, p.created_at,
|
|
58
|
+
'forum'::text as target, s.forum_id as target_id
|
|
59
|
+
from forum_subscriptions s
|
|
60
|
+
join threads t on t.forum_id = s.forum_id
|
|
61
|
+
join posts p on p.thread_id = t.id and p.id > s.last_notified_post_id
|
|
62
|
+
left join users u on u.id = p.author_user_id
|
|
63
|
+
where s.user_id = ${userId} ${byMode}
|
|
64
|
+
and p.author_user_id is distinct from ${userId}
|
|
65
|
+
and ${visibleIn(sql`p.visibility`, PUBLIC_CONTENT)}
|
|
66
|
+
and ${visibleIn(sql`t.visibility`, PUBLIC_CONTENT)}
|
|
67
|
+
${visible}
|
|
68
|
+
`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface RawPending {
|
|
72
|
+
post_id: number
|
|
73
|
+
thread_id: number
|
|
74
|
+
thread_title: string
|
|
75
|
+
thread_slug: string
|
|
76
|
+
forum_id: number
|
|
77
|
+
author_username: string | null
|
|
78
|
+
created_at: string | Date
|
|
79
|
+
target: SubscriptionTarget
|
|
80
|
+
target_id: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class PostgresSubscriptionRepository implements SubscriptionRepository {
|
|
84
|
+
constructor(private readonly db: Database) {}
|
|
85
|
+
|
|
86
|
+
async subscribe(input: {
|
|
87
|
+
readonly userId: number
|
|
88
|
+
readonly target: SubscriptionTarget
|
|
89
|
+
readonly targetId: number
|
|
90
|
+
readonly mode: SubscriptionMode
|
|
91
|
+
readonly at: Date
|
|
92
|
+
}): Promise<boolean> {
|
|
93
|
+
const rows = resultRows(
|
|
94
|
+
input.target === 'thread'
|
|
95
|
+
? await this.db.execute(sql`
|
|
96
|
+
insert into thread_subscriptions
|
|
97
|
+
(user_id, thread_id, mode, last_notified_post_id, created_at)
|
|
98
|
+
select ${input.userId}, t.id, ${input.mode},
|
|
99
|
+
coalesce(t.last_post_id, 0), ${input.at}
|
|
100
|
+
from threads t
|
|
101
|
+
where t.id = ${input.targetId}
|
|
102
|
+
on conflict (user_id, thread_id)
|
|
103
|
+
do update set mode = excluded.mode
|
|
104
|
+
returning user_id
|
|
105
|
+
`)
|
|
106
|
+
: await this.db.execute(sql`
|
|
107
|
+
insert into forum_subscriptions
|
|
108
|
+
(user_id, forum_id, mode, last_notified_post_id, created_at)
|
|
109
|
+
select ${input.userId}, f.id, ${input.mode},
|
|
110
|
+
coalesce(f.last_post_id, 0), ${input.at}
|
|
111
|
+
from forums f
|
|
112
|
+
where f.id = ${input.targetId}
|
|
113
|
+
on conflict (user_id, forum_id)
|
|
114
|
+
do update set mode = excluded.mode
|
|
115
|
+
returning user_id
|
|
116
|
+
`),
|
|
117
|
+
) as Array<{ user_id: number }>
|
|
118
|
+
|
|
119
|
+
return rows.length > 0
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async unsubscribe(input: {
|
|
123
|
+
readonly userId: number
|
|
124
|
+
readonly target: SubscriptionTarget
|
|
125
|
+
readonly targetId: number
|
|
126
|
+
}): Promise<boolean> {
|
|
127
|
+
const { table, column } = shapeFor(input.target)
|
|
128
|
+
const rows = resultRows(
|
|
129
|
+
await this.db.execute(sql`
|
|
130
|
+
delete from ${table}
|
|
131
|
+
where user_id = ${input.userId} and ${column} = ${input.targetId}
|
|
132
|
+
returning user_id
|
|
133
|
+
`),
|
|
134
|
+
) as Array<{ user_id: number }>
|
|
135
|
+
|
|
136
|
+
return rows.length > 0
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async modeFor(
|
|
140
|
+
userId: number,
|
|
141
|
+
target: SubscriptionTarget,
|
|
142
|
+
targetId: number,
|
|
143
|
+
): Promise<SubscriptionMode | null> {
|
|
144
|
+
const { table, column } = shapeFor(target)
|
|
145
|
+
const rows = resultRows(
|
|
146
|
+
await this.db.execute(sql`
|
|
147
|
+
select mode from ${table}
|
|
148
|
+
where user_id = ${userId} and ${column} = ${targetId}
|
|
149
|
+
`),
|
|
150
|
+
) as Array<{ mode: string }>
|
|
151
|
+
|
|
152
|
+
const stored = rows[0]?.mode
|
|
153
|
+
return stored === undefined ? null : parseSubscriptionMode(stored)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async listFor(
|
|
157
|
+
userId: number,
|
|
158
|
+
options: { readonly visibleForumIds: readonly number[]; readonly limit: number },
|
|
159
|
+
): Promise<readonly SubscriptionRow[]> {
|
|
160
|
+
const visible = idList(options.visibleForumIds)
|
|
161
|
+
|
|
162
|
+
const rows = resultRows(
|
|
163
|
+
await this.db.execute(sql`
|
|
164
|
+
with pending as (
|
|
165
|
+
select target, target_id, count(*)::int as pending
|
|
166
|
+
from (${pendingPosts(userId, null, options.visibleForumIds)}) p
|
|
167
|
+
group by target, target_id
|
|
168
|
+
)
|
|
169
|
+
select 'thread'::text as target, s.thread_id as target_id, t.title,
|
|
170
|
+
t.slug, s.mode, s.created_at,
|
|
171
|
+
coalesce(pending.pending, 0) as pending
|
|
172
|
+
from thread_subscriptions s
|
|
173
|
+
join threads t on t.id = s.thread_id
|
|
174
|
+
left join pending
|
|
175
|
+
on pending.target = 'thread' and pending.target_id = s.thread_id
|
|
176
|
+
where s.user_id = ${userId}
|
|
177
|
+
and ${visibleIn(sql`t.visibility`, PUBLIC_CONTENT)}
|
|
178
|
+
and t.forum_id in ${visible}
|
|
179
|
+
|
|
180
|
+
union all
|
|
181
|
+
|
|
182
|
+
select 'forum'::text as target, s.forum_id as target_id, f.title,
|
|
183
|
+
f.slug, s.mode, s.created_at,
|
|
184
|
+
coalesce(pending.pending, 0) as pending
|
|
185
|
+
from forum_subscriptions s
|
|
186
|
+
join forums f on f.id = s.forum_id
|
|
187
|
+
left join pending
|
|
188
|
+
on pending.target = 'forum' and pending.target_id = s.forum_id
|
|
189
|
+
where s.user_id = ${userId} and s.forum_id in ${visible}
|
|
190
|
+
|
|
191
|
+
order by created_at desc, target_id desc
|
|
192
|
+
limit ${options.limit}
|
|
193
|
+
`),
|
|
194
|
+
) as Array<{
|
|
195
|
+
target: SubscriptionTarget
|
|
196
|
+
target_id: number
|
|
197
|
+
title: string
|
|
198
|
+
slug: string
|
|
199
|
+
mode: string
|
|
200
|
+
created_at: string | Date
|
|
201
|
+
pending: number
|
|
202
|
+
}>
|
|
203
|
+
|
|
204
|
+
return rows.map((row) => ({
|
|
205
|
+
target: row.target,
|
|
206
|
+
targetId: Number(row.target_id),
|
|
207
|
+
title: row.title,
|
|
208
|
+
href:
|
|
209
|
+
row.target === 'thread'
|
|
210
|
+
? `/thread/${Number(row.target_id)}-${row.slug}`
|
|
211
|
+
: `/${Number(row.target_id)}-${row.slug}`,
|
|
212
|
+
mode: parseSubscriptionMode(row.mode) ?? 'instant',
|
|
213
|
+
createdAt: toDate(row.created_at),
|
|
214
|
+
pending: Number(row.pending),
|
|
215
|
+
}))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async usersWithPending(input: {
|
|
219
|
+
readonly mode: SubscriptionMode
|
|
220
|
+
readonly dueBefore: Date | null
|
|
221
|
+
readonly limit: number
|
|
222
|
+
}): Promise<readonly number[]> {
|
|
223
|
+
const cadence = input.dueBefore === null ? null : input.mode
|
|
224
|
+
const dueClause =
|
|
225
|
+
input.dueBefore === null || cadence === null
|
|
226
|
+
? sql``
|
|
227
|
+
: sql`
|
|
228
|
+
and not exists (
|
|
229
|
+
select 1 from digest_runs d
|
|
230
|
+
where d.user_id = s.user_id and d.cadence = ${cadence}
|
|
231
|
+
and d.last_sent_at > ${input.dueBefore}
|
|
232
|
+
)
|
|
233
|
+
`
|
|
234
|
+
|
|
235
|
+
const rows = resultRows(
|
|
236
|
+
await this.db.execute(sql`
|
|
237
|
+
select distinct s.user_id
|
|
238
|
+
from (
|
|
239
|
+
select user_id, thread_id as target_id, mode, last_notified_post_id,
|
|
240
|
+
'thread'::text as target
|
|
241
|
+
from thread_subscriptions
|
|
242
|
+
union all
|
|
243
|
+
select user_id, forum_id as target_id, mode, last_notified_post_id,
|
|
244
|
+
'forum'::text as target
|
|
245
|
+
from forum_subscriptions
|
|
246
|
+
) s
|
|
247
|
+
where s.mode = ${input.mode}
|
|
248
|
+
and exists (
|
|
249
|
+
select 1
|
|
250
|
+
from threads t
|
|
251
|
+
join posts p on p.thread_id = t.id and p.id > s.last_notified_post_id
|
|
252
|
+
where (
|
|
253
|
+
(s.target = 'thread' and t.id = s.target_id)
|
|
254
|
+
or (s.target = 'forum' and t.forum_id = s.target_id)
|
|
255
|
+
)
|
|
256
|
+
and p.author_user_id is distinct from s.user_id
|
|
257
|
+
and ${visibleIn(sql`p.visibility`, PUBLIC_CONTENT)}
|
|
258
|
+
and ${visibleIn(sql`t.visibility`, PUBLIC_CONTENT)}
|
|
259
|
+
)
|
|
260
|
+
${dueClause}
|
|
261
|
+
order by s.user_id
|
|
262
|
+
limit ${input.limit}
|
|
263
|
+
`),
|
|
264
|
+
) as Array<{ user_id: number }>
|
|
265
|
+
|
|
266
|
+
return rows.map((row) => Number(row.user_id))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async pendingFor(input: {
|
|
270
|
+
readonly userId: number
|
|
271
|
+
readonly mode: SubscriptionMode
|
|
272
|
+
readonly visibleForumIds: readonly number[]
|
|
273
|
+
readonly limit: number
|
|
274
|
+
}): Promise<PendingForUser> {
|
|
275
|
+
const rows = resultRows(
|
|
276
|
+
await this.db.execute(sql`
|
|
277
|
+
select * from (${pendingPosts(input.userId, input.mode, input.visibleForumIds)}) p
|
|
278
|
+
order by p.thread_id, p.post_id
|
|
279
|
+
limit ${input.limit}
|
|
280
|
+
`),
|
|
281
|
+
) as RawPending[]
|
|
282
|
+
|
|
283
|
+
const highest = new Map<
|
|
284
|
+
string,
|
|
285
|
+
{ target: SubscriptionTarget; targetId: number; lastPostId: number }
|
|
286
|
+
>()
|
|
287
|
+
for (const row of rows) {
|
|
288
|
+
const key = `${row.target}:${row.target_id}`
|
|
289
|
+
const current = highest.get(key)
|
|
290
|
+
const postId = Number(row.post_id)
|
|
291
|
+
if (current === undefined || postId > current.lastPostId) {
|
|
292
|
+
highest.set(key, {
|
|
293
|
+
target: row.target,
|
|
294
|
+
targetId: Number(row.target_id),
|
|
295
|
+
lastPostId: postId,
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const seen = new Set<number>()
|
|
301
|
+
const posts = []
|
|
302
|
+
for (const row of rows) {
|
|
303
|
+
const postId = Number(row.post_id)
|
|
304
|
+
if (seen.has(postId)) continue
|
|
305
|
+
seen.add(postId)
|
|
306
|
+
posts.push({
|
|
307
|
+
postId,
|
|
308
|
+
threadId: Number(row.thread_id),
|
|
309
|
+
threadTitle: row.thread_title,
|
|
310
|
+
threadSlug: row.thread_slug,
|
|
311
|
+
forumId: Number(row.forum_id),
|
|
312
|
+
authorUsername: row.author_username,
|
|
313
|
+
createdAt: toDate(row.created_at),
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { userId: input.userId, posts, watermarks: [...highest.values()] }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async advanceWatermarks(input: {
|
|
321
|
+
readonly userId: number
|
|
322
|
+
readonly watermarks: PendingForUser['watermarks']
|
|
323
|
+
}): Promise<void> {
|
|
324
|
+
if (input.watermarks.length === 0) return
|
|
325
|
+
|
|
326
|
+
await this.db.transaction(async (tx) => {
|
|
327
|
+
for (const mark of input.watermarks) {
|
|
328
|
+
const { table, column } = shapeFor(mark.target)
|
|
329
|
+
await tx.execute(sql`
|
|
330
|
+
update ${table}
|
|
331
|
+
set last_notified_post_id = greatest(last_notified_post_id, ${mark.lastPostId})
|
|
332
|
+
where user_id = ${input.userId} and ${column} = ${mark.targetId}
|
|
333
|
+
`)
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async recordDigestRun(input: {
|
|
339
|
+
readonly userId: number
|
|
340
|
+
readonly cadence: DigestCadence
|
|
341
|
+
readonly at: Date
|
|
342
|
+
}): Promise<void> {
|
|
343
|
+
await this.db.execute(sql`
|
|
344
|
+
insert into digest_runs (user_id, cadence, last_sent_at)
|
|
345
|
+
values (${input.userId}, ${input.cadence}, ${input.at})
|
|
346
|
+
on conflict (user_id, cadence)
|
|
347
|
+
do update set last_sent_at = excluded.last_sent_at
|
|
348
|
+
`)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { TaskHealthInput } from '@meith/tasks'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
import { toDate } from './row-values'
|
|
8
|
+
|
|
9
|
+
export interface TaskRunRow {
|
|
10
|
+
readonly taskKey: string
|
|
11
|
+
readonly succeeded: boolean
|
|
12
|
+
readonly durationMs: number | null
|
|
13
|
+
readonly detail: string | null
|
|
14
|
+
readonly error: string | null
|
|
15
|
+
readonly ranAt: Date
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RecountStateRow {
|
|
19
|
+
readonly id: string
|
|
20
|
+
readonly phase: string
|
|
21
|
+
readonly cursor: number
|
|
22
|
+
readonly passes: number
|
|
23
|
+
readonly corrected: number
|
|
24
|
+
readonly updatedAt: Date | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BoardVolumes {
|
|
28
|
+
readonly users: number
|
|
29
|
+
readonly threads: number
|
|
30
|
+
readonly posts: number
|
|
31
|
+
readonly attachments: number
|
|
32
|
+
readonly queuedJobs: number
|
|
33
|
+
readonly deadLetteredJobs: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class PostgresSystemHealthRepository {
|
|
37
|
+
constructor(private readonly db: Database) {}
|
|
38
|
+
|
|
39
|
+
async taskHealth(): Promise<readonly TaskHealthInput[]> {
|
|
40
|
+
const rows = resultRows(
|
|
41
|
+
await this.db.execute(sql`
|
|
42
|
+
select key, interval_seconds, enabled, last_run_at, next_run_at,
|
|
43
|
+
consecutive_failures
|
|
44
|
+
from tasks order by key
|
|
45
|
+
`),
|
|
46
|
+
) as Array<Record<string, unknown>>
|
|
47
|
+
|
|
48
|
+
return rows.map((row) => ({
|
|
49
|
+
key: String(row.key),
|
|
50
|
+
intervalSeconds: Number(row.interval_seconds),
|
|
51
|
+
enabled: row.enabled === true,
|
|
52
|
+
lastRunAt: row.last_run_at === null ? null : toDate(row.last_run_at),
|
|
53
|
+
nextRunAt: row.next_run_at === null ? null : toDate(row.next_run_at),
|
|
54
|
+
consecutiveFailures: Number(row.consecutive_failures),
|
|
55
|
+
}))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async recentRuns(limit = 20): Promise<readonly TaskRunRow[]> {
|
|
59
|
+
const rows = resultRows(
|
|
60
|
+
await this.db.execute(sql`
|
|
61
|
+
select task_key, succeeded, duration_ms, detail, error, ran_at
|
|
62
|
+
from task_log order by ran_at desc, id desc limit ${limit}
|
|
63
|
+
`),
|
|
64
|
+
) as Array<Record<string, unknown>>
|
|
65
|
+
|
|
66
|
+
return rows.map((row) => ({
|
|
67
|
+
taskKey: String(row.task_key),
|
|
68
|
+
succeeded: row.succeeded === true,
|
|
69
|
+
durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
|
|
70
|
+
detail: row.detail === null ? null : String(row.detail),
|
|
71
|
+
error: row.error === null ? null : String(row.error),
|
|
72
|
+
ranAt: toDate(row.ran_at),
|
|
73
|
+
}))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async recountState(): Promise<readonly RecountStateRow[]> {
|
|
77
|
+
const rows = resultRows(
|
|
78
|
+
await this.db.execute(sql`
|
|
79
|
+
select id, phase, cursor, passes, corrected, updated_at
|
|
80
|
+
from counter_recount_state order by id
|
|
81
|
+
`),
|
|
82
|
+
) as Array<Record<string, unknown>>
|
|
83
|
+
|
|
84
|
+
return rows.map((row) => ({
|
|
85
|
+
id: String(row.id),
|
|
86
|
+
phase: String(row.phase),
|
|
87
|
+
cursor: Number(row.cursor),
|
|
88
|
+
passes: Number(row.passes),
|
|
89
|
+
corrected: Number(row.corrected),
|
|
90
|
+
updatedAt: row.updated_at === null ? null : toDate(row.updated_at),
|
|
91
|
+
}))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async volumes(): Promise<BoardVolumes> {
|
|
95
|
+
const rows = resultRows(
|
|
96
|
+
await this.db.execute(sql`
|
|
97
|
+
select
|
|
98
|
+
(select count(*) from users where deleted_at is null)::int as users,
|
|
99
|
+
(select count(*) from threads)::int as threads,
|
|
100
|
+
(select count(*) from posts)::int as posts,
|
|
101
|
+
(select count(*) from attachments)::int as attachments,
|
|
102
|
+
(select count(*) from jobs where status in ('pending', 'running'))::int
|
|
103
|
+
as queued_jobs,
|
|
104
|
+
(select count(*) from jobs where status = 'dead')::int as dead_lettered
|
|
105
|
+
`),
|
|
106
|
+
) as Array<Record<string, unknown>>
|
|
107
|
+
|
|
108
|
+
const row = rows[0] as Record<string, unknown>
|
|
109
|
+
return {
|
|
110
|
+
users: Number(row.users),
|
|
111
|
+
threads: Number(row.threads),
|
|
112
|
+
posts: Number(row.posts),
|
|
113
|
+
attachments: Number(row.attachments),
|
|
114
|
+
queuedJobs: Number(row.queued_jobs),
|
|
115
|
+
deadLetteredJobs: Number(row.dead_lettered),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async activeConnections(): Promise<number> {
|
|
120
|
+
const rows = resultRows(
|
|
121
|
+
await this.db.execute(sql`
|
|
122
|
+
select count(*)::int as active
|
|
123
|
+
from pg_stat_activity
|
|
124
|
+
where datname = current_database()
|
|
125
|
+
`),
|
|
126
|
+
) as Array<Record<string, unknown>>
|
|
127
|
+
|
|
128
|
+
return Number(rows[0]?.active ?? 0)
|
|
129
|
+
}
|
|
130
|
+
}
|