@meith/db 0.31.0 → 0.32.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 (41) hide show
  1. package/migrations/0060_webhook_payload_format.sql +1 -0
  2. package/migrations/0061_task_schedule.sql +1 -0
  3. package/migrations/0062_pretty_zombie.sql +12 -0
  4. package/migrations/0063_board_digest.sql +3 -0
  5. package/migrations/0064_redundant_lady_bullseye.sql +2 -0
  6. package/migrations/0065_serious_puff_adder.sql +1 -0
  7. package/migrations/meta/0061_snapshot.json +9515 -0
  8. package/migrations/meta/0062_snapshot.json +9617 -0
  9. package/migrations/meta/0063_snapshot.json +9653 -0
  10. package/migrations/meta/0064_snapshot.json +9667 -0
  11. package/migrations/meta/0065_snapshot.json +9674 -0
  12. package/migrations/meta/_journal.json +42 -0
  13. package/package.json +31 -30
  14. package/src/account-repos.ts +32 -15
  15. package/src/api-repo.ts +192 -21
  16. package/src/attachment-repo.ts +26 -0
  17. package/src/board-digest-repo.ts +45 -0
  18. package/src/content-counters.ts +14 -0
  19. package/src/discovery-repo.ts +22 -0
  20. package/src/draft-repo.ts +40 -1
  21. package/src/feed-token-repo.ts +103 -0
  22. package/src/forum-admin-repo.ts +43 -18
  23. package/src/index.ts +10 -0
  24. package/src/member-settings-repo.ts +25 -1
  25. package/src/moderation-queue.ts +61 -21
  26. package/src/plugin-data.ts +19 -0
  27. package/src/plugin-grants.ts +83 -0
  28. package/src/plugin-role.ts +79 -0
  29. package/src/post-repo.ts +26 -0
  30. package/src/post-writes.ts +32 -2
  31. package/src/presence-repo.ts +29 -1
  32. package/src/read-state-repo.ts +21 -1
  33. package/src/report-repo.ts +151 -7
  34. package/src/schema/content.ts +5 -0
  35. package/src/schema/identity.ts +33 -1
  36. package/src/schema/platform.ts +2 -0
  37. package/src/search-repo.ts +48 -17
  38. package/src/task-repo.ts +19 -7
  39. package/src/thread-writes.ts +7 -5
  40. package/src/upgrade-repo.ts +15 -7
  41. package/src/user-merge-map.ts +1 -0
