@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.dockerhub.md +354 -0
  4. package/README.md +387 -0
  5. package/dist/ApplicationFactory.js +144 -0
  6. package/dist/config/EnvironmentConfigLoader.js +179 -0
  7. package/dist/config/PackageVersionLoader.js +43 -0
  8. package/dist/connections/ConnectionManager.js +101 -0
  9. package/dist/connections/ConnectionRegistry.js +109 -0
  10. package/dist/connections/ConnectionTargetFactory.js +104 -0
  11. package/dist/connections/ConnectionUrlParser.js +195 -0
  12. package/dist/domain/ConnectionProfile.js +39 -0
  13. package/dist/domain/ConnectionTarget.js +139 -0
  14. package/dist/domain/Engine.js +159 -0
  15. package/dist/drivers/BaseDriver.js +35 -0
  16. package/dist/drivers/DatabaseDriver.js +1 -0
  17. package/dist/drivers/DriverCache.js +107 -0
  18. package/dist/drivers/DriverProvider.js +71 -0
  19. package/dist/drivers/DriverRegistry.js +24 -0
  20. package/dist/drivers/GlobPattern.js +41 -0
  21. package/dist/drivers/LazyResource.js +56 -0
  22. package/dist/drivers/document/MongoDriver.js +187 -0
  23. package/dist/drivers/document/MongoSchemaSampler.js +74 -0
  24. package/dist/drivers/document/MongoStageAllowlist.js +87 -0
  25. package/dist/drivers/keyvalue/RedisCommandFlagsGuard.js +69 -0
  26. package/dist/drivers/keyvalue/RedisDriver.js +224 -0
  27. package/dist/drivers/search/ElasticsearchDriver.js +159 -0
  28. package/dist/drivers/sql/ClickHouseDriver.js +156 -0
  29. package/dist/drivers/sql/MsSqlDriver.js +147 -0
  30. package/dist/drivers/sql/MySqlDriver.js +144 -0
  31. package/dist/drivers/sql/MySqlSessionInitializer.js +100 -0
  32. package/dist/drivers/sql/PostgresDriver.js +176 -0
  33. package/dist/drivers/sql/SqlIdentifier.js +36 -0
  34. package/dist/drivers/sql/SqliteDriver.js +202 -0
  35. package/dist/drivers/sql/SqliteProtocol.js +7 -0
  36. package/dist/drivers/sql/SqliteWorker.js +71 -0
  37. package/dist/errors/ApplicationError.js +15 -0
  38. package/dist/errors/EngineMismatchError.js +15 -0
  39. package/dist/errors/InvalidConnectionUrlError.js +14 -0
  40. package/dist/errors/InvalidProfileDefinitionError.js +15 -0
  41. package/dist/errors/NoActiveConnectionError.js +13 -0
  42. package/dist/errors/NoDatabaseSelectedError.js +13 -0
  43. package/dist/errors/ObjectNotFoundError.js +14 -0
  44. package/dist/errors/UnknownProfileError.js +16 -0
  45. package/dist/errors/UnsupportedOperationError.js +14 -0
  46. package/dist/errors/index.js +9 -0
  47. package/dist/formatting/JsonSerializer.js +49 -0
  48. package/dist/formatting/RowFormatter.js +43 -0
  49. package/dist/formatting/ToolResponse.js +25 -0
  50. package/dist/index.js +15 -0
  51. package/dist/server/McpDbServer.js +69 -0
  52. package/dist/tools/BaseTool.js +42 -0
  53. package/dist/tools/DatabaseScopedTool.js +61 -0
  54. package/dist/tools/QueryTools.js +13 -0
  55. package/dist/tools/browse/DescribeTableTool.js +37 -0
  56. package/dist/tools/browse/GetForeignKeysTool.js +36 -0
  57. package/dist/tools/browse/GetTableIndexesTool.js +30 -0
  58. package/dist/tools/browse/GetTableSampleTool.js +50 -0
  59. package/dist/tools/browse/ListTablesTool.js +51 -0
  60. package/dist/tools/connection/ConnectTool.js +67 -0
  61. package/dist/tools/connection/CurrentConnectionTool.js +36 -0
  62. package/dist/tools/connection/ListConnectionsTool.js +38 -0
  63. package/dist/tools/connection/ListDatabasesTool.js +41 -0
  64. package/dist/tools/connection/UseConnectionTool.js +57 -0
  65. package/dist/tools/connection/UseDatabaseTool.js +45 -0
  66. package/dist/tools/document/AggregateTool.js +44 -0
  67. package/dist/tools/document/CountDocumentsTool.js +32 -0
  68. package/dist/tools/document/DistinctValuesTool.js +36 -0
  69. package/dist/tools/document/DocumentTool.js +38 -0
  70. package/dist/tools/document/FindDocumentsTool.js +56 -0
  71. package/dist/tools/keyvalue/RedisCommandTool.js +40 -0
  72. package/dist/tools/search/SearchTool.js +54 -0
  73. package/dist/tools/sql/RunQueryTool.js +46 -0
  74. package/dist/types/config.types.js +1 -0
  75. package/dist/types/connection.types.js +1 -0
  76. package/dist/types/driver.types.js +1 -0
  77. package/dist/types/index.js +1 -0
  78. package/dist/types/tool.types.js +1 -0
  79. package/dist/types/validation.types.js +1 -0
  80. package/dist/validation/document/MongoOperatorGuard.js +72 -0
  81. package/dist/validation/keyvalue/RedisCommandValidator.js +176 -0
  82. package/dist/validation/names/NamePolicy.js +122 -0
  83. package/dist/validation/names/NamePolicyRegistry.js +33 -0
  84. package/dist/validation/search/SearchBodyValidator.js +56 -0
  85. package/dist/validation/sql/ReadOnlyQueryValidator.js +82 -0
  86. package/dist/validation/sql/SqlDialect.js +196 -0
  87. package/dist/validation/sql/SqlSkeletonizer.js +197 -0
  88. package/dist/validation/sql/SqlValidatorRegistry.js +24 -0
  89. package/dist/validation/sql/rules/AmbiguousSyntaxRule.js +23 -0
  90. package/dist/validation/sql/rules/EmptyQueryRule.js +16 -0
  91. package/dist/validation/sql/rules/ForbiddenPatternRule.js +30 -0
  92. package/dist/validation/sql/rules/LeadingKeywordRule.js +29 -0
  93. package/dist/validation/sql/rules/SingleStatementRule.js +26 -0
  94. package/dist/validation/sql/rules/SmuggledWriteRule.js +50 -0
  95. package/dist/validation/sql/rules/index.js +6 -0
  96. package/package.json +76 -0
