@zvndev/powdb-client 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,6 +81,101 @@ await client.query("insert User { name := $1, age := $2 }", ["Dana", null]);
81
81
 
82
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
83
 
84
+ ## Multi-statement scripts (`execScript`)
85
+
86
+ `execScript` runs a whole `;`-separated PowQL script down one connection,
87
+ **pipelined**: every statement is written back-to-back without waiting for
88
+ the previous reply, so an N-statement script costs one round trip instead of
89
+ N. Splitting is statement-aware with the exact semantics of the CLI's script
90
+ path — `;` inside `"..."` string literals or `#` comments never splits, and
91
+ empty statements are dropped. (The splitter is exported as
92
+ `splitStatements(script)` if you need it standalone.)
93
+
94
+ ```typescript
95
+ const results = await client.execScript(`
96
+ type User { required name: str, age: int };
97
+ insert User { name := "Alice", age := 30 }; # seed; more to come
98
+ insert User { name := "Bob;Bobby", age := 25 };
99
+ count(User)
100
+ `);
101
+ // results: QueryResult[] in statement order — results[3].value === "2"
102
+ ```
103
+
104
+ By default execution is **fail-fast**: the first failed statement rejects the
105
+ promise with a `PowDBScriptError` carrying `statementIndex`, the failing
106
+ `statement` text, and the successful `results` so far. Because dispatch is
107
+ pipelined, statements already on the wire when the error reply arrives
108
+ (typically the whole script) still execute server-side — use
109
+ `transactional: true` if you need all-or-nothing behavior. Do **not** embed
110
+ `begin`/`commit` in the script yourself: the trailing `commit` is already on
111
+ the wire when an error reply arrives, so it commits the partial work.
112
+
113
+ With `transactional: true`, `execScript` opens the transaction itself, waits
114
+ for every statement's reply, and only then sends `commit` — or `rollback` if
115
+ any statement failed — so no statement's effect survives a failure.
116
+ Transactional scripts may not contain their own `begin`/`commit`/`rollback`,
117
+ and the option is mutually exclusive with `continueOnError`. On failure the
118
+ `PowDBScriptError`'s `results` are the replies received before rollback —
119
+ their effects are not persisted.
120
+
121
+ ```typescript
122
+ // Either every statement commits, or none do.
123
+ await client.execScript(seedScript, { transactional: true });
124
+ ```
125
+
126
+ ```typescript
127
+ import { isPowDBScriptError } from "@zvndev/powdb-client";
128
+
129
+ try {
130
+ await client.execScript(script);
131
+ } catch (err) {
132
+ if (isPowDBScriptError(err)) {
133
+ console.error(`statement ${err.statementIndex} failed: ${err.statement}`);
134
+ console.error(`${err.results.length} statements succeeded before it`);
135
+ }
136
+ }
137
+ ```
138
+
139
+ With `continueOnError: true`, every statement runs regardless of failures and
140
+ you get a dense per-statement outcome array instead of a throw:
141
+
142
+ ```typescript
143
+ const outcomes = await client.execScript(script, { continueOnError: true });
144
+ for (const o of outcomes) {
145
+ if (o.ok) console.log(o.result.kind);
146
+ else console.warn(`failed: ${o.statement} — ${o.error.message}`);
147
+ }
148
+ ```
149
+
150
+ `Pool.execScript(script, opts?)` does the same through a pool, checking out a
151
+ single connection for the whole script (so statement order — and
152
+ `transactional: true` — holds).
153
+
154
+ ## Eager (pipelined) connect
155
+
156
+ `Client.connect` normally performs a blocking Connect→ConnectOk handshake, so
157
+ every fresh connection pays a protocol round trip before its first query.
158
+ With `eager: true` the promise resolves as soon as the socket is open and the
159
+ Connect frame is written; queries issued immediately are queued right behind
160
+ the Connect frame on the wire, and the server answers everything in order.
161
+ For connection-per-request callers this removes the handshake round trip
162
+ entirely:
163
+
164
+ ```typescript
165
+ const client = await Client.connect({ host, port, eager: true });
166
+ // No extra round trip — this query rides directly behind the Connect frame.
167
+ const result = await client.query("count(User)");
168
+ ```
169
+
170
+ If the handshake then fails (bad password, protocol mismatch), every queued
171
+ query rejects with the handshake error (`code: "auth_failed"`, etc.) and the
172
+ client closes. Two things to know:
173
+
174
+ - `client.serverVersion` is `""` until the ConnectOk arrives.
175
+ - `await client.ready()` waits for the handshake outcome explicitly — it
176
+ resolves on ConnectOk and rejects with the handshake error. (For non-eager
177
+ clients it has already settled by the time `connect` returns.)
178
+
84
179
  ## Experimental embedded sync protocol helpers
85
180
 
86
181
  The client exposes experimental low-level helpers for the private authenticated
@@ -255,6 +350,11 @@ The full taxonomy: `connect_failed`, `auth_failed`, `query_failed`,
255
350
  `aborted`, `size_exceeded`, `protocol_error`, `closed`, `timeout`,
256
351
  `type_coercion_failed`.
257
352
 
353
+ `execScript` failures throw `PowDBScriptError`, a `PowDBError` subclass whose
354
+ `code` mirrors the failing statement's error and which adds
355
+ `statementIndex`, `statement`, and `results` (see Multi-statement scripts
356
+ above). Narrow with `isPowDBScriptError(err)`.
357
+
258
358
  ## Polling watch
259
359
 
