@mcpher/gas-fakes 2.3.4 → 2.3.5

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/README.md CHANGED
@@ -196,6 +196,7 @@ As I mentioned earlier, to take this further, I'm going to need a lot of help to
196
196
  - [running gas-fakes on Amazon AWS lambda](https://github.com/brucemcpherson/gas-fakes-containers)
197
197
  - [running gas-fakes on Azure ACA](https://github.com/brucemcpherson/gas-fakes-containers)
198
198
  - [running gas-fakes on Github actions](https://github.com/brucemcpherson/gas-fakes-containers)
199
+ - [jdbc notes](jdbc-notes.md)
199
200
  - [Yes – you can run native apps script code on Azure ACA as well!](https://ramblings.mcpher.com/yes-you-can-run-native-apps-script-code-on-azure-aca-as-well/)
200
201
  - [Yes – you can run native apps script code on AWS Lambda!](https://ramblings.mcpher.com/apps-script-on-aws-lambda/)
201
202
  - [initial idea and thoughts](https://ramblings.mcpher.com/a-proof-of-concept-implementation-of-apps-script-environment-on-node/)
package/appsscript.json CHANGED
@@ -5,6 +5,7 @@
5
5
  "oauthScopes": [
6
6
  "https://www.googleapis.com/auth/cloud-platform",
7
7
  "https://www.googleapis.com/auth/drive",
8
+ "https://www.googleapis.com/auth/sqlservice",
8
9
  "https://www.googleapis.com/auth/script.external_request",
9
10
  "https://www.googleapis.com/auth/spreadsheets",
10
11
  "https://www.googleapis.com/auth/userinfo.email",
package/package.json CHANGED
@@ -11,6 +11,7 @@
11
11
  "@sindresorhus/is": "^7.2.0",
12
12
  "acorn": "^8.16.0",
13
13
  "adm-zip": "^0.5.16",
14
+ "child_process": "^1.0.2",
14
15
  "commander": "^14.0.3",
15
16
  "dotenv": "^17.3.1",
16
17
  "fast-xml-parser": "^5.5.9",
@@ -22,6 +23,8 @@
22
23
  "keyv": "^5.6.0",
23
24
  "keyv-file": "^5.3.3",
24
25
  "mime": "^4.1.0",
26
+ "mysql2": "^3.20.0",
27
+ "pg": "^8.20.0",
25
28
  "prompts": "^2.4.2",
26
29
  "sleep-synchronously": "^2.0.0",
27
30
  "zod": "^4.3.6"
@@ -35,7 +38,7 @@
35
38
  },
36
39
  "name": "@mcpher/gas-fakes",
37
40
  "author": "bruce mcpherson",
38
- "version": "2.3.4",
41
+ "version": "2.3.5",
39
42
  "license": "MIT",
40
43
  "main": "main.js",
41
44
  "description": "An implementation of the Google Workspace Apps Script runtime: Run native App Script Code on Node and Cloud Run",
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "skills": {
4
+ "neon-postgres": {
5
+ "source": "neondatabase/agent-skills",
6
+ "sourceType": "github",
7
+ "computedHash": "391693eee67001639ab65474df185da3ea5cf9ee4b99badecfd64561113edbb4"
8
+ }
9
+ }
10
+ }
package/src/cli/app.js CHANGED
@@ -175,6 +175,24 @@ export async function main() {
175
175
  .option("-t, --tools <string>", "Path to custom tools file.")
176
176
  .action(startMcpServer);
177
177
 
178
+ // --- JDBC Command ---
179
+ program
180
+ .command("jdbc")
181
+ .description("Parse a JDBC connection string and output configurations for App Script and Local environments.")
182
+ .requiredOption("-c, --connection-string <string>", "The JDBC connection string to parse.")
183
+ .action(async (options) => {
184
+ try {
185
+ const { newFakeJdbcService } = await import("../services/jdbc/fakejdbcservice.js");
186
+ const service = newFakeJdbcService();
187
+ const config = service.__normalConnection(options.connectionString);
188
+ process.stdout.write(JSON.stringify(config, null, 2) + "\n");
189
+ process.exit(0);
190
+ } catch (err) {
191
+ process.stderr.write(`Error parsing connection string: ${err.message}\n`);
192
+ process.exit(1);
193
+ }
194
+ });
195
+
178
196
  program.showHelpAfterError("(add --help for additional information)");
179
197
 
180
198
  await program.parseAsync(process.argv);
package/src/cli/setup.js CHANGED
@@ -242,16 +242,60 @@ export async function initializeConfiguration(options = {}) {
242
242
  // Discover Scopes from appsscript.json (Shared across backends)
243
243
  const manifestPath = path.resolve(process.cwd(), responses.GF_MANIFEST_PATH);
244
244
  let manifestScopes = [];
245
+ let manifestHasCloudSql = false;
245
246
  if (fs.existsSync(manifestPath)) {
246
247
  try {
247
248
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
248
249
  manifestScopes = manifest.oauthScopes || [];
249
250
  console.log(`...discovered ${manifestScopes.length} scopes in ${responses.GF_MANIFEST_PATH}`);
251
+
252
+ // Check for JDBC or Cloud SQL usage (simplified check)
253
+ const manifestContent = fs.readFileSync(manifestPath, "utf8");
254
+ if (manifestContent.includes("jdbc:google:") || manifestContent.includes("Jdbc.")) {
255
+ manifestHasCloudSql = true;
256
+ }
250
257
  } catch (err) {
251
258
  console.warn(`...warning: failed to parse ${responses.GF_MANIFEST_PATH}.`);
252
259
  }
253
260
  }
254
261
 
262
+ // --- Step 2.5: Database & Cloud SQL Proxy Configuration ---
263
+ const hasJdbc = manifestHasCloudSql || Object.keys(existingConfig).some(k => k.includes("DATABASE_URL"));
264
+ if (hasJdbc) {
265
+ console.log("\n--- Configuring Database & Cloud SQL Auth Proxy ---");
266
+
267
+ // Check if proxy is installed
268
+ let proxyInstalled = false;
269
+ try {
270
+ execSync("cloud-sql-proxy --version", { stdio: "ignore" });
271
+ proxyInstalled = true;
272
+ } catch (e) {
273
+ // not installed or not in path
274
+ }
275
+
276
+ if (!proxyInstalled) {
277
+ console.log("\x1b[1;33mNotice: Cloud SQL Auth Proxy is not installed or not in your PATH.\x1b[0m");
278
+ console.log("If you plan to test Google Cloud SQL locally, please install it:");
279
+ console.log(" - macOS (Homebrew): brew install google-cloud-sdk");
280
+ console.log(" then: gcloud components install cloud-sql-proxy");
281
+ console.log(" - Other: https://cloud.google.com/sql/docs/postgres/sql-proxy#install\n");
282
+ }
283
+
284
+ const dbQuestions = [
285
+ {
286
+ type: "toggle",
287
+ name: "GF_USE_CLOUD_PG_SQL_PROXY",
288
+ message: "Use Cloud SQL Auth Proxy for local database connections?",
289
+ initial: existingConfig.GF_USE_CLOUD_PG_SQL_PROXY === "true",
290
+ active: "yes",
291
+ inactive: "no"
292
+ }
293
+ ];
294
+
295
+ const dbResponses = await prompts(dbQuestions);
296
+ Object.assign(responses, dbResponses);
297
+ }
298
+
255
299
  // --- Step 3: Google Workspace Configuration ---
256
300
  if (platforms.includes("google")) {
257
301
  console.log("\n--- Configuring Google Workspace backend ---");
@@ -756,8 +800,8 @@ export async function authenticateUser(options = {}) {
756
800
  runCommandSync(`gcloud iam service-accounts add-iam-policy-binding "${sa_email}" --member="user:${current_user}" --role="roles/iam.serviceAccountTokenCreator" --quiet`, true);
757
801
 
758
802
  const saUniqueId = execSync(`gcloud iam service-accounts describe "${sa_email}" --format="value(uniqueId)"`, { shell: true }).toString().trim();
759
- console.log(`\n\x1b[1;33m************************************************************************`);
760
- console.log(`IMPORTANT: Add this to Admin Console (Domain-Wide Delegation):`);
803
+ console.log(`\n\x1b[1;33m*************************************************************************************************`);
804
+ console.log(`IMPORTANT: If you haven't already done it, add this to Admin Console (Domain-Wide Delegation):`);
761
805
  console.log(`************************************************************************\x1b[0m`);
762
806
  console.log(`URL: https://admin.google.com/ac/owl/domainwidedelegation`);
763
807
  console.log(`Client ID: ${saUniqueId}\nScopes: ${scopes}`);
package/src/index.js CHANGED
@@ -28,3 +28,5 @@ import './services/slidesapp/app.js'
28
28
  import './services/mimetype/app.js'
29
29
  import './services/lock/app.js'
30
30
  import './services/libhandlerapp/app.js'
31
+ import './services/jdbc/app.js'
32
+
@@ -0,0 +1,9 @@
1
+ /**
2
+ * the idea here is to create an empty global entry for the singleton
3
+ * but only load it when it is actually used.
4
+ */
5
+ import { newFakeJdbcService as maker } from './fakejdbcservice.js';
6
+ import { lazyLoaderApp } from '../common/lazyloader.js'
7
+
8
+ let _app = null;
9
+ _app = lazyLoaderApp(_app, 'Jdbc', maker);
@@ -0,0 +1,45 @@
1
+ import { Proxies } from '../../support/proxies.js';
2
+ import { Syncit } from '../../support/syncit.js';
3
+ import { newFakeJdbcStatement } from './fakejdbcstatement.js';
4
+
5
+ class FakeJdbcConnection {
6
+ constructor(url, user, password) {
7
+ this.__fakeObjectType = 'JdbcConnection';
8
+ this._url = url;
9
+
10
+ // Connect synchronously using the worker
11
+ // Only pass arguments that are provided to avoid passing null/undefined to Syncit
12
+ const args = [url];
13
+ if (user !== null && typeof user !== 'undefined') args.push(user);
14
+ if (password !== null && typeof password !== 'undefined') args.push(password);
15
+
16
+ const result = Syncit.fxJdbcConnect(...args);
17
+ this._connectionId = result.id;
18
+ }
19
+
20
+ createStatement() {
21
+ return newFakeJdbcStatement(this._connectionId);
22
+ }
23
+
24
+ getMetaData() {
25
+ // Return an object that mimics DatabaseMetaData
26
+ return Proxies.guard({
27
+ __fakeObjectType: 'JdbcDatabaseMetaData',
28
+ getURL: () => this._url
29
+ });
30
+ }
31
+
32
+ // To match GAS JdbcConnection basic capabilities
33
+ close() {
34
+ if (this._connectionId) {
35
+ Syncit.fxJdbcClose(this._connectionId);
36
+ this._connectionId = null;
37
+ }
38
+ }
39
+
40
+ isClosed() {
41
+ return !this._connectionId;
42
+ }
43
+ }
44
+
45
+ export const newFakeJdbcConnection = (...args) => Proxies.guard(new FakeJdbcConnection(...args));
@@ -0,0 +1,70 @@
1
+ import { Proxies } from '../../support/proxies.js';
2
+ import { newFakeJdbcResultSetMetaData } from './fakejdbcresultsetmetadata.js';
3
+
4
+ class FakeJdbcResultSet {
5
+ constructor(result) {
6
+ this.__fakeObjectType = 'JdbcResultSet';
7
+ this._rows = result.rows || [];
8
+ this._fields = result.fields || [];
9
+ this._currentIndex = -1;
10
+ this._isClosed = false;
11
+ }
12
+
13
+ next() {
14
+ if (this._isClosed) throw new Error('ResultSet is closed.');
15
+ this._currentIndex++;
16
+ return this._currentIndex < this._rows.length;
17
+ }
18
+
19
+ _getValue(columnIndex) {
20
+ if (this._isClosed) throw new Error('ResultSet is closed.');
21
+ if (this._currentIndex < 0 || this._currentIndex >= this._rows.length) {
22
+ throw new Error('No current row.');
23
+ }
24
+ // JDBC columns are 1-indexed
25
+ const field = this._fields[columnIndex - 1];
26
+ if (!field) throw new Error(`Invalid column index: ${columnIndex}`);
27
+ return this._rows[this._currentIndex][field.name];
28
+ }
29
+
30
+ getString(columnIndex) {
31
+ const val = this._getValue(columnIndex);
32
+ return val !== null && val !== undefined ? String(val) : null;
33
+ }
34
+
35
+ getInt(columnIndex) {
36
+ const val = this._getValue(columnIndex);
37
+ return val !== null && val !== undefined ? parseInt(val, 10) : 0;
38
+ }
39
+
40
+ getFloat(columnIndex) {
41
+ const val = this._getValue(columnIndex);
42
+ return val !== null && val !== undefined ? parseFloat(val) : 0.0;
43
+ }
44
+
45
+ getDate(columnIndex) {
46
+ const val = this._getValue(columnIndex);
47
+ return val ? new Date(val) : null;
48
+ }
49
+
50
+ getObject(columnIndex) {
51
+ return this._getValue(columnIndex);
52
+ }
53
+
54
+ getMetaData() {
55
+ if (this._isClosed) throw new Error('ResultSet is closed.');
56
+ return newFakeJdbcResultSetMetaData(this._fields);
57
+ }
58
+
59
+ close() {
60
+ this._isClosed = true;
61
+ this._rows = [];
62
+ this._fields = [];
63
+ }
64
+
65
+ isClosed() {
66
+ return this._isClosed;
67
+ }
68
+ }
69
+
70
+ export const newFakeJdbcResultSet = (...args) => Proxies.guard(new FakeJdbcResultSet(...args));
@@ -0,0 +1,39 @@
1
+ import { Proxies } from '../../support/proxies.js';
2
+
3
+ class FakeJdbcResultSetMetaData {
4
+ constructor(fields) {
5
+ this.__fakeObjectType = 'JdbcResultSetMetaData';
6
+ this._fields = fields || [];
7
+ }
8
+
9
+ getColumnCount() {
10
+ return this._fields.length;
11
+ }
12
+
13
+ getColumnName(column) {
14
+ const field = this._fields[column - 1];
15
+ if (!field) throw new Error(`Invalid column index: ${column}`);
16
+ return field.name;
17
+ }
18
+
19
+ getColumnLabel(column) {
20
+ return this.getColumnName(column);
21
+ }
22
+
23
+ getColumnType(column) {
24
+ // Return approximate JDBC type from pg dataTypeID
25
+ const field = this._fields[column - 1];
26
+ if (!field) throw new Error(`Invalid column index: ${column}`);
27
+ // node-postgres gives type ids (OIDs) like 23 for INT4, 25 for TEXT
28
+ // Usually JDBC maps them to java.sql.Types integers. For a fake, returning the OID or a mock type integer is adequate for basic use cases.
29
+ return field.dataTypeID;
30
+ }
31
+
32
+ getColumnTypeName(column) {
33
+ const field = this._fields[column - 1];
34
+ if (!field) throw new Error(`Invalid column index: ${column}`);
35
+ return `TYPE_${field.dataTypeID}`; // basic fallback
36
+ }
37
+ }
38
+
39
+ export const newFakeJdbcResultSetMetaData = (...args) => Proxies.guard(new FakeJdbcResultSetMetaData(...args));
@@ -0,0 +1,245 @@
1
+ import { Proxies } from '../../support/proxies.js';
2
+ import { newFakeJdbcConnection } from './fakejdbcconnection.js';
3
+ import { execSync } from 'child_process';
4
+
5
+ class FakeJdbcService {
6
+ constructor() {
7
+ this.__fakeObjectType = 'Jdbc';
8
+ }
9
+
10
+ /**
11
+ * Connects to a JDBC database.
12
+ * In gas-fakes, we primarily support postgres via pg module, mapping jdbc:postgresql to it.
13
+ *
14
+ * @param {string} url The URL of the database to connect to.
15
+ * @param {string} user (optional) The user name to connect as.
16
+ * @param {string} password (optional) The password for the user.
17
+ * @returns {JdbcConnection} A JDBC connection object.
18
+ */
19
+ getConnection(url, user, password) {
20
+ let finalUrl = url;
21
+
22
+ if (!finalUrl) {
23
+ throw new Error('Jdbc.getConnection: URL is required or DATABASE_URL must be set in environment');
24
+ }
25
+
26
+ // Explicitly merge user/password into the URL structure for gas-fakes processing
27
+ if (user !== undefined && user !== null && password !== undefined && password !== null) {
28
+ try {
29
+ const cleanUrl = finalUrl.replace(/^jdbc:google:/, '').replace(/^jdbc:/, '');
30
+ const urlObj = new URL(cleanUrl);
31
+ urlObj.username = encodeURIComponent(String(user));
32
+ urlObj.password = encodeURIComponent(String(password));
33
+
34
+ // Restore the prefix
35
+ let prefix = "jdbc:";
36
+ if (finalUrl.startsWith("jdbc:google:")) prefix = "jdbc:google:";
37
+ finalUrl = prefix + urlObj.toString();
38
+ } catch (e) {
39
+ // Fallback for malformed URLs
40
+ }
41
+ }
42
+
43
+ // We intentionally pass undefined for user and password so the worker only sees finalUrl
44
+ return newFakeJdbcConnection(finalUrl, undefined, undefined);
45
+ }
46
+
47
+ /**
48
+ * Connects to a Google Cloud SQL instance - but on gas-fakes its the same thing
49
+ * @param {string} url The URL of the database to connect to.
50
+ * @param {string} user (optional) The user name to connect as.
51
+ * @param {string} password (optional) The password for the user.
52
+ * @returns {JdbcConnection} A JDBC connection object.
53
+ */
54
+ getCloudSqlConnection(url, user, password) {
55
+ return this.getConnection (url, user, password)
56
+ }
57
+
58
+ parseCsv(csv) {
59
+ throw new Error('Not implemented: parseCsv');
60
+ }
61
+
62
+ __useProxy (val) {
63
+ if (!val) return false;
64
+ const normal = this.__normalConnection (val)
65
+ return normal.local.useProxy;
66
+ };
67
+
68
+ /**
69
+ * Converts Database URLs to JDBC-compatible formats for testing and local proxy usage.
70
+ * @param {string} url - Format: protocol://user:pass@host/db OR protocol://host/db?user=X&password=Y
71
+ * @return {object} - Connection metadata.
72
+ */
73
+ __normalConnection(url) {
74
+ const isProxyRunning = (instanceName) => {
75
+ try {
76
+ execSync(`pgrep -f "cloud.*sql.*proxy.*${instanceName}"`, {
77
+ stdio: "ignore",
78
+ });
79
+ return true;
80
+ } catch (e) {
81
+ return false;
82
+ }
83
+ };
84
+
85
+ const aggressiveEncode = (str) => {
86
+ return encodeURIComponent(str).replace(/[!'()*]/g, (c) => {
87
+ return "%" + c.charCodeAt(0).toString(16).toUpperCase();
88
+ });
89
+ };
90
+
91
+ const cleanUrl = url.replace(/^jdbc:google:/, "").replace(/^jdbc:/, "");
92
+
93
+ let scheme, user, pass, host, db;
94
+
95
+ try {
96
+ const parsed = new URL(cleanUrl);
97
+ scheme = parsed.protocol.replace(':', '');
98
+ user = parsed.username || "";
99
+ pass = parsed.password || "";
100
+ host = parsed.hostname;
101
+ const portFromUrl = parsed.port;
102
+ if (portFromUrl) {
103
+ host = `${host}:${portFromUrl}`;
104
+ }
105
+ db = parsed.pathname.replace(/^\//, '');
106
+
107
+ const remainingParams = new URLSearchParams(parsed.search);
108
+ if (!user && remainingParams.has('user')) user = remainingParams.get('user');
109
+ if (!pass && remainingParams.has('password')) pass = remainingParams.get('password');
110
+ remainingParams.delete('user');
111
+ remainingParams.delete('password');
112
+
113
+ const searchString = remainingParams.toString();
114
+ if (searchString) {
115
+ db = `${db}?${searchString}`;
116
+ }
117
+ } catch (e) {
118
+ const regex = /^([^:]+):\/\/(.+):([^@]+)@([^/]+)(?:\/(.*))?/;
119
+ const match = cleanUrl.match(regex);
120
+ if (!match) throw new Error(`Format not recognized for ${cleanUrl}`);
121
+ scheme = match[1].trim();
122
+ user = match[2].trim();
123
+ pass = match[3].trim();
124
+ host = match[4].trim();
125
+ db = match[5] ? match[5].trim() : "postgres";
126
+ }
127
+
128
+ user = decodeURIComponent(user.trim());
129
+ pass = decodeURIComponent(pass.trim());
130
+ host = host.trim();
131
+
132
+ let pureDb = db ? db.trim() : "postgres";
133
+ if (pureDb.includes('?')) pureDb = pureDb.split('?')[0];
134
+
135
+ const isPostgres = scheme.toLowerCase().includes("post");
136
+ const type = isPostgres ? "pg" : "mysql";
137
+ const protocol = isPostgres ? "jdbc:postgresql://" : "jdbc:mysql://";
138
+ const defaultPort = isPostgres ? ":5432" : ":3306";
139
+
140
+ let hostWithoutPort = host.replace(/:\d+$/, "");
141
+ let isCloudSql = hostWithoutPort.includes(":");
142
+ let isGoogle = isCloudSql || url.startsWith('jdbc:google:');
143
+ let useProxy = false;
144
+ let remoteHost = host;
145
+
146
+ const cloudSqlInstanceName = hostWithoutPort;
147
+
148
+ if (isCloudSql) {
149
+ try {
150
+ const instanceParts = hostWithoutPort.split(":");
151
+ const instanceName = instanceParts[instanceParts.length - 1];
152
+ useProxy = isProxyRunning(instanceName);
153
+
154
+ const instanceInfoStr = execSync(`gcloud sql instances describe ${instanceName} --format=json`, { encoding: "utf-8", stdio: "pipe" });
155
+ const instanceInfo = JSON.parse(instanceInfoStr);
156
+ const primaryIpObj = instanceInfo.ipAddresses?.find((ip) => ip.type === "PRIMARY");
157
+ const ip = primaryIpObj ? primaryIpObj.ipAddress : null;
158
+
159
+ if (ip && ip.match(/^[0-9.]+$/)) {
160
+ remoteHost = ip;
161
+ }
162
+
163
+ if (!useProxy) {
164
+ const localIp = execSync("curl -s https://ifconfig.me", { encoding: "utf-8", stdio: "pipe" }).trim();
165
+ if (localIp && localIp.match(/^[0-9.]+$/)) {
166
+ const authorizedNetworks = instanceInfo.settings?.ipConfiguration?.authorizedNetworks || [];
167
+ if (!authorizedNetworks.some((net) => net.value === localIp || net.value === `${localIp}/32`)) {
168
+ const newNetworks = authorizedNetworks.map((n) => n.value).concat(`${localIp}/32`).join(",");
169
+ execSync(`gcloud sql instances patch ${instanceName} --authorized-networks="${newNetworks}" --quiet`, { encoding: "utf-8", stdio: "pipe" });
170
+ }
171
+ }
172
+ }
173
+ } catch (e) {}
174
+ }
175
+
176
+ if (!remoteHost.includes(":")) remoteHost += defaultPort;
177
+
178
+ const encodedUser = aggressiveEncode(user);
179
+ const encodedPass = aggressiveEncode(pass);
180
+
181
+ // Live Apps Script Java JDBC requires properly encoded URI components to prevent "Connection URL is malformed"
182
+ const gasAuthQuery = (encodedUser || encodedPass) ? `?user=${encodedUser}&password=${encodedPass}` : '';
183
+
184
+ // --- GAS ---
185
+ let gasUrl, gasConnectionString, gasFullConnectionString;
186
+ let isCloudSqlConnection = false;
187
+
188
+ if (isGoogle && !isPostgres) {
189
+ gasUrl = `jdbc:google:mysql://${cloudSqlInstanceName}/${pureDb}`;
190
+ gasConnectionString = gasUrl;
191
+ gasFullConnectionString = gasUrl;
192
+ isCloudSqlConnection = true;
193
+ } else {
194
+ gasUrl = `${protocol}${remoteHost}/${pureDb}`;
195
+ gasConnectionString = `${protocol}${remoteHost}/${pureDb}`;
196
+ // Drop all original query parameters, only append explicit unencoded user, password, and ssl
197
+ gasFullConnectionString = `${protocol}${remoteHost}/${pureDb}${gasAuthQuery}`;
198
+ }
199
+
200
+ // --- LOCAL ---
201
+ let localHostAddr = useProxy ? `127.0.0.1${defaultPort}` : remoteHost;
202
+ let localSslParam = useProxy ? "ssl=false" : "";
203
+ let localAuthParams = (encodedUser || encodedPass) ? `user=${encodedUser}&password=${encodedPass}` : '';
204
+
205
+ // Add an ampersand if both auth params and ssl param exist
206
+ if (localAuthParams && localSslParam) {
207
+ localAuthParams += "&";
208
+ }
209
+
210
+ let localUrl = `${protocol}${localHostAddr}/${pureDb}`;
211
+ let localQuery = `${localAuthParams}${localSslParam}`;
212
+ let localConnectionString = `${protocol}${localHostAddr}/${pureDb}${localQuery ? '?' + localQuery : ''}`;
213
+ let localFullConnectionString = localConnectionString;
214
+
215
+ const proxyCommand = (isCloudSql || isGoogle) && cloudSqlInstanceName.includes(":") ? `cloud-sql-proxy ${cloudSqlInstanceName}` : "";
216
+
217
+ const gasObj = {
218
+ url: gasUrl,
219
+ connectionString: gasConnectionString,
220
+ user, password: pass,
221
+ fullConnectionString: gasFullConnectionString,
222
+ isCloudSqlConnection,
223
+ proxyCommand
224
+ };
225
+
226
+ const localObj = {
227
+ url: localUrl,
228
+ connectionString: localConnectionString,
229
+ user, password: pass,
230
+ fullConnectionString: localFullConnectionString,
231
+ useProxy, proxyCommand
232
+ };
233
+
234
+ return {
235
+ current: (typeof ScriptApp !== 'undefined' && ScriptApp.isFake) ? 'local' : 'gas',
236
+ gas: gasObj,
237
+ local: localObj,
238
+ host: remoteHost,
239
+ proxyCommand,
240
+ isGoogle, type
241
+ };
242
+ }
243
+ }
244
+
245
+ export const newFakeJdbcService = (...args) => Proxies.guard(new FakeJdbcService(...args));
@@ -0,0 +1,39 @@
1
+ import { Proxies } from '../../support/proxies.js';
2
+ import { Syncit } from '../../support/syncit.js';
3
+ import { newFakeJdbcResultSet } from './fakejdbcresultset.js';
4
+
5
+ class FakeJdbcStatement {
6
+ constructor(connectionId) {
7
+ this.__fakeObjectType = 'JdbcStatement';
8
+ this._connectionId = connectionId;
9
+ this._isClosed = false;
10
+ }
11
+
12
+ executeQuery(sql) {
13
+ if (this._isClosed) throw new Error('Statement is closed.');
14
+
15
+ // Fetch result synchronously from worker
16
+ const result = Syncit.fxJdbcQuery(this._connectionId, sql);
17
+
18
+ return newFakeJdbcResultSet(result);
19
+ }
20
+
21
+ execute(sql) {
22
+ if (this._isClosed) throw new Error('Statement is closed.');
23
+
24
+ // In actual JDBC, execute returns true if the first result is a ResultSet
25
+ // node-postgres gives us an array of rows or a rowCount.
26
+ const result = Syncit.fxJdbcQuery(this._connectionId, sql);
27
+ return result.fields && result.fields.length > 0;
28
+ }
29
+
30
+ close() {
31
+ this._isClosed = true;
32
+ }
33
+
34
+ isClosed() {
35
+ return this._isClosed;
36
+ }
37
+ }
38
+
39
+ export const newFakeJdbcStatement = (...args) => Proxies.guard(new FakeJdbcStatement(...args));
@@ -128,6 +128,32 @@ export const sxInit = async ({ manifestPath, claspPath, settingsPath, cachePath,
128
128
 
129
129
  } catch (err) {
130
130
  syncWarn(`Google authentication failed: ${err.message}`);
131
+
132
+ // Provide guidance for Domain Wide Delegation issues
133
+ if (err.message.includes('unauthorized_client')) {
134
+ const clientId = Auth.getClientId();
135
+ const msg = [
136
+ "",
137
+ "=".repeat(80),
138
+ "GOOGLE AUTHENTICATION ERROR: unauthorized_client",
139
+ "This usually means Domain-Wide Delegation (DWD) is missing for one or more scopes.",
140
+ "",
141
+ `Your Service Account Client ID is: ${clientId || 'unknown (check your service account JSON file)'}`,
142
+ "",
143
+ "The following scopes should be authorized in the Google Admin Console:",
144
+ finalScopes.join(","),
145
+ "",
146
+ "To fix this:",
147
+ "1. Go to https://admin.google.com",
148
+ "2. Security -> Access and data control -> API controls",
149
+ "3. Manage Domain Wide Delegation",
150
+ "4. Find/Add your Client ID and ensure the list of scopes above matches exactly.",
151
+ "=".repeat(80),
152
+ ""
153
+ ].join("\n");
154
+ console.error(msg);
155
+ }
156
+
131
157
  if (!platforms.includes('ksuite') && !platforms.includes('msgraph')) throw err;
132
158
  }
133
159
  }
@@ -0,0 +1,166 @@
1
+ import pg from 'pg';
2
+ import mysql from 'mysql2/promise';
3
+
4
+ const connections = new Map();
5
+
6
+ /**
7
+ * Creates and stores a connection to the database.
8
+ * @param {import('./auth.js').Auth} Auth
9
+ * @param {object} params
10
+ * @param {string} params.url JDBC connection URL
11
+ * @param {string} params.user Optional username
12
+ * @param {string} params.password Optional password
13
+ * @returns {object} Connection details/ID
14
+ */
15
+ export const sxJdbcConnect = async (Auth, { url, user, password }) => {
16
+ let connectionString = url;
17
+ let type = 'pg';
18
+
19
+ if (url.startsWith('jdbc:postgresql:')) {
20
+ connectionString = url.replace('jdbc:postgresql:', 'postgresql:');
21
+ type = 'pg';
22
+ } else if (url.startsWith('jdbc:mysql:')) {
23
+ connectionString = url.replace('jdbc:mysql:', 'mysql:');
24
+ type = 'mysql';
25
+ } else if (url.startsWith('jdbc:google:mysql:')) {
26
+ connectionString = url.replace('jdbc:google:mysql:', 'mysql:');
27
+ type = 'mysql';
28
+ } else if (url.startsWith('mysql:')) {
29
+ type = 'mysql';
30
+ }
31
+
32
+ // Parse custom flags
33
+ const disableSsl = connectionString.includes('ssl=false') || connectionString.includes('127.0.0.1');
34
+
35
+ let client;
36
+ const id = `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
37
+
38
+ if (type === 'pg') {
39
+ // Strip ssl parameters to avoid conflict with explicit ssl object
40
+ let pgConnectionString = connectionString.replace(/([?&])ssl=[^&]*(&|$)/g, '$1').replace(/[?&]$/, '');
41
+
42
+ // Neon postgres usually requires ssl
43
+ if (pgConnectionString.includes('sslmode=require') && !pgConnectionString.includes('uselibpqcompat')) {
44
+ const separator = pgConnectionString.includes('?') ? '&' : '?';
45
+ pgConnectionString += `${separator}uselibpqcompat=true`;
46
+ }
47
+
48
+ const clientConfig = {
49
+ connectionString: pgConnectionString,
50
+ ssl: disableSsl ? false : { rejectUnauthorized: false }
51
+ };
52
+
53
+ if (user !== null && typeof user !== 'undefined' && password !== null && typeof password !== 'undefined') {
54
+ client = new pg.Client({
55
+ ...clientConfig,
56
+ user: String(user),
57
+ password: String(password)
58
+ });
59
+ } else {
60
+ client = new pg.Client(clientConfig);
61
+ }
62
+ await client.connect();
63
+ } else if (type === 'mysql') {
64
+ let cleanUrl = connectionString.replace(/^jdbc:/, '');
65
+ if (!cleanUrl.startsWith('mysql://')) {
66
+ cleanUrl = 'mysql://' + cleanUrl;
67
+ }
68
+
69
+ let host = '127.0.0.1';
70
+ let port = 3306;
71
+ let database = '';
72
+ let urlUser;
73
+ let urlPassword;
74
+
75
+ try {
76
+ const parsedUrl = new URL(cleanUrl);
77
+ host = parsedUrl.hostname;
78
+ port = parsedUrl.port ? parseInt(parsedUrl.port, 10) : 3306;
79
+ database = parsedUrl.pathname.replace(/^\//, '');
80
+ urlUser = parsedUrl.username ? decodeURIComponent(parsedUrl.username) : parsedUrl.searchParams.get('user');
81
+ urlPassword = parsedUrl.password ? decodeURIComponent(parsedUrl.password) : parsedUrl.searchParams.get('password');
82
+ } catch (e) {
83
+ // Fallback for weird formats (like Cloud SQL instance names without resolved IPs)
84
+ const match = cleanUrl.match(/^mysql:\/\/(?:([^:]+):([^@]+)@)?([^/]+?)(?::(\d+))?(?:\/([^?]+))?(?:\?(.*))?$/);
85
+ if (match) {
86
+ urlUser = match[1] ? decodeURIComponent(match[1]) : undefined;
87
+ urlPassword = match[2] ? decodeURIComponent(match[2]) : undefined;
88
+ host = match[3];
89
+ port = match[4] ? parseInt(match[4], 10) : 3306;
90
+ database = match[5] || '';
91
+ } else {
92
+ host = cleanUrl.replace(/^mysql:\/\//, '');
93
+ }
94
+ }
95
+
96
+ const finalUser = (user !== null && typeof user !== 'undefined') ? String(user) : urlUser;
97
+ const finalPassword = (password !== null && typeof password !== 'undefined') ? String(password) : urlPassword;
98
+
99
+ const mysqlConfig = {
100
+ host: host,
101
+ port: port,
102
+ database: database,
103
+ user: finalUser,
104
+ password: finalPassword,
105
+ ssl: disableSsl ? undefined : { rejectUnauthorized: false }
106
+ };
107
+
108
+ client = await mysql.createConnection(mysqlConfig);
109
+ }
110
+
111
+ connections.set(id, { client, type });
112
+ return { id };
113
+ };
114
+
115
+ /**
116
+ * Executes a query on a given connection.
117
+ * @param {import('./auth.js').Auth} Auth
118
+ * @param {object} params
119
+ * @param {string} params.connectionId The stored connection ID
120
+ * @param {string} params.sql The SQL query to execute
121
+ * @returns {object} The query result (rows, fields, etc.)
122
+ */
123
+ export const sxJdbcQuery = async (Auth, { connectionId, sql }) => {
124
+ const entry = connections.get(connectionId);
125
+ if (!entry) throw new Error('Invalid or closed JDBC connection.');
126
+
127
+ const { client, type } = entry;
128
+
129
+ if (type === 'pg') {
130
+ const result = await client.query(sql);
131
+ return {
132
+ rows: result.rows,
133
+ fields: result.fields,
134
+ rowCount: result.rowCount,
135
+ };
136
+ } else if (type === 'mysql') {
137
+ // mysql2 returns [rows, fields]
138
+ const [rows, fields] = await client.query(sql);
139
+ return {
140
+ rows: rows,
141
+ fields: fields, // mysql2 fields are objects with name, columnType, etc.
142
+ rowCount: rows.length || 0,
143
+ };
144
+ }
145
+ };
146
+
147
+ /**
148
+ * Closes a given connection.
149
+ * @param {import('./auth.js').Auth} Auth
150
+ * @param {object} params
151
+ * @param {string} params.connectionId The stored connection ID
152
+ * @returns {boolean} True if closed successfully
153
+ */
154
+ export const sxJdbcClose = async (Auth, { connectionId }) => {
155
+ const entry = connections.get(connectionId);
156
+ if (entry) {
157
+ const { client, type } = entry;
158
+ if (type === 'pg') {
159
+ await client.end();
160
+ } else if (type === 'mysql') {
161
+ await client.end();
162
+ }
163
+ connections.delete(connectionId);
164
+ }
165
+ return true;
166
+ };
@@ -412,6 +412,21 @@ const fxTestRetry = (errorMessage) => {
412
412
  return safeCallSync("sxTestRetry", { errorMessage });
413
413
  };
414
414
 
415
+ const fxJdbcConnect = (url, user, password) => {
416
+ const args = { url };
417
+ if (user !== null && typeof user !== 'undefined') args.user = user;
418
+ if (password !== null && typeof password !== 'undefined') args.password = password;
419
+ return safeCallSync("sxJdbcConnect", args);
420
+ };
421
+
422
+ const fxJdbcQuery = (connectionId, sql) => {
423
+ return safeCallSync("sxJdbcQuery", { connectionId, sql });
424
+ };
425
+
426
+ const fxJdbcClose = (connectionId) => {
427
+ return safeCallSync("sxJdbcClose", { connectionId });
428
+ };
429
+
415
430
  const fxSheets = (args) =>
416
431
  fxGeneric({
417
432
  ...args,
@@ -479,5 +494,8 @@ export const Syncit = {
479
494
  fxGetAccessToken,
480
495
  fxGetAccessTokenInfo,
481
496
  fxGetSourceAccessTokenInfo,
482
- fxTestRetry
497
+ fxTestRetry,
498
+ fxJdbcConnect,
499
+ fxJdbcQuery,
500
+ fxJdbcClose
483
501
  }
@@ -14,3 +14,4 @@ export * from "../sxtoken.js";
14
14
 
15
15
 
16
16
 
17
+ export * from "../sxjdbc.js";