feat: add persistent store, atomic 0600 writes
This commit is contained in:
@@ -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 ?? '<unnamed>'
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user