@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.
Files changed (3) hide show
  1. package/README.md +97 -0
  2. package/package.json +27 -0
  3. 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
+ [![Node](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen)]()
6
+ [![License](https://img.shields.io/badge/license-MIT-blue)]()
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
+ }