@zvndev/powdb-client 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -6,6 +6,7 @@ All notable changes to `@zvndev/powdb-client`.
6
6
 
7
7
  | Client version | Compatible PowDB server | Notes |
8
8
  |---|---|---|
9
+ | 0.5.x | 0.4.7+ | Adds `client.query(powql, params)` for `$N` parameter binding. The `QueryWithParams` (`0x04`) wire message is only understood by server ≥0.4.7; parameterized queries against an older server error out. Plain `query(powql)` calls remain compatible with 0.3.x–0.4.x. |
9
10
  | 0.4.x | 0.3.x – 0.4.x | Wire protocol v1 plus the optional Connect `username` field. **Multi-user mode requires client ≥0.4.0 AND server ≥0.4.6** (the server enforces roles as of 0.4.6; 0.4.5 accepted the username but did not enforce `readonly`). When `user` is omitted the Connect frame is byte-identical to the 0.3.x shape, so legacy shared-password and no-auth servers work unchanged. |
10
11
  | 0.3.x | 0.3.x – 0.4.x | Wire protocol v1. The client warns only on a major-version mismatch, so any `0.x` server connects. Minor server bumps may add new opcodes; the client tolerates unknown response codes by surfacing `PowDBError`. Pin both ends. **Caveat:** the 0.3.x client has no `user` option, so it cannot authenticate to a 0.4.5+ server running in **multi-user mode** (the server requires a username once any named user is defined). Shared-password mode works fine. |
11
12
 
@@ -13,6 +14,20 @@ The client warns on major-version mismatch with the server during the
13
14
  handshake. Within `0.x`, treat any minor-version skew between client and
14
15
  server as best-effort and pin both ends.
15
16
 
17
+ ## [0.5.0] — 2026-06-10
18
+
19
+ ### Added
20
+ - **Parameter binding** — `client.query(powql, params)` accepts a second
21
+ argument of positional values bound to `$1`, `$2`, … placeholders in the
22
+ PowQL text. Values are sent as a typed parameter list in the new
23
+ `QueryWithParams` (`0x04`) wire message and bound at the token level on the
24
+ server, so they are injection-inert and byte-faithful (a value containing
25
+ PowQL syntax is stored verbatim, never re-parsed). Requires server ≥0.4.7.
26
+
27
+ ### Compatibility
28
+ - Parameterized queries require **server ≥0.4.7**. Plain `query(powql)` (no
29
+ params) is unchanged and still works against 0.3.x–0.4.x servers.
30
+
16
31
  ## [0.4.0] — 2026-06-09
17
32
 
18
33
  ### Added
package/README.md CHANGED
@@ -60,6 +60,27 @@ escapeIdent("User"); // → "User" (throws on invalid)
60
60
 
61
61
  `escapeLiteral` accepts `string | number | bigint | boolean | null`. It rejects `NaN`/`Infinity`, `undefined`, objects, arrays, symbols, and `Date` — convert those yourself before passing them in.
62
62
 
63
+ ### Parameter binding (`$N`)
64
+
65
+ For the strongest separation between code and data, pass values as positional `$N` parameters instead of interpolating them. The server binds each placeholder at the **token level** — a string becomes a literal token, never interpolated text — so an injection-shaped value is inert and can never change the query's shape. Placeholders are 1-based (`?` is not a placeholder; `??` is the COALESCE operator).
66
+
67
+ ```typescript
68
+ // Values are passed as the second argument, in $1, $2, … order.
69
+ await client.query("insert User { name := $1, email := $2, age := $3 }", [
70
+ name,
71
+ email,
72
+ age,
73
+ ]);
74
+
75
+ const r = await client.query("User filter .email = $1 { .name }", [email]);
76
+
77
+ // null binds PowQL null; numbers bind as int when integral, float otherwise;
78
+ // bigint always binds as int.
79
+ await client.query("insert User { name := $1, age := $2 }", ["Dana", null]);
80
+ ```
81
+
82
+ `QueryParam` is `string | number | bigint | boolean | null`. The params form sends the `QueryWithParams` (0x04) wire message and **requires powdb-server >= 0.4.7**. The plain `query(q)` and `query(q, { signal })` forms are unchanged.
83
+
63
84
  ## Authentication
