@zvndev/powdb-client 0.4.0 → 0.6.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
@@ -1,11 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 - 2026-06-17
4
+
5
+ - Kept the npm client release in lockstep with PowDB workspace v0.5.1.
6
+ - No TypeScript API changes; this is a dependency/version-alignment release.
7
+
8
+
3
9
  All notable changes to `@zvndev/powdb-client`.
4
10
 
5
11
  ## Compatibility
6
12
 
7
13
  | Client version | Compatible PowDB server | Notes |
8
14
  |---|---|---|
15
+ | 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
16
  | 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
17
  | 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
18
 
@@ -13,6 +20,20 @@ The client warns on major-version mismatch with the server during the
13
20
  handshake. Within `0.x`, treat any minor-version skew between client and
14
21
  server as best-effort and pin both ends.
15
22
 
23
+ ## [0.5.0] — 2026-06-10
24
+
25
+ ### Added
26
+ - **Parameter binding** — `client.query(powql, params)` accepts a second
27
+ argument of positional values bound to `$1`, `$2`, … placeholders in the
28
+ PowQL text. Values are sent as a typed parameter list in the new
29
+ `QueryWithParams` (`0x04`) wire message and bound at the token level on the
30
+ server, so they are injection-inert and byte-faithful (a value containing
31
+ PowQL syntax is stored verbatim, never re-parsed). Requires server ≥0.4.7.
32
+
33
+ ### Compatibility
34
+ - Parameterized queries require **server ≥0.4.7**. Plain `query(powql)` (no
35
+ params) is unchanged and still works against 0.3.x–0.4.x servers.
36
+
16
37
  ## [0.4.0] — 2026-06-09
17
38
 
18
39
  ### 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
@@ -18,7 +18,7 @@ import * as tls from "node:tls";
18
18
  import { EventEmitter } from "node:events";
19
19
  import { type TypedRow, type TypedSchema } from "./typed.js";
20
20
  /** Client library version. Compared to the server's reported version. */
21
- export declare const CLIENT_VERSION = "0.4.0";
21
+ export declare const CLIENT_VERSION = "0.6.0";
22
22
  export type QueryResult = {
23
23
  kind: "rows";
24
24
  columns: string[];
@@ -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,16 @@ 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?: {
115
+ signal?: AbortSignal;
116
+ }): Promise<QueryResult>;
117
+ /**
118
+ * Run a SQL statement through the server-side SQL frontend. The plain
119
+ * {@link query} method remains PowQL for wire compatibility.
120
+ */
121
+ querySql(query: string, opts?: {
104
122
  signal?: AbortSignal;
105
123
  }): Promise<QueryResult>;
106
124
  /**
@@ -148,8 +166,8 @@ export declare class Client extends EventEmitter<ClientEvents> {
148
166
  private onClose;
149
167
  }
150
168
  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";
169
+ export type { Message, WireParam } from "./protocol.js";
170
+ export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, } from "./protocol.js";
153
171
  export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
154
172
  export { Pool } from "./pool.js";
155
173
  export type { PoolOptions } from "./pool.js";
package/dist/index.js CHANGED
@@ -17,11 +17,33 @@
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
- export const CLIENT_VERSION = "0.4.0";
24
+ export const CLIENT_VERSION = "0.6.0";
25
+ function socketChunkToBuffer(chunk) {
26
+ return typeof chunk === "string" ? Buffer.from(chunk) : chunk;
27
+ }
28
+ /** Map a JS {@link QueryParam} to its wire encoding. */
29
+ function toWireParam(p) {
30
+ if (p === null)
31
+ return { tag: "null" };
32
+ switch (typeof p) {
33
+ case "string":
34
+ return { tag: "str", value: p };
35
+ case "boolean":
36
+ return { tag: "bool", value: p };
37
+ case "bigint":
38
+ return { tag: "int", value: p };
39
+ case "number":
40
+ return Number.isInteger(p)
41
+ ? { tag: "int", value: BigInt(p) }
42
+ : { tag: "float", value: p };
43
+ default:
44
+ throw new PowDBError(`unsupported query parameter type: ${typeof p}`, "protocol_error");
45
+ }
46
+ }
25
47
  /** Module-level set of host:port pairs we've already warned about. */
