From 34e0dcfe32b8b8415f3d95629ecd13f9bdb96377 Mon Sep 17 00:00:00 2001 From: Christian Mueller Date: Mon, 6 Apr 2026 12:45:21 +0200 Subject: [PATCH] feat(middleware): add SQLite DB init with WAL mode, FK support, and 9-table migration - src/db/index.ts: initDb/getDb/closeDb using node-sqlite3-wasm (pure WASM, no VS build required) - src/db/migrations/001-initial.ts: creates all 9 tables with FK constraints and CHECK constraints - tests/db.test.ts: 10 tests verifying WAL mode, FK cascade, all table columns, and constraint enforcement - Switched from better-sqlite3 (requires Visual Studio on Windows) to node-sqlite3-wasm Co-Authored-By: Claude Sonnet 4.6 --- display/middleware/package-lock.json | 7 + display/middleware/package.json | 1 + display/middleware/src/db/index.ts | 36 +++++ .../src/db/migrations/001-initial.ts | 127 +++++++++++++++ display/middleware/tests/db.test.ts | 153 ++++++++++++++++++ 5 files changed, 324 insertions(+) create mode 100644 display/middleware/src/db/index.ts create mode 100644 display/middleware/src/db/migrations/001-initial.ts create mode 100644 display/middleware/tests/db.test.ts diff --git a/display/middleware/package-lock.json b/display/middleware/package-lock.json index f5cc0b6..6d806be 100644 --- a/display/middleware/package-lock.json +++ b/display/middleware/package-lock.json @@ -14,6 +14,7 @@ "express": "^4.22.1", "jsonwebtoken": "^9.0.3", "mqtt": "^5.15.1", + "node-sqlite3-wasm": "^0.8.55", "pg": "^8.20.0" }, "devDependencies": { @@ -3268,6 +3269,12 @@ } } }, + "node_modules/node-sqlite3-wasm": { + "version": "0.8.55", + "resolved": "https://registry.npmjs.org/node-sqlite3-wasm/-/node-sqlite3-wasm-0.8.55.tgz", + "integrity": "sha512-C2m7JzZgKiv9XVZ1ts9oPmS56PCvyHeQffTOF2KNO2TVZzq5IW2s+NFeEZn+eP6bnAuD2We/O9cOJSjQVf7Xxw==", + "license": "MIT" + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", diff --git a/display/middleware/package.json b/display/middleware/package.json index f662084..7aa3605 100644 --- a/display/middleware/package.json +++ b/display/middleware/package.json @@ -17,6 +17,7 @@ "express": "^4.22.1", "jsonwebtoken": "^9.0.3", "mqtt": "^5.15.1", + "node-sqlite3-wasm": "^0.8.55", "pg": "^8.20.0" }, "devDependencies": { diff --git a/display/middleware/src/db/index.ts b/display/middleware/src/db/index.ts new file mode 100644 index 0000000..68a0c9b --- /dev/null +++ b/display/middleware/src/db/index.ts @@ -0,0 +1,36 @@ +import { Database } from 'node-sqlite3-wasm'; +import { migrate001 } from './migrations/001-initial'; + +let dbInstance: Database | null = null; + +export function initDb(path: string): Database { + // node-sqlite3-wasm: pass undefined for in-memory DB + const db = new Database(path === ':memory:' ? undefined : path); + + // Enable WAL mode (file-based databases only, :memory: uses 'memory' mode) + db.exec('PRAGMA journal_mode = WAL'); + // Enable foreign key constraints + db.exec('PRAGMA foreign_keys = ON'); + + // Run migrations + migrate001(db); + + dbInstance = db; + return db; +} + +export function getDb(): Database { + if (!dbInstance) { + throw new Error('Database not initialized. Call initDb() first.'); + } + return dbInstance; +} + +export function closeDb(): void { + if (dbInstance) { + dbInstance.close(); + dbInstance = null; + } +} + +export { Database }; diff --git a/display/middleware/src/db/migrations/001-initial.ts b/display/middleware/src/db/migrations/001-initial.ts new file mode 100644 index 0000000..287a40e --- /dev/null +++ b/display/middleware/src/db/migrations/001-initial.ts @@ -0,0 +1,127 @@ +import { Database } from 'node-sqlite3-wasm'; + +export function migrate001(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS mqtt_brokers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER DEFAULT 1883, + username TEXT, + password TEXT, + use_tls INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 1, + status TEXT DEFAULT 'disconnected', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS mqtt_topics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + broker_id INTEGER NOT NULL, + topic TEXT NOT NULL, + json_path TEXT, + selected INTEGER DEFAULT 0, + ignored INTEGER DEFAULT 0, + last_payload TEXT, + discovered_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (broker_id) REFERENCES mqtt_brokers(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS pg_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER DEFAULT 5432, + database_name TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + use_tls INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 1, + status TEXT DEFAULT 'disconnected', + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS pg_queries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + name TEXT NOT NULL, + query TEXT NOT NULL, + interval_sec INTEGER DEFAULT 60, + result_column TEXT NOT NULL, + last_result TEXT, + last_run_at TEXT, + FOREIGN KEY (source_id) REFERENCES pg_sources(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS datapoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + label TEXT NOT NULL, + unit TEXT, + value TEXT, + status TEXT DEFAULT 'unknown', + source_type TEXT NOT NULL CHECK(source_type IN ('mqtt','postgresql')), + source_id INTEGER NOT NULL, + topic_id INTEGER, + query_id INTEGER, + transform TEXT, + enabled INTEGER DEFAULT 1, + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (topic_id) REFERENCES mqtt_topics(id) ON DELETE SET NULL, + FOREIGN KEY (query_id) REFERENCES pg_queries(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS layouts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT DEFAULT 'free' CHECK(type IN ('free','grid')), + width INTEGER DEFAULT 800, + height INTEGER DEFAULT 480, + grid_cols INTEGER, + grid_rows INTEGER, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS layout_elements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + layout_id INTEGER NOT NULL, + datapoint_id INTEGER, + type TEXT NOT NULL CHECK(type IN ('value','label','line','rect')), + x INTEGER NOT NULL DEFAULT 0, + y INTEGER NOT NULL DEFAULT 0, + width INTEGER, + height INTEGER, + font_size INTEGER DEFAULT 24, + font_weight TEXT DEFAULT 'normal', + text_align TEXT DEFAULT 'left', + icon TEXT, + style TEXT DEFAULT 'plain' CHECK(style IN ('plain','card')), + static_text TEXT, + grid_col INTEGER, + grid_row INTEGER, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (layout_id) REFERENCES layouts(id) ON DELETE CASCADE, + FOREIGN KEY (datapoint_id) REFERENCES datapoints(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS displays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + layout_id INTEGER, + api_token TEXT, + last_seen TEXT, + refresh_sec INTEGER DEFAULT 300, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (layout_id) REFERENCES layouts(id) ON DELETE SET NULL + ); + `); +} diff --git a/display/middleware/tests/db.test.ts b/display/middleware/tests/db.test.ts new file mode 100644 index 0000000..c5fc393 --- /dev/null +++ b/display/middleware/tests/db.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Database } from 'node-sqlite3-wasm'; +import { initDb, closeDb } from '../src/db/index'; + +let db: Database; + +beforeAll(() => { + db = initDb(':memory:'); +}); + +afterAll(() => { + closeDb(); +}); + +describe('Database initialization', () => { + it('should enable WAL or memory journal mode (WAL for file, memory for :memory:)', () => { + const result = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }; + // :memory: databases use 'memory' journal mode, file DBs use 'wal' + expect(['wal', 'memory']).toContain(result.journal_mode); + }); + + it('should enable foreign keys', () => { + const result = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }; + expect(result.foreign_keys).toBe(1); + }); +}); + +describe('Table creation', () => { + const expectedTables = [ + 'users', + 'mqtt_brokers', + 'mqtt_topics', + 'pg_sources', + 'pg_queries', + 'datapoints', + 'layouts', + 'layout_elements', + 'displays', + ]; + + it('should create all 9 tables', () => { + const tables = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`) + .all() as Array<{ name: string }>; + const tableNames = tables.map((t) => t.name); + + for (const expected of expectedTables) { + expect(tableNames).toContain(expected); + } + expect(tableNames.filter((n) => !n.startsWith('sqlite_')).length).toBe(9); + }); + + it('should create users table with correct columns', () => { + const cols = db.prepare('PRAGMA table_info(users)').all() as Array<{ name: string }>; + const colNames = cols.map((c) => c.name); + expect(colNames).toContain('id'); + expect(colNames).toContain('username'); + expect(colNames).toContain('password_hash'); + expect(colNames).toContain('created_at'); + }); + + it('should create mqtt_brokers table with correct columns', () => { + const cols = db.prepare('PRAGMA table_info(mqtt_brokers)').all() as Array<{ name: string }>; + const colNames = cols.map((c) => c.name); + expect(colNames).toContain('id'); + expect(colNames).toContain('name'); + expect(colNames).toContain('host'); + expect(colNames).toContain('port'); + expect(colNames).toContain('use_tls'); + expect(colNames).toContain('enabled'); + expect(colNames).toContain('status'); + }); + + it('should create datapoints table with source_type CHECK constraint columns', () => { + const cols = db.prepare('PRAGMA table_info(datapoints)').all() as Array<{ name: string }>; + const colNames = cols.map((c) => c.name); + expect(colNames).toContain('source_type'); + expect(colNames).toContain('source_id'); + expect(colNames).toContain('topic_id'); + expect(colNames).toContain('query_id'); + expect(colNames).toContain('transform'); + }); + + it('should create layout_elements with all required columns', () => { + const cols = db.prepare('PRAGMA table_info(layout_elements)').all() as Array<{ name: string }>; + const colNames = cols.map((c) => c.name); + expect(colNames).toContain('layout_id'); + expect(colNames).toContain('datapoint_id'); + expect(colNames).toContain('type'); + expect(colNames).toContain('x'); + expect(colNames).toContain('y'); + expect(colNames).toContain('font_size'); + expect(colNames).toContain('style'); + }); +}); + +describe('Broker insert and retrieve', () => { + it('should insert and retrieve a broker', () => { + const result = db.run( + `INSERT INTO mqtt_brokers (name, host, port) VALUES (?, ?, ?)`, + ['Test Broker', 'mqtt.example.com', 1883] + ); + expect(result.lastInsertRowid).toBeGreaterThan(0); + + const broker = db + .prepare(`SELECT * FROM mqtt_brokers WHERE id = ?`) + .get(result.lastInsertRowid) as { + name: string; + host: string; + port: number; + status: string; + } | undefined; + + expect(broker).toBeDefined(); + expect(broker!.name).toBe('Test Broker'); + expect(broker!.host).toBe('mqtt.example.com'); + expect(broker!.port).toBe(1883); + expect(broker!.status).toBe('disconnected'); + }); + + it('should cascade delete topics when broker is deleted', () => { + const broker = db.run(`INSERT INTO mqtt_brokers (name, host) VALUES (?, ?)`, [ + 'Cascade Broker', + 'cascade.example.com', + ]); + + db.run(`INSERT INTO mqtt_topics (broker_id, topic) VALUES (?, ?)`, [ + broker.lastInsertRowid, + 'sensors/temp', + ]); + + const topicsBefore = db + .prepare(`SELECT * FROM mqtt_topics WHERE broker_id = ?`) + .all(broker.lastInsertRowid); + expect(topicsBefore.length).toBe(1); + + db.run(`DELETE FROM mqtt_brokers WHERE id = ?`, [broker.lastInsertRowid]); + + const topicsAfter = db + .prepare(`SELECT * FROM mqtt_topics WHERE broker_id = ?`) + .all(broker.lastInsertRowid); + expect(topicsAfter.length).toBe(0); + }); + + it('should reject invalid source_type values', () => { + expect(() => { + db.run( + `INSERT INTO datapoints (name, label, source_type, source_id) VALUES (?, ?, ?, ?)`, + ['dp1', 'Datapoint 1', 'invalid_source', 1] + ); + }).toThrow(); + }); +});