@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.
- package/LICENSE.md +165 -0
- package/migrations/0000_initial_schema.sql +538 -0
- package/migrations/0001_seed_usergroups.sql +111 -0
- package/migrations/0002_group_promotions.sql +25 -0
- package/migrations/0003_counter_maintenance.sql +34 -0
- package/migrations/0004_post_render_cache.sql +7 -0
- package/migrations/0005_reports.sql +51 -0
- package/migrations/0006_warnings.sql +91 -0
- package/migrations/0007_notifications.sql +44 -0
- package/migrations/0008_subscriptions.sql +48 -0
- package/migrations/0009_member_preferences.sql +13 -0
- package/migrations/0010_profile_fields.sql +53 -0
- package/migrations/0011_private_messages.sql +63 -0
- package/migrations/0012_user_relations.sql +18 -0
- package/migrations/0013_reputation.sql +45 -0
- package/migrations/0014_signatures.sql +16 -0
- package/migrations/0015_admin_sessions.sql +24 -0
- package/migrations/0016_attachments.sql +51 -0
- package/migrations/0017_avatars.sql +33 -0
- package/migrations/0018_mass_mail.sql +15 -0
- package/migrations/0019_word_filters.sql +8 -0
- package/migrations/0020_searches.sql +15 -0
- package/migrations/0021_presence_stats.sql +18 -0
- package/migrations/0022_api_tokens_webhooks.sql +58 -0
- package/migrations/0023_install_state.sql +6 -0
- package/migrations/0024_component_versions.sql +13 -0
- package/migrations/0025_import.sql +25 -0
- package/migrations/0026_polls.sql +40 -0
- package/migrations/0027_drafts.sql +13 -0
- package/migrations/0028_board_vocabulary.sql +32 -0
- package/migrations/0029_announcements.sql +16 -0
- package/migrations/0030_antispam.sql +18 -0
- package/migrations/0031_markdown.sql +24 -0
- package/migrations/0032_theme_enablement.sql +1 -0
- package/migrations/0033_group_identity.sql +5 -0
- package/migrations/0034_search_document_version.sql +4 -0
- package/migrations/0035_categories_hold_no_threads.sql +1 -0
- package/migrations/0036_expiring_group_grants.sql +6 -0
- package/migrations/0037_automatic_timezone.sql +3 -0
- package/migrations/0038_primary_group_grants.sql +3 -0
- package/migrations/0039_moderator_log_forum_scope.sql +1 -0
- package/migrations/0040_moderator_restore_grants.sql +1 -0
- package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
- package/migrations/0042_drop_placebo_delete_others.sql +2 -0
- package/migrations/0043_federated_identity.sql +28 -0
- package/migrations/0044_second_factor.sql +34 -0
- package/migrations/0045_drop_dead_task_due_index.sql +1 -0
- package/migrations/0046_member_locale.sql +1 -0
- package/migrations/0047_navigation_items.sql +34 -0
- package/migrations/0048_web_push.sql +16 -0
- package/migrations/0049_plugin_health.sql +7 -0
- package/migrations/0050_drop_forum_password_grants.sql +1 -0
- package/migrations/0051_glossy_barracuda.sql +1 -0
- package/migrations/0052_many_professor_monster.sql +2 -0
- package/migrations/0053_strange_maximus.sql +14 -0
- package/migrations/0054_legacy_import_sources.sql +25 -0
- package/migrations/0055_poll_parity.sql +14 -0
- package/migrations/0056_marketplace_catalog.sql +11 -0
- package/migrations/meta/0000_snapshot.json +4412 -0
- package/migrations/meta/0006_snapshot.json +5411 -0
- package/migrations/meta/0007_snapshot.json +5634 -0
- package/migrations/meta/0008_snapshot.json +5779 -0
- package/migrations/meta/0009_snapshot.json +5816 -0
- package/migrations/meta/0010_snapshot.json +6117 -0
- package/migrations/meta/0011_snapshot.json +6406 -0
- package/migrations/meta/0012_snapshot.json +6517 -0
- package/migrations/meta/0013_snapshot.json +6722 -0
- package/migrations/meta/0014_snapshot.json +6777 -0
- package/migrations/meta/0015_snapshot.json +6914 -0
- package/migrations/meta/0016_snapshot.json +7192 -0
- package/migrations/meta/0017_snapshot.json +7248 -0
- package/migrations/meta/0018_snapshot.json +7355 -0
- package/migrations/meta/0019_snapshot.json +7419 -0
- package/migrations/meta/0020_snapshot.json +7562 -0
- package/migrations/meta/0021_snapshot.json +7653 -0
- package/migrations/meta/0031_snapshot.json +8162 -0
- package/migrations/meta/0032_snapshot.json +8169 -0
- package/migrations/meta/0033_snapshot.json +8193 -0
- package/migrations/meta/0034_snapshot.json +8221 -0
- package/migrations/meta/0036_snapshot.json +8262 -0
- package/migrations/meta/0037_snapshot.json +8262 -0
- package/migrations/meta/0038_snapshot.json +8293 -0
- package/migrations/meta/0039_snapshot.json +8308 -0
- package/migrations/meta/0041_snapshot.json +8287 -0
- package/migrations/meta/0042_snapshot.json +8274 -0
- package/migrations/meta/0043_snapshot.json +8521 -0
- package/migrations/meta/0044_snapshot.json +8814 -0
- package/migrations/meta/0045_snapshot.json +8797 -0
- package/migrations/meta/0046_snapshot.json +8804 -0
- package/migrations/meta/0047_snapshot.json +9018 -0
- package/migrations/meta/0048_snapshot.json +9145 -0
- package/migrations/meta/0049_snapshot.json +9190 -0
- package/migrations/meta/0050_snapshot.json +9126 -0
- package/migrations/meta/0051_snapshot.json +9132 -0
- package/migrations/meta/0052_snapshot.json +9148 -0
- package/migrations/meta/0053_snapshot.json +9268 -0
- package/migrations/meta/0054_snapshot.json +9378 -0
- package/migrations/meta/0056_snapshot.json +9447 -0
- package/migrations/meta/_journal.json +405 -0
- package/package.json +64 -0
- package/src/account-repos.ts +580 -0
- package/src/actor-builder.ts +130 -0
- package/src/admin-repo.ts +85 -0
- package/src/admin-session-repo.ts +205 -0
- package/src/admin-undo-repo.ts +128 -0
- package/src/announcement-repo.ts +176 -0
- package/src/antispam-repo.ts +145 -0
- package/src/api-repo.ts +276 -0
- package/src/attachment-admin-repo.ts +161 -0
- package/src/attachment-repo.ts +240 -0
- package/src/authorization-source.ts +138 -0
- package/src/avatar-repo.ts +185 -0
- package/src/ban-repos.ts +146 -0
- package/src/client.ts +85 -0
- package/src/content-admin-repo.ts +298 -0
- package/src/content-counters.ts +152 -0
- package/src/counter-recount.ts +263 -0
- package/src/cursor.ts +16 -0
- package/src/denormalised-username.ts +34 -0
- package/src/discovery-repo.ts +140 -0
- package/src/draft-repo.ts +59 -0
- package/src/feed-repo.ts +219 -0
- package/src/forum-admin-repo.ts +310 -0
- package/src/forum-repo.ts +206 -0
- package/src/group-admin-repo.ts +256 -0
- package/src/group-identity-repo.ts +84 -0
- package/src/identity-link-repo.ts +145 -0
- package/src/import-repo.ts +123 -0
- package/src/import-sink.ts +1377 -0
- package/src/index.ts +316 -0
- package/src/inline-moderation.ts +344 -0
- package/src/install-repo.ts +67 -0
- package/src/latest-repo.ts +115 -0
- package/src/maintenance-repo.ts +54 -0
- package/src/marketplace-repo.ts +111 -0
- package/src/member-profile-repo.ts +82 -0
- package/src/member-settings-repo.ts +160 -0
- package/src/message-repo.ts +384 -0
- package/src/migrate-run.ts +18 -0
- package/src/migrate.ts +129 -0
- package/src/modcp-repo.ts +253 -0
- package/src/moderation-queue.ts +279 -0
- package/src/navigation-repo.ts +288 -0
- package/src/notification-repo.ts +402 -0
- package/src/orphan-keys.ts +19 -0
- package/src/outbox-repo.ts +78 -0
- package/src/permission-version.ts +24 -0
- package/src/permissions-map.ts +52 -0
- package/src/pglite.fixture.ts +87 -0
- package/src/plugin-data.ts +88 -0
- package/src/plugin-grants.ts +369 -0
- package/src/plugin-health-repo.ts +80 -0
- package/src/plugin-purge-repo.ts +91 -0
- package/src/plugin-users.ts +39 -0
- package/src/poll-repo.ts +334 -0
- package/src/post-repo.ts +261 -0
- package/src/post-writes.ts +227 -0
- package/src/presence-repo.ts +220 -0
- package/src/profile-field-repo.ts +182 -0
- package/src/promotion-repo.ts +152 -0
- package/src/read-state-repo.ts +81 -0
- package/src/relation-repo.ts +113 -0
- package/src/render-backfill.ts +110 -0
- package/src/render-signature.ts +33 -0
- package/src/report-repo.ts +349 -0
- package/src/reputation-repo.ts +295 -0
- package/src/result-rows.ts +13 -0
- package/src/row-values.ts +7 -0
- package/src/schema/content.ts +451 -0
- package/src/schema/identity.ts +699 -0
- package/src/schema/index.ts +10 -0
- package/src/schema/messages.ts +76 -0
- package/src/schema/permission-columns.ts +34 -0
- package/src/schema/platform.ts +376 -0
- package/src/schema/structure.ts +164 -0
- package/src/search-repo.ts +359 -0
- package/src/search-store.ts +113 -0
- package/src/seed-groups.ts +11 -0
- package/src/settings-repo.ts +32 -0
- package/src/signature-repo.ts +102 -0
- package/src/sql-lists.ts +17 -0
- package/src/staff-groups.ts +50 -0
- package/src/stats-repo.ts +151 -0
- package/src/subscription-repo.ts +350 -0
- package/src/system-health-repo.ts +130 -0
- package/src/task-repo.ts +104 -0
- package/src/theme-admin-repo.ts +172 -0
- package/src/theme-repo.ts +43 -0
- package/src/thread-audience.ts +45 -0
- package/src/thread-counters.ts +107 -0
- package/src/thread-repo.ts +255 -0
- package/src/thread-surgery.ts +282 -0
- package/src/thread-tools.ts +311 -0
- package/src/thread-views.ts +41 -0
- package/src/thread-writes.ts +356 -0
- package/src/two-factor-repo.ts +235 -0
- package/src/upgrade-repo.ts +66 -0
- package/src/user-admin-repo.ts +367 -0
- package/src/user-bulk-repo.ts +397 -0
- package/src/user-merge-map.ts +111 -0
- package/src/user-merge-repo.ts +304 -0
- package/src/visibility-counters.ts +190 -0
- package/src/visibility.ts +17 -0
- package/src/vocabulary-repo.ts +54 -0
- 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
|
+
}
|