@@ -0,0 +1,179 @@
1
+ import { ConnectionProfile } from "../domain/ConnectionProfile.js";
2
+ import { ConnectionTargetFactory } from "../connections/ConnectionTargetFactory.js";
3
+ /**
4
+ * Reads configuration from environment variables.
5
+ *
6
+ * Implements ConfigurationLoader so tests, and any future file or remote
7
+ * source, can be substituted without touching anything downstream.
8
+ *
9
+ * Two families of variables are read, in this order of precedence:
10
+ *
11
+ * 1. `DB_PROFILES` and `DB_URL`, the native form: connection URLs for any
12
+ * engine.
13
+ * 2. `MYSQL_PROFILES` and `MYSQL_HOST`/`USER`/`DATABASE`, the configuration
14
+ * of the MySQL-only server this one generalises. Read so that an existing
15
+ * setup keeps working unchanged when the image is swapped. A profile name
16
+ * already defined by the native form wins.
17
+ *
18
+ * Two properties are deliberate:
19
+ *
20
+ * - **Nothing is logged here.** Problems are returned as `warnings` and the
21
+ * composition root decides where they go. That keeps the loader pure, and
22
+ * lets tests assert on warnings instead of intercepting console output.
23
+ * - **Nothing is fatal.** Malformed JSON, a broken profile, or no
24
+ * configuration at all all produce a usable result. A server that starts and
25
+ * explains the problem can be fixed with the connect tool; one that exits
26
+ * during handshake is reported by the client as a broken install.
27
+ */
28
+ export class EnvironmentConfigLoader {
29
+ env;
30
+ targetFactory;
31
+ static DEFAULT_QUERY_TIMEOUT_MS = 30000;
32
+ static DEFAULT_CONNECT_TIMEOUT_MS = 10000;
33
+ constructor(env = process.env, targetFactory = new ConnectionTargetFactory()) {
34
+ this.env = env;
35
+ this.targetFactory = targetFactory;
36
+ }
37
+ load() {
38
+ const warnings = [];
39
+ const profiles = new Map();
40
+ this.loadUrlProfiles(profiles, warnings);
41
+ this.loadSingleUrl(profiles, warnings);
42
+ this.loadLegacyMySqlProfiles(profiles, warnings);
43
+ this.loadLegacyMySqlConnection(profiles);
44
+ return {
45
+ profiles: Array.from(profiles.values()),
46
+ defaultProfileName: this.env.DB_DEFAULT_PROFILE || this.env.MYSQL_DEFAULT_PROFILE || null,
47
+ queryTimeoutMs: this.readTimeout(this.env.DB_QUERY_TIMEOUT_MS ?? this.env.MYSQL_QUERY_TIMEOUT_MS, EnvironmentConfigLoader.DEFAULT_QUERY_TIMEOUT_MS),
48
+ connectTimeoutMs: this.readTimeout(this.env.DB_CONNECT_TIMEOUT_MS ?? this.env.MYSQL_CONNECT_TIMEOUT_MS, EnvironmentConfigLoader.DEFAULT_CONNECT_TIMEOUT_MS),
49
+ warnings,
50
+ };
51
+ }
52
+ /**
53
+ * DB_PROFILES: `{"name": "url"}` or `{"name": {"url": "...", "password": "..."}}`.
54
+ *
55
+ * The object form exists for passwords. A password inside a URL must be
56
+ * percent-encoded, and one containing `@`, `/` or `#` silently splits the URL
57
+ * in the wrong place when it is not. Giving it its own field removes that
58
+ * whole class of mistake.
59
+ */
60
+ loadUrlProfiles(target, warnings) {
61
+ const parsed = this.readJsonObject("DB_PROFILES", warnings);
62
+ if (!parsed) {
63
+ return;
64
+ }
65
+ for (const [name, definition] of Object.entries(parsed)) {
66
+ const entry = this.readUrlDefinition(definition);
67
+ if (!entry) {
68
+ warnings.push(`Skipping profile "${name}": expected a URL string or an object with "url".`);
69
+ continue;
70
+ }
71
+ try {
72
+ const connection = this.targetFactory.fromUrl(entry.url, entry.password);
73
+ target.set(name, ConnectionProfile.fromEnvironment(name, connection));
74
+ }
75
+ catch (error) {
76
+ warnings.push(`Skipping profile "${name}": ${this.reason(error)}`);
77
+ }
78
+ }
79
+ }
80
+ /** DB_URL, with DB_PASSWORD as its optional separate password, as `default`. */
81
+ loadSingleUrl(target, warnings) {
82
+ const url = this.env.DB_URL;
83
+ if (!url || target.has("default")) {
84
+ return;
85
+ }
86
+ try {
87
+ const connection = this.targetFactory.fromUrl(url, this.env.DB_PASSWORD);
88
+ target.set("default", ConnectionProfile.fromEnvironment("default", connection));
89
+ }
90
+ catch (error) {
91
+ warnings.push(`Ignoring DB_URL: ${this.reason(error)}`);
92
+ }
93
+ }
94
+ /** The legacy MYSQL_PROFILES object form, exactly as the MySQL-only server read it. */
95
+ loadLegacyMySqlProfiles(target, warnings) {
96
+ const parsed = this.readJsonObject("MYSQL_PROFILES", warnings);
97
+ if (!parsed) {
98
+ return;
99
+ }
100
+ for (const [name, definition] of Object.entries(parsed)) {
101
+ if (target.has(name)) {
102
+ warnings.push(`Skipping MYSQL_PROFILES "${name}": DB_PROFILES already defines it.`);
103
+ continue;
104
+ }
105
+ if (!definition || typeof definition !== "object" || Array.isArray(definition)) {
106
+ warnings.push(`Skipping profile "${name}": not an object.`);
107
+ continue;
108
+ }
109
+ try {
110
+ const connection = this.targetFactory.fromMySqlDefinition(definition, name);
111
+ target.set(name, ConnectionProfile.fromEnvironment(name, connection));
112
+ }
113
+ catch (error) {
114
+ warnings.push(`Skipping profile "${name}": ${this.reason(error)}`);
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * The MYSQL_HOST/USER/DATABASE form, registered as a profile named `default`
120
+ * unless something above already claimed that name.
121
+ */
122
+ loadLegacyMySqlConnection(target) {
123
+ const user = this.env.MYSQL_USER;
124
+ const database = this.env.MYSQL_DATABASE;
125
+ if (!user || !database || target.has("default")) {
126
+ return;
127
+ }
128
+ const connection = this.targetFactory.fromMySqlValues({
129
+ host: this.env.MYSQL_HOST,
130
+ port: this.env.MYSQL_PORT ? Number.parseInt(this.env.MYSQL_PORT, 10) : undefined,
131
+ user,
132
+ password: this.env.MYSQL_PASSWORD ?? "",
133
+ database,
134
+ });
135
+ target.set("default", ConnectionProfile.fromEnvironment("default", connection));
136
+ }
137
+ readJsonObject(variable, warnings) {
138
+ const raw = this.env[variable];
139
+ if (!raw) {
140
+ return null;
141
+ }
142
+ let parsed;
143
+ try {
144
+ parsed = JSON.parse(raw);
145
+ }
146
+ catch (error) {
147
+ warnings.push(`${variable} is not valid JSON, ignoring it: ${this.reason(error)}`);
148
+ return null;
149
+ }
150
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
151
+ warnings.push(`${variable} must be a JSON object of named profiles, ignoring it.`);
152
+ return null;
153
+ }
154
+ return parsed;
155
+ }
156
+ readUrlDefinition(definition) {
157
+ if (typeof definition === "string" && definition) {
158
+ return { url: definition };
159
+ }
160
+ if (!definition || typeof definition !== "object" || Array.isArray(definition)) {
161
+ return null;
162
+ }
163
+ const raw = definition;
164
+ if (typeof raw.url !== "string" || !raw.url) {
165
+ return null;
166
+ }
167
+ return {
168
+ url: raw.url,
169
+ password: typeof raw.password === "string" ? raw.password : undefined,
170
+ };
171
+ }
172
+ readTimeout(value, fallback) {
173
+ const parsed = Number.parseInt(value ?? "", 10);
174
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
175
+ }
176
+ reason(error) {
177
+ return error instanceof Error ? error.message : String(error);
178
+ }
179
+ }
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from "node:fs";
2
+ /**
3
+ * Reads the version clients see in the MCP handshake from `package.json`.
4
+ *
5
+ * A literal in the server class would mean a release has to remember to
6
+ * change the same number in two files. Nothing enforces that, and the tag
7
+ * check in the publish workflows never looks at a literal, so a missed bump
8
+ * would ship a server that misreports itself to every client. The MySQL-only
9
+ * predecessor of this server shipped exactly that bug before this class
10
+ * existed.
11
+ *
12
+ * `package.json` is present in all three ways this server is distributed: the
13
+ * npm tarball always includes it, the runtime image copies it in before the
14
+ * build output, and a local checkout has it by definition.
15
+ */
16
+ export class PackageVersionLoader {
17
+ packageJsonUrl;
18
+ /**
19
+ * Used when `package.json` cannot be read. A server that starts and reports
20
+ * an obviously wrong version is easier to diagnose than one that refuses to
21
+ * start over metadata it does not need in order to answer queries.
22
+ */
23
+ static UNKNOWN_VERSION = "0.0.0";
24
+ /**
25
+ * Resolved from this module rather than from `process.cwd()`, so the answer
26
+ * does not depend on the directory the client happened to launch us from.
27
+ */
28
+ constructor(packageJsonUrl = new URL("../../package.json", import.meta.url)) {
29
+ this.packageJsonUrl = packageJsonUrl;
30
+ }
31
+ load() {
32
+ try {
33
+ const contents = readFileSync(this.packageJsonUrl, "utf8");
34
+ const version = JSON.parse(contents).version;
35
+ return typeof version === "string" && version.length > 0
36
+ ? version
37
+ : PackageVersionLoader.UNKNOWN_VERSION;
38
+ }
39
+ catch {
40
+ return PackageVersionLoader.UNKNOWN_VERSION;
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,101 @@
1
+ import { ConnectionProfile } from "../domain/ConnectionProfile.js";
2
+ import { EngineCatalog } from "../domain/Engine.js";
3
+ import { UnknownProfileError } from "../errors/UnknownProfileError.js";
4
+ import { UnsupportedOperationError } from "../errors/UnsupportedOperationError.js";
5
+ /**
6
+ * Orchestrates changing the active connection.
7
+ *
8
+ * Sits between the tools and the registry so that every switch follows the
9
+ * same rule: **verify, then commit**. The registry cannot enforce that itself
10
+ * without taking a dependency on the drivers, and the tools should not each be
11
+ * trusted to remember it.
12
+ *
13
+ * Committing only after a successful verification is what makes a failed
14
+ * switch harmless: the previous connection stays active and the session
15
+ * remains usable.
16
+ */
17
+ export class ConnectionManager {
18
+ registry;
19
+ drivers;
20
+ constructor(registry, drivers) {
21
+ this.registry = registry;
22
+ this.drivers = drivers;
23
+ }
24
+ getActiveTarget() {
25
+ return this.registry.getActiveTarget();
26
+ }
27
+ getActiveName() {
28
+ return this.registry.getActiveName();
29
+ }
30
+ requireActiveTarget() {
31
+ return this.registry.requireActiveTarget();
32
+ }
33
+ listProfiles() {
34
+ return this.registry.list();
35
+ }
36
+ findProfile(profileName) {
37
+ return this.registry.find(profileName);
38
+ }
39
+ /**
40
+ * Move to a different database on the current server.
41
+ *
42
+ * Keeps the current profile label: the connection is still "staging", just
43
+ * pointed elsewhere, and renaming it would throw away that context.
44
+ *
45
+ * @throws UnsupportedOperationError for engines with nothing to switch to,
46
+ * before any network work.
47
+ */
48
+ async useDatabase(database) {
49
+ const active = this.registry.requireActiveTarget();
50
+ ConnectionManager.assertSwitchable(active);
51
+ const candidate = active.withDatabase(database);
52
+ await this.drivers.verify(candidate);
53
+ return this.registry.activateTarget(candidate, this.registry.getActiveName() ?? "custom");
54
+ }
55
+ /**
56
+ * Move to a named profile, optionally overriding its database.
57
+ *
58
+ * @throws UnknownProfileError carrying the known names, checked before any
59
+ * network work so a typo fails instantly.
60
+ */
61
+ async useProfile(profileName, database) {
62
+ const profile = this.registry.find(profileName);
63
+ if (!profile) {
64
+ throw new UnknownProfileError(profileName, this.registry.names());
65
+ }
66
+ let candidate = profile.target;
67
+ if (database) {
68
+ ConnectionManager.assertSwitchable(profile.target);
69
+ candidate = profile.target.withDatabase(database);
70
+ }
71
+ await this.drivers.verify(candidate);
72
+ return this.registry.activateTarget(candidate, profileName);
73
+ }
74
+ /**
75
+ * Open an arbitrary connection and keep it under an alias for the session.
76
+ *
77
+ * This is what makes a restart unnecessary in every case: DB_PROFILES is a
78
+ * convenience, this is the guarantee. Credentials are held in memory only
79
+ * and vanish on exit, which list_connections communicates via the `session`
80
+ * origin.
81
+ */
82
+ async connect(target, alias) {
83
+ await this.drivers.verify(target);
84
+ this.registry.register(ConnectionProfile.fromSession(alias, target));
85
+ return this.registry.activateTarget(target, alias);
86
+ }
87
+ /**
88
+ * Shared with DriverProvider so use_database and the per-call `database`
89
+ * argument refuse in exactly the same words.
90
+ */
91
+ static assertSwitchable(target) {
92
+ const engine = EngineCatalog.describe(target.engine);
93
+ if (engine.switchesDatabases) {
94
+ return;
95
+ }
96
+ const alternative = target.engine === "sqlite"
97
+ ? "The file is the database; call connect with another sqlite:// URL to open a different one."
98
+ : "Its indices are listed by list_tables.";
99
+ throw new UnsupportedOperationError(engine.label, "separate databases to switch between", alternative);
100
+ }
101
+ }
@@ -0,0 +1,109 @@
1
+ import { NoActiveConnectionError } from "../errors/NoActiveConnectionError.js";
2
+ import { UnknownProfileError } from "../errors/UnknownProfileError.js";
3
+ /**
4
+ * The Registry: every known connection, and which one is active.
5
+ *
6
+ * This is the only mutable state that makes runtime switching possible. As an
7
+ * injected instance rather than module-level state it is ordinary to
8
+ * construct, and several can exist side by side in a test file.
9
+ *
10
+ * It holds no database machinery on purpose. Verifying that a connection
11
+ * actually works belongs to ConnectionManager, which keeps this class free of
12
+ * I/O and trivially testable.
13
+ */
14
+ export class ConnectionRegistry {
15
+ profiles = new Map();
16
+ activeName = null;
17
+ activeTarget = null;
18
+ constructor(profiles = []) {
19
+ for (const profile of profiles) {
20
+ this.profiles.set(profile.name, profile);
21
+ }
22
+ }
23
+ /**
24
+ * Choose the starting connection, in order of preference:
25
+ * the requested name, a profile called `default`, then the first defined.
26
+ *
27
+ * @returns a warning when the requested name does not exist, else null.
28
+ * A typo should not stop the server; saying so and carrying on is more
29
+ * useful than refusing to start.
30
+ */
31
+ selectInitial(preferredName) {
32
+ if (preferredName && this.profiles.has(preferredName)) {
33
+ this.activateProfile(preferredName);
34
+ return null;
35
+ }
36
+ const warning = preferredName
37
+ ? `The default profile "${preferredName}" does not match any profile.`
38
+ : null;
39
+ if (this.profiles.has("default")) {
40
+ this.activateProfile("default");
41
+ return warning;
42
+ }
43
+ const first = this.profiles.keys().next();
44
+ if (!first.done) {
45
+ this.activateProfile(first.value);
46
+ }
47
+ return warning;
48
+ }
49
+ /** Insertion order, which is what makes "the first profile" meaningful. */
50
+ list() {
51
+ return Array.from(this.profiles.values());
52
+ }
53
+ names() {
54
+ return Array.from(this.profiles.keys());
55
+ }
56
+ find(profileName) {
57
+ return this.profiles.get(profileName);
58
+ }
59
+ has(profileName) {
60
+ return this.profiles.has(profileName);
61
+ }
62
+ /**
63
+ * Register an alias opened at runtime. An existing name is replaced, so
64
+ * reconnecting with corrected credentials fixes the entry rather than
65
+ * failing.
66
+ */
67
+ register(profile) {
68
+ this.profiles.set(profile.name, profile);
69
+ }
70
+ getActiveTarget() {
71
+ return this.activeTarget;
72
+ }
73
+ getActiveName() {
74
+ return this.activeName;
75
+ }
76
+ /**
77
+ * @throws NoActiveConnectionError whose message names both ways out. Tool
78
+ * handlers convert it to a tool result, so that message is what the user
79
+ * reads.
80
+ */
81
+ requireActiveTarget() {
82
+ if (!this.activeTarget) {
83
+ throw new NoActiveConnectionError();
84
+ }
85
+ return this.activeTarget;
86
+ }
87
+ /** @throws UnknownProfileError listing the names that do exist. */
88
+ activateProfile(profileName) {
89
+ const profile = this.profiles.get(profileName);
90
+ if (!profile) {
91
+ throw new UnknownProfileError(profileName, this.names());
92
+ }
93
+ this.activeName = profile.name;
94
+ this.activeTarget = profile.target;
95
+ return this.activeTarget;
96
+ }
97
+ /**
98
+ * Point at an explicit target under a label.
99
+ *
100
+ * No defensive copy is needed because ConnectionTarget is immutable: nothing
101
+ * downstream can mutate the active target into corrupting the profile it
102
+ * came from.
103
+ */
104
+ activateTarget(target, label) {
105
+ this.activeName = label;
106
+ this.activeTarget = target;
107
+ return target;
108
+ }
109
+ }
@@ -0,0 +1,104 @@
1
+ import { resolve } from "node:path";
2
+ import { ConnectionTarget } from "../domain/ConnectionTarget.js";
3
+ import { InvalidProfileDefinitionError } from "../errors/InvalidProfileDefinitionError.js";
4
+ import { ConnectionUrlParser } from "./ConnectionUrlParser.js";
5
+ /**
6
+ * Builds ConnectionTarget instances from untrusted input.
7
+ *
8
+ * A Factory rather than a constructor because the input arrives in three
9
+ * shapes (a URL, a legacy MYSQL_PROFILES object, and the legacy MYSQL_*
10
+ * variables) and every one has to be checked and defaulted the same way.
11
+ * Keeping all three here means they cannot drift apart in what they accept.
12
+ */
13
+ export class ConnectionTargetFactory {
14
+ parser;
15
+ resolvePath;
16
+ /**
17
+ * The legacy MYSQL_* form's default host.
18
+ *
19
+ * `localhost` inside a container means the container itself, which is the
20
+ * single most common mistake when the database runs on the host. Kept from
21
+ * the MySQL-only server so an existing configuration behaves identically.
22
+ * URLs always name their host, so this never applies to them.
23
+ */
24
+ static DEFAULT_MYSQL_HOST = "host.docker.internal";
25
+ static DEFAULT_MYSQL_PORT = 3306;
26
+ /**
27
+ * @param resolvePath turns a SQLite path absolute. Injected so tests do not
28
+ * depend on the directory they run from.
29
+ */
30
+ constructor(parser = new ConnectionUrlParser(), resolvePath = (path) => resolve(path)) {
31
+ this.parser = parser;
32
+ this.resolvePath = resolvePath;
33
+ }
34
+ /**
35
+ * @param password used instead of the URL's own password when non-empty, so
36
+ * a password full of `@`, `/` and `#` never needs percent-encoding.
37
+ * @throws InvalidConnectionUrlError describing the problem without the URL.
38
+ */
39
+ fromUrl(url, password) {
40
+ const props = this.parser.parse(url);
41
+ // A SQLite path is made absolute once, here, so the same file reached as
42
+ // `./app.db` and `/work/app.db` is recognised as one connection, and so a
43
+ // later change of working directory cannot repoint it.
44
+ const database = props.engine === "sqlite" ? this.resolvePath(props.database) : props.database;
45
+ return new ConnectionTarget({
46
+ ...props,
47
+ password: password ? password : props.password,
48
+ database,
49
+ });
50
+ }
51
+ /**
52
+ * One entry of the legacy MYSQL_PROFILES object form.
53
+ *
54
+ * @throws InvalidProfileDefinitionError when user or database is missing.
55
+ * The caller turns this into a warning; one broken profile must not stop
56
+ * the server from starting.
57
+ */
58
+ fromMySqlDefinition(raw, profileName) {
59
+ const user = this.readString(raw.user);
60
+ const database = this.readString(raw.database);
61
+ if (!user) {
62
+ throw new InvalidProfileDefinitionError(profileName, 'is missing "user".');
63
+ }
64
+ if (!database) {
65
+ throw new InvalidProfileDefinitionError(profileName, 'is missing "database".');
66
+ }
67
+ return this.fromMySqlValues({
68
+ host: this.readString(raw.host),
69
+ port: this.readPort(raw.port),
70
+ user,
71
+ password: this.readString(raw.password),
72
+ database,
73
+ });
74
+ }
75
+ /** Builds a MySQL target from already-trusted values, applying the legacy defaults. */
76
+ fromMySqlValues(values) {
77
+ return new ConnectionTarget({
78
+ engine: "mysql",
79
+ scheme: "mysql",
80
+ hosts: [
81
+ {
82
+ host: values.host || ConnectionTargetFactory.DEFAULT_MYSQL_HOST,
83
+ port: values.port ?? ConnectionTargetFactory.DEFAULT_MYSQL_PORT,
84
+ },
85
+ ],
86
+ user: values.user,
87
+ password: values.password ?? "",
88
+ database: values.database,
89
+ options: {},
90
+ secretOptions: {},
91
+ });
92
+ }
93
+ readString(value) {
94
+ return typeof value === "string" ? value : "";
95
+ }
96
+ /**
97
+ * Only a real number is accepted. A port given as the string "3306" falls
98
+ * back to the default rather than becoming NaN and failing much later with
99
+ * an unrecognisable error.
100
+ */
101
+ readPort(value) {
102
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
103
+ }
104
+ }