feat: add registry with cascade merge and CRUD

This commit is contained in:
smartass
2026-06-11 23:29:26 +05:00
parent 51aa6e3258
commit 52c446519a
2 changed files with 287 additions and 0 deletions
+154
View File
@@ -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 }
}