@xeplr/actions 1.1.11 → 1.1.12
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 +18 -0
- package/lib/drivers/db/mssql.js +5 -0
- package/lib/drivers/db/mysql.js +14 -1
- package/lib/drivers/db/postgres.js +33 -4
- package/lib/drivers/db/procedure.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -255,6 +255,24 @@ None published. The package has no HTTP routes (a host serves `list()`, e.g. `@x
|
|
|
255
255
|
|
|
256
256
|
The repository has `scripts/upload-file.js` (not in the npm package) — a CLI that loads a CSV or Excel file into Postgres with `file-upload`; its header documents the flags (`--file`, `--table`, `--conn-name`, `--conn`, `--host`/`--port`/`--db`/`--user`/`--password`, `--encrypt-conn`).
|
|
257
257
|
|
|
258
|
+
## Read-only connections — for SQL you did not write
|
|
259
|
+
|
|
260
|
+
A report preview, a user's query, anything whose SQL text came from outside your own code: connect with `readOnly: true`.
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
const pool = await driver.connect({ ...connection, readOnly: true })
|
|
264
|
+
await driver.query(pool, userSql, params) // reads only
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
| Driver | What `readOnly` does |
|
|
268
|
+
|---|---|
|
|
269
|
+
| postgres | every `query` and `fetchStream` runs inside `BEGIN TRANSACTION READ ONLY`, always rolled back, over the **extended protocol** — one statement only, so `…; COMMIT; DROP TABLE t` is refused, and nothing inside can switch the transaction back to read-write |
|
|
270
|
+
| mysql | every connection the pool opens is set to `TRANSACTION READ ONLY`; `multipleStatements` stays off, so one statement per query |
|
|
271
|
+
| mssql | `readOnlyIntent` (routes to a readable secondary where one exists) — **not enforcement**: SQL Server runs whole batches and has no session read-only mode. Give such SQL a login with only `db_datareader` |
|
|
272
|
+
| duckdb | read-only unless `access: 'rw'` |
|
|
273
|
+
|
|
274
|
+
**Values are bound, never written into SQL.** On MySQL, `stringifyObjects` is always on, so an object or array passed as a value is compared as text instead of being expanded into `key = value` pairs. **Procedure parameter names** are written into the call (`@region = @p0`, `region => $1`) and must be plain names — letters, digits and `_`.
|
|
275
|
+
|
|
258
276
|
## Rules the code enforces, and why
|
|
259
277
|
|
|
260
278
|
| rule | why |
|
package/lib/drivers/db/mssql.js
CHANGED
|
@@ -87,6 +87,11 @@ async function connect(config) {
|
|
|
87
87
|
pool: { max: config.maxConnections || 10 },
|
|
88
88
|
options: Object.assign(
|
|
89
89
|
{ encrypt: true, trustServerCertificate: true },
|
|
90
|
+
// readOnly: ApplicationIntent=ReadOnly — routed to a readable secondary
|
|
91
|
+
// where there is one. It is NOT enforcement: SQL Server runs a whole
|
|
92
|
+
// batch and has no session-level read-only mode, so SQL the caller did
|
|
93
|
+
// not write needs a login that can only read (db_datareader). See README.
|
|
94
|
+
config.readOnly ? { readOnlyIntent: true } : {},
|
|
90
95
|
config.options || {}
|
|
91
96
|
)
|
|
92
97
|
});
|
package/lib/drivers/db/mysql.js
CHANGED
|
@@ -89,8 +89,21 @@ async function connect(config) {
|
|
|
89
89
|
timezone: 'Z',
|
|
90
90
|
dateStrings: true,
|
|
91
91
|
supportBigNumbers: true,
|
|
92
|
-
bigNumberStrings: true
|
|
92
|
+
bigNumberStrings: true,
|
|
93
|
+
// A VALUE IS A VALUE. Without this mysql2 expands an object bound to `?`
|
|
94
|
+
// into `key` = value pairs and nested arrays into lists — so a filter value
|
|
95
|
+
// of {"a":1} rewrites the predicate instead of being compared to it.
|
|
96
|
+
stringifyObjects: true
|
|
97
|
+
// multipleStatements stays at its default (off): one statement per query.
|
|
93
98
|
});
|
|
99
|
+
// READ-ONLY CONNECTIONS — `connect({ ..., readOnly: true })` for SQL the
|
|
100
|
+
// caller did not write itself. Every connection the pool opens is put into
|
|
101
|
+
// read-only transaction mode before it is used, so a write fails.
|
|
102
|
+
if (config.readOnly) {
|
|
103
|
+
pool.pool.on('connection', function(conn) {
|
|
104
|
+
conn.query('SET SESSION TRANSACTION READ ONLY');
|
|
105
|
+
});
|
|
106
|
+
}
|
|
94
107
|
return pool;
|
|
95
108
|
}
|
|
96
109
|
|
|
@@ -41,6 +41,17 @@ var TYPE_MAP = {
|
|
|
41
41
|
var INTERNAL_ID_COL = '__xeplr_id__';
|
|
42
42
|
var INTERNAL_MOVEMENT_COL = '__xeplr_movement_id__';
|
|
43
43
|
|
|
44
|
+
// READ-ONLY CONNECTIONS — for SQL the caller did not write itself (a report
|
|
45
|
+
// preview, a user's query). `connect({ ..., readOnly: true })` makes every
|
|
46
|
+
// query and fetchStream on that pool:
|
|
47
|
+
//
|
|
48
|
+
// • run inside BEGIN TRANSACTION READ ONLY, always rolled back — a write
|
|
49
|
+
// fails with "cannot execute … in a read-only transaction", and nothing
|
|
50
|
+
// inside the transaction can switch it back to read-write
|
|
51
|
+
// • go over the EXTENDED protocol, which carries exactly one statement — so
|
|
52
|
+
// `…; COMMIT; DROP TABLE t` is refused rather than run after the SELECT
|
|
53
|
+
// (the simple protocol, which pg uses when there are no parameters, runs
|
|
54
|
+
// every statement in the string)
|
|
44
55
|
async function connect(config) {
|
|
45
56
|
var pg = require('pg');
|
|
46
57
|
var pool = new pg.Pool({
|
|
@@ -51,6 +62,7 @@ async function connect(config) {
|
|
|
51
62
|
database: config.database,
|
|
52
63
|
max: config.maxConnections || 10
|
|
53
64
|
});
|
|
65
|
+
if (config.readOnly) pool.__xeplrReadOnly = true;
|
|
54
66
|
return pool;
|
|
55
67
|
}
|
|
56
68
|
|
|
@@ -67,7 +79,19 @@ async function close(pool) {
|
|
|
67
79
|
// this as the canonical shape the other drivers normalize TO, so replacing
|
|
68
80
|
// the object risks dropping a property something else reads.
|
|
69
81
|
async function query(pool, sql, params) {
|
|
70
|
-
var result
|
|
82
|
+
var result;
|
|
83
|
+
if (pool.__xeplrReadOnly) {
|
|
84
|
+
var client = await pool.connect();
|
|
85
|
+
try {
|
|
86
|
+
await client.query('BEGIN TRANSACTION READ ONLY');
|
|
87
|
+
result = await client.query({ text: sql, values: params || [], queryMode: 'extended' });
|
|
88
|
+
} finally {
|
|
89
|
+
try { await client.query('ROLLBACK'); } catch (_) { /* the connection is released either way */ }
|
|
90
|
+
client.release();
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
result = await pool.query(sql, params || []);
|
|
94
|
+
}
|
|
71
95
|
result.columns = (result.fields || []).map(function(f) { return f.name; });
|
|
72
96
|
return result;
|
|
73
97
|
}
|
|
@@ -323,8 +347,13 @@ async function* fetchStream(pool, opts) {
|
|
|
323
347
|
var client = await pool.connect();
|
|
324
348
|
var declared = false;
|
|
325
349
|
try {
|
|
326
|
-
|
|
327
|
-
await client.query(
|
|
350
|
+
var readOnly = Boolean(pool.__xeplrReadOnly);
|
|
351
|
+
await client.query(readOnly ? 'BEGIN TRANSACTION READ ONLY' : 'BEGIN');
|
|
352
|
+
// Extended protocol when read-only: one statement, so the caller's SQL
|
|
353
|
+
// cannot close the cursor declaration and carry on with its own.
|
|
354
|
+
await client.query(readOnly
|
|
355
|
+
? { text: 'DECLARE __xeplr_fetch_cur NO SCROLL CURSOR FOR ' + opts.sql, values: opts.params || [], queryMode: 'extended' }
|
|
356
|
+
: 'DECLARE __xeplr_fetch_cur NO SCROLL CURSOR FOR ' + opts.sql, readOnly ? undefined : (opts.params || []));
|
|
328
357
|
declared = true;
|
|
329
358
|
while (true) {
|
|
330
359
|
var res = await client.query('FETCH FORWARD ' + batchSize + ' FROM __xeplr_fetch_cur');
|
|
@@ -333,7 +362,7 @@ async function* fetchStream(pool, opts) {
|
|
|
333
362
|
}
|
|
334
363
|
} finally {
|
|
335
364
|
try { if (declared) await client.query('CLOSE __xeplr_fetch_cur'); } catch (_) {}
|
|
336
|
-
try { await client.query('COMMIT'); } catch (_) {}
|
|
365
|
+
try { await client.query(pool.__xeplrReadOnly ? 'ROLLBACK' : 'COMMIT'); } catch (_) {}
|
|
337
366
|
client.release();
|
|
338
367
|
}
|
|
339
368
|
}
|
|
@@ -183,6 +183,12 @@ function buildProcedureCall(opts) {
|
|
|
183
183
|
var marks = entries.map(function(p, i) {
|
|
184
184
|
var mark = ph(i);
|
|
185
185
|
if (!p.name || !supportsNames) return mark;
|
|
186
|
+
// Written into the call as text (`@region = @p0`, `region => $1`), so a
|
|
187
|
+
// plain name only — a saved or requested parameter name must not be able
|
|
188
|
+
// to close the call and start another statement.
|
|
189
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(p.name))) {
|
|
190
|
+
throw new Error('procedure parameter name "' + String(p.name).slice(0, 40) + '" — use letters, digits and _ only');
|
|
191
|
+
}
|
|
186
192
|
if (dbType === 'mssql') return '@' + p.name + ' = ' + mark;
|
|
187
193
|
return p.name + ' => ' + mark; // postgres named-argument call
|
|
188
194
|
}).join(', ');
|
package/package.json
CHANGED