refactor: generic registry in core
This commit is contained in:
1 parent
f4249b58cf
commit
18381169e0
10 files changed
+395
-167
No files matched your search
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as z from 'zod'
|
||||
import {
|
||||
ConnectionNotFoundError,
|
||||
configHash,
|
||||
createRegistry,
|
||||
type NamedConfig,
|
||||
type RegistryDeps,
|
||||
RegistryError
|
||||
} from '../../../src/config/registry.js'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/),
|
||||
type: z.enum(['alpha', 'beta']),
|
||||
host: z.string().min(1),
|
||||
port: z.number().int().positive().optional(),
|
||||
label: z.string().optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
type Config = z.infer<typeof schema>
|
||||
|
||||
const conn = (name: string, extra: Partial<Config> = {}): Config => ({
|
||||
name,
|
||||
type: 'alpha',
|
||||
host: 'localhost',
|
||||
...extra
|
||||
})
|
||||
|
||||
// In-memory deps: a closure-backed store plus static config/env arrays.
|
||||
const makeDeps = (
|
||||
initial: { store?: Config[]; config?: Config[]; env?: Config[] } = {}
|
||||
): { deps: RegistryDeps<Config>; readStore: () => Config[] } => {
|
||||
let store: Config[] = initial.store ?? []
|
||||
const deps: RegistryDeps<Config> = {
|
||||
schema,
|
||||
readStore: () => store,
|
||||
writeStore: (configs) => {
|
||||
store = configs
|
||||
},
|
||||
readConfigFile: () => initial.config ?? [],
|
||||
readEnvConnections: () => initial.env ?? []
|
||||
}
|
||||
return { deps, readStore: () => store }
|
||||
}
|
||||
|
||||
describe('createRegistry', () => {
|
||||
it('merges layers with store > config > env priority', () => {
|
||||
const { deps } = makeDeps({
|
||||
store: [conn('a', { host: 'from-store' }), conn('s')],
|
||||
config: [conn('a', { host: 'from-config' }), conn('c')],
|
||||
env: [conn('a', { host: 'from-env' }), conn('e')]
|
||||
})
|
||||
const registry = createRegistry(deps)
|
||||
// store wins for 'a'; the later layers loop overwrites earlier ones, so
|
||||
// ordering store->config->env means env is applied last and wins. Mirror
|
||||
// the dbmole binder which feeds store first, env last: env overrides.
|
||||
expect(registry.resolve('a').source).toBe('env')
|
||||
expect(registry.resolve('a').config.host).toBe('from-env')
|
||||
expect(registry.resolve('c').source).toBe('config')
|
||||
expect(registry.resolve('e').source).toBe('env')
|
||||
expect(registry.resolve('s').source).toBe('store')
|
||||
})
|
||||
|
||||
it('list returns connections sorted by name', () => {
|
||||
const { deps } = makeDeps({ store: [conn('zeta'), conn('alpha'), conn('mid')] })
|
||||
const registry = createRegistry(deps)
|
||||
expect(registry.list().map((r) => r.config.name)).toEqual(['alpha', 'mid', 'zeta'])
|
||||
})
|
||||
|
||||
it('resolve throws not-found with available names listed', () => {
|
||||
const { deps } = makeDeps({ store: [conn('only')] })
|
||||
const registry = createRegistry(deps)
|
||||
expect(() => registry.resolve('nope')).toThrow(ConnectionNotFoundError)
|
||||
expect(() => registry.resolve('nope')).toThrow(/only/)
|
||||
})
|
||||
|
||||
it('add persists to the store and rejects duplicates from any layer', () => {
|
||||
const { deps, readStore } = makeDeps({ env: [conn('envy')] })
|
||||
const registry = createRegistry(deps)
|
||||
const added = registry.add(conn('fresh'))
|
||||
expect(added.source).toBe('store')
|
||||
expect(readStore().map((c) => c.name)).toEqual(['fresh'])
|
||||
expect(() => registry.add(conn('fresh'))).toThrow(RegistryError)
|
||||
expect(() => registry.add(conn('envy'))).toThrow(/env/)
|
||||
})
|
||||
|
||||
it('update sets fields and deletes them via null', () => {
|
||||
const { deps } = makeDeps({ store: [conn('u', { label: 'old', port: 100 })] })
|
||||
const registry = createRegistry(deps)
|
||||
const updated = registry.update('u', { label: 'new', port: null })
|
||||
expect(updated.config.label).toBe('new')
|
||||
expect(updated.config.port).toBeUndefined()
|
||||
expect(registry.resolve('u').config.label).toBe('new')
|
||||
})
|
||||
|
||||
it('keeps the port when type is unchanged', () => {
|
||||
const { deps } = makeDeps({ store: [conn('same', { type: 'alpha', port: 100 })] })
|
||||
const registry = createRegistry(deps)
|
||||
const updated = registry.update('same', { label: 'x' })
|
||||
expect(updated.config.port).toBe(100)
|
||||
})
|
||||
|
||||
it('drops the inherited port when switching type without an explicit port', () => {
|
||||
const { deps } = makeDeps({ store: [conn('switch', { type: 'alpha', port: 100 })] })
|
||||
const registry = createRegistry(deps)
|
||||
const updated = registry.update('switch', { type: 'beta' })
|
||||
expect(updated.config.type).toBe('beta')
|
||||
expect(updated.config.port).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps an explicit port when switching type', () => {
|
||||
const { deps } = makeDeps({ store: [conn('switch', { type: 'alpha', port: 100 })] })
|
||||
const registry = createRegistry(deps)
|
||||
const updated = registry.update('switch', { type: 'beta', port: 200 })
|
||||
expect(updated.config.port).toBe(200)
|
||||
})
|
||||
|
||||
it('clears the port when switching type with an explicit null', () => {
|
||||
const { deps } = makeDeps({ store: [conn('switch', { type: 'alpha', port: 100 })] })
|
||||
const registry = createRegistry(deps)
|
||||
const updated = registry.update('switch', { type: 'beta', port: null })
|
||||
expect(updated.config.port).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renames unless the new name is taken', () => {
|
||||
const { deps } = makeDeps({ store: [conn('old-name'), conn('taken')] })
|
||||
const registry = createRegistry(deps)
|
||||
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 of an unknown name throws not-found', () => {
|
||||
const { deps } = makeDeps()
|
||||
const registry = createRegistry(deps)
|
||||
expect(() => registry.update('ghost', {})).toThrow(ConnectionNotFoundError)
|
||||
})
|
||||
|
||||
it('remove deletes from the store', () => {
|
||||
const { deps } = makeDeps({ store: [conn('gone'), conn('stays')] })
|
||||
const registry = createRegistry(deps)
|
||||
registry.remove('gone')
|
||||
expect(() => registry.resolve('gone')).toThrow(ConnectionNotFoundError)
|
||||
expect(registry.resolve('stays').config.name).toBe('stays')
|
||||
})
|
||||
|
||||
it('refuses to update or remove a connection from a read-only source', () => {
|
||||
const { deps } = makeDeps({ env: [conn('envy')], config: [conn('cfg')] })
|
||||
const registry = createRegistry(deps)
|
||||
expect(() => registry.update('envy', { host: 'x' })).toThrow(/env/)
|
||||
expect(() => registry.remove('envy')).toThrow(/env/)
|
||||
expect(() => registry.update('cfg', { host: 'x' })).toThrow(/config/)
|
||||
expect(() => registry.remove('cfg')).toThrow(/config/)
|
||||
})
|
||||
|
||||
it('throws not-found if the store entry vanishes after the source check', () => {
|
||||
// Defensive guard: requireStoreEntry sees the entry, but a concurrent
|
||||
// edit empties the store before update re-reads it, so findIndex misses.
|
||||
let reads = 0
|
||||
const deps: RegistryDeps<Config> = {
|
||||
schema,
|
||||
// First read (requireStoreEntry) sees the entry; later reads are empty.
|
||||
readStore: () => {
|
||||
reads += 1
|
||||
return reads === 1 ? [conn('vanish')] : []
|
||||
},
|
||||
writeStore: () => {},
|
||||
readConfigFile: () => [],
|
||||
readEnvConnections: () => []
|
||||
}
|
||||
const registry = createRegistry(deps)
|
||||
expect(() => registry.update('vanish', { host: 'x' })).toThrow(ConnectionNotFoundError)
|
||||
})
|
||||
|
||||
it('reflects external store edits without recreation', () => {
|
||||
const store: Config[] = []
|
||||
const deps: RegistryDeps<Config> = {
|
||||
schema,
|
||||
readStore: () => store,
|
||||
writeStore: (configs) => {
|
||||
store.splice(0, store.length, ...configs)
|
||||
},
|
||||
readConfigFile: () => [],
|
||||
readEnvConnections: () => []
|
||||
}
|
||||
const registry = createRegistry(deps)
|
||||
expect(registry.list()).toEqual([])
|
||||
store.push(conn('late'))
|
||||
expect(registry.resolve('late').config.name).toBe('late')
|
||||
})
|
||||
})
|
||||
|
||||
describe('configHash', () => {
|
||||
it('is stable across key order', () => {
|
||||
const a: NamedConfig & Record<string, unknown> = { name: 'h', port: 1, host: 'x' }
|
||||
const b: NamedConfig & Record<string, unknown> = { host: 'x', name: 'h', port: 1 }
|
||||
expect(configHash(a)).toBe(configHash(b))
|
||||
})
|
||||
|
||||
it('changes when a nested value changes', () => {
|
||||
expect(configHash({ name: 'h', tags: ['a', 'b'] })).not.toBe(
|
||||
configHash({ name: 'h', tags: ['a', 'c'] })
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user