@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,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
|
+
'&': '&',
|
|
36
|
+
'<': '<',
|
|
37
|
+
'>': '>',
|
|
38
|
+
'"': '"',
|
|
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
|
+
}
|
package/src/sql-lists.ts
ADDED
|
@@ -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
|
+
}
|