@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,104 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { TaskRepository } from '@meith/tasks'
4
+
5
+ import type { Database } from './client'
6
+ import { resultRows } from './result-rows'
7
+ import { taskLog, tasks } from './schema'
8
+
9
+ const NEVER_RUN = new Date(0)
10
+
11
+ export class PostgresTaskRepository implements TaskRepository {
12
+ constructor(private readonly db: Database) {}
13
+
14
+ async ensureRegistered(
15
+ definitions: readonly { id: string; intervalSeconds: number }[],
16
+ ): Promise<void> {
17
+ if (definitions.length === 0) return
18
+
19
+ await this.db
20
+ .insert(tasks)
21
+ .values(
22
+ definitions.map((d) => ({
23
+ key: d.id,
24
+ intervalSeconds: d.intervalSeconds,
25
+ nextRunAt: NEVER_RUN,
26
+ })),
27
+ )
28
+ .onConflictDoUpdate({
29
+ target: tasks.key,
30
+ set: {
31
+ intervalSeconds: sql`excluded.interval_seconds`,
32
+ nextRunAt: sql`
33
+ case when ${tasks.intervalSeconds} is distinct from excluded.interval_seconds
34
+ then coalesce(${tasks.lastRunAt}, now())
35
+ + make_interval(secs => excluded.interval_seconds)
36
+ else ${tasks.nextRunAt}
37
+ end
38
+ `,
39
+ },
40
+ })
41
+ }
42
+
43
+ async claim(input: {
44
+ taskId: string
45
+ now: Date
46
+ staleBefore: Date
47
+ }): Promise<{ previousLastRunAt: Date | null } | null> {
48
+ const leaseMs = input.now.getTime() - input.staleBefore.getTime()
49
+ const lockedUntil = new Date(input.now.getTime() + leaseMs)
50
+
51
+ const result = await this.db.execute(sql`
52
+ with previous as (
53
+ select key, last_run_at from tasks where key = ${input.taskId}
54
+ )
55
+ update tasks as t
56
+ set locked_until = ${lockedUntil},
57
+ last_run_at = ${input.now}
58
+ from previous p
59
+ where t.key = p.key
60
+ and t.enabled
61
+ and (t.locked_until is null or t.locked_until <= ${input.now})
62
+ and t.next_run_at <= ${input.now}
63
+ returning p.last_run_at as previous_last_run_at
64
+ `)
65
+
66
+ const row = resultRows<{ previous_last_run_at: Date | string | null }>(result)[0]
67
+ if (!row) return null
68
+
69
+ const previous = row.previous_last_run_at
70
+ return {
71
+ previousLastRunAt:
72
+ previous === null ? null : previous instanceof Date ? previous : new Date(previous),
73
+ }
74
+ }
75
+
76
+ async release(input: {
77
+ taskId: string
78
+ finishedAt: Date
79
+ success: boolean
80
+ detail?: Record<string, unknown>
81
+ error?: string
82
+ }): Promise<void> {
83
+ await this.db.transaction(async (tx) => {
84
+ await tx.execute(sql`
85
+ update tasks
86
+ set locked_until = null,
87
+ -- Explicit cast: an untyped bind parameter leaves Postgres
88
+ -- unable to resolve timestamp-plus-interval at parse time.
89
+ next_run_at = ${input.finishedAt}::timestamptz
90
+ + make_interval(secs => interval_seconds),
91
+ consecutive_failures = ${input.success ? sql`0` : sql`consecutive_failures + 1`}
92
+ where key = ${input.taskId}
93
+ `)
94
+
95
+ await tx.insert(taskLog).values({
96
+ taskKey: input.taskId,
97
+ succeeded: input.success,
98
+ detail: input.detail === undefined ? null : JSON.stringify(input.detail),
99
+ error: input.error ?? null,
100
+ ranAt: input.finishedAt,
101
+ })
102
+ })
103
+ }
104
+ }
@@ -0,0 +1,172 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+ import { msg } from '@meith/i18n'
5
+
6
+ import type { Database } from './client'
7
+ import { resultRows } from './result-rows'
8
+
9
+ export interface ThemeRecord {
10
+ readonly key: string
11
+ readonly title: string
12
+ readonly tokenOverrides: unknown
13
+ readonly customCss: string | null
14
+ readonly enabled: boolean
15
+ readonly isDefault: boolean
16
+ readonly updatedAt: Date | null
17
+ }
18
+
19
+ export interface ThemeExport {
20
+ readonly version: 2
21
+ readonly key: string
22
+ readonly tokenOverrides: unknown
23
+ readonly customCss: string | null
24
+ }
25
+
26
+ export class PostgresThemeAdminRepository {
27
+ constructor(private readonly db: Database) {}
28
+
29
+ async list(): Promise<readonly ThemeRecord[]> {
30
+ const rows = resultRows(
31
+ await this.db.execute(sql`
32
+ select key, title, token_overrides, custom_css, enabled, is_default, updated_at
33
+ from themes order by key
34
+ `),
35
+ ) as Array<Record<string, unknown>>
36
+
37
+ return rows.map(toRecord)
38
+ }
39
+
40
+ async read(key: string): Promise<ThemeRecord | null> {
41
+ const rows = resultRows(
42
+ await this.db.execute(sql`
43
+ select key, title, token_overrides, custom_css, enabled, is_default, updated_at
44
+ from themes where key = ${key}
45
+ `),
46
+ ) as Array<Record<string, unknown>>
47
+
48
+ const row = rows[0]
49
+ return row === undefined ? null : toRecord(row)
50
+ }
51
+
52
+ async save(input: {
53
+ readonly key: string
54
+ readonly title: string
55
+ readonly tokenOverrides: unknown
56
+ readonly customCss: string | null
57
+ }): Promise<void> {
58
+ if (input.key.trim() === '') throw new ValidationError(msg('error.db.such-theme'))
59
+
60
+ await this.db.execute(sql`
61
+ insert into themes (key, title, token_overrides, custom_css, updated_at)
62
+ values (${input.key}, ${input.title},
63
+ ${JSON.stringify(input.tokenOverrides ?? {})}::jsonb, ${input.customCss}, now())
64
+ on conflict (key) do update
65
+ set title = excluded.title,
66
+ token_overrides = excluded.token_overrides,
67
+ custom_css = excluded.custom_css,
68
+ updated_at = now()
69
+ `)
70
+ }
71
+
72
+ async setEnabled(key: string, enabled: boolean, title: string): Promise<void> {
73
+ if (key.trim() === '') throw new ValidationError(msg('error.db.such-theme'))
74
+
75
+ await this.db.execute(sql`
76
+ insert into themes (key, title, enabled, updated_at)
77
+ values (${key}, ${title}, ${enabled}, now())
78
+ on conflict (key) do update set enabled = excluded.enabled, updated_at = now()
79
+ `)
80
+ }
81
+
82
+ async setDefault(key: string, title: string): Promise<void> {
83
+ if (key.trim() === '') throw new ValidationError(msg('error.db.such-theme'))
84
+
85
+ await this.db.transaction(async (tx) => {
86
+ await tx.execute(sql`update themes set is_default = false where is_default`)
87
+ await tx.execute(sql`
88
+ insert into themes (key, title, is_default, enabled, updated_at)
89
+ values (${key}, ${title}, true, true, now())
90
+ on conflict (key) do update
91
+ set is_default = true, enabled = true, updated_at = now()
92
+ `)
93
+ })
94
+ }
95
+
96
+ async reset(key: string): Promise<void> {
97
+ await this.db.execute(sql`
98
+ update themes set token_overrides = '{}'::jsonb, custom_css = null, updated_at = now()
99
+ where key = ${key}
100
+ `)
101
+ await this.db.execute(sql`
102
+ delete from themes where key = ${key} and enabled and not is_default
103
+ `)
104
+ }
105
+
106
+ async exportTheme(key: string): Promise<ThemeExport> {
107
+ const record = await this.read(key)
108
+ return {
109
+ version: 2,
110
+ key,
111
+ tokenOverrides: record?.tokenOverrides ?? {},
112
+ customCss: record?.customCss ?? null,
113
+ }
114
+ }
115
+ }
116
+
117
+ export function parseThemeExport(raw: string): {
118
+ readonly tokenOverrides: Readonly<Record<string, unknown>>
119
+ readonly customCss: string | null
120
+ } {
121
+ let parsed: unknown
122
+ try {
123
+ parsed = JSON.parse(raw)
124
+ } catch {
125
+ throw new ValidationError(msg('error.db.valid-json'))
126
+ }
127
+
128
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
129
+ throw new ValidationError(msg('error.db.theme-file-json-object'))
130
+ }
131
+
132
+ const document = parsed as Record<string, unknown>
133
+ if (document.version !== 1 && document.version !== 2) {
134
+ throw new ValidationError(msg('error.db.theme-file-written-by-different'))
135
+ }
136
+
137
+ const tokens = document.tokenOverrides
138
+ if (typeof tokens !== 'object' || tokens === null || Array.isArray(tokens)) {
139
+ throw new ValidationError(msg('error.db.theme-file-needs-tokenoverrides-object'))
140
+ }
141
+
142
+ const css = document.customCss
143
+ if (css !== null && css !== undefined && typeof css !== 'string') {
144
+ throw new ValidationError(msg('error.db.customcss-must-text-null'))
145
+ }
146
+
147
+ return {
148
+ tokenOverrides: tokens as Record<string, unknown>,
149
+ customCss: typeof css === 'string' ? css : null,
150
+ }
151
+ }
152
+
153
+ function toRecord(row: Record<string, unknown>): ThemeRecord {
154
+ const overrides = row.token_overrides
155
+ return {
156
+ key: String(row.key),
157
+ title: String(row.title),
158
+ tokenOverrides:
159
+ typeof overrides === 'object' && overrides !== null && !Array.isArray(overrides)
160
+ ? overrides
161
+ : {},
162
+ customCss: row.custom_css === null ? null : String(row.custom_css),
163
+ enabled: row.enabled !== false,
164
+ isDefault: row.is_default === true,
165
+ updatedAt:
166
+ row.updated_at === null || row.updated_at === undefined
167
+ ? null
168
+ : row.updated_at instanceof Date
169
+ ? row.updated_at
170
+ : new Date(String(row.updated_at)),
171
+ }
172
+ }
@@ -0,0 +1,43 @@
1
+ import { eq } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+ import { themes } from './schema'
5
+
6
+ export interface ThemeRuntimeRecord {
7
+ readonly tokenOverrides: unknown
8
+ readonly customCss: string | null
9
+ }
10
+
11
+ export interface ThemeRuntimeState extends ThemeRuntimeRecord {
12
+ readonly key: string
13
+ readonly enabled: boolean
14
+ readonly isDefault: boolean
15
+ }
16
+
17
+ export class PostgresThemeRepository {
18
+ constructor(private readonly db: Database) {}
19
+
20
+ async findRuntimeByKey(key: string): Promise<ThemeRuntimeRecord | null> {
21
+ const rows = await this.db
22
+ .select({ tokenOverrides: themes.tokenOverrides, customCss: themes.customCss })
23
+ .from(themes)
24
+ .where(eq(themes.key, key))
25
+ .limit(1)
26
+
27
+ return rows[0] ?? null
28
+ }
29
+
30
+ async listRuntime(): Promise<readonly ThemeRuntimeState[]> {
31
+ const rows = await this.db
32
+ .select({
33
+ key: themes.key,
34
+ tokenOverrides: themes.tokenOverrides,
35
+ customCss: themes.customCss,
36
+ enabled: themes.enabled,
37
+ isDefault: themes.isDefault,
38
+ })
39
+ .from(themes)
40
+
41
+ return rows
42
+ }
43
+ }
@@ -0,0 +1,45 @@
1
+ import { type SQL, sql } from 'drizzle-orm'
2
+ import type { PgColumn } from 'drizzle-orm/pg-core'
3
+
4
+ import {
5
+ audienceForumIds,
6
+ audienceIsEmpty,
7
+ type ThreadAudience,
8
+ type ThreadAuthorFilter,
9
+ } from '@meith/core'
10
+
11
+ import { idList } from './sql-lists'
12
+
13
+ export function authoredBy(column: PgColumn | SQL, filter: ThreadAuthorFilter): SQL {
14
+ switch (filter.kind) {
15
+ case 'all':
16
+ return sql`true`
17
+ case 'none':
18
+ return sql`false`
19
+ case 'author':
20
+ return sql`${column} = ${filter.userId}`
21
+ }
22
+ }
23
+
24
+ export function inAudience(
25
+ forumColumn: PgColumn | SQL,
26
+ authorColumn: PgColumn | SQL,
27
+ audience: ThreadAudience,
28
+ ): SQL {
29
+ if (audienceIsEmpty(audience)) return sql`false`
30
+
31
+ const { open, ownThreadsOnly } = audienceForumIds(audience)
32
+ const parts: SQL[] = []
33
+
34
+ if (open.length > 0) parts.push(sql`${forumColumn} in ${idList(open)}`)
35
+ if (ownThreadsOnly.length > 0) {
36
+ parts.push(
37
+ sql`(${forumColumn} in ${idList(ownThreadsOnly)}
38
+ and ${authorColumn} = ${audience.viewerUserId})`,
39
+ )
40
+ }
41
+
42
+ return sql`(${sql.join(parts, sql` or `)})`
43
+ }
44
+
45
+ export { audienceIsEmpty }
@@ -0,0 +1,107 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import { resultRows } from './result-rows'
4
+
5
+ export interface CounterTx {
6
+ execute(query: ReturnType<typeof sql>): Promise<unknown>
7
+ }
8
+
9
+ export interface ThreadTally {
10
+ readonly posts: number
11
+ readonly byAuthor: ReadonlyArray<{ userId: number; posts: number }>
12
+ readonly threadAuthorId: number | null
13
+ }
14
+
15
+ export async function tallyThread(tx: CounterTx, threadId: number): Promise<ThreadTally> {
16
+ const rows = resultRows(
17
+ await tx.execute(sql`
18
+ select p.author_user_id, count(*)::int as n
19
+ from posts p
20
+ where p.thread_id = ${threadId} and p.visibility = 'visible'
21
+ group by p.author_user_id
22
+ `),
23
+ ) as Array<{ author_user_id: number | null; n: number }>
24
+
25
+ const owner = resultRows(
26
+ await tx.execute(sql`select author_user_id from threads where id = ${threadId}`),
27
+ ) as Array<{ author_user_id: number | null }>
28
+
29
+ return {
30
+ posts: rows.reduce((total, row) => total + Number(row.n), 0),
31
+ byAuthor: rows.flatMap((row) =>
32
+ row.author_user_id === null
33
+ ? []
34
+ : [{ userId: Number(row.author_user_id), posts: Number(row.n) }],
35
+ ),
36
+ threadAuthorId: owner[0]?.author_user_id == null ? null : Number(owner[0].author_user_id),
37
+ }
38
+ }
39
+
40
+ export async function applyForumChain(
41
+ tx: CounterTx,
42
+ forumId: number,
43
+ delta: 1 | -1,
44
+ tally: ThreadTally,
45
+ ): Promise<void> {
46
+ await tx.execute(sql`
47
+ update forums f
48
+ set post_count = greatest(f.post_count + ${delta * tally.posts}, 0),
49
+ thread_count = greatest(f.thread_count + ${delta}, 0),
50
+ updated_at = now()
51
+ from forums child
52
+ where child.id = ${forumId}
53
+ and (f.id = child.id or child.path like f.path || '.%')
54
+ `)
55
+ }
56
+
57
+ export async function applyAuthorCounts(
58
+ tx: CounterTx,
59
+ delta: 1 | -1,
60
+ tally: ThreadTally,
61
+ ): Promise<void> {
62
+ for (const author of tally.byAuthor) {
63
+ await tx.execute(sql`
64
+ update users
65
+ set post_count = greatest(post_count + ${delta * author.posts}, 0),
66
+ updated_at = now()
67
+ where id = ${author.userId}
68
+ `)
69
+ }
70
+ if (tally.threadAuthorId !== null) {
71
+ await tx.execute(sql`
72
+ update users
73
+ set thread_count = greatest(thread_count + ${delta}, 0), updated_at = now()
74
+ where id = ${tally.threadAuthorId}
75
+ `)
76
+ }
77
+ }
78
+
79
+ export async function syncLedger(tx: CounterTx, threadId: number, counted: boolean): Promise<void> {
80
+ if (counted) {
81
+ await tx.execute(sql`
82
+ insert into content_counter_rollups (post_id)
83
+ select p.id from posts p
84
+ where p.thread_id = ${threadId} and p.visibility = 'visible'
85
+ on conflict (post_id) do nothing
86
+ `)
87
+ return
88
+ }
89
+ await tx.execute(sql`
90
+ delete from content_counter_rollups r
91
+ using posts p
92
+ where r.post_id = p.id and p.thread_id = ${threadId}
93
+ `)
94
+ }
95
+
96
+ export async function logModeratorAction(
97
+ tx: CounterTx,
98
+ action: string,
99
+ actorUserId: number,
100
+ detail: Record<string, unknown>,
101
+ at: Date,
102
+ ): Promise<void> {
103
+ await tx.execute(sql`
104
+ insert into admin_log (user_id, action, detail, created_at)
105
+ values (${actorUserId}, ${action}, ${JSON.stringify(detail)}::jsonb, ${at})
106
+ `)
107
+ }