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 <noreply@anthropic.com>
This commit is contained in:
parent
00968a4626
commit
34e0dcfe32
7
display/middleware/package-lock.json
generated
7
display/middleware/package-lock.json
generated
@ -14,6 +14,7 @@
|
|||||||
"express": "^4.22.1",
|
"express": "^4.22.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mqtt": "^5.15.1",
|
"mqtt": "^5.15.1",
|
||||||
|
"node-sqlite3-wasm": "^0.8.55",
|
||||||
"pg": "^8.20.0"
|
"pg": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": {
|
"node_modules/nopt": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"express": "^4.22.1",
|
"express": "^4.22.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mqtt": "^5.15.1",
|
"mqtt": "^5.15.1",
|
||||||
|
"node-sqlite3-wasm": "^0.8.55",
|
||||||
"pg": "^8.20.0"
|
"pg": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
36
display/middleware/src/db/index.ts
Normal file
36
display/middleware/src/db/index.ts
Normal file
@ -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 };
|
||||||
127
display/middleware/src/db/migrations/001-initial.ts
Normal file
127
display/middleware/src/db/migrations/001-initial.ts
Normal file
@ -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
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
153
display/middleware/tests/db.test.ts
Normal file
153
display/middleware/tests/db.test.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user