@zvndev/powdb-client 0.8.0 → 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
@@ -322,11 +422,12 @@ try {
322
422
  }
323
423
  ```
324
424
 
325
- If you abort with a custom reason (`ctrl.abort(myError)`) and that reason is
326
- an `Error`, it is thrown as-is instead. Otherwise the client throws a
327
- `PowDBError` with `code === "aborted"`.
425
+ A plain `ctrl.abort()` throws a `PowDBError` with `code === "aborted"`. If you
426
+ abort with a custom `Error` reason (`ctrl.abort(myError)`), that error is thrown
427
+ as-is; any non-`Error` reason is wrapped in a `PowDBError` (`code === "aborted"`).
328
428
 
329
- The socket stays open — the server's reply is silently discarded so other in-flight queries keep working.
429
+ The socket stays open — the aborted query's reply is silently discarded when it
430
+ arrives, and every other in-flight query still receives its own result.
330
431
 
331
432
  ## API
332
433
 
@@ -343,6 +444,7 @@ Returns a `Promise<Client>`. Options:
343
444
  | `user` | `string` | *(omitted)* | User name for multi-user servers (see Authentication above). Omit for shared-password / no-auth servers |
344
445
  | `connectTimeoutMs` | `number` | `5000` | Connection timeout in milliseconds |
345
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) |
346
448
 
347
449
  > **Multi-user servers:** requires client ≥0.4.0 (`user` option) and server
348
450
  > ≥0.4.6 (enforced roles). See the version matrix under Authentication.
@@ -361,11 +463,44 @@ Sends a PowQL query and returns a `Promise<QueryResult>`:
361
463
 
362
464
  Throws a `PowDBError` (see Structured errors above) on any failure.
363
465
 
466
+ ### `client.querySql(query, opts?)`
467
+
468
+ Runs a statement through the server's **SQL frontend** and returns a
469
+ `Promise<QueryResult>` — the same result shape as `query()`. The plain
470
+ `query()` method stays PowQL for wire compatibility; `querySql()` sends the
471
+ dedicated `QuerySql` wire message. Requires server ≥0.5.0 (the SQL frontend was
472
+ added in 0.5.0); see `docs/SQL.md` for the supported subset. `opts.signal?:
473
+ AbortSignal` cancels the query (see Cancellation above).
474
+
475
+ ```typescript
476
+ const result = await client.querySql("SELECT name, age FROM User WHERE age > 27");
477
+ ```
478
+
364
479
  ### `client.queryTyped(query, schema, opts?)`
365
480
 
366
481
  Like `query()`, but coerces each row's string values to JS types using the
367
482
  supplied schema and returns `Promise<TypedRow[]>`. See Typed rows above.
368
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
+
369
504
  ### `client.watch(query, options)`
370
505
 
371
506
  Re-runs `query` every `intervalMs` and passes each `QueryResult` to
@@ -381,7 +516,7 @@ Sends a disconnect message and closes the TCP socket.
381
516
 
382
517
  ### `client.serverVersion`
383
518
 
384
- 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).
385
520
 
386
521
  ### `Pool` (class)
387
522
 
@@ -395,7 +530,7 @@ Constructor options extend `ClientOptions` with:
395
530
  | `connectBackoffMs` | `number` | `100` | Initial delay between connect retries; doubles each attempt |
396
531
  | `connectMaxBackoffMs` | `number` | `2000` | Cap on the exponential backoff |
397
532
 
398
- Methods: `acquire()`, `release(client)`, `destroy(client)`, `withClient(fn)`, `close()`.
533
+ Methods: `acquire()`, `release(client)`, `destroy(client)`, `withClient(fn)`, `execScript(script, opts?)`, `close()`.
399
534
  Getters: `size`, `idle`, `closed`.
400
535
 
401
536
  ### Safety helpers
@@ -404,6 +539,7 @@ Getters: `size`, `idle`, `closed`.
404
539
  - `ident(name)` — wrap a string so `powql` treats it as an identifier
405
540
  - `escapeLiteral(value)` — render a JS value as a PowQL literal
406
541
  - `escapeIdent(name)` — validate an identifier (throws `TypeError` on invalid)
542
+ - `splitStatements(script)` — the statement-aware script splitter used by `execScript`
407
543
 
408
544
  ## Limits
409
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.0";
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.0";
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
  }
@@ -98,16 +108,19 @@ function majorOf(version) {
98
108
  return dot === -1 ? version : version.slice(0, dot);
99
109
  }
100
110
  /**
101
- * Build an AbortError. Prefers the caller-supplied `signal.reason` if it is
102
- * itself an `Error` (matches DOM semantics); otherwise wraps it in a
103
- * `PowDBError` with code `"aborted"` so catch-blocks can branch uniformly.
111
+ * Build an AbortError. A caller-supplied custom `Error` reason
112
+ * (`ctrl.abort(myError)`) passes through as-is to match DOM semantics. The
113
+ * default abort reason — Node's `DOMException` named `"AbortError"` and any
114
+ * non-Error reason are wrapped in a `PowDBError` with code `"aborted"` so the
115
+ * common `ctrl.abort()` case branches uniformly on `err.code === "aborted"`.
104
116
  */
105
117
  function abortError(signal) {
106
118
  if (signal && signal.reason !== undefined) {
107
119
  const r = signal.reason;
108
- if (r instanceof Error)
120
+ const isDefaultAbort = r instanceof DOMException && r.name === "AbortError";
121
+ if (r instanceof Error && !isDefaultAbort)
109
122
  return r;
110
- return new PowDBError(String(r), "aborted");
123
+ return new PowDBError(isDefaultAbort ? "query was aborted" : String(r), "aborted");
111
124
  }
112
125
  return new PowDBError("query was aborted", "aborted");
113
126
  }
@@ -120,91 +133,99 @@ export class Client extends EventEmitter {
120
133
  pending = [];
121
134
  closed = false;
122
135
  closeError = null;
123
- serverVersion;
124
- 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) {
125
150
  super();
126
151
  this.socket = socket;
127
- this.serverVersion = serverVersion;
128
152
  this.socket.on("data", (chunk) => this.onData(socketChunkToBuffer(chunk)));
129
153
  this.socket.on("error", (err) => this.onClose(err));
130
154
  this.socket.on("close", () => this.onClose(null));
131
155
  }
132
- /** 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
+ */
133
163
  static async connect(opts) {
134
- 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;
135
165
  if (path === undefined && (host === undefined || port === undefined)) {
136
166
  throw new PowDBError("connect requires either { path } (Unix socket) or { host, port } (TCP)", "connect_failed");
137
167
  }
138
168
  const socket = await openSocket({ host, port, path }, connectTimeoutMs, tlsOpt);
139
- // We need to read the initial ConnectOk before wiring up the normal
140
- // pending-queue machinery, so we do a one-shot handshake here.
141
- const handshake = new Promise((resolve, reject) => {
142
- let scratch = Buffer.alloc(0);
143
- const cleanup = () => {
144
- socket.removeListener("data", onData);
145
- socket.removeListener("error", onError);
146
- socket.removeListener("close", onClose);
147
- };
148
- const onData = (chunk) => {
149
- scratch = Buffer.concat([scratch, socketChunkToBuffer(chunk)]);
150
- let decoded;
151
- try {
152
- decoded = tryDecode(scratch);
153
- }
154
- catch (err) {
155
- cleanup();
156
- reject(err);
157
- return;
158
- }
159
- if (decoded !== null) {
160
- cleanup();
161
- // Any bytes past the handshake frame belong to later responses.
162
- // This should not happen in practice, but handle it defensively.
163
- const leftover = scratch.subarray(decoded.consumed);
164
- if (leftover.length > 0) {
165
- socket.unshift(leftover);
166
- }
167
- 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.`);
168
212
  }
