@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,311 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+ import { msg } from '@meith/i18n'
5
+ import type { MoveDestination, ThreadToolsRepository, ThreadToolTarget } from '@meith/moderation'
6
+
7
+ import type { Database } from './client'
8
+ import { resultRows } from './result-rows'
9
+ import {
10
+ applyAuthorCounts,
11
+ applyForumChain,
12
+ logModeratorAction as log,
13
+ syncLedger,
14
+ tallyThread,
15
+ } from './thread-counters'
16
+ import { repairForumLastPostChain, repairThreadLastPost } from './visibility-counters'
17
+
18
+ export class PostgresThreadToolsRepository implements ThreadToolsRepository {
19
+ constructor(private readonly db: Database) {}
20
+
21
+ async find(threadId: number): Promise<ThreadToolTarget | null> {
22
+ const rows = resultRows(
23
+ await this.db.execute(sql`
24
+ select id, forum_id, slug, title, is_locked, is_sticky, visibility
25
+ from threads where id = ${threadId}
26
+ `),
27
+ ) as Array<{
28
+ id: number
29
+ forum_id: number
30
+ slug: string
31
+ title: string
32
+ is_locked: boolean
33
+ is_sticky: boolean
34
+ visibility: ThreadToolTarget['visibility']
35
+ }>
36
+
37
+ const row = rows[0]
38
+ if (!row) return null
39
+ return {
40
+ id: Number(row.id),
41
+ forumId: Number(row.forum_id),
42
+ slug: row.slug,
43
+ title: row.title,
44
+ isLocked: row.is_locked,
45
+ isSticky: row.is_sticky,
46
+ visibility: row.visibility,
47
+ }
48
+ }
49
+
50
+ async findDestination(forumId: number): Promise<MoveDestination | null> {
51
+ const rows = resultRows(
52
+ await this.db.execute(sql`select id, type, allow_threads from forums where id = ${forumId}`),
53
+ ) as Array<{ id: number; type: MoveDestination['type']; allow_threads: boolean }>
54
+ const row = rows[0]
55
+ return row === undefined
56
+ ? null
57
+ : { id: Number(row.id), type: row.type, allowThreads: row.allow_threads === true }
58
+ }
59
+
60
+ async setLocked(input: {
61
+ readonly threadId: number
62
+ readonly locked: boolean
63
+ readonly actorUserId: number
64
+ readonly at: Date
65
+ }): Promise<boolean> {
66
+ return this.flag(
67
+ 'is_locked',
68
+ input.locked,
69
+ input,
70
+ input.locked ? 'thread.lock' : 'thread.unlock',
71
+ )
72
+ }
73
+
74
+ async setSticky(input: {
75
+ readonly threadId: number
76
+ readonly sticky: boolean
77
+ readonly actorUserId: number
78
+ readonly at: Date
79
+ }): Promise<boolean> {
80
+ return this.flag(
81
+ 'is_sticky',
82
+ input.sticky,
83
+ input,
84
+ input.sticky ? 'thread.stick' : 'thread.unstick',
85
+ )
86
+ }
87
+
88
+ private async flag(
89
+ column: 'is_locked' | 'is_sticky',
90
+ value: boolean,
91
+ input: { readonly threadId: number; readonly actorUserId: number; readonly at: Date },
92
+ action: string,
93
+ ): Promise<boolean> {
94
+ return this.db.transaction(async (tx) => {
95
+ const moved = resultRows(
96
+ await tx.execute(
97
+ column === 'is_locked'
98
+ ? sql`update threads set is_locked = ${value}, updated_at = ${input.at}
99
+ where id = ${input.threadId} and is_locked <> ${value}
100
+ returning id, forum_id`
101
+ : sql`update threads set is_sticky = ${value}, updated_at = ${input.at}
102
+ where id = ${input.threadId} and is_sticky <> ${value}
103
+ returning id, forum_id`,
104
+ ),
105
+ ) as Array<{ id: number; forum_id: number }>
106
+ const row = moved[0]
107
+ if (!row) return false
108
+
109
+ const forumId = Number(row.forum_id)
110
+ await log(
111
+ tx,
112
+ action,
113
+ input.actorUserId,
114
+ { threadId: input.threadId, forumId, forumIds: [forumId] },
115
+ input.at,
116
+ )
117
+ return true
118
+ })
119
+ }
120
+
121
+ async setVisibility(input: {
122
+ readonly threadId: number
123
+ readonly from: 'visible' | 'deleted'
124
+ readonly to: 'visible' | 'deleted'
125
+ readonly actorUserId: number
126
+ readonly at: Date
127
+ }): Promise<boolean> {
128
+ return this.db.transaction(async (tx) => {
129
+ const tally = await tallyThread(tx, input.threadId)
130
+
131
+ const moved = resultRows(
132
+ await tx.execute(sql`
133
+ update threads set visibility = ${input.to}, updated_at = ${input.at}
134
+ where id = ${input.threadId} and visibility = ${input.from}
135
+ returning forum_id
136
+ `),
137
+ ) as Array<{ forum_id: number }>
138
+ const row = moved[0]
139
+ if (!row) return false
140
+
141
+ const forumId = Number(row.forum_id)
142
+ const delta: 1 | -1 = input.to === 'visible' ? 1 : -1
143
+
144
+ await applyForumChain(tx, forumId, delta, tally)
145
+ await applyAuthorCounts(tx, delta, tally)
146
+ await syncLedger(tx, input.threadId, input.to === 'visible')
147
+ await repairForumLastPostChain(tx, forumId)
148
+
149
+ await log(
150
+ tx,
151
+ input.to === 'visible' ? 'thread.restore' : 'thread.delete',
152
+ input.actorUserId,
153
+ { threadId: input.threadId, forumId, forumIds: [forumId], posts: tally.posts },
154
+ input.at,
155
+ )
156
+ return true
157
+ })
158
+ }
159
+
160
+ async move(input: {
161
+ readonly threadId: number
162
+ readonly fromForumId: number
163
+ readonly toForumId: number
164
+ readonly actorUserId: number
165
+ readonly at: Date
166
+ }): Promise<boolean> {
167
+ return this.db.transaction(async (tx) => {
168
+ const tally = await tallyThread(tx, input.threadId)
169
+
170
+ const moved = resultRows(
171
+ await tx.execute(sql`
172
+ update threads set forum_id = ${input.toForumId}, updated_at = ${input.at}
173
+ where id = ${input.threadId} and forum_id = ${input.fromForumId}
174
+ returning id
175
+ `),
176
+ ) as Array<{ id: number }>
177
+ if (moved.length === 0) return false
178
+
179
+ await tx.execute(sql`
180
+ update posts set forum_id = ${input.toForumId} where thread_id = ${input.threadId}
181
+ `)
182
+
183
+ await applyForumChain(tx, input.fromForumId, -1, tally)
184
+ await applyForumChain(tx, input.toForumId, 1, tally)
185
+
186
+ await repairForumLastPostChain(tx, input.fromForumId)
187
+ await repairForumLastPostChain(tx, input.toForumId)
188
+
189
+ await log(
190
+ tx,
191
+ 'thread.move',
192
+ input.actorUserId,
193
+ {
194
+ threadId: input.threadId,
195
+ fromForumId: input.fromForumId,
196
+ toForumId: input.toForumId,
197
+ forumIds: [input.fromForumId, input.toForumId],
198
+ posts: tally.posts,
199
+ },
200
+ input.at,
201
+ )
202
+ return true
203
+ })
204
+ }
205
+
206
+ async copy(input: {
207
+ readonly threadId: number
208
+ readonly toForumId: number
209
+ readonly actorUserId: number
210
+ readonly at: Date
211
+ }): Promise<{ threadId: number; slug: string; posts: number }> {
212
+ return this.db.transaction(async (tx) => {
213
+ const sourceRows = resultRows(
214
+ await tx.execute(sql`
215
+ select id, forum_id, title, slug, prefix_id, author_user_id, author_username
216
+ from threads where id = ${input.threadId}
217
+ `),
218
+ ) as Array<{
219
+ id: number
220
+ forum_id: number
221
+ title: string
222
+ slug: string
223
+ prefix_id: number | null
224
+ author_user_id: number | null
225
+ author_username: string
226
+ }>
227
+ const source = sourceRows[0]
228
+ if (!source) {
229
+ throw new ValidationError(msg('error.db.thread-exist'))
230
+ }
231
+
232
+ const created = resultRows(
233
+ await tx.execute(sql`
234
+ insert into threads
235
+ (forum_id, title, slug, prefix_id, author_user_id, author_username,
236
+ visibility, created_at, updated_at)
237
+ values
238
+ (${input.toForumId}, ${source.title}, ${source.slug}, ${source.prefix_id},
239
+ ${source.author_user_id}, ${source.author_username},
240
+ 'visible', ${input.at}, ${input.at})
241
+ returning id, slug
242
+ `),
243
+ ) as Array<{ id: number; slug: string }>
244
+ const newThreadId = Number(created[0]!.id)
245
+
246
+ const copied = resultRows(
247
+ await tx.execute(sql`
248
+ insert into posts
249
+ (thread_id, forum_id, author_user_id, author_username, subject, message,
250
+ message_html, render_version, vocab_version, body_format, visibility,
251
+ is_first_post, created_at, search_vector, search_version)
252
+ select ${newThreadId}, ${input.toForumId}, p.author_user_id, p.author_username,
253
+ p.subject, p.message, p.message_html, p.render_version,
254
+ /*
255
+ * Both stamps travel with the body. A copy that claimed to be
256
+ * Markdown because the column defaults that way would be a post
257
+ * whose BBCode the backfill has been told it already converted.
258
+ */
259
+ p.vocab_version, p.body_format,
260
+ 'visible', p.is_first_post, p.created_at,
261
+ /*
262
+ * The search document travels too, for the third time in this list
263
+ * and the same reason: a copy left with no vector would be a
264
+ * thread nobody could find until the backfill happened to
265
+ * reach it. Copying rather than recomputing is exact here —
266
+ * the copy keeps the source's title, body and opening post, so
267
+ * every input to the document is the same.
268
+ */
269
+ p.search_vector, p.search_version
270
+ from posts p
271
+ where p.thread_id = ${input.threadId} and p.visibility = 'visible'
272
+ order by p.id
273
+ returning id, author_user_id, is_first_post
274
+ `),
275
+ ) as Array<{ id: number; author_user_id: number | null; is_first_post: boolean }>
276
+
277
+ const first = copied.find((row) => row.is_first_post) ?? copied[0]
278
+ await tx.execute(sql`
279
+ update threads
280
+ set first_post_id = ${first === undefined ? null : Number(first.id)},
281
+ reply_count = ${Math.max(copied.length - 1, 0)}
282
+ where id = ${newThreadId}
283
+ `)
284
+
285
+ const tally = await tallyThread(tx, newThreadId)
286
+ await applyForumChain(tx, input.toForumId, 1, tally)
287
+ await applyAuthorCounts(tx, 1, tally)
288
+ await syncLedger(tx, newThreadId, true)
289
+ await repairThreadLastPost(tx, newThreadId)
290
+ await repairForumLastPostChain(tx, input.toForumId)
291
+
292
+ const fromForumId = Number(source.forum_id)
293
+ await log(
294
+ tx,
295
+ 'thread.copy',
296
+ input.actorUserId,
297
+ {
298
+ threadId: input.threadId,
299
+ fromForumId,
300
+ toForumId: input.toForumId,
301
+ forumIds: [...new Set([fromForumId, input.toForumId])],
302
+ newThreadId,
303
+ posts: copied.length,
304
+ },
305
+ input.at,
306
+ )
307
+
308
+ return { threadId: newThreadId, slug: created[0]!.slug, posts: copied.length }
309
+ })
310
+ }
311
+ }
@@ -0,0 +1,41 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+ import { resultRows } from './result-rows'
5
+
6
+ export class PostgresThreadViewBuffer {
7
+ constructor(private readonly db: Database) {}
8
+
9
+ async record(threadId: number): Promise<void> {
10
+ await this.db.execute(sql`
11
+ insert into thread_view_buffer (thread_id, pending, updated_at)
12
+ values (${threadId}, 1, now())
13
+ on conflict (thread_id) do update
14
+ set pending = thread_view_buffer.pending + 1,
15
+ updated_at = now()
16
+ `)
17
+ }
18
+
19
+ async flush(limit = 500): Promise<number> {
20
+ const result = await this.db.execute(sql`
21
+ with claimed as (
22
+ delete from thread_view_buffer
23
+ where thread_id in (
24
+ select thread_id from thread_view_buffer
25
+ where pending > 0
26
+ order by updated_at
27
+ limit ${limit}
28
+ for update skip locked
29
+ )
30
+ returning thread_id, pending
31
+ )
32
+ update threads t
33
+ set view_count = t.view_count + c.pending
34
+ from claimed c
35
+ where t.id = c.thread_id
36
+ returning t.id
37
+ `)
38
+
39
+ return resultRows(result).length
40
+ }
41
+ }
@@ -0,0 +1,356 @@
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 { ValidatedPoll } from '@meith/polls'
12
+ import type {
13
+ CreatedThread,
14
+ ForumPostingTarget,
15
+ NewReplyRecord,
16
+ NewThreadRecord,
17
+ ReplyTarget,
18
+ ReplyWriteRepository,
19
+ ThreadWriteRepository,
20
+ } from '@meith/threads'
21
+
22
+ import type { Database } from './client'
23
+ import { applyCreatedContentCounters } from './content-counters'
24
+ import { resultRows } from './result-rows'
25
+ import { SEARCH_DOCUMENT_VERSION, searchVectorSql } from './search-repo'
26
+ import { readBoardVocabulary } from './vocabulary-repo'
27
+
28
+ export class PostgresThreadWriteRepository implements ThreadWriteRepository, ReplyWriteRepository {
29
+ constructor(
30
+ private readonly db: Database,
31
+ private readonly rendering: MarkdownPipeline = CORE_RENDERING,
32
+ ) {}
33
+
34
+ async postingRules(forumId: number): Promise<ForumPostingTarget | null> {
35
+ const rows = resultRows(
36
+ await this.db.execute(sql`
37
+ select id, type, slug, is_open, allow_threads, allow_replies, allow_polls,
38
+ allow_attachments, requires_prefix, moderate_new_threads,
39
+ moderate_new_posts
40
+ from forums where id = ${forumId}
41
+ `),
42
+ ) as Array<{
43
+ id: number
44
+ type: 'category' | 'forum' | 'link'
45
+ slug: string
46
+ is_open: boolean
47
+ allow_threads: boolean
48
+ allow_replies: boolean
49
+ allow_polls: boolean
50
+ allow_attachments: boolean
51
+ requires_prefix: boolean
52
+ moderate_new_threads: boolean
53
+ moderate_new_posts: boolean
54
+ }>
55
+
56
+ const row = rows[0]
57
+ if (!row) return null
58
+
59
+ return {
60
+ id: Number(row.id),
61
+ type: row.type,
62
+ slug: row.slug,
63
+ isOpen: row.is_open,
64
+ allowThreads: row.allow_threads,
65
+ allowReplies: row.allow_replies,
66
+ allowPolls: row.allow_polls,
67
+ allowAttachments: row.allow_attachments,
68
+ requiresPrefix: row.requires_prefix,
69
+ moderateNewThreads: row.moderate_new_threads,
70
+ moderateNewPosts: row.moderate_new_posts,
71
+ }
72
+ }
73
+
74
+ async create(record: NewThreadRecord): Promise<CreatedThread> {
75
+ const vocabulary = await readBoardVocabulary(this.db, this.rendering)
76
+ const body = await renderThrough(
77
+ this.rendering,
78
+ record.message,
79
+ { source: 'post', viewer: authorRef(record.authorUserId) },
80
+ vocabularyOptions(vocabulary),
81
+ )
82
+
83
+ return this.db.transaction(async (tx) => {
84
+ const threadRows = resultRows(
85
+ await tx.execute(sql`
86
+ insert into threads
87
+ (forum_id, title, slug, prefix_id, author_user_id, author_username,
88
+ visibility, last_post_at, created_at, updated_at)
89
+ values
90
+ (${record.forumId}, ${record.title}, ${record.slug}, ${record.prefixId},
91
+ ${record.authorUserId}, ${record.authorUsername}, ${record.visibility},
92
+ ${record.createdAt}, ${record.createdAt}, ${record.createdAt})
93
+ returning id
94
+ `),
95
+ ) as Array<{ id: number }>
96
+ const threadId = Number(threadRows[0]!.id)
97
+
98
+ const postRows = resultRows(
99
+ await tx.execute(sql`
100
+ insert into posts
101
+ (thread_id, forum_id, author_user_id, author_username, message,
102
+ message_html, render_version, vocab_version, body_format, visibility,
103
+ is_first_post, created_at, search_vector, search_version)
104
+ values
105
+ (${threadId}, ${record.forumId}, ${record.authorUserId},
106
+ ${record.authorUsername}, ${record.message}, ${body.html},
107
+ ${body.version}, ${vocabulary.revision}, ${BodyFormat.Markdown},
108
+ ${record.visibility}, true,
109
+ ${record.createdAt},
110
+ /*
111
+ * The **title** is the weight-A field, because this is the
112
+ * thread's opening post and a thread's subject is its title. The
113
+ * row cannot be read while it is being inserted, so the value is
114
+ * passed rather than derived — and it is the same value
115
+ * indexedSubjectSql would produce for this row (subject null,
116
+ * is_first_post true), which search-repo.test.ts pins so the
117
+ * writer and the backfill cannot drift apart.
118
+ */
119
+ ${searchVectorSql(sql`${record.title}`, sql`${record.message}`)},
120
+ ${SEARCH_DOCUMENT_VERSION})
121
+ returning id
122
+ `),
123
+ ) as Array<{ id: number }>
124
+ const postId = Number(postRows[0]!.id)
125
+
126
+ if (record.poll !== undefined) await createPoll(tx as Database, threadId, record.poll)
127
+
128
+ if (record.visibility === 'visible') {
129
+ await applyCreatedContentCounters(tx, {
130
+ postId,
131
+ threadId,
132
+ forumId: record.forumId,
133
+ authorId: record.authorUserId,
134
+ authorUsername: record.authorUsername,
135
+ threadTitle: record.title,
136
+ createdAt: record.createdAt,
137
+ isNewThread: true,
138
+ })
139
+ } else {
140
+ await tx.execute(sql`
141
+ update threads set first_post_id = ${postId} where id = ${threadId}
142
+ `)
143
+ }
144
+
145
+ if (record.subscribe) {
146
+ await tx.execute(sql`
147
+ insert into thread_subscriptions (user_id, thread_id, mode, last_notified_post_id)
148
+ values (${record.authorUserId}, ${threadId}, 'instant', ${postId})
149
+ on conflict (user_id, thread_id) do nothing
150
+ `)
151
+ }
152
+
153
+ return {
154
+ threadId,
155
+ postId,
156
+ slug: record.slug,
157
+ visibility: record.visibility,
158
+ }
159
+ })
160
+ }
161
+
162
+ async replyTarget(threadId: number): Promise<ReplyTarget | null> {
163
+ const rows = resultRows(
164
+ await this.db.execute(sql`
165
+ select t.id, t.slug, t.title, t.author_user_id, t.is_locked, t.visibility,
166
+ t.last_post_id, t.reply_count,
167
+ f.id as forum_id, f.type as forum_type, f.slug as forum_slug,
168
+ f.is_open, f.allow_threads, f.allow_replies, f.allow_polls,
169
+ f.allow_attachments, f.requires_prefix,
170
+ f.moderate_new_threads, f.moderate_new_posts
171
+ from threads t
172
+ join forums f on f.id = t.forum_id
173
+ where t.id = ${threadId}
174
+ `),
175
+ ) as Array<{
176
+ id: number
177
+ slug: string
178
+ title: string
179
+ author_user_id: number | null
180
+ is_locked: boolean
181
+ visibility: 'visible' | 'unapproved' | 'deleted'
182
+ last_post_id: number | null
183
+ reply_count: number
184
+ forum_id: number
185
+ forum_type: 'category' | 'forum' | 'link'
186
+ forum_slug: string
187
+ is_open: boolean
188
+ allow_threads: boolean
189
+ allow_replies: boolean
190
+ allow_polls: boolean
191
+ allow_attachments: boolean
192
+ requires_prefix: boolean
193
+ moderate_new_threads: boolean
194
+ moderate_new_posts: boolean
195
+ }>
196
+
197
+ const row = rows[0]
198
+ if (!row) return null
199
+
200
+ return {
201
+ threadId: Number(row.id),
202
+ slug: row.slug,
203
+ title: row.title,
204
+ authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
205
+ isLocked: row.is_locked,
206
+ visibility: row.visibility,
207
+ lastPostId: row.last_post_id === null ? null : Number(row.last_post_id),
208
+ replyCount: Number(row.reply_count),
209
+ forum: {
210
+ id: Number(row.forum_id),
211
+ type: row.forum_type,
212
+ slug: row.forum_slug,
213
+ isOpen: row.is_open,
214
+ allowThreads: row.allow_threads,
215
+ allowReplies: row.allow_replies,
216
+ allowPolls: row.allow_polls,
217
+ allowAttachments: row.allow_attachments,
218
+ requiresPrefix: row.requires_prefix,
219
+ moderateNewThreads: row.moderate_new_threads,
220
+ moderateNewPosts: row.moderate_new_posts,
221
+ },
222
+ }
223
+ }
224
+
225
+ async createReply(record: NewReplyRecord): Promise<{ postId: number }> {
226
+ const vocabulary = await readBoardVocabulary(this.db, this.rendering)
227
+ const body = await renderThrough(
228
+ this.rendering,
229
+ record.message,
230
+ { source: 'post', viewer: authorRef(record.authorUserId) },
231
+ vocabularyOptions(vocabulary),
232
+ )
233
+
234
+ return this.db.transaction(async (tx) => {
235
+ const postRows = resultRows(
236
+ await tx.execute(sql`
237
+ insert into posts
238
+ (thread_id, forum_id, author_user_id, author_username, message,
239
+ message_html, render_version, vocab_version, body_format, visibility,
240
+ is_first_post, created_at, search_vector, search_version)
241
+ values
242
+ (${record.threadId}, ${record.forumId}, ${record.authorUserId},
243
+ ${record.authorUsername}, ${record.message}, ${body.html},
244
+ ${body.version}, ${vocabulary.revision}, ${BodyFormat.Markdown},
245
+ ${record.visibility}, false,
246
+ ${record.createdAt},
247
+ /*
248
+ * No weight-A field, and that is the rule rather than an
249
+ * omission: a reply has no subject of its own, and folding the
250
+ * thread's title in here would make one title term match every
251
+ * post in the thread — forty hits that are all the same thread,
252
+ * where the opening post already stands for it.
253
+ */
254
+ ${searchVectorSql(sql`${null}`, sql`${record.message}`)},
255
+ ${SEARCH_DOCUMENT_VERSION})
256
+ returning id
257
+ `),
258
+ ) as Array<{ id: number }>
259
+ const postId = Number(postRows[0]!.id)
260
+
261
+ if (record.visibility === 'visible') {
262
+ await applyCreatedContentCounters(tx, {
263
+ postId,
264
+ threadId: record.threadId,
265
+ forumId: record.forumId,
266
+ authorId: record.authorUserId,
267
+ authorUsername: record.authorUsername,
268
+ threadTitle: record.threadTitle,
269
+ createdAt: record.createdAt,
270
+ isNewThread: false,
271
+ })
272
+ }
273
+
274
+ if (record.subscribe) {
275
+ await tx.execute(sql`
276
+ insert into thread_subscriptions (user_id, thread_id, mode, last_notified_post_id)
277
+ values (${record.authorUserId}, ${record.threadId}, 'instant', ${postId})
278
+ on conflict (user_id, thread_id) do nothing
279
+ `)
280
+ }
281
+
282
+ return { postId }
283
+ })
284
+ }
285
+
286
+ async lastPostAt(userId: number): Promise<Date | null> {
287
+ const rows = resultRows(
288
+ await this.db.execute(sql`
289
+ select created_at from posts
290
+ where author_user_id = ${userId} and visibility <> 'deleted'
291
+ order by created_at desc
292
+ limit 1
293
+ `),
294
+ ) as Array<{ created_at: Date }>
295
+
296
+ const at = rows[0]?.created_at
297
+ return at === undefined ? null : new Date(at)
298
+ }
299
+
300
+ async allowedPrefixIds(forumId: number): Promise<readonly number[]> {
301
+ const rows = resultRows(
302
+ await this.db.execute(sql`
303
+ select p.id
304
+ from thread_prefixes p
305
+ left join forums f on f.id = ${forumId}
306
+ where p.forum_path_prefix is null
307
+ or f.path = p.forum_path_prefix
308
+ or f.path like p.forum_path_prefix || '.%'
309
+ order by p.display_order, p.id
310
+ `),
311
+ ) as Array<{ id: number }>
312
+
313
+ return rows.map((row) => Number(row.id))
314
+ }
315
+
316
+ async listPrefixes(
317
+ forumId: number,
318
+ ): Promise<readonly { id: number; label: string; token: string | null }[]> {
319
+ const rows = resultRows(
320
+ await this.db.execute(sql`
321
+ select p.id, p.label, p.token
322
+ from thread_prefixes p
323
+ left join forums f on f.id = ${forumId}
324
+ where p.forum_path_prefix is null
325
+ or f.path = p.forum_path_prefix
326
+ or f.path like p.forum_path_prefix || '.%'
327
+ order by p.display_order, p.id
328
+ `),
329
+ ) as Array<{ id: number; label: string; token: string | null }>
330
+
331
+ return rows.map((row) => ({
332
+ id: Number(row.id),
333
+ label: row.label,
334
+ token: row.token,
335
+ }))
336
+ }
337
+ }
338
+
339
+ async function createPoll(tx: Database, threadId: number, poll: ValidatedPoll): Promise<void> {
340
+ const rows = resultRows(
341
+ await tx.execute(sql`
342
+ insert into polls (thread_id, question, closes_at, max_options, allow_revote, public_votes)
343
+ values (${threadId}, ${poll.question}, ${poll.closesAt}, ${poll.maxOptions},
344
+ ${poll.allowRevote}, ${poll.publicVotes}) returning id
345
+ `),
346
+ ) as Array<{ id: number }>
347
+ const pollId = rows[0]?.id
348
+ if (pollId === undefined) throw new Error('Poll insert returned no row.')
349
+ await tx.execute(sql`
350
+ insert into poll_options (poll_id, label, display_order)
351
+ values ${sql.join(
352
+ poll.options.map((label, index) => sql`(${pollId}, ${label}, ${index})`),
353
+ sql`, `,
354
+ )}
355
+ `)
356
+ }