@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,1046 @@
1
+ "use strict";
2
+ /**
3
+ * PowDB TypeScript client.
4
+ *
5
+ * Thin async wrapper around a TCP (or TLS) socket speaking the PowDB wire
6
+ * protocol.
7
+ *
8
+ * const client = await Client.connect({
9
+ * host: "127.0.0.1",
10
+ * port: 5433,
11
+ * dbName: "default",
12
+ * password: process.env.POWDB_PASSWORD,
13
+ * });
14
+ *
15
+ * const result = await client.query("User filter .age > 27 { .name, .age }");
16
+ * await client.close();
17
+ */
18
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ var desc = Object.getOwnPropertyDescriptor(m, k);
21
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
22
+ desc = { enumerable: true, get: function() { return m[k]; } };
23
+ }
24
+ Object.defineProperty(o, k2, desc);
25
+ }) : (function(o, m, k, k2) {
26
+ if (k2 === undefined) k2 = k;
27
+ o[k2] = m[k];
28
+ }));
29
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
30
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
31
+ }) : function(o, v) {
32
+ o["default"] = v;
33
+ });
34
+ var __importStar = (this && this.__importStar) || (function () {
35
+ var ownKeys = function(o) {
36
+ ownKeys = Object.getOwnPropertyNames || function (o) {
37
+ var ar = [];
38
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
39
+ return ar;
40
+ };
41
+ return ownKeys(o);
42
+ };
43
+ return function (mod) {
44
+ if (mod && mod.__esModule) return mod;
45
+ var result = {};
46
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
47
+ __setModuleDefault(result, mod);
48
+ return result;
49
+ };
50
+ })();
51
+ Object.defineProperty(exports, "__esModule", { value: true });
52
+ exports.coerceRows = exports.coerceRow = exports.coerceValue = exports.isPowDBScriptError = exports.PowDBScriptError = exports.isPowDBError = exports.PowDBError = exports.splitStatements = exports.Pool = exports.PowqlIdent = exports.powql = exports.ident = exports.escapeIdent = exports.escapeLiteral = exports.MAX_SYNC_PULL_BYTES = exports.MAX_SYNC_PULL_UNITS = exports.MAX_SYNC_UNITS = exports.MAX_PARAMS = exports.MAX_COLUMNS = exports.MAX_ROWS = exports.MAX_PAYLOAD_SIZE = exports.tryDecode = exports.encode = exports.Client = exports.CLIENT_VERSION = void 0;
53
+ const net = __importStar(require("node:net"));
54
+ const tls = __importStar(require("node:tls"));
55
+ const node_events_1 = require("node:events");
56
+ const protocol_js_1 = require("./protocol.js");
57
+ const errors_js_1 = require("./errors.js");
58
+ const script_js_1 = require("./script.js");
59
+ const typed_js_1 = require("./typed.js");
60
+ /** Client library version. Compared to the server's reported version. */
61
+ exports.CLIENT_VERSION = "0.10.0";
62
+ /** Transaction-control statements a `transactional` script may not contain. */
63
+ const TX_CONTROL_RE = /^(begin|commit|rollback)\b/i;
64
+ /**
65
+ * Leading trivia (whitespace and `#` line comments) that may precede a
66
+ * statement's first real token. Must be stripped before matching
67
+ * {@link TX_CONTROL_RE}: the server's lexer skips comments, so
68
+ * `"# note\ncommit"` executes a commit — the guard has to see it too.
69
+ */
70
+ const LEADING_TRIVIA_RE = /^(?:\s+|#[^\n]*)+/;
71
+ function socketChunkToBuffer(chunk) {
72
+ return typeof chunk === "string" ? Buffer.from(chunk) : chunk;
73
+ }
74
+ /** Map a JS {@link QueryParam} to its wire encoding. */
75
+ function toWireParam(p) {
76
+ if (p === null)
77
+ return { tag: "null" };
78
+ switch (typeof p) {
79
+ case "string":
80
+ return { tag: "str", value: p };
81
+ case "boolean":
82
+ return { tag: "bool", value: p };
83
+ case "bigint":
84
+ return { tag: "int", value: p };
85
+ case "number":
86
+ return Number.isInteger(p)
87
+ ? { tag: "int", value: BigInt(p) }
88
+ : { tag: "float", value: p };
89
+ default:
90
+ throw new errors_js_1.PowDBError(`unsupported query parameter type: ${typeof p}`, "protocol_error");
91
+ }
92
+ }
93
+ function toU64(value, label) {
94
+ if (typeof value === "bigint") {
95
+ if (value < 0n || value > 0xffffffffffffffffn) {
96
+ throw new errors_js_1.PowDBError(`${label} must fit in u64`, "protocol_error");
97
+ }
98
+ return value;
99
+ }
100
+ if (!Number.isSafeInteger(value) || value < 0) {
101
+ throw new errors_js_1.PowDBError(`${label} must be a safe non-negative integer or bigint`, "protocol_error");
102
+ }
103
+ return BigInt(value);
104
+ }
105
+ function toU16(value, label) {
106
+ if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
107
+ throw new errors_js_1.PowDBError(`${label} must fit in u16`, "protocol_error");
108
+ }
109
+ return value;
110
+ }
111
+ function toSyncMaxUnits(value) {
112
+ if (!Number.isInteger(value) ||
113
+ value < 1 ||
114
+ value > protocol_js_1.MAX_SYNC_PULL_UNITS) {
115
+ throw new errors_js_1.PowDBError(`maxUnits must be between 1 and ${protocol_js_1.MAX_SYNC_PULL_UNITS}`, "protocol_error");
116
+ }
117
+ return value;
118
+ }
119
+ function toSyncMaxBytes(value) {
120
+ const bytes = toU64(value, "maxBytes");
121
+ if (bytes < 1n || bytes > BigInt(protocol_js_1.MAX_SYNC_PULL_BYTES)) {
122
+ throw new errors_js_1.PowDBError(`maxBytes must be between 1 and ${protocol_js_1.MAX_SYNC_PULL_BYTES}`, "protocol_error");
123
+ }
124
+ return bytes;
125
+ }
126
+ function toDatabaseId(value) {
127
+ if (typeof value === "string") {
128
+ if (!/^[0-9a-fA-F]{32}$/.test(value)) {
129
+ throw new errors_js_1.PowDBError("databaseId string must be exactly 32 hex characters", "protocol_error");
130
+ }
131
+ return Buffer.from(value, "hex");
132
+ }
133
+ const bytes = Buffer.from(value);
134
+ if (bytes.length !== 16) {
135
+ throw new errors_js_1.PowDBError(`databaseId must be exactly 16 bytes, got ${bytes.length}`, "protocol_error");
136
+ }
137
+ return bytes;
138
+ }
139
+ /** Module-level set of host:port pairs we've already warned about. */
140
+ const versionWarnings = new Set();
141
+ /** Extract the major component of a dotted version string, e.g. "0.2.0" → "0". */
142
+ function majorOf(version) {
143
+ const dot = version.indexOf(".");
144
+ return dot === -1 ? version : version.slice(0, dot);
145
+ }
146
+ /**
147
+ * Build an AbortError. A caller-supplied custom `Error` reason
148
+ * (`ctrl.abort(myError)`) passes through as-is to match DOM semantics. The
149
+ * default abort reason — Node's `DOMException` named `"AbortError"` — and any
150
+ * non-Error reason are wrapped in a `PowDBError` with code `"aborted"` so the
151
+ * common `ctrl.abort()` case branches uniformly on `err.code === "aborted"`.
152
+ */
153
+ function abortError(signal) {
154
+ if (signal && signal.reason !== undefined) {
155
+ const r = signal.reason;
156
+ const isDefaultAbort = r instanceof DOMException && r.name === "AbortError";
157
+ if (r instanceof Error && !isDefaultAbort)
158
+ return r;
159
+ return new errors_js_1.PowDBError(isDefaultAbort ? "query was aborted" : String(r), "aborted");
160
+ }
161
+ return new errors_js_1.PowDBError("query was aborted", "aborted");
162
+ }
163
+ class Client extends node_events_1.EventEmitter {
164
+ socket;
165
+ /** FIFO of raw chunks; concatenated lazily when we try to decode. */
166
+ chunks = [];
167
+ /** Cached length of everything currently in `chunks`. */
168
+ totalLen = 0;
169
+ pending = [];
170
+ closed = false;
171
+ closeError = null;
172
+ /** Settled once the Connect→ConnectOk handshake completes (or fails). */
173
+ handshake = Promise.resolve();
174
+ /** True once ConnectOk has been received. */
175
+ handshakeComplete = false;
176
+ _serverVersion = "";
177
+ /**
178
+ * Server version from the ConnectOk frame. For a client opened with
179
+ * `eager: true` this is `""` until the handshake reply arrives (await
180
+ * {@link ready} to guarantee it is populated).
181
+ */
182
+ get serverVersion() {
183
+ return this._serverVersion;
184
+ }
185
+ constructor(socket) {
186
+ super();
187
+ this.socket = socket;
188
+ this.socket.on("data", (chunk) => this.onData(socketChunkToBuffer(chunk)));
189
+ this.socket.on("error", (err) => this.onClose(err));
190
+ this.socket.on("close", () => this.onClose(null));
191
+ }
192
+ /**
193
+ * Open a connection, send Connect, and wait for ConnectOk.
194
+ *
195
+ * With `eager: true`, resolve as soon as the Connect frame is written
196
+ * instead — queries may be issued immediately and are pipelined behind
197
+ * the handshake (see {@link ClientOptions.eager} and {@link ready}).
198
+ */
199
+ static async connect(opts) {
200
+ const { host, port, path, dbName = "default", password = null, user, connectTimeoutMs = 5000, tls: tlsOpt = false, eager = false, } = opts;
201
+ if (path === undefined && (host === undefined || port === undefined)) {
202
+ throw new errors_js_1.PowDBError("connect requires either { path } (Unix socket) or { host, port } (TCP)", "connect_failed");
203
+ }
204
+ const socket = await openSocket({ host, port, path }, connectTimeoutMs, tlsOpt);
205
+ const client = new Client(socket);
206
+ client.startHandshake({ type: "Connect", dbName, password, username: user ?? null }, path ?? `${host}:${port}`);
207
+ if (!eager) {
208
+ await client.ready();
209
+ }
210
+ return client;
211
+ }
212
+ /**
213
+ * Resolves once the Connect→ConnectOk handshake has completed; rejects
214
+ * with the handshake error if it failed. For non-eager clients this has
215
+ * already settled by the time {@link connect} returns. Eager callers can
216
+ * await it to learn the handshake outcome without issuing a query.
217
+ */
218
+ ready() {
219
+ return this.handshake;
220
+ }
221
+ /**
222
+ * Write the Connect frame and register the handshake as the first entry
223
+ * in the pending queue. The reply-matching machinery is strictly FIFO, so
224
+ * the ConnectOk (or Error) frame is matched to the handshake before any
225
+ * pipelined query sees a reply. On failure, every queued query is
226
+ * rejected with the handshake error and the socket is torn down.
227
+ */
228
+ startHandshake(connect, versionWarnKey) {
229
+ this.handshake = this.send(connect).then((reply) => {
230
+ if (reply.type === "Error") {
231
+ throw new errors_js_1.PowDBError(`connect failed: ${reply.message}`, "auth_failed");
232
+ }
233
+ if (reply.type !== "ConnectOk") {
234
+ throw new errors_js_1.PowDBError(`expected ConnectOk, got ${reply.type}`, "protocol_error");
235
+ }
236
+ this.handshakeComplete = true;
237
+ this._serverVersion = reply.version;
238
+ // Advisory: warn once per host:port if the server's major differs
239
+ // from the client's. Do not throw or close — this is best-effort.
240
+ const serverMajor = majorOf(reply.version);
241
+ const clientMajor = majorOf(exports.CLIENT_VERSION);
242
+ if (serverMajor !== clientMajor) {
243
+ if (!versionWarnings.has(versionWarnKey)) {
244
+ versionWarnings.add(versionWarnKey);
245
+ console.warn(`[powdb] server version ${reply.version} major (${serverMajor}) ` +
246
+ `differs from client ${exports.CLIENT_VERSION} major (${clientMajor}); ` +
247
+ `behaviour may be inconsistent.`);
248
+ }
249
+ }
250
+ }, (err) => {
251
+ // The connection dropped before a handshake reply — surface it as a
252
+ // connect failure (transient, retryable) rather than a bare close.
253
+ if ((0, errors_js_1.isPowDBError)(err) && err.code === "closed") {
254
+ throw new errors_js_1.PowDBError("connection closed during handshake", "connect_failed", { cause: err });
255
+ }
256
+ throw err;
257
+ });
258
+ // On handshake failure, reject everything queued behind it and tear the
259
+ // socket down. The extra no-op catch keeps an eager caller that never
260
+ // awaits ready() from tripping an unhandled-rejection crash.
261
+ this.handshake.catch((err) => {
262
+ this.onClose(err);
263
+ this.socket.destroy();
264
+ });
265
+ }
266
+ /**
267
+ * Run a PowQL statement and return the typed result.
268
+ *
269
+ * When `opts.signal` is provided and fires, the returned promise rejects
270
+ * with the signal's `reason` (or an `AbortError`). The socket is NOT
271
+ * destroyed — the server will still eventually send its reply, which we
272
+ * silently discard so other in-flight queries keep working.
273
+ */
274
+ async query(query, paramsOrOpts, maybeOpts) {
275
+ // Disambiguate the two overloads:
276
+ // query(q) — no params, no opts
277
+ // query(q, opts) — legacy 2-arg opts form (back-compat)
278
+ // query(q, params) — positional $N parameters
279
+ // query(q, params, opts) — params + opts
280
+ const hasParams = Array.isArray(paramsOrOpts);
281
+ const params = hasParams ? paramsOrOpts : undefined;
282
+ const opts = hasParams
283
+ ? maybeOpts
284
+ : paramsOrOpts;
285
+ const start = Date.now();
286
+ try {
287
+ const request = params === undefined
288
+ ? { type: "Query", query }
289
+ : { type: "QueryWithParams", query, params: params.map(toWireParam) };
290
+ const reply = await this.send(request, opts);
291
+ let result;
292
+ switch (reply.type) {
293
+ case "ResultRows":
294
+ result = { kind: "rows", columns: reply.columns, rows: reply.rows };
295
+ break;
296
+ case "ResultScalar":
297
+ result = { kind: "scalar", value: reply.value };
298
+ break;
299
+ case "ResultOk":
300
+ result = { kind: "ok", affected: reply.affected };
301
+ break;
302
+ case "ResultMessage":
303
+ result = { kind: "message", message: reply.message };
304
+ break;
305
+ case "Error":
306
+ throw new errors_js_1.PowDBError(`query failed: ${reply.message}`, "query_failed");
307
+ default:
308
+ throw new errors_js_1.PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
309
+ }
310
+ this.emit("query", {
311
+ query,
312
+ durationMs: Date.now() - start,
313
+ ok: true,
314
+ kind: result.kind,
315
+ });
316
+ return result;
317
+ }
318
+ catch (err) {
319
+ this.emit("query", {
320
+ query,
321
+ durationMs: Date.now() - start,
322
+ ok: false,
323
+ error: err,
324
+ });
325
+ throw err;
326
+ }
327
+ }
328
+ /**
329
+ * Run a SQL statement through the server-side SQL frontend. The plain
330
+ * {@link query} method remains PowQL for wire compatibility.
331
+ */
332
+ async querySql(query, opts) {
333
+ const start = Date.now();
334
+ try {
335
+ const reply = await this.send({ type: "QuerySql", query }, opts);
336
+ let result;
337
+ switch (reply.type) {
338
+ case "ResultRows":
339
+ result = { kind: "rows", columns: reply.columns, rows: reply.rows };
340
+ break;
341
+ case "ResultScalar":
342
+ result = { kind: "scalar", value: reply.value };
343
+ break;
344
+ case "ResultOk":
345
+ result = { kind: "ok", affected: reply.affected };
346
+ break;
347
+ case "ResultMessage":
348
+ result = { kind: "message", message: reply.message };
349
+ break;
350
+ case "Error":
351
+ throw new errors_js_1.PowDBError(`query failed: ${reply.message}`, "query_failed");
352
+ default:
353
+ throw new errors_js_1.PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
354
+ }
355
+ this.emit("query", {
356
+ query,
357
+ durationMs: Date.now() - start,
358
+ ok: true,
359
+ kind: result.kind,
360
+ });
361
+ return result;
362
+ }
363
+ catch (err) {
364
+ this.emit("query", {
365
+ query,
366
+ durationMs: Date.now() - start,
367
+ ok: false,
368
+ error: err,
369
+ });
370
+ throw err;
371
+ }
372
+ }
373
+ /**
374
+ * Fetch primary-side sync status for one embedded replica cursor.
375
+ *
376
+ * This speaks the private authenticated sync frame added for the embedded
377
+ * replica product. Servers without sync support return a protocol/query
378
+ * error; unauthenticated or readonly users are rejected server-side.
379
+ */
380
+ async syncStatus(replicaId, opts) {
381
+ const start = Date.now();
382
+ try {
383
+ const reply = await this.send({ type: "SyncStatus", replicaId }, opts);
384
+ if (reply.type === "Error") {
385
+ throw new errors_js_1.PowDBError(`sync status failed: ${reply.message}`, "query_failed");
386
+ }
387
+ if (reply.type !== "SyncStatusResult") {
388
+ throw new errors_js_1.PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
389
+ }
390
+ this.emit("sync", {
391
+ operation: "status",
392
+ replicaId,
393
+ durationMs: Date.now() - start,
394
+ ok: true,
395
+ status: reply.status,
396
+ stale: reply.status.stale,
397
+ repairAction: reply.status.repairAction,
398
+ });
399
+ return reply.status;
400
+ }
401
+ catch (err) {
402
+ this.emit("sync", {
403
+ operation: "status",
404
+ replicaId,
405
+ durationMs: Date.now() - start,
406
+ ok: false,
407
+ error: err,
408
+ });
409
+ throw err;
410
+ }
411
+ }
412
+ /**
413
+ * Pull a bounded retained-unit chunk after this replica's primary-side cursor.
414
+ *
415
+ * The caller supplies the database identity and format versions from the
416
+ * sync bootstrap metadata. The server rejects mismatches and non-applyable
417
+ * transaction cuts instead of returning ambiguous history.
418
+ */
419
+ async syncPull(request, opts) {
420
+ const start = Date.now();
421
+ try {
422
+ const reply = await this.send({
423
+ type: "SyncPull",
424
+ replicaId: request.replicaId,
425
+ sinceLsn: toU64(request.sinceLsn, "sinceLsn"),
426
+ maxUnits: toSyncMaxUnits(request.maxUnits),
427
+ maxBytes: toSyncMaxBytes(request.maxBytes),
428
+ databaseId: toDatabaseId(request.databaseId),
429
+ primaryGeneration: toU64(request.primaryGeneration, "primaryGeneration"),
430
+ walFormatVersion: toU16(request.walFormatVersion, "walFormatVersion"),
431
+ catalogVersion: toU16(request.catalogVersion, "catalogVersion"),
432
+ segmentFormatVersion: toU16(request.segmentFormatVersion, "segmentFormatVersion"),
433
+ }, opts);
434
+ if (reply.type === "Error") {
435
+ throw new errors_js_1.PowDBError(`sync pull failed: ${reply.message}`, "query_failed");
436
+ }
437
+ if (reply.type !== "SyncPullResult") {
438
+ throw new errors_js_1.PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
439
+ }
440
+ this.emit("sync", {
441
+ operation: "pull",
442
+ replicaId: request.replicaId,
443
+ durationMs: Date.now() - start,
444
+ ok: true,
445
+ status: reply.status,
446
+ stale: reply.status.stale,
447
+ repairAction: reply.status.repairAction,
448
+ units: reply.units.length,
449
+ });
450
+ return {
451
+ status: reply.status,
452
+ units: reply.units,
453
+ hasMore: reply.hasMore,
454
+ };
455
+ }
456
+ catch (err) {
457
+ this.emit("sync", {
458
+ operation: "pull",
459
+ replicaId: request.replicaId,
460
+ durationMs: Date.now() - start,
461
+ ok: false,
462
+ error: err,
463
+ });
464
+ throw err;
465
+ }
466
+ }
467
+ /** Acknowledge that the replica applied retained history through `appliedLsn`. */
468
+ async syncAck(request, opts) {
469
+ const start = Date.now();
470
+ try {
471
+ const reply = await this.send({
472
+ type: "SyncAck",
473
+ replicaId: request.replicaId,
474
+ appliedLsn: toU64(request.appliedLsn, "appliedLsn"),
475
+ remoteLsn: toU64(request.remoteLsn, "remoteLsn"),
476
+ }, opts);
477
+ if (reply.type === "Error") {
478
+ throw new errors_js_1.PowDBError(`sync ack failed: ${reply.message}`, "query_failed");
479
+ }
480
+ if (reply.type !== "SyncAckResult") {
481
+ throw new errors_js_1.PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
482
+ }
483
+ this.emit("sync", {
484
+ operation: "ack",
485
+ replicaId: request.replicaId,
486
+ durationMs: Date.now() - start,
487
+ ok: true,
488
+ status: reply.status,
489
+ stale: reply.status.stale,
490
+ repairAction: reply.status.repairAction,
491
+ advanced: reply.advanced,
492
+ });
493
+ return {
494
+ previousAppliedLsn: reply.previousAppliedLsn,
495
+ appliedLsn: reply.appliedLsn,
496
+ remoteLsn: reply.remoteLsn,
497
+ advanced: reply.advanced,
498
+ status: reply.status,
499
+ };
500
+ }
501
+ catch (err) {
502
+ this.emit("sync", {
503
+ operation: "ack",
504
+ replicaId: request.replicaId,
505
+ durationMs: Date.now() - start,
506
+ ok: false,
507
+ error: err,
508
+ });
509
+ throw err;
510
+ }
511
+ }
512
+ /**
513
+ * Like {@link query}, but coerces string result columns to typed JS values
514
+ * using the caller-supplied schema. See `./typed.ts` for the coercion
515
+ * rules and supported column types.
516
+ *
517
+ * Returns a `TypedRow[]` — an array of objects keyed by column name.
518
+ * Throws `PowDBError(code="query_failed")` if the query is not a
519
+ * rows-returning query.
520
+ */
521
+ async queryTyped(query, schema, opts) {
522
+ const result = await this.query(query, opts);
523
+ if (result.kind !== "rows") {
524
+ throw new errors_js_1.PowDBError(`queryTyped: expected rows result, got ${result.kind}`, "query_failed");
525
+ }
526
+ return (0, typed_js_1.coerceRows)(result.columns, result.rows, schema);
527
+ }
528
+ async execScript(script, opts) {
529
+ const statements = (0, script_js_1.splitStatements)(script);
530
+ const continueOnError = opts?.continueOnError === true;
531
+ const transactional = opts?.transactional === true;
532
+ const signal = opts?.signal;
533
+ if (transactional && continueOnError) {
534
+ throw new errors_js_1.PowDBError("execScript: `transactional` and `continueOnError` are mutually exclusive", "protocol_error");
535
+ }
536
+ if (transactional) {
537
+ for (let i = 0; i < statements.length; i++) {
538
+ if (TX_CONTROL_RE.test(statements[i].replace(LEADING_TRIVIA_RE, ""))) {
539
+ throw new errors_js_1.PowDBError(`execScript: a transactional script may not contain its own transaction control (statement ${i + 1}: ${statements[i]})`, "protocol_error");
540
+ }
541
+ }
542
+ // Open the transaction and WAIT for its reply before dispatching
543
+ // anything: if `begin` fails there must be nothing else on the wire.
544
+ // Deliberately no signal — an abort after `begin` is on the wire
545
+ // would leave the transaction open with no rollback; an
546
+ // already-aborted signal stops the loop below at statement 0 and
547
+ // takes the rollback path.
548
+ await this.query("begin");
549
+ }
550
+ const outcomes = new Array(statements.length);
551
+ const inFlight = [];
552
+ let firstFailureIndex = -1;
553
+ let firstFailureError = null;
554
+ let dispatched = 0;
555
+ for (let i = 0; i < statements.length; i++) {
556
+ // Stop dispatching when the script is already doomed: fail-fast saw
557
+ // an error, the caller aborted, or the connection is gone.
558
+ if (this.closed || signal?.aborted)
559
+ break;
560
+ if (!continueOnError && firstFailureIndex !== -1)
561
+ break;
562
+ const statement = statements[i];
563
+ const idx = i;
564
+ dispatched++;
565
+ // query() writes the frame synchronously before its first await, so
566
+ // this loop puts every statement on the wire without waiting for any
567
+ // reply; the FIFO pending queue matches replies back in order.
568
+ inFlight.push(this.query(statement, signal ? { signal } : undefined).then((result) => {
569
+ outcomes[idx] = { statement, ok: true, result };
570
+ }, (error) => {
571
+ const err = error instanceof Error ? error : new Error(String(error));
572
+ outcomes[idx] = { statement, ok: false, error: err };
573
+ // Replies are FIFO so failures normally arrive in statement
574
+ // order; keep the earliest defensively regardless.
575
+ if (firstFailureIndex === -1 || idx < firstFailureIndex) {
576
+ firstFailureIndex = idx;
577
+ firstFailureError = err;
578
+ }
579
+ }));
580
+ // Backpressure: only yield when the kernel buffer is full. These
581
+ // yields are also the windows in which an already-arrived error reply
582
+ // or abort can stop further dispatch (the checks at the loop head).
583
+ if (this.socket.writableNeedDrain)
584
+ await this.drained();
585
+ }
586
+ // Every dispatched statement has a handler attached, so this never
587
+ // rejects; it resolves once all in-flight replies have settled.
588
+ await Promise.all(inFlight);
589
+ if (!continueOnError) {
590
+ if (firstFailureIndex === -1 && dispatched < statements.length) {
591
+ // Dispatch stopped without any statement failing — e.g. a signal
592
+ // that was already aborted before the first write. Treat the first
593
+ // undispatched statement as the failure point.
594
+ firstFailureIndex = dispatched;
595
+ firstFailureError = signal?.aborted
596
+ ? abortError(signal)
597
+ : (this.closeError ?? new errors_js_1.PowDBError("client is closed", "closed"));
598
+ }
599
+ if (firstFailureIndex !== -1) {
600
+ if (transactional) {
601
+ // Every dispatched reply has settled, so the connection is quiet
602
+ // and the transaction is still open. Best-effort rollback — the
603
+ // throw below is what callers act on either way, and if the
604
+ // connection died the server aborts the transaction itself.
605
+ try {
606
+ await this.query("rollback");
607
+ }
608
+ catch {
609
+ /* connection may already be gone */
610
+ }
611
+ }
612
+ const cause = firstFailureError;
613
+ const results = [];
614
+ for (let i = 0; i < firstFailureIndex; i++) {
615
+ const o = outcomes[i];
616
+ if (o !== undefined && o.ok)
617
+ results.push(o.result);
618
+ }
619
+ throw new errors_js_1.PowDBScriptError(`script failed at statement ${firstFailureIndex + 1}/${statements.length}: ${cause.message}`, (0, errors_js_1.isPowDBError)(cause) ? cause.code : "query_failed", {
620
+ statementIndex: firstFailureIndex,
621
+ statement: statements[firstFailureIndex],
622
+ results,
623
+ cause,
624
+ });
625
+ }
626
+ if (transactional) {
627
+ // Commit only after every statement's reply settled successfully —
628
+ // this is the all-or-nothing guarantee. The commit reply is not
629
+ // part of the returned results. No signal here either: aborting a
630
+ // commit already on the wire buys nothing but ambiguity.
631
+ try {
632
+ await this.query("commit");
633
+ }
634
+ catch (err) {
635
+ try {
636
+ await this.query("rollback");
637
+ }
638
+ catch {
639
+ /* connection may already be gone */
640
+ }
641
+ throw err;
642
+ }
643
+ }
644
+ return outcomes.map((o) => o.result);
645
+ }
646
+ // continueOnError: synthesize outcomes for statements that were never
647
+ // dispatched (abort or connection loss) so the array stays dense.
648
+ if (dispatched < statements.length) {
649
+ const reason = signal?.aborted
650
+ ? abortError(signal)
651
+ : (this.closeError ?? new errors_js_1.PowDBError("client is closed", "closed"));
652
+ for (let i = dispatched; i < statements.length; i++) {
653
+ outcomes[i] = { statement: statements[i], ok: false, error: reason };
654
+ }
655
+ }
656
+ return outcomes;
657
+ }
658
+ /**
659
+ * Poll a query on an interval and invoke `onRows` for every successful
660
+ * run. Returns an unsubscribe function. Does NOT deduplicate results —
661
+ * `onRows` fires every interval, even if the rows are unchanged.
662
+ *
663
+ * Pragmatic first-cut live-data. If a query takes longer than
664
+ * `intervalMs`, the next tick waits for the in-flight one to finish
665
+ * (no pile-up). Errors fire `onError` (if provided) without stopping
666
+ * the watcher unless `stopOnError: true`.
667
+ */
668
+ watch(query, opts) {
669
+ if (!(opts.intervalMs > 0)) {
670
+ throw new errors_js_1.PowDBError(`watch: intervalMs must be > 0, got ${opts.intervalMs}`, "protocol_error");
671
+ }
672
+ let stopped = false;
673
+ let inFlight = false;
674
+ const tick = async () => {
675
+ if (stopped || inFlight)
676
+ return;
677
+ inFlight = true;
678
+ try {
679
+ const r = await this.query(query);
680
+ if (!stopped)
681
+ opts.onRows(r);
682
+ }
683
+ catch (err) {
684
+ if (stopped)
685
+ return;
686
+ if (opts.onError)
687
+ opts.onError(err);
688
+ if (opts.stopOnError) {
689
+ stopped = true;
690
+ clearInterval(handle);
691
+ return;
692
+ }
693
+ }
694
+ finally {
695
+ inFlight = false;
696
+ }
697
+ };
698
+ // Run immediately so callers don't wait a full interval for the first
699
+ // emission, then every `intervalMs`.
700
+ void tick();
701
+ const handle = setInterval(tick, opts.intervalMs);
702
+ if (typeof handle.unref === "function")
703
+ handle.unref();
704
+ return {
705
+ stop: () => {
706
+ stopped = true;
707
+ clearInterval(handle);
708
+ },
709
+ };
710
+ }
711
+ /**
712
+ * Send Disconnect and tear down the socket. Waits for the remote FIN-ack
713
+ * (`socket.end` callback) but falls back to `destroy()` after a bounded
714
+ * timeout so an unresponsive peer cannot make `close()` hang forever.
715
+ */
716
+ async close() {
717
+ if (this.closed) {
718
+ // Already torn down (e.g. an error in onData/onClose closed the client
719
+ // without destroying the socket). Ensure the socket is released so a
720
+ // post-teardown close() resolves instead of keeping the event loop
721
+ // alive. `writableEnded` distinguishes that case from a concurrent
722
+ // close() mid-graceful-shutdown, which must not be force-destroyed.
723
+ if (!this.socket.destroyed && !this.socket.writableEnded) {
724
+ this.socket.destroy();
725
+ }
726
+ return;
727
+ }
728
+ try {
729
+ this.socket.write((0, protocol_js_1.encode)({ type: "Disconnect" }));
730
+ }
731
+ catch {
732
+ // socket may already be half-closed; ignore
733
+ }
734
+ this.closed = true;
735
+ await new Promise((resolve) => {
736
+ let done = false;
737
+ const finish = () => {
738
+ if (done)
739
+ return;
740
+ done = true;
741
+ clearTimeout(timer);
742
+ resolve();
743
+ };
744
+ const timer = setTimeout(() => {
745
+ // Peer didn't ack FIN in time — force-close so we don't hang.
746
+ this.socket.destroy();
747
+ finish();
748
+ }, 5_000);
749
+ if (typeof timer.unref === "function")
750
+ timer.unref();
751
+ this.socket.end(finish);
752
+ });
753
+ }
754
+ // ───── internals ─────────────────────────────────────────────────────────
755
+ send(msg, opts) {
756
+ if (this.closed) {
757
+ return Promise.reject(this.closeError ?? new errors_js_1.PowDBError("client is closed", "closed"));
758
+ }
759
+ const signal = opts?.signal;
760
+ // Pre-check: if already aborted, reject immediately and do not enqueue.
761
+ // This matches fetch() semantics for pre-aborted signals.
762
+ if (signal?.aborted) {
763
+ return Promise.reject(abortError(signal));
764
+ }
765
+ return new Promise((resolve, reject) => {
766
+ const entry = {
767
+ resolve: (m) => {
768
+ entry.settled = true;
769
+ resolve(m);
770
+ },
771
+ reject: (e) => {
772
+ entry.settled = true;
773
+ reject(e);
774
+ },
775
+ settled: false,
776
+ };
777
+ this.pending.push(entry);
778
+ let onAbort = null;
779
+ if (signal) {
780
+ onAbort = () => {
781
+ if (entry.settled)
782
+ return;
783
+ // Mark settled but DO NOT remove the entry from the queue — the
784
+ // server will still send a reply, and onData drops replies for
785
+ // already-settled entries at the head of the queue.
786
+ entry.settled = true;
787
+ reject(abortError(signal));
788
+ };
789
+ signal.addEventListener("abort", onAbort, { once: true });
790
+ // Strip the listener once the entry resolves/rejects naturally.
791
+ const origResolve = entry.resolve;
792
+ const origReject = entry.reject;
793
+ entry.resolve = (m) => {
794
+ if (onAbort)
795
+ signal.removeEventListener("abort", onAbort);
796
+ origResolve(m);
797
+ };
798
+ entry.reject = (e) => {
799
+ if (onAbort)
800
+ signal.removeEventListener("abort", onAbort);
801
+ origReject(e);
802
+ };
803
+ }
804
+ this.socket.write((0, protocol_js_1.encode)(msg), (err) => {
805
+ if (err) {
806
+ if (entry.settled)
807
+ return;
808
+ // Writer error — the promise will also be rejected by onClose,
809
+ // but rejecting here gives a faster, more specific failure.
810
+ //
811
+ // Splicing an entry from the middle of `pending` is only safe
812
+ // because a write-callback error implies the bytes never reached
813
+ // the server — no reply will ever come for this slot, so FIFO
814
+ // alignment with subsequent entries is preserved.
815
+ const idx = this.pending.indexOf(entry);
816
+ if (idx !== -1)
817
+ this.pending.splice(idx, 1);
818
+ entry.reject(err);
819
+ }
820
+ });
821
+ });
822
+ }
823
+ /**
824
+ * Resolve once the socket's write buffer has drained. Also resolves if
825
+ * the client closes while waiting, so a dead peer cannot hang a
826
+ * backpressured `execScript` dispatch loop forever.
827
+ */
828
+ drained() {
829
+ return new Promise((resolve) => {
830
+ if (!this.socket.writableNeedDrain || this.closed) {
831
+ resolve();
832
+ return;
833
+ }
834
+ const done = () => {
835
+ this.socket.removeListener("drain", done);
836
+ this.removeListener("close", done);
837
+ resolve();
838
+ };
839
+ this.socket.on("drain", done);
840
+ this.on("close", done);
841
+ });
842
+ }
843
+ onData(chunk) {
844
+ // Append to the chunk queue — O(1) — and lazily concat only when
845
+ // we actually need contiguous bytes to decode.
846
+ this.chunks.push(chunk);
847
+ this.totalLen += chunk.length;
848
+ while (this.totalLen > 0) {
849
+ // Fast path: if the first chunk already contains a full frame, we
850
+ // can decode without concatenating.
851
+ let view;
852
+ if (this.chunks.length === 1) {
853
+ view = this.chunks[0];
854
+ }
855
+ else {
856
+ // Peek at the header if we don't already have >=6 bytes up front.
857
+ // We need up to 6 bytes to read payloadLen, then enough to hold
858
+ // the full frame. Coalesce lazily.
859
+ if (this.chunks[0].length < 6 && this.totalLen >= 6) {
860
+ this.coalesce();
861
+ }
862
+ // If the first chunk still has a full frame, great. Otherwise
863
+ // coalesce the whole queue so tryDecode sees contiguous bytes.
864
+ const first = this.chunks[0];
865
+ if (first.length >= 6) {
866
+ const payloadLen = first.readUInt32LE(2);
867
+ if (first.length >= 6 + payloadLen) {
868
+ view = first;
869
+ }
870
+ else if (this.totalLen >= 6 + payloadLen) {
871
+ this.coalesce();
872
+ view = this.chunks[0];
873
+ }
874
+ else {
875
+ // Not enough bytes yet for the full frame — wait for more data.
876
+ break;
877
+ }
878
+ }
879
+ else {
880
+ // Still short of a header even after coalesce attempt above.
881
+ break;
882
+ }
883
+ }
884
+ let decoded;
885
+ try {
886
+ decoded = (0, protocol_js_1.tryDecode)(view);
887
+ }
888
+ catch (err) {
889
+ this.onClose(err);
890
+ return;
891
+ }
892
+ if (decoded === null)
893
+ break;
894
+ // Advance past the consumed bytes without copying the trailing data.
895
+ this.consume(decoded.consumed);
896
+ // Handle Ping frames: auto-reply with Pong and continue decoding.
897
+ if (decoded.msg.type === "Ping") {
898
+ this.socket.write((0, protocol_js_1.encode)({ type: "Pong" }));
899
+ continue;
900
+ }
901
+ // Replies are strictly FIFO: this frame belongs to exactly one pending
902
+ // entry — the head. Consume one entry per frame. If it was aborted
903
+ // (settled), its reply is arriving now; drop the frame and keep decoding
904
+ // rather than delivering it to a later, live query.
905
+ const entry = this.pending.shift();
906
+ if (entry === undefined) {
907
+ // No pending entry at all — the server sent an unsolicited frame.
908
+ this.onClose(new errors_js_1.PowDBError("received unexpected frame from server", "protocol_error"));
909
+ return;
910
+ }
911
+ if (entry.settled)
912
+ continue;
913
+ entry.resolve(decoded.msg);
914
+ }
915
+ }
916
+ /** Collapse the chunk queue into a single Buffer. */
917
+ coalesce() {
918
+ if (this.chunks.length <= 1)
919
+ return;
920
+ const merged = Buffer.concat(this.chunks, this.totalLen);
921
+ this.chunks.length = 0;
922
+ this.chunks.push(merged);
923
+ }
924
+ /** Drop the first `n` bytes off the chunk queue. */
925
+ consume(n) {
926
+ let remaining = n;
927
+ while (remaining > 0 && this.chunks.length > 0) {
928
+ const head = this.chunks[0];
929
+ if (head.length <= remaining) {
930
+ remaining -= head.length;
931
+ this.totalLen -= head.length;
932
+ this.chunks.shift();
933
+ }
934
+ else {
935
+ this.chunks[0] = head.subarray(remaining);
936
+ this.totalLen -= remaining;
937
+ remaining = 0;
938
+ }
939
+ }
940
+ }
941
+ onClose(err) {
942
+ const firstClose = !this.closed;
943
+ if (this.closed && err === null)
944
+ return;
945
+ this.closed = true;
946
+ let error = err ?? new errors_js_1.PowDBError("connection closed", "closed");
947
+ // A teardown before ConnectOk is a handshake failure: everything queued
948
+ // (the handshake entry and any eagerly pipelined queries) rejects with
949
+ // the handshake error. Auth/protocol errors already carry the precise
950
+ // cause; anything else becomes a retryable connect failure.
951
+ if (!this.handshakeComplete &&
952
+ !((0, errors_js_1.isPowDBError)(error) &&
953
+ (error.code === "auth_failed" || error.code === "protocol_error"))) {
954
+ error = new errors_js_1.PowDBError("connection closed during handshake", "connect_failed", { cause: err ?? undefined });
955
+ }
956
+ this.closeError = error;
957
+ while (this.pending.length > 0) {
958
+ const entry = this.pending.shift();
959
+ if (!entry.settled) {
960
+ entry.reject(error);
961
+ }
962
+ }
963
+ if (firstClose) {
964
+ // Best-effort: surface the close event once. Late listeners on a
965
+ // closed client miss it, which matches Node socket semantics.
966
+ this.emit("close", { error: err });
967
+ }
968
+ }
969
+ }
970
+ exports.Client = Client;
971
+ function openSocket(target, timeoutMs, tlsOpt) {
972
+ return new Promise((resolve, reject) => {
973
+ let socket;
974
+ const timer = setTimeout(() => {
975
+ socket.destroy();
976
+ reject(new errors_js_1.PowDBError(`connect timeout after ${timeoutMs}ms`, "timeout"));
977
+ }, timeoutMs);
978
+ const onConnect = () => {
979
+ clearTimeout(timer);
980
+ socket.setNoDelay(true);
981
+ // Enable keepalive on the underlying OS socket so dead peers are detected
982
+ // even when the app is otherwise idle. (No-op for Unix sockets.)
983
+ socket.setKeepAlive(true, 30_000);
984
+ resolve(socket);
985
+ };
986
+ const onError = (err) => {
987
+ clearTimeout(timer);
988
+ // Wrap raw socket errors in the PowDBError taxonomy — callers can
989
+ // branch on `.code === "connect_failed"` rather than string-matching.
990
+ reject(new errors_js_1.PowDBError(`connect failed: ${err.message}`, "connect_failed", { cause: err }));
991
+ };
992
+ if (target.path !== undefined) {
993
+ // Unix domain socket: no TLS (local, same-host), no host/port.
994
+ socket = new net.Socket();
995
+ socket.once("connect", onConnect);
996
+ socket.once("error", onError);
997
+ socket.connect(target.path);
998
+ }
999
+ else if (tlsOpt) {
1000
+ // TLS path: `tls.connect` wraps an underlying net.Socket. `secureConnect`
1001
+ // fires once the TLS handshake is complete — that is the right hook for
1002
+ // "ready to send application data".
1003
+ const tlsOptions = tlsOpt === true ? {} : tlsOpt;
1004
+ const tlsSock = tls.connect(target.port, target.host, tlsOptions);
1005
+ socket = tlsSock;
1006
+ tlsSock.once("secureConnect", onConnect);
1007
+ tlsSock.once("error", onError);
1008
+ }
1009
+ else {
1010
+ socket = new net.Socket();
1011
+ socket.once("connect", onConnect);
1012
+ socket.once("error", onError);
1013
+ socket.connect(target.port, target.host);
1014
+ }
1015
+ });
1016
+ }
1017
+ var protocol_js_2 = require("./protocol.js");
1018
+ Object.defineProperty(exports, "encode", { enumerable: true, get: function () { return protocol_js_2.encode; } });
1019
+ Object.defineProperty(exports, "tryDecode", { enumerable: true, get: function () { return protocol_js_2.tryDecode; } });
1020
+ var protocol_js_3 = require("./protocol.js");
1021
+ Object.defineProperty(exports, "MAX_PAYLOAD_SIZE", { enumerable: true, get: function () { return protocol_js_3.MAX_PAYLOAD_SIZE; } });
1022
+ Object.defineProperty(exports, "MAX_ROWS", { enumerable: true, get: function () { return protocol_js_3.MAX_ROWS; } });
1023
+ Object.defineProperty(exports, "MAX_COLUMNS", { enumerable: true, get: function () { return protocol_js_3.MAX_COLUMNS; } });
1024
+ Object.defineProperty(exports, "MAX_PARAMS", { enumerable: true, get: function () { return protocol_js_3.MAX_PARAMS; } });
1025
+ Object.defineProperty(exports, "MAX_SYNC_UNITS", { enumerable: true, get: function () { return protocol_js_3.MAX_SYNC_UNITS; } });
1026
+ Object.defineProperty(exports, "MAX_SYNC_PULL_UNITS", { enumerable: true, get: function () { return protocol_js_3.MAX_SYNC_PULL_UNITS; } });
1027
+ Object.defineProperty(exports, "MAX_SYNC_PULL_BYTES", { enumerable: true, get: function () { return protocol_js_3.MAX_SYNC_PULL_BYTES; } });
1028
+ var escape_js_1 = require("./escape.js");
1029
+ Object.defineProperty(exports, "escapeLiteral", { enumerable: true, get: function () { return escape_js_1.escapeLiteral; } });
1030
+ Object.defineProperty(exports, "escapeIdent", { enumerable: true, get: function () { return escape_js_1.escapeIdent; } });
1031
+ Object.defineProperty(exports, "ident", { enumerable: true, get: function () { return escape_js_1.ident; } });
1032
+ Object.defineProperty(exports, "powql", { enumerable: true, get: function () { return escape_js_1.powql; } });
1033
+ Object.defineProperty(exports, "PowqlIdent", { enumerable: true, get: function () { return escape_js_1.PowqlIdent; } });
1034
+ var pool_js_1 = require("./pool.js");
1035
+ Object.defineProperty(exports, "Pool", { enumerable: true, get: function () { return pool_js_1.Pool; } });
1036
+ var script_js_2 = require("./script.js");
1037
+ Object.defineProperty(exports, "splitStatements", { enumerable: true, get: function () { return script_js_2.splitStatements; } });
1038
+ var errors_js_2 = require("./errors.js");
1039
+ Object.defineProperty(exports, "PowDBError", { enumerable: true, get: function () { return errors_js_2.PowDBError; } });
1040
+ Object.defineProperty(exports, "isPowDBError", { enumerable: true, get: function () { return errors_js_2.isPowDBError; } });
1041
+ Object.defineProperty(exports, "PowDBScriptError", { enumerable: true, get: function () { return errors_js_2.PowDBScriptError; } });
1042
+ Object.defineProperty(exports, "isPowDBScriptError", { enumerable: true, get: function () { return errors_js_2.isPowDBScriptError; } });
1043
+ var typed_js_2 = require("./typed.js");
1044
+ Object.defineProperty(exports, "coerceValue", { enumerable: true, get: function () { return typed_js_2.coerceValue; } });
1045
+ Object.defineProperty(exports, "coerceRow", { enumerable: true, get: function () { return typed_js_2.coerceRow; } });
1046
+ Object.defineProperty(exports, "coerceRows", { enumerable: true, get: function () { return typed_js_2.coerceRows; } });