@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,255 @@
1
+ import { and, desc, eq, lt, or, sql } from 'drizzle-orm'
2
+
3
+ import type { ContentScope, ThreadAuthorFilter } from '@meith/core'
4
+ import type {
5
+ ThreadCursor,
6
+ ThreadListingRow,
7
+ ThreadLocation,
8
+ ThreadPage,
9
+ ThreadRepository,
10
+ ThreadSort,
11
+ } from '@meith/threads'
12
+
13
+ import type { Database } from './client'
14
+ import { threadPrefixes, threads } from './schema'
15
+ import { authoredBy } from './thread-audience'
16
+ import { visibleIn } from './visibility'
17
+
18
+ function afterActivity(cursor: ThreadCursor) {
19
+ const olderInSameBucket = or(
20
+ lt(threads.lastPostAt, cursor.lastPostAt),
21
+ and(eq(threads.lastPostAt, cursor.lastPostAt), lt(threads.id, cursor.id)),
22
+ )
23
+
24
+ return cursor.isSticky
25
+ ? or(eq(threads.isSticky, false), and(eq(threads.isSticky, true), olderInSameBucket))
26
+ : and(eq(threads.isSticky, false), olderInSameBucket)
27
+ }
28
+
29
+ function afterRating(cursor: ThreadCursor) {
30
+ const sameRating =
31
+ cursor.ratingCount === 0
32
+ ? eq(threads.ratingCount, 0)
33
+ : sql<boolean>`
34
+ (${threads.ratingCount} > 0 and
35
+ ${threads.ratingTotal} * ${cursor.ratingCount} = ${cursor.ratingTotal} * ${threads.ratingCount})
36
+ `
37
+ const lowerRating =
38
+ cursor.ratingCount === 0
39
+ ? sql<boolean>`false`
40
+ : sql<boolean>`
41
+ ${threads.ratingCount} = 0 or
42
+ (${threads.ratingCount} > 0 and
43
+ ${threads.ratingTotal} * ${cursor.ratingCount} < ${cursor.ratingTotal} * ${threads.ratingCount})
44
+ `
45
+ const olderInSameBucket = or(
46
+ lowerRating,
47
+ and(
48
+ sameRating,
49
+ or(
50
+ lt(threads.ratingCount, cursor.ratingCount),
51
+ and(eq(threads.ratingCount, cursor.ratingCount), lt(threads.lastPostAt, cursor.lastPostAt)),
52
+ and(
53
+ eq(threads.ratingCount, cursor.ratingCount),
54
+ eq(threads.lastPostAt, cursor.lastPostAt),
55
+ lt(threads.id, cursor.id),
56
+ ),
57
+ ),
58
+ ),
59
+ )
60
+
61
+ return cursor.isSticky
62
+ ? or(eq(threads.isSticky, false), and(eq(threads.isSticky, true), olderInSameBucket))
63
+ : and(eq(threads.isSticky, false), olderInSameBucket)
64
+ }
65
+
66
+ function rowToListing(row: {
67
+ id: number
68
+ forumId: number
69
+ title: string
70
+ slug: string
71
+ prefixLabel: string | null
72
+ prefixToken: string | null
73
+ authorUserId: number | null
74
+ authorUsername: string
75
+ replyCount: number
76
+ viewCount: number
77
+ ratingTotal: number
78
+ ratingCount: number
79
+ visibility: string
80
+ isSticky: boolean
81
+ isLocked: boolean
82
+ movedToThreadId: number | null
83
+ lastPostId: number | null
84
+ lastPostUserId: number | null
85
+ lastPostUsername: string | null
86
+ lastPostAt: Date
87
+ }): ThreadListingRow {
88
+ return {
89
+ id: row.id,
90
+ forumId: row.forumId,
91
+ title: row.title,
92
+ slug: row.slug,
93
+ prefix: row.prefixLabel === null ? null : { label: row.prefixLabel, token: row.prefixToken },
94
+ authorUserId: row.authorUserId,
95
+ authorUsername: row.authorUsername,
96
+ replyCount: row.replyCount,
97
+ viewCount: row.viewCount,
98
+ ratingTotal: row.ratingTotal,
99
+ ratingCount: row.ratingCount,
100
+ visibility: row.visibility as ThreadListingRow['visibility'],
101
+ isSticky: row.isSticky,
102
+ isLocked: row.isLocked,
103
+ isMoved: row.movedToThreadId !== null,
104
+ lastPost:
105
+ row.lastPostId === null || row.lastPostUsername === null
106
+ ? null
107
+ : {
108
+ postId: row.lastPostId,
109
+ userId: row.lastPostUserId,
110
+ username: row.lastPostUsername,
111
+ at: row.lastPostAt,
112
+ },
113
+ lastPostAt: row.lastPostAt,
114
+ }
115
+ }
116
+
117
+ export class PostgresThreadRepository implements ThreadRepository {
118
+ constructor(private readonly db: Database) {}
119
+
120
+ async locate(threadId: number): Promise<ThreadLocation | null> {
121
+ const rows = await this.db
122
+ .select({
123
+ forumId: threads.forumId,
124
+ authorUserId: threads.authorUserId,
125
+ })
126
+ .from(threads)
127
+ .where(eq(threads.id, threadId))
128
+ .limit(1)
129
+ const row = rows[0]
130
+ return row ? { forumId: row.forumId, authorUserId: row.authorUserId } : null
131
+ }
132
+
133
+ async findById(
134
+ id: number,
135
+ scope: ContentScope,
136
+ authors: ThreadAuthorFilter,
137
+ ): Promise<ThreadListingRow | null> {
138
+ const rows = await this.db
139
+ .select({
140
+ id: threads.id,
141
+ forumId: threads.forumId,
142
+ title: threads.title,
143
+ slug: threads.slug,
144
+ prefixLabel: threadPrefixes.label,
145
+ prefixToken: threadPrefixes.token,
146
+ authorUserId: threads.authorUserId,
147
+ authorUsername: threads.authorUsername,
148
+ replyCount: threads.replyCount,
149
+ viewCount: threads.viewCount,
150
+ ratingTotal: threads.ratingTotal,
151
+ ratingCount: threads.ratingCount,
152
+ visibility: threads.visibility,
153
+ isSticky: threads.isSticky,
154
+ isLocked: threads.isLocked,
155
+ movedToThreadId: threads.movedToThreadId,
156
+ lastPostId: threads.lastPostId,
157
+ lastPostUserId: threads.lastPostUserId,
158
+ lastPostUsername: threads.lastPostUsername,
159
+ lastPostAt: threads.lastPostAt,
160
+ })
161
+ .from(threads)
162
+ .leftJoin(threadPrefixes, eq(threads.prefixId, threadPrefixes.id))
163
+ .where(
164
+ and(
165
+ eq(threads.id, id),
166
+ visibleIn(threads.visibility, scope),
167
+ authoredBy(threads.authorUserId, authors),
168
+ ),
169
+ )
170
+ .limit(1)
171
+ const row = rows[0]
172
+ return row ? rowToListing(row) : null
173
+ }
174
+
175
+ async listForum(
176
+ forumId: number,
177
+ options: {
178
+ readonly after?: ThreadCursor
179
+ readonly offset?: number
180
+ readonly limit: number
181
+ readonly scope: ContentScope
182
+ readonly authors: ThreadAuthorFilter
183
+ readonly sort?: ThreadSort
184
+ },
185
+ ): Promise<ThreadPage> {
186
+ const sort = options.sort ?? 'activity'
187
+ const rows = await this.db
188
+ .select({
189
+ id: threads.id,
190
+ forumId: threads.forumId,
191
+ title: threads.title,
192
+ slug: threads.slug,
193
+ prefixLabel: threadPrefixes.label,
194
+ prefixToken: threadPrefixes.token,
195
+ authorUserId: threads.authorUserId,
196
+ authorUsername: threads.authorUsername,
197
+ replyCount: threads.replyCount,
198
+ viewCount: threads.viewCount,
199
+ ratingTotal: threads.ratingTotal,
200
+ ratingCount: threads.ratingCount,
201
+ visibility: threads.visibility,
202
+ isSticky: threads.isSticky,
203
+ isLocked: threads.isLocked,
204
+ movedToThreadId: threads.movedToThreadId,
205
+ lastPostId: threads.lastPostId,
206
+ lastPostUserId: threads.lastPostUserId,
207
+ lastPostUsername: threads.lastPostUsername,
208
+ lastPostAt: threads.lastPostAt,
209
+ })
210
+ .from(threads)
211
+ .leftJoin(threadPrefixes, eq(threads.prefixId, threadPrefixes.id))
212
+ .where(
213
+ and(
214
+ eq(threads.forumId, forumId),
215
+ visibleIn(threads.visibility, options.scope),
216
+ authoredBy(threads.authorUserId, options.authors),
217
+ ...(options.after
218
+ ? [sort === 'rating' ? afterRating(options.after) : afterActivity(options.after)]
219
+ : []),
220
+ ),
221
+ )
222
+ .orderBy(
223
+ desc(threads.isSticky),
224
+ ...(sort === 'rating'
225
+ ? [
226
+ desc(
227
+ sql`coalesce(${threads.ratingTotal}::numeric / nullif(${threads.ratingCount}, 0), -1)`,
228
+ ),
229
+ desc(threads.ratingCount),
230
+ ]
231
+ : []),
232
+ desc(threads.lastPostAt),
233
+ desc(threads.id),
234
+ )
235
+ .limit(options.limit + 1)
236
+ .offset(options.offset ?? 0)
237
+
238
+ const page = rows.slice(0, options.limit).map(rowToListing)
239
+ const last = page.at(-1)
240
+ return {
241
+ rows: page,
242
+ nextCursor:
243
+ rows.length > options.limit && last
244
+ ? {
245
+ sort,
246
+ isSticky: last.isSticky,
247
+ lastPostAt: last.lastPostAt,
248
+ ratingTotal: last.ratingTotal,
249
+ ratingCount: last.ratingCount,
250
+ id: last.id,
251
+ }
252
+ : null,
253
+ }
254
+ }
255
+ }
@@ -0,0 +1,282 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ MergePlan,
5
+ SplitPlan,
6
+ SurgeryOutcome,
7
+ SurgeryThread,
8
+ ThreadSurgeryRepository,
9
+ } from '@meith/moderation'
10
+
11
+ import type { Database } from './client'
12
+ import { resultRows } from './result-rows'
13
+ import { idList } from './sql-lists'
14
+ import { repairForumLastPostChain, repairThreadLastPost } from './visibility-counters'
15
+
16
+ interface Tx {
17
+ execute(query: ReturnType<typeof sql>): Promise<unknown>
18
+ }
19
+
20
+ async function applyForumChain(
21
+ tx: Tx,
22
+ forumId: number,
23
+ posts: number,
24
+ threads: number,
25
+ ): Promise<void> {
26
+ if (posts === 0 && threads === 0) return
27
+ await tx.execute(sql`
28
+ update forums f
29
+ set post_count = greatest(f.post_count + ${posts}, 0),
30
+ thread_count = greatest(f.thread_count + ${threads}, 0),
31
+ updated_at = now()
32
+ from forums child
33
+ where child.id = ${forumId}
34
+ and (f.id = child.id or child.path like f.path || '.%')
35
+ `)
36
+ }
37
+
38
+ async function log(
39
+ tx: Tx,
40
+ action: string,
41
+ actorUserId: number,
42
+ detail: Record<string, unknown>,
43
+ at: Date,
44
+ ): Promise<void> {
45
+ await tx.execute(sql`
46
+ insert into admin_log (user_id, action, detail, created_at)
47
+ values (${actorUserId}, ${action}, ${JSON.stringify(detail)}::jsonb, ${at})
48
+ `)
49
+ }
50
+
51
+ export class PostgresThreadSurgeryRepository implements ThreadSurgeryRepository {
52
+ constructor(private readonly db: Database) {}
53
+
54
+ async find(threadId: number): Promise<SurgeryThread | null> {
55
+ const rows = resultRows(
56
+ await this.db.execute(sql`
57
+ select t.id, t.forum_id, t.slug, t.title, t.visibility, t.first_post_id,
58
+ (select count(*)::int from posts p
59
+ where p.thread_id = t.id and p.visibility = 'visible') as visible_posts
60
+ from threads t where t.id = ${threadId}
61
+ `),
62
+ ) as Array<{
63
+ id: number
64
+ forum_id: number
65
+ slug: string
66
+ title: string
67
+ visibility: SurgeryThread['visibility']
68
+ first_post_id: number | null
69
+ visible_posts: number
70
+ }>
71
+
72
+ const row = rows[0]
73
+ if (!row) return null
74
+ return {
75
+ id: Number(row.id),
76
+ forumId: Number(row.forum_id),
77
+ slug: row.slug,
78
+ title: row.title,
79
+ visibility: row.visibility,
80
+ firstPostId: row.first_post_id === null ? null : Number(row.first_post_id),
81
+ visiblePosts: Number(row.visible_posts),
82
+ }
83
+ }
84
+
85
+ async postsFrom(threadId: number, fromPostId: number): Promise<readonly number[]> {
86
+ const rows = resultRows(
87
+ await this.db.execute(sql`
88
+ select id from posts
89
+ where thread_id = ${threadId} and visibility = 'visible' and id >= ${fromPostId}
90
+ order by id
91
+ `),
92
+ ) as Array<{ id: number }>
93
+ return rows.some((row) => Number(row.id) === fromPostId)
94
+ ? rows.map((row) => Number(row.id))
95
+ : []
96
+ }
97
+
98
+ async visiblePostIdsIn(threadId: number, postIds: readonly number[]): Promise<readonly number[]> {
99
+ if (postIds.length === 0) return []
100
+ const list = sql.join(
101
+ postIds.map((id) => sql`${id}`),
102
+ sql`, `,
103
+ )
104
+ const rows = resultRows(
105
+ await this.db.execute(sql`
106
+ select id from posts
107
+ where thread_id = ${threadId} and visibility = 'visible' and id in (${list})
108
+ order by id
109
+ `),
110
+ ) as Array<{ id: number }>
111
+ return rows.map((row) => Number(row.id))
112
+ }
113
+
114
+ async split(plan: SplitPlan & { actorUserId: number; at: Date }): Promise<SurgeryOutcome> {
115
+ return this.db.transaction(async (tx) => {
116
+ const source = resultRows(
117
+ await tx.execute(sql`
118
+ select forum_id, slug from threads where id = ${plan.sourceThreadId}
119
+ `),
120
+ ) as Array<{ forum_id: number; slug: string }>
121
+ const forumId = Number(source[0]!.forum_id)
122
+
123
+ const opener = plan.postIds[0]!
124
+ const author = resultRows(
125
+ await tx.execute(sql`
126
+ select author_user_id, author_username, created_at
127
+ from posts where id = ${opener}
128
+ `),
129
+ ) as Array<{
130
+ author_user_id: number | null
131
+ author_username: string
132
+ created_at: Date
133
+ }>
134
+ const opening = author[0]!
135
+
136
+ const created = resultRows(
137
+ await tx.execute(sql`
138
+ insert into threads
139
+ (forum_id, title, slug, author_user_id, author_username, visibility,
140
+ first_post_id, last_post_at, created_at, updated_at)
141
+ values (${forumId}, ${plan.title}, ${slugFor(plan.title)},
142
+ ${opening.author_user_id}, ${opening.author_username}, 'visible',
143
+ ${opener}, ${opening.created_at}, ${opening.created_at}, ${plan.at})
144
+ returning id, slug
145
+ `),
146
+ ) as Array<{ id: number; slug: string }>
147
+ const newThreadId = Number(created[0]!.id)
148
+
149
+ await tx.execute(sql`
150
+ update posts set thread_id = ${newThreadId}, is_first_post = (id = ${opener})
151
+ where id in ${idList(plan.postIds)}
152
+ `)
153
+
154
+ const moved = plan.postIds.length
155
+ await applyForumChain(tx, forumId, 0, 1)
156
+ await tx.execute(sql`
157
+ update threads
158
+ set reply_count = greatest(reply_count - ${moved}, 0), updated_at = ${plan.at}
159
+ where id = ${plan.sourceThreadId}
160
+ `)
161
+ await tx.execute(sql`
162
+ update threads set reply_count = ${moved - 1}, updated_at = ${plan.at}
163
+ where id = ${newThreadId}
164
+ `)
165
+
166
+ if (opening.author_user_id !== null) {
167
+ await tx.execute(sql`
168
+ update users set thread_count = thread_count + 1, updated_at = ${plan.at}
169
+ where id = ${opening.author_user_id}
170
+ `)
171
+ }
172
+
173
+ await repairThreadLastPost(tx, plan.sourceThreadId)
174
+ await repairThreadLastPost(tx, newThreadId)
175
+ await repairForumLastPostChain(tx, forumId)
176
+
177
+ await log(
178
+ tx,
179
+ 'thread.split',
180
+ plan.actorUserId,
181
+ {
182
+ threadId: plan.sourceThreadId,
183
+ newThreadId,
184
+ forumId,
185
+ forumIds: [forumId],
186
+ posts: moved,
187
+ },
188
+ plan.at,
189
+ )
190
+
191
+ return { threadId: newThreadId, slug: created[0]!.slug, posts: moved }
192
+ })
193
+ }
194
+
195
+ async merge(plan: MergePlan & { actorUserId: number; at: Date }): Promise<SurgeryOutcome> {
196
+ return this.db.transaction(async (tx) => {
197
+ const rows = resultRows(
198
+ await tx.execute(sql`
199
+ select id, forum_id, slug, author_user_id,
200
+ (select count(*)::int from posts p
201
+ where p.thread_id = t.id and p.visibility = 'visible') as visible_posts
202
+ from threads t where t.id in (${plan.sourceThreadId}, ${plan.targetThreadId})
203
+ `),
204
+ ) as Array<{
205
+ id: number
206
+ forum_id: number
207
+ slug: string
208
+ author_user_id: number | null
209
+ visible_posts: number
210
+ }>
211
+
212
+ const source = rows.find((r) => Number(r.id) === plan.sourceThreadId)!
213
+ const target = rows.find((r) => Number(r.id) === plan.targetThreadId)!
214
+ const sourceForum = Number(source.forum_id)
215
+ const targetForum = Number(target.forum_id)
216
+ const moved = Number(source.visible_posts)
217
+
218
+ await tx.execute(sql`
219
+ update posts
220
+ set thread_id = ${plan.targetThreadId},
221
+ forum_id = ${targetForum},
222
+ is_first_post = false
223
+ where thread_id = ${plan.sourceThreadId}
224
+ `)
225
+
226
+ await tx.execute(sql`
227
+ update threads
228
+ set reply_count = reply_count + ${moved}, updated_at = ${plan.at}
229
+ where id = ${plan.targetThreadId}
230
+ `)
231
+
232
+ await tx.execute(sql`delete from threads where id = ${plan.sourceThreadId}`)
233
+
234
+ if (sourceForum === targetForum) {
235
+ await applyForumChain(tx, sourceForum, 0, -1)
236
+ } else {
237
+ await applyForumChain(tx, sourceForum, -moved, -1)
238
+ await applyForumChain(tx, targetForum, moved, 0)
239
+ }
240
+
241
+ if (source.author_user_id !== null) {
242
+ await tx.execute(sql`
243
+ update users
244
+ set thread_count = greatest(thread_count - 1, 0), updated_at = ${plan.at}
245
+ where id = ${source.author_user_id}
246
+ `)
247
+ }
248
+
249
+ await repairThreadLastPost(tx, plan.targetThreadId)
250
+ await repairForumLastPostChain(tx, sourceForum)
251
+ if (sourceForum !== targetForum) {
252
+ await repairForumLastPostChain(tx, targetForum)
253
+ }
254
+
255
+ await log(
256
+ tx,
257
+ 'thread.merge',
258
+ plan.actorUserId,
259
+ {
260
+ threadId: plan.sourceThreadId,
261
+ targetThreadId: plan.targetThreadId,
262
+ posts: moved,
263
+ fromForumId: sourceForum,
264
+ toForumId: targetForum,
265
+ forumIds: [...new Set([sourceForum, targetForum])],
266
+ },
267
+ plan.at,
268
+ )
269
+
270
+ return { threadId: plan.targetThreadId, slug: target.slug, posts: moved }
271
+ })
272
+ }
273
+ }
274
+
275
+ function slugFor(title: string): string {
276
+ const slug = title
277
+ .toLowerCase()
278
+ .replace(/[^a-z0-9]+/g, '-')
279
+ .replace(/^-+|-+$/g, '')
280
+ .slice(0, 80)
281
+ return slug.length === 0 ? 'thread' : slug
282
+ }