@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,311 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '@meith/core'
|
|
4
|
+
import { msg } from '@meith/i18n'
|
|
5
|
+
import type { MoveDestination, ThreadToolsRepository, ThreadToolTarget } from '@meith/moderation'
|
|
6
|
+
|
|
7
|
+
import type { Database } from './client'
|
|
8
|
+
import { resultRows } from './result-rows'
|
|
9
|
+
import {
|
|
10
|
+
applyAuthorCounts,
|
|
11
|
+
applyForumChain,
|
|
12
|
+
logModeratorAction as log,
|
|
13
|
+
syncLedger,
|
|
14
|
+
tallyThread,
|
|
15
|
+
} from './thread-counters'
|
|
16
|
+
import { repairForumLastPostChain, repairThreadLastPost } from './visibility-counters'
|
|
17
|
+
|
|
18
|
+
export class PostgresThreadToolsRepository implements ThreadToolsRepository {
|
|
19
|
+
constructor(private readonly db: Database) {}
|
|
20
|
+
|
|
21
|
+
async find(threadId: number): Promise<ThreadToolTarget | null> {
|
|
22
|
+
const rows = resultRows(
|
|
23
|
+
await this.db.execute(sql`
|
|
24
|
+
select id, forum_id, slug, title, is_locked, is_sticky, visibility
|
|
25
|
+
from threads where id = ${threadId}
|
|
26
|
+
`),
|
|
27
|
+
) as Array<{
|
|
28
|
+
id: number
|
|
29
|
+
forum_id: number
|
|
30
|
+
slug: string
|
|
31
|
+
title: string
|
|
32
|
+
is_locked: boolean
|
|
33
|
+
is_sticky: boolean
|
|
34
|
+
visibility: ThreadToolTarget['visibility']
|
|
35
|
+
}>
|
|
36
|
+
|
|
37
|
+
const row = rows[0]
|
|
38
|
+
if (!row) return null
|
|
39
|
+
return {
|
|
40
|
+
id: Number(row.id),
|
|
41
|
+
forumId: Number(row.forum_id),
|
|
42
|
+
slug: row.slug,
|
|
43
|
+
title: row.title,
|
|
44
|
+
isLocked: row.is_locked,
|
|
45
|
+
isSticky: row.is_sticky,
|
|
46
|
+
visibility: row.visibility,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async findDestination(forumId: number): Promise<MoveDestination | null> {
|
|
51
|
+
const rows = resultRows(
|
|
52
|
+
await this.db.execute(sql`select id, type, allow_threads from forums where id = ${forumId}`),
|
|
53
|
+
) as Array<{ id: number; type: MoveDestination['type']; allow_threads: boolean }>
|
|
54
|
+
const row = rows[0]
|
|
55
|
+
return row === undefined
|
|
56
|
+
? null
|
|
57
|
+
: { id: Number(row.id), type: row.type, allowThreads: row.allow_threads === true }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async setLocked(input: {
|
|
61
|
+
readonly threadId: number
|
|
62
|
+
readonly locked: boolean
|
|
63
|
+
readonly actorUserId: number
|
|
64
|
+
readonly at: Date
|
|
65
|
+
}): Promise<boolean> {
|
|
66
|
+
return this.flag(
|
|
67
|
+
'is_locked',
|
|
68
|
+
input.locked,
|
|
69
|
+
input,
|
|
70
|
+
input.locked ? 'thread.lock' : 'thread.unlock',
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async setSticky(input: {
|
|
75
|
+
readonly threadId: number
|
|
76
|
+
readonly sticky: boolean
|
|
77
|
+
readonly actorUserId: number
|
|
78
|
+
readonly at: Date
|
|
79
|
+
}): Promise<boolean> {
|
|
80
|
+
return this.flag(
|
|
81
|
+
'is_sticky',
|
|
82
|
+
input.sticky,
|
|
83
|
+
input,
|
|
84
|
+
input.sticky ? 'thread.stick' : 'thread.unstick',
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async flag(
|
|
89
|
+
column: 'is_locked' | 'is_sticky',
|
|
90
|
+
value: boolean,
|
|
91
|
+
input: { readonly threadId: number; readonly actorUserId: number; readonly at: Date },
|
|
92
|
+
action: string,
|
|
93
|
+
): Promise<boolean> {
|
|
94
|
+
return this.db.transaction(async (tx) => {
|
|
95
|
+
const moved = resultRows(
|
|
96
|
+
await tx.execute(
|
|
97
|
+
column === 'is_locked'
|
|
98
|
+
? sql`update threads set is_locked = ${value}, updated_at = ${input.at}
|
|
99
|
+
where id = ${input.threadId} and is_locked <> ${value}
|
|
100
|
+
returning id, forum_id`
|
|
101
|
+
: sql`update threads set is_sticky = ${value}, updated_at = ${input.at}
|
|
102
|
+
where id = ${input.threadId} and is_sticky <> ${value}
|
|
103
|
+
returning id, forum_id`,
|
|
104
|
+
),
|
|
105
|
+
) as Array<{ id: number; forum_id: number }>
|
|
106
|
+
const row = moved[0]
|
|
107
|
+
if (!row) return false
|
|
108
|
+
|
|
109
|
+
const forumId = Number(row.forum_id)
|
|
110
|
+
await log(
|
|
111
|
+
tx,
|
|
112
|
+
action,
|
|
113
|
+
input.actorUserId,
|
|
114
|
+
{ threadId: input.threadId, forumId, forumIds: [forumId] },
|
|
115
|
+
input.at,
|
|
116
|
+
)
|
|
117
|
+
return true
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async setVisibility(input: {
|
|
122
|
+
readonly threadId: number
|
|
123
|
+
readonly from: 'visible' | 'deleted'
|
|
124
|
+
readonly to: 'visible' | 'deleted'
|
|
125
|
+
readonly actorUserId: number
|
|
126
|
+
readonly at: Date
|
|
127
|
+
}): Promise<boolean> {
|
|
128
|
+
return this.db.transaction(async (tx) => {
|
|
129
|
+
const tally = await tallyThread(tx, input.threadId)
|
|
130
|
+
|
|
131
|
+
const moved = resultRows(
|
|
132
|
+
await tx.execute(sql`
|
|
133
|
+
update threads set visibility = ${input.to}, updated_at = ${input.at}
|
|
134
|
+
where id = ${input.threadId} and visibility = ${input.from}
|
|
135
|
+
returning forum_id
|
|
136
|
+
`),
|
|
137
|
+
) as Array<{ forum_id: number }>
|
|
138
|
+
const row = moved[0]
|
|
139
|
+
if (!row) return false
|
|
140
|
+
|
|
141
|
+
const forumId = Number(row.forum_id)
|
|
142
|
+
const delta: 1 | -1 = input.to === 'visible' ? 1 : -1
|
|
143
|
+
|
|
144
|
+
await applyForumChain(tx, forumId, delta, tally)
|
|
145
|
+
await applyAuthorCounts(tx, delta, tally)
|
|
146
|
+
await syncLedger(tx, input.threadId, input.to === 'visible')
|
|
147
|
+
await repairForumLastPostChain(tx, forumId)
|
|
148
|
+
|
|
149
|
+
await log(
|
|
150
|
+
tx,
|
|
151
|
+
input.to === 'visible' ? 'thread.restore' : 'thread.delete',
|
|
152
|
+
input.actorUserId,
|
|
153
|
+
{ threadId: input.threadId, forumId, forumIds: [forumId], posts: tally.posts },
|
|
154
|
+
input.at,
|
|
155
|
+
)
|
|
156
|
+
return true
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async move(input: {
|
|
161
|
+
readonly threadId: number
|
|
162
|
+
readonly fromForumId: number
|
|
163
|
+
readonly toForumId: number
|
|
164
|
+
readonly actorUserId: number
|
|
165
|
+
readonly at: Date
|
|
166
|
+
}): Promise<boolean> {
|
|
167
|
+
return this.db.transaction(async (tx) => {
|
|
168
|
+
const tally = await tallyThread(tx, input.threadId)
|
|
169
|
+
|
|
170
|
+
const moved = resultRows(
|
|
171
|
+
await tx.execute(sql`
|
|
172
|
+
update threads set forum_id = ${input.toForumId}, updated_at = ${input.at}
|
|
173
|
+
where id = ${input.threadId} and forum_id = ${input.fromForumId}
|
|
174
|
+
returning id
|
|
175
|
+
`),
|
|
176
|
+
) as Array<{ id: number }>
|
|
177
|
+
if (moved.length === 0) return false
|
|
178
|
+
|
|
179
|
+
await tx.execute(sql`
|
|
180
|
+
update posts set forum_id = ${input.toForumId} where thread_id = ${input.threadId}
|
|
181
|
+
`)
|
|
182
|
+
|
|
183
|
+
await applyForumChain(tx, input.fromForumId, -1, tally)
|
|
184
|
+
await applyForumChain(tx, input.toForumId, 1, tally)
|
|
185
|
+
|
|
186
|
+
await repairForumLastPostChain(tx, input.fromForumId)
|
|
187
|
+
await repairForumLastPostChain(tx, input.toForumId)
|
|
188
|
+
|
|
189
|
+
await log(
|
|
190
|
+
tx,
|
|
191
|
+
'thread.move',
|
|
192
|
+
input.actorUserId,
|
|
193
|
+
{
|
|
194
|
+
threadId: input.threadId,
|
|
195
|
+
fromForumId: input.fromForumId,
|
|
196
|
+
toForumId: input.toForumId,
|
|
197
|
+
forumIds: [input.fromForumId, input.toForumId],
|
|
198
|
+
posts: tally.posts,
|
|
199
|
+
},
|
|
200
|
+
input.at,
|
|
201
|
+
)
|
|
202
|
+
return true
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async copy(input: {
|
|
207
|
+
readonly threadId: number
|
|
208
|
+
readonly toForumId: number
|
|
209
|
+
readonly actorUserId: number
|
|
210
|
+
readonly at: Date
|
|
211
|
+
}): Promise<{ threadId: number; slug: string; posts: number }> {
|
|
212
|
+
return this.db.transaction(async (tx) => {
|
|
213
|
+
const sourceRows = resultRows(
|
|
214
|
+
await tx.execute(sql`
|
|
215
|
+
select id, forum_id, title, slug, prefix_id, author_user_id, author_username
|
|
216
|
+
from threads where id = ${input.threadId}
|
|
217
|
+
`),
|
|
218
|
+
) as Array<{
|
|
219
|
+
id: number
|
|
220
|
+
forum_id: number
|
|
221
|
+
title: string
|
|
222
|
+
slug: string
|
|
223
|
+
prefix_id: number | null
|
|
224
|
+
author_user_id: number | null
|
|
225
|
+
author_username: string
|
|
226
|
+
}>
|
|
227
|
+
const source = sourceRows[0]
|
|
228
|
+
if (!source) {
|
|
229
|
+
throw new ValidationError(msg('error.db.thread-exist'))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const created = resultRows(
|
|
233
|
+
await tx.execute(sql`
|
|
234
|
+
insert into threads
|
|
235
|
+
(forum_id, title, slug, prefix_id, author_user_id, author_username,
|
|
236
|
+
visibility, created_at, updated_at)
|
|
237
|
+
values
|
|
238
|
+
(${input.toForumId}, ${source.title}, ${source.slug}, ${source.prefix_id},
|
|
239
|
+
${source.author_user_id}, ${source.author_username},
|
|
240
|
+
'visible', ${input.at}, ${input.at})
|
|
241
|
+
returning id, slug
|
|
242
|
+
`),
|
|
243
|
+
) as Array<{ id: number; slug: string }>
|
|
244
|
+
const newThreadId = Number(created[0]!.id)
|
|
245
|
+
|
|
246
|
+
const copied = resultRows(
|
|
247
|
+
await tx.execute(sql`
|
|
248
|
+
insert into posts
|
|
249
|
+
(thread_id, forum_id, author_user_id, author_username, subject, message,
|
|
250
|
+
message_html, render_version, vocab_version, body_format, visibility,
|
|
251
|
+
is_first_post, created_at, search_vector, search_version)
|
|
252
|
+
select ${newThreadId}, ${input.toForumId}, p.author_user_id, p.author_username,
|
|
253
|
+
p.subject, p.message, p.message_html, p.render_version,
|
|
254
|
+
/*
|
|
255
|
+
* Both stamps travel with the body. A copy that claimed to be
|
|
256
|
+
* Markdown because the column defaults that way would be a post
|
|
257
|
+
* whose BBCode the backfill has been told it already converted.
|
|
258
|
+
*/
|
|
259
|
+
p.vocab_version, p.body_format,
|
|
260
|
+
'visible', p.is_first_post, p.created_at,
|
|
261
|
+
/*
|
|
262
|
+
* The search document travels too, for the third time in this list
|
|
263
|
+
* and the same reason: a copy left with no vector would be a
|
|
264
|
+
* thread nobody could find until the backfill happened to
|
|
265
|
+
* reach it. Copying rather than recomputing is exact here —
|
|
266
|
+
* the copy keeps the source's title, body and opening post, so
|
|
267
|
+
* every input to the document is the same.
|
|
268
|
+
*/
|
|
269
|
+
p.search_vector, p.search_version
|
|
270
|
+
from posts p
|
|
271
|
+
where p.thread_id = ${input.threadId} and p.visibility = 'visible'
|
|
272
|
+
order by p.id
|
|
273
|
+
returning id, author_user_id, is_first_post
|
|
274
|
+
`),
|
|
275
|
+
) as Array<{ id: number; author_user_id: number | null; is_first_post: boolean }>
|
|
276
|
+
|
|
277
|
+
const first = copied.find((row) => row.is_first_post) ?? copied[0]
|
|
278
|
+
await tx.execute(sql`
|
|
279
|
+
update threads
|
|
280
|
+
set first_post_id = ${first === undefined ? null : Number(first.id)},
|
|
281
|
+
reply_count = ${Math.max(copied.length - 1, 0)}
|
|
282
|
+
where id = ${newThreadId}
|
|
283
|
+
`)
|
|
284
|
+
|
|
285
|
+
const tally = await tallyThread(tx, newThreadId)
|
|
286
|
+
await applyForumChain(tx, input.toForumId, 1, tally)
|
|
287
|
+
await applyAuthorCounts(tx, 1, tally)
|
|
288
|
+
await syncLedger(tx, newThreadId, true)
|
|
289
|
+
await repairThreadLastPost(tx, newThreadId)
|
|
290
|
+
await repairForumLastPostChain(tx, input.toForumId)
|
|
291
|
+
|
|
292
|
+
const fromForumId = Number(source.forum_id)
|
|
293
|
+
await log(
|
|
294
|
+
tx,
|
|
295
|
+
'thread.copy',
|
|
296
|
+
input.actorUserId,
|
|
297
|
+
{
|
|
298
|
+
threadId: input.threadId,
|
|
299
|
+
fromForumId,
|
|
300
|
+
toForumId: input.toForumId,
|
|
301
|
+
forumIds: [...new Set([fromForumId, input.toForumId])],
|
|
302
|
+
newThreadId,
|
|
303
|
+
posts: copied.length,
|
|
304
|
+
},
|
|
305
|
+
input.at,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return { threadId: newThreadId, slug: created[0]!.slug, posts: copied.length }
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Database } from './client'
|
|
4
|
+
import { resultRows } from './result-rows'
|
|
5
|
+
|
|
6
|
+
export class PostgresThreadViewBuffer {
|
|
7
|
+
constructor(private readonly db: Database) {}
|
|
8
|
+
|
|
9
|
+
async record(threadId: number): Promise<void> {
|
|
10
|
+
await this.db.execute(sql`
|
|
11
|
+
insert into thread_view_buffer (thread_id, pending, updated_at)
|
|
12
|
+
values (${threadId}, 1, now())
|
|
13
|
+
on conflict (thread_id) do update
|
|
14
|
+
set pending = thread_view_buffer.pending + 1,
|
|
15
|
+
updated_at = now()
|
|
16
|
+
`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async flush(limit = 500): Promise<number> {
|
|
20
|
+
const result = await this.db.execute(sql`
|
|
21
|
+
with claimed as (
|
|
22
|
+
delete from thread_view_buffer
|
|
23
|
+
where thread_id in (
|
|
24
|
+
select thread_id from thread_view_buffer
|
|
25
|
+
where pending > 0
|
|
26
|
+
order by updated_at
|
|
27
|
+
limit ${limit}
|
|
28
|
+
for update skip locked
|
|
29
|
+
)
|
|
30
|
+
returning thread_id, pending
|
|
31
|
+
)
|
|
32
|
+
update threads t
|
|
33
|
+
set view_count = t.view_count + c.pending
|
|
34
|
+
from claimed c
|
|
35
|
+
where t.id = c.thread_id
|
|
36
|
+
returning t.id
|
|
37
|
+
`)
|
|
38
|
+
|
|
39
|
+
return resultRows(result).length
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
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 { ValidatedPoll } from '@meith/polls'
|
|
12
|
+
import type {
|
|
13
|
+
CreatedThread,
|
|
14
|
+
ForumPostingTarget,
|
|
15
|
+
NewReplyRecord,
|
|
16
|
+
NewThreadRecord,
|
|
17
|
+
ReplyTarget,
|
|
18
|
+
ReplyWriteRepository,
|
|
19
|
+
ThreadWriteRepository,
|
|
20
|
+
} from '@meith/threads'
|
|
21
|
+
|
|
22
|
+
import type { Database } from './client'
|
|
23
|
+
import { applyCreatedContentCounters } from './content-counters'
|
|
24
|
+
import { resultRows } from './result-rows'
|
|
25
|
+
import { SEARCH_DOCUMENT_VERSION, searchVectorSql } from './search-repo'
|
|
26
|
+
import { readBoardVocabulary } from './vocabulary-repo'
|
|
27
|
+
|
|
28
|
+
export class PostgresThreadWriteRepository implements ThreadWriteRepository, ReplyWriteRepository {
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly db: Database,
|
|
31
|
+
private readonly rendering: MarkdownPipeline = CORE_RENDERING,
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
async postingRules(forumId: number): Promise<ForumPostingTarget | null> {
|
|
35
|
+
const rows = resultRows(
|
|
36
|
+
await this.db.execute(sql`
|
|
37
|
+
select id, type, slug, is_open, allow_threads, allow_replies, allow_polls,
|
|
38
|
+
allow_attachments, requires_prefix, moderate_new_threads,
|
|
39
|
+
moderate_new_posts
|
|
40
|
+
from forums where id = ${forumId}
|
|
41
|
+
`),
|
|
42
|
+
) as Array<{
|
|
43
|
+
id: number
|
|
44
|
+
type: 'category' | 'forum' | 'link'
|
|
45
|
+
slug: string
|
|
46
|
+
is_open: boolean
|
|
47
|
+
allow_threads: boolean
|
|
48
|
+
allow_replies: boolean
|
|
49
|
+
allow_polls: boolean
|
|
50
|
+
allow_attachments: boolean
|
|
51
|
+
requires_prefix: boolean
|
|
52
|
+
moderate_new_threads: boolean
|
|
53
|
+
moderate_new_posts: boolean
|
|
54
|
+
}>
|
|
55
|
+
|
|
56
|
+
const row = rows[0]
|
|
57
|
+
if (!row) return null
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
id: Number(row.id),
|
|
61
|
+
type: row.type,
|
|
62
|
+
slug: row.slug,
|
|
63
|
+
isOpen: row.is_open,
|
|
64
|
+
allowThreads: row.allow_threads,
|
|
65
|
+
allowReplies: row.allow_replies,
|
|
66
|
+
allowPolls: row.allow_polls,
|
|
67
|
+
allowAttachments: row.allow_attachments,
|
|
68
|
+
requiresPrefix: row.requires_prefix,
|
|
69
|
+
moderateNewThreads: row.moderate_new_threads,
|
|
70
|
+
moderateNewPosts: row.moderate_new_posts,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async create(record: NewThreadRecord): Promise<CreatedThread> {
|
|
75
|
+
const vocabulary = await readBoardVocabulary(this.db, this.rendering)
|
|
76
|
+
const body = await renderThrough(
|
|
77
|
+
this.rendering,
|
|
78
|
+
record.message,
|
|
79
|
+
{ source: 'post', viewer: authorRef(record.authorUserId) },
|
|
80
|
+
vocabularyOptions(vocabulary),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return this.db.transaction(async (tx) => {
|
|
84
|
+
const threadRows = resultRows(
|
|
85
|
+
await tx.execute(sql`
|
|
86
|
+
insert into threads
|
|
87
|
+
(forum_id, title, slug, prefix_id, author_user_id, author_username,
|
|
88
|
+
visibility, last_post_at, created_at, updated_at)
|
|
89
|
+
values
|
|
90
|
+
(${record.forumId}, ${record.title}, ${record.slug}, ${record.prefixId},
|
|
91
|
+
${record.authorUserId}, ${record.authorUsername}, ${record.visibility},
|
|
92
|
+
${record.createdAt}, ${record.createdAt}, ${record.createdAt})
|
|
93
|
+
returning id
|
|
94
|
+
`),
|
|
95
|
+
) as Array<{ id: number }>
|
|
96
|
+
const threadId = Number(threadRows[0]!.id)
|
|
97
|
+
|
|
98
|
+
const postRows = resultRows(
|
|
99
|
+
await tx.execute(sql`
|
|
100
|
+
insert into posts
|
|
101
|
+
(thread_id, forum_id, author_user_id, author_username, message,
|
|
102
|
+
message_html, render_version, vocab_version, body_format, visibility,
|
|
103
|
+
is_first_post, created_at, search_vector, search_version)
|
|
104
|
+
values
|
|
105
|
+
(${threadId}, ${record.forumId}, ${record.authorUserId},
|
|
106
|
+
${record.authorUsername}, ${record.message}, ${body.html},
|
|
107
|
+
${body.version}, ${vocabulary.revision}, ${BodyFormat.Markdown},
|
|
108
|
+
${record.visibility}, true,
|
|
109
|
+
${record.createdAt},
|
|
110
|
+
/*
|
|
111
|
+
* The **title** is the weight-A field, because this is the
|
|
112
|
+
* thread's opening post and a thread's subject is its title. The
|
|
113
|
+
* row cannot be read while it is being inserted, so the value is
|
|
114
|
+
* passed rather than derived — and it is the same value
|
|
115
|
+
* indexedSubjectSql would produce for this row (subject null,
|
|
116
|
+
* is_first_post true), which search-repo.test.ts pins so the
|
|
117
|
+
* writer and the backfill cannot drift apart.
|
|
118
|
+
*/
|
|
119
|
+
${searchVectorSql(sql`${record.title}`, sql`${record.message}`)},
|
|
120
|
+
${SEARCH_DOCUMENT_VERSION})
|
|
121
|
+
returning id
|
|
122
|
+
`),
|
|
123
|
+
) as Array<{ id: number }>
|
|
124
|
+
const postId = Number(postRows[0]!.id)
|
|
125
|
+
|
|
126
|
+
if (record.poll !== undefined) await createPoll(tx as Database, threadId, record.poll)
|
|
127
|
+
|
|
128
|
+
if (record.visibility === 'visible') {
|
|
129
|
+
await applyCreatedContentCounters(tx, {
|
|
130
|
+
postId,
|
|
131
|
+
threadId,
|
|
132
|
+
forumId: record.forumId,
|
|
133
|
+
authorId: record.authorUserId,
|
|
134
|
+
authorUsername: record.authorUsername,
|
|
135
|
+
threadTitle: record.title,
|
|
136
|
+
createdAt: record.createdAt,
|
|
137
|
+
isNewThread: true,
|
|
138
|
+
})
|
|
139
|
+
} else {
|
|
140
|
+
await tx.execute(sql`
|
|
141
|
+
update threads set first_post_id = ${postId} where id = ${threadId}
|
|
142
|
+
`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (record.subscribe) {
|
|
146
|
+
await tx.execute(sql`
|
|
147
|
+
insert into thread_subscriptions (user_id, thread_id, mode, last_notified_post_id)
|
|
148
|
+
values (${record.authorUserId}, ${threadId}, 'instant', ${postId})
|
|
149
|
+
on conflict (user_id, thread_id) do nothing
|
|
150
|
+
`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
threadId,
|
|
155
|
+
postId,
|
|
156
|
+
slug: record.slug,
|
|
157
|
+
visibility: record.visibility,
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async replyTarget(threadId: number): Promise<ReplyTarget | null> {
|
|
163
|
+
const rows = resultRows(
|
|
164
|
+
await this.db.execute(sql`
|
|
165
|
+
select t.id, t.slug, t.title, t.author_user_id, t.is_locked, t.visibility,
|
|
166
|
+
t.last_post_id, t.reply_count,
|
|
167
|
+
f.id as forum_id, f.type as forum_type, f.slug as forum_slug,
|
|
168
|
+
f.is_open, f.allow_threads, f.allow_replies, f.allow_polls,
|
|
169
|
+
f.allow_attachments, f.requires_prefix,
|
|
170
|
+
f.moderate_new_threads, f.moderate_new_posts
|
|
171
|
+
from threads t
|
|
172
|
+
join forums f on f.id = t.forum_id
|
|
173
|
+
where t.id = ${threadId}
|
|
174
|
+
`),
|
|
175
|
+
) as Array<{
|
|
176
|
+
id: number
|
|
177
|
+
slug: string
|
|
178
|
+
title: string
|
|
179
|
+
author_user_id: number | null
|
|
180
|
+
is_locked: boolean
|
|
181
|
+
visibility: 'visible' | 'unapproved' | 'deleted'
|
|
182
|
+
last_post_id: number | null
|
|
183
|
+
reply_count: number
|
|
184
|
+
forum_id: number
|
|
185
|
+
forum_type: 'category' | 'forum' | 'link'
|
|
186
|
+
forum_slug: string
|
|
187
|
+
is_open: boolean
|
|
188
|
+
allow_threads: boolean
|
|
189
|
+
allow_replies: boolean
|
|
190
|
+
allow_polls: boolean
|
|
191
|
+
allow_attachments: boolean
|
|
192
|
+
requires_prefix: boolean
|
|
193
|
+
moderate_new_threads: boolean
|
|
194
|
+
moderate_new_posts: boolean
|
|
195
|
+
}>
|
|
196
|
+
|
|
197
|
+
const row = rows[0]
|
|
198
|
+
if (!row) return null
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
threadId: Number(row.id),
|
|
202
|
+
slug: row.slug,
|
|
203
|
+
title: row.title,
|
|
204
|
+
authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
|
|
205
|
+
isLocked: row.is_locked,
|
|
206
|
+
visibility: row.visibility,
|
|
207
|
+
lastPostId: row.last_post_id === null ? null : Number(row.last_post_id),
|
|
208
|
+
replyCount: Number(row.reply_count),
|
|
209
|
+
forum: {
|
|
210
|
+
id: Number(row.forum_id),
|
|
211
|
+
type: row.forum_type,
|
|
212
|
+
slug: row.forum_slug,
|
|
213
|
+
isOpen: row.is_open,
|
|
214
|
+
allowThreads: row.allow_threads,
|
|
215
|
+
allowReplies: row.allow_replies,
|
|
216
|
+
allowPolls: row.allow_polls,
|
|
217
|
+
allowAttachments: row.allow_attachments,
|
|
218
|
+
requiresPrefix: row.requires_prefix,
|
|
219
|
+
moderateNewThreads: row.moderate_new_threads,
|
|
220
|
+
moderateNewPosts: row.moderate_new_posts,
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async createReply(record: NewReplyRecord): Promise<{ postId: number }> {
|
|
226
|
+
const vocabulary = await readBoardVocabulary(this.db, this.rendering)
|
|
227
|
+
const body = await renderThrough(
|
|
228
|
+
this.rendering,
|
|
229
|
+
record.message,
|
|
230
|
+
{ source: 'post', viewer: authorRef(record.authorUserId) },
|
|
231
|
+
vocabularyOptions(vocabulary),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return this.db.transaction(async (tx) => {
|
|
235
|
+
const postRows = resultRows(
|
|
236
|
+
await tx.execute(sql`
|
|
237
|
+
insert into posts
|
|
238
|
+
(thread_id, forum_id, author_user_id, author_username, message,
|
|
239
|
+
message_html, render_version, vocab_version, body_format, visibility,
|
|
240
|
+
is_first_post, created_at, search_vector, search_version)
|
|
241
|
+
values
|
|
242
|
+
(${record.threadId}, ${record.forumId}, ${record.authorUserId},
|
|
243
|
+
${record.authorUsername}, ${record.message}, ${body.html},
|
|
244
|
+
${body.version}, ${vocabulary.revision}, ${BodyFormat.Markdown},
|
|
245
|
+
${record.visibility}, false,
|
|
246
|
+
${record.createdAt},
|
|
247
|
+
/*
|
|
248
|
+
* No weight-A field, and that is the rule rather than an
|
|
249
|
+
* omission: a reply has no subject of its own, and folding the
|
|
250
|
+
* thread's title in here would make one title term match every
|
|
251
|
+
* post in the thread — forty hits that are all the same thread,
|
|
252
|
+
* where the opening post already stands for it.
|
|
253
|
+
*/
|
|
254
|
+
${searchVectorSql(sql`${null}`, sql`${record.message}`)},
|
|
255
|
+
${SEARCH_DOCUMENT_VERSION})
|
|
256
|
+
returning id
|
|
257
|
+
`),
|
|
258
|
+
) as Array<{ id: number }>
|
|
259
|
+
const postId = Number(postRows[0]!.id)
|
|
260
|
+
|
|
261
|
+
if (record.visibility === 'visible') {
|
|
262
|
+
await applyCreatedContentCounters(tx, {
|
|
263
|
+
postId,
|
|
264
|
+
threadId: record.threadId,
|
|
265
|
+
forumId: record.forumId,
|
|
266
|
+
authorId: record.authorUserId,
|
|
267
|
+
authorUsername: record.authorUsername,
|
|
268
|
+
threadTitle: record.threadTitle,
|
|
269
|
+
createdAt: record.createdAt,
|
|
270
|
+
isNewThread: false,
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (record.subscribe) {
|
|
275
|
+
await tx.execute(sql`
|
|
276
|
+
insert into thread_subscriptions (user_id, thread_id, mode, last_notified_post_id)
|
|
277
|
+
values (${record.authorUserId}, ${record.threadId}, 'instant', ${postId})
|
|
278
|
+
on conflict (user_id, thread_id) do nothing
|
|
279
|
+
`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { postId }
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async lastPostAt(userId: number): Promise<Date | null> {
|
|
287
|
+
const rows = resultRows(
|
|
288
|
+
await this.db.execute(sql`
|
|
289
|
+
select created_at from posts
|
|
290
|
+
where author_user_id = ${userId} and visibility <> 'deleted'
|
|
291
|
+
order by created_at desc
|
|
292
|
+
limit 1
|
|
293
|
+
`),
|
|
294
|
+
) as Array<{ created_at: Date }>
|
|
295
|
+
|
|
296
|
+
const at = rows[0]?.created_at
|
|
297
|
+
return at === undefined ? null : new Date(at)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async allowedPrefixIds(forumId: number): Promise<readonly number[]> {
|
|
301
|
+
const rows = resultRows(
|
|
302
|
+
await this.db.execute(sql`
|
|
303
|
+
select p.id
|
|
304
|
+
from thread_prefixes p
|
|
305
|
+
left join forums f on f.id = ${forumId}
|
|
306
|
+
where p.forum_path_prefix is null
|
|
307
|
+
or f.path = p.forum_path_prefix
|
|
308
|
+
or f.path like p.forum_path_prefix || '.%'
|
|
309
|
+
order by p.display_order, p.id
|
|
310
|
+
`),
|
|
311
|
+
) as Array<{ id: number }>
|
|
312
|
+
|
|
313
|
+
return rows.map((row) => Number(row.id))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async listPrefixes(
|
|
317
|
+
forumId: number,
|
|
318
|
+
): Promise<readonly { id: number; label: string; token: string | null }[]> {
|
|
319
|
+
const rows = resultRows(
|
|
320
|
+
await this.db.execute(sql`
|
|
321
|
+
select p.id, p.label, p.token
|
|
322
|
+
from thread_prefixes p
|
|
323
|
+
left join forums f on f.id = ${forumId}
|
|
324
|
+
where p.forum_path_prefix is null
|
|
325
|
+
or f.path = p.forum_path_prefix
|
|
326
|
+
or f.path like p.forum_path_prefix || '.%'
|
|
327
|
+
order by p.display_order, p.id
|
|
328
|
+
`),
|
|
329
|
+
) as Array<{ id: number; label: string; token: string | null }>
|
|
330
|
+
|
|
331
|
+
return rows.map((row) => ({
|
|
332
|
+
id: Number(row.id),
|
|
333
|
+
label: row.label,
|
|
334
|
+
token: row.token,
|
|
335
|
+
}))
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function createPoll(tx: Database, threadId: number, poll: ValidatedPoll): Promise<void> {
|
|
340
|
+
const rows = resultRows(
|
|
341
|
+
await tx.execute(sql`
|
|
342
|
+
insert into polls (thread_id, question, closes_at, max_options, allow_revote, public_votes)
|
|
343
|
+
values (${threadId}, ${poll.question}, ${poll.closesAt}, ${poll.maxOptions},
|
|
344
|
+
${poll.allowRevote}, ${poll.publicVotes}) returning id
|
|
345
|
+
`),
|
|
346
|
+
) as Array<{ id: number }>
|
|
347
|
+
const pollId = rows[0]?.id
|
|
348
|
+
if (pollId === undefined) throw new Error('Poll insert returned no row.')
|
|
349
|
+
await tx.execute(sql`
|
|
350
|
+
insert into poll_options (poll_id, label, display_order)
|
|
351
|
+
values ${sql.join(
|
|
352
|
+
poll.options.map((label, index) => sql`(${pollId}, ${label}, ${index})`),
|
|
353
|
+
sql`, `,
|
|
354
|
+
)}
|
|
355
|
+
`)
|
|
356
|
+
}
|