@grafema/rfdb-client 0.2.1-beta → 0.2.6-beta
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 +28 -15
- package/dist/client.d.ts +171 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +498 -18
- package/dist/client.js.map +1 -0
- package/dist/client.test.js +213 -0
- package/dist/client.test.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -0
- package/dist/protocol.js.map +1 -0
- package/dist/stream-queue.d.ts +30 -0
- package/dist/stream-queue.d.ts.map +1 -0
- package/dist/stream-queue.js +74 -0
- package/dist/stream-queue.js.map +1 -0
- package/package.json +8 -5
- package/ts/client.test.ts +635 -0
- package/ts/client.ts +589 -20
- package/ts/index.ts +10 -0
- package/ts/protocol.ts +11 -0
- package/ts/stream-queue.test.ts +114 -0
- package/ts/stream-queue.ts +83 -0
package/ts/client.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { createConnection, Socket } from 'net';
|
|
9
9
|
import { encode, decode } from '@msgpack/msgpack';
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
11
|
+
import { StreamQueue } from './stream-queue.js';
|
|
11
12
|
|
|
12
13
|
import type {
|
|
13
14
|
RFDBCommand,
|
|
@@ -16,9 +17,24 @@ import type {
|
|
|
16
17
|
RFDBResponse,
|
|
17
18
|
IRFDBClient,
|
|
18
19
|
AttrQuery,
|
|
20
|
+
FieldDeclaration,
|
|
19
21
|
DatalogResult,
|
|
20
22
|
NodeType,
|
|
21
23
|
EdgeType,
|
|
24
|
+
HelloResponse,
|
|
25
|
+
CreateDatabaseResponse,
|
|
26
|
+
OpenDatabaseResponse,
|
|
27
|
+
ListDatabasesResponse,
|
|
28
|
+
CurrentDatabaseResponse,
|
|
29
|
+
SnapshotRef,
|
|
30
|
+
SnapshotDiff,
|
|
31
|
+
SnapshotInfo,
|
|
32
|
+
DiffSnapshotsResponse,
|
|
33
|
+
FindSnapshotResponse,
|
|
34
|
+
ListSnapshotsResponse,
|
|
35
|
+
CommitDelta,
|
|
36
|
+
CommitBatchResponse,
|
|
37
|
+
NodesChunkResponse,
|
|
22
38
|
} from '@grafema/types';
|
|
23
39
|
|
|
24
40
|
interface PendingRequest {
|
|
@@ -34,6 +50,17 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
34
50
|
private reqId: number;
|
|
35
51
|
private buffer: Buffer;
|
|
36
52
|
|
|
53
|
+
// Batch state
|
|
54
|
+
private _batching: boolean = false;
|
|
55
|
+
private _batchNodes: WireNode[] = [];
|
|
56
|
+
private _batchEdges: WireEdge[] = [];
|
|
57
|
+
private _batchFiles: Set<string> = new Set();
|
|
58
|
+
|
|
59
|
+
// Streaming state
|
|
60
|
+
private _supportsStreaming: boolean = false;
|
|
61
|
+
private _pendingStreams: Map<number, StreamQueue<WireNode>> = new Map();
|
|
62
|
+
private _streamTimers: Map<number, ReturnType<typeof setTimeout>> = new Map();
|
|
63
|
+
|
|
37
64
|
constructor(socketPath: string = '/tmp/rfdb.sock') {
|
|
38
65
|
super();
|
|
39
66
|
this.socketPath = socketPath;
|
|
@@ -44,6 +71,14 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
44
71
|
this.buffer = Buffer.alloc(0);
|
|
45
72
|
}
|
|
46
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Whether the connected server supports streaming responses.
|
|
76
|
+
* Set after calling hello(). Defaults to false.
|
|
77
|
+
*/
|
|
78
|
+
get supportsStreaming(): boolean {
|
|
79
|
+
return this._supportsStreaming;
|
|
80
|
+
}
|
|
81
|
+
|
|
47
82
|
/**
|
|
48
83
|
* Connect to RFDB server
|
|
49
84
|
*/
|
|
@@ -59,11 +94,12 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
59
94
|
resolve();
|
|
60
95
|
});
|
|
61
96
|
|
|
62
|
-
this.socket.on('error', (err:
|
|
97
|
+
this.socket.on('error', (err: NodeJS.ErrnoException) => {
|
|
98
|
+
const enhancedError = this._enhanceConnectionError(err);
|
|
63
99
|
if (!this.connected) {
|
|
64
|
-
reject(
|
|
100
|
+
reject(enhancedError);
|
|
65
101
|
} else {
|
|
66
|
-
this.emit('error',
|
|
102
|
+
this.emit('error', enhancedError);
|
|
67
103
|
}
|
|
68
104
|
});
|
|
69
105
|
|
|
@@ -75,6 +111,15 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
75
111
|
reject(new Error('Connection closed'));
|
|
76
112
|
}
|
|
77
113
|
this.pending.clear();
|
|
114
|
+
// Fail all pending streams
|
|
115
|
+
for (const [, stream] of this._pendingStreams) {
|
|
116
|
+
stream.fail(new Error('Connection closed'));
|
|
117
|
+
}
|
|
118
|
+
this._pendingStreams.clear();
|
|
119
|
+
for (const [, timer] of this._streamTimers) {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
this._streamTimers.clear();
|
|
78
123
|
});
|
|
79
124
|
|
|
80
125
|
this.socket.on('data', (chunk: Buffer) => {
|
|
@@ -83,6 +128,39 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
83
128
|
});
|
|
84
129
|
}
|
|
85
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Enhance connection errors with helpful messages about --auto-start
|
|
133
|
+
*/
|
|
134
|
+
private _enhanceConnectionError(err: NodeJS.ErrnoException): Error {
|
|
135
|
+
const code = err.code;
|
|
136
|
+
|
|
137
|
+
if (code === 'EPIPE' || code === 'ECONNRESET') {
|
|
138
|
+
return new Error(
|
|
139
|
+
`RFDB server connection lost (${code}). The server may have crashed or been stopped.\n` +
|
|
140
|
+
`Try running with --auto-start flag to automatically start the server, or manually start it with:\n` +
|
|
141
|
+
` rfdb-server start`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (code === 'ENOENT') {
|
|
146
|
+
return new Error(
|
|
147
|
+
`RFDB server socket not found at ${this.socketPath}.\n` +
|
|
148
|
+
`The server is not running. Use --auto-start flag to automatically start it, or manually start with:\n` +
|
|
149
|
+
` rfdb-server start`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (code === 'ECONNREFUSED') {
|
|
154
|
+
return new Error(
|
|
155
|
+
`Cannot connect to RFDB server at ${this.socketPath} (connection refused).\n` +
|
|
156
|
+
`The server may not be running. Use --auto-start flag to automatically start it, or manually start with:\n` +
|
|
157
|
+
` rfdb-server start`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return err;
|
|
162
|
+
}
|
|
163
|
+
|
|
86
164
|
/**
|
|
87
165
|
* Handle incoming data, parse framed messages
|
|
88
166
|
*/
|
|
@@ -107,22 +185,55 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
107
185
|
const response = decode(msgBytes) as RFDBResponse;
|
|
108
186
|
this._handleResponse(response);
|
|
109
187
|
} catch (err) {
|
|
110
|
-
|
|
188
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
189
|
+
this.emit('error', new Error(`Failed to decode response: ${message}`));
|
|
111
190
|
}
|
|
112
191
|
}
|
|
113
192
|
}
|
|
114
193
|
|
|
115
194
|
/**
|
|
116
|
-
* Handle decoded response
|
|
195
|
+
* Handle decoded response — match by requestId, route streaming chunks
|
|
196
|
+
* to StreamQueue or resolve single-response Promise.
|
|
117
197
|
*/
|
|
118
198
|
private _handleResponse(response: RFDBResponse): void {
|
|
119
|
-
if (this.pending.size === 0) {
|
|
199
|
+
if (this.pending.size === 0 && this._pendingStreams.size === 0) {
|
|
120
200
|
this.emit('error', new Error('Received response with no pending request'));
|
|
121
201
|
return;
|
|
122
202
|
}
|
|
123
203
|
|
|
124
|
-
|
|
125
|
-
|
|
204
|
+
let id: number;
|
|
205
|
+
|
|
206
|
+
if (response.requestId) {
|
|
207
|
+
const parsed = this._parseRequestId(response.requestId);
|
|
208
|
+
if (parsed === null) {
|
|
209
|
+
this.emit('error', new Error(`Received response for unknown requestId: ${response.requestId}`));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
id = parsed;
|
|
213
|
+
} else {
|
|
214
|
+
// FIFO fallback for servers that don't echo requestId
|
|
215
|
+
if (this.pending.size > 0) {
|
|
216
|
+
id = (this.pending.entries().next().value as [number, PendingRequest])[0];
|
|
217
|
+
} else {
|
|
218
|
+
this.emit('error', new Error('Received response with no pending request'));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Route to streaming handler if this requestId has a StreamQueue
|
|
224
|
+
const streamQueue = this._pendingStreams.get(id);
|
|
225
|
+
if (streamQueue) {
|
|
226
|
+
this._handleStreamingResponse(id, response, streamQueue);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Non-streaming response — existing behavior
|
|
231
|
+
if (!this.pending.has(id)) {
|
|
232
|
+
this.emit('error', new Error(`Received response for unknown requestId: ${response.requestId}`));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const { resolve, reject } = this.pending.get(id)!;
|
|
126
237
|
this.pending.delete(id);
|
|
127
238
|
|
|
128
239
|
if (response.error) {
|
|
@@ -133,19 +244,136 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
133
244
|
}
|
|
134
245
|
|
|
135
246
|
/**
|
|
136
|
-
*
|
|
247
|
+
* Handle a response for a streaming request.
|
|
248
|
+
* Routes chunk data to StreamQueue and manages stream lifecycle.
|
|
249
|
+
* Resets per-chunk timeout on each successful chunk arrival.
|
|
250
|
+
*/
|
|
251
|
+
private _handleStreamingResponse(
|
|
252
|
+
id: number,
|
|
253
|
+
response: RFDBResponse,
|
|
254
|
+
streamQueue: StreamQueue<WireNode>,
|
|
255
|
+
): void {
|
|
256
|
+
// Error response — fail the stream
|
|
257
|
+
if (response.error) {
|
|
258
|
+
this._cleanupStream(id);
|
|
259
|
+
streamQueue.fail(new Error(response.error));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Streaming chunk (has `done` field)
|
|
264
|
+
if ('done' in response) {
|
|
265
|
+
const chunk = response as unknown as NodesChunkResponse;
|
|
266
|
+
const nodes = chunk.nodes || [];
|
|
267
|
+
for (const node of nodes) {
|
|
268
|
+
streamQueue.push(node);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (chunk.done) {
|
|
272
|
+
this._cleanupStream(id);
|
|
273
|
+
streamQueue.end();
|
|
274
|
+
} else {
|
|
275
|
+
// Reset per-chunk timeout
|
|
276
|
+
this._resetStreamTimer(id, streamQueue);
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Auto-fallback: server sent a non-streaming Nodes response
|
|
282
|
+
// (server doesn't support streaming or result was below threshold)
|
|
283
|
+
const nodesResponse = response as unknown as { nodes?: WireNode[] };
|
|
284
|
+
const nodes = nodesResponse.nodes || [];
|
|
285
|
+
for (const node of nodes) {
|
|
286
|
+
streamQueue.push(node);
|
|
287
|
+
}
|
|
288
|
+
this._cleanupStream(id);
|
|
289
|
+
streamQueue.end();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Reset the per-chunk timeout for a streaming request.
|
|
294
|
+
*/
|
|
295
|
+
private _resetStreamTimer(id: number, streamQueue: StreamQueue<WireNode>): void {
|
|
296
|
+
const existing = this._streamTimers.get(id);
|
|
297
|
+
if (existing) clearTimeout(existing);
|
|
298
|
+
|
|
299
|
+
const timer = setTimeout(() => {
|
|
300
|
+
this._cleanupStream(id);
|
|
301
|
+
streamQueue.fail(new Error(
|
|
302
|
+
`RFDB queryNodesStream timed out after ${RFDBClient.DEFAULT_TIMEOUT_MS}ms (no chunk received)`
|
|
303
|
+
));
|
|
304
|
+
}, RFDBClient.DEFAULT_TIMEOUT_MS);
|
|
305
|
+
|
|
306
|
+
this._streamTimers.set(id, timer);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Clean up all state for a completed/failed streaming request.
|
|
311
|
+
*/
|
|
312
|
+
private _cleanupStream(id: number): void {
|
|
313
|
+
this._pendingStreams.delete(id);
|
|
314
|
+
this.pending.delete(id);
|
|
315
|
+
const timer = this._streamTimers.get(id);
|
|
316
|
+
if (timer) {
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
this._streamTimers.delete(id);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private _parseRequestId(requestId: string): number | null {
|
|
323
|
+
if (!requestId.startsWith('r')) return null;
|
|
324
|
+
const num = parseInt(requestId.slice(1), 10);
|
|
325
|
+
return Number.isNaN(num) ? null : num;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Default timeout for operations (60 seconds)
|
|
330
|
+
* Flush/compact may take time for large graphs, but should not hang indefinitely
|
|
331
|
+
*/
|
|
332
|
+
private static readonly DEFAULT_TIMEOUT_MS = 60_000;
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Send a request and wait for response with timeout
|
|
137
336
|
*/
|
|
138
|
-
private async _send(
|
|
337
|
+
private async _send(
|
|
338
|
+
cmd: RFDBCommand,
|
|
339
|
+
payload: Record<string, unknown> = {},
|
|
340
|
+
timeoutMs: number = RFDBClient.DEFAULT_TIMEOUT_MS
|
|
341
|
+
): Promise<RFDBResponse> {
|
|
139
342
|
if (!this.connected || !this.socket) {
|
|
140
343
|
throw new Error('Not connected to RFDB server');
|
|
141
344
|
}
|
|
142
345
|
|
|
143
|
-
const request = { cmd, ...payload };
|
|
144
|
-
const msgBytes = encode(request);
|
|
145
|
-
|
|
146
346
|
return new Promise((resolve, reject) => {
|
|
147
347
|
const id = this.reqId++;
|
|
148
|
-
|
|
348
|
+
const request = { requestId: `r${id}`, cmd, ...payload };
|
|
349
|
+
const msgBytes = encode(request);
|
|
350
|
+
|
|
351
|
+
// Setup timeout
|
|
352
|
+
const timer = setTimeout(() => {
|
|
353
|
+
this.pending.delete(id);
|
|
354
|
+
reject(new Error(`RFDB ${cmd} timed out after ${timeoutMs}ms. Server may be unresponsive or dbPath may be invalid.`));
|
|
355
|
+
}, timeoutMs);
|
|
356
|
+
|
|
357
|
+
// Handle socket errors during this request
|
|
358
|
+
const errorHandler = (err: NodeJS.ErrnoException) => {
|
|
359
|
+
this.pending.delete(id);
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
reject(this._enhanceConnectionError(err));
|
|
362
|
+
};
|
|
363
|
+
this.socket!.once('error', errorHandler);
|
|
364
|
+
|
|
365
|
+
this.pending.set(id, {
|
|
366
|
+
resolve: (value) => {
|
|
367
|
+
clearTimeout(timer);
|
|
368
|
+
this.socket?.removeListener('error', errorHandler);
|
|
369
|
+
resolve(value);
|
|
370
|
+
},
|
|
371
|
+
reject: (error) => {
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
this.socket?.removeListener('error', errorHandler);
|
|
374
|
+
reject(error);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
149
377
|
|
|
150
378
|
// Write length prefix + message
|
|
151
379
|
const header = Buffer.alloc(4);
|
|
@@ -169,13 +397,13 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
169
397
|
const nodeRecord = n as Record<string, unknown>;
|
|
170
398
|
|
|
171
399
|
// Extract known wire format fields, rest goes to metadata
|
|
172
|
-
const { id, type, node_type, nodeType, name, file, exported, metadata, ...rest } = nodeRecord;
|
|
400
|
+
const { id, type, node_type, nodeType, name, file, exported, metadata, semanticId, semantic_id, ...rest } = nodeRecord;
|
|
173
401
|
|
|
174
402
|
// Merge explicit metadata with extra properties
|
|
175
403
|
const existingMeta = typeof metadata === 'string' ? JSON.parse(metadata as string) : (metadata || {});
|
|
176
404
|
const combinedMeta = { ...existingMeta, ...rest };
|
|
177
405
|
|
|
178
|
-
|
|
406
|
+
const wire: WireNode = {
|
|
179
407
|
id: String(id),
|
|
180
408
|
nodeType: (node_type || nodeType || type || 'UNKNOWN') as NodeType,
|
|
181
409
|
name: (name as string) || '',
|
|
@@ -183,8 +411,24 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
183
411
|
exported: (exported as boolean) || false,
|
|
184
412
|
metadata: JSON.stringify(combinedMeta),
|
|
185
413
|
};
|
|
414
|
+
|
|
415
|
+
// Preserve semanticId as top-level field for v3 protocol
|
|
416
|
+
const sid = semanticId || semantic_id;
|
|
417
|
+
if (sid) {
|
|
418
|
+
(wire as WireNode & { semanticId: string }).semanticId = String(sid);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return wire;
|
|
186
422
|
});
|
|
187
423
|
|
|
424
|
+
if (this._batching) {
|
|
425
|
+
this._batchNodes.push(...wireNodes);
|
|
426
|
+
for (const node of wireNodes) {
|
|
427
|
+
if (node.file) this._batchFiles.add(node.file);
|
|
428
|
+
}
|
|
429
|
+
return { ok: true } as RFDBResponse;
|
|
430
|
+
}
|
|
431
|
+
|
|
188
432
|
return this._send('addNodes', { nodes: wireNodes });
|
|
189
433
|
}
|
|
190
434
|
|
|
@@ -215,6 +459,11 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
215
459
|
};
|
|
216
460
|
});
|
|
217
461
|
|
|
462
|
+
if (this._batching) {
|
|
463
|
+
this._batchEdges.push(...wireEdges);
|
|
464
|
+
return { ok: true } as RFDBResponse;
|
|
465
|
+
}
|
|
466
|
+
|
|
218
467
|
return this._send('addEdges', { edges: wireEdges, skipValidation });
|
|
219
468
|
}
|
|
220
469
|
|
|
@@ -444,18 +693,87 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
444
693
|
* Query nodes (async generator)
|
|
445
694
|
*/
|
|
446
695
|
async *queryNodes(query: AttrQuery): AsyncGenerator<WireNode, void, unknown> {
|
|
696
|
+
// When server supports streaming (protocol v3+), delegate to streaming handler
|
|
697
|
+
// to correctly handle chunked NodesChunk responses for large result sets.
|
|
698
|
+
if (this._supportsStreaming) {
|
|
699
|
+
yield* this.queryNodesStream(query);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const serverQuery = this._buildServerQuery(query);
|
|
704
|
+
const response = await this._send('queryNodes', { query: serverQuery });
|
|
705
|
+
const nodes = (response as { nodes?: WireNode[] }).nodes || [];
|
|
706
|
+
|
|
707
|
+
for (const node of nodes) {
|
|
708
|
+
yield node;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Build a server query object from an AttrQuery.
|
|
714
|
+
*/
|
|
715
|
+
private _buildServerQuery(query: AttrQuery): Record<string, unknown> {
|
|
447
716
|
const serverQuery: Record<string, unknown> = {};
|
|
448
717
|
if (query.nodeType) serverQuery.nodeType = query.nodeType;
|
|
449
718
|
if (query.type) serverQuery.nodeType = query.type;
|
|
450
719
|
if (query.name) serverQuery.name = query.name;
|
|
451
720
|
if (query.file) serverQuery.file = query.file;
|
|
452
721
|
if (query.exported !== undefined) serverQuery.exported = query.exported;
|
|
722
|
+
return serverQuery;
|
|
723
|
+
}
|
|
453
724
|
|
|
454
|
-
|
|
455
|
-
|
|
725
|
+
/**
|
|
726
|
+
* Stream nodes matching query with true streaming support.
|
|
727
|
+
*
|
|
728
|
+
* Behavior depends on server capabilities:
|
|
729
|
+
* - Server supports streaming (protocol v3): receives chunked NodesChunk
|
|
730
|
+
* responses via StreamQueue. Nodes are yielded as they arrive.
|
|
731
|
+
* - Server does NOT support streaming (fallback): delegates to queryNodes()
|
|
732
|
+
* which yields nodes one by one from bulk response.
|
|
733
|
+
*
|
|
734
|
+
* The generator can be aborted by breaking out of the loop or calling .return().
|
|
735
|
+
*/
|
|
736
|
+
async *queryNodesStream(query: AttrQuery): AsyncGenerator<WireNode, void, unknown> {
|
|
737
|
+
if (!this._supportsStreaming) {
|
|
738
|
+
yield* this.queryNodes(query);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
456
741
|
|
|
457
|
-
|
|
458
|
-
|
|
742
|
+
if (!this.connected || !this.socket) {
|
|
743
|
+
throw new Error('Not connected to RFDB server');
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const serverQuery = this._buildServerQuery(query);
|
|
747
|
+
const id = this.reqId++;
|
|
748
|
+
const streamQueue = new StreamQueue<WireNode>();
|
|
749
|
+
this._pendingStreams.set(id, streamQueue);
|
|
750
|
+
|
|
751
|
+
// Build and send request manually (can't use _send which expects single response)
|
|
752
|
+
const request = { requestId: `r${id}`, cmd: 'queryNodes', query: serverQuery };
|
|
753
|
+
const msgBytes = encode(request);
|
|
754
|
+
const header = Buffer.alloc(4);
|
|
755
|
+
header.writeUInt32BE(msgBytes.length);
|
|
756
|
+
|
|
757
|
+
// Register in pending map for error routing
|
|
758
|
+
this.pending.set(id, {
|
|
759
|
+
resolve: () => { this._cleanupStream(id); },
|
|
760
|
+
reject: (error) => {
|
|
761
|
+
this._cleanupStream(id);
|
|
762
|
+
streamQueue.fail(error);
|
|
763
|
+
},
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Start per-chunk timeout (resets on each chunk in _handleStreamingResponse)
|
|
767
|
+
this._resetStreamTimer(id, streamQueue);
|
|
768
|
+
|
|
769
|
+
this.socket!.write(Buffer.concat([header, Buffer.from(msgBytes)]));
|
|
770
|
+
|
|
771
|
+
try {
|
|
772
|
+
for await (const node of streamQueue) {
|
|
773
|
+
yield node;
|
|
774
|
+
}
|
|
775
|
+
} finally {
|
|
776
|
+
this._cleanupStream(id);
|
|
459
777
|
}
|
|
460
778
|
}
|
|
461
779
|
|
|
@@ -517,6 +835,16 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
517
835
|
return this._send('updateNodeVersion', { id: String(id), version });
|
|
518
836
|
}
|
|
519
837
|
|
|
838
|
+
/**
|
|
839
|
+
* Declare metadata fields for server-side indexing.
|
|
840
|
+
* Call before adding nodes so the server builds indexes on flush.
|
|
841
|
+
* Returns the number of declared fields.
|
|
842
|
+
*/
|
|
843
|
+
async declareFields(fields: FieldDeclaration[]): Promise<number> {
|
|
844
|
+
const response = await this._send('declareFields', { fields });
|
|
845
|
+
return (response as { count?: number }).count || 0;
|
|
846
|
+
}
|
|
847
|
+
|
|
520
848
|
// ===========================================================================
|
|
521
849
|
// Datalog API
|
|
522
850
|
// ===========================================================================
|
|
@@ -552,6 +880,15 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
552
880
|
return (response as { violations?: DatalogResult[] }).violations || [];
|
|
553
881
|
}
|
|
554
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Execute unified Datalog — handles both direct queries and rule-based programs.
|
|
885
|
+
* Auto-detects the head predicate instead of hardcoding violation(X).
|
|
886
|
+
*/
|
|
887
|
+
async executeDatalog(source: string): Promise<DatalogResult[]> {
|
|
888
|
+
const response = await this._send('executeDatalog', { source });
|
|
889
|
+
return (response as { results?: DatalogResult[] }).results || [];
|
|
890
|
+
}
|
|
891
|
+
|
|
555
892
|
/**
|
|
556
893
|
* Ping the server
|
|
557
894
|
*/
|
|
@@ -560,6 +897,238 @@ export class RFDBClient extends EventEmitter implements IRFDBClient {
|
|
|
560
897
|
return response.pong && response.version ? response.version : false;
|
|
561
898
|
}
|
|
562
899
|
|
|
900
|
+
// ===========================================================================
|
|
901
|
+
// Protocol v2 - Multi-Database Commands
|
|
902
|
+
// ===========================================================================
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Negotiate protocol version with server
|
|
906
|
+
* @param protocolVersion - Protocol version to negotiate (default: 2)
|
|
907
|
+
* @returns Server capabilities including protocolVersion, serverVersion, features
|
|
908
|
+
*/
|
|
909
|
+
async hello(protocolVersion: number = 3): Promise<HelloResponse> {
|
|
910
|
+
const response = await this._send('hello' as RFDBCommand, { protocolVersion });
|
|
911
|
+
const hello = response as HelloResponse;
|
|
912
|
+
this._supportsStreaming = hello.features?.includes('streaming') ?? false;
|
|
913
|
+
return hello;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Create a new database
|
|
918
|
+
* @param name - Database name (alphanumeric, _, -)
|
|
919
|
+
* @param ephemeral - If true, database is in-memory and auto-cleaned on disconnect
|
|
920
|
+
*/
|
|
921
|
+
async createDatabase(name: string, ephemeral: boolean = false): Promise<CreateDatabaseResponse> {
|
|
922
|
+
const response = await this._send('createDatabase' as RFDBCommand, { name, ephemeral });
|
|
923
|
+
return response as CreateDatabaseResponse;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Open a database and set as current for this session
|
|
928
|
+
* @param name - Database name
|
|
929
|
+
* @param mode - 'rw' (read-write) or 'ro' (read-only)
|
|
930
|
+
*/
|
|
931
|
+
async openDatabase(name: string, mode: 'rw' | 'ro' = 'rw'): Promise<OpenDatabaseResponse> {
|
|
932
|
+
const response = await this._send('openDatabase' as RFDBCommand, { name, mode });
|
|
933
|
+
return response as OpenDatabaseResponse;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Close current database
|
|
938
|
+
*/
|
|
939
|
+
async closeDatabase(): Promise<RFDBResponse> {
|
|
940
|
+
return this._send('closeDatabase' as RFDBCommand);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Drop (delete) a database - must not be in use
|
|
945
|
+
* @param name - Database name
|
|
946
|
+
*/
|
|
947
|
+
async dropDatabase(name: string): Promise<RFDBResponse> {
|
|
948
|
+
return this._send('dropDatabase' as RFDBCommand, { name });
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* List all databases
|
|
953
|
+
*/
|
|
954
|
+
async listDatabases(): Promise<ListDatabasesResponse> {
|
|
955
|
+
const response = await this._send('listDatabases' as RFDBCommand);
|
|
956
|
+
return response as ListDatabasesResponse;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Get current database for this session
|
|
961
|
+
*/
|
|
962
|
+
async currentDatabase(): Promise<CurrentDatabaseResponse> {
|
|
963
|
+
const response = await this._send('currentDatabase' as RFDBCommand);
|
|
964
|
+
return response as CurrentDatabaseResponse;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// ===========================================================================
|
|
968
|
+
// Snapshot Operations
|
|
969
|
+
// ===========================================================================
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Convert a SnapshotRef to wire format payload fields.
|
|
973
|
+
*
|
|
974
|
+
* - number -> { version: N }
|
|
975
|
+
* - { tag, value } -> { tagKey, tagValue }
|
|
976
|
+
*/
|
|
977
|
+
private _resolveSnapshotRef(ref: SnapshotRef): Record<string, unknown> {
|
|
978
|
+
if (typeof ref === 'number') return { version: ref };
|
|
979
|
+
return { tagKey: ref.tag, tagValue: ref.value };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Compute diff between two snapshots.
|
|
984
|
+
* @param from - Source snapshot (version number or tag reference)
|
|
985
|
+
* @param to - Target snapshot (version number or tag reference)
|
|
986
|
+
* @returns SnapshotDiff with added/removed segments and stats
|
|
987
|
+
*/
|
|
988
|
+
async diffSnapshots(from: SnapshotRef, to: SnapshotRef): Promise<SnapshotDiff> {
|
|
989
|
+
const response = await this._send('diffSnapshots', {
|
|
990
|
+
from: this._resolveSnapshotRef(from),
|
|
991
|
+
to: this._resolveSnapshotRef(to),
|
|
992
|
+
});
|
|
993
|
+
return (response as DiffSnapshotsResponse).diff;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Tag a snapshot with key-value metadata.
|
|
998
|
+
* @param version - Snapshot version to tag
|
|
999
|
+
* @param tags - Key-value pairs to apply (e.g. { "release": "v1.0" })
|
|
1000
|
+
*/
|
|
1001
|
+
async tagSnapshot(version: number, tags: Record<string, string>): Promise<void> {
|
|
1002
|
+
await this._send('tagSnapshot', { version, tags });
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Find a snapshot by tag key/value pair.
|
|
1007
|
+
* @param tagKey - Tag key to search for
|
|
1008
|
+
* @param tagValue - Tag value to match
|
|
1009
|
+
* @returns Snapshot version number, or null if not found
|
|
1010
|
+
*/
|
|
1011
|
+
async findSnapshot(tagKey: string, tagValue: string): Promise<number | null> {
|
|
1012
|
+
const response = await this._send('findSnapshot', { tagKey, tagValue });
|
|
1013
|
+
return (response as FindSnapshotResponse).version;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* List snapshots, optionally filtered by tag key.
|
|
1018
|
+
* @param filterTag - Optional tag key to filter by (only snapshots with this tag)
|
|
1019
|
+
* @returns Array of SnapshotInfo objects
|
|
1020
|
+
*/
|
|
1021
|
+
async listSnapshots(filterTag?: string): Promise<SnapshotInfo[]> {
|
|
1022
|
+
const payload: Record<string, unknown> = {};
|
|
1023
|
+
if (filterTag !== undefined) payload.filterTag = filterTag;
|
|
1024
|
+
const response = await this._send('listSnapshots', payload);
|
|
1025
|
+
return (response as ListSnapshotsResponse).snapshots;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// ===========================================================================
|
|
1029
|
+
// Batch Operations
|
|
1030
|
+
// ===========================================================================
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Begin a batch operation.
|
|
1034
|
+
* While batching, addNodes/addEdges buffer locally instead of sending to server.
|
|
1035
|
+
* Call commitBatch() to send all buffered data atomically.
|
|
1036
|
+
*/
|
|
1037
|
+
beginBatch(): void {
|
|
1038
|
+
if (this._batching) throw new Error('Batch already in progress');
|
|
1039
|
+
this._batching = true;
|
|
1040
|
+
this._batchNodes = [];
|
|
1041
|
+
this._batchEdges = [];
|
|
1042
|
+
this._batchFiles = new Set();
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Commit the current batch to the server.
|
|
1047
|
+
* Sends all buffered nodes/edges with the list of changed files.
|
|
1048
|
+
* Server atomically replaces old data for changed files with new data.
|
|
1049
|
+
*/
|
|
1050
|
+
async commitBatch(tags?: string[]): Promise<CommitDelta> {
|
|
1051
|
+
if (!this._batching) throw new Error('No batch in progress');
|
|
1052
|
+
const response = await this._send('commitBatch', {
|
|
1053
|
+
changedFiles: [...this._batchFiles],
|
|
1054
|
+
nodes: this._batchNodes,
|
|
1055
|
+
edges: this._batchEdges,
|
|
1056
|
+
tags,
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
this._batching = false;
|
|
1060
|
+
this._batchNodes = [];
|
|
1061
|
+
this._batchEdges = [];
|
|
1062
|
+
this._batchFiles = new Set();
|
|
1063
|
+
|
|
1064
|
+
return (response as CommitBatchResponse).delta;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Abort the current batch, discarding all buffered data.
|
|
1069
|
+
*/
|
|
1070
|
+
abortBatch(): void {
|
|
1071
|
+
this._batching = false;
|
|
1072
|
+
this._batchNodes = [];
|
|
1073
|
+
this._batchEdges = [];
|
|
1074
|
+
this._batchFiles = new Set();
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Check if a batch is currently in progress.
|
|
1079
|
+
*/
|
|
1080
|
+
isBatching(): boolean {
|
|
1081
|
+
return this._batching;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Find files that depend on the given changed files.
|
|
1086
|
+
* Uses backward reachability to find dependent modules.
|
|
1087
|
+
*
|
|
1088
|
+
* Note: For large result sets, each reachable node requires a separate
|
|
1089
|
+
* getNode RPC. A future server-side optimization could return file paths
|
|
1090
|
+
* directly from the reachability query.
|
|
1091
|
+
*/
|
|
1092
|
+
async findDependentFiles(changedFiles: string[]): Promise<string[]> {
|
|
1093
|
+
const nodeIds: string[] = [];
|
|
1094
|
+
for (const file of changedFiles) {
|
|
1095
|
+
const ids = await this.findByAttr({ file });
|
|
1096
|
+
nodeIds.push(...ids);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (nodeIds.length === 0) return [];
|
|
1100
|
+
|
|
1101
|
+
const reachable = await this.reachability(
|
|
1102
|
+
nodeIds,
|
|
1103
|
+
2,
|
|
1104
|
+
['IMPORTS_FROM', 'DEPENDS_ON', 'CALLS'] as EdgeType[],
|
|
1105
|
+
true,
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
const changedSet = new Set(changedFiles);
|
|
1109
|
+
const files = new Set<string>();
|
|
1110
|
+
for (const id of reachable) {
|
|
1111
|
+
const node = await this.getNode(id);
|
|
1112
|
+
if (node?.file && !changedSet.has(node.file)) {
|
|
1113
|
+
files.add(node.file);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
return [...files];
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Unref the socket so it doesn't keep the process alive.
|
|
1122
|
+
*
|
|
1123
|
+
* Call this in test environments to allow process to exit
|
|
1124
|
+
* even if connections remain open.
|
|
1125
|
+
*/
|
|
1126
|
+
unref(): void {
|
|
1127
|
+
if (this.socket) {
|
|
1128
|
+
this.socket.unref();
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
563
1132
|
/**
|
|
564
1133
|
* Close connection
|
|
565
1134
|
*/
|