@agenticmail/enterprise 0.5.285 → 0.5.286

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.
@@ -722,72 +722,169 @@ export class DatabaseConnectionManager {
722
722
  this.drivers.set(type, driver);
723
723
  }
724
724
 
725
+ // ─── Auto-Install Helper ──────────────────────────────────────────────────
726
+
727
+ private _installCache = new Set<string>();
728
+
729
+ /**
730
+ * Auto-install a npm package if not already installed.
731
+ * Caches install attempts to avoid repeated installs in the same process.
732
+ */
733
+ private async ensurePackage(pkg: string): Promise<any> {
734
+ try {
735
+ return await import(pkg);
736
+ } catch {
737
+ // Package not installed — auto-install it
738
+ if (this._installCache.has(pkg)) {
739
+ throw new Error(`Package "${pkg}" could not be loaded after auto-install. Please install manually: npm install ${pkg}`);
740
+ }
741
+ this._installCache.add(pkg);
742
+ console.log(`[database-access] Auto-installing "${pkg}"...`);
743
+ const { execSync } = await import('child_process');
744
+ try {
745
+ execSync(`npm install --no-save ${pkg}`, {
746
+ stdio: 'pipe',
747
+ timeout: 120_000,
748
+ cwd: process.cwd(),
749
+ });
750
+ console.log(`[database-access] Successfully installed "${pkg}"`);
751
+ // Clear Node's module cache to pick up the new package
752
+ return await import(pkg);
753
+ } catch (installErr: any) {
754
+ const msg = installErr.stderr?.toString?.().slice(0, 200) || installErr.message;
755
+ throw new Error(`Failed to auto-install "${pkg}": ${msg}. Please install manually: npm install ${pkg}`);
756
+ }
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Connect with a timeout wrapper — all drivers get a 15s connect timeout.
762
+ */
763
+ private async connectWithTimeout<T>(fn: () => Promise<T>, timeoutMs = 15_000, label = 'Connection'): Promise<T> {
764
+ return new Promise<T>((resolve, reject) => {
765
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms — check your host/port and ensure the database is reachable`)), timeoutMs);
766
+ fn().then(
767
+ (v) => { clearTimeout(timer); resolve(v); },
768
+ (e) => { clearTimeout(timer); reject(e); },
769
+ );
770
+ });
771
+ }
772
+
773
+ /**
774
+ * Detect SSL requirement from connection string or cloud provider type.
775
+ * Cloud-hosted databases almost always require SSL.
776
+ */
777
+ private needsSsl(config: any, credentials?: any): boolean | Record<string, any> {
778
+ if (config.ssl === true) return true;
779
+ if (config.ssl === false) return false;
780
+ // Auto-enable SSL for cloud providers
781
+ const cloudTypes: string[] = ['supabase', 'neon', 'planetscale', 'cockroachdb', 'turso', 'upstash'];
782
+ if (cloudTypes.includes(config.type)) return true;
783
+ // Detect from connection string
784
+ const connStr = credentials?.connectionString || '';
785
+ if (connStr.includes('sslmode=require') || connStr.includes('ssl=true')) return true;
786
+ // Supabase/Neon/etc URLs contain their domain
787
+ if (/supabase|neon\.tech|cockroachlabs|planetscale|turso\.io|railway\.app|render\.com|aiven\.io|timescale\.com/i.test(connStr)) return true;
788
+ if (/supabase|neon\.tech|cockroachlabs|planetscale|turso\.io|railway|render|aiven|timescale/i.test(config.host || '')) return true;
789
+ return false;
790
+ }
791
+
792
+ /**
793
+ * Parse a connection string to extract host/port/database/username when fields are missing.
794
+ */
795
+ private parseConnectionString(connStr: string, type: string): { host?: string; port?: number; database?: string; username?: string } {
796
+ try {
797
+ // Handle postgres://, mysql://, mongodb://, redis://, libsql:// etc
798
+ const cleaned = connStr.replace(/^(postgres|postgresql|mysql|mongodb\+srv|mongodb|redis|rediss|libsql)/, 'http');
799
+ const url = new URL(cleaned);
800
+ return {
801
+ host: url.hostname || undefined,
802
+ port: url.port ? parseInt(url.port) : undefined,
803
+ database: url.pathname?.replace(/^\//, '') || undefined,
804
+ username: url.username || undefined,
805
+ };
806
+ } catch {
807
+ return {};
808
+ }
809
+ }
810
+
725
811
  private registerBuiltinDrivers(): void {
726
- // PostgreSQL / CockroachDB / Supabase / Neon
812
+ const self = this;
813
+
814
+ // ── PostgreSQL / CockroachDB / Supabase / Neon ─────────────────────────
727
815
  const pgDriver: DatabaseDriver = {
728
816
  async connect(config, credentials) {
729
- if (credentials.connectionString) {
730
- const pgMod = await import('postgres' as string);
731
- const pgFn = pgMod.default || pgMod;
732
- const sql = pgFn(credentials.connectionString, {
817
+ const pgMod = await self.ensurePackage('postgres');
818
+ const pgFn = pgMod.default || pgMod;
819
+
820
+ // Build connection string from parts if not provided
821
+ let connStr = credentials.connectionString;
822
+ if (!connStr) {
823
+ const user = encodeURIComponent(config.username || 'postgres');
824
+ const pass = encodeURIComponent(credentials.password || '');
825
+ const host = config.host || 'localhost';
826
+ const port = config.port || 5432;
827
+ const db = config.database || 'postgres';
828
+ connStr = `postgresql://${user}:${pass}@${host}:${port}/${db}`;
829
+ }
830
+
831
+ const ssl = self.needsSsl(config, credentials);
832
+ const sql: any = await self.connectWithTimeout(() => {
833
+ const s = pgFn(connStr, {
733
834
  max: config.pool?.max ?? 10,
734
- idle_timeout: (config.pool?.idleTimeoutMs ?? 30000) / 1000,
735
- connect_timeout: (config.pool?.acquireTimeoutMs ?? 10000) / 1000,
736
- ssl: config.ssl ? (config.sslRejectUnauthorized === false ? 'prefer' as any : 'require' as any) : false,
835
+ idle_timeout: (config.pool?.idleTimeoutMs ?? 30_000) / 1000,
836
+ connect_timeout: 10,
837
+ ssl: ssl ? (config.sslRejectUnauthorized === false ? 'prefer' as any : 'require' as any) : false,
737
838
  });
738
- return {
739
- async query(q: string, params?: any[]) {
740
- const result = params?.length
741
- ? await sql.unsafe(q, params)
742
- : await sql.unsafe(q);
743
- return {
744
- rows: [...result],
745
- affectedRows: result.count,
746
- fields: result.columns?.map((c: any) => ({ name: c.name, type: String(c.type) })),
747
- };
748
- },
749
- async close() { await sql.end(); },
750
- async ping() { try { await sql`SELECT 1`; return true; } catch { return false; } },
751
- };
752
- }
753
- // Fallback: construct connection from parts
754
- const connStr = `postgresql://${encodeURIComponent(config.username || '')}:${encodeURIComponent(credentials.password || '')}@${config.host || 'localhost'}:${config.port || 5432}/${config.database || 'postgres'}`;
755
- const pgMod2 = await import('postgres' as string);
756
- const postgres = pgMod2.default || pgMod2;
757
- const sql = postgres(connStr, {
758
- max: config.pool?.max ?? 10,
759
- ssl: config.ssl ? {} : false,
760
- });
839
+ // Force a connection attempt so errors surface now
840
+ return s`SELECT 1`.then(() => s);
841
+ }, 15_000, 'PostgreSQL');
842
+
761
843
  return {
762
844
  async query(q: string, params?: any[]) {
763
845
  const result = params?.length ? await sql.unsafe(q, params) : await sql.unsafe(q);
764
- return { rows: [...result], affectedRows: result.count };
846
+ return {
847
+ rows: [...result],
848
+ affectedRows: result.count,
849
+ fields: result.columns?.map((c: any) => ({ name: c.name, type: String(c.type) })),
850
+ };
765
851
  },
766
- async close() { await sql.end(); },
852
+ async close() { try { await sql.end({ timeout: 5 }); } catch {} },
767
853
  async ping() { try { await sql`SELECT 1`; return true; } catch { return false; } },
768
854
  };
769
855
  },
770
856
  };
771
-
772
857
  this.drivers.set('postgresql', pgDriver);
773
858
  this.drivers.set('cockroachdb', pgDriver);
774
859
  this.drivers.set('supabase', pgDriver);
775
860
  this.drivers.set('neon', pgDriver);
776
861
 
777
- // MySQL / MariaDB / PlanetScale
862
+ // ── MySQL / MariaDB / PlanetScale ──────────────────────────────────────
778
863
  const mysqlDriver: DatabaseDriver = {
779
864
  async connect(config, credentials) {
780
- const mysql2 = await import('mysql2/promise' as string);
781
- const pool = mysql2.createPool({
782
- host: config.host || 'localhost',
783
- port: config.port || 3306,
784
- user: config.username,
785
- password: credentials.password,
786
- database: config.database,
787
- connectionLimit: config.pool?.max ?? 10,
788
- ssl: config.ssl ? {} : undefined,
789
- uri: credentials.connectionString || undefined,
790
- });
865
+ const mysql2 = await self.ensurePackage('mysql2/promise');
866
+
867
+ // Parse connection string for missing fields
868
+ const parsed = credentials.connectionString ? self.parseConnectionString(credentials.connectionString, config.type) : {};
869
+ const ssl = self.needsSsl(config, credentials);
870
+
871
+ const pool = await self.connectWithTimeout(async () => {
872
+ const p = mysql2.createPool({
873
+ host: config.host || parsed.host || 'localhost',
874
+ port: config.port || parsed.port || 3306,
875
+ user: config.username || parsed.username,
876
+ password: credentials.password,
877
+ database: config.database || parsed.database,
878
+ connectionLimit: config.pool?.max ?? 10,
879
+ connectTimeout: 10_000,
880
+ ssl: ssl ? { rejectUnauthorized: config.sslRejectUnauthorized !== false } : undefined,
881
+ uri: credentials.connectionString || undefined,
882
+ });
883
+ // Validate connection
884
+ await p.execute('SELECT 1');
885
+ return p;
886
+ }, 15_000, 'MySQL');
887
+
791
888
  return {
792
889
  async query(q: string, params?: any[]) {
793
890
  const [rows, fields] = await pool.execute(q, params);
@@ -798,51 +895,55 @@ export class DatabaseConnectionManager {
798
895
  fields: (fields as any[])?.map((f: any) => ({ name: f.name, type: String(f.type) })),
799
896
  };
800
897
  },
801
- async close() { await pool.end(); },
898
+ async close() { try { await pool.end(); } catch {} },
802
899
  async ping() { try { await pool.execute('SELECT 1'); return true; } catch { return false; } },
803
900
  };
804
901
  },
805
902
  };
806
-
807
903
  this.drivers.set('mysql', mysqlDriver);
808
904
  this.drivers.set('mariadb', mysqlDriver);
809
905
  this.drivers.set('planetscale', mysqlDriver);
810
906
 
811
- // SQLite
907
+ // ── SQLite ─────────────────────────────────────────────────────────────
812
908
  this.drivers.set('sqlite', {
813
- async connect(config, _credentials) {
814
- const { default: Database } = await import('better-sqlite3' as string);
815
- const db = new Database(config.database || ':memory:', { readonly: !config.host });
909
+ async connect(config, credentials) {
910
+ const sqliteMod = await self.ensurePackage('better-sqlite3');
911
+ const Database = sqliteMod.default || sqliteMod;
912
+ const dbPath = credentials.connectionString || config.database || ':memory:';
913
+ const db = new Database(dbPath, { readonly: false });
914
+ // Enable WAL mode for better concurrency
915
+ try { db.pragma('journal_mode = WAL'); } catch {}
816
916
  return {
817
917
  async query(q: string, params?: any[]) {
818
- try {
819
- if (q.trim().toUpperCase().startsWith('SELECT') || q.trim().toUpperCase().startsWith('WITH') || q.trim().toUpperCase().startsWith('PRAGMA')) {
820
- const stmt = db.prepare(q);
821
- const rows = params?.length ? stmt.all(...params) : stmt.all();
822
- return { rows, fields: rows.length > 0 ? Object.keys(rows[0]).map(k => ({ name: k, type: 'unknown' })) : [] };
823
- } else {
824
- const stmt = db.prepare(q);
825
- const result = params?.length ? stmt.run(...params) : stmt.run();
826
- return { rows: [], affectedRows: result.changes };
827
- }
828
- } catch (err: any) {
829
- throw err;
918
+ const trimmed = q.trim().toUpperCase();
919
+ if (trimmed.startsWith('SELECT') || trimmed.startsWith('WITH') || trimmed.startsWith('PRAGMA') || trimmed.startsWith('EXPLAIN')) {
920
+ const stmt = db.prepare(q);
921
+ const rows = params?.length ? stmt.all(...params) : stmt.all();
922
+ return { rows, fields: rows.length > 0 ? Object.keys(rows[0]).map(k => ({ name: k, type: 'unknown' })) : [] };
923
+ } else {
924
+ const stmt = db.prepare(q);
925
+ const result = params?.length ? stmt.run(...params) : stmt.run();
926
+ return { rows: [], affectedRows: result.changes };
830
927
  }
831
928
  },
832
- async close() { db.close(); },
929
+ async close() { try { db.close(); } catch {} },
833
930
  async ping() { try { db.prepare('SELECT 1').get(); return true; } catch { return false; } },
834
931
  };
835
932
  },
836
933
  });
837
934
 
838
- // Turso / LibSQL
935
+ // ── Turso / LibSQL ─────────────────────────────────────────────────────
839
936
  this.drivers.set('turso', {
840
937
  async connect(config, credentials) {
841
- const { createClient } = await import('@libsql/client' as string);
842
- const client = createClient({
843
- url: credentials.connectionString || `libsql://${config.host}`,
844
- authToken: credentials.password,
845
- });
938
+ const libsql = await self.ensurePackage('@libsql/client');
939
+ const { createClient } = libsql;
940
+
941
+ const url = credentials.connectionString || `libsql://${config.host}`;
942
+ const client = createClient({ url, authToken: credentials.password });
943
+
944
+ // Validate
945
+ await self.connectWithTimeout(() => client.execute('SELECT 1'), 15_000, 'Turso');
946
+
846
947
  return {
847
948
  async query(q: string, params?: any[]) {
848
949
  const result = await client.execute({ sql: q, args: params || [] });
@@ -852,91 +953,159 @@ export class DatabaseConnectionManager {
852
953
  fields: result.columns?.map((c: any) => ({ name: String(c), type: 'unknown' })),
853
954
  };
854
955
  },
855
- async close() { client.close(); },
956
+ async close() { try { client.close(); } catch {} },
856
957
  async ping() { try { await client.execute('SELECT 1'); return true; } catch { return false; } },
857
958
  };
858
959
  },
859
960
  });
860
961
 
861
- // MongoDB
962
+ // ── MongoDB ────────────────────────────────────────────────────────────
862
963
  this.drivers.set('mongodb', {
863
964
  async connect(config, credentials) {
864
- const { MongoClient } = await import('mongodb' as string);
865
- const uri = credentials.connectionString || `mongodb://${config.username}:${encodeURIComponent(credentials.password || '')}@${config.host || 'localhost'}:${config.port || 27017}/${config.database || 'admin'}`;
965
+ const mongoMod = await self.ensurePackage('mongodb');
966
+ const { MongoClient } = mongoMod;
967
+
968
+ const uri = credentials.connectionString || (() => {
969
+ const user = encodeURIComponent(config.username || '');
970
+ const pass = encodeURIComponent(credentials.password || '');
971
+ const host = config.host || 'localhost';
972
+ const port = config.port || 27017;
973
+ const db = config.database || 'admin';
974
+ const auth = user ? `${user}:${pass}@` : '';
975
+ return `mongodb://${auth}${host}:${port}/${db}`;
976
+ })();
977
+
978
+ const ssl = self.needsSsl(config, credentials);
979
+ // mongodb+srv:// always uses TLS
980
+ const useTls = ssl || uri.startsWith('mongodb+srv://');
981
+
866
982
  const client = new MongoClient(uri, {
867
983
  maxPoolSize: config.pool?.max ?? 10,
868
- tls: config.ssl || false,
984
+ serverSelectionTimeoutMS: 10_000,
985
+ connectTimeoutMS: 10_000,
986
+ tls: useTls || undefined,
869
987
  });
870
- await client.connect();
871
- const db = client.db(config.database);
988
+
989
+ await self.connectWithTimeout(() => client.connect(), 15_000, 'MongoDB');
990
+ const db = client.db(config.database || self.parseConnectionString(uri, 'mongodb').database);
991
+
872
992
  return {
873
993
  async query(q: string, _params?: any[]) {
874
- // MongoDB queries come as JSON strings: { collection: "users", operation: "find", filter: {...} }
875
994
  try {
876
995
  const cmd = JSON.parse(q);
877
996
  const collection = db.collection(cmd.collection);
878
- if (cmd.operation === 'find') {
879
- const cursor = collection.find(cmd.filter || {});
880
- if (cmd.limit) cursor.limit(cmd.limit);
881
- if (cmd.sort) cursor.sort(cmd.sort);
882
- const rows = await cursor.toArray();
883
- return { rows };
884
- } else if (cmd.operation === 'insertOne') {
885
- const result = await collection.insertOne(cmd.document);
886
- return { rows: [{ insertedId: result.insertedId }], affectedRows: 1 };
887
- } else if (cmd.operation === 'insertMany') {
888
- const result = await collection.insertMany(cmd.documents);
889
- return { rows: [{ insertedCount: result.insertedCount }], affectedRows: result.insertedCount };
890
- } else if (cmd.operation === 'updateOne' || cmd.operation === 'updateMany') {
891
- const fn = cmd.operation === 'updateOne' ? collection.updateOne.bind(collection) : collection.updateMany.bind(collection);
892
- const result = await fn(cmd.filter || {}, cmd.update);
893
- return { rows: [], affectedRows: result.modifiedCount };
894
- } else if (cmd.operation === 'deleteOne' || cmd.operation === 'deleteMany') {
895
- const fn = cmd.operation === 'deleteOne' ? collection.deleteOne.bind(collection) : collection.deleteMany.bind(collection);
896
- const result = await fn(cmd.filter || {});
897
- return { rows: [], affectedRows: result.deletedCount };
898
- } else if (cmd.operation === 'aggregate') {
899
- const rows = await collection.aggregate(cmd.pipeline || []).toArray();
900
- return { rows };
901
- } else if (cmd.operation === 'count') {
902
- const count = await collection.countDocuments(cmd.filter || {});
903
- return { rows: [{ count }] };
997
+ switch (cmd.operation) {
998
+ case 'find': {
999
+ const cursor = collection.find(cmd.filter || {});
1000
+ if (cmd.projection) cursor.project(cmd.projection);
1001
+ if (cmd.sort) cursor.sort(cmd.sort);
1002
+ if (cmd.skip) cursor.skip(cmd.skip);
1003
+ cursor.limit(cmd.limit || 100);
1004
+ return { rows: await cursor.toArray() };
1005
+ }
1006
+ case 'findOne': {
1007
+ const doc = await collection.findOne(cmd.filter || {}, { projection: cmd.projection });
1008
+ return { rows: doc ? [doc] : [] };
1009
+ }
1010
+ case 'insertOne': {
1011
+ const r = await collection.insertOne(cmd.document);
1012
+ return { rows: [{ insertedId: r.insertedId }], affectedRows: 1 };
1013
+ }
1014
+ case 'insertMany': {
1015
+ const r = await collection.insertMany(cmd.documents);
1016
+ return { rows: [{ insertedCount: r.insertedCount }], affectedRows: r.insertedCount };
1017
+ }
1018
+ case 'updateOne': case 'updateMany': {
1019
+ const fn = cmd.operation === 'updateOne' ? collection.updateOne.bind(collection) : collection.updateMany.bind(collection);
1020
+ const r = await fn(cmd.filter || {}, cmd.update, { upsert: cmd.upsert });
1021
+ return { rows: [{ matchedCount: r.matchedCount, modifiedCount: r.modifiedCount, upsertedId: r.upsertedId }], affectedRows: r.modifiedCount };
1022
+ }
1023
+ case 'deleteOne': case 'deleteMany': {
1024
+ const fn = cmd.operation === 'deleteOne' ? collection.deleteOne.bind(collection) : collection.deleteMany.bind(collection);
1025
+ const r = await fn(cmd.filter || {});
1026
+ return { rows: [], affectedRows: r.deletedCount };
1027
+ }
1028
+ case 'aggregate': return { rows: await collection.aggregate(cmd.pipeline || []).toArray() };
1029
+ case 'count': case 'countDocuments': return { rows: [{ count: await collection.countDocuments(cmd.filter || {}) }] };
1030
+ case 'distinct': return { rows: [{ values: await collection.distinct(cmd.field, cmd.filter || {}) }] };
1031
+ case 'listCollections': {
1032
+ const cols = await db.listCollections().toArray();
1033
+ return { rows: cols.map((c: any) => ({ name: c.name, type: c.type })) };
1034
+ }
1035
+ default: throw new Error(`Unknown operation: ${cmd.operation}. Supported: find, findOne, insertOne, insertMany, updateOne, updateMany, deleteOne, deleteMany, aggregate, count, distinct, listCollections`);
904
1036
  }
905
- throw new Error(`Unknown MongoDB operation: ${cmd.operation}`);
906
1037
  } catch (err: any) {
907
- if (err.message.startsWith('Unknown MongoDB')) throw err;
908
1038
  throw new Error(`MongoDB query error: ${err.message}`);
909
1039
  }
910
1040
  },
911
- async close() { await client.close(); },
1041
+ async close() { try { await client.close(); } catch {} },
912
1042
  async ping() { try { await db.command({ ping: 1 }); return true; } catch { return false; } },
913
1043
  };
914
1044
  },
915
1045
  });
916
1046
 
917
- // Redis
1047
+ // ── Redis (self-hosted or cloud with standard Redis protocol) ──────────
918
1048
  this.drivers.set('redis', {
919
1049
  async connect(config, credentials) {
920
- // Dynamic import redis is optional
921
- const redisUrl = credentials.connectionString || `redis://${config.username ? config.username + ':' : ''}${credentials.password ? credentials.password + '@' : ''}${config.host || 'localhost'}:${config.port || 6379}`;
922
- // Use a minimal Redis interface via net socket to avoid hard dependency
923
- const net = await import('net');
924
- const socket = new net.Socket();
925
- await new Promise<void>((resolve, reject) => {
926
- socket.connect(config.port || 6379, config.host || 'localhost', () => resolve());
927
- socket.on('error', reject);
928
- });
929
- if (credentials.password) {
930
- await sendRedisCommand(socket, `AUTH ${credentials.password}`);
1050
+ const host = config.host || 'localhost';
1051
+ const port = config.port || 6379;
1052
+ const ssl = self.needsSsl(config, credentials);
1053
+ const connStr = credentials.connectionString || '';
1054
+
1055
+ // Detect if connection string uses rediss:// (TLS)
1056
+ const useTls = ssl || connStr.startsWith('rediss://');
1057
+
1058
+ // Parse auth from connection string if present
1059
+ let password = credentials.password;
1060
+ let username = config.username;
1061
+ if (connStr) {
1062
+ try {
1063
+ const parsed = self.parseConnectionString(connStr, 'redis');
1064
+ if (!password) {
1065
+ // redis://user:pass@host:port or redis://:pass@host:port
1066
+ const url = new URL(connStr.replace(/^redis(s?)/, 'http'));
1067
+ password = url.password || password;
1068
+ username = url.username || username;
1069
+ }
1070
+ } catch {}
931
1071
  }
932
1072
 
933
- async function sendRedisCommand(sock: any, cmd: string): Promise<string> {
1073
+ // Use TLS or plain net socket
1074
+ let socket: any;
1075
+ const connectHost = connStr ? (self.parseConnectionString(connStr, 'redis').host || host) : host;
1076
+ const connectPort = connStr ? (self.parseConnectionString(connStr, 'redis').port || port) : port;
1077
+
1078
+ await self.connectWithTimeout(async () => {
1079
+ if (useTls) {
1080
+ const tls = await import('tls');
1081
+ socket = tls.connect({ host: connectHost, port: connectPort, rejectUnauthorized: config.sslRejectUnauthorized !== false });
1082
+ await new Promise<void>((resolve, reject) => {
1083
+ socket.on('secureConnect', resolve);
1084
+ socket.on('error', reject);
1085
+ });
1086
+ } else {
1087
+ const net = await import('net');
1088
+ socket = new net.Socket();
1089
+ await new Promise<void>((resolve, reject) => {
1090
+ socket.connect(connectPort, connectHost, () => resolve());
1091
+ socket.on('error', reject);
1092
+ });
1093
+ }
1094
+
1095
+ // AUTH if needed — support Redis 6+ ACL: AUTH username password
1096
+ if (password) {
1097
+ const authCmd = username && username !== 'default' ? `AUTH ${username} ${password}` : `AUTH ${password}`;
1098
+ await sendRedisCommand(socket, authCmd);
1099
+ }
1100
+ }, 15_000, 'Redis');
1101
+
1102
+ function sendRedisCommand(sock: any, cmd: string): Promise<string> {
934
1103
  return new Promise((resolve, reject) => {
935
1104
  let data = '';
936
- const onData = (chunk: Buffer) => { data += chunk.toString(); sock.off('data', onData); resolve(data); };
1105
+ const onData = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('\r\n')) { sock.off('data', onData); resolve(data); } };
937
1106
  sock.on('data', onData);
938
1107
  sock.write(cmd + '\r\n');
939
- setTimeout(() => { sock.off('data', onData); reject(new Error('Redis timeout')); }, 5000);
1108
+ setTimeout(() => { sock.off('data', onData); reject(new Error('Redis command timed out after 10s')); }, 10_000);
940
1109
  });
941
1110
  }
942
1111
 
@@ -945,36 +1114,86 @@ export class DatabaseConnectionManager {
945
1114
  const result = await sendRedisCommand(socket, q);
946
1115
  return { rows: [{ result: result.trim() }] };
947
1116
  },
948
- async close() { socket.destroy(); },
1117
+ async close() { try { socket.destroy(); } catch {} },
949
1118
  async ping() { try { const r = await sendRedisCommand(socket, 'PING'); return r.includes('PONG'); } catch { return false; } },
950
1119
  };
951
1120
  },
952
1121
  });
953
1122
 
954
- // Upstash Redis — uses REST API over HTTPS
955
- this.registerDriver('upstash', {
1123
+ // ── Upstash Redis (REST API zero dependencies) ──────────────────────
1124
+ this.drivers.set('upstash', {
956
1125
  async connect(config, credentials) {
957
- // Upstash uses REST API: https://<endpoint>.upstash.io with token auth
958
- const baseUrl = credentials.connectionString || `https://${config.host}`;
959
- const token = credentials.password || '';
1126
+ // Upstash REST: https://<endpoint>.upstash.io with Bearer token
1127
+ // Connection string format: https://<token>@<endpoint>.upstash.io or just the URL
1128
+ let baseUrl = '';
1129
+ let token = credentials.password || '';
1130
+
1131
+ if (credentials.connectionString) {
1132
+ const cs = credentials.connectionString;
1133
+ // Handle https://token@host format
1134
+ if (cs.startsWith('https://') && cs.includes('@')) {
1135
+ try {
1136
+ const url = new URL(cs);
1137
+ token = token || url.username || url.password;
1138
+ baseUrl = `https://${url.host}`;
1139
+ } catch {
1140
+ baseUrl = cs;
1141
+ }
1142
+ } else if (cs.startsWith('https://')) {
1143
+ baseUrl = cs.replace(/\/$/, '');
1144
+ } else if (cs.startsWith('rediss://')) {
1145
+ // Upstash also provides redis:// URLs — extract host for REST
1146
+ try {
1147
+ const url = new URL(cs.replace('rediss://', 'https://'));
1148
+ token = token || url.password;
1149
+ baseUrl = `https://${url.hostname}`;
1150
+ } catch {
1151
+ baseUrl = `https://${config.host}`;
1152
+ }
1153
+ } else {
1154
+ baseUrl = `https://${cs}`;
1155
+ }
1156
+ } else {
1157
+ baseUrl = `https://${config.host}`;
1158
+ }
1159
+
1160
+ if (!baseUrl) throw new Error('Upstash requires a REST URL or hostname. Find it in your Upstash console under REST API.');
1161
+ if (!token) throw new Error('Upstash requires an auth token. Find it in your Upstash console under REST API.');
960
1162
 
961
1163
  async function upstashRequest(command: string[]): Promise<any> {
962
- const resp = await fetch(baseUrl, {
963
- method: 'POST',
964
- headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
965
- body: JSON.stringify(command),
966
- });
967
- if (!resp.ok) throw new Error(`Upstash error: ${resp.status} ${await resp.text()}`);
968
- return resp.json();
1164
+ const ctrl = new AbortController();
1165
+ const timer = setTimeout(() => ctrl.abort(), 10_000);
1166
+ try {
1167
+ const resp = await fetch(baseUrl, {
1168
+ method: 'POST',
1169
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
1170
+ body: JSON.stringify(command),
1171
+ signal: ctrl.signal,
1172
+ });
1173
+ if (!resp.ok) {
1174
+ const body = await resp.text().catch(() => '');
1175
+ throw new Error(`Upstash HTTP ${resp.status}: ${body.slice(0, 200)}`);
1176
+ }
1177
+ return resp.json();
1178
+ } finally {
1179
+ clearTimeout(timer);
1180
+ }
969
1181
  }
970
1182
 
1183
+ // Validate connection
1184
+ await self.connectWithTimeout(async () => {
1185
+ const r = await upstashRequest(['PING']);
1186
+ if (r.result !== 'PONG') throw new Error('Upstash PING failed — check your token and endpoint URL');
1187
+ }, 15_000, 'Upstash');
1188
+
971
1189
  return {
972
1190
  async query(q: string) {
973
1191
  const parts = q.trim().split(/\s+/);
974
1192
  const result = await upstashRequest(parts);
975
- return { rows: [{ result: JSON.stringify(result.result ?? result) }] };
1193
+ const val = result.result ?? result;
1194
+ return { rows: [{ result: typeof val === 'string' ? val : JSON.stringify(val) }] };
976
1195
  },
977
- async close() { /* stateless REST */ },
1196
+ async close() { /* stateless REST — nothing to close */ },
978
1197
  async ping() { try { const r = await upstashRequest(['PING']); return r.result === 'PONG'; } catch { return false; } },
979
1198
  };
980
1199
  },