@meith/db 0.33.4 → 0.35.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/migrations/0068_backup_runs.sql +22 -0
- package/migrations/0069_backup_runs_active.sql +1 -0
- package/migrations/meta/0068_snapshot.json +9864 -0
- package/migrations/meta/0069_snapshot.json +9880 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +32 -31
- package/src/backup-run-repo.ts +212 -0
- package/src/identity-link-repo.ts +11 -4
- package/src/index.ts +7 -1
- package/src/message-repo.ts +23 -0
- package/src/notification-repo.ts +9 -0
- package/src/post-repo.ts +1 -1
- package/src/post-writes.ts +8 -3
- package/src/schema/platform.ts +39 -0
- package/src/settings-repo.ts +64 -4
- package/src/subscription-repo.ts +11 -11
- package/src/system-health-repo.ts +2 -1
- package/src/task-repo.ts +14 -0
- package/src/thread-repo.ts +8 -1
- package/src/user-bulk-repo.ts +7 -1
- package/src/user-merge-map.ts +1 -0
|
@@ -477,6 +477,20 @@
|
|
|
477
477
|
"when": 1788464192391,
|
|
478
478
|
"tag": "0067_minimal_navigation",
|
|
479
479
|
"breakpoints": true
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
"idx": 68,
|
|
483
|
+
"version": "7",
|
|
484
|
+
"when": 1788501076230,
|
|
485
|
+
"tag": "0068_backup_runs",
|
|
486
|
+
"breakpoints": true
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
"idx": 69,
|
|
490
|
+
"version": "7",
|
|
491
|
+
"when": 1788517301313,
|
|
492
|
+
"tag": "0069_backup_runs_active",
|
|
493
|
+
"breakpoints": true
|
|
480
494
|
}
|
|
481
495
|
]
|
|
482
496
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -22,36 +22,37 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"drizzle-orm": "^0.45.2",
|
|
24
24
|
"postgres": "^3.4.7",
|
|
25
|
-
"@meith/accounts": "0.
|
|
26
|
-
"@meith/admin": "0.
|
|
27
|
-
"@meith/antispam": "0.
|
|
28
|
-
"@meith/api": "0.
|
|
29
|
-
"@meith/attachments": "0.
|
|
30
|
-
"@meith/authorization": "0.
|
|
31
|
-
"@meith/avatars": "0.
|
|
32
|
-
"@meith/
|
|
33
|
-
"@meith/
|
|
34
|
-
"@meith/
|
|
35
|
-
"@meith/
|
|
36
|
-
"@meith/
|
|
37
|
-
"@meith/
|
|
38
|
-
"@meith/
|
|
39
|
-
"@meith/
|
|
40
|
-
"@meith/
|
|
41
|
-
"@meith/
|
|
42
|
-
"@meith/
|
|
43
|
-
"@meith/
|
|
44
|
-
"@meith/
|
|
45
|
-
"@meith/
|
|
46
|
-
"@meith/
|
|
47
|
-
"@meith/
|
|
48
|
-
"@meith/
|
|
49
|
-
"@meith/
|
|
50
|
-
"@meith/
|
|
51
|
-
"@meith/
|
|
52
|
-
"@meith/
|
|
53
|
-
"@meith/
|
|
54
|
-
"@meith/
|
|
25
|
+
"@meith/accounts": "0.35.0",
|
|
26
|
+
"@meith/admin": "0.35.0",
|
|
27
|
+
"@meith/antispam": "0.35.0",
|
|
28
|
+
"@meith/api": "0.35.0",
|
|
29
|
+
"@meith/attachments": "0.35.0",
|
|
30
|
+
"@meith/authorization": "0.35.0",
|
|
31
|
+
"@meith/avatars": "0.35.0",
|
|
32
|
+
"@meith/backup": "0.35.0",
|
|
33
|
+
"@meith/board-digest": "0.35.0",
|
|
34
|
+
"@meith/core": "0.35.0",
|
|
35
|
+
"@meith/drafts": "0.35.0",
|
|
36
|
+
"@meith/events": "0.35.0",
|
|
37
|
+
"@meith/forums": "0.35.0",
|
|
38
|
+
"@meith/groups": "0.35.0",
|
|
39
|
+
"@meith/i18n": "0.35.0",
|
|
40
|
+
"@meith/markdown": "0.35.0",
|
|
41
|
+
"@meith/marketplace": "0.35.0",
|
|
42
|
+
"@meith/messages": "0.35.0",
|
|
43
|
+
"@meith/moderation": "0.35.0",
|
|
44
|
+
"@meith/notifications": "0.35.0",
|
|
45
|
+
"@meith/plugin-kit": "0.35.0",
|
|
46
|
+
"@meith/polls": "0.35.0",
|
|
47
|
+
"@meith/profile-fields": "0.35.0",
|
|
48
|
+
"@meith/relations": "0.35.0",
|
|
49
|
+
"@meith/reputation": "0.35.0",
|
|
50
|
+
"@meith/search": "0.35.0",
|
|
51
|
+
"@meith/settings": "0.35.0",
|
|
52
|
+
"@meith/signatures": "0.35.0",
|
|
53
|
+
"@meith/subscriptions": "0.35.0",
|
|
54
|
+
"@meith/tasks": "0.35.0",
|
|
55
|
+
"@meith/threads": "0.35.0"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
57
58
|
"drizzle-kit": "^0.31.6",
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
BackupRunFinish,
|
|
5
|
+
BackupRunRecord,
|
|
6
|
+
BackupRunRepository,
|
|
7
|
+
BackupRunStatus,
|
|
8
|
+
BackupTrigger,
|
|
9
|
+
} from '@meith/backup'
|
|
10
|
+
|
|
11
|
+
import type { Database } from './client'
|
|
12
|
+
import { resultRows } from './result-rows'
|
|
13
|
+
import { toDate } from './row-values'
|
|
14
|
+
|
|
15
|
+
const COLUMNS = sql`
|
|
16
|
+
id, trigger, status, requested_by_user_id, requested_at, started_at, finished_at,
|
|
17
|
+
heartbeat_at, bundle_name, size_bytes, uploads, shipped, skipped_keys, error
|
|
18
|
+
`
|
|
19
|
+
|
|
20
|
+
const UNIQUE_VIOLATION = '23505'
|
|
21
|
+
|
|
22
|
+
function isUniqueViolation(error: unknown): boolean {
|
|
23
|
+
return (
|
|
24
|
+
typeof error === 'object' &&
|
|
25
|
+
error !== null &&
|
|
26
|
+
((error as { code?: unknown }).code === UNIQUE_VIOLATION ||
|
|
27
|
+
(error as { cause?: { code?: unknown } }).cause?.code === UNIQUE_VIOLATION)
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toRecord(row: Record<string, unknown>): BackupRunRecord {
|
|
32
|
+
const optionalDate = (value: unknown): Date | null => (value === null ? null : toDate(value))
|
|
33
|
+
return {
|
|
34
|
+
id: Number(row.id),
|
|
35
|
+
trigger: String(row.trigger) as BackupTrigger,
|
|
36
|
+
status: String(row.status) as BackupRunStatus,
|
|
37
|
+
requestedByUserId: row.requested_by_user_id === null ? null : Number(row.requested_by_user_id),
|
|
38
|
+
requestedAt: toDate(row.requested_at),
|
|
39
|
+
startedAt: optionalDate(row.started_at),
|
|
40
|
+
finishedAt: optionalDate(row.finished_at),
|
|
41
|
+
heartbeatAt: optionalDate(row.heartbeat_at),
|
|
42
|
+
bundleName: row.bundle_name === null ? null : String(row.bundle_name),
|
|
43
|
+
sizeBytes: row.size_bytes === null ? null : Number(row.size_bytes),
|
|
44
|
+
uploads:
|
|
45
|
+
row.uploads === 'included' || row.uploads === 'skipped'
|
|
46
|
+
? (row.uploads as 'included' | 'skipped')
|
|
47
|
+
: null,
|
|
48
|
+
shipped: row.shipped === true,
|
|
49
|
+
skippedKeys: Number(row.skipped_keys),
|
|
50
|
+
error: row.error === null ? null : String(row.error),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class PostgresBackupRunRepository implements BackupRunRepository {
|
|
55
|
+
constructor(private readonly db: Database) {}
|
|
56
|
+
|
|
57
|
+
async enqueue(input: {
|
|
58
|
+
readonly trigger: BackupTrigger
|
|
59
|
+
readonly requestedByUserId?: number | null | undefined
|
|
60
|
+
readonly now: Date
|
|
61
|
+
}): Promise<{ readonly id: number; readonly queued: boolean }> {
|
|
62
|
+
const pending = async (): Promise<number | undefined> => {
|
|
63
|
+
const row = resultRows<{ id: number }>(
|
|
64
|
+
await this.db.execute(sql`
|
|
65
|
+
select id from backup_runs
|
|
66
|
+
where status in ('queued', 'running')
|
|
67
|
+
order by id
|
|
68
|
+
limit 1
|
|
69
|
+
`),
|
|
70
|
+
)[0]
|
|
71
|
+
return row === undefined ? undefined : Number(row.id)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const existing = await pending()
|
|
75
|
+
if (existing !== undefined) return { id: existing, queued: false }
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const inserted = resultRows<{ id: number }>(
|
|
79
|
+
await this.db.execute(sql`
|
|
80
|
+
insert into backup_runs (trigger, status, requested_by_user_id, requested_at)
|
|
81
|
+
values (${input.trigger}, 'queued', ${input.requestedByUserId ?? null}, ${input.now})
|
|
82
|
+
returning id
|
|
83
|
+
`),
|
|
84
|
+
)[0]
|
|
85
|
+
return { id: Number(inserted?.id), queued: true }
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (!isUniqueViolation(error)) throw error
|
|
88
|
+
const raced = await pending()
|
|
89
|
+
if (raced === undefined) throw error
|
|
90
|
+
return { id: raced, queued: false }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async claimNext(now: Date): Promise<BackupRunRecord | null> {
|
|
95
|
+
let rows: Array<Record<string, unknown>>
|
|
96
|
+
try {
|
|
97
|
+
rows = resultRows(
|
|
98
|
+
await this.db.execute(sql`
|
|
99
|
+
update backup_runs
|
|
100
|
+
set status = 'running', started_at = ${now}, heartbeat_at = ${now}
|
|
101
|
+
where id = (
|
|
102
|
+
select id from backup_runs
|
|
103
|
+
where status = 'queued'
|
|
104
|
+
and not exists (select 1 from backup_runs where status = 'running')
|
|
105
|
+
order by id
|
|
106
|
+
limit 1
|
|
107
|
+
for update skip locked
|
|
108
|
+
)
|
|
109
|
+
returning ${COLUMNS}
|
|
110
|
+
`),
|
|
111
|
+
) as Array<Record<string, unknown>>
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (isUniqueViolation(error)) return null
|
|
114
|
+
throw error
|
|
115
|
+
}
|
|
116
|
+
const row = rows[0]
|
|
117
|
+
return row === undefined ? null : toRecord(row)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async heartbeat(id: number, now: Date): Promise<void> {
|
|
121
|
+
await this.db.execute(sql`
|
|
122
|
+
update backup_runs set heartbeat_at = ${now} where id = ${id} and status = 'running'
|
|
123
|
+
`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async finish(id: number, outcome: BackupRunFinish): Promise<void> {
|
|
127
|
+
await this.db.execute(sql`
|
|
128
|
+
update backup_runs
|
|
129
|
+
set status = ${outcome.status},
|
|
130
|
+
finished_at = ${outcome.finishedAt},
|
|
131
|
+
heartbeat_at = ${outcome.finishedAt},
|
|
132
|
+
bundle_name = ${outcome.bundleName ?? null},
|
|
133
|
+
size_bytes = ${outcome.sizeBytes ?? null},
|
|
134
|
+
uploads = ${outcome.uploads ?? null},
|
|
135
|
+
shipped = ${outcome.shipped ?? false},
|
|
136
|
+
skipped_keys = ${outcome.skippedKeys ?? 0},
|
|
137
|
+
error = ${outcome.error ?? null}
|
|
138
|
+
where id = ${id}
|
|
139
|
+
`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async active(now: Date, staleBefore: Date): Promise<BackupRunRecord | null> {
|
|
143
|
+
const rows = resultRows(
|
|
144
|
+
await this.db.execute(sql`
|
|
145
|
+
select ${COLUMNS} from backup_runs
|
|
146
|
+
where status = 'queued'
|
|
147
|
+
or (status = 'running' and coalesce(heartbeat_at, started_at, ${now}) > ${staleBefore})
|
|
148
|
+
order by id
|
|
149
|
+
limit 1
|
|
150
|
+
`),
|
|
151
|
+
) as Array<Record<string, unknown>>
|
|
152
|
+
const row = rows[0]
|
|
153
|
+
return row === undefined ? null : toRecord(row)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async recent(limit: number): Promise<readonly BackupRunRecord[]> {
|
|
157
|
+
const rows = resultRows(
|
|
158
|
+
await this.db.execute(sql`
|
|
159
|
+
select ${COLUMNS} from backup_runs order by id desc limit ${limit}
|
|
160
|
+
`),
|
|
161
|
+
) as Array<Record<string, unknown>>
|
|
162
|
+
return rows.map(toRecord)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async lastScheduledAt(): Promise<Date | null> {
|
|
166
|
+
const rows = resultRows<{ requested_at: unknown }>(
|
|
167
|
+
await this.db.execute(sql`
|
|
168
|
+
select requested_at from backup_runs
|
|
169
|
+
where trigger = 'schedule'
|
|
170
|
+
order by id desc
|
|
171
|
+
limit 1
|
|
172
|
+
`),
|
|
173
|
+
)
|
|
174
|
+
const row = rows[0]
|
|
175
|
+
return row === undefined ? null : toDate(row.requested_at)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async failInterrupted(now: Date, staleBefore: Date): Promise<number> {
|
|
179
|
+
const rows = resultRows(
|
|
180
|
+
await this.db.execute(sql`
|
|
181
|
+
update backup_runs
|
|
182
|
+
set status = 'failed',
|
|
183
|
+
finished_at = ${now},
|
|
184
|
+
error = 'The backup was interrupted before it finished: the process running it stopped.'
|
|
185
|
+
where status = 'running'
|
|
186
|
+
and coalesce(heartbeat_at, started_at, requested_at) <= ${staleBefore}
|
|
187
|
+
returning id
|
|
188
|
+
`),
|
|
189
|
+
)
|
|
190
|
+
return rows.length
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async record(input: {
|
|
194
|
+
readonly trigger: BackupTrigger
|
|
195
|
+
readonly requestedByUserId?: number | null | undefined
|
|
196
|
+
readonly startedAt: Date
|
|
197
|
+
readonly outcome: BackupRunFinish
|
|
198
|
+
}): Promise<void> {
|
|
199
|
+
const { outcome } = input
|
|
200
|
+
await this.db.execute(sql`
|
|
201
|
+
insert into backup_runs (
|
|
202
|
+
trigger, status, requested_by_user_id, requested_at, started_at, finished_at,
|
|
203
|
+
heartbeat_at, bundle_name, size_bytes, uploads, shipped, skipped_keys, error
|
|
204
|
+
) values (
|
|
205
|
+
${input.trigger}, ${outcome.status}, ${input.requestedByUserId ?? null},
|
|
206
|
+
${input.startedAt}, ${input.startedAt}, ${outcome.finishedAt}, ${outcome.finishedAt},
|
|
207
|
+
${outcome.bundleName ?? null}, ${outcome.sizeBytes ?? null}, ${outcome.uploads ?? null},
|
|
208
|
+
${outcome.shipped ?? false}, ${outcome.skippedKeys ?? 0}, ${outcome.error ?? null}
|
|
209
|
+
)
|
|
210
|
+
`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, asc, eq } from 'drizzle-orm'
|
|
1
|
+
import { and, asc, eq, gt, or, sql } from 'drizzle-orm'
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
4
|
LinkIdentityInput,
|
|
@@ -136,10 +136,17 @@ export class PostgresPasskeyRepository implements PasskeyRepository {
|
|
|
136
136
|
return removed.length > 0
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
async markUsed(passkeyId: number, signCount: number, now: Date): Promise<
|
|
140
|
-
await this.db
|
|
139
|
+
async markUsed(passkeyId: number, signCount: number, now: Date): Promise<boolean> {
|
|
140
|
+
const rows = await this.db
|
|
141
141
|
.update(passkeys)
|
|
142
142
|
.set({ signCount, lastUsedAt: now })
|
|
143
|
-
.where(
|
|
143
|
+
.where(
|
|
144
|
+
and(
|
|
145
|
+
eq(passkeys.id, passkeyId),
|
|
146
|
+
or(eq(sql`${signCount}`, 0), gt(sql`${signCount}`, passkeys.signCount)),
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
.returning({ id: passkeys.id })
|
|
150
|
+
return rows.length > 0
|
|
144
151
|
}
|
|
145
152
|
}
|
package/src/index.ts
CHANGED
|
@@ -54,6 +54,7 @@ export {
|
|
|
54
54
|
parseAncestorPath,
|
|
55
55
|
} from './authorization-source'
|
|
56
56
|
export { PostgresAvatarRepository } from './avatar-repo'
|
|
57
|
+
export { PostgresBackupRunRepository } from './backup-run-repo'
|
|
57
58
|
export {
|
|
58
59
|
PostgresBanFilterRepository,
|
|
59
60
|
PostgresBanRepository,
|
|
@@ -254,7 +255,12 @@ export {
|
|
|
254
255
|
type StoredSearch,
|
|
255
256
|
} from './search-store'
|
|
256
257
|
export { SEED_GROUP_KEY, type SeedGroupKey } from './seed-groups'
|
|
257
|
-
export {
|
|
258
|
+
export {
|
|
259
|
+
PostgresSettingsRepository,
|
|
260
|
+
SETTING_SEAL_PURPOSE,
|
|
261
|
+
type SettingSealer,
|
|
262
|
+
settingSealer,
|
|
263
|
+
} from './settings-repo'
|
|
258
264
|
export { PostgresSignatureRepository } from './signature-repo'
|
|
259
265
|
export {
|
|
260
266
|
type BoardTotals,
|
package/src/message-repo.ts
CHANGED
|
@@ -336,6 +336,29 @@ export class PostgresMessageRepository implements MessageRepository {
|
|
|
336
336
|
return rows.length
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
async restore(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
|
+
update private_message_copies
|
|
348
|
+
set folder = case when role = 'author' then 'sent' else 'inbox' end
|
|
349
|
+
where owner_user_id = ${input.userId}
|
|
350
|
+
and folder = 'trash'
|
|
351
|
+
and id in (${sql.join(
|
|
352
|
+
input.copyIds.map((id) => sql`${id}`),
|
|
353
|
+
sql`, `,
|
|
354
|
+
)})
|
|
355
|
+
returning id
|
|
356
|
+
`),
|
|
357
|
+
) as Array<{ id: number }>
|
|
358
|
+
|
|
359
|
+
return rows.length
|
|
360
|
+
}
|
|
361
|
+
|
|
339
362
|
async remove(input: {
|
|
340
363
|
readonly userId: number
|
|
341
364
|
readonly copyIds: readonly number[]
|
package/src/notification-repo.ts
CHANGED
|
@@ -266,6 +266,15 @@ export class PostgresNotificationRepository implements NotificationRepository {
|
|
|
266
266
|
return rows.length > 0
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
async removeAllPushSubscriptions(userId: number): Promise<number> {
|
|
270
|
+
const rows = resultRows(
|
|
271
|
+
await this.db.execute(sql`
|
|
272
|
+
delete from push_subscriptions where user_id = ${userId} returning id
|
|
273
|
+
`),
|
|
274
|
+
) as Array<{ id: number }>
|
|
275
|
+
return rows.length
|
|
276
|
+
}
|
|
277
|
+
|
|
269
278
|
async pushSubscriptionsFor(userId: number): Promise<readonly PushSubscriptionRecord[]> {
|
|
270
279
|
const rows = resultRows(
|
|
271
280
|
await this.db.execute(sql`
|
package/src/post-repo.ts
CHANGED
|
@@ -111,7 +111,7 @@ export class PostgresPostRepository implements PostRepository {
|
|
|
111
111
|
where r.post_id = ${postId}
|
|
112
112
|
and exists (select 1 from posts p where p.id = r.post_id and p.thread_id = ${threadId})
|
|
113
113
|
union all
|
|
114
|
-
select p.revision_count, p.message, p.subject, p.edited_by_user_id,
|
|
114
|
+
select p.revision_count + 1, p.message, p.subject, p.edited_by_user_id,
|
|
115
115
|
coalesce(u.username, p.author_username, 'Deleted member'),
|
|
116
116
|
p.edit_reason, coalesce(p.edited_at, p.created_at), true
|
|
117
117
|
from posts p
|
package/src/post-writes.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
CORE_RENDERING,
|
|
7
7
|
type MarkdownPipeline,
|
|
8
8
|
renderThrough,
|
|
9
|
+
sourceAsMarkdown,
|
|
9
10
|
vocabularyOptions,
|
|
10
11
|
} from '@meith/markdown'
|
|
11
12
|
import type {
|
|
@@ -58,8 +59,9 @@ export class PostgresPostWriteRepository implements PostWriteRepository {
|
|
|
58
59
|
const rows = resultRows(
|
|
59
60
|
await this.db.execute(sql`
|
|
60
61
|
select p.id, p.thread_id, p.forum_id, p.author_user_id, p.subject, p.message,
|
|
61
|
-
p.visibility, p.is_first_post, p.revision_count, p.created_at,
|
|
62
|
-
t.slug as thread_slug, t.title as thread_title,
|
|
62
|
+
p.body_format, p.visibility, p.is_first_post, p.revision_count, p.created_at,
|
|
63
|
+
t.slug as thread_slug, t.title as thread_title,
|
|
64
|
+
t.author_user_id as thread_author_user_id, t.is_locked,
|
|
63
65
|
t.visibility as thread_visibility,
|
|
64
66
|
f.slug as forum_slug, f.is_open
|
|
65
67
|
from posts p
|
|
@@ -74,12 +76,14 @@ export class PostgresPostWriteRepository implements PostWriteRepository {
|
|
|
74
76
|
author_user_id: number | null
|
|
75
77
|
subject: string | null
|
|
76
78
|
message: string
|
|
79
|
+
body_format: number
|
|
77
80
|
visibility: 'visible' | 'unapproved' | 'deleted'
|
|
78
81
|
is_first_post: boolean
|
|
79
82
|
revision_count: number
|
|
80
83
|
created_at: Date
|
|
81
84
|
thread_slug: string
|
|
82
85
|
thread_title: string
|
|
86
|
+
thread_author_user_id: number | null
|
|
83
87
|
is_locked: boolean
|
|
84
88
|
thread_visibility: 'visible' | 'unapproved' | 'deleted'
|
|
85
89
|
forum_slug: string
|
|
@@ -96,7 +100,7 @@ export class PostgresPostWriteRepository implements PostWriteRepository {
|
|
|
96
100
|
forumId: Number(row.forum_id),
|
|
97
101
|
authorUserId: row.author_user_id === null ? null : Number(row.author_user_id),
|
|
98
102
|
subject: row.subject,
|
|
99
|
-
message: row.message,
|
|
103
|
+
message: sourceAsMarkdown(row.message, Number(row.body_format)),
|
|
100
104
|
visibility: row.visibility,
|
|
101
105
|
isFirstPost: row.is_first_post,
|
|
102
106
|
revisionCount: Number(row.revision_count),
|
|
@@ -106,6 +110,7 @@ export class PostgresPostWriteRepository implements PostWriteRepository {
|
|
|
106
110
|
id: Number(row.thread_id),
|
|
107
111
|
slug: row.thread_slug,
|
|
108
112
|
title: row.thread_title,
|
|
113
|
+
authorUserId: row.thread_author_user_id === null ? null : Number(row.thread_author_user_id),
|
|
109
114
|
isLocked: row.is_locked,
|
|
110
115
|
visibility: row.thread_visibility,
|
|
111
116
|
},
|
package/src/schema/platform.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm'
|
|
2
2
|
import {
|
|
3
|
+
bigint,
|
|
3
4
|
boolean,
|
|
4
5
|
check,
|
|
5
6
|
foreignKey,
|
|
@@ -148,6 +149,44 @@ export const taskLog = pgTable(
|
|
|
148
149
|
(t) => [index('task_log_key_ran_idx').on(t.taskKey, t.ranAt.desc())],
|
|
149
150
|
)
|
|
150
151
|
|
|
152
|
+
export const backupRuns = pgTable(
|
|
153
|
+
'backup_runs',
|
|
154
|
+
{
|
|
155
|
+
id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
|
|
156
|
+
trigger: text('trigger').notNull(),
|
|
157
|
+
status: text('status').notNull().default('queued'),
|
|
158
|
+
requestedByUserId: integer('requested_by_user_id').references(() => users.id, {
|
|
159
|
+
onDelete: 'set null',
|
|
160
|
+
}),
|
|
161
|
+
requestedAt: timestamp('requested_at', { withTimezone: true }).notNull().defaultNow(),
|
|
162
|
+
startedAt: timestamp('started_at', { withTimezone: true }),
|
|
163
|
+
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
|
164
|
+
heartbeatAt: timestamp('heartbeat_at', { withTimezone: true }),
|
|
165
|
+
bundleName: text('bundle_name'),
|
|
166
|
+
sizeBytes: bigint('size_bytes', { mode: 'number' }),
|
|
167
|
+
uploads: text('uploads'),
|
|
168
|
+
shipped: boolean('shipped').notNull().default(false),
|
|
169
|
+
skippedKeys: integer('skipped_keys').notNull().default(0),
|
|
170
|
+
error: text('error'),
|
|
171
|
+
},
|
|
172
|
+
(t) => [
|
|
173
|
+
index('backup_runs_status_idx').on(t.status, t.id),
|
|
174
|
+
index('backup_runs_recent_idx').on(t.requestedAt.desc()),
|
|
175
|
+
uniqueIndex('backup_runs_active_idx')
|
|
176
|
+
.on(t.status)
|
|
177
|
+
.where(sql`${t.status} in ('queued', 'running')`),
|
|
178
|
+
|
|
179
|
+
check(
|
|
180
|
+
'backup_runs_trigger_check',
|
|
181
|
+
sql`${t.trigger} in ('manual', 'schedule', 'upgrade', 'cli')`,
|
|
182
|
+
),
|
|
183
|
+
check(
|
|
184
|
+
'backup_runs_status_check',
|
|
185
|
+
sql`${t.status} in ('queued', 'running', 'done', 'incomplete', 'failed')`,
|
|
186
|
+
),
|
|
187
|
+
],
|
|
188
|
+
)
|
|
189
|
+
|
|
151
190
|
export const cacheVersions = pgTable('cache_versions', {
|
|
152
191
|
key: text('key').primaryKey(),
|
|
153
192
|
version: integer('version').notNull().default(1),
|
package/src/settings-repo.ts
CHANGED
|
@@ -1,24 +1,84 @@
|
|
|
1
1
|
import { inArray, sql } from 'drizzle-orm'
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { ConfigurationError, env, logger, openValue, sealValue } from '@meith/core'
|
|
4
|
+
import { SETTING_DEFINITIONS, type SettingsRepository } from '@meith/settings'
|
|
4
5
|
|
|
5
6
|
import type { Database } from './client'
|
|
6
7
|
import { settings } from './schema'
|
|
7
8
|
|
|
9
|
+
export const SETTING_SEAL_PURPOSE = 'meith/sealed-setting'
|
|
10
|
+
|
|
11
|
+
const SEALED_KEYS: ReadonlySet<string> = new Set(
|
|
12
|
+
SETTING_DEFINITIONS.filter((definition) => definition.sealed === true).map(
|
|
13
|
+
(definition) => definition.key,
|
|
14
|
+
),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
export interface SettingSealer {
|
|
18
|
+
seal(key: string, value: string): Promise<string>
|
|
19
|
+
open(key: string, stored: string): Promise<string | null>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function settingSealer(passphrase: string | undefined): SettingSealer {
|
|
23
|
+
return {
|
|
24
|
+
async seal(key, value) {
|
|
25
|
+
if (passphrase === undefined || passphrase.trim() === '') {
|
|
26
|
+
throw new ConfigurationError(
|
|
27
|
+
`The ${key} setting is stored sealed, which needs AUTH_SECRET set: without a key ` +
|
|
28
|
+
'to seal it with, the credential would sit in the database in the clear.',
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
return sealValue(value, passphrase, SETTING_SEAL_PURPOSE)
|
|
32
|
+
},
|
|
33
|
+
async open(_key, stored) {
|
|
34
|
+
if (passphrase === undefined || passphrase.trim() === '') return null
|
|
35
|
+
return openValue(stored, passphrase, SETTING_SEAL_PURPOSE)
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
8
40
|
export class PostgresSettingsRepository implements SettingsRepository {
|
|
9
|
-
|
|
41
|
+
private readonly sealer: SettingSealer
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
private readonly db: Database,
|
|
45
|
+
sealer?: SettingSealer,
|
|
46
|
+
) {
|
|
47
|
+
this.sealer = sealer ?? settingSealer(env.AUTH_SECRET)
|
|
48
|
+
}
|
|
10
49
|
|
|
11
50
|
async loadAll(): Promise<ReadonlyMap<string, string>> {
|
|
12
51
|
const rows = await this.db.select({ key: settings.key, value: settings.value }).from(settings)
|
|
13
|
-
|
|
52
|
+
const loaded = new Map<string, string>()
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
if (!SEALED_KEYS.has(row.key)) {
|
|
55
|
+
loaded.set(row.key, row.value)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
const opened = await this.sealer.open(row.key, row.value)
|
|
59
|
+
if (opened === null) {
|
|
60
|
+
logger({ module: 'settings' }).warn(
|
|
61
|
+
{ key: row.key },
|
|
62
|
+
'a sealed setting could not be opened under AUTH_SECRET and is treated as unset',
|
|
63
|
+
)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
loaded.set(row.key, opened)
|
|
67
|
+
}
|
|
68
|
+
return loaded
|
|
14
69
|
}
|
|
15
70
|
|
|
16
71
|
async save(entries: ReadonlyMap<string, string>): Promise<void> {
|
|
17
72
|
if (entries.size === 0) return
|
|
18
73
|
|
|
74
|
+
const values: { key: string; value: string }[] = []
|
|
75
|
+
for (const [key, value] of entries) {
|
|
76
|
+
values.push({ key, value: SEALED_KEYS.has(key) ? await this.sealer.seal(key, value) : value })
|
|
77
|
+
}
|
|
78
|
+
|
|
19
79
|
await this.db
|
|
20
80
|
.insert(settings)
|
|
21
|
-
.values(
|
|
81
|
+
.values(values)
|
|
22
82
|
.onConflictDoUpdate({
|
|
23
83
|
target: settings.key,
|
|
24
84
|
set: { value: sql`excluded.value`, updatedAt: new Date() },
|