169
- };
170
- const onError = (err) => {
171
- cleanup();
172
- reject(err);
173
- };
174
- // Without this, a peer that FINs the connection mid-handshake (no
175
- // explicit error event) would leave this promise pending forever.
176
- const onClose = () => {
177
- cleanup();
178
- reject(new PowDBError("connection closed during handshake", "connect_failed"));
179
- };
180
- socket.on("data", onData);
181
- socket.on("error", onError);
182
- 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();
183
228
  });
184
- socket.write(encode({ type: "Connect", dbName, password, username: user ?? null }));
185
- const reply = await handshake;
186
- if (reply.type === "Error") {
187
- socket.destroy();
188
- throw new PowDBError(`connect failed: ${reply.message}`, "auth_failed");
189
- }
190
- if (reply.type !== "ConnectOk") {
191
- socket.destroy();
192
- throw new PowDBError(`expected ConnectOk, got ${reply.type}`, "protocol_error");
193
- }
194
- // Advisory: warn once per host:port if the server's major differs
195
- // from the client's. Do not throw or close — this is best-effort.
196
- const serverMajor = majorOf(reply.version);
197
- const clientMajor = majorOf(CLIENT_VERSION);
198
- if (serverMajor !== clientMajor) {
199
- const key = path ?? `${host}:${port}`;
200
- if (!versionWarnings.has(key)) {
201
- versionWarnings.add(key);
202
- console.warn(`[powdb] server version ${reply.version} major (${serverMajor}) ` +
203
- `differs from client ${CLIENT_VERSION} major (${clientMajor}); ` +
204
- `behaviour may be inconsistent.`);
205
- }
206
- }
207
- return new Client(socket, reply.version);
208
229
  }
