@shibbirweb/mcp-db-read-only 0.1.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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.dockerhub.md +354 -0
- package/README.md +387 -0
- package/dist/ApplicationFactory.js +144 -0
- package/dist/config/EnvironmentConfigLoader.js +179 -0
- package/dist/config/PackageVersionLoader.js +43 -0
- package/dist/connections/ConnectionManager.js +101 -0
- package/dist/connections/ConnectionRegistry.js +109 -0
- package/dist/connections/ConnectionTargetFactory.js +104 -0
- package/dist/connections/ConnectionUrlParser.js +195 -0
- package/dist/domain/ConnectionProfile.js +39 -0
- package/dist/domain/ConnectionTarget.js +139 -0
- package/dist/domain/Engine.js +159 -0
- package/dist/drivers/BaseDriver.js +35 -0
- package/dist/drivers/DatabaseDriver.js +1 -0
- package/dist/drivers/DriverCache.js +107 -0
- package/dist/drivers/DriverProvider.js +71 -0
- package/dist/drivers/DriverRegistry.js +24 -0
- package/dist/drivers/GlobPattern.js +41 -0
- package/dist/drivers/LazyResource.js +56 -0
- package/dist/drivers/document/MongoDriver.js +187 -0
- package/dist/drivers/document/MongoSchemaSampler.js +74 -0
- package/dist/drivers/document/MongoStageAllowlist.js +87 -0
- package/dist/drivers/keyvalue/RedisCommandFlagsGuard.js +69 -0
- package/dist/drivers/keyvalue/RedisDriver.js +224 -0
- package/dist/drivers/search/ElasticsearchDriver.js +159 -0
- package/dist/drivers/sql/ClickHouseDriver.js +156 -0
- package/dist/drivers/sql/MsSqlDriver.js +147 -0
- package/dist/drivers/sql/MySqlDriver.js +144 -0
- package/dist/drivers/sql/MySqlSessionInitializer.js +100 -0
- package/dist/drivers/sql/PostgresDriver.js +176 -0
- package/dist/drivers/sql/SqlIdentifier.js +36 -0
- package/dist/drivers/sql/SqliteDriver.js +202 -0
- package/dist/drivers/sql/SqliteProtocol.js +7 -0
- package/dist/drivers/sql/SqliteWorker.js +71 -0
- package/dist/errors/ApplicationError.js +15 -0
- package/dist/errors/EngineMismatchError.js +15 -0
- package/dist/errors/InvalidConnectionUrlError.js +14 -0
- package/dist/errors/InvalidProfileDefinitionError.js +15 -0
- package/dist/errors/NoActiveConnectionError.js +13 -0
- package/dist/errors/NoDatabaseSelectedError.js +13 -0
- package/dist/errors/ObjectNotFoundError.js +14 -0
- package/dist/errors/UnknownProfileError.js +16 -0
- package/dist/errors/UnsupportedOperationError.js +14 -0
- package/dist/errors/index.js +9 -0
- package/dist/formatting/JsonSerializer.js +49 -0
- package/dist/formatting/RowFormatter.js +43 -0
- package/dist/formatting/ToolResponse.js +25 -0
- package/dist/index.js +15 -0
- package/dist/server/McpDbServer.js +69 -0
- package/dist/tools/BaseTool.js +42 -0
- package/dist/tools/DatabaseScopedTool.js +61 -0
- package/dist/tools/QueryTools.js +13 -0
- package/dist/tools/browse/DescribeTableTool.js +37 -0
- package/dist/tools/browse/GetForeignKeysTool.js +36 -0
- package/dist/tools/browse/GetTableIndexesTool.js +30 -0
- package/dist/tools/browse/GetTableSampleTool.js +50 -0
- package/dist/tools/browse/ListTablesTool.js +51 -0
- package/dist/tools/connection/ConnectTool.js +67 -0
- package/dist/tools/connection/CurrentConnectionTool.js +36 -0
- package/dist/tools/connection/ListConnectionsTool.js +38 -0
- package/dist/tools/connection/ListDatabasesTool.js +41 -0
- package/dist/tools/connection/UseConnectionTool.js +57 -0
- package/dist/tools/connection/UseDatabaseTool.js +45 -0
- package/dist/tools/document/AggregateTool.js +44 -0
- package/dist/tools/document/CountDocumentsTool.js +32 -0
- package/dist/tools/document/DistinctValuesTool.js +36 -0
- package/dist/tools/document/DocumentTool.js +38 -0
- package/dist/tools/document/FindDocumentsTool.js +56 -0
- package/dist/tools/keyvalue/RedisCommandTool.js +40 -0
- package/dist/tools/search/SearchTool.js +54 -0
- package/dist/tools/sql/RunQueryTool.js +46 -0
- package/dist/types/config.types.js +1 -0
- package/dist/types/connection.types.js +1 -0
- package/dist/types/driver.types.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/tool.types.js +1 -0
- package/dist/types/validation.types.js +1 -0
- package/dist/validation/document/MongoOperatorGuard.js +72 -0
- package/dist/validation/keyvalue/RedisCommandValidator.js +176 -0
- package/dist/validation/names/NamePolicy.js +122 -0
- package/dist/validation/names/NamePolicyRegistry.js +33 -0
- package/dist/validation/search/SearchBodyValidator.js +56 -0
- package/dist/validation/sql/ReadOnlyQueryValidator.js +82 -0
- package/dist/validation/sql/SqlDialect.js +196 -0
- package/dist/validation/sql/SqlSkeletonizer.js +197 -0
- package/dist/validation/sql/SqlValidatorRegistry.js +24 -0
- package/dist/validation/sql/rules/AmbiguousSyntaxRule.js +23 -0
- package/dist/validation/sql/rules/EmptyQueryRule.js +16 -0
- package/dist/validation/sql/rules/ForbiddenPatternRule.js +30 -0
- package/dist/validation/sql/rules/LeadingKeywordRule.js +29 -0
- package/dist/validation/sql/rules/SingleStatementRule.js +26 -0
- package/dist/validation/sql/rules/SmuggledWriteRule.js +50 -0
- package/dist/validation/sql/rules/index.js +6 -0
- package/package.json +76 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { EngineCatalog } from "../domain/Engine.js";
|
|
2
|
+
import { InvalidConnectionUrlError } from "../errors/InvalidConnectionUrlError.js";
|
|
3
|
+
/**
|
|
4
|
+
* Parses a connection URL for any supported engine into target fields.
|
|
5
|
+
*
|
|
6
|
+
* Hand-written rather than delegated to the WHATWG `URL` class, which cannot
|
|
7
|
+
* parse the one URL shape this server most needs to accept: a MongoDB replica
|
|
8
|
+
* set, `mongodb://a:27017,b:27017/app`, whose comma-separated authority is not
|
|
9
|
+
* a valid host to `URL` and throws. It also cannot express a SQLite path.
|
|
10
|
+
* Since one engine needed a custom parser, every engine uses the same one, so
|
|
11
|
+
* the rules for credentials, ports and options are identical everywhere.
|
|
12
|
+
*
|
|
13
|
+
* Nothing thrown from here contains the URL. See InvalidConnectionUrlError.
|
|
14
|
+
*/
|
|
15
|
+
export class ConnectionUrlParser {
|
|
16
|
+
static SCHEME = /^([A-Za-z][A-Za-z0-9+.-]*):([\s\S]*)$/;
|
|
17
|
+
/**
|
|
18
|
+
* Query parameters that are credentials, kept apart so they never reach a
|
|
19
|
+
* displayed or logged string. Matched loosely on purpose: a false positive
|
|
20
|
+
* only hides a harmless option from `list_connections`, while a false
|
|
21
|
+
* negative prints a secret.
|
|
22
|
+
*/
|
|
23
|
+
static SECRET_OPTION = /(pass|secret|token|api[_-]?key|credential)/i;
|
|
24
|
+
parse(url) {
|
|
25
|
+
const match = ConnectionUrlParser.SCHEME.exec(url.trim());
|
|
26
|
+
if (!match) {
|
|
27
|
+
throw new InvalidConnectionUrlError(`expected <scheme>://..., with one of: ${EngineCatalog.schemeNames().join(", ")}.`);
|
|
28
|
+
}
|
|
29
|
+
const schemeName = match[1].toLowerCase();
|
|
30
|
+
const found = EngineCatalog.findScheme(schemeName);
|
|
31
|
+
if (!found) {
|
|
32
|
+
throw new InvalidConnectionUrlError(`unsupported scheme "${schemeName}". Supported: ${EngineCatalog.schemeNames().join(", ")}.`);
|
|
33
|
+
}
|
|
34
|
+
if (found.engine.engine === "sqlite") {
|
|
35
|
+
return this.parseSqlite(match[2]);
|
|
36
|
+
}
|
|
37
|
+
return this.parseNetworked(match[2], found.engine, found.scheme);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `sqlite:///abs/path.db`, `sqlite://relative.db` and `sqlite:relative.db`
|
|
41
|
+
* all name a file. Everything after the scheme up to `?` is the path, so a
|
|
42
|
+
* path containing `@` or `:` is not misread as credentials or a port.
|
|
43
|
+
*/
|
|
44
|
+
parseSqlite(rest) {
|
|
45
|
+
const withoutSlashes = rest.startsWith("//") ? rest.slice(2) : rest;
|
|
46
|
+
const queryAt = withoutSlashes.indexOf("?");
|
|
47
|
+
const rawPath = queryAt === -1 ? withoutSlashes : withoutSlashes.slice(0, queryAt);
|
|
48
|
+
const query = queryAt === -1 ? "" : withoutSlashes.slice(queryAt + 1);
|
|
49
|
+
const path = this.decode(rawPath, "the file path");
|
|
50
|
+
if (!path) {
|
|
51
|
+
throw new InvalidConnectionUrlError("a sqlite URL needs a file path, e.g. sqlite:///data/app.db.");
|
|
52
|
+
}
|
|
53
|
+
// An in-memory database opened read-only is empty and always will be, so
|
|
54
|
+
// accepting it would only produce a connection that can never show
|
|
55
|
+
// anything.
|
|
56
|
+
if (path === ":memory:") {
|
|
57
|
+
throw new InvalidConnectionUrlError("an in-memory SQLite database is always empty when read-only.");
|
|
58
|
+
}
|
|
59
|
+
const { options, secretOptions } = this.parseQuery(query);
|
|
60
|
+
return {
|
|
61
|
+
engine: "sqlite",
|
|
62
|
+
scheme: "sqlite",
|
|
63
|
+
hosts: [],
|
|
64
|
+
user: "",
|
|
65
|
+
password: "",
|
|
66
|
+
database: path,
|
|
67
|
+
options,
|
|
68
|
+
secretOptions,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `scheme://[user[:password]@]host[:port][,host[:port]...][/database][?options]`
|
|
73
|
+
*
|
|
74
|
+
* The authority ends at the first `/`, `?` or `#`, as RFC 3986 says, so a
|
|
75
|
+
* password containing any of those must be percent-encoded. That is why the
|
|
76
|
+
* connect tool and DB_PROFILES also accept the password separately.
|
|
77
|
+
*/
|
|
78
|
+
parseNetworked(rest, engine, scheme) {
|
|
79
|
+
if (!rest.startsWith("//")) {
|
|
80
|
+
throw new InvalidConnectionUrlError(`expected "${scheme.name}://" followed by a host.`);
|
|
81
|
+
}
|
|
82
|
+
const remainder = rest.slice(2);
|
|
83
|
+
const authorityEnd = this.firstIndexOf(remainder, ["/", "?", "#"]);
|
|
84
|
+
const authority = authorityEnd === -1 ? remainder : remainder.slice(0, authorityEnd);
|
|
85
|
+
const tail = (authorityEnd === -1 ? "" : remainder.slice(authorityEnd)).split("#")[0];
|
|
86
|
+
const queryAt = tail.indexOf("?");
|
|
87
|
+
const path = queryAt === -1 ? tail : tail.slice(0, queryAt);
|
|
88
|
+
const query = queryAt === -1 ? "" : tail.slice(queryAt + 1);
|
|
89
|
+
// lastIndexOf, so an unencoded "@" inside a password still splits at the
|
|
90
|
+
// real separator, the one immediately before the host.
|
|
91
|
+
const at = authority.lastIndexOf("@");
|
|
92
|
+
const userInfo = at === -1 ? "" : authority.slice(0, at);
|
|
93
|
+
const hostPart = at === -1 ? authority : authority.slice(at + 1);
|
|
94
|
+
const colon = userInfo.indexOf(":");
|
|
95
|
+
const user = this.decode(colon === -1 ? userInfo : userInfo.slice(0, colon), "the user name");
|
|
96
|
+
const password = colon === -1 ? "" : this.decode(userInfo.slice(colon + 1), "the password");
|
|
97
|
+
const hosts = this.parseHosts(hostPart, engine, scheme);
|
|
98
|
+
const database = this.parseDatabase(path, engine);
|
|
99
|
+
const { options, secretOptions } = this.parseQuery(query);
|
|
100
|
+
return {
|
|
101
|
+
engine: engine.engine,
|
|
102
|
+
scheme: scheme.name,
|
|
103
|
+
hosts,
|
|
104
|
+
user,
|
|
105
|
+
password,
|
|
106
|
+
database,
|
|
107
|
+
options,
|
|
108
|
+
secretOptions,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
parseHosts(hostPart, engine, scheme) {
|
|
112
|
+
if (!hostPart) {
|
|
113
|
+
throw new InvalidConnectionUrlError("the host is missing.");
|
|
114
|
+
}
|
|
115
|
+
const hosts = hostPart.split(",").map((entry) => this.parseHost(entry, scheme));
|
|
116
|
+
if (hosts.length > 1 && !engine.multipleHosts) {
|
|
117
|
+
throw new InvalidConnectionUrlError(`${engine.label} URLs take a single host.`);
|
|
118
|
+
}
|
|
119
|
+
// An SRV record supplies the hosts and ports itself; a port in the URL is
|
|
120
|
+
// rejected by every MongoDB driver, so it is rejected here with a clearer
|
|
121
|
+
// message instead.
|
|
122
|
+
if (scheme.defaultPort === 0 && hosts.some((entry) => entry.port !== 0)) {
|
|
123
|
+
throw new InvalidConnectionUrlError(`${scheme.name} URLs must not include a port.`);
|
|
124
|
+
}
|
|
125
|
+
if (scheme.name === "mongodb+srv" && hosts.length > 1) {
|
|
126
|
+
throw new InvalidConnectionUrlError("mongodb+srv URLs take a single host name.");
|
|
127
|
+
}
|
|
128
|
+
return hosts;
|
|
129
|
+
}
|
|
130
|
+
/** Accepts `host`, `host:port`, `[::1]` and `[::1]:port`. */
|
|
131
|
+
parseHost(entry, scheme) {
|
|
132
|
+
let host;
|
|
133
|
+
let portText;
|
|
134
|
+
if (entry.startsWith("[")) {
|
|
135
|
+
const close = entry.indexOf("]");
|
|
136
|
+
if (close === -1) {
|
|
137
|
+
throw new InvalidConnectionUrlError("an IPv6 host is missing its closing bracket.");
|
|
138
|
+
}
|
|
139
|
+
host = entry.slice(1, close);
|
|
140
|
+
const after = entry.slice(close + 1);
|
|
141
|
+
portText = after.startsWith(":") ? after.slice(1) : undefined;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
const colon = entry.lastIndexOf(":");
|
|
145
|
+
host = colon === -1 ? entry : entry.slice(0, colon);
|
|
146
|
+
portText = colon === -1 ? undefined : entry.slice(colon + 1);
|
|
147
|
+
}
|
|
148
|
+
if (!host) {
|
|
149
|
+
throw new InvalidConnectionUrlError("the host is missing.");
|
|
150
|
+
}
|
|
151
|
+
return { host, port: portText === undefined ? scheme.defaultPort : this.parsePort(portText) };
|
|
152
|
+
}
|
|
153
|
+
parsePort(text) {
|
|
154
|
+
const port = Number(text);
|
|
155
|
+
if (!/^\d+$/.test(text) || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
156
|
+
throw new InvalidConnectionUrlError("the port must be a number from 1 to 65535.");
|
|
157
|
+
}
|
|
158
|
+
return port;
|
|
159
|
+
}
|
|
160
|
+
parseDatabase(path, engine) {
|
|
161
|
+
const trimmed = path.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
162
|
+
if (!trimmed) {
|
|
163
|
+
return engine.defaultDatabase;
|
|
164
|
+
}
|
|
165
|
+
if (!engine.switchesDatabases) {
|
|
166
|
+
throw new InvalidConnectionUrlError(`${engine.label} URLs do not take a database in the path.`);
|
|
167
|
+
}
|
|
168
|
+
return this.decode(trimmed, "the database name");
|
|
169
|
+
}
|
|
170
|
+
parseQuery(query) {
|
|
171
|
+
const options = {};
|
|
172
|
+
const secretOptions = {};
|
|
173
|
+
for (const [name, value] of new URLSearchParams(query)) {
|
|
174
|
+
if (ConnectionUrlParser.SECRET_OPTION.test(name)) {
|
|
175
|
+
secretOptions[name] = value;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
options[name] = value;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { options, secretOptions };
|
|
182
|
+
}
|
|
183
|
+
decode(value, what) {
|
|
184
|
+
try {
|
|
185
|
+
return decodeURIComponent(value);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new InvalidConnectionUrlError(`${what} contains malformed percent-encoding.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
firstIndexOf(value, needles) {
|
|
192
|
+
const positions = needles.map((needle) => value.indexOf(needle)).filter((index) => index !== -1);
|
|
193
|
+
return positions.length === 0 ? -1 : Math.min(...positions);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { EngineCatalog } from "./Engine.js";
|
|
2
|
+
/**
|
|
3
|
+
* A named connection.
|
|
4
|
+
*
|
|
5
|
+
* Distinct from ConnectionTarget because a name and an origin are not part of
|
|
6
|
+
* a connection's identity: the same endpoint can be reachable as "staging" from
|
|
7
|
+
* configuration and as an ad-hoc alias in the same session.
|
|
8
|
+
*/
|
|
9
|
+
export class ConnectionProfile {
|
|
10
|
+
name;
|
|
11
|
+
target;
|
|
12
|
+
origin;
|
|
13
|
+
constructor(name, target, origin) {
|
|
14
|
+
this.name = name;
|
|
15
|
+
this.target = target;
|
|
16
|
+
this.origin = origin;
|
|
17
|
+
Object.freeze(this);
|
|
18
|
+
}
|
|
19
|
+
/** Created from the environment, so it returns after a restart. */
|
|
20
|
+
static fromEnvironment(name, target) {
|
|
21
|
+
return new ConnectionProfile(name, target, "env");
|
|
22
|
+
}
|
|
23
|
+
/** Opened at runtime by the connect tool, so it is lost on exit. */
|
|
24
|
+
static fromSession(name, target) {
|
|
25
|
+
return new ConnectionProfile(name, target, "session");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* One line for `list_connections`. Never contains the password.
|
|
29
|
+
*
|
|
30
|
+
* Names the engine in words as well as through the scheme, because with
|
|
31
|
+
* eight engines behind one server "which kind of database is this" is the
|
|
32
|
+
* first thing a reader needs to know about each line.
|
|
33
|
+
*/
|
|
34
|
+
describe(isActive) {
|
|
35
|
+
const marker = isActive ? "* " : " ";
|
|
36
|
+
const label = EngineCatalog.label(this.target.engine);
|
|
37
|
+
return `${marker}${this.name} (${this.origin}, ${label}) -> ${this.target.describe()}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An immutable value object describing one connection, on any engine.
|
|
3
|
+
*
|
|
4
|
+
* Immutability is the point. A plain object passed around has to be
|
|
5
|
+
* spread-copied on every assignment, and a single missed copy means mutating
|
|
6
|
+
* the active connection silently rewrites the stored profile it came from.
|
|
7
|
+
* Here there is no setter to forget: `withDatabase` returns a new instance and
|
|
8
|
+
* the original cannot change.
|
|
9
|
+
*
|
|
10
|
+
* Identity is defined by the value of every non-secret field, exposed through
|
|
11
|
+
* `key()`, so the driver cache can treat two equal targets as one connection.
|
|
12
|
+
*/
|
|
13
|
+
export class ConnectionTarget {
|
|
14
|
+
engine;
|
|
15
|
+
scheme;
|
|
16
|
+
hosts;
|
|
17
|
+
user;
|
|
18
|
+
password;
|
|
19
|
+
database;
|
|
20
|
+
options;
|
|
21
|
+
secretOptions;
|
|
22
|
+
constructor(props) {
|
|
23
|
+
this.engine = props.engine;
|
|
24
|
+
this.scheme = props.scheme;
|
|
25
|
+
this.hosts = Object.freeze(props.hosts.map((entry) => Object.freeze({ ...entry })));
|
|
26
|
+
this.user = props.user;
|
|
27
|
+
this.password = props.password;
|
|
28
|
+
this.database = props.database;
|
|
29
|
+
this.options = Object.freeze({ ...props.options });
|
|
30
|
+
this.secretOptions = Object.freeze({ ...props.secretOptions });
|
|
31
|
+
Object.freeze(this);
|
|
32
|
+
}
|
|
33
|
+
/** The first host, which is the only one for every engine but MongoDB. */
|
|
34
|
+
get host() {
|
|
35
|
+
return this.hosts[0]?.host ?? "";
|
|
36
|
+
}
|
|
37
|
+
get port() {
|
|
38
|
+
return this.hosts[0]?.port ?? 0;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Stable identity, and the driver cache key.
|
|
42
|
+
*
|
|
43
|
+
* The password and every secret option are excluded deliberately: they are
|
|
44
|
+
* not part of what makes two connections the same endpoint, and this string
|
|
45
|
+
* is shown to users and written to logs. Because identity and display are
|
|
46
|
+
* the same string by construction, they cannot drift apart.
|
|
47
|
+
*
|
|
48
|
+
* A field added to this class that distinguishes two otherwise identical
|
|
49
|
+
* connections must be added here too, or the second will silently reuse the
|
|
50
|
+
* first one's driver.
|
|
51
|
+
*/
|
|
52
|
+
key() {
|
|
53
|
+
if (this.engine === "sqlite") {
|
|
54
|
+
return `sqlite:${this.database}`;
|
|
55
|
+
}
|
|
56
|
+
const user = this.user ? `${this.user}@` : "";
|
|
57
|
+
const hosts = this.hosts
|
|
58
|
+
.map((entry) => (entry.port > 0 ? `${entry.host}:${entry.port}` : entry.host))
|
|
59
|
+
.join(",");
|
|
60
|
+
return `${this.scheme}://${user}${hosts}/${this.database}${this.optionSuffix()}`;
|
|
61
|
+
}
|
|
62
|
+
/** Human-readable form. Same as the key, and safe to print. */
|
|
63
|
+
describe() {
|
|
64
|
+
return this.key();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Same endpoint and same credentials.
|
|
68
|
+
*
|
|
69
|
+
* `key()` alone is not enough for the driver cache: a reconnect with a
|
|
70
|
+
* corrected password has the same key, and reusing the cached driver would
|
|
71
|
+
* keep using the wrong password.
|
|
72
|
+
*/
|
|
73
|
+
equals(other) {
|
|
74
|
+
return (this.key() === other.key() &&
|
|
75
|
+
this.password === other.password &&
|
|
76
|
+
ConnectionTarget.sameRecord(this.secretOptions, other.secretOptions));
|
|
77
|
+
}
|
|
78
|
+
/** A copy pointing at a different database. The receiver is untouched. */
|
|
79
|
+
withDatabase(database) {
|
|
80
|
+
return new ConnectionTarget({ ...this.toProps(), database });
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A URL parameter by name, ignoring case.
|
|
84
|
+
*
|
|
85
|
+
* Case-insensitive because connection-string conventions disagree
|
|
86
|
+
* (`trustServerCertificate`, `sslmode`, `authSource`), and a lookup that
|
|
87
|
+
* silently misses `TrustServerCertificate` would quietly change behaviour.
|
|
88
|
+
*/
|
|
89
|
+
option(name) {
|
|
90
|
+
return ConnectionTarget.lookup(this.options, name);
|
|
91
|
+
}
|
|
92
|
+
secretOption(name) {
|
|
93
|
+
return ConnectionTarget.lookup(this.secretOptions, name);
|
|
94
|
+
}
|
|
95
|
+
/** Reads a boolean URL parameter: true, 1 and yes are true. */
|
|
96
|
+
flag(name) {
|
|
97
|
+
const value = this.option(name);
|
|
98
|
+
if (value === undefined) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
return ["true", "1", "yes"].includes(value.toLowerCase());
|
|
102
|
+
}
|
|
103
|
+
toProps() {
|
|
104
|
+
return {
|
|
105
|
+
engine: this.engine,
|
|
106
|
+
scheme: this.scheme,
|
|
107
|
+
hosts: this.hosts,
|
|
108
|
+
user: this.user,
|
|
109
|
+
password: this.password,
|
|
110
|
+
database: this.database,
|
|
111
|
+
options: this.options,
|
|
112
|
+
secretOptions: this.secretOptions,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Sorted so the same options in a different order are the same target. */
|
|
116
|
+
optionSuffix() {
|
|
117
|
+
const entries = Object.entries(this.options).sort(([a], [b]) => a.localeCompare(b));
|
|
118
|
+
if (entries.length === 0) {
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
return `?${entries.map(([name, value]) => `${name}=${value}`).join("&")}`;
|
|
122
|
+
}
|
|
123
|
+
static lookup(record, name) {
|
|
124
|
+
const wanted = name.toLowerCase();
|
|
125
|
+
for (const [key, value] of Object.entries(record)) {
|
|
126
|
+
if (key.toLowerCase() === wanted) {
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
static sameRecord(left, right) {
|
|
133
|
+
const leftKeys = Object.keys(left);
|
|
134
|
+
if (leftKeys.length !== Object.keys(right).length) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return leftKeys.every((key) => left[key] === right[key]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static facts about each engine, in one table.
|
|
3
|
+
*
|
|
4
|
+
* Kept as data rather than spread across the drivers, because the connection
|
|
5
|
+
* layer needs these answers (which scheme is which engine, can it switch
|
|
6
|
+
* databases) long before any driver exists, and must not load a driver module
|
|
7
|
+
* to get them.
|
|
8
|
+
*/
|
|
9
|
+
export class EngineCatalog {
|
|
10
|
+
static DESCRIPTORS = [
|
|
11
|
+
{
|
|
12
|
+
engine: "mysql",
|
|
13
|
+
label: "MySQL",
|
|
14
|
+
family: "sql",
|
|
15
|
+
schemes: [
|
|
16
|
+
{ name: "mysql", defaultPort: 3306, secure: false },
|
|
17
|
+
{ name: "mariadb", defaultPort: 3306, secure: false },
|
|
18
|
+
],
|
|
19
|
+
switchesDatabases: true,
|
|
20
|
+
defaultDatabase: "",
|
|
21
|
+
objectNoun: "table",
|
|
22
|
+
multipleHosts: false,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
engine: "postgres",
|
|
26
|
+
label: "PostgreSQL",
|
|
27
|
+
family: "sql",
|
|
28
|
+
schemes: [
|
|
29
|
+
{ name: "postgres", defaultPort: 5432, secure: false },
|
|
30
|
+
{ name: "postgresql", defaultPort: 5432, secure: false },
|
|
31
|
+
],
|
|
32
|
+
switchesDatabases: true,
|
|
33
|
+
defaultDatabase: "",
|
|
34
|
+
objectNoun: "table",
|
|
35
|
+
multipleHosts: false,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
engine: "sqlite",
|
|
39
|
+
label: "SQLite",
|
|
40
|
+
family: "sql",
|
|
41
|
+
schemes: [{ name: "sqlite", defaultPort: 0, secure: false }],
|
|
42
|
+
switchesDatabases: false,
|
|
43
|
+
defaultDatabase: "",
|
|
44
|
+
objectNoun: "table",
|
|
45
|
+
multipleHosts: false,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
engine: "mssql",
|
|
49
|
+
label: "SQL Server",
|
|
50
|
+
family: "sql",
|
|
51
|
+
schemes: [
|
|
52
|
+
{ name: "mssql", defaultPort: 1433, secure: false },
|
|
53
|
+
{ name: "sqlserver", defaultPort: 1433, secure: false },
|
|
54
|
+
],
|
|
55
|
+
switchesDatabases: true,
|
|
56
|
+
defaultDatabase: "",
|
|
57
|
+
objectNoun: "table",
|
|
58
|
+
multipleHosts: false,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
engine: "clickhouse",
|
|
62
|
+
label: "ClickHouse",
|
|
63
|
+
family: "sql",
|
|
64
|
+
// The HTTP interface, which is what the official JavaScript client
|
|
65
|
+
// speaks. The native protocol port 9000 is not what these point at.
|
|
66
|
+
schemes: [
|
|
67
|
+
{ name: "clickhouse", defaultPort: 8123, secure: false },
|
|
68
|
+
{ name: "clickhouse+https", defaultPort: 8443, secure: true },
|
|
69
|
+
],
|
|
70
|
+
switchesDatabases: true,
|
|
71
|
+
defaultDatabase: "default",
|
|
72
|
+
objectNoun: "table",
|
|
73
|
+
multipleHosts: false,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
engine: "mongodb",
|
|
77
|
+
label: "MongoDB",
|
|
78
|
+
family: "document",
|
|
79
|
+
schemes: [
|
|
80
|
+
{ name: "mongodb", defaultPort: 27017, secure: false },
|
|
81
|
+
{ name: "mongodb+srv", defaultPort: 0, secure: true },
|
|
82
|
+
],
|
|
83
|
+
switchesDatabases: true,
|
|
84
|
+
defaultDatabase: "",
|
|
85
|
+
objectNoun: "collection",
|
|
86
|
+
multipleHosts: true,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
engine: "redis",
|
|
90
|
+
label: "Redis",
|
|
91
|
+
family: "keyvalue",
|
|
92
|
+
schemes: [
|
|
93
|
+
{ name: "redis", defaultPort: 6379, secure: false },
|
|
94
|
+
{ name: "rediss", defaultPort: 6379, secure: true },
|
|
95
|
+
],
|
|
96
|
+
switchesDatabases: true,
|
|
97
|
+
defaultDatabase: "0",
|
|
98
|
+
objectNoun: "key",
|
|
99
|
+
multipleHosts: false,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
engine: "elasticsearch",
|
|
103
|
+
label: "Elasticsearch",
|
|
104
|
+
family: "search",
|
|
105
|
+
// OpenSearch answers the same read endpoints, so it shares the driver.
|
|
106
|
+
schemes: [
|
|
107
|
+
{ name: "elasticsearch", defaultPort: 9200, secure: false },
|
|
108
|
+
{ name: "elasticsearch+https", defaultPort: 9200, secure: true },
|
|
109
|
+
{ name: "opensearch", defaultPort: 9200, secure: false },
|
|
110
|
+
{ name: "opensearch+https", defaultPort: 9200, secure: true },
|
|
111
|
+
],
|
|
112
|
+
switchesDatabases: false,
|
|
113
|
+
defaultDatabase: "",
|
|
114
|
+
objectNoun: "index",
|
|
115
|
+
multipleHosts: false,
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
static all() {
|
|
119
|
+
return EngineCatalog.DESCRIPTORS;
|
|
120
|
+
}
|
|
121
|
+
static describe(engine) {
|
|
122
|
+
const descriptor = EngineCatalog.DESCRIPTORS.find((entry) => entry.engine === engine);
|
|
123
|
+
if (!descriptor) {
|
|
124
|
+
throw new Error(`Unknown engine "${engine}".`);
|
|
125
|
+
}
|
|
126
|
+
return descriptor;
|
|
127
|
+
}
|
|
128
|
+
static label(engine) {
|
|
129
|
+
return EngineCatalog.describe(engine).label;
|
|
130
|
+
}
|
|
131
|
+
/** @returns undefined for a scheme no engine claims. Case-insensitive. */
|
|
132
|
+
static findScheme(scheme) {
|
|
133
|
+
const wanted = scheme.toLowerCase();
|
|
134
|
+
for (const engine of EngineCatalog.DESCRIPTORS) {
|
|
135
|
+
const match = engine.schemes.find((entry) => entry.name === wanted);
|
|
136
|
+
if (match) {
|
|
137
|
+
return { engine, scheme: match };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
/** @throws when the scheme is unknown, which a parsed target never has. */
|
|
143
|
+
static scheme(scheme) {
|
|
144
|
+
const found = EngineCatalog.findScheme(scheme);
|
|
145
|
+
if (!found) {
|
|
146
|
+
throw new Error(`Unknown scheme "${scheme}".`);
|
|
147
|
+
}
|
|
148
|
+
return found.scheme;
|
|
149
|
+
}
|
|
150
|
+
static schemeNames() {
|
|
151
|
+
return EngineCatalog.DESCRIPTORS.flatMap((engine) => engine.schemes.map((entry) => entry.name));
|
|
152
|
+
}
|
|
153
|
+
/** "MySQL, PostgreSQL, SQLite" for the engines of one family, for messages. */
|
|
154
|
+
static labelsFor(family) {
|
|
155
|
+
return EngineCatalog.DESCRIPTORS.filter((engine) => engine.family === family)
|
|
156
|
+
.map((engine) => engine.label)
|
|
157
|
+
.join(", ");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { EngineCatalog } from "../domain/Engine.js";
|
|
2
|
+
import { NoDatabaseSelectedError } from "../errors/NoDatabaseSelectedError.js";
|
|
3
|
+
import { UnsupportedOperationError } from "../errors/UnsupportedOperationError.js";
|
|
4
|
+
/**
|
|
5
|
+
* Shared behaviour for every driver.
|
|
6
|
+
*
|
|
7
|
+
* The two optional capabilities, indexes and foreign keys, default to a
|
|
8
|
+
* refusal here, so an engine without them needs no code to say so, and an
|
|
9
|
+
* engine that has them overrides the one method.
|
|
10
|
+
*/
|
|
11
|
+
export class BaseDriver {
|
|
12
|
+
target;
|
|
13
|
+
constructor(target) {
|
|
14
|
+
this.target = target;
|
|
15
|
+
}
|
|
16
|
+
async listIndexes(_name) {
|
|
17
|
+
throw new UnsupportedOperationError(this.label, "indexes to list");
|
|
18
|
+
}
|
|
19
|
+
async listForeignKeys(_name) {
|
|
20
|
+
throw new UnsupportedOperationError(this.label, "foreign keys");
|
|
21
|
+
}
|
|
22
|
+
get label() {
|
|
23
|
+
return EngineCatalog.label(this.target.engine);
|
|
24
|
+
}
|
|
25
|
+
get objectNoun() {
|
|
26
|
+
return EngineCatalog.describe(this.target.engine).objectNoun;
|
|
27
|
+
}
|
|
28
|
+
/** @throws NoDatabaseSelectedError explaining how to choose one. */
|
|
29
|
+
requireDatabase() {
|
|
30
|
+
if (!this.target.database) {
|
|
31
|
+
throw new NoDatabaseSelectedError(this.label);
|
|
32
|
+
}
|
|
33
|
+
return this.target.database;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An Object Pool registry: one driver per distinct connection, with LRU
|
|
3
|
+
* eviction.
|
|
4
|
+
*
|
|
5
|
+
* This is what makes switching cheap. Changing database selects a different
|
|
6
|
+
* driver rather than reconnecting, and switching back reuses a warm one.
|
|
7
|
+
*
|
|
8
|
+
* Drivers are keyed by ConnectionTarget.key() rather than by, say, issuing
|
|
9
|
+
* `USE` on a shared pool. A pool holds several connections and `USE` affects
|
|
10
|
+
* only the one it ran on, so a later query served by a different connection
|
|
11
|
+
* would silently run against the old database. Keying by target means every
|
|
12
|
+
* connection a driver opens was pointed at the right database from the start.
|
|
13
|
+
*/
|
|
14
|
+
export class DriverCache {
|
|
15
|
+
registry;
|
|
16
|
+
maxDrivers;
|
|
17
|
+
drivers = new Map();
|
|
18
|
+
constructor(registry, maxDrivers) {
|
|
19
|
+
this.registry = registry;
|
|
20
|
+
this.maxDrivers = maxDrivers;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The cached driver for this target, or a new one.
|
|
24
|
+
*
|
|
25
|
+
* A cached driver whose target has the same key but different credentials
|
|
26
|
+
* is replaced, not reused. The key excludes the password by design, so
|
|
27
|
+
* without this check a reconnect with a corrected password would keep
|
|
28
|
+
* failing on the old one.
|
|
29
|
+
*/
|
|
30
|
+
async acquire(target) {
|
|
31
|
+
const key = target.key();
|
|
32
|
+
const existing = this.drivers.get(key);
|
|
33
|
+
if (existing && existing.target.equals(target)) {
|
|
34
|
+
this.touch(key, existing);
|
|
35
|
+
return existing;
|
|
36
|
+
}
|
|
37
|
+
if (existing) {
|
|
38
|
+
this.drivers.delete(key);
|
|
39
|
+
await existing.close();
|
|
40
|
+
}
|
|
41
|
+
const driver = this.registry.create(target);
|
|
42
|
+
this.drivers.set(key, driver);
|
|
43
|
+
await this.evictOverflow();
|
|
44
|
+
return driver;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Open the connection and prove it works.
|
|
48
|
+
*
|
|
49
|
+
* A driver that fails verification is evicted, so the next attempt, perhaps
|
|
50
|
+
* after the server has come back, starts from nothing.
|
|
51
|
+
*/
|
|
52
|
+
async verify(target) {
|
|
53
|
+
const driver = await this.acquire(target);
|
|
54
|
+
try {
|
|
55
|
+
await driver.verify();
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (this.drivers.get(target.key()) === driver) {
|
|
59
|
+
this.drivers.delete(target.key());
|
|
60
|
+
}
|
|
61
|
+
await driver.close();
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Closing drivers is what allows the process to exit: open sockets keep the
|
|
67
|
+
* Node event loop alive.
|
|
68
|
+
*
|
|
69
|
+
* The map is cleared before awaiting, so a call arriving mid-shutdown gets a
|
|
70
|
+
* fresh driver rather than one being torn down. Closes run concurrently,
|
|
71
|
+
* because shutdown must not stall on one unreachable server.
|
|
72
|
+
*/
|
|
73
|
+
async closeAll() {
|
|
74
|
+
const open = Array.from(this.drivers.values());
|
|
75
|
+
this.drivers.clear();
|
|
76
|
+
await Promise.all(open.map((driver) => driver.close()));
|
|
77
|
+
}
|
|
78
|
+
get size() {
|
|
79
|
+
return this.drivers.size;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A Map iterates in insertion order, so deleting and re-inserting moves an
|
|
83
|
+
* entry to the back and leaves the least recently used at the front. That is
|
|
84
|
+
* a complete LRU for two Map operations and no extra bookkeeping.
|
|
85
|
+
*/
|
|
86
|
+
touch(key, driver) {
|
|
87
|
+
this.drivers.delete(key);
|
|
88
|
+
this.drivers.set(key, driver);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Runs after insertion, so the cap is briefly exceeded then corrected.
|
|
92
|
+
* Evicting first would risk dropping the driver that is about to be used.
|
|
93
|
+
*/
|
|
94
|
+
async evictOverflow() {
|
|
95
|
+
while (this.drivers.size > this.maxDrivers) {
|
|
96
|
+
const oldest = this.drivers.keys().next();
|
|
97
|
+
if (oldest.done) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const driver = this.drivers.get(oldest.value);
|
|
101
|
+
this.drivers.delete(oldest.value);
|
|
102
|
+
if (driver) {
|
|
103
|
+
await driver.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|