@zvndev/powdb-client 0.7.2 → 0.8.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/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
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.0";
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.0";
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". */
@@ -267,6 +313,145 @@ export class Client extends EventEmitter {
267
313
  throw err;
268
314
  }
269
315
  }
316
+ /**
317
+ * Fetch primary-side sync status for one embedded replica cursor.
318
+ *
319
+ * This speaks the private authenticated sync frame added for the embedded
320
+ * replica product. Servers without sync support return a protocol/query
321
+ * error; unauthenticated or readonly users are rejected server-side.
322
+ */
323
+ async syncStatus(replicaId, opts) {
324
+ const start = Date.now();
325
+ try {
326
+ const reply = await this.send({ type: "SyncStatus", replicaId }, opts);
327
+ if (reply.type === "Error") {
328
+ throw new PowDBError(`sync status failed: ${reply.message}`, "query_failed");
329
+ }
330
+ if (reply.type !== "SyncStatusResult") {
331
+ throw new PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
332
+ }
333
+ this.emit("sync", {
334
+ operation: "status",
335
+ replicaId,
336
+ durationMs: Date.now() - start,
337
+ ok: true,
338
+ status: reply.status,
339
+ stale: reply.status.stale,
340
+ repairAction: reply.status.repairAction,
341
+ });
342
+ return reply.status;
343
+ }
344
+ catch (err) {
345
+ this.emit("sync", {
346
+ operation: "status",
347
+ replicaId,
348
+ durationMs: Date.now() - start,
349
+ ok: false,
350
+ error: err,
351
+ });
352
+ throw err;
353
+ }
354
+ }
355
+ /**
356
+ * Pull a bounded retained-unit chunk after this replica's primary-side cursor.
357
+ *
358
+ * The caller supplies the database identity and format versions from the
359
+ * sync bootstrap metadata. The server rejects mismatches and non-applyable
360
+ * transaction cuts instead of returning ambiguous history.
361
+ */
362
+ async syncPull(request, opts) {
363
+ const start = Date.now();
364
+ try {
365
+ const reply = await this.send({
366
+ type: "SyncPull",
367
+ replicaId: request.replicaId,
368
+ sinceLsn: toU64(request.sinceLsn, "sinceLsn"),
369
+ maxUnits: toSyncMaxUnits(request.maxUnits),
370
+ maxBytes: toSyncMaxBytes(request.maxBytes),
371
+ databaseId: toDatabaseId(request.databaseId),
372
+ primaryGeneration: toU64(request.primaryGeneration, "primaryGeneration"),
373
+ walFormatVersion: toU16(request.walFormatVersion, "walFormatVersion"),
374
+ catalogVersion: toU16(request.catalogVersion, "catalogVersion"),
375
+ segmentFormatVersion: toU16(request.segmentFormatVersion, "segmentFormatVersion"),
376
+ }, opts);
377
+ if (reply.type === "Error") {
378
+ throw new PowDBError(`sync pull failed: ${reply.message}`, "query_failed");
379
+ }
380
+ if (reply.type !== "SyncPullResult") {
381
+ throw new PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
382
+ }
383
+ this.emit("sync", {
384
+ operation: "pull",
385
+ replicaId: request.replicaId,
386
+ durationMs: Date.now() - start,
387
+ ok: true,
388
+ status: reply.status,
389
+ stale: reply.status.stale,
390
+ repairAction: reply.status.repairAction,
391
+ units: reply.units.length,
392
+ });
393
+ return {
394
+ status: reply.status,
395
+ units: reply.units,
396
+ hasMore: reply.hasMore,
397
+ };
398
+ }
399
+ catch (err) {
400
+ this.emit("sync", {
401
+ operation: "pull",
402
+ replicaId: request.replicaId,
403
+ durationMs: Date.now() - start,
404
+ ok: false,
405
+ error: err,
406
+ });
407
+ throw err;
408
+ }
409
+ }
410
+ /** Acknowledge that the replica applied retained history through `appliedLsn`. */
411
+ async syncAck(request, opts) {
412
+ const start = Date.now();
413
+ try {
414
+ const reply = await this.send({
415
+ type: "SyncAck",
416
+ replicaId: request.replicaId,
417
+ appliedLsn: toU64(request.appliedLsn, "appliedLsn"),
418
+ remoteLsn: toU64(request.remoteLsn, "remoteLsn"),
419
+ }, opts);
420
+ if (reply.type === "Error") {
421
+ throw new PowDBError(`sync ack failed: ${reply.message}`, "query_failed");
422
+ }
423
+ if (reply.type !== "SyncAckResult") {
424
+ throw new PowDBError(`unexpected reply: ${reply.type}`, "protocol_error");
425
+ }
426
+ this.emit("sync", {
427
+ operation: "ack",
428
+ replicaId: request.replicaId,
429
+ durationMs: Date.now() - start,
430
+ ok: true,
431
+ status: reply.status,
432
+ stale: reply.status.stale,
433
+ repairAction: reply.status.repairAction,
434
+ advanced: reply.advanced,
435
+ });
436
+ return {
437
+ previousAppliedLsn: reply.previousAppliedLsn,
438
+ appliedLsn: reply.appliedLsn,
439
+ remoteLsn: reply.remoteLsn,
440
+ advanced: reply.advanced,
441
+ status: reply.status,
442
+ };
443
+ }
444
+ catch (err) {
445
+ this.emit("sync", {
446
+ operation: "ack",
447
+ replicaId: request.replicaId,
448
+ durationMs: Date.now() - start,
449
+ ok: false,
450
+ error: err,
451
+ });
452
+ throw err;
453
+ }
454
+ }
270
455
  /**
271
456
  * Like {@link query}, but coerces string result columns to typed JS values
272
457
  * using the caller-supplied schema. See `./typed.ts` for the coercion
@@ -605,7 +790,7 @@ function openSocket(target, timeoutMs, tlsOpt) {
605
790
  });
606
791
  }
607
792
  export { encode, tryDecode } from "./protocol.js";
608
- export { MAX_PAYLOAD_SIZE, MAX_ROWS, MAX_COLUMNS, MAX_PARAMS, } from "./protocol.js";
793
+ 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
794
  export { escapeLiteral, escapeIdent, ident, powql, PowqlIdent, } from "./escape.js";
610
795
  export { Pool } from "./pool.js";
611
796
  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.0",
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",