Skip to content

Commit 769578d

Browse files
committed
Add Unix socket connection support to PostgreSQL session storage
Use pg-connection-string (already a declared dependency) to parse connection URLs instead of manual URL decomposition. This preserves all connection parameters including the host query parameter used for Unix domain sockets (e.g. Google Cloud SQL Auth Proxy) and SSL parameters. Fixes #3113
1 parent 0361595 commit 769578d

4 files changed

Lines changed: 127 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/shopify-app-session-storage-postgresql': minor
3+
---
4+
5+
Add Unix socket connection support by using pg-connection-string to parse connection URLs instead of manual URL decomposition. This enables connecting via Unix domain sockets (e.g. Google Cloud SQL Auth Proxy) using the standard `?host=` query parameter, and also preserves SSL and other connection parameters that were previously dropped.

packages/apps/session-storage/shopify-app-session-storage-postgresql/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ const shopify = shopifyApp({
3636
});
3737
```
3838

39+
## Connecting via Unix Socket (e.g. Google Cloud SQL)
40+
41+
When deploying on Google Cloud (Cloud Run, App Engine, GKE), the Cloud SQL Auth Proxy provides database connections through Unix sockets. You can connect by specifying the socket path via the `host` query parameter:
42+
43+
```js
44+
const shopify = shopifyApp({
45+
sessionStorage: new PostgreSQLSessionStorage(
46+
'postgres://username:password@/database?host=/cloudsql/my-project:us-central1:my-instance',
47+
),
48+
// ...
49+
});
50+
```
51+
52+
This also works for any other environment where PostgreSQL is accessed via a Unix domain socket.
53+
3954
## Expiring Offline Access Tokens
4055

4156
This storage adapter supports [expiring offline access tokens](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens#step-7-get-a-new-access-token-exchange). When enabled, the adapter automatically stores and retrieves refresh tokens alongside your session data.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {parse} from 'pg-connection-string';
2+
3+
/**
4+
* Unit tests for the connection URL parsing logic used in PostgresConnection.
5+
* These verify that pg-connection-string + decodeURIComponent produces the
6+
* correct pool config for all supported URL formats, without requiring a
7+
* running PostgreSQL instance.
8+
*/
9+
10+
// Replicates the exact parsing logic from postgres-connection.ts init()
11+
function parseConnectionConfig(connectionString: string) {
12+
const config = parse(connectionString);
13+
if (config.database) {
14+
config.database = decodeURIComponent(config.database);
15+
}
16+
return config;
17+
}
18+
19+
// Replicates the exact logic from postgres-connection.ts getDatabase()
20+
function getDatabase(connectionString: string): string | undefined {
21+
const database = parse(connectionString).database;
22+
return database ? decodeURIComponent(database) : undefined;
23+
}
24+
25+
describe('PostgresConnection URL parsing', () => {
26+
it('parses a standard TCP connection URL with port', () => {
27+
const config = parseConnectionConfig(
28+
'postgres://user:password@localhost:5432/mydb',
29+
);
30+
expect(config.host).toBe('localhost');
31+
expect(config.port).toBe('5432');
32+
expect(config.user).toBe('user');
33+
expect(config.password).toBe('password');
34+
expect(config.database).toBe('mydb');
35+
});
36+
37+
it('parses a TCP connection URL without port', () => {
38+
const config = parseConnectionConfig(
39+
'postgres://user:password@localhost/mydb',
40+
);
41+
expect(config.host).toBe('localhost');
42+
expect(config.user).toBe('user');
43+
expect(config.password).toBe('password');
44+
expect(config.database).toBe('mydb');
45+
});
46+
47+
it('parses a Unix socket URL with host query parameter', () => {
48+
const config = parseConnectionConfig(
49+
'postgres://user:password@/mydb?host=/cloudsql/my-project:us-central1:my-instance',
50+
);
51+
expect(config.host).toBe(
52+
'/cloudsql/my-project:us-central1:my-instance',
53+
);
54+
expect(config.user).toBe('user');
55+
expect(config.password).toBe('password');
56+
expect(config.database).toBe('mydb');
57+
});
58+
59+
it('decodes special characters in credentials and database name', () => {
60+
const config = parseConnectionConfig(
61+
'postgres://shop%26fy:passify%23%24@localhost:5432/shop%26test',
62+
);
63+
expect(config.user).toBe('shop&fy');
64+
expect(config.password).toBe('passify#$');
65+
expect(config.database).toBe('shop&test');
66+
});
67+
68+
it('preserves SSL query parameters', () => {
69+
const config = parseConnectionConfig(
70+
'postgres://user:password@localhost/mydb?ssl=true',
71+
);
72+
expect(config.ssl).toBe(true);
73+
expect(config.database).toBe('mydb');
74+
});
75+
76+
describe('getDatabase', () => {
77+
it('returns the decoded database name for a standard URL', () => {
78+
expect(
79+
getDatabase('postgres://user:pass@localhost:5432/mydb'),
80+
).toBe('mydb');
81+
});
82+
83+
it('returns the decoded database name for a Unix socket URL', () => {
84+
expect(
85+
getDatabase(
86+
'postgres://user:pass@/mydb?host=/cloudsql/project:region:instance',
87+
),
88+
).toBe('mydb');
89+
});
90+
91+
it('decodes special characters in database name', () => {
92+
expect(
93+
getDatabase('postgres://user:pass@localhost/shop%26test'),
94+
).toBe('shop&test');
95+
});
96+
});
97+
});

packages/apps/session-storage/shopify-app-session-storage-postgresql/src/postgres-connection.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import pg from 'pg';
2+
import {parse} from 'pg-connection-string';
23
import {RdbmsConnection} from '@shopify/shopify-app-session-storage';
34

45
export class PostgresConnection implements RdbmsConnection {
56
sessionStorageIdentifier: string;
67
private ready: Promise<void>;
78
private pool: pg.Pool;
8-
private dbUrl: URL;
9+
private connectionString: string;
910

1011
constructor(dbUrl: string, sessionStorageIdentifier: string) {
11-
this.dbUrl = new URL(dbUrl);
12+
this.connectionString = dbUrl;
1213
this.ready = this.init();
1314
this.sessionStorageIdentifier = sessionStorageIdentifier;
1415
}
@@ -60,7 +61,8 @@ export class PostgresConnection implements RdbmsConnection {
6061
}
6162

6263
public getDatabase(): string | undefined {
63-
return decodeURIComponent(this.dbUrl.pathname.slice(1));
64+
const database = parse(this.connectionString).database;
65+
return database ? decodeURIComponent(database) : undefined;
6466
}
6567

6668
async hasTable(tablename: string): Promise<boolean> {
@@ -83,12 +85,10 @@ export class PostgresConnection implements RdbmsConnection {
8385
}
8486

8587
private async init(): Promise<void> {
86-
this.pool = new pg.Pool({
87-
host: this.dbUrl.hostname,
88-
user: decodeURIComponent(this.dbUrl.username),
89-
password: decodeURIComponent(this.dbUrl.password),
90-
database: this.getDatabase(),
91-
port: Number(this.dbUrl.port),
92-
});
88+
const config = parse(this.connectionString);
89+
if (config.database) {
90+
config.database = decodeURIComponent(config.database);
91+
}
92+
this.pool = new pg.Pool(config as pg.PoolConfig);
9393
}
9494
}

0 commit comments

Comments
 (0)