@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.
@@ -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.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.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.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<void> {
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(eq(passkeys.id, passkeyId))
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 { 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,
@@ -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[]
@@ -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
@@ -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, t.is_locked,
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
  },
@@ -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() },