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.
- package/LICENSE +21 -0
- package/README.md +557 -0
- package/adapters/AdapterFactory.js +87 -0
- package/adapters/DatabaseAdapter.js +135 -0
- package/adapters/MongoAdapter.js +187 -0
- package/adapters/MySQLAdapter.js +172 -0
- package/adapters/PostgresAdapter.js +167 -0
- package/adapters/SQLiteAdapter.js +161 -0
- package/adapters/index.js +15 -0
- package/constants.js +8 -0
- package/examples/node-red-app.js +115 -0
- package/index.js +185 -0
- package/package.json +69 -0
- package/utils/UrlParser.js +140 -0
- package/utils/index.js +5 -0
|
@@ -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;
|