209
230
  /**
210
231
  * Run a PowQL statement and return the typed result.
@@ -468,6 +489,136 @@ export class Client extends EventEmitter {
468
489
  }
469
490
  return coerceRows(result.columns, result.rows, schema);
470
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
+ }
471
622
  /**
472
623
  * Poll a query on an interval and invoke `onRows` for every successful
473
624
  * run. Returns an unsubscribe function. Does NOT deduplicate results —
@@ -527,8 +678,17 @@ export class Client extends EventEmitter {
527
678
  * timeout so an unresponsive peer cannot make `close()` hang forever.
528
679
  */
529
680
  async close() {
530
- if (this.closed)
681
+ if (this.closed) {
682
+ // Already torn down (e.g. an error in onData/onClose closed the client
683
+ // without destroying the socket). Ensure the socket is released so a
684
+ // post-teardown close() resolves instead of keeping the event loop
685
+ // alive. `writableEnded` distinguishes that case from a concurrent
686
+ // close() mid-graceful-shutdown, which must not be force-destroyed.
687
+ if (!this.socket.destroyed && !this.socket.writableEnded) {
688
+ this.socket.destroy();
689
+ }
531
690
  return;
691
+ }
532
692
  try {
533
693
  this.socket.write(encode({ type: "Disconnect" }));
534
694
  }
@@ -624,6 +784,26 @@ export class Client extends EventEmitter {
624
784
  });
625
785
  });
626
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
+ }
627
807
  onData(chunk) {
628
808
  // Append to the chunk queue — O(1) — and lazily concat only when
629
809
  // we actually need contiguous bytes to decode.
@@ -682,19 +862,18 @@ export class Client extends EventEmitter {
682
862
  this.socket.write(encode({ type: "Pong" }));
683
863
  continue;
684
864
  }
685
- // Find the next non-settled pending entry and hand it the reply.
686
- // Settled entries at the head were aborted by the caller but their
687
- // reply is arriving now drop it silently.
688
- let entry = this.pending.shift();
689
- while (entry && entry.settled) {
690
- entry = this.pending.shift();
691
- }
692
- if (!entry) {
693
- // Server sent an unsolicited frame (or we got extra after aborts
694
- // with no replacement). Treat as protocol error.
865
+ // Replies are strictly FIFO: this frame belongs to exactly one pending
866
+ // entry the head. Consume one entry per frame. If it was aborted
867
+ // (settled), its reply is arriving now; drop the frame and keep decoding
868
+ // rather than delivering it to a later, live query.
869
+ const entry = this.pending.shift();
870
+ if (entry === undefined) {
871
+ // No pending entry at all — the server sent an unsolicited frame.
695
872
  this.onClose(new PowDBError("received unexpected frame from server", "protocol_error"));
696
873
  return;
697
874
  }
875
+ if (entry.settled)
876
+ continue;
698
877
  entry.resolve(decoded.msg);
699
878
  }
700
879
  }
@@ -728,8 +907,17 @@ export class Client extends EventEmitter {
728
907
  if (this.closed && err === null)
729
908
  return;
730
909
  this.closed = true;
731
- this.closeError = err;
732
- 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;
733
921
  while (this.pending.length > 0) {
734
922
  const entry = this.pending.shift();
735
923
  if (!entry.settled) {
@@ -793,5 +981,6 @@ export { encode, tryDecode } from "./protocol.js";
793
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";
794
982
  export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
795
983
  export { Pool } from "./pool.js";
796
- export { PowDBError, isPowDBError } from "./errors.js";
984
+ export { splitStatements } from "./script.js";
985
+ export { PowDBError, isPowDBError, PowDBScriptError, isPowDBScriptError, } from "./errors.js";
797
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.0",
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,12 +56,15 @@
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"
62
65
  },
63
66
  "devDependencies": {
64
- "@types/node": "^25",
67
+ "@types/node": "^26",
65
68
  "tsx": "^4",
66
69
  "typescript": "^6.0"
67
70
  }