@ttoss/lambda-postgres-query 0.6.2 → 1.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.
package/README.md CHANGED
@@ -4,7 +4,17 @@ Create an AWS Lambda function to securely query a PostgreSQL database in a priva
4
4
 
5
5
  ## When to Use
6
6
 
7
- This package solves the challenge of querying a PostgreSQL database from AWS Lambda functions without internet access. Traditional approaches require expensive NAT Gateways or complex multi-Lambda architectures. This package provides a simpler solution by deploying a dedicated Lambda function within your VPC.
7
+ Use this package when Lambdas outside your database VPC need to query PostgreSQL. Instead of adding NAT gateways or moving every consumer into the VPC, deploy one or more small query Lambdas inside the VPC and invoke the right one from each consumer.
8
+
9
+ ```mermaid
10
+ flowchart LR
11
+ A[Consumer Lambda] -->|InvokeFunction| B[Read Query Lambda]
12
+ A -->|InvokeFunction| C[Write Query Lambda]
13
+ B -->|read credentials| D[(PostgreSQL)]
14
+ C -->|write credentials| D
15
+ ```
16
+
17
+ The best setup is usually multiple query Lambdas from the same code artifact: one for read-only traffic, one for writes, and more when teams, tenants, or schemas need isolated credentials.
8
18
 
9
19
  ## Installation
10
20
 
@@ -12,64 +22,177 @@ This package solves the challenge of querying a PostgreSQL database from AWS Lam
12
22
  pnpm install @ttoss/lambda-postgres-query
13
23
  ```
14
24
 
15
- ## Setup
25
+ ## Multiple Lambda Setup
26
+
27
+ This flow creates two query Lambdas, each with dedicated database credentials and CloudFormation outputs that consumers can use as Lambda function names.
16
28
 
17
29
  ### CloudFormation Template
18
30
 
19
- Create a CloudFormation template to deploy the Lambda function:
31
+ Create `src/cloudformation.ts`:
20
32
 
21
33
  ```typescript
22
34
  import { createLambdaPostgresQueryTemplate } from '@ttoss/lambda-postgres-query/cloudformation';
23
35
 
24
- const template = createLambdaPostgresQueryTemplate();
36
+ const queryLambdas = {
37
+ read: 'LambdaPostgresReadQueryFunction',
38
+ write: 'LambdaPostgresWriteQueryFunction',
39
+ } as const;
40
+
41
+ const databaseParameters = ({ prefix }: { prefix: 'Read' | 'Write' }) => ({
42
+ host: `${prefix}DatabaseHost`,
43
+ name: `${prefix}DatabaseName`,
44
+ username: `${prefix}DatabaseUsername`,
45
+ password: `${prefix}DatabasePassword`,
46
+ port: `${prefix}DatabasePort`,
47
+ });
48
+
49
+ export default createLambdaPostgresQueryTemplate({
50
+ functions: Object.entries(queryLambdas).map(([target, logicalId]) => {
51
+ const prefix = target === 'read' ? 'Read' : 'Write';
25
52
 
26
- export default template;
53
+ return {
54
+ logicalId,
55
+ databaseParameters: databaseParameters({ prefix }),
56
+ outputArnName: `${logicalId}Arn`,
57
+ };
58
+ }),
59
+ });
27
60
  ```
28
61
 
62
+ Each `logicalId` is the CloudFormation resource ID for one Lambda. The template does not set `FunctionName`, so AWS creates the physical function name. The template creates two outputs per Lambda: one output named like the logical ID with the physical Lambda function name, and one ARN output named by `outputArnName`.
63
+
29
64
  ### Lambda Handler
30
65
 
31
- Create a handler file that exports both Lambda functions:
66
+ Create `src/handler.ts`:
32
67
 
33
68
  ```typescript
