@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,80 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+ import { resultRows } from './result-rows'
5
+
6
+ export interface PluginHealthRecord {
7
+ readonly pluginKey: string
8
+ readonly failures: number
9
+ readonly disabledAt: Date | null
10
+ readonly reason: string | null
11
+ }
12
+
13
+ function toRecord(row: Record<string, unknown>): PluginHealthRecord {
14
+ return {
15
+ pluginKey: String(row.plugin_key),
16
+ failures: Number(row.failures),
17
+ disabledAt: row.disabled_at === null ? null : new Date(row.disabled_at as string),
18
+ reason: row.reason === null ? null : String(row.reason),
19
+ }
20
+ }
21
+
22
+ export async function readPluginHealth(db: Database): Promise<readonly PluginHealthRecord[]> {
23
+ const rows = resultRows(
24
+ await db.execute(sql`
25
+ select plugin_key, failures, disabled_at, reason
26
+ from plugin_health
27
+ order by plugin_key
28
+ `),
29
+ ) as Array<Record<string, unknown>>
30
+
31
+ return rows.map(toRecord)
32
+ }
33
+
34
+ export async function recordPluginFailure(
35
+ db: Database,
36
+ input: {
37
+ readonly pluginKey: string
38
+ readonly threshold: number
39
+ readonly reason: string
40
+ },
41
+ ): Promise<PluginHealthRecord> {
42
+ const rows = resultRows(
43
+ await db.execute(sql`
44
+ insert into plugin_health (plugin_key, failures, disabled_at, reason, updated_at)
45
+ values (
46
+ ${input.pluginKey},
47
+ 1,
48
+ case when 1 >= ${input.threshold} then now() else null end,
49
+ case when 1 >= ${input.threshold} then ${input.reason} else null end,
50
+ now()
51
+ )
52
+ on conflict (plugin_key) do update
53
+ set failures = plugin_health.failures + 1,
54
+ disabled_at = case
55
+ when plugin_health.disabled_at is not null then plugin_health.disabled_at
56
+ when plugin_health.failures + 1 >= ${input.threshold} then now()
57
+ else null
58
+ end,
59
+ reason = case
60
+ when plugin_health.disabled_at is not null then plugin_health.reason
61
+ when plugin_health.failures + 1 >= ${input.threshold} then ${input.reason}
62
+ else null
63
+ end,
64
+ updated_at = now()
65
+ returning plugin_key, failures, disabled_at, reason
66
+ `),
67
+ ) as Array<Record<string, unknown>>
68
+
69
+ return toRecord(rows[0]!)
70
+ }
71
+
72
+ export async function clearPluginHealth(db: Database, pluginKey: string): Promise<boolean> {
73
+ const rows = resultRows(
74
+ await db.execute(sql`
75
+ delete from plugin_health where plugin_key = ${pluginKey} returning plugin_key
76
+ `),
77
+ ) as Array<Record<string, unknown>>
78
+
79
+ return rows.length > 0
80
+ }
@@ -0,0 +1,91 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import { pluginTablePrefix } from '@meith/plugin-kit'
4
+
5
+ import type { Database } from './client'
6
+ import { resultRows } from './result-rows'
7
+
8
+ export interface PluginPurgeResult {
9
+ readonly tables: readonly string[]
10
+ readonly settings: number
11
+ readonly migrations: number
12
+ readonly navigation: number
13
+ readonly versionRemoved: boolean
14
+ readonly healthRemoved: boolean
15
+ }
16
+
17
+ export async function pluginOwnedTables(
18
+ db: Database,
19
+ pluginKey: string,
20
+ ): Promise<readonly string[]> {
21
+ const rows = resultRows(
22
+ await db.execute(sql`
23
+ select table_name
24
+ from information_schema.tables
25
+ where table_schema = 'public'
26
+ and table_type = 'BASE TABLE'
27
+ and table_name like ${`${pluginTablePrefix(pluginKey)}%`}
28
+ order by table_name
29
+ `),
30
+ ) as Array<{ table_name: string }>
31
+
32
+ return rows.map((row) => String(row.table_name))
33
+ }
34
+
35
+ /**
36
+ * Everything a plugin left behind, taken away in one transaction.
37
+ *
38
+ * The tables are found by prefix rather than from the plugin's own migration
39
+ * list, because the list says what it meant to create and the schema says what
40
+ * it did. The prefix is safe to trust: `definePlugin` refuses a migration that
41
+ * names anything outside it, so nothing the board owns can match.
42
+ */
43
+ export async function purgePlugin(db: Database, pluginKey: string): Promise<PluginPurgeResult> {
44
+ const tables = await pluginOwnedTables(db, pluginKey)
45
+
46
+ return db.transaction(async (tx) => {
47
+ for (const table of tables) {
48
+ await tx.execute(sql.raw(`drop table if exists "${table}" cascade`))
49
+ }
50
+
51
+ const settings = resultRows(
52
+ await tx.execute(sql`
53
+ delete from settings where key like ${`plugin.${pluginKey}.%`} returning key
54
+ `),
55
+ ) as Array<unknown>
56
+
57
+ const migrations = resultRows(
58
+ await tx.execute(sql`
59
+ delete from plugin_migrations where plugin_key = ${pluginKey} returning migration_id
60
+ `),
61
+ ) as Array<unknown>
62
+
63
+ const navigation = resultRows(
64
+ await tx.execute(sql`
65
+ delete from navigation_items where key like ${`plugin.${pluginKey}.%`} returning id
66
+ `),
67
+ ) as Array<unknown>
68
+
69
+ const version = resultRows(
70
+ await tx.execute(sql`
71
+ delete from component_versions where component = ${`plugin:${pluginKey}`}
72
+ returning component
73
+ `),
74
+ ) as Array<unknown>
75
+
76
+ const health = resultRows(
77
+ await tx.execute(sql`
78
+ delete from plugin_health where plugin_key = ${pluginKey} returning plugin_key
79
+ `),
80
+ ) as Array<unknown>
81
+
82
+ return {
83
+ tables,
84
+ settings: settings.length,
85
+ migrations: migrations.length,
86
+ navigation: navigation.length,
87
+ versionRemoved: version.length > 0,
88
+ healthRemoved: health.length > 0,
89
+ }
90
+ })
91
+ }
@@ -0,0 +1,39 @@
1
+ import { and, eq, ne } from 'drizzle-orm'
2
+
3
+ import type { PluginUserRef, PluginUsers } from '@meith/plugin-kit'
4
+
5
+ import type { Database } from './client'
6
+ import { users } from './schema'
7
+
8
+ const REF = { userId: users.id, username: users.username } as const
9
+
10
+ export function pluginUsers(db: Database): PluginUsers {
11
+ const first = (rows: readonly PluginUserRef[]): PluginUserRef | null => rows[0] ?? null
12
+
13
+ return {
14
+ async byUsername(username) {
15
+ const needle = username.trim().toLowerCase()
16
+ if (needle === '') return null
17
+
18
+ return first(
19
+ await db
20
+ .select(REF)
21
+ .from(users)
22
+ .where(and(eq(users.usernameLower, needle), ne(users.state, 'deleted')))
23
+ .limit(1),
24
+ )
25
+ },
26
+
27
+ async byId(userId) {
28
+ if (!Number.isSafeInteger(userId) || userId <= 0) return null
29
+
30
+ return first(
31
+ await db
32
+ .select(REF)
33
+ .from(users)
34
+ .where(and(eq(users.id, userId), ne(users.state, 'deleted')))
35
+ .limit(1),
36
+ )
37
+ },
38
+ }
39
+ }
@@ -0,0 +1,334 @@
1
+ import { type SQL, sql } from 'drizzle-orm'
2
+
3
+ import {
4
+ POLL_CHOICES_UNLIMITED,
5
+ POLL_VOTERS_SHOWN_MAX,
6
+ type Poll,
7
+ type PollEditPlan,
8
+ type PollRepository,
9
+ type PollVote,
10
+ type PollVoter,
11
+ type ThreadRating,
12
+ type ThreadRatingRepository,
13
+ type ValidatedPoll,
14
+ } from '@meith/polls'
15
+
16
+ import type { Database } from './client'
17
+ import { resultRows } from './result-rows'
18
+
19
+ function date(value: Date | string | null): Date | null {
20
+ return value === null ? null : value instanceof Date ? value : new Date(value)
21
+ }
22
+
23
+ function ids(values: readonly number[]): SQL {
24
+ return sql.join(
25
+ values.map((value) => sql`${value}`),
26
+ sql`, `,
27
+ )
28
+ }
29
+
30
+ export class PostgresPollRepository implements PollRepository, ThreadRatingRepository {
31
+ constructor(private readonly db: Database) {}
32
+
33
+ async create(threadId: number, poll: ValidatedPoll): Promise<void> {
34
+ await this.db.transaction(async (tx) => {
35
+ const rows = resultRows(
36
+ await tx.execute(sql`
37
+ insert into polls (thread_id, question, closes_at, max_options, allow_revote, public_votes)
38
+ values (${threadId}, ${poll.question}, ${poll.closesAt}, ${poll.maxOptions},
39
+ ${poll.allowRevote}, ${poll.publicVotes}) returning id
40
+ `),
41
+ ) as Array<{ id: number }>
42
+ const id = rows[0]?.id
43
+ if (id === undefined) throw new Error('Poll insert returned no row.')
44
+ await tx.execute(sql`
45
+ insert into poll_options (poll_id, label, display_order)
46
+ values ${sql.join(
47
+ poll.options.map((label, index) => sql`(${id}, ${label}, ${index})`),
48
+ sql`, `,
49
+ )}
50
+ `)
51
+ })
52
+ }
53
+
54
+ async find(threadId: number, voterUserId: number | null): Promise<Poll | null> {
55
+ const polls = resultRows(
56
+ await this.db.execute(sql`
57
+ select id, question, closes_at, created_at, max_options, allow_revote, public_votes
58
+ from polls where thread_id = ${threadId}
59
+ `),
60
+ ) as Array<{
61
+ id: number
62
+ question: string
63
+ closes_at: Date | string | null
64
+ created_at: Date | string
65
+ max_options: number
66
+ allow_revote: boolean
67
+ public_votes: boolean
68
+ }>
69
+ const poll = polls[0]
70
+ if (poll === undefined) return null
71
+
72
+ const [options, votes, voters] = await Promise.all([
73
+ this.db.execute(sql`
74
+ select id, label, vote_count from poll_options
75
+ where poll_id = ${poll.id} order by display_order, id
76
+ `),
77
+ voterUserId === null
78
+ ? Promise.resolve([])
79
+ : this.db.execute(sql`
80
+ select option_id from poll_votes
81
+ where poll_id = ${poll.id} and user_id = ${voterUserId}
82
+ `),
83
+ poll.public_votes ? this.voters(Number(poll.id)) : Promise.resolve(new Map()),
84
+ ])
85
+ const voteRows = resultRows(votes) as Array<{ option_id: number }>
86
+ const byOption = voters as Map<number, PollVoter[]>
87
+
88
+ return {
89
+ id: Number(poll.id),
90
+ threadId,
91
+ question: poll.question,
92
+ closesAt: date(poll.closes_at),
93
+ createdAt: date(poll.created_at) ?? new Date(0),
94
+ maxOptions: Number(poll.max_options),
95
+ allowRevote: poll.allow_revote === true,
96
+ publicVotes: poll.public_votes === true,
97
+ options: (
98
+ resultRows(options) as Array<{
99
+ id: number
100
+ label: string
101
+ vote_count: number
102
+ }>
103
+ ).map((row) => ({
104
+ id: Number(row.id),
105
+ label: row.label,
106
+ votes: Number(row.vote_count),
107
+ voters: byOption.get(Number(row.id)) ?? [],
108
+ })),
109
+ votedOptionIds: voteRows.map((row) => Number(row.option_id)),
110
+ }
111
+ }
112
+
113
+ private async voters(pollId: number): Promise<Map<number, PollVoter[]>> {
114
+ const rows = resultRows(
115
+ await this.db.execute(sql`
116
+ select o.id as option_id, v.user_id, u.username, v.created_at
117
+ from poll_options o
118
+ join lateral (
119
+ select pv.user_id, pv.created_at from poll_votes pv
120
+ where pv.poll_id = o.poll_id and pv.option_id = o.id
121
+ order by pv.created_at, pv.user_id limit ${POLL_VOTERS_SHOWN_MAX}
122
+ ) v on true
123
+ join users u on u.id = v.user_id
124
+ where o.poll_id = ${pollId}
125
+ order by o.display_order, o.id, v.created_at, v.user_id
126
+ `),
127
+ ) as Array<{
128
+ option_id: number
129
+ user_id: number
130
+ username: string
131
+ created_at: Date | string | null
132
+ }>
133
+
134
+ const byOption = new Map<number, PollVoter[]>()
135
+ for (const row of rows) {
136
+ const optionId = Number(row.option_id)
137
+ const list = byOption.get(optionId) ?? []
138
+ list.push({
139
+ userId: Number(row.user_id),
140
+ username: row.username,
141
+ votedAt: date(row.created_at),
142
+ })
143
+ byOption.set(optionId, list)
144
+ }
145
+ return byOption
146
+ }
147
+
148
+ async vote(input: PollVote): Promise<boolean> {
149
+ const wanted = [...new Set(input.optionIds)]
150
+ if (wanted.length === 0) return false
151
+
152
+ return this.db.transaction(async (tx) => {
153
+ const polls = resultRows(
154
+ await tx.execute(sql`
155
+ select id, max_options, allow_revote from polls
156
+ where id = ${input.pollId} and thread_id = ${input.threadId}
157
+ and (closes_at is null or closes_at > now())
158
+ for update
159
+ `),
160
+ ) as Array<{ id: number; max_options: number; allow_revote: boolean }>
161
+ const poll = polls[0]
162
+ if (poll === undefined) return false
163
+
164
+ const maxOptions = Number(poll.max_options)
165
+ if (maxOptions !== POLL_CHOICES_UNLIMITED && wanted.length > maxOptions) return false
166
+
167
+ const chosen = resultRows(
168
+ await tx.execute(sql`
169
+ select id from poll_options
170
+ where poll_id = ${poll.id} and id in (${ids(wanted)})
171
+ `),
172
+ )
173
+ if (chosen.length !== wanted.length) return false
174
+
175
+ const previous = resultRows(
176
+ await tx.execute(sql`
177
+ select option_id from poll_votes
178
+ where poll_id = ${poll.id} and user_id = ${input.userId}
179
+ `),
180
+ ) as Array<{ option_id: number }>
181
+
182
+ if (previous.length > 0) {
183
+ if (poll.allow_revote !== true) return false
184
+ await tx.execute(sql`
185
+ delete from poll_votes where poll_id = ${poll.id} and user_id = ${input.userId}
186
+ `)
187
+ await tx.execute(sql`
188
+ update poll_options set vote_count = greatest(0, vote_count - 1)
189
+ where poll_id = ${poll.id}
190
+ and id in (${ids(previous.map((row) => Number(row.option_id)))})
191
+ `)
192
+ }
193
+
194
+ await tx.execute(sql`
195
+ insert into poll_votes (poll_id, user_id, option_id)
196
+ values ${sql.join(
197
+ wanted.map((optionId) => sql`(${poll.id}, ${input.userId}, ${optionId})`),
198
+ sql`, `,
199
+ )}
200
+ `)
201
+ await tx.execute(sql`
202
+ update poll_options set vote_count = vote_count + 1
203
+ where poll_id = ${poll.id} and id in (${ids(wanted)})
204
+ `)
205
+ return true
206
+ })
207
+ }
208
+
209
+ async applyEdit(plan: PollEditPlan): Promise<boolean> {
210
+ return this.db.transaction(async (tx) => {
211
+ const polls = resultRows(
212
+ await tx.execute(sql`
213
+ select id, public_votes from polls where id = ${plan.pollId} for update
214
+ `),
215
+ ) as Array<{ id: number; public_votes: boolean }>
216
+ const poll = polls[0]
217
+ if (poll === undefined) return false
218
+
219
+ const kept = plan.options.map((option) => option.id).filter((id): id is number => id !== null)
220
+ const known = resultRows(
221
+ await tx.execute(sql`
222
+ select id from poll_options where poll_id = ${poll.id}
223
+ `),
224
+ ) as Array<{ id: number }>
225
+ const existing = new Set(known.map((row) => Number(row.id)))
226
+ if (kept.some((id) => !existing.has(id))) return false
227
+
228
+ const cast = resultRows(
229
+ await tx.execute(sql`
230
+ select option_id from poll_votes where poll_id = ${poll.id}
231
+ `),
232
+ ) as Array<{ option_id: number }>
233
+
234
+ const removed = [...existing].filter((id) => !kept.includes(id))
235
+ if (removed.some((id) => cast.some((row) => Number(row.option_id) === id))) return false
236
+ if (plan.publicVotes && poll.public_votes !== true && cast.length > 0) return false
237
+
238
+ if (removed.length > 0) {
239
+ await tx.execute(sql`
240
+ delete from poll_options where poll_id = ${poll.id} and id in (${ids(removed)})
241
+ `)
242
+ }
243
+
244
+ if (kept.length > 0) {
245
+ await tx.execute(sql`
246
+ update poll_options set display_order = -1 - display_order where poll_id = ${poll.id}
247
+ `)
248
+ }
249
+
250
+ let order = 0
251
+ for (const option of plan.options) {
252
+ if (option.id === null) {
253
+ await tx.execute(sql`
254
+ insert into poll_options (poll_id, label, display_order)
255
+ values (${poll.id}, ${option.label}, ${order})
256
+ `)
257
+ } else {
258
+ await tx.execute(sql`
259
+ update poll_options set label = ${option.label}, display_order = ${order}
260
+ where poll_id = ${poll.id} and id = ${option.id}
261
+ `)
262
+ }
263
+ order += 1
264
+ }
265
+
266
+ await tx.execute(sql`
267
+ update polls set question = ${plan.question}, closes_at = ${plan.closesAt},
268
+ max_options = ${plan.maxOptions}, allow_revote = ${plan.allowRevote},
269
+ public_votes = ${plan.publicVotes}
270
+ where id = ${poll.id}
271
+ `)
272
+ return true
273
+ })
274
+ }
275
+
276
+ async rate(input: {
277
+ readonly threadId: number
278
+ readonly userId: number
279
+ readonly rating: number
280
+ }): Promise<ThreadRating | null> {
281
+ return this.db.transaction(async (tx) => {
282
+ const locked = resultRows(
283
+ await tx.execute(sql`select id from threads where id = ${input.threadId} for update`),
284
+ )
285
+ if (locked.length === 0) return null
286
+ const previous = resultRows(
287
+ await tx.execute(sql`
288
+ select rating from thread_ratings where thread_id = ${input.threadId} and user_id = ${input.userId}
289
+ `),
290
+ ) as Array<{ rating: number }>
291
+ await tx.execute(sql`
292
+ insert into thread_ratings (thread_id, user_id, rating, updated_at)
293
+ values (${input.threadId}, ${input.userId}, ${input.rating}, now())
294
+ on conflict (thread_id, user_id) do update set rating = excluded.rating, updated_at = excluded.updated_at
295
+ `)
296
+ const prior = Number(previous[0]?.rating ?? 0)
297
+ const rows = resultRows(
298
+ await tx.execute(sql`
299
+ update threads set rating_total = rating_total + ${input.rating - prior},
300
+ rating_count = rating_count + ${previous.length === 0 ? 1 : 0}
301
+ where id = ${input.threadId}
302
+ returning rating_total, rating_count
303
+ `),
304
+ ) as Array<{ rating_total: number; rating_count: number }>
305
+ const row = rows[0]!
306
+ return {
307
+ average: Number(row.rating_total) / Number(row.rating_count),
308
+ count: Number(row.rating_count),
309
+ mine: input.rating,
310
+ }
311
+ })
312
+ }
313
+
314
+ async findRating(threadId: number, userId: number | null): Promise<ThreadRating | null> {
315
+ const rows = resultRows(
316
+ await this.db.execute(sql`
317
+ select t.rating_total, t.rating_count, r.rating as mine
318
+ from threads t left join thread_ratings r on r.thread_id = t.id and r.user_id = ${userId}
319
+ where t.id = ${threadId}
320
+ `),
321
+ ) as Array<{
322
+ rating_total: number
323
+ rating_count: number
324
+ mine: number | null
325
+ }>
326
+ const row = rows[0]
327
+ if (row === undefined) return null
328
+ return {
329
+ average: row.rating_count === 0 ? 0 : Number(row.rating_total) / Number(row.rating_count),
330
+ count: Number(row.rating_count),
331
+ mine: row.mine === null ? null : Number(row.mine),
332
+ }
333
+ }
334
+ }