@aikoql/client 0.0.0-stage → 0.2.4
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/README.md +22 -2
- package/package.json +30 -4
- package/src/client.ts +548 -0
- package/src/error.ts +95 -0
- package/src/index.ts +20 -0
- package/src/pool.ts +297 -0
- package/src/sdk-conformance.ts +496 -0
- package/src/tcp.ts +147 -0
- package/src/tools.ts +72 -0
- package/src/tx.ts +83 -0
- package/tests/conformance.test.ts +60 -0
- package/tests/corpus-pin.test.ts +96 -0
- package/tests/fault-matrix.test.ts +298 -0
- package/tests/fuzz-estate-pin.test.ts +32 -0
- package/tests/fuzz-estate.test.ts +515 -0
- package/tests/helpers.ts +69 -0
- package/tests/identity.test.ts +26 -0
- package/tests/leader-hint.test.ts +50 -0
- package/tests/pool.test.ts +297 -0
- package/tests/tls.test.ts +74 -0
- package/tests/wire.test.ts +207 -0
- package/tsconfig.json +16 -0
package/src/error.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The SDK error surface: one classified error type (the repo's
|
|
2
|
+
// classified-error idiom). Every protocol error keeps the frozen SDK-012
|
|
3
|
+
// fields (code/message/retryable/suggestion) — a mapped error never loses
|
|
4
|
+
// its code. The server's error taxonomy (MRFC-0040 / SDK-012) is frozen;
|
|
5
|
+
// this SDK only constructs the codes it meets on the wire plus its own
|
|
6
|
+
// transport folds (IO/JSON).
|
|
7
|
+
|
|
8
|
+
import { MIN_SERVER_VERSION } from "./client.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A structured error from the MCP server: the tool-level ok/error envelope
|
|
12
|
+
* and RPC-level failures both carry it. Transport failures fold into the
|
|
13
|
+
* nearest code ("IO" for a dead connection, "JSON" for a malformed frame)
|
|
14
|
+
* so callers always match on `code`.
|
|
15
|
+
*/
|
|
16
|
+
export class McpError extends Error {
|
|
17
|
+
code: string;
|
|
18
|
+
retryable: boolean;
|
|
19
|
+
suggestion: string;
|
|
20
|
+
/** The leader's client address on a NOT_LEADER refusal (MRFC-0080 R4d). */
|
|
21
|
+
leader?: string;
|
|
22
|
+
|
|
23
|
+
constructor(code: string, message: string, retryable = false, suggestion = "", leader?: string) {
|
|
24
|
+
super(`[${code}] ${message}`);
|
|
25
|
+
this.name = "McpError";
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.retryable = retryable;
|
|
28
|
+
this.suggestion = suggestion;
|
|
29
|
+
if (leader !== undefined) this.leader = leader;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The frozen TIMEOUT (retryable): a deadline elapsed before a response. */
|
|
33
|
+
static deadline(): McpError {
|
|
34
|
+
return new McpError(
|
|
35
|
+
"TIMEOUT",
|
|
36
|
+
"no response within the deadline",
|
|
37
|
+
true,
|
|
38
|
+
"Retry with backoff; the request may have committed.",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A call on a closed client fails observably (§7 principle 11): a dead
|
|
43
|
+
* connection never deadlocks the caller, and a fresh dial recovers it. */
|
|
44
|
+
static unavailable(): McpError {
|
|
45
|
+
return new McpError(
|
|
46
|
+
"UNAVAILABLE",
|
|
47
|
+
"the client is closed",
|
|
48
|
+
false,
|
|
49
|
+
"Connect again.",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static protocolError(requestId: number, responseId: number): McpError {
|
|
54
|
+
return new McpError(
|
|
55
|
+
"PROTOCOL_ERROR",
|
|
56
|
+
`response id ${responseId} does not match request ${requestId}`,
|
|
57
|
+
false,
|
|
58
|
+
"Check SDK/server version pairing.",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static versionMismatch(server: string): McpError {
|
|
63
|
+
return new McpError(
|
|
64
|
+
"VERSION_MISMATCH",
|
|
65
|
+
`server version ${JSON.stringify(server)} is older than the SDK minimum ${MIN_SERVER_VERSION}`,
|
|
66
|
+
false,
|
|
67
|
+
"Upgrade the aikoql-mcp server to a supported version",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static invalidArgument(message: string): McpError {
|
|
72
|
+
return new McpError("INVALID_ARGUMENT", message, false, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A transport failure, folded to the "IO" code. */
|
|
76
|
+
static io(message: string): McpError {
|
|
77
|
+
return new McpError("IO", message, false, "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A malformed frame or payload, folded to the "JSON" code. */
|
|
81
|
+
static json(message: string): McpError {
|
|
82
|
+
return new McpError("JSON", message, false, "");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** §19: an over-cap frame tripped the 1 MiB cap mid-accumulation — the
|
|
86
|
+
* stream is desynced, the transport is poisoned. */
|
|
87
|
+
static frameTooLarge(): McpError {
|
|
88
|
+
return new McpError(
|
|
89
|
+
"FRAME_TOO_LARGE",
|
|
90
|
+
"response frame exceeds the 1 MiB cap",
|
|
91
|
+
false,
|
|
92
|
+
"The server sent an over-cap frame; reconnect.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The TypeScript SDK for Aikoql (D-13): a remote MCP client — the
|
|
2
|
+
// canonical Database API (docs/DATABASE-API.md) over the MCP wire, mirror
|
|
3
|
+
// of the Rust reference (crates/sdk/rust). Node-first: the browser
|
|
4
|
+
// transport is a Transport-implementation swap (§25 "where applicable");
|
|
5
|
+
// no embedded mode (the kernel is Rust-only).
|
|
6
|
+
|
|
7
|
+
export { Client, MIN_SERVER_VERSION, withDeadline } from "./client.ts";
|
|
8
|
+
export { McpError } from "./error.ts";
|
|
9
|
+
export { TcpTransport, type Transport } from "./tcp.ts";
|
|
10
|
+
export { Pool, PooledConn, type PoolConfig, type PoolStats } from "./pool.ts";
|
|
11
|
+
export { Tx, type StagedOp, type CommitResult } from "./tx.ts";
|
|
12
|
+
export type {
|
|
13
|
+
FindSimilarParams,
|
|
14
|
+
KnowledgeObject,
|
|
15
|
+
Metrics,
|
|
16
|
+
RememberParams,
|
|
17
|
+
Remembered,
|
|
18
|
+
ScoredKO,
|
|
19
|
+
SessionParams,
|
|
20
|
+
} from "./tools.ts";
|
package/src/pool.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// The §3.4 connection pool: a client-side abstraction over dial (no
|
|
2
|
+
// protocol surface — api-v1.json is frozen). The factory is the seam:
|
|
3
|
+
// each call must return one fully established session (dialed +
|
|
4
|
+
// initialized), which is also the auth reset on every reconnect. Release
|
|
5
|
+
// rolls back any open transaction (session reset — the next borrower
|
|
6
|
+
// never inherits it, §21), reaps lifetime-expired connections, and hands
|
|
7
|
+
// the connection to a waiter; acquire waits up to acquireTimeoutMs and
|
|
8
|
+
// throws the retryable RESOURCE_EXHAUSTED when the pool stays exhausted.
|
|
9
|
+
// A failed health ping drops the connection and dials a fresh one; a dead
|
|
10
|
+
// server makes the dial path retry until the deadline (the reconnect leg
|
|
11
|
+
// of §21). Mirrors crates/sdk/go/pool.go.
|
|
12
|
+
//
|
|
13
|
+
// ponytail: one idle stack and a FIFO waiter queue (wake one waiter per
|
|
14
|
+
// return, not Go's broadcast herd) — per-waiter priorities only if a
|
|
15
|
+
// profile ever shows contention.
|
|
16
|
+
|
|
17
|
+
import type { Client } from "./client.ts";
|
|
18
|
+
import { withDeadline } from "./client.ts";
|
|
19
|
+
import { McpError } from "./error.ts";
|
|
20
|
+
import type { Tx } from "./tx.ts";
|
|
21
|
+
|
|
22
|
+
export interface PoolConfig {
|
|
23
|
+
/** Returns one fully established session per call. */
|
|
24
|
+
factory: () => Promise<Client>;
|
|
25
|
+
/** Bounds the pool (default 10). */
|
|
26
|
+
maxConns?: number;
|
|
27
|
+
/** Honored by fillMinIdle (default 0; no background refill). */
|
|
28
|
+
minIdle?: number;
|
|
29
|
+
/** Bounds a waiter (default 30s; RESOURCE_EXHAUSTED). */
|
|
30
|
+
acquireTimeoutMs?: number;
|
|
31
|
+
/** Reaps a connection idle longer than this (default 60s). */
|
|
32
|
+
idleTimeoutMs?: number;
|
|
33
|
+
/** Reaps a connection older than this (default 1h). */
|
|
34
|
+
maxLifetimeMs?: number;
|
|
35
|
+
/** Pings a borrowed connection when it sat idle longer than this
|
|
36
|
+
* (default 30s; a near-zero value pings every borrow). */
|
|
37
|
+
healthCheckIntervalMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PoolStats {
|
|
41
|
+
total: number;
|
|
42
|
+
idle: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Waiter {
|
|
46
|
+
resolve: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sleep(ms: number): Promise<void> {
|
|
50
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One checked-out connection. Client calls go through client(); begin
|
|
54
|
+
* pins the transaction so release can reset the session. */
|
|
55
|
+
export class PooledConn {
|
|
56
|
+
private p: Pool;
|
|
57
|
+
private c: Client;
|
|
58
|
+
private tx: Tx | null = null;
|
|
59
|
+
// performance.now() milliseconds (monotonic, sub-ms): a borrow in the same
|
|
60
|
+
// millisecond as the release still sees time pass, so healthCheckIntervalMs
|
|
61
|
+
// 0 pings every borrow
|
|
62
|
+
createdAt: number;
|
|
63
|
+
lastUsed: number;
|
|
64
|
+
broken = false;
|
|
65
|
+
|
|
66
|
+
constructor(p: Pool, c: Client, now: number) {
|
|
67
|
+
this.p = p;
|
|
68
|
+
this.c = c;
|
|
69
|
+
this.createdAt = now;
|
|
70
|
+
this.lastUsed = now;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The underlying client (transport-level access). */
|
|
74
|
+
client(): Client {
|
|
75
|
+
return this.c;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Opens a transaction pinned to this connection; release rolls it back
|
|
79
|
+
* if it is still open. */
|
|
80
|
+
async begin(txnId?: string): Promise<Tx> {
|
|
81
|
+
const tx = await this.c.begin(txnId);
|
|
82
|
+
this.tx = tx;
|
|
83
|
+
return tx;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Returns the connection to the pool, rolling back a still-open
|
|
87
|
+
* transaction first (session reset). */
|
|
88
|
+
async release(): Promise<void> {
|
|
89
|
+
if (this.tx !== null && !this.tx.done()) {
|
|
90
|
+
try {
|
|
91
|
+
await withDeadline(5000, (signal) => this.tx!.rollback({ signal }));
|
|
92
|
+
} catch {
|
|
93
|
+
this.broken = true; // session reset failed — drop the connection
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this.tx = null;
|
|
97
|
+
this.p.put(this);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Drops the connection (for a caller that saw a transport error and
|
|
101
|
+
* does not trust the connection anymore). */
|
|
102
|
+
invalidate(): void {
|
|
103
|
+
this.broken = true;
|
|
104
|
+
this.p.put(this);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A bounded pool of client connections. */
|
|
109
|
+
export class Pool {
|
|
110
|
+
private cfg: Required<Omit<PoolConfig, "factory">> & Pick<PoolConfig, "factory">;
|
|
111
|
+
private idle: PooledConn[] = [];
|
|
112
|
+
private total = 0;
|
|
113
|
+
private waiters: Waiter[] = [];
|
|
114
|
+
private closedFlag = false;
|
|
115
|
+
|
|
116
|
+
/** Builds a pool with the defaults filled in. */
|
|
117
|
+
constructor(cfg: PoolConfig) {
|
|
118
|
+
if (!cfg.factory) throw new Error("aikoql: PoolConfig.factory is required");
|
|
119
|
+
this.cfg = {
|
|
120
|
+
factory: cfg.factory,
|
|
121
|
+
maxConns: cfg.maxConns ?? 10,
|
|
122
|
+
minIdle: cfg.minIdle ?? 0,
|
|
123
|
+
acquireTimeoutMs: cfg.acquireTimeoutMs ?? 30_000,
|
|
124
|
+
idleTimeoutMs: cfg.idleTimeoutMs ?? 60_000,
|
|
125
|
+
maxLifetimeMs: cfg.maxLifetimeMs ?? 3_600_000,
|
|
126
|
+
healthCheckIntervalMs: cfg.healthCheckIntervalMs ?? 30_000,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Returns a connection: an idle one (health-checked when due), a fresh
|
|
131
|
+
* dial, or — when the pool is exhausted — it waits for a return up to
|
|
132
|
+
* acquireTimeoutMs and fails with the retryable RESOURCE_EXHAUSTED. */
|
|
133
|
+
async acquire(): Promise<PooledConn> {
|
|
134
|
+
for (;;) {
|
|
135
|
+
if (this.closedFlag) {
|
|
136
|
+
throw new McpError(
|
|
137
|
+
"UNAVAILABLE",
|
|
138
|
+
"connection pool is closed",
|
|
139
|
+
true,
|
|
140
|
+
"Create a new pool.",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
while (this.idle.length > 0) {
|
|
144
|
+
const pc = this.idle.pop()!;
|
|
145
|
+
if (this.expired(pc)) {
|
|
146
|
+
this.total--;
|
|
147
|
+
await pc.client().close().catch(() => {});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const now = performance.now();
|
|
151
|
+
if (now - pc.lastUsed > this.cfg.healthCheckIntervalMs) {
|
|
152
|
+
// Ping: a dead connection is dropped and replaced by a fresh
|
|
153
|
+
// dial (the reconnect leg of §21).
|
|
154
|
+
try {
|
|
155
|
+
await pc.client().health();
|
|
156
|
+
} catch {
|
|
157
|
+
this.total--;
|
|
158
|
+
await pc.client().close().catch(() => {});
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
pc.lastUsed = now;
|
|
162
|
+
return pc;
|
|
163
|
+
}
|
|
164
|
+
pc.lastUsed = now;
|
|
165
|
+
return pc;
|
|
166
|
+
}
|
|
167
|
+
if (this.total < this.cfg.maxConns) {
|
|
168
|
+
this.total++;
|
|
169
|
+
let c: Client;
|
|
170
|
+
try {
|
|
171
|
+
c = await this.dialRetry();
|
|
172
|
+
} catch (e) {
|
|
173
|
+
this.total--;
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
const now = performance.now();
|
|
177
|
+
return new PooledConn(this, c, now);
|
|
178
|
+
}
|
|
179
|
+
// Exhausted: wait for a return or the acquire timeout.
|
|
180
|
+
let entry!: Waiter;
|
|
181
|
+
const wake = new Promise<"wake">((resolve) => {
|
|
182
|
+
entry = { resolve: () => resolve("wake") };
|
|
183
|
+
this.waiters.push(entry);
|
|
184
|
+
});
|
|
185
|
+
const timer = new Promise<"timer">((resolve) => {
|
|
186
|
+
const t = setTimeout(() => resolve("timer"), this.cfg.acquireTimeoutMs);
|
|
187
|
+
t.unref?.();
|
|
188
|
+
});
|
|
189
|
+
if ((await Promise.race([wake, timer])) === "timer") {
|
|
190
|
+
const i = this.waiters.indexOf(entry);
|
|
191
|
+
if (i >= 0) this.waiters.splice(i, 1);
|
|
192
|
+
throw new McpError(
|
|
193
|
+
"RESOURCE_EXHAUSTED",
|
|
194
|
+
"connection pool exhausted: all connections busy and the acquire timeout elapsed",
|
|
195
|
+
true,
|
|
196
|
+
"Retry with backoff, or raise MaxConns.",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Calls the factory with a short backoff until the deadline — the
|
|
203
|
+
* reconnect leg: a dead server keeps the borrow pending and succeeds
|
|
204
|
+
* once the server returns. */
|
|
205
|
+
private async dialRetry(): Promise<Client> {
|
|
206
|
+
const deadline = performance.now() + this.cfg.acquireTimeoutMs;
|
|
207
|
+
let lastErr: unknown;
|
|
208
|
+
for (;;) {
|
|
209
|
+
try {
|
|
210
|
+
return await this.cfg.factory();
|
|
211
|
+
} catch (e) {
|
|
212
|
+
lastErr = e;
|
|
213
|
+
}
|
|
214
|
+
if (performance.now() >= deadline) {
|
|
215
|
+
throw new McpError(
|
|
216
|
+
"UNAVAILABLE",
|
|
217
|
+
`could not connect to the server: ${String(lastErr)}`,
|
|
218
|
+
true,
|
|
219
|
+
"Verify the server; the pool retries until the acquire timeout.",
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
await sleep(100);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Whether an idle connection was reaped by the idle or lifetime
|
|
227
|
+
* timeout. */
|
|
228
|
+
private expired(pc: PooledConn): boolean {
|
|
229
|
+
const now = performance.now();
|
|
230
|
+
return (
|
|
231
|
+
now - pc.lastUsed > this.cfg.idleTimeoutMs ||
|
|
232
|
+
now - pc.createdAt > this.cfg.maxLifetimeMs
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Returns a connection to the pool: to a waiter when one is blocked,
|
|
237
|
+
* else to the idle stack; lifetime-expired or broken connections are
|
|
238
|
+
* dropped instead. Public only for PooledConn — the stand-in for Go's
|
|
239
|
+
* same-package access. */
|
|
240
|
+
put(pc: PooledConn): void {
|
|
241
|
+
const now = performance.now();
|
|
242
|
+
pc.lastUsed = now;
|
|
243
|
+
const drop =
|
|
244
|
+
this.closedFlag || pc.broken || now - pc.createdAt > this.cfg.maxLifetimeMs;
|
|
245
|
+
if (!drop) {
|
|
246
|
+
this.idle.push(pc);
|
|
247
|
+
const w = this.waiters.shift();
|
|
248
|
+
if (w) w.resolve();
|
|
249
|
+
} else {
|
|
250
|
+
this.total--;
|
|
251
|
+
}
|
|
252
|
+
if (drop) {
|
|
253
|
+
void pc.client().close().catch(() => {});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Dials until minIdle connections sit idle (best-effort, no background
|
|
258
|
+
* refill). */
|
|
259
|
+
async fillMinIdle(): Promise<void> {
|
|
260
|
+
for (;;) {
|
|
261
|
+
if (
|
|
262
|
+
this.closedFlag ||
|
|
263
|
+
this.idle.length >= this.cfg.minIdle ||
|
|
264
|
+
this.total >= this.cfg.maxConns
|
|
265
|
+
) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
this.total++;
|
|
269
|
+
let c: Client;
|
|
270
|
+
try {
|
|
271
|
+
c = await this.dialRetry();
|
|
272
|
+
} catch (e) {
|
|
273
|
+
this.total--;
|
|
274
|
+
throw e;
|
|
275
|
+
}
|
|
276
|
+
this.put(new PooledConn(this, c, performance.now()));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The observable pool shape. */
|
|
281
|
+
stats(): PoolStats {
|
|
282
|
+
return { total: this.total, idle: this.idle.length };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Closes the idle connections and marks the pool closed; checked-out
|
|
286
|
+
* connections are closed when they are released. */
|
|
287
|
+
async close(): Promise<void> {
|
|
288
|
+
if (this.closedFlag) return;
|
|
289
|
+
this.closedFlag = true;
|
|
290
|
+
const idle = this.idle;
|
|
291
|
+
this.idle = [];
|
|
292
|
+
this.total -= idle.length;
|
|
293
|
+
for (const pc of idle) {
|
|
294
|
+
await pc.client().close().catch(() => {});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|