feat: config loading and sqlite schema
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ web/dist/
|
|||||||
data/
|
data/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
.env*
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
dataDir: string
|
dataDir: string
|
||||||
artworkDir: string
|
artworkDir: string
|
||||||
@@ -5,3 +9,30 @@ export interface Config {
|
|||||||
port: number
|
port: number
|
||||||
sessionSecret: string
|
sessionSecret: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
||||||
|
const dataDir = env.DATA_DIR ?? path.resolve('data')
|
||||||
|
mkdirSync(dataDir, { recursive: true })
|
||||||
|
const artworkDir = path.join(dataDir, 'artwork-cache')
|
||||||
|
mkdirSync(artworkDir, { recursive: true })
|
||||||
|
return {
|
||||||
|
dataDir,
|
||||||
|
artworkDir,
|
||||||
|
dbPath: path.join(dataDir, 'record-shop.db'),
|
||||||
|
port: Number(env.PORT ?? 3000),
|
||||||
|
sessionSecret: getOrCreateSecret(dataDir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateSecret(dataDir: string): string {
|
||||||
|
const secretPath = path.join(dataDir, 'session-secret')
|
||||||
|
try {
|
||||||
|
const existing = readFileSync(secretPath, 'utf8').trim()
|
||||||
|
if (existing) return existing
|
||||||
|
} catch {
|
||||||
|
// first boot — create below
|
||||||
|
}
|
||||||
|
const secret = crypto.randomBytes(32).toString('hex')
|
||||||
|
writeFileSync(secretPath, secret, { mode: 0o600 })
|
||||||
|
return secret
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,5 +6,75 @@ export function openDatabase(path: string): DB {
|
|||||||
const db = new Database(path)
|
const db = new Database(path)
|
||||||
db.pragma('journal_mode = WAL')
|
db.pragma('journal_mode = WAL')
|
||||||
db.pragma('foreign_keys = ON')
|
db.pragma('foreign_keys = ON')
|
||||||
|
migrate(db)
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function migrate(db: DB): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS app_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
discogs_token TEXT,
|
||||||
|
subsonic_url TEXT,
|
||||||
|
subsonic_username TEXT,
|
||||||
|
subsonic_password TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS collection_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
discogs_release_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist TEXT NOT NULL,
|
||||||
|
year INTEGER,
|
||||||
|
formats TEXT NOT NULL DEFAULT '[]',
|
||||||
|
genres TEXT NOT NULL DEFAULT '[]',
|
||||||
|
labels TEXT NOT NULL DEFAULT '[]',
|
||||||
|
tracklist TEXT NOT NULL DEFAULT '[]',
|
||||||
|
catno TEXT,
|
||||||
|
country TEXT,
|
||||||
|
cover_url TEXT,
|
||||||
|
local_artwork_path TEXT,
|
||||||
|
barcodes TEXT NOT NULL DEFAULT '[]',
|
||||||
|
rip_override INTEGER,
|
||||||
|
date_added TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE (user_id, discogs_release_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS digital_albums (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
subsonic_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist TEXT NOT NULL,
|
||||||
|
UNIQUE (user_id, subsonic_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS match_links (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE,
|
||||||
|
album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, item_id)
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|||||||
46
server/test/config.test.ts
Normal file
46
server/test/config.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { loadConfig } from '../src/config.js'
|
||||||
|
|
||||||
|
function tempDir(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), 'rs-config-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loadConfig', () => {
|
||||||
|
it('creates data + artwork dirs and returns paths', () => {
|
||||||
|
const dir = tempDir()
|
||||||
|
try {
|
||||||
|
const cfg = loadConfig({ DATA_DIR: dir })
|
||||||
|
expect(cfg.dataDir).toBe(dir)
|
||||||
|
expect(cfg.dbPath).toBe(path.join(dir, 'record-shop.db'))
|
||||||
|
expect(cfg.port).toBe(3000)
|
||||||
|
expect(existsSync(path.join(dir, 'artwork-cache'))).toBe(true)
|
||||||
|
expect(cfg.sessionSecret).toMatch(/^[0-9a-f]{64}$/)
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists and reuses the session secret', () => {
|
||||||
|
const dir = tempDir()
|
||||||
|
try {
|
||||||
|
const a = loadConfig({ DATA_DIR: dir })
|
||||||
|
const b = loadConfig({ DATA_DIR: dir })
|
||||||
|
expect(a.sessionSecret).toBe(b.sessionSecret)
|
||||||
|
expect(readFileSync(path.join(dir, 'session-secret'), 'utf8')).toBe(a.sessionSecret)
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours PORT', () => {
|
||||||
|
const dir = tempDir()
|
||||||
|
try {
|
||||||
|
expect(loadConfig({ DATA_DIR: dir, PORT: '8080' }).port).toBe(8080)
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
37
server/test/db.test.ts
Normal file
37
server/test/db.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { openDatabase } from '../src/db.js'
|
||||||
|
|
||||||
|
describe('openDatabase', () => {
|
||||||
|
it('creates the full schema', () => {
|
||||||
|
const db = openDatabase(':memory:')
|
||||||
|
const tables = (
|
||||||
|
db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as {
|
||||||
|
name: string
|
||||||
|
}[]
|
||||||
|
).map((r) => r.name)
|
||||||
|
for (const t of [
|
||||||
|
'users',
|
||||||
|
'sessions',
|
||||||
|
'settings',
|
||||||
|
'collection_items',
|
||||||
|
'digital_albums',
|
||||||
|
'match_links',
|
||||||
|
'app_meta',
|
||||||
|
]) {
|
||||||
|
expect(tables).toContain(t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enforces unique (user_id, discogs_release_id)', () => {
|
||||||
|
const db = openDatabase(':memory:')
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO users (username, password_hash, is_admin) VALUES ('sam', 'x', 1)"
|
||||||
|
).run()
|
||||||
|
const insert = db.prepare(
|
||||||
|
`INSERT INTO collection_items (user_id, discogs_release_id, title, artist)
|
||||||
|
VALUES (1, 100, 'Album', 'Artist')`
|
||||||
|
)
|
||||||
|
insert.run()
|
||||||
|
expect(() => insert.run()).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user