34
- export { handler, readOnlyHandler } from '@ttoss/lambda-postgres-query';
69
+ export { handler } from '@ttoss/lambda-postgres-query/cloudformation';
35
70
  ```
36
71
 
37
- ### Environment Variables
72
+ The default handler is `handler.handler`, so the entry file should compile to `handler.js` and export `handler`. If you use another file or export name, set `handler` in the function definition.
38
73
 
39
- Configure the following environment variables for the general-purpose Lambda:
74
+ ### Carlin Configuration
40
75
 
41
- ```env
42
- DATABASE_NAME=your_database_name
43
- DATABASE_USERNAME=your_username
44
- DATABASE_PASSWORD=your_password
45
- DATABASE_HOST=your_database_host
46
- DATABASE_PORT=5432
47
- SECURITY_GROUP_IDS=sg-xxxxx,sg-yyyyy
48
- SUBNET_IDS=subnet-xxxxx,subnet-yyyyy
49
- ```
76
+ Create `carlin.ts` to map each environment to the CloudFormation parameters used by the template:
50
77
 
51
- For the dedicated read-only Lambda (`readOnlyHandler`), define at least one `*_READ_ONLY` variable. Any read-only variable that is not set falls back to the corresponding main variable. For example, to point only the host to a read replica:
78
+ ```typescript
79
+ import { defineConfig, requiredEnv } from 'carlin/config';
80
+
81
+ type DatabaseConfig = {
82
+ host: string;
83
+ name: string;
84
+ usernameEnv: string;
85
+ passwordEnv: string;
86
+ port?: string;
87
+ };
52
88
 
53
- ```env
54
- DATABASE_HOST_READ_ONLY=your_read_only_host
55
- # DATABASE_NAME, DATABASE_USERNAME, DATABASE_PASSWORD, DATABASE_PORT are reused automatically
56
- ```
89
+ type EnvironmentConfig = {
90
+ securityGroupIds: string[];
91
+ subnetIds: string[];
92
+ databases: {
93
+ read: DatabaseConfig;
94
+ write: DatabaseConfig;
95
+ };
96
+ };
57
97
 
58
- To use a fully isolated read-only database user:
98
+ const environments = {
99
+ Staging: {
100
+ securityGroupIds: ['sg-staging'],
101
+ subnetIds: ['subnet-staging-a', 'subnet-staging-b'],
102
+ databases: {
103
+ read: {
104
+ host: 'staging-reader.cluster-ro.example.us-east-1.rds.amazonaws.com',
105
+ name: 'app_staging',
106
+ usernameEnv: 'STAGING_READ_DATABASE_USERNAME',
107
+ passwordEnv: 'STAGING_READ_DATABASE_PASSWORD',
108
+ },
109
+ write: {
110
+ host: 'staging-writer.cluster.example.us-east-1.rds.amazonaws.com',
111
+ name: 'app_staging',
112
+ usernameEnv: 'STAGING_WRITE_DATABASE_USERNAME',
113
+ passwordEnv: 'STAGING_WRITE_DATABASE_PASSWORD',
114
+ },
115
+ },
116
+ },
117
+ Production: {
118
+ securityGroupIds: ['sg-production'],
119
+ subnetIds: ['subnet-production-a', 'subnet-production-b'],
120
+ databases: {
121
+ read: {
122
+ host: 'production-reader.cluster-ro.example.us-east-1.rds.amazonaws.com',
123
+ name: 'app_production',
124
+ usernameEnv: 'PRODUCTION_READ_DATABASE_USERNAME',
125
+ passwordEnv: 'PRODUCTION_READ_DATABASE_PASSWORD',
126
+ },
127
+ write: {
128
+ host: 'production-writer.cluster.example.us-east-1.rds.amazonaws.com',
129
+ name: 'app_production',
130
+ usernameEnv: 'PRODUCTION_WRITE_DATABASE_USERNAME',
131
+ passwordEnv: 'PRODUCTION_WRITE_DATABASE_PASSWORD',
132
+ },
133
+ },
134
+ },
135
+ } satisfies Record<string, EnvironmentConfig>;
136
+
137
+ type EnvironmentName = keyof typeof environments;
138
+
139
+ const databaseParameters = ({
140
+ database,
141
+ prefix,
142
+ }: {
143
+ database: DatabaseConfig;
144
+ prefix: 'Read' | 'Write';
145
+ }) => ({
146
+ [`${prefix}DatabaseHost`]: database.host,
147
+ [`${prefix}DatabaseName`]: database.name,
148
+ [`${prefix}DatabaseUsername`]: requiredEnv({ name: database.usernameEnv }),
149
+ [`${prefix}DatabasePassword`]: requiredEnv({ name: database.passwordEnv }),
150
+ [`${prefix}DatabasePort`]: database.port || '5432',
151
+ });
59
152
 
