@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 +142 -6
- package/dist/errors.d.ts +27 -0
- package/dist/errors.js +30 -0
- package/dist/index.d.ts +137 -4
- package/dist/index.js +282 -93
- package/dist/pool.d.ts +18 -1
- package/dist/pool.js +18 -1
- package/dist/script.d.ts +25 -0
- package/dist/script.js +66 -0
- package/package.json +5 -2
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
|
-
|
|
326
|
-
|
|
327
|
-
`PowDBError`
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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.
|
|
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.
|
|
102
|
-
*
|
|
103
|
-
* `
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
//
|
|
686
|
-
//
|
|
687
|
-
// reply is arriving now
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
732
|
-
|
|
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 {
|
|
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
|
package/dist/script.d.ts
ADDED
|
@@ -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.
|
|
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": "^
|
|
67
|
+
"@types/node": "^26",
|
|
65
68
|
"tsx": "^4",
|
|
66
69
|
"typescript": "^6.0"
|
|
67
70
|
}
|