@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,1377 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import { BodyFormat } from '@meith/markdown'
4
+
5
+ import type { Database } from './client'
6
+ import { type LegacyKind, resolveLegacyIds } from './import-repo'
7
+ import { resultRows } from './result-rows'
8
+
9
+ interface ImportedUserRow {
10
+ readonly legacyId: number
11
+ readonly username: string
12
+ readonly email: string
13
+ readonly legacyPasswordHash: string
14
+ readonly registeredAt: Date
15
+ readonly lastVisitAt: Date | null
16
+ readonly postCount: number
17
+ readonly legacyGroupId: number
18
+ }
19
+
20
+ interface ImportedForumRow {
21
+ readonly legacyId: number
22
+ readonly type: 'category' | 'forum' | 'link'
23
+ readonly title: string
24
+ readonly description: string | null
25
+ readonly legacyParentId: number | null
26
+ readonly displayOrder: number
27
+ readonly linkUrl: string | null
28
+ }
29
+
30
+ type Visibility = 'visible' | 'unapproved' | 'deleted'
31
+
32
+ interface ImportedThreadRow {
33
+ readonly legacyId: number
34
+ readonly legacyForumId: number
35
+ readonly title: string
36
+ readonly legacyAuthorId: number
37
+ readonly authorUsername: string
38
+ readonly createdAt: Date
39
+ readonly lastPostAt: Date
40
+ readonly replyCount: number
41
+ readonly viewCount: number
42
+ readonly isSticky: boolean
43
+ readonly isLocked: boolean
44
+ readonly visibility: Visibility
45
+ }
46
+
47
+ interface ImportedPostRow {
48
+ readonly legacyId: number
49
+ readonly legacyThreadId: number
50
+ readonly legacyForumId: number
51
+ readonly legacyAuthorId: number
52
+ readonly authorUsername: string
53
+ readonly body: string
54
+ readonly createdAt: Date
55
+ readonly editedAt: Date | null
56
+ readonly visibility: Visibility
57
+ }
58
+
59
+ interface ImportedAvatarRow {
60
+ readonly legacyUserId: number
61
+ readonly source: 'upload' | 'gallery' | 'remote'
62
+ readonly path: string
63
+ readonly width: number | null
64
+ readonly height: number | null
65
+ }
66
+
67
+ interface ImportedAttachmentRow {
68
+ readonly legacyId: number
69
+ readonly legacyPostId: number
70
+ readonly legacyUserId: number
71
+ readonly filename: string
72
+ readonly contentType: string | null
73
+ readonly sizeBytes: number
74
+ readonly path: string
75
+ readonly thumbnailPath: string | null
76
+ readonly downloadCount: number
77
+ readonly createdAt: Date
78
+ }
79
+
80
+ interface ImportedPollRow {
81
+ readonly legacyId: number
82
+ readonly legacyThreadId: number
83
+ readonly question: string
84
+ readonly options: readonly { order: number; label: string; voteCount: number }[]
85
+ readonly closesAt: Date | null
86
+ readonly createdAt: Date
87
+ readonly maxOptions: number
88
+ readonly allowRevote: boolean
89
+ readonly publicVotes: boolean
90
+ }
91
+
92
+ interface ImportedPollVoteRow {
93
+ readonly legacyPollId: number
94
+ readonly legacyUserId: number
95
+ readonly optionOrder: number
96
+ readonly votedAt: Date | null
97
+ }
98
+
99
+ interface ImportedPrivateMessageRow {
100
+ readonly legacyId: number
101
+ readonly legacyAuthorId: number
102
+ readonly subject: string
103
+ readonly body: string
104
+ readonly sentAt: Date
105
+ readonly copies: readonly {
106
+ readonly legacyOwnerId: number
107
+ readonly folder: 'inbox' | 'sent' | 'trash'
108
+ readonly readAt: Date | null
109
+ }[]
110
+ }
111
+
112
+ interface ImportedSubscriptionRow {
113
+ readonly legacyId: number
114
+ readonly legacyUserId: number
115
+ readonly legacyTargetId: number
116
+ readonly mode: 'instant' | 'none'
117
+ }
118
+
119
+ interface ImportedReputationRow {
120
+ readonly legacyId: number
121
+ readonly legacyUserId: number
122
+ readonly legacyGivenByUserId: number
123
+ readonly legacyPostId: number | null
124
+ readonly points: number
125
+ readonly comment: string
126
+ readonly createdAt: Date
127
+ }
128
+
129
+ interface ImportedWarningRow {
130
+ readonly legacyId: number
131
+ readonly legacyUserId: number
132
+ readonly legacyIssuedByUserId: number
133
+ readonly legacyPostId: number | null
134
+ readonly title: string
135
+ readonly points: number
136
+ readonly notes: string
137
+ readonly issuedAt: Date
138
+ readonly expiresAt: Date | null
139
+ readonly revokedAt: Date | null
140
+ readonly revokeReason: string | null
141
+ }
142
+
143
+ interface ImportedBanRow {
144
+ readonly legacyId: number
145
+ readonly legacyUserId: number
146
+ readonly legacyBannedByUserId: number
147
+ readonly reason: string
148
+ readonly publicReason: string | null
149
+ readonly createdAt: Date
150
+ readonly expiresAt: Date | null
151
+ }
152
+
153
+ interface ImportedUserRelationRow {
154
+ readonly legacyUserId: number
155
+ readonly legacyOtherUserId: number
156
+ readonly kind: 'buddy' | 'ignore'
157
+ }
158
+
159
+ export type CopiedAttachment =
160
+ | {
161
+ readonly outcome: 'ready'
162
+ readonly storageKey: string
163
+ readonly thumbnailKey: string | null
164
+ readonly contentType: string
165
+ readonly sizeBytes: number
166
+ readonly width: number | null
167
+ readonly height: number | null
168
+ }
169
+ | { readonly outcome: 'missing' | 'failed'; readonly reason: string }
170
+
171
+ export type CopiedAvatar =
172
+ | {
173
+ readonly outcome: 'ready'
174
+ readonly key: string
175
+ readonly width: number | null
176
+ readonly height: number | null
177
+ }
178
+ | { readonly outcome: 'missing' | 'failed'; readonly reason: string }
179
+
180
+ export interface ImportFileCopier {
181
+ attachment(input: {
182
+ readonly legacyId: number
183
+ readonly path: string
184
+ readonly thumbnailPath: string | null
185
+ readonly filename: string
186
+ readonly contentType: string | null
187
+ }): Promise<CopiedAttachment>
188
+ avatar(input: { readonly legacyUserId: number; readonly path: string }): Promise<CopiedAvatar>
189
+ }
190
+
191
+ interface Skip {
192
+ readonly legacyId: number
193
+ readonly reason: string
194
+ }
195
+
196
+ interface WriteResult {
197
+ readonly inserted: number
198
+ readonly updated: number
199
+ readonly skipped: readonly Skip[]
200
+ }
201
+
202
+ interface Written {
203
+ readonly legacy_id: number
204
+ readonly new_id: number
205
+ readonly inserted: boolean
206
+ }
207
+
208
+ function parentIds(ids: readonly (number | null)[]): number[] {
209
+ return [...new Set(ids.filter((id): id is number => id !== null && id > 0))]
210
+ }
211
+
212
+ const empty: WriteResult = { inserted: 0, updated: 0, skipped: [] }
213
+
214
+ function tally(rows: readonly Written[], skipped: readonly Skip[]): WriteResult {
215
+ return {
216
+ inserted: rows.filter((row) => row.inserted).length,
217
+ updated: rows.filter((row) => !row.inserted).length,
218
+ skipped,
219
+ }
220
+ }
221
+
222
+ function slugFor(title: string, id: number, fallback: string): string {
223
+ const slug = title
224
+ .toLowerCase()
225
+ .replace(/[^a-z0-9]+/g, '-')
226
+ .replace(/^-+|-+$/g, '')
227
+ .slice(0, 80)
228
+ return slug.length === 0 ? `${fallback}-${id}` : slug
229
+ }
230
+
231
+ function missingGroup(key: string): string {
232
+ return `No "${key}" usergroup. Run migrations before importing.`
233
+ }
234
+
235
+ function algoOf(legacyPasswordHash: string): string {
236
+ const separator = legacyPasswordHash.indexOf('$')
237
+ return separator <= 0 ? 'mybb' : legacyPasswordHash.slice(0, separator)
238
+ }
239
+
240
+ export class PostgresImportSink {
241
+ readonly #copier: ImportFileCopier | null
242
+
243
+ constructor(
244
+ private readonly db: Database,
245
+ options?: { readonly copier?: ImportFileCopier | undefined },
246
+ ) {
247
+ this.#copier = options?.copier ?? null
248
+ }
249
+
250
+ async putUsers(rows: readonly ImportedUserRow[]): Promise<WriteResult> {
251
+ if (rows.length === 0) return empty
252
+
253
+ const groupRows = resultRows<{ id: number }>(
254
+ await this.db.execute(sql`select id from usergroups where key = 'registered' limit 1`),
255
+ )
256
+
257
+ const groupId = groupRows[0]?.id
258
+ if (groupId === undefined) {
259
+ throw new Error(missingGroup('registered'))
260
+ }
261
+
262
+ const taken = resultRows<{
263
+ username_lower: string
264
+ email_lower: string
265
+ legacy_id: number | null
266
+ }>(
267
+ await this.db.execute(sql`
268
+ select username_lower, email_lower, legacy_id from users
269
+ where username_lower in (${sql.join(
270
+ rows.map((r) => sql`${r.username.toLowerCase()}`),
271
+ sql`, `,
272
+ )})
273
+ or email_lower in (${sql.join(
274
+ rows.map((r) => sql`${r.email.toLowerCase()}`),
275
+ sql`, `,
276
+ )})
277
+ `),
278
+ )
279
+
280
+ const byName = new Map(taken.map((row) => [row.username_lower, row.legacy_id]))
281
+ const byEmail = new Map(taken.map((row) => [row.email_lower, row.legacy_id]))
282
+
283
+ const skipped: Skip[] = []
284
+ const writable = rows.filter((row) => {
285
+ const nameOwner = byName.get(row.username.toLowerCase())
286
+ const emailOwner = byEmail.get(row.email.toLowerCase())
287
+
288
+ if (nameOwner !== undefined && nameOwner !== row.legacyId) {
289
+ skipped.push({ legacyId: row.legacyId, reason: `username "${row.username}" already taken` })
290
+ return false
291
+ }
292
+ if (emailOwner !== undefined && emailOwner !== row.legacyId) {
293
+ skipped.push({ legacyId: row.legacyId, reason: 'e-mail address already registered' })
294
+ return false
295
+ }
296
+ return true
297
+ })
298
+
299
+ if (writable.length === 0) return { ...empty, skipped }
300
+
301
+ const written = resultRows<Written>(
302
+ await this.db.execute(sql`
303
+ insert into users (
304
+ username, username_lower, email, email_lower,
305
+ password_hash, password_algo, primary_group_id, display_group_id,
306
+ post_count, created_at, last_active_at, legacy_id
307
+ )
308
+ values ${sql.join(
309
+ writable.map(
310
+ (row) => sql`(
311
+ ${row.username}, ${row.username.toLowerCase()},
312
+ ${row.email}, ${row.email.toLowerCase()},
313
+ ${row.legacyPasswordHash}, ${algoOf(row.legacyPasswordHash)},
314
+ ${groupId}, ${groupId},
315
+ ${row.postCount}, ${row.registeredAt}, ${row.lastVisitAt},
316
+ ${row.legacyId}
317
+ )`,
318
+ ),
319
+ sql`, `,
320
+ )}
321
+ on conflict (legacy_id) where legacy_id is not null do update set
322
+ username = excluded.username,
323
+ username_lower = excluded.username_lower,
324
+ email = excluded.email,
325
+ email_lower = excluded.email_lower,
326
+ password_hash = excluded.password_hash,
327
+ password_algo = excluded.password_algo,
328
+ post_count = excluded.post_count
329
+ returning legacy_id, id as new_id, (xmax = 0) as inserted
330
+ `),
331
+ )
332
+
333
+ await this.#map('user', written)
334
+ return tally(written, skipped)
335
+ }
336
+
337
+ async putForums(rows: readonly ImportedForumRow[]): Promise<WriteResult> {
338
+ if (rows.length === 0) return empty
339
+
340
+ const parents = await resolveLegacyIds(
341
+ this.db,
342
+ 'forum',
343
+ parentIds(rows.map((row) => row.legacyParentId)),
344
+ )
345
+
346
+ const skipped: Skip[] = []
347
+ const writable: { row: ImportedForumRow; parentId: number | null }[] = []
348
+
349
+ for (const row of rows) {
350
+ if (row.legacyParentId === null || row.legacyParentId === 0) {
351
+ writable.push({ row, parentId: null })
352
+ continue
353
+ }
354
+ const parentId = parents.get(row.legacyParentId)
355
+ if (parentId === undefined) {
356
+ skipped.push({
357
+ legacyId: row.legacyId,
358
+ reason: `parent forum ${row.legacyParentId} not imported yet`,
359
+ })
360
+ continue
361
+ }
362
+ writable.push({ row, parentId })
363
+ }
364
+
365
+ if (writable.length === 0) return { ...empty, skipped }
366
+
367
+ const written = resultRows<Written>(
368
+ await this.db.execute(sql`
369
+ insert into forums (
370
+ type, title, description, slug, parent_id, path, depth,
371
+ display_order, link_url, allow_threads, legacy_id
372
+ )
373
+ values ${sql.join(
374
+ writable.map(
375
+ ({ row, parentId }) => sql`(
376
+ ${row.type}, ${row.title}, ${row.description},
377
+ ${slugFor(row.title, row.legacyId, 'forum')},
378
+ ${parentId}, '', 0,
379
+ ${row.displayOrder}, ${row.linkUrl}, ${row.type === 'forum'}, ${row.legacyId}
380
+ )`,
381
+ ),
382
+ sql`, `,
383
+ )}
384
+ on conflict (legacy_id) where legacy_id is not null do update set
385
+ type = excluded.type,
386
+ title = excluded.title,
387
+ description = excluded.description,
388
+ parent_id = excluded.parent_id,
389
+ display_order = excluded.display_order,
390
+ link_url = excluded.link_url
391
+ returning legacy_id, id as new_id, (xmax = 0) as inserted
392
+ `),
393
+ )
394
+
395
+ await this.#map('forum', written)
396
+ await this.#rebuildPaths()
397
+ return tally(written, skipped)
398
+ }
399
+
400
+ async putThreads(rows: readonly ImportedThreadRow[]): Promise<WriteResult> {
401
+ if (rows.length === 0) return empty
402
+
403
+ const forums = await resolveLegacyIds(
404
+ this.db,
405
+ 'forum',
406
+ parentIds(rows.map((r) => r.legacyForumId)),
407
+ )
408
+ const authors = await resolveLegacyIds(
409
+ this.db,
410
+ 'user',
411
+ parentIds(rows.map((r) => r.legacyAuthorId)),
412
+ )
413
+
414
+ const skipped: Skip[] = []
415
+ const writable: { row: ImportedThreadRow; forumId: number; authorId: number | null }[] = []
416
+
417
+ for (const row of rows) {
418
+ const forumId = forums.get(row.legacyForumId)
419
+ if (forumId === undefined) {
420
+ skipped.push({
421
+ legacyId: row.legacyId,
422
+ reason: `forum ${row.legacyForumId} not imported`,
423
+ })
424
+ continue
425
+ }
426
+ writable.push({ row, forumId, authorId: authors.get(row.legacyAuthorId) ?? null })
427
+ }
428
+
429
+ if (writable.length === 0) return { ...empty, skipped }
430
+
431
+ const written = resultRows<Written>(
432
+ await this.db.execute(sql`
433
+ insert into threads (
434
+ forum_id, title, slug, author_user_id, author_username,
435
+ created_at, last_post_at, reply_count, view_count,
436
+ is_sticky, is_locked, visibility, legacy_id
437
+ )
438
+ values ${sql.join(
439
+ writable.map(
440
+ ({ row, forumId, authorId }) => sql`(
441
+ ${forumId}, ${row.title}, ${slugFor(row.title, row.legacyId, 'thread')},
442
+ ${authorId}, ${row.authorUsername},
443
+ ${row.createdAt}, ${row.lastPostAt}, ${row.replyCount}, ${row.viewCount},
444
+ ${row.isSticky}, ${row.isLocked}, ${row.visibility}, ${row.legacyId}
445
+ )`,
446
+ ),
447
+ sql`, `,
448
+ )}
449
+ on conflict (legacy_id) where legacy_id is not null do update set
450
+ forum_id = excluded.forum_id,
451
+ title = excluded.title,
452
+ author_user_id = excluded.author_user_id,
453
+ author_username = excluded.author_username,
454
+ last_post_at = excluded.last_post_at,
455
+ reply_count = excluded.reply_count,
456
+ view_count = excluded.view_count,
457
+ is_sticky = excluded.is_sticky,
458
+ is_locked = excluded.is_locked,
459
+ visibility = excluded.visibility
460
+ returning legacy_id, id as new_id, (xmax = 0) as inserted
461
+ `),
462
+ )
463
+
464
+ await this.#map('thread', written)
465
+ return tally(written, skipped)
466
+ }
467
+
468
+ async putPosts(rows: readonly ImportedPostRow[]): Promise<WriteResult> {
469
+ if (rows.length === 0) return empty
470
+
471
+ const threads = await resolveLegacyIds(
472
+ this.db,
473
+ 'thread',
474
+ parentIds(rows.map((r) => r.legacyThreadId)),
475
+ )
476
+ const forums = await resolveLegacyIds(
477
+ this.db,
478
+ 'forum',
479
+ parentIds(rows.map((r) => r.legacyForumId)),
480
+ )
481
+ const authors = await resolveLegacyIds(
482
+ this.db,
483
+ 'user',
484
+ parentIds(rows.map((r) => r.legacyAuthorId)),
485
+ )
486
+
487
+ const skipped: Skip[] = []
488
+ const writable: {
489
+ row: ImportedPostRow
490
+ threadId: number
491
+ forumId: number
492
+ authorId: number | null
493
+ }[] = []
494
+
495
+ for (const row of rows) {
496
+ const threadId = threads.get(row.legacyThreadId)
497
+ const forumId = forums.get(row.legacyForumId)
498
+
499
+ if (threadId === undefined) {
500
+ skipped.push({
501
+ legacyId: row.legacyId,
502
+ reason: `thread ${row.legacyThreadId} not imported`,
503
+ })
504
+ continue
505
+ }
506
+ if (forumId === undefined) {
507
+ skipped.push({ legacyId: row.legacyId, reason: `forum ${row.legacyForumId} not imported` })
508
+ continue
509
+ }
510
+ writable.push({ row, threadId, forumId, authorId: authors.get(row.legacyAuthorId) ?? null })
511
+ }
512
+
513
+ if (writable.length === 0) return { ...empty, skipped }
514
+
515
+ const written = resultRows<Written>(
516
+ await this.db.execute(sql`
517
+ insert into posts (
518
+ thread_id, forum_id, author_user_id, author_username,
519
+ message, body_format, created_at, edited_at, visibility, is_first_post,
520
+ legacy_id
521
+ )
522
+ values ${sql.join(
523
+ writable.map(
524
+ ({ row, threadId, forumId, authorId }) => sql`(
525
+ ${threadId}, ${forumId}, ${authorId}, ${row.authorUsername},
526
+ ${row.body}, ${BodyFormat.LegacyBBCode}, ${row.createdAt}, ${row.editedAt},
527
+ ${row.visibility}, false, ${row.legacyId}
528
+ )`,
529
+ ),
530
+ sql`, `,
531
+ )}
532
+ on conflict (legacy_id) where legacy_id is not null do update set
533
+ thread_id = excluded.thread_id,
534
+ forum_id = excluded.forum_id,
535
+ author_user_id = excluded.author_user_id,
536
+ author_username = excluded.author_username,
537
+ message = excluded.message,
538
+ body_format = excluded.body_format,
539
+ edited_at = excluded.edited_at,
540
+ visibility = excluded.visibility
541
+ returning legacy_id, id as new_id, (xmax = 0) as inserted
542
+ `),
543
+ )
544
+
545
+ await this.#map('post', written)
546
+ await this.#markFirstPosts([...new Set(writable.map((w) => w.threadId))])
547
+ return tally(written, skipped)
548
+ }
549
+
550
+ async putAvatars(rows: readonly ImportedAvatarRow[]): Promise<WriteResult> {
551
+ if (rows.length === 0) return empty
552
+
553
+ const users = await resolveLegacyIds(
554
+ this.db,
555
+ 'user',
556
+ parentIds(rows.map((row) => row.legacyUserId)),
557
+ )
558
+
559
+ const skipped: Skip[] = []
560
+ let updated = 0
561
+
562
+ for (const row of rows) {
563
+ const userId = users.get(row.legacyUserId)
564
+ if (userId === undefined) {
565
+ skipped.push({ legacyId: row.legacyUserId, reason: 'member not imported' })
566
+ continue
567
+ }
568
+ if (row.source === 'remote') {
569
+ skipped.push({
570
+ legacyId: row.legacyUserId,
571
+ reason: `remote avatar ${row.path} not migrated`,
572
+ })
573
+ continue
574
+ }
575
+ if (this.#copier === null) {
576
+ skipped.push({
577
+ legacyId: row.legacyUserId,
578
+ reason: `avatar file ${row.path} not copied — re-run with --uploads-dir`,
579
+ })
580
+ continue
581
+ }
582
+
583
+ const copied = await this.#copier.avatar({ legacyUserId: row.legacyUserId, path: row.path })
584
+ if (copied.outcome !== 'ready') {
585
+ skipped.push({ legacyId: row.legacyUserId, reason: copied.reason })
586
+ continue
587
+ }
588
+
589
+ await this.db.execute(sql`
590
+ update users
591
+ set avatar_status = 'ready',
592
+ avatar_key = ${copied.key},
593
+ avatar_source_key = null,
594
+ avatar_width = ${copied.width ?? row.width},
595
+ avatar_height = ${copied.height ?? row.height},
596
+ avatar_failure_reason = null,
597
+ avatar_updated_at = now()
598
+ where id = ${userId}
599
+ `)
600
+ updated += 1
601
+ }
602
+
603
+ return { inserted: 0, updated, skipped }
604
+ }
605
+
606
+ async putAttachments(rows: readonly ImportedAttachmentRow[]): Promise<WriteResult> {
607
+ if (rows.length === 0) return empty
608
+
609
+ const posts = await resolveLegacyIds(
610
+ this.db,
611
+ 'post',
612
+ parentIds(rows.map((row) => row.legacyPostId)),
613
+ )
614
+ const uploaders = await resolveLegacyIds(
615
+ this.db,
616
+ 'user',
617
+ parentIds(rows.map((row) => row.legacyUserId)),
618
+ )
619
+
620
+ const forumOf = new Map<number, number>()
621
+ if (posts.size > 0) {
622
+ const postRows = resultRows<{ id: number; forum_id: number }>(
623
+ await this.db.execute(sql`
624
+ select id, forum_id from posts
625
+ where id in (${sql.join(
626
+ [...posts.values()].map((id) => sql`${id}`),
627
+ sql`, `,
628
+ )})
629
+ `),
630
+ )
631
+ for (const row of postRows) forumOf.set(Number(row.id), Number(row.forum_id))
632
+ }
633
+
634
+ const skipped: Skip[] = []
635
+ const written: Written[] = []
636
+
637
+ for (const row of rows) {
638
+ const postId = posts.get(row.legacyPostId)
639
+ const forumId = postId === undefined ? undefined : forumOf.get(postId)
640
+ if (postId === undefined || forumId === undefined) {
641
+ skipped.push({ legacyId: row.legacyId, reason: `post ${row.legacyPostId} not imported` })
642
+ continue
643
+ }
644
+
645
+ const copied: CopiedAttachment =
646
+ this.#copier === null
647
+ ? {
648
+ outcome: 'missing',
649
+ reason: `legacy file ${row.path} not copied — re-run with --uploads-dir`,
650
+ }
651
+ : await this.#copier.attachment({
652
+ legacyId: row.legacyId,
653
+ path: row.path,
654
+ thumbnailPath: row.thumbnailPath,
655
+ filename: row.filename,
656
+ contentType: row.contentType,
657
+ })
658
+
659
+ const ready = copied.outcome === 'ready'
660
+ const contentType = ready
661
+ ? copied.contentType
662
+ : (row.contentType ?? 'application/octet-stream')
663
+
664
+ const result = resultRows<Written>(
665
+ await this.db.execute(sql`
666
+ insert into attachments (
667
+ post_id, forum_id, uploader_user_id, filename, content_type, size_bytes,
668
+ storage_key, thumbnail_key, width, height, status, failure_reason,
669
+ download_count, created_at, ready_at, legacy_id
670
+ )
671
+ values (
672
+ ${postId}, ${forumId}, ${uploaders.get(row.legacyUserId) ?? null},
673
+ ${row.filename}, ${contentType},
674
+ ${ready ? copied.sizeBytes : row.sizeBytes},
675
+ ${ready ? copied.storageKey : null}, ${ready ? copied.thumbnailKey : null},
676
+ ${ready ? copied.width : null}, ${ready ? copied.height : null},
677
+ ${ready ? 'ready' : 'failed'}, ${ready ? null : copied.reason},
678
+ ${row.downloadCount}, ${row.createdAt}, ${ready ? sql`now()` : null},
679
+ ${row.legacyId}
680
+ )
681
+ on conflict (legacy_id) where legacy_id is not null do update set
682
+ post_id = excluded.post_id,
683
+ forum_id = excluded.forum_id,
684
+ uploader_user_id = excluded.uploader_user_id,
685
+ filename = excluded.filename,
686
+ download_count = excluded.download_count,
687
+ content_type = case
688
+ when excluded.status = 'ready' then excluded.content_type
689
+ else attachments.content_type end,
690
+ size_bytes = case
691
+ when excluded.status = 'ready' then excluded.size_bytes
692
+ else attachments.size_bytes end,
693
+ storage_key = case
694
+ when excluded.status = 'ready' then excluded.storage_key
695
+ else attachments.storage_key end,
696
+ thumbnail_key = case
697
+ when excluded.status = 'ready' then excluded.thumbnail_key
698
+ else attachments.thumbnail_key end,
699
+ width = case
700
+ when excluded.status = 'ready' then excluded.width
701
+ else attachments.width end,
702
+ height = case
703
+ when excluded.status = 'ready' then excluded.height
704
+ else attachments.height end,
705
+ ready_at = case
706
+ when excluded.status = 'ready' then excluded.ready_at
707
+ else attachments.ready_at end,
708
+ status = case
709
+ when excluded.status = 'ready' or attachments.status = 'ready' then 'ready'
710
+ else excluded.status end,
711
+ failure_reason = case
712
+ when excluded.status = 'ready' or attachments.status = 'ready' then null
713
+ else excluded.failure_reason end
714
+ returning legacy_id, id as new_id, (xmax = 0) as inserted
715
+ `),
716
+ )
717
+ written.push(...result)
718
+ }
719
+
720
+ await this.#map('attachment', written)
721
+ return tally(written, skipped)
722
+ }
723
+
724
+ async putPolls(rows: readonly ImportedPollRow[]): Promise<WriteResult> {
725
+ if (rows.length === 0) return empty
726
+
727
+ const threads = await resolveLegacyIds(
728
+ this.db,
729
+ 'thread',
730
+ parentIds(rows.map((row) => row.legacyThreadId)),
731
+ )
732
+
733
+ const skipped: Skip[] = []
734
+ const written: Written[] = []
735
+
736
+ for (const row of rows) {
737
+ const threadId = threads.get(row.legacyThreadId)
738
+ if (threadId === undefined) {
739
+ skipped.push({
740
+ legacyId: row.legacyId,
741
+ reason: `thread ${row.legacyThreadId} not imported`,
742
+ })
743
+ continue
744
+ }
745
+
746
+ const result = resultRows<Written>(
747
+ await this.db.execute(sql`
748
+ insert into polls (thread_id, question, closes_at, created_at, legacy_id,
749
+ max_options, allow_revote, public_votes)
750
+ values (${threadId}, ${row.question}, ${row.closesAt}, ${row.createdAt}, ${row.legacyId},
751
+ ${row.maxOptions}, ${row.allowRevote}, ${row.publicVotes})
752
+ on conflict (legacy_id) where legacy_id is not null do update set
753
+ thread_id = excluded.thread_id,
754
+ question = excluded.question,
755
+ closes_at = excluded.closes_at,
756
+ max_options = excluded.max_options,
757
+ allow_revote = excluded.allow_revote,
758
+ public_votes = excluded.public_votes
759
+ returning legacy_id, id as new_id, (xmax = 0) as inserted
760
+ `),
761
+ )
762
+
763
+ const poll = result[0]
764
+ if (poll === undefined) continue
765
+ written.push(poll)
766
+
767
+ await this.db.execute(sql`
768
+ insert into poll_options (poll_id, label, display_order, vote_count)
769
+ values ${sql.join(
770
+ row.options.map(
771
+ (option) =>
772
+ sql`(${poll.new_id}, ${option.label}, ${option.order}, ${option.voteCount})`,
773
+ ),
774
+ sql`, `,
775
+ )}
776
+ on conflict (poll_id, display_order) do update set
777
+ label = excluded.label,
778
+ vote_count = excluded.vote_count
779
+ `)
780
+ }
781
+
782
+ await this.#map('poll', written)
783
+ return tally(written, skipped)
784
+ }
785
+
786
+ async putPollVotes(rows: readonly ImportedPollVoteRow[]): Promise<WriteResult> {
787
+ if (rows.length === 0) return empty
788
+
789
+ const polls = await resolveLegacyIds(
790
+ this.db,
791
+ 'poll',
792
+ parentIds(rows.map((row) => row.legacyPollId)),
793
+ )
794
+ const users = await resolveLegacyIds(
795
+ this.db,
796
+ 'user',
797
+ parentIds(rows.map((row) => row.legacyUserId)),
798
+ )
799
+
800
+ const options = new Map<string, number>()
801
+ if (polls.size > 0) {
802
+ const optionRows = resultRows<{ poll_id: number; display_order: number; id: number }>(
803
+ await this.db.execute(sql`
804
+ select poll_id, display_order, id from poll_options
805
+ where poll_id in (${sql.join(
806
+ [...polls.values()].map((id) => sql`${id}`),
807
+ sql`, `,
808
+ )})
809
+ `),
810
+ )
811
+ for (const row of optionRows) {
812
+ options.set(`${row.poll_id}:${row.display_order}`, Number(row.id))
813
+ }
814
+ }
815
+
816
+ const skipped: Skip[] = []
817
+ const writable: { pollId: number; userId: number; optionId: number; votedAt: Date | null }[] =
818
+ []
819
+
820
+ for (const row of rows) {
821
+ const pollId = polls.get(row.legacyPollId)
822
+ const userId = users.get(row.legacyUserId)
823
+ const optionId =
824
+ pollId === undefined ? undefined : options.get(`${pollId}:${row.optionOrder}`)
825
+
826
+ if (pollId === undefined) {
827
+ skipped.push({ legacyId: row.legacyPollId, reason: 'poll not imported' })
828
+ continue
829
+ }
830
+ if (userId === undefined) {
831
+ skipped.push({
832
+ legacyId: row.legacyPollId,
833
+ reason: `member ${row.legacyUserId} not imported`,
834
+ })
835
+ continue
836
+ }
837
+ if (optionId === undefined) {
838
+ skipped.push({
839
+ legacyId: row.legacyPollId,
840
+ reason: `option ${row.optionOrder} does not exist`,
841
+ })
842
+ continue
843
+ }
844
+ writable.push({ pollId, userId, optionId, votedAt: row.votedAt })
845
+ }
846
+
847
+ if (writable.length === 0) return { ...empty, skipped }
848
+
849
+ const inserted = resultRows<{ poll_id: number }>(
850
+ await this.db.execute(sql`
851
+ insert into poll_votes (poll_id, user_id, option_id, created_at)
852
+ values ${sql.join(
853
+ writable.map(
854
+ (vote) =>
855
+ sql`(${vote.pollId}, ${vote.userId}, ${vote.optionId}, ${vote.votedAt ?? sql`now()`})`,
856
+ ),
857
+ sql`, `,
858
+ )}
859
+ on conflict (poll_id, user_id, option_id) do nothing
860
+ returning poll_id
861
+ `),
862
+ )
863
+
864
+ return { inserted: inserted.length, updated: writable.length - inserted.length, skipped }
865
+ }
866
+
867
+ async putPrivateMessages(rows: readonly ImportedPrivateMessageRow[]): Promise<WriteResult> {
868
+ if (rows.length === 0) return empty
869
+
870
+ const authorIds = parentIds(rows.map((row) => row.legacyAuthorId))
871
+ const ownerIds = parentIds(rows.flatMap((row) => row.copies.map((copy) => copy.legacyOwnerId)))
872
+ const users = await resolveLegacyIds(this.db, 'user', [...new Set([...authorIds, ...ownerIds])])
873
+
874
+ const usernames = new Map<number, string>()
875
+ if (users.size > 0) {
876
+ const nameRows = resultRows<{ id: number; username: string }>(
877
+ await this.db.execute(sql`
878
+ select id, username from users
879
+ where id in (${sql.join(
880
+ [...users.values()].map((id) => sql`${id}`),
881
+ sql`, `,
882
+ )})
883
+ `),
884
+ )
885
+ for (const row of nameRows) usernames.set(Number(row.id), row.username)
886
+ }
887
+
888
+ const written = resultRows<Written>(
889
+ await this.db.execute(sql`
890
+ insert into private_messages (
891
+ author_user_id, author_username, subject, message, body_format, sent_at, legacy_id
892
+ )
893
+ values ${sql.join(
894
+ rows.map((row) => {
895
+ const authorId = users.get(row.legacyAuthorId) ?? null
896
+ return sql`(
897
+ ${authorId}, ${authorId === null ? '' : (usernames.get(authorId) ?? '')},
898
+ ${row.subject}, ${row.body}, ${BodyFormat.LegacyBBCode}, ${row.sentAt},
899
+ ${row.legacyId}
900
+ )`
901
+ }),
902
+ sql`, `,
903
+ )}
904
+ on conflict (legacy_id) where legacy_id is not null do update set
905
+ author_user_id = excluded.author_user_id,
906
+ author_username = excluded.author_username,
907
+ subject = excluded.subject,
908
+ message = excluded.message,
909
+ sent_at = excluded.sent_at
910
+ returning legacy_id, id as new_id, (xmax = 0) as inserted
911
+ `),
912
+ )
913
+
914
+ await this.#map('pm', written)
915
+
916
+ const messageIds = new Map(written.map((row) => [Number(row.legacy_id), Number(row.new_id)]))
917
+ const skipped: Skip[] = []
918
+ const copies: {
919
+ messageId: number
920
+ ownerId: number
921
+ authorOwned: boolean
922
+ folder: string
923
+ readAt: Date | null
924
+ }[] = []
925
+
926
+ for (const row of rows) {
927
+ const messageId = messageIds.get(row.legacyId)
928
+ if (messageId === undefined) continue
929
+ for (const copy of row.copies) {
930
+ const ownerId = users.get(copy.legacyOwnerId)
931
+ if (ownerId === undefined) {
932
+ skipped.push({
933
+ legacyId: row.legacyId,
934
+ reason: `recipient ${copy.legacyOwnerId} not imported`,
935
+ })
936
+ continue
937
+ }
938
+ copies.push({
939
+ messageId,
940
+ ownerId,
941
+ authorOwned: copy.legacyOwnerId === row.legacyAuthorId,
942
+ folder: copy.folder,
943
+ readAt: copy.readAt,
944
+ })
945
+ }
946
+ }
947
+
948
+ if (copies.length > 0) {
949
+ await this.db.execute(sql`
950
+ insert into private_message_copies (message_id, owner_user_id, folder, role, read_at)
951
+ values ${sql.join(
952
+ copies.map(
953
+ (copy) => sql`(
954
+ ${copy.messageId}, ${copy.ownerId}, ${copy.folder},
955
+ ${copy.authorOwned ? 'author' : 'to'}, ${copy.readAt}
956
+ )`,
957
+ ),
958
+ sql`, `,
959
+ )}
960
+ on conflict (message_id, owner_user_id) do update set
961
+ folder = excluded.folder,
962
+ read_at = excluded.read_at
963
+ `)
964
+ }
965
+
966
+ return tally(written, skipped)
967
+ }
968
+
969
+ putThreadSubscriptions(rows: readonly ImportedSubscriptionRow[]): Promise<WriteResult> {
970
+ return this.#putSubscriptions(rows, 'thread')
971
+ }
972
+
973
+ putForumSubscriptions(rows: readonly ImportedSubscriptionRow[]): Promise<WriteResult> {
974
+ return this.#putSubscriptions(rows, 'forum')
975
+ }
976
+
977
+ async putReputation(rows: readonly ImportedReputationRow[]): Promise<WriteResult> {
978
+ if (rows.length === 0) return empty
979
+
980
+ const users = await resolveLegacyIds(
981
+ this.db,
982
+ 'user',
983
+ parentIds([
984
+ ...rows.map((row) => row.legacyUserId),
985
+ ...rows.map((row) => row.legacyGivenByUserId),
986
+ ]),
987
+ )
988
+ const posts = await resolveLegacyIds(
989
+ this.db,
990
+ 'post',
991
+ parentIds(rows.map((row) => row.legacyPostId)),
992
+ )
993
+
994
+ const skipped: Skip[] = []
995
+ const writable: { row: ImportedReputationRow; userId: number }[] = []
996
+
997
+ for (const row of rows) {
998
+ const userId = users.get(row.legacyUserId)
999
+ if (userId === undefined) {
1000
+ skipped.push({ legacyId: row.legacyId, reason: `member ${row.legacyUserId} not imported` })
1001
+ continue
1002
+ }
1003
+ writable.push({ row, userId })
1004
+ }
1005
+
1006
+ if (writable.length === 0) return { ...empty, skipped }
1007
+
1008
+ const written = resultRows<Written>(
1009
+ await this.db.execute(sql`
1010
+ insert into reputation (
1011
+ user_id, given_by_user_id, post_id, points, comment, created_at, legacy_id
1012
+ )
1013
+ values ${sql.join(
1014
+ writable.map(
1015
+ ({ row, userId }) => sql`(
1016
+ ${userId}, ${users.get(row.legacyGivenByUserId) ?? null},
1017
+ ${row.legacyPostId === null ? null : (posts.get(row.legacyPostId) ?? null)},
1018
+ ${row.points}, ${row.comment}, ${row.createdAt}, ${row.legacyId}
1019
+ )`,
1020
+ ),
1021
+ sql`, `,
1022
+ )}
1023
+ on conflict (legacy_id) where legacy_id is not null do update set
1024
+ points = excluded.points,
1025
+ comment = excluded.comment
1026
+ returning legacy_id, id as new_id, (xmax = 0) as inserted
1027
+ `),
1028
+ )
1029
+
1030
+ await this.#recountReputation([...new Set(writable.map(({ userId }) => userId))])
1031
+ return tally(written, skipped)
1032
+ }
1033
+
1034
+ async putWarnings(rows: readonly ImportedWarningRow[]): Promise<WriteResult> {
1035
+ if (rows.length === 0) return empty
1036
+
1037
+ const users = await resolveLegacyIds(
1038
+ this.db,
1039
+ 'user',
1040
+ parentIds([
1041
+ ...rows.map((row) => row.legacyUserId),
1042
+ ...rows.map((row) => row.legacyIssuedByUserId),
1043
+ ]),
1044
+ )
1045
+ const posts = await resolveLegacyIds(
1046
+ this.db,
1047
+ 'post',
1048
+ parentIds(rows.map((row) => row.legacyPostId)),
1049
+ )
1050
+
1051
+ const skipped: Skip[] = []
1052
+ const writable: { row: ImportedWarningRow; userId: number }[] = []
1053
+
1054
+ for (const row of rows) {
1055
+ const userId = users.get(row.legacyUserId)
1056
+ if (userId === undefined) {
1057
+ skipped.push({ legacyId: row.legacyId, reason: `member ${row.legacyUserId} not imported` })
1058
+ continue
1059
+ }
1060
+ writable.push({ row, userId })
1061
+ }
1062
+
1063
+ if (writable.length === 0) return { ...empty, skipped }
1064
+
1065
+ const written = resultRows<Written>(
1066
+ await this.db.execute(sql`
1067
+ insert into warnings (
1068
+ user_id, issued_by_user_id, title, points, reason, post_id,
1069
+ created_at, expires_at, revoked_at, revoke_reason, legacy_id
1070
+ )
1071
+ values ${sql.join(
1072
+ writable.map(
1073
+ ({ row, userId }) => sql`(
1074
+ ${userId}, ${users.get(row.legacyIssuedByUserId) ?? null},
1075
+ ${row.title}, ${row.points}, ${row.notes},
1076
+ ${row.legacyPostId === null ? null : (posts.get(row.legacyPostId) ?? null)},
1077
+ ${row.issuedAt}, ${row.expiresAt}, ${row.revokedAt}, ${row.revokeReason},
1078
+ ${row.legacyId}
1079
+ )`,
1080
+ ),
1081
+ sql`, `,
1082
+ )}
1083
+ on conflict (legacy_id) where legacy_id is not null do update set
1084
+ title = excluded.title,
1085
+ points = excluded.points,
1086
+ reason = excluded.reason,
1087
+ expires_at = excluded.expires_at,
1088
+ revoked_at = excluded.revoked_at,
1089
+ revoke_reason = excluded.revoke_reason
1090
+ returning legacy_id, id as new_id, (xmax = 0) as inserted
1091
+ `),
1092
+ )
1093
+
1094
+ await this.#recountWarningPoints([...new Set(writable.map(({ userId }) => userId))])
1095
+ return tally(written, skipped)
1096
+ }
1097
+
1098
+ async putBans(rows: readonly ImportedBanRow[]): Promise<WriteResult> {
1099
+ if (rows.length === 0) return empty
1100
+
1101
+ const groups = resultRows<{ id: number; key: string }>(
1102
+ await this.db.execute(sql`select id, key from usergroups where key = 'banned' limit 1`),
1103
+ )
1104
+ const bannedGroupId = groups[0]?.id
1105
+ if (bannedGroupId === undefined) {
1106
+ throw new Error(missingGroup('banned'))
1107
+ }
1108
+
1109
+ const users = await resolveLegacyIds(
1110
+ this.db,
1111
+ 'user',
1112
+ parentIds([
1113
+ ...rows.map((row) => row.legacyUserId),
1114
+ ...rows.map((row) => row.legacyBannedByUserId),
1115
+ ]),
1116
+ )
1117
+
1118
+ const activeBans = new Map<number, number | null>()
1119
+ if (users.size > 0) {
1120
+ const activeRows = resultRows<{ user_id: number; legacy_id: number | null }>(
1121
+ await this.db.execute(sql`
1122
+ select user_id, legacy_id from bans
1123
+ where lifted_at is null
1124
+ and user_id in (${sql.join(
1125
+ [...users.values()].map((id) => sql`${id}`),
1126
+ sql`, `,
1127
+ )})
1128
+ `),
1129
+ )
1130
+ for (const row of activeRows) {
1131
+ activeBans.set(Number(row.user_id), row.legacy_id === null ? null : Number(row.legacy_id))
1132
+ }
1133
+ }
1134
+
1135
+ const skipped: Skip[] = []
1136
+ const written: Written[] = []
1137
+
1138
+ for (const row of rows) {
1139
+ const userId = users.get(row.legacyUserId)
1140
+ if (userId === undefined) {
1141
+ skipped.push({ legacyId: row.legacyId, reason: `member ${row.legacyUserId} not imported` })
1142
+ continue
1143
+ }
1144
+
1145
+ const existing = activeBans.get(userId)
1146
+ if (existing !== undefined && existing !== row.legacyId) {
1147
+ skipped.push({ legacyId: row.legacyId, reason: 'member already has an active ban' })
1148
+ continue
1149
+ }
1150
+
1151
+ const result = resultRows<Written & { active: boolean }>(
1152
+ await this.db.execute(sql`
1153
+ insert into bans (
1154
+ user_id, banned_by_user_id, reason, public_reason,
1155
+ previous_primary_group_id, expires_at, created_at, legacy_id
1156
+ )
1157
+ select ${userId}, ${users.get(row.legacyBannedByUserId) ?? null},
1158
+ ${row.reason}, ${row.publicReason},
1159
+ case when u.primary_group_id = ${bannedGroupId} then null
1160
+ else u.primary_group_id end,
1161
+ ${row.expiresAt}, ${row.createdAt}, ${row.legacyId}
1162
+ from users u where u.id = ${userId}
1163
+ on conflict (legacy_id) where legacy_id is not null do update set
1164
+ reason = excluded.reason,
1165
+ public_reason = excluded.public_reason,
1166
+ expires_at = excluded.expires_at
1167
+ returning legacy_id, id as new_id, (xmax = 0) as inserted,
1168
+ lifted_at is null as active
1169
+ `),
1170
+ )
1171
+
1172
+ const ban = result[0]
1173
+ if (ban === undefined) continue
1174
+ written.push(ban)
1175
+ activeBans.set(userId, row.legacyId)
1176
+
1177
+ if (ban.active) {
1178
+ await this.db.execute(sql`
1179
+ update users
1180
+ set primary_group_id = ${bannedGroupId}, display_group_id = ${bannedGroupId}
1181
+ where id = ${userId}
1182
+ `)
1183
+ }
1184
+ }
1185
+
1186
+ return tally(written, skipped)
1187
+ }
1188
+
1189
+ async putUserRelations(rows: readonly ImportedUserRelationRow[]): Promise<WriteResult> {
1190
+ if (rows.length === 0) return empty
1191
+
1192
+ const users = await resolveLegacyIds(
1193
+ this.db,
1194
+ 'user',
1195
+ parentIds([
1196
+ ...rows.map((row) => row.legacyUserId),
1197
+ ...rows.map((row) => row.legacyOtherUserId),
1198
+ ]),
1199
+ )
1200
+
1201
+ const skipped: Skip[] = []
1202
+ const writable: { userId: number; otherUserId: number; kind: string }[] = []
1203
+
1204
+ for (const row of rows) {
1205
+ const userId = users.get(row.legacyUserId)
1206
+ const otherUserId = users.get(row.legacyOtherUserId)
1207
+ if (userId === undefined || otherUserId === undefined) {
1208
+ skipped.push({
1209
+ legacyId: row.legacyUserId,
1210
+ reason: `member ${userId === undefined ? row.legacyUserId : row.legacyOtherUserId} not imported`,
1211
+ })
1212
+ continue
1213
+ }
1214
+ writable.push({ userId, otherUserId, kind: row.kind })
1215
+ }
1216
+
1217
+ if (writable.length === 0) return { ...empty, skipped }
1218
+
1219
+ const inserted = resultRows<{ user_id: number; inserted: boolean }>(
1220
+ await this.db.execute(sql`
1221
+ insert into user_relations (user_id, other_user_id, kind)
1222
+ values ${sql.join(
1223
+ writable.map((row) => sql`(${row.userId}, ${row.otherUserId}, ${row.kind})`),
1224
+ sql`, `,
1225
+ )}
1226
+ on conflict (user_id, other_user_id) do update set kind = excluded.kind
1227
+ returning user_id, (xmax = 0) as inserted
1228
+ `),
1229
+ )
1230
+
1231
+ return {
1232
+ inserted: inserted.filter((row) => row.inserted).length,
1233
+ updated: inserted.filter((row) => !row.inserted).length,
1234
+ skipped,
1235
+ }
1236
+ }
1237
+
1238
+ async #putSubscriptions(
1239
+ rows: readonly ImportedSubscriptionRow[],
1240
+ target: 'thread' | 'forum',
1241
+ ): Promise<WriteResult> {
1242
+ if (rows.length === 0) return empty
1243
+
1244
+ const users = await resolveLegacyIds(
1245
+ this.db,
1246
+ 'user',
1247
+ parentIds(rows.map((row) => row.legacyUserId)),
1248
+ )
1249
+ const targets = await resolveLegacyIds(
1250
+ this.db,
1251
+ target,
1252
+ parentIds(rows.map((row) => row.legacyTargetId)),
1253
+ )
1254
+
1255
+ const skipped: Skip[] = []
1256
+ const writable: { userId: number; targetId: number; mode: string }[] = []
1257
+
1258
+ for (const row of rows) {
1259
+ const userId = users.get(row.legacyUserId)
1260
+ const targetId = targets.get(row.legacyTargetId)
1261
+ if (userId === undefined) {
1262
+ skipped.push({ legacyId: row.legacyId, reason: `member ${row.legacyUserId} not imported` })
1263
+ continue
1264
+ }
1265
+ if (targetId === undefined) {
1266
+ skipped.push({
1267
+ legacyId: row.legacyId,
1268
+ reason: `${target} ${row.legacyTargetId} not imported`,
1269
+ })
1270
+ continue
1271
+ }
1272
+ writable.push({ userId, targetId, mode: row.mode === 'none' ? 'none' : 'instant' })
1273
+ }
1274
+
1275
+ if (writable.length === 0) return { ...empty, skipped }
1276
+
1277
+ const table = target === 'thread' ? sql`thread_subscriptions` : sql`forum_subscriptions`
1278
+ const column = target === 'thread' ? sql`thread_id` : sql`forum_id`
1279
+
1280
+ const written = resultRows<{ inserted: boolean }>(
1281
+ await this.db.execute(sql`
1282
+ insert into ${table} (user_id, ${column}, mode)
1283
+ values ${sql.join(
1284
+ writable.map((row) => sql`(${row.userId}, ${row.targetId}, ${row.mode})`),
1285
+ sql`, `,
1286
+ )}
1287
+ on conflict (user_id, ${column}) do update set mode = excluded.mode
1288
+ returning (xmax = 0) as inserted
1289
+ `),
1290
+ )
1291
+
1292
+ return {
1293
+ inserted: written.filter((row) => row.inserted).length,
1294
+ updated: written.filter((row) => !row.inserted).length,
1295
+ skipped,
1296
+ }
1297
+ }
1298
+
1299
+ async #recountReputation(userIds: readonly number[]): Promise<void> {
1300
+ if (userIds.length === 0) return
1301
+ await this.db.execute(sql`
1302
+ update users u
1303
+ set reputation = coalesce(
1304
+ (select sum(r.points)::int from reputation r where r.user_id = u.id), 0)
1305
+ where u.id in (${sql.join(
1306
+ userIds.map((id) => sql`${id}`),
1307
+ sql`, `,
1308
+ )})
1309
+ `)
1310
+ }
1311
+
1312
+ async #recountWarningPoints(userIds: readonly number[]): Promise<void> {
1313
+ if (userIds.length === 0) return
1314
+ await this.db.execute(sql`
1315
+ update users u
1316
+ set warning_points = coalesce((
1317
+ select sum(w.points)::int from warnings w
1318
+ where w.user_id = u.id
1319
+ and w.revoked_at is null
1320
+ and (w.expires_at is null or w.expires_at > now())
1321
+ ), 0)
1322
+ where u.id in (${sql.join(
1323
+ userIds.map((id) => sql`${id}`),
1324
+ sql`, `,
1325
+ )})
1326
+ `)
1327
+ }
1328
+
1329
+ async #map(kind: LegacyKind, written: readonly Written[]): Promise<void> {
1330
+ if (written.length === 0) return
1331
+
1332
+ await this.db.execute(sql`
1333
+ insert into legacy_ids (kind, legacy_id, new_id)
1334
+ values ${sql.join(
1335
+ written.map((row) => sql`(${kind}, ${row.legacy_id}, ${row.new_id})`),
1336
+ sql`, `,
1337
+ )}
1338
+ on conflict (kind, legacy_id) do update set new_id = excluded.new_id
1339
+ `)
1340
+ }
1341
+
1342
+ async #rebuildPaths(): Promise<void> {
1343
+ await this.db.execute(sql`
1344
+ with recursive tree as (
1345
+ select id, parent_id, id::text as path, 0 as depth
1346
+ from forums where parent_id is null
1347
+ union all
1348
+ select f.id, f.parent_id, t.path || '.' || f.id::text, t.depth + 1
1349
+ from forums f join tree t on f.parent_id = t.id
1350
+ )
1351
+ update forums set path = tree.path, depth = tree.depth
1352
+ from tree
1353
+ where forums.id = tree.id
1354
+ and (forums.path is distinct from tree.path or forums.depth is distinct from tree.depth)
1355
+ `)
1356
+ }
1357
+
1358
+ async #markFirstPosts(threadIds: readonly number[]): Promise<void> {
1359
+ if (threadIds.length === 0) return
1360
+
1361
+ await this.db.execute(sql`
1362
+ with ranked as (
1363
+ select id, thread_id,
1364
+ row_number() over (partition by thread_id order by created_at asc, id asc) = 1 as first
1365
+ from posts
1366
+ where thread_id in (${sql.join(
1367
+ threadIds.map((id) => sql`${id}`),
1368
+ sql`, `,
1369
+ )})
1370
+ )
1371
+ update posts set is_first_post = ranked.first
1372
+ from ranked
1373
+ where posts.id = ranked.id
1374
+ and posts.is_first_post is distinct from ranked.first
1375
+ `)
1376
+ }
1377
+ }