@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,328 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ PostingRestriction,
5
+ WarningLevel,
6
+ WarningPage,
7
+ WarningRepository,
8
+ WarningRow,
9
+ WarningType,
10
+ } from '@meith/moderation'
11
+ import { parseWarningAction } from '@meith/moderation'
12
+
13
+ import type { Database } from './client'
14
+ import { decodeCursor, encodeCursor } from './cursor'
15
+ import { resultRows } from './result-rows'
16
+
17
+ interface Tx {
18
+ execute(query: ReturnType<typeof sql>): Promise<unknown>
19
+ }
20
+
21
+ const LIVE = sql`revoked_at is null and (expires_at is null or expires_at > now())`
22
+
23
+ export async function recalculateWarningPoints(tx: Tx, userId: number): Promise<number> {
24
+ const rows = resultRows(
25
+ await tx.execute(sql`
26
+ update users u
27
+ set warning_points = coalesce((
28
+ select sum(w.points)::int from warnings w
29
+ where w.user_id = ${userId} and ${LIVE}
30
+ ), 0),
31
+ updated_at = now()
32
+ where u.id = ${userId}
33
+ returning u.warning_points
34
+ `),
35
+ ) as Array<{ warning_points: number }>
36
+ return Number(rows[0]?.warning_points ?? 0)
37
+ }
38
+
39
+ function toRow(row: {
40
+ id: number
41
+ user_id: number
42
+ title: string
43
+ points: number
44
+ reason: string
45
+ issued_by_user_id: number | null
46
+ issued_by_username: string | null
47
+ post_id: number | null
48
+ created_at: Date
49
+ expires_at: Date | null
50
+ revoked_at: Date | null
51
+ revoked_by_username: string | null
52
+ revoke_reason: string | null
53
+ }): WarningRow {
54
+ return {
55
+ id: Number(row.id),
56
+ userId: Number(row.user_id),
57
+ title: row.title,
58
+ points: Number(row.points),
59
+ reason: row.reason,
60
+ issuedByUserId: row.issued_by_user_id === null ? null : Number(row.issued_by_user_id),
61
+ issuedByUsername: row.issued_by_username,
62
+ postId: row.post_id === null ? null : Number(row.post_id),
63
+ createdAt: new Date(row.created_at),
64
+ expiresAt: row.expires_at === null ? null : new Date(row.expires_at),
65
+ revokedAt: row.revoked_at === null ? null : new Date(row.revoked_at),
66
+ revokedByUsername: row.revoked_by_username,
67
+ revokeReason: row.revoke_reason,
68
+ }
69
+ }
70
+
71
+ export class PostgresWarningRepository implements WarningRepository {
72
+ constructor(private readonly db: Database) {}
73
+
74
+ async listTypes(): Promise<readonly WarningType[]> {
75
+ const rows = resultRows(
76
+ await this.db.execute(sql`
77
+ select id, title, points, expiry_days from warning_types
78
+ where is_active = true
79
+ order by display_order, id
80
+ `),
81
+ ) as Array<{ id: number; title: string; points: number; expiry_days: number | null }>
82
+
83
+ return rows.map((row) => ({
84
+ id: Number(row.id),
85
+ title: row.title,
86
+ points: Number(row.points),
87
+ expiryDays: row.expiry_days === null ? null : Number(row.expiry_days),
88
+ }))
89
+ }
90
+
91
+ async listLevels(): Promise<readonly WarningLevel[]> {
92
+ const rows = resultRows(
93
+ await this.db.execute(sql`
94
+ select points, action, duration_days from warning_levels order by points
95
+ `),
96
+ ) as Array<{ points: number; action: string; duration_days: number | null }>
97
+
98
+ return rows.flatMap((row) => {
99
+ const action = parseWarningAction(row.action)
100
+ return action === null
101
+ ? []
102
+ : [
103
+ {
104
+ points: Number(row.points),
105
+ action,
106
+ durationDays: row.duration_days === null ? null : Number(row.duration_days),
107
+ },
108
+ ]
109
+ })
110
+ }
111
+
112
+ async findType(typeId: number): Promise<WarningType | null> {
113
+ const rows = resultRows(
114
+ await this.db.execute(sql`
115
+ select id, title, points, expiry_days from warning_types
116
+ where id = ${typeId} and is_active = true
117
+ `),
118
+ ) as Array<{ id: number; title: string; points: number; expiry_days: number | null }>
119
+ const row = rows[0]
120
+ if (!row) return null
121
+ return {
122
+ id: Number(row.id),
123
+ title: row.title,
124
+ points: Number(row.points),
125
+ expiryDays: row.expiry_days === null ? null : Number(row.expiry_days),
126
+ }
127
+ }
128
+
129
+ async findWarnable(userId: number): Promise<{ id: number; username: string } | null> {
130
+ const rows = resultRows(
131
+ await this.db.execute(sql`
132
+ select id, username from users where id = ${userId} and state <> 'deleted'
133
+ `),
134
+ ) as Array<{ id: number; username: string }>
135
+ const row = rows[0]
136
+ return row === undefined ? null : { id: Number(row.id), username: row.username }
137
+ }
138
+
139
+ async findPostAuthor(postId: number): Promise<number | null> {
140
+ const rows = resultRows(
141
+ await this.db.execute(sql`select author_user_id from posts where id = ${postId}`),
142
+ ) as Array<{ author_user_id: number | null }>
143
+ const row = rows[0]
144
+ return row === undefined || row.author_user_id === null ? null : Number(row.author_user_id)
145
+ }
146
+
147
+ async issue(input: {
148
+ readonly userId: number
149
+ readonly issuedByUserId: number
150
+ readonly typeId: number | null
151
+ readonly title: string
152
+ readonly points: number
153
+ readonly reason: string
154
+ readonly postId: number | null
155
+ readonly expiresAt: Date | null
156
+ readonly at: Date
157
+ }): Promise<{ warningId: number; points: number }> {
158
+ return this.db.transaction(async (tx) => {
159
+ const written = resultRows(
160
+ await tx.execute(sql`
161
+ insert into warnings
162
+ (user_id, issued_by_user_id, type_id, title, points, reason, post_id,
163
+ created_at, expires_at)
164
+ values
165
+ (${input.userId}, ${input.issuedByUserId}, ${input.typeId}, ${input.title},
166
+ ${input.points}, ${input.reason}, ${input.postId}, ${input.at},
167
+ ${input.expiresAt})
168
+ returning id
169
+ `),
170
+ ) as Array<{ id: number }>
171
+
172
+ const points = await recalculateWarningPoints(tx, input.userId)
173
+
174
+ await tx.execute(sql`
175
+ insert into admin_log (user_id, action, detail, created_at)
176
+ values (
177
+ ${input.issuedByUserId},
178
+ 'warning.issue',
179
+ ${JSON.stringify({
180
+ warningId: Number(written[0]!.id),
181
+ userId: input.userId,
182
+ points: input.points,
183
+ total: points,
184
+ })}::jsonb,
185
+ ${input.at}
186
+ )
187
+ `)
188
+
189
+ return { warningId: Number(written[0]!.id), points }
190
+ })
191
+ }
192
+
193
+ async revoke(input: {
194
+ readonly warningId: number
195
+ readonly actorUserId: number
196
+ readonly reason: string
197
+ readonly at: Date
198
+ }): Promise<{ userId: number; points: number } | null> {
199
+ return this.db.transaction(async (tx) => {
200
+ const moved = resultRows(
201
+ await tx.execute(sql`
202
+ update warnings
203
+ set revoked_at = ${input.at},
204
+ revoked_by_user_id = ${input.actorUserId},
205
+ revoke_reason = ${input.reason}
206
+ where id = ${input.warningId} and revoked_at is null
207
+ returning user_id
208
+ `),
209
+ ) as Array<{ user_id: number }>
210
+ const row = moved[0]
211
+ if (!row) return null
212
+
213
+ const userId = Number(row.user_id)
214
+ const points = await recalculateWarningPoints(tx, userId)
215
+
216
+ await tx.execute(sql`
217
+ insert into admin_log (user_id, action, detail, created_at)
218
+ values (
219
+ ${input.actorUserId},
220
+ 'warning.revoke',
221
+ ${JSON.stringify({ warningId: input.warningId, userId, total: points })}::jsonb,
222
+ ${input.at}
223
+ )
224
+ `)
225
+
226
+ return { userId, points }
227
+ })
228
+ }
229
+
230
+ async pointsFor(userId: number): Promise<number> {
231
+ return this.db.transaction(async (tx) => recalculateWarningPoints(tx, userId))
232
+ }
233
+
234
+ async readRestriction(userId: number): Promise<PostingRestriction> {
235
+ const rows = resultRows(
236
+ await this.db.execute(sql`
237
+ select suspended_posting_until, moderated_posting_until
238
+ from users where id = ${userId}
239
+ `),
240
+ ) as Array<{
241
+ suspended_posting_until: Date | null
242
+ moderated_posting_until: Date | null
243
+ }>
244
+ const row = rows[0]
245
+ if (!row) return { suspendedUntil: null, moderatedUntil: null }
246
+ return {
247
+ suspendedUntil:
248
+ row.suspended_posting_until === null ? null : new Date(row.suspended_posting_until),
249
+ moderatedUntil:
250
+ row.moderated_posting_until === null ? null : new Date(row.moderated_posting_until),
251
+ }
252
+ }
253
+
254
+ async applyRestriction(input: {
255
+ readonly userId: number
256
+ readonly restriction: PostingRestriction
257
+ readonly at: Date
258
+ }): Promise<void> {
259
+ await this.db.execute(sql`
260
+ update users
261
+ set suspended_posting_until = ${input.restriction.suspendedUntil},
262
+ moderated_posting_until = ${input.restriction.moderatedUntil},
263
+ updated_at = now()
264
+ where id = ${input.userId}
265
+ `)
266
+ }
267
+
268
+ async history(
269
+ userId: number,
270
+ options: { readonly limit: number; readonly after?: string },
271
+ ): Promise<WarningPage> {
272
+ const cursor = options.after === undefined ? null : decodeCursor(options.after)
273
+ const after = cursor ? sql`and (w.created_at, w.id) < (${cursor.at}, ${cursor.id})` : sql``
274
+
275
+ const rows = resultRows(
276
+ await this.db.execute(sql`
277
+ select w.id, w.user_id, w.title, w.points, w.reason,
278
+ w.issued_by_user_id, issuer.username as issued_by_username,
279
+ w.post_id, w.created_at, w.expires_at,
280
+ w.revoked_at, revoker.username as revoked_by_username, w.revoke_reason
281
+ from warnings w
282
+ left join users issuer on issuer.id = w.issued_by_user_id
283
+ left join users revoker on revoker.id = w.revoked_by_user_id
284
+ where w.user_id = ${userId} ${after}
285
+ order by w.created_at desc, w.id desc
286
+ limit ${options.limit + 1}
287
+ `),
288
+ ) as Parameters<typeof toRow>[0][]
289
+
290
+ const mapped = rows.slice(0, options.limit).map(toRow)
291
+ const last = mapped.at(-1)
292
+ return rows.length > options.limit && last
293
+ ? { rows: mapped, nextCursor: encodeCursor(last.createdAt, last.id) }
294
+ : { rows: mapped }
295
+ }
296
+
297
+ async expireDue(
298
+ now: Date,
299
+ limit: number,
300
+ ): Promise<{ expired: number; userIds: readonly number[] }> {
301
+ const rows = resultRows(
302
+ await this.db.execute(sql`
303
+ select w.id, w.user_id
304
+ from warnings w
305
+ join users u on u.id = w.user_id
306
+ where w.revoked_at is null
307
+ and w.expires_at is not null
308
+ and w.expires_at <= ${now}
309
+ /*
310
+ * Only the members whose cached total still counts this warning.
311
+ * Without it, every past expiry on the board is rediscovered on
312
+ * every tick and the sweep never reaches the newest ones.
313
+ */
314
+ and u.warning_points > coalesce((
315
+ select sum(x.points)::int from warnings x
316
+ where x.user_id = w.user_id and ${LIVE}
317
+ ), 0)
318
+ order by w.expires_at
319
+ limit ${limit}
320
+ `),
321
+ ) as Array<{ id: number; user_id: number }>
322
+
323
+ return {
324
+ expired: rows.length,
325
+ userIds: [...new Set(rows.map((row) => Number(row.user_id)))],
326
+ }
327
+ }
328
+ }