64
85
 
65
86
  For servers using the legacy shared password (`POWDB_PASSWORD`), pass
@@ -277,7 +298,7 @@ Returns a `Promise<Client>`. Options:
277
298
  > **Multi-user servers:** requires client ≥0.4.0 (`user` option) and server
278
299
  > ≥0.4.6 (enforced roles). See the version matrix under Authentication.
279
300
 
280
- ### `client.query(query, opts?)`
301
+ ### `client.query(query, params?, opts?)`
281
302
 
282
303
  Sends a PowQL query and returns a `Promise<QueryResult>`:
283
304
 
@@ -285,6 +306,8 @@ Sends a PowQL query and returns a `Promise<QueryResult>`:
285
306
  - `{ kind: "scalar", value: string }` — for aggregates (`count`, `sum`, `avg`, etc.)
286
307
  - `{ kind: "ok", affected: bigint }` — for mutations (`insert`, `update`, `delete`)
287
308
 
309
+ `params?: QueryParam[]` — positional values bound to `$1`, `$2`, … placeholders (see Parameter binding above; requires server ≥0.4.7). When omitted, the plain query path is used. The legacy two-argument `query(q, { signal })` form is still accepted — an array second argument is treated as params, an object as options.
310
+
288
311
  `opts.signal?: AbortSignal` — aborts the returned promise (see Cancellation above).
289
312
 
290
313
  Throws a `PowDBError` (see Structured errors above) on any failure.
package/dist/index.d.ts CHANGED
@@ -33,6 +33,15 @@ export type QueryResult = {
33
33
  kind: "message";
34
34
  message: string;
35
35
  };
