@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/pool.ts ADDED
@@ -0,0 +1,375 @@
1
+ /**
2
+ * A tiny connection pool for the PowDB TypeScript client.
3
+ *
4
+ * const pool = new Pool({ host, port, max: 8 });
5
+ *
6
+ * // manual acquire/release
7
+ * const c = await pool.acquire();
8
+ * try {
9
+ * await c.query("...");
10
+ * pool.release(c);
11
+ * } catch (err) {
12
+ * // a broken client should NOT be returned to the pool
13
+ * pool.destroy(c);
14
+ * throw err;
15
+ * }
16
+ *
17
+ * // ...or, preferred, use the scoped helper:
18
+ * const users = await pool.withClient((c) => c.query("Users"));
19
+ *
20
+ * await pool.close();
21
+ */
22
+
23
+ import { Client, type ClientOptions } from "./index.js";
24
+ import { PowDBError, isPowDBError } from "./errors.js";
25
+
26
+ export interface PoolOptions extends ClientOptions {
27
+ /** Maximum concurrent clients. Default 10. */
28
+ max?: number;
29
+ /** Acquire timeout in ms. Default 30_000. */
30
+ acquireTimeoutMs?: number;
31
+ /**
32
+ * How many times to retry `Client.connect` on transient errors
33
+ * (connect_failed / timeout). Auth failures and other non-transient
34
+ * errors are never retried. Default 3. Set to 0 to disable.
35
+ */
36
+ connectRetries?: number;
37
+ /**
38
+ * Initial backoff (ms) between connect retries. Each subsequent retry
39
+ * doubles the delay up to `connectMaxBackoffMs`. Default 100.
40
+ */
41
+ connectBackoffMs?: number;
42
+ /** Maximum backoff (ms) between connect retries. Default 2_000. */
43
+ connectMaxBackoffMs?: number;
44
+ }
45
+
46
+ /**
47
+ * Is this error worth retrying? Only the transient connect-phase errors
48
+ * are retryable. Auth failures, size violations, aborts, and protocol
49
+ * errors are not — retrying would just fail the same way.
50
+ */
51
+ function isRetryable(err: unknown): boolean {
52
+ if (!isPowDBError(err)) {
53
+ // Raw socket errors surface as generic Error before we wrap them in
54
+ // connect_failed. In practice connect() wraps them, but be defensive.
55
+ return err instanceof Error;
56
+ }
57
+ return err.code === "connect_failed" || err.code === "timeout";
58
+ }
59
+
60
+ function sleep(ms: number): Promise<void> {
61
+ return new Promise((resolve) => {
62
+ const t = setTimeout(resolve, ms);
63
+ if (typeof t.unref === "function") t.unref();
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Connect to the server with bounded retry + exponential backoff. Throws
69
+ * the last error if every attempt fails, or the first error unchanged if
70
+ * it was not retryable.
71
+ */
72
+ async function connectWithRetry(
73
+ opts: ClientOptions,
74
+ retries: number,
75
+ initialBackoffMs: number,
76
+ maxBackoffMs: number,
77
+ ): Promise<Client> {
78
+ let lastErr: unknown;
79
+ let delay = initialBackoffMs;
80
+ for (let attempt = 0; attempt <= retries; attempt++) {
81
+ try {
82
+ return await Client.connect(opts);
83
+ } catch (err) {
84
+ lastErr = err;
85
+ if (!isRetryable(err) || attempt === retries) break;
86
+ await sleep(delay);
87
+ delay = Math.min(delay * 2, maxBackoffMs);
88
+ }
89
+ }
90
+ throw lastErr;
91
+ }
92
+
93
+ type Waiter = {
94
+ resolve: (c: Client) => void;
95
+ reject: (err: Error) => void;
96
+ timer: NodeJS.Timeout | null;
97
+ };
98
+
99
+ /**
100
+ * Lazy, FIFO connection pool.
101
+ *
102
+ * Semantics:
103
+ * - `acquire` returns an idle client if one exists; otherwise creates a new
104
+ * one up to `max`; otherwise waits until `release`/`destroy` frees a slot
105
+ * (subject to `acquireTimeoutMs`).
106
+ * - `release(c)` puts `c` back in the idle queue unconditionally. **If the
107
+ * caller observed a socket/connection error on `c`, they MUST call
108
+ * `destroy(c)` instead** — the pool cannot safely detect dead sockets.
109
+ * - `destroy(c)` closes `c` and decrements the live count, freeing a slot
110
+ * for a new client to be created.
111
+ * - `withClient(fn)` acquires, runs `fn`, and always destroys on error or
112
+ * releases on success. This is the safe default for most call sites.
113
+ * - `close()` rejects any pending waiters and awaits `close()` on every
114
+ * idle client. Subsequent calls to `acquire` reject immediately.
115
+ */
116
+ export class Pool {
117
+ private readonly opts: ClientOptions;
118
+ private readonly max: number;
119
+ private readonly acquireTimeoutMs: number;
120
+ private readonly connectRetries: number;
121
+ private readonly connectBackoffMs: number;
122
+ private readonly connectMaxBackoffMs: number;
123
+
124
+ private readonly idleClients: Client[] = [];
125
+ private readonly waiters: Waiter[] = [];
126
+ /** Clients owned by this pool (idle + checked-out). Used to reject
127
+ * foreign/double-destroyed clients from corrupting the slot accounting. */
128
+ private readonly owned: Set<Client> = new Set();
129
+ private live = 0;
130
+ private _closed = false;
131
+
132
+ constructor(opts: PoolOptions) {
133
+ const {
134
+ max = 10,
135
+ acquireTimeoutMs = 30_000,
136
+ connectRetries = 3,
137
+ connectBackoffMs = 100,
138
+ connectMaxBackoffMs = 2_000,
139
+ ...clientOpts
140
+ } = opts;
141
+ if (!Number.isFinite(max) || max < 1) {
142
+ throw new TypeError(`Pool: max must be a positive integer, got ${max}`);
143
+ }
144
+ if (!Number.isFinite(acquireTimeoutMs) || acquireTimeoutMs < 0) {
145
+ throw new TypeError(
146
+ `Pool: acquireTimeoutMs must be a non-negative number, got ${acquireTimeoutMs}`
147
+ );
148
+ }
149
+ if (!Number.isFinite(connectRetries) || connectRetries < 0) {
150
+ throw new TypeError(
151
+ `Pool: connectRetries must be a non-negative integer, got ${connectRetries}`
152
+ );
153
+ }
154
+ this.opts = clientOpts;
155
+ this.max = max;
156
+ this.acquireTimeoutMs = acquireTimeoutMs;
157
+ this.connectRetries = connectRetries;
158
+ this.connectBackoffMs = connectBackoffMs;
159
+ this.connectMaxBackoffMs = connectMaxBackoffMs;
160
+ }
161
+
162
+ /**
163
+ * Wraps Client.connect with the pool's configured retry policy. Used by
164
+ * `acquire` and `drainWaiters`.
165
+ */
166
+ private connect(): Promise<Client> {
167
+ return connectWithRetry(
168
+ this.opts,
169
+ this.connectRetries,
170
+ this.connectBackoffMs,
171
+ this.connectMaxBackoffMs,
172
+ );
173
+ }
174
+
175
+ /** Live client count (idle + checked out). */
176
+ get size(): number {
177
+ return this.live;
178
+ }
179
+
180
+ /** Clients currently sitting in the idle queue. */
181
+ get idle(): number {
182
+ return this.idleClients.length;
183
+ }
184
+
185
+ /** `true` once {@link close} has been called. */
186
+ get closed(): boolean {
187
+ return this._closed;
188
+ }
189
+
190
+ /**
191
+ * Acquire a client from the pool.
192
+ *
193
+ * Resolves with an idle client if one is available, creates a new one if
194
+ * `live < max`, or waits in a FIFO queue. Rejects if `acquireTimeoutMs`
195
+ * elapses, or if the pool is closed.
196
+ */
197
+ async acquire(): Promise<Client> {
198
+ if (this._closed) {
199
+ throw new Error("pool closed");
200
+ }
201
+
202
+ const existing = this.idleClients.shift();
203
+ if (existing !== undefined) {
204
+ return existing;
205
+ }
206
+
207
+ if (this.live < this.max) {
208
+ this.live++;
209
+ try {
210
+ const c = await this.connect();
211
+ this.owned.add(c);
212
+ return c;
213
+ } catch (err) {
214
+ // Creation failed — we never had a live client, so give the slot
215
+ // back and propagate.
216
+ this.live--;
217
+ // A newly-freed slot may let a pending waiter try again... but only
218
+ // via a future release/destroy. To be fair to waiters, schedule a
219
+ // drain here.
220
+ this.drainWaiters();
221
+ throw err;
222
+ }
223
+ }
224
+
225
+ return new Promise<Client>((resolve, reject) => {
226
+ const waiter: Waiter = {
227
+ resolve,
228
+ reject,
229
+ timer: null,
230
+ };
231
+ if (this.acquireTimeoutMs > 0) {
232
+ waiter.timer = setTimeout(() => {
233
+ const idx = this.waiters.indexOf(waiter);
234
+ if (idx !== -1) this.waiters.splice(idx, 1);
235
+ reject(new Error("pool acquire timeout"));
236
+ }, this.acquireTimeoutMs);
237
+ // Don't keep the event loop alive just for the timeout.
238
+ if (typeof waiter.timer.unref === "function") waiter.timer.unref();
239
+ }
240
+ this.waiters.push(waiter);
241
+ });
242
+ }
243
+
244
+ /**
245
+ * Return `c` to the idle queue so subsequent `acquire` calls can hand it
246
+ * out. If the pool is closed, `c` is closed instead.
247
+ *
248
+ * **Do not call this if `c` has experienced a connection error** — use
249
+ * {@link destroy} for that case.
250
+ */
251
+ release(c: Client): void {
252
+ if (!this.owned.has(c)) {
253
+ // Foreign client or already-destroyed — refuse to touch accounting.
254
+ return;
255
+ }
256
+
257
+ if (this._closed) {
258
+ // Pool is gone — just close the client, fire-and-forget.
259
+ this.owned.delete(c);
260
+ if (this.live > 0) this.live--;
261
+ void c.close().catch(() => {});
262
+ return;
263
+ }
264
+
265
+ const waiter = this.waiters.shift();
266
+ if (waiter !== undefined) {
267
+ if (waiter.timer !== null) clearTimeout(waiter.timer);
268
+ waiter.resolve(c);
269
+ return;
270
+ }
271
+
272
+ this.idleClients.push(c);
273
+ }
274
+
275
+ /**
276
+ * Close `c` and free its slot so a new client can be created on the next
277
+ * `acquire`. Use this when `c` has hit a socket error or is otherwise
278
+ * known-bad.
279
+ */
280
+ destroy(c: Client): void {
281
+ if (!this.owned.has(c)) {
282
+ // Foreign client or already destroyed — don't touch slot accounting.
283
+ return;
284
+ }
285
+ this.owned.delete(c);
286
+ // Decrement first so any waiter we hand off to can create a fresh one.
287
+ if (this.live > 0) this.live--;
288
+ void c.close().catch(() => {});
289
+
290
+ if (this._closed) return;
291
+
292
+ // Drain one waiter if possible — the freed slot lets them create a new
293
+ // connection.
294
+ this.drainWaiters();
295
+ }
296
+
297
+ /**
298
+ * Acquire a client, run `fn`, and either release (on success) or destroy
299
+ * (on error). This is the recommended call site — it makes it impossible
300
+ * to leak a client on an unhandled exception.
301
+ */
302
+ async withClient<T>(fn: (c: Client) => Promise<T>): Promise<T> {
303
+ const c = await this.acquire();
304
+ try {
305
+ const out = await fn(c);
306
+ this.release(c);
307
+ return out;
308
+ } catch (err) {
309
+ this.destroy(c);
310
+ throw err;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Close the pool. Rejects all pending waiters, awaits `close()` on every
316
+ * idle client, and marks the pool closed. Subsequent `acquire` calls
317
+ * reject immediately.
318
+ *
319
+ * Checked-out clients are NOT tracked — callers that still hold one when
320
+ * `close()` is called are responsible for closing it themselves.
321
+ */
322
+ async close(): Promise<void> {
323
+ if (this._closed) return;
324
+ this._closed = true;
325
+
326
+ // Reject every pending waiter.
327
+ while (this.waiters.length > 0) {
328
+ const w = this.waiters.shift()!;
329
+ if (w.timer !== null) clearTimeout(w.timer);
330
+ w.reject(new Error("pool closed"));
331
+ }
332
+
333
+ // Close every idle client.
334
+ const idle = this.idleClients.splice(0, this.idleClients.length);
335
+ for (const c of idle) this.owned.delete(c);
336
+ this.live -= idle.length;
337
+ await Promise.all(idle.map((c) => c.close().catch(() => {})));
338
+ }
339
+
340
+ // ──────────────────────────────────────────────────────────
341
+ // internals
342
+ // ──────────────────────────────────────────────────────────
343
+
344
+ private drainWaiters(): void {
345
+ // If we have headroom AND a waiter, try to create a client for them.
346
+ // (Handles the "acquire failed → slot freed" path cleanly without
347
+ // forcing the caller to re-poll.)
348
+ if (this._closed) return;
349
+ while (this.waiters.length > 0 && this.live < this.max) {
350
+ const waiter = this.waiters.shift()!;
351
+ if (waiter.timer !== null) clearTimeout(waiter.timer);
352
+ this.live++;
353
+ this.connect().then(
354
+ (c) => {
355
+ // Pool may have been closed while the connect was in flight.
356
+ // If so, close the freshly-opened client instead of handing it
357
+ // to a waiter that's already been rejected.
358
+ if (this._closed) {
359
+ if (this.live > 0) this.live--;
360
+ void c.close().catch(() => {});
361
+ return;
362
+ }
363
+ this.owned.add(c);
364
+ waiter.resolve(c);
365
+ },
366
+ (err) => {
367
+ this.live--;
368
+ waiter.reject(err as Error);
369
+ // Keep trying for the next waiter, if any.
370
+ this.drainWaiters();
371
+ }
372
+ );
373
+ }
374
+ }
375
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * PowDB wire protocol.
3
+ *
4
+ * Frame format: [type(1)][flags(1)][len(4 LE)][payload]
5
+ * Strings are encoded as [len(4 LE)][utf-8 bytes].
6
+ *
7
+ * This mirrors crates/server/src/protocol.rs — keep them in sync.
8
+ */
9
+
10
+ export const MSG_CONNECT = 0x01;
11
+ export const MSG_CONNECT_OK = 0x02;
12
+ export const MSG_QUERY = 0x03;
13
+ export const MSG_RESULT_ROWS = 0x07;
14
+ export const MSG_RESULT_SCALAR = 0x08;
15
+ export const MSG_RESULT_OK = 0x09;
16
+ export const MSG_ERROR = 0x0a;
17
+ export const MSG_DISCONNECT = 0x10;
18
+
19
+ // ───── Size limits (mirror crates/server/src/protocol.rs) ──────────────────
20
+
21
+ /** Maximum payload size accepted from the wire (64 MB). */
22
+ export const MAX_PAYLOAD_SIZE = 64 * 1024 * 1024;
23
+
24
+ /** Maximum number of rows allowed in a single result message. */
25
+ export const MAX_ROWS = 10_000_000;
26
+
27
+ /** Maximum number of columns allowed in a result set. */
28
+ export const MAX_COLUMNS = 4096;
29
+
30
+ export type Message =
31
+ | { type: "Connect"; dbName: string; password: string | null }
32
+ | { type: "ConnectOk"; version: string }
33
+ | { type: "Query"; query: string }
34
+ | { type: "ResultRows"; columns: string[]; rows: string[][] }
35
+ | { type: "ResultScalar"; value: string }
36
+ | { type: "ResultOk"; affected: bigint }
37
+ | { type: "Error"; message: string }
38
+ | { type: "Disconnect" };
39
+
40
+ // ───── Encoding ────────────────────────────────────────────────────────────
41
+
42
+ export function encode(msg: Message): Buffer {
43
+ let msgType: number;
44
+ let payload: Buffer;
45
+
46
+ switch (msg.type) {
47
+ case "Connect": {
48
+ const dbBuf = encodeString(msg.dbName);
49
+ const pwBuf =
50
+ msg.password === null ? u32LE(0) : encodeString(msg.password);
51
+ payload = Buffer.concat([dbBuf, pwBuf]);
52
+ msgType = MSG_CONNECT;
53
+ break;
54
+ }
55
+ case "ConnectOk":
56
+ payload = encodeString(msg.version);
57
+ msgType = MSG_CONNECT_OK;
58
+ break;
59
+ case "Query":
60
+ payload = encodeString(msg.query);
61
+ msgType = MSG_QUERY;
62
+ break;
63
+ case "ResultRows": {
64
+ const parts: Buffer[] = [];
65
+ const colCount = Buffer.alloc(2);
66
+ colCount.writeUInt16LE(msg.columns.length, 0);
67
+ parts.push(colCount);
68
+ for (const col of msg.columns) parts.push(encodeString(col));
69
+ parts.push(u32LE(msg.rows.length));
70
+ for (const row of msg.rows) {
71
+ for (const val of row) parts.push(encodeString(val));
72
+ }
73
+ payload = Buffer.concat(parts);
74
+ msgType = MSG_RESULT_ROWS;
75
+ break;
76
+ }
77
+ case "ResultScalar":
78
+ payload = encodeString(msg.value);
79
+ msgType = MSG_RESULT_SCALAR;
80
+ break;
81
+ case "ResultOk": {
82
+ payload = Buffer.alloc(8);
83
+ payload.writeBigUInt64LE(msg.affected, 0);
84
+ msgType = MSG_RESULT_OK;
85
+ break;
86
+ }
87
+ case "Error":
88
+ payload = encodeString(msg.message);
89
+ msgType = MSG_ERROR;
90
+ break;
91
+ case "Disconnect":
92
+ payload = Buffer.alloc(0);
93
+ msgType = MSG_DISCONNECT;
94
+ break;
95
+ }
96
+
97
+ const frame = Buffer.alloc(6 + payload.length);
98
+ frame.writeUInt8(msgType, 0);
99
+ frame.writeUInt8(0, 1); // flags
100
+ frame.writeUInt32LE(payload.length, 2);
101
+ payload.copy(frame, 6);
102
+ return frame;
103
+ }
104
+
105
+ // ───── Decoding ────────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Attempts to parse a single frame from the start of `buf`. Returns the parsed
109
+ * message and the number of bytes consumed, or `null` if the buffer does not
110
+ * yet contain a complete frame.
111
+ */
112
+ export function tryDecode(
113
+ buf: Buffer,
114
+ ): { msg: Message; consumed: number } | null {
115
+ if (buf.length < 6) return null;
116
+ const msgType = buf.readUInt8(0);
117
+ // flags byte at offset 1 is currently unused
118
+ const payloadLen = buf.readUInt32LE(2);
119
+ if (payloadLen > MAX_PAYLOAD_SIZE) {
120
+ throw new Error(
121
+ `payload too large: ${payloadLen} bytes (max ${MAX_PAYLOAD_SIZE})`,
122
+ );
123
+ }
124
+ if (buf.length < 6 + payloadLen) return null;
125
+ const payload = buf.subarray(6, 6 + payloadLen);
126
+ const msg = decodePayload(msgType, payload);
127
+ return { msg, consumed: 6 + payloadLen };
128
+ }
129
+
130
+ function decodePayload(msgType: number, payload: Buffer): Message {
131
+ const cursor = { pos: 0 };
132
+ switch (msgType) {
133
+ case MSG_CONNECT: {
134
+ const dbName = decodeString(payload, cursor);
135
+ let password: string | null = null;
136
+ if (cursor.pos < payload.length) {
137
+ const p = decodeString(payload, cursor);
138
+ password = p.length === 0 ? null : p;
139
+ }
140
+ return { type: "Connect", dbName, password };
141
+ }
142
+ case MSG_CONNECT_OK:
143
+ return { type: "ConnectOk", version: decodeString(payload, cursor) };
144
+ case MSG_QUERY:
145
+ return { type: "Query", query: decodeString(payload, cursor) };
146
+ case MSG_RESULT_ROWS: {
147
+ const colCount = payload.readUInt16LE(cursor.pos);
148
+ cursor.pos += 2;
149
+ if (colCount > MAX_COLUMNS) {
150
+ throw new Error(
151
+ `too many columns: ${colCount} (max ${MAX_COLUMNS})`,
152
+ );
153
+ }
154
+ const columns: string[] = [];
155
+ for (let i = 0; i < colCount; i++) {
156
+ columns.push(decodeString(payload, cursor));
157
+ }
158
+ const rowCount = payload.readUInt32LE(cursor.pos);
159
+ cursor.pos += 4;
160
+ if (rowCount > MAX_ROWS) {
161
+ throw new Error(
162
+ `too many rows: ${rowCount} (max ${MAX_ROWS})`,
163
+ );
164
+ }
165
+ const rows: string[][] = [];
166
+ for (let r = 0; r < rowCount; r++) {
167
+ const row: string[] = [];
168
+ for (let c = 0; c < colCount; c++) {
169
+ row.push(decodeString(payload, cursor));
170
+ }
171
+ rows.push(row);
172
+ }
173
+ return { type: "ResultRows", columns, rows };
174
+ }
175
+ case MSG_RESULT_SCALAR:
176
+ return { type: "ResultScalar", value: decodeString(payload, cursor) };
177
+ case MSG_RESULT_OK: {
178
+ const affected = payload.readBigUInt64LE(0);
179
+ return { type: "ResultOk", affected };
180
+ }
181
+ case MSG_ERROR:
182
+ return { type: "Error", message: decodeString(payload, cursor) };
183
+ case MSG_DISCONNECT:
184
+ return { type: "Disconnect" };
185
+ default:
186
+ throw new Error(`unknown message type: 0x${msgType.toString(16)}`);
187
+ }
188
+ }
189
+
190
+ // ───── String helpers ──────────────────────────────────────────────────────
191
+
192
+ function encodeString(s: string): Buffer {
193
+ const bytes = Buffer.from(s, "utf8");
194
+ const out = Buffer.alloc(4 + bytes.length);
195
+ out.writeUInt32LE(bytes.length, 0);
196
+ bytes.copy(out, 4);
197
+ return out;
198
+ }
199
+
200
+ function decodeString(buf: Buffer, cursor: { pos: number }): string {
201
+ if (cursor.pos + 4 > buf.length) {
202
+ throw new Error("truncated string length");
203
+ }
204
+ const len = buf.readUInt32LE(cursor.pos);
205
+ cursor.pos += 4;
206
+ if (cursor.pos + len > buf.length) {
207
+ throw new Error("truncated string data");
208
+ }
209
+ const s = buf.toString("utf8", cursor.pos, cursor.pos + len);
210
+ cursor.pos += len;
211
+ return s;
212
+ }
213
+
214
+ function u32LE(n: number): Buffer {
215
+ const b = Buffer.alloc(4);
216
+ b.writeUInt32LE(n, 0);
217
+ return b;
218
+ }