@zvndev/powdb-client 0.8.1 → 0.10.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.
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /**
3
+ * Statement-aware PowQL script splitting.
4
+ *
5
+ * Mirrors the server-side splitter (`powdb_query::lexer::split_statements`)
6
+ * exactly, so a script file behaves the same whether it is fed to the CLI's
7
+ * `--exec` / `.powql` path or to {@link Client.execScript} over the wire:
8
+ *
9
+ * - `;` splits statements only at the top level.
10
+ * - `;` inside a `"..."` string literal never splits. A backslash inside a
11
+ * string consumes the next character unconditionally (mirroring the
12
+ * PowQL lexer), so `\"` and `\;` stay inside the string.
13
+ * - `#` starts a comment that runs to end-of-line; a `;` inside a comment
14
+ * never splits.
15
+ * - Segments are trimmed; empty segments (leading/doubled/trailing `;`,
16
+ * blank lines) are dropped.
17
+ * - Never errors: an unterminated string simply makes the rest of the
18
+ * input the final segment.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.splitStatements = splitStatements;
22
+ /**
23
+ * Split a PowQL script into individual statements.
24
+ *
25
+ * String-literal- and `#`-comment-aware `;` splitting with the exact
26
+ * semantics of the CLI/server splitter (see module docs above).
27
+ */
28
+ function splitStatements(input) {
29
+ const out = [];
30
+ let start = 0;
31
+ let state = "normal";
32
+ for (let i = 0; i < input.length; i++) {
33
+ const c = input[i];
34
+ switch (state) {
35
+ case "normal":
36
+ if (c === ";") {
37
+ const seg = input.slice(start, i).trim();
38
+ if (seg.length > 0)
39
+ out.push(seg);
40
+ start = i + 1;
41
+ }
42
+ else if (c === '"') {
43
+ state = "in-string";
44
+ }
45
+ else if (c === "#") {
46
+ state = "in-comment";
47
+ }
48
+ break;
49
+ case "in-string":
50
+ // Mirror the lexer: a backslash consumes the next char
51
+ // unconditionally, so `\"` and `\;` stay inside the string.
52
+ if (c === "\\") {
53
+ i++;
54
+ }
55
+ else if (c === '"') {
56
+ state = "normal";
57
+ }
58
+ break;
59
+ case "in-comment":
60
+ if (c === "\n")
61
+ state = "normal";
62
+ break;
63
+ }
64
+ }
65
+ const seg = input.slice(start).trim();
66
+ if (seg.length > 0)
67
+ out.push(seg);
68
+ return out;
69
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Optional row-level type coercion for `queryTyped`.
3
+ *
4
+ * The server's wire format serialises every value to a string (see
5
+ * `crates/server/src/handler.rs::value_to_display`):
6
+ * Int → decimal digits, e.g. "42" or "-7"
7
+ * Float → Rust `{}` format, e.g. "3.14"
8
+ * Bool → "true" / "false"
9
+ * Str → raw UTF-8 (may contain anything)
10
+ * DateTime → microseconds since Unix epoch as decimal, e.g. "1718928000000000"
11
+ * Uuid → canonical "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
12
+ * Bytes → "<N bytes>" (LOSSY — see note below)
13
+ *
14
+ * `queryTyped` takes a user-supplied schema and coerces each column string
15
+ * into the corresponding JS value. Rationale for requiring the caller to
16
+ * supply the schema (rather than introspecting): PowDB has no `describe`
17
+ * statement yet, and going via `select` off a system catalog is one extra
18
+ * round trip per typed query. When schema introspection lands, a helper
19
+ * that builds the schema object can be added without changing this API.
20
+ *
21
+ * Bytes caveat: the current wire format is lossy for byte columns (renders
22
+ * `<N bytes>`). Until the wire protocol grows a binary column type, this
23
+ * module throws on `bytes` columns rather than guessing — use `str` in the
24
+ * schema if you just want the `<N bytes>` placeholder.
25
+ */
26
+ /**
27
+ * Supported column types. Mirrors the server's `DataType` enum minus the
28
+ * `Bytes` variant, which is intentionally unsupported here.
29
+ */
30
+ export type ColumnType = "int" | "float" | "bool" | "str" | "datetime" | "uuid";
31
+ /** Map of column name → declared type. Columns not in the map pass through as strings. */
32
+ export type TypedSchema = Record<string, ColumnType>;
33
+ /** Coerced value type. `int` may return `bigint` if the value exceeds Number.MAX_SAFE_INTEGER. */
34
+ export type Coerced = number | bigint | boolean | string | Date | null;
35
+ /** A row of coerced values keyed by column name. */
36
+ export type TypedRow = Record<string, Coerced>;
37
+ /**
38
+ * Coerce a single string value to the JS type declared in `schema`. `null`
39
+ * is returned for the conventional null sentinel — see note at call site.
40
+ * Unknown column types fall through as strings.
41
+ */
42
+ export declare function coerceValue(column: string, raw: string, type: ColumnType | undefined): Coerced;
43
+ /**
44
+ * Coerce a single result row (a string tuple) into a typed object keyed by
45
+ * column name.
46
+ */
47
+ export declare function coerceRow(columns: string[], values: string[], schema: TypedSchema): TypedRow;
48
+ /** Coerce every row in a rows-result into typed objects. */
49
+ export declare function coerceRows(columns: string[], rows: string[][], schema: TypedSchema): TypedRow[];
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ /**
3
+ * Optional row-level type coercion for `queryTyped`.
4
+ *
5
+ * The server's wire format serialises every value to a string (see
6
+ * `crates/server/src/handler.rs::value_to_display`):
7
+ * Int → decimal digits, e.g. "42" or "-7"
8
+ * Float → Rust `{}` format, e.g. "3.14"
9
+ * Bool → "true" / "false"
10
+ * Str → raw UTF-8 (may contain anything)
11
+ * DateTime → microseconds since Unix epoch as decimal, e.g. "1718928000000000"
12
+ * Uuid → canonical "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
13
+ * Bytes → "<N bytes>" (LOSSY — see note below)
14
+ *
15
+ * `queryTyped` takes a user-supplied schema and coerces each column string
16
+ * into the corresponding JS value. Rationale for requiring the caller to
17
+ * supply the schema (rather than introspecting): PowDB has no `describe`
18
+ * statement yet, and going via `select` off a system catalog is one extra
19
+ * round trip per typed query. When schema introspection lands, a helper
20
+ * that builds the schema object can be added without changing this API.
21
+ *
22
+ * Bytes caveat: the current wire format is lossy for byte columns (renders
23
+ * `<N bytes>`). Until the wire protocol grows a binary column type, this
24
+ * module throws on `bytes` columns rather than guessing — use `str` in the
25
+ * schema if you just want the `<N bytes>` placeholder.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.coerceValue = coerceValue;
29
+ exports.coerceRow = coerceRow;
30
+ exports.coerceRows = coerceRows;
31
+ const errors_js_1 = require("./errors.js");
32
+ /**
33
+ * Coerce a single string value to the JS type declared in `schema`. `null`
34
+ * is returned for the conventional null sentinel — see note at call site.
35
+ * Unknown column types fall through as strings.
36
+ */
37
+ function coerceValue(column, raw, type) {
38
+ // The server's wire format distinguishes NULL from empty string by the
39
+ // literal "null" bareword in scalar displays. For typed columns we treat
40
+ // the single token "null" as NULL; empty string remains empty string for
41
+ // `str` columns (can be an intentional empty value).
42
+ if (type !== "str" && raw === "null")
43
+ return null;
44
+ switch (type) {
45
+ case undefined:
46
+ case "str":
47
+ return raw;
48
+ case "int": {
49
+ if (!/^-?\d+$/.test(raw)) {
50
+ throw new errors_js_1.PowDBError(`queryTyped: column "${column}" declared int but got ${JSON.stringify(raw)}`, "type_coercion_failed");
51
+ }
52
+ // Promote to BigInt when the value doesn't round-trip through Number.
53
+ const n = Number(raw);
54
+ if (Number.isSafeInteger(n))
55
+ return n;
56
+ return BigInt(raw);
57
+ }
58
+ case "float": {
59
+ const n = Number(raw);
60
+ if (!Number.isFinite(n)) {
61
+ throw new errors_js_1.PowDBError(`queryTyped: column "${column}" declared float but got ${JSON.stringify(raw)}`, "type_coercion_failed");
62
+ }
63
+ return n;
64
+ }
65
+ case "bool":
66
+ if (raw === "true")
67
+ return true;
68
+ if (raw === "false")
69
+ return false;
70
+ throw new errors_js_1.PowDBError(`queryTyped: column "${column}" declared bool but got ${JSON.stringify(raw)}`, "type_coercion_failed");
71
+ case "datetime": {
72
+ if (!/^-?\d+$/.test(raw)) {
73
+ throw new errors_js_1.PowDBError(`queryTyped: column "${column}" declared datetime but got ${JSON.stringify(raw)} (expected microseconds since epoch)`, "type_coercion_failed");
74
+ }
75
+ // Server sends microseconds since epoch. JS Date uses milliseconds.
76
+ // BigInt division avoids float precision loss for far-future dates.
77
+ const micros = BigInt(raw);
78
+ const millis = Number(micros / 1000n);
79
+ return new Date(millis);
80
+ }
81
+ case "uuid": {
82
+ // Canonical 8-4-4-4-12 hex, lowercase (per server format).
83
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(raw)) {
84
+ throw new errors_js_1.PowDBError(`queryTyped: column "${column}" declared uuid but got ${JSON.stringify(raw)}`, "type_coercion_failed");
85
+ }
86
+ return raw;
87
+ }
88
+ }
89
+ }
90
+ /**
91
+ * Coerce a single result row (a string tuple) into a typed object keyed by
92
+ * column name.
93
+ */
94
+ function coerceRow(columns, values, schema) {
95
+ const out = {};
96
+ for (let i = 0; i < columns.length; i++) {
97
+ const col = columns[i];
98
+ out[col] = coerceValue(col, values[i] ?? "null", schema[col]);
99
+ }
100
+ return out;
101
+ }
102
+ /** Coerce every row in a rows-result into typed objects. */
103
+ function coerceRows(columns, rows, schema) {
104
+ return rows.map((r) => coerceRow(columns, r, schema));
105
+ }
package/dist/errors.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * is intentionally small — new codes are added only when a caller has a
7
7
  * legitimate reason to handle them differently.
