diff --git a/src/config/registry.ts b/src/config/registry.ts new file mode 100644 index 0000000..c398587 --- /dev/null +++ b/src/config/registry.ts @@ -0,0 +1,154 @@ +import { createHash } from 'node:crypto' +import { readConfigFile, readEnvConnections } from './sources.js' +import { readStore, writeStore } from './store.js' +import { + type ConnectionConfig, + type ConnectionSource, + connectionConfigSchema, + type ResolvedConnection +} from './types.js' + +export class ConnectionNotFoundError extends Error { + constructor(name: string, available: string[]) { + const list = available.length > 0 ? available.join(', ') : '' + super("connection '" + name + "' not found. Available connections: " + list) + this.name = 'ConnectionNotFoundError' + } +} + +export class RegistryError extends Error { + constructor(message: string) { + super(message) + this.name = 'RegistryError' + } +} + +const sortValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(sortValue) + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, inner]) => [key, sortValue(inner)]) + ) + } + return value +} + +export const configHash = (config: ConnectionConfig): string => + createHash('sha256') + .update(JSON.stringify(sortValue(config))) + .digest('hex') + +export type RegistryOptions = { + storePath: string + configPath?: string + env?: NodeJS.ProcessEnv +} + +export type Registry = { + list: () => ResolvedConnection[] + resolve: (name: string) => ResolvedConnection + add: (config: ConnectionConfig) => ResolvedConnection + update: (name: string, patch: Record) => ResolvedConnection + remove: (name: string) => void +} + +export const createRegistry = (options: RegistryOptions): Registry => { + const env = options.env ?? process.env + + const snapshot = (): Map => { + const layers: [ConnectionSource, ConnectionConfig[]][] = [ + ['store', readStore(options.storePath)], + ['config', readConfigFile(options.configPath)], + ['env', readEnvConnections(env)] + ] + const merged = new Map() + for (const [source, configs] of layers) { + for (const config of configs) { + merged.set(config.name, { config, source, hash: configHash(config) }) + } + } + return merged + } + + const list = (): ResolvedConnection[] => + [...snapshot().values()].sort((a, b) => a.config.name.localeCompare(b.config.name)) + + const resolve = (name: string): ResolvedConnection => { + const current = snapshot() + const found = current.get(name) + if (!found) { + throw new ConnectionNotFoundError(name, [...current.keys()].sort()) + } + return found + } + + const requireStoreEntry = (name: string): ResolvedConnection => { + const current = snapshot() + const found = current.get(name) + if (!found) { + throw new ConnectionNotFoundError(name, [...current.keys()].sort()) + } + if (found.source !== 'store') { + throw new RegistryError( + "connection '" + + name + + "' comes from " + + found.source + + ' and is read-only at runtime; change it at its source' + ) + } + return found + } + + const add = (config: ConnectionConfig): ResolvedConnection => { + const existing = snapshot().get(config.name) + if (existing) { + throw new RegistryError( + "connection '" + config.name + "' already exists (source: " + existing.source + ')' + ) + } + const store = readStore(options.storePath) + writeStore(options.storePath, [...store, config]) + return { config, source: 'store', hash: configHash(config) } + } + + const update = (name: string, patch: Record): ResolvedConnection => { + requireStoreEntry(name) + const store = readStore(options.storePath) + const index = store.findIndex((c) => c.name === name) + if (index === -1) { + throw new ConnectionNotFoundError(name, store.map((c) => c.name).sort()) + } + const base: Record = { ...store[index] } + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete base[key] + } else if (value !== undefined) { + base[key] = value + } + } + const next = connectionConfigSchema.parse(base) + if (next.name !== name && snapshot().has(next.name)) { + throw new RegistryError("cannot rename to '" + next.name + "': name already taken") + } + const updated = [...store] + updated[index] = next + writeStore(options.storePath, updated) + return { config: next, source: 'store', hash: configHash(next) } + } + + const remove = (name: string): void => { + requireStoreEntry(name) + const store = readStore(options.storePath) + writeStore( + options.storePath, + store.filter((c) => c.name !== name) + ) + } + + return { list, resolve, add, update, remove } +} diff --git a/test/unit/config/registry.test.ts b/test/unit/config/registry.test.ts new file mode 100644 index 0000000..0cf5dbf --- /dev/null +++ b/test/unit/config/registry.test.ts @@ -0,0 +1,133 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ConnectionNotFoundError, + configHash, + createRegistry, + RegistryError +} from '../../../src/config/registry.js' +import { writeStore } from '../../../src/config/store.js' +import type { ConnectionConfig } from '../../../src/config/types.js' + +const conn = (name: string, extra: Partial = {}): ConnectionConfig => ({ + name, + type: 'postgres', + host: 'localhost', + user: 'postgres', + readonly: false, + ...extra +}) + +describe('registry', () => { + let dir: string + let storePath: string + let configPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'dbmole-registry-')) + storePath = join(dir, 'connections.json') + configPath = join(dir, 'config.json') + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('merges layers with env > config > store priority', () => { + writeStore(storePath, [conn('a', { host: 'from-store' }), conn('s')]) + writeFileSync( + configPath, + JSON.stringify({ connections: [conn('a', { host: 'from-config' }), conn('c')] }) + ) + const env = { DBMOLE_CONNECTIONS: JSON.stringify([conn('a', { host: 'from-env' })]) } + const registry = createRegistry({ storePath, configPath, env }) + + const list = registry.list() + expect(list.map((r) => r.config.name)).toEqual(['a', 'c', 's']) + expect(registry.resolve('a').config.host).toBe('from-env') + expect(registry.resolve('a').source).toBe('env') + expect(registry.resolve('c').source).toBe('config') + expect(registry.resolve('s').source).toBe('store') + }) + + it('resolve throws with available names listed', () => { + writeStore(storePath, [conn('only')]) + const registry = createRegistry({ storePath, env: {} }) + expect(() => registry.resolve('nope')).toThrow(ConnectionNotFoundError) + expect(() => registry.resolve('nope')).toThrow(/only/) + }) + + it('sees external store edits without recreation (hot reload)', () => { + const registry = createRegistry({ storePath, env: {} }) + expect(registry.list()).toEqual([]) + writeStore(storePath, [conn('late')]) + expect(registry.resolve('late').config.name).toBe('late') + }) + + it('add writes to store and rejects duplicates from any layer', () => { + const env = { DBMOLE_CONNECTIONS: JSON.stringify([conn('envy')]) } + const registry = createRegistry({ storePath, env }) + const added = registry.add(conn('fresh')) + expect(added.source).toBe('store') + expect(registry.resolve('fresh').source).toBe('store') + expect(() => registry.add(conn('fresh'))).toThrow(RegistryError) + expect(() => registry.add(conn('envy'))).toThrow(/env/) + }) + + it('update patches fields, removes them via null, and revalidates', () => { + writeStore(storePath, [conn('u', { database: 'old', port: 5433 })]) + const registry = createRegistry({ storePath, env: {} }) + const updated = registry.update('u', { database: 'new', port: null, readonly: true }) + expect(updated.config.database).toBe('new') + expect(updated.config.port).toBeUndefined() + expect(updated.config.readonly).toBe(true) + expect(registry.resolve('u').config.database).toBe('new') + expect(() => registry.update('u', { host: '' })).toThrow() + }) + + it('update can rename unless the new name is taken', () => { + writeStore(storePath, [conn('old-name'), conn('taken')]) + const registry = createRegistry({ storePath, env: {} }) + registry.update('old-name', { name: 'new-name' }) + expect(registry.resolve('new-name').config.name).toBe('new-name') + expect(() => registry.resolve('old-name')).toThrow(ConnectionNotFoundError) + expect(() => registry.update('new-name', { name: 'taken' })).toThrow(RegistryError) + }) + + it('update and remove refuse non-store connections', () => { + const env = { DBMOLE_CONNECTIONS: JSON.stringify([conn('envy')]) } + const registry = createRegistry({ storePath, env }) + expect(() => registry.update('envy', { host: 'x' })).toThrow(/env/) + expect(() => registry.remove('envy')).toThrow(/env/) + }) + + it('remove deletes from store', () => { + writeStore(storePath, [conn('gone'), conn('stays')]) + const registry = createRegistry({ storePath, env: {} }) + registry.remove('gone') + expect(() => registry.resolve('gone')).toThrow(ConnectionNotFoundError) + expect(registry.resolve('stays').config.name).toBe('stays') + }) + + it('update and remove of unknown names throw not-found', () => { + const registry = createRegistry({ storePath, env: {} }) + expect(() => registry.update('ghost', {})).toThrow(ConnectionNotFoundError) + expect(() => registry.remove('ghost')).toThrow(ConnectionNotFoundError) + }) +}) + +describe('configHash', () => { + it('is stable across key order', () => { + const a = conn('h', { port: 1, database: 'd' }) + const b = { database: 'd', port: 1, ...conn('h') } as ConnectionConfig + expect(configHash(a)).toBe(configHash(b)) + }) + + it('changes when a value changes', () => { + expect(configHash(conn('h'))).not.toBe(configHash(conn('h', { port: 9 }))) + }) +})