@zvndev/powdb-client 0.9.0 → 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.
- package/dist/cjs/errors.d.ts +70 -0
- package/dist/cjs/errors.js +68 -0
- package/dist/cjs/escape.d.ts +54 -0
- package/dist/cjs/escape.js +131 -0
- package/dist/cjs/index.d.ts +393 -0
- package/dist/cjs/index.js +1046 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/pool.d.ts +140 -0
- package/dist/cjs/pool.js +335 -0
- package/dist/cjs/protocol.d.ts +185 -0
- package/dist/cjs/protocol.js +645 -0
- package/dist/cjs/script.d.ts +25 -0
- package/dist/cjs/script.js +69 -0
- package/dist/cjs/typed.d.ts +49 -0
- package/dist/cjs/typed.js +105 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +15 -6
|
@@ -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; } });
|