@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
package/src/post-repo.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { and, asc, eq, gt, type SQL, sql } from 'drizzle-orm'
|
|
2
|
+
import { alias } from 'drizzle-orm/pg-core'
|
|
3
|
+
|
|
4
|
+
import { type ContentScope, PUBLIC_CONTENT } from '@meith/core'
|
|
5
|
+
import { sourceAsMarkdown } from '@meith/markdown'
|
|
6
|
+
import type {
|
|
7
|
+
PostListingRow,
|
|
8
|
+
PostLocation,
|
|
9
|
+
PostPage,
|
|
10
|
+
PostRepository,
|
|
11
|
+
PostRevision,
|
|
12
|
+
QuotablePost,
|
|
13
|
+
} from '@meith/posts'
|
|
14
|
+
|
|
15
|
+
import type { Database } from './client'
|
|
16
|
+
import { resultRows } from './result-rows'
|
|
17
|
+
import { posts, users } from './schema'
|
|
18
|
+
import { visibleIn } from './visibility'
|
|
19
|
+
|
|
20
|
+
const editors = alias(users, 'editors')
|
|
21
|
+
|
|
22
|
+
function toPost(row: {
|
|
23
|
+
id: number
|
|
24
|
+
threadId: number
|
|
25
|
+
forumId: number
|
|
26
|
+
beforeCount: number
|
|
27
|
+
authorUserId: number | null
|
|
28
|
+
authorUsername: string
|
|
29
|
+
authorPostCount: number | null
|
|
30
|
+
authorJoinedAt: Date | null
|
|
31
|
+
message: string
|
|
32
|
+
messageHtml: string | null
|
|
33
|
+
renderVersion: number
|
|
34
|
+
bodyFormat: number
|
|
35
|
+
isFirstPost: boolean
|
|
36
|
+
visibility: string
|
|
37
|
+
createdAt: Date
|
|
38
|
+
editedAt: Date | null
|
|
39
|
+
editedByUsername: string | null
|
|
40
|
+
editReason: string | null
|
|
41
|
+
}): PostListingRow {
|
|
42
|
+
return {
|
|
43
|
+
id: row.id,
|
|
44
|
+
threadId: row.threadId,
|
|
45
|
+
forumId: row.forumId,
|
|
46
|
+
number: Number(row.beforeCount) + 1,
|
|
47
|
+
authorUserId: row.authorUserId,
|
|
48
|
+
authorUsername: row.authorUsername,
|
|
49
|
+
authorPostCount: row.authorPostCount ?? 0,
|
|
50
|
+
authorJoinedAt: row.authorJoinedAt,
|
|
51
|
+
message: row.message,
|
|
52
|
+
messageHtml: row.messageHtml,
|
|
53
|
+
renderVersion: Number(row.renderVersion),
|
|
54
|
+
bodyFormat: Number(row.bodyFormat),
|
|
55
|
+
isFirstPost: row.isFirstPost,
|
|
56
|
+
visibility: row.visibility as PostListingRow['visibility'],
|
|
57
|
+
createdAt: row.createdAt,
|
|
58
|
+
editedAt: row.editedAt,
|
|
59
|
+
editedByUsername: row.editedByUsername,
|
|
60
|
+
editReason: row.editReason,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class PostgresPostRepository implements PostRepository {
|
|
65
|
+
constructor(private readonly db: Database) {}
|
|
66
|
+
|
|
67
|
+
async findRatingTarget(postId: number) {
|
|
68
|
+
const rows = await this.db
|
|
69
|
+
.select({ id: posts.id, threadId: posts.threadId, authorUserId: posts.authorUserId })
|
|
70
|
+
.from(posts)
|
|
71
|
+
.where(eq(posts.id, postId))
|
|
72
|
+
.limit(1)
|
|
73
|
+
return rows[0] ?? null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async findQuotable(threadId: number, postId: number): Promise<QuotablePost | null> {
|
|
77
|
+
const rows = await this.db
|
|
78
|
+
.select({
|
|
79
|
+
id: posts.id,
|
|
80
|
+
authorUsername: posts.authorUsername,
|
|
81
|
+
message: posts.message,
|
|
82
|
+
bodyFormat: posts.bodyFormat,
|
|
83
|
+
})
|
|
84
|
+
.from(posts)
|
|
85
|
+
.where(
|
|
86
|
+
and(
|
|
87
|
+
eq(posts.id, postId),
|
|
88
|
+
eq(posts.threadId, threadId),
|
|
89
|
+
visibleIn(posts.visibility, PUBLIC_CONTENT),
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
.limit(1)
|
|
93
|
+
|
|
94
|
+
const row = rows[0]
|
|
95
|
+
if (row === undefined) return null
|
|
96
|
+
return {
|
|
97
|
+
id: row.id,
|
|
98
|
+
authorUsername: row.authorUsername,
|
|
99
|
+
message: sourceAsMarkdown(row.message, Number(row.bodyFormat)),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async listRevisions(threadId: number, postId: number): Promise<readonly PostRevision[]> {
|
|
104
|
+
const rows = resultRows(
|
|
105
|
+
await this.db.execute(sql`
|
|
106
|
+
select r.revision, r.message, r.subject, r.edited_by_user_id,
|
|
107
|
+
coalesce(u.username, 'Deleted member') as edited_by_username,
|
|
108
|
+
r.edit_reason, r.created_at, false as current
|
|
109
|
+
from post_revisions r
|
|
110
|
+
left join users u on u.id = r.edited_by_user_id
|
|
111
|
+
where r.post_id = ${postId}
|
|
112
|
+
and exists (select 1 from posts p where p.id = r.post_id and p.thread_id = ${threadId})
|
|
113
|
+
union all
|
|
114
|
+
select p.revision_count, p.message, p.subject, p.edited_by_user_id,
|
|
115
|
+
coalesce(u.username, p.author_username, 'Deleted member'),
|
|
116
|
+
p.edit_reason, coalesce(p.edited_at, p.created_at), true
|
|
117
|
+
from posts p
|
|
118
|
+
left join users u on u.id = p.edited_by_user_id
|
|
119
|
+
where p.id = ${postId} and p.thread_id = ${threadId}
|
|
120
|
+
order by revision asc
|
|
121
|
+
`),
|
|
122
|
+
) as Array<{
|
|
123
|
+
revision: number
|
|
124
|
+
message: string
|
|
125
|
+
subject: string | null
|
|
126
|
+
edited_by_user_id: number | null
|
|
127
|
+
edited_by_username: string
|
|
128
|
+
edit_reason: string | null
|
|
129
|
+
created_at: Date | string
|
|
130
|
+
current: boolean
|
|
131
|
+
}>
|
|
132
|
+
|
|
133
|
+
return rows.map((row) => ({
|
|
134
|
+
revision: Number(row.revision),
|
|
135
|
+
message: row.message,
|
|
136
|
+
subject: row.subject,
|
|
137
|
+
editedByUserId: row.edited_by_user_id === null ? null : Number(row.edited_by_user_id),
|
|
138
|
+
editedByUsername: row.edited_by_username,
|
|
139
|
+
reason: row.edit_reason,
|
|
140
|
+
createdAt: new Date(row.created_at),
|
|
141
|
+
current: row.current,
|
|
142
|
+
}))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async locate(
|
|
146
|
+
threadId: number,
|
|
147
|
+
postId: number,
|
|
148
|
+
options: { readonly scope: ContentScope; readonly pageSize: number },
|
|
149
|
+
): Promise<PostLocation | null> {
|
|
150
|
+
const visible: SQL = visibleIn(posts.visibility, options.scope)
|
|
151
|
+
const size = Math.max(1, Math.trunc(options.pageSize))
|
|
152
|
+
|
|
153
|
+
const counted = await this.db
|
|
154
|
+
.select({
|
|
155
|
+
number: sql<number>`count(*) filter (where ${posts.id} <= ${postId})::int`,
|
|
156
|
+
found: sql<number>`count(*) filter (where ${posts.id} = ${postId})::int`,
|
|
157
|
+
})
|
|
158
|
+
.from(posts)
|
|
159
|
+
.where(and(eq(posts.threadId, threadId), visible))
|
|
160
|
+
|
|
161
|
+
const row = counted[0]
|
|
162
|
+
if (row === undefined || Number(row.found) === 0) return null
|
|
163
|
+
|
|
164
|
+
const number = Number(row.number)
|
|
165
|
+
const page = Math.floor((number - 1) / size) + 1
|
|
166
|
+
if (page === 1) return { number, page, afterId: null }
|
|
167
|
+
|
|
168
|
+
const cursor = await this.db
|
|
169
|
+
.select({ id: posts.id })
|
|
170
|
+
.from(posts)
|
|
171
|
+
.where(and(eq(posts.threadId, threadId), visible))
|
|
172
|
+
.orderBy(asc(posts.id))
|
|
173
|
+
.limit(1)
|
|
174
|
+
.offset((page - 1) * size - 1)
|
|
175
|
+
|
|
176
|
+
return { number, page, afterId: cursor[0]?.id ?? null }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async findVisibleById(threadId: number, postId: number): Promise<number | null> {
|
|
180
|
+
const rows = await this.db
|
|
181
|
+
.select({ id: posts.id })
|
|
182
|
+
.from(posts)
|
|
183
|
+
.where(
|
|
184
|
+
and(
|
|
185
|
+
eq(posts.threadId, threadId),
|
|
186
|
+
eq(posts.id, postId),
|
|
187
|
+
visibleIn(posts.visibility, PUBLIC_CONTENT),
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
.limit(1)
|
|
191
|
+
return rows[0]?.id ?? null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async listThread(
|
|
195
|
+
threadId: number,
|
|
196
|
+
options: {
|
|
197
|
+
readonly afterId?: number
|
|
198
|
+
readonly offset?: number
|
|
199
|
+
readonly limit: number
|
|
200
|
+
readonly scope: ContentScope
|
|
201
|
+
},
|
|
202
|
+
): Promise<PostPage> {
|
|
203
|
+
const visible: SQL = visibleIn(posts.visibility, options.scope)
|
|
204
|
+
|
|
205
|
+
const beforeCount = options.afterId
|
|
206
|
+
? sql<number>`(
|
|
207
|
+
select count(*)::int from ${posts}
|
|
208
|
+
where ${posts.threadId} = ${threadId}
|
|
209
|
+
and ${visible}
|
|
210
|
+
and ${posts.id} <= ${options.afterId}
|
|
211
|
+
)`
|
|
212
|
+
: sql<number>`0`
|
|
213
|
+
|
|
214
|
+
const rows = await this.db
|
|
215
|
+
.select({
|
|
216
|
+
id: posts.id,
|
|
217
|
+
threadId: posts.threadId,
|
|
218
|
+
forumId: posts.forumId,
|
|
219
|
+
beforeCount,
|
|
220
|
+
authorUserId: posts.authorUserId,
|
|
221
|
+
authorUsername: posts.authorUsername,
|
|
222
|
+
authorPostCount: users.postCount,
|
|
223
|
+
authorJoinedAt: users.createdAt,
|
|
224
|
+
message: posts.message,
|
|
225
|
+
messageHtml: posts.messageHtml,
|
|
226
|
+
renderVersion: posts.renderVersion,
|
|
227
|
+
bodyFormat: posts.bodyFormat,
|
|
228
|
+
isFirstPost: posts.isFirstPost,
|
|
229
|
+
visibility: posts.visibility,
|
|
230
|
+
createdAt: posts.createdAt,
|
|
231
|
+
editedAt: posts.editedAt,
|
|
232
|
+
editedByUsername: editors.username,
|
|
233
|
+
editReason: posts.editReason,
|
|
234
|
+
})
|
|
235
|
+
.from(posts)
|
|
236
|
+
.leftJoin(users, eq(posts.authorUserId, users.id))
|
|
237
|
+
.leftJoin(editors, eq(posts.editedByUserId, editors.id))
|
|
238
|
+
.where(
|
|
239
|
+
and(
|
|
240
|
+
eq(posts.threadId, threadId),
|
|
241
|
+
visible,
|
|
242
|
+
...(options.afterId ? [gt(posts.id, options.afterId)] : []),
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
.orderBy(asc(posts.id))
|
|
246
|
+
.limit(options.limit + 1)
|
|
247
|
+
.offset(options.offset ?? 0)
|
|
248
|
+
|
|
249
|
+
const skipped = options.offset ?? 0
|
|
250
|
+
const page = rows
|
|
251
|
+
.slice(0, options.limit)
|
|
252
|
+
.map((row, index) =>
|
|
253
|
+
toPost({ ...row, beforeCount: Number(row.beforeCount) + skipped + index }),
|
|
254
|
+
)
|
|
255
|
+
const last = page.at(-1)
|
|
256
|
+
return {
|
|
257
|
+
rows: page,
|
|
258
|
+
nextAfterId: rows.length > options.limit && last ? last.id : null,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
authorRef,
|
|
5
|
+
BodyFormat,
|
|
6
|
+
CORE_RENDERING,
|
|
7
|
+
type MarkdownPipeline,
|
|
8
|
+
renderThrough,
|
|
9
|
+
vocabularyOptions,
|
|
10
|
+
} from '@meith/markdown'
|
|
11
|
+
import type {
|
|
12
|
+
PostEditRecord,
|
|
13
|
+
PostEditTarget,
|
|
14
|
+
PostVisibilityRecord,
|
|
15
|
+
PostWriteRepository,
|
|
16
|
+
} from '@meith/posts'
|
|
17
|
+
|
|
18
|
+
import type { Database } from './client'
|
|
19
|
+
import { resultRows } from './result-rows'
|
|
20
|
+
import { indexedSubjectSql, SEARCH_DOCUMENT_VERSION, searchVectorSql } from './search-repo'
|
|
21
|
+
import { logModeratorAction } from './thread-counters'
|
|
22
|
+
import { applyVisibilityChangeCounters } from './visibility-counters'
|
|
23
|
+
import { readBoardVocabulary } from './vocabulary-repo'
|
|
24
|
+
|
|
25
|
+
function isCounted(visibility: string): boolean {
|
|
26
|
+
return visibility === 'visible'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function actedOnAnother(actorUserId: number, authorUserId: number | null): boolean {
|
|
30
|
+
return authorUserId === null || actorUserId !== authorUserId
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function scopedDetail(record: {
|
|
34
|
+
readonly postId: number
|
|
35
|
+
readonly threadId: number
|
|
36
|
+
readonly forumId: number
|
|
37
|
+
}): Record<string, unknown> {
|
|
38
|
+
return {
|
|
39
|
+
postId: record.postId,
|
|
40
|
+
threadId: record.threadId,
|
|
41
|
+
forumId: record.forumId,
|
|
42
|
+
forumIds: [record.forumId],
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class PostgresPostWriteRepository implements PostWriteRepository {
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly db: Database,
|
|
49
|
+
private readonly rendering: MarkdownPipeline = CORE_RENDERING,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
async findEditTarget(threadId: number, postId: number): Promise<PostEditTarget | null> {
|
|
53
|
+
const rows = resultRows(
|
|
54
|
+
await this.db.execute(sql`
|
|
55
|
+
select p.id, p.thread_id, p.forum_id, p.author_user_id, p.subject, p.message,
|
|
56
|
+
p.visibility, p.is_first_post, p.revision_count, p.created_at,
|
|
57
|
+
t.slug as thread_slug, t.title as thread_title, t.is_locked,
|
|
58
|
+
t.visibility as thread_visibility,
|
|
59
|
+
f.slug as forum_slug, f.is_open
|
|
60
|
+
from posts p
|
|
61
|
+
join threads t on t.id = p.thread_id
|
|
62
|
+
join forums f on f.id = p.forum_id
|
|
63
|
+
where p.id = ${postId} and p.thread_id = ${threadId}
|
|
64
|
+
`),
|
|
65
|
+
) as Array<{
|
|
66
|
+
id: number
|
|
67
|
+
thread_id: number
|
|
68
|
+
forum_id: number
|
|
69
|
+
author_user_id: number | null
|
|
70
|
+
subject: string | null
|
|
71
|
+
message: string
|
|
72
|
+
visibility: 'visible' | 'unapproved' | 'deleted'
|
|
73
|
+
is_first_post: boolean
|
|
74
|
+
revision_count: number
|
|
75
|
+
created_at: Date
|
|
76
|
+
thread_slug: string
|
|
77
|
+
thread_title: string
|
|
78
|
+
is_locked: boolean
|
|
79
|
+
thread_visibility: 'visible' | 'unapproved' | 'deleted'
|
|
80
|
+
forum_slug: string
|
|
81
|
+
is_open: boolean
|
|
82
|
+
}>
|
|
83
|
+
|
|
84
|
+
const row = rows[0]
|
|
85
|
+
if (!row) return null
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
post: {
|
|
89
|
+
id: Number(row.id),
|
|
90
|
+
threadId: Number(row.thread_id),
|
|
91
|
+
forumId: Number(row.forum_id),
|
|
92
|
+
authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
|
|
93
|
+
subject: row.subject,
|
|
94
|
+
message: row.message,
|
|
95
|
+
visibility: row.visibility,
|
|
96
|
+
isFirstPost: row.is_first_post,
|
|
97
|
+
revisionCount: Number(row.revision_count),
|
|
98
|
+
createdAt: new Date(row.created_at),
|
|
99
|
+
},
|
|
100
|
+
thread: {
|
|
101
|
+
id: Number(row.thread_id),
|
|
102
|
+
slug: row.thread_slug,
|
|
103
|
+
title: row.thread_title,
|
|
104
|
+
isLocked: row.is_locked,
|
|
105
|
+
visibility: row.thread_visibility,
|
|
106
|
+
},
|
|
107
|
+
forum: { id: Number(row.forum_id), slug: row.forum_slug, isOpen: row.is_open },
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async applyEdit(record: PostEditRecord): Promise<void> {
|
|
112
|
+
const vocabulary = await readBoardVocabulary(this.db, this.rendering)
|
|
113
|
+
const body = await renderThrough(
|
|
114
|
+
this.rendering,
|
|
115
|
+
record.message,
|
|
116
|
+
{ source: 'post', viewer: authorRef(record.editedByUserId), postId: record.postId },
|
|
117
|
+
vocabularyOptions(vocabulary),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
await this.db.transaction(async (tx) => {
|
|
121
|
+
await tx.execute(sql`
|
|
122
|
+
insert into post_revisions
|
|
123
|
+
(post_id, revision, message, subject, edited_by_user_id, edit_reason, created_at)
|
|
124
|
+
values
|
|
125
|
+
(${record.postId}, ${record.revision}, ${record.previousMessage},
|
|
126
|
+
${record.previousSubject}, ${record.editedByUserId}, ${record.reason},
|
|
127
|
+
${record.editedAt})
|
|
128
|
+
`)
|
|
129
|
+
|
|
130
|
+
const notice = record.silent
|
|
131
|
+
? sql``
|
|
132
|
+
: sql`edited_at = ${record.editedAt},
|
|
133
|
+
edited_by_user_id = ${record.editedByUserId},
|
|
134
|
+
edit_reason = ${record.reason},`
|
|
135
|
+
await tx.execute(sql`
|
|
136
|
+
update posts p
|
|
137
|
+
set message = ${record.message},
|
|
138
|
+
message_html = ${body.html},
|
|
139
|
+
/*
|
|
140
|
+
* The indexed document is rewritten with the text, in the
|
|
141
|
+
* same statement. An edit that changed the body and left the
|
|
142
|
+
* vector alone would make the post findable by words it no
|
|
143
|
+
* longer contains — and unfindable by the ones it does.
|
|
144
|
+
*
|
|
145
|
+
* Through indexedSubjectSql rather than off subject, which is
|
|
146
|
+
* null for everything this board writes: reading the column
|
|
147
|
+
* alone left the weight-A half of the document empty, so an
|
|
148
|
+
* edited opening post lost the title an edit is supposed to
|
|
149
|
+
* preserve. The alias is why the statement names "posts p".
|
|
150
|
+
*/
|
|
151
|
+
search_vector = ${searchVectorSql(indexedSubjectSql(sql`p`), sql`${record.message}`)},
|
|
152
|
+
search_version = ${SEARCH_DOCUMENT_VERSION},
|
|
153
|
+
render_version = ${body.version},
|
|
154
|
+
vocab_version = ${vocabulary.revision},
|
|
155
|
+
/*
|
|
156
|
+
* Named rather than left to the column default: an edit is the
|
|
157
|
+
* one path that can turn a legacy row into a current one, and a
|
|
158
|
+
* row whose text is now Markdown while its stamp still says
|
|
159
|
+
* BBCode would be converted a second time by the backfill.
|
|
160
|
+
*/
|
|
161
|
+
body_format = ${BodyFormat.Markdown},
|
|
162
|
+
visibility = ${record.toVisibility},
|
|
163
|
+
${notice}
|
|
164
|
+
revision_count = revision_count + 1
|
|
165
|
+
where id = ${record.postId}
|
|
166
|
+
`)
|
|
167
|
+
|
|
168
|
+
const delta =
|
|
169
|
+
(isCounted(record.toVisibility) ? 1 : 0) - (isCounted(record.fromVisibility) ? 1 : 0)
|
|
170
|
+
if (delta !== 0) {
|
|
171
|
+
await applyVisibilityChangeCounters(tx, {
|
|
172
|
+
postId: record.postId,
|
|
173
|
+
threadId: record.threadId,
|
|
174
|
+
forumId: record.forumId,
|
|
175
|
+
authorId: record.authorUserId,
|
|
176
|
+
isFirstPost: record.isFirstPost,
|
|
177
|
+
delta: delta as 1 | -1,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (actedOnAnother(record.editedByUserId, record.authorUserId)) {
|
|
182
|
+
await logModeratorAction(
|
|
183
|
+
tx,
|
|
184
|
+
'post.edit',
|
|
185
|
+
record.editedByUserId,
|
|
186
|
+
scopedDetail(record),
|
|
187
|
+
record.editedAt,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async applyVisibility(record: PostVisibilityRecord): Promise<boolean> {
|
|
194
|
+
return this.db.transaction(async (tx) => {
|
|
195
|
+
const moved = await tx.execute(sql`
|
|
196
|
+
update posts set visibility = ${record.to}
|
|
197
|
+
where id = ${record.postId} and visibility = ${record.from}
|
|
198
|
+
returning id
|
|
199
|
+
`)
|
|
200
|
+
if (resultRows(moved).length === 0) return false
|
|
201
|
+
|
|
202
|
+
const delta = (isCounted(record.to) ? 1 : 0) - (isCounted(record.from) ? 1 : 0)
|
|
203
|
+
if (delta !== 0) {
|
|
204
|
+
await applyVisibilityChangeCounters(tx, {
|
|
205
|
+
postId: record.postId,
|
|
206
|
+
threadId: record.threadId,
|
|
207
|
+
forumId: record.forumId,
|
|
208
|
+
authorId: record.authorUserId,
|
|
209
|
+
isFirstPost: record.isFirstPost,
|
|
210
|
+
delta: delta as 1 | -1,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (actedOnAnother(record.actedByUserId, record.authorUserId)) {
|
|
215
|
+
await logModeratorAction(
|
|
216
|
+
tx,
|
|
217
|
+
record.to === 'deleted' ? 'post.delete' : 'post.restore',
|
|
218
|
+
record.actedByUserId,
|
|
219
|
+
scopedDetail(record),
|
|
220
|
+
record.at,
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return true
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { ContentScope } from '@meith/core'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
import { toDate } from './row-values'
|
|
8
|
+
import { inAudience } from './thread-audience'
|
|
9
|
+
import { visibleIn } from './visibility'
|
|
10
|
+
|
|
11
|
+
export const ONLINE_WINDOW_MINUTES = 15
|
|
12
|
+
|
|
13
|
+
export interface OnlineScope {
|
|
14
|
+
readonly forumIds: readonly number[]
|
|
15
|
+
readonly ownThreadsOnlyForumIds: readonly number[]
|
|
16
|
+
readonly viewerUserId: number | null
|
|
17
|
+
readonly content: ContentScope
|
|
18
|
+
readonly seesInvisible: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OnlineMember {
|
|
22
|
+
readonly userId: number
|
|
23
|
+
readonly username: string
|
|
24
|
+
readonly invisible: boolean
|
|
25
|
+
readonly lastSeenAt: Date
|
|
26
|
+
readonly forumId: number | null
|
|
27
|
+
readonly forumTitle: string | null
|
|
28
|
+
readonly threadId: number | null
|
|
29
|
+
readonly threadTitle: string | null
|
|
30
|
+
readonly threadSlug: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface OnlineSnapshot {
|
|
34
|
+
readonly members: readonly OnlineMember[]
|
|
35
|
+
readonly guestCount: number
|
|
36
|
+
readonly total: number
|
|
37
|
+
readonly invisibleCount: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface OnlineRecord {
|
|
41
|
+
readonly count: number
|
|
42
|
+
readonly at: Date | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class PostgresPresenceRepository {
|
|
46
|
+
constructor(private readonly db: Database) {}
|
|
47
|
+
|
|
48
|
+
async onlineNow(now: Date, scope: OnlineScope): Promise<OnlineSnapshot> {
|
|
49
|
+
const since = new Date(now.getTime() - ONLINE_WINDOW_MINUTES * 60_000)
|
|
50
|
+
|
|
51
|
+
const mayName =
|
|
52
|
+
scope.forumIds.length === 0
|
|
53
|
+
? sql`false`
|
|
54
|
+
: sql`s.location_forum_id in (${sql.join(
|
|
55
|
+
scope.forumIds.map((id) => sql`${id}`),
|
|
56
|
+
sql`, `,
|
|
57
|
+
)})`
|
|
58
|
+
|
|
59
|
+
const mayNameThread = sql`(${inAudience(
|
|
60
|
+
sql`t.forum_id`,
|
|
61
|
+
sql`t.author_user_id`,
|
|
62
|
+
scope,
|
|
63
|
+
)} and ${visibleIn(sql`t.visibility`, scope.content)})`
|
|
64
|
+
|
|
65
|
+
const rows = resultRows(
|
|
66
|
+
await this.db.execute(sql`
|
|
67
|
+
select distinct on (coalesce(s.user_id, -s.id))
|
|
68
|
+
s.user_id, s.id as session_id, s.last_seen_at,
|
|
69
|
+
u.username, u.invisible,
|
|
70
|
+
case when ${mayName} then s.location_forum_id end as forum_id,
|
|
71
|
+
case when ${mayName} then f.title end as forum_title,
|
|
72
|
+
case when ${mayNameThread} then s.location_thread_id end as thread_id,
|
|
73
|
+
case when ${mayNameThread} then t.title end as thread_title,
|
|
74
|
+
case when ${mayNameThread} then t.slug end as thread_slug
|
|
75
|
+
from sessions s
|
|
76
|
+
left join users u on u.id = s.user_id
|
|
77
|
+
left join forums f on f.id = s.location_forum_id
|
|
78
|
+
left join threads t on t.id = s.location_thread_id
|
|
79
|
+
where s.revoked_at is null
|
|
80
|
+
and s.last_seen_at >= ${since}
|
|
81
|
+
and (s.user_id is null or u.state = 'active')
|
|
82
|
+
order by coalesce(s.user_id, -s.id), s.last_seen_at desc
|
|
83
|
+
`),
|
|
84
|
+
) as Array<Record<string, unknown>>
|
|
85
|
+
|
|
86
|
+
let guestCount = 0
|
|
87
|
+
let invisibleCount = 0
|
|
88
|
+
const members: OnlineMember[] = []
|
|
89
|
+
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
if (row.user_id === null) {
|
|
92
|
+
guestCount += 1
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const invisible = row.invisible === true
|
|
97
|
+
if (invisible) invisibleCount += 1
|
|
98
|
+
if (invisible && !scope.seesInvisible) continue
|
|
99
|
+
|
|
100
|
+
members.push({
|
|
101
|
+
userId: Number(row.user_id),
|
|
102
|
+
username: String(row.username),
|
|
103
|
+
invisible,
|
|
104
|
+
lastSeenAt: toDate(row.last_seen_at),
|
|
105
|
+
forumId: row.forum_id === null ? null : Number(row.forum_id),
|
|
106
|
+
forumTitle: row.forum_title === null ? null : String(row.forum_title),
|
|
107
|
+
threadId: row.thread_id === null ? null : Number(row.thread_id),
|
|
108
|
+
threadTitle: row.thread_title === null ? null : String(row.thread_title),
|
|
109
|
+
threadSlug: row.thread_slug === null ? null : String(row.thread_slug),
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
members,
|
|
115
|
+
guestCount,
|
|
116
|
+
total: guestCount + members.length,
|
|
117
|
+
invisibleCount: scope.seesInvisible ? invisibleCount : 0,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Records that an anonymous reader is here, under a session row with no user.
|
|
123
|
+
*
|
|
124
|
+
* The read side has counted these rows since presence was written — the
|
|
125
|
+
* `user_id` column is nullable for exactly this — but nothing ever wrote one,
|
|
126
|
+
* so every board reported zero guests forever. This is the missing half.
|
|
127
|
+
*
|
|
128
|
+
* `windowSeconds` throttles the write the way the member path does: a reader
|
|
129
|
+
* clicking through a thread should cost one update a minute, not one an
|
|
130
|
+
* article. The insert is unconditional, though, or a reader whose first page
|
|
131
|
+
* view landed inside another reader's throttle window would never appear.
|
|
132
|
+
*/
|
|
133
|
+
async touchGuest(input: {
|
|
134
|
+
readonly tokenHash: string
|
|
135
|
+
readonly location: {
|
|
136
|
+
readonly path: string
|
|
137
|
+
readonly forumId: number | null
|
|
138
|
+
readonly threadId: number | null
|
|
139
|
+
}
|
|
140
|
+
readonly now: Date
|
|
141
|
+
readonly windowSeconds: number
|
|
142
|
+
readonly expiresAt: Date
|
|
143
|
+
}): Promise<void> {
|
|
144
|
+
const cutoff = new Date(input.now.getTime() - input.windowSeconds * 1000)
|
|
145
|
+
|
|
146
|
+
await this.db.execute(sql`
|
|
147
|
+
insert into sessions
|
|
148
|
+
(token_hash, user_id, location_path, location_forum_id, location_thread_id,
|
|
149
|
+
last_seen_at, expires_at)
|
|
150
|
+
values
|
|
151
|
+
(${input.tokenHash}, null, ${input.location.path}, ${input.location.forumId},
|
|
152
|
+
${input.location.threadId}, ${input.now}, ${input.expiresAt})
|
|
153
|
+
on conflict (token_hash) do update
|
|
154
|
+
set location_path = excluded.location_path,
|
|
155
|
+
location_forum_id = excluded.location_forum_id,
|
|
156
|
+
location_thread_id = excluded.location_thread_id,
|
|
157
|
+
last_seen_at = excluded.last_seen_at,
|
|
158
|
+
expires_at = excluded.expires_at
|
|
159
|
+
where sessions.user_id is null
|
|
160
|
+
and sessions.last_seen_at < ${cutoff}
|
|
161
|
+
`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Forgets a guest row, for when the reader holding it has just signed in.
|
|
166
|
+
*
|
|
167
|
+
* Without this they are two people for the next fifteen minutes — a member in
|
|
168
|
+
* the list and a guest in the count — because the row they browsed under is
|
|
169
|
+
* still inside the window and there is nothing tying it to the account they
|
|
170
|
+
* have now proved they own.
|
|
171
|
+
*/
|
|
172
|
+
async dropGuest(tokenHash: string): Promise<void> {
|
|
173
|
+
await this.db.execute(sql`
|
|
174
|
+
delete from sessions where token_hash = ${tokenHash} and user_id is null
|
|
175
|
+
`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async concurrentCount(now: Date): Promise<number> {
|
|
179
|
+
const since = new Date(now.getTime() - ONLINE_WINDOW_MINUTES * 60_000)
|
|
180
|
+
const rows = resultRows(
|
|
181
|
+
await this.db.execute(sql`
|
|
182
|
+
select count(distinct coalesce(s.user_id, -s.id))::int as n
|
|
183
|
+
from sessions s
|
|
184
|
+
where s.revoked_at is null and s.last_seen_at >= ${since}
|
|
185
|
+
`),
|
|
186
|
+
) as Array<{ n: number }>
|
|
187
|
+
|
|
188
|
+
return rows[0]?.n ?? 0
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async recordIfHigher(count: number, now: Date): Promise<boolean> {
|
|
192
|
+
const rows = resultRows(
|
|
193
|
+
await this.db.execute(sql`
|
|
194
|
+
update board_stats
|
|
195
|
+
set most_online_count = ${count},
|
|
196
|
+
most_online_at = ${now},
|
|
197
|
+
updated_at = ${now}
|
|
198
|
+
where id = 1 and most_online_count < ${count}
|
|
199
|
+
returning most_online_count
|
|
200
|
+
`),
|
|
201
|
+
) as Array<unknown>
|
|
202
|
+
|
|
203
|
+
return rows.length > 0
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async readRecord(): Promise<OnlineRecord> {
|
|
207
|
+
const rows = resultRows(
|
|
208
|
+
await this.db.execute(sql`
|
|
209
|
+
select most_online_count, most_online_at from board_stats where id = 1
|
|
210
|
+
`),
|
|
211
|
+
) as Array<Record<string, unknown>>
|
|
212
|
+
|
|
213
|
+
const row = rows[0]
|
|
214
|
+
if (row === undefined) return { count: 0, at: null }
|
|
215
|
+
return {
|
|
216
|
+
count: Number(row.most_online_count),
|
|
217
|
+
at: row.most_online_at === null ? null : toDate(row.most_online_at),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|