From 87b3f118ed7f32595853615b93d741d24652be36 Mon Sep 17 00:00:00 2001 From: smartass Date: Thu, 11 Jun 2026 23:15:05 +0500 Subject: [PATCH] feat: add persistent store, atomic 0600 writes --- src/config/store.ts | 50 +++++++++++++++++++ test/unit/config/store.test.ts | 91 ++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/config/store.ts create mode 100644 test/unit/config/store.test.ts diff --git a/src/config/store.ts b/src/config/store.ts new file mode 100644 index 0000000..b63b3ca --- /dev/null +++ b/src/config/store.ts @@ -0,0 +1,50 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { type ConnectionConfig, connectionConfigSchema } from './types.js' + +export const defaultStorePath = (env: NodeJS.ProcessEnv = process.env): string => + env.DBMOLE_STORE ?? join(homedir(), '.config', 'dbmole', 'connections.json') + +export const parseConnections = (items: unknown[], origin: string): ConnectionConfig[] => { + const result: ConnectionConfig[] = [] + for (const item of items) { + const check = connectionConfigSchema.safeParse(item) + if (check.success) { + result.push(check.data) + } else { + const name = (item as { name?: unknown })?.name ?? '' + console.error(`dbmole: skipping invalid connection '${String(name)}' from ${origin}`) + } + } + return result +} + +export const readStore = (path: string): ConnectionConfig[] => { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + if (!existsSync(path)) return [] + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch { + console.error(`dbmole: store file ${path} is not valid JSON, treating as empty`) + return [] + } + if (!Array.isArray(parsed)) { + console.error(`dbmole: store file ${path} must contain a JSON array, treating as empty`) + return [] + } + return parseConnections(parsed, `store ${path}`) +} + +let writeSeq = 0 + +export const writeStore = (path: string, connections: ConnectionConfig[]): void => { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + writeSeq += 1 + const tmp = `${path}.${process.pid}.${writeSeq}.tmp` + const items = connections.map((c) => JSON.stringify(c, null, 4)) + const content = items.length === 0 ? '[]\n' : `[\n${items.join(',\n')}\n]\n` + writeFileSync(tmp, content, { mode: 0o600 }) + renameSync(tmp, path) +} diff --git a/test/unit/config/store.test.ts b/test/unit/config/store.test.ts new file mode 100644 index 0000000..062babb --- /dev/null +++ b/test/unit/config/store.test.ts @@ -0,0 +1,91 @@ +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defaultStorePath, readStore, writeStore } from '../../../src/config/store.js' +import type { ConnectionConfig } from '../../../src/config/types.js' + +const connection: ConnectionConfig = { + name: 'lab', + type: 'postgres', + host: 'localhost', + user: 'postgres', + readonly: false +} + +describe('store', () => { + let dir: string + let path: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'dbmole-store-')) + path = join(dir, 'nested', 'connections.json') + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('reads missing file as empty list', () => { + expect(readStore(path)).toEqual([]) + }) + + it('round-trips connections and creates parent dirs', () => { + writeStore(path, [connection]) + expect(readStore(path)).toEqual([connection]) + }) + + it('writes file with 0600 permissions and leaves no tmp files', () => { + writeStore(path, [connection]) + const mode = statSync(path).mode & 0o777 + expect(mode).toBe(0o600) + const leftovers = readdirSync(join(dir, 'nested')).filter((f) => f.includes('.tmp')) + expect(leftovers).toEqual([]) + }) + + it('treats invalid JSON as empty with a warning', () => { + writeStore(path, [connection]) + writeFileSync(path, '{ broken') + expect(readStore(path)).toEqual([]) + expect(console.error).toHaveBeenCalled() + }) + + it('treats non-array JSON as empty with a warning', () => { + writeStore(path, [connection]) + writeFileSync(path, '{"connections": []}') + expect(readStore(path)).toEqual([]) + expect(console.error).toHaveBeenCalled() + }) + + it('skips invalid entries but keeps valid ones', () => { + mkdirSync(join(dir, 'nested'), { recursive: true }) + writeFileSync(path, JSON.stringify([connection, { name: 'broken' }])) + expect(readStore(path)).toEqual([connection]) + expect(console.error).toHaveBeenCalled() + }) + + it('stores pretty JSON for hand editing', () => { + writeStore(path, [connection]) + expect(readFileSync(path, 'utf8')).toContain('\n "name": "lab"') + }) +}) + +describe('defaultStorePath', () => { + it('honors DBMOLE_STORE override', () => { + expect(defaultStorePath({ DBMOLE_STORE: '/tmp/custom.json' })).toBe('/tmp/custom.json') + }) + + it('falls back to ~/.config/dbmole/connections.json', () => { + expect(defaultStorePath({})).toMatch(/\.config\/dbmole\/connections\.json$/) + }) +})