@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.
Files changed (205) hide show
  1. package/LICENSE.md +165 -0
  2. package/migrations/0000_initial_schema.sql +538 -0
  3. package/migrations/0001_seed_usergroups.sql +111 -0
  4. package/migrations/0002_group_promotions.sql +25 -0
  5. package/migrations/0003_counter_maintenance.sql +34 -0
  6. package/migrations/0004_post_render_cache.sql +7 -0
  7. package/migrations/0005_reports.sql +51 -0
  8. package/migrations/0006_warnings.sql +91 -0
  9. package/migrations/0007_notifications.sql +44 -0
  10. package/migrations/0008_subscriptions.sql +48 -0
  11. package/migrations/0009_member_preferences.sql +13 -0
  12. package/migrations/0010_profile_fields.sql +53 -0
  13. package/migrations/0011_private_messages.sql +63 -0
  14. package/migrations/0012_user_relations.sql +18 -0
  15. package/migrations/0013_reputation.sql +45 -0
  16. package/migrations/0014_signatures.sql +16 -0
  17. package/migrations/0015_admin_sessions.sql +24 -0
  18. package/migrations/0016_attachments.sql +51 -0
  19. package/migrations/0017_avatars.sql +33 -0
  20. package/migrations/0018_mass_mail.sql +15 -0
  21. package/migrations/0019_word_filters.sql +8 -0
  22. package/migrations/0020_searches.sql +15 -0
  23. package/migrations/0021_presence_stats.sql +18 -0
  24. package/migrations/0022_api_tokens_webhooks.sql +58 -0
  25. package/migrations/0023_install_state.sql +6 -0
  26. package/migrations/0024_component_versions.sql +13 -0
  27. package/migrations/0025_import.sql +25 -0
  28. package/migrations/0026_polls.sql +40 -0
  29. package/migrations/0027_drafts.sql +13 -0
  30. package/migrations/0028_board_vocabulary.sql +32 -0
  31. package/migrations/0029_announcements.sql +16 -0
  32. package/migrations/0030_antispam.sql +18 -0
  33. package/migrations/0031_markdown.sql +24 -0
  34. package/migrations/0032_theme_enablement.sql +1 -0
  35. package/migrations/0033_group_identity.sql +5 -0
  36. package/migrations/0034_search_document_version.sql +4 -0
  37. package/migrations/0035_categories_hold_no_threads.sql +1 -0
  38. package/migrations/0036_expiring_group_grants.sql +6 -0
  39. package/migrations/0037_automatic_timezone.sql +3 -0
  40. package/migrations/0038_primary_group_grants.sql +3 -0
  41. package/migrations/0039_moderator_log_forum_scope.sql +1 -0
  42. package/migrations/0040_moderator_restore_grants.sql +1 -0
  43. package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
  44. package/migrations/0042_drop_placebo_delete_others.sql +2 -0
  45. package/migrations/0043_federated_identity.sql +28 -0
  46. package/migrations/0044_second_factor.sql +34 -0
  47. package/migrations/0045_drop_dead_task_due_index.sql +1 -0
  48. package/migrations/0046_member_locale.sql +1 -0
  49. package/migrations/0047_navigation_items.sql +34 -0
  50. package/migrations/0048_web_push.sql +16 -0
  51. package/migrations/0049_plugin_health.sql +7 -0
  52. package/migrations/0050_drop_forum_password_grants.sql +1 -0
  53. package/migrations/0051_glossy_barracuda.sql +1 -0
  54. package/migrations/0052_many_professor_monster.sql +2 -0
  55. package/migrations/0053_strange_maximus.sql +14 -0
  56. package/migrations/0054_legacy_import_sources.sql +25 -0
  57. package/migrations/0055_poll_parity.sql +14 -0
  58. package/migrations/0056_marketplace_catalog.sql +11 -0
  59. package/migrations/meta/0000_snapshot.json +4412 -0
  60. package/migrations/meta/0006_snapshot.json +5411 -0
  61. package/migrations/meta/0007_snapshot.json +5634 -0
  62. package/migrations/meta/0008_snapshot.json +5779 -0
  63. package/migrations/meta/0009_snapshot.json +5816 -0
  64. package/migrations/meta/0010_snapshot.json +6117 -0
  65. package/migrations/meta/0011_snapshot.json +6406 -0
  66. package/migrations/meta/0012_snapshot.json +6517 -0
  67. package/migrations/meta/0013_snapshot.json +6722 -0
  68. package/migrations/meta/0014_snapshot.json +6777 -0
  69. package/migrations/meta/0015_snapshot.json +6914 -0
  70. package/migrations/meta/0016_snapshot.json +7192 -0
  71. package/migrations/meta/0017_snapshot.json +7248 -0
  72. package/migrations/meta/0018_snapshot.json +7355 -0
  73. package/migrations/meta/0019_snapshot.json +7419 -0
  74. package/migrations/meta/0020_snapshot.json +7562 -0
  75. package/migrations/meta/0021_snapshot.json +7653 -0
  76. package/migrations/meta/0031_snapshot.json +8162 -0
  77. package/migrations/meta/0032_snapshot.json +8169 -0
  78. package/migrations/meta/0033_snapshot.json +8193 -0
  79. package/migrations/meta/0034_snapshot.json +8221 -0
  80. package/migrations/meta/0036_snapshot.json +8262 -0
  81. package/migrations/meta/0037_snapshot.json +8262 -0
  82. package/migrations/meta/0038_snapshot.json +8293 -0
  83. package/migrations/meta/0039_snapshot.json +8308 -0
  84. package/migrations/meta/0041_snapshot.json +8287 -0
  85. package/migrations/meta/0042_snapshot.json +8274 -0
  86. package/migrations/meta/0043_snapshot.json +8521 -0
  87. package/migrations/meta/0044_snapshot.json +8814 -0
  88. package/migrations/meta/0045_snapshot.json +8797 -0
  89. package/migrations/meta/0046_snapshot.json +8804 -0
  90. package/migrations/meta/0047_snapshot.json +9018 -0
  91. package/migrations/meta/0048_snapshot.json +9145 -0
  92. package/migrations/meta/0049_snapshot.json +9190 -0
  93. package/migrations/meta/0050_snapshot.json +9126 -0
  94. package/migrations/meta/0051_snapshot.json +9132 -0
  95. package/migrations/meta/0052_snapshot.json +9148 -0
  96. package/migrations/meta/0053_snapshot.json +9268 -0
  97. package/migrations/meta/0054_snapshot.json +9378 -0
  98. package/migrations/meta/0056_snapshot.json +9447 -0
  99. package/migrations/meta/_journal.json +405 -0
  100. package/package.json +64 -0
  101. package/src/account-repos.ts +580 -0
  102. package/src/actor-builder.ts +130 -0
  103. package/src/admin-repo.ts +85 -0
  104. package/src/admin-session-repo.ts +205 -0
  105. package/src/admin-undo-repo.ts +128 -0
  106. package/src/announcement-repo.ts +176 -0
  107. package/src/antispam-repo.ts +145 -0
  108. package/src/api-repo.ts +276 -0
  109. package/src/attachment-admin-repo.ts +161 -0
  110. package/src/attachment-repo.ts +240 -0
  111. package/src/authorization-source.ts +138 -0
  112. package/src/avatar-repo.ts +185 -0
  113. package/src/ban-repos.ts +146 -0
  114. package/src/client.ts +85 -0
  115. package/src/content-admin-repo.ts +298 -0
  116. package/src/content-counters.ts +152 -0
  117. package/src/counter-recount.ts +263 -0
  118. package/src/cursor.ts +16 -0
  119. package/src/denormalised-username.ts +34 -0
  120. package/src/discovery-repo.ts +140 -0
  121. package/src/draft-repo.ts +59 -0
  122. package/src/feed-repo.ts +219 -0
  123. package/src/forum-admin-repo.ts +310 -0
  124. package/src/forum-repo.ts +206 -0
  125. package/src/group-admin-repo.ts +256 -0
  126. package/src/group-identity-repo.ts +84 -0
  127. package/src/identity-link-repo.ts +145 -0
  128. package/src/import-repo.ts +123 -0
  129. package/src/import-sink.ts +1377 -0
  130. package/src/index.ts +316 -0
  131. package/src/inline-moderation.ts +344 -0
  132. package/src/install-repo.ts +67 -0
  133. package/src/latest-repo.ts +115 -0
  134. package/src/maintenance-repo.ts +54 -0
  135. package/src/marketplace-repo.ts +111 -0
  136. package/src/member-profile-repo.ts +82 -0
  137. package/src/member-settings-repo.ts +160 -0
  138. package/src/message-repo.ts +384 -0
  139. package/src/migrate-run.ts +18 -0
  140. package/src/migrate.ts +129 -0
  141. package/src/modcp-repo.ts +253 -0
  142. package/src/moderation-queue.ts +279 -0
  143. package/src/navigation-repo.ts +288 -0
  144. package/src/notification-repo.ts +402 -0
  145. package/src/orphan-keys.ts +19 -0
  146. package/src/outbox-repo.ts +78 -0
  147. package/src/permission-version.ts +24 -0
  148. package/src/permissions-map.ts +52 -0
  149. package/src/pglite.fixture.ts +87 -0
  150. package/src/plugin-data.ts +88 -0
  151. package/src/plugin-grants.ts +369 -0
  152. package/src/plugin-health-repo.ts +80 -0
  153. package/src/plugin-purge-repo.ts +91 -0
  154. package/src/plugin-users.ts +39 -0
  155. package/src/poll-repo.ts +334 -0
  156. package/src/post-repo.ts +261 -0
  157. package/src/post-writes.ts +227 -0
  158. package/src/presence-repo.ts +220 -0
  159. package/src/profile-field-repo.ts +182 -0
  160. package/src/promotion-repo.ts +152 -0
  161. package/src/read-state-repo.ts +81 -0
  162. package/src/relation-repo.ts +113 -0
  163. package/src/render-backfill.ts +110 -0
  164. package/src/render-signature.ts +33 -0
  165. package/src/report-repo.ts +349 -0
  166. package/src/reputation-repo.ts +295 -0
  167. package/src/result-rows.ts +13 -0
  168. package/src/row-values.ts +7 -0
  169. package/src/schema/content.ts +451 -0
  170. package/src/schema/identity.ts +699 -0
  171. package/src/schema/index.ts +10 -0
  172. package/src/schema/messages.ts +76 -0
  173. package/src/schema/permission-columns.ts +34 -0
  174. package/src/schema/platform.ts +376 -0
  175. package/src/schema/structure.ts +164 -0
  176. package/src/search-repo.ts +359 -0
  177. package/src/search-store.ts +113 -0
  178. package/src/seed-groups.ts +11 -0
  179. package/src/settings-repo.ts +32 -0
  180. package/src/signature-repo.ts +102 -0
  181. package/src/sql-lists.ts +17 -0
  182. package/src/staff-groups.ts +50 -0
  183. package/src/stats-repo.ts +151 -0
  184. package/src/subscription-repo.ts +350 -0
  185. package/src/system-health-repo.ts +130 -0
  186. package/src/task-repo.ts +104 -0
  187. package/src/theme-admin-repo.ts +172 -0
  188. package/src/theme-repo.ts +43 -0
  189. package/src/thread-audience.ts +45 -0
  190. package/src/thread-counters.ts +107 -0
  191. package/src/thread-repo.ts +255 -0
  192. package/src/thread-surgery.ts +282 -0
  193. package/src/thread-tools.ts +311 -0
  194. package/src/thread-views.ts +41 -0
  195. package/src/thread-writes.ts +356 -0
  196. package/src/two-factor-repo.ts +235 -0
  197. package/src/upgrade-repo.ts +66 -0
  198. package/src/user-admin-repo.ts +367 -0
  199. package/src/user-bulk-repo.ts +397 -0
  200. package/src/user-merge-map.ts +111 -0
  201. package/src/user-merge-repo.ts +304 -0
  202. package/src/visibility-counters.ts +190 -0
  203. package/src/visibility.ts +17 -0
  204. package/src/vocabulary-repo.ts +54 -0
  205. 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
+ }