@zvndev/powdb-client 0.19.1 → 0.20.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,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.20.0 - 2026-07-25
6
+
7
+ - **Security (medium): bounded result allocation.** A hostile or MITM'd server
8
+ could declare 10,000,000 single-column rows backed by a ~40 MB frame of empty
9
+ cells and make the client allocate roughly 1.9 GB (measured), since a cell
10
+ costs 4 bytes on the wire but far more as a JS value. Result decoding now
11
+ enforces `MAX_RESULT_CELLS` (2,000,000 cells per frame) on both the legacy and
12
+ native paths. Results larger than that must be paged with `limit`/`offset`.
13
+ - `queryTyped` is now generic and takes positional `$N` parameters:
14
+ `queryTyped<User>(q, schema, params?, opts?)`. Typed rows and injection-safe
15
+ binding were previously mutually exclusive. The old
16
+ `queryTyped(q, schema, opts?)` form is unchanged.
17
+ - New `queryObjects<Row>(query, params?, opts?)`: object rows keyed by column
18
+ name on the LOSSLESS native path, so no schema is needed (bytes stay bytes,
19
+ JSON stays recursive data, out-of-range integers stay `bigint`).
20
+ `querySqlObjects<Row>(query, opts?)` is the SQL counterpart. New `NativeRow`
21
+ type.
22
+ - New SQL composition helpers in `escape.ts`: `sql` tagged template,
23
+ `sqlIdent`, `escapeSqlLiteral`, `escapeSqlIdent`. PowDB's SQL frontend has NO
24
+ parameter binding on any wire frame, so string concatenation was previously
25
+ the only way to build SQL with user input. These escape for PowDB's own SQL
26
+ lexer (`''` doubling plus backslash escaping, identifiers validated rather
27
+ than quoted). Escaping is weaker than binding: prefer PowQL `$N` parameters
28
+ for untrusted input.
29
+
3
30
  ## 0.19.1 - 2026-07-24
4
31
 
5
32
  - `SUPPORTED_CATALOG_VERSION` raised from 6 to 7: sync/replica clients are now
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zvndev/powdb-client
2
2
 
