@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,384 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ import { BodyFormat } from '@meith/markdown'
4
+ import type {
5
+ FolderCounts,
6
+ MessageDetail,
7
+ MessageFolder,
8
+ MessageListRow,
9
+ MessageRepository,
10
+ MessageRole,
11
+ PrivateMessage,
12
+ } from '@meith/messages'
13
+
14
+ import type { Database } from './client'
15
+ import { resultRows } from './result-rows'
16
+ import { toDate, toNullableDate } from './row-values'
17
+
18
+ const NAMES_SHOWN = 3
19
+
20
+ interface RawMessage {
21
+ id: number
22
+ author_user_id: number | null
23
+ author_username: string
24
+ subject: string
25
+ message: string
26
+ message_html: string | null
27
+ render_version: number
28
+ body_format: number
29
+ vocab_version: number
30
+ reply_to_id: number | null
31
+ receipt_requested: boolean
32
+ sent_at: string | Date
33
+ }
34
+
35
+ function toMessage(row: RawMessage): PrivateMessage {
36
+ return {
37
+ id: Number(row.id),
38
+ authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
39
+ authorUsername: row.author_username,
40
+ subject: row.subject,
41
+ message: row.message,
42
+ messageHtml: row.message_html,
43
+ renderVersion: Number(row.render_version),
44
+ bodyFormat: Number(row.body_format),
45
+ vocabVersion: Number(row.vocab_version),
46
+ replyToId: row.reply_to_id === null ? null : Number(row.reply_to_id),
47
+ receiptRequested: row.receipt_requested === true,
48
+ sentAt: toDate(row.sent_at),
49
+ }
50
+ }
51
+
52
+ const MESSAGE_COLUMNS = sql`
53
+ m.id, m.author_user_id, m.author_username, m.subject, m.message,
54
+ m.message_html, m.render_version, m.body_format, m.vocab_version, m.reply_to_id,
55
+ m.receipt_requested, m.sent_at
56
+ `
57
+
58
+ export class PostgresMessageRepository implements MessageRepository {
59
+ constructor(private readonly db: Database) {}
60
+
61
+ async list(input: {
62
+ readonly userId: number
63
+ readonly folder: MessageFolder
64
+ readonly limit: number
65
+ readonly before?: number | undefined
66
+ readonly offset?: number | undefined
67
+ }): Promise<readonly MessageListRow[]> {
68
+ const before = input.before === undefined ? sql`` : sql`and c.id < ${input.before}`
69
+
70
+ const rows = resultRows(
71
+ await this.db.execute(sql`
72
+ select c.id as copy_id,
73
+ c.message_id,
74
+ c.folder,
75
+ c.role,
76
+ c.read_at,
77
+ m.subject,
78
+ m.sent_at,
79
+ /*
80
+ * The *other* people on the message. The author is excluded from
81
+ * a sent line (it is you) and the viewer from an inbox line, so
82
+ * both folders read as "who else was involved".
83
+ *
84
+ * A bcc recipient is named only to the author. Everybody else
85
+ * gets the count without the name, which is the whole point of
86
+ * bcc — and doing it in SQL means no listing path can forget.
87
+ */
88
+ coalesce(names.shown, '{}') as counterparties,
89
+ coalesce(names.total, 0) as counterparty_total
90
+ from private_message_copies c
91
+ join private_messages m on m.id = c.message_id
92
+ left join lateral (
93
+ select array_agg(u.username order by o.id)
94
+ filter (where o.rn <= ${NAMES_SHOWN}) as shown,
95
+ count(*) as total
96
+ from (
97
+ select oc.owner_user_id as id,
98
+ row_number() over (order by oc.id) as rn
99
+ from private_message_copies oc
100
+ where oc.message_id = c.message_id
101
+ and oc.owner_user_id <> ${input.userId}
102
+ and (oc.role <> 'bcc' or m.author_user_id = ${input.userId})
103
+ ) o
104
+ join users u on u.id = o.id
105
+ ) names on true
106
+ where c.owner_user_id = ${input.userId}
107
+ and c.folder = ${input.folder}
108
+ ${before}
109
+ order by c.id desc
110
+ limit ${input.limit} offset ${input.offset ?? 0}
111
+ `),
112
+ ) as Array<{
113
+ copy_id: number
114
+ message_id: number
115
+ folder: string
116
+ role: string
117
+ read_at: string | Date | null
118
+ subject: string
119
+ sent_at: string | Date
120
+ counterparties: string[] | null
121
+ counterparty_total: number
122
+ }>
123
+
124
+ return rows.map((row) => {
125
+ const shown = row.counterparties ?? []
126
+ return {
127
+ copyId: Number(row.copy_id),
128
+ messageId: Number(row.message_id),
129
+ folder: row.folder as MessageFolder,
130
+ role: row.role as MessageRole,
131
+ subject: row.subject,
132
+ sentAt: toDate(row.sent_at),
133
+ readAt: toNullableDate(row.read_at),
134
+ counterparties: shown,
135
+ moreCounterparties: Math.max(0, Number(row.counterparty_total) - shown.length),
136
+ }
137
+ })
138
+ }
139
+
140
+ async counts(userId: number): Promise<FolderCounts> {
141
+ const rows = resultRows(
142
+ await this.db.execute(sql`
143
+ select
144
+ count(*) filter (where folder = 'inbox')::int as inbox,
145
+ count(*) filter (where folder = 'sent')::int as sent,
146
+ count(*) filter (where folder = 'trash')::int as trash,
147
+ count(*) filter (where folder = 'inbox' and read_at is null)::int as unread,
148
+ count(*)::int as stored
149
+ from private_message_copies
150
+ where owner_user_id = ${userId}
151
+ `),
152
+ ) as Array<{ inbox: number; sent: number; trash: number; unread: number; stored: number }>
153
+
154
+ const row = rows[0]
155
+ return {
156
+ inbox: Number(row?.inbox ?? 0),
157
+ sent: Number(row?.sent ?? 0),
158
+ trash: Number(row?.trash ?? 0),
159
+ unread: Number(row?.unread ?? 0),
160
+ stored: Number(row?.stored ?? 0),
161
+ }
162
+ }
163
+
164
+ async storedCounts(userIds: readonly number[]): Promise<ReadonlyMap<number, number>> {
165
+ if (userIds.length === 0) return new Map()
166
+
167
+ const rows = resultRows(
168
+ await this.db.execute(sql`
169
+ select owner_user_id, count(*)::int as n
170
+ from private_message_copies
171
+ where owner_user_id in (${sql.join(
172
+ userIds.map((id) => sql`${id}`),
173
+ sql`, `,
174
+ )})
175
+ group by owner_user_id
176
+ `),
177
+ ) as Array<{ owner_user_id: number; n: number }>
178
+
179
+ return new Map(rows.map((row) => [Number(row.owner_user_id), Number(row.n)]))
180
+ }
181
+
182
+ async detail(input: {
183
+ readonly messageId: number
184
+ readonly userId: number
185
+ }): Promise<MessageDetail | null> {
186
+ const rows = resultRows(
187
+ await this.db.execute(sql`
188
+ select ${MESSAGE_COLUMNS},
189
+ c.id as copy_id, c.folder, c.role, c.read_at
190
+ from private_message_copies c
191
+ join private_messages m on m.id = c.message_id
192
+ where c.message_id = ${input.messageId}
193
+ and c.owner_user_id = ${input.userId}
194
+ `),
195
+ ) as Array<
196
+ RawMessage & {
197
+ copy_id: number
198
+ folder: string
199
+ role: string
200
+ read_at: string | Date | null
201
+ }
202
+ >
203
+
204
+ const row = rows[0]
205
+ if (row === undefined) return null
206
+
207
+ const people = resultRows(
208
+ await this.db.execute(sql`
209
+ select c.owner_user_id, c.role, c.read_at, u.username
210
+ from private_message_copies c
211
+ join users u on u.id = c.owner_user_id
212
+ where c.message_id = ${input.messageId}
213
+ order by c.id
214
+ `),
215
+ ) as Array<{
216
+ owner_user_id: number
217
+ role: string
218
+ read_at: string | Date | null
219
+ username: string
220
+ }>
221
+
222
+ return {
223
+ message: toMessage(row),
224
+ copy: {
225
+ id: Number(row.copy_id),
226
+ messageId: Number(row.id),
227
+ ownerUserId: input.userId,
228
+ folder: row.folder as MessageFolder,
229
+ role: row.role as MessageRole,
230
+ readAt: toNullableDate(row.read_at),
231
+ },
232
+ participants: people.map((person) => ({
233
+ userId: Number(person.owner_user_id),
234
+ username: person.username,
235
+ role: person.role as MessageRole,
236
+ readAt: toNullableDate(person.read_at),
237
+ })),
238
+ }
239
+ }
240
+
241
+ async send(input: {
242
+ readonly authorUserId: number
243
+ readonly authorUsername: string
244
+ readonly subject: string
245
+ readonly message: string
246
+ readonly messageHtml: string
247
+ readonly renderVersion: number
248
+ readonly vocabVersion: number
249
+ readonly replyToId: number | null
250
+ readonly receiptRequested: boolean
251
+ readonly recipients: readonly { userId: number; bcc: boolean }[]
252
+ readonly at: Date
253
+ }): Promise<number> {
254
+ return this.db.transaction(async (tx) => {
255
+ const rows = resultRows(
256
+ await tx.execute(sql`
257
+ insert into private_messages
258
+ (author_user_id, author_username, subject, message, message_html,
259
+ render_version, body_format, vocab_version, reply_to_id,
260
+ receipt_requested, sent_at)
261
+ values (${input.authorUserId}, ${input.authorUsername}, ${input.subject},
262
+ ${input.message}, ${input.messageHtml}, ${input.renderVersion},
263
+ ${BodyFormat.Markdown}, ${input.vocabVersion}, ${input.replyToId},
264
+ ${input.receiptRequested}, ${input.at})
265
+ returning id
266
+ `),
267
+ ) as Array<{ id: number }>
268
+
269
+ const row = rows[0]
270
+ if (row === undefined) throw new Error('Private message insert returned no row')
271
+ const messageId = Number(row.id)
272
+
273
+ const copies = [
274
+ sql`(${messageId}, ${input.authorUserId}, 'sent', 'author', ${input.at}, ${input.at})`,
275
+ ...input.recipients.map(
276
+ (recipient) =>
277
+ sql`(${messageId}, ${recipient.userId}, 'inbox', ${
278
+ recipient.bcc ? 'bcc' : 'to'
279
+ }, null, ${input.at})`,
280
+ ),
281
+ ]
282
+
283
+ await tx.execute(sql`
284
+ insert into private_message_copies
285
+ (message_id, owner_user_id, folder, role, read_at, created_at)
286
+ values ${sql.join(copies, sql`, `)}
287
+ `)
288
+
289
+ return messageId
290
+ })
291
+ }
292
+
293
+ async setRead(input: {
294
+ readonly userId: number
295
+ readonly copyIds: readonly number[]
296
+ readonly at: Date | null
297
+ }): Promise<number> {
298
+ if (input.copyIds.length === 0) return 0
299
+
300
+ const rows = resultRows(
301
+ await this.db.execute(sql`
302
+ update private_message_copies
303
+ set read_at = ${input.at}
304
+ where owner_user_id = ${input.userId}
305
+ and id in (${sql.join(
306
+ input.copyIds.map((id) => sql`${id}`),
307
+ sql`, `,
308
+ )})
309
+ returning id
310
+ `),
311
+ ) as Array<{ id: number }>
312
+
313
+ return rows.length
314
+ }
315
+
316
+ async move(input: {
317
+ readonly userId: number
318
+ readonly copyIds: readonly number[]
319
+ readonly folder: MessageFolder
320
+ }): Promise<number> {
321
+ if (input.copyIds.length === 0) return 0
322
+
323
+ const rows = resultRows(
324
+ await this.db.execute(sql`
325
+ update private_message_copies
326
+ set folder = ${input.folder}
327
+ where owner_user_id = ${input.userId}
328
+ and id in (${sql.join(
329
+ input.copyIds.map((id) => sql`${id}`),
330
+ sql`, `,
331
+ )})
332
+ returning id
333
+ `),
334
+ ) as Array<{ id: number }>
335
+
336
+ return rows.length
337
+ }
338
+
339
+ async remove(input: {
340
+ readonly userId: number
341
+ readonly copyIds: readonly number[]
342
+ }): Promise<number> {
343
+ if (input.copyIds.length === 0) return 0
344
+
345
+ const rows = resultRows(
346
+ await this.db.execute(sql`
347
+ delete from private_message_copies
348
+ where owner_user_id = ${input.userId}
349
+ and id in (${sql.join(
350
+ input.copyIds.map((id) => sql`${id}`),
351
+ sql`, `,
352
+ )})
353
+ returning id
354
+ `),
355
+ ) as Array<{ id: number }>
356
+
357
+ return rows.length
358
+ }
359
+
360
+ async emptyFolder(input: {
361
+ readonly userId: number
362
+ readonly folder: MessageFolder
363
+ }): Promise<number> {
364
+ const rows = resultRows(
365
+ await this.db.execute(sql`
366
+ delete from private_message_copies
367
+ where owner_user_id = ${input.userId} and folder = ${input.folder}
368
+ returning id
369
+ `),
370
+ ) as Array<{ id: number }>
371
+
372
+ return rows.length
373
+ }
374
+
375
+ async forReport(messageId: number): Promise<PrivateMessage | null> {
376
+ const rows = resultRows(
377
+ await this.db.execute(sql`
378
+ select ${MESSAGE_COLUMNS} from private_messages m where m.id = ${messageId}
379
+ `),
380
+ ) as RawMessage[]
381
+
382
+ return rows[0] === undefined ? null : toMessage(rows[0])
383
+ }
384
+ }
@@ -0,0 +1,18 @@
1
+ import { logger } from '@meith/core'
2
+ import { loadEnvFiles } from '@meith/core/env-files'
3
+
4
+ import { runMigrations } from './migrate'
5
+
6
+ const log = () => logger({ module: 'migrate' })
7
+
8
+ loadEnvFiles()
9
+
10
+ runMigrations()
11
+ .then((applied) => {
12
+ log().info({ applied }, applied === 0 ? 'already up to date' : 'migrations applied')
13
+ process.exit(0)
14
+ })
15
+ .catch((error: unknown) => {
16
+ log().error({ err: error }, 'migration failed')
17
+ process.exit(1)
18
+ })
package/src/migrate.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { existsSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import { drizzle } from 'drizzle-orm/postgres-js'
6
+ import { migrate } from 'drizzle-orm/postgres-js/migrator'
7
+ import postgres from 'postgres'
8
+
9
+ import { ConfigurationError, env, logger } from '@meith/core'
10
+
11
+ const JOURNAL = path.join('meta', '_journal.json')
12
+
13
+ const IMAGE_MIGRATIONS = '/app/migrations'
14
+
15
+ export function migrationFolderCandidates(input: {
16
+ readonly explicit?: string | undefined
17
+ readonly moduleDir?: string | undefined
18
+ readonly cwd: string
19
+ }): readonly string[] {
20
+ if (input.explicit !== undefined && input.explicit !== '') return [input.explicit]
21
+
22
+ const candidates: string[] = []
23
+
24
+ if (input.moduleDir !== undefined && input.moduleDir !== '') {
25
+ candidates.push(path.resolve(input.moduleDir, '..', 'migrations'))
26
+ }
27
+
28
+ candidates.push(IMAGE_MIGRATIONS)
29
+
30
+ let dir = path.resolve(input.cwd)
31
+ for (;;) {
32
+ candidates.push(path.join(dir, 'migrations'))
33
+ candidates.push(path.join(dir, 'packages', 'db', 'migrations'))
34
+ const parent = path.dirname(dir)
35
+ if (parent === dir) break
36
+ dir = parent
37
+ }
38
+
39
+ return candidates
40
+ }
41
+
42
+ function moduleDir(): string | undefined {
43
+ try {
44
+ return path.dirname(fileURLToPath(import.meta.url))
45
+ } catch {
46
+ return undefined
47
+ }
48
+ }
49
+
50
+ function migrationsFolder(): string {
51
+ const candidates = migrationFolderCandidates({
52
+ explicit: env.MIGRATIONS_DIR,
53
+ moduleDir: moduleDir(),
54
+ cwd: process.cwd(),
55
+ })
56
+
57
+ const found = candidates.find((folder) => existsSync(path.join(folder, JOURNAL)))
58
+ if (found !== undefined) return found
59
+
60
+ throw new ConfigurationError(
61
+ 'Cannot find the migrations. Set MIGRATIONS_DIR to the folder holding ' +
62
+ `meta/_journal.json — looked in ${candidates.slice(0, 4).join(', ')}.`,
63
+ )
64
+ }
65
+
66
+ export const MIGRATION_LOCK_KEY = '-2943916371013839929'
67
+
68
+ export function migrationUrl(source: {
69
+ readonly DIRECT_DATABASE_URL?: string | undefined
70
+ readonly DATABASE_URL?: string | undefined
71
+ }): string {
72
+ const url = source.DIRECT_DATABASE_URL ?? source.DATABASE_URL
73
+
74
+ if (!url) {
75
+ throw new ConfigurationError('Cannot migrate without DATABASE_URL (or DIRECT_DATABASE_URL).')
76
+ }
77
+
78
+ return url
79
+ }
80
+
81
+ export async function withMigrationLock<T>(
82
+ sql: ReturnType<typeof postgres>,
83
+ work: () => Promise<T>,
84
+ ): Promise<T> {
85
+ await sql`select pg_advisory_lock(${MIGRATION_LOCK_KEY}::bigint)`
86
+
87
+ try {
88
+ return await work()
89
+ } finally {
90
+ await sql`select pg_advisory_unlock(${MIGRATION_LOCK_KEY}::bigint)`
91
+ }
92
+ }
93
+
94
+ export async function runMigrations(
95
+ options: { readonly folder?: string; readonly url?: string } = {},
96
+ ): Promise<number> {
97
+ const url = options.url ?? migrationUrl(env)
98
+
99
+ const sql = postgres(url, { max: 1, prepare: false, onnotice: () => {} })
100
+
101
+ try {
102
+ return await withMigrationLock(sql, async () => {
103
+ const before = await appliedCount(sql)
104
+ await migrate(drizzle(sql), { migrationsFolder: options.folder ?? migrationsFolder() })
105
+ const after = await appliedCount(sql)
106
+
107
+ const applied = after - before
108
+ logger({ component: 'migrate' }).info({ applied }, 'migrations applied')
109
+ return applied
110
+ })
111
+ } finally {
112
+ await sql.end({ timeout: 5 })
113
+ }
114
+ }
115
+
116
+ async function appliedCount(sql: ReturnType<typeof postgres>): Promise<number> {
117
+ const rows = await sql<{ count: string }[]>`
118
+ SELECT COUNT(*)::text AS count
119
+ FROM information_schema.tables
120
+ WHERE table_schema = 'drizzle' AND table_name = '__drizzle_migrations'
121
+ `
122
+
123
+ if (rows[0]?.count === '0') return 0
124
+
125
+ const applied = await sql<{ count: string }[]>`
126
+ SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations
127
+ `
128
+ return Number(applied[0]?.count ?? '0')
129
+ }