@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 +12 -0
- package/README.md +67 -4
- package/dist/index.d.ts +77 -3
- package/dist/index.js +215 -19
- package/dist/protocol.d.ts +68 -0
- package/dist/protocol.js +287 -0
- package/package.json +3 -2
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
|
-
|
|
277
|
-
|
|
278
|
-
`PowDBError`
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
56
|
-
*
|
|
57
|
-
* `
|
|
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
|
-
|
|
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
|
-
//
|
|
501
|
-
//
|
|
502
|
-
// reply is arriving now
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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";
|
package/dist/protocol.d.ts
CHANGED
|
@@ -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.
|
|
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": "^
|
|
64
|
+
"@types/node": "^26",
|
|
64
65
|
"tsx": "^4",
|
|
65
66
|
"typescript": "^6.0"
|
|
66
67
|
}
|