8
8
  */
9
+ import type { QueryResult } from "./index.js";
9
10
  export type PowDBErrorCode =
10
11
  /** TCP/TLS connect, DNS, connect timeout. Transient — safe to retry. */
11
12
  "connect_failed"
@@ -41,3 +42,29 @@ export declare class PowDBError extends Error {
41
42
  * to branch on `.code` but TypeScript sees `unknown`.
42
43
  */
43
44
  export declare function isPowDBError(err: unknown): err is PowDBError;
45
+ /**
46
+ * Failure of one statement inside `client.execScript(...)` (fail-fast mode).
47
+ *
48
+ * `code` mirrors the failing statement's error code (`"query_failed"` for a
49
+ * server Error frame, `"aborted"` for a fired AbortSignal, ...), so the usual
50
+ * `.code` branching keeps working; `cause` carries the underlying error.
51
+ * `statementIndex`/`statement` identify the failing statement within the
52
+ * split script, and `results` holds the successful results of every
53
+ * statement before it, in order.
54
+ */
55
+ export declare class PowDBScriptError extends PowDBError {
56
+ /** Zero-based index of the failing statement within the split script. */
57
+ readonly statementIndex: number;
58
+ /** Text of the failing statement (as split, trimmed). */
59
+ readonly statement: string;
60
+ /** Results of the statements before the failing one, in order. */
61
+ readonly results: QueryResult[];
62
+ constructor(message: string, code: PowDBErrorCode, details: {
63
+ statementIndex: number;
64
+ statement: string;
65
+ results: QueryResult[];
66
+ cause?: unknown;
67
+ });
68
+ }
69
+ /** Narrow `unknown` to a PowDBScriptError. */
70
+ export declare function isPowDBScriptError(err: unknown): err is PowDBScriptError;
package/dist/errors.js CHANGED
@@ -29,3 +29,33 @@ export class PowDBError extends Error {
29
29
  export function isPowDBError(err) {
30
30
  return err instanceof PowDBError;
31
31
  }
32
+ /**
33
+ * Failure of one statement inside `client.execScript(...)` (fail-fast mode).
34
+ *
35
+ * `code` mirrors the failing statement's error code (`"query_failed"` for a
36
+ * server Error frame, `"aborted"` for a fired AbortSignal, ...), so the usual
37
+ * `.code` branching keeps working; `cause` carries the underlying error.
38
+ * `statementIndex`/`statement` identify the failing statement within the
39
+ * split script, and `results` holds the successful results of every
40
+ * statement before it, in order.
41
+ */
42
+ export class PowDBScriptError extends PowDBError {
43
+ /** Zero-based index of the failing statement within the split script. */
44
+ statementIndex;
45
+ /** Text of the failing statement (as split, trimmed). */
46
+ statement;
47
+ /** Results of the statements before the failing one, in order. */
48
+ results;
49
+ constructor(message, code, details) {
50
+ super(message, code, { cause: details.cause });
51
+ this.name = "PowDBScriptError";
52
+ this.statementIndex = details.statementIndex;
53
+ this.statement = details.statement;
54
+ this.results = details.results;
55
+ Object.setPrototypeOf(this, PowDBScriptError.prototype);
56
+ }
57
+ }
58
+ /** Narrow `unknown` to a PowDBScriptError. */
59
+ export function isPowDBScriptError(err) {
60
+ return err instanceof PowDBScriptError;
61
+ }
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ import { EventEmitter } from "node:events";
19
19
  import { type SyncRepairAction, type WireRetainedUnit, type WireSyncStatus } from "./protocol.js";
20
20
  import { type TypedRow, type TypedSchema } from "./typed.js";
21
21
  /** Client library version. Compared to the server's reported version. */
22
- export declare const CLIENT_VERSION = "0.8.1";
22
+ export declare const CLIENT_VERSION = "0.10.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";