@yamf/services-postgres 0.1.2
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 +97 -0
- package/package.json +27 -0
- package/service.js +109 -0
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# @yamf/services-postgres
|
|
2
|
+
|
|
3
|
+
Postgres.js wrapper for YAMF: parameterized SQL templates, camelCase result mapping, and safe placeholder validation.
|
|
4
|
+
|
|
5
|
+
[]()
|
|
6
|
+
[]()
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @yamf/services-postgres
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Peer dependencies: `@yamf/core`, `@yamf/shared`. The package uses [postgres](https://github.com/porsager/postgres) (`postgres` npm package).
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
```javascript
|
|
19
|
+
import { registryServer, createService, callService } from '@yamf/core'
|
|
20
|
+
import createPostgreSqlService from '@yamf/services-postgres'
|
|
21
|
+
|
|
22
|
+
await registryServer()
|
|
23
|
+
|
|
24
|
+
const postgres = await createPostgreSqlService({
|
|
25
|
+
serviceName: 'postgres-service',
|
|
26
|
+
psqlConfig: 'postgres://user:pass@localhost/dbname'
|
|
27
|
+
// or: { PGDATABASE: 'yamf', PGUSER: 'yamf', PGPASSWORD: 'changeme' }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Call from another service or gateway
|
|
31
|
+
const [row] = await callService('postgres-service', {
|
|
32
|
+
template: `SELECT user_id, username, is_active FROM yamf.user WHERE username = :username`,
|
|
33
|
+
data: { username: 'alice@example.com' }
|
|
34
|
+
})
|
|
35
|
+
// row has camelCase keys: userId, username, isActive
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **Parameterized queries** – Template placeholders `:name` are replaced with safe parameters; no string interpolation of user input.
|
|
41
|
+
- **CamelCase mapping** – Result rows are converted from `snake_case` to camelCase by default (via `@yamf/shared`).
|
|
42
|
+
- **Placeholder validation** – Only valid identifiers are allowed for placeholder names to prevent injection.
|
|
43
|
+
- **Postgres.js** – Uses [postgres](https://github.com/porsager/postgres) under the hood.
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
### `createPostgreSqlService(options)`
|
|
48
|
+
|
|
49
|
+
| Option | Default | Description |
|
|
50
|
+
|---------------|---------------------|-------------|
|
|
51
|
+
| `serviceName` | `'postgres-service'` | YAMF service name to register. |
|
|
52
|
+
| `psqlConfig` | env / `{ PGDATABASE, PGUSER, PGPASSWORD }` | Postgres connection string or config object. |
|
|
53
|
+
| `schema` | `null` | Optional schema setup. |
|
|
54
|
+
| `seed` | `null` | Optional seed logic. |
|
|
55
|
+
|
|
56
|
+
### Payload
|
|
57
|
+
|
|
58
|
+
Call the service with:
|
|
59
|
+
|
|
60
|
+
- **`template`** (string) – SQL with `:placeholder` names in camelCase (e.g. `:userId`, `:isActive`).
|
|
61
|
+
- **`data`** (object) – Keys match placeholder names (camelCase). All placeholders must have a value.
|
|
62
|
+
- **`options`** (optional) – e.g. `{ mapCase: true }` (default). Set `mapCase: false` to skip camelCase conversion.
|
|
63
|
+
|
|
64
|
+
Returns the result of the query (array of rows, or raw result from postgres.js). With `mapCase: true`, row keys are camelCase.
|
|
65
|
+
|
|
66
|
+
## Template and data
|
|
67
|
+
|
|
68
|
+
- Use **camelCase** for placeholder names in the template and for keys in `data`: `:userId`, `:username`, `:isActive`.
|
|
69
|
+
- Write **snake_case** in SQL as usual: `user_id`, `username`, `is_active`.
|
|
70
|
+
- Only valid identifier names are allowed for placeholders (alphanumeric and underscore, no SQL injection via placeholder names).
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
|
|
74
|
+
```javascript
|
|
75
|
+
await callService('postgres-service', {
|
|
76
|
+
template: `
|
|
77
|
+
SELECT user_id, username, is_registered, is_active
|
|
78
|
+
FROM yamf.user
|
|
79
|
+
WHERE user_id = :userId OR username = :username
|
|
80
|
+
`,
|
|
81
|
+
data: { userId: 1, username: 'alice@example.com' }
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Using with other services
|
|
86
|
+
|
|
87
|
+
This service is the data layer for **@yamf/services-user** and for custom auth (e.g. looking up users and verifying passwords). For a full example that wires Postgres + User + Auth together, see the [psql-user-auth example](../../core/examples/psql-user-auth/) in the repo.
|
|
88
|
+
|
|
89
|
+
## Dependencies
|
|
90
|
+
|
|
91
|
+
- `postgres` – Postgres.js driver
|
|
92
|
+
- `@yamf/core` – createService, callService, HttpError
|
|
93
|
+
- `@yamf/shared` – toCamelCase for result mapping
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yamf/services-postgres",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "service.js",
|
|
9
|
+
"files": [
|
|
10
|
+
"service.js",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"postgres": "^3.4.8"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@yamf/core": "0.4.0",
|
|
18
|
+
"@yamf/shared": "0.1.2"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@yamf/cli": "0.2.0",
|
|
22
|
+
"@yamf/test": "0.1.4"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "yamf test -d ."
|
|
26
|
+
}
|
|
27
|
+
}
|
package/service.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createService,
|
|
3
|
+
Logger,
|
|
4
|
+
HttpError,
|
|
5
|
+
next,
|
|
6
|
+
envConfig
|
|
7
|
+
} from '@yamf/core'
|
|
8
|
+
|
|
9
|
+
import postgres from 'postgres'
|
|
10
|
+
import { toCamelCase } from '@yamf/shared'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
// requires a database, schema, and user all called "yamf"
|
|
14
|
+
// make sure to grant the user permissions on the database and schema
|
|
15
|
+
const defaultPsqlConfig = `postgres://yamf:changeme@localhost/yamf`
|
|
16
|
+
|
|
17
|
+
// Strict pattern for placeholder names - only valid identifiers allowed
|
|
18
|
+
const VALID_PLACEHOLDER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validates that a placeholder name is safe (alphanumeric + underscore, starts with letter/underscore)
|
|
22
|
+
* This prevents code injection through malicious placeholder names.
|
|
23
|
+
*/
|
|
24
|
+
function validatePlaceholderName(name) {
|
|
25
|
+
if (!VALID_PLACEHOLDER_PATTERN.test(name)) {
|
|
26
|
+
throw new HttpError(400, `Invalid placeholder name: "${name}". Must be a valid identifier.`)
|
|
27
|
+
}
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default async function createPostgreSqlService({
|
|
32
|
+
serviceName = 'postgres-service',
|
|
33
|
+
psqlConfig = defaultPsqlConfig,
|
|
34
|
+
schema = null,
|
|
35
|
+
seed = null
|
|
36
|
+
}) {
|
|
37
|
+
const sql = postgres(psqlConfig) // psql environment variables
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Processes a template query string with provided data using safe parameterized queries.
|
|
41
|
+
*
|
|
42
|
+
* SECURITY: This implementation avoids the dangerous `new Function()` pattern.
|
|
43
|
+
* Instead, it extracts placeholders, validates them strictly, and uses postgres.js's
|
|
44
|
+
* built-in parameterized query support which safely escapes all values.
|
|
45
|
+
*
|
|
46
|
+
* Template placeholders use camelCase (e.g., :userId, :isActive) to match JS data keys.
|
|
47
|
+
* SQL column names in the template are written as snake_case by the developer.
|
|
48
|
+
* Output is automatically converted from snake_case to camelCase.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} template The string containing `:<name>` placeholders (camelCase).
|
|
51
|
+
* @param {object} data An object with camelCase keys corresponding to the placeholders.
|
|
52
|
+
* @param {object} options Query options.
|
|
53
|
+
* @param {boolean} options.mapCase Whether to convert output from snake_case to camelCase (default: true)
|
|
54
|
+
* @returns {Promise<Array>} The query result with camelCase keys.
|
|
55
|
+
*/
|
|
56
|
+
async function processQueryTemplate(template, data, options) {
|
|
57
|
+
const { mapCase = true } = options || {}
|
|
58
|
+
|
|
59
|
+
// Extract all placeholder names from template (placeholders are camelCase)
|
|
60
|
+
const placeholderMatches = [...template.matchAll(/(?<!:):([a-zA-Z_][a-zA-Z0-9_]*)/g)]
|
|
61
|
+
const placeholders = placeholderMatches.map(m => m[1])
|
|
62
|
+
|
|
63
|
+
// Validate all placeholder names are safe identifiers
|
|
64
|
+
for (const name of placeholders) {
|
|
65
|
+
validatePlaceholderName(name)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check all placeholders have corresponding data
|
|
69
|
+
for (const name of placeholders) {
|
|
70
|
+
if (!(name in data)) {
|
|
71
|
+
throw new HttpError(400, `Missing data for placeholder: "${name}"`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Build the values array in placeholder order
|
|
76
|
+
const values = placeholders.map(name => data[name])
|
|
77
|
+
|
|
78
|
+
// Replace :name placeholders with $1, $2, etc. for parameterized query
|
|
79
|
+
let paramIndex = 0
|
|
80
|
+
const parameterizedQuery = template.replace(/(?<!:):([a-zA-Z_][a-zA-Z0-9_]*)/g, () => {
|
|
81
|
+
return `$${++paramIndex}`
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// Use sql.unsafe for the parameterized query - this is safe because:
|
|
86
|
+
// 1. The query structure is from our template (not user input in production)
|
|
87
|
+
// 2. All values are passed as parameters, never interpolated into the query string
|
|
88
|
+
// 3. postgres.js handles proper escaping of parameter values
|
|
89
|
+
const result = await sql.unsafe(parameterizedQuery, values)
|
|
90
|
+
|
|
91
|
+
// Convert result keys from snake_case to camelCase for JS consumption
|
|
92
|
+
return mapCase ? toCamelCase(result) : result
|
|
93
|
+
} catch (err) {
|
|
94
|
+
const httpError = new HttpError(500, `Query error: ${err.message}`)
|
|
95
|
+
httpError.stack = err.stack
|
|
96
|
+
httpError.parameterizedQuery = parameterizedQuery
|
|
97
|
+
throw httpError
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
let service = await createService(serviceName, async ({ template, data, options }) => {
|
|
103
|
+
if (!template) throw new HttpError(400, 'Expected "template" query string in payload')
|
|
104
|
+
if (!data) throw new HttpError(400, 'Expected "data" map in payload')
|
|
105
|
+
return await processQueryTemplate(template, data, options)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return service
|
|
109
|
+
}
|