@zvndev/powdb-client 0.3.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/src/index.ts ADDED
@@ -0,0 +1,652 @@
1
+ /**
2
+ * PowDB TypeScript client.
3
+ *
4
+ * Thin async wrapper around a TCP (or TLS) socket speaking the PowDB wire
5
+ * protocol.
6
+ *
7
+ * const client = await Client.connect({
8
+ * host: "213.188.194.202",
9
+ * port: 5433,
10
+ * dbName: "default",
11
+ * password: process.env.POWDB_PASSWORD,
12
+ * });
13
+ *
14
+ * const result = await client.query("User filter .age > 27 { .name, .age }");
15
+ * await client.close();
16
+ */
17
+
18
+ import * as net from "node:net";
19
+ import * as tls from "node:tls";
20
+ import { EventEmitter } from "node:events";
21
+ import { encode, tryDecode, type Message } from "./protocol.js";
22
+ import { PowDBError } from "./errors.js";
23
+ import {
24
+ coerceRows,
25
+ type TypedRow,
26
+ type TypedSchema,
27
+ } from "./typed.js";
28
+
29
+ /** Client library version. Compared to the server's reported version. */
30
+ export const CLIENT_VERSION = "0.3.0";
31
+
32
+ export type QueryResult =
33
+ | { kind: "rows"; columns: string[]; rows: string[][] }
34
+ | { kind: "scalar"; value: string }
35
+ | { kind: "ok"; affected: bigint };
36
+
37
+ export interface ClientOptions {
38
+ host: string;
39
+ port: number;
40
+ dbName?: string;
41
+ password?: string | null;
42
+ /** Connection timeout in ms. Defaults to 5000. */
43
+ connectTimeoutMs?: number;
44
+ /**
45
+ * Enable TLS. When `true`, connect over TLS with system defaults
46
+ * (servername is taken from `host`). When an object, passed through to
47
+ * `tls.connect(port, host, options)`. Defaults to plain TCP.
48
+ */
49
+ tls?: boolean | tls.ConnectionOptions;
50
+ }
51
+
52
+ type Pending = {
53
+ resolve: (msg: Message) => void;
54
+ reject: (err: Error) => void;
55
+ /** Set to true once the promise has been resolved or rejected. */
56
+ settled: boolean;
57
+ };
58
+
59
+ /** Module-level set of host:port pairs we've already warned about. */
60
+ const versionWarnings = new Set<string>();
61
+
62
+ /** Extract the major component of a dotted version string, e.g. "0.2.0" → "0". */
63
+ function majorOf(version: string): string {
64
+ const dot = version.indexOf(".");
65
+ return dot === -1 ? version : version.slice(0, dot);
66
+ }
67
+
68
+ /**
69
+ * Build an AbortError. Prefers the caller-supplied `signal.reason` if it is
70
+ * itself an `Error` (matches DOM semantics); otherwise wraps it in a
71
+ * `PowDBError` with code `"aborted"` so catch-blocks can branch uniformly.
72
+ */
73
+ function abortError(signal?: AbortSignal): Error {
74
+ if (signal && signal.reason !== undefined) {
75
+ const r = signal.reason;
76
+ if (r instanceof Error) return r;
77
+ return new PowDBError(String(r), "aborted");
78
+ }
79
+ return new PowDBError("query was aborted", "aborted");
80
+ }
81
+
82
+ /**
83
+ * Event map for {@link Client}. Typed so `client.on("query", ...)` gets
84
+ * inference for the payload.
85
+ *
86
+ * - `"query"`: one emission per completed (or failed) query attempt,
87
+ * whether via `query` or `queryTyped`. `durationMs` is the client-side
88
+ * round-trip including abort handling. `ok=false` means the server
89
+ * returned an Error frame or the client rejected locally.
90
+ * - `"close"`: the underlying socket has been fully torn down. Emitted
91
+ * once per client.
92
+ */
93
+ export interface ClientEvents {
94
+ query: [
95
+ {
96
+ query: string;
97
+ durationMs: number;
98
+ ok: boolean;
99
+ kind?: "rows" | "scalar" | "ok";
100
+ error?: Error;
101
+ },
102
+ ];
103
+ close: [{ error: Error | null }];
104
+ }
105
+
106
+ export class Client extends EventEmitter<ClientEvents> {
107
+ private readonly socket: net.Socket;
108
+ /** FIFO of raw chunks; concatenated lazily when we try to decode. */
109
+ private readonly chunks: Buffer[] = [];
110
+ /** Cached length of everything currently in `chunks`. */
111
+ private totalLen = 0;
112
+ private readonly pending: Pending[] = [];
113
+ private closed = false;
114
+ private closeError: Error | null = null;
115
+
116
+ readonly serverVersion: string;
117
+
118
+ private constructor(socket: net.Socket, serverVersion: string) {
119
+ super();
120
+ this.socket = socket;
121
+ this.serverVersion = serverVersion;
122
+
123
+ this.socket.on("data", (chunk) => this.onData(chunk));
124
+ this.socket.on("error", (err) => this.onClose(err));
125
+ this.socket.on("close", () => this.onClose(null));
126
+ }
127
+
128
+ /** Open a connection, send Connect, wait for ConnectOk. */
129
+ static async connect(opts: ClientOptions): Promise<Client> {
130
+ const {
131
+ host,
132
+ port,
133
+ dbName = "default",
134
+ password = null,
135
+ connectTimeoutMs = 5000,
136
+ tls: tlsOpt = false,
137
+ } = opts;
138
+
139
+ const socket = await openSocket(host, port, connectTimeoutMs, tlsOpt);
140
+
141
+ // We need to read the initial ConnectOk before wiring up the normal
142
+ // pending-queue machinery, so we do a one-shot handshake here.
143
+ const handshake = new Promise<Message>((resolve, reject) => {
144
+ let scratch = Buffer.alloc(0);
145
+ const cleanup = () => {
146
+ socket.removeListener("data", onData);
147
+ socket.removeListener("error", onError);
148
+ socket.removeListener("close", onClose);
149
+ };
150
+ const onData = (chunk: Buffer) => {
151
+ scratch = Buffer.concat([scratch, chunk]);
152
+ let decoded: { msg: Message; consumed: number } | null;
153
+ try {
154
+ decoded = tryDecode(scratch);
155
+ } catch (err) {
156
+ cleanup();
157
+ reject(err as Error);
158
+ return;
159
+ }
160
+ if (decoded !== null) {
161
+ cleanup();
162
+ // Any bytes past the handshake frame belong to later responses.
163
+ // This should not happen in practice, but handle it defensively.
164
+ const leftover = scratch.subarray(decoded.consumed);
165
+ if (leftover.length > 0) {
166
+ socket.unshift(leftover);
167
+ }
168
+ resolve(decoded.msg);
169
+ }
170
+ };
171
+ const onError = (err: Error) => {
172
+ cleanup();
173
+ reject(err);
174
+ };
175
+ // Without this, a peer that FINs the connection mid-handshake (no
176
+ // explicit error event) would leave this promise pending forever.
177
+ const onClose = () => {
178
+ cleanup();
179
+ reject(new PowDBError("connection closed during handshake", "connect_failed"));
180
+ };
181
+ socket.on("data", onData);
182
+ socket.on("error", onError);
183
+ socket.on("close", onClose);
184
+ });
185
+
186
+ socket.write(encode({ type: "Connect", dbName, password }));
187
+ const reply = await handshake;
188
+
189
+ if (reply.type === "Error") {
190
+ socket.destroy();
191
+ throw new PowDBError(`connect failed: ${reply.message}`, "auth_failed");
192
+ }
193
+ if (reply.type !== "ConnectOk") {
194
+ socket.destroy();
195
+ throw new PowDBError(`expected ConnectOk, got ${reply.type}`, "protocol_error");
196
+ }
197
+
198
+ // Advisory: warn once per host:port if the server's major differs
199
+ // from the client's. Do not throw or close — this is best-effort.
200
+ const serverMajor = majorOf(reply.version);
201
+ const clientMajor = majorOf(CLIENT_VERSION);
202
+ if (serverMajor !== clientMajor) {
203
+ const key = `${host}:${port}`;
204
+ if (!versionWarnings.has(key)) {
205
+ versionWarnings.add(key);
206
+ console.warn(
207
+ `[powdb] server version ${reply.version} major (${serverMajor}) ` +
208
+ `differs from client ${CLIENT_VERSION} major (${clientMajor}); ` +
209
+ `behaviour may be inconsistent.`,
210
+ );
211
+ }
212
+ }
213
+
214
+ return new Client(socket, reply.version);
215
+ }
216
+
217
+ /**
218
+ * Run a PowQL statement and return the typed result.
219
+ *
220
+ * When `opts.signal` is provided and fires, the returned promise rejects
221
+ * with the signal's `reason` (or an `AbortError`). The socket is NOT
222
+ * destroyed — the server will still eventually send its reply, which we
223
+ * silently discard so other in-flight queries keep working.
224
+ */
225
+ async query(
226
+ query: string,
227
+ opts?: { signal?: AbortSignal },
228
+ ): Promise<QueryResult> {
229
+ const start = Date.now();
230
+ try {
231
+ const reply = await this.send({ type: "Query", query }, opts);
232
+ let result: QueryResult;
233
+ switch (reply.type) {
234
+ case "ResultRows":
235
+ result = { kind: "rows", columns: reply.columns, rows: reply.rows };
236
+ break;
237
+ case "ResultScalar":
238
+ result = { kind: "scalar", value: reply.value };
239
+ break;
240
+ case "ResultOk":
241
+ result = { kind: "ok", affected: reply.affected };
242
+ break;
243
+ case "Error":
244
+ throw new PowDBError(`query failed: ${reply.message}`, "query_failed");
245
+ default:
246
+ throw new PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
247
+ }
248
+ this.emit("query", {
249
+ query,
250
+ durationMs: Date.now() - start,
251
+ ok: true,
252
+ kind: result.kind,
253
+ });
254
+ return result;
255
+ } catch (err) {
256
+ this.emit("query", {
257
+ query,
258
+ durationMs: Date.now() - start,
259
+ ok: false,
260
+ error: err as Error,
261
+ });
262
+ throw err;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Like {@link query}, but coerces string result columns to typed JS values
268
+ * using the caller-supplied schema. See `./typed.ts` for the coercion
269
+ * rules and supported column types.
270
+ *
271
+ * Returns a `TypedRow[]` — an array of objects keyed by column name.
272
+ * Throws `PowDBError(code="query_failed")` if the query is not a
273
+ * rows-returning query.
274
+ */
275
+ async queryTyped(
276
+ query: string,
277
+ schema: TypedSchema,
278
+ opts?: { signal?: AbortSignal },
279
+ ): Promise<TypedRow[]> {
280
+ const result = await this.query(query, opts);
281
+ if (result.kind !== "rows") {
282
+ throw new PowDBError(
283
+ `queryTyped: expected rows result, got ${result.kind}`,
284
+ "query_failed",
285
+ );
286
+ }
287
+ return coerceRows(result.columns, result.rows, schema);
288
+ }
289
+
290
+ /**
291
+ * Poll a query on an interval and invoke `onRows` for every successful
292
+ * run. Returns an unsubscribe function. Does NOT deduplicate results —
293
+ * `onRows` fires every interval, even if the rows are unchanged.
294
+ *
295
+ * Pragmatic first-cut live-data. If a query takes longer than
296
+ * `intervalMs`, the next tick waits for the in-flight one to finish
297
+ * (no pile-up). Errors fire `onError` (if provided) without stopping
298
+ * the watcher unless `stopOnError: true`.
299
+ */
300
+ watch(
301
+ query: string,
302
+ opts: {
303
+ intervalMs: number;
304
+ onRows: (rows: QueryResult) => void;
305
+ onError?: (err: Error) => void;
306
+ stopOnError?: boolean;
307
+ },
308
+ ): { stop: () => void } {
309
+ if (!(opts.intervalMs > 0)) {
310
+ throw new PowDBError(
311
+ `watch: intervalMs must be > 0, got ${opts.intervalMs}`,
312
+ "protocol_error",
313
+ );
314
+ }
315
+ let stopped = false;
316
+ let inFlight = false;
317
+
318
+ const tick = async () => {
319
+ if (stopped || inFlight) return;
320
+ inFlight = true;
321
+ try {
322
+ const r = await this.query(query);
323
+ if (!stopped) opts.onRows(r);
324
+ } catch (err) {
325
+ if (stopped) return;
326
+ if (opts.onError) opts.onError(err as Error);
327
+ if (opts.stopOnError) {
328
+ stopped = true;
329
+ clearInterval(handle);
330
+ return;
331
+ }
332
+ } finally {
333
+ inFlight = false;
334
+ }
335
+ };
336
+
337
+ // Run immediately so callers don't wait a full interval for the first
338
+ // emission, then every `intervalMs`.
339
+ void tick();
340
+ const handle: NodeJS.Timeout = setInterval(tick, opts.intervalMs);
341
+ if (typeof handle.unref === "function") handle.unref();
342
+
343
+ return {
344
+ stop: () => {
345
+ stopped = true;
346
+ clearInterval(handle);
347
+ },
348
+ };
349
+ }
350
+
351
+ /**
352
+ * Send Disconnect and tear down the socket. Waits for the remote FIN-ack
353
+ * (`socket.end` callback) but falls back to `destroy()` after a bounded
354
+ * timeout so an unresponsive peer cannot make `close()` hang forever.
355
+ */
356
+ async close(): Promise<void> {
357
+ if (this.closed) return;
358
+ try {
359
+ this.socket.write(encode({ type: "Disconnect" }));
360
+ } catch {
361
+ // socket may already be half-closed; ignore
362
+ }
363
+ this.closed = true;
364
+ await new Promise<void>((resolve) => {
365
+ let done = false;
366
+ const finish = () => {
367
+ if (done) return;
368
+ done = true;
369
+ clearTimeout(timer);
370
+ resolve();
371
+ };
372
+ const timer = setTimeout(() => {
373
+ // Peer didn't ack FIN in time — force-close so we don't hang.
374
+ this.socket.destroy();
375
+ finish();
376
+ }, 5_000);
377
+ if (typeof timer.unref === "function") timer.unref();
378
+ this.socket.end(finish);
379
+ });
380
+ }
381
+
382
+ // ───── internals ─────────────────────────────────────────────────────────
383
+
384
+ private send(
385
+ msg: Message,
386
+ opts?: { signal?: AbortSignal },
387
+ ): Promise<Message> {
388
+ if (this.closed) {
389
+ return Promise.reject(
390
+ this.closeError ?? new PowDBError("client is closed", "closed"),
391
+ );
392
+ }
393
+
394
+ const signal = opts?.signal;
395
+
396
+ // Pre-check: if already aborted, reject immediately and do not enqueue.
397
+ // This matches fetch() semantics for pre-aborted signals.
398
+ if (signal?.aborted) {
399
+ return Promise.reject(abortError(signal));
400
+ }
401
+
402
+ return new Promise((resolve, reject) => {
403
+ const entry: Pending = {
404
+ resolve: (m) => {
405
+ entry.settled = true;
406
+ resolve(m);
407
+ },
408
+ reject: (e) => {
409
+ entry.settled = true;
410
+ reject(e);
411
+ },
412
+ settled: false,
413
+ };
414
+ this.pending.push(entry);
415
+
416
+ let onAbort: (() => void) | null = null;
417
+ if (signal) {
418
+ onAbort = () => {
419
+ if (entry.settled) return;
420
+ // Mark settled but DO NOT remove the entry from the queue — the
421
+ // server will still send a reply, and onData drops replies for
422
+ // already-settled entries at the head of the queue.
423
+ entry.settled = true;
424
+ reject(abortError(signal));
425
+ };
426
+ signal.addEventListener("abort", onAbort, { once: true });
427
+ // Strip the listener once the entry resolves/rejects naturally.
428
+ const origResolve = entry.resolve;
429
+ const origReject = entry.reject;
430
+ entry.resolve = (m) => {
431
+ if (onAbort) signal.removeEventListener("abort", onAbort);
432
+ origResolve(m);
433
+ };
434
+ entry.reject = (e) => {
435
+ if (onAbort) signal.removeEventListener("abort", onAbort);
436
+ origReject(e);
437
+ };
438
+ }
439
+
440
+ this.socket.write(encode(msg), (err) => {
441
+ if (err) {
442
+ if (entry.settled) return;
443
+ // Writer error — the promise will also be rejected by onClose,
444
+ // but rejecting here gives a faster, more specific failure.
445
+ //
446
+ // Splicing an entry from the middle of `pending` is only safe
447
+ // because a write-callback error implies the bytes never reached
448
+ // the server — no reply will ever come for this slot, so FIFO
449
+ // alignment with subsequent entries is preserved.
450
+ const idx = this.pending.indexOf(entry);
451
+ if (idx !== -1) this.pending.splice(idx, 1);
452
+ entry.reject(err);
453
+ }
454
+ });
455
+ });
456
+ }
457
+
458
+ private onData(chunk: Buffer): void {
459
+ // Append to the chunk queue — O(1) — and lazily concat only when
460
+ // we actually need contiguous bytes to decode.
461
+ this.chunks.push(chunk);
462
+ this.totalLen += chunk.length;
463
+
464
+ while (this.totalLen > 0) {
465
+ // Fast path: if the first chunk already contains a full frame, we
466
+ // can decode without concatenating.
467
+ let view: Buffer;
468
+ if (this.chunks.length === 1) {
469
+ view = this.chunks[0]!;
470
+ } else {
471
+ // Peek at the header if we don't already have >=6 bytes up front.
472
+ // We need up to 6 bytes to read payloadLen, then enough to hold
473
+ // the full frame. Coalesce lazily.
474
+ if (this.chunks[0]!.length < 6 && this.totalLen >= 6) {
475
+ this.coalesce();
476
+ }
477
+ // If the first chunk still has a full frame, great. Otherwise
478
+ // coalesce the whole queue so tryDecode sees contiguous bytes.
479
+ const first = this.chunks[0]!;
480
+ if (first.length >= 6) {
481
+ const payloadLen = first.readUInt32LE(2);
482
+ if (first.length >= 6 + payloadLen) {
483
+ view = first;
484
+ } else if (this.totalLen >= 6 + payloadLen) {
485
+ this.coalesce();
486
+ view = this.chunks[0]!;
487
+ } else {
488
+ // Not enough bytes yet for the full frame — wait for more data.
489
+ break;
490
+ }
491
+ } else {
492
+ // Still short of a header even after coalesce attempt above.
493
+ break;
494
+ }
495
+ }
496
+
497
+ let decoded: { msg: Message; consumed: number } | null;
498
+ try {
499
+ decoded = tryDecode(view);
500
+ } catch (err) {
501
+ this.onClose(err as Error);
502
+ return;
503
+ }
504
+ if (decoded === null) break;
505
+
506
+ // Advance past the consumed bytes without copying the trailing data.
507
+ this.consume(decoded.consumed);
508
+
509
+ // Find the next non-settled pending entry and hand it the reply.
510
+ // Settled entries at the head were aborted by the caller but their
511
+ // reply is arriving now — drop it silently.
512
+ let entry = this.pending.shift();
513
+ while (entry && entry.settled) {
514
+ entry = this.pending.shift();
515
+ }
516
+ if (!entry) {
517
+ // Server sent an unsolicited frame (or we got extra after aborts
518
+ // with no replacement). Treat as protocol error.
519
+ this.onClose(
520
+ new PowDBError("received unexpected frame from server", "protocol_error"),
521
+ );
522
+ return;
523
+ }
524
+ entry.resolve(decoded.msg);
525
+ }
526
+ }
527
+
528
+ /** Collapse the chunk queue into a single Buffer. */
529
+ private coalesce(): void {
530
+ if (this.chunks.length <= 1) return;
531
+ const merged = Buffer.concat(this.chunks, this.totalLen);
532
+ this.chunks.length = 0;
533
+ this.chunks.push(merged);
534
+ }
535
+
536
+ /** Drop the first `n` bytes off the chunk queue. */
537
+ private consume(n: number): void {
538
+ let remaining = n;
539
+ while (remaining > 0 && this.chunks.length > 0) {
540
+ const head = this.chunks[0]!;
541
+ if (head.length <= remaining) {
542
+ remaining -= head.length;
543
+ this.totalLen -= head.length;
544
+ this.chunks.shift();
545
+ } else {
546
+ this.chunks[0] = head.subarray(remaining);
547
+ this.totalLen -= remaining;
548
+ remaining = 0;
549
+ }
550
+ }
551
+ }
552
+
553
+ private onClose(err: Error | null): void {
554
+ const firstClose = !this.closed;
555
+ if (this.closed && err === null) return;
556
+ this.closed = true;
557
+ this.closeError = err;
558
+ const error = err ?? new PowDBError("connection closed", "closed");
559
+ while (this.pending.length > 0) {
560
+ const entry = this.pending.shift()!;
561
+ if (!entry.settled) {
562
+ entry.reject(error);
563
+ }
564
+ }
565
+ if (firstClose) {
566
+ // Best-effort: surface the close event once. Late listeners on a
567
+ // closed client miss it, which matches Node socket semantics.
568
+ this.emit("close", { error: err });
569
+ }
570
+ }
571
+ }
572
+
573
+ function openSocket(
574
+ host: string,
575
+ port: number,
576
+ timeoutMs: number,
577
+ tlsOpt: boolean | tls.ConnectionOptions,
578
+ ): Promise<net.Socket> {
579
+ return new Promise((resolve, reject) => {
580
+ let socket: net.Socket;
581
+ const timer = setTimeout(() => {
582
+ socket.destroy();
583
+ reject(new PowDBError(`connect timeout after ${timeoutMs}ms`, "timeout"));
584
+ }, timeoutMs);
585
+
586
+ const onConnect = () => {
587
+ clearTimeout(timer);
588
+ socket.setNoDelay(true);
589
+ // Enable TCP keepalive on the underlying OS socket so dead peers are
590
+ // detected even when the app is otherwise idle.
591
+ socket.setKeepAlive(true, 30_000);
592
+ resolve(socket);
593
+ };
594
+ const onError = (err: Error) => {
595
+ clearTimeout(timer);
596
+ // Wrap raw socket errors in the PowDBError taxonomy — callers can
597
+ // branch on `.code === "connect_failed"` rather than string-matching.
598
+ reject(new PowDBError(`connect failed: ${err.message}`, "connect_failed", { cause: err }));
599
+ };
600
+
601
+ if (tlsOpt) {
602
+ // TLS path: `tls.connect` wraps an underlying net.Socket. `secureConnect`
603
+ // fires once the TLS handshake is complete — that is the right hook for
604
+ // "ready to send application data".
605
+ const tlsOptions: tls.ConnectionOptions =
606
+ tlsOpt === true ? {} : tlsOpt;
607
+ const tlsSock = tls.connect(port, host, tlsOptions);
608
+ socket = tlsSock;
609
+ tlsSock.once("secureConnect", onConnect);
610
+ tlsSock.once("error", onError);
611
+ } else {
612
+ socket = new net.Socket();
613
+ socket.once("connect", onConnect);
614
+ socket.once("error", onError);
615
+ socket.connect(port, host);
616
+ }
617
+ });
618
+ }
619
+
620
+ export { encode, tryDecode } from "./protocol.js";
621
+ export type { Message } from "./protocol.js";
622
+ export {
623
+ MAX_PAYLOAD_SIZE,
624
+ MAX_ROWS,
625
+ MAX_COLUMNS,
626
+ } from "./protocol.js";
627
+
628
+ export {
629
+ escapeLiteral,
630
+ escapeIdent,
631
+ ident,
632
+ powql,
633
+ PowqlIdent,
634
+ } from "./escape.js";
635
+
636
+ export { Pool } from "./pool.js";
637
+ export type { PoolOptions } from "./pool.js";
638
+
639
+ export { PowDBError, isPowDBError } from "./errors.js";
640
+ export type { PowDBErrorCode } from "./errors.js";
641
+
642
+ export {
643
+ coerceValue,
644
+ coerceRow,
645
+ coerceRows,
646
+ } from "./typed.js";
647
+ export type {
648
+ ColumnType,
649
+ TypedSchema,
650
+ TypedRow,
651
+ Coerced,
652
+ } from "./typed.js";