@zvndev/powdb-client 0.7.2 → 0.8.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 - 2026-07-02
4
+
5
+ - Released in lockstep with PowDB workspace v0.8.0 (Embedded Sync Milestone 0).
6
+ - Added **experimental** low-level authenticated embedded-sync protocol helpers:
7
+ `client.syncStatus`, `client.syncPull`, and `client.syncAck`.
8
+ - Added TypeScript wire support for the private sync status/pull/ack frames
9
+ (`0x20`-`0x25`), retained-unit payloads, sync repair actions, and the
10
+ `sync` observability event.
11
+ - These sync helpers are experimental and beta-gated: pin matching client/server
12
+ versions. Plain `query(powql)` / `querySql(sql)` frames are unchanged.
13
+
3
14
  ## 0.5.1 - 2026-06-17
4
15
 
5
16
  - Kept the npm client release in lockstep with PowDB workspace v0.5.1.
@@ -12,6 +23,7 @@ All notable changes to `@zvndev/powdb-client`.
12
23
 
13
24
  | Client version | Compatible PowDB server | Notes |
14
25
  |---|---|---|
26
+ | 0.8.x | Matching sync-enabled 0.8.x server | Adds experimental private authenticated embedded-sync helpers (`syncStatus`, `syncPull`, `syncAck`) over wire frames `0x20`-`0x25`. Pin matching client/server versions for these helpers until the stable `@zvndev/powdb-sync` package boundary exists; plain `query(powql)` and `querySql(sql)` frames remain unchanged. |
15
27
  | 0.5.x | 0.4.7+ | Adds `client.query(powql, params)` for `$N` parameter binding. The `QueryWithParams` (`0x04`) wire message is only understood by server ≥0.4.7; parameterized queries against an older server error out. Plain `query(powql)` calls remain compatible with 0.3.x–0.4.x. |
16
28
  | 0.4.x | 0.3.x – 0.4.x | Wire protocol v1 plus the optional Connect `username` field. **Multi-user mode requires client ≥0.4.0 AND server ≥0.4.6** (the server enforces roles as of 0.4.6; 0.4.5 accepted the username but did not enforce `readonly`). When `user` is omitted the Connect frame is byte-identical to the 0.3.x shape, so legacy shared-password and no-auth servers work unchanged. |
17
29
  | 0.3.x | 0.3.x – 0.4.x | Wire protocol v1. The client warns only on a major-version mismatch, so any `0.x` server connects. Minor server bumps may add new opcodes; the client tolerates unknown response codes by surfacing `PowDBError`. Pin both ends. **Caveat:** the 0.3.x client has no `user` option, so it cannot authenticate to a 0.4.5+ server running in **multi-user mode** (the server requires a username once any named user is defined). Shared-password mode works fine. |
package/README.md CHANGED
@@ -81,6 +81,54 @@ await client.query("insert User { name := $1, age := $2 }", ["Dana", null]);
81
81
 
82
82
  `QueryParam` is `string | number | bigint | boolean | null`. The params form sends the `QueryWithParams` (0x04) wire message and **requires powdb-server >= 0.4.7**. The plain `query(q)` and `query(q, { signal })` forms are unchanged.
83
83
 
84
+ ## Experimental embedded sync protocol helpers
85
+
86
+ The client exposes experimental low-level helpers for the private authenticated
87
+ embedded-sync frames used by the in-progress replica product:
88
+
89
+ ```typescript
90
+ client.on("sync", ({ operation, durationMs, ok, stale, repairAction }) => {
91
+ metrics.histogram("powdb_sync_ms", durationMs, {
92
+ operation,
93
+ ok: String(ok),
94
+ repairAction: repairAction ?? "error",
95
+ });
96
+ });
97
+
98
+ const status = await client.syncStatus("replica-a");
99
+
100
+ if (status.repairAction === "pull" && status.lastAppliedLsn !== null) {
101
+ const pull = await client.syncPull({
102
+ replicaId: "replica-a",
103
+ sinceLsn: status.lastAppliedLsn,
104
+ maxUnits: 4096,
105
+ maxBytes: 16n * 1024n * 1024n,
106
+ databaseId: "0123456789abcdeffedcba9876543210",
107
+ primaryGeneration: 1n,
108
+ walFormatVersion: 1,
109
+ catalogVersion: 1,
110
+ segmentFormatVersion: 1,
111
+ });
112
+
113
+ // Apply `pull.units` with the embedded replica layer, then acknowledge.
114
+ const lastUnit = pull.units.at(-1);
115
+ if (lastUnit) {
116
+ await client.syncAck({
117
+ replicaId: "replica-a",
118
+ appliedLsn: lastUnit.lsn,
119
+ remoteLsn: pull.status.remoteLsn,
120
+ });
121
+ }
122
+ }
123
+ ```
124
+
125
+ These methods are intentionally not a complete or stable
126
+ `@zvndev/powdb-sync` package. They expose the authenticated server
127
+ pull/status/ack protocol so the embedded replica layer can be built and tested
128
+ without hand-rolled frame code. Pin matching client/server versions while using
129
+ them; the stable product boundary will be the sync package. Servers must have
130
+ sync enabled and reject unauthenticated or `readonly` users.
131
+
84
132
  ## Authentication
85
133
 
86
134
  For servers using the legacy shared password (`POWDB_PASSWORD`), pass
@@ -254,6 +302,7 @@ Events:
254
302
  | Event | Payload | Fires when |
255
303
  |---|---|---|
256
304
  | `query` | `{ query, durationMs, ok, kind?, error? }` | After every query completes (success or failure) |
305
+ | `sync` | `{ operation, replicaId, durationMs, ok, status?, stale?, repairAction?, units?, advanced?, error? }` | After every sync status/pull/ack request completes |
257
306
  | `close` | `{ error: Error \| null }` | Exactly once per socket, on normal or error close |
258
307
 
259
308
  ## Cancellation
@@ -273,11 +322,12 @@ try {
273
322
  }