60
- ```env
61
- DATABASE_NAME_READ_ONLY=your_read_only_database_name
62
- DATABASE_USERNAME_READ_ONLY=your_read_only_username
63
- DATABASE_PASSWORD_READ_ONLY=your_read_only_password
64
- DATABASE_HOST_READ_ONLY=your_read_only_host
65
- DATABASE_PORT_READ_ONLY=5432
153
+ const getEnvironmentName = ({ environment }: { environment?: string }) => {
154
+ if (!environment || !(environment in environments)) {
155
+ throw new Error(
156
+ `Use --environment with one of: ${Object.keys(environments).join(', ')}`
157
+ );
158
+ }
159
+
160
+ return environment as EnvironmentName;
161
+ };
162
+
163
+ const parametersForEnvironment = ({
164
+ environment,
165
+ }: {
166
+ environment: EnvironmentName;
167
+ }) => {
168
+ const config = environments[environment];
169
+
170
+ return {
171
+ SecurityGroupIds: config.securityGroupIds.join(','),
172
+ SubnetIds: config.subnetIds.join(','),
173
+ ...databaseParameters({ database: config.databases.read, prefix: 'Read' }),
174
+ ...databaseParameters({
175
+ database: config.databases.write,
176
+ prefix: 'Write',
177
+ }),
178
+ };
179
+ };
180
+
181
+ export default defineConfig(({ environment }) => {
182
+ const selectedEnvironment = getEnvironmentName({ environment });
183
+
184
+ return {
185
+ lambdaFormat: 'cjs',
186
+ parameters: parametersForEnvironment({ environment: selectedEnvironment }),
187
+ };
188
+ });
66
189
  ```
67
190
 
68
- > **Security note:** If none of the `*_READ_ONLY` variables are set, the read-only Lambda throws an error at invocation time.
191
+ Keep secrets in `.env` or CI variables, and keep non-secret environment values in `environments`. This config resolves secrets only for the selected `--environment`, so a staging deploy does not require production credentials. The keys returned by `databaseParameters` must match the parameter names used in `src/cloudformation.ts`.
69
192
 
70
- ### Deployment
193
+ ### Deploy
71
194
 
72
- Add a deploy script to your `package.json`:
195
+ Add a deploy script:
73
196
 
74
197
  ```json
75
198
  {
@@ -79,30 +202,69 @@ Add a deploy script to your `package.json`:
79
202
  }
80
203
  ```
81
204
 
82
- Deploy using Carlin:
205
+ Deploy one environment:
83
206
 
84
207
  ```bash
85
- pnpm deploy
208
+ pnpm deploy --environment Staging
209
+ ```
210
+
211
+ Set `lambdaFormat: 'cjs'` because `pg` requires CommonJS in this package.
212
+
213
+ ## Runtime Parameters
214
+
215
+ The template creates these stack parameters:
216
+
217
+ - `SecurityGroupIds` and `SubnetIds`: VPC config shared by all query Lambdas.
218
+ - `ReadDatabase*`: credentials injected only into the read query Lambda.
219
+ - `WriteDatabase*`: credentials injected only into the write query Lambda.
220
+
221
+ Each query Lambda receives only database runtime variables:
222
+
223
+ ```env
224
+ DATABASE_HOST=...
225
+ DATABASE_NAME=...
226
+ DATABASE_USERNAME=...
227
+ DATABASE_PASSWORD=...
228
+ DATABASE_PORT=5432
86
229
  ```
87
230
 
88
- **Note:** Set `lambdaFormat: 'cjs'` in your Carlin configuration, as the `pg` package requires CommonJS.
231
+ `SecurityGroupIds` and `SubnetIds` configure `VpcConfig`; they are not Lambda environment variables.
89
232
 
90
233
  ## Usage
91
234
 
92
- ### Querying from External Lambdas
235
+ ### Query from a Consumer Lambda
93
236
 
94
- Query the database from Lambda functions outside the VPC:
237
+ Use the CloudFormation output value to configure the consumer. For example, set `LAMBDA_POSTGRES_QUERY_FUNCTION_NAME` to the `LambdaPostgresReadQueryFunction` output for read traffic:
95
238
 