260
360
  For simple change-polling (the server doesn't ship a subscription
@@ -344,6 +444,7 @@ Returns a `Promise<Client>`. Options:
344
444
  | `user` | `string` | *(omitted)* | User name for multi-user servers (see Authentication above). Omit for shared-password / no-auth servers |
345
445
  | `connectTimeoutMs` | `number` | `5000` | Connection timeout in milliseconds |
346
446
  | `tls` | `boolean \| tls.ConnectionOptions` | `false` | Enable TLS; `true` uses system defaults, or pass a `tls.connect` options object |
447
+ | `eager` | `boolean` | `false` | Resolve as soon as the Connect frame is written instead of waiting for ConnectOk; queries pipeline behind the handshake (see Eager connect above) |
347
448
 
348
449
  > **Multi-user servers:** requires client ≥0.4.0 (`user` option) and server
349
450
  > ≥0.4.6 (enforced roles). See the version matrix under Authentication.
@@ -380,6 +481,26 @@ const result = await client.querySql("SELECT name, age FROM User WHERE age > 27"
380
481
  Like `query()`, but coerces each row's string values to JS types using the
381
482
  supplied schema and returns `Promise<TypedRow[]>`. See Typed rows above.
382
483
 
484
+ ### `client.execScript(script, opts?)`
485
+
486
+ Splits `script` into statements (statement-aware — see Multi-statement
487
+ scripts above) and executes them all down this connection, pipelined.
488
+ Returns `Promise<QueryResult[]>` in statement order; rejects with a
489
+ `PowDBScriptError` on the first failed statement. With
490
+ `opts.transactional: true`, runs the whole script atomically (commit only
491
+ after every reply; rollback on any failure; embedded
492
+ `begin`/`commit`/`rollback` rejected; mutually exclusive with
493
+ `continueOnError`). With `opts.continueOnError: true`, returns
494
+ `Promise<ScriptStatementOutcome[]>` (`{ statement, ok: true, result }` or
495
+ `{ statement, ok: false, error }`) and never rejects for statement failures.
496
+ `opts.signal?: AbortSignal` aborts the remaining statements.
497
+
498
+ ### `client.ready()`
499
+
500
+ Resolves once the Connect→ConnectOk handshake has completed; rejects with
501
+ the handshake error if it failed. Only interesting for eager connects — for
502
+ default connects it has settled before `Client.connect` returns.
503
+
383
504
  ### `client.watch(query, options)`
384
505
 
385
506
  Re-runs `query` every `intervalMs` and passes each `QueryResult` to
@@ -395,7 +516,7 @@ Sends a disconnect message and closes the TCP socket.
395
516
 
396
517
  ### `client.serverVersion`
397
518
 
398
- The PowDB server version string (e.g., `"0.4.4"`). On connect, the client warns once per `host:port` if the server's major version differs from the client's.
519
+ The PowDB server version string (e.g., `"0.4.4"`). On connect, the client warns once per `host:port` if the server's major version differs from the client's. For an eager connect this is `""` until the ConnectOk arrives (await `client.ready()` to guarantee it is populated).
399
520
 
400
521
  ### `Pool` (class)
401
522
 
@@ -409,7 +530,7 @@ Constructor options extend `ClientOptions` with:
409
530
  | `connectBackoffMs` | `number` | `100` | Initial delay between connect retries; doubles each attempt |
410
531
  | `connectMaxBackoffMs` | `number` | `2000` | Cap on the exponential backoff |
411
532
 
412
- Methods: `acquire()`, `release(client)`, `destroy(client)`, `withClient(fn)`, `close()`.
533
+ Methods: `acquire()`, `release(client)`, `destroy(client)`, `withClient(fn)`, `execScript(script, opts?)`, `close()`.
413
534
  Getters: `size`, `idle`, `closed`.
414
535
 
415
536
  ### Safety helpers
@@ -418,6 +539,7 @@ Getters: `size`, `idle`, `closed`.
418
539
  - `ident(name)` — wrap a string so `powql` treats it as an identifier
419
540
  - `escapeLiteral(value)` — render a JS value as a PowQL literal
420
541
  - `escapeIdent(name)` — validate an identifier (throws `TypeError` on invalid)
542
+ - `splitStatements(script)` — the statement-aware script splitter used by `execScript`
421
543
 
422
544
  ## Limits
423
545
 
package/dist/errors.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * is intentionally small — new codes are added only when a caller has a
7
7
  * legitimate reason to handle them differently.
8
8
  */
9
+ import type { QueryResult } from "./index.js";
9
10
  export type PowDBErrorCode =
10
11
  /** TCP/TLS connect, DNS, connect timeout. Transient — safe to retry. */
11
12
  "connect_failed"
@@ -41,3 +42,29 @@ export declare class PowDBError extends Error {
41
42
  * to branch on `.code` but TypeScript sees `unknown`.
42
43
  */
43
44
  export declare function isPowDBError(err: unknown): err is PowDBError;
45
+ /**
46
+ * Failure of one statement inside `client.execScript(...)` (fail-fast mode).
47
+ *
48
+ * `code` mirrors the failing statement's error code (`"query_failed"` for a
49
+ * server Error frame, `"aborted"` for a fired AbortSignal, ...), so the usual
50
+ * `.code` branching keeps working; `cause` carries the underlying error.
51
+ * `statementIndex`/`statement` identify the failing statement within the
52
+ * split script, and `results` holds the successful results of every
53
+ * statement before it, in order.
54
+ */
55
+ export declare class PowDBScriptError extends PowDBError {
56
+ /** Zero-based index of the failing statement within the split script. */
57
+ readonly statementIndex: number;
58
+ /** Text of the failing statement (as split, trimmed). */
59
+ readonly statement: string;
60
+ /** Results of the statements before the failing one, in order. */
61
+ readonly results: QueryResult[];
62
+ constructor(message: string, code: PowDBErrorCode, details: {
63
+ statementIndex: number;
64
+ statement: string;
65
+ results: QueryResult[];
66
+ cause?: unknown;
67
+ });
68
+ }
69
+ /** Narrow `unknown` to a PowDBScriptError. */
70
+ export declare function isPowDBScriptError(err: unknown): err is PowDBScriptError;
package/dist/errors.js CHANGED
@@ -29,3 +29,33 @@ export class PowDBError extends Error {
29
29
  export function isPowDBError(err) {
30
30
  return err instanceof PowDBError;
31
31
  }
32
+ /**
33
+ * Failure of one statement inside `client.execScript(...)` (fail-fast mode).
34
+ *
35
+ * `code` mirrors the failing statement's error code (`"query_failed"` for a
36
+ * server Error frame, `"aborted"` for a fired AbortSignal, ...), so the usual
37
+ * `.code` branching keeps working; `cause` carries the underlying error.
38
+ * `statementIndex`/`statement` identify the failing statement within the
39
+ * split script, and `results` holds the successful results of every
40
+ * statement before it, in order.
41
+ */
42
+ export class PowDBScriptError extends PowDBError {
43
+ /** Zero-based index of the failing statement within the split script. */
44
+ statementIndex;
45
+ /** Text of the failing statement (as split, trimmed). */
46
+ statement;
47
+ /** Results of the statements before the failing one, in order. */
48
+ results;
49
+ constructor(message, code, details) {
50
+ super(message, code, { cause: details.cause });
51
+ this.name = "PowDBScriptError";
52
+ this.statementIndex = details.statementIndex;
53
+ this.statement = details.statement;
54
+ this.results = details.results;
55
+ Object.setPrototypeOf(this, PowDBScriptError.prototype);
56
+ }
57
+ }
58
+ /** Narrow `unknown` to a PowDBScriptError. */
59
+ export function isPowDBScriptError(err) {
60
+ return err instanceof PowDBScriptError;
61
+ }
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ import { EventEmitter } from "node:events";
19
19
  import { type SyncRepairAction, type WireRetainedUnit, type WireSyncStatus } 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.8.1";
22
+ export declare const CLIENT_VERSION = "0.9.0";
23
23
  export type QueryResult = {
24
24
  kind: "rows";
25
25
  columns: string[];
@@ -43,6 +43,43 @@ export type QueryResult = {
43
43
  * binds as an int; `null` binds PowQL `null`.
44
44
  */
45
45
  export type QueryParam = string | number | bigint | boolean | null;
46
+ /** Options for {@link Client.execScript} / {@link Pool.execScript}. */
47
+ export interface ExecScriptOptions {
48
+ /**
49
+ * When `true`, keep dispatching statements after one fails and return a
50
+ * per-statement outcome array instead of throwing. Defaults to `false`
51
+ * (fail-fast: stop dispatching and reject with a {@link PowDBScriptError}
52
+ * carrying the failing statement's index and the results so far).
53
+ */
54
+ continueOnError?: boolean;
55
+ /**
56
+ * When `true`, run the whole script atomically: `execScript` opens a
57
+ * transaction before dispatching and sends `commit` only after EVERY
58
+ * statement's reply has arrived successfully — on any failure it sends
59
+ * `rollback` instead, so no statement's effect survives. This is the only
60
+ * all-or-nothing mode: embedding your own `begin`/`commit` in a pipelined
61
+ * script is NOT safe (a trailing `commit` is already on the wire when an
62
+ * earlier error reply arrives, so partial work would commit), and a
63
+ * transactional script containing its own transaction-control statements
64
+ * is rejected up front. Mutually exclusive with `continueOnError`.
65
+ */
66
+ transactional?: boolean;
67
+ /** Abort the remaining statements (see {@link Client.query}). */
68
+ signal?: AbortSignal;
69
+ }
70
+ /**
71
+ * Per-statement outcome from `execScript(script, { continueOnError: true })`.
72
+ * Array order matches statement order in the script.
73
+ */
74
+ export type ScriptStatementOutcome = {
75
+ statement: string;
76
+ ok: true;
77
+ result: QueryResult;
78
+ } | {
79
+ statement: string;
80
+ ok: false;
81
+ error: Error;
82
+ };
46
83
  /** Unsigned 64-bit sync protocol value. Numbers must be safe non-negative integers. */
47
84
  export type SyncU64 = bigint | number;
48
85
  /**
@@ -107,6 +144,21 @@ export interface ClientOptions {
107
144
  * `tls.connect(port, host, options)`. Defaults to plain TCP.
108
145
  */
109
146
  tls?: boolean | tls.ConnectionOptions;
147
+ /**
148
+ * Pipelined ("eager") connect. When `true`, {@link Client.connect}
149
+ * resolves as soon as the socket is open and the Connect frame has been
150
+ * written — it does NOT wait for the server's ConnectOk. Queries issued
151
+ * immediately are queued and written right behind the Connect frame (the
152
+ * server reads frames sequentially, so this is valid on the wire), saving
153
+ * a full round trip on every fresh connection.
154
+ *
155
+ * If the handshake then fails (bad password, protocol mismatch), every
156
+ * queued query rejects with the handshake error and the client closes.
157
+ * `serverVersion` is `""` until the ConnectOk arrives; await
158
+ * {@link Client.ready} if you need the handshake settled. Defaults to
159
+ * `false` (connect blocks until ConnectOk, exactly as before).
160
+ */
161
+ eager?: boolean;
110
162
  }
111
163
  /**
112
164
  * Event map for {@link Client}. Typed so `client.on("query", ...)` gets
@@ -156,10 +208,41 @@ export declare class Client extends EventEmitter<ClientEvents> {
156
208
  private readonly pending;
157
209
  private closed;
158
210
  private closeError;
159
- readonly serverVersion: string;
211
+ /** Settled once the Connect→ConnectOk handshake completes (or fails). */
212
+ private handshake;
213
+ /** True once ConnectOk has been received. */
214
+ private handshakeComplete;
215
+ private _serverVersion;
216
+ /**
217
+ * Server version from the ConnectOk frame. For a client opened with
218
+ * `eager: true` this is `""` until the handshake reply arrives (await
219
+ * {@link ready} to guarantee it is populated).
220
+ */
221
+ get serverVersion(): string;
160
222
  private constructor();
161
- /** Open a connection, send Connect, wait for ConnectOk. */
223
+ /**
224
+ * Open a connection, send Connect, and wait for ConnectOk.
225
+ *
226
+ * With `eager: true`, resolve as soon as the Connect frame is written
227
+ * instead — queries may be issued immediately and are pipelined behind
228
+ * the handshake (see {@link ClientOptions.eager} and {@link ready}).
229
+ */
162
230
  static connect(opts: ClientOptions): Promise<Client>;
231
+ /**
232
+ * Resolves once the Connect→ConnectOk handshake has completed; rejects
233
+ * with the handshake error if it failed. For non-eager clients this has
234
+ * already settled by the time {@link connect} returns. Eager callers can
235
+ * await it to learn the handshake outcome without issuing a query.
236
+ */
237
+ ready(): Promise<void>;
238
+ /**
239
+ * Write the Connect frame and register the handshake as the first entry
240
+ * in the pending queue. The reply-matching machinery is strictly FIFO, so
241
+ * the ConnectOk (or Error) frame is matched to the handshake before any
242
+ * pipelined query sees a reply. On failure, every queued query is
243
+ * rejected with the handshake error and the socket is torn down.
244
+ */
245
+ private startHandshake;
163
246
  /**
164
247
  * Run a PowQL statement and return the typed result.
165
248
  *
@@ -216,6 +299,49 @@ export declare class Client extends EventEmitter<ClientEvents> {
216
299
  queryTyped(query: string, schema: TypedSchema, opts?: {
217
300
  signal?: AbortSignal;
218
301
  }): Promise<TypedRow[]>;
302
+ /**
303
+ * Execute a multi-statement PowQL script on this connection, pipelined.
304
+ *
305
+ * The script is split with the same statement-aware semantics as the CLI
306
+ * (see {@link splitStatements}: `;` inside string literals and `#`
307
+ * comments never splits; empty statements are dropped). All statements
308
+ * are then written down the single connection back-to-back WITHOUT
309
+ * waiting for each reply — the server reads frames sequentially, so an
310
+ * N-statement script costs one round trip instead of N. Results are
311
+ * collected in statement order.
312
+ *
313
+ * Error handling:
314
+ * - Default (fail-fast): resolve with `QueryResult[]` (one entry per
315
+ * statement). On the first failed statement, stop dispatching further
316
+ * statements and reject with a {@link PowDBScriptError} carrying the
317
+ * failing `statementIndex`, the `statement` text, and the successful
318
+ * `results` so far. NOTE: because dispatch is pipelined, statements
319
+ * already written when the error reply arrives (typically the whole
320
+ * script) still execute server-side. If you need all-or-nothing
321
+ * behavior use `transactional: true` — do NOT embed `begin`/`commit`
322
+ * in the script yourself: the trailing `commit` is already on the
323
+ * wire when an error reply arrives, so it commits the partial work.
324
+ * - `transactional: true`: `execScript` opens the transaction itself,
325
+ * waits for every statement's reply, and only then sends `commit`
326
+ * (or `rollback` if any statement failed), so no statement's effect
327
+ * survives a failure. On failure the {@link PowDBScriptError}'s
328
+ * `results` are the replies received before rollback — their effects
329
+ * are NOT persisted.
330
+ * - `continueOnError: true`: dispatch every statement regardless of
331
+ * failures and resolve with a dense {@link ScriptStatementOutcome}
332
+ * array recording each statement's result or error.
333
+ *
334
+ * Each statement emits the usual `"query"` event.
335
+ */
336
+ execScript(script: string, opts?: {
337
+ continueOnError?: false;
338
+ transactional?: boolean;
339
+ signal?: AbortSignal;
340
+ }): Promise<QueryResult[]>;
341
+ execScript(script: string, opts: {
342
+ continueOnError: true;
343
+ signal?: AbortSignal;
344
+ }): Promise<ScriptStatementOutcome[]>;
219
345
  /**
220
346
  * Poll a query on an interval and invoke `onRows` for every successful
221
347
  * run. Returns an unsubscribe function. Does NOT deduplicate results —
@@ -241,6 +367,12 @@ export declare class Client extends EventEmitter<ClientEvents> {
241
367
  */
242
368
  close(): Promise<void>;
243
369
  private send;
370
+ /**
371
+ * Resolve once the socket's write buffer has drained. Also resolves if
372
+ * the client closes while waiting, so a dead peer cannot hang a
373
+ * backpressured `execScript` dispatch loop forever.
374
+ */
375
+ private drained;
244
376
  private onData;
245
377
  /** Collapse the chunk queue into a single Buffer. */
246
378
  private coalesce;
@@ -254,7 +386,8 @@ export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, MAX_SYNC_UNITS, MA
254
386
  export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
255
387
  export { Pool } from "./pool.js";
256
388
  export type { PoolOptions } from "./pool.js";
257
- export { PowDBError, isPowDBError } from "./errors.js";
389
+ export { splitStatements } from "./script.js";
390
+ export { PowDBError, isPowDBError, PowDBScriptError, isPowDBScriptError, } from "./errors.js";
258
391
  export type { PowDBErrorCode } from "./errors.js";
259
392
  export { coerceValue, coerceRow, coerceRows, } from "./typed.js";
260
393
  export type { ColumnType, TypedSchema, TypedRow, Coerced, } from "./typed.js";
package/dist/index.js CHANGED
@@ -18,10 +18,20 @@ import * as net from "node:net";
18
18
  import * as tls from "node:tls";
19
19
  import { EventEmitter } from "node:events";
20
20
  import { encode, tryDecode, MAX_SYNC_PULL_BYTES, MAX_SYNC_PULL_UNITS, } from "./protocol.js";
21
- import { PowDBError } from "./errors.js";
21
+ import { PowDBError, PowDBScriptError, isPowDBError } from "./errors.js";
22
+ import { splitStatements } from "./script.js";
22
23
  import { coerceRows, } from "./typed.js";
23
24
  /** Client library version. Compared to the server's reported version. */
24
- export const CLIENT_VERSION = "0.8.1";
25
+ export const CLIENT_VERSION = "0.9.0";
26
+ /** Transaction-control statements a `transactional` script may not contain. */
27
+ const TX_CONTROL_RE = /^(begin|commit|rollback)\b/i;
28
+ /**
29
+ * Leading trivia (whitespace and `#` line comments) that may precede a
30
+ * statement's first real token. Must be stripped before matching
31
+ * {@link TX_CONTROL_RE}: the server's lexer skips comments, so
32
+ * `"# note\ncommit"` executes a commit — the guard has to see it too.
33
+ */
34
+ const LEADING_TRIVIA_RE = /^(?:\s+|#[^\n]*)+/;
25
35
  function socketChunkToBuffer(chunk) {
26
36
  return typeof chunk === "string" ? Buffer.from(chunk) : chunk;
27
37
  }
@@ -123,91 +133,99 @@ export class Client extends EventEmitter {
123
133
  pending = [];
124
134
  closed = false;
125
135
  closeError = null;
126
- serverVersion;
127
- constructor(socket, serverVersion) {
136
+ /** Settled once the Connect→ConnectOk handshake completes (or fails). */
137
+ handshake = Promise.resolve();
138
+ /** True once ConnectOk has been received. */
139
+ handshakeComplete = false;
140
+ _serverVersion = "";
141
+ /**
142
+ * Server version from the ConnectOk frame. For a client opened with
143
+ * `eager: true` this is `""` until the handshake reply arrives (await
144
+ * {@link ready} to guarantee it is populated).
145
+ */
146
+ get serverVersion() {
147
+ return this._serverVersion;
148
+ }
149
+ constructor(socket) {
128
150
  super();
129
151
  this.socket = socket;
130
- this.serverVersion = serverVersion;
131
152
  this.socket.on("data", (chunk) => this.onData(socketChunkToBuffer(chunk)));
132
153
  this.socket.on("error", (err) => this.onClose(err));
133
154
  this.socket.on("close", () => this.onClose(null));
134
155
  }
135
- /** Open a connection, send Connect, wait for ConnectOk. */
156
+ /**
157
+ * Open a connection, send Connect, and wait for ConnectOk.
158
+ *
159
+ * With `eager: true`, resolve as soon as the Connect frame is written
160
+ * instead — queries may be issued immediately and are pipelined behind
161
+ * the handshake (see {@link ClientOptions.eager} and {@link ready}).
162
+ */
136
163
  static async connect(opts) {
137
- const { host, port, path, dbName = "default", password = null, user, connectTimeoutMs = 5000, tls: tlsOpt = false, } = opts;
164
+ const { host, port, path, dbName = "default", password = null, user, connectTimeoutMs = 5000, tls: tlsOpt = false, eager = false, } = opts;
138
165
  if (path === undefined && (host === undefined || port === undefined)) {
139
166
  throw new PowDBError("connect requires either { path } (Unix socket) or { host, port } (TCP)", "connect_failed");
140
167
  }
141
168
  const socket = await openSocket({ host, port, path }, connectTimeoutMs, tlsOpt);
142
- // We need to read the initial ConnectOk before wiring up the normal
143
- // pending-queue machinery, so we do a one-shot handshake here.
144
- const handshake = new Promise((resolve, reject) => {
145
- let scratch = Buffer.alloc(0);
146
- const cleanup = () => {
147
- socket.removeListener("data", onData);
148
- socket.removeListener("error", onError);
149
- socket.removeListener("close", onClose);
150
- };
151
- const onData = (chunk) => {
152
- scratch = Buffer.concat([scratch, socketChunkToBuffer(chunk)]);
153
- let decoded;
154
- try {
155
- decoded = tryDecode(scratch);
156
- }
157
- catch (err) {
158
- cleanup();
159
- reject(err);
160
- return;
161
- }
162
- if (decoded !== null) {
163
- cleanup();
164
- // Any bytes past the handshake frame belong to later responses.
165
- // This should not happen in practice, but handle it defensively.
166
- const leftover = scratch.subarray(decoded.consumed);
167
- if (leftover.length > 0) {
168
- socket.unshift(leftover);
169
- }
170
- resolve(decoded.msg);
169
+ const client = new Client(socket);
170
+ client.startHandshake({ type: "Connect", dbName, password, username: user ?? null }, path ?? `${host}:${port}`);
171
+ if (!eager) {
172
+ await client.ready();
173
+ }
174
+ return client;
175
+ }
176
+ /**
177
+ * Resolves once the Connect→ConnectOk handshake has completed; rejects
178
+ * with the handshake error if it failed. For non-eager clients this has
179
+ * already settled by the time {@link connect} returns. Eager callers can
180
+ * await it to learn the handshake outcome without issuing a query.
181
+ */
182
+ ready() {
183
+ return this.handshake;
184
+ }
185
+ /**
186
+ * Write the Connect frame and register the handshake as the first entry
187
+ * in the pending queue. The reply-matching machinery is strictly FIFO, so
188
+ * the ConnectOk (or Error) frame is matched to the handshake before any
189
+ * pipelined query sees a reply. On failure, every queued query is
190
+ * rejected with the handshake error and the socket is torn down.
191
+ */
192
+ startHandshake(connect, versionWarnKey) {
193
+ this.handshake = this.send(connect).then((reply) => {
194
+ if (reply.type === "Error") {
195
+ throw new PowDBError(`connect failed: ${reply.message}`, "auth_failed");
196
+ }
197
+ if (reply.type !== "ConnectOk") {
198
+ throw new PowDBError(`expected ConnectOk, got ${reply.type}`, "protocol_error");
199
+ }
200
+ this.handshakeComplete = true;
201
+ this._serverVersion = reply.version;
202
+ // Advisory: warn once per host:port if the server's major differs
203
+ // from the client's. Do not throw or close — this is best-effort.
204
+ const serverMajor = majorOf(reply.version);
205
+ const clientMajor = majorOf(CLIENT_VERSION);
206
+ if (serverMajor !== clientMajor) {
207
+ if (!versionWarnings.has(versionWarnKey)) {
208
+ versionWarnings.add(versionWarnKey);
209
+ console.warn(`[powdb] server version ${reply.version} major (${serverMajor}) ` +
210
+ `differs from client ${CLIENT_VERSION} major (${clientMajor}); ` +
211
+ `behaviour may be inconsistent.`);
171
212
  }
172
- };
173
- const onError = (err) => {
174
- cleanup();
175
- reject(err);
176
- };
177
- // Without this, a peer that FINs the connection mid-handshake (no
178
- // explicit error event) would leave this promise pending forever.
179
- const onClose = () => {
180
- cleanup();
181
- reject(new PowDBError("connection closed during handshake", "connect_failed"));
182
- };
183
- socket.on("data", onData);
184
- socket.on("error", onError);
185
- socket.on("close", onClose);
213
+ }
214
+ }, (err) => {
215
+ // The connection dropped before a handshake reply — surface it as a
216
+ // connect failure (transient, retryable) rather than a bare close.
217
+ if (isPowDBError(err) && err.code === "closed") {
218
+ throw new PowDBError("connection closed during handshake", "connect_failed", { cause: err });
219
+ }
220
+ throw err;
221
+ });
222
+ // On handshake failure, reject everything queued behind it and tear the
223
+ // socket down. The extra no-op catch keeps an eager caller that never
224
+ // awaits ready() from tripping an unhandled-rejection crash.
225
+ this.handshake.catch((err) => {
226
+ this.onClose(err);
227
+ this.socket.destroy();
186
228
  });
187
- socket.write(encode({ type: "Connect", dbName, password, username: user ?? null }));
188
- const reply = await handshake;
189
- if (reply.type === "Error") {
190
- socket.destroy();
191
- throw new PowDBError(`connect failed: ${reply.message}`, "auth_failed");
192
- }
193
- if (reply.type !== "ConnectOk") {
194
- socket.destroy();
195
- throw new PowDBError(`expected ConnectOk, got ${reply.type}`, "protocol_error");
196
- }
197
- // Advisory: warn once per host:port if the server's major differs
198
- // from the client's. Do not throw or close — this is best-effort.
199
- const serverMajor = majorOf(reply.version);
200
- const clientMajor = majorOf(CLIENT_VERSION);
201
- if (serverMajor !== clientMajor) {
202
- const key = path ?? `${host}:${port}`;
203
- if (!versionWarnings.has(key)) {
204
- versionWarnings.add(key);
205
- console.warn(`[powdb] server version ${reply.version} major (${serverMajor}) ` +
206
- `differs from client ${CLIENT_VERSION} major (${clientMajor}); ` +
207
- `behaviour may be inconsistent.`);
208
- }
209
- }
210
- return new Client(socket, reply.version);
211
229
  }
212
230
  /**
213
231
  * Run a PowQL statement and return the typed result.
@@ -471,6 +489,136 @@ export class Client extends EventEmitter {
471
489
  }
472
490
  return coerceRows(result.columns, result.rows, schema);
473
491
  }
492
+ async execScript(script, opts) {
493
+ const statements = splitStatements(script);
494
+ const continueOnError = opts?.continueOnError === true;
495
+ const transactional = opts?.transactional === true;
496
+ const signal = opts?.signal;
497
+ if (transactional && continueOnError) {
498
+ throw new PowDBError("execScript: `transactional` and `continueOnError` are mutually exclusive", "protocol_error");
499
+ }
500
+ if (transactional) {
501
+ for (let i = 0; i < statements.length; i++) {
502
+ if (TX_CONTROL_RE.test(statements[i].replace(LEADING_TRIVIA_RE, ""))) {
503
+ throw new PowDBError(`execScript: a transactional script may not contain its own transaction control (statement ${i + 1}: ${statements[i]})`, "protocol_error");
504
+ }
505
+ }
506
+ // Open the transaction and WAIT for its reply before dispatching
507
+ // anything: if `begin` fails there must be nothing else on the wire.
508
+ // Deliberately no signal — an abort after `begin` is on the wire
509
+ // would leave the transaction open with no rollback; an
510
+ // already-aborted signal stops the loop below at statement 0 and
511
+ // takes the rollback path.
512
+ await this.query("begin");
513
+ }
514
+ const outcomes = new Array(statements.length);
515
+ const inFlight = [];
516
+ let firstFailureIndex = -1;
517
+ let firstFailureError = null;
518
+ let dispatched = 0;
519
+ for (let i = 0; i < statements.length; i++) {
520
+ // Stop dispatching when the script is already doomed: fail-fast saw
521
+ // an error, the caller aborted, or the connection is gone.
522
+ if (this.closed || signal?.aborted)
523
+ break;
524
+ if (!continueOnError && firstFailureIndex !== -1)
525
+ break;
526
+ const statement = statements[i];
527
+ const idx = i;
528
+ dispatched++;
529
+ // query() writes the frame synchronously before its first await, so
530
+ // this loop puts every statement on the wire without waiting for any
531
+ // reply; the FIFO pending queue matches replies back in order.
532
+ inFlight.push(this.query(statement, signal ? { signal } : undefined).then((result) => {
533
+ outcomes[idx] = { statement, ok: true, result };
534
+ }, (error) => {
535
+ const err = error instanceof Error ? error : new Error(String(error));
536
+ outcomes[idx] = { statement, ok: false, error: err };
537
+ // Replies are FIFO so failures normally arrive in statement
538
+ // order; keep the earliest defensively regardless.
539
+ if (firstFailureIndex === -1 || idx < firstFailureIndex) {
540
+ firstFailureIndex = idx;
541
+ firstFailureError = err;
542
+ }
543
+ }));
544
+ // Backpressure: only yield when the kernel buffer is full. These
545
+ // yields are also the windows in which an already-arrived error reply
546
+ // or abort can stop further dispatch (the checks at the loop head).
547
+ if (this.socket.writableNeedDrain)
548
+ await this.drained();
549
+ }
550
+ // Every dispatched statement has a handler attached, so this never
551
+ // rejects; it resolves once all in-flight replies have settled.
552
+ await Promise.all(inFlight);
553
+ if (!continueOnError) {
554
+ if (firstFailureIndex === -1 && dispatched < statements.length) {
555
+ // Dispatch stopped without any statement failing — e.g. a signal
556
+ // that was already aborted before the first write. Treat the first
557
+ // undispatched statement as the failure point.
558
+ firstFailureIndex = dispatched;
559
+ firstFailureError = signal?.aborted
560
+ ? abortError(signal)
561
+ : (this.closeError ?? new PowDBError("client is closed", "closed"));
562
+ }
563
+ if (firstFailureIndex !== -1) {
564
+ if (transactional) {
565
+ // Every dispatched reply has settled, so the connection is quiet
566
+ // and the transaction is still open. Best-effort rollback — the
567
+ // throw below is what callers act on either way, and if the
568
+ // connection died the server aborts the transaction itself.
569
+ try {
570
+ await this.query("rollback");
571
+ }
572
+ catch {
573
+ /* connection may already be gone */
574
+ }
575
+ }
576
+ const cause = firstFailureError;
577
+ const results = [];
578
+ for (let i = 0; i < firstFailureIndex; i++) {
579
+ const o = outcomes[i];
580
+ if (o !== undefined && o.ok)
581
+ results.push(o.result);
582
+ }
583
+ throw new PowDBScriptError(`script failed at statement ${firstFailureIndex + 1}/${statements.length}: ${cause.message}`, isPowDBError(cause) ? cause.code : "query_failed", {
584
+ statementIndex: firstFailureIndex,
585
+ statement: statements[firstFailureIndex],
586
+ results,
587
+ cause,
588
+ });
589
+ }
590
+ if (transactional) {
591
+ // Commit only after every statement's reply settled successfully —
592
+ // this is the all-or-nothing guarantee. The commit reply is not
593
+ // part of the returned results. No signal here either: aborting a
594
+ // commit already on the wire buys nothing but ambiguity.
595
+ try {
596
+ await this.query("commit");
597
+ }
598
+ catch (err) {
599
+ try {
600
+ await this.query("rollback");
601
+ }
602
+ catch {
603
+ /* connection may already be gone */
604
+ }
605
+ throw err;
606
+ }
607
+ }
608
+ return outcomes.map((o) => o.result);
609
+ }
610
+ // continueOnError: synthesize outcomes for statements that were never
611
+ // dispatched (abort or connection loss) so the array stays dense.
612
+ if (dispatched < statements.length) {
613
+ const reason = signal?.aborted
614
+ ? abortError(signal)
615
+ : (this.closeError ?? new PowDBError("client is closed", "closed"));
616
+ for (let i = dispatched; i < statements.length; i++) {
617
+ outcomes[i] = { statement: statements[i], ok: false, error: reason };
618
+ }
619
+ }
620
+ return outcomes;
621
+ }
474
622
  /**
475
623
  * Poll a query on an interval and invoke `onRows` for every successful
476
624
  * run. Returns an unsubscribe function. Does NOT deduplicate results —
@@ -636,6 +784,26 @@ export class Client extends EventEmitter {
636
784
  });
637
785
  });
638
786
  }
787
+ /**
788
+ * Resolve once the socket's write buffer has drained. Also resolves if
789
+ * the client closes while waiting, so a dead peer cannot hang a
790
+ * backpressured `execScript` dispatch loop forever.
791
+ */
792
+ drained() {
793
+ return new Promise((resolve) => {
794
+ if (!this.socket.writableNeedDrain || this.closed) {
795
+ resolve();
796
+ return;
797
+ }
798
+ const done = () => {
799
+ this.socket.removeListener("drain", done);
800
+ this.removeListener("close", done);
801
+ resolve();
802
+ };
803
+ this.socket.on("drain", done);
804
+ this.on("close", done);
805
+ });
806
+ }
639
807
  onData(chunk) {
640
808
  // Append to the chunk queue — O(1) — and lazily concat only when
641
809
  // we actually need contiguous bytes to decode.
@@ -739,8 +907,17 @@ export class Client extends EventEmitter {
739
907
  if (this.closed && err === null)
740
908
  return;
741
909
  this.closed = true;
742
- this.closeError = err;
743
- const error = err ?? new PowDBError("connection closed", "closed");
910
+ let error = err ?? new PowDBError("connection closed", "closed");
911
+ // A teardown before ConnectOk is a handshake failure: everything queued
912
+ // (the handshake entry and any eagerly pipelined queries) rejects with
913
+ // the handshake error. Auth/protocol errors already carry the precise
914
+ // cause; anything else becomes a retryable connect failure.
915
+ if (!this.handshakeComplete &&
916
+ !(isPowDBError(error) &&
917
+ (error.code === "auth_failed" || error.code === "protocol_error"))) {
918
+ error = new PowDBError("connection closed during handshake", "connect_failed", { cause: err ?? undefined });
919
+ }
920
+ this.closeError = error;
744
921
  while (this.pending.length > 0) {
745
922
  const entry = this.pending.shift();
746
923
  if (!entry.settled) {
@@ -804,5 +981,6 @@ export { encode, tryDecode } from "./protocol.js";
804
981
  export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, MAX_SYNC_UNITS, MAX_SYNC_PULL_UNITS, MAX_SYNC_PULL_BYTES, } from "./protocol.js";
805
982
  export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
806
983
  export { Pool } from "./pool.js";
807
- export { PowDBError, isPowDBError } from "./errors.js";
984
+ export { splitStatements } from "./script.js";
985
+ export { PowDBError, isPowDBError, PowDBScriptError, isPowDBScriptError, } from "./errors.js";
808
986
  export { coerceValue, coerceRow, coerceRows, } from "./typed.js";
package/dist/pool.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * await pool.close();
21
21
  */
22
- import { Client, type ClientOptions } from "./index.js";
22
+ import { Client, type ClientOptions, type QueryResult, type ScriptStatementOutcome } from "./index.js";
23
23
  export interface PoolOptions extends ClientOptions {
24
24
  /** Maximum concurrent clients. Default 10. */
25
25
  max?: number;
@@ -110,6 +110,23 @@ export declare class Pool {
110
110
  * to leak a client on an unhandled exception.
111
111
  */
112
112
  withClient<T>(fn: (c: Client) => Promise<T>): Promise<T>;
113
+ /**
114
+ * Run a multi-statement PowQL script on ONE pooled connection, pipelined.
115
+ *
116
+ * Checks out a single client for the whole script (statement order —
117
+ * and `transactional: true` — therefore holds), delegates to
118
+ * {@link Client.execScript}, and releases the client on success or
119
+ * destroys it on error — same lifecycle as {@link withClient}.
120
+ */
121
+ execScript(script: string, opts?: {
122
+ continueOnError?: false;
123
+ transactional?: boolean;
124
+ signal?: AbortSignal;
125
+ }): Promise<QueryResult[]>;
126
+ execScript(script: string, opts: {
127
+ continueOnError: true;
128
+ signal?: AbortSignal;
129
+ }): Promise<ScriptStatementOutcome[]>;
113
130
  /**
114
131
  * Close the pool. Rejects all pending waiters, awaits `close()` on every
115
132
  * idle client, and marks the pool closed. Subsequent `acquire` calls
package/dist/pool.js CHANGED
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * await pool.close();
21
21
  */
22
- import { Client } from "./index.js";
22
+ import { Client, } from "./index.js";
23
23
  import { isPowDBError } from "./errors.js";
24
24
  /**
25
25
  * Is this error worth retrying? Only the transient connect-phase errors
@@ -251,6 +251,23 @@ export class Pool {
251
251
  throw err;
252
252
  }
253
253
  }
254
+ async execScript(script, opts) {
255
+ if (opts?.continueOnError === true) {
256
+ return this.withClient((c) => c.execScript(script, {
257
+ continueOnError: true,
258
+ ...(opts.transactional !== undefined
259
+ ? { transactional: opts.transactional }
260
+ : {}),
261
+ ...(opts.signal ? { signal: opts.signal } : {}),
262
+ }));
263
+ }
264
+ return this.withClient((c) => c.execScript(script, {
265
+ ...(opts?.transactional !== undefined
266
+ ? { transactional: opts.transactional }
267
+ : {}),
268
+ ...(opts?.signal ? { signal: opts.signal } : {}),
269
+ }));
270
+ }
254
271
  /**
255
272
  * Close the pool. Rejects all pending waiters, awaits `close()` on every
256
273
  * idle client, and marks the pool closed. Subsequent `acquire` calls
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Statement-aware PowQL script splitting.
3
+ *
4
+ * Mirrors the server-side splitter (`powdb_query::lexer::split_statements`)
5
+ * exactly, so a script file behaves the same whether it is fed to the CLI's
6
+ * `--exec` / `.powql` path or to {@link Client.execScript} over the wire:
7
+ *
8
+ * - `;` splits statements only at the top level.
9
+ * - `;` inside a `"..."` string literal never splits. A backslash inside a
10
+ * string consumes the next character unconditionally (mirroring the
11
+ * PowQL lexer), so `\"` and `\;` stay inside the string.
12
+ * - `#` starts a comment that runs to end-of-line; a `;` inside a comment
13
+ * never splits.
14
+ * - Segments are trimmed; empty segments (leading/doubled/trailing `;`,
15
+ * blank lines) are dropped.
16
+ * - Never errors: an unterminated string simply makes the rest of the
17
+ * input the final segment.
18
+ */
19
+ /**
20
+ * Split a PowQL script into individual statements.
21
+ *
22
+ * String-literal- and `#`-comment-aware `;` splitting with the exact
23
+ * semantics of the CLI/server splitter (see module docs above).
24
+ */
25
+ export declare function splitStatements(input: string): string[];
package/dist/script.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Statement-aware PowQL script splitting.
3
+ *
4
+ * Mirrors the server-side splitter (`powdb_query::lexer::split_statements`)
5
+ * exactly, so a script file behaves the same whether it is fed to the CLI's
6
+ * `--exec` / `.powql` path or to {@link Client.execScript} over the wire:
7
+ *
8
+ * - `;` splits statements only at the top level.
9
+ * - `;` inside a `"..."` string literal never splits. A backslash inside a
10
+ * string consumes the next character unconditionally (mirroring the
11
+ * PowQL lexer), so `\"` and `\;` stay inside the string.
12
+ * - `#` starts a comment that runs to end-of-line; a `;` inside a comment
13
+ * never splits.
14
+ * - Segments are trimmed; empty segments (leading/doubled/trailing `;`,
15
+ * blank lines) are dropped.
16
+ * - Never errors: an unterminated string simply makes the rest of the
17
+ * input the final segment.
18
+ */
19
+ /**
20
+ * Split a PowQL script into individual statements.
21
+ *
22
+ * String-literal- and `#`-comment-aware `;` splitting with the exact
23
+ * semantics of the CLI/server splitter (see module docs above).
24
+ */
25
+ export function splitStatements(input) {
26
+ const out = [];
27
+ let start = 0;
28
+ let state = "normal";
29
+ for (let i = 0; i < input.length; i++) {
30
+ const c = input[i];
31
+ switch (state) {
32
+ case "normal":
33
+ if (c === ";") {
34
+ const seg = input.slice(start, i).trim();
35
+ if (seg.length > 0)
36
+ out.push(seg);
37
+ start = i + 1;
38
+ }
39
+ else if (c === '"') {
40
+ state = "in-string";
41
+ }
42
+ else if (c === "#") {
43
+ state = "in-comment";
44
+ }
45
+ break;
46
+ case "in-string":
47
+ // Mirror the lexer: a backslash consumes the next char
48
+ // unconditionally, so `\"` and `\;` stay inside the string.
49
+ if (c === "\\") {
50
+ i++;
51
+ }
52
+ else if (c === '"') {
53
+ state = "normal";
54
+ }
55
+ break;
56
+ case "in-comment":
57
+ if (c === "\n")
58
+ state = "normal";
59
+ break;
60
+ }
61
+ }
62
+ const seg = input.slice(start).trim();
63
+ if (seg.length > 0)
64
+ out.push(seg);
65
+ return out;
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/powdb-client",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "TypeScript client for PowDB (PowQL wire protocol)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.29.3",
@@ -56,6 +56,9 @@
56
56
  "test:pool": "tsx test/run-with-server.ts test/pool.test.ts",
57
57
  "test:typed": "tsx test/typed.test.ts",
58
58
  "test:errors": "tsx test/errors.test.ts",
59
+ "test:script": "tsx test/script.test.ts",
60
+ "test:eager": "tsx test/run-with-server.ts test/eager.test.ts",
61
+ "test:exec-script": "tsx test/run-with-server.ts test/exec-script.test.ts",
59
62
  "demo": "tsx demo/demo.ts",
60
63
  "clean": "rm -rf dist",
61
64
  "prepublishOnly": "npm run build"