@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.
- package/LICENSE.md +165 -0
- package/migrations/0000_initial_schema.sql +538 -0
- package/migrations/0001_seed_usergroups.sql +111 -0
- package/migrations/0002_group_promotions.sql +25 -0
- package/migrations/0003_counter_maintenance.sql +34 -0
- package/migrations/0004_post_render_cache.sql +7 -0
- package/migrations/0005_reports.sql +51 -0
- package/migrations/0006_warnings.sql +91 -0
- package/migrations/0007_notifications.sql +44 -0
- package/migrations/0008_subscriptions.sql +48 -0
- package/migrations/0009_member_preferences.sql +13 -0
- package/migrations/0010_profile_fields.sql +53 -0
- package/migrations/0011_private_messages.sql +63 -0
- package/migrations/0012_user_relations.sql +18 -0
- package/migrations/0013_reputation.sql +45 -0
- package/migrations/0014_signatures.sql +16 -0
- package/migrations/0015_admin_sessions.sql +24 -0
- package/migrations/0016_attachments.sql +51 -0
- package/migrations/0017_avatars.sql +33 -0
- package/migrations/0018_mass_mail.sql +15 -0
- package/migrations/0019_word_filters.sql +8 -0
- package/migrations/0020_searches.sql +15 -0
- package/migrations/0021_presence_stats.sql +18 -0
- package/migrations/0022_api_tokens_webhooks.sql +58 -0
- package/migrations/0023_install_state.sql +6 -0
- package/migrations/0024_component_versions.sql +13 -0
- package/migrations/0025_import.sql +25 -0
- package/migrations/0026_polls.sql +40 -0
- package/migrations/0027_drafts.sql +13 -0
- package/migrations/0028_board_vocabulary.sql +32 -0
- package/migrations/0029_announcements.sql +16 -0
- package/migrations/0030_antispam.sql +18 -0
- package/migrations/0031_markdown.sql +24 -0
- package/migrations/0032_theme_enablement.sql +1 -0
- package/migrations/0033_group_identity.sql +5 -0
- package/migrations/0034_search_document_version.sql +4 -0
- package/migrations/0035_categories_hold_no_threads.sql +1 -0
- package/migrations/0036_expiring_group_grants.sql +6 -0
- package/migrations/0037_automatic_timezone.sql +3 -0
- package/migrations/0038_primary_group_grants.sql +3 -0
- package/migrations/0039_moderator_log_forum_scope.sql +1 -0
- package/migrations/0040_moderator_restore_grants.sql +1 -0
- package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
- package/migrations/0042_drop_placebo_delete_others.sql +2 -0
- package/migrations/0043_federated_identity.sql +28 -0
- package/migrations/0044_second_factor.sql +34 -0
- package/migrations/0045_drop_dead_task_due_index.sql +1 -0
- package/migrations/0046_member_locale.sql +1 -0
- package/migrations/0047_navigation_items.sql +34 -0
- package/migrations/0048_web_push.sql +16 -0
- package/migrations/0049_plugin_health.sql +7 -0
- package/migrations/0050_drop_forum_password_grants.sql +1 -0
- package/migrations/0051_glossy_barracuda.sql +1 -0
- package/migrations/0052_many_professor_monster.sql +2 -0
- package/migrations/0053_strange_maximus.sql +14 -0
- package/migrations/0054_legacy_import_sources.sql +25 -0
- package/migrations/0055_poll_parity.sql +14 -0
- package/migrations/0056_marketplace_catalog.sql +11 -0
- package/migrations/meta/0000_snapshot.json +4412 -0
- package/migrations/meta/0006_snapshot.json +5411 -0
- package/migrations/meta/0007_snapshot.json +5634 -0
- package/migrations/meta/0008_snapshot.json +5779 -0
- package/migrations/meta/0009_snapshot.json +5816 -0
- package/migrations/meta/0010_snapshot.json +6117 -0
- package/migrations/meta/0011_snapshot.json +6406 -0
- package/migrations/meta/0012_snapshot.json +6517 -0
- package/migrations/meta/0013_snapshot.json +6722 -0
- package/migrations/meta/0014_snapshot.json +6777 -0
- package/migrations/meta/0015_snapshot.json +6914 -0
- package/migrations/meta/0016_snapshot.json +7192 -0
- package/migrations/meta/0017_snapshot.json +7248 -0
- package/migrations/meta/0018_snapshot.json +7355 -0
- package/migrations/meta/0019_snapshot.json +7419 -0
- package/migrations/meta/0020_snapshot.json +7562 -0
- package/migrations/meta/0021_snapshot.json +7653 -0
- package/migrations/meta/0031_snapshot.json +8162 -0
- package/migrations/meta/0032_snapshot.json +8169 -0
- package/migrations/meta/0033_snapshot.json +8193 -0
- package/migrations/meta/0034_snapshot.json +8221 -0
- package/migrations/meta/0036_snapshot.json +8262 -0
- package/migrations/meta/0037_snapshot.json +8262 -0
- package/migrations/meta/0038_snapshot.json +8293 -0
- package/migrations/meta/0039_snapshot.json +8308 -0
- package/migrations/meta/0041_snapshot.json +8287 -0
- package/migrations/meta/0042_snapshot.json +8274 -0
- package/migrations/meta/0043_snapshot.json +8521 -0
- package/migrations/meta/0044_snapshot.json +8814 -0
- package/migrations/meta/0045_snapshot.json +8797 -0
- package/migrations/meta/0046_snapshot.json +8804 -0
- package/migrations/meta/0047_snapshot.json +9018 -0
- package/migrations/meta/0048_snapshot.json +9145 -0
- package/migrations/meta/0049_snapshot.json +9190 -0
- package/migrations/meta/0050_snapshot.json +9126 -0
- package/migrations/meta/0051_snapshot.json +9132 -0
- package/migrations/meta/0052_snapshot.json +9148 -0
- package/migrations/meta/0053_snapshot.json +9268 -0
- package/migrations/meta/0054_snapshot.json +9378 -0
- package/migrations/meta/0056_snapshot.json +9447 -0
- package/migrations/meta/_journal.json +405 -0
- package/package.json +64 -0
- package/src/account-repos.ts +580 -0
- package/src/actor-builder.ts +130 -0
- package/src/admin-repo.ts +85 -0
- package/src/admin-session-repo.ts +205 -0
- package/src/admin-undo-repo.ts +128 -0
- package/src/announcement-repo.ts +176 -0
- package/src/antispam-repo.ts +145 -0
- package/src/api-repo.ts +276 -0
- package/src/attachment-admin-repo.ts +161 -0
- package/src/attachment-repo.ts +240 -0
- package/src/authorization-source.ts +138 -0
- package/src/avatar-repo.ts +185 -0
- package/src/ban-repos.ts +146 -0
- package/src/client.ts +85 -0
- package/src/content-admin-repo.ts +298 -0
- package/src/content-counters.ts +152 -0
- package/src/counter-recount.ts +263 -0
- package/src/cursor.ts +16 -0
- package/src/denormalised-username.ts +34 -0
- package/src/discovery-repo.ts +140 -0
- package/src/draft-repo.ts +59 -0
- package/src/feed-repo.ts +219 -0
- package/src/forum-admin-repo.ts +310 -0
- package/src/forum-repo.ts +206 -0
- package/src/group-admin-repo.ts +256 -0
- package/src/group-identity-repo.ts +84 -0
- package/src/identity-link-repo.ts +145 -0
- package/src/import-repo.ts +123 -0
- package/src/import-sink.ts +1377 -0
- package/src/index.ts +316 -0
- package/src/inline-moderation.ts +344 -0
- package/src/install-repo.ts +67 -0
- package/src/latest-repo.ts +115 -0
- package/src/maintenance-repo.ts +54 -0
- package/src/marketplace-repo.ts +111 -0
- package/src/member-profile-repo.ts +82 -0
- package/src/member-settings-repo.ts +160 -0
- package/src/message-repo.ts +384 -0
- package/src/migrate-run.ts +18 -0
- package/src/migrate.ts +129 -0
- package/src/modcp-repo.ts +253 -0
- package/src/moderation-queue.ts +279 -0
- package/src/navigation-repo.ts +288 -0
- package/src/notification-repo.ts +402 -0
- package/src/orphan-keys.ts +19 -0
- package/src/outbox-repo.ts +78 -0
- package/src/permission-version.ts +24 -0
- package/src/permissions-map.ts +52 -0
- package/src/pglite.fixture.ts +87 -0
- package/src/plugin-data.ts +88 -0
- package/src/plugin-grants.ts +369 -0
- package/src/plugin-health-repo.ts +80 -0
- package/src/plugin-purge-repo.ts +91 -0
- package/src/plugin-users.ts +39 -0
- package/src/poll-repo.ts +334 -0
- package/src/post-repo.ts +261 -0
- package/src/post-writes.ts +227 -0
- package/src/presence-repo.ts +220 -0
- package/src/profile-field-repo.ts +182 -0
- package/src/promotion-repo.ts +152 -0
- package/src/read-state-repo.ts +81 -0
- package/src/relation-repo.ts +113 -0
- package/src/render-backfill.ts +110 -0
- package/src/render-signature.ts +33 -0
- package/src/report-repo.ts +349 -0
- package/src/reputation-repo.ts +295 -0
- package/src/result-rows.ts +13 -0
- package/src/row-values.ts +7 -0
- package/src/schema/content.ts +451 -0
- package/src/schema/identity.ts +699 -0
- package/src/schema/index.ts +10 -0
- package/src/schema/messages.ts +76 -0
- package/src/schema/permission-columns.ts +34 -0
- package/src/schema/platform.ts +376 -0
- package/src/schema/structure.ts +164 -0
- package/src/search-repo.ts +359 -0
- package/src/search-store.ts +113 -0
- package/src/seed-groups.ts +11 -0
- package/src/settings-repo.ts +32 -0
- package/src/signature-repo.ts +102 -0
- package/src/sql-lists.ts +17 -0
- package/src/staff-groups.ts +50 -0
- package/src/stats-repo.ts +151 -0
- package/src/subscription-repo.ts +350 -0
- package/src/system-health-repo.ts +130 -0
- package/src/task-repo.ts +104 -0
- package/src/theme-admin-repo.ts +172 -0
- package/src/theme-repo.ts +43 -0
- package/src/thread-audience.ts +45 -0
- package/src/thread-counters.ts +107 -0
- package/src/thread-repo.ts +255 -0
- package/src/thread-surgery.ts +282 -0
- package/src/thread-tools.ts +311 -0
- package/src/thread-views.ts +41 -0
- package/src/thread-writes.ts +356 -0
- package/src/two-factor-repo.ts +235 -0
- package/src/upgrade-repo.ts +66 -0
- package/src/user-admin-repo.ts +367 -0
- package/src/user-bulk-repo.ts +397 -0
- package/src/user-merge-map.ts +111 -0
- package/src/user-merge-repo.ts +304 -0
- package/src/visibility-counters.ts +190 -0
- package/src/visibility.ts +17 -0
- package/src/vocabulary-repo.ts +54 -0
- 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
|
+
}
|