@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,263 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Database } from './client'
|
|
4
|
+
import { resultRows } from './result-rows'
|
|
5
|
+
|
|
6
|
+
export const RECOUNT_PHASES = ['threads', 'forums', 'users'] as const
|
|
7
|
+
export type RecountPhase = (typeof RECOUNT_PHASES)[number]
|
|
8
|
+
|
|
9
|
+
export interface RecountRun {
|
|
10
|
+
readonly phase: RecountPhase
|
|
11
|
+
readonly scanned: number
|
|
12
|
+
readonly corrected: number
|
|
13
|
+
readonly cursor: number
|
|
14
|
+
readonly nextPhase: RecountPhase
|
|
15
|
+
readonly completedPass: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PhaseState {
|
|
19
|
+
phase: RecountPhase
|
|
20
|
+
cursor: number
|
|
21
|
+
passes: number
|
|
22
|
+
corrected: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function nextPhase(phase: RecountPhase): RecountPhase {
|
|
26
|
+
const index = RECOUNT_PHASES.indexOf(phase)
|
|
27
|
+
return RECOUNT_PHASES[(index + 1) % RECOUNT_PHASES.length]!
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class PostgresCounterRecount {
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly db: Database,
|
|
33
|
+
private readonly stateId = 'default',
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
async run(batchSize = 500): Promise<RecountRun> {
|
|
37
|
+
const state = await this.loadState()
|
|
38
|
+
|
|
39
|
+
const batch = await this.scan(state.phase, state.cursor, batchSize)
|
|
40
|
+
const corrected =
|
|
41
|
+
batch.ids.length === 0 ? 0 : await this.correct(state.phase, state.cursor, batchSize)
|
|
42
|
+
|
|
43
|
+
const phaseComplete = batch.ids.length < batchSize
|
|
44
|
+
const phase = phaseComplete ? nextPhase(state.phase) : state.phase
|
|
45
|
+
const cursor = phaseComplete ? 0 : batch.maxId
|
|
46
|
+
const completedPass = phaseComplete && state.phase === RECOUNT_PHASES[RECOUNT_PHASES.length - 1]
|
|
47
|
+
|
|
48
|
+
await this.db.execute(sql`
|
|
49
|
+
update counter_recount_state
|
|
50
|
+
set phase = ${phase},
|
|
51
|
+
cursor = ${cursor},
|
|
52
|
+
passes = ${state.passes + (completedPass ? 1 : 0)},
|
|
53
|
+
corrected = ${state.corrected + corrected},
|
|
54
|
+
updated_at = now()
|
|
55
|
+
where id = ${this.stateId}
|
|
56
|
+
`)
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
phase: state.phase,
|
|
60
|
+
scanned: batch.ids.length,
|
|
61
|
+
corrected,
|
|
62
|
+
cursor,
|
|
63
|
+
nextPhase: phase,
|
|
64
|
+
completedPass,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async state(): Promise<PhaseState> {
|
|
69
|
+
return this.loadState()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async loadState(): Promise<PhaseState> {
|
|
73
|
+
await this.db.execute(sql`
|
|
74
|
+
insert into counter_recount_state (id) values (${this.stateId})
|
|
75
|
+
on conflict (id) do nothing
|
|
76
|
+
`)
|
|
77
|
+
|
|
78
|
+
const rows = resultRows(
|
|
79
|
+
await this.db.execute(sql`
|
|
80
|
+
select phase, cursor, passes, corrected
|
|
81
|
+
from counter_recount_state where id = ${this.stateId}
|
|
82
|
+
`),
|
|
83
|
+
) as Array<{ phase: string; cursor: number; passes: number; corrected: number }>
|
|
84
|
+
|
|
85
|
+
const row = rows[0]
|
|
86
|
+
const phase = RECOUNT_PHASES.find((p) => p === row?.phase) ?? 'threads'
|
|
87
|
+
return {
|
|
88
|
+
phase,
|
|
89
|
+
cursor: Number(row?.cursor ?? 0),
|
|
90
|
+
passes: Number(row?.passes ?? 0),
|
|
91
|
+
corrected: Number(row?.corrected ?? 0),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async scan(
|
|
96
|
+
phase: RecountPhase,
|
|
97
|
+
cursor: number,
|
|
98
|
+
batchSize: number,
|
|
99
|
+
): Promise<{ ids: number[]; maxId: number }> {
|
|
100
|
+
const table = { threads: sql`threads`, forums: sql`forums`, users: sql`users` }[phase]
|
|
101
|
+
const rows = resultRows(
|
|
102
|
+
await this.db.execute(sql`
|
|
103
|
+
select id from ${table} where id > ${cursor} order by id limit ${batchSize}
|
|
104
|
+
`),
|
|
105
|
+
) as Array<{ id: number }>
|
|
106
|
+
|
|
107
|
+
const ids = rows.map((r) => Number(r.id))
|
|
108
|
+
return { ids, maxId: ids.length === 0 ? cursor : Math.max(...ids) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async correct(phase: RecountPhase, cursor: number, batchSize: number): Promise<number> {
|
|
112
|
+
if (phase === 'threads') return this.correctThreads(cursor, batchSize)
|
|
113
|
+
if (phase === 'forums') return this.correctForums(cursor, batchSize)
|
|
114
|
+
return this.correctUsers(cursor, batchSize)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async correctThreads(cursor: number, batchSize: number): Promise<number> {
|
|
118
|
+
const result = await this.db.execute(sql`
|
|
119
|
+
with batch as (
|
|
120
|
+
select id, created_at from threads where id > ${cursor} order by id limit ${batchSize}
|
|
121
|
+
),
|
|
122
|
+
agg as (
|
|
123
|
+
select b.id,
|
|
124
|
+
b.created_at,
|
|
125
|
+
count(p.id)::int as post_count,
|
|
126
|
+
min(p.id) as first_post_id,
|
|
127
|
+
max(p.id) as last_post_id
|
|
128
|
+
from batch b
|
|
129
|
+
left join posts p on p.thread_id = b.id and p.visibility = 'visible'
|
|
130
|
+
group by b.id, b.created_at
|
|
131
|
+
),
|
|
132
|
+
truth as (
|
|
133
|
+
select a.id,
|
|
134
|
+
greatest(a.post_count - 1, 0) as reply_count,
|
|
135
|
+
a.first_post_id,
|
|
136
|
+
a.last_post_id,
|
|
137
|
+
lp.author_user_id as last_post_user_id,
|
|
138
|
+
lp.author_username as last_post_username,
|
|
139
|
+
coalesce(lp.created_at, a.created_at) as last_post_at
|
|
140
|
+
from agg a
|
|
141
|
+
left join posts lp on lp.id = a.last_post_id
|
|
142
|
+
)
|
|
143
|
+
update threads t
|
|
144
|
+
set reply_count = truth.reply_count,
|
|
145
|
+
first_post_id = truth.first_post_id,
|
|
146
|
+
last_post_id = truth.last_post_id,
|
|
147
|
+
last_post_user_id = truth.last_post_user_id,
|
|
148
|
+
last_post_username = truth.last_post_username,
|
|
149
|
+
last_post_at = truth.last_post_at,
|
|
150
|
+
updated_at = now()
|
|
151
|
+
from truth
|
|
152
|
+
where t.id = truth.id
|
|
153
|
+
and (t.reply_count is distinct from truth.reply_count
|
|
154
|
+
or t.first_post_id is distinct from truth.first_post_id
|
|
155
|
+
or t.last_post_id is distinct from truth.last_post_id
|
|
156
|
+
or t.last_post_user_id is distinct from truth.last_post_user_id
|
|
157
|
+
or t.last_post_username is distinct from truth.last_post_username
|
|
158
|
+
or t.last_post_at is distinct from truth.last_post_at)
|
|
159
|
+
returning t.id
|
|
160
|
+
`)
|
|
161
|
+
|
|
162
|
+
return resultRows(result).length
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async correctForums(cursor: number, batchSize: number): Promise<number> {
|
|
166
|
+
const result = await this.db.execute(sql`
|
|
167
|
+
with batch as (
|
|
168
|
+
select id, path from forums where id > ${cursor} order by id limit ${batchSize}
|
|
169
|
+
),
|
|
170
|
+
subtree as (
|
|
171
|
+
select b.id as root_id, d.id as forum_id
|
|
172
|
+
from batch b
|
|
173
|
+
join forums d on d.id = b.id or d.path like b.path || '.%'
|
|
174
|
+
),
|
|
175
|
+
thread_agg as (
|
|
176
|
+
select s.root_id, count(*)::int as thread_count
|
|
177
|
+
from subtree s
|
|
178
|
+
join threads th on th.forum_id = s.forum_id and th.visibility = 'visible'
|
|
179
|
+
group by s.root_id
|
|
180
|
+
),
|
|
181
|
+
post_agg as (
|
|
182
|
+
select s.root_id, count(*)::int as post_count
|
|
183
|
+
from subtree s
|
|
184
|
+
join posts p on p.forum_id = s.forum_id and p.visibility = 'visible'
|
|
185
|
+
join threads th on th.id = p.thread_id and th.visibility = 'visible'
|
|
186
|
+
group by s.root_id
|
|
187
|
+
),
|
|
188
|
+
last_agg as (
|
|
189
|
+
select distinct on (s.root_id)
|
|
190
|
+
s.root_id, p.id, p.thread_id, th.title, p.author_user_id,
|
|
191
|
+
p.author_username, p.created_at
|
|
192
|
+
from subtree s
|
|
193
|
+
join posts p on p.forum_id = s.forum_id and p.visibility = 'visible'
|
|
194
|
+
join threads th on th.id = p.thread_id and th.visibility = 'visible'
|
|
195
|
+
order by s.root_id, p.created_at desc, p.id desc
|
|
196
|
+
)
|
|
197
|
+
update forums f
|
|
198
|
+
set thread_count = coalesce(ta.thread_count, 0),
|
|
199
|
+
post_count = coalesce(pa.post_count, 0),
|
|
200
|
+
last_post_id = la.id,
|
|
201
|
+
last_post_thread_id = la.thread_id,
|
|
202
|
+
last_post_thread_title = la.title,
|
|
203
|
+
last_post_user_id = la.author_user_id,
|
|
204
|
+
last_post_username = la.author_username,
|
|
205
|
+
last_post_at = la.created_at,
|
|
206
|
+
updated_at = now()
|
|
207
|
+
from batch b
|
|
208
|
+
left join thread_agg ta on ta.root_id = b.id
|
|
209
|
+
left join post_agg pa on pa.root_id = b.id
|
|
210
|
+
left join last_agg la on la.root_id = b.id
|
|
211
|
+
where f.id = b.id
|
|
212
|
+
and (f.thread_count is distinct from coalesce(ta.thread_count, 0)
|
|
213
|
+
or f.post_count is distinct from coalesce(pa.post_count, 0)
|
|
214
|
+
or f.last_post_id is distinct from la.id
|
|
215
|
+
or f.last_post_thread_id is distinct from la.thread_id
|
|
216
|
+
or f.last_post_thread_title is distinct from la.title
|
|
217
|
+
or f.last_post_user_id is distinct from la.author_user_id
|
|
218
|
+
or f.last_post_username is distinct from la.author_username
|
|
219
|
+
or f.last_post_at is distinct from la.created_at)
|
|
220
|
+
returning f.id
|
|
221
|
+
`)
|
|
222
|
+
|
|
223
|
+
return resultRows(result).length
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async correctUsers(cursor: number, batchSize: number): Promise<number> {
|
|
227
|
+
const result = await this.db.execute(sql`
|
|
228
|
+
with batch as (
|
|
229
|
+
select id from users where id > ${cursor} order by id limit ${batchSize}
|
|
230
|
+
),
|
|
231
|
+
-- FILTER, not WHERE: a user whose every post sits in a deleted thread
|
|
232
|
+
-- must still appear in this aggregate with a count of zero. Moving the
|
|
233
|
+
-- predicate into the WHERE clause drops their row entirely, and the
|
|
234
|
+
-- update would then leave exactly the stale count this is meant to fix.
|
|
235
|
+
post_agg as (
|
|
236
|
+
select b.id,
|
|
237
|
+
(count(p.id) filter (where pt.visibility = 'visible'))::int as post_count
|
|
238
|
+
from batch b
|
|
239
|
+
left join posts p on p.author_user_id = b.id and p.visibility = 'visible'
|
|
240
|
+
left join threads pt on pt.id = p.thread_id
|
|
241
|
+
group by b.id
|
|
242
|
+
),
|
|
243
|
+
thread_agg as (
|
|
244
|
+
select b.id, count(th.id)::int as thread_count
|
|
245
|
+
from batch b
|
|
246
|
+
left join threads th on th.author_user_id = b.id and th.visibility = 'visible'
|
|
247
|
+
group by b.id
|
|
248
|
+
)
|
|
249
|
+
update users u
|
|
250
|
+
set post_count = pa.post_count,
|
|
251
|
+
thread_count = ta.thread_count,
|
|
252
|
+
updated_at = now()
|
|
253
|
+
from post_agg pa
|
|
254
|
+
join thread_agg ta on ta.id = pa.id
|
|
255
|
+
where u.id = pa.id
|
|
256
|
+
and (u.post_count is distinct from pa.post_count
|
|
257
|
+
or u.thread_count is distinct from ta.thread_count)
|
|
258
|
+
returning u.id
|
|
259
|
+
`)
|
|
260
|
+
|
|
261
|
+
return resultRows(result).length
|
|
262
|
+
}
|
|
263
|
+
}
|
package/src/cursor.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function encodeCursor(at: Date, id: number): string {
|
|
2
|
+
return Buffer.from(`${at.toISOString()}|${id}`, 'utf8').toString('base64url')
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function decodeCursor(value: string): { at: Date; id: number } | null {
|
|
6
|
+
try {
|
|
7
|
+
const [at, id] = Buffer.from(value, 'base64url').toString('utf8').split('|')
|
|
8
|
+
if (at === undefined || id === undefined) return null
|
|
9
|
+
const when = new Date(at)
|
|
10
|
+
const numeric = Number(id)
|
|
11
|
+
if (Number.isNaN(when.getTime()) || !Number.isSafeInteger(numeric)) return null
|
|
12
|
+
return { at: when, id: numeric }
|
|
13
|
+
} catch {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Tx } from './permission-version'
|
|
4
|
+
|
|
5
|
+
export interface DenormalisedUsernameColumn {
|
|
6
|
+
readonly table: string
|
|
7
|
+
readonly column: string
|
|
8
|
+
readonly idColumn: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const DENORMALISED_USERNAME_COLUMNS: readonly DenormalisedUsernameColumn[] = [
|
|
12
|
+
{ table: 'forums', column: 'last_post_username', idColumn: 'last_post_user_id' },
|
|
13
|
+
{ table: 'threads', column: 'last_post_username', idColumn: 'last_post_user_id' },
|
|
14
|
+
{ table: 'threads', column: 'author_username', idColumn: 'author_user_id' },
|
|
15
|
+
{ table: 'posts', column: 'author_username', idColumn: 'author_user_id' },
|
|
16
|
+
{ table: 'private_messages', column: 'author_username', idColumn: 'author_user_id' },
|
|
17
|
+
{ table: 'announcements', column: 'author_username', idColumn: 'author_user_id' },
|
|
18
|
+
{ table: 'board_stats', column: 'newest_username', idColumn: 'newest_user_id' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
export async function rewriteDenormalisedUsernames(
|
|
22
|
+
tx: Tx,
|
|
23
|
+
userId: number,
|
|
24
|
+
username: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
for (const entry of DENORMALISED_USERNAME_COLUMNS) {
|
|
27
|
+
await tx.execute(sql`
|
|
28
|
+
update ${sql.raw(entry.table)}
|
|
29
|
+
set ${sql.raw(entry.column)} = ${username}
|
|
30
|
+
where ${sql.raw(entry.idColumn)} = ${userId}
|
|
31
|
+
and ${sql.raw(entry.column)} is distinct from ${username}
|
|
32
|
+
`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { type SQL, 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 { audienceIsEmpty, inAudience } from './thread-audience'
|
|
8
|
+
import { visibleIn } from './visibility'
|
|
9
|
+
|
|
10
|
+
export interface DiscoveryScope {
|
|
11
|
+
readonly forumIds: readonly number[]
|
|
12
|
+
readonly ownThreadsOnlyForumIds: readonly number[]
|
|
13
|
+
readonly content: ContentScope
|
|
14
|
+
readonly viewerUserId: number | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DiscoveryRow {
|
|
18
|
+
readonly threadId: number
|
|
19
|
+
readonly forumId: number
|
|
20
|
+
readonly forumTitle: string
|
|
21
|
+
readonly forumSlug: string
|
|
22
|
+
readonly title: string
|
|
23
|
+
readonly slug: string
|
|
24
|
+
readonly authorUserId: number | null
|
|
25
|
+
readonly authorUsername: string
|
|
26
|
+
readonly replyCount: number
|
|
27
|
+
readonly lastPostAt: Date
|
|
28
|
+
readonly lastPostUsername: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DiscoveryPage {
|
|
32
|
+
readonly rows: readonly DiscoveryRow[]
|
|
33
|
+
readonly nextCursor: { readonly at: Date; readonly threadId: number } | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DiscoveryQuery {
|
|
37
|
+
readonly limit: number
|
|
38
|
+
readonly after: { readonly at: Date; readonly threadId: number } | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class PostgresDiscoveryRepository {
|
|
42
|
+
constructor(private readonly db: Database) {}
|
|
43
|
+
|
|
44
|
+
async activeSince(
|
|
45
|
+
since: Date,
|
|
46
|
+
query: DiscoveryQuery,
|
|
47
|
+
scope: DiscoveryScope,
|
|
48
|
+
): Promise<DiscoveryPage> {
|
|
49
|
+
return this.page([sql`t.last_post_at >= ${since}`], query, scope)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async unanswered(query: DiscoveryQuery, scope: DiscoveryScope): Promise<DiscoveryPage> {
|
|
53
|
+
return this.page([sql`t.reply_count = 0`], query, scope)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async startedBy(
|
|
57
|
+
userId: number,
|
|
58
|
+
query: DiscoveryQuery,
|
|
59
|
+
scope: DiscoveryScope,
|
|
60
|
+
): Promise<DiscoveryPage> {
|
|
61
|
+
return this.page([sql`t.author_user_id = ${userId}`], query, scope)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async participatedIn(
|
|
65
|
+
userId: number,
|
|
66
|
+
query: DiscoveryQuery,
|
|
67
|
+
scope: DiscoveryScope,
|
|
68
|
+
): Promise<DiscoveryPage> {
|
|
69
|
+
return this.page(
|
|
70
|
+
[
|
|
71
|
+
sql`exists (
|
|
72
|
+
select 1 from posts p
|
|
73
|
+
where p.thread_id = t.id and p.author_user_id = ${userId}
|
|
74
|
+
and ${visibleIn(sql`p.visibility`, scope.content)}
|
|
75
|
+
)`,
|
|
76
|
+
],
|
|
77
|
+
query,
|
|
78
|
+
scope,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async page(
|
|
83
|
+
conditions: readonly SQL[],
|
|
84
|
+
query: DiscoveryQuery,
|
|
85
|
+
scope: DiscoveryScope,
|
|
86
|
+
): Promise<DiscoveryPage> {
|
|
87
|
+
if (audienceIsEmpty(scope)) return { rows: [], nextCursor: null }
|
|
88
|
+
|
|
89
|
+
const where: SQL[] = [
|
|
90
|
+
inAudience(sql`t.forum_id`, sql`t.author_user_id`, scope),
|
|
91
|
+
visibleIn(sql`t.visibility`, scope.content),
|
|
92
|
+
...conditions,
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
if (query.after !== null) {
|
|
96
|
+
where.push(
|
|
97
|
+
sql`(t.last_post_at < ${query.after.at}
|
|
98
|
+
or (t.last_post_at = ${query.after.at} and t.id < ${query.after.threadId}))`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const rows = resultRows(
|
|
103
|
+
await this.db.execute(sql`
|
|
104
|
+
select t.id as thread_id, t.forum_id, f.title as forum_title,
|
|
105
|
+
f.slug as forum_slug,
|
|
106
|
+
t.title, t.slug, t.author_user_id, t.author_username,
|
|
107
|
+
t.reply_count, t.last_post_at, t.last_post_username
|
|
108
|
+
from threads t
|
|
109
|
+
join forums f on f.id = t.forum_id
|
|
110
|
+
where ${sql.join(where, sql` and `)}
|
|
111
|
+
order by t.last_post_at desc, t.id desc
|
|
112
|
+
limit ${query.limit}
|
|
113
|
+
`),
|
|
114
|
+
) as Array<Record<string, unknown>>
|
|
115
|
+
|
|
116
|
+
const mapped: DiscoveryRow[] = rows.map((row) => ({
|
|
117
|
+
threadId: Number(row.thread_id),
|
|
118
|
+
forumId: Number(row.forum_id),
|
|
119
|
+
forumTitle: String(row.forum_title),
|
|
120
|
+
forumSlug: String(row.forum_slug),
|
|
121
|
+
title: String(row.title),
|
|
122
|
+
slug: String(row.slug),
|
|
123
|
+
authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
|
|
124
|
+
authorUsername: String(row.author_username),
|
|
125
|
+
replyCount: Number(row.reply_count),
|
|
126
|
+
lastPostAt:
|
|
127
|
+
row.last_post_at instanceof Date ? row.last_post_at : new Date(String(row.last_post_at)),
|
|
128
|
+
lastPostUsername: row.last_post_username === null ? null : String(row.last_post_username),
|
|
129
|
+
}))
|
|
130
|
+
|
|
131
|
+
const last = mapped.at(-1)
|
|
132
|
+
return {
|
|
133
|
+
rows: mapped,
|
|
134
|
+
nextCursor:
|
|
135
|
+
mapped.length < query.limit || last === undefined
|
|
136
|
+
? null
|
|
137
|
+
: { at: last.lastPostAt, threadId: last.threadId },
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Draft, DraftRepository } from '@meith/drafts'
|
|
4
|
+
import { BodyFormat, sourceAsMarkdown } from '@meith/markdown'
|
|
5
|
+
|
|
6
|
+
import type { Database } from './client'
|
|
7
|
+
import { resultRows } from './result-rows'
|
|
8
|
+
|
|
9
|
+
export class PostgresDraftRepository implements DraftRepository {
|
|
10
|
+
constructor(private readonly db: Database) {}
|
|
11
|
+
|
|
12
|
+
async find(userId: number, forumId: number, threadId: number | null): Promise<Draft | null> {
|
|
13
|
+
const rows = resultRows(
|
|
14
|
+
await this.db.execute(sql`
|
|
15
|
+
select forum_id, thread_id, title, message, body_format, prefix_id, updated_at from post_drafts
|
|
16
|
+
where user_id = ${userId} and forum_id = ${forumId}
|
|
17
|
+
and thread_id is not distinct from ${threadId}
|
|
18
|
+
`),
|
|
19
|
+
) as Array<{
|
|
20
|
+
forum_id: number
|
|
21
|
+
thread_id: number | null
|
|
22
|
+
title: string
|
|
23
|
+
message: string
|
|
24
|
+
body_format: number
|
|
25
|
+
prefix_id: number | null
|
|
26
|
+
updated_at: Date | string
|
|
27
|
+
}>
|
|
28
|
+
const row = rows[0]
|
|
29
|
+
return row === undefined
|
|
30
|
+
? null
|
|
31
|
+
: {
|
|
32
|
+
forumId: Number(row.forum_id),
|
|
33
|
+
threadId: row.thread_id === null ? null : Number(row.thread_id),
|
|
34
|
+
title: row.title,
|
|
35
|
+
message: sourceAsMarkdown(row.message, Number(row.body_format)),
|
|
36
|
+
prefixId: row.prefix_id === null ? null : Number(row.prefix_id),
|
|
37
|
+
updatedAt: new Date(row.updated_at),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async save(userId: number, draft: Draft): Promise<void> {
|
|
42
|
+
await this.db.execute(sql`
|
|
43
|
+
insert into post_drafts (user_id, forum_id, thread_id, title, message, body_format, prefix_id)
|
|
44
|
+
values (${userId}, ${draft.forumId}, ${draft.threadId}, ${draft.title}, ${draft.message}, ${BodyFormat.Markdown}, ${draft.prefixId})
|
|
45
|
+
on conflict (${sql.raw(draft.threadId === null ? 'user_id, forum_id' : 'user_id, thread_id')})
|
|
46
|
+
where ${sql.raw(draft.threadId === null ? 'thread_id is null' : 'thread_id is not null')}
|
|
47
|
+
do update set title = excluded.title, message = excluded.message,
|
|
48
|
+
body_format = excluded.body_format, prefix_id = excluded.prefix_id,
|
|
49
|
+
updated_at = now()
|
|
50
|
+
`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async remove(userId: number, forumId: number, threadId: number | null): Promise<void> {
|
|
54
|
+
await this.db.execute(sql`
|
|
55
|
+
delete from post_drafts where user_id = ${userId} and forum_id = ${forumId}
|
|
56
|
+
and thread_id is not distinct from ${threadId}
|
|
57
|
+
`)
|
|
58
|
+
}
|
|
59
|
+
}
|