@meith/db 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/LICENSE.md +165 -0
  2. package/migrations/0000_initial_schema.sql +538 -0
  3. package/migrations/0001_seed_usergroups.sql +111 -0
  4. package/migrations/0002_group_promotions.sql +25 -0
  5. package/migrations/0003_counter_maintenance.sql +34 -0
  6. package/migrations/0004_post_render_cache.sql +7 -0
  7. package/migrations/0005_reports.sql +51 -0
  8. package/migrations/0006_warnings.sql +91 -0
  9. package/migrations/0007_notifications.sql +44 -0
  10. package/migrations/0008_subscriptions.sql +48 -0
  11. package/migrations/0009_member_preferences.sql +13 -0
  12. package/migrations/0010_profile_fields.sql +53 -0
  13. package/migrations/0011_private_messages.sql +63 -0
  14. package/migrations/0012_user_relations.sql +18 -0
  15. package/migrations/0013_reputation.sql +45 -0
  16. package/migrations/0014_signatures.sql +16 -0
  17. package/migrations/0015_admin_sessions.sql +24 -0
  18. package/migrations/0016_attachments.sql +51 -0
  19. package/migrations/0017_avatars.sql +33 -0
  20. package/migrations/0018_mass_mail.sql +15 -0
  21. package/migrations/0019_word_filters.sql +8 -0
  22. package/migrations/0020_searches.sql +15 -0
  23. package/migrations/0021_presence_stats.sql +18 -0
  24. package/migrations/0022_api_tokens_webhooks.sql +58 -0
  25. package/migrations/0023_install_state.sql +6 -0
  26. package/migrations/0024_component_versions.sql +13 -0
  27. package/migrations/0025_import.sql +25 -0
  28. package/migrations/0026_polls.sql +40 -0
  29. package/migrations/0027_drafts.sql +13 -0
  30. package/migrations/0028_board_vocabulary.sql +32 -0
  31. package/migrations/0029_announcements.sql +16 -0
  32. package/migrations/0030_antispam.sql +18 -0
  33. package/migrations/0031_markdown.sql +24 -0
  34. package/migrations/0032_theme_enablement.sql +1 -0
  35. package/migrations/0033_group_identity.sql +5 -0
  36. package/migrations/0034_search_document_version.sql +4 -0
  37. package/migrations/0035_categories_hold_no_threads.sql +1 -0
  38. package/migrations/0036_expiring_group_grants.sql +6 -0
  39. package/migrations/0037_automatic_timezone.sql +3 -0
  40. package/migrations/0038_primary_group_grants.sql +3 -0
  41. package/migrations/0039_moderator_log_forum_scope.sql +1 -0
  42. package/migrations/0040_moderator_restore_grants.sql +1 -0
  43. package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
  44. package/migrations/0042_drop_placebo_delete_others.sql +2 -0
  45. package/migrations/0043_federated_identity.sql +28 -0
  46. package/migrations/0044_second_factor.sql +34 -0
  47. package/migrations/0045_drop_dead_task_due_index.sql +1 -0
  48. package/migrations/0046_member_locale.sql +1 -0
  49. package/migrations/0047_navigation_items.sql +34 -0
  50. package/migrations/0048_web_push.sql +16 -0
  51. package/migrations/0049_plugin_health.sql +7 -0
  52. package/migrations/0050_drop_forum_password_grants.sql +1 -0
  53. package/migrations/0051_glossy_barracuda.sql +1 -0
  54. package/migrations/0052_many_professor_monster.sql +2 -0
  55. package/migrations/0053_strange_maximus.sql +14 -0
  56. package/migrations/0054_legacy_import_sources.sql +25 -0
  57. package/migrations/0055_poll_parity.sql +14 -0
  58. package/migrations/0056_marketplace_catalog.sql +11 -0
  59. package/migrations/meta/0000_snapshot.json +4412 -0
  60. package/migrations/meta/0006_snapshot.json +5411 -0
  61. package/migrations/meta/0007_snapshot.json +5634 -0
  62. package/migrations/meta/0008_snapshot.json +5779 -0
  63. package/migrations/meta/0009_snapshot.json +5816 -0
  64. package/migrations/meta/0010_snapshot.json +6117 -0
  65. package/migrations/meta/0011_snapshot.json +6406 -0
  66. package/migrations/meta/0012_snapshot.json +6517 -0
  67. package/migrations/meta/0013_snapshot.json +6722 -0
  68. package/migrations/meta/0014_snapshot.json +6777 -0
  69. package/migrations/meta/0015_snapshot.json +6914 -0
  70. package/migrations/meta/0016_snapshot.json +7192 -0
  71. package/migrations/meta/0017_snapshot.json +7248 -0
  72. package/migrations/meta/0018_snapshot.json +7355 -0
  73. package/migrations/meta/0019_snapshot.json +7419 -0
  74. package/migrations/meta/0020_snapshot.json +7562 -0
  75. package/migrations/meta/0021_snapshot.json +7653 -0
  76. package/migrations/meta/0031_snapshot.json +8162 -0
  77. package/migrations/meta/0032_snapshot.json +8169 -0
  78. package/migrations/meta/0033_snapshot.json +8193 -0
  79. package/migrations/meta/0034_snapshot.json +8221 -0
  80. package/migrations/meta/0036_snapshot.json +8262 -0
  81. package/migrations/meta/0037_snapshot.json +8262 -0
  82. package/migrations/meta/0038_snapshot.json +8293 -0
  83. package/migrations/meta/0039_snapshot.json +8308 -0
  84. package/migrations/meta/0041_snapshot.json +8287 -0
  85. package/migrations/meta/0042_snapshot.json +8274 -0
  86. package/migrations/meta/0043_snapshot.json +8521 -0
  87. package/migrations/meta/0044_snapshot.json +8814 -0
  88. package/migrations/meta/0045_snapshot.json +8797 -0
  89. package/migrations/meta/0046_snapshot.json +8804 -0
  90. package/migrations/meta/0047_snapshot.json +9018 -0
  91. package/migrations/meta/0048_snapshot.json +9145 -0
  92. package/migrations/meta/0049_snapshot.json +9190 -0
  93. package/migrations/meta/0050_snapshot.json +9126 -0
  94. package/migrations/meta/0051_snapshot.json +9132 -0
  95. package/migrations/meta/0052_snapshot.json +9148 -0
  96. package/migrations/meta/0053_snapshot.json +9268 -0
  97. package/migrations/meta/0054_snapshot.json +9378 -0
  98. package/migrations/meta/0056_snapshot.json +9447 -0
  99. package/migrations/meta/_journal.json +405 -0
  100. package/package.json +64 -0
  101. package/src/account-repos.ts +580 -0
  102. package/src/actor-builder.ts +130 -0
  103. package/src/admin-repo.ts +85 -0
  104. package/src/admin-session-repo.ts +205 -0
  105. package/src/admin-undo-repo.ts +128 -0
  106. package/src/announcement-repo.ts +176 -0
  107. package/src/antispam-repo.ts +145 -0
  108. package/src/api-repo.ts +276 -0
  109. package/src/attachment-admin-repo.ts +161 -0
  110. package/src/attachment-repo.ts +240 -0
  111. package/src/authorization-source.ts +138 -0
  112. package/src/avatar-repo.ts +185 -0
  113. package/src/ban-repos.ts +146 -0
  114. package/src/client.ts +85 -0
  115. package/src/content-admin-repo.ts +298 -0
  116. package/src/content-counters.ts +152 -0
  117. package/src/counter-recount.ts +263 -0
  118. package/src/cursor.ts +16 -0
  119. package/src/denormalised-username.ts +34 -0
  120. package/src/discovery-repo.ts +140 -0
  121. package/src/draft-repo.ts +59 -0
  122. package/src/feed-repo.ts +219 -0
  123. package/src/forum-admin-repo.ts +310 -0
  124. package/src/forum-repo.ts +206 -0
  125. package/src/group-admin-repo.ts +256 -0
  126. package/src/group-identity-repo.ts +84 -0
  127. package/src/identity-link-repo.ts +145 -0
  128. package/src/import-repo.ts +123 -0
  129. package/src/import-sink.ts +1377 -0
  130. package/src/index.ts +316 -0
  131. package/src/inline-moderation.ts +344 -0
  132. package/src/install-repo.ts +67 -0
  133. package/src/latest-repo.ts +115 -0
  134. package/src/maintenance-repo.ts +54 -0
  135. package/src/marketplace-repo.ts +111 -0
  136. package/src/member-profile-repo.ts +82 -0
  137. package/src/member-settings-repo.ts +160 -0
  138. package/src/message-repo.ts +384 -0
  139. package/src/migrate-run.ts +18 -0
  140. package/src/migrate.ts +129 -0
  141. package/src/modcp-repo.ts +253 -0
  142. package/src/moderation-queue.ts +279 -0
  143. package/src/navigation-repo.ts +288 -0
  144. package/src/notification-repo.ts +402 -0
  145. package/src/orphan-keys.ts +19 -0
  146. package/src/outbox-repo.ts +78 -0
  147. package/src/permission-version.ts +24 -0
  148. package/src/permissions-map.ts +52 -0
  149. package/src/pglite.fixture.ts +87 -0
  150. package/src/plugin-data.ts +88 -0
  151. package/src/plugin-grants.ts +369 -0
  152. package/src/plugin-health-repo.ts +80 -0
  153. package/src/plugin-purge-repo.ts +91 -0
  154. package/src/plugin-users.ts +39 -0
  155. package/src/poll-repo.ts +334 -0
  156. package/src/post-repo.ts +261 -0
  157. package/src/post-writes.ts +227 -0
  158. package/src/presence-repo.ts +220 -0
  159. package/src/profile-field-repo.ts +182 -0
  160. package/src/promotion-repo.ts +152 -0
  161. package/src/read-state-repo.ts +81 -0
  162. package/src/relation-repo.ts +113 -0
  163. package/src/render-backfill.ts +110 -0
  164. package/src/render-signature.ts +33 -0
  165. package/src/report-repo.ts +349 -0
  166. package/src/reputation-repo.ts +295 -0
  167. package/src/result-rows.ts +13 -0
  168. package/src/row-values.ts +7 -0
  169. package/src/schema/content.ts +451 -0
  170. package/src/schema/identity.ts +699 -0
  171. package/src/schema/index.ts +10 -0
  172. package/src/schema/messages.ts +76 -0
  173. package/src/schema/permission-columns.ts +34 -0
  174. package/src/schema/platform.ts +376 -0
  175. package/src/schema/structure.ts +164 -0
  176. package/src/search-repo.ts +359 -0
  177. package/src/search-store.ts +113 -0
  178. package/src/seed-groups.ts +11 -0
  179. package/src/settings-repo.ts +32 -0
  180. package/src/signature-repo.ts +102 -0
  181. package/src/sql-lists.ts +17 -0
  182. package/src/staff-groups.ts +50 -0
  183. package/src/stats-repo.ts +151 -0
  184. package/src/subscription-repo.ts +350 -0
  185. package/src/system-health-repo.ts +130 -0
  186. package/src/task-repo.ts +104 -0
  187. package/src/theme-admin-repo.ts +172 -0
  188. package/src/theme-repo.ts +43 -0
  189. package/src/thread-audience.ts +45 -0
  190. package/src/thread-counters.ts +107 -0
  191. package/src/thread-repo.ts +255 -0
  192. package/src/thread-surgery.ts +282 -0
  193. package/src/thread-tools.ts +311 -0
  194. package/src/thread-views.ts +41 -0
  195. package/src/thread-writes.ts +356 -0
  196. package/src/two-factor-repo.ts +235 -0
  197. package/src/upgrade-repo.ts +66 -0
  198. package/src/user-admin-repo.ts +367 -0
  199. package/src/user-bulk-repo.ts +397 -0
  200. package/src/user-merge-map.ts +111 -0
  201. package/src/user-merge-repo.ts +304 -0
  202. package/src/visibility-counters.ts +190 -0
  203. package/src/visibility.ts +17 -0
  204. package/src/vocabulary-repo.ts +54 -0
  205. package/src/warning-repo.ts +328 -0
@@ -0,0 +1,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
+ }