feat: add registry with cascade merge and CRUD
This commit is contained in:
@@ -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(', ') : '<none>'
|
||||
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<string, unknown>)
|
||||
.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<string, unknown>) => ResolvedConnection
|
||||
remove: (name: string) => void
|
||||
}
|
||||
|
||||
export const createRegistry = (options: RegistryOptions): Registry => {
|
||||
const env = options.env ?? process.env
|
||||
|
||||
const snapshot = (): Map<string, ResolvedConnection> => {
|
||||
const layers: [ConnectionSource, ConnectionConfig[]][] = [
|
||||
['store', readStore(options.storePath)],
|
||||
['config', readConfigFile(options.configPath)],
|
||||
['env', readEnvConnections(env)]
|
||||
]
|
||||
const merged = new Map<string, ResolvedConnection>()
|
||||
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<string, unknown>): 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<string, unknown> = { ...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 }
|
||||
}
|
||||
Reference in New Issue
Block a user