Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-unix-socket-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/shopify-app-session-storage-postgresql': minor
---

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.
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ const shopify = shopifyApp({
});
```

## Connecting via Unix Socket (e.g. Google Cloud SQL)

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:

```js
const shopify = shopifyApp({
sessionStorage: new PostgreSQLSessionStorage(
'postgres://username:password@/database?host=/cloudsql/my-project:us-central1:my-instance',
),
// ...
});
```

This also works for any other environment where PostgreSQL is accessed via a Unix domain socket.

## Expiring Offline Access Tokens

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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {parse} from 'pg-connection-string';

/**
* Unit tests for the connection URL parsing logic used in PostgresConnection.
* These verify that pg-connection-string + decodeURIComponent produces the
* correct pool config for all supported URL formats, without requiring a
* running PostgreSQL instance.
*/

// Replicates the exact parsing logic from postgres-connection.ts init()
function parseConnectionConfig(connectionString: string) {
const config = parse(connectionString);
if (config.database) {
config.database = decodeURIComponent(config.database);
}
return config;
}

// Replicates the exact logic from postgres-connection.ts getDatabase()
function getDatabase(connectionString: string): string | undefined {
const database = parse(connectionString).database;
return database ? decodeURIComponent(database) : undefined;
}

describe('PostgresConnection URL parsing', () => {
it('parses a standard TCP connection URL with port', () => {
const config = parseConnectionConfig(
'postgres://user:password@localhost:5432/mydb',
);
expect(config.host).toBe('localhost');
expect(config.port).toBe('5432');
expect(config.user).toBe('user');
expect(config.password).toBe('password');
expect(config.database).toBe('mydb');
});

it('parses a TCP connection URL without port', () => {
const config = parseConnectionConfig(
'postgres://user:password@localhost/mydb',
);
expect(config.host).toBe('localhost');
expect(config.user).toBe('user');
expect(config.password).toBe('password');
expect(config.database).toBe('mydb');
});

it('parses a Unix socket URL with host query parameter', () => {
const config = parseConnectionConfig(
'postgres://user:password@/mydb?host=/cloudsql/my-project:us-central1:my-instance',
);
expect(config.host).toBe(
'/cloudsql/my-project:us-central1:my-instance',
);
expect(config.user).toBe('user');
expect(config.password).toBe('password');
expect(config.database).toBe('mydb');
});

it('decodes special characters in credentials and database name', () => {
const config = parseConnectionConfig(
'postgres://shop%26fy:passify%23%24@localhost:5432/shop%26test',
);
expect(config.user).toBe('shop&fy');
expect(config.password).toBe('passify#$');
expect(config.database).toBe('shop&test');
});

it('preserves SSL query parameters', () => {
const config = parseConnectionConfig(
'postgres://user:password@localhost/mydb?ssl=true',
);
expect(config.ssl).toBe(true);
expect(config.database).toBe('mydb');
});

describe('getDatabase', () => {
it('returns the decoded database name for a standard URL', () => {
expect(
getDatabase('postgres://user:pass@localhost:5432/mydb'),
).toBe('mydb');
});

it('returns the decoded database name for a Unix socket URL', () => {
expect(
getDatabase(
'postgres://user:pass@/mydb?host=/cloudsql/project:region:instance',
),
).toBe('mydb');
});

it('decodes special characters in database name', () => {
expect(
getDatabase('postgres://user:pass@localhost/shop%26test'),
).toBe('shop&test');
});
});
});
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import pg from 'pg';
import {parse} from 'pg-connection-string';
import {RdbmsConnection} from '@shopify/shopify-app-session-storage';

export class PostgresConnection implements RdbmsConnection {
sessionStorageIdentifier: string;
private ready: Promise<void>;
private pool: pg.Pool;
private dbUrl: URL;
private connectionString: string;

constructor(dbUrl: string, sessionStorageIdentifier: string) {
this.dbUrl = new URL(dbUrl);
this.connectionString = dbUrl;
this.ready = this.init();
this.sessionStorageIdentifier = sessionStorageIdentifier;
}
Expand Down Expand Up @@ -60,7 +61,8 @@ export class PostgresConnection implements RdbmsConnection {
}

public getDatabase(): string | undefined {
return decodeURIComponent(this.dbUrl.pathname.slice(1));
const database = parse(this.connectionString).database;
return database ? decodeURIComponent(database) : undefined;
}

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

private async init(): Promise<void> {
this.pool = new pg.Pool({
host: this.dbUrl.hostname,
user: decodeURIComponent(this.dbUrl.username),
password: decodeURIComponent(this.dbUrl.password),
database: this.getDatabase(),
port: Number(this.dbUrl.port),
});
const config = parse(this.connectionString);
if (config.database) {
config.database = decodeURIComponent(config.database);
}
this.pool = new pg.Pool(config as pg.PoolConfig);
}
}
Loading