node-red-contrib-db-storage 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Base DatabaseAdapter class that defines the interface for all database adapters.
3
+ * All database-specific adapters must extend this class and implement all methods.
4
+ *
5
+ * Standardized Configuration Interface:
6
+ * @typedef {Object} AdapterConfig
7
+ * @property {string} url - Required: Database connection URL
8
+ * @property {string} database - Required: Database name
9
+ *
10
+ * All adapters must accept this standardized configuration format.
11
+ */
12
+ class DatabaseAdapter {
13
+ constructor(config) {
14
+ if (new.target === DatabaseAdapter) {
15
+ throw new Error('DatabaseAdapter is an abstract class and cannot be instantiated directly');
16
+ }
17
+ this.validateConfig(config);
18
+ this.config = config;
19
+ this.url = config.url;
20
+ this.databaseName = config.database;
21
+ }
22
+
23
+ /**
24
+ * Validate the configuration object
25
+ * @param {AdapterConfig} config - The configuration object
26
+ * @throws {Error} If required fields are missing
27
+ */
28
+ validateConfig(config) {
29
+ if (!config) {
30
+ throw new Error('Configuration is required');
31
+ }
32
+ if (!config.url) {
33
+ throw new Error('Database URL (config.url) is required');
34
+ }
35
+ if (!config.database) {
36
+ throw new Error('Database name (config.database) is required');
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Validate collection/table name to prevent SQL injection
42
+ * @param {string} collectionName - The collection/table name
43
+ * @throws {Error} If name is invalid
44
+ */
45
+ validateCollectionName(collectionName) {
46
+ if (!collectionName || typeof collectionName !== 'string') {
47
+ throw new Error('Collection name must be a non-empty string');
48
+ }
49
+ // Only allow alphanumeric, hyphens, and underscores
50
+ if (!/^[a-zA-Z0-9_-]+$/.test(collectionName)) {
51
+ throw new Error('Collection name contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed');
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Validate objects array
57
+ * @param {Array} objects - Array of objects to validate
58
+ * @throws {Error} If not a valid array
59
+ */
60
+ validateObjectArray(objects) {
61
+ if (!Array.isArray(objects)) {
62
+ throw new Error('Objects must be an array');
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Validate path parameter
68
+ * @param {string} path - The path to validate
69
+ * @throws {Error} If path is invalid
70
+ */
71
+ validatePath(path) {
72
+ if (!path || typeof path !== 'string') {
73
+ throw new Error('Path must be a non-empty string');
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Connect to the database
79
+ * @returns {Promise<void>}
80
+ */
81
+ async connect() {
82
+ throw new Error('Method connect() must be implemented');
83
+ }
84
+
85
+ /**
86
+ * Close the database connection
87
+ * @returns {Promise<void>}
88
+ */
89
+ async close() {
90
+ throw new Error('Method close() must be implemented');
91
+ }
92
+
93
+ /**
94
+ * Find all documents/rows in a collection/table
95
+ * @param {string} collectionName - The collection/table name
96
+ * @returns {Promise<Array>} Array of documents/rows
97
+ */
98
+ async findAll(collectionName) {
99
+ throw new Error('Method findAll() must be implemented');
100
+ }
101
+
102
+ /**
103
+ * Save all objects to a collection/table (replaces existing data)
104
+ * @param {string} collectionName - The collection/table name
105
+ * @param {Array} objects - Array of objects to save
106
+ * @returns {Promise<void>}
107
+ */
108
+ async saveAll(collectionName, objects) {
109
+ throw new Error('Method saveAll() must be implemented');
110
+ }
111
+
112
+ /**
113
+ * Find a single document/row by path
114
+ * @param {string} collectionName - The collection/table name
115
+ * @param {string} path - The path to search for
116
+ * @returns {Promise<Object>} The document body or empty object
117
+ */
118
+ async findOneByPath(collectionName, path) {
119
+ throw new Error('Method findOneByPath() must be implemented');
120
+ }
121
+
122
+ /**
123
+ * Save or update a document/row by path (upsert)
124
+ * @param {string} collectionName - The collection/table name
125
+ * @param {string} path - The path identifier
126
+ * @param {Object} meta - Metadata object
127
+ * @param {*} body - The body content
128
+ * @returns {Promise<void>}
129
+ */
130
+ async saveOrUpdateByPath(collectionName, path, meta, body) {
131
+ throw new Error('Method saveOrUpdateByPath() must be implemented');
132
+ }
133
+ }
134
+
135
+ module.exports = DatabaseAdapter;
@@ -0,0 +1,187 @@
1
+ const DatabaseAdapter = require("./DatabaseAdapter");
2
+ const MongoDB = require("mongodb");
3
+ const MongoClient = MongoDB.MongoClient;
4
+
5
+ /**
6
+ * MongoDB adapter for Node-RED storage
7
+ *
8
+ * Configuration:
9
+ * @param {Object} config - Standardized adapter configuration
10
+ * @param {string} config.url - MongoDB connection URL (e.g., 'mongodb://localhost:27017')
11
+ * @param {string} config.database - Database name to use
12
+ */
13
+ class MongoAdapter extends DatabaseAdapter {
14
+ constructor(config) {
15
+ super(config);
16
+ // url and databaseName are set by parent class
17
+ this.client = null;
18
+ this.db = null;
19
+ }
20
+
21
+ async connect() {
22
+ return new Promise((resolve, reject) => {
23
+ MongoClient.connect(
24
+ this.url,
25
+ { useUnifiedTopology: true },
26
+ (err, client) => {
27
+ if (err) {
28
+ reject(err);
29
+ return;
30
+ }
31
+
32
+ this.client = client;
33
+ this.db = client.db(this.databaseName);
34
+ resolve();
35
+ },
36
+ );
37
+ });
38
+ }
39
+
40
+ async close() {
41
+ if (this.client) {
42
+ await this.client.close();
43
+ this.client = null;
44
+ this.db = null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Replace $ keys with __dollar__ to avoid MongoDB restrictions
50
+ */
51
+ _escapeDollarKeys(obj) {
52
+ if (obj === null || typeof obj !== "object") {
53
+ return obj;
54
+ }
55
+
56
+ if (Array.isArray(obj)) {
57
+ return obj.map((item) => this._escapeDollarKeys(item));
58
+ }
59
+
60
+ const result = {};
61
+ for (const key in obj) {
62
+ const newKey = key === "$" ? "__dollar__" : key;
63
+ result[newKey] = this._escapeDollarKeys(obj[key]);
64
+ }
65
+ return result;
66
+ }
67
+
68
+ /**
69
+ * Restore __dollar__ back to $ keys
70
+ */
71
+ _restoreDollarKeys(obj) {
72
+ if (obj === null || typeof obj !== "object") {
73
+ return obj;
74
+ }
75
+
76
+ if (Array.isArray(obj)) {
77
+ return obj.map((item) => this._restoreDollarKeys(item));
78
+ }
79
+
80
+ const result = {};
81
+ for (const key in obj) {
82
+ const newKey = key === "__dollar__" ? "$" : key;
83
+ result[newKey] = this._restoreDollarKeys(obj[key]);
84
+ }
85
+ return result;
86
+ }
87
+
88
+ async findAll(collectionName) {
89
+ this.validateCollectionName(collectionName);
90
+ return new Promise((resolve, reject) => {
91
+ this.db
92
+ .collection(collectionName)
93
+ .find({})
94
+ .toArray((err, documents) => {
95
+ if (err) {
96
+ reject(err);
97
+ return;
98
+ }
99
+
100
+ if (documents == null) {
101
+ resolve([]);
102
+ } else {
103
+ // Remove MongoDB _id field and restore $ keys
104
+ resolve(
105
+ documents.map((doc) => {
106
+ const { _id, ...data } = doc;
107
+ return this._restoreDollarKeys(data);
108
+ }),
109
+ );
110
+ }
111
+ });
112
+ });
113
+ }
114
+
115
+ async saveAll(collectionName, objects) {
116
+ this.validateCollectionName(collectionName);
117
+ this.validateObjectArray(objects);
118
+ await this._dropCollectionIfExists(collectionName);
119
+
120
+ if (objects.length > 0) {
121
+ // Escape $ keys to avoid MongoDB restrictions
122
+ const escapedObjects = objects.map((obj) => this._escapeDollarKeys(obj));
123
+ await this.db.collection(collectionName).insertMany(escapedObjects);
124
+ }
125
+ }
126
+
127
+ async _dropCollectionIfExists(collectionName) {
128
+ const collections = await this.db
129
+ .listCollections({ name: collectionName })
130
+ .toArray();
131
+
132
+ if (collections.length > 0) {
133
+ await this.db.collection(collectionName).drop();
134
+ }
135
+ }
136
+
137
+ async findOneByPath(collectionName, path) {
138
+ this.validateCollectionName(collectionName);
139
+ this.validatePath(path);
140
+ return new Promise((resolve, reject) => {
141
+ this.db
142
+ .collection(collectionName)
143
+ .findOne({ path: path }, (err, document) => {
144
+ if (err) {
145
+ reject(err);
146
+ return;
147
+ }
148
+
149
+ if (document == null) {
150
+ resolve({});
151
+ } else if (document.body) {
152
+ const body =
153
+ typeof document.body === "string"
154
+ ? JSON.parse(document.body)
155
+ : document.body;
156
+ resolve(this._restoreDollarKeys(body));
157
+ } else {
158
+ resolve({});
159
+ }
160
+ });
161
+ });
162
+ }
163
+
164
+ async saveOrUpdateByPath(collectionName, path, meta, body) {
165
+ this.validateCollectionName(collectionName);
166
+ this.validatePath(path);
167
+ return new Promise((resolve, reject) => {
168
+ const document = {
169
+ path: path,
170
+ meta: JSON.stringify(meta),
171
+ body: JSON.stringify(body),
172
+ };
173
+
174
+ this.db
175
+ .collection(collectionName)
176
+ .replaceOne({ path: path }, document, { upsert: true }, (err) => {
177
+ if (err) {
178
+ reject(err);
179
+ } else {
180
+ resolve();
181
+ }
182
+ });
183
+ });
184
+ }
185
+ }
186
+
187
+ module.exports = MongoAdapter;
@@ -0,0 +1,172 @@
1
+ const DatabaseAdapter = require("./DatabaseAdapter");
2
+
3
+ /**
4
+ * MySQL/MariaDB adapter for Node-RED storage
5
+ *
6
+ * Configuration:
7
+ * @param {Object} config - Standardized adapter configuration
8
+ * @param {string} config.url - MySQL connection URL (e.g., 'mysql://user:password@localhost:3306/dbname')
9
+ * @param {string} config.database - Database name to use
10
+ */
11
+ class MySQLAdapter extends DatabaseAdapter {
12
+ constructor(config) {
13
+ super(config);
14
+ // url and databaseName are set by parent class
15
+ this.pool = null;
16
+ this._mysql = null;
17
+ }
18
+
19
+ async connect() {
20
+ // Dynamically require mysql2 to make it an optional dependency
21
+ try {
22
+ this._mysql = require("mysql2/promise");
23
+ } catch (err) {
24
+ throw new Error(
25
+ 'MySQL adapter requires the "mysql2" package. Install it with: npm install mysql2',
26
+ );
27
+ }
28
+
29
+ this.pool = this._mysql.createPool(this.url);
30
+
31
+ // Test the connection
32
+ const connection = await this.pool.getConnection();
33
+ connection.release();
34
+
35
+ // Create tables if they don't exist
36
+ await this._initializeTables();
37
+ }
38
+
39
+ async close() {
40
+ if (this.pool) {
41
+ await this.pool.end();
42
+ this.pool = null;
43
+ }
44
+ }
45
+
46
+ async _initializeTables() {
47
+ // We'll create tables dynamically as needed
48
+ // This is a helper to ensure the schema exists
49
+ }
50
+
51
+ async _ensureTable(tableName) {
52
+ const createTableQuery = `
53
+ CREATE TABLE IF NOT EXISTS \`${tableName}\` (
54
+ id INT AUTO_INCREMENT PRIMARY KEY,
55
+ path VARCHAR(512) UNIQUE,
56
+ data JSON NOT NULL,
57
+ meta JSON,
58
+ body JSON,
59
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
60
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
61
+ )
62
+ `;
63
+ await this.pool.query(createTableQuery);
64
+
65
+ // Create index on path for faster lookups (MySQL doesn't support IF NOT EXISTS for indexes)
66
+ const createIndexQuery = `
67
+ CREATE INDEX \`${tableName}_path_idx\` ON \`${tableName}\` (path)
68
+ `;
69
+ try {
70
+ await this.pool.query(createIndexQuery);
71
+ } catch (err) {
72
+ // Index might already exist, ignore error
73
+ if (!err.message.includes("Duplicate key name")) {
74
+ throw err;
75
+ }
76
+ }
77
+ }
78
+
79
+ async findAll(collectionName) {
80
+ this.validateCollectionName(collectionName);
81
+ await this._ensureTable(collectionName);
82
+
83
+ const query = `SELECT data FROM \`${collectionName}\``;
84
+ const [rows] = await this.pool.query(query);
85
+
86
+ if (rows.length === 0) {
87
+ return [];
88
+ }
89
+
90
+ return rows.map((row) => row.data);
91
+ }
92
+
93
+ async saveAll(collectionName, objects) {
94
+ this.validateCollectionName(collectionName);
95
+ this.validateObjectArray(objects);
96
+ await this._ensureTable(collectionName);
97
+
98
+ // Start a transaction
99
+ const connection = await this.pool.getConnection();
100
+
101
+ try {
102
+ await connection.beginTransaction();
103
+
104
+ // Delete all existing data
105
+ await connection.query(`DELETE FROM \`${collectionName}\``);
106
+
107
+ // Insert new data
108
+ if (objects.length > 0) {
109
+ for (const obj of objects) {
110
+ await connection.query(
111
+ `INSERT INTO \`${collectionName}\` (data) VALUES (?)`,
112
+ [JSON.stringify(obj)],
113
+ );
114
+ }
115
+ }
116
+
117
+ await connection.commit();
118
+ } catch (err) {
119
+ await connection.rollback();
120
+ throw err;
121
+ } finally {
122
+ connection.release();
123
+ }
124
+ }
125
+
126
+ async findOneByPath(collectionName, path) {
127
+ this.validateCollectionName(collectionName);
128
+ this.validatePath(path);
129
+ await this._ensureTable(collectionName);
130
+
131
+ const query = `SELECT body FROM \`${collectionName}\` WHERE path = ?`;
132
+ const [rows] = await this.pool.query(query, [path]);
133
+
134
+ if (rows.length === 0) {
135
+ return {};
136
+ }
137
+
138
+ const body = rows[0].body;
139
+ if (body == null) {
140
+ return {};
141
+ }
142
+
143
+ return body;
144
+ }
145
+
146
+ async saveOrUpdateByPath(collectionName, path, meta, body) {
147
+ this.validateCollectionName(collectionName);
148
+ this.validatePath(path);
149
+ await this._ensureTable(collectionName);
150
+
151
+ const query = `
152
+ INSERT INTO \`${collectionName}\` (path, meta, body, data, updated_at)
153
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
154
+ ON DUPLICATE KEY UPDATE
155
+ meta = VALUES(meta),
156
+ body = VALUES(body),
157
+ data = VALUES(data),
158
+ updated_at = CURRENT_TIMESTAMP
159
+ `;
160
+
161
+ const data = { path, meta, body };
162
+
163
+ await this.pool.query(query, [
164
+ path,
165
+ JSON.stringify(meta),
166
+ JSON.stringify(body),
167
+ JSON.stringify(data),
168
+ ]);
169
+ }
170
+ }
171
+
172
+ module.exports = MySQLAdapter;
@@ -0,0 +1,167 @@
1
+ const DatabaseAdapter = require('./DatabaseAdapter');
2
+
3
+ /**
4
+ * PostgreSQL adapter for Node-RED storage
5
+ *
6
+ * Configuration:
7
+ * @param {Object} config - Standardized adapter configuration
8
+ * @param {string} config.url - PostgreSQL connection URL (e.g., 'postgresql://user:password@localhost:5432/dbname')
9
+ * @param {string} config.database - Database name (should match the database in the URL)
10
+ */
11
+ class PostgresAdapter extends DatabaseAdapter {
12
+ constructor(config) {
13
+ super(config);
14
+ // url and databaseName are set by parent class
15
+ this.connectionString = this.url;
16
+ this.pool = null;
17
+ this._pg = null;
18
+ }
19
+
20
+ async connect() {
21
+ // Dynamically require pg to make it an optional dependency
22
+ try {
23
+ this._pg = require('pg');
24
+ } catch (err) {
25
+ throw new Error('PostgreSQL adapter requires the "pg" package. Install it with: npm install pg');
26
+ }
27
+
28
+ this.pool = new this._pg.Pool({
29
+ connectionString: this.connectionString
30
+ });
31
+
32
+ // Test the connection
33
+ const client = await this.pool.connect();
34
+ client.release();
35
+
36
+ // Create tables if they don't exist
37
+ await this._initializeTables();
38
+ }
39
+
40
+ async close() {
41
+ if (this.pool) {
42
+ await this.pool.end();
43
+ this.pool = null;
44
+ }
45
+ }
46
+
47
+ async _initializeTables() {
48
+ // We'll create tables dynamically as needed
49
+ // This is a helper to ensure the schema exists
50
+ }
51
+
52
+ async _ensureTable(tableName) {
53
+ const createTableQuery = `
54
+ CREATE TABLE IF NOT EXISTS "${tableName}" (
55
+ id SERIAL PRIMARY KEY,
56
+ path VARCHAR(512) UNIQUE,
57
+ data JSONB NOT NULL,
58
+ meta JSONB,
59
+ body JSONB,
60
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
61
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
62
+ )
63
+ `;
64
+ await this.pool.query(createTableQuery);
65
+
66
+ // Create index on path for faster lookups
67
+ const createIndexQuery = `
68
+ CREATE INDEX IF NOT EXISTS "${tableName}_path_idx" ON "${tableName}" (path)
69
+ `;
70
+ await this.pool.query(createIndexQuery);
71
+ }
72
+
73
+ async findAll(collectionName) {
74
+ this.validateCollectionName(collectionName);
75
+ await this._ensureTable(collectionName);
76
+
77
+ const query = `SELECT data FROM "${collectionName}"`;
78
+ const result = await this.pool.query(query);
79
+
80
+ if (result.rows.length === 0) {
81
+ return [];
82
+ }
83
+
84
+ return result.rows.map(row => row.data);
85
+ }
86
+
87
+ async saveAll(collectionName, objects) {
88
+ this.validateCollectionName(collectionName);
89
+ this.validateObjectArray(objects);
90
+ await this._ensureTable(collectionName);
91
+
92
+ // Start a transaction
93
+ const client = await this.pool.connect();
94
+
95
+ try {
96
+ await client.query('BEGIN');
97
+
98
+ // Delete all existing data
99
+ await client.query(`DELETE FROM "${collectionName}"`);
100
+
101
+ // Insert new data
102
+ if (objects.length > 0) {
103
+ for (const obj of objects) {
104
+ await client.query(
105
+ `INSERT INTO "${collectionName}" (data) VALUES ($1)`,
106
+ [JSON.stringify(obj)]
107
+ );
108
+ }
109
+ }
110
+
111
+ await client.query('COMMIT');
112
+ } catch (err) {
113
+ await client.query('ROLLBACK');
114
+ throw err;
115
+ } finally {
116
+ client.release();
117
+ }
118
+ }
119
+
120
+ async findOneByPath(collectionName, path) {
121
+ this.validateCollectionName(collectionName);
122
+ this.validatePath(path);
123
+ await this._ensureTable(collectionName);
124
+
125
+ const query = `SELECT body FROM "${collectionName}" WHERE path = $1`;
126
+ const result = await this.pool.query(query, [path]);
127
+
128
+ if (result.rows.length === 0) {
129
+ return {};
130
+ }
131
+
132
+ const body = result.rows[0].body;
133
+ if (body == null) {
134
+ return {};
135
+ }
136
+
137
+ return body;
138
+ }
139
+
140
+ async saveOrUpdateByPath(collectionName, path, meta, body) {
141
+ this.validateCollectionName(collectionName);
142
+ this.validatePath(path);
143
+ await this._ensureTable(collectionName);
144
+
145
+ const query = `
146
+ INSERT INTO "${collectionName}" (path, meta, body, data, updated_at)
147
+ VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
148
+ ON CONFLICT (path)
149
+ DO UPDATE SET
150
+ meta = EXCLUDED.meta,
151
+ body = EXCLUDED.body,
152
+ data = EXCLUDED.data,
153
+ updated_at = CURRENT_TIMESTAMP
154
+ `;
155
+
156
+ const data = { path, meta, body };
157
+
158
+ await this.pool.query(query, [
159
+ path,
160
+ JSON.stringify(meta),
161
+ JSON.stringify(body),
162
+ JSON.stringify(data)
163
+ ]);
164
+ }
165
+ }
166
+
167
+ module.exports = PostgresAdapter;