mushroomdb-client 0.1.2

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/src/types.ts ADDED
@@ -0,0 +1,460 @@
1
+ /**
2
+ * TypeScript types for the mushroomdb HTTP + WebSocket API.
3
+ *
4
+ * Every type is annotated with the Rust struct / enum it mirrors.
5
+ */
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Primitive value
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /**
12
+ * A single cell value in a query result row.
13
+ *
14
+ * Mirrors `core_storage::types::Value`:
15
+ * Int(i64) → number
16
+ * Float(f64) → number (NaN serializes as null per server behaviour)
17
+ * Str(String) → string
18
+ * Bool(bool) → boolean
19
+ * List(Vec<Value>) → CellValue[]
20
+ * null cell → null
21
+ *
22
+ * **Precision warning (Int / i64):** JavaScript `number` (IEEE 754 double)
23
+ * safely represents integers only up to ±2^53 − 1 (~9×10^15). Rust `i64`
24
+ * reaches ±9.2×10^18. Integer node properties whose absolute value exceeds
25
+ * 2^53 will be silently corrupted when parsed as JS numbers. For such values
26
+ * use a string representation in the graph instead. A future release will
27
+ * offer a BigInt-aware parsing mode.
28
+ */
29
+ export type CellValue = number | string | boolean | null | CellValue[];
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Query result
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Wire shape of a successful `POST /query?format=json` response.
37
+ *
38
+ * Mirrors `crates/server/src/json.rs::result_set_json` output:
39
+ * { columns: string[], rows: (scalar|null)[][] }
40
+ */
41
+ export interface QueryResult {
42
+ columns: string[];
43
+ rows: CellValue[][];
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Stats
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Per-rule statistics.
52
+ *
53
+ * Mirrors `core_api::db::RuleStats`.
54
+ */
55
+ export interface RuleStats {
56
+ name: string;
57
+ edges: number;
58
+ tripped: boolean;
59
+ fires: number;
60
+ approximate: boolean;
61
+ }
62
+
63
+ /**
64
+ * Database-wide counters returned by `GET /stats`.
65
+ *
66
+ * Mirrors `core_api::db::Stats`.
67
+ */
68
+ export interface Stats {
69
+ nodes_live: number;
70
+ nodes_tombstoned: number;
71
+ edges: number;
72
+ rules: RuleStats[];
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Ingest
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Options for `POST /ingest`.
81
+ *
82
+ * Mirrors `core_api::IngestOptions` (serialised by `crates/server/src/http.rs::ingest_options`).
83
+ */
84
+ export interface IngestOptions {
85
+ /** Name of the field to use as the node key. Defaults to "key". */
86
+ key_field?: string;
87
+ /**
88
+ * Auto foreign-key detection mode.
89
+ * false | "off" → disabled
90
+ * { suffix: string } → detect fields ending with `suffix` (e.g. "_id")
91
+ */
92
+ auto_fk?: false | "off" | { suffix: string };
93
+ }
94
+
95
+ /** An explicit edge to wire during ingest. */
96
+ export interface IngestEdge {
97
+ edge_type: string;
98
+ src: string;
99
+ dst: string;
100
+ }
101
+
102
+ /** Body sent to `POST /ingest`. */
103
+ export interface IngestRequest {
104
+ label: string;
105
+ rows: Record<string, unknown>[];
106
+ options?: IngestOptions;
107
+ edges?: IngestEdge[];
108
+ }
109
+
110
+ /** Response from `POST /ingest`. Shape is opaque; use `ok` to check success. */
111
+ export type IngestReport = Record<string, unknown>;
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Node / explain / neighborhood / rules
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Wire `NodeInfo` from `GET /node/{key}`.
119
+ *
120
+ * `props` uses the same untagged Value JSON as `/query`. Unknown keys are
121
+ * HTTP 404; the client `node()` method maps that to `null`.
122
+ */
123
+ export interface NodeInfo {
124
+ key: string;
125
+ label: string;
126
+ props: Record<string, CellValue>;
127
+ }
128
+
129
+ /**
130
+ * Predicate kind on an {@link Explanation}, matching HTTP snake_case JSON.
131
+ */
132
+ export type PredicateKind =
133
+ | "key_match"
134
+ | "field_equal"
135
+ | "overlap"
136
+ | "all"
137
+ | "numeric_within"
138
+ | "geo_radius"
139
+ | "vector_similar";
140
+
141
+ /**
142
+ * Wire `PredicateSummary`. Option fields are present-null, never omitted.
143
+ */
144
+ export interface PredicateSummary {
145
+ kind: PredicateKind;
146
+ fields: string[];
147
+ min: number | null;
148
+ tolerance: number | null;
149
+ km: number | null;
150
+ parts: PredicateSummary[] | null;
151
+ }
152
+
153
+ /**
154
+ * One rule-derived edge between two nodes from `GET /explain?a=&b=`.
155
+ *
156
+ * Mirrors `core_api::db::Explanation`.
157
+ */
158
+ export interface Explanation {
159
+ rule: string;
160
+ edge_type: string;
161
+ src_key: string;
162
+ dst_key: string;
163
+ weight: number | null;
164
+ predicate: PredicateSummary;
165
+ }
166
+
167
+ /**
168
+ * Wire neighborhood expansion from `GET /node/{key}/neighborhood`.
169
+ *
170
+ * Columns are `key`, `label`, `depth`.
171
+ */
172
+ export interface Neighborhood {
173
+ columns: string[];
174
+ rows: CellValue[][];
175
+ }
176
+
177
+ /**
178
+ * Internally-tagged `Predicate` JSON accepted by `POST /rules`.
179
+ *
180
+ * Mirrors `core_rules::Predicate`.
181
+ */
182
+ export type RulePredicate =
183
+ | { KeyMatch: { field: string } }
184
+ | { FieldEqual: { field: string } }
185
+ | { Overlap: { field: string; min: number } }
186
+ | { NumericWithin: { field: string; tolerance: number } }
187
+ | { GeoRadius: { field: string; km: number } }
188
+ | { VectorSimilar: { field: string; min: number } }
189
+ | { All: RulePredicate[] }
190
+ | { Any: RulePredicate[] };
191
+
192
+ /**
193
+ * Rule definition posted to `POST /rules`.
194
+ *
195
+ * Mirrors `core_rules::RuleDef`. Omit or `max_edges: null` → server fills
196
+ * scored top-k 32, or 1 if the predicate is KeyMatch-rooted. HTTP has no
197
+ * uncapped hatch (Rust/Python explicit `None` still uses the 1_000_000
198
+ * global first-N-by-id budget).
199
+ */
200
+ export interface RuleDef {
201
+ name: string;
202
+ src_label: string;
203
+ dst_label: string;
204
+ predicate: RulePredicate;
205
+ edge_type: string;
206
+ weight_prop?: string | null;
207
+ /** Per-source top-k. Omit/`null` fills 32 (scored) or 1 (KeyMatch-rooted). */
208
+ max_edges?: number | null;
209
+ approximate?: boolean;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Suggest
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /**
217
+ * One rule suggestion returned by `GET /suggest`.
218
+ *
219
+ * Mirrors `core_rules::suggest::RuleSuggestion`.
220
+ */
221
+ export interface RuleSuggestion {
222
+ /** The proposed rule definition (not yet created in the database). */
223
+ def: Record<string, unknown>;
224
+ /** Estimated edge count if the rule were applied. */
225
+ est_edges: number;
226
+ /** Up to 3 example (src_key, dst_key, score) triples drawn from sample evaluation. */
227
+ examples: [string, string, number][];
228
+ /** Human-readable explanation of why this rule was suggested. */
229
+ rationale: string;
230
+ }
231
+
232
+ /**
233
+ * Response from `GET /suggest`.
234
+ *
235
+ * Mirrors `core_rules::suggest::SuggestReport`.
236
+ */
237
+ export interface SuggestReport {
238
+ suggestions: RuleSuggestion[];
239
+ /**
240
+ * true when the global time budget fired before all candidates were evaluated.
241
+ * Partial results are still returned.
242
+ */
243
+ truncated: boolean;
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Algo
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Edge direction for graph algorithms.
252
+ *
253
+ * Mirrors `core_api::algo::AlgoDir` (serialises as lowercase string).
254
+ */
255
+ export type AlgoDir = "out" | "in" | "both";
256
+
257
+ /**
258
+ * Config for `POST /algo/pagerank`.
259
+ *
260
+ * Mirrors `core_api::algo::PageRankConfig`. All fields are optional — the
261
+ * server applies defaults (damping=0.85, max_iters=50, tol=1e-6,
262
+ * edge_type=null, direction="out", budget_ms=5000).
263
+ */
264
+ export interface PageRankConfig {
265
+ damping?: number;
266
+ max_iters?: number;
267
+ tol?: number;
268
+ edge_type?: string | null;
269
+ direction?: AlgoDir;
270
+ budget_ms?: number;
271
+ }
272
+
273
+ /**
274
+ * Result of `POST /algo/pagerank`.
275
+ *
276
+ * Mirrors `core_api::algo::PageRankReport`.
277
+ */
278
+ export interface PageRankReport {
279
+ /** [node_key, score] pairs, sorted by score descending (ties: key asc). */
280
+ scores: [string, number][];
281
+ /** true when the algorithm converged before max_iters and before any budget fired. */
282
+ converged: boolean;
283
+ }
284
+
285
+ /**
286
+ * Config for `POST /algo/wcc`.
287
+ *
288
+ * Mirrors `core_api::algo::WccConfig`. Defaults: edge_type=null, budget_ms=5000.
289
+ */
290
+ export interface WccConfig {
291
+ edge_type?: string | null;
292
+ budget_ms?: number;
293
+ }
294
+
295
+ /**
296
+ * Result of `POST /algo/wcc`.
297
+ *
298
+ * Mirrors `core_api::algo::WccReport`.
299
+ */
300
+ export interface WccReport {
301
+ /** [node_key, component_id] pairs. component_id is the smallest key in the component. */
302
+ components: [string, string][];
303
+ truncated: boolean;
304
+ }
305
+
306
+ /**
307
+ * Config for `POST /algo/degree`.
308
+ *
309
+ * Mirrors `core_api::algo::DegreeConfig`. Defaults: edge_type=null, direction="both", budget_ms=5000.
310
+ */
311
+ export interface DegreeConfig {
312
+ edge_type?: string | null;
313
+ direction?: AlgoDir;
314
+ budget_ms?: number;
315
+ }
316
+
317
+ /**
318
+ * Result of `POST /algo/degree`.
319
+ *
320
+ * Mirrors `core_api::algo::DegreeReport`.
321
+ */
322
+ export interface DegreeReport {
323
+ /**
324
+ * [node_key, degree] pairs, sorted by degree descending (ties: key asc).
325
+ *
326
+ * **Precision warning:** Rust returns `u64` degrees. JS `number` (IEEE 754)
327
+ * safely represents values up to 2^53 − 1. Nodes with degree above ~9×10^15
328
+ * will silently lose precision. In practice graphs with that many edges per
329
+ * node do not exist, but be aware of the type constraint.
330
+ */
331
+ scores: [string, number][];
332
+ truncated: boolean;
333
+ }
334
+
335
+ /** Union of all algo configs. */
336
+ export type AlgoConfig =
337
+ | { algo: "pagerank"; config?: PageRankConfig }
338
+ | { algo: "wcc"; config?: WccConfig }
339
+ | { algo: "degree"; config?: DegreeConfig };
340
+
341
+ /** Union of all algo results. */
342
+ export type AlgoReport = PageRankReport | WccReport | DegreeReport;
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // WebSocket subscription events
346
+ // ---------------------------------------------------------------------------
347
+
348
+ /**
349
+ * A post-commit event delivered over the `/subscribe` WebSocket.
350
+ *
351
+ * Mirrors `core_api::subscription::DbEvent` — serialised as internally-tagged
352
+ * JSON with `"type"` as the discriminant.
353
+ *
354
+ * All variants except `lagged` carry `commit_seq: number` (Rust: `u64`).
355
+ * `lagged` means the subscriber's internal queue overflowed; the caller should
356
+ * re-read graph state to recover consistency for lossless consumers.
357
+ *
358
+ * **Precision warning (`commit_seq` / `missed`):** Both are Rust `u64` and map
359
+ * to JS `number` (IEEE 754 double, safe up to 2^53 − 1 ≈ 9×10^15). A
360
+ * long-lived server generating more than ~9×10^15 commits will silently
361
+ * corrupt these values. A future release will represent u64 fields as
362
+ * `string` or `bigint`. For now treat `commit_seq` as an opaque ordering key,
363
+ * not an exact integer.
364
+ */
365
+ export type DbEvent =
366
+ | {
367
+ type: "edge_fired";
368
+ rule: string;
369
+ src_key: string;
370
+ dst_key: string;
371
+ edge_type: string;
372
+ weight?: number;
373
+ commit_seq: number;
374
+ }
375
+ | {
376
+ type: "edge_retracted";
377
+ rule: string;
378
+ src_key: string;
379
+ dst_key: string;
380
+ edge_type: string;
381
+ commit_seq: number;
382
+ }
383
+ | {
384
+ type: "node_inserted";
385
+ label: string;
386
+ key: string;
387
+ commit_seq: number;
388
+ }
389
+ | {
390
+ type: "node_deleted";
391
+ key: string;
392
+ commit_seq: number;
393
+ }
394
+ | {
395
+ type: "edge_inserted";
396
+ edge_type: string;
397
+ src: string;
398
+ dst: string;
399
+ commit_seq: number;
400
+ }
401
+ | {
402
+ type: "edge_deleted";
403
+ edge_type: string;
404
+ src: string;
405
+ dst: string;
406
+ commit_seq: number;
407
+ }
408
+ | {
409
+ type: "prop_set";
410
+ key: string;
411
+ field: string;
412
+ commit_seq: number;
413
+ }
414
+ | {
415
+ type: "prop_removed";
416
+ key: string;
417
+ field: string;
418
+ commit_seq: number;
419
+ }
420
+ | {
421
+ /**
422
+ * One or more events were dropped because the subscriber's queue was full
423
+ * (capacity 65,536). The caller must re-read graph state to recover
424
+ * consistency for lossless consumers.
425
+ */
426
+ type: "lagged";
427
+ missed: number;
428
+ };
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Subscribe message (sent by client → server)
432
+ // ---------------------------------------------------------------------------
433
+
434
+ /**
435
+ * Subscribe message sent to the server after the WebSocket upgrade.
436
+ *
437
+ * Mirrors `crates/server/src/subscribe.rs::SubscribeMsg`.
438
+ * All fields are optional. With no fields the client receives no events.
439
+ */
440
+ export interface SubscribeMessage {
441
+ /** Rule names to subscribe to. Receive EdgeFired/EdgeRetracted for each. */
442
+ rules?: string[];
443
+ /** If true, also receive node/property write events. */
444
+ writes?: boolean;
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Client error
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /**
452
+ * Thrown by the client when the server returns an HTTP error or the
453
+ * WebSocket handshake fails.
454
+ */
455
+ export class MushroomError extends Error {
456
+ constructor(public readonly detail: string) {
457
+ super(detail);
458
+ this.name = "MushroomError";
459
+ }
460
+ }
package/src/ws.ts ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * WebSocket subscription over `GET /subscribe`.
3
+ *
4
+ * # Protocol (matches crates/server/src/subscribe.rs)
5
+ *
6
+ * 1. Connect to `ws[s]://<host>/subscribe`.
7
+ * 2. Server waits for one JSON subscribe message: `{rules?, writes?}`.
8
+ * 3. Server responds with `{"subscribed":true}`.
9
+ * 4. Server streams DbEvent JSON frames until the connection closes.
10
+ *
11
+ * # Reconnection
12
+ *
13
+ * Auto-reconnect is NOT implemented in v1. When the connection drops
14
+ * (network error, server restart), no further events are delivered. The
15
+ * caller is responsible for reconnecting if required.
16
+ *
17
+ * # Lagged events
18
+ *
19
+ * If the server's per-subscriber queue overflows, it emits a
20
+ * `{"type":"lagged","missed":N}` frame. This is passed to `onEvent` like any
21
+ * other event. For lossless consumers: on receiving a `lagged` event, re-read
22
+ * the affected graph state via a query.
23
+ *
24
+ * # Node.js usage
25
+ *
26
+ * The browser WebSocket global is not present in Node < 21. Pass the `ws`
27
+ * package's WebSocket class via `opts.wsConstructor`:
28
+ *
29
+ * ```ts
30
+ * import WS from 'ws';
31
+ * const handle = await subscribe(wsUrl, { writes: true, wsConstructor: WS as WsConstructor }, onEvent);
32
+ * ```
33
+ */
34
+
35
+ import { MushroomError } from "./types.js";
36
+ import type { DbEvent, SubscribeMessage } from "./types.js";
37
+
38
+ export type { DbEvent };
39
+
40
+ /**
41
+ * Minimal WebSocket-like interface required by subscribe().
42
+ * Satisfied by both browser WebSocket and the `ws` npm package.
43
+ */
44
+ export interface WsLike {
45
+ send(data: string): void;
46
+ close(): void;
47
+ set onopen(handler: ((ev: unknown) => void) | null);
48
+ set onmessage(handler: ((ev: { data: unknown }) => void) | null);
49
+ set onclose(handler: ((ev: unknown) => void) | null);
50
+ set onerror(handler: ((ev: unknown) => void) | null);
51
+ }
52
+
53
+ /** Constructor type for both browser WebSocket and the `ws` package. */
54
+ export type WsConstructor = new (url: string) => WsLike;
55
+
56
+ /** Options for {@link subscribe}. */
57
+ export interface SubscribeOptions extends SubscribeMessage {
58
+ /**
59
+ * Custom WebSocket constructor.
60
+ *
61
+ * **Node.js only** — required when `globalThis.WebSocket` is not available
62
+ * (Node < 21). Example:
63
+ *
64
+ * ```ts
65
+ * import WS from 'ws';
66
+ * { wsConstructor: WS as WsConstructor }
67
+ * ```
68
+ *
69
+ * In the browser the native `WebSocket` global is used automatically.
70
+ */
71
+ wsConstructor?: WsConstructor;
72
+ }
73
+
74
+ /** Handle returned by {@link subscribe}. */
75
+ export interface SubscribeHandle {
76
+ /**
77
+ * Close the WebSocket connection.
78
+ *
79
+ * Returns a promise that resolves when the connection is fully closed.
80
+ * Always await this before ending a test or shutting down to avoid
81
+ * dangling handles that keep the event loop alive.
82
+ */
83
+ close(): Promise<void>;
84
+ }
85
+
86
+ /** Coerce an unknown message `data` value to a UTF-8 string. */
87
+ function dataToString(data: unknown): string {
88
+ if (typeof data === "string") return data;
89
+ // Node.js ws package delivers Buffer objects for text frames.
90
+ if (data != null && typeof (data as { toString?: unknown }).toString === "function") {
91
+ return (data as { toString(): string }).toString();
92
+ }
93
+ return String(data);
94
+ }
95
+
96
+ /** Resolve the WebSocket constructor: explicit option → global. */
97
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
98
+ function resolveWsConstructor(opt?: WsConstructor): WsConstructor {
99
+ if (opt) return opt;
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
+ const g = globalThis as any;
102
+ if (typeof g["WebSocket"] === "function") return g["WebSocket"] as WsConstructor;
103
+ throw new Error(
104
+ "No WebSocket implementation available. " +
105
+ "In Node.js < 21, install the `ws` package and pass " +
106
+ "`wsConstructor: WS as WsConstructor` in the options.",
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Open a `GET /subscribe` WebSocket and begin streaming {@link DbEvent}s.
112
+ *
113
+ * Resolves when the server acknowledges the subscribe message
114
+ * (`{"subscribed":true}`). Rejects on connection failure or if the server
115
+ * returns an error (e.g. unknown rule name).
116
+ *
117
+ * @param wsUrl Full WebSocket URL, e.g. `ws://127.0.0.1:8080/subscribe`.
118
+ * @param opts Subscribe options — rules, writes flag, optional wsConstructor.
119
+ * @param onEvent Callback invoked for each {@link DbEvent}, including `lagged`.
120
+ */
121
+ export async function subscribe(
122
+ wsUrl: string,
123
+ opts: SubscribeOptions,
124
+ onEvent: (event: DbEvent) => void,
125
+ ): Promise<SubscribeHandle> {
126
+ const WS = resolveWsConstructor(opts.wsConstructor);
127
+ const ws = new WS(wsUrl);
128
+
129
+ return new Promise<SubscribeHandle>((resolve, reject) => {
130
+ let subscribed = false;
131
+ let closeResolve: (() => void) | null = null;
132
+
133
+ const closePromise = new Promise<void>((res) => {
134
+ closeResolve = res;
135
+ });
136
+
137
+ ws.onopen = () => {
138
+ const msg: SubscribeMessage = {
139
+ rules: opts.rules ?? [],
140
+ writes: opts.writes ?? false,
141
+ };
142
+ ws.send(JSON.stringify(msg));
143
+ };
144
+
145
+ ws.onmessage = (ev: { data: unknown }) => {
146
+ let text: string;
147
+ try {
148
+ text = dataToString(ev.data);
149
+ } catch {
150
+ return; // unreadable frame — skip
151
+ }
152
+
153
+ let parsed: unknown;
154
+ try {
155
+ parsed = JSON.parse(text);
156
+ } catch {
157
+ return; // unparseable frame — skip
158
+ }
159
+
160
+ const frame = parsed as Record<string, unknown>;
161
+
162
+ if (!subscribed) {
163
+ if (frame["subscribed"] === true) {
164
+ subscribed = true;
165
+ resolve({
166
+ close(): Promise<void> {
167
+ ws.close();
168
+ return closePromise;
169
+ },
170
+ });
171
+ } else if (typeof frame["error"] === "string") {
172
+ reject(new MushroomError(frame["error"] as string));
173
+ ws.close();
174
+ } else {
175
+ reject(new Error("Unexpected subscribe response: " + text));
176
+ ws.close();
177
+ }
178
+ } else {
179
+ onEvent(frame as unknown as DbEvent);
180
+ }
181
+ };
182
+
183
+ ws.onerror = (err: unknown) => {
184
+ if (!subscribed) {
185
+ reject(
186
+ err instanceof Error
187
+ ? err
188
+ : new Error("WebSocket error before subscribe ack"),
189
+ );
190
+ }
191
+ };
192
+
193
+ ws.onclose = () => {
194
+ if (!subscribed) {
195
+ reject(new Error("WebSocket closed before subscribe ack"));
196
+ }
197
+ closeResolve?.();
198
+ };
199
+ });
200
+ }