Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/settings-db-overrides.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat: store relay settings overrides in PostgreSQL when SETTINGS_BACKEND=db
1 change: 1 addition & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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 | <project_root>/.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 | |
Expand Down
11 changes: 11 additions & 0 deletions migrations/20260905_120000_create_settings_overrides_table.js
Original file line number Diff line number Diff line change
@@ -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')
}
9 changes: 7 additions & 2 deletions src/cli/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
loadMergedSettings,
loadUserSettings,
parseTypedValue,
saveSettings,
setByPath,
toCategoryLabel,
validatePathAgainstDefaults,
Expand All @@ -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'
Expand Down Expand Up @@ -77,6 +77,7 @@ const restartRelay = async (): Promise<number> => {
}

export const runConfigList = async (options: { json?: boolean } = {}): Promise<number> => {
await bootstrapSettingsStore()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Config commands stay open

With SETTINGS_BACKEND=db, the config commands now open the shared database pool but never close it. Returning from the command does not exit the CLI because the database connections remain open. With the default settings, commands wait for the 60-second idle timeout after printing their result. If DB_MIN_POOL_SIZE is above zero, they can remain open indefinitely.

Close the database client after each one-shot command, including error paths.

const settings = loadMergedSettings()

if (options.json) {
Expand All @@ -89,6 +90,7 @@ export const runConfigList = async (options: { json?: boolean } = {}): Promise<n
}

export const runConfigGet = async (path: string, options: { json?: boolean } = {}): Promise<number> => {
await bootstrapSettingsStore()
const settings = loadMergedSettings() as unknown as Record<string, unknown>
const value = getByPath(settings, path)

Expand Down Expand Up @@ -122,6 +124,8 @@ export const runConfigSet = async (
): Promise<number> => {
const valueType = options.valueType ?? 'inferred'

await bootstrapSettingsStore()

const pathIssues = validatePathAgainstDefaults(path)
if (pathIssues.length > 0) {
logError(pathIssues[0].message)
Expand All @@ -146,7 +150,7 @@ export const runConfigSet = async (
}
}

saveSettings(next as any)
await saveUserSettingsOverrides(next as any)

logInfo(`Updated ${path}`)

Expand All @@ -158,6 +162,7 @@ export const runConfigSet = async (
}

export const runConfigValidate = async (): Promise<number> => {
await bootstrapSettingsStore()
const settings = loadMergedSettings()
const issues = validateSettings(settings)

Expand Down
4 changes: 2 additions & 2 deletions src/controllers/admin/patch-notifications-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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' }],
Expand Down
4 changes: 2 additions & 2 deletions src/controllers/admin/patch-settings-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -70,7 +70,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)),
Expand Down
2 changes: 1 addition & 1 deletion src/controllers/admin/post-settings-restore-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class PostAdminSettingsRestoreController implements IController {
}

try {
restoreSettingsBackup(validation.value.filename)
await restoreSettingsBackup(validation.value.filename)
appendSettingsAuditLog({
action: 'settings.restored',
filename: validation.value.filename,
Expand Down
13 changes: 12 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
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 { 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) {
Expand All @@ -29,5 +33,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)
})
}
44 changes: 44 additions & 0 deletions src/repositories/settings-override-repository.ts
Original file line number Diff line number Diff line change
@@ -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<Settings> {
const rows = await this.dbClient('settings_overrides').select('path', 'value')

let settings: Record<string, unknown> = {}
for (const row of rows) {
settings = setByPath(settings, row.path, row.value)
}

return settings as unknown as Settings
}

public async replaceOverrides(overrides: Settings): Promise<void> {
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: JSON.stringify(entry.value),
updated_at: now,
})),
)
})

logger('replaced %d settings override paths', entries.length)
}
}
Loading
Loading