@meith/db 0.33.4 → 0.34.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/index.ts +7 -1
- package/src/schema/platform.ts +39 -0
- package/src/settings-repo.ts +64 -4
- package/src/system-health-repo.ts +2 -1
- package/src/task-repo.ts +14 -0
- 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.34.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/groups": "0.
|
|
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/subscriptions": "0.
|
|
53
|
-
"@meith/
|
|
54
|
-
"@meith/
|
|
25
|
+
"@meith/accounts": "0.34.0",
|
|
26
|
+
"@meith/admin": "0.34.0",
|
|
27
|
+
"@meith/antispam": "0.34.0",
|
|
28
|
+
"@meith/api": "0.34.0",
|
|
29
|
+
"@meith/attachments": "0.34.0",
|
|
30
|
+
"@meith/authorization": "0.34.0",
|
|
31
|
+
"@meith/avatars": "0.34.0",
|
|
32
|
+
"@meith/backup": "0.34.0",
|
|
33
|
+
"@meith/board-digest": "0.34.0",
|
|
34
|
+
"@meith/core": "0.34.0",
|
|
35
|
+
"@meith/drafts": "0.34.0",
|
|
36
|
+
"@meith/events": "0.34.0",
|
|
37
|
+
"@meith/groups": "0.34.0",
|
|
38
|
+
"@meith/forums": "0.34.0",
|
|
39
|
+
"@meith/i18n": "0.34.0",
|
|
40
|
+
"@meith/markdown": "0.34.0",
|
|
41
|
+
"@meith/marketplace": "0.34.0",
|
|
42
|
+
"@meith/messages": "0.34.0",
|
|
43
|
+
"@meith/moderation": "0.34.0",
|
|
44
|
+
"@meith/notifications": "0.34.0",
|
|
45
|
+
"@meith/plugin-kit": "0.34.0",
|
|
46
|
+
"@meith/polls": "0.34.0",
|
|
47
|
+
"@meith/profile-fields": "0.34.0",
|
|
48
|
+
"@meith/relations": "0.34.0",
|
|
49
|
+
"@meith/reputation": "0.34.0",
|
|
50
|
+
"@meith/search": "0.34.0",
|
|
51
|
+
"@meith/settings": "0.34.0",
|
|
52
|
+
"@meith/subscriptions": "0.34.0",
|
|
53
|
+
"@meith/signatures": "0.34.0",
|
|
54
|
+
"@meith/tasks": "0.34.0",
|
|
55
|
+
"@meith/threads": "0.34.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
|
+
}
|
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/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() },
|
|
@@ -41,7 +41,7 @@ export class PostgresSystemHealthRepository {
|
|
|
41
41
|
const rows = resultRows(
|
|
42
42
|
await this.db.execute(sql`
|
|
43
43
|
select key, interval_seconds, enabled, last_run_at, next_run_at,
|
|
44
|
-
consecutive_failures
|
|
44
|
+
locked_until, consecutive_failures
|
|
45
45
|
from tasks order by key
|
|
46
46
|
`),
|
|
47
47
|
) as Array<Record<string, unknown>>
|
|
@@ -52,6 +52,7 @@ export class PostgresSystemHealthRepository {
|
|
|
52
52
|
enabled: row.enabled === true,
|
|
53
53
|
lastRunAt: row.last_run_at === null ? null : toDate(row.last_run_at),
|
|
54
54
|
nextRunAt: row.next_run_at === null ? null : toDate(row.next_run_at),
|
|
55
|
+
lockedUntil: row.locked_until === null ? null : toDate(row.locked_until),
|
|
55
56
|
consecutiveFailures: Number(row.consecutive_failures),
|
|
56
57
|
}))
|
|
57
58
|
}
|
package/src/task-repo.ts
CHANGED
|
@@ -49,6 +49,20 @@ export class PostgresTaskRepository implements TaskRepository {
|
|
|
49
49
|
})
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
async renew(input: { taskId: string; now: Date; staleBefore: Date }): Promise<boolean> {
|
|
53
|
+
const leaseMs = input.now.getTime() - input.staleBefore.getTime()
|
|
54
|
+
const lockedUntil = new Date(input.now.getTime() + leaseMs)
|
|
55
|
+
const result = await this.db.execute(sql`
|
|
56
|
+
update tasks
|
|
57
|
+
set locked_until = ${lockedUntil}
|
|
58
|
+
where key = ${input.taskId}
|
|
59
|
+
and locked_until is not null
|
|
60
|
+
and locked_until > ${input.now}
|
|
61
|
+
returning key
|
|
62
|
+
`)
|
|
63
|
+
return resultRows(result).length === 1
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
async claim(input: {
|
|
53
67
|
taskId: string
|
|
54
68
|
now: Date
|
package/src/user-merge-map.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type DiscardColumn = ReassignColumn
|
|
|
15
15
|
export const MERGE_REASSIGN: readonly ReassignColumn[] = [
|
|
16
16
|
{ table: 'admin_log', column: 'user_id' },
|
|
17
17
|
{ table: 'admin_undo_operations', column: 'actor_user_id' },
|
|
18
|
+
{ table: 'backup_runs', column: 'requested_by_user_id' },
|
|
18
19
|
{ table: 'attachments', column: 'uploader_user_id' },
|
|
19
20
|
{ table: 'ban_filters', column: 'created_by_user_id' },
|
|
20
21
|
{ table: 'bans', column: 'banned_by_user_id' },
|