@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,261 @@
1
+ import { and, asc, eq, gt, type SQL, sql } from 'drizzle-orm'
2
+ import { alias } from 'drizzle-orm/pg-core'
3
+
4
+ import { type ContentScope, PUBLIC_CONTENT } from '@meith/core'
5
+ import { sourceAsMarkdown } from '@meith/markdown'
6
+ import type {
7
+ PostListingRow,
8
+ PostLocation,
9
+ PostPage,
10
+ PostRepository,
11
+ PostRevision,
12
+ QuotablePost,
13
+ } from '@meith/posts'
14
+
15
+ import type { Database } from './client'
16
+ import { resultRows } from './result-rows'
17
+ import { posts, users } from './schema'
18
+ import { visibleIn } from './visibility'
19
+
20
+ const editors = alias(users, 'editors')
21
+
22
+ function toPost(row: {
23
+ id: number
24
+ threadId: number
25
+ forumId: number
26
+ beforeCount: number
27
+ authorUserId: number | null
28
+ authorUsername: string
29
+ authorPostCount: number | null
30
+ authorJoinedAt: Date | null
31
+ message: string
32
+ messageHtml: string | null
33
+ renderVersion: number
34
+ bodyFormat: number
35
+ isFirstPost: boolean
36
+ visibility: string
37
+ createdAt: Date
38
+ editedAt: Date | null
39
+ editedByUsername: string | null
40
+ editReason: string | null
41
+ }): PostListingRow {
42
+ return {
43
+ id: row.id,
44
+ threadId: row.threadId,
45
+ forumId: row.forumId,
46
+ number: Number(row.beforeCount) + 1,
47
+ authorUserId: row.authorUserId,
48
+ authorUsername: row.authorUsername,
49
+ authorPostCount: row.authorPostCount ?? 0,
50
+ authorJoinedAt: row.authorJoinedAt,
51
+ message: row.message,
52
+ messageHtml: row.messageHtml,
53
+ renderVersion: Number(row.renderVersion),
54
+ bodyFormat: Number(row.bodyFormat),
55
+ isFirstPost: row.isFirstPost,
56
+ visibility: row.visibility as PostListingRow['visibility'],
57
+ createdAt: row.createdAt,
58
+ editedAt: row.editedAt,
59
+ editedByUsername: row.editedByUsername,
60
+ editReason: row.editReason,
61
+ }
62
+ }
63
+
64
+ export class PostgresPostRepository implements PostRepository {
65
+ constructor(private readonly db: Database) {}
66
+
67
+ async findRatingTarget(postId: number) {
68
+ const rows = await this.db
69
+ .select({ id: posts.id, threadId: posts.threadId, authorUserId: posts.authorUserId })
70
+ .from(posts)
71
+ .where(eq(posts.id, postId))
72
+ .limit(1)
73
+ return rows[0] ?? null
74
+ }
75
+
76
+ async findQuotable(threadId: number, postId: number): Promise<QuotablePost | null> {
77
+ const rows = await this.db
78
+ .select({
79
+ id: posts.id,
80
+ authorUsername: posts.authorUsername,
81
+ message: posts.message,
82
+ bodyFormat: posts.bodyFormat,
83
+ })
84
+ .from(posts)
85
+ .where(
86
+ and(
87
+ eq(posts.id, postId),
88
+ eq(posts.threadId, threadId),
89
+ visibleIn(posts.visibility, PUBLIC_CONTENT),
90
+ ),
91
+ )
92
+ .limit(1)
93
+
94
+ const row = rows[0]
95
+ if (row === undefined) return null
96
+ return {
97
+ id: row.id,
98
+ authorUsername: row.authorUsername,
99
+ message: sourceAsMarkdown(row.message, Number(row.bodyFormat)),
100
+ }
101
+ }
102
+
103
+ async listRevisions(threadId: number, postId: number): Promise<readonly PostRevision[]> {
104
+ const rows = resultRows(
105
+ await this.db.execute(sql`
106
+ select r.revision, r.message, r.subject, r.edited_by_user_id,
107
+ coalesce(u.username, 'Deleted member') as edited_by_username,
108
+ r.edit_reason, r.created_at, false as current
109
+ from post_revisions r
110
+ left join users u on u.id = r.edited_by_user_id
111
+ where r.post_id = ${postId}
112
+ and exists (select 1 from posts p where p.id = r.post_id and p.thread_id = ${threadId})
113
+ union all
114
+ select p.revision_count, p.message, p.subject, p.edited_by_user_id,
115
+ coalesce(u.username, p.author_username, 'Deleted member'),
116
+ p.edit_reason, coalesce(p.edited_at, p.created_at), true
117
+ from posts p
118
+ left join users u on u.id = p.edited_by_user_id
119
+ where p.id = ${postId} and p.thread_id = ${threadId}
120
+ order by revision asc
121
+ `),
122
+ ) as Array<{
123
+ revision: number
124
+ message: string
125
+ subject: string | null
126
+ edited_by_user_id: number | null
127
+ edited_by_username: string
128
+ edit_reason: string | null
129
+ created_at: Date | string
130
+ current: boolean
131
+ }>
132
+
133
+ return rows.map((row) => ({
134
+ revision: Number(row.revision),
135
+ message: row.message,
136
+ subject: row.subject,
137
+ editedByUserId: row.edited_by_user_id === null ? null : Number(row.edited_by_user_id),
138
+ editedByUsername: row.edited_by_username,
139
+ reason: row.edit_reason,
140
+ createdAt: new Date(row.created_at),
141
+ current: row.current,
142
+ }))
143
+ }
144
+
145
+ async locate(
146
+ threadId: number,
147
+ postId: number,
148
+ options: { readonly scope: ContentScope; readonly pageSize: number },
149
+ ): Promise<PostLocation | null> {
150
+ const visible: SQL = visibleIn(posts.visibility, options.scope)
151
+ const size = Math.max(1, Math.trunc(options.pageSize))
152
+
153
+ const counted = await this.db
154
+ .select({
155
+ number: sql<number>`count(*) filter (where ${posts.id} <= ${postId})::int`,
156
+ found: sql<number>`count(*) filter (where ${posts.id} = ${postId})::int`,
157
+ })
158
+ .from(posts)
159
+ .where(and(eq(posts.threadId, threadId), visible))
160
+
161
+ const row = counted[0]
162
+ if (row === undefined || Number(row.found) === 0) return null
163
+
164
+ const number = Number(row.number)
165
+ const page = Math.floor((number - 1) / size) + 1
166
+ if (page === 1) return { number, page, afterId: null }
167
+
168
+ const cursor = await this.db
169
+ .select({ id: posts.id })
170
+ .from(posts)
171
+ .where(and(eq(posts.threadId, threadId), visible))
172
+ .orderBy(asc(posts.id))
173
+ .limit(1)
174
+ .offset((page - 1) * size - 1)
175
+
176
+ return { number, page, afterId: cursor[0]?.id ?? null }
177
+ }
178
+
179
+ async findVisibleById(threadId: number, postId: number): Promise<number | null> {
180
+ const rows = await this.db
181
+ .select({ id: posts.id })
182
+ .from(posts)
183
+ .where(
184
+ and(
185
+ eq(posts.threadId, threadId),
186
+ eq(posts.id, postId),
187
+ visibleIn(posts.visibility, PUBLIC_CONTENT),
188
+ ),
189
+ )
190
+ .limit(1)
191
+ return rows[0]?.id ?? null
192
+ }
193
+
194
+ async listThread(
195
+ threadId: number,
196
+ options: {
197
+ readonly afterId?: number
198
+ readonly offset?: number
199
+ readonly limit: number
200
+ readonly scope: ContentScope
201
+ },
202
+ ): Promise<PostPage> {
203
+ const visible: SQL = visibleIn(posts.visibility, options.scope)
204
+
205
+ const beforeCount = options.afterId
206
+ ? sql<number>`(
207
+ select count(*)::int from ${posts}
208
+ where ${posts.threadId} = ${threadId}
209
+ and ${visible}
210
+ and ${posts.id} <= ${options.afterId}
211
+ )`
212
+ : sql<number>`0`
213
+
214
+ const rows = await this.db
215
+ .select({
216
+ id: posts.id,
217
+ threadId: posts.threadId,
218
+ forumId: posts.forumId,
219
+ beforeCount,
220
+ authorUserId: posts.authorUserId,
221
+ authorUsername: posts.authorUsername,
222
+ authorPostCount: users.postCount,
223
+ authorJoinedAt: users.createdAt,
224
+ message: posts.message,
225
+ messageHtml: posts.messageHtml,
226
+ renderVersion: posts.renderVersion,
227
+ bodyFormat: posts.bodyFormat,
228
+ isFirstPost: posts.isFirstPost,
229
+ visibility: posts.visibility,
230
+ createdAt: posts.createdAt,
231
+ editedAt: posts.editedAt,
232
+ editedByUsername: editors.username,
233
+ editReason: posts.editReason,
234
+ })
235
+ .from(posts)
236
+ .leftJoin(users, eq(posts.authorUserId, users.id))
237
+ .leftJoin(editors, eq(posts.editedByUserId, editors.id))
238
+ .where(
239
+ and(
240
+ eq(posts.threadId, threadId),
241
+ visible,
242
+ ...(options.afterId ? [gt(posts.id, options.afterId)] : []),
243
+ ),
244
+ )
245
+ .orderBy(asc(posts.id))
246
+ .limit(options.limit + 1)
247
+ .offset(options.offset ?? 0)
248
+
249
+ const skipped = options.offset ?? 0
250
+ const page = rows
251
+ .slice(0, options.limit)
252
+ .map((row, index) =>
253
+ toPost({ ...row, beforeCount: Number(row.beforeCount) + skipped + index }),
254
+ )
255
+ const last = page.at(-1)
256
+ return {
257
+ rows: page,
258
+ nextAfterId: rows.length > options.limit && last ? last.id : null,
259
+ }
260
+ }
261
+ }
@@ -0,0 +1,227 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import {
4
+ authorRef,
5
+ BodyFormat,
6
+ CORE_RENDERING,
7
+ type MarkdownPipeline,
8
+ renderThrough,
9
+ vocabularyOptions,
10
+ } from '@meith/markdown'
11
+ import type {
12
+ PostEditRecord,
13
+ PostEditTarget,
14
+ PostVisibilityRecord,
15
+ PostWriteRepository,
16
+ } from '@meith/posts'
17
+
18
+ import type { Database } from './client'
19
+ import { resultRows } from './result-rows'
20
+ import { indexedSubjectSql, SEARCH_DOCUMENT_VERSION, searchVectorSql } from './search-repo'
21
+ import { logModeratorAction } from './thread-counters'
22
+ import { applyVisibilityChangeCounters } from './visibility-counters'
23
+ import { readBoardVocabulary } from './vocabulary-repo'
24
+
25
+ function isCounted(visibility: string): boolean {
26
+ return visibility === 'visible'
27
+ }
28
+
29
+ function actedOnAnother(actorUserId: number, authorUserId: number | null): boolean {
30
+ return authorUserId === null || actorUserId !== authorUserId
31
+ }
32
+
33
+ function scopedDetail(record: {
34
+ readonly postId: number
35
+ readonly threadId: number
36
+ readonly forumId: number
37
+ }): Record<string, unknown> {
38
+ return {
39
+ postId: record.postId,
40
+ threadId: record.threadId,
41
+ forumId: record.forumId,
42
+ forumIds: [record.forumId],
43
+ }
44
+ }
45
+
46
+ export class PostgresPostWriteRepository implements PostWriteRepository {
47
+ constructor(
48
+ private readonly db: Database,
49
+ private readonly rendering: MarkdownPipeline = CORE_RENDERING,
50
+ ) {}
51
+
52
+ async findEditTarget(threadId: number, postId: number): Promise<PostEditTarget | null> {
53
+ const rows = resultRows(
54
+ await this.db.execute(sql`
55
+ select p.id, p.thread_id, p.forum_id, p.author_user_id, p.subject, p.message,
56
+ p.visibility, p.is_first_post, p.revision_count, p.created_at,
57
+ t.slug as thread_slug, t.title as thread_title, t.is_locked,
58
+ t.visibility as thread_visibility,
59
+ f.slug as forum_slug, f.is_open
60
+ from posts p
61
+ join threads t on t.id = p.thread_id
62
+ join forums f on f.id = p.forum_id
63
+ where p.id = ${postId} and p.thread_id = ${threadId}
64
+ `),
65
+ ) as Array<{
66
+ id: number
67
+ thread_id: number
68
+ forum_id: number
69
+ author_user_id: number | null
70
+ subject: string | null
71
+ message: string
72
+ visibility: 'visible' | 'unapproved' | 'deleted'
73
+ is_first_post: boolean
74
+ revision_count: number
75
+ created_at: Date
76
+ thread_slug: string
77
+ thread_title: string
78
+ is_locked: boolean
79
+ thread_visibility: 'visible' | 'unapproved' | 'deleted'
80
+ forum_slug: string
81
+ is_open: boolean
82
+ }>
83
+
84
+ const row = rows[0]
85
+ if (!row) return null
86
+
87
+ return {
88
+ post: {
89
+ id: Number(row.id),
90
+ threadId: Number(row.thread_id),
91
+ forumId: Number(row.forum_id),
92
+ authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
93
+ subject: row.subject,
94
+ message: row.message,
95
+ visibility: row.visibility,
96
+ isFirstPost: row.is_first_post,
97
+ revisionCount: Number(row.revision_count),
98
+ createdAt: new Date(row.created_at),
99
+ },
100
+ thread: {
101
+ id: Number(row.thread_id),
102
+ slug: row.thread_slug,
103
+ title: row.thread_title,
104
+ isLocked: row.is_locked,
105
+ visibility: row.thread_visibility,
106
+ },
107
+ forum: { id: Number(row.forum_id), slug: row.forum_slug, isOpen: row.is_open },
108
+ }
109
+ }
110
+
111
+ async applyEdit(record: PostEditRecord): Promise<void> {
112
+ const vocabulary = await readBoardVocabulary(this.db, this.rendering)
113
+ const body = await renderThrough(
114
+ this.rendering,
115
+ record.message,
116
+ { source: 'post', viewer: authorRef(record.editedByUserId), postId: record.postId },
117
+ vocabularyOptions(vocabulary),
118
+ )
119
+
120
+ await this.db.transaction(async (tx) => {
121
+ await tx.execute(sql`
122
+ insert into post_revisions
123
+ (post_id, revision, message, subject, edited_by_user_id, edit_reason, created_at)
124
+ values
125
+ (${record.postId}, ${record.revision}, ${record.previousMessage},
126
+ ${record.previousSubject}, ${record.editedByUserId}, ${record.reason},
127
+ ${record.editedAt})
128
+ `)
129
+
130
+ const notice = record.silent
131
+ ? sql``
132
+ : sql`edited_at = ${record.editedAt},
133
+ edited_by_user_id = ${record.editedByUserId},
134
+ edit_reason = ${record.reason},`
135
+ await tx.execute(sql`
136
+ update posts p
137
+ set message = ${record.message},
138
+ message_html = ${body.html},
139
+ /*
140
+ * The indexed document is rewritten with the text, in the
141
+ * same statement. An edit that changed the body and left the
142
+ * vector alone would make the post findable by words it no
143
+ * longer contains — and unfindable by the ones it does.
144
+ *
145
+ * Through indexedSubjectSql rather than off subject, which is
146
+ * null for everything this board writes: reading the column
147
+ * alone left the weight-A half of the document empty, so an
148
+ * edited opening post lost the title an edit is supposed to
149
+ * preserve. The alias is why the statement names "posts p".
150
+ */
151
+ search_vector = ${searchVectorSql(indexedSubjectSql(sql`p`), sql`${record.message}`)},
152
+ search_version = ${SEARCH_DOCUMENT_VERSION},
153
+ render_version = ${body.version},
154
+ vocab_version = ${vocabulary.revision},
155
+ /*
156
+ * Named rather than left to the column default: an edit is the
157
+ * one path that can turn a legacy row into a current one, and a
158
+ * row whose text is now Markdown while its stamp still says
159
+ * BBCode would be converted a second time by the backfill.
160
+ */
161
+ body_format = ${BodyFormat.Markdown},
162
+ visibility = ${record.toVisibility},
163
+ ${notice}
164
+ revision_count = revision_count + 1
165
+ where id = ${record.postId}
166
+ `)
167
+
168
+ const delta =
169
+ (isCounted(record.toVisibility) ? 1 : 0) - (isCounted(record.fromVisibility) ? 1 : 0)
170
+ if (delta !== 0) {
171
+ await applyVisibilityChangeCounters(tx, {
172
+ postId: record.postId,
173
+ threadId: record.threadId,
174
+ forumId: record.forumId,
175
+ authorId: record.authorUserId,
176
+ isFirstPost: record.isFirstPost,
177
+ delta: delta as 1 | -1,
178
+ })
179
+ }
180
+
181
+ if (actedOnAnother(record.editedByUserId, record.authorUserId)) {
182
+ await logModeratorAction(
183
+ tx,
184
+ 'post.edit',
185
+ record.editedByUserId,
186
+ scopedDetail(record),
187
+ record.editedAt,
188
+ )
189
+ }
190
+ })
191
+ }
192
+
193
+ async applyVisibility(record: PostVisibilityRecord): Promise<boolean> {
194
+ return this.db.transaction(async (tx) => {
195
+ const moved = await tx.execute(sql`
196
+ update posts set visibility = ${record.to}
197
+ where id = ${record.postId} and visibility = ${record.from}
198
+ returning id
199
+ `)
200
+ if (resultRows(moved).length === 0) return false
201
+
202
+ const delta = (isCounted(record.to) ? 1 : 0) - (isCounted(record.from) ? 1 : 0)
203
+ if (delta !== 0) {
204
+ await applyVisibilityChangeCounters(tx, {
205
+ postId: record.postId,
206
+ threadId: record.threadId,
207
+ forumId: record.forumId,
208
+ authorId: record.authorUserId,
209
+ isFirstPost: record.isFirstPost,
210
+ delta: delta as 1 | -1,
211
+ })
212
+ }
213
+
214
+ if (actedOnAnother(record.actedByUserId, record.authorUserId)) {
215
+ await logModeratorAction(
216
+ tx,
217
+ record.to === 'deleted' ? 'post.delete' : 'post.restore',
218
+ record.actedByUserId,
219
+ scopedDetail(record),
220
+ record.at,
221
+ )
222
+ }
223
+
224
+ return true
225
+ })
226
+ }
227
+ }
@@ -0,0 +1,220 @@
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 { inAudience } from './thread-audience'
9
+ import { visibleIn } from './visibility'
10
+
11
+ export const ONLINE_WINDOW_MINUTES = 15
12
+
13
+ export interface OnlineScope {
14
+ readonly forumIds: readonly number[]
15
+ readonly ownThreadsOnlyForumIds: readonly number[]
16
+ readonly viewerUserId: number | null
17
+ readonly content: ContentScope
18
+ readonly seesInvisible: boolean
19
+ }
20
+
21
+ export interface OnlineMember {
22
+ readonly userId: number
23
+ readonly username: string
24
+ readonly invisible: boolean
25
+ readonly lastSeenAt: Date
26
+ readonly forumId: number | null
27
+ readonly forumTitle: string | null
28
+ readonly threadId: number | null
29
+ readonly threadTitle: string | null
30
+ readonly threadSlug: string | null
31
+ }
32
+
33
+ export interface OnlineSnapshot {
34
+ readonly members: readonly OnlineMember[]
35
+ readonly guestCount: number
36
+ readonly total: number
37
+ readonly invisibleCount: number
38
+ }
39
+
40
+ export interface OnlineRecord {
41
+ readonly count: number
42
+ readonly at: Date | null
43
+ }
44
+
45
+ export class PostgresPresenceRepository {
46
+ constructor(private readonly db: Database) {}
47
+
48
+ async onlineNow(now: Date, scope: OnlineScope): Promise<OnlineSnapshot> {
49
+ const since = new Date(now.getTime() - ONLINE_WINDOW_MINUTES * 60_000)
50
+
51
+ const mayName =
52
+ scope.forumIds.length === 0
53
+ ? sql`false`
54
+ : sql`s.location_forum_id in (${sql.join(
55
+ scope.forumIds.map((id) => sql`${id}`),
56
+ sql`, `,
57
+ )})`
58
+
59
+ const mayNameThread = sql`(${inAudience(
60
+ sql`t.forum_id`,
61
+ sql`t.author_user_id`,
62
+ scope,
63
+ )} and ${visibleIn(sql`t.visibility`, scope.content)})`
64
+
65
+ const rows = resultRows(
66
+ await this.db.execute(sql`
67
+ select distinct on (coalesce(s.user_id, -s.id))
68
+ s.user_id, s.id as session_id, s.last_seen_at,
69
+ u.username, u.invisible,
70
+ case when ${mayName} then s.location_forum_id end as forum_id,
71
+ case when ${mayName} then f.title end as forum_title,
72
+ case when ${mayNameThread} then s.location_thread_id end as thread_id,
73
+ case when ${mayNameThread} then t.title end as thread_title,
74
+ case when ${mayNameThread} then t.slug end as thread_slug
75
+ from sessions s
76
+ left join users u on u.id = s.user_id
77
+ left join forums f on f.id = s.location_forum_id
78
+ left join threads t on t.id = s.location_thread_id
79
+ where s.revoked_at is null
80
+ and s.last_seen_at >= ${since}
81
+ and (s.user_id is null or u.state = 'active')
82
+ order by coalesce(s.user_id, -s.id), s.last_seen_at desc
83
+ `),
84
+ ) as Array<Record<string, unknown>>
85
+
86
+ let guestCount = 0
87
+ let invisibleCount = 0
88
+ const members: OnlineMember[] = []
89
+
90
+ for (const row of rows) {
91
+ if (row.user_id === null) {
92
+ guestCount += 1
93
+ continue
94
+ }
95
+
96
+ const invisible = row.invisible === true
97
+ if (invisible) invisibleCount += 1
98
+ if (invisible && !scope.seesInvisible) continue
99
+
100
+ members.push({
101
+ userId: Number(row.user_id),
102
+ username: String(row.username),
103
+ invisible,
104
+ lastSeenAt: toDate(row.last_seen_at),
105
+ forumId: row.forum_id === null ? null : Number(row.forum_id),
106
+ forumTitle: row.forum_title === null ? null : String(row.forum_title),
107
+ threadId: row.thread_id === null ? null : Number(row.thread_id),
108
+ threadTitle: row.thread_title === null ? null : String(row.thread_title),
109
+ threadSlug: row.thread_slug === null ? null : String(row.thread_slug),
110
+ })
111
+ }
112
+
113
+ return {
114
+ members,
115
+ guestCount,
116
+ total: guestCount + members.length,
117
+ invisibleCount: scope.seesInvisible ? invisibleCount : 0,
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Records that an anonymous reader is here, under a session row with no user.
123
+ *
124
+ * The read side has counted these rows since presence was written — the
125
+ * `user_id` column is nullable for exactly this — but nothing ever wrote one,
126
+ * so every board reported zero guests forever. This is the missing half.
127
+ *
128
+ * `windowSeconds` throttles the write the way the member path does: a reader
129
+ * clicking through a thread should cost one update a minute, not one an
130
+ * article. The insert is unconditional, though, or a reader whose first page
131
+ * view landed inside another reader's throttle window would never appear.
132
+ */
133
+ async touchGuest(input: {
134
+ readonly tokenHash: string
135
+ readonly location: {
136
+ readonly path: string
137
+ readonly forumId: number | null
138
+ readonly threadId: number | null
139
+ }
140
+ readonly now: Date
141
+ readonly windowSeconds: number
142
+ readonly expiresAt: Date
143
+ }): Promise<void> {
144
+ const cutoff = new Date(input.now.getTime() - input.windowSeconds * 1000)
145
+
146
+ await this.db.execute(sql`
147
+ insert into sessions
148
+ (token_hash, user_id, location_path, location_forum_id, location_thread_id,
149
+ last_seen_at, expires_at)
150
+ values
151
+ (${input.tokenHash}, null, ${input.location.path}, ${input.location.forumId},
152
+ ${input.location.threadId}, ${input.now}, ${input.expiresAt})
153
+ on conflict (token_hash) do update
154
+ set location_path = excluded.location_path,
155
+ location_forum_id = excluded.location_forum_id,
156
+ location_thread_id = excluded.location_thread_id,
157
+ last_seen_at = excluded.last_seen_at,
158
+ expires_at = excluded.expires_at
159
+ where sessions.user_id is null
160
+ and sessions.last_seen_at < ${cutoff}
161
+ `)
162
+ }
163
+
164
+ /**
165
+ * Forgets a guest row, for when the reader holding it has just signed in.
166
+ *
167
+ * Without this they are two people for the next fifteen minutes — a member in
168
+ * the list and a guest in the count — because the row they browsed under is
169
+ * still inside the window and there is nothing tying it to the account they
170
+ * have now proved they own.
171
+ */
172
+ async dropGuest(tokenHash: string): Promise<void> {
173
+ await this.db.execute(sql`
174
+ delete from sessions where token_hash = ${tokenHash} and user_id is null
175
+ `)
176
+ }
177
+
178
+ async concurrentCount(now: Date): Promise<number> {
179
+ const since = new Date(now.getTime() - ONLINE_WINDOW_MINUTES * 60_000)
180
+ const rows = resultRows(
181
+ await this.db.execute(sql`
182
+ select count(distinct coalesce(s.user_id, -s.id))::int as n
183
+ from sessions s
184
+ where s.revoked_at is null and s.last_seen_at >= ${since}
185
+ `),
186
+ ) as Array<{ n: number }>
187
+
188
+ return rows[0]?.n ?? 0
189
+ }
190
+
191
+ async recordIfHigher(count: number, now: Date): Promise<boolean> {
192
+ const rows = resultRows(
193
+ await this.db.execute(sql`
194
+ update board_stats
195
+ set most_online_count = ${count},
196
+ most_online_at = ${now},
197
+ updated_at = ${now}
198
+ where id = 1 and most_online_count < ${count}
199
+ returning most_online_count
200
+ `),
201
+ ) as Array<unknown>
202
+
203
+ return rows.length > 0
204
+ }
205
+
206
+ async readRecord(): Promise<OnlineRecord> {
207
+ const rows = resultRows(
208
+ await this.db.execute(sql`
209
+ select most_online_count, most_online_at from board_stats where id = 1
210
+ `),
211
+ ) as Array<Record<string, unknown>>
212
+
213
+ const row = rows[0]
214
+ if (row === undefined) return { count: 0, at: null }
215
+ return {
216
+ count: Number(row.most_online_count),
217
+ at: row.most_online_at === null ? null : toDate(row.most_online_at),
218
+ }
219
+ }
220
+ }