274
323
  ```
275
324
 
276
- If you abort with a custom reason (`ctrl.abort(myError)`) and that reason is
277
- an `Error`, it is thrown as-is instead. Otherwise the client throws a
278
- `PowDBError` with `code === "aborted"`.
325
+ A plain `ctrl.abort()` throws a `PowDBError` with `code === "aborted"`. If you
326
+ abort with a custom `Error` reason (`ctrl.abort(myError)`), that error is thrown
327
+ as-is; any non-`Error` reason is wrapped in a `PowDBError` (`code === "aborted"`).
279
328
 
280
- The socket stays open — the server's reply is silently discarded so other in-flight queries keep working.
329
+ The socket stays open — the aborted query's reply is silently discarded when it
330
+ arrives, and every other in-flight query still receives its own result.
281
331
 
282
332
  ## API
283
333
 
@@ -312,6 +362,19 @@ Sends a PowQL query and returns a `Promise<QueryResult>`:
312
362
 
313
363
  Throws a `PowDBError` (see Structured errors above) on any failure.
314
364
 
365
+ ### `client.querySql(query, opts?)`
366
+
367
+ Runs a statement through the server's **SQL frontend** and returns a
368
+ `Promise<QueryResult>` — the same result shape as `query()`. The plain
369
+ `query()` method stays PowQL for wire compatibility; `querySql()` sends the
370
+ dedicated `QuerySql` wire message. Requires server ≥0.5.0 (the SQL frontend was
371
+ added in 0.5.0); see `docs/SQL.md` for the supported subset. `opts.signal?:
372
+ AbortSignal` cancels the query (see Cancellation above).
373
+
374
+ ```typescript
375
+ const result = await client.querySql("SELECT name, age FROM User WHERE age > 27");
376
+ ```
377
+
315
378
  ### `client.queryTyped(query, schema, opts?)`
316
379
 
317
380
  Like `query()`, but coerces each row's string values to JS types using the
package/dist/index.d.ts CHANGED
@@ -16,9 +16,10 @@
16
16
  */
17
17
  import * as tls from "node:tls";
18
18
  import { EventEmitter } from "node:events";
19
+ import { type SyncRepairAction, type WireRetainedUnit, type WireSyncStatus } from "./protocol.js";
19
20
  import { type TypedRow, type TypedSchema } from "./typed.js";
20
21
  /** Client library version. Compared to the server's reported version. */
21
- export declare const CLIENT_VERSION = "0.7.2";
22
+ export declare const CLIENT_VERSION = "0.8.1";
22
23
  export type QueryResult = {
23
24
  kind: "rows";
24
25
  columns: string[];
@@ -42,6 +43,41 @@ export type QueryResult = {
42
43
  * binds as an int; `null` binds PowQL `null`.
43
44
  */
44
45
  export type QueryParam = string | number | bigint | boolean | null;
46
+ /** Unsigned 64-bit sync protocol value. Numbers must be safe non-negative integers. */
47
+ export type SyncU64 = bigint | number;
48
+ /**
49
+ * Database identity carried by the sync protocol. Strings are the 32-hex-char
50
+ * form stored in `.powdb-sync/identity.json`; byte arrays must be exactly 16B.
51
+ */
52
+ export type SyncDatabaseId = string | Uint8Array;
53
+ export interface SyncPullRequest {
54
+ replicaId: string;
55
+ sinceLsn: SyncU64;
56
+ maxUnits: number;
57
+ maxBytes: SyncU64;
58
+ databaseId: SyncDatabaseId;
59
+ primaryGeneration: SyncU64;
60
+ walFormatVersion: number;
61
+ catalogVersion: number;
62
+ segmentFormatVersion: number;
63
+ }
64
+ export interface SyncAckRequest {
65
+ replicaId: string;
66
+ appliedLsn: SyncU64;
67
+ remoteLsn: SyncU64;
68
+ }
69
+ export interface SyncPullResult {
70
+ status: WireSyncStatus;
71
+ units: WireRetainedUnit[];
72
+ hasMore: boolean;
73
+ }
74
+ export interface SyncAckResult {
75
+ previousAppliedLsn: bigint;
76
+ appliedLsn: bigint;
77
+ remoteLsn: bigint;
78
+ advanced: boolean;
79
+ status: WireSyncStatus;
80
+ }
45
81
  export interface ClientOptions {
46
82
  /** TCP host. Required unless `path` (a Unix domain socket) is given. */
47
83
  host?: string;
@@ -93,6 +129,20 @@ export interface ClientEvents {
93
129
  error?: Error;
94
130
  }
95
131
  ];
132
+ sync: [
133
+ {
134
+ operation: "status" | "pull" | "ack";
135
+ replicaId: string;
136
+ durationMs: number;
137
+ ok: boolean;
138
+ status?: WireSyncStatus;
139
+ stale?: boolean;
140
+ repairAction?: SyncRepairAction;
141
+ units?: number;
142
+ advanced?: boolean;
143
+ error?: Error;
144
+ }
145
+ ];
96
146
  close: [{
97
147
  error: Error | null;
98
148
  }];
@@ -130,6 +180,30 @@ export declare class Client extends EventEmitter<ClientEvents> {
130
180
  querySql(query: string, opts?: {
131
181
  signal?: AbortSignal;
132
182
  }): Promise<QueryResult>;
183
+ /**
184
+ * Fetch primary-side sync status for one embedded replica cursor.
185
+ *
186
+ * This speaks the private authenticated sync frame added for the embedded
187
+ * replica product. Servers without sync support return a protocol/query
188
+ * error; unauthenticated or readonly users are rejected server-side.
189
+ */
190
+ syncStatus(replicaId: string, opts?: {
191
+ signal?: AbortSignal;
192
+ }): Promise<WireSyncStatus>;
193
+ /**
194
+ * Pull a bounded retained-unit chunk after this replica's primary-side cursor.
195
+ *
196
+ * The caller supplies the database identity and format versions from the
197
+ * sync bootstrap metadata. The server rejects mismatches and non-applyable
198
+ * transaction cuts instead of returning ambiguous history.
199
+ */
200
+ syncPull(request: SyncPullRequest, opts?: {
201
+ signal?: AbortSignal;
202
+ }): Promise<SyncPullResult>;
203
+ /** Acknowledge that the replica applied retained history through `appliedLsn`. */
204
+ syncAck(request: SyncAckRequest, opts?: {
205
+ signal?: AbortSignal;
206
+ }): Promise<SyncAckResult>;
133
207
  /**
134
208
  * Like {@link query}, but coerces string result columns to typed JS values
135
209
  * using the caller-supplied schema. See `./typed.ts` for the coercion
@@ -175,8 +249,8 @@ export declare class Client extends EventEmitter<ClientEvents> {
175
249
  private onClose;
176
250
  }
177
251
  export { encode, tryDecode } from "./protocol.js";
178
- export type { Message, WireParam } from "./protocol.js";
179
- export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, } from "./protocol.js";
252
+ export type { Message, SyncRepairAction, WireParam, WireRetainedUnit, WireSyncStatus, } from "./protocol.js";
253
+ export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, MAX_SYNC_UNITS, MAX_SYNC_PULL_UNITS, MAX_SYNC_PULL_BYTES, } from "./protocol.js";
180
254
  export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
181
255
  export { Pool } from "./pool.js";
182
256
  export type { PoolOptions } from "./pool.js";
package/dist/index.js CHANGED
@@ -17,11 +17,11 @@
17
17
  import * as net from "node:net";
18
18
  import * as tls from "node:tls";
19
19
  import { EventEmitter } from "node:events";
20
- import { encode, tryDecode, } from "./protocol.js";
20
+ import { encode, tryDecode, MAX_SYNC_PULL_BYTES, MAX_SYNC_PULL_UNITS, } from "./protocol.js";
21
21
  import { PowDBError } from "./errors.js";
22
22
  import { coerceRows, } from "./typed.js";
23
23
  /** Client library version. Compared to the server's reported version. */
24
- export const CLIENT_VERSION = "0.7.2";
24
+ export const CLIENT_VERSION = "0.8.1";
25
25
  function socketChunkToBuffer(chunk) {
26
26
  return typeof chunk === "string" ? Buffer.from(chunk) : chunk;
27
27
  }
@@ -44,6 +44,52 @@ function toWireParam(p) {
44
44
  throw new PowDBError(`unsupported query parameter type: ${typeof p}`, "protocol_error");
45
45
  }
46
46
  }
47
+ function toU64(value, label) {
48
+ if (typeof value === "bigint") {
49
+ if (value < 0n || value > 0xffffffffffffffffn) {
50
+ throw new PowDBError(`${label} must fit in u64`, "protocol_error");
51
+ }
52
+ return value;
53
+ }
54
+ if (!Number.isSafeInteger(value) || value < 0) {
55
+ throw new PowDBError(`${label} must be a safe non-negative integer or bigint`, "protocol_error");
56
+ }
57
+ return BigInt(value);
58
+ }
59
+ function toU16(value, label) {
60
+ if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
61
+ throw new PowDBError(`${label} must fit in u16`, "protocol_error");
62
+ }
63
+ return value;
64
+ }
65
+ function toSyncMaxUnits(value) {
66
+ if (!Number.isInteger(value) ||
67
+ value < 1 ||
68
+ value > MAX_SYNC_PULL_UNITS) {
69
+ throw new PowDBError(`maxUnits must be between 1 and ${MAX_SYNC_PULL_UNITS}`, "protocol_error");
70
+ }
71
+ return value;
72
+ }
73
+ function toSyncMaxBytes(value) {
74
+ const bytes = toU64(value, "maxBytes");
75
+ if (bytes < 1n || bytes > BigInt(MAX_SYNC_PULL_BYTES)) {
76
+ throw new PowDBError(`maxBytes must be between 1 and ${MAX_SYNC_PULL_BYTES}`, "protocol_error");
77
+ }
78
+ return bytes;
79
+ }
80
+ function toDatabaseId(value) {
81
+ if (typeof value === "string") {
82
+ if (!/^[0-9a-fA-F]{32}$/.test(value)) {
83
+ throw new PowDBError("databaseId string must be exactly 32 hex characters", "protocol_error");
84
+ }
85
+ return Buffer.from(value, "hex");
86
+ }
87
+ const bytes = Buffer.from(value);
88
+ if (bytes.length !== 16) {
89
+ throw new PowDBError(`databaseId must be exactly 16 bytes, got ${bytes.length}`, "protocol_error");
90
+ }
91
+ return bytes;
92
+ }
47
93
  /** Module-level set of host:port pairs we've already warned about. */
48
94
  const versionWarnings = new Set();
49
95
  /** Extract the major component of a dotted version string, e.g. "0.2.0" → "0". */
@@ -52,16 +98,19 @@ function majorOf(version) {
52
98
  return dot === -1 ? version : version.slice(0, dot);
53
99
  }
54
100
  /**
55
- * Build an AbortError. Prefers the caller-supplied `signal.reason` if it is
56
- * itself an `Error` (matches DOM semantics); otherwise wraps it in a
57
- * `PowDBError` with code `"aborted"` so catch-blocks can branch uniformly.
101
+ * Build an AbortError. A caller-supplied custom `Error` reason
102
+ * (`ctrl.abort(myError)`) passes through as-is to match DOM semantics. The
103
+ * default abort reason — Node's `DOMException` named `"AbortError"` and any
104
+ * non-Error reason are wrapped in a `PowDBError` with code `"aborted"` so the
105
+ * common `ctrl.abort()` case branches uniformly on `err.code === "aborted"`.
58
106
  */
59
107
  function abortError(signal) {
60
108
  if (signal && signal.reason !== undefined) {
61
109
  const r = signal.reason;
62
- if (r instanceof Error)
110
+ const isDefaultAbort = r instanceof DOMException && r.name === "AbortError";
111
+ if (r instanceof Error && !isDefaultAbort)
63
112
  return r;
64
- return new PowDBError(String(r), "aborted");
113
+ return new PowDBError(isDefaultAbort ? "query was aborted" : String(r), "aborted");
65
114
  }
66
115
  return new PowDBError("query was aborted", "aborted");
67
116
  }
@@ -267,6 +316,145 @@ export class Client extends EventEmitter {
267
316
  throw err;
268
317
  }
269
318
  }
319
+ /**
320
+ * Fetch primary-side sync status for one embedded replica cursor.
321
+ *
322
+ * This speaks the private authenticated sync frame added for the embedded
323
+ * replica product. Servers without sync support return a protocol/query
324
+ * error; unauthenticated or readonly users are rejected server-side.
325
+ */
326
+ async syncStatus(replicaId, opts) {
327
+ const start = Date.now();
328
+ try {
329
+ const reply = await this.send({ type: "SyncStatus", replicaId }, opts);
330
+ if (reply.type === "Error") {
331
+ throw new PowDBError(`sync status failed: ${reply.message}`, "query_failed");
332
+ }
333
+ if (reply.type !== "SyncStatusResult") {
334
+ throw new PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
335
+ }
336
+ this.emit("sync", {
337
+ operation: "status",
338
+ replicaId,
339
+ durationMs: Date.now() - start,
340
+ ok: true,
341
+ status: reply.status,
342
+ stale: reply.status.stale,
343
+ repairAction: reply.status.repairAction,
344
+ });
345
+ return reply.status;
346
+ }
347
+ catch (err) {
348
+ this.emit("sync", {
349
+ operation: "status",
350
+ replicaId,
351
+ durationMs: Date.now() - start,
352
+ ok: false,
353
+ error: err,
354
+ });
355
+ throw err;
356
+ }
357
+ }
358
+ /**
359
+ * Pull a bounded retained-unit chunk after this replica's primary-side cursor.
360
+ *
361
+ * The caller supplies the database identity and format versions from the
362
+ * sync bootstrap metadata. The server rejects mismatches and non-applyable
363
+ * transaction cuts instead of returning ambiguous history.
364
+ */
365
+ async syncPull(request, opts) {
366
+ const start = Date.now();
367
+ try {
368
+ const reply = await this.send({
369
+ type: "SyncPull",
370
+ replicaId: request.replicaId,
371
+ sinceLsn: toU64(request.sinceLsn, "sinceLsn"),
372
+ maxUnits: toSyncMaxUnits(request.maxUnits),
373
+ maxBytes: toSyncMaxBytes(request.maxBytes),
374
+ databaseId: toDatabaseId(request.databaseId),
375
+ primaryGeneration: toU64(request.primaryGeneration, "primaryGeneration"),
376
+ walFormatVersion: toU16(request.walFormatVersion, "walFormatVersion"),
377
+ catalogVersion: toU16(request.catalogVersion, "catalogVersion"),
378
+ segmentFormatVersion: toU16(request.segmentFormatVersion, "segmentFormatVersion"),
379
+ }, opts);
380
+ if (reply.type === "Error") {
381
+ throw new PowDBError(`sync pull failed: ${reply.message}`, "query_failed");
382
+ }
383
+ if (reply.type !== "SyncPullResult") {
384
+ throw new PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
385
+ }
386
+ this.emit("sync", {
387
+ operation: "pull",
388
+ replicaId: request.replicaId,
389
+ durationMs: Date.now() - start,
390
+ ok: true,
391
+ status: reply.status,
392
+ stale: reply.status.stale,
393
+ repairAction: reply.status.repairAction,
394
+ units: reply.units.length,
395
+ });
396
+ return {
397
+ status: reply.status,
398
+ units: reply.units,
399
+ hasMore: reply.hasMore,
400
+ };
401
+ }
402
+ catch (err) {
403
+ this.emit("sync", {
404
+ operation: "pull",
405
+ replicaId: request.replicaId,
406
+ durationMs: Date.now() - start,
407
+ ok: false,
408
+ error: err,
409
+ });
410
+ throw err;
411
+ }
412
+ }
413
+ /** Acknowledge that the replica applied retained history through `appliedLsn`. */
414
+ async syncAck(request, opts) {
415
+ const start = Date.now();
416
+ try {
417
+ const reply = await this.send({
418
+ type: "SyncAck",
419
+ replicaId: request.replicaId,
420
+ appliedLsn: toU64(request.appliedLsn, "appliedLsn"),
421
+ remoteLsn: toU64(request.remoteLsn, "remoteLsn"),
422
+ }, opts);
423
+ if (reply.type === "Error") {
424
+ throw new PowDBError(`sync ack failed: ${reply.message}`, "query_failed");
425
+ }
426
+ if (reply.type !== "SyncAckResult") {
427
+ throw new PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
428
+ }
429
+ this.emit("sync", {
430
+ operation: "ack",
431
+ replicaId: request.replicaId,
432
+ durationMs: Date.now() - start,
433
+ ok: true,
434
+ status: reply.status,
435
+ stale: reply.status.stale,
436
+ repairAction: reply.status.repairAction,
437
+ advanced: reply.advanced,
438
+ });
439
+ return {
440
+ previousAppliedLsn: reply.previousAppliedLsn,
441
+ appliedLsn: reply.appliedLsn,
442
+ remoteLsn: reply.remoteLsn,
443
+ advanced: reply.advanced,
444
+ status: reply.status,
445
+ };
446
+ }
447
+ catch (err) {
448
+ this.emit("sync", {
449
+ operation: "ack",
450
+ replicaId: request.replicaId,
451
+ durationMs: Date.now() - start,
452
+ ok: false,
453
+ error: err,
454
+ });
455
+ throw err;
456
+ }
457
+ }
270
458
  /**
271
459
  * Like {@link query}, but coerces string result columns to typed JS values
272
460
  * using the caller-supplied schema. See `./typed.ts` for the coercion
@@ -342,8 +530,17 @@ export class Client extends EventEmitter {
342
530
  * timeout so an unresponsive peer cannot make `close()` hang forever.
343
531
  */
344
532
  async close() {
345
- if (this.closed)
533
+ if (this.closed) {
534
+ // Already torn down (e.g. an error in onData/onClose closed the client
535
+ // without destroying the socket). Ensure the socket is released so a
536
+ // post-teardown close() resolves instead of keeping the event loop
537
+ // alive. `writableEnded` distinguishes that case from a concurrent
538
+ // close() mid-graceful-shutdown, which must not be force-destroyed.
539
+ if (!this.socket.destroyed && !this.socket.writableEnded) {
540
+ this.socket.destroy();
541
+ }
346
542
  return;
543
+ }
347
544
  try {
348
545
  this.socket.write(encode({ type: "Disconnect" }));
349
546
  }
@@ -497,19 +694,18 @@ export class Client extends EventEmitter {
497
694
  this.socket.write(encode({ type: "Pong" }));
498
695
  continue;
499
696
  }
500
- // Find the next non-settled pending entry and hand it the reply.
501
- // Settled entries at the head were aborted by the caller but their
502
- // reply is arriving now drop it silently.
503
- let entry = this.pending.shift();
504
- while (entry && entry.settled) {
505
- entry = this.pending.shift();
506
- }
507
- if (!entry) {
508
- // Server sent an unsolicited frame (or we got extra after aborts
509
- // with no replacement). Treat as protocol error.
697
+ // Replies are strictly FIFO: this frame belongs to exactly one pending
698
+ // entry the head. Consume one entry per frame. If it was aborted
699
+ // (settled), its reply is arriving now; drop the frame and keep decoding
700
+ // rather than delivering it to a later, live query.
701
+ const entry = this.pending.shift();
702
+ if (entry === undefined) {
703
+ // No pending entry at all — the server sent an unsolicited frame.
510
704
  this.onClose(new PowDBError("received unexpected frame from server", "protocol_error"));
511
705
  return;
512
706
  }
707
+ if (entry.settled)
708
+ continue;
513
709
  entry.resolve(decoded.msg);
514
710
  }
515
711
  }
@@ -605,7 +801,7 @@ function openSocket(target, timeoutMs, tlsOpt) {
605
801
  });
606
802
  }
607
803
  export { encode, tryDecode } from "./protocol.js";
608
- export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, } from "./protocol.js";
804
+ export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, MAX_SYNC_UNITS, MAX_SYNC_PULL_UNITS, MAX_SYNC_PULL_BYTES, } from "./protocol.js";
609
805
  export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
610
806
  export { Pool } from "./pool.js";
611
807
  export { PowDBError, isPowDBError } from "./errors.js";
@@ -17,6 +17,13 @@ export declare const MSG_QUERY = 3;
17
17
  export declare const MSG_QUERY_PARAMS = 4;
18
18
  /** SQL query frame. Plain Query remains PowQL for backward compatibility. */
19
19
  export declare const MSG_QUERY_SQL = 5;
20
+ /** Private embedded-sync control frames. Requires authenticated server support. */
21
+ export declare const MSG_SYNC_STATUS = 32;
22
+ export declare const MSG_SYNC_PULL = 33;
23
+ export declare const MSG_SYNC_ACK = 34;
24
+ export declare const MSG_SYNC_STATUS_RESULT = 35;
25
+ export declare const MSG_SYNC_PULL_RESULT = 36;
26
+ export declare const MSG_SYNC_ACK_RESULT = 37;
20
27
  export declare const MSG_RESULT_ROWS = 7;
21
28
  export declare const MSG_RESULT_SCALAR = 8;
22
29
  export declare const MSG_RESULT_OK = 9;
@@ -33,6 +40,12 @@ export declare const MAX_ROWS = 10000000;
33
40
  export declare const MAX_COLUMNS = 4096;
34
41
  /** Maximum number of bound parameters in a QueryWithParams message. */
35
42
  export declare const MAX_PARAMS = 4096;
43
+ /** Maximum retained units accepted in one sync pull result. */
44
+ export declare const MAX_SYNC_UNITS = 4096;
45
+ /** Maximum retained units accepted by the server for one sync pull request. */
46
+ export declare const MAX_SYNC_PULL_UNITS = 4096;
47
+ /** Maximum retained-unit payload budget accepted by the server for one sync pull. */
48
+ export declare const MAX_SYNC_PULL_BYTES: number;
36
49
  /**
37
50
  * A positional parameter value for {@link MSG_QUERY_PARAMS}. Wire encoding
38
51
  * per param: a 1-byte tag followed by the body —
@@ -57,6 +70,27 @@ export type WireParam = {
57
70
  tag: "str";
58
71
  value: string;
59
72
  };
73
+ export type SyncRepairAction = "none" | "pull" | "awaitArchive" | "rebootstrap";
74
+ export interface WireSyncStatus {
75
+ replicaId: string;
76
+ active: boolean;
77
+ lastAppliedLsn: bigint | null;
78
+ remoteLsn: bigint;
79
+ servableLsn: bigint | null;
80
+ unarchivedLsn: bigint | null;
81
+ lagLsn: bigint | null;
82
+ lagBytes: bigint | null;
83
+ lagMs: bigint | null;
84
+ stale: boolean;
85
+ repairAction: SyncRepairAction;
86
+ lastSyncError: string | null;
87
+ }
88
+ export interface WireRetainedUnit {
89
+ txId: bigint;
90
+ recordType: number;
91
+ lsn: bigint;
92
+ data: Buffer;
93
+ }
60
94
  export type Message = {
61
95
  type: "Connect";
62
96
  dbName: string;
@@ -82,6 +116,40 @@ export type Message = {
82
116
  type: "QueryWithParams";
83
117
  query: string;
84
118
  params: WireParam[];
119
+ } | {
120
+ type: "SyncStatus";
121
+ replicaId: string;
122
+ } | {
123
+ type: "SyncPull";
124
+ replicaId: string;
125
+ sinceLsn: bigint;
126
+ maxUnits: number;
127
+ maxBytes: bigint;
128
+ databaseId: Buffer;
129
+ primaryGeneration: bigint;
130
+ walFormatVersion: number;
131
+ catalogVersion: number;
132
+ segmentFormatVersion: number;
133
+ } | {
134
+ type: "SyncAck";
135
+ replicaId: string;
136
+ appliedLsn: bigint;
137
+ remoteLsn: bigint;
138
+ } | {
139
+ type: "SyncStatusResult";
140
+ status: WireSyncStatus;
141
+ } | {
142
+ type: "SyncPullResult";
143
+ status: WireSyncStatus;
144
+ units: WireRetainedUnit[];
145
+ hasMore: boolean;
146
+ } | {
147
+ type: "SyncAckResult";
148
+ previousAppliedLsn: bigint;
149
+ appliedLsn: bigint;
150
+ remoteLsn: bigint;
151
+ advanced: boolean;
152
+ status: WireSyncStatus;
85
153
  } | {
86
154
  type: "ResultRows";
87
155
  columns: string[];
package/dist/protocol.js CHANGED
@@ -17,6 +17,13 @@ export const MSG_QUERY = 0x03;
17
17
  export const MSG_QUERY_PARAMS = 0x04;
18
18
  /** SQL query frame. Plain Query remains PowQL for backward compatibility. */
19
19
  export const MSG_QUERY_SQL = 0x05;
20
+ /** Private embedded-sync control frames. Requires authenticated server support. */
21
+ export const MSG_SYNC_STATUS = 0x20;
22
+ export const MSG_SYNC_PULL = 0x21;
23
+ export const MSG_SYNC_ACK = 0x22;
24
+ export const MSG_SYNC_STATUS_RESULT = 0x23;
25
+ export const MSG_SYNC_PULL_RESULT = 0x24;
26
+ export const MSG_SYNC_ACK_RESULT = 0x25;
20
27
  export const MSG_RESULT_ROWS = 0x07;
21
28
  export const MSG_RESULT_SCALAR = 0x08;
22
29
  export const MSG_RESULT_OK = 0x09;
@@ -34,6 +41,12 @@ export const MAX_ROWS = 10_000_000;
34
41
  export const MAX_COLUMNS = 4096;
35
42
  /** Maximum number of bound parameters in a QueryWithParams message. */
36
43
  export const MAX_PARAMS = 4096;
44
+ /** Maximum retained units accepted in one sync pull result. */
45
+ export const MAX_SYNC_UNITS = 4096;
46
+ /** Maximum retained units accepted by the server for one sync pull request. */
47
+ export const MAX_SYNC_PULL_UNITS = 4096;
48
+ /** Maximum retained-unit payload budget accepted by the server for one sync pull. */
49
+ export const MAX_SYNC_PULL_BYTES = 16 * 1024 * 1024;
37
50
  // ───── Encoding ────────────────────────────────────────────────────────────
38
51
  export function encode(msg) {
39
52
  let msgType;
@@ -102,6 +115,63 @@ export function encode(msg) {
102
115
  msgType = MSG_QUERY_PARAMS;
103
116
  break;
104
117
  }
118
+ case "SyncStatus":
119
+ payload = encodeString(msg.replicaId);
120
+ msgType = MSG_SYNC_STATUS;
121
+ break;
122
+ case "SyncPull": {
123
+ if (msg.databaseId.length !== 16) {
124
+ throw new Error(`sync databaseId must be exactly 16 bytes, got ${msg.databaseId.length}`);
125
+ }
126
+ payload = Buffer.concat([
127
+ encodeString(msg.replicaId),
128
+ u64LE(msg.sinceLsn),
129
+ u32LE(msg.maxUnits),
130
+ u64LE(msg.maxBytes),
131
+ msg.databaseId,
132
+ u64LE(msg.primaryGeneration),
133
+ u16LE(msg.walFormatVersion),
134
+ u16LE(msg.catalogVersion),
135
+ u16LE(msg.segmentFormatVersion),
136
+ ]);
137
+ msgType = MSG_SYNC_PULL;
138
+ break;
139
+ }
140
+ case "SyncAck":
141
+ payload = Buffer.concat([
142
+ encodeString(msg.replicaId),
143
+ u64LE(msg.appliedLsn),
144
+ u64LE(msg.remoteLsn),
145
+ ]);
146
+ msgType = MSG_SYNC_ACK;
147
+ break;
148
+ case "SyncStatusResult":
149
+ payload = encodeSyncStatus(msg.status);
150
+ msgType = MSG_SYNC_STATUS_RESULT;
151
+ break;
152
+ case "SyncPullResult": {
153
+ const parts = [
154
+ encodeSyncStatus(msg.status),
155
+ u32LE(msg.units.length),
156
+ ];
157
+ for (const unit of msg.units) {
158
+ parts.push(encodeRetainedUnit(unit));
159
+ }
160
+ parts.push(Buffer.from([msg.hasMore ? 1 : 0]));
161
+ payload = Buffer.concat(parts);
162
+ msgType = MSG_SYNC_PULL_RESULT;
163
+ break;
164
+ }
165
+ case "SyncAckResult":
166
+ payload = Buffer.concat([
167
+ u64LE(msg.previousAppliedLsn),
168
+ u64LE(msg.appliedLsn),
169
+ u64LE(msg.remoteLsn),
170
+ Buffer.from([msg.advanced ? 1 : 0]),
171
+ encodeSyncStatus(msg.status),
172
+ ]);
173
+ msgType = MSG_SYNC_ACK_RESULT;
174
+ break;
105
175
  case "ResultRows": {
106
176
  const parts = [];
107
177
  const colCount = Buffer.alloc(2);
@@ -235,6 +305,70 @@ function decodePayload(msgType, payload) {
235
305
  }
236
306
  return { type: "QueryWithParams", query, params };
237
307
  }
308
+ case MSG_SYNC_STATUS:
309
+ return { type: "SyncStatus", replicaId: decodeString(payload, cursor) };
310
+ case MSG_SYNC_PULL: {
311
+ const replicaId = decodeString(payload, cursor);
312
+ const sinceLsn = readU64(payload, cursor, "sync pull since LSN");
313
+ const maxUnits = readU32(payload, cursor, "sync pull max units");
314
+ const maxBytes = readU64(payload, cursor, "sync pull max bytes");
315
+ const databaseId = readFixedBytes(payload, cursor, 16, "sync database id");
316
+ const primaryGeneration = readU64(payload, cursor, "sync primary generation");
317
+ const walFormatVersion = readU16(payload, cursor, "sync WAL format version");
318
+ const catalogVersion = readU16(payload, cursor, "sync catalog version");
319
+ const segmentFormatVersion = readU16(payload, cursor, "sync segment format version");
320
+ return {
321
+ type: "SyncPull",
322
+ replicaId,
323
+ sinceLsn,
324
+ maxUnits,
325
+ maxBytes,
326
+ databaseId,
327
+ primaryGeneration,
328
+ walFormatVersion,
329
+ catalogVersion,
330
+ segmentFormatVersion,
331
+ };
332
+ }
333
+ case MSG_SYNC_ACK: {
334
+ const replicaId = decodeString(payload, cursor);
335
+ const appliedLsn = readU64(payload, cursor, "sync ack applied LSN");
336
+ const remoteLsn = readU64(payload, cursor, "sync ack remote LSN");
337
+ return { type: "SyncAck", replicaId, appliedLsn, remoteLsn };
338
+ }
339
+ case MSG_SYNC_STATUS_RESULT:
340
+ return {
341
+ type: "SyncStatusResult",
342
+ status: decodeSyncStatus(payload, cursor),
343
+ };
344
+ case MSG_SYNC_PULL_RESULT: {
345
+ const status = decodeSyncStatus(payload, cursor);
346
+ const count = readU32(payload, cursor, "sync retained unit count");
347
+ if (count > MAX_SYNC_UNITS) {
348
+ throw new Error(`too many retained units: ${count} (max ${MAX_SYNC_UNITS})`);
349
+ }
350
+ const units = [];
351
+ for (let i = 0; i < count; i++) {
352
+ units.push(decodeRetainedUnit(payload, cursor));
353
+ }
354
+ const hasMore = readBool(payload, cursor, "sync has_more");
355
+ return { type: "SyncPullResult", status, units, hasMore };
356
+ }
357
+ case MSG_SYNC_ACK_RESULT: {
358
+ const previousAppliedLsn = readU64(payload, cursor, "previous applied LSN");
359
+ const appliedLsn = readU64(payload, cursor, "applied LSN");
360
+ const remoteLsn = readU64(payload, cursor, "remote LSN");
361
+ const advanced = readBool(payload, cursor, "sync ack advanced");
362
+ const status = decodeSyncStatus(payload, cursor);
363
+ return {
364
+ type: "SyncAckResult",
365
+ previousAppliedLsn,
366
+ appliedLsn,
367
+ remoteLsn,
368
+ advanced,
369
+ status,
370
+ };
371
+ }
238
372
  case MSG_RESULT_ROWS: {
239
373
  const colCount = readU16(payload, cursor, "column count");
240
374
  if (colCount > MAX_COLUMNS) {
@@ -248,6 +382,14 @@ function decodePayload(msgType, payload) {
248
382
  if (rowCount > MAX_ROWS) {
249
383
  throw new Error(`too many rows: ${rowCount} (max ${MAX_ROWS})`);
250
384
  }
385
+ if (colCount === 0 && rowCount > 0) {
386
+ throw new Error("nonzero row count with zero columns");
387
+ }
388
+ const remaining = BigInt(payload.length - cursor.pos);
389
+ const minRowBytes = BigInt(rowCount) * BigInt(colCount) * 4n;
390
+ if (remaining < minRowBytes) {
391
+ throw new Error(`row data too short for declared shape: ${rowCount} rows x ${colCount} columns`);
392
+ }
251
393
  const rows = [];
252
394
  for (let r = 0; r < rowCount; r++) {
253
395
  const row = [];
@@ -278,6 +420,112 @@ function decodePayload(msgType, payload) {
278
420
  throw new Error(`unknown message type: 0x${msgType.toString(16)}`);
279
421
  }
280
422
  }
423
+ function encodeSyncStatus(status) {
424
+ return Buffer.concat([
425
+ encodeString(status.replicaId),
426
+ Buffer.from([status.active ? 1 : 0]),
427
+ encodeOptionalU64(status.lastAppliedLsn),
428
+ u64LE(status.remoteLsn),
429
+ encodeOptionalU64(status.servableLsn),
430
+ encodeOptionalU64(status.unarchivedLsn),
431
+ encodeOptionalU64(status.lagLsn),
432
+ encodeOptionalU64(status.lagBytes),
433
+ encodeOptionalU64(status.lagMs),
434
+ Buffer.from([
435
+ status.stale ? 1 : 0,
436
+ encodeRepairAction(status.repairAction),
437
+ ]),
438
+ encodeOptionalString(status.lastSyncError),
439
+ ]);
440
+ }
441
+ function decodeSyncStatus(buf, cursor) {
442
+ const replicaId = decodeString(buf, cursor);
443
+ const active = readBool(buf, cursor, "sync status active");
444
+ const lastAppliedLsn = readOptionalU64(buf, cursor, "sync status last applied LSN");
445
+ const remoteLsn = readU64(buf, cursor, "sync status remote LSN");
446
+ const servableLsn = readOptionalU64(buf, cursor, "sync status servable LSN");
447
+ const unarchivedLsn = readOptionalU64(buf, cursor, "sync status unarchived LSN");
448
+ const lagLsn = readOptionalU64(buf, cursor, "sync status lag LSN");
449
+ const lagBytes = readOptionalU64(buf, cursor, "sync status lag bytes");
450
+ const lagMs = readOptionalU64(buf, cursor, "sync status lag milliseconds");
451
+ const stale = readBool(buf, cursor, "sync status stale");
452
+ const repairAction = decodeRepairAction(readU8(buf, cursor, "sync repair action"));
453
+ const lastSyncError = readOptionalString(buf, cursor, "sync status last error");
454
+ return {
455
+ replicaId,
456
+ active,
457
+ lastAppliedLsn,
458
+ remoteLsn,
459
+ servableLsn,
460
+ unarchivedLsn,
461
+ lagLsn,
462
+ lagBytes,
463
+ lagMs,
464
+ stale,
465
+ repairAction,
466
+ lastSyncError,
467
+ };
468
+ }
469
+ function encodeRetainedUnit(unit) {
470
+ return Buffer.concat([
471
+ u64LE(unit.txId),
472
+ Buffer.from([u8(unit.recordType, "sync retained unit record type")]),
473
+ u64LE(unit.lsn),
474
+ encodeBytes(unit.data),
475
+ ]);
476
+ }
477
+ function decodeRetainedUnit(buf, cursor) {
478
+ const txId = readU64(buf, cursor, "sync retained unit tx id");
479
+ const recordType = readU8(buf, cursor, "sync retained unit record type");
480
+ const lsn = readU64(buf, cursor, "sync retained unit LSN");
481
+ const data = readBytes(buf, cursor, "sync retained unit payload");
482
+ return { txId, recordType, lsn, data };
483
+ }
484
+ function encodeRepairAction(action) {
485
+ switch (action) {
486
+ case "none":
487
+ return 0;
488
+ case "pull":
489
+ return 1;
490
+ case "awaitArchive":
491
+ return 2;
492
+ case "rebootstrap":
493
+ return 3;
494
+ }
495
+ }
496
+ function decodeRepairAction(raw) {
497
+ switch (raw) {
498
+ case 0:
499
+ return "none";
500
+ case 1:
501
+ return "pull";
502
+ case 2:
503
+ return "awaitArchive";
504
+ case 3:
505
+ return "rebootstrap";
506
+ default:
507
+ throw new Error(`unknown sync repair action: ${raw}`);
508
+ }
509
+ }
510
+ function encodeOptionalU64(value) {
511
+ return value === null
512
+ ? Buffer.from([0])
513
+ : Buffer.concat([Buffer.from([1]), u64LE(value)]);
514
+ }
515
+ function readOptionalU64(buf, cursor, label) {
516
+ return readBool(buf, cursor, label) ? readU64(buf, cursor, label) : null;
517
+ }
518
+ function encodeOptionalString(value) {
519
+ return value === null
520
+ ? Buffer.from([0])
521
+ : Buffer.concat([Buffer.from([1]), encodeString(value)]);
522
+ }
523
+ function readOptionalString(buf, cursor, label) {
524
+ return readBool(buf, cursor, label) ? decodeString(buf, cursor) : null;
525
+ }
526
+ function encodeBytes(bytes) {
527
+ return Buffer.concat([u32LE(bytes.length), bytes]);
528
+ }
281
529
  // ───── String helpers ──────────────────────────────────────────────────────
282
530
  function encodeString(s) {
283
531
  const bytes = Buffer.from(s, "utf8");
@@ -306,6 +554,14 @@ function readU8(buf, cursor, label) {
306
554
  cursor.pos += 1;
307
555
  return value;
308
556
  }
557
+ function readBool(buf, cursor, label) {
558
+ const raw = readU8(buf, cursor, label);
559
+ if (raw === 0)
560
+ return false;
561
+ if (raw === 1)
562
+ return true;
563
+ throw new Error(`invalid boolean for ${label}: ${raw}`);
564
+ }
309
565
  function readI64(buf, cursor, label) {
310
566
  if (cursor.pos + 8 > buf.length) {
311
567
  throw new Error(`truncated ${label}`);
@@ -346,8 +602,39 @@ function readU64(buf, cursor, label) {
346
602
  cursor.pos += 8;
347
603
  return value;
348
604
  }
605
+ function readFixedBytes(buf, cursor, len, label) {
606
+ if (cursor.pos + len > buf.length) {
607
+ throw new Error(`truncated ${label}`);
608
+ }
609
+ const value = Buffer.from(buf.subarray(cursor.pos, cursor.pos + len));
610
+ cursor.pos += len;
611
+ return value;
612
+ }
613
+ function readBytes(buf, cursor, label) {
614
+ const len = readU32(buf, cursor, label);
615
+ return readFixedBytes(buf, cursor, len, label);
616
+ }
617
+ function u16LE(n) {
618
+ const b = Buffer.alloc(2);
619
+ b.writeUInt16LE(n, 0);
620
+ return b;
621
+ }
622
+ function u8(n, label) {
623
+ if (!Number.isInteger(n) || n < 0 || n > 0xff) {
624
+ throw new Error(`${label} must fit in u8`);
625
+ }
626
+ return n;
627
+ }
349
628
  function u32LE(n) {
350
629
  const b = Buffer.alloc(4);
351
630
  b.writeUInt32LE(n, 0);
352
631
  return b;
353
632
  }
633
+ function u64LE(n) {
634
+ if (n < 0n || n > 0xffffffffffffffffn) {
635
+ throw new Error(`u64 value out of range: ${n}`);
636
+ }
637
+ const b = Buffer.alloc(8);
638
+ b.writeBigUInt64LE(n, 0);
639
+ return b;
640
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/powdb-client",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
4
  "description": "TypeScript client for PowDB (PowQL wire protocol)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.29.3",
@@ -51,6 +51,7 @@
51
51
  "test": "tsx test/run-with-server.ts test/client.test.ts",
52
52
  "test:escape": "tsx test/escape.test.ts",
53
53
  "test:protocol": "tsx test/protocol.test.ts",
54
+ "test:sync-live": "tsx test/sync-live.test.ts",
54
55
  "test:auth": "tsx test/auth.test.ts",
55
56
  "test:pool": "tsx test/run-with-server.ts test/pool.test.ts",
56
57
  "test:typed": "tsx test/typed.test.ts",
@@ -60,7 +61,7 @@
60
61
  "prepublishOnly": "npm run build"
61
62
  },
62
63
  "devDependencies": {
63
- "@types/node": "^25",
64
+ "@types/node": "^26",
64
65
  "tsx": "^4",
65
66
  "typescript": "^6.0"
66
67
  }