From 04e4e2945f1faad70239e2dd16037eea1411f381 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sat, 5 Sep 2026 10:27:38 +0530 Subject: [PATCH 1/3] feat(settings): store user overrides in PostgreSQL Add settings_overrides table and SETTINGS_BACKEND=db opt-in so operator overrides live in the database instead of editable YAML files. Existing file overrides are imported on first bootstrap when the table is empty. Admin PATCH and restore save through a single async restore path. --- .changeset/settings-db-overrides.md | 5 + CONFIGURATION.md | 1 + ..._120000_create_settings_overrides_table.js | 11 +++ .../admin/patch-settings-controller.ts | 4 +- .../admin/post-settings-restore-controller.ts | 2 +- src/index.ts | 13 ++- .../settings-override-repository.ts | 44 +++++++++ src/utils/settings-config.ts | 59 +++++++++++- src/utils/settings-store.ts | 62 ++++++++++++ src/utils/settings.ts | 4 + .../settings-overrides-migration.spec.ts | 13 +++ .../settings-override-repository.spec.ts | 96 +++++++++++++++++++ test/unit/utils/settings-flatten.spec.ts | 28 ++++++ test/unit/utils/settings-store.spec.ts | 27 ++++++ 14 files changed, 362 insertions(+), 7 deletions(-) create mode 100644 .changeset/settings-db-overrides.md create mode 100644 migrations/20260905_120000_create_settings_overrides_table.js create mode 100644 src/repositories/settings-override-repository.ts create mode 100644 src/utils/settings-store.ts create mode 100644 test/unit/migrations/settings-overrides-migration.spec.ts create mode 100644 test/unit/repositories/settings-override-repository.spec.ts create mode 100644 test/unit/utils/settings-flatten.spec.ts create mode 100644 test/unit/utils/settings-store.spec.ts diff --git a/.changeset/settings-db-overrides.md b/.changeset/settings-db-overrides.md new file mode 100644 index 000000000..466c634e3 --- /dev/null +++ b/.changeset/settings-db-overrides.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: store relay settings overrides in PostgreSQL when SETTINGS_BACKEND=db diff --git a/CONFIGURATION.md b/CONFIGURATION.md index f47212af4..9732650be 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -61,6 +61,7 @@ The following environment variables can be set: | ADMIN_DEPENDENCY_PING_TIMEOUT_MS | Timeout for admin DB/Redis dependency pings (ms) | 3000 | | GRAFANA_URL | Grafana base URL for admin dashboard embeds | http://127.0.0.1:7777 | | NOSTR_CONFIG_DIR | Configuration directory | /.nostr/ | +| SETTINGS_BACKEND | Where operator overrides are stored: `file` (default) or `db` | file | | MONITOR_PRIVATE_KEY | Hex-encoded private key for the NIP-66 monitor identity that will sign kind 30166/10166 events. Configure via environment variable, not settings.yaml. | | | DEBUG | Debugging filter | | | ZEBEDEE_API_KEY | Zebedee Project API Key | | diff --git a/migrations/20260905_120000_create_settings_overrides_table.js b/migrations/20260905_120000_create_settings_overrides_table.js new file mode 100644 index 000000000..e91fe7711 --- /dev/null +++ b/migrations/20260905_120000_create_settings_overrides_table.js @@ -0,0 +1,11 @@ +exports.up = async function (knex) { + await knex.schema.createTable('settings_overrides', (table) => { + table.text('path').primary() + table.jsonb('value').notNullable() + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + }) +} + +exports.down = async function (knex) { + await knex.schema.dropTableIfExists('settings_overrides') +} diff --git a/src/controllers/admin/patch-settings-controller.ts b/src/controllers/admin/patch-settings-controller.ts index 7914d3b8e..8d82c8757 100644 --- a/src/controllers/admin/patch-settings-controller.ts +++ b/src/controllers/admin/patch-settings-controller.ts @@ -8,11 +8,11 @@ import { getByPath, loadMergedSettings, loadUserSettings, - saveSettings, setByPath, validatePathAgainstDefaults, validateSettings, } from '../../utils/settings-config' +import { saveUserSettingsOverrides } from '../../utils/settings-store' import { isWriteProtectedSettingsPath, redactSettingsValue, @@ -61,7 +61,7 @@ export class PatchAdminSettingsController implements IController { return } - saveSettings(nextUserSettings as unknown as Settings) + await saveUserSettingsOverrides(nextUserSettings as unknown as Settings) const updatedChanges = changes.map(({ path }) => ({ path, value: redactSettingsValue(path, getByPath(nextUserSettings, path)), diff --git a/src/controllers/admin/post-settings-restore-controller.ts b/src/controllers/admin/post-settings-restore-controller.ts index aadcca090..45ae23a79 100644 --- a/src/controllers/admin/post-settings-restore-controller.ts +++ b/src/controllers/admin/post-settings-restore-controller.ts @@ -14,7 +14,7 @@ export class PostAdminSettingsRestoreController implements IController { } try { - restoreSettingsBackup(validation.value.filename) + await restoreSettingsBackup(validation.value.filename) appendSettingsAuditLog({ action: 'settings.restored', filename: validation.value.filename, diff --git a/src/index.ts b/src/index.ts index a1f9d1a99..43a3bb681 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,16 @@ import cluster from 'cluster' import { appFactory } from './factories/app-factory' +import { createLogger } from './factories/logger-factory' import { dvmOrchestratorWorkerFactory } from './factories/dvm-orchestrator-worker-factory' import { maintenanceWorkerFactory } from './factories/maintenance-worker-factory' import { relayMonitorWorkerFactory } from './factories/relay-monitor-worker-factory' import { staticMirroringWorkerFactory } from './factories/static-mirroring.worker-factory' import { workerFactory } from './factories/worker-factory' import { initializeMetricsTelemetry } from './telemetry/metrics' +import { bootstrapSettingsStore } from './utils/settings-store' + +const logger = createLogger('index') export const getRunner = () => { if (cluster.isPrimary) { @@ -32,5 +36,12 @@ export const getRunner = () => { if (require.main === module) { initializeMetricsTelemetry() - getRunner().run() + void bootstrapSettingsStore() + .then(() => { + getRunner().run() + }) + .catch((error) => { + logger.error('failed to bootstrap settings store:', error) + process.exit(1) + }) } diff --git a/src/repositories/settings-override-repository.ts b/src/repositories/settings-override-repository.ts new file mode 100644 index 000000000..0f4422aed --- /dev/null +++ b/src/repositories/settings-override-repository.ts @@ -0,0 +1,44 @@ +import { DatabaseClient } from '../@types/base' +import { Settings } from '../@types/settings' +import { createLogger } from '../factories/logger-factory' +import { flattenSettingsToPaths, setByPath } from '../utils/settings-config' + +const logger = createLogger('settings-override-repository') + +export class SettingsOverrideRepository { + public constructor(private readonly dbClient: DatabaseClient) {} + + public async loadOverrides(): Promise { + const rows = await this.dbClient('settings_overrides').select('path', 'value') + + let settings: Record = {} + for (const row of rows) { + settings = setByPath(settings, row.path, row.value) + } + + return settings as unknown as Settings + } + + public async replaceOverrides(overrides: Settings): Promise { + const entries = flattenSettingsToPaths(overrides) + + await this.dbClient.transaction(async (transaction) => { + await transaction('settings_overrides').delete() + + if (entries.length === 0) { + return + } + + const now = new Date() + await transaction('settings_overrides').insert( + entries.map((entry) => ({ + path: entry.path, + value: entry.value, + updated_at: now, + })), + ) + }) + + logger('replaced %d settings override paths', entries.length) + } +} diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index d6b83e725..d97584009 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -303,7 +303,7 @@ export const loadDefaults = (): Settings => { return yaml.load(defaultsRaw) as Settings } -export const loadUserSettings = (): Settings => { +export const loadUserSettingsFromFiles = (): Settings => { ensureSettingsExists() const settingsPath = getSettingsFilePath() @@ -323,6 +323,17 @@ export const loadUserSettings = (): Settings => { return {} as Settings } +export const loadUserSettings = (): Settings => { + if (process.env.SETTINGS_BACKEND === 'db') { + // Lazy import avoids a circular dependency with settings-store bootstrap. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getDbOverridesCache } = require('./settings-store') as typeof import('./settings-store') + return getDbOverridesCache() + } + + return loadUserSettingsFromFiles() +} + export const loadMergedSettings = (): Settings => { return mergeDeepRight(loadDefaults(), loadUserSettings()) as Settings } @@ -366,6 +377,40 @@ export const filterSettingsAgainstDefaults = (settings: unknown, defaults: unkno return settings } +export const flattenSettingsToPaths = ( + value: unknown, + prefix = '', +): Array<{ path: string; value: unknown }> => { + if (Array.isArray(value)) { + return value.flatMap((entry, index) => { + const nextPrefix = prefix ? `${prefix}[${index}]` : `[${index}]` + return flattenSettingsToPaths(entry, nextPrefix) + }) + } + + if (isPlainObject(value)) { + const entries = Object.entries(value) + if (entries.length === 0 && prefix) { + return [{ path: prefix, value: {} }] + } + + return entries.flatMap(([key, entry]) => { + const nextPrefix = prefix ? `${prefix}.${key}` : key + if (isPlainObject(entry) || Array.isArray(entry)) { + return flattenSettingsToPaths(entry, nextPrefix) + } + + return [{ path: nextPrefix, value: entry }] + }) + } + + if (prefix) { + return [{ path: prefix, value }] + } + + return [] +} + export const saveSettings = (settings: Settings): void => { ensureSettingsExists() const serialized = yaml.dump(toSerializable(settings), { lineWidth: 120 }) @@ -428,7 +473,7 @@ export const listSettingsBackups = (): SettingsBackupInfo[] => { .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) } -export const restoreSettingsBackup = (filename: string): Settings => { +const loadValidatedSettingsBackup = (filename: string): Settings => { if (!isSafeBackupFilename(filename)) { throw new Error('Invalid backup filename') } @@ -444,7 +489,15 @@ export const restoreSettingsBackup = (filename: string): Settings => { throw new Error(`Backup failed validation: ${issues.map((issue) => issue.path).join(', ')}`) } - saveSettings(restored) + return restored +} + +export const restoreSettingsBackup = async (filename: string): Promise => { + const restored = loadValidatedSettingsBackup(filename) + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { saveUserSettingsOverrides } = require('./settings-store') as typeof import('./settings-store') + await saveUserSettingsOverrides(restored) return restored } diff --git a/src/utils/settings-store.ts b/src/utils/settings-store.ts new file mode 100644 index 000000000..5767a7350 --- /dev/null +++ b/src/utils/settings-store.ts @@ -0,0 +1,62 @@ +import { Settings } from '../@types/settings' +import { getMasterDbClient } from '../database/client' +import { createLogger } from '../factories/logger-factory' +import { SettingsOverrideRepository } from '../repositories/settings-override-repository' +import { SettingsStatic } from './settings' +import { loadUserSettingsFromFiles, saveSettings } from './settings-config' + +const logger = createLogger('settings-store') + +let dbOverridesCache: Settings = {} as Settings +let bootstrapComplete = false + +export type SettingsBackend = 'file' | 'db' + +export const getSettingsBackend = (): SettingsBackend => { + return process.env.SETTINGS_BACKEND === 'db' ? 'db' : 'file' +} + +export const getDbOverridesCache = (): Settings => structuredClone(dbOverridesCache) + +const isEmptyOverrides = (settings: Settings): boolean => { + return Object.keys(settings as object).length === 0 +} + +export const bootstrapSettingsStore = async (): Promise => { + if (getSettingsBackend() !== 'db') { + bootstrapComplete = true + return + } + + const repository = new SettingsOverrideRepository(getMasterDbClient()) + let overrides = await repository.loadOverrides() + + if (isEmptyOverrides(overrides)) { + const fileOverrides = loadUserSettingsFromFiles() + if (!isEmptyOverrides(fileOverrides)) { + logger.info('importing settings overrides from file into database') + await repository.replaceOverrides(fileOverrides) + overrides = fileOverrides + } + } + + dbOverridesCache = overrides + bootstrapComplete = true + SettingsStatic.invalidateCache() +} + +export const saveUserSettingsOverrides = async (settings: Settings): Promise => { + if (getSettingsBackend() === 'db') { + if (!bootstrapComplete) { + throw new Error('settings store has not been bootstrapped') + } + + const repository = new SettingsOverrideRepository(getMasterDbClient()) + await repository.replaceOverrides(settings) + dbOverridesCache = structuredClone(settings) + SettingsStatic.invalidateCache() + return + } + + saveSettings(settings) +} diff --git a/src/utils/settings.ts b/src/utils/settings.ts index c47aa321a..665a6ff4a 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -95,6 +95,10 @@ export class SettingsStatic { } } + public static invalidateCache(): void { + SettingsStatic._settings = undefined + } + public static saveSettings(path: string, settings: Settings) { logger('saving settings to %s: %o', path, settings) return fs.writeFileSync(join(path, 'settings.yaml'), yaml.dump(settings), { encoding: 'utf-8' }) diff --git a/test/unit/migrations/settings-overrides-migration.spec.ts b/test/unit/migrations/settings-overrides-migration.spec.ts new file mode 100644 index 000000000..bd197646f --- /dev/null +++ b/test/unit/migrations/settings-overrides-migration.spec.ts @@ -0,0 +1,13 @@ +import { createRequire } from 'node:module' + +import { expect } from 'chai' + +const requireFromHere = createRequire(__filename) +const migration = requireFromHere('../../../migrations/20260905_120000_create_settings_overrides_table.js') + +describe('migrations/20260905_120000_create_settings_overrides_table', () => { + it('exports up and down', () => { + expect(migration.up).to.be.a('function') + expect(migration.down).to.be.a('function') + }) +}) diff --git a/test/unit/repositories/settings-override-repository.spec.ts b/test/unit/repositories/settings-override-repository.spec.ts new file mode 100644 index 000000000..2d5cd1dfd --- /dev/null +++ b/test/unit/repositories/settings-override-repository.spec.ts @@ -0,0 +1,96 @@ +import * as chai from 'chai' +import * as sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { SettingsOverrideRepository } from '../../../src/repositories/settings-override-repository' + +chai.use(sinonChai) + +const { expect } = chai + +describe('SettingsOverrideRepository', () => { + let queryBuilder: { + select: sinon.SinonStub + delete: sinon.SinonStub + insert: sinon.SinonStub + } + let transactionBuilder: { + delete: sinon.SinonStub + insert: sinon.SinonStub + } + let dbClient: any + let repository: SettingsOverrideRepository + + beforeEach(() => { + queryBuilder = { + select: sinon.stub().resolves([ + { path: 'info.name', value: 'Test Relay' }, + { path: 'nip66.enabled', value: true }, + ]), + delete: sinon.stub(), + insert: sinon.stub(), + } + + transactionBuilder = { + delete: sinon.stub().resolves(), + insert: sinon.stub().resolves(), + } + + const transactionFn = sinon.stub() + transactionFn.withArgs('settings_overrides').returns(transactionBuilder) + + dbClient = sinon.stub() + dbClient.callsFake((table: string) => { + if (table === 'settings_overrides') { + return queryBuilder + } + + throw new Error(`unexpected table ${table}`) + }) + dbClient.transaction = sinon.stub().callsFake(async (callback: (trx: typeof transactionFn) => Promise) => { + await callback(transactionFn) + }) + + repository = new SettingsOverrideRepository(dbClient) + }) + + it('loads overrides into a nested settings object', async () => { + const overrides = await repository.loadOverrides() + + expect(overrides).to.deep.equal({ + info: { name: 'Test Relay' }, + nip66: { enabled: true }, + }) + }) + + it('replaces all override rows from a nested settings object', async () => { + await repository.replaceOverrides({ + info: { name: 'Updated Relay' }, + admin: { enabled: true }, + } as any) + + expect(dbClient.transaction).to.have.been.calledOnce + expect(transactionBuilder.delete).to.have.been.calledOnce + expect(transactionBuilder.insert).to.have.been.calledOnce + + const insertedRows = transactionBuilder.insert.firstCall.args[0] + expect(insertedRows).to.have.length(2) + expect(insertedRows[0]).to.deep.include({ + path: 'info.name', + value: 'Updated Relay', + }) + expect(insertedRows[0].updated_at).to.be.instanceOf(Date) + expect(insertedRows[1]).to.deep.include({ + path: 'admin.enabled', + value: true, + }) + expect(insertedRows[1].updated_at).to.be.instanceOf(Date) + }) + + it('clears all rows when overrides are empty', async () => { + await repository.replaceOverrides({} as any) + + expect(transactionBuilder.delete).to.have.been.calledOnce + expect(transactionBuilder.insert).not.to.have.been.called + }) +}) diff --git a/test/unit/utils/settings-flatten.spec.ts b/test/unit/utils/settings-flatten.spec.ts new file mode 100644 index 000000000..3994a4584 --- /dev/null +++ b/test/unit/utils/settings-flatten.spec.ts @@ -0,0 +1,28 @@ +import { expect } from 'chai' + +import { flattenSettingsToPaths } from '../../../src/utils/settings-config' + +describe('flattenSettingsToPaths', () => { + it('flattens nested objects into dot paths', () => { + expect( + flattenSettingsToPaths({ + info: { name: 'Relay', relay_url: 'wss://relay.example.com' }, + nip66: { enabled: true }, + }), + ).to.deep.equal([ + { path: 'info.name', value: 'Relay' }, + { path: 'info.relay_url', value: 'wss://relay.example.com' }, + { path: 'nip66.enabled', value: true }, + ]) + }) + + it('flattens indexed array paths', () => { + expect( + flattenSettingsToPaths({ + nip66: { + targets: ['wss://relay.example.com'], + }, + }), + ).to.deep.equal([{ path: 'nip66.targets[0]', value: 'wss://relay.example.com' }]) + }) +}) diff --git a/test/unit/utils/settings-store.spec.ts b/test/unit/utils/settings-store.spec.ts new file mode 100644 index 000000000..aedd91f5b --- /dev/null +++ b/test/unit/utils/settings-store.spec.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai' + +import { getSettingsBackend } from '../../../src/utils/settings-store' + +describe('settings-store backend selection', () => { + const originalBackend = process.env.SETTINGS_BACKEND + + afterEach(() => { + if (originalBackend === undefined) { + delete process.env.SETTINGS_BACKEND + } else { + process.env.SETTINGS_BACKEND = originalBackend + } + }) + + it('defaults to file backend', () => { + delete process.env.SETTINGS_BACKEND + + expect(getSettingsBackend()).to.equal('file') + }) + + it('selects db backend when SETTINGS_BACKEND=db', () => { + process.env.SETTINGS_BACKEND = 'db' + + expect(getSettingsBackend()).to.equal('db') + }) +}) From a351a517f5887cb5f021d3cf066296e7bb4abf7e Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sat, 24 Oct 2026 09:33:38 +0530 Subject: [PATCH 2/3] fix(settings): address db override persistence and reload gaps JSON-encode jsonb values, preserve dotted map keys and empty arrays, reload overrides across workers, route CLI and notifications through the db store, and backup before database replaces. --- src/cli/commands/config.ts | 9 +- .../admin/patch-notifications-controller.ts | 4 +- .../settings-override-repository.ts | 2 +- src/utils/settings-config.ts | 86 +++++++++++++++++-- src/utils/settings-paths.ts | 2 + src/utils/settings-store.ts | 70 ++++++++++++++- .../patch-notifications-controller.spec.ts | 11 +-- .../settings-override-repository.spec.ts | 4 +- test/unit/utils/settings-flatten.spec.ts | 27 ++++++ 9 files changed, 194 insertions(+), 21 deletions(-) diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index c851ff7ce..5d61520a2 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -6,7 +6,6 @@ import { loadMergedSettings, loadUserSettings, parseTypedValue, - saveSettings, setByPath, toCategoryLabel, validatePathAgainstDefaults, @@ -21,6 +20,7 @@ import { validateEnvPair, validateEnvValues, } from '../utils/env-config' +import { bootstrapSettingsStore, saveUserSettingsOverrides } from '../../utils/settings-store' import { logError, logInfo } from '../utils/output' import { runStart } from './start' import { runStop } from './stop' @@ -77,6 +77,7 @@ const restartRelay = async (): Promise => { } export const runConfigList = async (options: { json?: boolean } = {}): Promise => { + await bootstrapSettingsStore() const settings = loadMergedSettings() if (options.json) { @@ -89,6 +90,7 @@ export const runConfigList = async (options: { json?: boolean } = {}): Promise => { + await bootstrapSettingsStore() const settings = loadMergedSettings() as unknown as Record const value = getByPath(settings, path) @@ -122,6 +124,8 @@ export const runConfigSet = async ( ): Promise => { const valueType = options.valueType ?? 'inferred' + await bootstrapSettingsStore() + const pathIssues = validatePathAgainstDefaults(path) if (pathIssues.length > 0) { logError(pathIssues[0].message) @@ -146,7 +150,7 @@ export const runConfigSet = async ( } } - saveSettings(next as any) + await saveUserSettingsOverrides(next as any) logInfo(`Updated ${path}`) @@ -158,6 +162,7 @@ export const runConfigSet = async ( } export const runConfigValidate = async (): Promise => { + await bootstrapSettingsStore() const settings = loadMergedSettings() const issues = validateSettings(settings) diff --git a/src/controllers/admin/patch-notifications-controller.ts b/src/controllers/admin/patch-notifications-controller.ts index 2e3545d49..fdfbd83a1 100644 --- a/src/controllers/admin/patch-notifications-controller.ts +++ b/src/controllers/admin/patch-notifications-controller.ts @@ -17,9 +17,9 @@ import { loadDefaults, loadMergedSettings, loadUserSettings, - saveSettings, validateSettings, } from '../../utils/settings-config' +import { saveUserSettingsOverrides } from '../../utils/settings-store' import { validateSchema } from '../../utils/validation' import { recordAdminAuditFromRequest } from '../../utils/record-admin-audit' @@ -55,7 +55,7 @@ export class PatchAdminNotificationsController implements IController { return } - saveSettings(nextUserSettings) + await saveUserSettingsOverrides(nextUserSettings) appendSettingsAuditLog({ action: 'settings.updated', changes: [{ path: 'admin.notifications', reload: 'hot-reload' }], diff --git a/src/repositories/settings-override-repository.ts b/src/repositories/settings-override-repository.ts index 0f4422aed..a52016e20 100644 --- a/src/repositories/settings-override-repository.ts +++ b/src/repositories/settings-override-repository.ts @@ -33,7 +33,7 @@ export class SettingsOverrideRepository { await transaction('settings_overrides').insert( entries.map((entry) => ({ path: entry.path, - value: entry.value, + value: JSON.stringify(entry.value), updated_at: now, })), ) diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index ee8a5fa6f..882b5dd13 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -72,6 +72,49 @@ const hasOwn = (object: Record, key: string): boolean => { return Object.prototype.hasOwnProperty.call(object, key) } +const QUOTED_PATH_KEY = /^\["((?:\\.|[^"\\])*)"\]$/ + +export const formatSettingsPathKey = (key: string): string => { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + return key + } + + return `["${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]` +} + +const tokenizePathSegments = (input: string): string[] => { + const segments: string[] = [] + let index = 0 + + while (index < input.length) { + if (input[index] === '.') { + index += 1 + continue + } + + if (input[index] === '[') { + const closing = input.indexOf('"]', index + 1) + if (closing < 0) { + throw new Error(`Invalid path segment in: ${input}`) + } + + segments.push(input.slice(index, closing + 2)) + index = closing + 2 + continue + } + + let next = index + while (next < input.length && input[next] !== '.') { + next += 1 + } + + segments.push(input.slice(index, next)) + index = next + } + + return segments.filter(Boolean) +} + const parsePath = (path: string): PathToken[] => { const input = path.trim() @@ -80,11 +123,14 @@ const parsePath = (path: string): PathToken[] => { } const tokens: PathToken[] = [] - const segments = input.split('.').map((part) => part.trim()) - for (const segment of segments) { - if (!segment) { - throw new Error(`Invalid path segment in: ${path}`) + for (const segment of tokenizePathSegments(input)) { + const quoted = segment.match(QUOTED_PATH_KEY) + if (quoted) { + const key = quoted[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') + assertSafePathKey(key, segment) + tokens.push({ type: 'key', key }) + continue } const match = segment.match(/^([A-Za-z_][A-Za-z0-9_]*)(\[(\d+)\])*$/) @@ -114,7 +160,8 @@ const formatPathTokens = (tokens: PathToken[]): string => { for (const token of tokens) { if (token.type === 'key') { - out = out ? `${out}.${token.key}` : token.key + const formatted = formatSettingsPathKey(token.key) + out = out ? `${out}.${formatted}` : formatted continue } @@ -395,6 +442,10 @@ export const flattenSettingsToPaths = ( prefix = '', ): Array<{ path: string; value: unknown }> => { if (Array.isArray(value)) { + if (value.length === 0 && prefix) { + return [{ path: prefix, value: [] }] + } + return value.flatMap((entry, index) => { const nextPrefix = prefix ? `${prefix}[${index}]` : `[${index}]` return flattenSettingsToPaths(entry, nextPrefix) @@ -408,7 +459,12 @@ export const flattenSettingsToPaths = ( } return entries.flatMap(([key, entry]) => { - const nextPrefix = prefix ? `${prefix}.${key}` : key + const keySegment = formatSettingsPathKey(key) + const nextPrefix = prefix + ? keySegment.startsWith('[') + ? `${prefix}${keySegment}` + : `${prefix}.${keySegment}` + : keySegment if (isPlainObject(entry) || Array.isArray(entry)) { return flattenSettingsToPaths(entry, nextPrefix) } @@ -463,7 +519,23 @@ export type SettingsBackupInfo = { } const isSafeBackupFilename = (filename: string): boolean => { - return /^settings\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.yaml$/.test(filename) + return /^settings(?:-db)?\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.yaml$/.test(filename) +} + +export const backupUserSettingsOverrides = (overrides: Settings): string => { + ensureSettingsExists() + const backupDirectory = getSettingsBackupDir() + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const filename = `settings-db.${timestamp}.yaml` + + fs.mkdirSync(backupDirectory, { recursive: true }) + fs.writeFileSync( + join(backupDirectory, filename), + yaml.dump(toSerializable(overrides), { lineWidth: 120 }), + { encoding: 'utf-8', mode: 0o600 }, + ) + + return filename } export const listSettingsBackups = (): SettingsBackupInfo[] => { diff --git a/src/utils/settings-paths.ts b/src/utils/settings-paths.ts index d55743195..6bc154f75 100644 --- a/src/utils/settings-paths.ts +++ b/src/utils/settings-paths.ts @@ -12,3 +12,5 @@ export const getDefaultSettingsFilePath = (): string => join(process.cwd(), 'res export const getSettingsBackupDir = (): string => join(getConfigBaseDir(), 'backups') export const getSettingsAuditLogPath = (): string => join(getConfigBaseDir(), 'settings-audit.jsonl') + +export const getSettingsDbImportMarkerPath = (): string => join(getConfigBaseDir(), '.settings-db-import-complete') diff --git a/src/utils/settings-store.ts b/src/utils/settings-store.ts index 5767a7350..efb6ab846 100644 --- a/src/utils/settings-store.ts +++ b/src/utils/settings-store.ts @@ -1,14 +1,26 @@ +import fs from 'fs' + +import cluster from 'cluster' + import { Settings } from '../@types/settings' import { getMasterDbClient } from '../database/client' import { createLogger } from '../factories/logger-factory' import { SettingsOverrideRepository } from '../repositories/settings-override-repository' import { SettingsStatic } from './settings' -import { loadUserSettingsFromFiles, saveSettings } from './settings-config' +import { + backupUserSettingsOverrides, + loadUserSettingsFromFiles, + saveSettings, +} from './settings-config' +import { getSettingsDbImportMarkerPath } from './settings-paths' const logger = createLogger('settings-store') +const SETTINGS_OVERRIDES_RELOAD_EVENT = 'settings-overrides-reload' + let dbOverridesCache: Settings = {} as Settings let bootstrapComplete = false +let reloadListenerRegistered = false export type SettingsBackend = 'file' | 'db' @@ -22,7 +34,54 @@ const isEmptyOverrides = (settings: Settings): boolean => { return Object.keys(settings as object).length === 0 } +const markSettingsFileImportComplete = (): void => { + fs.writeFileSync(getSettingsDbImportMarkerPath(), `${new Date().toISOString()}\n`, { encoding: 'utf-8', mode: 0o600 }) +} + +const hasCompletedSettingsFileImport = (): boolean => { + return fs.existsSync(getSettingsDbImportMarkerPath()) +} + +export const reloadDbOverridesFromDatabase = async (): Promise => { + if (getSettingsBackend() !== 'db') { + return + } + + const repository = new SettingsOverrideRepository(getMasterDbClient()) + dbOverridesCache = await repository.loadOverrides() + SettingsStatic.invalidateCache() +} + +const registerSettingsOverridesReloadListener = (): void => { + if (reloadListenerRegistered || getSettingsBackend() !== 'db') { + return + } + + reloadListenerRegistered = true + process.on('message', (message: { eventName?: string }) => { + if (message?.eventName !== SETTINGS_OVERRIDES_RELOAD_EVENT) { + return + } + + void reloadDbOverridesFromDatabase().catch((error) => { + logger.error('failed to reload database settings overrides: %o', error) + }) + }) +} + +const broadcastSettingsOverridesReload = (): void => { + if (getSettingsBackend() !== 'db' || !cluster.isPrimary || !cluster.workers) { + return + } + + for (const worker of Object.values(cluster.workers)) { + worker?.send({ eventName: SETTINGS_OVERRIDES_RELOAD_EVENT }) + } +} + export const bootstrapSettingsStore = async (): Promise => { + registerSettingsOverridesReloadListener() + if (getSettingsBackend() !== 'db') { bootstrapComplete = true return @@ -31,13 +90,14 @@ export const bootstrapSettingsStore = async (): Promise => { const repository = new SettingsOverrideRepository(getMasterDbClient()) let overrides = await repository.loadOverrides() - if (isEmptyOverrides(overrides)) { + if (!hasCompletedSettingsFileImport() && isEmptyOverrides(overrides)) { const fileOverrides = loadUserSettingsFromFiles() if (!isEmptyOverrides(fileOverrides)) { logger.info('importing settings overrides from file into database') await repository.replaceOverrides(fileOverrides) overrides = fileOverrides } + markSettingsFileImportComplete() } dbOverridesCache = overrides @@ -52,9 +112,15 @@ export const saveUserSettingsOverrides = async (settings: Settings): Promise { sandbox.stub(settingsConfig, 'loadMergedSettings').returns(baseMergedSettings as any) sandbox.stub(settingsConfig, 'loadUserSettings').returns({ admin: { notifications: baseNotifications } } as any) - sandbox.stub(settingsConfig, 'saveSettings') + sandbox.stub(settingsStore, 'saveUserSettingsOverrides').resolves() sandbox.stub(settingsConfig, 'appendSettingsAuditLog') sandbox.stub(adminNotificationsSettings, 'getRedactedAdminNotifications').returns({ ...baseNotifications, @@ -93,7 +94,7 @@ describe('PatchAdminNotificationsController', () => { expect(response.status.calledOnceWithExactly(400)).to.equal(true) expect(response.send.firstCall.args[0].error).to.equal('Validation failed') expect(response.send.firstCall.args[0].issues).to.be.an('array').that.is.not.empty - expect(settingsConfig.saveSettings).to.not.have.been.called + expect(settingsStore.saveUserSettingsOverrides).to.not.have.been.called expect(outboxRepository.enqueue).to.not.have.been.called }) @@ -118,8 +119,8 @@ describe('PatchAdminNotificationsController', () => { ) expect(response.status.calledOnceWithExactly(200)).to.equal(true) - expect(settingsConfig.saveSettings).to.have.been.calledOnce - const saved = (settingsConfig.saveSettings as Sinon.SinonStub).firstCall.args[0] + expect(settingsStore.saveUserSettingsOverrides).to.have.been.calledOnce + const saved = (settingsStore.saveUserSettingsOverrides as Sinon.SinonStub).firstCall.args[0] expect(saved.admin.notifications.enabled).to.equal(true) expect(saved.admin.notifications.targets[0].enabled).to.equal(false) expect(saved.admin.notifications.targets[0].url).to.equal('https://discord.com/api/webhooks/secret') @@ -137,6 +138,6 @@ describe('PatchAdminNotificationsController', () => { await controller.handleRequest({ body: { enabled: true }, ip: '127.0.0.1' } as any, response as any) expect(response.status.calledOnceWithExactly(200)).to.equal(true) - expect(settingsConfig.saveSettings).to.have.been.calledOnce + expect(settingsStore.saveUserSettingsOverrides).to.have.been.calledOnce }) }) diff --git a/test/unit/repositories/settings-override-repository.spec.ts b/test/unit/repositories/settings-override-repository.spec.ts index 2d5cd1dfd..dddad30e7 100644 --- a/test/unit/repositories/settings-override-repository.spec.ts +++ b/test/unit/repositories/settings-override-repository.spec.ts @@ -77,12 +77,12 @@ describe('SettingsOverrideRepository', () => { expect(insertedRows).to.have.length(2) expect(insertedRows[0]).to.deep.include({ path: 'info.name', - value: 'Updated Relay', + value: JSON.stringify('Updated Relay'), }) expect(insertedRows[0].updated_at).to.be.instanceOf(Date) expect(insertedRows[1]).to.deep.include({ path: 'admin.enabled', - value: true, + value: JSON.stringify(true), }) expect(insertedRows[1].updated_at).to.be.instanceOf(Date) }) diff --git a/test/unit/utils/settings-flatten.spec.ts b/test/unit/utils/settings-flatten.spec.ts index 3994a4584..88435deab 100644 --- a/test/unit/utils/settings-flatten.spec.ts +++ b/test/unit/utils/settings-flatten.spec.ts @@ -16,6 +16,33 @@ describe('flattenSettingsToPaths', () => { ]) }) + it('preserves notification event keys that contain dots', () => { + expect( + flattenSettingsToPaths({ + admin: { + notifications: { + events: { 'settings.changed': true, 'mirror.disconnected': false }, + }, + }, + }), + ).to.deep.equal([ + { path: 'admin.notifications.events["settings.changed"]', value: true }, + { path: 'admin.notifications.events["mirror.disconnected"]', value: false }, + ]) + }) + + it('stores empty arrays as explicit override rows', () => { + expect( + flattenSettingsToPaths({ + limits: { + message: { + ipWhitelist: [], + }, + }, + }), + ).to.deep.equal([{ path: 'limits.message.ipWhitelist', value: [] }]) + }) + it('flattens indexed array paths', () => { expect( flattenSettingsToPaths({ From c77a7da74f957bdda005b1dc9f1c9fa540527190 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sun, 1 Nov 2026 23:09:47 +0530 Subject: [PATCH 3/3] fix(settings): address Greptile review for DB overrides (#758) Close CLI DB pools, bootstrap settings reads across commands, serialize override writes, fan out worker reloads through the primary, wire SETTINGS_BACKEND in Compose/env CLI, and fix dotted settings path parsing. --- .changeset/settings-db-overrides.md | 2 + .env.example | 1 + deploy/docker-compose.haproxy.yml | 1 + deploy/docker-compose.prod.yml | 1 + docker-compose.yml | 1 + src/app/app.ts | 11 ++ src/cli/commands/config.ts | 161 ++++++++++-------- src/cli/commands/info.ts | 10 ++ src/cli/commands/invite.ts | 12 ++ src/cli/tui/menus/configure.ts | 10 ++ src/cli/utils/env-config.ts | 10 ++ .../admin/patch-settings-controller.ts | 23 +-- src/database/client.ts | 14 ++ src/utils/admin-moderation-persist.ts | 2 +- src/utils/admin-moderation.ts | 21 +-- src/utils/settings-config.ts | 115 +++++++++++-- src/utils/settings-store.ts | 128 +++++++++++--- test/unit/cli/commands.spec.ts | 3 + .../settings-override-repository.spec.ts | 20 +++ test/unit/utils/admin-moderation.spec.ts | 16 +- test/unit/utils/settings-flatten.spec.ts | 22 ++- test/unit/utils/settings-store.spec.ts | 96 ++++++++++- 22 files changed, 542 insertions(+), 138 deletions(-) diff --git a/.changeset/settings-db-overrides.md b/.changeset/settings-db-overrides.md index 466c634e3..2398b0772 100644 --- a/.changeset/settings-db-overrides.md +++ b/.changeset/settings-db-overrides.md @@ -3,3 +3,5 @@ --- feat: store relay settings overrides in PostgreSQL when SETTINGS_BACKEND=db + +Serialize settings writes, fan out DB override reloads from workers through the primary, close CLI database pools after config commands, bootstrap the settings store for info/invite/configure CLI paths, route moderation and admin PATCH saves through the DB override store, forward SETTINGS_BACKEND in Compose and the env CLI, and fix dotted settings path round-trips for database rows. diff --git a/.env.example b/.env.example index 64191de5f..6c6bfc7cf 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,7 @@ REDIS_PORT=6379 RELAY_PORT=8008 WORKER_COUNT=2 # Defaults to CPU count. Use 1 or 2 for local testing. # NOSTR_CONFIG_DIR=.nostr # Where settings.yaml lives +# SETTINGS_BACKEND=file # Operator overrides in settings.yaml; use db to store overrides in PostgreSQL # --- DEBUGGING --- # Useful namespaces: maintenance-worker, database-client:*, cache-client, etc. diff --git a/deploy/docker-compose.haproxy.yml b/deploy/docker-compose.haproxy.yml index ca7ca668a..6d7264a66 100644 --- a/deploy/docker-compose.haproxy.yml +++ b/deploy/docker-compose.haproxy.yml @@ -16,6 +16,7 @@ x-nostream-relay: &nostream-relay environment: RELAY_PORT: 8008 NOSTR_CONFIG_DIR: /home/node/.nostr + SETTINGS_BACKEND: ${SETTINGS_BACKEND:-file} DB_HOST: nostream-db DB_PORT: 5432 DB_USER: ${DB_USER} diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index f86501c6e..f802c2cd9 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -7,6 +7,7 @@ services: environment: RELAY_PORT: 8008 NOSTR_CONFIG_DIR: /home/node/.nostr + SETTINGS_BACKEND: ${SETTINGS_BACKEND:-file} DB_HOST: nostream-db DB_PORT: 5432 DB_USER: ${DB_USER} diff --git a/docker-compose.yml b/docker-compose.yml index d640b0232..281c506ab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: RELAY_PORT: 8008 # Master NOSTR_CONFIG_DIR: /home/node/.nostr + SETTINGS_BACKEND: ${SETTINGS_BACKEND:-file} DB_HOST: nostream-db DB_PORT: 5432 DB_USER: nostr_ts_relay diff --git a/src/app/app.ts b/src/app/app.ts index c11cb7939..f7b81b4e3 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -26,6 +26,10 @@ import { } from '../utils/relay-broadcast-message' import { setRelayBroadcastFanoutReady } from '../utils/relay-broadcast-state' import { getPrimaryShutdownDeadlineMs } from '../utils/shutdown-state' +import { + handleSettingsOverridesClusterSyncFromWorker, + isSettingsOverridesClusterSyncMessage, +} from '../utils/settings-store' const logger = createLogger('app-primary') @@ -215,6 +219,13 @@ export class App implements IRunnable { private onClusterMessage(source: Worker, message: Serializable) { logger('message received from worker %s: %o', source.process.pid, message) + if (isSettingsOverridesClusterSyncMessage(message)) { + void handleSettingsOverridesClusterSyncFromWorker(source).catch((error) => { + logger.error('settings overrides cluster sync failed: %o', error) + }) + return + } + if (isRelayBroadcastMessage(message)) { this.relayBroadcastDeduplicator.mark(message.event.id) const publishPromise = this.relayBroadcastFanout?.publish(message) diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 5d61520a2..2932a469b 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -4,7 +4,6 @@ import yaml from 'js-yaml' import { getByPath, loadMergedSettings, - loadUserSettings, parseTypedValue, setByPath, toCategoryLabel, @@ -20,13 +19,25 @@ import { validateEnvPair, validateEnvValues, } from '../utils/env-config' -import { bootstrapSettingsStore, saveUserSettingsOverrides } from '../../utils/settings-store' +import { + bootstrapSettingsStore, + shutdownSettingsStore, + updateUserSettingsOverrides, +} from '../../utils/settings-store' import { logError, logInfo } from '../utils/output' import { runStart } from './start' import { runStop } from './stop' type ValueType = 'inferred' | 'json' +const withSettingsCliSession = async (operation: () => Promise): Promise => { + try { + return await operation() + } finally { + await shutdownSettingsStore() + } +} + const toJson = (value: unknown): string => { return JSON.stringify( value, @@ -76,42 +87,44 @@ const restartRelay = async (): Promise => { return 0 } -export const runConfigList = async (options: { json?: boolean } = {}): Promise => { - await bootstrapSettingsStore() - const settings = loadMergedSettings() - - if (options.json) { - logInfo(toJson(settings)) - return 0 - } +export const runConfigList = async (options: { json?: boolean } = {}): Promise => + withSettingsCliSession(async () => { + await bootstrapSettingsStore() + const settings = loadMergedSettings() - logInfo(yaml.dump(settings, { lineWidth: 120 })) - return 0 -} + if (options.json) { + logInfo(toJson(settings)) + return 0 + } -export const runConfigGet = async (path: string, options: { json?: boolean } = {}): Promise => { - await bootstrapSettingsStore() - const settings = loadMergedSettings() as unknown as Record - const value = getByPath(settings, path) + logInfo(yaml.dump(settings, { lineWidth: 120 })) + return 0 + }) + +export const runConfigGet = async (path: string, options: { json?: boolean } = {}): Promise => + withSettingsCliSession(async () => { + await bootstrapSettingsStore() + const settings = loadMergedSettings() as unknown as Record + const value = getByPath(settings, path) + + if (value === undefined) { + if (options.json) { + process.stderr.write(`${JSON.stringify({ error: { message: `Path not found: ${path}`, code: 1 } })}\n`) + return 1 + } - if (value === undefined) { - if (options.json) { - process.stderr.write(`${JSON.stringify({ error: { message: `Path not found: ${path}`, code: 1 } })}\n`) + logError(`Path not found: ${path}`) return 1 } - logError(`Path not found: ${path}`) - return 1 - } + if (options.json) { + logInfo(toJson(value)) + return 0 + } - if (options.json) { - logInfo(toJson(value)) + logInfo(serialize(value)) return 0 - } - - logInfo(serialize(value)) - return 0 -} + }) export const runConfigSet = async ( path: string, @@ -121,63 +134,67 @@ export const runConfigSet = async ( validate?: boolean valueType?: ValueType } = {}, -): Promise => { - const valueType = options.valueType ?? 'inferred' +): Promise => + withSettingsCliSession(async () => { + const valueType = options.valueType ?? 'inferred' - await bootstrapSettingsStore() + await bootstrapSettingsStore() - const pathIssues = validatePathAgainstDefaults(path) - if (pathIssues.length > 0) { - logError(pathIssues[0].message) - return 1 - } + const pathIssues = validatePathAgainstDefaults(path) + if (pathIssues.length > 0) { + logError(pathIssues[0].message) + return 1 + } - const settings = loadUserSettings() as unknown as Record - const next = setByPath(settings, path, parseTypedValue(rawValue, valueType)) + const parsedValue = parseTypedValue(rawValue, valueType) - if (options.validate !== false) { - const merged = loadMergedSettings() as unknown as Record - const mergedNext = setByPath(merged, path, getByPath(next, path)) - const validationIssues = validateSettings(mergedNext as any) + if (options.validate !== false) { + const merged = loadMergedSettings() as unknown as Record + const mergedNext = setByPath(merged, path, parsedValue) + const validationIssues = validateSettings(mergedNext as any) - if (validationIssues.length > 0) { - logError('Config update rejected by validation:') - for (const issue of validationIssues) { - logError(`- ${issue.path}: ${issue.message}`) - } + if (validationIssues.length > 0) { + logError('Config update rejected by validation:') + for (const issue of validationIssues) { + logError(`- ${issue.path}: ${issue.message}`) + } - return 1 + return 1 + } } - } - await saveUserSettingsOverrides(next as any) + await updateUserSettingsOverrides((userSettings) => + setByPath(userSettings as unknown as Record, path, parsedValue) as unknown as typeof userSettings, + ) - logInfo(`Updated ${path}`) + logInfo(`Updated ${path}`) - if (options.restart) { - return restartRelay() - } + if (options.restart) { + return restartRelay() + } - return 0 -} + return 0 + }) -export const runConfigValidate = async (): Promise => { - await bootstrapSettingsStore() - const settings = loadMergedSettings() - const issues = validateSettings(settings) +export const runConfigValidate = async (): Promise => + withSettingsCliSession(async () => { + await bootstrapSettingsStore() + const settings = loadMergedSettings() + const issues = validateSettings(settings) - if (issues.length === 0) { - logInfo('Settings are valid') - return 0 - } + if (issues.length === 0) { + logInfo('Settings are valid') + return 0 + } - logError('Settings validation failed:') - for (const issue of issues) { - logError(`- ${issue.path}: ${issue.message}`) - } + logError('Settings validation failed:') + for (const issue of issues) { + logError(`- ${issue.path}: ${issue.message}`) + } + + return 1 + }) - return 1 -} export const runConfigEnvList = async (options: { showSecrets?: boolean } = {}): Promise => { const values = readEnvValues() const entries = Object.entries(values).sort(([a], [b]) => a.localeCompare(b)) diff --git a/src/cli/commands/info.ts b/src/cli/commands/info.ts index d70cafefe..763373da6 100644 --- a/src/cli/commands/info.ts +++ b/src/cli/commands/info.ts @@ -2,6 +2,7 @@ import fs from 'fs' import { Client } from 'pg' import packageJson from '../../../package.json' +import { bootstrapSettingsStore, shutdownSettingsStore } from '../../utils/settings-store' import { loadMergedSettings } from '../utils/config' import { tryGetRelayNip43Pubkey } from '../../utils/nip43' import { logError, logInfo } from '../utils/output' @@ -152,6 +153,15 @@ export const getInfoPayload = async () => { } export const runInfo = async (options: InfoOptions): Promise => { + try { + await bootstrapSettingsStore() + return await runInfoWithBootstrappedSettings(options) + } finally { + await shutdownSettingsStore() + } +} + +const runInfoWithBootstrappedSettings = async (options: InfoOptions): Promise => { const payload = await getInfoPayload() if (options.torHostname) { diff --git a/src/cli/commands/invite.ts b/src/cli/commands/invite.ts index bd345d00f..e87b517c2 100644 --- a/src/cli/commands/invite.ts +++ b/src/cli/commands/invite.ts @@ -6,6 +6,7 @@ import { IInviteCodeRepository } from '../../@types/repositories' import { Settings } from '../../@types/settings' import { InviteCodeRepository } from '../../repositories/invite-code-repository' import { issueInviteCode, parseRelayPubkey } from '../../utils/nip43-invites' +import { bootstrapSettingsStore, shutdownSettingsStore } from '../../utils/settings-store' import { loadMergedSettings } from '../utils/config' import { readEnvValues } from '../utils/env-config' import { logInfo } from '../utils/output' @@ -146,6 +147,13 @@ export const runInviteCreate = async ( const loadSettings = deps.loadSettings ?? loadMergedSettings const issue = deps.issue ?? issueInviteCode const now = deps.now ?? Date.now + + const shouldBootstrapSettings = !deps.loadSettings + + if (shouldBootstrapSettings) { + await bootstrapSettingsStore() + } + const settings = loadSettings() const overrides: Parameters[2] = {} @@ -186,5 +194,9 @@ export const runInviteCreate = async ( if (dbClient && typeof (dbClient as Knex).destroy === 'function') { await (dbClient as Knex).destroy() } + + if (shouldBootstrapSettings) { + await shutdownSettingsStore() + } } } diff --git a/src/cli/tui/menus/configure.ts b/src/cli/tui/menus/configure.ts index ae2f3d8ec..12913cdfb 100644 --- a/src/cli/tui/menus/configure.ts +++ b/src/cli/tui/menus/configure.ts @@ -4,6 +4,7 @@ import { runConfigSet, runConfigValidate, } from '../../commands/config' +import { bootstrapSettingsStore, shutdownSettingsStore } from '../../../utils/settings-store' import { getByPath, getTopLevelSettingCategories, loadMergedSettings, toCategoryLabel } from '../../../utils/settings-config' import { type GuidedSettingField, @@ -209,6 +210,15 @@ const runGuidedConfigureMenu = async (): Promise => { } export const runConfigureMenu = async (): Promise => { + try { + await bootstrapSettingsStore() + return await runConfigureMenuWithStore() + } finally { + await shutdownSettingsStore() + } +} + +const runConfigureMenuWithStore = async (): Promise => { const action = await tuiPrompts.select({ message: 'Configuration action', options: [ diff --git a/src/cli/utils/env-config.ts b/src/cli/utils/env-config.ts index bc9817e1f..2f713dd8e 100644 --- a/src/cli/utils/env-config.ts +++ b/src/cli/utils/env-config.ts @@ -42,6 +42,7 @@ const SUPPORTED_ENV_KEYS = new Set([ 'REDIS_USER', 'REDIS_PASSWORD', 'NOSTR_CONFIG_DIR', + 'SETTINGS_BACKEND', 'DEBUG', 'ZEBEDEE_API_KEY', 'NODELESS_API_KEY', @@ -68,6 +69,10 @@ const INTEGER_KEYS = new Set([ const BOOLEAN_KEYS = new Set(['READ_REPLICA_ENABLED']) +const ENUM_KEYS: Record> = { + SETTINGS_BACKEND: new Set(['file', 'db']), +} + const RR_INTEGER_KEY_REGEX = /^RR\d+_DB_(PORT|MIN_POOL_SIZE|MAX_POOL_SIZE|ACQUIRE_CONNECTION_TIMEOUT)$/ const SECRET_KEY_REGEX = /(SECRET|PASSWORD|API_KEY|PRIVATE_KEY)/i @@ -148,6 +153,11 @@ export const validateEnvPair = (key: string, value: string): string | undefined return validateBoolean(key, value) } + const allowedValues = ENUM_KEYS[key] + if (allowedValues && !allowedValues.has(value.trim())) { + return `${key} must be one of: ${[...allowedValues].join(', ')}` + } + return undefined } diff --git a/src/controllers/admin/patch-settings-controller.ts b/src/controllers/admin/patch-settings-controller.ts index 1829090f7..3abd8c58a 100644 --- a/src/controllers/admin/patch-settings-controller.ts +++ b/src/controllers/admin/patch-settings-controller.ts @@ -7,12 +7,11 @@ import { appendSettingsAuditLog, getByPath, loadMergedSettings, - loadUserSettings, setByPath, validatePathAgainstDefaults, validateSettings, } from '../../utils/settings-config' -import { saveUserSettingsOverrides } from '../../utils/settings-store' +import { updateUserSettingsOverrides } from '../../utils/settings-store' import { isWriteProtectedSettingsPath, redactSettingsValue, @@ -50,17 +49,12 @@ export class PatchAdminSettingsController implements IController { return } - const userSettings = loadUserSettings() as unknown as Record - const nextUserSettings = changes.reduce( - (settings, change) => setByPath(settings, change.path, change.value), - userSettings, - ) const merged = loadMergedSettings() as unknown as Record - const mergedNext = changes.reduce( - (settings, change) => setByPath(settings, change.path, getByPath(nextUserSettings, change.path)), + const mergedPreview = changes.reduce( + (settings, change) => setByPath(settings, change.path, change.value), merged, ) - const validationIssues = validateSettings(mergedNext as unknown as Settings) + const validationIssues = validateSettings(mergedPreview as unknown as Settings) if (validationIssues.length > 0) { response @@ -70,7 +64,14 @@ export class PatchAdminSettingsController implements IController { return } - await saveUserSettingsOverrides(nextUserSettings as unknown as Settings) + let nextUserSettings: Record = {} + await updateUserSettingsOverrides((currentUserSettings) => { + nextUserSettings = changes.reduce( + (settings, change) => setByPath(settings as Record, change.path, change.value), + currentUserSettings as unknown as Record, + ) + return nextUserSettings as unknown as Settings + }) const updatedChanges = changes.map(({ path }) => ({ path, value: redactSettingsValue(path, getByPath(nextUserSettings, path)), diff --git a/src/database/client.ts b/src/database/client.ts index 86cbdd7ff..f39f05f22 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -107,3 +107,17 @@ export const getReadReplicaDbClient = () => { return readClient } + +export const destroyMasterDbClient = async (): Promise => { + if (writeClient) { + await writeClient.destroy() + writeClient = undefined as unknown as Knex + } +} + +export const destroyReadReplicaDbClient = async (): Promise => { + if (readClient) { + await readClient.destroy() + readClient = undefined as unknown as Knex + } +} diff --git a/src/utils/admin-moderation-persist.ts b/src/utils/admin-moderation-persist.ts index ddfcc3dc5..e971fdcdb 100644 --- a/src/utils/admin-moderation-persist.ts +++ b/src/utils/admin-moderation-persist.ts @@ -26,7 +26,7 @@ export const saveModerationLimitsFromRequest = async ( auditDetail?: Record, ): Promise => { try { - persistAdminModerationLimits(limits) + await persistAdminModerationLimits(limits) } catch (error) { if (error instanceof ValidationError) { throw error diff --git a/src/utils/admin-moderation.ts b/src/utils/admin-moderation.ts index 9020cb8fd..c4d29a731 100644 --- a/src/utils/admin-moderation.ts +++ b/src/utils/admin-moderation.ts @@ -7,9 +7,9 @@ import { loadDefaults, loadMergedSettings, loadUserSettings, - saveSettings, validateSettings, } from './settings-config' +import { updateUserSettingsOverrides } from './settings-store' export interface AdminModerationLimits { pubkey: { @@ -119,7 +119,7 @@ const applyLimitsToUserSettings = (userSettings: Settings, limits: AdminModerati } } -export const persistAdminModerationLimits = (next: AdminModerationLimits): void => { +export const persistAdminModerationLimits = async (next: AdminModerationLimits): Promise => { const normalized: AdminModerationLimits = { pubkey: { blacklist: normalizePubkeyList(next.pubkey.blacklist, 'pubkey blacklist'), @@ -135,16 +135,17 @@ export const persistAdminModerationLimits = (next: AdminModerationLimits): void }, } - const userSettings = loadUserSettings() as Settings - const nextUserSettings = applyLimitsToUserSettings(userSettings, normalized) - const mergedNext = mergeDeepRight(loadDefaults(), nextUserSettings) as Settings + await updateUserSettingsOverrides((userSettings) => { + const nextUserSettings = applyLimitsToUserSettings(userSettings as Settings, normalized) + const mergedNext = mergeDeepRight(loadDefaults(), nextUserSettings) as Settings - const issues = validateSettings(mergedNext) - if (issues.length > 0) { - throw new ValidationError('Validation failed', issues) - } + const issues = validateSettings(mergedNext) + if (issues.length > 0) { + throw new ValidationError('Validation failed', issues) + } - saveSettings(nextUserSettings) + return nextUserSettings + }) } export class ValidationError extends Error { diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index 1fd7cb646..e1c758da8 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -82,6 +82,28 @@ export const formatSettingsPathKey = (key: string): string => { return `["${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]` } +const readQuotedBracketSegment = (input: string, start: number): number => { + if (input[start] !== '[' || input[start + 1] !== '"') { + throw new Error(`Invalid path segment in: ${input}`) + } + + let index = start + 2 + while (index < input.length) { + if (input[index] === '\\') { + index += 2 + continue + } + + if (input[index] === '"' && input[index + 1] === ']') { + return index + 2 + } + + index += 1 + } + + throw new Error(`Invalid path segment in: ${input}`) +} + const tokenizePathSegments = (input: string): string[] => { const segments: string[] = [] let index = 0 @@ -92,24 +114,54 @@ const tokenizePathSegments = (input: string): string[] => { continue } + const start = index + if (input[index] === '[') { - const closing = input.indexOf('"]', index + 1) - if (closing < 0) { - throw new Error(`Invalid path segment in: ${input}`) + if (input[index + 1] === '"') { + index = readQuotedBracketSegment(input, index) + } else { + const closing = input.indexOf(']', index) + if (closing < 0) { + throw new Error(`Invalid path segment in: ${input}`) + } + index = closing + 1 + } + + while (index < input.length && input[index] === '[') { + if (input[index + 1] === '"') { + index = readQuotedBracketSegment(input, index) + continue + } + + const closing = input.indexOf(']', index) + if (closing < 0) { + throw new Error(`Invalid path segment in: ${input}`) + } + index = closing + 1 } - segments.push(input.slice(index, closing + 2)) - index = closing + 2 + segments.push(input.slice(start, index)) continue } - let next = index - while (next < input.length && input[next] !== '.') { - next += 1 + while (index < input.length && input[index] !== '.' && input[index] !== '[') { + index += 1 } - segments.push(input.slice(index, next)) - index = next + while (index < input.length && input[index] === '[') { + if (input[index + 1] === '"') { + index = readQuotedBracketSegment(input, index) + continue + } + + const closing = input.indexOf(']', index) + if (closing < 0) { + throw new Error(`Invalid path segment in: ${input}`) + } + index = closing + 1 + } + + segments.push(input.slice(start, index)) } return segments.filter(Boolean) @@ -125,11 +177,47 @@ const parsePath = (path: string): PathToken[] => { const tokens: PathToken[] = [] for (const segment of tokenizePathSegments(input)) { + const indexOnly = segment.match(/^(\[\d+\])+$/) + if (indexOnly) { + for (const entry of segment.matchAll(/\[(\d+)\]/g)) { + tokens.push({ + type: 'index', + index: Number(entry[1]), + }) + } + continue + } + const quoted = segment.match(QUOTED_PATH_KEY) if (quoted) { const key = quoted[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') assertSafePathKey(key, segment) tokens.push({ type: 'key', key }) + + for (const entry of segment.matchAll(/\[(\d+)\]/g)) { + tokens.push({ + type: 'index', + index: Number(entry[1]), + }) + } + continue + } + + const identifierQuoted = segment.match(/^([A-Za-z_][A-Za-z0-9_]*)\["((?:\\.|[^"\\])*)"\](.*)$/) + if (identifierQuoted) { + assertSafePathKey(identifierQuoted[1], segment) + tokens.push({ type: 'key', key: identifierQuoted[1] }) + + const nestedKey = identifierQuoted[2].replace(/\\"/g, '"').replace(/\\\\/g, '\\') + assertSafePathKey(nestedKey, segment) + tokens.push({ type: 'key', key: nestedKey }) + + for (const entry of identifierQuoted[3].matchAll(/\[(\d+)\]/g)) { + tokens.push({ + type: 'index', + index: Number(entry[1]), + }) + } continue } @@ -161,7 +249,12 @@ const formatPathTokens = (tokens: PathToken[]): string => { for (const token of tokens) { if (token.type === 'key') { const formatted = formatSettingsPathKey(token.key) - out = out ? `${out}.${formatted}` : formatted + if (!out) { + out = formatted + continue + } + + out = formatted.startsWith('[') ? `${out}${formatted}` : `${out}.${formatted}` continue } diff --git a/src/utils/settings-store.ts b/src/utils/settings-store.ts index efb6ab846..c296ce899 100644 --- a/src/utils/settings-store.ts +++ b/src/utils/settings-store.ts @@ -1,9 +1,9 @@ import fs from 'fs' -import cluster from 'cluster' +import cluster, { Worker } from 'cluster' import { Settings } from '../@types/settings' -import { getMasterDbClient } from '../database/client' +import { destroyMasterDbClient, getMasterDbClient } from '../database/client' import { createLogger } from '../factories/logger-factory' import { SettingsOverrideRepository } from '../repositories/settings-override-repository' import { SettingsStatic } from './settings' @@ -16,14 +16,27 @@ import { getSettingsDbImportMarkerPath } from './settings-paths' const logger = createLogger('settings-store') -const SETTINGS_OVERRIDES_RELOAD_EVENT = 'settings-overrides-reload' +export const SETTINGS_OVERRIDES_RELOAD_EVENT = 'settings-overrides-reload' +export const SETTINGS_OVERRIDES_CLUSTER_SYNC_EVENT = 'settings-overrides-cluster-sync' let dbOverridesCache: Settings = {} as Settings let bootstrapComplete = false let reloadListenerRegistered = false +let writeChain: Promise = Promise.resolve() export type SettingsBackend = 'file' | 'db' +export type SettingsOverridesClusterSyncMessage = { + eventName: typeof SETTINGS_OVERRIDES_CLUSTER_SYNC_EVENT +} + +export const isSettingsOverridesClusterSyncMessage = ( + message: unknown, +): message is SettingsOverridesClusterSyncMessage => + typeof message === 'object' && + message !== null && + (message as SettingsOverridesClusterSyncMessage).eventName === SETTINGS_OVERRIDES_CLUSTER_SYNC_EVENT + export const getSettingsBackend = (): SettingsBackend => { return process.env.SETTINGS_BACKEND === 'db' ? 'db' : 'file' } @@ -70,15 +83,77 @@ const registerSettingsOverridesReloadListener = (): void => { } const broadcastSettingsOverridesReload = (): void => { - if (getSettingsBackend() !== 'db' || !cluster.isPrimary || !cluster.workers) { + if (getSettingsBackend() !== 'db') { return } - for (const worker of Object.values(cluster.workers)) { + if (cluster.isWorker && typeof process.send === 'function') { + process.send({ eventName: SETTINGS_OVERRIDES_CLUSTER_SYNC_EVENT }) + return + } + + if (cluster.isPrimary && cluster.workers) { + for (const worker of Object.values(cluster.workers)) { + worker?.send({ eventName: SETTINGS_OVERRIDES_RELOAD_EVENT }) + } + } +} + +export const handleSettingsOverridesClusterSyncFromWorker = async (source: Worker): Promise => { + if (getSettingsBackend() !== 'db' || !cluster.isPrimary) { + return + } + + await reloadDbOverridesFromDatabase() + + for (const worker of Object.values(cluster.workers ?? {})) { + if (worker?.id === source.id) { + continue + } + worker?.send({ eventName: SETTINGS_OVERRIDES_RELOAD_EVENT }) } } +const withSettingsOverridesWriteLock = async (operation: () => Promise): Promise => { + let release: () => void = () => undefined + const gate = new Promise((resolve) => { + release = resolve + }) + + const previous = writeChain + writeChain = previous.then(() => gate) + await previous + + try { + return await operation() + } finally { + release() + } +} + +const commitUserSettingsOverrides = async (settings: Settings): Promise => { + if (getSettingsBackend() === 'db') { + if (!bootstrapComplete) { + throw new Error('settings store has not been bootstrapped') + } + + const repository = new SettingsOverrideRepository(getMasterDbClient()) + const previous = await repository.loadOverrides() + if (!isEmptyOverrides(previous)) { + backupUserSettingsOverrides(previous) + } + + await repository.replaceOverrides(settings) + dbOverridesCache = structuredClone(settings) + SettingsStatic.invalidateCache() + broadcastSettingsOverridesReload() + return + } + + saveSettings(settings) +} + export const bootstrapSettingsStore = async (): Promise => { registerSettingsOverridesReloadListener() @@ -105,24 +180,37 @@ export const bootstrapSettingsStore = async (): Promise => { SettingsStatic.invalidateCache() } -export const saveUserSettingsOverrides = async (settings: Settings): Promise => { - if (getSettingsBackend() === 'db') { - if (!bootstrapComplete) { - throw new Error('settings store has not been bootstrapped') - } +export const updateUserSettingsOverrides = async ( + updater: (current: Settings) => Settings | Promise, +): Promise => { + await withSettingsOverridesWriteLock(async () => { + if (getSettingsBackend() === 'db') { + if (!bootstrapComplete) { + throw new Error('settings store has not been bootstrapped') + } - const repository = new SettingsOverrideRepository(getMasterDbClient()) - const previous = await repository.loadOverrides() - if (!isEmptyOverrides(previous)) { - backupUserSettingsOverrides(previous) + await reloadDbOverridesFromDatabase() } - await repository.replaceOverrides(settings) - dbOverridesCache = structuredClone(settings) - SettingsStatic.invalidateCache() - broadcastSettingsOverridesReload() - return + const current = + getSettingsBackend() === 'db' + ? structuredClone(dbOverridesCache) + : (loadUserSettingsFromFiles() as Settings) + const next = await updater(current) + await commitUserSettingsOverrides(next) + }) +} + +export const saveUserSettingsOverrides = async (settings: Settings): Promise => { + await updateUserSettingsOverrides(() => settings) +} + +export const shutdownSettingsStore = async (): Promise => { + if (getSettingsBackend() === 'db') { + await destroyMasterDbClient() } - saveSettings(settings) + bootstrapComplete = false + dbOverridesCache = {} as Settings + writeChain = Promise.resolve() } diff --git a/test/unit/cli/commands.spec.ts b/test/unit/cli/commands.spec.ts index adc4dfaf7..bf552aeb7 100644 --- a/test/unit/cli/commands.spec.ts +++ b/test/unit/cli/commands.spec.ts @@ -14,5 +14,8 @@ describe('cli env config helpers', () => { expect(validateEnvPair('RELAY_PORT', 'bad')).to.include('must be an integer') expect(validateEnvPair('READ_REPLICA_ENABLED', 'true')).to.equal(undefined) expect(validateEnvPair('READ_REPLICA_ENABLED', 'yes')).to.include('must be true or false') + expect(isSupportedEnvKey('SETTINGS_BACKEND')).to.equal(true) + expect(validateEnvPair('SETTINGS_BACKEND', 'db')).to.equal(undefined) + expect(validateEnvPair('SETTINGS_BACKEND', 'files')).to.include('must be one of') }) }) diff --git a/test/unit/repositories/settings-override-repository.spec.ts b/test/unit/repositories/settings-override-repository.spec.ts index dddad30e7..6f96fde3b 100644 --- a/test/unit/repositories/settings-override-repository.spec.ts +++ b/test/unit/repositories/settings-override-repository.spec.ts @@ -93,4 +93,24 @@ describe('SettingsOverrideRepository', () => { expect(transactionBuilder.delete).to.have.been.calledOnce expect(transactionBuilder.insert).not.to.have.been.called }) + + it('loads overrides whose paths contain dotted notification keys', async () => { + queryBuilder.select.resolves([ + { path: 'admin.notifications.events["settings.changed"]', value: true }, + { path: 'admin.notifications.events["mirror.disconnected"]', value: false }, + ]) + + const overrides = await repository.loadOverrides() + + expect(overrides).to.deep.equal({ + admin: { + notifications: { + events: { + 'settings.changed': true, + 'mirror.disconnected': false, + }, + }, + }, + }) + }) }) diff --git a/test/unit/utils/admin-moderation.spec.ts b/test/unit/utils/admin-moderation.spec.ts index ee202b0f9..4aa984408 100644 --- a/test/unit/utils/admin-moderation.spec.ts +++ b/test/unit/utils/admin-moderation.spec.ts @@ -105,9 +105,9 @@ describe('admin-moderation', () => { expect(withoutIp.rateLimitBypass.ipAddresses).to.not.include('203.0.113.10') }) - it('persists normalized moderation limits to settings', () => { + it('persists normalized moderation limits to settings', async () => { const pubkey = 'c'.repeat(64) - persistAdminModerationLimits({ + await persistAdminModerationLimits({ ...baseLimits, pubkey: { blacklist: [pubkey], whitelist: [] }, }) @@ -116,16 +116,14 @@ describe('admin-moderation', () => { expect(limits.pubkey.blacklist).to.include(pubkey) }) - it('persists when user settings only override part of info', () => { + it('persists when user settings only override part of info', async () => { settingsConfig.saveSettings({ info: { relay_url: 'wss://relay.example' } } as any) const pubkey = 'f'.repeat(64) - expect(() => - persistAdminModerationLimits({ - ...baseLimits, - pubkey: { blacklist: [pubkey], whitelist: [] }, - }), - ).to.not.throw() + await persistAdminModerationLimits({ + ...baseLimits, + pubkey: { blacklist: [pubkey], whitelist: [] }, + }) expect(readAdminModerationLimits().pubkey.blacklist).to.include(pubkey) }) diff --git a/test/unit/utils/settings-flatten.spec.ts b/test/unit/utils/settings-flatten.spec.ts index 88435deab..229ee7565 100644 --- a/test/unit/utils/settings-flatten.spec.ts +++ b/test/unit/utils/settings-flatten.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai' -import { flattenSettingsToPaths } from '../../../src/utils/settings-config' +import { flattenSettingsToPaths, getByPath, setByPath } from '../../../src/utils/settings-config' describe('flattenSettingsToPaths', () => { it('flattens nested objects into dot paths', () => { @@ -52,4 +52,24 @@ describe('flattenSettingsToPaths', () => { }), ).to.deep.equal([{ path: 'nip66.targets[0]', value: 'wss://relay.example.com' }]) }) + + it('round-trips dotted notification keys through setByPath', () => { + const source = { + admin: { + notifications: { + events: { 'settings.changed': true, 'mirror.disconnected': false }, + targets: [{ id: 'ops', type: 'http', url: 'https://example.com/hook' }], + }, + }, + } + + let rebuilt: Record = {} + for (const entry of flattenSettingsToPaths(source)) { + rebuilt = setByPath(rebuilt, entry.path, entry.value) + } + + expect(getByPath(rebuilt, 'admin.notifications.events["settings.changed"]')).to.equal(true) + expect(getByPath(rebuilt, 'admin.notifications.targets[0].id')).to.equal('ops') + expect(rebuilt).to.deep.equal(source) + }) }) diff --git a/test/unit/utils/settings-store.spec.ts b/test/unit/utils/settings-store.spec.ts index aedd91f5b..bd03ef51f 100644 --- a/test/unit/utils/settings-store.spec.ts +++ b/test/unit/utils/settings-store.spec.ts @@ -1,6 +1,9 @@ import { expect } from 'chai' +import cluster, { Worker } from 'cluster' +import Sinon from 'sinon' -import { getSettingsBackend } from '../../../src/utils/settings-store' +import * as settingsConfig from '../../../src/utils/settings-config' +import * as settingsStore from '../../../src/utils/settings-store' describe('settings-store backend selection', () => { const originalBackend = process.env.SETTINGS_BACKEND @@ -16,12 +19,99 @@ describe('settings-store backend selection', () => { it('defaults to file backend', () => { delete process.env.SETTINGS_BACKEND - expect(getSettingsBackend()).to.equal('file') + expect(settingsStore.getSettingsBackend()).to.equal('file') }) it('selects db backend when SETTINGS_BACKEND=db', () => { process.env.SETTINGS_BACKEND = 'db' - expect(getSettingsBackend()).to.equal('db') + expect(settingsStore.getSettingsBackend()).to.equal('db') + }) +}) + +describe('settings-store cluster sync messages', () => { + it('recognizes cluster sync messages from workers', () => { + expect( + settingsStore.isSettingsOverridesClusterSyncMessage({ + eventName: settingsStore.SETTINGS_OVERRIDES_CLUSTER_SYNC_EVENT, + }), + ).to.equal(true) + expect(settingsStore.isSettingsOverridesClusterSyncMessage({ eventName: 'other' })).to.equal(false) + }) +}) + +describe('settings-store updateUserSettingsOverrides', () => { + const originalBackend = process.env.SETTINGS_BACKEND + + afterEach(async () => { + Sinon.restore() + await settingsStore.shutdownSettingsStore() + + if (originalBackend === undefined) { + delete process.env.SETTINGS_BACKEND + } else { + process.env.SETTINGS_BACKEND = originalBackend + } + }) + + it('serializes overlapping file-backend updates', async () => { + delete process.env.SETTINGS_BACKEND + const saveSettings = Sinon.stub(settingsConfig, 'saveSettings') + + await Promise.all([ + settingsStore.updateUserSettingsOverrides((current) => + ({ + ...current, + info: { relay_url: 'wss://a.example', name: 'A' }, + }) as any, + ), + settingsStore.updateUserSettingsOverrides((current) => + ({ + ...current, + info: { relay_url: 'wss://b.example', name: 'B' }, + }) as any, + ), + ]) + + expect(saveSettings.callCount).to.equal(2) + const secondSaved = saveSettings.secondCall.args[0] as { info?: { name?: string } } + expect(secondSaved.info?.name).to.equal('B') + }) +}) + +describe('handleSettingsOverridesClusterSyncFromWorker', () => { + const originalBackend = process.env.SETTINGS_BACKEND + + afterEach(() => { + Sinon.restore() + if (originalBackend === undefined) { + delete process.env.SETTINGS_BACKEND + } else { + process.env.SETTINGS_BACKEND = originalBackend + } + }) + + it('reloads on the primary and notifies other workers', async () => { + process.env.SETTINGS_BACKEND = 'db' + Sinon.stub(cluster, 'isPrimary').value(true) + + const reload = Sinon.stub(settingsStore, 'reloadDbOverridesFromDatabase').resolves() + const source = { id: 1 } as Worker + const notifiedWorker = { id: 2, send: Sinon.stub() } + const sourceWorker = { id: 1, send: Sinon.stub() } + Sinon.stub(cluster, 'workers').value({ + 1: sourceWorker, + 2: notifiedWorker, + }) + + await settingsStore.handleSettingsOverridesClusterSyncFromWorker(source) + + expect(reload.calledOnce).to.equal(true) + expect( + notifiedWorker.send.calledOnceWith({ + eventName: settingsStore.SETTINGS_OVERRIDES_RELOAD_EVENT, + }), + ).to.equal(true) + expect(sourceWorker.send.called).to.equal(false) }) })