@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,145 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { CaptchaQuestion, RateLimitStore, RateLimitSubject } from '@meith/antispam'
4
+ import { ValidationError } from '@meith/core'
5
+ import { msg } from '@meith/i18n'
6
+
7
+ import type { Database } from './client'
8
+ import { resultRows } from './result-rows'
9
+
10
+ export class PostgresRateLimitBucketStore implements RateLimitStore {
11
+ constructor(private readonly db: Database) {}
12
+
13
+ async spend(subject: RateLimitSubject, windowStart: Date, cost: number): Promise<number> {
14
+ const rows = resultRows(
15
+ await this.db.execute(sql`
16
+ insert into rate_limits (scope, subject, window_start, used)
17
+ values (${subject.scope}, ${subject.subject}, ${windowStart}, ${cost})
18
+ on conflict (scope, subject, window_start)
19
+ do update set used = rate_limits.used + ${cost}
20
+ returning used
21
+ `),
22
+ ) as Array<{ used: number }>
23
+
24
+ return Number(rows[0]?.used ?? cost)
25
+ }
26
+
27
+ async peek(subject: RateLimitSubject, windowStart: Date): Promise<number> {
28
+ const rows = resultRows(
29
+ await this.db.execute(sql`
30
+ select used from rate_limits
31
+ where scope = ${subject.scope}
32
+ and subject = ${subject.subject}
33
+ and window_start = ${windowStart}
34
+ `),
35
+ ) as Array<{ used: number }>
36
+
37
+ return Number(rows[0]?.used ?? 0)
38
+ }
39
+
40
+ async prune(before: Date, limit = 5000): Promise<number> {
41
+ const rows = resultRows(
42
+ await this.db.execute(sql`
43
+ delete from rate_limits
44
+ where ctid in (
45
+ select ctid from rate_limits where window_start < ${before} limit ${limit}
46
+ )
47
+ returning subject
48
+ `),
49
+ ) as Array<unknown>
50
+
51
+ return rows.length
52
+ }
53
+ }
54
+
55
+ export interface CaptchaQuestionRow extends CaptchaQuestion {
56
+ readonly enabled: boolean
57
+ }
58
+
59
+ function splitAnswers(raw: string): readonly string[] {
60
+ return raw
61
+ .split('\n')
62
+ .map((answer) => answer.trim())
63
+ .filter((answer) => answer !== '')
64
+ }
65
+
66
+ export class PostgresCaptchaQuestionRepository {
67
+ constructor(private readonly db: Database) {}
68
+
69
+ async active(): Promise<readonly CaptchaQuestion[]> {
70
+ const rows = resultRows(
71
+ await this.db.execute(sql`
72
+ select id, question, answers from captcha_questions
73
+ where enabled = true and question <> '' and answers <> ''
74
+ order by id
75
+ `),
76
+ ) as Array<Record<string, unknown>>
77
+
78
+ return rows
79
+ .map((row) => ({
80
+ id: Number(row.id),
81
+ question: String(row.question),
82
+ answers: splitAnswers(String(row.answers)),
83
+ }))
84
+ .filter((question) => question.answers.length > 0)
85
+ }
86
+
87
+ async list(): Promise<readonly CaptchaQuestionRow[]> {
88
+ const rows = resultRows(
89
+ await this.db.execute(sql`
90
+ select id, question, answers, enabled from captcha_questions order by id
91
+ `),
92
+ ) as Array<Record<string, unknown>>
93
+
94
+ return rows.map((row) => ({
95
+ id: Number(row.id),
96
+ question: String(row.question),
97
+ answers: splitAnswers(String(row.answers)),
98
+ enabled: row.enabled === true,
99
+ }))
100
+ }
101
+
102
+ async create(input: { readonly question: string; readonly answers: string }): Promise<number> {
103
+ assertUsable(input.question, input.answers)
104
+
105
+ const rows = resultRows(
106
+ await this.db.execute(sql`
107
+ insert into captcha_questions (question, answers)
108
+ values (${input.question.trim()}, ${input.answers})
109
+ returning id
110
+ `),
111
+ ) as Array<{ id: number }>
112
+
113
+ return Number(rows[0]?.id)
114
+ }
115
+
116
+ async update(
117
+ id: number,
118
+ input: { readonly question: string; readonly answers: string; readonly enabled: boolean },
119
+ ): Promise<void> {
120
+ assertUsable(input.question, input.answers)
121
+
122
+ const rows = resultRows(
123
+ await this.db.execute(sql`
124
+ update captcha_questions
125
+ set question = ${input.question.trim()}, answers = ${input.answers},
126
+ enabled = ${input.enabled}
127
+ where id = ${id}
128
+ returning id
129
+ `),
130
+ ) as Array<{ id: number }>
131
+
132
+ if (rows[0] === undefined) throw new ValidationError(msg('error.db.such-question'))
133
+ }
134
+
135
+ async delete(id: number): Promise<void> {
136
+ await this.db.execute(sql`delete from captcha_questions where id = ${id}`)
137
+ }
138
+ }
139
+
140
+ function assertUsable(question: string, answers: string): void {
141
+ if (question.trim() === '') throw new ValidationError(msg('error.db.question-needs-asked'))
142
+ if (splitAnswers(answers).length === 0) {
143
+ throw new ValidationError(msg('error.db.question-needs-at-least-one'))
144
+ }
145
+ }
@@ -0,0 +1,276 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { ApiTokenRecord, RateLimitStore, Scope } from '@meith/api'
4
+
5
+ import type { Database } from './client'
6
+
7
+ interface TokenRow {
8
+ id: number
9
+ user_id: number
10
+ name: string
11
+ lookup: string
12
+ secret_hash: string
13
+ scopes: unknown
14
+ expires_at: Date | null
15
+ revoked_at: Date | null
16
+ }
17
+
18
+ function parseScopes(raw: unknown, known: (value: string) => value is Scope): readonly Scope[] {
19
+ if (!Array.isArray(raw)) return []
20
+ return raw.filter((value): value is Scope => typeof value === 'string' && known(value))
21
+ }
22
+
23
+ export interface ApiTokenSummary {
24
+ readonly id: number
25
+ readonly userId: number
26
+ readonly username: string
27
+ readonly name: string
28
+ readonly lookup: string
29
+ readonly scopes: readonly Scope[]
30
+ readonly createdAt: Date
31
+ readonly expiresAt: Date | null
32
+ readonly revokedAt: Date | null
33
+ readonly lastUsedAt: Date | null
34
+ }
35
+
36
+ export class PostgresApiTokenRepository {
37
+ constructor(
38
+ private readonly db: Database,
39
+ private readonly isScope: (value: string) => value is Scope,
40
+ ) {}
41
+
42
+ async findByLookup(lookup: string): Promise<ApiTokenRecord | null> {
43
+ const rows = (await this.db.execute(sql`
44
+ select id, user_id, name, lookup, secret_hash, scopes, expires_at, revoked_at
45
+ from api_tokens
46
+ where lookup = ${lookup}
47
+ limit 1
48
+ `)) as unknown as TokenRow[]
49
+
50
+ const row = rows[0]
51
+ if (row === undefined) return null
52
+
53
+ return {
54
+ id: row.id,
55
+ userId: row.user_id,
56
+ name: row.name,
57
+ lookup: row.lookup,
58
+ secretHash: row.secret_hash,
59
+ scopes: parseScopes(row.scopes, this.isScope),
60
+ expiresAt: row.expires_at,
61
+ revokedAt: row.revoked_at,
62
+ }
63
+ }
64
+
65
+ async listAll(limit = 200): Promise<readonly ApiTokenSummary[]> {
66
+ const rows = (await this.db.execute(sql`
67
+ select t.id, t.user_id, u.username, t.name, t.lookup, t.scopes,
68
+ t.created_at, t.expires_at, t.revoked_at, t.last_used_at
69
+ from api_tokens t
70
+ join users u on u.id = t.user_id
71
+ order by t.revoked_at nulls first, t.created_at desc
72
+ limit ${limit}
73
+ `)) as unknown as Array<{
74
+ id: number
75
+ user_id: number
76
+ username: string
77
+ name: string
78
+ lookup: string
79
+ scopes: unknown
80
+ created_at: Date | string
81
+ expires_at: Date | string | null
82
+ revoked_at: Date | string | null
83
+ last_used_at: Date | string | null
84
+ }>
85
+
86
+ return rows.map((row) => ({
87
+ id: row.id,
88
+ userId: row.user_id,
89
+ username: row.username,
90
+ name: row.name,
91
+ lookup: row.lookup,
92
+ scopes: parseScopes(row.scopes, this.isScope),
93
+ createdAt: new Date(row.created_at),
94
+ expiresAt: row.expires_at === null ? null : new Date(row.expires_at),
95
+ revokedAt: row.revoked_at === null ? null : new Date(row.revoked_at),
96
+ lastUsedAt: row.last_used_at === null ? null : new Date(row.last_used_at),
97
+ }))
98
+ }
99
+
100
+ async create(input: {
101
+ readonly userId: number
102
+ readonly name: string
103
+ readonly lookup: string
104
+ readonly secretHash: string
105
+ readonly scopes: readonly Scope[]
106
+ readonly expiresAt: Date | null
107
+ }): Promise<number> {
108
+ const rows = (await this.db.execute(sql`
109
+ insert into api_tokens (user_id, name, lookup, secret_hash, scopes, expires_at)
110
+ values (${input.userId}, ${input.name}, ${input.lookup}, ${input.secretHash},
111
+ ${JSON.stringify(input.scopes)}, ${input.expiresAt})
112
+ returning id
113
+ `)) as unknown as Array<{ id: number }>
114
+
115
+ return rows[0]!.id
116
+ }
117
+
118
+ async revoke(id: number, at: Date): Promise<boolean> {
119
+ const rows = (await this.db.execute(sql`
120
+ update api_tokens set revoked_at = ${at}
121
+ where id = ${id} and revoked_at is null
122
+ returning id
123
+ `)) as unknown as Array<{ id: number }>
124
+
125
+ return rows.length > 0
126
+ }
127
+
128
+ async touch(id: number, at: Date): Promise<void> {
129
+ await this.db.execute(sql`
130
+ update api_tokens
131
+ set last_used_at = ${at}
132
+ where id = ${id}
133
+ and (last_used_at is null or last_used_at < ${new Date(at.getTime() - 60_000)})
134
+ `)
135
+ }
136
+ }
137
+
138
+ export class PostgresRateLimitStore implements RateLimitStore {
139
+ constructor(private readonly db: Database) {}
140
+
141
+ async spend(tokenId: number, windowStart: Date, cost: number): Promise<number> {
142
+ const rows = (await this.db.execute(sql`
143
+ insert into api_rate_limits (token_id, window_start, used)
144
+ values (${tokenId}, ${windowStart}, ${cost})
145
+ on conflict (token_id, window_start)
146
+ do update set used = api_rate_limits.used + ${cost}
147
+ returning used
148
+ `)) as unknown as { used: number }[]
149
+
150
+ return rows[0]?.used ?? cost
151
+ }
152
+
153
+ async peek(tokenId: number, windowStart: Date): Promise<number> {
154
+ const rows = (await this.db.execute(sql`
155
+ select used from api_rate_limits
156
+ where token_id = ${tokenId} and window_start = ${windowStart}
157
+ `)) as unknown as { used: number }[]
158
+
159
+ return rows[0]?.used ?? 0
160
+ }
161
+
162
+ async prune(before: Date, limit = 5000): Promise<number> {
163
+ const rows = (await this.db.execute(sql`
164
+ delete from api_rate_limits
165
+ where ctid in (
166
+ select ctid from api_rate_limits where window_start < ${before} limit ${limit}
167
+ )
168
+ returning token_id
169
+ `)) as unknown as unknown[]
170
+ return rows.length
171
+ }
172
+ }
173
+
174
+ export interface WebhookDeliveryRow {
175
+ readonly id: number
176
+ readonly webhookId: number
177
+ readonly deliveryId: string
178
+ readonly topic: string
179
+ readonly payload: Record<string, unknown>
180
+ readonly attempts: number
181
+ readonly url: string
182
+ readonly secret: string
183
+ }
184
+
185
+ export class PostgresWebhookRepository {
186
+ constructor(private readonly db: Database) {}
187
+
188
+ async enqueue(
189
+ topic: string,
190
+ deliveryId: string,
191
+ payload: Record<string, unknown>,
192
+ ): Promise<number> {
193
+ const rows = (await this.db.execute(sql`
194
+ insert into webhook_deliveries (webhook_id, delivery_id, topic, payload)
195
+ select w.id, ${deliveryId}, ${topic}, ${JSON.stringify(payload)}::jsonb
196
+ from webhooks w
197
+ where w.active = true and w.topics ? ${topic}
198
+ on conflict (webhook_id, delivery_id) do nothing
199
+ returning id
200
+ `)) as unknown as unknown[]
201
+
202
+ return rows.length
203
+ }
204
+
205
+ async claimDue(now: Date, limit: number): Promise<readonly WebhookDeliveryRow[]> {
206
+ const rows = (await this.db.execute(sql`
207
+ with due as (
208
+ select d.id
209
+ from webhook_deliveries d
210
+ where d.status = 'pending' and d.next_attempt_at <= ${now}
211
+ order by d.next_attempt_at
212
+ limit ${limit}
213
+ for update skip locked
214
+ )
215
+ update webhook_deliveries d
216
+ set attempts = d.attempts + 1
217
+ from due, webhooks w
218
+ where d.id = due.id and w.id = d.webhook_id
219
+ returning d.id, d.webhook_id, d.delivery_id, d.topic, d.payload, d.attempts, w.url, w.secret
220
+ `)) as unknown as {
221
+ id: number
222
+ webhook_id: number
223
+ delivery_id: string
224
+ topic: string
225
+ payload: Record<string, unknown>
226
+ attempts: number
227
+ url: string
228
+ secret: string
229
+ }[]
230
+
231
+ return rows.map((row) => ({
232
+ id: row.id,
233
+ webhookId: row.webhook_id,
234
+ deliveryId: row.delivery_id,
235
+ topic: row.topic,
236
+ payload: row.payload,
237
+ attempts: row.attempts,
238
+ url: row.url,
239
+ secret: row.secret,
240
+ }))
241
+ }
242
+
243
+ async markDelivered(id: number, status: number, at: Date): Promise<void> {
244
+ await this.db.execute(sql`
245
+ update webhook_deliveries
246
+ set status = 'delivered', last_status_code = ${status}, completed_at = ${at}, last_error = null
247
+ where id = ${id}
248
+ `)
249
+ }
250
+
251
+ async scheduleRetry(id: number, at: Date, status: number | null, error: string): Promise<void> {
252
+ await this.db.execute(sql`
253
+ update webhook_deliveries
254
+ set next_attempt_at = ${at}, last_status_code = ${status}, last_error = ${error}
255
+ where id = ${id}
256
+ `)
257
+ }
258
+
259
+ async markDead(id: number, status: number | null, error: string, at: Date): Promise<void> {
260
+ await this.db.execute(sql`
261
+ update webhook_deliveries
262
+ set status = 'dead', last_status_code = ${status}, last_error = ${error}, completed_at = ${at}
263
+ where id = ${id}
264
+ `)
265
+ }
266
+
267
+ async retryDead(id: number, at: Date): Promise<boolean> {
268
+ const rows = (await this.db.execute(sql`
269
+ update webhook_deliveries
270
+ set status = 'pending', attempts = 0, next_attempt_at = ${at}, last_error = null
271
+ where id = ${id} and status = 'dead'
272
+ returning id
273
+ `)) as unknown as unknown[]
274
+ return rows.length > 0
275
+ }
276
+ }
@@ -0,0 +1,161 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from './client'
4
+ import { resultRows } from './result-rows'
5
+ import { likeFragment } from './user-admin-repo'
6
+
7
+ export interface AttachmentAdminRow {
8
+ readonly id: number
9
+ readonly postId: number
10
+ readonly threadSlug: string | null
11
+ readonly filename: string
12
+ readonly contentType: string
13
+ readonly sizeBytes: number
14
+ readonly status: string
15
+ readonly failureReason: string | null
16
+ readonly downloadCount: number
17
+ readonly uploaderUserId: number | null
18
+ readonly uploaderUsername: string | null
19
+ readonly createdAt: Date
20
+ }
21
+
22
+ export interface AttachmentAdminFilter {
23
+ readonly filename?: string | undefined
24
+ readonly status?: string | undefined
25
+ readonly uploaderUserId?: number | undefined
26
+ readonly beforeId?: number | undefined
27
+ readonly offset?: number | undefined
28
+ readonly limit: number
29
+ }
30
+
31
+ export interface AttachmentAdminPage {
32
+ readonly rows: readonly AttachmentAdminRow[]
33
+ readonly nextBeforeId: number | null
34
+ /** How many attachments the filter matches, whichever page was asked for. */
35
+ readonly total: number
36
+ }
37
+
38
+ export interface AttachmentTotals {
39
+ readonly count: number
40
+ readonly bytes: number
41
+ readonly pending: number
42
+ readonly failed: number
43
+ }
44
+
45
+ const STATUSES = new Set(['pending', 'ready', 'failed'])
46
+
47
+ export class PostgresAttachmentAdminRepository {
48
+ constructor(private readonly db: Database) {}
49
+
50
+ async list(filter: AttachmentAdminFilter): Promise<AttachmentAdminPage> {
51
+ const conditions = [sql`true`]
52
+
53
+ if (filter.filename !== undefined && filter.filename !== '') {
54
+ conditions.push(sql`a.filename ilike ${`%${likeFragment(filter.filename)}%`} escape '\\'`)
55
+ }
56
+ if (filter.status !== undefined && STATUSES.has(filter.status)) {
57
+ conditions.push(sql`a.status = ${filter.status}`)
58
+ }
59
+ if (filter.uploaderUserId !== undefined) {
60
+ conditions.push(sql`a.uploader_user_id = ${filter.uploaderUserId}`)
61
+ }
62
+ if (filter.beforeId !== undefined) {
63
+ conditions.push(sql`a.id < ${filter.beforeId}`)
64
+ }
65
+
66
+ const where = sql.join(conditions, sql` and `)
67
+
68
+ const [listed, counted] = await Promise.all([
69
+ this.db.execute(sql`
70
+ select a.id, a.post_id, a.filename, a.content_type, a.size_bytes, a.status,
71
+ a.failure_reason, a.download_count, a.uploader_user_id, a.created_at,
72
+ u.username as uploader_username, t.slug as thread_slug
73
+ from attachments a
74
+ left join users u on u.id = a.uploader_user_id
75
+ left join posts p on p.id = a.post_id
76
+ left join threads t on t.id = p.thread_id
77
+ where ${where}
78
+ order by a.id desc
79
+ limit ${filter.limit + 1} offset ${filter.offset ?? 0}
80
+ `),
81
+ this.db.execute(sql`
82
+ select count(*)::int as total from attachments a where ${where}
83
+ `),
84
+ ])
85
+
86
+ const rows = resultRows(listed) as Array<Record<string, unknown>>
87
+
88
+ const page = rows.slice(0, filter.limit).map(
89
+ (row): AttachmentAdminRow => ({
90
+ id: Number(row.id),
91
+ postId: Number(row.post_id),
92
+ threadSlug: row.thread_slug === null ? null : String(row.thread_slug),
93
+ filename: String(row.filename),
94
+ contentType: String(row.content_type),
95
+ sizeBytes: Number(row.size_bytes),
96
+ status: String(row.status),
97
+ failureReason: row.failure_reason === null ? null : String(row.failure_reason),
98
+ downloadCount: Number(row.download_count),
99
+ uploaderUserId: row.uploader_user_id === null ? null : Number(row.uploader_user_id),
100
+ uploaderUsername: row.uploader_username === null ? null : String(row.uploader_username),
101
+ createdAt: new Date(String(row.created_at)),
102
+ }),
103
+ )
104
+
105
+ const total = Number((resultRows(counted) as Array<Record<string, unknown>>)[0]?.total ?? 0)
106
+
107
+ return {
108
+ rows: page,
109
+ nextBeforeId: rows.length > filter.limit ? (page[page.length - 1]?.id ?? null) : null,
110
+ total,
111
+ }
112
+ }
113
+
114
+ async totals(): Promise<AttachmentTotals> {
115
+ const rows = resultRows(
116
+ await this.db.execute(sql`
117
+ select count(*)::int as count,
118
+ coalesce(sum(size_bytes), 0)::bigint as bytes,
119
+ count(*) filter (where status = 'pending')::int as pending,
120
+ count(*) filter (where status = 'failed')::int as failed
121
+ from attachments
122
+ `),
123
+ ) as Array<Record<string, unknown>>
124
+
125
+ const row = rows[0]
126
+ return {
127
+ count: Number(row?.count ?? 0),
128
+ bytes: Number(row?.bytes ?? 0),
129
+ pending: Number(row?.pending ?? 0),
130
+ failed: Number(row?.failed ?? 0),
131
+ }
132
+ }
133
+
134
+ async delete(id: number): Promise<boolean> {
135
+ return this.db.transaction(async (tx) => {
136
+ const rows = resultRows(
137
+ await tx.execute(sql`
138
+ delete from attachments
139
+ where id = ${id}
140
+ returning storage_key, source_key, thumbnail_key
141
+ `),
142
+ ) as Array<Record<string, unknown>>
143
+
144
+ const row = rows[0]
145
+ if (row === undefined) return false
146
+
147
+ const keys = [row.storage_key, row.source_key, row.thumbnail_key].filter(
148
+ (key): key is string => typeof key === 'string' && key !== '',
149
+ )
150
+
151
+ for (const key of keys) {
152
+ await tx.execute(sql`
153
+ insert into attachment_orphans (storage_key) values (${key})
154
+ on conflict (storage_key) do nothing
155
+ `)
156
+ }
157
+
158
+ return true
159
+ })
160
+ }
161
+ }