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