@meith/db 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/LICENSE.md +165 -0
  2. package/migrations/0000_initial_schema.sql +538 -0
  3. package/migrations/0001_seed_usergroups.sql +111 -0
  4. package/migrations/0002_group_promotions.sql +25 -0
  5. package/migrations/0003_counter_maintenance.sql +34 -0
  6. package/migrations/0004_post_render_cache.sql +7 -0
  7. package/migrations/0005_reports.sql +51 -0
  8. package/migrations/0006_warnings.sql +91 -0
  9. package/migrations/0007_notifications.sql +44 -0
  10. package/migrations/0008_subscriptions.sql +48 -0
  11. package/migrations/0009_member_preferences.sql +13 -0
  12. package/migrations/0010_profile_fields.sql +53 -0
  13. package/migrations/0011_private_messages.sql +63 -0
  14. package/migrations/0012_user_relations.sql +18 -0
  15. package/migrations/0013_reputation.sql +45 -0
  16. package/migrations/0014_signatures.sql +16 -0
  17. package/migrations/0015_admin_sessions.sql +24 -0
  18. package/migrations/0016_attachments.sql +51 -0
  19. package/migrations/0017_avatars.sql +33 -0
  20. package/migrations/0018_mass_mail.sql +15 -0
  21. package/migrations/0019_word_filters.sql +8 -0
  22. package/migrations/0020_searches.sql +15 -0
  23. package/migrations/0021_presence_stats.sql +18 -0
  24. package/migrations/0022_api_tokens_webhooks.sql +58 -0
  25. package/migrations/0023_install_state.sql +6 -0
  26. package/migrations/0024_component_versions.sql +13 -0
  27. package/migrations/0025_import.sql +25 -0
  28. package/migrations/0026_polls.sql +40 -0
  29. package/migrations/0027_drafts.sql +13 -0
  30. package/migrations/0028_board_vocabulary.sql +32 -0
  31. package/migrations/0029_announcements.sql +16 -0
  32. package/migrations/0030_antispam.sql +18 -0
  33. package/migrations/0031_markdown.sql +24 -0
  34. package/migrations/0032_theme_enablement.sql +1 -0
  35. package/migrations/0033_group_identity.sql +5 -0
  36. package/migrations/0034_search_document_version.sql +4 -0
  37. package/migrations/0035_categories_hold_no_threads.sql +1 -0
  38. package/migrations/0036_expiring_group_grants.sql +6 -0
  39. package/migrations/0037_automatic_timezone.sql +3 -0
  40. package/migrations/0038_primary_group_grants.sql +3 -0
  41. package/migrations/0039_moderator_log_forum_scope.sql +1 -0
  42. package/migrations/0040_moderator_restore_grants.sql +1 -0
  43. package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
  44. package/migrations/0042_drop_placebo_delete_others.sql +2 -0
  45. package/migrations/0043_federated_identity.sql +28 -0
  46. package/migrations/0044_second_factor.sql +34 -0
  47. package/migrations/0045_drop_dead_task_due_index.sql +1 -0
  48. package/migrations/0046_member_locale.sql +1 -0
  49. package/migrations/0047_navigation_items.sql +34 -0
  50. package/migrations/0048_web_push.sql +16 -0
  51. package/migrations/0049_plugin_health.sql +7 -0
  52. package/migrations/0050_drop_forum_password_grants.sql +1 -0
  53. package/migrations/0051_glossy_barracuda.sql +1 -0
  54. package/migrations/0052_many_professor_monster.sql +2 -0
  55. package/migrations/0053_strange_maximus.sql +14 -0
  56. package/migrations/0054_legacy_import_sources.sql +25 -0
  57. package/migrations/0055_poll_parity.sql +14 -0
  58. package/migrations/0056_marketplace_catalog.sql +11 -0
  59. package/migrations/meta/0000_snapshot.json +4412 -0
  60. package/migrations/meta/0006_snapshot.json +5411 -0
  61. package/migrations/meta/0007_snapshot.json +5634 -0
  62. package/migrations/meta/0008_snapshot.json +5779 -0
  63. package/migrations/meta/0009_snapshot.json +5816 -0
  64. package/migrations/meta/0010_snapshot.json +6117 -0
  65. package/migrations/meta/0011_snapshot.json +6406 -0
  66. package/migrations/meta/0012_snapshot.json +6517 -0
  67. package/migrations/meta/0013_snapshot.json +6722 -0
  68. package/migrations/meta/0014_snapshot.json +6777 -0
  69. package/migrations/meta/0015_snapshot.json +6914 -0
  70. package/migrations/meta/0016_snapshot.json +7192 -0
  71. package/migrations/meta/0017_snapshot.json +7248 -0
  72. package/migrations/meta/0018_snapshot.json +7355 -0
  73. package/migrations/meta/0019_snapshot.json +7419 -0
  74. package/migrations/meta/0020_snapshot.json +7562 -0
  75. package/migrations/meta/0021_snapshot.json +7653 -0
  76. package/migrations/meta/0031_snapshot.json +8162 -0
  77. package/migrations/meta/0032_snapshot.json +8169 -0
  78. package/migrations/meta/0033_snapshot.json +8193 -0
  79. package/migrations/meta/0034_snapshot.json +8221 -0
  80. package/migrations/meta/0036_snapshot.json +8262 -0
  81. package/migrations/meta/0037_snapshot.json +8262 -0
  82. package/migrations/meta/0038_snapshot.json +8293 -0
  83. package/migrations/meta/0039_snapshot.json +8308 -0
  84. package/migrations/meta/0041_snapshot.json +8287 -0
  85. package/migrations/meta/0042_snapshot.json +8274 -0
  86. package/migrations/meta/0043_snapshot.json +8521 -0
  87. package/migrations/meta/0044_snapshot.json +8814 -0
  88. package/migrations/meta/0045_snapshot.json +8797 -0
  89. package/migrations/meta/0046_snapshot.json +8804 -0
  90. package/migrations/meta/0047_snapshot.json +9018 -0
  91. package/migrations/meta/0048_snapshot.json +9145 -0
  92. package/migrations/meta/0049_snapshot.json +9190 -0
  93. package/migrations/meta/0050_snapshot.json +9126 -0
  94. package/migrations/meta/0051_snapshot.json +9132 -0
  95. package/migrations/meta/0052_snapshot.json +9148 -0
  96. package/migrations/meta/0053_snapshot.json +9268 -0
  97. package/migrations/meta/0054_snapshot.json +9378 -0
  98. package/migrations/meta/0056_snapshot.json +9447 -0
  99. package/migrations/meta/_journal.json +405 -0
  100. package/package.json +64 -0
  101. package/src/account-repos.ts +580 -0
  102. package/src/actor-builder.ts +130 -0
  103. package/src/admin-repo.ts +85 -0
  104. package/src/admin-session-repo.ts +205 -0
  105. package/src/admin-undo-repo.ts +128 -0
  106. package/src/announcement-repo.ts +176 -0
  107. package/src/antispam-repo.ts +145 -0
  108. package/src/api-repo.ts +276 -0
  109. package/src/attachment-admin-repo.ts +161 -0
  110. package/src/attachment-repo.ts +240 -0
  111. package/src/authorization-source.ts +138 -0
  112. package/src/avatar-repo.ts +185 -0
  113. package/src/ban-repos.ts +146 -0
  114. package/src/client.ts +85 -0
  115. package/src/content-admin-repo.ts +298 -0
  116. package/src/content-counters.ts +152 -0
  117. package/src/counter-recount.ts +263 -0
  118. package/src/cursor.ts +16 -0
  119. package/src/denormalised-username.ts +34 -0
  120. package/src/discovery-repo.ts +140 -0
  121. package/src/draft-repo.ts +59 -0
  122. package/src/feed-repo.ts +219 -0
  123. package/src/forum-admin-repo.ts +310 -0
  124. package/src/forum-repo.ts +206 -0
  125. package/src/group-admin-repo.ts +256 -0
  126. package/src/group-identity-repo.ts +84 -0
  127. package/src/identity-link-repo.ts +145 -0
  128. package/src/import-repo.ts +123 -0
  129. package/src/import-sink.ts +1377 -0
  130. package/src/index.ts +316 -0
  131. package/src/inline-moderation.ts +344 -0
  132. package/src/install-repo.ts +67 -0
  133. package/src/latest-repo.ts +115 -0
  134. package/src/maintenance-repo.ts +54 -0
  135. package/src/marketplace-repo.ts +111 -0
  136. package/src/member-profile-repo.ts +82 -0
  137. package/src/member-settings-repo.ts +160 -0
  138. package/src/message-repo.ts +384 -0
  139. package/src/migrate-run.ts +18 -0
  140. package/src/migrate.ts +129 -0
  141. package/src/modcp-repo.ts +253 -0
  142. package/src/moderation-queue.ts +279 -0
  143. package/src/navigation-repo.ts +288 -0
  144. package/src/notification-repo.ts +402 -0
  145. package/src/orphan-keys.ts +19 -0
  146. package/src/outbox-repo.ts +78 -0
  147. package/src/permission-version.ts +24 -0
  148. package/src/permissions-map.ts +52 -0
  149. package/src/pglite.fixture.ts +87 -0
  150. package/src/plugin-data.ts +88 -0
  151. package/src/plugin-grants.ts +369 -0
  152. package/src/plugin-health-repo.ts +80 -0
  153. package/src/plugin-purge-repo.ts +91 -0
  154. package/src/plugin-users.ts +39 -0
  155. package/src/poll-repo.ts +334 -0
  156. package/src/post-repo.ts +261 -0
  157. package/src/post-writes.ts +227 -0
  158. package/src/presence-repo.ts +220 -0
  159. package/src/profile-field-repo.ts +182 -0
  160. package/src/promotion-repo.ts +152 -0
  161. package/src/read-state-repo.ts +81 -0
  162. package/src/relation-repo.ts +113 -0
  163. package/src/render-backfill.ts +110 -0
  164. package/src/render-signature.ts +33 -0
  165. package/src/report-repo.ts +349 -0
  166. package/src/reputation-repo.ts +295 -0
  167. package/src/result-rows.ts +13 -0
  168. package/src/row-values.ts +7 -0
  169. package/src/schema/content.ts +451 -0
  170. package/src/schema/identity.ts +699 -0
  171. package/src/schema/index.ts +10 -0
  172. package/src/schema/messages.ts +76 -0
  173. package/src/schema/permission-columns.ts +34 -0
  174. package/src/schema/platform.ts +376 -0
  175. package/src/schema/structure.ts +164 -0
  176. package/src/search-repo.ts +359 -0
  177. package/src/search-store.ts +113 -0
  178. package/src/seed-groups.ts +11 -0
  179. package/src/settings-repo.ts +32 -0
  180. package/src/signature-repo.ts +102 -0
  181. package/src/sql-lists.ts +17 -0
  182. package/src/staff-groups.ts +50 -0
  183. package/src/stats-repo.ts +151 -0
  184. package/src/subscription-repo.ts +350 -0
  185. package/src/system-health-repo.ts +130 -0
  186. package/src/task-repo.ts +104 -0
  187. package/src/theme-admin-repo.ts +172 -0
  188. package/src/theme-repo.ts +43 -0
  189. package/src/thread-audience.ts +45 -0
  190. package/src/thread-counters.ts +107 -0
  191. package/src/thread-repo.ts +255 -0
  192. package/src/thread-surgery.ts +282 -0
  193. package/src/thread-tools.ts +311 -0
  194. package/src/thread-views.ts +41 -0
  195. package/src/thread-writes.ts +356 -0
  196. package/src/two-factor-repo.ts +235 -0
  197. package/src/upgrade-repo.ts +66 -0
  198. package/src/user-admin-repo.ts +367 -0
  199. package/src/user-bulk-repo.ts +397 -0
  200. package/src/user-merge-map.ts +111 -0
  201. package/src/user-merge-repo.ts +304 -0
  202. package/src/visibility-counters.ts +190 -0
  203. package/src/visibility.ts +17 -0
  204. package/src/vocabulary-repo.ts +54 -0
  205. package/src/warning-repo.ts +328 -0