@@ -17,7 +17,12 @@ import type {
17
17
 
18
18
  import type { Database } from './client'
19
19
  import { resultRows } from './result-rows'
20
- import { indexedSubjectSql, SEARCH_DOCUMENT_VERSION, searchVectorSql } from './search-repo'
20
+ import {
21
+ indexedSubjectSql,
22
+ readSearchConfig,
23
+ SEARCH_DOCUMENT_VERSION,
24
+ searchVectorSql,
25
+ } from './search-repo'
21
26
  import { logModeratorAction } from './thread-counters'
22
27
  import { applyVisibilityChangeCounters } from './visibility-counters'
23
28
  import { readBoardVocabulary } from './vocabulary-repo'
@@ -116,6 +121,7 @@ export class PostgresPostWriteRepository implements PostWriteRepository {
116
121
  { source: 'post', viewer: authorRef(record.editedByUserId), postId: record.postId },
117
122
  vocabularyOptions(vocabulary),
118
123
  )
124
+ const searchConfig = await readSearchConfig(this.db)
119
125
 
120
126
  await this.db.transaction(async (tx) => {
121
127
  await tx.execute(sql`
@@ -148,7 +154,7 @@ export class PostgresPostWriteRepository implements PostWriteRepository {
148
154
  * edited opening post lost the title an edit is supposed to
149
155
  * preserve. The alias is why the statement names "posts p".
150
156
  */
151
- search_vector = ${searchVectorSql(indexedSubjectSql(sql`p`), sql`${record.message}`)},
157
+ search_vector = ${searchVectorSql(searchConfig, indexedSubjectSql(sql`p`), sql`${record.message}`)},
152
158
  search_version = ${SEARCH_DOCUMENT_VERSION},
153
159
  render_version = ${body.version},
154
160
  vocab_version = ${vocabulary.revision},
@@ -187,6 +193,16 @@ export class PostgresPostWriteRepository implements PostWriteRepository {
187
193
  record.editedAt,
188
194
  )
189
195
  }
196
+
197
+ if (record.toVisibility === 'visible') {
198
+ await tx.execute(sql`
199
+ insert into outbox (topic, payload)
200
+ values (
201
+ 'post.edited',
202
+ ${JSON.stringify({ postId: record.postId, threadId: record.threadId })}::jsonb
203
+ )
204
+ `)
205
+ }
190
206
  })
191
207
  }
192
208
 
@@ -221,6 +237,20 @@ export class PostgresPostWriteRepository implements PostWriteRepository {
221
237
  )
222
238
  }
223
239
 
240
+ if (record.to === 'deleted' && record.from === 'visible') {
241
+ await tx.execute(sql`
242
+ insert into outbox (topic, payload)
243
+ values (
244
+ 'post.deleted',
245
+ ${JSON.stringify({
246
+ postId: record.postId,
247
+ threadId: record.threadId,
248
+ forumId: record.forumId,
249
+ })}::jsonb
250
+ )
251
+ `)
252
+ }
253
+
224
254
  return true
225
255
  })
226
256
  }
@@ -4,7 +4,7 @@ import type { ContentScope } from '@meith/core'
4
4
 
5
5
  import type { Database } from './client'
6
6
  import { resultRows } from './result-rows'
7
- import { toDate } from './row-values'
7
+ import { toDate, toNullableDate } from './row-values'
8
8
  import { inAudience } from './thread-audience'
9
9
  import { visibleIn } from './visibility'
10
10
 
@@ -42,6 +42,12 @@ export interface OnlineRecord {
42
42
  readonly at: Date | null
43
43
  }
44
44
 
45
+ export interface MemberPresence {
46
+ readonly userId: number
47
+ readonly lastActiveAt: Date | null
48
+ readonly invisible: boolean
49
+ }
50
+
45
51
  export class PostgresPresenceRepository {
46
52
  constructor(private readonly db: Database) {}
47
53
 
@@ -118,6 +124,28 @@ export class PostgresPresenceRepository {
118
124
  }
119
125
  }
120
126
 
127
+ async lastActiveFor(userIds: readonly number[]): Promise<readonly MemberPresence[]> {
128
+ if (userIds.length === 0) return []
129
+
130
+ const rows = resultRows(
131
+ await this.db.execute(sql`
132
+ select id, last_active_at, invisible
133
+ from users
134
+ where id in (${sql.join(
135
+ userIds.map((id) => sql`${id}`),
136
+ sql`, `,
137
+ )})
138
+ and state = 'active'
139
+ `),
140
+ ) as Array<Record<string, unknown>>
141
+
142
+ return rows.map((row) => ({
143
+ userId: Number(row.id),
144
+ lastActiveAt: toNullableDate(row.last_active_at),
145
+ invisible: row.invisible === true,
146
+ }))
147
+ }
148
+
121
149
  async touchGuest(input: {
122
150
  readonly tokenHash: string
123
151
  readonly location: {
@@ -1,7 +1,7 @@
1
1
  import { and, eq, gt, isNull, or, sql } from 'drizzle-orm'
2
2
 
3
3
  import { PUBLIC_CONTENT } from '@meith/core'
4
- import type { ReadState, ReadStateRepository } from '@meith/threads'
4
+ import type { ReadState, ReadStateRepository, ThreadReadMarker } from '@meith/threads'
5
5
 
6
6
  import type { Database } from './client'
7
7
  import { forumsRead, threads, threadsRead } from './schema'
@@ -55,6 +55,26 @@ export class PostgresReadStateRepository implements ReadStateRepository {
55
55
  }
56
56
  }
57
57
 
58
+ async markerFor(userId: number, threadId: number, forumId: number): Promise<ThreadReadMarker> {
59
+ const [threadRows, forumRows] = await Promise.all([
60
+ this.db
61
+ .select({ lastReadPostId: threadsRead.lastReadPostId })
62
+ .from(threadsRead)
63
+ .where(and(eq(threadsRead.userId, userId), eq(threadsRead.threadId, threadId)))
64
+ .limit(1),
65
+ this.db
66
+ .select({ readAt: forumsRead.readAt })
67
+ .from(forumsRead)
68
+ .where(and(eq(forumsRead.userId, userId), eq(forumsRead.forumId, forumId)))
69
+ .limit(1),
70
+ ])
71
+
72
+ return {
73
+ lastReadPostId: threadRows[0]?.lastReadPostId ?? null,
74
+ forumReadAt: forumRows[0]?.readAt ?? null,
75
+ }
76
+ }
77
+
58
78
  async markForumsRead(userId: number, forumIds: readonly number[], at: Date): Promise<void> {
59
79
  if (forumIds.length === 0) return
60
80
  await this.db
@@ -3,6 +3,7 @@ import { sql } from 'drizzle-orm'
3
3
  import { PUBLIC_CONTENT } from '@meith/core'
4
4
  import type {
5
5
  NewReport,
6
+ ReportCategory,
6
7
  ReportEvent,
7
8
  ReportPage,
8
9
  ReportRepository,
@@ -17,8 +18,9 @@ import { decodeCursor, encodeCursor } from './cursor'
17
18
  import { resultRows } from './result-rows'
18
19
  import { threads } from './schema'
19
20
  import { idList } from './sql-lists'
20
- import { logModeratorAction } from './thread-counters'
21
- import { visibleIn } from './visibility'
21
+ import { type CounterTx, logModeratorAction } from './thread-counters'
22
+ import { PENDING_APPROVAL, VISIBLE, visibleIn } from './visibility'
23
+ import { applyVisibilityChangeCounters } from './visibility-counters'
22
24
 
23
25
  interface RawReport {
24
26
  id: number
@@ -29,6 +31,7 @@ interface RawReport {
29
31
  target_label: string
30
32
  reporter_user_id: number | null
31
33
  reporter_username: string | null
34
+ category: ReportCategory
32
35
  reason: string
33
36
  status: ReportRow['status']
34
37
  assigned_to_user_id: number | null
@@ -46,6 +49,7 @@ function toReport(row: RawReport): ReportRow {
46
49
  targetLabel: row.target_label,
47
50
  reporterUserId: row.reporter_user_id === null ? null : Number(row.reporter_user_id),
48
51
  reporterUsername: row.reporter_username,
52
+ category: row.category,
49
53
  reason: row.reason,
50
54
  status: row.status,
51
55
  assignedToUserId: row.assigned_to_user_id === null ? null : Number(row.assigned_to_user_id),
@@ -57,7 +61,7 @@ function toReport(row: RawReport): ReportRow {
57
61
  const SELECT_REPORT = sql`
58
62
  select r.id, r.target_kind, r.target_id, r.forum_id, r.thread_id, r.target_label,
59
63
  r.reporter_user_id, reporter.username as reporter_username,
60
- r.reason, r.status, r.assigned_to_user_id,
64
+ r.category, r.reason, r.status, r.assigned_to_user_id,
61
65
  assignee.username as assigned_username, r.created_at
62
66
  from reports r
63
67
  left join users reporter on reporter.id = r.reporter_user_id
@@ -174,11 +178,11 @@ export class PostgresReportRepository implements ReportRepository {
174
178
  await tx.execute(sql`
175
179
  insert into reports
176
180
  (target_kind, target_id, forum_id, thread_id, target_label,
177
- reporter_user_id, reason, status, created_at, updated_at)
181
+ reporter_user_id, category, reason, status, created_at, updated_at)
178
182
  values
179
183
  (${report.target.kind}, ${report.target.id}, ${report.target.forumId},
180
184
  ${report.target.threadId}, ${report.target.label},
181
- ${report.reporterUserId}, ${report.reason}, 'open',
185
+ ${report.reporterUserId}, ${report.category}, ${report.reason}, 'open',
182
186
  ${report.at}, ${report.at})
183
187
  on conflict do nothing
184
188
  returning id
@@ -192,21 +196,159 @@ export class PostgresReportRepository implements ReportRepository {
192
196
  insert into report_events (report_id, actor_user_id, kind, created_at)
193
197
  values (${row.id}, ${report.reporterUserId}, 'opened', ${report.at})
194
198
  `)
199
+
200
+ await tx.execute(sql`
201
+ insert into outbox (topic, payload)
202
+ values (
203
+ 'report.created',
204
+ ${JSON.stringify({
205
+ reportId: Number(row.id),
206
+ targetKind: report.target.kind,
207
+ targetId: report.target.id,
208
+ reporterId: report.reporterUserId,
209
+ })}::jsonb
210
+ )
211
+ `)
212
+
213
+ await this.autoHold(tx, report)
214
+
195
215
  return Number(row.id)
196
216
  })
197
217
  }
198
218
 
219
+ private async autoHold(tx: CounterTx, report: NewReport): Promise<void> {
220
+ if (report.flagThreshold <= 0 || report.target.kind !== 'post') return
221
+
222
+ const counted = resultRows(
223
+ await tx.execute(sql`
224
+ with last_hold as (
225
+ select max(created_at) as at
226
+ from admin_log
227
+ where action = 'post.autohold'
228
+ and (detail->>'postId')::int = ${report.target.id}
229
+ )
230
+ select count(distinct r.reporter_user_id)::int as reporters
231
+ from reports r, last_hold
232
+ where r.status = 'open'
233
+ and r.target_kind = 'post'
234
+ and r.target_id = ${report.target.id}
235
+ and r.reporter_user_id is not null
236
+ and (last_hold.at is null or r.created_at > last_hold.at)
237
+ `),
238
+ ) as Array<{ reporters: number }>
239
+ if (Number(counted[0]?.reporters ?? 0) < report.flagThreshold) return
240
+
241
+ const found = resultRows(
242
+ await tx.execute(sql`
243
+ select thread_id, forum_id, is_first_post from posts where id = ${report.target.id}
244
+ `),
245
+ ) as Array<{ thread_id: number; forum_id: number; is_first_post: boolean }>
246
+ const post = found[0]
247
+ if (!post) return
248
+
249
+ const threadId = Number(post.thread_id)
250
+ const forumId = Number(post.forum_id)
251
+
252
+ const held = post.is_first_post
253
+ ? await this.holdThread(tx, threadId, forumId)
254
+ : await this.holdReply(tx, report.target.id, threadId, forumId)
255
+ if (held === null) return
256
+
257
+ await tx.execute(sql`
258
+ insert into admin_log (user_id, action, detail, created_at)
259
+ values (
260
+ null,
261
+ 'post.autohold',
262
+ ${JSON.stringify({
263
+ postId: report.target.id,
264
+ threadId,
265
+ forumId,
266
+ forumIds: [forumId],
267
+ heldPostIds: held,
268
+ })}::jsonb,
269
+ ${report.at}
270
+ )
271
+ `)
272
+ }
273
+
274
+ private async holdReply(
275
+ tx: CounterTx,
276
+ postId: number,
277
+ threadId: number,
278
+ forumId: number,
279
+ ): Promise<readonly number[] | null> {
280
+ const moved = resultRows(
281
+ await tx.execute(sql`
282
+ update posts set visibility = ${PENDING_APPROVAL}
283
+ where id = ${postId} and visibility = ${VISIBLE}
284
+ returning author_user_id
285
+ `),
286
+ ) as Array<{ author_user_id: number | null }>
287
+ const row = moved[0]
288
+ if (!row) return null
289
+
290
+ await applyVisibilityChangeCounters(tx, {
291
+ postId,
292
+ threadId,
293
+ forumId,
294
+ authorId: row.author_user_id === null ? null : Number(row.author_user_id),
295
+ isFirstPost: false,
296
+ delta: -1,
297
+ })
298
+ return [postId]
299
+ }
300
+
301
+ private async holdThread(
302
+ tx: CounterTx,
303
+ threadId: number,
304
+ forumId: number,
305
+ ): Promise<readonly number[] | null> {
306
+ const movedThread = resultRows(
307
+ await tx.execute(sql`
308
+ update threads set visibility = ${PENDING_APPROVAL}, updated_at = now()
309
+ where id = ${threadId} and visibility = ${VISIBLE}
310
+ returning id
311
+ `),
312
+ ) as Array<{ id: number }>
313
+ if (movedThread.length === 0) return null
314
+
315
+ const posts = resultRows(
316
+ await tx.execute(sql`
317
+ update posts set visibility = ${PENDING_APPROVAL}
318
+ where thread_id = ${threadId} and visibility = ${VISIBLE}
319
+ returning id, author_user_id, is_first_post
320
+ `),
321
+ ) as Array<{ id: number; author_user_id: number | null; is_first_post: boolean }>
322
+
323
+ for (const post of posts) {
324
+ await applyVisibilityChangeCounters(tx, {
325
+ postId: Number(post.id),
326
+ threadId,
327
+ forumId,
328
+ authorId: post.author_user_id === null ? null : Number(post.author_user_id),
329
+ isFirstPost: post.is_first_post === true,
330
+ delta: -1,
331
+ })
332
+ }
333
+ return posts.map((post) => Number(post.id))
334
+ }
335
+
199
336
  private scopePredicate(scope: ReportScope): ReturnType<typeof sql> {
200
337
  const byForum = sql`r.forum_id in ${idList(scope.forumIds)}`
201
338
  return scope.global ? sql`(${byForum} or r.forum_id is null)` : byForum
202
339
  }
203
340
 
341
+ private categoryPredicate(category: ReportCategory | undefined): ReturnType<typeof sql> {
342
+ return category === undefined ? sql`` : sql`and r.category = ${category}`
343
+ }
344
+
204
345
  async listOpen(
205
346
  scope: ReportScope,
206
347
  options: {
207
348
  readonly limit: number
208
349
  readonly after?: string
209
350
  readonly offset?: number
351
+ readonly category?: ReportCategory
210
352
  },
211
353
  ): Promise<ReportPage> {
212
354
  const cursor = options.after === undefined ? null : decodeCursor(options.after)
@@ -215,7 +357,8 @@ export class PostgresReportRepository implements ReportRepository {
215
357
  const rows = resultRows(
216
358
  await this.db.execute(sql`
217
359
  ${SELECT_REPORT}
218
- where r.status = 'open' and ${this.scopePredicate(scope)} ${after}
360
+ where r.status = 'open' and ${this.scopePredicate(scope)}
361
+ ${this.categoryPredicate(options.category)} ${after}
219
362
  order by r.created_at, r.id
220
363
  limit ${options.limit + 1} offset ${options.offset ?? 0}
221
364
  `),
@@ -228,11 +371,12 @@ export class PostgresReportRepository implements ReportRepository {
228
371
  : { rows: page }
229
372
  }
230
373
 
231
- async countOpen(scope: ReportScope): Promise<number> {
374
+ async countOpen(scope: ReportScope, category?: ReportCategory): Promise<number> {
232
375
  const rows = resultRows(
233
376
  await this.db.execute(sql`
234
377
  select count(*)::int as open from reports r
235
378
  where r.status = 'open' and ${this.scopePredicate(scope)}
379
+ ${this.categoryPredicate(category)}
236
380
  `),
237
381
  ) as Array<{ open: number }>
238
382
  return Number(rows[0]?.open ?? 0)
@@ -248,6 +248,9 @@ export type ReportTargetKind = (typeof REPORT_TARGET_KINDS)[number]
248
248
  export const REPORT_STATUSES = ['open', 'resolved', 'rejected'] as const
249
249
  export type ReportStatus = (typeof REPORT_STATUSES)[number]
250
250
 
251
+ export const REPORT_CATEGORIES = ['spam', 'off_topic', 'abuse', 'other'] as const
252
+ export type ReportCategory = (typeof REPORT_CATEGORIES)[number]
253
+
251
254
  export const reports = pgTable(
252
255
  'reports',
253
256
  {
@@ -256,6 +259,8 @@ export const reports = pgTable(
256
259
  targetKind: text('target_kind').notNull(),
257
260
  targetId: integer('target_id').notNull(),
258
261
 
262
+ category: text('category').notNull().default('other'),
263
+
259
264
  forumId: integer('forum_id').references(() => forums.id, {
260
265
  onDelete: 'set null',
261
266
  }),
@@ -112,6 +112,9 @@ export const users = pgTable(
112
112
  emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
113
113
  massMailOptInAt: timestamp('mass_mail_opt_in_at', { withTimezone: true }),
114
114
 
115
+ boardDigestCadence: text('board_digest_cadence').notNull().default('weekly'),
116
+ boardDigestSentAt: timestamp('board_digest_sent_at', { withTimezone: true }),
117
+
115
118
  suspendedPostingUntil: timestamp('suspended_posting_until', { withTimezone: true }),
116
119
  moderatedPostingUntil: timestamp('moderated_posting_until', { withTimezone: true }),
117
120
 
@@ -121,6 +124,9 @@ export const users = pgTable(
121
124
  threadsPerPage: smallint('threads_per_page'),
122
125
  invisible: boolean('invisible').notNull().default(false),
123
126
 
127
+ autoWatchOwnThreads: text('auto_watch_own_threads').notNull().default('none'),
128
+ autoWatchRepliedThreads: text('auto_watch_replied_threads').notNull().default('none'),
129
+
124
130
  location: text('location'),
125
131
  website: text('website'),
126
132
  bio: text('bio'),
@@ -246,6 +252,27 @@ export const rememberTokens = pgTable(
246
252
  ],
247
253
  )
248
254
 
255
+ export const feedTokens = pgTable(
256
+ 'feed_tokens',
257
+ {
258
+ id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
259
+
260
+ userId: integer('user_id')
261
+ .notNull()
262
+ .references(() => users.id, { onDelete: 'cascade' }),
263
+
264
+ lookup: text('lookup').notNull(),
265
+ secretHash: text('secret_hash').notNull(),
266
+
267
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
268
+ lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
269
+ },
270
+ (t) => [
271
+ uniqueIndex('feed_tokens_user_key').on(t.userId),
272
+ uniqueIndex('feed_tokens_lookup_key').on(t.lookup),
273
+ ],
274
+ )
275
+
249
276
  export const userIdentities = pgTable(
250
277
  'user_identities',
251
278
  {
@@ -570,7 +597,12 @@ export const notificationPreferences = pgTable(
570
597
  push: boolean('push'),
571
598
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
572
599
  },
573
- (t) => [primaryKey({ name: 'notification_preferences_pkey', columns: [t.userId, t.kind] })],
600
+ (t) => [
601
+ primaryKey({ name: 'notification_preferences_pkey', columns: [t.userId, t.kind] }),
602
+ index('notification_preferences_kind_email_idx')
603
+ .on(t.kind, t.userId)
604
+ .where(sql`${t.email} = true`),
605
+ ],
574
606
  )
575
607
 
576
608
  export const pushSubscriptions = pgTable(
@@ -122,6 +122,8 @@ export const tasks = pgTable('tasks', {
122
122
 
123
123
  intervalSeconds: integer('interval_seconds').notNull(),
124
124
 
125
+ schedule: text('schedule'),
126
+
125
127
  enabled: boolean('enabled').notNull().default(true),
126
128
 
127
129
  lastRunAt: timestamp('last_run_at', { withTimezone: true }),
@@ -10,13 +10,27 @@ import type {
10
10
  SearchScope,
11
11
  SearchSummary,
12
12
  } from '@meith/search'
13
+ import { DEFAULT_SEARCH_LANGUAGE, SEARCH_LANGUAGES } from '@meith/settings'
13
14
 
14
15
  import type { Database } from './client'
15
16
  import { resultRows } from './result-rows'
16
17
  import { inAudience } from './thread-audience'
17
18
  import { visibleIn } from './visibility'
18
19
 
19
- const SEARCH_CONFIG = 'english'
20
+ const SEARCH_LANGUAGE_SET: ReadonlySet<string> = new Set(SEARCH_LANGUAGES)
21
+
22
+ export const DEFAULT_SEARCH_CONFIG: string = DEFAULT_SEARCH_LANGUAGE
23
+
24
+ export function resolveSearchConfig(value: string | null | undefined): string {
25
+ return typeof value === 'string' && SEARCH_LANGUAGE_SET.has(value) ? value : DEFAULT_SEARCH_CONFIG
26
+ }
27
+
28
+ export async function readSearchConfig(db: Database): Promise<string> {
29
+ const rows = resultRows(
30
+ await db.execute(sql`select value from settings where key = 'search.language'`),
31
+ ) as Array<{ value: string }>
32
+ return resolveSearchConfig(rows[0]?.value)
33
+ }
20
34
 
21
35
  export const SEARCH_WINDOW = 20_000
22
36
 
@@ -51,10 +65,10 @@ export function renderExcerptHtml(headline: string): string {
51
65
  .join('</b>')
52
66
  }
53
67
 
54
- export function searchVectorSql(subject: SQL | string, message: SQL | string): SQL {
68
+ export function searchVectorSql(config: string, subject: SQL | string, message: SQL | string): SQL {
55
69
  return sql`
56
- setweight(to_tsvector(${SEARCH_CONFIG}, coalesce(${subject}, '')), 'A') ||
57
- setweight(to_tsvector(${SEARCH_CONFIG}, coalesce(${message}, '')), 'B')
70
+ setweight(to_tsvector(${config}, coalesce(${subject}, '')), 'A') ||
71
+ setweight(to_tsvector(${config}, coalesce(${message}, '')), 'B')
58
72
  `
59
73
  }
60
74
 
@@ -77,13 +91,20 @@ export interface ReindexResult {
77
91
  }
78
92
 
79
93
  export class PostgresSearchRepository {
80
- constructor(private readonly db: Database) {}
94
+ private readonly config: string
95
+
96
+ constructor(
97
+ private readonly db: Database,
98
+ config: string = DEFAULT_SEARCH_CONFIG,
99
+ ) {
100
+ this.config = resolveSearchConfig(config)
101
+ }
81
102
 
82
103
  async search(query: SearchQuery, scope: SearchScope): Promise<SearchResults> {
83
- const conditions = matchConditions(query, scope)
104
+ const conditions = matchConditions(this.config, query, scope)
84
105
  if (conditions === null) return { hits: [], nextCursor: null }
85
106
 
86
- const rank = rankSql(query.terms)
107
+ const rank = rankSql(this.config, query.terms)
87
108
  const finalOrder = orderSql(query.sort)
88
109
 
89
110
  const selection = sql`
@@ -104,8 +125,8 @@ export class PostgresSearchRepository {
104
125
  await this.db.execute(sql`
105
126
  select post_id, thread_id, forum_id, thread_title, thread_slug,
106
127
  author_user_id, author_username, created_at, rank,
107
- ts_headline(${SEARCH_CONFIG}, excerpt_source,
108
- websearch_to_tsquery(${SEARCH_CONFIG}, ${query.terms}),
128
+ ts_headline(${this.config}, excerpt_source,
129
+ websearch_to_tsquery(${this.config}, ${query.terms}),
109
130
  ${HEADLINE_OPTIONS}) as excerpt
110
131
  from ${candidates}
111
132
  order by ${finalOrder}
@@ -136,7 +157,7 @@ export class PostgresSearchRepository {
136
157
  }
137
158
 
138
159
  async summarize(query: SearchQuery, scope: SearchScope): Promise<SearchSummary> {
139
- const conditions = matchConditions(query, scope)
160
+ const conditions = matchConditions(this.config, query, scope)
140
161
  if (conditions === null) return NOTHING
141
162
 
142
163
  const rows = resultRows(
@@ -204,10 +225,11 @@ export class PostgresSearchRepository {
204
225
  }
205
226
 
206
227
  async reindexChunk(afterPostId: number, limit: number): Promise<ReindexResult> {
228
+ const config = await readSearchConfig(this.db)
207
229
  const rows = resultRows(
208
230
  await this.db.execute(sql`
209
231
  update posts p
210
- set search_vector = ${searchVectorSql(indexedSubjectSql(sql`p`), sql`p.message`)},
232
+ set search_vector = ${searchVectorSql(config, indexedSubjectSql(sql`p`), sql`p.message`)},
211
233
  search_version = ${SEARCH_DOCUMENT_VERSION}
212
234
  where p.id in (
213
235
  select id from posts
@@ -245,10 +267,19 @@ export class PostgresSearchRepository {
245
267
  async invalidateIndex(): Promise<void> {
246
268
  await this.db.execute(sql`update posts set search_vector = null, search_version = 0`)
247
269
  }
270
+
271
+ async markForReindex(): Promise<number> {
272
+ const rows = resultRows(
273
+ await this.db.execute(
274
+ sql`update posts set search_version = 0 where search_version <> 0 returning id`,
275
+ ),
276
+ ) as Array<{ id: number }>
277
+ return rows.length
278
+ }
248
279
  }
249
280
 
250
- function rankSql(terms: string): SQL {
251
- return sql`ts_rank_cd(p.search_vector, websearch_to_tsquery(${SEARCH_CONFIG}, ${terms}))`
281
+ function rankSql(config: string, terms: string): SQL {
282
+ return sql`ts_rank_cd(p.search_vector, websearch_to_tsquery(${config}, ${terms}))`
252
283
  }
253
284
 
254
285
  function excerptSourceSql(match: SearchQuery['match']): SQL {
@@ -263,7 +294,7 @@ function orderSql(sort: SearchQuery['sort']): SQL {
263
294
  : sql`post_id asc`
264
295
  }
265
296
 
266
- function matchConditions(query: SearchQuery, scope: SearchScope): SQL[] | null {
297
+ function matchConditions(config: string, query: SearchQuery, scope: SearchScope): SQL[] | null {
267
298
  if (query.terms.trim() === '') return null
268
299
 
269
300
  const allowed =
@@ -279,7 +310,7 @@ function matchConditions(query: SearchQuery, scope: SearchScope): SQL[] | null {
279
310
  ...scope,
280
311
  forumIds: allowed,
281
312
  }),
282
- sql`p.search_vector @@ websearch_to_tsquery(${SEARCH_CONFIG}, ${query.terms})`,
313
+ sql`p.search_vector @@ websearch_to_tsquery(${config}, ${query.terms})`,
283
314
  ]
284
315
 
285
316
  conditions.push(visibleIn(sql`p.visibility`, scope.content))
@@ -287,8 +318,8 @@ function matchConditions(query: SearchQuery, scope: SearchScope): SQL[] | null {
287
318
 
288
319
  if (query.match === 'titles') {
289
320
  conditions.push(
290
- sql`to_tsvector(${SEARCH_CONFIG}, coalesce(${indexedSubjectSql(sql`p`)}, ''))
291
- @@ websearch_to_tsquery(${SEARCH_CONFIG}, ${query.terms})`,
321
+ sql`to_tsvector(${config}, coalesce(${indexedSubjectSql(sql`p`)}, ''))
322
+ @@ websearch_to_tsquery(${config}, ${query.terms})`,
292
323
  )
293
324
  }
294
325