@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
package/src/client.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'
2
+ import postgres from 'postgres'
3
+
4
+ import { ConfigurationError, env } from '@meith/core'
5
+
6
+ import * as schema from './schema'
7
+
8
+ export type Database = PostgresJsDatabase<typeof schema>
9
+
10
+ interface DbGlobal {
11
+ __forumSql: ReturnType<typeof postgres> | undefined
12
+ __forumDb: Database | undefined
13
+ }
14
+
15
+ const globalRef = globalThis as unknown as DbGlobal
16
+
17
+ function connectionOptions() {
18
+ return {
19
+ max: env.DATABASE_POOL_MAX,
20
+ prepare: false,
21
+ idle_timeout: 20,
22
+ max_lifetime: 60 * 30,
23
+ connect_timeout: 10,
24
+ onnotice: () => {},
25
+ } as const
26
+ }
27
+
28
+ export function getDb(): Database {
29
+ if (env.DATA_SOURCE !== 'postgres') {
30
+ throw new ConfigurationError(
31
+ 'getDb() called while DATA_SOURCE is "' +
32
+ env.DATA_SOURCE +
33
+ '". A repository is talking to Postgres directly instead of going ' +
34
+ 'through its driver. Fix the call site, do not set DATABASE_URL.',
35
+ )
36
+ }
37
+
38
+ if (globalRef.__forumDb) return globalRef.__forumDb
39
+
40
+ const url = env.DATABASE_URL
41
+ if (!url) {
42
+ throw new ConfigurationError('DATABASE_URL is required when DATA_SOURCE is "postgres".')
43
+ }
44
+
45
+ const sql = postgres(url, connectionOptions())
46
+ const db = drizzle(sql, { schema, casing: 'snake_case' })
47
+ restoreDateSerialisers(sql)
48
+
49
+ globalRef.__forumSql = sql
50
+ globalRef.__forumDb = db
51
+
52
+ return db
53
+ }
54
+
55
+ const DATE_OIDS = ['1184', '1114', '1082', '1083', '1182', '1185', '1115', '1231'] as const
56
+
57
+ function restoreDateSerialisers(client: ReturnType<typeof postgres>): void {
58
+ const serialisers = (
59
+ client as unknown as { options: { serializers: Record<string, (v: unknown) => unknown> } }
60
+ ).options.serializers
61
+ for (const oid of DATE_OIDS) {
62
+ serialisers[oid] = (value: unknown) => (value instanceof Date ? value.toISOString() : value)
63
+ }
64
+ }
65
+
66
+ export function createIsolatedDb(url: string, poolMax = 1) {
67
+ const sql = postgres(url, { ...connectionOptions(), max: poolMax })
68
+ const db = drizzle(sql, { schema, casing: 'snake_case' })
69
+ restoreDateSerialisers(sql)
70
+ return {
71
+ db,
72
+ sql,
73
+ close: () => sql.end({ timeout: 5 }),
74
+ }
75
+ }
76
+
77
+ export async function closeDb(): Promise<void> {
78
+ const sql = globalRef.__forumSql
79
+ if (!sql) return
80
+ await sql.end({ timeout: 5 })
81
+ globalRef.__forumSql = undefined
82
+ globalRef.__forumDb = undefined
83
+ }
84
+
85
+ export { schema }
@@ -0,0 +1,298 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+ import { msg } from '@meith/i18n'
5
+ import type { WordFilterRule } from '@meith/markdown'
6
+
7
+ import type { Database } from './client'
8
+ import { resultRows } from './result-rows'
9
+
10
+ export interface WordFilterRow extends WordFilterRule {
11
+ readonly id: number
12
+ readonly enabled: boolean
13
+ }
14
+
15
+ export interface SmileyRow {
16
+ readonly id: number
17
+ readonly code: string
18
+ readonly src: string
19
+ readonly alt: string | null
20
+ readonly enabled: boolean
21
+ }
22
+
23
+ export interface DirectiveRow {
24
+ readonly id: number
25
+ readonly name: string
26
+ readonly block: boolean
27
+ readonly description: string | null
28
+ readonly enabled: boolean
29
+ }
30
+
31
+ export interface ThreadPrefixRow {
32
+ readonly id: number
33
+ readonly label: string
34
+ readonly token: string | null
35
+ readonly displayOrder: number
36
+ readonly forumPathPrefix: string | null
37
+ }
38
+
39
+ export class PostgresContentAdminRepository {
40
+ constructor(private readonly db: Database) {}
41
+
42
+ async listWordFilters(): Promise<readonly WordFilterRow[]> {
43
+ const rows = resultRows(
44
+ await this.db.execute(sql`
45
+ select id, pattern, replacement, whole_word, enabled
46
+ from word_filters order by id
47
+ `),
48
+ ) as Array<Record<string, unknown>>
49
+
50
+ return rows.map(toWordFilter)
51
+ }
52
+
53
+ async activeWordFilters(): Promise<readonly WordFilterRule[]> {
54
+ const rows = resultRows(
55
+ await this.db.execute(sql`
56
+ select pattern, replacement, whole_word from word_filters
57
+ where enabled = true and pattern <> ''
58
+ order by id
59
+ `),
60
+ ) as Array<Record<string, unknown>>
61
+
62
+ return rows.map((row) => ({
63
+ pattern: String(row.pattern),
64
+ replacement: String(row.replacement),
65
+ wholeWord: row.whole_word === true,
66
+ }))
67
+ }
68
+
69
+ async createWordFilter(input: WordFilterRule): Promise<number> {
70
+ const pattern = input.pattern.trim()
71
+ if (pattern === '') throw new ValidationError(msg('error.db.filter-needs-something-match'))
72
+
73
+ const rows = resultRows(
74
+ await this.db.execute(sql`
75
+ insert into word_filters (pattern, replacement, whole_word)
76
+ values (${pattern}, ${input.replacement}, ${input.wholeWord})
77
+ returning id
78
+ `),
79
+ ) as Array<{ id: number }>
80
+
81
+ return Number(rows[0]?.id)
82
+ }
83
+
84
+ async updateWordFilter(
85
+ id: number,
86
+ input: WordFilterRule & { readonly enabled: boolean },
87
+ ): Promise<void> {
88
+ const pattern = input.pattern.trim()
89
+ if (pattern === '') throw new ValidationError(msg('error.db.filter-needs-something-match'))
90
+
91
+ const rows = resultRows(
92
+ await this.db.execute(sql`
93
+ update word_filters
94
+ set pattern = ${pattern}, replacement = ${input.replacement},
95
+ whole_word = ${input.wholeWord}, enabled = ${input.enabled}
96
+ where id = ${id}
97
+ returning id
98
+ `),
99
+ ) as Array<{ id: number }>
100
+
101
+ if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-filter'))
102
+ }
103
+
104
+ async deleteWordFilter(id: number): Promise<void> {
105
+ await this.db.execute(sql`delete from word_filters where id = ${id}`)
106
+ }
107
+
108
+ async listPrefixes(): Promise<readonly ThreadPrefixRow[]> {
109
+ const rows = resultRows(
110
+ await this.db.execute(sql`
111
+ select id, label, token, display_order, forum_path_prefix
112
+ from thread_prefixes order by display_order, id
113
+ `),
114
+ ) as Array<Record<string, unknown>>
115
+
116
+ return rows.map((row) => ({
117
+ id: Number(row.id),
118
+ label: String(row.label),
119
+ token: row.token === null ? null : String(row.token),
120
+ displayOrder: Number(row.display_order),
121
+ forumPathPrefix: row.forum_path_prefix === null ? null : String(row.forum_path_prefix),
122
+ }))
123
+ }
124
+
125
+ async createPrefix(input: {
126
+ readonly label: string
127
+ readonly token: string | null
128
+ readonly displayOrder: number
129
+ readonly forumPathPrefix: string | null
130
+ }): Promise<number> {
131
+ if (input.label.trim() === '') throw new ValidationError(msg('error.db.prefix-needs-label'))
132
+
133
+ const rows = resultRows(
134
+ await this.db.execute(sql`
135
+ insert into thread_prefixes (label, token, display_order, forum_path_prefix)
136
+ values (${input.label.trim()}, ${input.token}, ${input.displayOrder},
137
+ ${input.forumPathPrefix})
138
+ returning id
139
+ `),
140
+ ) as Array<{ id: number }>
141
+
142
+ return Number(rows[0]?.id)
143
+ }
144
+
145
+ async deletePrefix(id: number): Promise<void> {
146
+ await this.db.execute(sql`delete from thread_prefixes where id = ${id}`)
147
+ }
148
+
149
+ async vocabularyRevision(): Promise<number> {
150
+ const rows = resultRows(
151
+ await this.db.execute(sql`
152
+ select version from cache_versions where key = 'markdown_vocabulary'
153
+ `),
154
+ ) as Array<{ version: number }>
155
+
156
+ return Number(rows[0]?.version ?? 0)
157
+ }
158
+
159
+ async bumpVocabulary(): Promise<void> {
160
+ await this.db.execute(sql`
161
+ insert into cache_versions (key, version) values ('markdown_vocabulary', 1)
162
+ on conflict (key) do update
163
+ set version = cache_versions.version + 1, bumped_at = now()
164
+ `)
165
+ }
166
+
167
+ async listSmilies(): Promise<readonly SmileyRow[]> {
168
+ const rows = resultRows(
169
+ await this.db.execute(sql`
170
+ select id, code, src, alt, enabled from smilies order by code
171
+ `),
172
+ ) as Array<Record<string, unknown>>
173
+
174
+ return rows.map((row) => ({
175
+ id: Number(row.id),
176
+ code: String(row.code),
177
+ src: String(row.src),
178
+ alt: row.alt === null ? null : String(row.alt),
179
+ enabled: row.enabled === true,
180
+ }))
181
+ }
182
+
183
+ async createSmiley(input: {
184
+ readonly code: string
185
+ readonly src: string
186
+ readonly alt: string | null
187
+ }): Promise<number> {
188
+ const rows = resultRows(
189
+ await this.db.execute(sql`
190
+ insert into smilies (code, src, alt)
191
+ values (${input.code}, ${input.src}, ${input.alt})
192
+ returning id
193
+ `),
194
+ ) as Array<{ id: number }>
195
+
196
+ await this.bumpVocabulary()
197
+ return Number(rows[0]?.id)
198
+ }
199
+
200
+ async updateSmiley(
201
+ id: number,
202
+ input: {
203
+ readonly code: string
204
+ readonly src: string
205
+ readonly alt: string | null
206
+ readonly enabled: boolean
207
+ },
208
+ ): Promise<void> {
209
+ const rows = resultRows(
210
+ await this.db.execute(sql`
211
+ update smilies
212
+ set code = ${input.code}, src = ${input.src}, alt = ${input.alt},
213
+ enabled = ${input.enabled}
214
+ where id = ${id}
215
+ returning id
216
+ `),
217
+ ) as Array<{ id: number }>
218
+
219
+ if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-smiley'))
220
+ await this.bumpVocabulary()
221
+ }
222
+
223
+ async deleteSmiley(id: number): Promise<void> {
224
+ await this.db.execute(sql`delete from smilies where id = ${id}`)
225
+ await this.bumpVocabulary()
226
+ }
227
+
228
+ async listDirectives(): Promise<readonly DirectiveRow[]> {
229
+ const rows = resultRows(
230
+ await this.db.execute(sql`
231
+ select id, name, block, description, enabled from custom_directives order by name
232
+ `),
233
+ ) as Array<Record<string, unknown>>
234
+
235
+ return rows.map((row) => ({
236
+ id: Number(row.id),
237
+ name: String(row.name),
238
+ block: row.block === true,
239
+ description: row.description === null ? null : String(row.description),
240
+ enabled: row.enabled === true,
241
+ }))
242
+ }
243
+
244
+ async createDirective(input: {
245
+ readonly name: string
246
+ readonly block: boolean
247
+ readonly description: string | null
248
+ }): Promise<number> {
249
+ const rows = resultRows(
250
+ await this.db.execute(sql`
251
+ insert into custom_directives (name, block, description)
252
+ values (${input.name}, ${input.block}, ${input.description})
253
+ returning id
254
+ `),
255
+ ) as Array<{ id: number }>
256
+
257
+ await this.bumpVocabulary()
258
+ return Number(rows[0]?.id)
259
+ }
260
+
261
+ async updateDirective(
262
+ id: number,
263
+ input: {
264
+ readonly name: string
265
+ readonly block: boolean
266
+ readonly description: string | null
267
+ readonly enabled: boolean
268
+ },
269
+ ): Promise<void> {
270
+ const rows = resultRows(
271
+ await this.db.execute(sql`
272
+ update custom_directives
273
+ set name = ${input.name}, block = ${input.block},
274
+ description = ${input.description}, enabled = ${input.enabled}
275
+ where id = ${id}
276
+ returning id
277
+ `),
278
+ ) as Array<{ id: number }>
279
+
280
+ if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-directive'))
281
+ await this.bumpVocabulary()
282
+ }
283
+
284
+ async deleteDirective(id: number): Promise<void> {
285
+ await this.db.execute(sql`delete from custom_directives where id = ${id}`)
286
+ await this.bumpVocabulary()
287
+ }
288
+ }
289
+
290
+ function toWordFilter(row: Record<string, unknown>): WordFilterRow {
291
+ return {
292
+ id: Number(row.id),
293
+ pattern: String(row.pattern),
294
+ replacement: String(row.replacement),
295
+ wholeWord: row.whole_word === true,
296
+ enabled: row.enabled === true,
297
+ }
298
+ }
@@ -0,0 +1,152 @@
1
+ import type { SQLWrapper } from 'drizzle-orm'
2
+ import { sql } from 'drizzle-orm'
3
+
4
+ import type { Database } from './client'
5
+ import { resultRows } from './result-rows'
6
+ import { applyAncestorVisibilityChange } from './visibility-counters'
7
+
8
+ export interface CreatedContent {
9
+ readonly postId: number
10
+ readonly threadId: number
11
+ readonly forumId: number
12
+ readonly authorId: number | null
13
+ readonly authorUsername: string
14
+ readonly threadTitle: string
15
+ readonly createdAt: Date
16
+ readonly isNewThread: boolean
17
+ }
18
+
19
+ interface CounterTransaction {
20
+ execute(query: SQLWrapper): Promise<unknown>
21
+ }
22
+
23
+ export async function applyCreatedContentCounters(
24
+ tx: CounterTransaction,
25
+ content: CreatedContent,
26
+ ): Promise<void> {
27
+ const newer = sql`last_post_at is null or last_post_id is null
28
+ or last_post_at < ${content.createdAt}
29
+ or (last_post_at = ${content.createdAt} and last_post_id < ${content.postId})`
30
+ const threadNewer = content.isNewThread ? sql`true` : newer
31
+
32
+ await tx.execute(sql`
33
+ update forums
34
+ set post_count = post_count + 1,
35
+ thread_count = thread_count + ${content.isNewThread ? 1 : 0},
36
+ last_post_id = case when ${newer} then ${content.postId} else last_post_id end,
37
+ last_post_thread_id = case when ${newer} then ${content.threadId} else last_post_thread_id end,
38
+ last_post_thread_title = case when ${newer} then ${content.threadTitle} else last_post_thread_title end,
39
+ last_post_user_id = case when ${newer} then ${content.authorId} else last_post_user_id end,
40
+ last_post_username = case when ${newer} then ${content.authorUsername} else last_post_username end,
41
+ last_post_at = case when ${newer} then ${content.createdAt} else last_post_at end,
42
+ updated_at = now()
43
+ where id = ${content.forumId}
44
+ `)
45
+
46
+ await tx.execute(sql`
47
+ update threads
48
+ set first_post_id = case when ${content.isNewThread} then ${content.postId} else first_post_id end,
49
+ reply_count = reply_count + ${content.isNewThread ? 0 : 1},
50
+ last_post_id = case when ${threadNewer} then ${content.postId} else last_post_id end,
51
+ last_post_user_id = case when ${threadNewer} then ${content.authorId} else last_post_user_id end,
52
+ last_post_username = case when ${threadNewer} then ${content.authorUsername} else last_post_username end,
53
+ last_post_at = case when ${threadNewer} then ${content.createdAt} else last_post_at end,
54
+ updated_at = now()
55
+ where id = ${content.threadId}
56
+ `)
57
+
58
+ if (content.authorId !== null) {
59
+ await tx.execute(sql`
60
+ update users
61
+ set post_count = post_count + 1,
62
+ thread_count = thread_count + ${content.isNewThread ? 1 : 0},
63
+ updated_at = now()
64
+ where id = ${content.authorId}
65
+ `)
66
+ }
67
+
68
+ await tx.execute(sql`
69
+ insert into outbox (topic, payload)
70
+ values (
71
+ 'post.created',
72
+ ${JSON.stringify({
73
+ postId: content.postId,
74
+ threadId: content.threadId,
75
+ forumId: content.forumId,
76
+ authorId: content.authorId,
77
+ })}::jsonb
78
+ )
79
+ `)
80
+ }
81
+
82
+ export async function rollUpAncestorCounters(db: Database, postId: number): Promise<boolean> {
83
+ return db.transaction(async (tx) => {
84
+ const claimed = await tx.execute(sql`
85
+ insert into content_counter_rollups (post_id)
86
+ select p.id from posts p
87
+ where p.id = ${postId} and p.visibility = 'visible'
88
+ on conflict (post_id) do nothing
89
+ returning post_id
90
+ `)
91
+ if (resultRows(claimed).length === 0) return false
92
+
93
+ const found = await tx.execute(sql`
94
+ select p.thread_id, p.forum_id, p.author_user_id, p.author_username,
95
+ p.created_at, p.is_first_post, t.title as thread_title
96
+ from posts p
97
+ join threads t on t.id = p.thread_id
98
+ where p.id = ${postId}
99
+ `)
100
+ const post = resultRows(found)[0] as
101
+ | {
102
+ thread_id: number
103
+ forum_id: number
104
+ author_user_id: number | null
105
+ author_username: string
106
+ created_at: Date
107
+ is_first_post: boolean
108
+ thread_title: string
109
+ }
110
+ | undefined
111
+ if (!post) return false
112
+
113
+ const ancestorNewer = sql`f.last_post_at is null or f.last_post_id is null
114
+ or f.last_post_at < ${post.created_at}
115
+ or (f.last_post_at = ${post.created_at} and f.last_post_id < ${postId})`
116
+
117
+ await tx.execute(sql`
118
+ update forums f
119
+ set post_count = f.post_count + 1,
120
+ thread_count = f.thread_count + ${post.is_first_post ? 1 : 0},
121
+ last_post_id = case when ${ancestorNewer} then ${postId} else f.last_post_id end,
122
+ last_post_thread_id = case when ${ancestorNewer} then ${post.thread_id} else f.last_post_thread_id end,
123
+ last_post_thread_title = case when ${ancestorNewer} then ${post.thread_title} else f.last_post_thread_title end,
124
+ last_post_user_id = case when ${ancestorNewer} then ${post.author_user_id} else f.last_post_user_id end,
125
+ last_post_username = case when ${ancestorNewer} then ${post.author_username} else f.last_post_username end,
126
+ last_post_at = case when ${ancestorNewer} then ${post.created_at} else f.last_post_at end,
127
+ updated_at = now()
128
+ from forums child
129
+ where child.id = ${post.forum_id}
130
+ and f.id <> child.id
131
+ and child.path like f.path || '.%'
132
+ `)
133
+
134
+ return true
135
+ })
136
+ }
137
+
138
+ export class PostgresContentCounterRepository {
139
+ constructor(private readonly db: Database) {}
140
+
141
+ async recordCreated(content: CreatedContent): Promise<void> {
142
+ await this.db.transaction((tx) => applyCreatedContentCounters(tx, content))
143
+ }
144
+
145
+ async rollUpAncestors(postId: number): Promise<boolean> {
146
+ return rollUpAncestorCounters(this.db, postId)
147
+ }
148
+
149
+ async applyVisibilityChange(postId: number): Promise<boolean> {
150
+ return applyAncestorVisibilityChange(this.db, postId)
151
+ }
152
+ }