@meith/import 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.
@@ -0,0 +1,391 @@
1
+ import {
2
+ mapAttachment,
3
+ mapAvatar,
4
+ mapBan,
5
+ mapForum,
6
+ mapForumSubscription,
7
+ mapPoll,
8
+ mapPollVote,
9
+ mapPost,
10
+ mapPrivateMessage,
11
+ mapRelationList,
12
+ mapReputation,
13
+ mapThread,
14
+ mapThreadSubscription,
15
+ mapUser,
16
+ mapWarning,
17
+ } from './map'
18
+ import type {
19
+ MybbAttachment,
20
+ MybbAvatarRow,
21
+ MybbBan,
22
+ MybbForum,
23
+ MybbForumSubscription,
24
+ MybbPoll,
25
+ MybbPollVote,
26
+ MybbPost,
27
+ MybbPrivateMessage,
28
+ MybbRelationList,
29
+ MybbReputation,
30
+ MybbThread,
31
+ MybbThreadSubscription,
32
+ MybbUser,
33
+ MybbWarning,
34
+ } from './mybb-rows'
35
+ import { type ImportSource, type Page, pageByGroup } from './source'
36
+ import type {
37
+ ImportedAttachment,
38
+ ImportedAvatar,
39
+ ImportedBan,
40
+ ImportedForum,
41
+ ImportedPoll,
42
+ ImportedPollVote,
43
+ ImportedPost,
44
+ ImportedPrivateMessage,
45
+ ImportedReputation,
46
+ ImportedSubscription,
47
+ ImportedThread,
48
+ ImportedUser,
49
+ ImportedUserRelation,
50
+ ImportedWarning,
51
+ } from './types'
52
+
53
+ export interface MysqlSourceOptions {
54
+ readonly host: string
55
+ readonly port?: number | undefined
56
+ readonly user: string
57
+ readonly password: string
58
+ readonly database: string
59
+ readonly tablePrefix?: string | undefined
60
+ readonly charset?: string | undefined
61
+ readonly ssl?: boolean | undefined
62
+ }
63
+
64
+ const PREFIX_PATTERN = /^[A-Za-z0-9_]{0,32}$/
65
+
66
+ export function assertSafePrefix(prefix: string): void {
67
+ if (!PREFIX_PATTERN.test(prefix)) {
68
+ throw new Error(
69
+ `Unsafe legacy table prefix ${JSON.stringify(prefix)}. ` +
70
+ 'Letters, digits and underscores only — a prefix becomes part of a table name, ' +
71
+ 'and a table name cannot be a bound parameter.',
72
+ )
73
+ }
74
+ }
75
+
76
+ export interface Queryable {
77
+ query(sql: string, values: readonly unknown[]): Promise<[unknown, unknown]>
78
+ end(): Promise<void>
79
+ }
80
+
81
+ export async function connectMysql(options: MysqlSourceOptions): Promise<Queryable> {
82
+ const mysql = await import('mysql2/promise')
83
+
84
+ const connection = await mysql.createConnection({
85
+ host: options.host,
86
+ port: options.port ?? 3306,
87
+ user: options.user,
88
+ password: options.password,
89
+ database: options.database,
90
+ charset: options.charset ?? 'utf8mb4',
91
+ ...(options.ssl === true ? { ssl: {} } : {}),
92
+ supportBigNumbers: true,
93
+ bigNumberStrings: false,
94
+ dateStrings: false,
95
+ })
96
+
97
+ return connection as unknown as Queryable
98
+ }
99
+
100
+ export async function selectRows<T>(
101
+ connection: Queryable,
102
+ sql: string,
103
+ values: readonly unknown[],
104
+ ): Promise<readonly T[]> {
105
+ const [result] = await connection.query(sql, values)
106
+ return (Array.isArray(result) ? result : []) as T[]
107
+ }
108
+
109
+ export async function keysetPage<T>(
110
+ connection: Queryable,
111
+ select: string,
112
+ keyColumn: string,
113
+ idOf: (row: T) => number,
114
+ afterId: number,
115
+ limit: number,
116
+ ): Promise<Page<T>> {
117
+ const rows = await selectRows<T>(
118
+ connection,
119
+ `${select} where ${keyColumn} > ? order by ${keyColumn} asc limit ?`,
120
+ [afterId, limit],
121
+ )
122
+
123
+ const last = rows.at(-1)
124
+ return { rows, nextCursor: rows.length < limit || last === undefined ? null : idOf(last) }
125
+ }
126
+
127
+ export function mapPage<Raw, Mapped>(
128
+ page: Page<Raw>,
129
+ map: (row: Raw) => Mapped | null,
130
+ ): Page<Mapped> {
131
+ return {
132
+ rows: page.rows.map(map).filter((row): row is NonNullable<Mapped> => row !== null),
133
+ nextCursor: page.nextCursor,
134
+ }
135
+ }
136
+
137
+ export class MysqlMybbSource implements ImportSource {
138
+ readonly name = 'mybb'
139
+
140
+ private constructor(
141
+ private readonly connection: Queryable,
142
+ private readonly prefix: string,
143
+ ) {}
144
+
145
+ static async connect(options: MysqlSourceOptions): Promise<MysqlMybbSource> {
146
+ const prefix = options.tablePrefix ?? 'mybb_'
147
+ assertSafePrefix(prefix)
148
+ return new MysqlMybbSource(await connectMysql(options), prefix)
149
+ }
150
+
151
+ async close(): Promise<void> {
152
+ await this.connection.end()
153
+ }
154
+
155
+ async users(afterId: number, limit: number): Promise<Page<ImportedUser>> {
156
+ return mapPage(
157
+ await this.#page<MybbUser>(
158
+ `select uid, username, email, password, salt, usergroup,
159
+ regdate, lastvisit, postnum
160
+ from \`${this.prefix}users\``,
161
+ 'uid',
162
+ (row) => row.uid,
163
+ afterId,
164
+ limit,
165
+ ),
166
+ mapUser,
167
+ )
168
+ }
169
+
170
+ async forums(afterId: number, limit: number): Promise<Page<ImportedForum>> {
171
+ return mapPage(
172
+ await this.#page<MybbForum>(
173
+ `select fid, name, description, type, pid, disporder, linkto, threads, posts
174
+ from \`${this.prefix}forums\``,
175
+ 'fid',
176
+ (row) => row.fid,
177
+ afterId,
178
+ limit,
179
+ ),
180
+ mapForum,
181
+ )
182
+ }
183
+
184
+ async threads(afterId: number, limit: number): Promise<Page<ImportedThread>> {
185
+ return mapPage(
186
+ await this.#page<MybbThread>(
187
+ `select tid, fid, subject, uid, username, dateline, lastpost,
188
+ replies, views, sticky, closed, visible
189
+ from \`${this.prefix}threads\``,
190
+ 'tid',
191
+ (row) => row.tid,
192
+ afterId,
193
+ limit,
194
+ ),
195
+ mapThread,
196
+ )
197
+ }
198
+
199
+ async posts(afterId: number, limit: number): Promise<Page<ImportedPost>> {
200
+ return mapPage(
201
+ await this.#page<MybbPost>(
202
+ `select pid, tid, fid, uid, username, subject, message,
203
+ dateline, edituid, edittime, visible
204
+ from \`${this.prefix}posts\``,
205
+ 'pid',
206
+ (row) => row.pid,
207
+ afterId,
208
+ limit,
209
+ ),
210
+ mapPost,
211
+ )
212
+ }
213
+
214
+ async avatars(afterId: number, limit: number): Promise<Page<ImportedAvatar>> {
215
+ const rows = await selectRows<MybbAvatarRow>(
216
+ this.connection,
217
+ `select uid, avatar, avatartype, avatardimensions
218
+ from \`${this.prefix}users\`
219
+ where uid > ? and avatar <> ''
220
+ order by uid asc limit ?`,
221
+ [afterId, limit],
222
+ )
223
+ const last = rows.at(-1)
224
+ return mapPage(
225
+ { rows, nextCursor: rows.length < limit || last === undefined ? null : last.uid },
226
+ mapAvatar,
227
+ )
228
+ }
229
+
230
+ async attachments(afterId: number, limit: number): Promise<Page<ImportedAttachment>> {
231
+ return mapPage(
232
+ await this.#page<MybbAttachment>(
233
+ `select aid, pid, uid, filename, filetype, filesize, attachname,
234
+ thumbnail, downloads, dateuploaded
235
+ from \`${this.prefix}attachments\``,
236
+ 'aid',
237
+ (row) => row.aid,
238
+ afterId,
239
+ limit,
240
+ ),
241
+ mapAttachment,
242
+ )
243
+ }
244
+
245
+ async polls(afterId: number, limit: number): Promise<Page<ImportedPoll>> {
246
+ return mapPage(
247
+ await this.#page<MybbPoll>(
248
+ `select pid, tid, question, dateline, options, votes, timeout, multiple, public
249
+ from \`${this.prefix}polls\``,
250
+ 'pid',
251
+ (row) => row.pid,
252
+ afterId,
253
+ limit,
254
+ ),
255
+ mapPoll,
256
+ )
257
+ }
258
+
259
+ async pollVotes(afterId: number, limit: number): Promise<Page<ImportedPollVote>> {
260
+ return mapPage(
261
+ await this.#page<MybbPollVote>(
262
+ `select vid, pid, uid, voteoption, dateline
263
+ from \`${this.prefix}pollvotes\``,
264
+ 'vid',
265
+ (row) => row.vid,
266
+ afterId,
267
+ limit,
268
+ ),
269
+ mapPollVote,
270
+ )
271
+ }
272
+
273
+ async privateMessages(afterId: number, limit: number): Promise<Page<ImportedPrivateMessage>> {
274
+ return mapPage(
275
+ await this.#page<MybbPrivateMessage>(
276
+ `select pmid, uid, fromid, subject, message, dateline, folder, status, readtime
277
+ from \`${this.prefix}privatemessages\``,
278
+ 'pmid',
279
+ (row) => row.pmid,
280
+ afterId,
281
+ limit,
282
+ ),
283
+ mapPrivateMessage,
284
+ )
285
+ }
286
+
287
+ async threadSubscriptions(afterId: number, limit: number): Promise<Page<ImportedSubscription>> {
288
+ return mapPage(
289
+ await this.#page<MybbThreadSubscription>(
290
+ `select sid, uid, tid, notification
291
+ from \`${this.prefix}threadsubscriptions\``,
292
+ 'sid',
293
+ (row) => row.sid,
294
+ afterId,
295
+ limit,
296
+ ),
297
+ mapThreadSubscription,
298
+ )
299
+ }
300
+
301
+ async forumSubscriptions(afterId: number, limit: number): Promise<Page<ImportedSubscription>> {
302
+ const table = `\`${this.prefix}forumsubscriptions\``
303
+ const page = await pageByGroup<MybbForumSubscription>({
304
+ afterGroup: afterId,
305
+ limit,
306
+ groupOf: (row) => row.uid,
307
+ fetch: (afterGroup, take) =>
308
+ selectRows(
309
+ this.connection,
310
+ `select fid, uid from ${table} where uid > ? order by uid asc, fid asc limit ?`,
311
+ [afterGroup, take],
312
+ ),
313
+ fetchGroup: (group) =>
314
+ selectRows(
315
+ this.connection,
316
+ `select fid, uid from ${table} where uid = ? order by fid asc`,
317
+ [group],
318
+ ),
319
+ })
320
+ return mapPage(page, mapForumSubscription)
321
+ }
322
+
323
+ async reputation(afterId: number, limit: number): Promise<Page<ImportedReputation>> {
324
+ return mapPage(
325
+ await this.#page<MybbReputation>(
326
+ `select rid, uid, adduid, pid, reputation, dateline, comments
327
+ from \`${this.prefix}reputation\``,
328
+ 'rid',
329
+ (row) => row.rid,
330
+ afterId,
331
+ limit,
332
+ ),
333
+ mapReputation,
334
+ )
335
+ }
336
+
337
+ async warnings(afterId: number, limit: number): Promise<Page<ImportedWarning>> {
338
+ return mapPage(
339
+ await this.#page<MybbWarning>(
340
+ `select wid, uid, pid, title, points, dateline, issuedby,
341
+ expires, daterevoked, revokereason, notes
342
+ from \`${this.prefix}warnings\``,
343
+ 'wid',
344
+ (row) => row.wid,
345
+ afterId,
346
+ limit,
347
+ ),
348
+ mapWarning,
349
+ )
350
+ }
351
+
352
+ async bans(afterId: number, limit: number): Promise<Page<ImportedBan>> {
353
+ return mapPage(
354
+ await this.#page<MybbBan>(
355
+ `select uid, admin, dateline, lifted, reason
356
+ from \`${this.prefix}banned\``,
357
+ 'uid',
358
+ (row) => row.uid,
359
+ afterId,
360
+ limit,
361
+ ),
362
+ mapBan,
363
+ )
364
+ }
365
+
366
+ async userRelations(afterId: number, limit: number): Promise<Page<ImportedUserRelation>> {
367
+ const rows = await selectRows<MybbRelationList>(
368
+ this.connection,
369
+ `select uid, buddylist, ignorelist
370
+ from \`${this.prefix}users\`
371
+ where uid > ? and (buddylist <> '' or ignorelist <> '')
372
+ order by uid asc limit ?`,
373
+ [afterId, limit],
374
+ )
375
+ const last = rows.at(-1)
376
+ return {
377
+ rows: rows.flatMap(mapRelationList),
378
+ nextCursor: rows.length < limit || last === undefined ? null : last.uid,
379
+ }
380
+ }
381
+
382
+ #page<T>(
383
+ select: string,
384
+ keyColumn: string,
385
+ idOf: (row: T) => number,
386
+ afterId: number,
387
+ limit: number,
388
+ ): Promise<Page<T>> {
389
+ return keysetPage(this.connection, select, keyColumn, idOf, afterId, limit)
390
+ }
391
+ }
@@ -0,0 +1,191 @@
1
+ import {
2
+ mapPhpbbAttachment,
3
+ mapPhpbbAvatar,
4
+ mapPhpbbBan,
5
+ mapPhpbbForum,
6
+ mapPhpbbPoll,
7
+ mapPhpbbPollVote,
8
+ mapPhpbbPost,
9
+ mapPhpbbPrivateMessage,
10
+ mapPhpbbTopic,
11
+ mapPhpbbUser,
12
+ mapPhpbbWarning,
13
+ mapPhpbbWatch,
14
+ mapPhpbbZebra,
15
+ noPhpbbReputation,
16
+ } from './phpbb-map'
17
+ import type { PhpbbTables, PhpbbWatch } from './phpbb-rows'
18
+ import { groupRows, type ImportSource, type Page, pageByGroup, pageById } from './source'
19
+ import type {
20
+ ImportedAttachment,
21
+ ImportedAvatar,
22
+ ImportedBan,
23
+ ImportedForum,
24
+ ImportedPoll,
25
+ ImportedPollVote,
26
+ ImportedPost,
27
+ ImportedPrivateMessage,
28
+ ImportedReputation,
29
+ ImportedSubscription,
30
+ ImportedThread,
31
+ ImportedUser,
32
+ ImportedUserRelation,
33
+ ImportedWarning,
34
+ } from './types'
35
+
36
+ function mapPage<Raw, Mapped>(page: Page<Raw>, map: (row: Raw) => Mapped | null): Page<Mapped> {
37
+ return {
38
+ rows: page.rows.map(map).filter((row): row is NonNullable<Mapped> => row !== null),
39
+ nextCursor: page.nextCursor,
40
+ }
41
+ }
42
+
43
+ export class FixturePhpbbSource implements ImportSource {
44
+ readonly name = 'phpbb'
45
+
46
+ constructor(private readonly data: PhpbbTables) {}
47
+
48
+ users(afterId: number, limit: number): Promise<Page<ImportedUser>> {
49
+ return Promise.resolve(
50
+ mapPage(
51
+ pageById(this.data.users ?? [], (row) => row.user_id, afterId, limit),
52
+ mapPhpbbUser,
53
+ ),
54
+ )
55
+ }
56
+
57
+ forums(afterId: number, limit: number): Promise<Page<ImportedForum>> {
58
+ return Promise.resolve(
59
+ mapPage(
60
+ pageById(this.data.forums ?? [], (row) => row.forum_id, afterId, limit),
61
+ mapPhpbbForum,
62
+ ),
63
+ )
64
+ }
65
+
66
+ threads(afterId: number, limit: number): Promise<Page<ImportedThread>> {
67
+ return Promise.resolve(
68
+ mapPage(
69
+ pageById(this.data.topics ?? [], (row) => row.topic_id, afterId, limit),
70
+ mapPhpbbTopic,
71
+ ),
72
+ )
73
+ }
74
+
75
+ posts(afterId: number, limit: number): Promise<Page<ImportedPost>> {
76
+ return Promise.resolve(
77
+ mapPage(
78
+ pageById(this.data.posts ?? [], (row) => row.post_id, afterId, limit),
79
+ mapPhpbbPost,
80
+ ),
81
+ )
82
+ }
83
+
84
+ avatars(afterId: number, limit: number): Promise<Page<ImportedAvatar>> {
85
+ return Promise.resolve(
86
+ mapPage(
87
+ pageById(this.data.avatars ?? [], (row) => row.user_id, afterId, limit),
88
+ mapPhpbbAvatar,
89
+ ),
90
+ )
91
+ }
92
+
93
+ attachments(afterId: number, limit: number): Promise<Page<ImportedAttachment>> {
94
+ return Promise.resolve(
95
+ mapPage(
96
+ pageById(this.data.attachments ?? [], (row) => row.attach_id, afterId, limit),
97
+ mapPhpbbAttachment,
98
+ ),
99
+ )
100
+ }
101
+
102
+ polls(afterId: number, limit: number): Promise<Page<ImportedPoll>> {
103
+ const page = pageById(this.data.pollTopics ?? [], (row) => row.topic_id, afterId, limit)
104
+ return Promise.resolve({
105
+ rows: page.rows
106
+ .map((topic) => mapPhpbbPoll(topic, this.data.pollOptions ?? []))
107
+ .filter((poll): poll is NonNullable<typeof poll> => poll !== null),
108
+ nextCursor: page.nextCursor,
109
+ })
110
+ }
111
+
112
+ async pollVotes(afterId: number, limit: number): Promise<Page<ImportedPollVote>> {
113
+ const { fetch, fetchGroup } = groupRows(this.data.pollVotes ?? [], (row) => row.topic_id)
114
+ const page = await pageByGroup({
115
+ afterGroup: afterId,
116
+ limit,
117
+ groupOf: (row) => row.topic_id,
118
+ fetch,
119
+ fetchGroup,
120
+ })
121
+ return mapPage(page, mapPhpbbPollVote)
122
+ }
123
+
124
+ privateMessages(afterId: number, limit: number): Promise<Page<ImportedPrivateMessage>> {
125
+ const page = pageById(this.data.privateMessages ?? [], (row) => row.msg_id, afterId, limit)
126
+ return Promise.resolve({
127
+ rows: page.rows
128
+ .map((message) => mapPhpbbPrivateMessage(message, this.data.privateMessageCopies ?? []))
129
+ .filter((message): message is NonNullable<typeof message> => message !== null),
130
+ nextCursor: page.nextCursor,
131
+ })
132
+ }
133
+
134
+ threadSubscriptions(afterId: number, limit: number): Promise<Page<ImportedSubscription>> {
135
+ return this.#watch(this.data.topicWatch ?? [], afterId, limit)
136
+ }
137
+
138
+ forumSubscriptions(afterId: number, limit: number): Promise<Page<ImportedSubscription>> {
139
+ return this.#watch(this.data.forumWatch ?? [], afterId, limit)
140
+ }
141
+
142
+ reputation(): Promise<Page<ImportedReputation>> {
143
+ return noPhpbbReputation()
144
+ }
145
+
146
+ warnings(afterId: number, limit: number): Promise<Page<ImportedWarning>> {
147
+ return Promise.resolve(
148
+ mapPage(
149
+ pageById(this.data.warnings ?? [], (row) => row.warning_id, afterId, limit),
150
+ mapPhpbbWarning,
151
+ ),
152
+ )
153
+ }
154
+
155
+ bans(afterId: number, limit: number): Promise<Page<ImportedBan>> {
156
+ return Promise.resolve(
157
+ mapPage(
158
+ pageById(this.data.bans ?? [], (row) => row.ban_id, afterId, limit),
159
+ mapPhpbbBan,
160
+ ),
161
+ )
162
+ }
163
+
164
+ async userRelations(afterId: number, limit: number): Promise<Page<ImportedUserRelation>> {
165
+ const { fetch, fetchGroup } = groupRows(this.data.zebra ?? [], (row) => row.user_id)
166
+ const page = await pageByGroup({
167
+ afterGroup: afterId,
168
+ limit,
169
+ groupOf: (row) => row.user_id,
170
+ fetch,
171
+ fetchGroup,
172
+ })
173
+ return mapPage(page, mapPhpbbZebra)
174
+ }
175
+
176
+ async #watch(
177
+ rows: readonly PhpbbWatch[],
178
+ afterId: number,
179
+ limit: number,
180
+ ): Promise<Page<ImportedSubscription>> {
181
+ const { fetch, fetchGroup } = groupRows(rows, (row) => row.user_id)
182
+ const page = await pageByGroup({
183
+ afterGroup: afterId,
184
+ limit,
185
+ groupOf: (row) => row.user_id,
186
+ fetch,
187
+ fetchGroup,
188
+ })
189
+ return mapPage(page, mapPhpbbWatch)
190
+ }
191
+ }