3
- TypeScript client for [PowDB](https://github.com/zvndev/powdb) speaks the native binary wire protocol over TCP (or TLS).
3
+ TypeScript client for [PowDB](https://github.com/ZVN-DEV/powdb). Speaks the native binary wire protocol over TCP (or TLS).
4
4
 
5
5
  ## Install
6
6
 
@@ -60,6 +60,24 @@ 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
+ ### SQL composition (`sql`, `sqlIdent`, `escapeSqlLiteral`)
64
+
65
+ **The SQL frontend has no parameter binding.** `querySql`, `querySqlNative`, and `querySqlObjects` take a statement and nothing else: there is no `QuerySqlParams` wire frame, so `$N` placeholders cannot be bound on the SQL path. Prefer PowQL with `$N` parameters for anything built from untrusted input.
66
+
67
+ When you must build SQL, use the `sql` tagged template rather than concatenation. It escapes for PowDB's own SQL lexer: `'` is doubled and a backslash is escaped (PowDB's SQL lexer honours backslash escapes, unlike standard SQL). Identifiers are validated, not quoted, because the lexer reads a double-quoted run as a string literal.
68
+
69
+ ```typescript
70
+ import { sql, sqlIdent, escapeSqlLiteral, escapeSqlIdent } from "@zvndev/powdb-client";
71
+
72
+ const q = sql`SELECT name FROM ${sqlIdent("User")} WHERE name = ${userName}`;
73
+ await client.querySql(q);
74
+
75
+ escapeSqlLiteral("o'neil"); // → "'o''neil'"
76
+ escapeSqlIdent("User"); // → "User" (throws on anything else)
77
+ ```
78
+
79
+ Escaping is a weaker guarantee than binding: it depends on the value landing in a string or number position, and it cannot make an identifier or keyword safe. Track the missing `QuerySqlParams` frame if you need real binding on the SQL path.
80
+
63
81
  ### Parameter binding (`$N`)
64
82
 
65
83
  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).
@@ -412,6 +430,45 @@ output always parses, so this fallback is inert in normal use).
412
430
  Bytes columns are intentionally unsupported by `queryTyped` because the
413
431
  legacy wire format renders `<N bytes>`. Use `queryNative` for exact bytes.
414
432
 
433
+ ## Object rows on the lossless path (`queryObjects`)
434
+
435
+ `queryTyped` coerces the legacy stringly-typed path with a caller-supplied
436
+ schema. `queryObjects` needs no schema: it runs on the **native** wire surface,
437
+ where the server states each cell's type, and returns objects keyed by column
438
+ name. It accepts `$N` parameters and an abort signal exactly like `query`.
439
+
440
+ ```typescript
441
+ interface User {
442
+ name: string;
443
+ age: number;
444
+ prefs: unknown;
445
+ }
446
+
447
+ const users = await client.queryObjects<User>(
448
+ "User filter .age > $1 { .name, .age, .prefs }",
449
+ [25],
450
+ );
451
+
452
+ users[0].age; // number (bigint when outside the safe integer range)
453
+ users[0].prefs; // recursively decoded JSON, not a string
454
+ ```
455
+
456
+ `querySqlObjects<Row>(sql, opts?)` is the SQL counterpart. `queryTyped` is also
457
+ generic and takes parameters, so typed rows and injection-safe binding compose:
458
+
459
+ ```typescript
460
+ const rows = await client.queryTyped<User>(
461
+ "User filter .age > $1 { .name, .age }",
462
+ { name: "str", age: "int" },
463
+ [25],
464
+ );
465
+ ```
466
+
467
+ The generic is an unchecked assertion about the query's shape, like a SQL
468
+ driver's row type: nothing validates it at runtime. Duplicate column names
469
+ collapse (last one wins), so alias them in the projection. A non-rows result
470
+ (a count, a write) throws `PowDBError(code="query_failed")`.
471
+
415
472
  ## Structured errors
416
473
 
417
474
  Every error thrown by the client is a `PowDBError` with a stable `.code`:
@@ -592,10 +649,26 @@ Runs SQL through the native typed wire surface and returns a
592
649
  `Promise<NativeQueryResult>`. It has the same no-replay guarantee as
593
650
  `queryNative()`.
594
651
 
595
- ### `client.queryTyped(query, schema, opts?)`
652
+ ### `client.queryTyped<Row>(query, schema, params?, opts?)`
596
653
 
597
654
  Like `query()`, but coerces each row's string values to JS types using the
598
- supplied schema and returns `Promise<TypedRow[]>`. See Typed rows above.
655
+ supplied schema and returns `Promise<Row[]>` (`TypedRow[]` when no generic is
656
+ given). Accepts positional `$N` parameters in the same third-argument position
657
+ as `query()`, so typed rows and parameter binding compose. The legacy
658
+ `queryTyped(q, schema, { signal })` form still works. See Schema-coerced rows
659
+ above.
660
+
661
+ ### `client.queryObjects<Row>(query, params?, opts?)`
662
+
663
+ Runs PowQL on the lossless native surface and returns object rows keyed by
664
+ column name (`Promise<Row[]>`, default `NativeRow[]`). No schema needed.
665
+ Parameters and abort options match `query()`. Throws
666
+ `PowDBError(code="query_failed")` if the result is not rows-shaped.
667
+
668
+ ### `client.querySqlObjects<Row>(query, opts?)`
669
+
670
+ SQL counterpart of `queryObjects`. The SQL path cannot bind parameters: build
671
+ statements with the `sql` tagged template (see SQL composition above).
599
672
 
600
673
  ### `client.execScript(script, opts?)`
601
674
 
@@ -655,6 +728,10 @@ Getters: `size`, `idle`, `closed`.
655
728
  - `ident(name)` — wrap a string so `powql` treats it as an identifier
656
729
  - `escapeLiteral(value)` — render a JS value as a PowQL literal
657
730
  - `escapeIdent(name)` — validate an identifier (throws `TypeError` on invalid)
731
+ - `sql`: tagged template for the SQL frontend; escapes literals, validates identifiers
732
+ - `sqlIdent(name)`: wrap a string so `sql` treats it as a SQL identifier
733
+ - `escapeSqlLiteral(value)`: render a JS value as a PowDB SQL literal
734
+ - `escapeSqlIdent(name)`: validate a SQL identifier (throws `TypeError` on invalid)
658
735
  - `splitStatements(script)` — the statement-aware script splitter used by `execScript`
659
736
 
660
737
  ## Limits
@@ -664,6 +741,11 @@ The client enforces the same frame limits as the server and throws on violation:
664
741
  - `MAX_PAYLOAD_SIZE` — 64 MiB per frame
665
742
  - `MAX_ROWS` — 10,000,000 rows per result
666
743
  - `MAX_COLUMNS` — 4,096 columns per result
744
+ - `MAX_RESULT_CELLS`: 2,000,000 cells (rows x columns) per result frame. This
745
+ caps how much heap a server-declared result shape can force the client to
746
+ allocate: a cell is only 4 bytes on the wire but costs far more as a JS
747
+ value, so without the cap a ~40 MB frame could expand to roughly 1.9 GB. Page
748
+ larger results with `limit`/`offset`.
667
749
 
668
750
  ## Requirements
669
751
 
@@ -52,3 +52,65 @@ export declare function escapeLiteral(value: string | number | bigint | boolean
52
52
  * const q = powql`insert ${ident(table)} { name := ${userName} }`;
53
53
  */
54
54
  export declare function powql(strings: TemplateStringsArray, ...values: unknown[]): string;
55
+ /**
56
+ * SQL composition helpers for PowDB's SQL frontend.
57
+ *
58
+ * READ THIS FIRST. PowDB's SQL surface has NO parameter binding on any wire
59
+ * frame: `querySql`/`querySqlNative` take a statement and nothing else, and
60
+ * there is no `QuerySqlParams` message. The PowQL surface does have real `$N`
61
+ * binding (`client.query(q, [values])`), where the server substitutes a literal
62
+ * TOKEN and injection-shaped input is inert.
63
+ *
64
+ * So: **prefer PowQL with `$N` parameters for anything built from untrusted
65
+ * input.** These helpers exist because the SQL path would otherwise leave
66
+ * string concatenation as the only option, which is strictly worse. They escape
67
+ * correctly for PowDB's own SQL lexer (`crates/query/src/sql.rs`), but escaping
68
+ * is a weaker guarantee than binding: it depends on the value landing in a
69
+ * string/number position, and it cannot make an identifier or a keyword safe.
70
+ *
71
+ * PowDB SQL string rules (verified against `lex_sql` in `crates/query/src/sql.rs`):
72
+ * - Strings are delimited by `'`.
73
+ * - `''` inside a single-quoted string is a literal `'`.
74
+ * - A backslash also escapes the next character (`\n`, `\t`, and `\X` → `X`),
75
+ * which standard SQL does NOT do, so a literal backslash must be doubled.
76
+ * - `"` also opens a string in this lexer, so double quotes cannot be used to
77
+ * quote an identifier. Identifiers are therefore validated, never quoted.
78
+ */
79
+ /**
80
+ * Render a JS value as a PowDB SQL literal. Same accepted types as
81
+ * {@link escapeLiteral}: `string`, `number`, `bigint`, `boolean`, `null`.
82
+ *
83
+ * - string → `'...'` with `\` doubled and `'` doubled
84
+ * - number → decimal; rejects `NaN`/`±Infinity`
85
+ * - bigint → decimal digits
86
+ * - boolean → `true` / `false`
87
+ * - null → `null`
88
+ */
89
+ export declare function escapeSqlLiteral(value: string | number | bigint | boolean | null): string;
90
+ /**
91
+ * Validate a SQL identifier (table, column, alias). Returns it unchanged on
92
+ * success and throws `TypeError` otherwise.
93
+ *
94
+ * Identifiers are validated rather than quoted because PowDB's SQL lexer reads
95
+ * `"..."` as a string literal, so there is no identifier-quoting syntax to fall
96
+ * back on. Only `^[A-Za-z_][A-Za-z0-9_]*$` is accepted.
97
+ */
98
+ export declare function escapeSqlIdent(name: string): string;
99
+ /** Wrapper marking a string as a SQL identifier for the `sql` tagged template. */
100
+ export declare class SqlIdent {
101
+ readonly name: string;
102
+ constructor(name: string);
103
+ }
104
+ /** Factory for {@link SqlIdent}. */
105
+ export declare function sqlIdent(name: string): SqlIdent;
106
+ /**
107
+ * Tagged template for SQL composition. Interpolated values are escaped as SQL
108
+ * literals; wrap one in `sqlIdent(...)` to interpolate an identifier.
109
+ *
110
+ * const q = sql`SELECT name FROM ${sqlIdent(table)} WHERE name = ${userName}`;
111
+ * await client.querySql(q);
112
+ *
113
+ * Escaping, not binding: see the section note above, and prefer PowQL's `$N`
114
+ * parameters when the input is untrusted.
115
+ */
116
+ export declare function sql(strings: TemplateStringsArray, ...values: unknown[]): string;
@@ -20,11 +20,15 @@
20
20
  * literal backslash must be `\\` (otherwise it would swallow the next char).
21
21
  */
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.PowqlIdent = void 0;
23
+ exports.SqlIdent = exports.PowqlIdent = void 0;
24
24
  exports.ident = ident;
25
25
  exports.escapeIdent = escapeIdent;
26
26
  exports.escapeLiteral = escapeLiteral;
27
27
  exports.powql = powql;
28
+ exports.escapeSqlLiteral = escapeSqlLiteral;
29
+ exports.escapeSqlIdent = escapeSqlIdent;
30
+ exports.sqlIdent = sqlIdent;
31
+ exports.sql = sql;
28
32
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
29
33
  /** Wrapper marking a string as an identifier (vs a literal) for `powql` tagged templates. */
30
34
  class PowqlIdent {
@@ -108,8 +112,131 @@ function powql(strings, ...values) {
108
112
  return out;
109
113
  }
110
114
  // ──────────────────────────────────────────────────────────
115
+ // SQL frontend
116
+ // ──────────────────────────────────────────────────────────
117
+ /**
118
+ * SQL composition helpers for PowDB's SQL frontend.
119
+ *
120
+ * READ THIS FIRST. PowDB's SQL surface has NO parameter binding on any wire
121
+ * frame: `querySql`/`querySqlNative` take a statement and nothing else, and
122
+ * there is no `QuerySqlParams` message. The PowQL surface does have real `$N`
123
+ * binding (`client.query(q, [values])`), where the server substitutes a literal
124
+ * TOKEN and injection-shaped input is inert.
125
+ *
126
+ * So: **prefer PowQL with `$N` parameters for anything built from untrusted
127
+ * input.** These helpers exist because the SQL path would otherwise leave
128
+ * string concatenation as the only option, which is strictly worse. They escape
129
+ * correctly for PowDB's own SQL lexer (`crates/query/src/sql.rs`), but escaping
130
+ * is a weaker guarantee than binding: it depends on the value landing in a
131
+ * string/number position, and it cannot make an identifier or a keyword safe.
132
+ *
133
+ * PowDB SQL string rules (verified against `lex_sql` in `crates/query/src/sql.rs`):
134
+ * - Strings are delimited by `'`.
135
+ * - `''` inside a single-quoted string is a literal `'`.
136
+ * - A backslash also escapes the next character (`\n`, `\t`, and `\X` → `X`),
137
+ * which standard SQL does NOT do, so a literal backslash must be doubled.
138
+ * - `"` also opens a string in this lexer, so double quotes cannot be used to
139
+ * quote an identifier. Identifiers are therefore validated, never quoted.
140
+ */
141
+ /**
142
+ * Render a JS value as a PowDB SQL literal. Same accepted types as
143
+ * {@link escapeLiteral}: `string`, `number`, `bigint`, `boolean`, `null`.
144
+ *
145
+ * - string → `'...'` with `\` doubled and `'` doubled
146
+ * - number → decimal; rejects `NaN`/`±Infinity`
147
+ * - bigint → decimal digits
148
+ * - boolean → `true` / `false`
149
+ * - null → `null`
150
+ */
151
+ function escapeSqlLiteral(value) {
152
+ if (value === null)
153
+ return "null";
154
+ const t = typeof value;
155
+ if (t === "string") {
156
+ // Backslash first: this lexer treats `\` as an escape inside strings, so a
157
+ // lone backslash would otherwise swallow the quote doubling that follows.
158
+ const escaped = value
159
+ .replace(/\\/g, "\\\\")
160
+ .replace(/'/g, "''");
161
+ return `'${escaped}'`;
162
+ }
163
+ if (t === "number") {
164
+ const n = value;
165
+ if (!Number.isFinite(n)) {
166
+ throw new TypeError(`escapeSqlLiteral: non-finite number ${String(n)} cannot be represented as a SQL literal`);
167
+ }
168
+ return String(n);
169
+ }
170
+ if (t === "bigint") {
171
+ return value.toString(10);
172
+ }
173
+ if (t === "boolean") {
174
+ return value ? "true" : "false";
175
+ }
176
+ throw new TypeError(`escapeSqlLiteral: unsupported type ${describe(value)}`);
177
+ }
178
+ /**
179
+ * Validate a SQL identifier (table, column, alias). Returns it unchanged on
180
+ * success and throws `TypeError` otherwise.
181
+ *
182
+ * Identifiers are validated rather than quoted because PowDB's SQL lexer reads
183
+ * `"..."` as a string literal, so there is no identifier-quoting syntax to fall
184
+ * back on. Only `^[A-Za-z_][A-Za-z0-9_]*$` is accepted.
185
+ */
186
+ function escapeSqlIdent(name) {
187
+ if (typeof name !== "string") {
188
+ throw new TypeError(`escapeSqlIdent: expected string, got ${typeof name}`);
189
+ }
190
+ if (name.length === 0) {
191
+ throw new TypeError("escapeSqlIdent: identifier must not be empty");
192
+ }
193
+ if (!IDENT_RE.test(name)) {
194
+ throw new TypeError(`escapeSqlIdent: invalid identifier ${JSON.stringify(name)} (must match /^[A-Za-z_][A-Za-z0-9_]*$/)`);
195
+ }
196
+ return name;
197
+ }
198
+ /** Wrapper marking a string as a SQL identifier for the `sql` tagged template. */
199
+ class SqlIdent {
200
+ name;
201
+ constructor(name) {
202
+ this.name = name;
203
+ }
204
+ }
205
+ exports.SqlIdent = SqlIdent;
206
+ /** Factory for {@link SqlIdent}. */
207
+ function sqlIdent(name) {
208
+ return new SqlIdent(name);
209
+ }
210
+ /**
211
+ * Tagged template for SQL composition. Interpolated values are escaped as SQL
212
+ * literals; wrap one in `sqlIdent(...)` to interpolate an identifier.
213
+ *
214
+ * const q = sql`SELECT name FROM ${sqlIdent(table)} WHERE name = ${userName}`;
215
+ * await client.querySql(q);
216
+ *
217
+ * Escaping, not binding: see the section note above, and prefer PowQL's `$N`
218
+ * parameters when the input is untrusted.
219
+ */
220
+ function sql(strings, ...values) {
221
+ let out = strings[0] ?? "";
222
+ for (let i = 0; i < values.length; i++) {
223
+ out += renderSqlInterpolation(values[i]);
224
+ out += strings[i + 1] ?? "";
225
+ }
226
+ return out;
227
+ }
228
+ // ──────────────────────────────────────────────────────────
111
229
  // internals
112
230
  // ──────────────────────────────────────────────────────────
231
+ function renderSqlInterpolation(value) {
232
+ if (value instanceof SqlIdent) {
233
+ return escapeSqlIdent(value.name);
234
+ }
235
+ if (value instanceof PowqlIdent) {
236
+ throw new TypeError("sql: use sqlIdent(...) for SQL identifiers, not ident(...)");
237
+ }
238
+ return escapeSqlLiteral(value);
239
+ }
113
240
  function renderInterpolation(value) {
114
241
  if (value instanceof PowqlIdent) {
115
242
  return escapeIdent(value.name);
@@ -19,7 +19,7 @@ import { EventEmitter } from "node:events";
19
19
  import { type NativeJson, type SyncRepairAction, type WireRetainedUnit, type WireSyncStatus, type WireValue } from "./protocol.js";
20
20
  import { type TypedRow, type TypedSchema } from "./typed.js";
21
21
  /** Client library version. Compared to the server's reported version. */
22
- export declare const CLIENT_VERSION = "0.19.1";
22
+ export declare const CLIENT_VERSION = "0.20.0";
23
23
  /**
24
24
  * The maximum catalog format version this client can read. State this as the
25
25
  * `catalogVersion` in sync pull requests: the server accepts any replica whose
@@ -57,6 +57,11 @@ export type QueryResult = {
57
57
  export type { NativeJson } from "./protocol.js";
58
58
  /** A value returned by the lossless native wire surface. */
59
59
  export type NativeValue = null | number | bigint | boolean | string | Uint8Array | NativeJson;
60
+ /**
61
+ * One row of a native (lossless) result, keyed by column name. The default row
62
+ * type of {@link Client.queryObjects} when no generic is supplied.
63
+ */
64
+ export type NativeRow = Record<string, NativeValue>;
60
65
  export type NativeQueryResult = {
61
66
  kind: "rows";
62
67
  columns: string[];
@@ -382,13 +387,62 @@ export declare class Client extends EventEmitter<ClientEvents> {
382
387
  * using the caller-supplied schema. See `./typed.ts` for the coercion
383
388
  * rules and supported column types.
384
389
  *
385
- * Returns a `TypedRow[]` an array of objects keyed by column name.
390
+ * Returns `Row[]`, an array of objects keyed by column name (`TypedRow[]`
391
+ * when no generic is supplied). The generic is an unchecked assertion about
392
+ * the query's shape: the schema drives coercion, nothing validates the type.
393
+ * Positional `$N` parameters are accepted in the same position as
394
+ * {@link query}, so typed rows and injection-safe binding compose.
395
+ *
396
+ * This is the LEGACY stringly-typed path plus schema coercion. For lossless
397
+ * object rows with no schema at all, use {@link queryObjects}.
398
+ *
386
399
  * Throws `PowDBError(code="query_failed")` if the query is not a
387
400
  * rows-returning query.
388
401
  */
389
- queryTyped(query: string, schema: TypedSchema, opts?: {
402
+ queryTyped<Row extends TypedRow = TypedRow>(query: string, schema: TypedSchema, paramsOrOpts?: QueryParam[] | {
403
+ signal?: AbortSignal;
404
+ }, maybeOpts?: {
405
+ signal?: AbortSignal;
406
+ }): Promise<Row[]>;
407
+ /**
408
+ * Run PowQL on the LOSSLESS native wire surface and return object rows keyed
409
+ * by column name.
410
+ *
411
+ * This is {@link queryNative} plus the row-to-object step, so no schema is
412
+ * needed: the server states each cell's type, bytes stay bytes, JSON stays
413
+ * recursive data, and out-of-range integers stay `bigint`. Contrast with
414
+ * {@link queryTyped}, which coerces the legacy stringly-typed path using a
415
+ * caller-supplied schema.
416
+ *
417
+ * interface User { name: string; age: number }
418
+ * const users = await client.queryObjects<User>(
419
+ * "User filter .age > $1 { .name, .age }",
420
+ * [25],
421
+ * );
422
+ *
423
+ * The generic is an unchecked assertion about the query's shape, exactly like
424
+ * a SQL driver's row type: nothing validates it at runtime. Duplicate column
425
+ * names collapse (last one wins), so alias them in the projection.
426
+ *
427
+ * Throws `PowDBError(code="query_failed")` if the query is not rows-returning.
428
+ */
429
+ queryObjects<Row = NativeRow>(query: string, paramsOrOpts?: QueryParam[] | {
430
+ signal?: AbortSignal;
431
+ }, maybeOpts?: {
432
+ signal?: AbortSignal;
433
+ }): Promise<Row[]>;
434
+ /**
435
+ * SQL counterpart of {@link queryObjects}: runs SQL on the lossless native
436
+ * surface and returns object rows keyed by column name.
437
+ *
438
+ * The SQL frontend cannot bind parameters on any wire frame yet, so build SQL
439
+ * with {@link escapeSqlLiteral} / {@link sqlIdent} (or the `sql` tagged
440
+ * template) rather than string concatenation. For parameter binding, use the
441
+ * PowQL surface, which has real `$N` placeholders.
442
+ */
443
+ querySqlObjects<Row = NativeRow>(query: string, opts?: {
390
444
  signal?: AbortSignal;
391
- }): Promise<TypedRow[]>;
445
+ }): Promise<Row[]>;
392
446
  /**
393
447
  * Execute a multi-statement PowQL script on this connection, pipelined.
394
448
  *
@@ -472,8 +526,8 @@ export declare class Client extends EventEmitter<ClientEvents> {
472
526
  }
473
527
  export { encode, tryDecode } from "./protocol.js";
474
528
  export type { Message, SyncRepairAction, WireValue, WireParam, WireRetainedUnit, WireSyncStatus, } from "./protocol.js";
475
- export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, MAX_SYNC_UNITS, MAX_SYNC_PULL_UNITS, MAX_SYNC_PULL_BYTES, } from "./protocol.js";
476
- export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
529
+ export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_RESULT_CELLS, MAX_COLUMNS, MAX_PARAMS, MAX_SYNC_UNITS, MAX_SYNC_PULL_UNITS, MAX_SYNC_PULL_BYTES, } from "./protocol.js";
530
+ export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, escapeSqlLiteral, escapeSqlIdent, sqlIdent, sql, SqlIdent, } from "./escape.js";
477
531
  export { Pool } from "./pool.js";
478
532
  export type { PoolOptions } from "./pool.js";
479
533
  export { splitStatements } from "./script.js";
package/dist/cjs/index.js CHANGED
@@ -49,7 +49,7 @@ var __importStar = (this && this.__importStar) || (function () {
49
49
  };
50
50
  })();
51
51
  Object.defineProperty(exports, "__esModule", { value: true });
52
- exports.coerceRows = exports.coerceRow = exports.coerceValue = exports.WIRE_ERROR_CLASS = exports.isPowDBScriptError = exports.PowDBScriptError = exports.isPowDBError = exports.PowDBError = exports.errorCodeForWireClass = exports.splitStatements = exports.Pool = exports.PowqlIdent = exports.powql = exports.ident = exports.escapeIdent = exports.escapeLiteral = exports.MAX_SYNC_PULL_BYTES = exports.MAX_SYNC_PULL_UNITS = exports.MAX_SYNC_UNITS = exports.MAX_PARAMS = exports.MAX_COLUMNS = exports.MAX_ROWS = exports.MAX_PAYLOAD_SIZE = exports.tryDecode = exports.encode = exports.Client = exports.SUPPORTED_CATALOG_VERSION = exports.CLIENT_VERSION = void 0;
52
+ exports.coerceRows = exports.coerceRow = exports.coerceValue = exports.WIRE_ERROR_CLASS = exports.isPowDBScriptError = exports.PowDBScriptError = exports.isPowDBError = exports.PowDBError = exports.errorCodeForWireClass = exports.splitStatements = exports.Pool = exports.SqlIdent = exports.sql = exports.sqlIdent = exports.escapeSqlIdent = exports.escapeSqlLiteral = exports.PowqlIdent = exports.powql = exports.ident = exports.escapeIdent = exports.escapeLiteral = exports.MAX_SYNC_PULL_BYTES = exports.MAX_SYNC_PULL_UNITS = exports.MAX_SYNC_UNITS = exports.MAX_PARAMS = exports.MAX_COLUMNS = exports.MAX_RESULT_CELLS = exports.MAX_ROWS = exports.MAX_PAYLOAD_SIZE = exports.tryDecode = exports.encode = exports.Client = exports.SUPPORTED_CATALOG_VERSION = exports.CLIENT_VERSION = void 0;
53
53
  exports.assertServerCatalogVersionSupported = assertServerCatalogVersionSupported;
54
54
  const net = __importStar(require("node:net"));
55
55
  const tls = __importStar(require("node:tls"));
@@ -59,7 +59,7 @@ const errors_js_1 = require("./errors.js");
59
59
  const script_js_1 = require("./script.js");
60
60
  const typed_js_1 = require("./typed.js");
61
61
  /** Client library version. Compared to the server's reported version. */
62
- exports.CLIENT_VERSION = "0.19.1";
62
+ exports.CLIENT_VERSION = "0.20.0";
63
63
  /**
64
64
  * The maximum catalog format version this client can read. State this as the
65
65
  * `catalogVersion` in sync pull requests: the server accepts any replica whose
@@ -163,6 +163,24 @@ function nativeQueryResult(reply) {
163
163
  throw new errors_js_1.PowDBError(`unexpected reply to native query: ${reply.type}`, "protocol_error");
164
164
  }
165
165
  }
166
+ /**
167
+ * Turn a rows-shaped native result into object rows keyed by column name.
168
+ * Non-rows results are a caller mistake, not a server error, so they raise the
169
+ * same `query_failed` code `queryTyped` uses.
170
+ */
171
+ function nativeRowObjects(result, method) {
172
+ if (result.kind !== "rows") {
173
+ throw new errors_js_1.PowDBError(`${method}: expected rows result, got ${result.kind}`, "query_failed");
174
+ }
175
+ const { columns, rows } = result;
176
+ return rows.map((values) => {
177
+ const row = {};
178
+ for (let i = 0; i < columns.length; i++) {
179
+ row[columns[i]] = values[i] ?? null;
180
+ }
181
+ return row;
182
+ });
183
+ }
166
184
  function rawNativeQueryResult(reply) {
167
185
  switch (reply.type) {
168
186
  case "ResultRowsNative":
@@ -710,17 +728,74 @@ class Client extends node_events_1.EventEmitter {
710
728
  * using the caller-supplied schema. See `./typed.ts` for the coercion
711
729
  * rules and supported column types.
712
730
  *
713
- * Returns a `TypedRow[]` an array of objects keyed by column name.
731
+ * Returns `Row[]`, an array of objects keyed by column name (`TypedRow[]`
732
+ * when no generic is supplied). The generic is an unchecked assertion about
733
+ * the query's shape: the schema drives coercion, nothing validates the type.
734
+ * Positional `$N` parameters are accepted in the same position as
735
+ * {@link query}, so typed rows and injection-safe binding compose.
736
+ *
737
+ * This is the LEGACY stringly-typed path plus schema coercion. For lossless
738
+ * object rows with no schema at all, use {@link queryObjects}.
739
+ *
714
740
  * Throws `PowDBError(code="query_failed")` if the query is not a
715
741
  * rows-returning query.
716
742
  */
717
- async queryTyped(query, schema, opts) {
718
- const result = await this.query(query, opts);
743
+ async queryTyped(query, schema, paramsOrOpts, maybeOpts) {
744
+ // Same overload disambiguation as `query`, so typed rows and `$N`
745
+ // parameter binding are usable together:
746
+ // queryTyped(q, schema) no params, no opts
747
+ // queryTyped(q, schema, opts) legacy 3-arg opts form
748
+ // queryTyped(q, schema, params) positional $N parameters
749
+ // queryTyped(q, schema, params, opts) params plus opts
750
+ const result = Array.isArray(paramsOrOpts)
751
+ ? await this.query(query, paramsOrOpts, maybeOpts)
752
+ : await this.query(query, paramsOrOpts);
719
753
  if (result.kind !== "rows") {
720
754
  throw new errors_js_1.PowDBError(`queryTyped: expected rows result, got ${result.kind}`, "query_failed");
721
755
  }
722
756
  return (0, typed_js_1.coerceRows)(result.columns, result.rows, schema);
723
757
  }
758
+ /**
759
+ * Run PowQL on the LOSSLESS native wire surface and return object rows keyed
760
+ * by column name.
761
+ *
762
+ * This is {@link queryNative} plus the row-to-object step, so no schema is
763
+ * needed: the server states each cell's type, bytes stay bytes, JSON stays
764
+ * recursive data, and out-of-range integers stay `bigint`. Contrast with
765
+ * {@link queryTyped}, which coerces the legacy stringly-typed path using a
766
+ * caller-supplied schema.
767
+ *
768
+ * interface User { name: string; age: number }
769
+ * const users = await client.queryObjects<User>(
770
+ * "User filter .age > $1 { .name, .age }",
771
+ * [25],
772
+ * );
773
+ *
774
+ * The generic is an unchecked assertion about the query's shape, exactly like
775
+ * a SQL driver's row type: nothing validates it at runtime. Duplicate column
776
+ * names collapse (last one wins), so alias them in the projection.
777
+ *
778
+ * Throws `PowDBError(code="query_failed")` if the query is not rows-returning.
779
+ */
780
+ async queryObjects(query, paramsOrOpts, maybeOpts) {
781
+ const result = Array.isArray(paramsOrOpts)
782
+ ? await this.queryNative(query, paramsOrOpts, maybeOpts)
783
+ : await this.queryNative(query, paramsOrOpts);
784
+ return nativeRowObjects(result, "queryObjects");
785
+ }
786
+ /**
787
+ * SQL counterpart of {@link queryObjects}: runs SQL on the lossless native
788
+ * surface and returns object rows keyed by column name.
789
+ *
790
+ * The SQL frontend cannot bind parameters on any wire frame yet, so build SQL
791
+ * with {@link escapeSqlLiteral} / {@link sqlIdent} (or the `sql` tagged
792
+ * template) rather than string concatenation. For parameter binding, use the
793
+ * PowQL surface, which has real `$N` placeholders.
794
+ */
795
+ async querySqlObjects(query, opts) {
796
+ const result = await this.querySqlNative(query, opts);
797
+ return nativeRowObjects(result, "querySqlObjects");
798
+ }
724
799
  async execScript(script, opts) {
725
800
  const statements = (0, script_js_1.splitStatements)(script);
726
801
  const continueOnError = opts?.continueOnError === true;
@@ -1216,6 +1291,7 @@ Object.defineProperty(exports, "tryDecode", { enumerable: true, get: function ()
1216
1291
  var protocol_js_3 = require("./protocol.js");
1217
1292
  Object.defineProperty(exports, "MAX_PAYLOAD_SIZE", { enumerable: true, get: function () { return protocol_js_3.MAX_PAYLOAD_SIZE; } });
1218
1293
  Object.defineProperty(exports, "MAX_ROWS", { enumerable: true, get: function () { return protocol_js_3.MAX_ROWS; } });
1294
+ Object.defineProperty(exports, "MAX_RESULT_CELLS", { enumerable: true, get: function () { return protocol_js_3.MAX_RESULT_CELLS; } });
1219
1295
  Object.defineProperty(exports, "MAX_COLUMNS", { enumerable: true, get: function () { return protocol_js_3.MAX_COLUMNS; } });
1220
1296
  Object.defineProperty(exports, "MAX_PARAMS", { enumerable: true, get: function () { return protocol_js_3.MAX_PARAMS; } });
1221
1297
  Object.defineProperty(exports, "MAX_SYNC_UNITS", { enumerable: true, get: function () { return protocol_js_3.MAX_SYNC_UNITS; } });
@@ -1227,6 +1303,11 @@ Object.defineProperty(exports, "escapeIdent", { enumerable: true, get: function
1227
1303
  Object.defineProperty(exports, "ident", { enumerable: true, get: function () { return escape_js_1.ident; } });
1228
1304
  Object.defineProperty(exports, "powql", { enumerable: true, get: function () { return escape_js_1.powql; } });
1229
1305
  Object.defineProperty(exports, "PowqlIdent", { enumerable: true, get: function () { return escape_js_1.PowqlIdent; } });
1306
+ Object.defineProperty(exports, "escapeSqlLiteral", { enumerable: true, get: function () { return escape_js_1.escapeSqlLiteral; } });
1307
+ Object.defineProperty(exports, "escapeSqlIdent", { enumerable: true, get: function () { return escape_js_1.escapeSqlIdent; } });
1308
+ Object.defineProperty(exports, "sqlIdent", { enumerable: true, get: function () { return escape_js_1.sqlIdent; } });
1309
+ Object.defineProperty(exports, "sql", { enumerable: true, get: function () { return escape_js_1.sql; } });
1310
+ Object.defineProperty(exports, "SqlIdent", { enumerable: true, get: function () { return escape_js_1.SqlIdent; } });
1230
1311
  var pool_js_1 = require("./pool.js");
1231
1312
  Object.defineProperty(exports, "Pool", { enumerable: true, get: function () { return pool_js_1.Pool; } });
1232
1313
  var script_js_2 = require("./script.js");
@@ -43,6 +43,23 @@ export declare const MAX_PAYLOAD_SIZE: number;
43
43
  export declare const MAX_ROWS = 10000000;
44
44
  /** Maximum number of columns allowed in a result set. */
45
45
  export declare const MAX_COLUMNS = 4096;
46
+ /**
47
+ * Maximum number of decoded cells (rows x columns) in one result frame.
48
+ *
49
+ * MAX_ROWS and the per-frame byte-shape check are not enough on their own: a
50
+ * cell costs 4 bytes on the wire at minimum (an empty string's length prefix)
51
+ * but far more than that in JS heap, since every row is its own array. A
52
+ * hostile or MITM'd server could therefore declare 10M single-column rows,
53
+ * back them with a ~40 MB frame of empty cells, and make the client allocate
54
+ * roughly 1.9 GB (measured) before anything downstream could react. The
55
+ * default transport is plaintext, so this is reachable by anyone on the path.
56
+ *
57
+ * This cap bounds that allocation absolutely instead of trusting the declared
58
+ * count: a result frame can never expand past a few hundred MB whatever the
59
+ * server claims. Results genuinely larger than this must be paged with
60
+ * `limit`/`offset`.
61
+ */
62
+ export declare const MAX_RESULT_CELLS = 2000000;
46
63
  /** Maximum number of bound parameters in a QueryWithParams message. */
47
64
  export declare const MAX_PARAMS = 4096;
48
65
  /** Maximum retained units accepted in one sync pull result. */
@@ -8,7 +8,7 @@
8
8
  * This mirrors crates/server/src/protocol.rs — keep them in sync.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.MAX_SYNC_PULL_BYTES = exports.MAX_SYNC_PULL_UNITS = exports.MAX_SYNC_UNITS = exports.MAX_PARAMS = exports.MAX_COLUMNS = exports.MAX_ROWS = exports.MAX_PAYLOAD_SIZE = exports.MSG_RESULT_SCALAR_NATIVE = exports.MSG_RESULT_ROWS_NATIVE = exports.MSG_QUERY_SQL_NATIVE = exports.MSG_QUERY_PARAMS_NATIVE = exports.MSG_QUERY_NATIVE = exports.MSG_PONG = exports.MSG_PING = exports.MSG_DISCONNECT = exports.MSG_RESULT_MSG = exports.MSG_ERROR = exports.MSG_RESULT_OK = exports.MSG_RESULT_SCALAR = exports.MSG_RESULT_ROWS = exports.MSG_SYNC_ACK_RESULT = exports.MSG_SYNC_PULL_RESULT = exports.MSG_SYNC_STATUS_RESULT = exports.MSG_SYNC_ACK = exports.MSG_SYNC_PULL = exports.MSG_SYNC_STATUS = exports.MSG_QUERY_SQL = exports.MSG_QUERY_PARAMS = exports.MSG_QUERY = exports.MSG_CONNECT_OK = exports.MSG_CONNECT = void 0;
11
+ exports.MAX_SYNC_PULL_BYTES = exports.MAX_SYNC_PULL_UNITS = exports.MAX_SYNC_UNITS = exports.MAX_PARAMS = exports.MAX_RESULT_CELLS = exports.MAX_COLUMNS = exports.MAX_ROWS = exports.MAX_PAYLOAD_SIZE = exports.MSG_RESULT_SCALAR_NATIVE = exports.MSG_RESULT_ROWS_NATIVE = exports.MSG_QUERY_SQL_NATIVE = exports.MSG_QUERY_PARAMS_NATIVE = exports.MSG_QUERY_NATIVE = exports.MSG_PONG = exports.MSG_PING = exports.MSG_DISCONNECT = exports.MSG_RESULT_MSG = exports.MSG_ERROR = exports.MSG_RESULT_OK = exports.MSG_RESULT_SCALAR = exports.MSG_RESULT_ROWS = exports.MSG_SYNC_ACK_RESULT = exports.MSG_SYNC_PULL_RESULT = exports.MSG_SYNC_STATUS_RESULT = exports.MSG_SYNC_ACK = exports.MSG_SYNC_PULL = exports.MSG_SYNC_STATUS = exports.MSG_QUERY_SQL = exports.MSG_QUERY_PARAMS = exports.MSG_QUERY = exports.MSG_CONNECT_OK = exports.MSG_CONNECT = void 0;
12
12
  exports.encode = encode;
13
13
  exports.tryDecode = tryDecode;
14
14
  exports.MSG_CONNECT = 0x01;
@@ -49,6 +49,29 @@ exports.MAX_PAYLOAD_SIZE = 64 * 1024 * 1024;
49
49
  exports.MAX_ROWS = 10_000_000;
50
50
  /** Maximum number of columns allowed in a result set. */
51
51
  exports.MAX_COLUMNS = 4096;
52
+ /**
53
+ * Maximum number of decoded cells (rows x columns) in one result frame.
54
+ *
55
+ * MAX_ROWS and the per-frame byte-shape check are not enough on their own: a
56
+ * cell costs 4 bytes on the wire at minimum (an empty string's length prefix)
57
+ * but far more than that in JS heap, since every row is its own array. A
58
+ * hostile or MITM'd server could therefore declare 10M single-column rows,
59
+ * back them with a ~40 MB frame of empty cells, and make the client allocate
60
+ * roughly 1.9 GB (measured) before anything downstream could react. The
61
+ * default transport is plaintext, so this is reachable by anyone on the path.
62
+ *
63
+ * This cap bounds that allocation absolutely instead of trusting the declared
64
+ * count: a result frame can never expand past a few hundred MB whatever the
65
+ * server claims. Results genuinely larger than this must be paged with
66
+ * `limit`/`offset`.
67
+ */
68
+ exports.MAX_RESULT_CELLS = 2_000_000;
69
+ /** Reject a declared result shape whose cell count exceeds MAX_RESULT_CELLS. */
70
+ function checkResultCells(rowCount, colCount) {
71
+ if (rowCount * colCount > exports.MAX_RESULT_CELLS) {
72
+ throw new Error(`result too large: ${rowCount * colCount} cells (${rowCount} rows x ${colCount} columns, max ${exports.MAX_RESULT_CELLS})`);
73
+ }
74
+ }
52
75
  /** Maximum number of bound parameters in a QueryWithParams message. */
53
76
  exports.MAX_PARAMS = 4096;
54
77
  /** Maximum retained units accepted in one sync pull result. */
@@ -490,6 +513,7 @@ function decodePayload(msgType, payload) {
490
513
  if (remaining < minRowBytes) {
491
514
  throw new Error(`row data too short for declared shape: ${rowCount} rows x ${colCount} columns`);
492
515
  }
516
+ checkResultCells(rowCount, colCount);
493
517
  const rows = [];
494
518
  for (let r = 0; r < rowCount; r++) {
495
519
  const row = [];
@@ -522,6 +546,7 @@ function decodePayload(msgType, payload) {
522
546
  if (BigInt(payload.length - cursor.pos) < minimumBytes) {
523
547
  throw new Error(`native row data too short for declared shape: ${rowCount} rows x ${colCount} columns`);
524
548
  }
549
+ checkResultCells(rowCount, colCount);
525
550
  const rows = [];
526
551
  for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
527
552
  const row = [];
package/dist/escape.d.ts CHANGED
@@ -52,3 +52,65 @@ export declare function escapeLiteral(value: string | number | bigint | boolean
52
52
  * const q = powql`insert ${ident(table)} { name := ${userName} }`;
53
53
  */
54
54
  export declare function powql(strings: TemplateStringsArray, ...values: unknown[]): string;
55
+ /**
56
+ * SQL composition helpers for PowDB's SQL frontend.
57
+ *
58
+ * READ THIS FIRST. PowDB's SQL surface has NO parameter binding on any wire
59
+ * frame: `querySql`/`querySqlNative` take a statement and nothing else, and
60
+ * there is no `QuerySqlParams` message. The PowQL surface does have real `$N`
61
+ * binding (`client.query(q, [values])`), where the server substitutes a literal
62
+ * TOKEN and injection-shaped input is inert.
63
+ *
64
+ * So: **prefer PowQL with `$N` parameters for anything built from untrusted
65
+ * input.** These helpers exist because the SQL path would otherwise leave
66
+ * string concatenation as the only option, which is strictly worse. They escape
67
+ * correctly for PowDB's own SQL lexer (`crates/query/src/sql.rs`), but escaping
68
+ * is a weaker guarantee than binding: it depends on the value landing in a
69
+ * string/number position, and it cannot make an identifier or a keyword safe.
70
+ *
71
+ * PowDB SQL string rules (verified against `lex_sql` in `crates/query/src/sql.rs`):
72
+ * - Strings are delimited by `'`.
73
+ * - `''` inside a single-quoted string is a literal `'`.
74
+ * - A backslash also escapes the next character (`\n`, `\t`, and `\X` → `X`),
75
+ * which standard SQL does NOT do, so a literal backslash must be doubled.
76
+ * - `"` also opens a string in this lexer, so double quotes cannot be used to
77
+ * quote an identifier. Identifiers are therefore validated, never quoted.
78
+ */
79
+ /**
80
+ * Render a JS value as a PowDB SQL literal. Same accepted types as
81
+ * {@link escapeLiteral}: `string`, `number`, `bigint`, `boolean`, `null`.
82
+ *
83
+ * - string → `'...'` with `\` doubled and `'` doubled
84
+ * - number → decimal; rejects `NaN`/`±Infinity`
85
+ * - bigint → decimal digits
86
+ * - boolean → `true` / `false`
87
+ * - null → `null`
88
+ */
89
+ export declare function escapeSqlLiteral(value: string | number | bigint | boolean | null): string;
90
+ /**
91
+ * Validate a SQL identifier (table, column, alias). Returns it unchanged on
92
+ * success and throws `TypeError` otherwise.
93
+ *
94
+ * Identifiers are validated rather than quoted because PowDB's SQL lexer reads
95
+ * `"..."` as a string literal, so there is no identifier-quoting syntax to fall
96
+ * back on. Only `^[A-Za-z_][A-Za-z0-9_]*$` is accepted.
97
+ */
98
+ export declare function escapeSqlIdent(name: string): string;
99
+ /** Wrapper marking a string as a SQL identifier for the `sql` tagged template. */
100
+ export declare class SqlIdent {
101
+ readonly name: string;
102
+ constructor(name: string);
103
+ }
104
+ /** Factory for {@link SqlIdent}. */
105
+ export declare function sqlIdent(name: string): SqlIdent;
106
+ /**
107
+ * Tagged template for SQL composition. Interpolated values are escaped as SQL
108
+ * literals; wrap one in `sqlIdent(...)` to interpolate an identifier.
109
+ *
110
+ * const q = sql`SELECT name FROM ${sqlIdent(table)} WHERE name = ${userName}`;
111
+ * await client.querySql(q);
112
+ *
113
+ * Escaping, not binding: see the section note above, and prefer PowQL's `$N`
114
+ * parameters when the input is untrusted.
115
+ */
116
+ export declare function sql(strings: TemplateStringsArray, ...values: unknown[]): string;
package/dist/escape.js CHANGED
@@ -100,8 +100,130 @@ export function powql(strings, ...values) {
100
100
  return out;
101
101
  }
102
102
  // ──────────────────────────────────────────────────────────
103
+ // SQL frontend
104
+ // ──────────────────────────────────────────────────────────
105
+ /**
106
+ * SQL composition helpers for PowDB's SQL frontend.
107
+ *
108
+ * READ THIS FIRST. PowDB's SQL surface has NO parameter binding on any wire
109
+ * frame: `querySql`/`querySqlNative` take a statement and nothing else, and
110
+ * there is no `QuerySqlParams` message. The PowQL surface does have real `$N`
111
+ * binding (`client.query(q, [values])`), where the server substitutes a literal
112
+ * TOKEN and injection-shaped input is inert.
113
+ *
114
+ * So: **prefer PowQL with `$N` parameters for anything built from untrusted
115
+ * input.** These helpers exist because the SQL path would otherwise leave
116
+ * string concatenation as the only option, which is strictly worse. They escape
117
+ * correctly for PowDB's own SQL lexer (`crates/query/src/sql.rs`), but escaping
118
+ * is a weaker guarantee than binding: it depends on the value landing in a
119
+ * string/number position, and it cannot make an identifier or a keyword safe.
120
+ *
121
+ * PowDB SQL string rules (verified against `lex_sql` in `crates/query/src/sql.rs`):
122
+ * - Strings are delimited by `'`.
123
+ * - `''` inside a single-quoted string is a literal `'`.
124
+ * - A backslash also escapes the next character (`\n`, `\t`, and `\X` → `X`),
125
+ * which standard SQL does NOT do, so a literal backslash must be doubled.
126
+ * - `"` also opens a string in this lexer, so double quotes cannot be used to
127
+ * quote an identifier. Identifiers are therefore validated, never quoted.
128
+ */
129
+ /**
130
+ * Render a JS value as a PowDB SQL literal. Same accepted types as
131
+ * {@link escapeLiteral}: `string`, `number`, `bigint`, `boolean`, `null`.
132
+ *
133
+ * - string → `'...'` with `\` doubled and `'` doubled
134
+ * - number → decimal; rejects `NaN`/`±Infinity`
135
+ * - bigint → decimal digits
136
+ * - boolean → `true` / `false`
137
+ * - null → `null`
138
+ */
139
+ export function escapeSqlLiteral(value) {
140
+ if (value === null)
141
+ return "null";
142
+ const t = typeof value;
143
+ if (t === "string") {
144
+ // Backslash first: this lexer treats `\` as an escape inside strings, so a
145
+ // lone backslash would otherwise swallow the quote doubling that follows.
146
+ const escaped = value
147
+ .replace(/\\/g, "\\\\")
148
+ .replace(/'/g, "''");
149
+ return `'${escaped}'`;
150
+ }
151
+ if (t === "number") {
152
+ const n = value;
153
+ if (!Number.isFinite(n)) {
154
+ throw new TypeError(`escapeSqlLiteral: non-finite number ${String(n)} cannot be represented as a SQL literal`);
155
+ }
156
+ return String(n);
157
+ }
158
+ if (t === "bigint") {
159
+ return value.toString(10);
160
+ }
161
+ if (t === "boolean") {
162
+ return value ? "true" : "false";
163
+ }
164
+ throw new TypeError(`escapeSqlLiteral: unsupported type ${describe(value)}`);
165
+ }
166
+ /**
167
+ * Validate a SQL identifier (table, column, alias). Returns it unchanged on
168
+ * success and throws `TypeError` otherwise.
169
+ *
170
+ * Identifiers are validated rather than quoted because PowDB's SQL lexer reads
171
+ * `"..."` as a string literal, so there is no identifier-quoting syntax to fall
172
+ * back on. Only `^[A-Za-z_][A-Za-z0-9_]*$` is accepted.
173
+ */
174
+ export function escapeSqlIdent(name) {
175
+ if (typeof name !== "string") {
176
+ throw new TypeError(`escapeSqlIdent: expected string, got ${typeof name}`);
177
+ }
178
+ if (name.length === 0) {
179
+ throw new TypeError("escapeSqlIdent: identifier must not be empty");
180
+ }
181
+ if (!IDENT_RE.test(name)) {
182
+ throw new TypeError(`escapeSqlIdent: invalid identifier ${JSON.stringify(name)} (must match /^[A-Za-z_][A-Za-z0-9_]*$/)`);
183
+ }
184
+ return name;
185
+ }
186
+ /** Wrapper marking a string as a SQL identifier for the `sql` tagged template. */
187
+ export class SqlIdent {
188
+ name;
189
+ constructor(name) {
190
+ this.name = name;
191
+ }
192
+ }
193
+ /** Factory for {@link SqlIdent}. */
194
+ export function sqlIdent(name) {
195
+ return new SqlIdent(name);
196
+ }
197
+ /**
198
+ * Tagged template for SQL composition. Interpolated values are escaped as SQL
199
+ * literals; wrap one in `sqlIdent(...)` to interpolate an identifier.
200
+ *
201
+ * const q = sql`SELECT name FROM ${sqlIdent(table)} WHERE name = ${userName}`;
202
+ * await client.querySql(q);
203
+ *
204
+ * Escaping, not binding: see the section note above, and prefer PowQL's `$N`
205
+ * parameters when the input is untrusted.
206
+ */
207
+ export function sql(strings, ...values) {
208
+ let out = strings[0] ?? "";
209
+ for (let i = 0; i < values.length; i++) {
210
+ out += renderSqlInterpolation(values[i]);
211
+ out += strings[i + 1] ?? "";
212
+ }
213
+ return out;
214
+ }
215
+ // ──────────────────────────────────────────────────────────
103
216
  // internals
104
217
  // ──────────────────────────────────────────────────────────
218
+ function renderSqlInterpolation(value) {
219
+ if (value instanceof SqlIdent) {
220
+ return escapeSqlIdent(value.name);
221
+ }
222
+ if (value instanceof PowqlIdent) {
223
+ throw new TypeError("sql: use sqlIdent(...) for SQL identifiers, not ident(...)");
224
+ }
225
+ return escapeSqlLiteral(value);
226
+ }
105
227
  function renderInterpolation(value) {
106
228
  if (value instanceof PowqlIdent) {
107
229
  return escapeIdent(value.name);
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ import { EventEmitter } from "node:events";
19
19
  import { type NativeJson, type SyncRepairAction, type WireRetainedUnit, type WireSyncStatus, type WireValue } from "./protocol.js";
20
20
  import { type TypedRow, type TypedSchema } from "./typed.js";
21
21
  /** Client library version. Compared to the server's reported version. */
22
- export declare const CLIENT_VERSION = "0.19.1";
22
+ export declare const CLIENT_VERSION = "0.20.0";
23
23
  /**
24
24
  * The maximum catalog format version this client can read. State this as the
25
25
  * `catalogVersion` in sync pull requests: the server accepts any replica whose
@@ -57,6 +57,11 @@ export type QueryResult = {
57
57
  export type { NativeJson } from "./protocol.js";
58
58
  /** A value returned by the lossless native wire surface. */
59
59
  export type NativeValue = null | number | bigint | boolean | string | Uint8Array | NativeJson;
60
+ /**
61
+ * One row of a native (lossless) result, keyed by column name. The default row
62
+ * type of {@link Client.queryObjects} when no generic is supplied.
63
+ */
64
+ export type NativeRow = Record<string, NativeValue>;
60
65
  export type NativeQueryResult = {
61
66
  kind: "rows";
62
67
  columns: string[];
@@ -382,13 +387,62 @@ export declare class Client extends EventEmitter<ClientEvents> {
382
387
  * using the caller-supplied schema. See `./typed.ts` for the coercion
383
388
  * rules and supported column types.
384
389
  *
385
- * Returns a `TypedRow[]` an array of objects keyed by column name.
390
+ * Returns `Row[]`, an array of objects keyed by column name (`TypedRow[]`
391
+ * when no generic is supplied). The generic is an unchecked assertion about
392
+ * the query's shape: the schema drives coercion, nothing validates the type.
393
+ * Positional `$N` parameters are accepted in the same position as
394
+ * {@link query}, so typed rows and injection-safe binding compose.
395
+ *
396
+ * This is the LEGACY stringly-typed path plus schema coercion. For lossless
397
+ * object rows with no schema at all, use {@link queryObjects}.
398
+ *
386
399
  * Throws `PowDBError(code="query_failed")` if the query is not a
387
400
  * rows-returning query.
388
401
  */
389
- queryTyped(query: string, schema: TypedSchema, opts?: {
402
+ queryTyped<Row extends TypedRow = TypedRow>(query: string, schema: TypedSchema, paramsOrOpts?: QueryParam[] | {
403
+ signal?: AbortSignal;
404
+ }, maybeOpts?: {
405
+ signal?: AbortSignal;
406
+ }): Promise<Row[]>;
407
+ /**
408
+ * Run PowQL on the LOSSLESS native wire surface and return object rows keyed
409
+ * by column name.
410
+ *
411
+ * This is {@link queryNative} plus the row-to-object step, so no schema is
412
+ * needed: the server states each cell's type, bytes stay bytes, JSON stays
413
+ * recursive data, and out-of-range integers stay `bigint`. Contrast with
414
+ * {@link queryTyped}, which coerces the legacy stringly-typed path using a
415
+ * caller-supplied schema.
416
+ *
417
+ * interface User { name: string; age: number }
418
+ * const users = await client.queryObjects<User>(
419
+ * "User filter .age > $1 { .name, .age }",
420
+ * [25],
421
+ * );
422
+ *
423
+ * The generic is an unchecked assertion about the query's shape, exactly like
424
+ * a SQL driver's row type: nothing validates it at runtime. Duplicate column
425
+ * names collapse (last one wins), so alias them in the projection.
426
+ *
427
+ * Throws `PowDBError(code="query_failed")` if the query is not rows-returning.
428
+ */
429
+ queryObjects<Row = NativeRow>(query: string, paramsOrOpts?: QueryParam[] | {
430
+ signal?: AbortSignal;
431
+ }, maybeOpts?: {
432
+ signal?: AbortSignal;
433
+ }): Promise<Row[]>;
434
+ /**
435
+ * SQL counterpart of {@link queryObjects}: runs SQL on the lossless native
436
+ * surface and returns object rows keyed by column name.
437
+ *
438
+ * The SQL frontend cannot bind parameters on any wire frame yet, so build SQL
439
+ * with {@link escapeSqlLiteral} / {@link sqlIdent} (or the `sql` tagged
440
+ * template) rather than string concatenation. For parameter binding, use the
441
+ * PowQL surface, which has real `$N` placeholders.
442
+ */
443
+ querySqlObjects<Row = NativeRow>(query: string, opts?: {
390
444
  signal?: AbortSignal;
391
- }): Promise<TypedRow[]>;
445
+ }): Promise<Row[]>;
392
446
  /**
393
447
  * Execute a multi-statement PowQL script on this connection, pipelined.
394
448
  *
@@ -472,8 +526,8 @@ export declare class Client extends EventEmitter<ClientEvents> {
472
526
  }
473
527
  export { encode, tryDecode } from "./protocol.js";
474
528
  export type { Message, SyncRepairAction, WireValue, WireParam, WireRetainedUnit, WireSyncStatus, } from "./protocol.js";
475
- export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, MAX_SYNC_UNITS, MAX_SYNC_PULL_UNITS, MAX_SYNC_PULL_BYTES, } from "./protocol.js";
476
- export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
529
+ export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_RESULT_CELLS, MAX_COLUMNS, MAX_PARAMS, MAX_SYNC_UNITS, MAX_SYNC_PULL_UNITS, MAX_SYNC_PULL_BYTES, } from "./protocol.js";
530
+ export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, escapeSqlLiteral, escapeSqlIdent, sqlIdent, sql, SqlIdent, } from "./escape.js";
477
531
  export { Pool } from "./pool.js";
478
532
  export type { PoolOptions } from "./pool.js";
479
533
  export { splitStatements } from "./script.js";
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@ import { errorCodeForWireClass, isPowDBError, PowDBError, PowDBScriptError, } fr
22
22
  import { splitStatements } from "./script.js";
23
23
  import { coerceRows, } from "./typed.js";
24
24
  /** Client library version. Compared to the server's reported version. */
25
- export const CLIENT_VERSION = "0.19.1";
25
+ export const CLIENT_VERSION = "0.20.0";
26
26
  /**
27
27
  * The maximum catalog format version this client can read. State this as the
28
28
  * `catalogVersion` in sync pull requests: the server accepts any replica whose
@@ -126,6 +126,24 @@ function nativeQueryResult(reply) {
126
126
  throw new PowDBError(`unexpected reply to native query: ${reply.type}`, "protocol_error");
127
127
  }
128
128
  }
129
+ /**
130
+ * Turn a rows-shaped native result into object rows keyed by column name.
131
+ * Non-rows results are a caller mistake, not a server error, so they raise the
132
+ * same `query_failed` code `queryTyped` uses.
133
+ */
134
+ function nativeRowObjects(result, method) {
135
+ if (result.kind !== "rows") {
136
+ throw new PowDBError(`${method}: expected rows result, got ${result.kind}`, "query_failed");
137
+ }
138
+ const { columns, rows } = result;
139
+ return rows.map((values) => {
140
+ const row = {};
141
+ for (let i = 0; i < columns.length; i++) {
142
+ row[columns[i]] = values[i] ?? null;
143
+ }
144
+ return row;
145
+ });
146
+ }
129
147
  function rawNativeQueryResult(reply) {
130
148
  switch (reply.type) {
131
149
  case "ResultRowsNative":
@@ -673,17 +691,74 @@ export class Client extends EventEmitter {
673
691
  * using the caller-supplied schema. See `./typed.ts` for the coercion
674
692
  * rules and supported column types.
675
693
  *
676
- * Returns a `TypedRow[]` an array of objects keyed by column name.
694
+ * Returns `Row[]`, an array of objects keyed by column name (`TypedRow[]`
695
+ * when no generic is supplied). The generic is an unchecked assertion about
696
+ * the query's shape: the schema drives coercion, nothing validates the type.
697
+ * Positional `$N` parameters are accepted in the same position as
698
+ * {@link query}, so typed rows and injection-safe binding compose.
699
+ *
700
+ * This is the LEGACY stringly-typed path plus schema coercion. For lossless
701
+ * object rows with no schema at all, use {@link queryObjects}.
702
+ *
677
703
  * Throws `PowDBError(code="query_failed")` if the query is not a
678
704
  * rows-returning query.
679
705
  */
680
- async queryTyped(query, schema, opts) {
681
- const result = await this.query(query, opts);
706
+ async queryTyped(query, schema, paramsOrOpts, maybeOpts) {
707
+ // Same overload disambiguation as `query`, so typed rows and `$N`
708
+ // parameter binding are usable together:
709
+ // queryTyped(q, schema) no params, no opts
710
+ // queryTyped(q, schema, opts) legacy 3-arg opts form
711
+ // queryTyped(q, schema, params) positional $N parameters
712
+ // queryTyped(q, schema, params, opts) params plus opts
713
+ const result = Array.isArray(paramsOrOpts)
714
+ ? await this.query(query, paramsOrOpts, maybeOpts)
715
+ : await this.query(query, paramsOrOpts);
682
716
  if (result.kind !== "rows") {
683
717
  throw new PowDBError(`queryTyped: expected rows result, got ${result.kind}`, "query_failed");
684
718
  }
685
719
  return coerceRows(result.columns, result.rows, schema);
686
720
  }
721
+ /**
722
+ * Run PowQL on the LOSSLESS native wire surface and return object rows keyed
723
+ * by column name.
724
+ *
725
+ * This is {@link queryNative} plus the row-to-object step, so no schema is
726
+ * needed: the server states each cell's type, bytes stay bytes, JSON stays
727
+ * recursive data, and out-of-range integers stay `bigint`. Contrast with
728
+ * {@link queryTyped}, which coerces the legacy stringly-typed path using a
729
+ * caller-supplied schema.
730
+ *
731
+ * interface User { name: string; age: number }
732
+ * const users = await client.queryObjects<User>(
733
+ * "User filter .age > $1 { .name, .age }",
734
+ * [25],
735
+ * );
736
+ *
737
+ * The generic is an unchecked assertion about the query's shape, exactly like
738
+ * a SQL driver's row type: nothing validates it at runtime. Duplicate column
739
+ * names collapse (last one wins), so alias them in the projection.
740
+ *
741
+ * Throws `PowDBError(code="query_failed")` if the query is not rows-returning.
742
+ */
743
+ async queryObjects(query, paramsOrOpts, maybeOpts) {
744
+ const result = Array.isArray(paramsOrOpts)
745
+ ? await this.queryNative(query, paramsOrOpts, maybeOpts)
746
+ : await this.queryNative(query, paramsOrOpts);
747
+ return nativeRowObjects(result, "queryObjects");
748
+ }
749
+ /**
750
+ * SQL counterpart of {@link queryObjects}: runs SQL on the lossless native
751
+ * surface and returns object rows keyed by column name.
752
+ *
753
+ * The SQL frontend cannot bind parameters on any wire frame yet, so build SQL
754
+ * with {@link escapeSqlLiteral} / {@link sqlIdent} (or the `sql` tagged
755
+ * template) rather than string concatenation. For parameter binding, use the
756
+ * PowQL surface, which has real `$N` placeholders.
757
+ */
758
+ async querySqlObjects(query, opts) {
759
+ const result = await this.querySqlNative(query, opts);
760
+ return nativeRowObjects(result, "querySqlObjects");
761
+ }
687
762
  async execScript(script, opts) {
688
763
  const statements = splitStatements(script);
689
764
  const continueOnError = opts?.continueOnError === true;
@@ -1173,8 +1248,8 @@ function openSocket(target, timeoutMs, tlsOpt) {
1173
1248
  });
1174
1249
  }
1175
1250
  export { encode, tryDecode } from "./protocol.js";
1176
- export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, MAX_SYNC_UNITS, MAX_SYNC_PULL_UNITS, MAX_SYNC_PULL_BYTES, } from "./protocol.js";
1177
- export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
1251
+ export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_RESULT_CELLS, MAX_COLUMNS, MAX_PARAMS, MAX_SYNC_UNITS, MAX_SYNC_PULL_UNITS, MAX_SYNC_PULL_BYTES, } from "./protocol.js";
1252
+ export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, escapeSqlLiteral, escapeSqlIdent, sqlIdent, sql, SqlIdent, } from "./escape.js";
1178
1253
  export { Pool } from "./pool.js";
1179
1254
  export { splitStatements } from "./script.js";
1180
1255
  export { errorCodeForWireClass, PowDBError, isPowDBError, PowDBScriptError, isPowDBScriptError, WIRE_ERROR_CLASS, } from "./errors.js";
@@ -43,6 +43,23 @@ export declare const MAX_PAYLOAD_SIZE: number;
43
43
  export declare const MAX_ROWS = 10000000;
44
44
  /** Maximum number of columns allowed in a result set. */
45
45
  export declare const MAX_COLUMNS = 4096;
46
+ /**
47
+ * Maximum number of decoded cells (rows x columns) in one result frame.
48
+ *
49
+ * MAX_ROWS and the per-frame byte-shape check are not enough on their own: a
50
+ * cell costs 4 bytes on the wire at minimum (an empty string's length prefix)
51
+ * but far more than that in JS heap, since every row is its own array. A
52
+ * hostile or MITM'd server could therefore declare 10M single-column rows,
53
+ * back them with a ~40 MB frame of empty cells, and make the client allocate
54
+ * roughly 1.9 GB (measured) before anything downstream could react. The
55
+ * default transport is plaintext, so this is reachable by anyone on the path.
56
+ *
57
+ * This cap bounds that allocation absolutely instead of trusting the declared
58
+ * count: a result frame can never expand past a few hundred MB whatever the
59
+ * server claims. Results genuinely larger than this must be paged with
60
+ * `limit`/`offset`.
61
+ */
62
+ export declare const MAX_RESULT_CELLS = 2000000;
46
63
  /** Maximum number of bound parameters in a QueryWithParams message. */
47
64
  export declare const MAX_PARAMS = 4096;
48
65
  /** Maximum retained units accepted in one sync pull result. */
package/dist/protocol.js CHANGED
@@ -44,6 +44,29 @@ export const MAX_PAYLOAD_SIZE = 64 * 1024 * 1024;
44
44
  export const MAX_ROWS = 10_000_000;
45
45
  /** Maximum number of columns allowed in a result set. */
46
46
  export const MAX_COLUMNS = 4096;
47
+ /**
48
+ * Maximum number of decoded cells (rows x columns) in one result frame.
49
+ *
50
+ * MAX_ROWS and the per-frame byte-shape check are not enough on their own: a
51
+ * cell costs 4 bytes on the wire at minimum (an empty string's length prefix)
52
+ * but far more than that in JS heap, since every row is its own array. A
53
+ * hostile or MITM'd server could therefore declare 10M single-column rows,
54
+ * back them with a ~40 MB frame of empty cells, and make the client allocate
55
+ * roughly 1.9 GB (measured) before anything downstream could react. The
56
+ * default transport is plaintext, so this is reachable by anyone on the path.
57
+ *
58
+ * This cap bounds that allocation absolutely instead of trusting the declared
59
+ * count: a result frame can never expand past a few hundred MB whatever the
60
+ * server claims. Results genuinely larger than this must be paged with
61
+ * `limit`/`offset`.
62
+ */
63
+ export const MAX_RESULT_CELLS = 2_000_000;
64
+ /** Reject a declared result shape whose cell count exceeds MAX_RESULT_CELLS. */
65
+ function checkResultCells(rowCount, colCount) {
66
+ if (rowCount * colCount > MAX_RESULT_CELLS) {
67
+ throw new Error(`result too large: ${rowCount * colCount} cells (${rowCount} rows x ${colCount} columns, max ${MAX_RESULT_CELLS})`);
68
+ }
69
+ }
47
70
  /** Maximum number of bound parameters in a QueryWithParams message. */
48
71
  export const MAX_PARAMS = 4096;
49
72
  /** Maximum retained units accepted in one sync pull result. */
@@ -485,6 +508,7 @@ function decodePayload(msgType, payload) {
485
508
  if (remaining < minRowBytes) {
486
509
  throw new Error(`row data too short for declared shape: ${rowCount} rows x ${colCount} columns`);
487
510
  }
511
+ checkResultCells(rowCount, colCount);
488
512
  const rows = [];
489
513
  for (let r = 0; r < rowCount; r++) {
490
514
  const row = [];
@@ -517,6 +541,7 @@ function decodePayload(msgType, payload) {
517
541
  if (BigInt(payload.length - cursor.pos) < minimumBytes) {
518
542
  throw new Error(`native row data too short for declared shape: ${rowCount} rows x ${colCount} columns`);
519
543
  }
544
+ checkResultCells(rowCount, colCount);
520
545
  const rows = [];
521
546
  for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
522
547
  const row = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/powdb-client",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "TypeScript client for PowDB (PowQL wire protocol)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.29.3",