26
48
  const versionWarnings = new Set();
27
49
  /** Extract the major component of a dotted version string, e.g. "0.2.0" → "0". */
@@ -57,7 +79,7 @@ export class Client extends EventEmitter {
57
79
  super();
58
80
  this.socket = socket;
59
81
  this.serverVersion = serverVersion;
60
- this.socket.on("data", (chunk) => this.onData(chunk));
82
+ this.socket.on("data", (chunk) => this.onData(socketChunkToBuffer(chunk)));
61
83
  this.socket.on("error", (err) => this.onClose(err));
62
84
  this.socket.on("close", () => this.onClose(null));
63
85
  }
@@ -75,7 +97,7 @@ export class Client extends EventEmitter {
75
97
  socket.removeListener("close", onClose);
76
98
  };
77
99
  const onData = (chunk) => {
78
- scratch = Buffer.concat([scratch, chunk]);
100
+ scratch = Buffer.concat([scratch, socketChunkToBuffer(chunk)]);
79
101
  let decoded;
80
102
  try {
81
103
  decoded = tryDecode(scratch);
@@ -143,10 +165,68 @@ export class Client extends EventEmitter {
143
165
  * destroyed — the server will still eventually send its reply, which we
144
166
  * silently discard so other in-flight queries keep working.
145
167
  */
146
- async query(query, opts) {
168
+ async query(query, paramsOrOpts, maybeOpts) {
169
+ // Disambiguate the two overloads:
170
+ // query(q) — no params, no opts
171
+ // query(q, opts) — legacy 2-arg opts form (back-compat)
172
+ // query(q, params) — positional $N parameters
173
+ // query(q, params, opts) — params + opts
174
+ const hasParams = Array.isArray(paramsOrOpts);
175
+ const params = hasParams ? paramsOrOpts : undefined;
176
+ const opts = hasParams
177
+ ? maybeOpts
178
+ : paramsOrOpts;
179
+ const start = Date.now();
180
+ try {
181
+ const request = params === undefined
182
+ ? { type: "Query", query }
183
+ : { type: "QueryWithParams", query, params: params.map(toWireParam) };
184
+ const reply = await this.send(request, opts);
185
+ let result;
186
+ switch (reply.type) {
187
+ case "ResultRows":
188
+ result = { kind: "rows", columns: reply.columns, rows: reply.rows };
189
+ break;
190
+ case "ResultScalar":
191
+ result = { kind: "scalar", value: reply.value };
192
+ break;
193
+ case "ResultOk":
194
+ result = { kind: "ok", affected: reply.affected };
195
+ break;
196
+ case "ResultMessage":
197
+ result = { kind: "message", message: reply.message };
198
+ break;
199
+ case "Error":
200
+ throw new PowDBError(`query failed: ${reply.message}`, "query_failed");
201
+ default:
202
+ throw new PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
203
+ }
204
+ this.emit("query", {
205
+ query,
206
+ durationMs: Date.now() - start,
207
+ ok: true,
208
+ kind: result.kind,
209
+ });
210
+ return result;
211
+ }
212
+ catch (err) {
213
+ this.emit("query", {
214
+ query,
215
+ durationMs: Date.now() - start,
216
+ ok: false,
217
+ error: err,
218
+ });
219
+ throw err;
220
+ }
221
+ }
222
+ /**
223
+ * Run a SQL statement through the server-side SQL frontend. The plain
224
+ * {@link query} method remains PowQL for wire compatibility.
225
+ */
226
+ async querySql(query, opts) {
147
227
  const start = Date.now();
148
228
  try {
149
- const reply = await this.send({ type: "Query", query }, opts);
229
+ const reply = await this.send({ type: "QuerySql", query }, opts);
150
230
  let result;
151
231
  switch (reply.type) {
152
232
  case "ResultRows":
@@ -515,7 +595,7 @@ function openSocket(host, port, timeoutMs, tlsOpt) {
515
595
  });
516
596
  }
517
597
  export { encode, tryDecode } from "./protocol.js";
518
- export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, } from "./protocol.js";
598
+ export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, } from "./protocol.js";
519
599
  export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
520
600
  export { Pool } from "./pool.js";
521
601
  export { PowDBError, isPowDBError } from "./errors.js";
@@ -9,6 +9,14 @@
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;
18
+ /** SQL query frame. Plain Query remains PowQL for backward compatibility. */
19
+ export declare const MSG_QUERY_SQL = 5;
12
20
  export declare const MSG_RESULT_ROWS = 7;
13
21
  export declare const MSG_RESULT_SCALAR = 8;
14
22
  export declare const MSG_RESULT_OK = 9;
@@ -23,6 +31,32 @@ export declare const MAX_PAYLOAD_SIZE: number;
23
31
  export declare const MAX_ROWS = 10000000;
24
32
  /** Maximum number of columns allowed in a result set. */
25
33
  export declare const MAX_COLUMNS = 4096;
34
+ /** Maximum number of bound parameters in a QueryWithParams message. */
35
+ export declare const MAX_PARAMS = 4096;
36
+ /**
37
+ * A positional parameter value for {@link MSG_QUERY_PARAMS}. Wire encoding
38
+ * per param: a 1-byte tag followed by the body —
39
+ * `0` null (no body), `1` int (8B LE i64), `2` float (8B LE f64),
40
+ * `3` bool (1B), `4` str (length-prefixed UTF-8).
41
+ *
42
+ * Integral numbers are sent as ints, non-integral numbers as floats; `null`
43
+ * binds PowQL `null`. `bigint` is always an int.
44
+ */
45
+ export type WireParam = {
46
+ tag: "null";
47
+ } | {
48
+ tag: "int";
49
+ value: bigint;
50
+ } | {
51
+ tag: "float";
52
+ value: number;
53
+ } | {
54
+ tag: "bool";
55
+ value: boolean;
56
+ } | {
57
+ tag: "str";
58
+ value: string;
59
+ };
26
60
  export type Message = {
27
61
  type: "Connect";
28
62
  dbName: string;
@@ -41,6 +75,13 @@ export type Message = {
41
75
  } | {
42
76
  type: "Query";
43
77
  query: string;
78
+ } | {
79
+ type: "QuerySql";
80
+ query: string;
81
+ } | {
82
+ type: "QueryWithParams";
83
+ query: string;
84
+ params: WireParam[];
44
85
  } | {
45
86
  type: "ResultRows";
46
87
  columns: string[];
package/dist/protocol.js CHANGED
@@ -9,6 +9,14 @@
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;
18
+ /** SQL query frame. Plain Query remains PowQL for backward compatibility. */
19
+ export const MSG_QUERY_SQL = 0x05;
12
20
  export const MSG_RESULT_ROWS = 0x07;
13
21
  export const MSG_RESULT_SCALAR = 0x08;
14
22
  export const MSG_RESULT_OK = 0x09;
@@ -24,6 +32,8 @@ export const MAX_PAYLOAD_SIZE = 64 * 1024 * 1024;
24
32
  export const MAX_ROWS = 10_000_000;
25
33
  /** Maximum number of columns allowed in a result set. */
26
34
  export const MAX_COLUMNS = 4096;
35
+ /** Maximum number of bound parameters in a QueryWithParams message. */
36
+ export const MAX_PARAMS = 4096;
27
37
  // ───── Encoding ────────────────────────────────────────────────────────────
28
38
  export function encode(msg) {
29
39
  let msgType;
@@ -52,6 +62,46 @@ export function encode(msg) {
52
62
  payload = encodeString(msg.query);
53
63
  msgType = MSG_QUERY;
54
64
  break;
65
+ case "QuerySql":
66
+ payload = encodeString(msg.query);
67
+ msgType = MSG_QUERY_SQL;
68
+ break;
69
+ case "QueryWithParams": {
70
+ const parts = [encodeString(msg.query)];
71
+ const count = Buffer.alloc(2);
72
+ count.writeUInt16LE(msg.params.length, 0);
73
+ parts.push(count);
74
+ for (const p of msg.params) {
75
+ switch (p.tag) {
76
+ case "null":
77
+ parts.push(Buffer.from([0]));
78
+ break;
79
+ case "int": {
80
+ const b = Buffer.alloc(9);
81
+ b.writeUInt8(1, 0);
82
+ b.writeBigInt64LE(p.value, 1);
83
+ parts.push(b);
84
+ break;
85
+ }
86
+ case "float": {
87
+ const b = Buffer.alloc(9);
88
+ b.writeUInt8(2, 0);
89
+ b.writeDoubleLE(p.value, 1);
90
+ parts.push(b);
91
+ break;
92
+ }
93
+ case "bool":
94
+ parts.push(Buffer.from([3, p.value ? 1 : 0]));
95
+ break;
96
+ case "str":
97
+ parts.push(Buffer.from([4]), encodeString(p.value));
98
+ break;
99
+ }
100
+ }
101
+ payload = Buffer.concat(parts);
102
+ msgType = MSG_QUERY_PARAMS;
103
+ break;
104
+ }
55
105
  case "ResultRows": {
56
106
  const parts = [];
57
107
  const colCount = Buffer.alloc(2);
@@ -150,6 +200,41 @@ function decodePayload(msgType, payload) {
150
200
  return { type: "ConnectOk", version: decodeString(payload, cursor) };
151
201
  case MSG_QUERY:
152
202
  return { type: "Query", query: decodeString(payload, cursor) };
203
+ case MSG_QUERY_SQL: {
204
+ const query = decodeString(payload, cursor);
205
+ return { type: "QuerySql", query };
206
+ }
207
+ case MSG_QUERY_PARAMS: {
208
+ const query = decodeString(payload, cursor);
209
+ const count = readU16(payload, cursor, "param count");
210
+ if (count > MAX_PARAMS) {
211
+ throw new Error(`too many parameters: ${count} (max ${MAX_PARAMS})`);
212
+ }
213
+ const params = [];
214
+ for (let i = 0; i < count; i++) {
215
+ const tag = readU8(payload, cursor, "param tag");
216
+ switch (tag) {
217
+ case 0:
218
+ params.push({ tag: "null" });
219
+ break;
220
+ case 1:
221
+ params.push({ tag: "int", value: readI64(payload, cursor, "int param") });
222
+ break;
223
+ case 2:
224
+ params.push({ tag: "float", value: readF64(payload, cursor, "float param") });
225
+ break;
226
+ case 3:
227
+ params.push({ tag: "bool", value: readU8(payload, cursor, "bool param") !== 0 });
228
+ break;
229
+ case 4:
230
+ params.push({ tag: "str", value: decodeString(payload, cursor) });
231
+ break;
232
+ default:
233
+ throw new Error(`unknown param tag: ${tag}`);
234
+ }
235
+ }
236
+ return { type: "QueryWithParams", query, params };
237
+ }
153
238
  case MSG_RESULT_ROWS: {
154
239
  const colCount = readU16(payload, cursor, "column count");
155
240
  if (colCount > MAX_COLUMNS) {
@@ -213,6 +298,30 @@ function decodeString(buf, cursor) {
213
298
  cursor.pos += len;
214
299
  return s;
215
300
  }
301
+ function readU8(buf, cursor, label) {
302
+ if (cursor.pos + 1 > buf.length) {
303
+ throw new Error(`truncated ${label}`);
304
+ }
305
+ const value = buf.readUInt8(cursor.pos);
306
+ cursor.pos += 1;
307
+ return value;
308
+ }
309
+ function readI64(buf, cursor, label) {
310
+ if (cursor.pos + 8 > buf.length) {
311
+ throw new Error(`truncated ${label}`);
312
+ }
313
+ const value = buf.readBigInt64LE(cursor.pos);
314
+ cursor.pos += 8;
315
+ return value;
316
+ }
317
+ function readF64(buf, cursor, label) {
318
+ if (cursor.pos + 8 > buf.length) {
319
+ throw new Error(`truncated ${label}`);
320
+ }
321
+ const value = buf.readDoubleLE(cursor.pos);
322
+ cursor.pos += 8;
323
+ return value;
324
+ }
216
325
  function readU16(buf, cursor, label) {
217
326
  if (cursor.pos + 2 > buf.length) {
218
327
  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.6.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
  }