@zvndev/powdb-client 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * PowDB wire protocol.
3
+ *
4
+ * Frame format: [type(1)][flags(1)][len(4 LE)][payload]
5
+ * Strings are encoded as [len(4 LE)][utf-8 bytes].
6
+ *
7
+ * This mirrors crates/server/src/protocol.rs — keep them in sync.
8
+ */
9
+ export const MSG_CONNECT = 0x01;
10
+ export const MSG_CONNECT_OK = 0x02;
11
+ export const MSG_QUERY = 0x03;
12
+ export const MSG_RESULT_ROWS = 0x07;
13
+ export const MSG_RESULT_SCALAR = 0x08;
14
+ export const MSG_RESULT_OK = 0x09;
15
+ export const MSG_ERROR = 0x0a;
16
+ export const MSG_DISCONNECT = 0x10;
17
+ // ───── Size limits (mirror crates/server/src/protocol.rs) ──────────────────
18
+ /** Maximum payload size accepted from the wire (64 MB). */
19
+ export const MAX_PAYLOAD_SIZE = 64 * 1024 * 1024;
20
+ /** Maximum number of rows allowed in a single result message. */
21
+ export const MAX_ROWS = 10_000_000;
22
+ /** Maximum number of columns allowed in a result set. */
23
+ export const MAX_COLUMNS = 4096;
24
+ // ───── Encoding ────────────────────────────────────────────────────────────
25
+ export function encode(msg) {
26
+ let msgType;
27
+ let payload;
28
+ switch (msg.type) {
29
+ case "Connect": {
30
+ const dbBuf = encodeString(msg.dbName);
31
+ const pwBuf = msg.password === null ? u32LE(0) : encodeString(msg.password);
32
+ payload = Buffer.concat([dbBuf, pwBuf]);
33
+ msgType = MSG_CONNECT;
34
+ break;
35
+ }
36
+ case "ConnectOk":
37
+ payload = encodeString(msg.version);
38
+ msgType = MSG_CONNECT_OK;
39
+ break;
40
+ case "Query":
41
+ payload = encodeString(msg.query);
42
+ msgType = MSG_QUERY;
43
+ break;
44
+ case "ResultRows": {
45
+ const parts = [];
46
+ const colCount = Buffer.alloc(2);
47
+ colCount.writeUInt16LE(msg.columns.length, 0);
48
+ parts.push(colCount);
49
+ for (const col of msg.columns)
50
+ parts.push(encodeString(col));
51
+ parts.push(u32LE(msg.rows.length));
52
+ for (const row of msg.rows) {
53
+ for (const val of row)
54
+ parts.push(encodeString(val));
55
+ }
56
+ payload = Buffer.concat(parts);
57
+ msgType = MSG_RESULT_ROWS;
58
+ break;
59
+ }
60
+ case "ResultScalar":
61
+ payload = encodeString(msg.value);
62
+ msgType = MSG_RESULT_SCALAR;
63
+ break;
64
+ case "ResultOk": {
65
+ payload = Buffer.alloc(8);
66
+ payload.writeBigUInt64LE(msg.affected, 0);
67
+ msgType = MSG_RESULT_OK;
68
+ break;
69
+ }
70
+ case "Error":
71
+ payload = encodeString(msg.message);
72
+ msgType = MSG_ERROR;
73
+ break;
74
+ case "Disconnect":
75
+ payload = Buffer.alloc(0);
76
+ msgType = MSG_DISCONNECT;
77
+ break;
78
+ }
79
+ const frame = Buffer.alloc(6 + payload.length);
80
+ frame.writeUInt8(msgType, 0);
81
+ frame.writeUInt8(0, 1); // flags
82
+ frame.writeUInt32LE(payload.length, 2);
83
+ payload.copy(frame, 6);
84
+ return frame;
85
+ }
86
+ // ───── Decoding ────────────────────────────────────────────────────────────
87
+ /**
88
+ * Attempts to parse a single frame from the start of `buf`. Returns the parsed
89
+ * message and the number of bytes consumed, or `null` if the buffer does not
90
+ * yet contain a complete frame.
91
+ */
92
+ export function tryDecode(buf) {
93
+ if (buf.length < 6)
94
+ return null;
95
+ const msgType = buf.readUInt8(0);
96
+ // flags byte at offset 1 is currently unused
97
+ const payloadLen = buf.readUInt32LE(2);
98
+ if (payloadLen > MAX_PAYLOAD_SIZE) {
99
+ throw new Error(`payload too large: ${payloadLen} bytes (max ${MAX_PAYLOAD_SIZE})`);
100
+ }
101
+ if (buf.length < 6 + payloadLen)
102
+ return null;
103
+ const payload = buf.subarray(6, 6 + payloadLen);
104
+ const msg = decodePayload(msgType, payload);
105
+ return { msg, consumed: 6 + payloadLen };
106
+ }
107
+ function decodePayload(msgType, payload) {
108
+ const cursor = { pos: 0 };
109
+ switch (msgType) {
110
+ case MSG_CONNECT: {
111
+ const dbName = decodeString(payload, cursor);
112
+ let password = null;
113
+ if (cursor.pos < payload.length) {
114
+ const p = decodeString(payload, cursor);
115
+ password = p.length === 0 ? null : p;
116
+ }
117
+ return { type: "Connect", dbName, password };
118
+ }
119
+ case MSG_CONNECT_OK:
120
+ return { type: "ConnectOk", version: decodeString(payload, cursor) };
121
+ case MSG_QUERY:
122
+ return { type: "Query", query: decodeString(payload, cursor) };
123
+ case MSG_RESULT_ROWS: {
124
+ const colCount = payload.readUInt16LE(cursor.pos);
125
+ cursor.pos += 2;
126
+ if (colCount > MAX_COLUMNS) {
127
+ throw new Error(`too many columns: ${colCount} (max ${MAX_COLUMNS})`);
128
+ }
129
+ const columns = [];
130
+ for (let i = 0; i < colCount; i++) {
131
+ columns.push(decodeString(payload, cursor));
132
+ }
133
+ const rowCount = payload.readUInt32LE(cursor.pos);
134
+ cursor.pos += 4;
135
+ if (rowCount > MAX_ROWS) {
136
+ throw new Error(`too many rows: ${rowCount} (max ${MAX_ROWS})`);
137
+ }
138
+ const rows = [];
139
+ for (let r = 0; r < rowCount; r++) {
140
+ const row = [];
141
+ for (let c = 0; c < colCount; c++) {
142
+ row.push(decodeString(payload, cursor));
143
+ }
144
+ rows.push(row);
145
+ }
146
+ return { type: "ResultRows", columns, rows };
147
+ }
148
+ case MSG_RESULT_SCALAR:
149
+ return { type: "ResultScalar", value: decodeString(payload, cursor) };
150
+ case MSG_RESULT_OK: {
151
+ const affected = payload.readBigUInt64LE(0);
152
+ return { type: "ResultOk", affected };
153
+ }
154
+ case MSG_ERROR:
155
+ return { type: "Error", message: decodeString(payload, cursor) };
156
+ case MSG_DISCONNECT:
157
+ return { type: "Disconnect" };
158
+ default:
159
+ throw new Error(`unknown message type: 0x${msgType.toString(16)}`);
160
+ }
161
+ }
162
+ // ───── String helpers ──────────────────────────────────────────────────────
163
+ function encodeString(s) {
164
+ const bytes = Buffer.from(s, "utf8");
165
+ const out = Buffer.alloc(4 + bytes.length);
166
+ out.writeUInt32LE(bytes.length, 0);
167
+ bytes.copy(out, 4);
168
+ return out;
169
+ }
170
+ function decodeString(buf, cursor) {
171
+ if (cursor.pos + 4 > buf.length) {
172
+ throw new Error("truncated string length");
173
+ }
174
+ const len = buf.readUInt32LE(cursor.pos);
175
+ cursor.pos += 4;
176
+ if (cursor.pos + len > buf.length) {
177
+ throw new Error("truncated string data");
178
+ }
179
+ const s = buf.toString("utf8", cursor.pos, cursor.pos + len);
180
+ cursor.pos += len;
181
+ return s;
182
+ }
183
+ function u32LE(n) {
184
+ const b = Buffer.alloc(4);
185
+ b.writeUInt32LE(n, 0);
186
+ return b;
187
+ }
188
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Optional row-level type coercion for `queryTyped`.
3
+ *
4
+ * The server's wire format serialises every value to a string (see
5
+ * `crates/server/src/handler.rs::value_to_display`):
6
+ * Int → decimal digits, e.g. "42" or "-7"
7
+ * Float → Rust `{}` format, e.g. "3.14"
8
+ * Bool → "true" / "false"
9
+ * Str → raw UTF-8 (may contain anything)
10
+ * DateTime → microseconds since Unix epoch as decimal, e.g. "1718928000000000"
11
+ * Uuid → canonical "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
12
+ * Bytes → "<N bytes>" (LOSSY — see note below)
13
+ *
14
+ * `queryTyped` takes a user-supplied schema and coerces each column string
15
+ * into the corresponding JS value. Rationale for requiring the caller to
16
+ * supply the schema (rather than introspecting): PowDB has no `describe`
17
+ * statement yet, and going via `select` off a system catalog is one extra
18
+ * round trip per typed query. When schema introspection lands, a helper
19
+ * that builds the schema object can be added without changing this API.
20
+ *
21
+ * Bytes caveat: the current wire format is lossy for byte columns (renders
22
+ * `<N bytes>`). Until the wire protocol grows a binary column type, this
23
+ * module throws on `bytes` columns rather than guessing — use `str` in the
24
+ * schema if you just want the `<N bytes>` placeholder.
25
+ */
26
+ /**
27
+ * Supported column types. Mirrors the server's `DataType` enum minus the
28
+ * `Bytes` variant, which is intentionally unsupported here.
29
+ */
30
+ export type ColumnType = "int" | "float" | "bool" | "str" | "datetime" | "uuid";
31
+ /** Map of column name → declared type. Columns not in the map pass through as strings. */
32
+ export type TypedSchema = Record<string, ColumnType>;
33
+ /** Coerced value type. `int` may return `bigint` if the value exceeds Number.MAX_SAFE_INTEGER. */
34
+ export type Coerced = number | bigint | boolean | string | Date | null;
35
+ /** A row of coerced values keyed by column name. */
36
+ export type TypedRow = Record<string, Coerced>;
37
+ /**
38
+ * Coerce a single string value to the JS type declared in `schema`. `null`
39
+ * is returned for the conventional null sentinel — see note at call site.
40
+ * Unknown column types fall through as strings.
41
+ */
42
+ export declare function coerceValue(column: string, raw: string, type: ColumnType | undefined): Coerced;
43
+ /**
44
+ * Coerce a single result row (a string tuple) into a typed object keyed by
45
+ * column name.
46
+ */
47
+ export declare function coerceRow(columns: string[], values: string[], schema: TypedSchema): TypedRow;
48
+ /** Coerce every row in a rows-result into typed objects. */
49
+ export declare function coerceRows(columns: string[], rows: string[][], schema: TypedSchema): TypedRow[];
50
+ //# sourceMappingURL=typed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typed.d.ts","sourceRoot":"","sources":["../src/typed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAIH;;;GAGG;AACH,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,KAAK,GAAG,UAAU,GAAG,MAAM,CAAC;AAEhF,0FAA0F;AAC1F,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;AAErD,kGAAkG;AAClG,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,IAAI,GAAG,IAAI,CAAC;AAEvE,oDAAoD;AACpD,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE/C;;;;GAIG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,UAAU,GAAG,SAAS,GAC3B,OAAO,CAqET;AAED;;;GAGG;AACH,wBAAgB,SAAS,CACvB,OAAO,EAAE,MAAM,EAAE,EACjB,MAAM,EAAE,MAAM,EAAE,EAChB,MAAM,EAAE,WAAW,GAClB,QAAQ,CAOV;AAED,4DAA4D;AAC5D,wBAAgB,UAAU,CACxB,OAAO,EAAE,MAAM,EAAE,EACjB,IAAI,EAAE,MAAM,EAAE,EAAE,EAChB,MAAM,EAAE,WAAW,GAClB,QAAQ,EAAE,CAEZ"}
package/dist/typed.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Optional row-level type coercion for `queryTyped`.
3
+ *
4
+ * The server's wire format serialises every value to a string (see
5
+ * `crates/server/src/handler.rs::value_to_display`):
6
+ * Int → decimal digits, e.g. "42" or "-7"
7
+ * Float → Rust `{}` format, e.g. "3.14"
8
+ * Bool → "true" / "false"
9
+ * Str → raw UTF-8 (may contain anything)
10
+ * DateTime → microseconds since Unix epoch as decimal, e.g. "1718928000000000"
11
+ * Uuid → canonical "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
12
+ * Bytes → "<N bytes>" (LOSSY — see note below)
13
+ *
14
+ * `queryTyped` takes a user-supplied schema and coerces each column string
15
+ * into the corresponding JS value. Rationale for requiring the caller to
16
+ * supply the schema (rather than introspecting): PowDB has no `describe`
17
+ * statement yet, and going via `select` off a system catalog is one extra
18
+ * round trip per typed query. When schema introspection lands, a helper
19
+ * that builds the schema object can be added without changing this API.
20
+ *
21
+ * Bytes caveat: the current wire format is lossy for byte columns (renders
22
+ * `<N bytes>`). Until the wire protocol grows a binary column type, this
23
+ * module throws on `bytes` columns rather than guessing — use `str` in the
24
+ * schema if you just want the `<N bytes>` placeholder.
25
+ */
26
+ import { PowDBError } from "./errors.js";
27
+ /**
28
+ * Coerce a single string value to the JS type declared in `schema`. `null`
29
+ * is returned for the conventional null sentinel — see note at call site.
30
+ * Unknown column types fall through as strings.
31
+ */
32
+ export function coerceValue(column, raw, type) {
33
+ // The server's wire format distinguishes NULL from empty string by the
34
+ // literal "null" bareword in scalar displays. For typed columns we treat
35
+ // the single token "null" as NULL; empty string remains empty string for
36
+ // `str` columns (can be an intentional empty value).
37
+ if (type !== "str" && raw === "null")
38
+ return null;
39
+ switch (type) {
40
+ case undefined:
41
+ case "str":
42
+ return raw;
43
+ case "int": {
44
+ if (!/^-?\d+$/.test(raw)) {
45
+ throw new PowDBError(`queryTyped: column "${column}" declared int but got ${JSON.stringify(raw)}`, "type_coercion_failed");
46
+ }
47
+ // Promote to BigInt when the value doesn't round-trip through Number.
48
+ const n = Number(raw);
49
+ if (Number.isSafeInteger(n))
50
+ return n;
51
+ return BigInt(raw);
52
+ }
53
+ case "float": {
54
+ const n = Number(raw);
55
+ if (!Number.isFinite(n)) {
56
+ throw new PowDBError(`queryTyped: column "${column}" declared float but got ${JSON.stringify(raw)}`, "type_coercion_failed");
57
+ }
58
+ return n;
59
+ }
60
+ case "bool":
61
+ if (raw === "true")
62
+ return true;
63
+ if (raw === "false")
64
+ return false;
65
+ throw new PowDBError(`queryTyped: column "${column}" declared bool but got ${JSON.stringify(raw)}`, "type_coercion_failed");
66
+ case "datetime": {
67
+ if (!/^-?\d+$/.test(raw)) {
68
+ throw new PowDBError(`queryTyped: column "${column}" declared datetime but got ${JSON.stringify(raw)} (expected microseconds since epoch)`, "type_coercion_failed");
69
+ }
70
+ // Server sends microseconds since epoch. JS Date uses milliseconds.
71
+ // BigInt division avoids float precision loss for far-future dates.
72
+ const micros = BigInt(raw);
73
+ const millis = Number(micros / 1000n);
74
+ return new Date(millis);
75
+ }
76
+ case "uuid": {
77
+ // Canonical 8-4-4-4-12 hex, lowercase (per server format).
78
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(raw)) {
79
+ throw new PowDBError(`queryTyped: column "${column}" declared uuid but got ${JSON.stringify(raw)}`, "type_coercion_failed");
80
+ }
81
+ return raw;
82
+ }
83
+ }
84
+ }
85
+ /**
86
+ * Coerce a single result row (a string tuple) into a typed object keyed by
87
+ * column name.
88
+ */
89
+ export function coerceRow(columns, values, schema) {
90
+ const out = {};
91
+ for (let i = 0; i < columns.length; i++) {
92
+ const col = columns[i];
93
+ out[col] = coerceValue(col, values[i] ?? "null", schema[col]);
94
+ }
95
+ return out;
96
+ }
97
+ /** Coerce every row in a rows-result into typed objects. */
98
+ export function coerceRows(columns, rows, schema) {
99
+ return rows.map((r) => coerceRow(columns, r, schema));
100
+ }
101
+ //# sourceMappingURL=typed.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typed.js","sourceRoot":"","sources":["../src/typed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAiBzC;;;;GAIG;AACH,MAAM,UAAU,WAAW,CACzB,MAAc,EACd,GAAW,EACX,IAA4B;IAE5B,uEAAuE;IACvE,yEAAyE;IACzE,yEAAyE;IACzE,qDAAqD;IACrD,IAAI,IAAI,KAAK,KAAK,IAAI,GAAG,KAAK,MAAM;QAAE,OAAO,IAAI,CAAC;IAElD,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,SAAS,CAAC;QACf,KAAK,KAAK;YACR,OAAO,GAAG,CAAC;QAEb,KAAK,KAAK,CAAC,CAAC,CAAC;YACX,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;gBACzB,MAAM,IAAI,UAAU,CAClB,uBAAuB,MAAM,0BAA0B,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,EAC5E,sBAAsB,CACvB,CAAC;YACJ,CAAC;YACD,sEAAsE;YACtE,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;YACtB,IAAI,MAAM,CAAC,aAAa,CAAC,CAAC,CAAC;gBAAE,OAAO,CAAC,CAAC;YACtC,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC;QACrB,CAAC;QAED,KAAK,OAAO,CAAC,CAAC,CAAC;YACb,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;YACtB,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;gBACxB,MAAM,IAAI,UAAU,CAClB,uBAAuB,MAAM,4BAA4B,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,EAC9E,sBAAsB,CACvB,CAAC;YACJ,CAAC;YACD,OAAO,CAAC,CAAC;QACX,CAAC;QAED,KAAK,MAAM;YACT,IAAI,GAAG,KAAK,MAAM;gBAAE,OAAO,IAAI,CAAC;YAChC,IAAI,GAAG,KAAK,OAAO;gBAAE,OAAO,KAAK,CAAC;YAClC,MAAM,IAAI,UAAU,CAClB,uBAAuB,MAAM,2BAA2B,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,EAC7E,sBAAsB,CACvB,CAAC;QAEJ,KAAK,UAAU,CAAC,CAAC,CAAC;YAChB,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;gBACzB,MAAM,IAAI,UAAU,CAClB,uBAAuB,MAAM,+BAA+B,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,sCAAsC,EACrH,sBAAsB,CACvB,CAAC;YACJ,CAAC;YACD,oEAAoE;YACpE,oEAAoE;YACpE,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;YAC3B,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,GAAG,KAAK,CAAC,CAAC;YACtC,OAAO,IAAI,IAAI,CAAC,MAAM,CAAC,CAAC;QAC1B,CAAC;QAED,KAAK,MAAM,CAAC,CAAC,CAAC;YACZ,2DAA2D;YAC3D,IAAI,CAAC,gEAAgE,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;gBAChF,MAAM,IAAI,UAAU,CAClB,uBAAuB,MAAM,2BAA2B,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,EAC7E,sBAAsB,CACvB,CAAC;YACJ,CAAC;YACD,OAAO,GAAG,CAAC;QACb,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,SAAS,CACvB,OAAiB,EACjB,MAAgB,EAChB,MAAmB;IAEnB,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACxC,MAAM,GAAG,GAAG,OAAO,CAAC,CAAC,CAAE,CAAC;QACxB,GAAG,CAAC,GAAG,CAAC,GAAG,WAAW,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,4DAA4D;AAC5D,MAAM,UAAU,UAAU,CACxB,OAAiB,EACjB,IAAgB,EAChB,MAAmB;IAEnB,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,SAAS,CAAC,OAAO,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;AACxD,CAAC"}
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@zvndev/powdb-client",
3
+ "version": "0.3.0",
4
+ "description": "TypeScript client for PowDB (PowQL wire protocol)",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": ["dist", "src", "README.md"],
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/zvndev/powdb",
19
+ "directory": "clients/ts"
20
+ },
21
+ "keywords": ["powdb", "database", "client", "powql"],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "test": "tsx test/client.test.ts",
28
+ "test:escape": "tsx test/escape.test.ts",
29
+ "test:protocol": "tsx test/protocol.test.ts",
30
+ "test:pool": "tsx test/pool.test.ts",
31
+ "test:typed": "tsx test/typed.test.ts",
32
+ "test:errors": "tsx test/errors.test.ts",
33
+ "demo": "tsx demo/demo.ts",
34
+ "clean": "rm -rf dist",
35
+ "prepublishOnly": "npm run build"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22",
39
+ "tsx": "^4",
40
+ "typescript": "^5.6"
41
+ }
42
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Structured error taxonomy for the PowDB client.
3
+ *
4
+ * Every error thrown by the client is a `PowDBError` with a stable `.code`
5
+ * so callers can branch without string-matching the message. The taxonomy
6
+ * is intentionally small — new codes are added only when a caller has a
7
+ * legitimate reason to handle them differently.
8
+ */
9
+
10
+ export type PowDBErrorCode =
11
+ /** TCP/TLS connect, DNS, connect timeout. Transient — safe to retry. */
12
+ | "connect_failed"
13
+ /** Server rejected the Connect handshake (bad password, unknown db). Not transient. */
14
+ | "auth_failed"
15
+ /** Server returned an `Error` frame in response to a query. Not transient. */
16
+ | "query_failed"
17
+ /** Caller's AbortSignal fired. Never retry — the caller asked to stop. */
18
+ | "aborted"
19
+ /** Peer sent a frame that exceeds one of the configured caps. Likely a bug/attack — do not retry. */
20
+ | "size_exceeded"
21
+ /** Wire protocol violation (bad framing, unknown message type, truncated payload). */
22
+ | "protocol_error"
23
+ /** `close()` has been called on the client or pool. */
24
+ | "closed"
25
+ /** An operation exceeded its configured time budget. */
26
+ | "timeout"
27
+ /** Type coercion on a row failed (queryTyped). */
28
+ | "type_coercion_failed";
29
+
30
+ /**
31
+ * All errors thrown by `@zvndev/powdb-client` are instances of this class.
32
+ * Use `err.code` to branch; `err.cause` optionally carries the underlying
33
+ * cause (e.g. the Node socket error).
34
+ */
35
+ export class PowDBError extends Error {
36
+ readonly code: PowDBErrorCode;
37
+
38
+ constructor(message: string, code: PowDBErrorCode, options?: { cause?: unknown }) {
39
+ // ErrorOptions.cause is supported in Node 16.9+; we require Node 18+.
40
+ super(message, options as ErrorOptions);
41
+ this.name = "PowDBError";
42
+ this.code = code;
43
+ // Preserve the prototype chain across `target: es2020` and friends.
44
+ Object.setPrototypeOf(this, PowDBError.prototype);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Narrow `unknown` to a PowDBError. Useful in catch blocks where you want
50
+ * to branch on `.code` but TypeScript sees `unknown`.
51
+ */
52
+ export function isPowDBError(err: unknown): err is PowDBError {
53
+ return err instanceof PowDBError;
54
+ }
package/src/escape.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Safe PowQL composition helpers: literal and identifier escaping, plus a
3
+ * tagged-template convenience for building queries without string splicing.
4
+ *
5
+ * const q = powql`insert ${ident("User")} { name := ${userName}, age := ${age} }`;
6
+ * await client.query(q);
7
+ *
8
+ * Security model:
9
+ * - Literals are rendered with quoting/escaping so interpolated values
10
+ * cannot break out of the surrounding literal.
11
+ * - Identifiers are validated against `^[A-Za-z_][A-Za-z0-9_]*$` and passed
12
+ * through unchanged. Anything else throws a `TypeError`.
13
+ *
14
+ * PowQL string-escape rules (verified against `crates/query/src/lexer.rs`):
15
+ * - Strings are delimited by `"`.
16
+ * - `\\` → `\`, `\"` → `"`, `\n` → newline, `\t` → tab.
17
+ * - For any other `\X`, the backslash is dropped and `X` is kept literally.
18
+ * - A bare `"` terminates the string, so `"` inside must be `\"` and any
19
+ * literal backslash must be `\\` (otherwise it would swallow the next char).
20
+ */
21
+
22
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
23
+
24
+ /** Wrapper marking a string as an identifier (vs a literal) for `powql` tagged templates. */
25
+ export class PowqlIdent {
26
+ constructor(public readonly name: string) {}
27
+ }
28
+
29
+ /** Factory for {@link PowqlIdent}. Prefer this over `new PowqlIdent(...)` at call sites. */
30
+ export function ident(name: string): PowqlIdent {
31
+ return new PowqlIdent(name);
32
+ }
33
+
34
+ /**
35
+ * Validate a PowQL identifier (table name, field name, alias). Returns the
36
+ * identifier unchanged on success; throws `TypeError` on any invalid input
37
+ * (non-string, empty, or containing characters outside `[A-Za-z_][A-Za-z0-9_]*`).
38
+ */
39
+ export function escapeIdent(name: string): string {
40
+ if (typeof name !== "string") {
41
+ throw new TypeError(
42
+ `escapeIdent: expected string, got ${typeof name}`
43
+ );
44
+ }
45
+ if (name.length === 0) {
46
+ throw new TypeError("escapeIdent: identifier must not be empty");
47
+ }
48
+ if (!IDENT_RE.test(name)) {
49
+ throw new TypeError(
50
+ `escapeIdent: invalid identifier ${JSON.stringify(name)} (must match /^[A-Za-z_][A-Za-z0-9_]*$/)`
51
+ );
52
+ }
53
+ return name;
54
+ }
55
+
56
+ /**
57
+ * Render a JS value as a PowQL literal. Supports `string`, `number`, `bigint`,
58
+ * `boolean`, and `null`. Rejects `NaN`/`±Infinity`, `undefined`, symbols,
59
+ * objects, and arrays with `TypeError`.
60
+ *
61
+ * - string → `"..."` with `\` and `"` backslash-escaped (C-style, per the
62
+ * PowQL lexer). Backslash must be escaped first to avoid double-processing.
63
+ * - number → decimal; rejects non-finite
64
+ * - bigint → decimal digits
65
+ * - boolean → `true` / `false`
66
+ * - null → `null`
67
+ */
68
+ export function escapeLiteral(
69
+ value: string | number | bigint | boolean | null
70
+ ): string {
71
+ if (value === null) return "null";
72
+
73
+ const t = typeof value;
74
+
75
+ if (t === "string") {
76
+ const escaped = (value as string)
77
+ .replace(/\\/g, "\\\\")
78
+ .replace(/"/g, '\\"');
79
+ return `"${escaped}"`;
80
+ }
81
+
82
+ if (t === "number") {
83
+ const n = value as number;
84
+ if (!Number.isFinite(n)) {
85
+ throw new TypeError(
86
+ `escapeLiteral: non-finite number ${String(n)} cannot be represented as a PowQL literal`
87
+ );
88
+ }
89
+ return String(n);
90
+ }
91
+
92
+ if (t === "bigint") {
93
+ return (value as bigint).toString(10);
94
+ }
95
+
96
+ if (t === "boolean") {
97
+ return value ? "true" : "false";
98
+ }
99
+
100
+ throw new TypeError(`escapeLiteral: unsupported type ${describe(value)}`);
101
+ }
102
+
103
+ /**
104
+ * Tagged template for safe PowQL composition. Each interpolated value is
105
+ * escaped as a literal by default; wrap it in `ident(...)` to interpolate as
106
+ * an identifier.
107
+ *
108
+ * const q = powql`insert ${ident(table)} { name := ${userName} }`;
109
+ */
110
+ export function powql(
111
+ strings: TemplateStringsArray,
112
+ ...values: unknown[]
113
+ ): string {
114
+ let out = strings[0] ?? "";
115
+ for (let i = 0; i < values.length; i++) {
116
+ out += renderInterpolation(values[i]);
117
+ out += strings[i + 1] ?? "";
118
+ }
119
+ return out;
120
+ }
121
+
122
+ // ──────────────────────────────────────────────────────────
123
+ // internals
124
+ // ──────────────────────────────────────────────────────────
125
+
126
+ function renderInterpolation(value: unknown): string {
127
+ if (value instanceof PowqlIdent) {
128
+ return escapeIdent(value.name);
129
+ }
130
+ // `escapeLiteral` enforces the allowed set — for anything else it throws.
131
+ return escapeLiteral(value as string | number | bigint | boolean | null);
132
+ }
133
+
134
+ function describe(value: unknown): string {
135
+ if (value === undefined) return "undefined";
136
+ if (value === null) return "null";
137
+ if (Array.isArray(value)) return "array";
138
+ const t = typeof value;
139
+ if (t === "object") return "object";
140
+ return t;
141
+ }