nusadb 0.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 +179 -0
- package/package.json +32 -0
- package/src/connection.js +695 -0
- package/src/index.d.ts +93 -0
- package/src/index.js +15 -0
- package/src/pool.js +63 -0
- package/src/protocol.js +233 -0
- package/src/scram.js +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# nusadb — Node.js / TypeScript driver for NusaDB
|
|
2
|
+
|
|
3
|
+
A zero-dependency Node.js driver (with first-class TypeScript typings) that speaks
|
|
4
|
+
the [Nusa Wire Protocol](../../docs/wire-protocol.md) (`PROTOCOL_VERSION 1.1`)
|
|
5
|
+
directly over a socket. SCRAM-SHA-256 uses Node's built-in `crypto`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install nusadb
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Node 16+.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
const { connect } = require('nusadb');
|
|
19
|
+
|
|
20
|
+
(async () => {
|
|
21
|
+
const conn = await connect({ host: '127.0.0.1', port: 5678, user: 'nusa-root', database: 'nusadb' });
|
|
22
|
+
|
|
23
|
+
await conn.query('CREATE TABLE t (id INT NOT NULL, name TEXT)');
|
|
24
|
+
await conn.query('INSERT INTO t VALUES ($1, $2)', [1, 'alice']);
|
|
25
|
+
|
|
26
|
+
const res = await conn.query('SELECT id, name FROM t WHERE id = $1', [1]);
|
|
27
|
+
console.log(res.rows); // [[1, 'alice']] (INT -> number, TEXT -> string)
|
|
28
|
+
console.log(res.columns); // ['id', 'name']
|
|
29
|
+
console.log(res.columnTypes); // ['INT', 'TEXT'] (protocol 1.1 typed metadata)
|
|
30
|
+
|
|
31
|
+
await conn.close();
|
|
32
|
+
})();
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Column types
|
|
36
|
+
|
|
37
|
+
The driver negotiates protocol `minor = 1`, so `query()` returns `columnTypes`
|
|
38
|
+
alongside `columns` — a canonical type name per column (`'INT'`, `'TEXT'`,
|
|
39
|
+
`'BOOL'`, `'TIMESTAMP'`, `'NUMERIC'`, …; see `wire-protocol.md` §9.2). Against a
|
|
40
|
+
1.0 server (which has no typed metadata) every entry is `null`.
|
|
41
|
+
|
|
42
|
+
`query()` decodes each cell to its natural JS type by that tag, following mainstream Node SQL driver
|
|
43
|
+
conventions:
|
|
44
|
+
|
|
45
|
+
| NusaDB type | JS type |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `BOOL` | `boolean` |
|
|
48
|
+
| `INT` | `number` (a 64-bit value beyond `Number.MAX_SAFE_INTEGER` becomes a `BigInt`, lossless) |
|
|
49
|
+
| `FLOAT` | `number` |
|
|
50
|
+
| `DATE` / `TIMESTAMP` / `TIMESTAMPTZ` | `Date` |
|
|
51
|
+
| `JSON` | parsed object |
|
|
52
|
+
| `ARRAY` | `Array` (elements stay strings — the wire array tag carries no element type) |
|
|
53
|
+
| `BYTEA` | `Buffer` (and a `Buffer` parameter binds as the `\x<hex>` form the server coerces into BYTEA) |
|
|
54
|
+
| `NUMERIC` / `UUID` / `TIME` / `TIMETZ` / `INTERVAL` / `TEXT` | `string` (no lossless native JS type) |
|
|
55
|
+
|
|
56
|
+
A value that does not parse as its tag falls back to the raw string, so an unexpected wire form
|
|
57
|
+
never throws mid-decode. Against a 1.0 (untyped) server every column stays a string.
|
|
58
|
+
|
|
59
|
+
TypeScript typings ship in `src/index.d.ts`:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { connect, QueryResult } from 'nusadb';
|
|
63
|
+
const conn = await connect({ port: 5678, user: 'nusa-root', database: 'nusadb' });
|
|
64
|
+
const res: QueryResult = await conn.query('SELECT 1');
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Parameters
|
|
68
|
+
|
|
69
|
+
Placeholders are positional **`$1`, `$2`, …**. Pass values as the second argument;
|
|
70
|
+
`null` is SQL `NULL`. Values are sent in the wire text format. Result cells are
|
|
71
|
+
returned as strings (`null` for SQL NULL).
|
|
72
|
+
|
|
73
|
+
### Prepared statements
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
const stmt = await conn.prepare('INSERT INTO t VALUES ($1, $2)');
|
|
77
|
+
await stmt.execute([1, 'a']);
|
|
78
|
+
await stmt.execute([2, 'b']);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Batch (bulk insert/update)
|
|
82
|
+
|
|
83
|
+
`conn.executeMany(sql, paramSets)` runs one statement once per parameter set, reusing a single
|
|
84
|
+
prepared statement. It returns `{ rowCount, counts }` (`counts[i]` is the i-th set's affected-row
|
|
85
|
+
count). The wire protocol has no batch pipeline, so this is N round-trips, not one.
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
const { rowCount } = await conn.executeMany('INSERT INTO t VALUES ($1, $2)', [
|
|
89
|
+
[1, 'a'],
|
|
90
|
+
[2, 'b'],
|
|
91
|
+
[3, 'c'],
|
|
92
|
+
]);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Bulk load / export (`COPY`)
|
|
96
|
+
|
|
97
|
+
For high-throughput load/export, `copyIn` / `copyOut` drive the `COPY` sub-protocol — one round-trip
|
|
98
|
+
for the whole dataset. Move bytes in the server's text format (tab-delimited fields, `\N` for SQL
|
|
99
|
+
`NULL`, one row per line); you write the `COPY` statement with any `WITH (...)` options.
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
// Bulk load from a Buffer or string.
|
|
103
|
+
const loaded = await conn.copyIn('COPY t (id, name) FROM STDIN', '1\talice\n2\t\\N\n');
|
|
104
|
+
|
|
105
|
+
// Bulk export: resolves to { rowCount, data } (data is a Buffer).
|
|
106
|
+
const { rowCount, data } = await conn.copyOut('COPY t TO STDOUT');
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A `COPY` the server refuses (bad SQL, an RLS-protected table) rejects; the connection stays usable.
|
|
110
|
+
|
|
111
|
+
### Pool
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
const { Pool } = require('nusadb');
|
|
115
|
+
const pool = new Pool({ maxSize: 10, port: 5678, user: 'nusa-root', database: 'nusadb' });
|
|
116
|
+
await pool.withConnection((c) => c.query('SELECT 1'));
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Authentication & TLS
|
|
120
|
+
|
|
121
|
+
Pass `password` for a server started with `--auth-user`; the driver runs
|
|
122
|
+
SCRAM-SHA-256 and verifies the server signature (mutual auth). Pass `tls` (options
|
|
123
|
+
forwarded to `tls.connect`) for an implicit TLS 1.3 connection.
|
|
124
|
+
|
|
125
|
+
## Transactions
|
|
126
|
+
|
|
127
|
+
By default the connection is in **autocommit** mode: the server runs each statement
|
|
128
|
+
on its own implicit transaction. Pass `autocommit: false` for the standard
|
|
129
|
+
transactional model — the driver sends `BEGIN` lazily before the first statement,
|
|
130
|
+
and `commit()` / `rollback()` end the transaction:
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
const conn = await connect({ port, user: 'u', database: 'nusadb', autocommit: false });
|
|
134
|
+
await conn.query("INSERT INTO t VALUES (1, 'alice')");
|
|
135
|
+
await conn.rollback(); // the insert is discarded
|
|
136
|
+
await conn.query("INSERT INTO t VALUES (2, 'bob')");
|
|
137
|
+
await conn.commit(); // this one persists
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Inside a transaction, `savepoint(name)` marks a point you can later undo to with
|
|
141
|
+
`rollbackToSavepoint(name)` (the transaction stays open) or forget with `releaseSavepoint(name)`:
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
await conn.query("INSERT INTO t VALUES (1, 'alice')");
|
|
145
|
+
await conn.savepoint('sp1');
|
|
146
|
+
await conn.query("INSERT INTO t VALUES (2, 'bob')");
|
|
147
|
+
await conn.rollbackToSavepoint('sp1'); // undoes (2), keeps (1); the transaction continues
|
|
148
|
+
await conn.commit();
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Notifications (LISTEN/NOTIFY)
|
|
152
|
+
|
|
153
|
+
`listen(channel)` subscribes the connection; a `notify(channel, payload)` from any connection on the
|
|
154
|
+
same database is then delivered asynchronously. Collect delivered notifications with `poll(timeoutMs)`
|
|
155
|
+
(`0` polls without blocking, `null` blocks) or drain the ones buffered during other queries with
|
|
156
|
+
`notifications()`:
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
await conn.listen('orders');
|
|
160
|
+
// ... elsewhere: await other.notify('orders', '42');
|
|
161
|
+
const note = await conn.poll(5000); // -> { pid, channel, payload }, or null on timeout
|
|
162
|
+
console.log(note.channel, note.payload);
|
|
163
|
+
await conn.unlisten('orders');
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Tests
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
cargo build -p nusadb-server
|
|
170
|
+
node drivers/node/test/test.js
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The tests boot a real `nusadb-server` (ephemeral port, honouring
|
|
174
|
+
`CARGO_TARGET_DIR`) and cover simple/parameterised/prepared queries, errors, the
|
|
175
|
+
pool, and SCRAM auth.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
Apache-2.0.
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nusadb",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Node.js / TypeScript driver for NusaDB (Nusa Wire Protocol)",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"types": "src/index.d.ts",
|
|
7
|
+
"type": "commonjs",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "node test/test.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"nusadb",
|
|
16
|
+
"database",
|
|
17
|
+
"sql",
|
|
18
|
+
"driver"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/nusadb/node.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/nusadb/node#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/nusadb/node/issues"
|
|
27
|
+
},
|
|
28
|
+
"license": "Apache-2.0",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=16"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const tls = require('tls');
|
|
5
|
+
const { encode, Reader, B, AUTH, HEADER_LEN, MAX_FRAME_LEN, SCRAM_MECHANISM, typeName } = require('./protocol');
|
|
6
|
+
const scram = require('./scram');
|
|
7
|
+
|
|
8
|
+
/** A server error carrying a 5-character SQLSTATE (docs/wire-protocol.md §14). */
|
|
9
|
+
class NusaError extends Error {
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(`${code}: ${message}`);
|
|
12
|
+
this.name = 'NusaError';
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.sqlMessage = message;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Reads length-delimited frames off a socket and hands them out one at a time. */
|
|
19
|
+
class FrameReader {
|
|
20
|
+
constructor(socket) {
|
|
21
|
+
this._buf = Buffer.alloc(0);
|
|
22
|
+
this._queue = [];
|
|
23
|
+
this._waiters = [];
|
|
24
|
+
this._error = null;
|
|
25
|
+
socket.on('data', (chunk) => this._onData(chunk));
|
|
26
|
+
socket.on('error', (e) => this._fail(e));
|
|
27
|
+
socket.on('close', () => this._fail(new Error('nusadb: connection closed')));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_onData(chunk) {
|
|
31
|
+
this._buf = Buffer.concat([this._buf, chunk]);
|
|
32
|
+
while (this._buf.length >= HEADER_LEN) {
|
|
33
|
+
const total = this._buf.readUInt32BE(1);
|
|
34
|
+
if (total < HEADER_LEN || total > MAX_FRAME_LEN) {
|
|
35
|
+
this._fail(new Error(`nusadb: invalid frame length ${total}`));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (this._buf.length < total) break;
|
|
39
|
+
const msg = { type: this._buf[0], payload: Buffer.from(this._buf.subarray(HEADER_LEN, total)) };
|
|
40
|
+
this._buf = this._buf.subarray(total);
|
|
41
|
+
const waiter = this._waiters.shift();
|
|
42
|
+
if (waiter) waiter.resolve(msg);
|
|
43
|
+
else this._queue.push(msg);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_fail(err) {
|
|
48
|
+
if (this._error) return;
|
|
49
|
+
this._error = err;
|
|
50
|
+
for (const w of this._waiters) w.reject(err);
|
|
51
|
+
this._waiters = [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
read() {
|
|
55
|
+
if (this._queue.length) return Promise.resolve(this._queue.shift());
|
|
56
|
+
if (this._error) return Promise.reject(this._error);
|
|
57
|
+
return new Promise((resolve, reject) => this._waiters.push({ resolve, reject }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read the next frame, or resolve to `null` after `ms` if none arrives. `ms === null` blocks.
|
|
61
|
+
* On timeout the pending waiter is removed so it never swallows a later frame. */
|
|
62
|
+
readWithTimeout(ms) {
|
|
63
|
+
if (this._queue.length) return Promise.resolve(this._queue.shift());
|
|
64
|
+
if (this._error) return Promise.reject(this._error);
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
let timer = null;
|
|
67
|
+
const waiter = {
|
|
68
|
+
resolve: (msg) => {
|
|
69
|
+
if (timer) clearTimeout(timer);
|
|
70
|
+
resolve(msg);
|
|
71
|
+
},
|
|
72
|
+
reject: (err) => {
|
|
73
|
+
if (timer) clearTimeout(timer);
|
|
74
|
+
reject(err);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
this._waiters.push(waiter);
|
|
78
|
+
if (ms !== null && ms !== undefined) {
|
|
79
|
+
timer = setTimeout(() => {
|
|
80
|
+
const idx = this._waiters.indexOf(waiter);
|
|
81
|
+
if (idx >= 0) this._waiters.splice(idx, 1);
|
|
82
|
+
resolve(null);
|
|
83
|
+
}, ms);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Quotes a savepoint identifier so an arbitrary name is emitted safely; the server folds a quoted
|
|
90
|
+
* identifier to its literal value, so the same quoting matches across SAVEPOINT/ROLLBACK/RELEASE. */
|
|
91
|
+
function quoteIdentifier(name) {
|
|
92
|
+
if (typeof name !== 'string') {
|
|
93
|
+
throw new TypeError('nusadb: savepoint name must be a string');
|
|
94
|
+
}
|
|
95
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Quotes a string as a SQL literal (`'…'`, doubling `'`) — used for a NOTIFY payload. */
|
|
99
|
+
function quoteLiteral(text) {
|
|
100
|
+
return `'${String(text).replace(/'/g, "''")}'`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Decode a NotificationResponse body: `[pid:u32][channel:str][payload:str]`. */
|
|
104
|
+
function decodeNotification(r) {
|
|
105
|
+
return { pid: r.u32(), channel: r.str(), payload: r.str() };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function encodeParam(value) {
|
|
109
|
+
if (value === null || value === undefined) return null;
|
|
110
|
+
// A Buffer binds as the BYTEA text form `\x<hex>`, which the server coerces into a BYTEA column.
|
|
111
|
+
if (Buffer.isBuffer(value)) return Buffer.from(`\\x${value.toString('hex')}`, 'utf8');
|
|
112
|
+
if (typeof value === 'boolean') return Buffer.from(value ? 'true' : 'false');
|
|
113
|
+
return Buffer.from(String(value), 'utf8');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Decode the BYTEA text form `\x<hex>` to a Buffer. */
|
|
117
|
+
function decodeBytea(text) {
|
|
118
|
+
return Buffer.from(text.startsWith('\\x') ? text.slice(2) : text, 'hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function decodeBool(text) {
|
|
122
|
+
if (text === 'true' || text === 't' || text === '1') return true;
|
|
123
|
+
if (text === 'false' || text === 'f' || text === '0') return false;
|
|
124
|
+
throw new Error('not a bool');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function decodeInt(text) {
|
|
128
|
+
const n = Number(text);
|
|
129
|
+
// A 64-bit value beyond JS's safe-integer range becomes a BigInt to avoid silent precision loss;
|
|
130
|
+
// BigInt() throws on a non-integer, so the caller falls back to the raw text.
|
|
131
|
+
return Number.isSafeInteger(n) ? n : BigInt(text);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function decodeFloat(text) {
|
|
135
|
+
const n = Number(text);
|
|
136
|
+
if (Number.isNaN(n) && text !== 'NaN') throw new Error('not a float');
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function decodeTimestamp(text) {
|
|
141
|
+
// Make the wire text ISO-parseable: space -> 'T', and pad a bare +HH offset to +HH:00.
|
|
142
|
+
const iso = text.replace(' ', 'T').replace(/([+-]\d{2})$/, '$1:00');
|
|
143
|
+
const d = new Date(iso);
|
|
144
|
+
if (Number.isNaN(d.getTime())) throw new Error('not a date');
|
|
145
|
+
return d;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function decodeDate(text) {
|
|
149
|
+
const d = new Date(text);
|
|
150
|
+
if (Number.isNaN(d.getTime())) throw new Error('not a date');
|
|
151
|
+
return d;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Parse a SQL array literal (`{a,b,c}`, possibly nested, quoted, or with `NULL`) to a JS
|
|
155
|
+
* array. Elements stay strings (the wire array tag carries no per-element type); a quoted element is
|
|
156
|
+
* unescaped and an unquoted `NULL` becomes `null`. */
|
|
157
|
+
function parseArray(text) {
|
|
158
|
+
let pos = 0;
|
|
159
|
+
function parseList() {
|
|
160
|
+
if (text[pos] !== '{') throw new Error('not an array');
|
|
161
|
+
pos += 1;
|
|
162
|
+
const items = [];
|
|
163
|
+
if (text[pos] === '}') {
|
|
164
|
+
pos += 1;
|
|
165
|
+
return items;
|
|
166
|
+
}
|
|
167
|
+
for (;;) {
|
|
168
|
+
const ch = text[pos];
|
|
169
|
+
if (ch === '{') items.push(parseList());
|
|
170
|
+
else if (ch === '"') items.push(parseQuoted());
|
|
171
|
+
else items.push(parseUnquoted());
|
|
172
|
+
const sep = text[pos];
|
|
173
|
+
if (sep === ',') {
|
|
174
|
+
pos += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (sep === '}') {
|
|
178
|
+
pos += 1;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
throw new Error('malformed array');
|
|
182
|
+
}
|
|
183
|
+
return items;
|
|
184
|
+
}
|
|
185
|
+
function parseQuoted() {
|
|
186
|
+
pos += 1; // opening quote
|
|
187
|
+
let out = '';
|
|
188
|
+
while (text[pos] !== '"') {
|
|
189
|
+
if (pos >= text.length) throw new Error('unterminated array element');
|
|
190
|
+
if (text[pos] === '\\') pos += 1;
|
|
191
|
+
out += text[pos];
|
|
192
|
+
pos += 1;
|
|
193
|
+
}
|
|
194
|
+
pos += 1; // closing quote
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
function parseUnquoted() {
|
|
198
|
+
const start = pos;
|
|
199
|
+
while (pos < text.length && text[pos] !== ',' && text[pos] !== '}') pos += 1;
|
|
200
|
+
const token = text.slice(start, pos);
|
|
201
|
+
return token === 'NULL' ? null : token;
|
|
202
|
+
}
|
|
203
|
+
const result = parseList();
|
|
204
|
+
if (pos !== text.length) throw new Error('trailing array data');
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Maps a RowDescriptionTyped type name (protocol 1.1) to the function that turns wire text into a
|
|
209
|
+
// typed JS value, following mainstream Node SQL driver conventions. NUMERIC, UUID, TIME/TIMETZ, INTERVAL, and
|
|
210
|
+
// TEXT stay strings (no lossless native JS type); BYTES is handled separately in `decodeCell`.
|
|
211
|
+
const TYPED_DECODERS = {
|
|
212
|
+
BOOL: decodeBool,
|
|
213
|
+
INT: decodeInt,
|
|
214
|
+
FLOAT: decodeFloat,
|
|
215
|
+
JSON: JSON.parse,
|
|
216
|
+
ARRAY: parseArray,
|
|
217
|
+
DATE: decodeDate,
|
|
218
|
+
TIMESTAMP: decodeTimestamp,
|
|
219
|
+
TIMESTAMPTZ: decodeTimestamp,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** Decode one text-format field into a typed JS value by its wire type tag (`null` for SQL NULL). A
|
|
223
|
+
* value that does not parse as its tag falls back to the raw text, so an unexpected form never throws
|
|
224
|
+
* mid-decode. */
|
|
225
|
+
function decodeCell(cell, type) {
|
|
226
|
+
if (cell === null) return null;
|
|
227
|
+
if (type === 'BYTES') return decodeBytea(cell.toString('utf8'));
|
|
228
|
+
const text = cell.toString('utf8');
|
|
229
|
+
const decoder = TYPED_DECODERS[type];
|
|
230
|
+
if (!decoder) return text;
|
|
231
|
+
try {
|
|
232
|
+
return decoder(text);
|
|
233
|
+
} catch {
|
|
234
|
+
return text;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** One authenticated connection to a NusaDB server. */
|
|
239
|
+
class Connection {
|
|
240
|
+
constructor(socket, options) {
|
|
241
|
+
this._socket = socket;
|
|
242
|
+
this._reader = new FrameReader(socket);
|
|
243
|
+
this._options = options;
|
|
244
|
+
this._counter = 0;
|
|
245
|
+
this.backendKey = null;
|
|
246
|
+
// When `autocommit` is false, a transaction is opened lazily (BEGIN) before the first statement
|
|
247
|
+
// and ended by commit()/rollback(). In autocommit mode the server runs each statement on its own.
|
|
248
|
+
this._inTxn = false;
|
|
249
|
+
// Async LISTEN/NOTIFY messages received while reading other responses; drained by notifications().
|
|
250
|
+
this._notifications = [];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Open, handshake, and authenticate. Returns a ready Connection. */
|
|
254
|
+
static connect(options = {}) {
|
|
255
|
+
const opts = {
|
|
256
|
+
host: '127.0.0.1',
|
|
257
|
+
port: 5678,
|
|
258
|
+
user: 'nusa-root',
|
|
259
|
+
database: 'nusadb',
|
|
260
|
+
password: 'nusa-root',
|
|
261
|
+
tls: null,
|
|
262
|
+
autocommit: true,
|
|
263
|
+
...options,
|
|
264
|
+
};
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const onError = (e) => reject(e);
|
|
267
|
+
const socket = opts.tls
|
|
268
|
+
? tls.connect({ host: opts.host, port: opts.port, ...opts.tls }, onConnect)
|
|
269
|
+
: net.connect({ host: opts.host, port: opts.port }, onConnect);
|
|
270
|
+
// Disable Nagle's algorithm: this is a request/response protocol, so
|
|
271
|
+
// coalescing small frames only adds delayed-ACK latency per round-trip.
|
|
272
|
+
socket.setNoDelay(true);
|
|
273
|
+
socket.once('error', onError);
|
|
274
|
+
function onConnect() {
|
|
275
|
+
socket.removeListener('error', onError);
|
|
276
|
+
const conn = new Connection(socket, opts);
|
|
277
|
+
conn._handshake().then(() => resolve(conn)).catch((err) => {
|
|
278
|
+
socket.destroy();
|
|
279
|
+
reject(err);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
_send(frame) {
|
|
286
|
+
this._socket.write(frame);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Send a transaction-control statement (BEGIN/COMMIT/ROLLBACK) and drain to ReadyForQuery. */
|
|
290
|
+
async _runControl(sql) {
|
|
291
|
+
this._send(encode.query(sql));
|
|
292
|
+
await this._collect();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Open a transaction before the first statement when not in autocommit mode. */
|
|
296
|
+
async _beginIfNeeded() {
|
|
297
|
+
if (!this._options.autocommit && !this._inTxn) {
|
|
298
|
+
await this._runControl('BEGIN');
|
|
299
|
+
this._inTxn = true;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Commit the current transaction. A no-op in autocommit mode or with no open transaction. */
|
|
304
|
+
async commit() {
|
|
305
|
+
if (!this._options.autocommit && this._inTxn) {
|
|
306
|
+
await this._runControl('COMMIT');
|
|
307
|
+
this._inTxn = false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Roll back the current transaction, discarding its uncommitted changes. A no-op in autocommit
|
|
312
|
+
* mode or with no open transaction. */
|
|
313
|
+
async rollback() {
|
|
314
|
+
if (!this._options.autocommit && this._inTxn) {
|
|
315
|
+
await this._runControl('ROLLBACK');
|
|
316
|
+
this._inTxn = false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Establish a savepoint inside the open transaction (SAVEPOINT name) — a named marker you can
|
|
321
|
+
* later roll back to without ending the transaction. Requires autocommit off; a BEGIN is opened
|
|
322
|
+
* lazily if none is active yet. */
|
|
323
|
+
async savepoint(name) {
|
|
324
|
+
if (this._options.autocommit) {
|
|
325
|
+
throw new Error('nusadb: cannot set a savepoint in autocommit mode');
|
|
326
|
+
}
|
|
327
|
+
await this._beginIfNeeded();
|
|
328
|
+
await this._runControl(`SAVEPOINT ${quoteIdentifier(name)}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Roll back to a savepoint (ROLLBACK TO SAVEPOINT name); the transaction stays open. */
|
|
332
|
+
async rollbackToSavepoint(name) {
|
|
333
|
+
if (this._options.autocommit || !this._inTxn) {
|
|
334
|
+
throw new Error('nusadb: no active transaction to roll back to a savepoint');
|
|
335
|
+
}
|
|
336
|
+
await this._runControl(`ROLLBACK TO SAVEPOINT ${quoteIdentifier(name)}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Release (forget) a savepoint (RELEASE SAVEPOINT name), keeping its work. */
|
|
340
|
+
async releaseSavepoint(name) {
|
|
341
|
+
if (this._options.autocommit || !this._inTxn) {
|
|
342
|
+
throw new Error('nusadb: no active transaction to release a savepoint');
|
|
343
|
+
}
|
|
344
|
+
await this._runControl(`RELEASE SAVEPOINT ${quoteIdentifier(name)}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Subscribe to asynchronous notifications on `channel` (LISTEN channel). Collect them with
|
|
348
|
+
* notifications() or poll(). */
|
|
349
|
+
async listen(channel) {
|
|
350
|
+
await this._runControl(`LISTEN ${quoteIdentifier(channel)}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Stop listening on `channel` (UNLISTEN channel), or on every channel when `channel` is omitted
|
|
354
|
+
* (UNLISTEN *). */
|
|
355
|
+
async unlisten(channel) {
|
|
356
|
+
const target = channel === undefined || channel === null ? '*' : quoteIdentifier(channel);
|
|
357
|
+
await this._runControl(`UNLISTEN ${target}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Send a notification on `channel` with an optional `payload` (NOTIFY channel[, 'payload']). */
|
|
361
|
+
async notify(channel, payload) {
|
|
362
|
+
let sql = `NOTIFY ${quoteIdentifier(channel)}`;
|
|
363
|
+
if (payload !== undefined && payload !== null) sql += `, ${quoteLiteral(payload)}`;
|
|
364
|
+
await this._runControl(sql);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Return and clear the notifications already received (buffered while reading other responses).
|
|
368
|
+
* Does not touch the socket — use poll() to wait for new ones. */
|
|
369
|
+
notifications() {
|
|
370
|
+
const pending = this._notifications;
|
|
371
|
+
this._notifications = [];
|
|
372
|
+
return pending;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Wait up to `timeoutMs` for the next notification (a buffered one is returned immediately; `null`
|
|
376
|
+
* on timeout). `timeoutMs` of `null` blocks until one arrives; `0` polls without blocking. Only
|
|
377
|
+
* meaningful after listen(). */
|
|
378
|
+
async poll(timeoutMs = 0) {
|
|
379
|
+
if (this._notifications.length) return this._notifications.shift();
|
|
380
|
+
const msg = await this._reader.readWithTimeout(timeoutMs);
|
|
381
|
+
if (msg === null) return null;
|
|
382
|
+
if (msg.type === B.NOTIFICATION) return decodeNotification(new Reader(msg.payload));
|
|
383
|
+
if (msg.type === B.ERROR) {
|
|
384
|
+
const r = new Reader(msg.payload);
|
|
385
|
+
throw new NusaError(r.str(), r.str());
|
|
386
|
+
}
|
|
387
|
+
throw new Error(`nusadb: unexpected message ${msg.type} while polling for notifications`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async _read() {
|
|
391
|
+
return this._reader.read();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async _handshake() {
|
|
395
|
+
this._send(encode.startup(this._options.user, this._options.database));
|
|
396
|
+
for (;;) {
|
|
397
|
+
const msg = await this._read();
|
|
398
|
+
if (msg.type === B.READY) return;
|
|
399
|
+
if (msg.type === B.ERROR) {
|
|
400
|
+
const r = new Reader(msg.payload);
|
|
401
|
+
throw new NusaError(r.str(), r.str());
|
|
402
|
+
}
|
|
403
|
+
if (msg.type === B.AUTH) {
|
|
404
|
+
const r = new Reader(msg.payload);
|
|
405
|
+
const sub = r.u32();
|
|
406
|
+
if (sub === AUTH.SASL) {
|
|
407
|
+
const count = r.u16();
|
|
408
|
+
const mechs = [];
|
|
409
|
+
for (let i = 0; i < count; i += 1) mechs.push(r.str());
|
|
410
|
+
await this._scram(mechs);
|
|
411
|
+
}
|
|
412
|
+
} else if (msg.type === B.BACKEND_KEY) {
|
|
413
|
+
const r = new Reader(msg.payload);
|
|
414
|
+
this.backendKey = { pid: r.u32(), secret: r.u32() };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async _scram(mechs) {
|
|
420
|
+
if (!mechs.includes(SCRAM_MECHANISM)) {
|
|
421
|
+
throw new Error('nusadb: server offered no supported SASL mechanism');
|
|
422
|
+
}
|
|
423
|
+
if (this._options.password === null || this._options.password === undefined) {
|
|
424
|
+
throw new Error('nusadb: server requires a password but none was given');
|
|
425
|
+
}
|
|
426
|
+
const { bare, full } = scram.clientFirst(this._options.user);
|
|
427
|
+
this._send(encode.saslInitial(SCRAM_MECHANISM, Buffer.from(full, 'utf8')));
|
|
428
|
+
|
|
429
|
+
let msg = await this._read();
|
|
430
|
+
if (msg.type === B.ERROR) {
|
|
431
|
+
const r = new Reader(msg.payload);
|
|
432
|
+
throw new NusaError(r.str(), r.str());
|
|
433
|
+
}
|
|
434
|
+
let r = new Reader(msg.payload);
|
|
435
|
+
if (msg.type !== B.AUTH || r.u32() !== AUTH.SASL_CONTINUE) {
|
|
436
|
+
throw new Error('nusadb: expected a SASL continue message');
|
|
437
|
+
}
|
|
438
|
+
const serverFirst = r.rest().toString('utf8');
|
|
439
|
+
|
|
440
|
+
const { final, expected } = scram.clientFinal(this._options.password, bare, serverFirst);
|
|
441
|
+
this._send(encode.saslResponse(final));
|
|
442
|
+
|
|
443
|
+
msg = await this._read();
|
|
444
|
+
if (msg.type === B.ERROR) {
|
|
445
|
+
const er = new Reader(msg.payload);
|
|
446
|
+
throw new NusaError(er.str(), er.str());
|
|
447
|
+
}
|
|
448
|
+
r = new Reader(msg.payload);
|
|
449
|
+
if (msg.type !== B.AUTH || r.u32() !== AUTH.SASL_FINAL) {
|
|
450
|
+
throw new Error('nusadb: expected a SASL final message');
|
|
451
|
+
}
|
|
452
|
+
if (!scram.verifyServerFinal(r.rest().toString('utf8'), expected)) {
|
|
453
|
+
throw new Error('nusadb: server signature did not verify');
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async _collect() {
|
|
458
|
+
let columns = [];
|
|
459
|
+
// Per-column type name, parallel to `columns`. `null` for an untyped (protocol 1.0)
|
|
460
|
+
// RowDescription; a canonical type name (e.g. 'INT') for the 1.1 typed form.
|
|
461
|
+
let columnTypes = [];
|
|
462
|
+
const rows = [];
|
|
463
|
+
let tag = null;
|
|
464
|
+
let error = null;
|
|
465
|
+
for (;;) {
|
|
466
|
+
const msg = await this._read();
|
|
467
|
+
const r = new Reader(msg.payload);
|
|
468
|
+
switch (msg.type) {
|
|
469
|
+
case B.ROW_DESCRIPTION: {
|
|
470
|
+
const n = r.u16();
|
|
471
|
+
columns = [];
|
|
472
|
+
columnTypes = [];
|
|
473
|
+
for (let i = 0; i < n; i += 1) {
|
|
474
|
+
columns.push(r.str());
|
|
475
|
+
columnTypes.push(null);
|
|
476
|
+
}
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
case B.ROW_DESCRIPTION_TYPED: {
|
|
480
|
+
const n = r.u16();
|
|
481
|
+
columns = [];
|
|
482
|
+
columnTypes = [];
|
|
483
|
+
for (let i = 0; i < n; i += 1) {
|
|
484
|
+
columns.push(r.str());
|
|
485
|
+
columnTypes.push(typeName(r.u8()));
|
|
486
|
+
}
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
case B.DATA_ROW:
|
|
490
|
+
rows.push(r.fields());
|
|
491
|
+
break;
|
|
492
|
+
case B.COMMAND_COMPLETE:
|
|
493
|
+
tag = r.str();
|
|
494
|
+
break;
|
|
495
|
+
case B.NOTIFICATION:
|
|
496
|
+
// A pending LISTEN/NOTIFY message can lead the next query's response; buffer it.
|
|
497
|
+
this._notifications.push(decodeNotification(r));
|
|
498
|
+
break;
|
|
499
|
+
case B.ERROR:
|
|
500
|
+
error = new NusaError(r.str(), r.str());
|
|
501
|
+
break;
|
|
502
|
+
case B.READY:
|
|
503
|
+
if (error) throw error;
|
|
504
|
+
return { columns, columnTypes, rows, tag };
|
|
505
|
+
default:
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
_freshName(prefix) {
|
|
512
|
+
this._counter += 1;
|
|
513
|
+
return `nusa_${prefix}_${this._counter}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Run one SQL string (simple query). Returns { columns, rows, rowCount, command }. */
|
|
517
|
+
async query(sql, params) {
|
|
518
|
+
await this._beginIfNeeded();
|
|
519
|
+
let raw;
|
|
520
|
+
if (params && params.length) {
|
|
521
|
+
const encoded = params.map(encodeParam);
|
|
522
|
+
this._send(encode.parse('', sql));
|
|
523
|
+
this._send(encode.bind('', '', encoded));
|
|
524
|
+
this._send(encode.describePortal(''));
|
|
525
|
+
this._send(encode.execute('', 0));
|
|
526
|
+
this._send(encode.sync());
|
|
527
|
+
raw = await this._collect();
|
|
528
|
+
} else {
|
|
529
|
+
this._send(encode.query(sql));
|
|
530
|
+
raw = await this._collect();
|
|
531
|
+
}
|
|
532
|
+
return shape(raw);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Prepare a named statement; returns a handle with .execute(params). */
|
|
536
|
+
async prepare(sql) {
|
|
537
|
+
const name = this._freshName('stmt');
|
|
538
|
+
this._send(encode.parse(name, sql));
|
|
539
|
+
this._send(encode.sync());
|
|
540
|
+
await this._collect(); // surfaces a parse error
|
|
541
|
+
const self = this;
|
|
542
|
+
return {
|
|
543
|
+
name,
|
|
544
|
+
async execute(params) {
|
|
545
|
+
await self._beginIfNeeded();
|
|
546
|
+
const encoded = (params || []).map(encodeParam);
|
|
547
|
+
const portal = self._freshName('portal');
|
|
548
|
+
self._send(encode.bind(portal, name, encoded));
|
|
549
|
+
self._send(encode.describePortal(portal));
|
|
550
|
+
self._send(encode.execute(portal, 0));
|
|
551
|
+
self._send(encode.sync());
|
|
552
|
+
return shape(await self._collect());
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Run one statement once per parameter set, reusing a single prepared statement — the bulk
|
|
559
|
+
* insert/update path. Returns `{ rowCount, counts }`: `counts[i]` is the affected-row count of the
|
|
560
|
+
* i-th set, `rowCount` their sum. One parse, then one execute per set (the wire protocol has no
|
|
561
|
+
* batch pipeline, so this is N round-trips, not one). The first failing set rejects.
|
|
562
|
+
*/
|
|
563
|
+
async executeMany(sql, paramSets) {
|
|
564
|
+
const stmt = await this.prepare(sql);
|
|
565
|
+
const counts = [];
|
|
566
|
+
let rowCount = 0;
|
|
567
|
+
for (const params of paramSets || []) {
|
|
568
|
+
const res = await stmt.execute(params);
|
|
569
|
+
const n = res.rowCount == null ? 0 : res.rowCount;
|
|
570
|
+
counts.push(n);
|
|
571
|
+
rowCount += n;
|
|
572
|
+
}
|
|
573
|
+
return { rowCount, counts };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Bulk-load via `COPY ... FROM STDIN` (§12.1). `data` is a Buffer or string already in the
|
|
578
|
+
* server's text format (tab-delimited fields, `\N` for SQL NULL, one row per line); it is sent in
|
|
579
|
+
* 64 KiB `CopyData` chunks. Resolves to the number of rows loaded. A refused COPY (bad SQL, an
|
|
580
|
+
* RLS-protected table) rejects; the connection stays usable.
|
|
581
|
+
*/
|
|
582
|
+
async copyIn(sql, data) {
|
|
583
|
+
await this._beginIfNeeded();
|
|
584
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8');
|
|
585
|
+
this._send(encode.query(sql));
|
|
586
|
+
await this._awaitCopyStart(B.COPY_IN);
|
|
587
|
+
for (let off = 0; off < buf.length; off += 65536) {
|
|
588
|
+
this._send(encode.copyData(buf.subarray(off, Math.min(off + 65536, buf.length))));
|
|
589
|
+
}
|
|
590
|
+
this._send(encode.copyDone());
|
|
591
|
+
return this._finishCopy();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Bulk-export via `COPY ... TO STDOUT` (§12.2). Resolves to `{ rowCount, data }`, where `data` is a
|
|
596
|
+
* Buffer of the server's text-format rows (tab-delimited, `\N` for NULL).
|
|
597
|
+
*/
|
|
598
|
+
async copyOut(sql) {
|
|
599
|
+
await this._beginIfNeeded();
|
|
600
|
+
this._send(encode.query(sql));
|
|
601
|
+
await this._awaitCopyStart(B.COPY_OUT);
|
|
602
|
+
const chunks = [];
|
|
603
|
+
for (;;) {
|
|
604
|
+
const msg = await this._read();
|
|
605
|
+
if (msg.type === B.COPY_DATA) {
|
|
606
|
+
chunks.push(msg.payload);
|
|
607
|
+
} else if (msg.type === B.COPY_DONE) {
|
|
608
|
+
break;
|
|
609
|
+
} else if (msg.type === B.ERROR) {
|
|
610
|
+
const r = new Reader(msg.payload);
|
|
611
|
+
const err = new NusaError(r.str(), r.str());
|
|
612
|
+
await this._drainToReady();
|
|
613
|
+
throw err;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const rowCount = await this._finishCopy();
|
|
617
|
+
return { rowCount, data: Buffer.concat(chunks) };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Read until the expected CopyInResponse/CopyOutResponse; a refused COPY arrives as Error then
|
|
621
|
+
* ReadyForQuery and is thrown, the connection left ready. */
|
|
622
|
+
async _awaitCopyStart(expected) {
|
|
623
|
+
let error = null;
|
|
624
|
+
for (;;) {
|
|
625
|
+
const msg = await this._read();
|
|
626
|
+
if (msg.type === expected) return;
|
|
627
|
+
if (msg.type === B.ERROR) {
|
|
628
|
+
const r = new Reader(msg.payload);
|
|
629
|
+
error = new NusaError(r.str(), r.str());
|
|
630
|
+
} else if (msg.type === B.READY) {
|
|
631
|
+
throw error || new Error('nusadb: COPY did not start');
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Drain to ReadyForQuery, returning the row count from the `COPY <n>` command tag. */
|
|
637
|
+
async _finishCopy() {
|
|
638
|
+
let tag = null;
|
|
639
|
+
let error = null;
|
|
640
|
+
for (;;) {
|
|
641
|
+
const msg = await this._read();
|
|
642
|
+
if (msg.type === B.COMMAND_COMPLETE) {
|
|
643
|
+
tag = new Reader(msg.payload).str();
|
|
644
|
+
} else if (msg.type === B.ERROR) {
|
|
645
|
+
const r = new Reader(msg.payload);
|
|
646
|
+
error = new NusaError(r.str(), r.str());
|
|
647
|
+
} else if (msg.type === B.READY) {
|
|
648
|
+
if (error) throw error;
|
|
649
|
+
return affected(tag) || 0;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Best-effort resync: read and discard frames until ReadyForQuery. */
|
|
655
|
+
async _drainToReady() {
|
|
656
|
+
for (;;) {
|
|
657
|
+
const msg = await this._read();
|
|
658
|
+
if (msg.type === B.READY) return;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Close the connection (Terminate). */
|
|
663
|
+
async close() {
|
|
664
|
+
try {
|
|
665
|
+
this._send(encode.terminate());
|
|
666
|
+
} catch (_e) {
|
|
667
|
+
// ignore
|
|
668
|
+
}
|
|
669
|
+
this._socket.destroy();
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function affected(tag) {
|
|
674
|
+
if (!tag) return null;
|
|
675
|
+
const last = tag.split(' ').pop();
|
|
676
|
+
const n = Number.parseInt(last, 10);
|
|
677
|
+
return Number.isNaN(n) ? null : n;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function shape(raw) {
|
|
681
|
+
const types = raw.columnTypes || [];
|
|
682
|
+
// Each cell decodes to its natural JS type by the column's protocol 1.1 type tag (see decodeCell).
|
|
683
|
+
const rows = raw.rows.map((fieldList) => fieldList.map((cell, i) => decodeCell(cell, types[i])));
|
|
684
|
+
return {
|
|
685
|
+
columns: raw.columns,
|
|
686
|
+
// Per-column type names (protocol 1.1, R42-B.03); each entry is a canonical type name or `null`
|
|
687
|
+
// when the server answered with the untyped (1.0) RowDescription.
|
|
688
|
+
columnTypes: raw.columnTypes || raw.columns.map(() => null),
|
|
689
|
+
rows,
|
|
690
|
+
rowCount: raw.columns.length ? rows.length : affected(raw.tag),
|
|
691
|
+
command: raw.tag,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
module.exports = { Connection, NusaError };
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// TypeScript declarations for the nusadb Node.js driver.
|
|
2
|
+
|
|
3
|
+
import type { ConnectionOptions as TlsConnectionOptions } from 'tls';
|
|
4
|
+
|
|
5
|
+
export interface ConnectOptions {
|
|
6
|
+
host?: string;
|
|
7
|
+
port?: number;
|
|
8
|
+
user?: string;
|
|
9
|
+
database?: string;
|
|
10
|
+
password?: string | null;
|
|
11
|
+
/** TLS options passed to `tls.connect`; omit for a plaintext connection. */
|
|
12
|
+
tls?: TlsConnectionOptions | null;
|
|
13
|
+
/**
|
|
14
|
+
* When `true` (the default), the server runs each statement on its own implicit transaction. When
|
|
15
|
+
* `false`, a transaction is opened lazily before the first statement and ended by
|
|
16
|
+
* {@link Connection.commit} / {@link Connection.rollback}.
|
|
17
|
+
*/
|
|
18
|
+
autocommit?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type Param = string | number | boolean | Buffer | null | undefined;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A decoded cell value. The driver decodes each cell to its natural JS type by the column's
|
|
25
|
+
* protocol 1.1 type tag: `BOOL` → boolean, `INT` → number (or `bigint` beyond the safe-integer
|
|
26
|
+
* range), `FLOAT` → number, `DATE`/`TIMESTAMP`/`TIMESTAMPTZ` → `Date`, `JSON` → parsed value,
|
|
27
|
+
* `ARRAY` → array, `BYTEA` → `Buffer`; `NUMERIC`/`UUID`/`TIME`/`INTERVAL`/`TEXT` stay strings.
|
|
28
|
+
* `null` is SQL NULL.
|
|
29
|
+
*/
|
|
30
|
+
export type Cell =
|
|
31
|
+
| string
|
|
32
|
+
| number
|
|
33
|
+
| bigint
|
|
34
|
+
| boolean
|
|
35
|
+
| Buffer
|
|
36
|
+
| Date
|
|
37
|
+
| unknown[]
|
|
38
|
+
| Record<string, unknown>
|
|
39
|
+
| null;
|
|
40
|
+
|
|
41
|
+
export interface QueryResult {
|
|
42
|
+
/** Output column names (empty for a non-row statement). */
|
|
43
|
+
columns: string[];
|
|
44
|
+
/**
|
|
45
|
+
* Per-column type name, parallel to `columns` (protocol 1.1). A canonical name such as
|
|
46
|
+
* `'INT'` / `'TEXT'` / `'TIMESTAMP'`, or `null` when the server answered with the untyped
|
|
47
|
+
* (1.0) row description.
|
|
48
|
+
*/
|
|
49
|
+
columnTypes: Array<string | null>;
|
|
50
|
+
/** Rows of decoded cells (see {@link Cell}); `null` is SQL NULL. */
|
|
51
|
+
rows: Cell[][];
|
|
52
|
+
/** Row count for SELECT, affected count for DML, or null. */
|
|
53
|
+
rowCount: number | null;
|
|
54
|
+
/** The server's command tag (e.g. "SELECT 3", "INSERT 1"). */
|
|
55
|
+
command: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PreparedStatement {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
execute(params?: Param[]): Promise<QueryResult>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export declare class NusaError extends Error {
|
|
64
|
+
readonly code: string;
|
|
65
|
+
readonly sqlMessage: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export declare class Connection {
|
|
69
|
+
static connect(options?: ConnectOptions): Promise<Connection>;
|
|
70
|
+
readonly backendKey: { pid: number; secret: number } | null;
|
|
71
|
+
query(sql: string, params?: Param[]): Promise<QueryResult>;
|
|
72
|
+
prepare(sql: string): Promise<PreparedStatement>;
|
|
73
|
+
/** Commit the current transaction (no-op in autocommit mode or with no open transaction). */
|
|
74
|
+
commit(): Promise<void>;
|
|
75
|
+
/** Roll back the current transaction (no-op in autocommit mode or with no open transaction). */
|
|
76
|
+
rollback(): Promise<void>;
|
|
77
|
+
close(): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PoolOptions extends ConnectOptions {
|
|
81
|
+
maxSize?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export declare class Pool {
|
|
85
|
+
constructor(options?: PoolOptions);
|
|
86
|
+
readonly idleCount: number;
|
|
87
|
+
acquire(): Promise<Connection>;
|
|
88
|
+
release(conn: Connection): void;
|
|
89
|
+
withConnection<T>(fn: (conn: Connection) => Promise<T>): Promise<T>;
|
|
90
|
+
close(): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export declare function connect(options?: ConnectOptions): Promise<Connection>;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Connection, NusaError } = require('./connection');
|
|
4
|
+
const { Pool } = require('./pool');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Open a connection to a NusaDB server.
|
|
8
|
+
* @param {object} options host, port, user, database, password, tls
|
|
9
|
+
* @returns {Promise<Connection>}
|
|
10
|
+
*/
|
|
11
|
+
function connect(options) {
|
|
12
|
+
return Connection.connect(options);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { connect, Connection, Pool, NusaError };
|
package/src/pool.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Connection } = require('./connection');
|
|
4
|
+
|
|
5
|
+
/** A bounded async connection pool. */
|
|
6
|
+
class Pool {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
const { maxSize = 10, ...connOptions } = options;
|
|
9
|
+
this._max = Math.max(1, maxSize);
|
|
10
|
+
this._options = connOptions;
|
|
11
|
+
this._idle = [];
|
|
12
|
+
this._live = 0;
|
|
13
|
+
this._waiters = [];
|
|
14
|
+
this._closed = false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get idleCount() {
|
|
18
|
+
return this._idle.length;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async acquire() {
|
|
22
|
+
if (this._closed) throw new Error('nusadb: pool is closed');
|
|
23
|
+
if (this._idle.length) return this._idle.pop();
|
|
24
|
+
if (this._live < this._max) {
|
|
25
|
+
this._live += 1;
|
|
26
|
+
try {
|
|
27
|
+
return await Connection.connect(this._options);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
this._live -= 1;
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return new Promise((resolve, reject) => this._waiters.push({ resolve, reject }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
release(conn) {
|
|
37
|
+
const waiter = this._waiters.shift();
|
|
38
|
+
if (waiter) {
|
|
39
|
+
waiter.resolve(conn);
|
|
40
|
+
} else {
|
|
41
|
+
this._idle.push(conn);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Acquire a connection, run `fn`, and always release it. */
|
|
46
|
+
async withConnection(fn) {
|
|
47
|
+
const conn = await this.acquire();
|
|
48
|
+
try {
|
|
49
|
+
return await fn(conn);
|
|
50
|
+
} finally {
|
|
51
|
+
this.release(conn);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async close() {
|
|
56
|
+
this._closed = true;
|
|
57
|
+
const idle = this._idle;
|
|
58
|
+
this._idle = [];
|
|
59
|
+
await Promise.all(idle.map((c) => c.close()));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { Pool };
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Low-level Nusa Wire Protocol codec (docs/wire-protocol.md, PROTOCOL_VERSION 1.1).
|
|
4
|
+
// A frame is [type:u8][len:u32][payload]; len is the total including the 5-byte
|
|
5
|
+
// header. Big-endian throughout; strings are [len:u32][utf8 bytes], not null-terminated.
|
|
6
|
+
|
|
7
|
+
const PROTOCOL_MAGIC = 0x4e555341; // "NUSA"
|
|
8
|
+
const PROTOCOL_MAJOR = 1;
|
|
9
|
+
// Protocol 1.1 (R42-B.02): request minor 1 to receive the typed RowDescription (per-column type
|
|
10
|
+
// tags). A 1.0 server ignores the minor and answers with the classic untyped RowDescription, which
|
|
11
|
+
// the driver still handles — so requesting 1 is safe against any server.
|
|
12
|
+
const PROTOCOL_MINOR = 1;
|
|
13
|
+
|
|
14
|
+
// Column type tags carried by RowDescriptionTyped (protocol 1.1, wire-protocol.md §9.2). Maps the
|
|
15
|
+
// 1-byte tag to a canonical type name; an unrecognised tag (or 0x00) is treated as UNKNOWN/TEXT.
|
|
16
|
+
const TYPE_TAGS = {
|
|
17
|
+
0x00: 'UNKNOWN',
|
|
18
|
+
0x01: 'BOOL',
|
|
19
|
+
0x02: 'INT',
|
|
20
|
+
0x03: 'FLOAT',
|
|
21
|
+
0x04: 'NUMERIC',
|
|
22
|
+
0x05: 'TEXT',
|
|
23
|
+
0x06: 'BYTES',
|
|
24
|
+
0x07: 'DATE',
|
|
25
|
+
0x08: 'TIME',
|
|
26
|
+
0x09: 'TIMETZ',
|
|
27
|
+
0x0a: 'TIMESTAMP',
|
|
28
|
+
0x0b: 'TIMESTAMPTZ',
|
|
29
|
+
0x0c: 'INTERVAL',
|
|
30
|
+
0x0d: 'UUID',
|
|
31
|
+
0x0e: 'JSON',
|
|
32
|
+
0x0f: 'ARRAY',
|
|
33
|
+
0x10: 'VECTOR',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function typeName(tag) {
|
|
37
|
+
return TYPE_TAGS[tag] || 'UNKNOWN';
|
|
38
|
+
}
|
|
39
|
+
const HEADER_LEN = 5;
|
|
40
|
+
const MAX_FRAME_LEN = 256 * 1024 * 1024;
|
|
41
|
+
const SCRAM_MECHANISM = 'SCRAM-SHA-256';
|
|
42
|
+
|
|
43
|
+
// Backend message type bytes.
|
|
44
|
+
const B = {
|
|
45
|
+
AUTH: 0x52, // R
|
|
46
|
+
BACKEND_KEY: 0x4b, // K
|
|
47
|
+
READY: 0x5a, // Z
|
|
48
|
+
COMMAND_COMPLETE: 0x43, // C
|
|
49
|
+
ERROR: 0x45, // E
|
|
50
|
+
ROW_DESCRIPTION: 0x54, // T
|
|
51
|
+
ROW_DESCRIPTION_TYPED: 0x79, // y (protocol 1.1)
|
|
52
|
+
DATA_ROW: 0x44, // D
|
|
53
|
+
PARSE_COMPLETE: 0x31, // 1
|
|
54
|
+
BIND_COMPLETE: 0x32, // 2
|
|
55
|
+
NO_DATA: 0x6e, // n
|
|
56
|
+
PARAMETER_DESCRIPTION: 0x74, // t
|
|
57
|
+
COPY_IN: 0x47, // G (CopyInResponse, §12.1)
|
|
58
|
+
COPY_OUT: 0x48, // H (CopyOutResponse, §12.2)
|
|
59
|
+
COPY_DATA: 0x64, // d (a COPY data chunk)
|
|
60
|
+
COPY_DONE: 0x63, // c (end of the COPY data stream)
|
|
61
|
+
NOTIFICATION: 0x41, // A (async LISTEN/NOTIFY: [pid:u32][channel:str][payload:str])
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Frontend message type bytes.
|
|
65
|
+
const F = {
|
|
66
|
+
STARTUP: 0x53, // S
|
|
67
|
+
QUERY: 0x51, // Q
|
|
68
|
+
PARSE: 0x50, // P
|
|
69
|
+
BIND: 0x42, // B
|
|
70
|
+
DESCRIBE: 0x44, // D
|
|
71
|
+
EXECUTE: 0x45, // E
|
|
72
|
+
SYNC: 0x59, // Y
|
|
73
|
+
SASL_INITIAL: 0x70, // p
|
|
74
|
+
SASL_RESPONSE: 0x72, // r
|
|
75
|
+
CANCEL: 0x4b, // K
|
|
76
|
+
TERMINATE: 0x58, // X
|
|
77
|
+
COPY_DATA: 0x64, // d
|
|
78
|
+
COPY_DONE: 0x63, // c
|
|
79
|
+
COPY_FAIL: 0x66, // f
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const AUTH = { OK: 0, SASL: 10, SASL_CONTINUE: 11, SASL_FINAL: 12 };
|
|
83
|
+
|
|
84
|
+
function u32(value) {
|
|
85
|
+
const b = Buffer.alloc(4);
|
|
86
|
+
b.writeUInt32BE(value >>> 0, 0);
|
|
87
|
+
return b;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function u16(value) {
|
|
91
|
+
const b = Buffer.alloc(2);
|
|
92
|
+
b.writeUInt16BE(value & 0xffff, 0);
|
|
93
|
+
return b;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function str(s) {
|
|
97
|
+
const body = Buffer.from(s, 'utf8');
|
|
98
|
+
return Buffer.concat([u32(body.length), body]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function frame(type, payload) {
|
|
102
|
+
const total = payload.length + HEADER_LEN;
|
|
103
|
+
const head = Buffer.alloc(HEADER_LEN);
|
|
104
|
+
head[0] = type;
|
|
105
|
+
head.writeUInt32BE(total, 1);
|
|
106
|
+
return Buffer.concat([head, payload]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Encode a Fields list: [count:u16] then per field a present byte (0 = NULL,
|
|
110
|
+
// 1 = [len:u32][bytes]). `values` is an array of Buffer|null.
|
|
111
|
+
function fields(values) {
|
|
112
|
+
const parts = [u16(values.length)];
|
|
113
|
+
for (const v of values) {
|
|
114
|
+
if (v === null || v === undefined) {
|
|
115
|
+
parts.push(Buffer.from([0]));
|
|
116
|
+
} else {
|
|
117
|
+
parts.push(Buffer.from([1]), u32(v.length), v);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return Buffer.concat(parts);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- frontend message builders ---
|
|
124
|
+
|
|
125
|
+
const encode = {
|
|
126
|
+
startup(user, database) {
|
|
127
|
+
return frame(
|
|
128
|
+
F.STARTUP,
|
|
129
|
+
Buffer.concat([u32(PROTOCOL_MAGIC), u16(PROTOCOL_MAJOR), u16(PROTOCOL_MINOR), str(user), str(database)])
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
query(sql) {
|
|
133
|
+
return frame(F.QUERY, str(sql));
|
|
134
|
+
},
|
|
135
|
+
parse(name, sql) {
|
|
136
|
+
return frame(F.PARSE, Buffer.concat([str(name), str(sql), u16(0)]));
|
|
137
|
+
},
|
|
138
|
+
bind(portal, statement, values) {
|
|
139
|
+
return frame(F.BIND, Buffer.concat([str(portal), str(statement), fields(values), u16(0)]));
|
|
140
|
+
},
|
|
141
|
+
describePortal(name) {
|
|
142
|
+
return frame(F.DESCRIBE, Buffer.concat([Buffer.from([0x50]), str(name)]));
|
|
143
|
+
},
|
|
144
|
+
execute(portal, maxRows) {
|
|
145
|
+
return frame(F.EXECUTE, Buffer.concat([str(portal), u32(maxRows || 0)]));
|
|
146
|
+
},
|
|
147
|
+
sync() {
|
|
148
|
+
return frame(F.SYNC, Buffer.alloc(0));
|
|
149
|
+
},
|
|
150
|
+
terminate() {
|
|
151
|
+
return frame(F.TERMINATE, Buffer.alloc(0));
|
|
152
|
+
},
|
|
153
|
+
saslInitial(mechanism, data) {
|
|
154
|
+
return frame(F.SASL_INITIAL, Buffer.concat([str(mechanism), u32(data.length), data]));
|
|
155
|
+
},
|
|
156
|
+
saslResponse(data) {
|
|
157
|
+
return frame(F.SASL_RESPONSE, data);
|
|
158
|
+
},
|
|
159
|
+
cancel(pid, secret) {
|
|
160
|
+
return frame(F.CANCEL, Buffer.concat([u32(pid), u32(secret)]));
|
|
161
|
+
},
|
|
162
|
+
copyData(data) {
|
|
163
|
+
return frame(F.COPY_DATA, data);
|
|
164
|
+
},
|
|
165
|
+
copyDone() {
|
|
166
|
+
return frame(F.COPY_DONE, Buffer.alloc(0));
|
|
167
|
+
},
|
|
168
|
+
copyFail(message) {
|
|
169
|
+
return frame(F.COPY_FAIL, str(message));
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Reader over a payload Buffer.
|
|
174
|
+
class Reader {
|
|
175
|
+
constructor(buf) {
|
|
176
|
+
this.buf = buf;
|
|
177
|
+
this.pos = 0;
|
|
178
|
+
}
|
|
179
|
+
u8() {
|
|
180
|
+
const v = this.buf.readUInt8(this.pos);
|
|
181
|
+
this.pos += 1;
|
|
182
|
+
return v;
|
|
183
|
+
}
|
|
184
|
+
u16() {
|
|
185
|
+
const v = this.buf.readUInt16BE(this.pos);
|
|
186
|
+
this.pos += 2;
|
|
187
|
+
return v;
|
|
188
|
+
}
|
|
189
|
+
u32() {
|
|
190
|
+
const v = this.buf.readUInt32BE(this.pos);
|
|
191
|
+
this.pos += 4;
|
|
192
|
+
return v;
|
|
193
|
+
}
|
|
194
|
+
str() {
|
|
195
|
+
const n = this.u32();
|
|
196
|
+
const s = this.buf.toString('utf8', this.pos, this.pos + n);
|
|
197
|
+
this.pos += n;
|
|
198
|
+
return s;
|
|
199
|
+
}
|
|
200
|
+
rest() {
|
|
201
|
+
const b = this.buf.subarray(this.pos);
|
|
202
|
+
this.pos = this.buf.length;
|
|
203
|
+
return b;
|
|
204
|
+
}
|
|
205
|
+
fields() {
|
|
206
|
+
const n = this.u16();
|
|
207
|
+
const out = [];
|
|
208
|
+
for (let i = 0; i < n; i += 1) {
|
|
209
|
+
if (this.u8() === 0) {
|
|
210
|
+
out.push(null);
|
|
211
|
+
} else {
|
|
212
|
+
const len = this.u32();
|
|
213
|
+
out.push(Buffer.from(this.buf.subarray(this.pos, this.pos + len)));
|
|
214
|
+
this.pos += len;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
PROTOCOL_MAGIC,
|
|
223
|
+
HEADER_LEN,
|
|
224
|
+
MAX_FRAME_LEN,
|
|
225
|
+
SCRAM_MECHANISM,
|
|
226
|
+
B,
|
|
227
|
+
F,
|
|
228
|
+
AUTH,
|
|
229
|
+
TYPE_TAGS,
|
|
230
|
+
typeName,
|
|
231
|
+
encode,
|
|
232
|
+
Reader,
|
|
233
|
+
};
|
package/src/scram.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Client-side SCRAM-SHA-256 (RFC 5802 / RFC 7677), docs/wire-protocol.md §7.2.
|
|
4
|
+
// Uses Node's built-in `crypto` (no dependencies).
|
|
5
|
+
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
const GS2_HEADER = 'n,,';
|
|
9
|
+
const CHANNEL_BINDING = Buffer.from(GS2_HEADER, 'ascii').toString('base64'); // "biws"
|
|
10
|
+
|
|
11
|
+
function hmac(key, msg) {
|
|
12
|
+
return crypto.createHmac('sha256', key).update(msg).digest();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function clientFirst(user) {
|
|
16
|
+
const nonce = crypto.randomBytes(18).toString('base64');
|
|
17
|
+
const bare = `n=${user},r=${nonce}`;
|
|
18
|
+
return { bare, full: GS2_HEADER + bare };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseServerFirst(message) {
|
|
22
|
+
const parts = {};
|
|
23
|
+
for (const field of message.split(',')) {
|
|
24
|
+
const i = field.indexOf('=');
|
|
25
|
+
if (i > 0) parts[field.slice(0, i)] = field.slice(i + 1);
|
|
26
|
+
}
|
|
27
|
+
const combinedNonce = parts.r;
|
|
28
|
+
const salt = Buffer.from(parts.s, 'base64');
|
|
29
|
+
const iterations = parseInt(parts.i, 10);
|
|
30
|
+
if (!combinedNonce || salt.length === 0 || !(iterations > 0)) {
|
|
31
|
+
throw new Error('nusadb: malformed server-first message');
|
|
32
|
+
}
|
|
33
|
+
return { combinedNonce, salt, iterations };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clientFinal(password, clientFirstBare, serverFirst) {
|
|
37
|
+
const { combinedNonce, salt, iterations } = parseServerFirst(serverFirst);
|
|
38
|
+
|
|
39
|
+
const salted = crypto.pbkdf2Sync(Buffer.from(password, 'utf8'), salt, iterations, 32, 'sha256');
|
|
40
|
+
const clientKey = hmac(salted, 'Client Key');
|
|
41
|
+
const storedKey = crypto.createHash('sha256').update(clientKey).digest();
|
|
42
|
+
|
|
43
|
+
const withoutProof = `c=${CHANNEL_BINDING},r=${combinedNonce}`;
|
|
44
|
+
const authMessage = `${clientFirstBare},${serverFirst},${withoutProof}`;
|
|
45
|
+
const clientSig = hmac(storedKey, authMessage);
|
|
46
|
+
|
|
47
|
+
const proof = Buffer.alloc(clientKey.length);
|
|
48
|
+
for (let i = 0; i < proof.length; i += 1) proof[i] = clientKey[i] ^ clientSig[i];
|
|
49
|
+
const final = `${withoutProof},p=${proof.toString('base64')}`;
|
|
50
|
+
|
|
51
|
+
const serverKey = hmac(salted, 'Server Key');
|
|
52
|
+
const serverSig = hmac(serverKey, authMessage);
|
|
53
|
+
return { final: Buffer.from(final, 'utf8'), expected: serverSig.toString('base64') };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function verifyServerFinal(serverFinal, expectedB64) {
|
|
57
|
+
let got = '';
|
|
58
|
+
for (const field of serverFinal.split(',')) {
|
|
59
|
+
if (field.startsWith('v=')) got = field.slice(2);
|
|
60
|
+
}
|
|
61
|
+
const a = Buffer.from(got, 'utf8');
|
|
62
|
+
const b = Buffer.from(expectedB64, 'utf8');
|
|
63
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { clientFirst, clientFinal, verifyServerFinal };
|