@@ -0,0 +1,359 @@
1
+ import { type SQL, sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ AuthorFacet,
5
+ ForumFacet,
6
+ SearchCursor,
7
+ SearchHit,
8
+ SearchQuery,
9
+ SearchResults,
10
+ SearchScope,
11
+ SearchSummary,
12
+ } from '@meith/search'
13
+
14
+ import type { Database } from './client'
15
+ import { resultRows } from './result-rows'
16
+ import { inAudience } from './thread-audience'
17
+ import { visibleIn } from './visibility'
18
+
19
+ const SEARCH_CONFIG = 'english'
20
+
21
+ export const SEARCH_WINDOW = 20_000
22
+
23
+ const TOP_AUTHORS = 24
24
+
25
+ export const SEARCH_DOCUMENT_VERSION = 1
26
+
27
+ const EXCERPT_HIGHLIGHT_START = String.fromCharCode(0xe000)
28
+ const EXCERPT_HIGHLIGHT_STOP = String.fromCharCode(0xe001)
29
+
30
+ const HEADLINE_OPTIONS =
31
+ `StartSel=${EXCERPT_HIGHLIGHT_START}, StopSel=${EXCERPT_HIGHLIGHT_STOP}, ` +
32
+ `MaxFragments=1, MaxWords=40, MinWords=15`
33
+
34
+ const EXCERPT_HTML_ESCAPES: Readonly<Record<string, string>> = {
35
+ '&': '&amp;',
36
+ '<': '&lt;',
37
+ '>': '&gt;',
38
+ '"': '&quot;',
39
+ "'": '&#39;',
40
+ }
41
+
42
+ export function renderExcerptHtml(headline: string): string {
43
+ const escaped = headline.replace(
44
+ /[&<>"']/g,
45
+ (character) => EXCERPT_HTML_ESCAPES[character] ?? character,
46
+ )
47
+ return escaped
48
+ .split(EXCERPT_HIGHLIGHT_START)
49
+ .join('<b>')
50
+ .split(EXCERPT_HIGHLIGHT_STOP)
51
+ .join('</b>')
52
+ }
53
+
54
+ export function searchVectorSql(subject: SQL | string, message: SQL | string): SQL {
55
+ return sql`
56
+ setweight(to_tsvector(${SEARCH_CONFIG}, coalesce(${subject}, '')), 'A') ||
57
+ setweight(to_tsvector(${SEARCH_CONFIG}, coalesce(${message}, '')), 'B')
58
+ `
59
+ }
60
+
61
+ export function indexedSubjectSql(post: SQL): SQL {
62
+ return sql`coalesce(
63
+ ${post}.subject,
64
+ case when ${post}.is_first_post
65
+ then (select t.title from threads t where t.id = ${post}.thread_id)
66
+ end
67
+ )`
68
+ }
69
+
70
+ const PENDING_INDEX: SQL = sql`(search_vector is null or search_version < ${SEARCH_DOCUMENT_VERSION})`
71
+
72
+ const NOTHING: SearchSummary = { total: 0, isCapped: false, forums: [], authors: [] }
73
+
74
+ export interface ReindexResult {
75
+ readonly indexed: number
76
+ readonly nextCursor: number | null
77
+ }
78
+
79
+ export class PostgresSearchRepository {
80
+ constructor(private readonly db: Database) {}
81
+
82
+ async search(query: SearchQuery, scope: SearchScope): Promise<SearchResults> {
83
+ const conditions = matchConditions(query, scope)
84
+ if (conditions === null) return { hits: [], nextCursor: null }
85
+
86
+ const rank = rankSql(query.terms)
87
+ const finalOrder = orderSql(query.sort)
88
+
89
+ const selection = sql`
90
+ select p.id as post_id, p.thread_id, p.forum_id,
91
+ t.title as thread_title, t.slug as thread_slug,
92
+ p.author_user_id, p.author_username, p.created_at,
93
+ ${excerptSourceSql(query.match)} as excerpt_source,
94
+ ${rank} as rank
95
+ from posts p
96
+ join threads t on t.id = p.thread_id`
97
+
98
+ const candidates =
99
+ query.grouping === 'threads'
100
+ ? groupedCandidates(query, selection, conditions)
101
+ : flatCandidates(query, selection, conditions, rank)
102
+
103
+ const rows = resultRows(
104
+ await this.db.execute(sql`
105
+ select post_id, thread_id, forum_id, thread_title, thread_slug,
106
+ author_user_id, author_username, created_at, rank,
107
+ ts_headline(${SEARCH_CONFIG}, excerpt_source,
108
+ websearch_to_tsquery(${SEARCH_CONFIG}, ${query.terms}),
109
+ ${HEADLINE_OPTIONS}) as excerpt
110
+ from ${candidates}
111
+ order by ${finalOrder}
112
+ limit ${query.limit} offset ${query.after === null ? (query.offset ?? 0) : 0}
113
+ `),
114
+ ) as Array<Record<string, unknown>>
115
+
116
+ const hits: SearchHit[] = rows.map((row) => ({
117
+ postId: Number(row.post_id),
118
+ threadId: Number(row.thread_id),
119
+ forumId: Number(row.forum_id),
120
+ threadTitle: String(row.thread_title),
121
+ threadSlug: String(row.thread_slug),
122
+ authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
123
+ authorUsername: String(row.author_username),
124
+ postedAt: row.created_at instanceof Date ? row.created_at : new Date(String(row.created_at)),
125
+ excerpt: renderExcerptHtml(String(row.excerpt ?? '')),
126
+ rank: Number(row.rank),
127
+ }))
128
+
129
+ const last = hits.at(-1)
130
+ const nextCursor: SearchCursor | null =
131
+ hits.length < query.limit || last === undefined
132
+ ? null
133
+ : { rank: last.rank, postId: last.postId }
134
+
135
+ return { hits, nextCursor }
136
+ }
137
+
138
+ async summarize(query: SearchQuery, scope: SearchScope): Promise<SearchSummary> {
139
+ const conditions = matchConditions(query, scope)
140
+ if (conditions === null) return NOTHING
141
+
142
+ const rows = resultRows(
143
+ await this.db.execute(sql`
144
+ with candidates as (
145
+ select p.forum_id, p.thread_id, p.author_user_id, p.author_username
146
+ from posts p
147
+ join threads t on t.id = p.thread_id
148
+ where ${sql.join(conditions, sql` and `)}
149
+ limit ${SEARCH_WINDOW + 1}
150
+ )
151
+ select 'total' as facet, null::int as id, ''::text as username,
152
+ count(*)::int as posts, count(distinct thread_id)::int as threads
153
+ from candidates
154
+ union all
155
+ select 'forum', forum_id, '', count(*)::int, count(distinct thread_id)::int
156
+ from candidates
157
+ group by forum_id
158
+ union all
159
+ select * from (
160
+ select 'author' as facet, author_user_id as id,
161
+ coalesce(max(author_username), '') as username,
162
+ count(*)::int as posts, count(distinct thread_id)::int as threads
163
+ from candidates
164
+ where author_user_id is not null
165
+ group by author_user_id
166
+ order by count(*) desc
167
+ limit ${TOP_AUTHORS}
168
+ ) as top_authors
169
+ `),
170
+ ) as Array<Record<string, unknown>>
171
+
172
+ const scanned = rows.find((row) => row.facet === 'total')
173
+ if (scanned === undefined) return NOTHING
174
+
175
+ const isCapped = Number(scanned.posts) > SEARCH_WINDOW
176
+ const count = (row: Record<string, unknown>): number =>
177
+ query.grouping === 'threads'
178
+ ? Number(row.threads)
179
+ : Math.min(Number(row.posts), SEARCH_WINDOW)
180
+
181
+ const forums: ForumFacet[] = []
182
+ const authors: AuthorFacet[] = []
183
+
184
+ for (const row of rows) {
185
+ if (row.facet === 'forum') {
186
+ forums.push({ forumId: Number(row.id), hits: count(row) })
187
+ } else if (row.facet === 'author') {
188
+ authors.push({
189
+ userId: Number(row.id),
190
+ username: String(row.username),
191
+ hits: count(row),
192
+ })
193
+ }
194
+ }
195
+
196
+ const byHits = <T extends { readonly hits: number }>(a: T, b: T): number => b.hits - a.hits
197
+
198
+ return {
199
+ total: count(scanned),
200
+ isCapped,
201
+ forums: forums.sort(byHits),
202
+ authors: authors.sort(byHits),
203
+ }
204
+ }
205
+
206
+ async reindexChunk(afterPostId: number, limit: number): Promise<ReindexResult> {
207
+ const rows = resultRows(
208
+ await this.db.execute(sql`
209
+ update posts p
210
+ set search_vector = ${searchVectorSql(indexedSubjectSql(sql`p`), sql`p.message`)},
211
+ search_version = ${SEARCH_DOCUMENT_VERSION}
212
+ where p.id in (
213
+ select id from posts
214
+ where ${PENDING_INDEX} and id > ${afterPostId}
215
+ order by id
216
+ limit ${limit}
217
+ )
218
+ returning p.id
219
+ `),
220
+ ) as Array<{ id: number }>
221
+
222
+ const ids = rows.map((row) => Number(row.id)).sort((a, b) => a - b)
223
+
224
+ return {
225
+ indexed: ids.length,
226
+ nextCursor: ids.length < limit ? null : (ids.at(-1) ?? null),
227
+ }
228
+ }
229
+
230
+ async indexProgress(): Promise<{ readonly indexed: number; readonly pending: number }> {
231
+ const rows = resultRows(
232
+ await this.db.execute(sql`
233
+ select count(*) filter (where not ${PENDING_INDEX})::int as indexed,
234
+ count(*) filter (where ${PENDING_INDEX})::int as pending
235
+ from posts
236
+ `),
237
+ ) as Array<{ indexed: number; pending: number }>
238
+
239
+ return {
240
+ indexed: Number(rows[0]?.indexed ?? 0),
241
+ pending: Number(rows[0]?.pending ?? 0),
242
+ }
243
+ }
244
+
245
+ async invalidateIndex(): Promise<void> {
246
+ await this.db.execute(sql`update posts set search_vector = null, search_version = 0`)
247
+ }
248
+ }
249
+
250
+ function rankSql(terms: string): SQL {
251
+ return sql`ts_rank_cd(p.search_vector, websearch_to_tsquery(${SEARCH_CONFIG}, ${terms}))`
252
+ }
253
+
254
+ function excerptSourceSql(match: SearchQuery['match']): SQL {
255
+ return match === 'titles' ? sql`coalesce(p.subject, t.title)` : sql`p.message`
256
+ }
257
+
258
+ function orderSql(sort: SearchQuery['sort']): SQL {
259
+ return sort === 'relevance'
260
+ ? sql`rank desc, post_id desc`
261
+ : sort === 'newest'
262
+ ? sql`post_id desc`
263
+ : sql`post_id asc`
264
+ }
265
+
266
+ function matchConditions(query: SearchQuery, scope: SearchScope): SQL[] | null {
267
+ if (query.terms.trim() === '') return null
268
+
269
+ const allowed =
270
+ query.forumIds === undefined
271
+ ? scope.forumIds
272
+ : scope.forumIds.filter((id) => query.forumIds!.includes(id))
273
+
274
+ if (allowed.length === 0) return null
275
+ if (query.authorUserIds !== undefined && query.authorUserIds.length === 0) return null
276
+
277
+ const conditions: SQL[] = [
278
+ inAudience(sql`p.forum_id`, sql`t.author_user_id`, {
279
+ ...scope,
280
+ forumIds: allowed,
281
+ }),
282
+ sql`p.search_vector @@ websearch_to_tsquery(${SEARCH_CONFIG}, ${query.terms})`,
283
+ ]
284
+
285
+ conditions.push(visibleIn(sql`p.visibility`, scope.content))
286
+ conditions.push(visibleIn(sql`t.visibility`, scope.content))
287
+
288
+ if (query.match === 'titles') {
289
+ conditions.push(
290
+ sql`to_tsvector(${SEARCH_CONFIG}, coalesce(${indexedSubjectSql(sql`p`)}, ''))
291
+ @@ websearch_to_tsquery(${SEARCH_CONFIG}, ${query.terms})`,
292
+ )
293
+ }
294
+
295
+ if (query.authorUserIds !== undefined) {
296
+ conditions.push(
297
+ sql`p.author_user_id in (${sql.join(
298
+ query.authorUserIds.map((id) => sql`${id}`),
299
+ sql`, `,
300
+ )})`,
301
+ )
302
+ }
303
+
304
+ if (query.postedAfter !== undefined) conditions.push(sql`p.created_at >= ${query.postedAfter}`)
305
+ if (query.postedBefore !== undefined) conditions.push(sql`p.created_at < ${query.postedBefore}`)
306
+
307
+ return conditions
308
+ }
309
+
310
+ function cursorSql(query: SearchQuery, rank: SQL, postId: SQL): SQL | null {
311
+ if (query.after === null) return null
312
+
313
+ return query.sort === 'relevance'
314
+ ? sql`(${rank} < ${query.after.rank}
315
+ or (${rank} = ${query.after.rank} and ${postId} < ${query.after.postId}))`
316
+ : query.sort === 'newest'
317
+ ? sql`${postId} < ${query.after.postId}`
318
+ : sql`${postId} > ${query.after.postId}`
319
+ }
320
+
321
+ function flatCandidates(
322
+ query: SearchQuery,
323
+ selection: SQL,
324
+ conditions: readonly SQL[],
325
+ rank: SQL,
326
+ ): SQL {
327
+ const cursor = cursorSql(query, rank, sql`p.id`)
328
+ const all = cursor === null ? [...conditions] : [...conditions, cursor]
329
+ const scan = sql`${selection} where ${sql.join(all, sql` and `)}`
330
+
331
+ return query.sort === 'relevance'
332
+ ? sql`(${scan} order by p.id desc limit ${SEARCH_WINDOW}) as candidates`
333
+ : sql`(${scan} order by ${query.sort === 'newest' ? sql`p.id desc` : sql`p.id asc`}
334
+ limit ${query.limit}) as candidates`
335
+ }
336
+
337
+ function groupedCandidates(query: SearchQuery, selection: SQL, conditions: readonly SQL[]): SQL {
338
+ const scan = sql`${selection} where ${sql.join([...conditions], sql` and `)}
339
+ order by ${query.sort === 'oldest' ? sql`p.id asc` : sql`p.id desc`}
340
+ limit ${SEARCH_WINDOW}`
341
+
342
+ const representative =
343
+ query.sort === 'relevance'
344
+ ? sql`thread_id, rank desc, post_id desc`
345
+ : query.sort === 'newest'
346
+ ? sql`thread_id, post_id desc`
347
+ : sql`thread_id, post_id asc`
348
+
349
+ const grouped = sql`
350
+ select distinct on (thread_id) * from (${scan}) as scanned order by ${representative}`
351
+
352
+ const cursor = cursorSql(query, sql`rank`, sql`post_id`)
353
+
354
+ return cursor === null
355
+ ? sql`(select * from (${grouped}) as grouped
356
+ order by ${orderSql(query.sort)} limit ${query.limit}) as candidates`
357
+ : sql`(select * from (${grouped}) as grouped where ${cursor}
358
+ order by ${orderSql(query.sort)} limit ${query.limit}) as candidates`
359
+ }
@@ -0,0 +1,113 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+ import { resultRows } from './result-rows'
5
+
6
+ export interface StoredSearch {
7
+ readonly id: number
8
+ readonly token: string
9
+ readonly userId: number | null
10
+ readonly sessionKey: string | null
11
+ readonly terms: string
12
+ readonly filters: Readonly<Record<string, unknown>>
13
+ readonly hitCount: number
14
+ readonly createdAt: Date
15
+ }
16
+
17
+ export interface CreateSearchInput {
18
+ readonly token: string
19
+ readonly userId: number | null
20
+ readonly sessionKey: string | null
21
+ readonly terms: string
22
+ readonly filters: Readonly<Record<string, unknown>>
23
+ readonly floodSeconds: number
24
+ }
25
+
26
+ export class PostgresSearchStore {
27
+ constructor(private readonly db: Database) {}
28
+
29
+ async create(input: CreateSearchInput): Promise<StoredSearch | null> {
30
+ const owner =
31
+ input.userId !== null
32
+ ? sql`user_id = ${input.userId}`
33
+ : input.sessionKey !== null
34
+ ? sql`session_key = ${input.sessionKey}`
35
+ : sql`false`
36
+
37
+ const rows = resultRows(
38
+ await this.db.execute(sql`
39
+ insert into searches (token, user_id, session_key, terms, filters)
40
+ select ${input.token}, ${input.userId}, ${input.sessionKey}, ${input.terms},
41
+ ${JSON.stringify(input.filters)}::jsonb
42
+ where not exists (
43
+ select 1 from searches
44
+ where ${owner}
45
+ and created_at > now() - make_interval(secs => ${input.floodSeconds})
46
+ )
47
+ returning id, token, user_id, session_key, terms, filters, hit_count, created_at
48
+ `),
49
+ ) as Array<Record<string, unknown>>
50
+
51
+ const row = rows[0]
52
+ return row === undefined ? null : toStored(row)
53
+ }
54
+
55
+ async recordHitCount(id: number, hitCount: number): Promise<void> {
56
+ await this.db.execute(sql`update searches set hit_count = ${hitCount} where id = ${id}`)
57
+ }
58
+
59
+ async findByToken(token: string): Promise<StoredSearch | null> {
60
+ const rows = resultRows(
61
+ await this.db.execute(sql`
62
+ select id, token, user_id, session_key, terms, filters, hit_count, created_at
63
+ from searches where token = ${token}
64
+ `),
65
+ ) as Array<Record<string, unknown>>
66
+
67
+ const row = rows[0]
68
+ return row === undefined ? null : toStored(row)
69
+ }
70
+
71
+ async prune(olderThan: Date, limit = 5_000): Promise<number> {
72
+ const rows = resultRows(
73
+ await this.db.execute(sql`
74
+ delete from searches
75
+ where id in (
76
+ select id from searches where created_at < ${olderThan} order by id limit ${limit}
77
+ )
78
+ returning id
79
+ `),
80
+ ) as Array<{ id: number }>
81
+
82
+ return rows.length
83
+ }
84
+ }
85
+
86
+ export function ownsSearch(
87
+ search: Pick<StoredSearch, 'userId' | 'sessionKey'>,
88
+ viewer: { readonly userId: number | null; readonly sessionKey: string | null },
89
+ ): boolean {
90
+ if (search.userId !== null) return search.userId === viewer.userId
91
+ if (search.sessionKey !== null) {
92
+ return viewer.userId === null && search.sessionKey === viewer.sessionKey
93
+ }
94
+
95
+ return true
96
+ }
97
+
98
+ function toStored(row: Record<string, unknown>): StoredSearch {
99
+ const filters = row.filters
100
+ return {
101
+ id: Number(row.id),
102
+ token: String(row.token),
103
+ userId: row.user_id === null ? null : Number(row.user_id),
104
+ sessionKey: row.session_key === null ? null : String(row.session_key),
105
+ terms: String(row.terms),
106
+ filters:
107
+ typeof filters === 'object' && filters !== null && !Array.isArray(filters)
108
+ ? (filters as Record<string, unknown>)
109
+ : {},
110
+ hitCount: Number(row.hit_count),
111
+ createdAt: row.created_at instanceof Date ? row.created_at : new Date(String(row.created_at)),
112
+ }
113
+ }
@@ -0,0 +1,11 @@
1
+ export const SEED_GROUP_KEY = {
2
+ guests: 'guests',
3
+ registered: 'registered',
4
+ administrators: 'administrators',
5
+ superModerators: 'super_moderators',
6
+ moderators: 'moderators',
7
+ awaitingActivation: 'awaiting_activation',
8
+ banned: 'banned',
9
+ } as const
10
+
11
+ export type SeedGroupKey = (typeof SEED_GROUP_KEY)[keyof typeof SEED_GROUP_KEY]
@@ -0,0 +1,32 @@
1
+ import { inArray, sql } from 'drizzle-orm'
2
+
3
+ import type { SettingsRepository } from '@meith/settings'
4
+
5
+ import type { Database } from './client'
6
+ import { settings } from './schema'
7
+
8
+ export class PostgresSettingsRepository implements SettingsRepository {
9
+ constructor(private readonly db: Database) {}
10
+
11
+ async loadAll(): Promise<ReadonlyMap<string, string>> {
12
+ const rows = await this.db.select({ key: settings.key, value: settings.value }).from(settings)
13
+ return new Map(rows.map((r) => [r.key, r.value]))
14
+ }
15
+
16
+ async save(entries: ReadonlyMap<string, string>): Promise<void> {
17
+ if (entries.size === 0) return
18
+
19
+ await this.db
20
+ .insert(settings)
21
+ .values([...entries].map(([key, value]) => ({ key, value })))
22
+ .onConflictDoUpdate({
23
+ target: settings.key,
24
+ set: { value: sql`excluded.value`, updatedAt: new Date() },
25
+ })
26
+ }
27
+
28
+ async delete(keys: readonly string[]): Promise<void> {
29
+ if (keys.length === 0) return
30
+ await this.db.delete(settings).where(inArray(settings.key, [...keys]))
31
+ }
32
+ }
@@ -0,0 +1,102 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import { BodyFormat } from '@meith/markdown'
4
+ import type { StoredSignature } from '@meith/signatures'
5
+
6
+ import type { Database } from './client'
7
+ import { resultRows } from './result-rows'
8
+
9
+ interface RawSignature {
10
+ signature: string
11
+ signature_html: string | null
12
+ signature_render_version: number
13
+ signature_format: number
14
+ signature_locked: boolean
15
+ signature_locked_reason: string | null
16
+ }
17
+
18
+ function toSignature(row: RawSignature): StoredSignature {
19
+ return {
20
+ signature: row.signature,
21
+ signatureHtml: row.signature_html,
22
+ signatureRenderVersion: Number(row.signature_render_version),
23
+ signatureFormat: Number(row.signature_format),
24
+ locked: row.signature_locked === true,
25
+ lockedReason: row.signature_locked_reason,
26
+ }
27
+ }
28
+
29
+ const COLUMNS = sql`
30
+ signature, signature_html, signature_render_version, signature_format,
31
+ signature_locked, signature_locked_reason
32
+ `
33
+
34
+ export class PostgresSignatureRepository {
35
+ constructor(private readonly db: Database) {}
36
+
37
+ async read(userId: number): Promise<StoredSignature | null> {
38
+ const rows = resultRows(
39
+ await this.db.execute(sql`
40
+ select ${COLUMNS} from users where id = ${userId} and deleted_at is null
41
+ `),
42
+ ) as RawSignature[]
43
+
44
+ return rows[0] === undefined ? null : toSignature(rows[0])
45
+ }
46
+
47
+ async readMany(userIds: readonly number[]): Promise<ReadonlyMap<number, StoredSignature>> {
48
+ if (userIds.length === 0) return new Map()
49
+
50
+ const rows = resultRows(
51
+ await this.db.execute(sql`
52
+ select id, ${COLUMNS}
53
+ from users
54
+ where id in (${sql.join(
55
+ userIds.map((id) => sql`${id}`),
56
+ sql`, `,
57
+ )})
58
+ `),
59
+ ) as Array<RawSignature & { id: number }>
60
+
61
+ return new Map(rows.map((row) => [Number(row.id), toSignature(row)]))
62
+ }
63
+
64
+ async save(input: {
65
+ readonly userId: number
66
+ readonly signature: string
67
+ readonly signatureHtml: string
68
+ readonly renderVersion: number
69
+ }): Promise<boolean> {
70
+ const rows = resultRows(
71
+ await this.db.execute(sql`
72
+ update users
73
+ set signature = ${input.signature},
74
+ signature_html = ${input.signatureHtml},
75
+ signature_render_version = ${input.renderVersion},
76
+ /*
77
+ * A save is what takes a member off the legacy format for good:
78
+ * what they just typed in the composer is Markdown, whatever the
79
+ * row said a moment ago.
80
+ */
81
+ signature_format = ${BodyFormat.Markdown}
82
+ where id = ${input.userId} and signature_locked = false
83
+ returning id
84
+ `),
85
+ ) as Array<{ id: number }>
86
+
87
+ return rows.length > 0
88
+ }
89
+
90
+ async setLocked(input: {
91
+ readonly userId: number
92
+ readonly locked: boolean
93
+ readonly reason: string | null
94
+ }): Promise<void> {
95
+ await this.db.execute(sql`
96
+ update users
97
+ set signature_locked = ${input.locked},
98
+ signature_locked_reason = ${input.locked ? input.reason : null}
99
+ where id = ${input.userId}
100
+ `)
101
+ }
102
+ }
@@ -0,0 +1,17 @@
1
+ import { type SQL, sql } from 'drizzle-orm'
2
+
3
+ export function idList(ids: readonly number[]): SQL {
4
+ if (ids.length === 0) return sql`(null)`
5
+ return sql`(${sql.join(
6
+ ids.map((id) => sql`${id}`),
7
+ sql`, `,
8
+ )})`
9
+ }
10
+
11
+ export function textList(values: readonly string[]): SQL {
12
+ if (values.length === 0) return sql`(null)`
13
+ return sql`(${sql.join(
14
+ values.map((v) => sql`${v}`),
15
+ sql`, `,
16
+ )})`
17
+ }
@@ -0,0 +1,50 @@
1
+ import { type SQL, sql } from 'drizzle-orm'
2
+
3
+ import { columnName } from './schema/permission-columns'
4
+
5
+ export const PLUGIN_UNGRANTABLE_PERMISSIONS = [
6
+ 'isAdministrator',
7
+ 'canAccessAdminCp',
8
+ 'isSuperModerator',
9
+ 'canAccessModCp',
10
+ 'canWarnUsers',
11
+ 'canApproveContent',
12
+ 'canEditOthersPosts',
13
+ 'canSoftDeletePosts',
14
+ ] as const
15
+
16
+ export type UngrantablePermission = (typeof PLUGIN_UNGRANTABLE_PERMISSIONS)[number]
17
+
18
+ export function permissionsCarryPower(
19
+ permissions: Readonly<Partial<Record<UngrantablePermission, boolean | number>>>,
20
+ ): boolean {
21
+ return PLUGIN_UNGRANTABLE_PERMISSIONS.some((key) => permissions[key] === true)
22
+ }
23
+
24
+ export function isStaffGroupSql(groupAlias: string): SQL {
25
+ const columns = PLUGIN_UNGRANTABLE_PERMISSIONS.map((key) =>
26
+ sql.raw(`${groupAlias}.${columnName(key)}`),
27
+ )
28
+
29
+ return sql.join(
30
+ [
31
+ sql`${sql.raw(`${groupAlias}.is_staff_group`)} = true`,
32
+ ...columns.map((c) => sql`${c} = true`),
33
+ ],
34
+ sql` or `,
35
+ )
36
+ }
37
+
38
+ export function keptDisplayGroupSql(input: {
39
+ readonly column: SQL
40
+ readonly leavingGroupId: SQL
41
+ readonly arrivingGroupId: SQL
42
+ }): SQL {
43
+ return sql`nullif(nullif(${input.column}, ${input.leavingGroupId}), ${input.arrivingGroupId})`
44
+ }
45
+
46
+ export function displayGroupIdSql(userAlias: string, primaryAlias: string): SQL {
47
+ return sql`case when (${isStaffGroupSql(primaryAlias)}) then ${sql.raw(`${userAlias}.primary_group_id`)}
48
+ else coalesce(${sql.raw(`${userAlias}.display_group_id`)}, ${sql.raw(`${userAlias}.primary_group_id`)})
49
+ end`
50
+ }