@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,240 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ AttachmentForDownload,
5
+ AttachmentRecord,
6
+ AttachmentRepository,
7
+ AttachmentStatus,
8
+ CreateAttachmentInput,
9
+ ReadyInput,
10
+ } from '@meith/attachments'
11
+
12
+ import type { Database } from './client'
13
+ import { forgetOrphanKeys, rememberOrphanKey } from './orphan-keys'
14
+ import { resultRows } from './result-rows'
15
+ import { toDate } from './row-values'
16
+
17
+ interface RawAttachment {
18
+ id: number
19
+ post_id: number | null
20
+ forum_id: number
21
+ uploader_user_id: number | null
22
+ filename: string
23
+ content_type: string
24
+ size_bytes: number
25
+ storage_key: string | null
26
+ source_key: string | null
27
+ thumbnail_key: string | null
28
+ width: number | null
29
+ height: number | null
30
+ status: string
31
+ failure_reason: string | null
32
+ download_count: number
33
+ created_at: string | Date
34
+ }
35
+
36
+ const COLUMNS = sql`
37
+ id, post_id, forum_id, uploader_user_id, filename, content_type, size_bytes,
38
+ storage_key, source_key, thumbnail_key, width, height, status,
39
+ failure_reason, download_count, created_at
40
+ `
41
+
42
+ function toRecord(row: RawAttachment): AttachmentRecord {
43
+ return {
44
+ id: Number(row.id),
45
+ postId: row.post_id === null ? null : Number(row.post_id),
46
+ forumId: Number(row.forum_id),
47
+ uploaderUserId: row.uploader_user_id === null ? null : Number(row.uploader_user_id),
48
+ filename: row.filename,
49
+ contentType: row.content_type,
50
+ sizeBytes: Number(row.size_bytes),
51
+ storageKey: row.storage_key,
52
+ sourceKey: row.source_key,
53
+ thumbnailKey: row.thumbnail_key,
54
+ width: row.width === null ? null : Number(row.width),
55
+ height: row.height === null ? null : Number(row.height),
56
+ status: (['pending', 'ready', 'failed'] as const).includes(row.status as AttachmentStatus)
57
+ ? (row.status as AttachmentStatus)
58
+ : 'failed',
59
+ failureReason: row.failure_reason,
60
+ downloadCount: Number(row.download_count),
61
+ createdAt: toDate(row.created_at),
62
+ }
63
+ }
64
+
65
+ export class PostgresAttachmentRepository implements AttachmentRepository {
66
+ constructor(private readonly db: Database) {}
67
+
68
+ async create(input: CreateAttachmentInput): Promise<AttachmentRecord> {
69
+ const rows = resultRows(
70
+ await this.db.execute(sql`
71
+ insert into attachments
72
+ (post_id, forum_id, uploader_user_id, filename, content_type,
73
+ size_bytes, storage_key, source_key, status, ready_at)
74
+ values (${input.postId}, ${input.forumId}, ${input.uploaderUserId},
75
+ ${input.filename}, ${input.contentType}, ${input.sizeBytes},
76
+ ${input.storageKey}, ${input.sourceKey}, ${input.status},
77
+ ${input.status === 'ready' ? sql`now()` : sql`null`})
78
+ returning ${COLUMNS}
79
+ `),
80
+ ) as RawAttachment[]
81
+
82
+ if (rows[0] === undefined) throw new Error('Attachment insert returned no row')
83
+ return toRecord(rows[0])
84
+ }
85
+
86
+ async findById(id: number): Promise<AttachmentRecord | null> {
87
+ const rows = resultRows(
88
+ await this.db.execute(sql`select ${COLUMNS} from attachments where id = ${id}`),
89
+ ) as RawAttachment[]
90
+ return rows[0] === undefined ? null : toRecord(rows[0])
91
+ }
92
+
93
+ async findForDownload(id: number): Promise<AttachmentForDownload | null> {
94
+ const rows = resultRows(
95
+ await this.db.execute(sql`
96
+ select a.id, a.post_id, a.forum_id, a.uploader_user_id, a.filename,
97
+ a.content_type, a.size_bytes, a.storage_key, a.source_key,
98
+ a.thumbnail_key, a.width, a.height, a.status, a.failure_reason,
99
+ a.download_count, a.created_at,
100
+ p.visibility as post_visibility, t.visibility as thread_visibility,
101
+ t.author_user_id as thread_author_user_id
102
+ from attachments a
103
+ join posts p on p.id = a.post_id
104
+ join threads t on t.id = p.thread_id
105
+ where a.id = ${id}
106
+ `),
107
+ ) as Array<
108
+ RawAttachment & {
109
+ post_visibility: string
110
+ thread_visibility: string
111
+ thread_author_user_id: number | null
112
+ }
113
+ >
114
+
115
+ const row = rows[0]
116
+ if (row === undefined) return null
117
+ return {
118
+ record: toRecord(row),
119
+ postVisibility: row.post_visibility,
120
+ threadVisibility: row.thread_visibility,
121
+ threadAuthorUserId:
122
+ row.thread_author_user_id === null ? null : Number(row.thread_author_user_id),
123
+ }
124
+ }
125
+
126
+ async listForPosts(postIds: readonly number[]): Promise<readonly AttachmentRecord[]> {
127
+ if (postIds.length === 0) return []
128
+
129
+ const rows = resultRows(
130
+ await this.db.execute(sql`
131
+ select ${COLUMNS} from attachments
132
+ where post_id in ${sql`(${sql.join(
133
+ postIds.map((id) => sql`${id}`),
134
+ sql`, `,
135
+ )})`}
136
+ order by post_id, id
137
+ `),
138
+ ) as RawAttachment[]
139
+
140
+ return rows.map(toRecord)
141
+ }
142
+
143
+ async countForPost(postId: number): Promise<number> {
144
+ const rows = resultRows(
145
+ await this.db.execute(
146
+ sql`select count(*)::int as n from attachments where post_id = ${postId}`,
147
+ ),
148
+ ) as Array<{ n: number }>
149
+ return Number(rows[0]?.n ?? 0)
150
+ }
151
+
152
+ async markReady(id: number, input: ReadyInput): Promise<void> {
153
+ await this.db.execute(sql`
154
+ update attachments
155
+ set storage_key = ${input.storageKey},
156
+ thumbnail_key = ${input.thumbnailKey},
157
+ width = ${input.width},
158
+ height = ${input.height},
159
+ size_bytes = ${input.sizeBytes},
160
+ source_key = null,
161
+ status = 'ready',
162
+ ready_at = now()
163
+ where id = ${id} and status = 'pending'
164
+ `)
165
+ }
166
+
167
+ async markFailed(id: number, reason: string): Promise<void> {
168
+ await this.db.execute(sql`
169
+ update attachments
170
+ set status = 'failed', failure_reason = ${reason}, source_key = null
171
+ where id = ${id} and status = 'pending'
172
+ `)
173
+ }
174
+
175
+ async recordDownload(id: number): Promise<void> {
176
+ await this.db.execute(sql`
177
+ update attachments set download_count = download_count + 1 where id = ${id}
178
+ `)
179
+ }
180
+
181
+ async attachTo(id: number, postId: number): Promise<AttachmentRecord | null> {
182
+ const rows = resultRows(
183
+ await this.db.execute(sql`
184
+ update attachments
185
+ set post_id = ${postId}
186
+ where id = ${id} and post_id is null
187
+ returning ${COLUMNS}
188
+ `),
189
+ ) as RawAttachment[]
190
+ return rows[0] === undefined ? null : toRecord(rows[0])
191
+ }
192
+
193
+ async deleteOrphans(before: Date, limit: number): Promise<readonly AttachmentRecord[]> {
194
+ const rows = resultRows(
195
+ await this.db.execute(sql`
196
+ delete from attachments
197
+ where id in (
198
+ select id from attachments
199
+ where post_id is null and created_at < ${before}
200
+ order by created_at
201
+ limit ${limit}
202
+ )
203
+ returning ${COLUMNS}
204
+ `),
205
+ ) as RawAttachment[]
206
+ return rows.map(toRecord)
207
+ }
208
+
209
+ async stalled(before: Date, limit: number): Promise<readonly AttachmentRecord[]> {
210
+ const rows = resultRows(
211
+ await this.db.execute(sql`
212
+ select ${COLUMNS} from attachments
213
+ where status = 'pending' and created_at < ${before}
214
+ order by created_at
215
+ limit ${limit}
216
+ `),
217
+ ) as RawAttachment[]
218
+ return rows.map(toRecord)
219
+ }
220
+
221
+ async rememberKey(key: string): Promise<void> {
222
+ await rememberOrphanKey(this.db, key)
223
+ }
224
+
225
+ async forgetKeys(keys: readonly string[]): Promise<void> {
226
+ await forgetOrphanKeys(this.db, keys)
227
+ }
228
+
229
+ async staleKeys(before: Date, limit: number): Promise<readonly string[]> {
230
+ const rows = resultRows(
231
+ await this.db.execute(sql`
232
+ select storage_key from attachment_orphans
233
+ where created_at < ${before}
234
+ order by created_at
235
+ limit ${limit}
236
+ `),
237
+ ) as Array<{ storage_key: string }>
238
+ return rows.map((row) => row.storage_key)
239
+ }
240
+ }
@@ -0,0 +1,138 @@
1
+ import { and, inArray, sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ AuthorizationSource,
5
+ ForumOverride,
6
+ GroupDefaults,
7
+ ModeratorAppointment,
8
+ } from '@meith/authorization'
9
+
10
+ import type { Database } from './client'
11
+ import { forumRowToOverride, groupRowToPermissionSet } from './permissions-map'
12
+ import { resultRows } from './result-rows'
13
+ import { forumPermissions, forums, usergroups } from './schema'
14
+
15
+ export function parseAncestorPath(path: string): number[] {
16
+ const ids = path
17
+ .split('.')
18
+ .map((segment) => Number(segment))
19
+ .filter((n) => Number.isInteger(n) && n > 0)
20
+ ids.reverse()
21
+ return ids
22
+ }
23
+
24
+ export class PostgresAuthorizationSource implements AuthorizationSource {
25
+ constructor(private readonly db: Database) {}
26
+
27
+ async groupDefaults(groupIds: readonly number[]): Promise<readonly GroupDefaults[]> {
28
+ if (groupIds.length === 0) return []
29
+
30
+ const rows = await this.db
31
+ .select()
32
+ .from(usergroups)
33
+ .where(inArray(usergroups.id, [...groupIds]))
34
+
35
+ return rows.map((row) => ({
36
+ groupId: row.id,
37
+ permissions: groupRowToPermissionSet(row as Record<string, unknown>),
38
+ }))
39
+ }
40
+
41
+ async ancestorChain(forumId: number): Promise<readonly number[]> {
42
+ const rows = await this.db
43
+ .select({ path: forums.path })
44
+ .from(forums)
45
+ .where(inArray(forums.id, [forumId]))
46
+ .limit(1)
47
+
48
+ const row = rows[0]
49
+ if (!row) return []
50
+ return parseAncestorPath(row.path)
51
+ }
52
+
53
+ async forumOverrides(
54
+ forumIds: readonly number[],
55
+ groupIds: readonly number[],
56
+ ): Promise<readonly ForumOverride[]> {
57
+ if (forumIds.length === 0 || groupIds.length === 0) return []
58
+
59
+ const rows = await this.db
60
+ .select()
61
+ .from(forumPermissions)
62
+ .where(
63
+ and(
64
+ inArray(forumPermissions.forumId, [...forumIds]),
65
+ inArray(forumPermissions.groupId, [...groupIds]),
66
+ ),
67
+ )
68
+
69
+ return rows.map((row) => ({
70
+ forumId: row.forumId,
71
+ groupId: row.groupId,
72
+ overrides: forumRowToOverride(row as Record<string, unknown>),
73
+ }))
74
+ }
75
+
76
+ async allForumIds(): Promise<readonly number[]> {
77
+ const rows = await this.db.select({ id: forums.id }).from(forums)
78
+ return rows.map((r) => r.id)
79
+ }
80
+
81
+ async allAncestorChains(): Promise<ReadonlyMap<number, readonly number[]>> {
82
+ const rows = await this.db.select({ id: forums.id, path: forums.path }).from(forums)
83
+
84
+ return new Map(rows.map((r) => [r.id, parseAncestorPath(r.path)]))
85
+ }
86
+ async moderatorAppointments(
87
+ userId: number | null,
88
+ groupIds: readonly number[],
89
+ ): Promise<readonly ModeratorAppointment[]> {
90
+ if (userId === null && groupIds.length === 0) return []
91
+
92
+ const groupList =
93
+ groupIds.length === 0
94
+ ? sql`(null)`
95
+ : sql`(${sql.join(
96
+ groupIds.map((id) => sql`${id}`),
97
+ sql`, `,
98
+ )})`
99
+
100
+ const rows = resultRows(
101
+ await this.db.execute(sql`
102
+ select forum_id, cascade_to_subforums, can_approve_content,
103
+ can_edit_posts, can_soft_delete_posts, can_restore_posts,
104
+ can_open_close_threads, can_stick_threads, can_move_threads,
105
+ can_merge_threads, can_split_threads
106
+ from forum_moderators
107
+ where (user_id is not null and user_id = ${userId})
108
+ or (group_id is not null and group_id in ${groupList})
109
+ `),
110
+ ) as Array<{
111
+ forum_id: number
112
+ cascade_to_subforums: boolean
113
+ can_approve_content: boolean
114
+ can_edit_posts: boolean
115
+ can_soft_delete_posts: boolean
116
+ can_restore_posts: boolean
117
+ can_open_close_threads: boolean
118
+ can_stick_threads: boolean
119
+ can_move_threads: boolean
120
+ can_merge_threads: boolean
121
+ can_split_threads: boolean
122
+ }>
123
+
124
+ return rows.map((row) => ({
125
+ forumId: Number(row.forum_id),
126
+ cascadeToSubforums: row.cascade_to_subforums,
127
+ canApproveContent: row.can_approve_content,
128
+ canEditPosts: row.can_edit_posts,
129
+ canSoftDeletePosts: row.can_soft_delete_posts,
130
+ canRestorePosts: row.can_restore_posts,
131
+ canOpenCloseThreads: row.can_open_close_threads,
132
+ canStickThreads: row.can_stick_threads,
133
+ canMoveThreads: row.can_move_threads,
134
+ canMergeThreads: row.can_merge_threads,
135
+ canSplitThreads: row.can_split_threads,
136
+ }))
137
+ }
138
+ }
@@ -0,0 +1,185 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type { AvatarRepository, AvatarStatus, StoredAvatar } from '@meith/avatars'
4
+
5
+ import type { Database } from './client'
6
+ import { forgetOrphanKeys, rememberOrphanKey } from './orphan-keys'
7
+ import { resultRows } from './result-rows'
8
+ import { toNullableDate } from './row-values'
9
+
10
+ interface RawAvatar {
11
+ id?: number
12
+ avatar_status: string
13
+ avatar_key: string | null
14
+ avatar_source_key: string | null
15
+ avatar_width: number | null
16
+ avatar_height: number | null
17
+ avatar_failure_reason: string | null
18
+ avatar_updated_at: string | Date | null
19
+ avatar_locked: boolean
20
+ avatar_locked_reason: string | null
21
+ }
22
+
23
+ const STATUSES = ['none', 'pending', 'ready', 'failed'] as const
24
+
25
+ function toAvatar(row: RawAvatar): StoredAvatar {
26
+ return {
27
+ status: STATUSES.includes(row.avatar_status as AvatarStatus)
28
+ ? (row.avatar_status as AvatarStatus)
29
+ : 'failed',
30
+ key: row.avatar_key,
31
+ sourceKey: row.avatar_source_key,
32
+ width: row.avatar_width === null ? null : Number(row.avatar_width),
33
+ height: row.avatar_height === null ? null : Number(row.avatar_height),
34
+ failureReason: row.avatar_failure_reason,
35
+ updatedAt: toNullableDate(row.avatar_updated_at),
36
+ locked: row.avatar_locked === true,
37
+ lockedReason: row.avatar_locked_reason,
38
+ }
39
+ }
40
+
41
+ const COLUMNS = sql`
42
+ avatar_status, avatar_key, avatar_source_key, avatar_width, avatar_height,
43
+ avatar_failure_reason, avatar_updated_at, avatar_locked, avatar_locked_reason
44
+ `
45
+
46
+ function droppedKeys(row: { avatar_key: string | null; avatar_source_key: string | null }) {
47
+ return [row.avatar_key, row.avatar_source_key].filter((key): key is string => key !== null)
48
+ }
49
+
50
+ export class PostgresAvatarRepository implements AvatarRepository {
51
+ constructor(private readonly db: Database) {}
52
+
53
+ async find(userId: number): Promise<StoredAvatar | null> {
54
+ const rows = resultRows(
55
+ await this.db.execute(sql`select ${COLUMNS} from users where id = ${userId}`),
56
+ ) as RawAvatar[]
57
+ return rows[0] === undefined ? null : toAvatar(rows[0])
58
+ }
59
+
60
+ async readMany(userIds: readonly number[]): Promise<ReadonlyMap<number, StoredAvatar>> {
61
+ if (userIds.length === 0) return new Map()
62
+
63
+ const rows = resultRows(
64
+ await this.db.execute(sql`
65
+ select id, ${COLUMNS} from users
66
+ where id in ${sql`(${sql.join(
67
+ userIds.map((id) => sql`${id}`),
68
+ sql`, `,
69
+ )})`}
70
+ `),
71
+ ) as RawAvatar[]
72
+
73
+ return new Map(rows.map((row) => [Number(row.id), toAvatar(row)]))
74
+ }
75
+
76
+ async beginUpload(input: {
77
+ readonly userId: number
78
+ readonly sourceKey: string
79
+ readonly at: Date
80
+ }): Promise<{ replaced: readonly string[] }> {
81
+ const rows = resultRows(
82
+ await this.db.execute(sql`
83
+ update users u
84
+ set avatar_source_key = ${input.sourceKey},
85
+ avatar_key = null,
86
+ avatar_status = 'pending',
87
+ avatar_failure_reason = null,
88
+ avatar_width = null,
89
+ avatar_height = null,
90
+ avatar_updated_at = ${input.at}
91
+ from users old
92
+ where u.id = ${input.userId}
93
+ and old.id = u.id
94
+ and u.avatar_locked = false
95
+ returning old.avatar_key, old.avatar_source_key
96
+ `),
97
+ ) as Array<{ avatar_key: string | null; avatar_source_key: string | null }>
98
+
99
+ const row = rows[0]
100
+ if (row === undefined) return { replaced: [input.sourceKey] }
101
+ return { replaced: droppedKeys(row) }
102
+ }
103
+
104
+ async markReady(input: {
105
+ readonly userId: number
106
+ readonly key: string
107
+ readonly width: number
108
+ readonly height: number
109
+ }): Promise<void> {
110
+ await this.db.execute(sql`
111
+ update users
112
+ set avatar_key = ${input.key},
113
+ avatar_source_key = null,
114
+ avatar_width = ${input.width},
115
+ avatar_height = ${input.height},
116
+ avatar_status = 'ready'
117
+ where id = ${input.userId} and avatar_status = 'pending'
118
+ `)
119
+ }
120
+
121
+ async markFailed(userId: number, reason: string): Promise<void> {
122
+ await this.db.execute(sql`
123
+ update users
124
+ set avatar_status = 'failed',
125
+ avatar_failure_reason = ${reason},
126
+ avatar_source_key = null
127
+ where id = ${userId} and avatar_status = 'pending'
128
+ `)
129
+ }
130
+
131
+ async clear(userId: number): Promise<{ replaced: readonly string[] }> {
132
+ const rows = resultRows(
133
+ await this.db.execute(sql`
134
+ update users u
135
+ set avatar_key = null,
136
+ avatar_source_key = null,
137
+ avatar_status = 'none',
138
+ avatar_failure_reason = null,
139
+ avatar_width = null,
140
+ avatar_height = null,
141
+ avatar_updated_at = null
142
+ from users old
143
+ where u.id = ${userId}
144
+ and old.id = u.id
145
+ and u.avatar_locked = false
146
+ returning old.avatar_key, old.avatar_source_key
147
+ `),
148
+ ) as Array<{ avatar_key: string | null; avatar_source_key: string | null }>
149
+
150
+ const row = rows[0]
151
+ return { replaced: row === undefined ? [] : droppedKeys(row) }
152
+ }
153
+
154
+ async lock(input: {
155
+ readonly userId: number
156
+ readonly locked: boolean
157
+ readonly reason: string | null
158
+ }): Promise<void> {
159
+ await this.db.execute(sql`
160
+ update users
161
+ set avatar_locked = ${input.locked}, avatar_locked_reason = ${input.reason}
162
+ where id = ${input.userId}
163
+ `)
164
+ }
165
+
166
+ async stalled(before: Date, limit: number): Promise<readonly number[]> {
167
+ const rows = resultRows(
168
+ await this.db.execute(sql`
169
+ select id from users
170
+ where avatar_status = 'pending' and avatar_updated_at < ${before}
171
+ order by avatar_updated_at
172
+ limit ${limit}
173
+ `),
174
+ ) as Array<{ id: number }>
175
+ return rows.map((row) => Number(row.id))
176
+ }
177
+
178
+ async rememberKey(key: string): Promise<void> {
179
+ await rememberOrphanKey(this.db, key)
180
+ }
181
+
182
+ async forgetKeys(keys: readonly string[]): Promise<void> {
183
+ await forgetOrphanKeys(this.db, keys)
184
+ }
185
+ }
@@ -0,0 +1,146 @@
1
+ import { and, asc, eq, isNotNull, isNull, lte, sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ BanFilter,
5
+ BanFilterRepository,
6
+ BanFilterType,
7
+ BanRecord,
8
+ BanRepository,
9
+ CreateBanInput,
10
+ } from '@meith/accounts'
11
+
12
+ import type { Database } from './client'
13
+ import { banFilters, bans, rememberTokens, sessions, users } from './schema'
14
+
15
+ const BAN_COLUMNS = {
16
+ id: bans.id,
17
+ userId: bans.userId,
18
+ reason: bans.reason,
19
+ publicReason: bans.publicReason,
20
+ previousPrimaryGroupId: bans.previousPrimaryGroupId,
21
+ expiresAt: bans.expiresAt,
22
+ liftedAt: bans.liftedAt,
23
+ } as const
24
+
25
+ type Tx = Parameters<Parameters<Database['transaction']>[0]>[0]
26
+
27
+ async function liftWithin(tx: Tx, ban: BanRecord, now: Date): Promise<void> {
28
+ await tx.update(bans).set({ liftedAt: now }).where(eq(bans.id, ban.id))
29
+
30
+ if (ban.previousPrimaryGroupId !== null) {
31
+ await tx
32
+ .update(users)
33
+ .set({
34
+ primaryGroupId: ban.previousPrimaryGroupId,
35
+ displayGroupId: ban.previousPrimaryGroupId,
36
+ })
37
+ .where(eq(users.id, ban.userId))
38
+ }
39
+ }
40
+
41
+ export class PostgresBanRepository implements BanRepository {
42
+ constructor(private readonly db: Database) {}
43
+
44
+ async findActive(userId: number): Promise<BanRecord | null> {
45
+ const rows = await this.db
46
+ .select(BAN_COLUMNS)
47
+ .from(bans)
48
+ .where(and(eq(bans.userId, userId), isNull(bans.liftedAt)))
49
+ .limit(1)
50
+
51
+ return rows[0] ?? null
52
+ }
53
+
54
+ async create(input: CreateBanInput): Promise<BanRecord> {
55
+ return this.db.transaction(async (tx) => {
56
+ const [target] = await tx
57
+ .select({
58
+ primaryGroupId: users.primaryGroupId,
59
+ })
60
+ .from(users)
61
+ .where(eq(users.id, input.userId))
62
+ .limit(1)
63
+
64
+ if (!target) throw new Error(`Cannot ban unknown user ${input.userId}`)
65
+
66
+ const [row] = await tx
67
+ .insert(bans)
68
+ .values({
69
+ userId: input.userId,
70
+ bannedByUserId: input.bannedByUserId,
71
+ reason: input.reason,
72
+ publicReason: input.publicReason,
73
+ previousPrimaryGroupId: target.primaryGroupId,
74
+ expiresAt: input.expiresAt,
75
+ createdAt: input.now,
76
+ })
77
+ .returning(BAN_COLUMNS)
78
+
79
+ await tx
80
+ .update(users)
81
+ .set({ primaryGroupId: input.bannedGroupId, displayGroupId: input.bannedGroupId })
82
+ .where(eq(users.id, input.userId))
83
+
84
+ await tx
85
+ .update(sessions)
86
+ .set({ revokedAt: input.now })
87
+ .where(and(eq(sessions.userId, input.userId), isNull(sessions.revokedAt)))
88
+
89
+ await tx
90
+ .update(rememberTokens)
91
+ .set({ revokedAt: input.now, revokedReason: 'account_banned' })
92
+ .where(and(eq(rememberTokens.userId, input.userId), isNull(rememberTokens.revokedAt)))
93
+
94
+ return row as BanRecord
95
+ })
96
+ }
97
+
98
+ async lift(banId: number, now: Date): Promise<void> {
99
+ await this.db.transaction(async (tx) => {
100
+ const [ban] = await tx
101
+ .select(BAN_COLUMNS)
102
+ .from(bans)
103
+ .where(and(eq(bans.id, banId), isNull(bans.liftedAt)))
104
+ .limit(1)
105
+
106
+ if (!ban) return
107
+ await liftWithin(tx, ban, now)
108
+ })
109
+ }
110
+
111
+ async expireDue(now: Date, limit: number): Promise<number> {
112
+ return this.db.transaction(async (tx) => {
113
+ const due = await tx
114
+ .select(BAN_COLUMNS)
115
+ .from(bans)
116
+ .where(and(isNull(bans.liftedAt), isNotNull(bans.expiresAt), lte(bans.expiresAt, now)))
117
+ .orderBy(asc(bans.expiresAt))
118
+ .limit(limit)
119
+ .for('update', { skipLocked: true })
120
+
121
+ for (const ban of due) await liftWithin(tx, ban, now)
122
+
123
+ if (due.length > 0) {
124
+ await tx.execute(sql`
125
+ insert into cache_versions (key, version)
126
+ values ('permissions', 1)
127
+ on conflict (key) do update set version = cache_versions.version + 1, bumped_at = now()
128
+ `)
129
+ }
130
+
131
+ return due.length
132
+ })
133
+ }
134
+ }
135
+
136
+ export class PostgresBanFilterRepository implements BanFilterRepository {
137
+ constructor(private readonly db: Database) {}
138
+
139
+ async listAll(): Promise<readonly BanFilter[]> {
140
+ const rows = await this.db
141
+ .select({ id: banFilters.id, type: banFilters.type, pattern: banFilters.pattern })
142
+ .from(banFilters)
143
+
144
+ return rows.map((r) => ({ id: r.id, type: r.type as BanFilterType, pattern: r.pattern }))
145
+ }
146
+ }