96
239
  ```typescript
97
240
  import { query } from '@ttoss/lambda-postgres-query';
98
241
  import type { Handler } from 'aws-lambda';
99
242
 
100
- export const handler: Handler = async (event) => {
243
+ export const handler: Handler = async () => {
101
244
  const result = await query('SELECT * FROM users');
245
+
102
246
  return result.rows;
103
247
  };
104
248
  ```
105
249
 
250
+ Pass `functionName` when a consumer can use more than one query Lambda:
251
+
252
+ ```typescript
253
+ import { query } from '@ttoss/lambda-postgres-query';
254
+
255
+ const users = await query({
256
+ text: 'SELECT * FROM users WHERE active = $1',
257
+ values: [true],
258
+ functionName: process.env.LAMBDA_POSTGRES_READ_QUERY_FUNCTION_NAME,
259
+ });
260
+
261
+ const updatedUser = await query({
262
+ text: 'UPDATE users SET last_seen_at = now() WHERE id = $1 RETURNING *',
263
+ values: [userId],
264
+ functionName: process.env.LAMBDA_POSTGRES_WRITE_QUERY_FUNCTION_NAME,
265
+ });
266
+ ```
267
+
106
268
  ### Advanced Query Options
107
269
 
108
270
  ```typescript
@@ -119,31 +281,57 @@ const result = await query({
119
281
  text: 'SELECT * FROM users',
120
282
  camelCaseKeys: false, // Defaults to true
121
283
  });
122
-
123
- // Specify custom Lambda function name
124
- const result = await query({
125
- text: 'SELECT * FROM users',
126
- lambdaPostgresQueryFunction: 'custom-function-name',
127
- });
128
284
  ```
129
285
 
130
- ## Security: Isolating Read-Only Access
286
+ ## Security: Isolating Access Per Lambda
287
+
288
+ Each function in `createLambdaPostgresQueryTemplate` can use different `databaseParameters`, so each deployed Lambda can receive different credentials. Use read-only database credentials for read consumers, write credentials only where writes are required, and separate IAM permissions by Lambda ARN.
289
+
290
+ Grant each consumer `lambda:InvokeFunction` only for the ARN output it needs, such as `LambdaPostgresReadQueryFunctionArn` for read-only consumers.
291
+
292
+ ARN outputs are exported with this CloudFormation export name pattern:
293
+
294
+ - `${AWS::StackName}-${outputArnName}`
131
295
 
132
- Deploying a dedicated `readOnlyHandler` Lambda lets you enforce the principle of least privilege at the AWS IAM level. Services that only need to read data (dashboards, reports, public APIs) receive IAM permissions to invoke **only** the read-only Lambda they have no way to invoke the general-purpose Lambda and cannot perform write operations, regardless of the SQL they send.
296
+ The practical reason to export these names is also deletion safety: when another stack imports an ARN export, CloudFormation blocks deleting the producer stack resource until that import is removed.
133
297
 
134
- This means a compromised or misconfigured service can never corrupt or delete data; it is limited to SELECT queries enforced both by the database (`BEGIN READ ONLY` transaction) and by the IAM boundary.
298
+ For example, `LambdaPostgresReadQueryFunctionArn` is exported as `${AWS::StackName}-LambdaPostgresReadQueryFunctionArn`.
299
+
300
+ In a consumer stack, define a parameter with that export name and import it using `importValueFromParameter`:
301
+
302
+ ```typescript
303
+ import { importValueFromParameter } from '@ttoss/cloudformation';
304
+
305
+ const resources = {
306
+ InvokePermission: {
307
+ Type: 'AWS::Lambda::Permission',
308
+ Properties: {
309
+ FunctionName: importValueFromParameter('ReadQueryFunctionArnExportName'),
310
+ Action: 'lambda:InvokeFunction',
311
+ Principal: 'apigateway.amazonaws.com',
312
+ },
313
+ },
314
+ };
315
+ ```
135
316
 
136
317
  ## API Reference
137
318
 
138
319
  ### `createLambdaPostgresQueryTemplate(options?)`
139
320
 
