@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.
@@ -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.33.4",
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.33.4",
26
- "@meith/admin": "0.33.4",
27
- "@meith/antispam": "0.33.4",
28
- "@meith/api": "0.33.4",
29
- "@meith/attachments": "0.33.4",
30
- "@meith/authorization": "0.33.4",
31
- "@meith/avatars": "0.33.4",
32
- "@meith/board-digest": "0.33.4",
33
- "@meith/core": "0.33.4",
34
- "@meith/drafts": "0.33.4",
35
- "@meith/events": "0.33.4",
36
- "@meith/forums": "0.33.4",
37
- "@meith/groups": "0.33.4",
38
- "@meith/i18n": "0.33.4",
39
- "@meith/markdown": "0.33.4",
40
- "@meith/marketplace": "0.33.4",
41
- "@meith/messages": "0.33.4",
42
- "@meith/moderation": "0.33.4",
43
- "@meith/notifications": "0.33.4",
44
- "@meith/plugin-kit": "0.33.4",
45
- "@meith/polls": "0.33.4",
46
- "@meith/profile-fields": "0.33.4",
47
- "@meith/relations": "0.33.4",
48
- "@meith/reputation": "0.33.4",
49
- "@meith/search": "0.33.4",
50
- "@meith/settings": "0.33.4",
51
- "@meith/signatures": "0.33.4",
52
- "@meith/subscriptions": "0.33.4",
53
- "@meith/tasks": "0.33.4",
54
- "@meith/threads": "0.33.4"
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 { PostgresSettingsRepository } from './settings-repo'
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,
@@ -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),
@@ -1,24 +1,84 @@
1
1
  import { inArray, sql } from 'drizzle-orm'
2
2
 
3
- import type { SettingsRepository } from '@meith/settings'
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
- constructor(private readonly db: Database) {}
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
- return new Map(rows.map((r) => [r.key, r.value]))
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([...entries].map(([key, value]) => ({ key, value })))
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
@@ -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' },