36
+ /**
37
+ * A value bound to a positional `$N` placeholder in {@link Client.query}.
38
+ *
39
+ * The server binds these at the token level — a string is substituted as a
40
+ * literal token, never interpolated — so injection-shaped input is inert.
41
+ * Numbers bind as ints when integral and floats otherwise; `bigint` always
42
+ * binds as an int; `null` binds PowQL `null`.
43
+ */
44
+ export type QueryParam = string | number | bigint | boolean | null;
36
45
  export interface ClientOptions {
37
46
  host: string;
38
47
  port: number;
@@ -100,7 +109,9 @@ export declare class Client extends EventEmitter<ClientEvents> {
100
109
  * destroyed — the server will still eventually send its reply, which we
101
110
  * silently discard so other in-flight queries keep working.
102
111
  */
103
- query(query: string, opts?: {
112
+ query(query: string, paramsOrOpts?: QueryParam[] | {
113
+ signal?: AbortSignal;
114
+ }, maybeOpts?: {
104
115
  signal?: AbortSignal;
105
116
  }): Promise<QueryResult>;
106
117
  /**
@@ -148,8 +159,8 @@ export declare class Client extends EventEmitter<ClientEvents> {
148
159
  private onClose;
149
160
  }
150
161
  export { encode, tryDecode } from "./protocol.js";
151
- export type { Message } from "./protocol.js";
152
- export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, } from "./protocol.js";
162
+ export type { Message, WireParam } from "./protocol.js";
163
+ export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, } from "./protocol.js";
153
164
  export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
154
165
  export { Pool } from "./pool.js";
155
166
  export type { PoolOptions } from "./pool.js";
package/dist/index.js CHANGED
@@ -17,11 +17,30 @@
17
17
  import * as net from "node:net";
18
18
  import * as tls from "node:tls";
19
19
  import { EventEmitter } from "node:events";
20
- import { encode, tryDecode } from "./protocol.js";
20
+ import { encode, tryDecode, } from "./protocol.js";
21
21
  import { PowDBError } from "./errors.js";
22
22
  import { coerceRows, } from "./typed.js";
23
23
  /** Client library version. Compared to the server's reported version. */
24
24
  export const CLIENT_VERSION = "0.4.0";
25
+ /** Map a JS {@link QueryParam} to its wire encoding. */
26
+ function toWireParam(p) {
27
+ if (p === null)
28
+ return { tag: "null" };
29
+ switch (typeof p) {
30
+ case "string":
31
+ return { tag: "str", value: p };
32
+ case "boolean":
33
+ return { tag: "bool", value: p };
34
+ case "bigint":
35
+ return { tag: "int", value: p };
36
+ case "number":
37
+ return Number.isInteger(p)
38
+ ? { tag: "int", value: BigInt(p) }
39
+ : { tag: "float", value: p };
40
+ default:
41
+ throw new PowDBError(`unsupported query parameter type: ${typeof p}`, "protocol_error");
42
+ }
43
+ }
25
44
  /** Module-level set of host:port pairs we've already warned about. */
26
45
  const versionWarnings = new Set();
27
46
  /** Extract the major component of a dotted version string, e.g. "0.2.0" → "0". */
@@ -143,10 +162,23 @@ export class Client extends EventEmitter {
143
162
  * destroyed — the server will still eventually send its reply, which we
144
163
  * silently discard so other in-flight queries keep working.
145
164
  */
146
- async query(query, opts) {
165
+ async query(query, paramsOrOpts, maybeOpts) {
166
+ // Disambiguate the two overloads:
167
+ // query(q) — no params, no opts
168
+ // query(q, opts) — legacy 2-arg opts form (back-compat)
169
+ // query(q, params) — positional $N parameters
170
+ // query(q, params, opts) — params + opts
171
+ const hasParams = Array.isArray(paramsOrOpts);
172
+ const params = hasParams ? paramsOrOpts : undefined;
173
+ const opts = hasParams
174
+ ? maybeOpts
175
+ : paramsOrOpts;
147
176
  const start = Date.now();
148
177
  try {
149
- const reply = await this.send({ type: "Query", query }, opts);
178
+ const request = params === undefined
179
+ ? { type: "Query", query }
180
+ : { type: "QueryWithParams", query, params: params.map(toWireParam) };
181
+ const reply = await this.send(request, opts);
150
182
  let result;
151
183
  switch (reply.type) {
152
184
  case "ResultRows":
@@ -515,7 +547,7 @@ function openSocket(host, port, timeoutMs, tlsOpt) {
515
547
  });
516
548
  }
517
549
  export { encode, tryDecode } from "./protocol.js";
518
- export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, } from "./protocol.js";
550
+ export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, } from "./protocol.js";
519
551
  export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
520
552
  export { Pool } from "./pool.js";
521
553
  export { PowDBError, isPowDBError } from "./errors.js";
@@ -9,6 +9,12 @@
9
9
  export declare const MSG_CONNECT = 1;
10
10
  export declare const MSG_CONNECT_OK = 2;
11
11
  export declare const MSG_QUERY = 3;
12
+ /**
13
+ * Query carrying positional `$N` parameters. Pure protocol addition: old
14
+ * servers reject it with the existing "unknown message type" error, and the
15
+ * plain `MSG_QUERY` frame is unchanged. Requires powdb-server ≥ 0.4.7.
16
+ */
17
+ export declare const MSG_QUERY_PARAMS = 4;
12
18
  export declare const MSG_RESULT_ROWS = 7;
13
19
  export declare const MSG_RESULT_SCALAR = 8;
14
20
  export declare const MSG_RESULT_OK = 9;
@@ -23,6 +29,32 @@ export declare const MAX_PAYLOAD_SIZE: number;
23
29
  export declare const MAX_ROWS = 10000000;
24
30
  /** Maximum number of columns allowed in a result set. */
25
31
  export declare const MAX_COLUMNS = 4096;
32
+ /** Maximum number of bound parameters in a QueryWithParams message. */
33
+ export declare const MAX_PARAMS = 4096;
34
+ /**
35
+ * A positional parameter value for {@link MSG_QUERY_PARAMS}. Wire encoding
36
+ * per param: a 1-byte tag followed by the body —
37
+ * `0` null (no body), `1` int (8B LE i64), `2` float (8B LE f64),
38
+ * `3` bool (1B), `4` str (length-prefixed UTF-8).
39
+ *
40
+ * Integral numbers are sent as ints, non-integral numbers as floats; `null`
41
+ * binds PowQL `null`. `bigint` is always an int.
42
+ */
43
+ export type WireParam = {
44
+ tag: "null";
45
+ } | {
46
+ tag: "int";
47
+ value: bigint;
48
+ } | {
49
+ tag: "float";
50
+ value: number;
51
+ } | {
52
+ tag: "bool";
53
+ value: boolean;
54
+ } | {
55
+ tag: "str";
56
+ value: string;
57
+ };
26
58
  export type Message = {
27
59
  type: "Connect";
28
60
  dbName: string;
@@ -41,6 +73,10 @@ export type Message = {
41
73
  } | {
42
74
  type: "Query";
43
75
  query: string;
76
+ } | {
77
+ type: "QueryWithParams";
78
+ query: string;
79
+ params: WireParam[];
44
80
  } | {
45
81
  type: "ResultRows";
46
82
  columns: string[];
package/dist/protocol.js CHANGED
@@ -9,6 +9,12 @@
9
9
  export const MSG_CONNECT = 0x01;
10
10
  export const MSG_CONNECT_OK = 0x02;
11
11
  export const MSG_QUERY = 0x03;
12
+ /**
13
+ * Query carrying positional `$N` parameters. Pure protocol addition: old
14
+ * servers reject it with the existing "unknown message type" error, and the
15
+ * plain `MSG_QUERY` frame is unchanged. Requires powdb-server ≥ 0.4.7.
16
+ */
17
+ export const MSG_QUERY_PARAMS = 0x04;
12
18
  export const MSG_RESULT_ROWS = 0x07;
13
19
  export const MSG_RESULT_SCALAR = 0x08;
14
20
  export const MSG_RESULT_OK = 0x09;
@@ -24,6 +30,8 @@ export const MAX_PAYLOAD_SIZE = 64 * 1024 * 1024;
24
30
  export const MAX_ROWS = 10_000_000;
25
31
  /** Maximum number of columns allowed in a result set. */
26
32
  export const MAX_COLUMNS = 4096;
33
+ /** Maximum number of bound parameters in a QueryWithParams message. */
34
+ export const MAX_PARAMS = 4096;
27
35
  // ───── Encoding ────────────────────────────────────────────────────────────
28
36
  export function encode(msg) {
29
37
  let msgType;
@@ -52,6 +60,42 @@ export function encode(msg) {
52
60
  payload = encodeString(msg.query);
53
61
  msgType = MSG_QUERY;
54
62
  break;
63
+ case "QueryWithParams": {
64
+ const parts = [encodeString(msg.query)];
65
+ const count = Buffer.alloc(2);
66
+ count.writeUInt16LE(msg.params.length, 0);
67
+ parts.push(count);
68
+ for (const p of msg.params) {
69
+ switch (p.tag) {
70
+ case "null":
71
+ parts.push(Buffer.from([0]));
72
+ break;
73
+ case "int": {
74
+ const b = Buffer.alloc(9);
75
+ b.writeUInt8(1, 0);
76
+ b.writeBigInt64LE(p.value, 1);
77
+ parts.push(b);
78
+ break;
79
+ }
80
+ case "float": {
81
+ const b = Buffer.alloc(9);
82
+ b.writeUInt8(2, 0);
83
+ b.writeDoubleLE(p.value, 1);
84
+ parts.push(b);
85
+ break;
86
+ }
87
+ case "bool":
88
+ parts.push(Buffer.from([3, p.value ? 1 : 0]));
89
+ break;
90
+ case "str":
91
+ parts.push(Buffer.from([4]), encodeString(p.value));
92
+ break;
93
+ }
94
+ }
95
+ payload = Buffer.concat(parts);
96
+ msgType = MSG_QUERY_PARAMS;
97
+ break;
98
+ }
55
99
  case "ResultRows": {
56
100
  const parts = [];
57
101
  const colCount = Buffer.alloc(2);
@@ -150,6 +194,37 @@ function decodePayload(msgType, payload) {
150
194
  return { type: "ConnectOk", version: decodeString(payload, cursor) };
151
195
  case MSG_QUERY:
152
196
  return { type: "Query", query: decodeString(payload, cursor) };
197
+ case MSG_QUERY_PARAMS: {
198
+ const query = decodeString(payload, cursor);
199
+ const count = readU16(payload, cursor, "param count");
200
+ if (count > MAX_PARAMS) {
201
+ throw new Error(`too many parameters: ${count} (max ${MAX_PARAMS})`);
202
+ }
203
+ const params = [];
204
+ for (let i = 0; i < count; i++) {
205
+ const tag = readU8(payload, cursor, "param tag");
206
+ switch (tag) {
207
+ case 0:
208
+ params.push({ tag: "null" });
209
+ break;
210
+ case 1:
211
+ params.push({ tag: "int", value: readI64(payload, cursor, "int param") });
212
+ break;
213
+ case 2:
214
+ params.push({ tag: "float", value: readF64(payload, cursor, "float param") });
215
+ break;
216
+ case 3:
217
+ params.push({ tag: "bool", value: readU8(payload, cursor, "bool param") !== 0 });
218
+ break;
219
+ case 4:
220
+ params.push({ tag: "str", value: decodeString(payload, cursor) });
221
+ break;
222
+ default:
223
+ throw new Error(`unknown param tag: ${tag}`);
224
+ }
225
+ }
226
+ return { type: "QueryWithParams", query, params };
227
+ }
153
228
  case MSG_RESULT_ROWS: {
154
229
  const colCount = readU16(payload, cursor, "column count");
155
230
  if (colCount > MAX_COLUMNS) {
@@ -213,6 +288,30 @@ function decodeString(buf, cursor) {
213
288
  cursor.pos += len;
214
289
  return s;
215
290
  }
291
+ function readU8(buf, cursor, label) {
292
+ if (cursor.pos + 1 > buf.length) {
293
+ throw new Error(`truncated ${label}`);
294
+ }
295
+ const value = buf.readUInt8(cursor.pos);
296
+ cursor.pos += 1;
297
+ return value;
298
+ }
299
+ function readI64(buf, cursor, label) {
300
+ if (cursor.pos + 8 > buf.length) {
301
+ throw new Error(`truncated ${label}`);
302
+ }
303
+ const value = buf.readBigInt64LE(cursor.pos);
304
+ cursor.pos += 8;
305
+ return value;
306
+ }
307
+ function readF64(buf, cursor, label) {
308
+ if (cursor.pos + 8 > buf.length) {
309
+ throw new Error(`truncated ${label}`);
310
+ }
311
+ const value = buf.readDoubleLE(cursor.pos);
312
+ cursor.pos += 8;
313
+ return value;
314
+ }
216
315
  function readU16(buf, cursor, label) {
217
316
  if (cursor.pos + 2 > buf.length) {
218
317
  throw new Error(`truncated ${label}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/powdb-client",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "TypeScript client for PowDB (PowQL wire protocol)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.29.3",
@@ -60,8 +60,8 @@
60
60
  "prepublishOnly": "npm run build"
61
61
  },
62
62
  "devDependencies": {
63
- "@types/node": "^22",
63
+ "@types/node": "^25",
64
64
  "tsx": "^4",
65
- "typescript": "^5.6"
65
+ "typescript": "^6.0"
66
66
  }
67
67
  }