@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,304 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+ import { msg } from '@meith/i18n'
5
+
6
+ import type { Database } from './client'
7
+ import { rewriteDenormalisedUsernames } from './denormalised-username'
8
+ import { type Tx, withPermissionVersionBump } from './permission-version'
9
+ import { recountReputation } from './reputation-repo'
10
+ import { resultRows } from './result-rows'
11
+ import { BANNED_PREDICATE } from './user-admin-repo'
12
+ import { type DedupeColumn, MERGE_DEDUPE, MERGE_DISCARD, MERGE_REASSIGN } from './user-merge-map'
13
+ import { recalculateWarningPoints } from './warning-repo'
14
+
15
+ export interface MergePreview {
16
+ readonly fromUsername: string
17
+ readonly toUsername: string
18
+ readonly posts: number
19
+ readonly threads: number
20
+ readonly privateMessages: number
21
+ readonly attachments: number
22
+ readonly blockedByBan: boolean
23
+ }
24
+
25
+ export interface MergeChunkResult {
26
+ readonly moved: number
27
+ readonly remaining: number
28
+ }
29
+
30
+ export class PostgresUserMergeRepository {
31
+ constructor(private readonly db: Database) {}
32
+
33
+ async preview(fromUserId: number, toUserId: number): Promise<MergePreview | null> {
34
+ const rows = resultRows(
35
+ await this.db.execute(sql`
36
+ select
37
+ (select username from users where id = ${fromUserId}) as from_username,
38
+ (select username from users where id = ${toUserId}) as to_username,
39
+ (select count(*) from posts where author_user_id = ${fromUserId})::int as posts,
40
+ (select count(*) from threads where author_user_id = ${fromUserId})::int as threads,
41
+ (select count(*) from private_messages where author_user_id = ${fromUserId})::int
42
+ as private_messages,
43
+ (select count(*) from attachments where uploader_user_id = ${fromUserId})::int
44
+ as attachments,
45
+ (select count(*) from users u
46
+ where u.id in (${fromUserId}, ${toUserId})
47
+ and ${BANNED_PREDICATE})::int as banned
48
+ `),
49
+ ) as Array<Record<string, unknown>>
50
+
51
+ const row = rows[0]
52
+ if (row === undefined || row.from_username === null || row.to_username === null) {
53
+ return null
54
+ }
55
+
56
+ return {
57
+ fromUsername: String(row.from_username),
58
+ toUsername: String(row.to_username),
59
+ posts: Number(row.posts),
60
+ threads: Number(row.threads),
61
+ privateMessages: Number(row.private_messages),
62
+ attachments: Number(row.attachments),
63
+ blockedByBan: Number(row.banned) > 0,
64
+ }
65
+ }
66
+
67
+ async mergePostsChunk(
68
+ fromUserId: number,
69
+ toUserId: number,
70
+ limit: number,
71
+ ): Promise<MergeChunkResult> {
72
+ assertDistinct(fromUserId, toUserId)
73
+
74
+ const moved = resultRows(
75
+ await this.db.execute(sql`
76
+ update posts
77
+ set author_user_id = ${toUserId}
78
+ where id in (
79
+ select id from posts where author_user_id = ${fromUserId} order by id limit ${limit}
80
+ )
81
+ returning id
82
+ `),
83
+ ) as Array<{ id: number }>
84
+
85
+ const remaining = resultRows(
86
+ await this.db.execute(
87
+ sql`select count(*)::int as n from posts where author_user_id = ${fromUserId}`,
88
+ ),
89
+ ) as Array<{ n: number }>
90
+
91
+ return { moved: moved.length, remaining: Number(remaining[0]?.n ?? 0) }
92
+ }
93
+
94
+ async finish(fromUserId: number, toUserId: number): Promise<void> {
95
+ assertDistinct(fromUserId, toUserId)
96
+
97
+ await withPermissionVersionBump(this.db, async (tx) => {
98
+ const state = resultRows(
99
+ await tx.execute(sql`
100
+ select
101
+ (select count(*) from users where id in (${fromUserId}, ${toUserId}))::int as found,
102
+ (select count(*) from users u
103
+ where u.id in (${fromUserId}, ${toUserId})
104
+ and ${BANNED_PREDICATE})::int as banned,
105
+ (select count(*) from posts where author_user_id = ${fromUserId})::int as posts
106
+ `),
107
+ ) as Array<Record<string, unknown>>
108
+
109
+ const row = state[0] as Record<string, unknown>
110
+ if (Number(row.found) !== 2) throw new ValidationError(msg('error.db.such-member'))
111
+ if (Number(row.banned) > 0) {
112
+ throw new ValidationError(msg('error.db.one-these-accounts-banned-lift'))
113
+ }
114
+ if (Number(row.posts) > 0) {
115
+ throw new ValidationError(msg('error.db.there-still-posts-move-finish'))
116
+ }
117
+
118
+ for (const entry of MERGE_DISCARD) {
119
+ await tx.execute(sql`
120
+ delete from ${sql.raw(entry.table)}
121
+ where ${sql.raw(entry.column)} = ${fromUserId}
122
+ `)
123
+ }
124
+
125
+ for (const entry of MERGE_DEDUPE) {
126
+ await dedupeThenMove(tx, entry, fromUserId, toUserId)
127
+ }
128
+
129
+ const rated = await mergeBespoke(tx, fromUserId, toUserId)
130
+
131
+ for (const entry of MERGE_REASSIGN) {
132
+ await tx.execute(sql`
133
+ update ${sql.raw(entry.table)}
134
+ set ${sql.raw(entry.column)} = ${toUserId}
135
+ where ${sql.raw(entry.column)} = ${fromUserId}
136
+ `)
137
+ }
138
+
139
+ const winner = resultRows(
140
+ await tx.execute(sql`select username from users where id = ${toUserId}`),
141
+ ) as Array<{ username: string }>
142
+ const username = winner[0]?.username
143
+ if (username === undefined) throw new ValidationError(msg('error.db.such-member'))
144
+
145
+ await rewriteDenormalisedUsernames(tx, toUserId, username)
146
+
147
+ await tx.execute(sql`
148
+ update users w
149
+ set post_count = w.post_count + l.post_count,
150
+ thread_count = w.thread_count + l.thread_count,
151
+ updated_at = now()
152
+ from users l
153
+ where w.id = ${toUserId} and l.id = ${fromUserId}
154
+ `)
155
+
156
+ const rerate = new Set(rated)
157
+ rerate.add(toUserId)
158
+ rerate.delete(fromUserId)
159
+ await recountReputation(tx, [...rerate])
160
+
161
+ await recalculateWarningPoints(tx, toUserId)
162
+ await recalculateWarningPoints(tx, fromUserId)
163
+
164
+ await tx.execute(sql`
165
+ update remember_tokens
166
+ set revoked_at = now(), revoked_reason = 'account_merge'
167
+ where user_id in (${fromUserId}, ${toUserId}) and revoked_at is null
168
+ `)
169
+
170
+ await tx.execute(sql`
171
+ update sessions
172
+ set revoked_at = now()
173
+ where user_id in (${fromUserId}, ${toUserId}) and revoked_at is null
174
+ `)
175
+
176
+ await tx.execute(sql`
177
+ update users
178
+ set deleted_at = now(), post_count = 0, thread_count = 0, reputation = 0,
179
+ updated_at = now()
180
+ where id = ${fromUserId}
181
+ `)
182
+ })
183
+ }
184
+ }
185
+
186
+ function assertDistinct(fromUserId: number, toUserId: number): void {
187
+ if (fromUserId === toUserId) {
188
+ throw new ValidationError(msg('error.db.choose-two-different-accounts'))
189
+ }
190
+ }
191
+
192
+ async function dedupeThenMove(
193
+ tx: Tx,
194
+ entry: DedupeColumn,
195
+ fromUserId: number,
196
+ toUserId: number,
197
+ ): Promise<void> {
198
+ const predicate = entry.where === undefined ? sql`` : sql` and ${sql.raw(entry.where)}`
199
+ const matches = entry.keys.map(
200
+ (key) => sql`loser.${sql.raw(key)} is not distinct from winner.${sql.raw(key)}`,
201
+ )
202
+
203
+ await tx.execute(sql`
204
+ delete from ${sql.raw(entry.table)} loser
205
+ where loser.${sql.raw(entry.column)} = ${fromUserId}${predicate}
206
+ and exists (
207
+ select 1 from ${sql.raw(entry.table)} winner
208
+ where winner.${sql.raw(entry.column)} = ${toUserId}
209
+ and ${sql.join(matches, sql` and `)}
210
+ )
211
+ `)
212
+
213
+ await tx.execute(sql`
214
+ update ${sql.raw(entry.table)}
215
+ set ${sql.raw(entry.column)} = ${toUserId}
216
+ where ${sql.raw(entry.column)} = ${fromUserId}
217
+ `)
218
+ }
219
+
220
+ async function mergeBespoke(
221
+ tx: Tx,
222
+ fromUserId: number,
223
+ toUserId: number,
224
+ ): Promise<readonly number[]> {
225
+ const rated = new Set<number>()
226
+
227
+ const deleteRatings = async (query: ReturnType<typeof sql>): Promise<void> => {
228
+ const rows = resultRows(await tx.execute(query)) as Array<{ user_id: number }>
229
+ for (const row of rows) rated.add(Number(row.user_id))
230
+ }
231
+
232
+ await deleteRatings(sql`
233
+ delete from reputation
234
+ where (given_by_user_id = ${fromUserId} and user_id = ${toUserId})
235
+ or (given_by_user_id = ${toUserId} and user_id = ${fromUserId})
236
+ or (given_by_user_id = ${fromUserId} and user_id = ${fromUserId})
237
+ returning user_id
238
+ `)
239
+
240
+ await deleteRatings(sql`
241
+ delete from reputation loser
242
+ where loser.given_by_user_id = ${fromUserId}
243
+ and exists (
244
+ select 1 from reputation winner
245
+ where winner.given_by_user_id = ${toUserId}
246
+ and winner.post_id is not distinct from loser.post_id
247
+ and winner.user_id is not distinct from loser.user_id
248
+ )
249
+ returning loser.user_id
250
+ `)
251
+ await tx.execute(sql`
252
+ update reputation set given_by_user_id = ${toUserId}
253
+ where given_by_user_id = ${fromUserId}
254
+ `)
255
+ await deleteRatings(sql`
256
+ delete from reputation loser
257
+ where loser.user_id = ${fromUserId}
258
+ and exists (
259
+ select 1 from reputation winner
260
+ where winner.user_id = ${toUserId}
261
+ and winner.given_by_user_id = loser.given_by_user_id
262
+ and winner.post_id is not distinct from loser.post_id
263
+ )
264
+ returning loser.user_id
265
+ `)
266
+ await tx.execute(sql`
267
+ update reputation set user_id = ${toUserId} where user_id = ${fromUserId}
268
+ `)
269
+
270
+ await tx.execute(sql`
271
+ delete from user_relations
272
+ where (user_id = ${fromUserId} and other_user_id = ${toUserId})
273
+ or (user_id = ${toUserId} and other_user_id = ${fromUserId})
274
+ or (user_id = ${fromUserId} and other_user_id = ${fromUserId})
275
+ `)
276
+
277
+ await tx.execute(sql`
278
+ delete from user_relations loser
279
+ where loser.user_id = ${fromUserId}
280
+ and exists (
281
+ select 1 from user_relations winner
282
+ where winner.user_id = ${toUserId}
283
+ and winner.other_user_id = loser.other_user_id
284
+ )
285
+ `)
286
+ await tx.execute(sql`
287
+ update user_relations set user_id = ${toUserId} where user_id = ${fromUserId}
288
+ `)
289
+
290
+ await tx.execute(sql`
291
+ delete from user_relations loser
292
+ where loser.other_user_id = ${fromUserId}
293
+ and exists (
294
+ select 1 from user_relations winner
295
+ where winner.other_user_id = ${toUserId}
296
+ and winner.user_id = loser.user_id
297
+ )
298
+ `)
299
+ await tx.execute(sql`
300
+ update user_relations set other_user_id = ${toUserId} where other_user_id = ${fromUserId}
301
+ `)
302
+
303
+ return [...rated]
304
+ }
@@ -0,0 +1,190 @@
1
+ import type { SQLWrapper } from 'drizzle-orm'
2
+ import { sql } from 'drizzle-orm'
3
+
4
+ import type { Database } from './client'
5
+ import { resultRows } from './result-rows'
6
+
7
+ interface CounterTransaction {
8
+ execute(query: SQLWrapper): Promise<unknown>
9
+ }
10
+
11
+ export interface VisibilityChange {
12
+ readonly postId: number
13
+ readonly threadId: number
14
+ readonly forumId: number
15
+ readonly authorId: number | null
16
+ readonly isFirstPost: boolean
17
+ readonly delta: 1 | -1
18
+ }
19
+
20
+ export async function repairThreadLastPost(
21
+ tx: CounterTransaction,
22
+ threadId: number,
23
+ ): Promise<void> {
24
+ await tx.execute(sql`
25
+ with newest as (
26
+ select id, author_user_id, author_username, created_at
27
+ from posts
28
+ where thread_id = ${threadId} and visibility = 'visible'
29
+ order by id desc
30
+ limit 1
31
+ )
32
+ update threads t
33
+ set last_post_id = (select id from newest),
34
+ last_post_user_id = (select author_user_id from newest),
35
+ last_post_username = (select author_username from newest),
36
+ last_post_at = coalesce((select created_at from newest), t.created_at),
37
+ updated_at = now()
38
+ where t.id = ${threadId}
39
+ `)
40
+ }
41
+
42
+ export async function repairForumLastPostChain(
43
+ tx: CounterTransaction,
44
+ forumId: number,
45
+ ): Promise<void> {
46
+ const chain = resultRows(
47
+ await tx.execute(sql`
48
+ select f.id
49
+ from forums f
50
+ join forums child on child.id = ${forumId}
51
+ where f.id = child.id or child.path like f.path || '.%'
52
+ order by f.depth desc
53
+ `),
54
+ ) as Array<{ id: number }>
55
+
56
+ for (const forum of chain) {
57
+ await tx.execute(sql`
58
+ with own as (
59
+ select t.last_post_id as post_id, t.last_post_at as at, t.id as thread_id,
60
+ t.title as thread_title, t.last_post_user_id as user_id,
61
+ t.last_post_username as username
62
+ from threads t
63
+ where t.forum_id = ${forum.id}
64
+ and t.visibility = 'visible'
65
+ and t.last_post_id is not null
66
+ order by t.last_post_at desc, t.last_post_id desc
67
+ limit 1
68
+ ),
69
+ kid as (
70
+ select c.last_post_id as post_id, c.last_post_at as at,
71
+ c.last_post_thread_id as thread_id, c.last_post_thread_title as thread_title,
72
+ c.last_post_user_id as user_id, c.last_post_username as username
73
+ from forums c
74
+ where c.parent_id = ${forum.id} and c.last_post_id is not null
75
+ order by c.last_post_at desc, c.last_post_id desc
76
+ limit 1
77
+ ),
78
+ best as (
79
+ select * from (select * from own union all select * from kid) candidates
80
+ order by at desc, post_id desc
81
+ limit 1
82
+ )
83
+ update forums f
84
+ set last_post_id = (select post_id from best),
85
+ last_post_thread_id = (select thread_id from best),
86
+ last_post_thread_title = (select thread_title from best),
87
+ last_post_user_id = (select user_id from best),
88
+ last_post_username = (select username from best),
89
+ last_post_at = (select at from best),
90
+ updated_at = now()
91
+ where f.id = ${forum.id}
92
+ `)
93
+ }
94
+ }
95
+
96
+ export async function applyVisibilityChangeCounters(
97
+ tx: CounterTransaction,
98
+ change: VisibilityChange,
99
+ ): Promise<void> {
100
+ const threadDelta = change.isFirstPost ? change.delta : 0
101
+ const replyDelta = change.isFirstPost ? 0 : change.delta
102
+
103
+ await tx.execute(sql`
104
+ update forums
105
+ set post_count = greatest(post_count + ${change.delta}, 0),
106
+ thread_count = greatest(thread_count + ${threadDelta}, 0),
107
+ updated_at = now()
108
+ where id = ${change.forumId}
109
+ `)
110
+
111
+ await tx.execute(sql`
112
+ update threads
113
+ set reply_count = greatest(reply_count + ${replyDelta}, 0),
114
+ updated_at = now()
115
+ where id = ${change.threadId}
116
+ `)
117
+
118
+ if (change.authorId !== null) {
119
+ await tx.execute(sql`
120
+ update users
121
+ set post_count = greatest(post_count + ${change.delta}, 0),
122
+ thread_count = greatest(thread_count + ${threadDelta}, 0),
123
+ updated_at = now()
124
+ where id = ${change.authorId}
125
+ `)
126
+ }
127
+
128
+ await repairThreadLastPost(tx, change.threadId)
129
+ await repairForumLastPostChain(tx, change.forumId)
130
+
131
+ await tx.execute(sql`
132
+ insert into outbox (topic, payload)
133
+ values (
134
+ 'post.visibility_changed',
135
+ ${JSON.stringify({
136
+ postId: change.postId,
137
+ threadId: change.threadId,
138
+ forumId: change.forumId,
139
+ visible: change.delta === 1,
140
+ })}::jsonb
141
+ )
142
+ `)
143
+ }
144
+
145
+ export async function applyAncestorVisibilityChange(
146
+ db: Database,
147
+ postId: number,
148
+ ): Promise<boolean> {
149
+ return db.transaction(async (tx) => {
150
+ const found = resultRows(
151
+ await tx.execute(sql`
152
+ select visibility, forum_id, is_first_post from posts where id = ${postId}
153
+ `),
154
+ ) as Array<{ visibility: string; forum_id: number; is_first_post: boolean }>
155
+
156
+ const post = found[0]
157
+ if (!post) return false
158
+
159
+ const shouldCount = post.visibility === 'visible'
160
+ if (shouldCount) {
161
+ const claimed = await tx.execute(sql`
162
+ insert into content_counter_rollups (post_id) values (${postId})
163
+ on conflict (post_id) do nothing
164
+ returning post_id
165
+ `)
166
+ if (resultRows(claimed).length === 0) return false
167
+ } else {
168
+ const released = await tx.execute(sql`
169
+ delete from content_counter_rollups where post_id = ${postId} returning post_id
170
+ `)
171
+ if (resultRows(released).length === 0) return false
172
+ }
173
+
174
+ const delta = shouldCount ? 1 : -1
175
+ const threadDelta = post.is_first_post ? delta : 0
176
+
177
+ await tx.execute(sql`
178
+ update forums f
179
+ set post_count = greatest(f.post_count + ${delta}, 0),
180
+ thread_count = greatest(f.thread_count + ${threadDelta}, 0),
181
+ updated_at = now()
182
+ from forums child
183
+ where child.id = ${post.forum_id}
184
+ and f.id <> child.id
185
+ and child.path like f.path || '.%'
186
+ `)
187
+
188
+ return true
189
+ })
190
+ }
@@ -0,0 +1,17 @@
1
+ import { type SQL, sql } from 'drizzle-orm'
2
+ import type { PgColumn } from 'drizzle-orm/pg-core'
3
+
4
+ import type { ContentScope } from '@meith/core'
5
+
6
+ export function visibleIn(column: PgColumn | SQL, scope: ContentScope): SQL {
7
+ const first = scope.states[0]
8
+ if (scope.states.length === 1) return sql`${column} = ${first}`
9
+ return sql`${column} in (${sql.join(
10
+ scope.states.map((state) => sql`${state}`),
11
+ sql`, `,
12
+ )})`
13
+ }
14
+
15
+ export const PENDING_APPROVAL = 'unapproved' as const
16
+
17
+ export const VISIBLE = 'visible' as const
@@ -0,0 +1,54 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import {
4
+ type BoardVocabulary,
5
+ CORE_RENDERING,
6
+ compileVocabulary,
7
+ type MarkdownPipeline,
8
+ NO_VOCABULARY_SOURCE,
9
+ type VocabularySource,
10
+ } from '@meith/markdown'
11
+
12
+ import type { Database } from './client'
13
+ import { resultRows } from './result-rows'
14
+
15
+ export async function readVocabularySource(db: Database): Promise<VocabularySource | null> {
16
+ return db.transaction(async (tx) => {
17
+ const revision = resultRows(
18
+ await tx.execute(sql`select version from cache_versions where key = 'markdown_vocabulary'`),
19
+ ) as Array<{ version: number }>
20
+
21
+ if (Number(revision[0]?.version ?? 0) === 0) return null
22
+
23
+ const smilies = resultRows(
24
+ await tx.execute(sql`select code, src, alt from smilies where enabled = true order by id`),
25
+ ) as Array<Record<string, unknown>>
26
+
27
+ const directives = resultRows(
28
+ await tx.execute(
29
+ sql`select name, block from custom_directives where enabled = true order by name`,
30
+ ),
31
+ ) as Array<Record<string, unknown>>
32
+
33
+ return {
34
+ revision: Number(revision[0]?.version ?? 0),
35
+ smilies: smilies.map((row) => ({
36
+ code: String(row.code),
37
+ src: String(row.src),
38
+ ...(row.alt === null ? {} : { alt: String(row.alt) }),
39
+ })),
40
+ directives: directives.map((row) => ({
41
+ name: String(row.name),
42
+ block: row.block === true,
43
+ })),
44
+ }
45
+ })
46
+ }
47
+
48
+ export async function readBoardVocabulary(
49
+ db: Database,
50
+ pipeline: MarkdownPipeline = CORE_RENDERING,
51
+ ): Promise<BoardVocabulary> {
52
+ const source = await pipeline.vocabulary((await readVocabularySource(db)) ?? NO_VOCABULARY_SOURCE)
53
+ return compileVocabulary(source)
54
+ }