@bitblit/ratchet-rdbms 6.0.146-alpha → 6.0.148-alpha
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/package.json +4 -3
- package/src/build/ratchet-rdbms-info.ts +19 -0
- package/src/model/connection-and-tunnel.ts +7 -0
- package/src/model/database-access-provider.ts +11 -0
- package/src/model/database-access.ts +30 -0
- package/src/model/database-config-list.ts +3 -0
- package/src/model/database-request-type.ts +6 -0
- package/src/model/group-by-count-result.ts +4 -0
- package/src/model/modify-results.ts +9 -0
- package/src/model/named-parameter-database-service-config.ts +13 -0
- package/src/model/paginated-results.ts +5 -0
- package/src/model/pagination-bounds.ts +12 -0
- package/src/model/paginator.ts +20 -0
- package/src/model/query-defaults.ts +4 -0
- package/src/model/query-text-provider.ts +4 -0
- package/src/model/request-results.ts +4 -0
- package/src/model/simple-query-text-provider.ts +18 -0
- package/src/model/sort-direction.ts +4 -0
- package/src/model/ssh/ssh-tunnel-config.ts +8 -0
- package/src/model/ssh/ssh-tunnel-container.ts +13 -0
- package/src/model/transaction-isolation-level.ts +4 -0
- package/src/mysql/model/mysql-db-config.ts +14 -0
- package/src/mysql/model/mysql-master-status.ts +6 -0
- package/src/mysql/model/mysql-slave-status.ts +52 -0
- package/src/mysql/mysql-style-database-access.ts +85 -0
- package/src/mysql/rds-mysql-style-connection-provider.ts +265 -0
- package/src/postgres/model/postgres-db-config.ts +8 -0
- package/src/postgres/postgres-style-connection-provider.ts +270 -0
- package/src/postgres/postgres-style-database-access.spec.ts +76 -0
- package/src/postgres/postgres-style-database-access.ts +110 -0
- package/src/query-builder/query-builder-result.ts +21 -0
- package/src/query-builder/query-builder.spec.ts +194 -0
- package/src/query-builder/query-builder.ts +445 -0
- package/src/query-builder/query-util.spec.ts +20 -0
- package/src/query-builder/query-util.ts +162 -0
- package/src/rds-data-api/model/rds-data-api-connection-config.ts +8 -0
- package/src/rds-data-api/rds-data-api-connection-provider.ts +39 -0
- package/src/rds-data-api/rds-data-api-database-access.spec.ts +139 -0
- package/src/rds-data-api/rds-data-api-database-access.ts +209 -0
- package/src/service/named-parameter-database-service.ts +421 -0
- package/src/service/ssh-tunnel-service.ts +62 -0
- package/src/service/transactional-named-parameter-database-service.ts +171 -0
- package/src/sqlite/model/fetch-remote-mode.ts +4 -0
- package/src/sqlite/model/flush-remote-mode.ts +4 -0
- package/src/sqlite/model/sqlite-connection-config-flag.ts +3 -0
- package/src/sqlite/model/sqlite-connection-config.ts +11 -0
- package/src/sqlite/model/sqlite-local-file-config.ts +3 -0
- package/src/sqlite/model/sqlite-remote-file-sync-config.ts +9 -0
- package/src/sqlite/sqlite-database-access.spec.ts +158 -0
- package/src/sqlite/sqlite-database-access.ts +126 -0
- package/src/sqlite/sqlite-remote-sync-database-access.ts +152 -0
- package/src/sqlite/sqlite-style-connection-provider.ts +181 -0
- package/src/util/aws-rds-cert-2023.ts +502 -0
- package/src/util/named-parameter-adapter/named-parameter-adapter.ts +51 -0
- package/src/util/named-parameter-adapter/query-and-params.ts +4 -0
- package/src/util/relational-database-utils.ts +54 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import maria, { Connection, ConnectionOptions } from 'mysql2/promise';
|
|
2
|
+
|
|
3
|
+
import { RequireRatchet } from '@bitblit/ratchet-common/lang/require-ratchet';
|
|
4
|
+
import { Logger } from '@bitblit/ratchet-common/logger/logger';
|
|
5
|
+
import { ErrorRatchet } from '@bitblit/ratchet-common/lang/error-ratchet';
|
|
6
|
+
import { StringRatchet } from '@bitblit/ratchet-common/lang/string-ratchet';
|
|
7
|
+
import { SshTunnelService } from '../service/ssh-tunnel-service.js';
|
|
8
|
+
import { DatabaseConfigList } from '../model/database-config-list.js';
|
|
9
|
+
import getPort from 'get-port';
|
|
10
|
+
import { SshTunnelContainer } from '../model/ssh/ssh-tunnel-container.js';
|
|
11
|
+
import { QueryDefaults } from '../model/query-defaults.js';
|
|
12
|
+
import { ConnectionAndTunnel } from '../model/connection-and-tunnel.js';
|
|
13
|
+
import { DatabaseAccessProvider } from '../model/database-access-provider.js';
|
|
14
|
+
import { DatabaseAccess } from '../model/database-access.js';
|
|
15
|
+
import { MysqlStyleDatabaseAccess } from './mysql-style-database-access.js';
|
|
16
|
+
import { MysqlDbConfig } from './model/mysql-db-config.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
*/
|
|
20
|
+
export class RdsMysqlStyleConnectionProvider implements DatabaseAccessProvider {
|
|
21
|
+
// While this _technically_ expands the possible scope of Sql injection, we already
|
|
22
|
+
// tightly limit to Named parameters, so multiple statements is more useful than not!
|
|
23
|
+
public static DEFAULT_CONNECTION_OPTIONS: ConnectionOptions = { multipleStatements: true };
|
|
24
|
+
|
|
25
|
+
private connectionCache = new Map<string, Promise<ConnectionAndTunnel<Connection, MysqlDbConfig>>>(); //// Cache the promises to make it a single connection
|
|
26
|
+
//private tunnels = new Map<string, SshTunnelContainer>();
|
|
27
|
+
//private dbPromise = new Map<string, Promise<Connection | undefined>>(); // Cache the promises to make it a single connection
|
|
28
|
+
private cacheConfigPromise: Promise<DatabaseConfigList<MysqlDbConfig>>;
|
|
29
|
+
constructor(
|
|
30
|
+
private configPromiseProvider: () => Promise<DatabaseConfigList<MysqlDbConfig>>,
|
|
31
|
+
private additionalConfig: ConnectionOptions = RdsMysqlStyleConnectionProvider.DEFAULT_CONNECTION_OPTIONS,
|
|
32
|
+
private ssh?: SshTunnelService,
|
|
33
|
+
) {
|
|
34
|
+
this.cacheConfigPromise = this.createConnectionConfig(); // Sets up tunnels, etc
|
|
35
|
+
Logger.info('Added shutdown handler to the process (Only once per instantiation)');
|
|
36
|
+
this.addShutdownHandlerToProcess();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public get usingSshTunnel(): boolean {
|
|
40
|
+
return !!this.ssh;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private addShutdownHandlerToProcess(): void {
|
|
44
|
+
process.on('exit', () => {
|
|
45
|
+
Logger.info('Process is shutting down, closing connections');
|
|
46
|
+
this.clearDatabaseAccessCache().catch((err) => {
|
|
47
|
+
Logger.error('Shutdown connection failed : %s', err);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public async clearDatabaseAccessCache(): Promise<boolean> {
|
|
53
|
+
const rval = false;
|
|
54
|
+
Logger.info('Clearing connection cache for RdsMysqlConnectionProvider');
|
|
55
|
+
// First, clear the connection caches so that subsequent connection attempts start fresh
|
|
56
|
+
const oldConnections: Promise<ConnectionAndTunnel<Connection, MysqlDbConfig>>[] = Array.from(this.connectionCache.values());
|
|
57
|
+
//const oldDbHooks = this.dbPromise;
|
|
58
|
+
//const oldSshTunnels = this.tunnels;
|
|
59
|
+
this.cacheConfigPromise = null; // Re-read config in case the password expired, etc
|
|
60
|
+
this.connectionCache = new Map();
|
|
61
|
+
//this.tunnels = new Map();
|
|
62
|
+
// Resolve any leftover DB connections & end them
|
|
63
|
+
if (oldConnections.length > 0) {
|
|
64
|
+
for (let i = 0; i < oldConnections.length; i++) {
|
|
65
|
+
Logger.info('Shutting down old connection %d of %d', i, oldConnections.length);
|
|
66
|
+
try {
|
|
67
|
+
const conn: ConnectionAndTunnel<Connection, MysqlDbConfig> = await oldConnections[i];
|
|
68
|
+
Logger.info('Conn %d is %s', i, conn?.config?.label);
|
|
69
|
+
if (conn.db) {
|
|
70
|
+
Logger.info('Stopping connection to database');
|
|
71
|
+
try {
|
|
72
|
+
conn.db.destroy();
|
|
73
|
+
Logger.info('Database connection closed');
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (ErrorRatchet.asErr(err).message.includes('closed state')) {
|
|
76
|
+
// DB was already closed, ignore
|
|
77
|
+
} else {
|
|
78
|
+
Logger.error('Something went wrong closing the database connection : %s', err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (conn.ssh) {
|
|
83
|
+
try {
|
|
84
|
+
Logger.info('Stopping ssh tunnel');
|
|
85
|
+
await this.ssh.shutdown(conn.ssh);
|
|
86
|
+
Logger.info('Ssh tunnel stopped');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
Logger.warn('Failed to stop ssh tunnel : %s', err, err);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
Logger.warn('Shutdown failed : %s ', err, err);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
Logger.info('Old db and tunnels removed');
|
|
97
|
+
return rval;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public async getConnectionAndTunnel(name: string): Promise<ConnectionAndTunnel<Connection, MysqlDbConfig>> {
|
|
101
|
+
Logger.silly('getConnectionAndTunnel : %s', name);
|
|
102
|
+
if (!this.connectionCache.has(name)) {
|
|
103
|
+
Logger.info('No connectionCache found for %s - creating new one', name);
|
|
104
|
+
const dbConfig = await this.getDbConfig(name);
|
|
105
|
+
const connection = this.createConnectionAndTunnel(dbConfig, this.additionalConfig, true);
|
|
106
|
+
this.connectionCache.set(name, connection);
|
|
107
|
+
Logger.info('Added connectionCache for %s', name);
|
|
108
|
+
}
|
|
109
|
+
return this.connectionCache.get(name);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public async getDatabaseAccess(name: string): Promise<DatabaseAccess | undefined> {
|
|
113
|
+
Logger.silly('getConnection : %s', name);
|
|
114
|
+
const conn: ConnectionAndTunnel<Connection, MysqlDbConfig> = await this.getConnectionAndTunnel(name);
|
|
115
|
+
const rval: DatabaseAccess = conn?.db ? new MysqlStyleDatabaseAccess(conn.db, this.additionalConfig) : null;
|
|
116
|
+
return rval;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public async createNonPooledConnectionAndTunnel(
|
|
120
|
+
queryDefaults: QueryDefaults,
|
|
121
|
+
additionalConfig: ConnectionOptions = RdsMysqlStyleConnectionProvider.DEFAULT_CONNECTION_OPTIONS,
|
|
122
|
+
): Promise<ConnectionAndTunnel<Connection, MysqlDbConfig>> {
|
|
123
|
+
Logger.info('Creating non-pooled connection for %s', queryDefaults.databaseName);
|
|
124
|
+
const dbConfig = await this.getDbConfig(queryDefaults.databaseName);
|
|
125
|
+
const rval = await this.createConnectionAndTunnel(dbConfig, additionalConfig, false);
|
|
126
|
+
return rval;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public async createNonPooledDatabaseConnection(
|
|
130
|
+
queryDefaults: QueryDefaults,
|
|
131
|
+
additionalConfig: ConnectionOptions = RdsMysqlStyleConnectionProvider.DEFAULT_CONNECTION_OPTIONS,
|
|
132
|
+
): Promise<Connection | undefined> {
|
|
133
|
+
const conTunnel: ConnectionAndTunnel<Connection, MysqlDbConfig> = await this.createNonPooledConnectionAndTunnel(
|
|
134
|
+
queryDefaults,
|
|
135
|
+
additionalConfig,
|
|
136
|
+
);
|
|
137
|
+
return conTunnel?.db;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async getDbConfig(name: string): Promise<MysqlDbConfig> {
|
|
141
|
+
Logger.info('RdsMysqlStyleConnectionProvider:getDbConfig:Initiating promise for %s', name);
|
|
142
|
+
const cfgs: DatabaseConfigList<MysqlDbConfig> = await this.configPromise();
|
|
143
|
+
const finder: string = StringRatchet.trimToEmpty(name).toLowerCase();
|
|
144
|
+
const dbConfig = cfgs.dbList.find((s) => StringRatchet.trimToEmpty(s.label).toLowerCase() === finder);
|
|
145
|
+
if (!dbConfig) {
|
|
146
|
+
throw ErrorRatchet.fErr(
|
|
147
|
+
'Cannot find any connection config named %s (Available are %j)',
|
|
148
|
+
name,
|
|
149
|
+
cfgs.dbList.map((d) => d.label),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return dbConfig;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Always creates a promise
|
|
156
|
+
private async createConnectionAndTunnel(
|
|
157
|
+
dbCfg: MysqlDbConfig,
|
|
158
|
+
additionalConfig: ConnectionOptions = RdsMysqlStyleConnectionProvider.DEFAULT_CONNECTION_OPTIONS,
|
|
159
|
+
clearCacheOnConnectionFailure: boolean,
|
|
160
|
+
): Promise<ConnectionAndTunnel<Connection, MysqlDbConfig> | undefined> {
|
|
161
|
+
Logger.info('In RdsMysqlStyleConnectionProvider:createConnectionAndTunnel : %s', dbCfg.label);
|
|
162
|
+
RequireRatchet.notNullOrUndefined(dbCfg, 'dbCfg');
|
|
163
|
+
|
|
164
|
+
let tunnel: SshTunnelContainer = null;
|
|
165
|
+
if (dbCfg.sshTunnelConfig) {
|
|
166
|
+
const localPort: number = dbCfg.sshTunnelConfig.forceLocalPort || (await getPort());
|
|
167
|
+
Logger.debug(
|
|
168
|
+
'SSH tunnel config found, opening tunnel to %s / %s to using local port %s',
|
|
169
|
+
dbCfg.sshTunnelConfig.host,
|
|
170
|
+
dbCfg.sshTunnelConfig.port,
|
|
171
|
+
localPort,
|
|
172
|
+
);
|
|
173
|
+
tunnel = await this.ssh.createSSHTunnel(dbCfg.sshTunnelConfig, dbCfg.host, dbCfg.port, localPort);
|
|
174
|
+
Logger.debug('SSH Tunnel open');
|
|
175
|
+
} else {
|
|
176
|
+
Logger.debug('No ssh configuration - skipping tunnel');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
Logger.debug('Opening connection for RdsMysqlStyleConnectionProvider');
|
|
180
|
+
let connection: Connection;
|
|
181
|
+
try {
|
|
182
|
+
const cfgCopy: MysqlDbConfig = structuredClone(dbCfg);
|
|
183
|
+
delete cfgCopy.label;
|
|
184
|
+
delete cfgCopy.sshTunnelConfig;
|
|
185
|
+
if (tunnel) {
|
|
186
|
+
cfgCopy.host = 'localhost';
|
|
187
|
+
cfgCopy.port = tunnel.localPort;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
connection = await maria.createConnection({ ...additionalConfig, ...cfgCopy });
|
|
191
|
+
} catch (err) {
|
|
192
|
+
Logger.info('Failed trying to create connection : %s : clearing for retry', err);
|
|
193
|
+
if (clearCacheOnConnectionFailure) {
|
|
194
|
+
this.connectionCache = new Map<string, Promise<ConnectionAndTunnel<Connection, MysqlDbConfig>>>();
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
connection.on('error', (err) => {
|
|
199
|
+
Logger.info('An error was detected on the connection : %s : Clearing', err);
|
|
200
|
+
this.clearDatabaseAccessCache()
|
|
201
|
+
.then((cleared) => {
|
|
202
|
+
Logger.info('Connection cleared: %s', cleared);
|
|
203
|
+
})
|
|
204
|
+
.catch((err) => Logger.error('Failed to clear RDS connection cache: %j', err));
|
|
205
|
+
});
|
|
206
|
+
Logger.info(
|
|
207
|
+
'Added error handler to db, there are now %d error handlers and %d shutdown handlers',
|
|
208
|
+
connection.rawListeners('error').length,
|
|
209
|
+
process.rawListeners('exit').length,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const rval: ConnectionAndTunnel<Connection, MysqlDbConfig> = {
|
|
213
|
+
config: dbCfg,
|
|
214
|
+
db: connection,
|
|
215
|
+
ssh: tunnel,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
return rval;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private configPromise(): Promise<DatabaseConfigList<MysqlDbConfig>> {
|
|
222
|
+
if (!this.cacheConfigPromise) {
|
|
223
|
+
this.cacheConfigPromise = this.createConnectionConfig();
|
|
224
|
+
}
|
|
225
|
+
return this.cacheConfigPromise;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private async createConnectionConfig(): Promise<DatabaseConfigList<MysqlDbConfig>> {
|
|
229
|
+
RequireRatchet.notNullOrUndefined(this.configPromiseProvider, 'input');
|
|
230
|
+
const inputPromise: Promise<DatabaseConfigList<MysqlDbConfig>> = this.configPromiseProvider();
|
|
231
|
+
Logger.info('Creating connection config');
|
|
232
|
+
const cfg: DatabaseConfigList<MysqlDbConfig> = await inputPromise;
|
|
233
|
+
RequireRatchet.true(cfg.dbList.length > 0, 'input.dbList');
|
|
234
|
+
|
|
235
|
+
cfg.dbList.forEach((db) => {
|
|
236
|
+
const errors: string[] = RdsMysqlStyleConnectionProvider.validDbConfig(db);
|
|
237
|
+
if (errors?.length) {
|
|
238
|
+
throw ErrorRatchet.fErr('Errors found in db config : %j', errors);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
return cfg;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
public static validDbConfig(cfg: MysqlDbConfig): string[] {
|
|
245
|
+
let rval: string[] = [];
|
|
246
|
+
if (!cfg) {
|
|
247
|
+
rval.push('The config is null');
|
|
248
|
+
} else {
|
|
249
|
+
rval.push(StringRatchet.trimToNull(cfg.host) ? null : 'host is required and non-empty');
|
|
250
|
+
rval.push(StringRatchet.trimToNull(cfg.label) ? null : 'label is required and non-empty');
|
|
251
|
+
rval.push(StringRatchet.trimToNull(cfg.database) ? null : 'database is required and non-empty');
|
|
252
|
+
rval.push(StringRatchet.trimToNull(cfg.user) ? null : 'user is required and non-empty');
|
|
253
|
+
rval.push(StringRatchet.trimToNull(cfg.password) ? null : 'password is required and non-empty');
|
|
254
|
+
rval.push(cfg.port ? null : 'port is required and non-empty');
|
|
255
|
+
}
|
|
256
|
+
if (cfg.sshTunnelConfig) {
|
|
257
|
+
rval.push(
|
|
258
|
+
StringRatchet.trimToNull(cfg.sshTunnelConfig.host) ? null : 'If sshTunnelConfig is non-null, host is required and non-empty',
|
|
259
|
+
);
|
|
260
|
+
rval.push(cfg.sshTunnelConfig.port ? null : 'If sshTunnelConfig is non-null, port is required and non-empty');
|
|
261
|
+
}
|
|
262
|
+
rval = rval.filter((s) => !!s);
|
|
263
|
+
return rval;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
|
|
2
|
+
import { RequireRatchet } from '@bitblit/ratchet-common/lang/require-ratchet';
|
|
3
|
+
import { Logger } from '@bitblit/ratchet-common/logger/logger';
|
|
4
|
+
import { ErrorRatchet } from '@bitblit/ratchet-common/lang/error-ratchet';
|
|
5
|
+
import { StringRatchet } from '@bitblit/ratchet-common/lang/string-ratchet';
|
|
6
|
+
import { SshTunnelService } from '../service/ssh-tunnel-service.js';
|
|
7
|
+
import { DatabaseConfigList } from '../model/database-config-list.js';
|
|
8
|
+
import getPort from 'get-port';
|
|
9
|
+
import { SshTunnelContainer } from '../model/ssh/ssh-tunnel-container.js';
|
|
10
|
+
import { QueryDefaults } from '../model/query-defaults.js';
|
|
11
|
+
import { ConnectionAndTunnel } from '../model/connection-and-tunnel.js';
|
|
12
|
+
import { DatabaseAccessProvider } from '../model/database-access-provider.js';
|
|
13
|
+
import { DatabaseAccess } from '../model/database-access.js';
|
|
14
|
+
import { Client } from 'pg';
|
|
15
|
+
import { PostgresDbConfig } from "./model/postgres-db-config.ts";
|
|
16
|
+
import { PostgresStyleDatabaseAccess } from "./postgres-style-database-access.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
*/
|
|
20
|
+
export class PostgresStyleConnectionProvider implements DatabaseAccessProvider {
|
|
21
|
+
|
|
22
|
+
private connectionCache = new Map<string, Promise<ConnectionAndTunnel<Client, PostgresDbConfig>>>(); //// Cache the promises to make it a single connection
|
|
23
|
+
//private tunnels = new Map<string, SshTunnelContainer>();
|
|
24
|
+
//private dbPromise = new Map<string, Promise<Connection | undefined>>(); // Cache the promises to make it a single connection
|
|
25
|
+
private cacheConfigPromise: Promise<DatabaseConfigList<PostgresDbConfig>>;
|
|
26
|
+
constructor(
|
|
27
|
+
private configPromiseProvider: () => Promise<DatabaseConfigList<PostgresDbConfig>>,
|
|
28
|
+
private ssh?: SshTunnelService,
|
|
29
|
+
) {
|
|
30
|
+
this.cacheConfigPromise = this.createConnectionConfig(); // Sets up tunnels, etc
|
|
31
|
+
Logger.info('Added shutdown handler to the process (Only once per instantiation)');
|
|
32
|
+
this.addShutdownHandlerToProcess();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public get usingSshTunnel(): boolean {
|
|
36
|
+
return !!this.ssh;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private addShutdownHandlerToProcess(): void {
|
|
40
|
+
process.on('exit', () => {
|
|
41
|
+
Logger.info('Process is shutting down, closing connections');
|
|
42
|
+
this.clearDatabaseAccessCache().catch((err) => {
|
|
43
|
+
Logger.error('Shutdown connection failed : %s', err);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public async clearDatabaseAccessCache(): Promise<boolean> {
|
|
49
|
+
const rval = false;
|
|
50
|
+
Logger.info('Clearing connection cache for PostgresStyleConnectionProvider');
|
|
51
|
+
// First, clear the connection caches so that subsequent connection attempts start fresh
|
|
52
|
+
const oldConnections: Promise<ConnectionAndTunnel<Client, PostgresDbConfig>>[] = Array.from(this.connectionCache.values());
|
|
53
|
+
//const oldDbHooks = this.dbPromise;
|
|
54
|
+
//const oldSshTunnels = this.tunnels;
|
|
55
|
+
this.cacheConfigPromise = null; // Re-read config in case the password expired, etc
|
|
56
|
+
this.connectionCache = new Map();
|
|
57
|
+
//this.tunnels = new Map();
|
|
58
|
+
// Resolve any leftover DB connections & end them
|
|
59
|
+
if (oldConnections.length > 0) {
|
|
60
|
+
for (let i = 0; i < oldConnections.length; i++) {
|
|
61
|
+
Logger.info('Shutting down old connection %d of %d', i, oldConnections.length);
|
|
62
|
+
try {
|
|
63
|
+
const conn: ConnectionAndTunnel<Client, PostgresDbConfig> = await oldConnections[i];
|
|
64
|
+
Logger.info('Conn %d is %s', i, conn?.config?.label);
|
|
65
|
+
if (conn.db) {
|
|
66
|
+
Logger.info('Stopping connection to database');
|
|
67
|
+
try {
|
|
68
|
+
await conn.db.end();
|
|
69
|
+
Logger.info('Database connection closed');
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (ErrorRatchet.asErr(err).message.includes('closed state')) {
|
|
72
|
+
// DB was already closed, ignore
|
|
73
|
+
} else {
|
|
74
|
+
Logger.error('Something went wrong closing the database connection : %s', err);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (conn.ssh) {
|
|
79
|
+
try {
|
|
80
|
+
Logger.info('Stopping ssh tunnel');
|
|
81
|
+
await this.ssh.shutdown(conn.ssh);
|
|
82
|
+
Logger.info('Ssh tunnel stopped');
|
|
83
|
+
} catch (err) {
|
|
84
|
+
Logger.warn('Failed to stop ssh tunnel : %s', err, err);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
Logger.warn('Shutdown failed : %s ', err, err);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
Logger.info('Old db and tunnels removed');
|
|
93
|
+
return rval;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public async getConnectionAndTunnel(name: string): Promise<ConnectionAndTunnel<Client, PostgresDbConfig>> {
|
|
97
|
+
Logger.silly('getConnectionAndTunnel : %s', name);
|
|
98
|
+
if (!this.connectionCache.has(name)) {
|
|
99
|
+
Logger.info('No connectionCache found for %s - creating new one', name);
|
|
100
|
+
const dbConfig = await this.getDbConfig(name);
|
|
101
|
+
const connection = this.createConnectionAndTunnel(dbConfig, true);
|
|
102
|
+
this.connectionCache.set(name, connection);
|
|
103
|
+
Logger.info('Added connectionCache for %s', name);
|
|
104
|
+
}
|
|
105
|
+
return this.connectionCache.get(name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public async getDatabaseAccess(name: string): Promise<DatabaseAccess | undefined> {
|
|
109
|
+
Logger.silly('getConnection : %s', name);
|
|
110
|
+
|
|
111
|
+
const conn: ConnectionAndTunnel<Client, PostgresDbConfig> = await this.getConnectionAndTunnel(name);
|
|
112
|
+
const dbConfig = await this.getDbConfig(name);
|
|
113
|
+
const rval: DatabaseAccess = conn?.db ? new PostgresStyleDatabaseAccess(conn.db, dbConfig) : null;
|
|
114
|
+
return rval;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public async createNonPooledConnectionAndTunnel(
|
|
118
|
+
queryDefaults: QueryDefaults
|
|
119
|
+
): Promise<ConnectionAndTunnel<Client, PostgresDbConfig>> {
|
|
120
|
+
Logger.info('Creating non-pooled connection for %s', queryDefaults.databaseName);
|
|
121
|
+
const dbConfig = await this.getDbConfig(queryDefaults.databaseName);
|
|
122
|
+
const rval = await this.createConnectionAndTunnel(dbConfig, false);
|
|
123
|
+
return rval;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public async createNonPooledDatabaseConnection(
|
|
127
|
+
queryDefaults: QueryDefaults
|
|
128
|
+
): Promise<Client | undefined> {
|
|
129
|
+
const conTunnel: ConnectionAndTunnel<Client, PostgresDbConfig> = await this.createNonPooledConnectionAndTunnel(
|
|
130
|
+
queryDefaults
|
|
131
|
+
);
|
|
132
|
+
return conTunnel?.db;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private async getDbConfig(name: string): Promise<PostgresDbConfig> {
|
|
136
|
+
Logger.info('PostgresStyleConnectionProvider:getDbConfig:Initiating promise for %s', name);
|
|
137
|
+
const cfgs: DatabaseConfigList<PostgresDbConfig> = await this.configPromise();
|
|
138
|
+
const finder: string = StringRatchet.trimToEmpty(name).toLowerCase();
|
|
139
|
+
const dbConfig = cfgs.dbList.find((s) => StringRatchet.trimToEmpty(s.label).toLowerCase() === finder);
|
|
140
|
+
if (!dbConfig) {
|
|
141
|
+
throw ErrorRatchet.fErr(
|
|
142
|
+
'Cannot find any connection config named %s (Available are %j)',
|
|
143
|
+
name,
|
|
144
|
+
cfgs.dbList.map((d) => d.label),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return dbConfig;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Always creates a promise
|
|
151
|
+
private async createConnectionAndTunnel(
|
|
152
|
+
dbCfg: PostgresDbConfig,
|
|
153
|
+
clearCacheOnConnectionFailure: boolean,
|
|
154
|
+
): Promise<ConnectionAndTunnel<Client, PostgresDbConfig> | undefined> {
|
|
155
|
+
Logger.info('In PostgresStyleConnectionProvider:createConnectionAndTunnel : %s', dbCfg.label);
|
|
156
|
+
RequireRatchet.notNullOrUndefined(dbCfg, 'dbCfg');
|
|
157
|
+
|
|
158
|
+
let tunnel: SshTunnelContainer = null;
|
|
159
|
+
if (dbCfg.sshTunnelConfig) {
|
|
160
|
+
const localPort: number = dbCfg.sshTunnelConfig.forceLocalPort || (await getPort());
|
|
161
|
+
Logger.debug(
|
|
162
|
+
'SSH tunnel config found, opening tunnel to %s / %s to using local port %s',
|
|
163
|
+
dbCfg.sshTunnelConfig.host,
|
|
164
|
+
dbCfg.sshTunnelConfig.port,
|
|
165
|
+
localPort,
|
|
166
|
+
);
|
|
167
|
+
tunnel = await this.ssh.createSSHTunnel(dbCfg.sshTunnelConfig, dbCfg.dbConfig.host, dbCfg.dbConfig.port, localPort);
|
|
168
|
+
Logger.debug('SSH Tunnel open');
|
|
169
|
+
} else {
|
|
170
|
+
Logger.debug('No ssh configuration - skipping tunnel');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
Logger.debug('Opening connection for PostgresStyleConnectionProvider');
|
|
174
|
+
let connection: Client;
|
|
175
|
+
try {
|
|
176
|
+
const cfgCopy: PostgresDbConfig = structuredClone(dbCfg);
|
|
177
|
+
delete cfgCopy.label;
|
|
178
|
+
delete cfgCopy.sshTunnelConfig;
|
|
179
|
+
if (tunnel) {
|
|
180
|
+
cfgCopy.dbConfig.host = 'localhost';
|
|
181
|
+
cfgCopy.dbConfig.port = tunnel.localPort;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
connection = new Client(cfgCopy.dbConfig);
|
|
185
|
+
await connection.connect();
|
|
186
|
+
} catch (err) {
|
|
187
|
+
Logger.info('Failed trying to create connection : %s : clearing for retry', err);
|
|
188
|
+
if (clearCacheOnConnectionFailure) {
|
|
189
|
+
this.connectionCache = new Map<string, Promise<ConnectionAndTunnel<Client, PostgresDbConfig>>>();
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
/*
|
|
194
|
+
TODO: Does postgres have error handlers like this (eg, like mysql driver?)
|
|
195
|
+
connection.on('error', (err) => {
|
|
196
|
+
Logger.info('An error was detected on the connection : %s : Clearing', err);
|
|
197
|
+
this.clearDatabaseAccessCache()
|
|
198
|
+
.then((cleared) => {
|
|
199
|
+
Logger.info('Connection cleared: %s', cleared);
|
|
200
|
+
})
|
|
201
|
+
.catch((err) => Logger.error('Failed to clear connection cache: %j', err));
|
|
202
|
+
});
|
|
203
|
+
Logger.info(
|
|
204
|
+
'Added error handler to db, there are now %d error handlers and %d shutdown handlers',
|
|
205
|
+
connection.rawListeners('error').length,
|
|
206
|
+
process.rawListeners('exit').length,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
*/
|
|
210
|
+
|
|
211
|
+
const rval: ConnectionAndTunnel<Client, PostgresDbConfig> = {
|
|
212
|
+
config: dbCfg,
|
|
213
|
+
db: connection,
|
|
214
|
+
ssh: tunnel,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
return rval;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private configPromise(): Promise<DatabaseConfigList<PostgresDbConfig>> {
|
|
221
|
+
if (!this.cacheConfigPromise) {
|
|
222
|
+
this.cacheConfigPromise = this.createConnectionConfig();
|
|
223
|
+
}
|
|
224
|
+
return this.cacheConfigPromise;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async createConnectionConfig(): Promise<DatabaseConfigList<PostgresDbConfig>> {
|
|
228
|
+
RequireRatchet.notNullOrUndefined(this.configPromiseProvider, 'input');
|
|
229
|
+
const inputPromise: Promise<DatabaseConfigList<PostgresDbConfig>> = this.configPromiseProvider();
|
|
230
|
+
Logger.info('Creating connection config');
|
|
231
|
+
const cfg: DatabaseConfigList<PostgresDbConfig> = await inputPromise;
|
|
232
|
+
RequireRatchet.true(cfg.dbList.length > 0, 'input.dbList');
|
|
233
|
+
|
|
234
|
+
cfg.dbList.forEach((db) => {
|
|
235
|
+
const errors: string[] = PostgresStyleConnectionProvider.validDbConfig(db);
|
|
236
|
+
if (errors?.length) {
|
|
237
|
+
throw ErrorRatchet.fErr('Errors found in db config : %j', errors);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
return cfg;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
public static validDbConfig(cfg: PostgresDbConfig): string[] {
|
|
244
|
+
let rval: string[] = [];
|
|
245
|
+
if (!cfg) {
|
|
246
|
+
rval.push('The config is null');
|
|
247
|
+
} else if (!cfg.dbConfig) {
|
|
248
|
+
rval.push('The options field is null');
|
|
249
|
+
} else {
|
|
250
|
+
if (StringRatchet.trimToNull(cfg?.dbConfig?.connectionString)) {
|
|
251
|
+
// TODO: validate connection string here
|
|
252
|
+
} else {
|
|
253
|
+
rval.push(StringRatchet.trimToNull(cfg.dbConfig.host) ? null : 'host is required and non-empty');
|
|
254
|
+
rval.push(StringRatchet.trimToNull(cfg.label) ? null : 'label is required and non-empty');
|
|
255
|
+
rval.push(StringRatchet.trimToNull(cfg.dbConfig.database) ? null : 'database is required and non-empty');
|
|
256
|
+
rval.push(StringRatchet.trimToNull(cfg.dbConfig.user) ? null : 'user is required and non-empty');
|
|
257
|
+
rval.push(cfg.dbConfig.password ? null : 'password is required and non-empty');
|
|
258
|
+
rval.push(cfg.dbConfig.port ? null : 'port is required and non-empty');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (cfg.sshTunnelConfig) {
|
|
262
|
+
rval.push(
|
|
263
|
+
StringRatchet.trimToNull(cfg.sshTunnelConfig.host) ? null : 'If sshTunnelConfig is non-null, host is required and non-empty',
|
|
264
|
+
);
|
|
265
|
+
rval.push(cfg.sshTunnelConfig.port ? null : 'If sshTunnelConfig is non-null, port is required and non-empty');
|
|
266
|
+
}
|
|
267
|
+
rval = rval.filter((s) => !!s);
|
|
268
|
+
return rval;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, test } from "vitest";
|
|
2
|
+
import { NamedParameterDatabaseService } from "../service/named-parameter-database-service.js";
|
|
3
|
+
import { Logger } from "@bitblit/ratchet-common/logger/logger";
|
|
4
|
+
import { SimpleQueryTextProvider } from "../model/simple-query-text-provider.js";
|
|
5
|
+
import { PostgresStyleConnectionProvider } from "./postgres-style-connection-provider";
|
|
6
|
+
import { DatabaseConfigList } from "../model/database-config-list";
|
|
7
|
+
import { PostgresDbConfig } from "./model/postgres-db-config";
|
|
8
|
+
import { StringRatchet } from "@bitblit/ratchet-common/lang/string-ratchet";
|
|
9
|
+
import { ModifyResults } from "../model/modify-results";
|
|
10
|
+
|
|
11
|
+
describe('postgres-style-database-access', () => {
|
|
12
|
+
const testQueries: SimpleQueryTextProvider = new SimpleQueryTextProvider({
|
|
13
|
+
create: 'create table testable (id SERIAL PRIMARY KEY, val varchar(255))',
|
|
14
|
+
fetchWithParam: 'select * from testable where val = :val',
|
|
15
|
+
singleIns: 'insert into testable (val) values (:val) RETURNING id',
|
|
16
|
+
singleDelete: 'delete from testable where val=:val',
|
|
17
|
+
csvIns: 'insert into testable (val) values (:valCsv)',
|
|
18
|
+
counter: 'select count(1) as cnt from testable',
|
|
19
|
+
multi: 'insert into testable (val) values :multiVal',
|
|
20
|
+
multiSelect: 'select * from testable',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('runs test query', async () => {
|
|
24
|
+
const ratchetPostgresTestDatabaseConnectionString = StringRatchet.trimToNull(process.env['RATCHET_POSTGRES_TEST_DATABASE_CONNECT_STRING']);
|
|
25
|
+
if (ratchetPostgresTestDatabaseConnectionString) {
|
|
26
|
+
const cfgProvider = async () =>{
|
|
27
|
+
const rval: DatabaseConfigList<PostgresDbConfig> = {
|
|
28
|
+
dbList: [
|
|
29
|
+
{
|
|
30
|
+
label: 'test',
|
|
31
|
+
sshTunnelConfig: null,
|
|
32
|
+
dbConfig: {
|
|
33
|
+
connectionString: ratchetPostgresTestDatabaseConnectionString,
|
|
34
|
+
ssl: { rejectUnauthorized: false }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
};
|
|
39
|
+
return rval;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Memory database
|
|
43
|
+
const prov: PostgresStyleConnectionProvider = new PostgresStyleConnectionProvider(cfgProvider);
|
|
44
|
+
const ns: NamedParameterDatabaseService = new NamedParameterDatabaseService({
|
|
45
|
+
serviceName: 'Test',
|
|
46
|
+
queryProvider: testQueries,
|
|
47
|
+
connectionProvider: prov,
|
|
48
|
+
queryDefaults: { databaseName: 'test', timeoutMS: 20_000 },
|
|
49
|
+
longQueryTimeMs: 8_500,
|
|
50
|
+
});
|
|
51
|
+
const testConnVal: any = await ns.testConnection(true);
|
|
52
|
+
Logger.info('TestConnection returned : %j', testConnVal);
|
|
53
|
+
let selectCounter: any = await ns.executeQueryByNameSingle('counter');
|
|
54
|
+
Logger.info('selectCounter returned : %j', selectCounter);
|
|
55
|
+
const multiSelect: any[] = await ns.executeQueryByName('multiSelect');
|
|
56
|
+
Logger.info('multiSelect returned : %j', multiSelect);
|
|
57
|
+
const testVal: string = 'TestValue:'+Date.now();
|
|
58
|
+
const insert: ModifyResults = await ns.executeUpdateOrInsertByName('singleIns', {val: testVal});
|
|
59
|
+
Logger.info('insert returned : %j', insert);
|
|
60
|
+
selectCounter = await ns.executeQueryByNameSingle('counter');
|
|
61
|
+
Logger.info('selectCounter returned : %j', selectCounter);
|
|
62
|
+
|
|
63
|
+
const fetch: any = await ns.executeQueryByNameSingle('fetchWithParam', {val: testVal});
|
|
64
|
+
Logger.info('fetch returned : %j', fetch);
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
const del: ModifyResults = await ns.executeUpdateOrInsertByName('singleDelete', {val: testVal});
|
|
68
|
+
Logger.info('del returned : %j', del);
|
|
69
|
+
selectCounter = await ns.executeQueryByNameSingle('counter');
|
|
70
|
+
Logger.info('selectCounter returned : %j', selectCounter);
|
|
71
|
+
|
|
72
|
+
} else {
|
|
73
|
+
Logger.info('Skipping - no connection string found')
|
|
74
|
+
}
|
|
75
|
+
}, 30_000);
|
|
76
|
+
});
|