140
- Creates a CloudFormation template for the PostgreSQL query Lambda function.
321
+ Creates a CloudFormation template for one or more PostgreSQL query Lambdas.
141
322
 
142
323
  #### Parameters
143
324
 
144
- - `handler` (string, optional): Lambda handler function name. Default: `'handler.handler'`
325
+ - `functions` (array, optional): Lambda definitions. Each item supports:
326
+ - `logicalId` (string, optional): CloudFormation logical ID and function-name output key. Default: `LAMBDA_POSTGRES_QUERY_FUNCTION_DEFAULT_NAME`
327
+ - `name` (string, optional): Backward-compatible alias for `logicalId`. It does not set the AWS physical function name
328
+ - `handler` (string, optional): Handler function. Default: `'handler.handler'`
329
+ - `databaseParameters` (object, optional): CloudFormation parameter names used to inject database settings into that Lambda
330
+ - `outputArnName` (string, optional): Output key for the function ARN. Default: `${name}Arn`
331
+ - The ARN output is exported with `Export.Name = ${AWS::StackName}-${outputArnName}`
145
332
  - `memorySize` (number, optional): Lambda memory size in MB. Default: `128`
146
333
  - `timeout` (number, optional): Lambda timeout in seconds. Default: `30`
334
+ - `deletionProtection` (boolean, optional): Adds `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to each Lambda resource so stack updates/deletes do not remove functions. Default: `false`
147
335
 
148
336
  #### Returns
149
337
 
@@ -159,7 +347,7 @@ Accepts either a SQL string or an options object extending [`QueryConfig`](https
159
347
 
160
348
  - `text` (string): SQL query text
161
349
  - `values` (array, optional): Query parameter values
162
- - `lambdaPostgresQueryFunction` (string, optional): Name of the query Lambda function. Default: `LAMBDA_POSTGRES_QUERY_FUNCTION` environment variable
350
+ - `functionName` (string, optional): Physical query Lambda name or ARN. Default: `process.env.LAMBDA_POSTGRES_QUERY_FUNCTION_NAME`
163
351
  - `camelCaseKeys` (boolean, optional): Convert snake_case column names to camelCase. Default: `true`
164
352
 
165
353
  #### Returns
@@ -168,8 +356,4 @@ A [`QueryResult`](https://node-postgres.com/apis/result) object with transformed
168
356
 
169
357
  ### `handler`
170
358
 
171
- AWS Lambda handler function for processing database queries within the VPC. Uses `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `DATABASE_HOST`, and `DATABASE_PORT`.
172
-
173
- ### `readOnlyHandler`
174
-
175
- AWS Lambda handler function for processing **read-only** database queries within the VPC. Enforces read-only access via a PostgreSQL `BEGIN READ ONLY` transaction. Uses dedicated `*_READ_ONLY` environment variables where defined, falling back to the main variables for any that are not set. Throws an error at invocation time if none of the `*_READ_ONLY` variables are defined.
359
+ AWS Lambda handler for processing database queries inside the VPC. It reads `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `DATABASE_HOST`, and `DATABASE_PORT` from the Lambda environment. Those variables are injected from each function's `databaseParameters` mapping.
@@ -5,18 +5,32 @@ import { QueryParams } from '../index.cjs';
5
5
  import 'pg';
6
6
 
7
7
  declare const HANDLER_DEFAULT = "handler.handler";
8
- declare const HANDLER_READ_ONLY_DEFAULT = "handler.readOnlyHandler";
8
+ declare const LAMBDA_POSTGRES_QUERY_FUNCTION_DEFAULT_NAME = "LambdaPostgresQueryFunction";
9
9
  declare const MEMORY_SIZE_DEFAULT = 128;
10
10
  declare const TIMEOUT_DEFAULT = 30;
11
- declare const createLambdaPostgresQueryTemplate: ({ handler, readOnlyHandler, memorySize, timeout, }?: {
11
+ type DatabaseParameters = {
12
+ host: string;
13
+ name: string;
14
+ username: string;
15
+ password: string;
16
+ port: string;
17
+ };
18
+ type LambdaDefinition = {
19
+ name?: string;
20
+ logicalId?: string;
12
21
  handler?: string;
13
- readOnlyHandler?: string;
22
+ databaseParameters?: DatabaseParameters;
23
+ outputArnName?: string;
24
+ };
25
+ type CreateLambdaPostgresQueryTemplateOptions = {
26
+ functions?: LambdaDefinition[];
14
27
  memorySize?: number;
15
28
  timeout?: number;
16
- }) => CloudFormationTemplate;
29
+ deletionProtection?: boolean;
30
+ };
31
+ declare const DATABASE_PARAMETERS_DEFAULT: DatabaseParameters;
32
+ declare const createLambdaPostgresQueryTemplate: ({ functions, memorySize, timeout, deletionProtection, }?: CreateLambdaPostgresQueryTemplateOptions) => CloudFormationTemplate;
17
33
 
18
34
  declare const handler: Handler<QueryParams>;
19
35
 
20
- declare const readOnlyHandler: Handler<QueryParams>;
21
-
22
- export { HANDLER_DEFAULT, HANDLER_READ_ONLY_DEFAULT, MEMORY_SIZE_DEFAULT, TIMEOUT_DEFAULT, createLambdaPostgresQueryTemplate, handler, readOnlyHandler };
36
+ export { type CreateLambdaPostgresQueryTemplateOptions, DATABASE_PARAMETERS_DEFAULT, type DatabaseParameters, HANDLER_DEFAULT, LAMBDA_POSTGRES_QUERY_FUNCTION_DEFAULT_NAME, type LambdaDefinition, MEMORY_SIZE_DEFAULT, TIMEOUT_DEFAULT, createLambdaPostgresQueryTemplate, handler };
@@ -5,18 +5,32 @@ import { QueryParams } from '../index.js';
5
5
  import 'pg';
6
6
 
7
7
  declare const HANDLER_DEFAULT = "handler.handler";
8
- declare const HANDLER_READ_ONLY_DEFAULT = "handler.readOnlyHandler";
8
+ declare const LAMBDA_POSTGRES_QUERY_FUNCTION_DEFAULT_NAME = "LambdaPostgresQueryFunction";
9
9
  declare const MEMORY_SIZE_DEFAULT = 128;
10
10
  declare const TIMEOUT_DEFAULT = 30;
11
- declare const createLambdaPostgresQueryTemplate: ({ handler, readOnlyHandler, memorySize, timeout, }?: {
11
+ type DatabaseParameters = {
12
+ host: string;
13
+ name: string;
14
+ username: string;
15
+ password: string;
16
+ port: string;
17
+ };
18
+ type LambdaDefinition = {
19
+ name?: string;
20
+ logicalId?: string;
12
21
  handler?: string;
13
- readOnlyHandler?: string;
22
+ databaseParameters?: DatabaseParameters;
23
+ outputArnName?: string;
24
+ };
25
+ type CreateLambdaPostgresQueryTemplateOptions = {
26
+ functions?: LambdaDefinition[];
14
27
  memorySize?: number;
15
28
  timeout?: number;
16
- }) => CloudFormationTemplate;
29
+ deletionProtection?: boolean;
30
+ };
31
+ declare const DATABASE_PARAMETERS_DEFAULT: DatabaseParameters;
32
+ declare const createLambdaPostgresQueryTemplate: ({ functions, memorySize, timeout, deletionProtection, }?: CreateLambdaPostgresQueryTemplateOptions) => CloudFormationTemplate;
17
33
 
18
34
  declare const handler: Handler<QueryParams>;
19
35
 
20
- declare const readOnlyHandler: Handler<QueryParams>;
21
-
22
- export { HANDLER_DEFAULT, HANDLER_READ_ONLY_DEFAULT, MEMORY_SIZE_DEFAULT, TIMEOUT_DEFAULT, createLambdaPostgresQueryTemplate, handler, readOnlyHandler };
36
+ export { type CreateLambdaPostgresQueryTemplateOptions, DATABASE_PARAMETERS_DEFAULT, type DatabaseParameters, HANDLER_DEFAULT, LAMBDA_POSTGRES_QUERY_FUNCTION_DEFAULT_NAME, type LambdaDefinition, MEMORY_SIZE_DEFAULT, TIMEOUT_DEFAULT, createLambdaPostgresQueryTemplate, handler };