mushroomdb-client 0.7.1 → 0.8.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.
package/src/types.ts CHANGED
@@ -428,6 +428,340 @@ export type DbEvent =
428
428
  missed: number;
429
429
  };
430
430
 
431
+ // ---------------------------------------------------------------------------
432
+ // Health
433
+ // ---------------------------------------------------------------------------
434
+
435
+ /**
436
+ * Response from `GET /health`.
437
+ *
438
+ * Unauthenticated callers receive only `{ ok: true }`. A full-access token
439
+ * also receives `nodes`, `edges` and `addr`.
440
+ */
441
+ export interface HealthReport {
442
+ ok: boolean;
443
+ nodes?: number;
444
+ edges?: number;
445
+ addr?: string;
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Metrics
450
+ // ---------------------------------------------------------------------------
451
+
452
+ /**
453
+ * One entry in the slow-query ring buffer from `GET /metrics`.
454
+ */
455
+ export interface SlowQueryEntry {
456
+ ms: number;
457
+ query: string;
458
+ at_commit: number;
459
+ }
460
+
461
+ /**
462
+ * Response from `GET /metrics`.
463
+ *
464
+ * Mirrors the JSON object emitted by `crates/server/src/http.rs::metrics`.
465
+ * `wal_size_bytes` and `rss_bytes` are null when the value is unavailable
466
+ * (non-Linux/macOS platforms or a filesystem error reading the WAL size).
467
+ */
468
+ export interface MetricsReport {
469
+ nodes_live: number;
470
+ nodes_tombstoned: number;
471
+ edges: number;
472
+ commit_seq: number;
473
+ wal_size_bytes: number | null;
474
+ rss_bytes: number | null;
475
+ uptime_s: number;
476
+ slow_queries: {
477
+ threshold_ms: number;
478
+ count: number;
479
+ last: SlowQueryEntry[];
480
+ };
481
+ }
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // Prop predicate (used by find_similar `where` and by role definitions)
485
+ // ---------------------------------------------------------------------------
486
+
487
+ /**
488
+ * A property filter accepted by `POST /find_similar`'s `where` field and by
489
+ * the server's role-definition API.
490
+ *
491
+ * Mirrors `core_api::roles::PropPredicate` (a flat struct serialised as a JSON
492
+ * object with `field` required and exactly one of `eq` or `in`).
493
+ *
494
+ * The server also accepts the graph's own tagged Value form for the values:
495
+ * `{"Str": "x"}`, `{"Int": 3}` and `{"Bool": true}` are equivalent to their
496
+ * plain scalar counterparts `"x"`, `3` and `true`.
497
+ */
498
+ export type PropPredicate =
499
+ /** Match nodes whose `field` equals `eq`. */
500
+ | { field: string; eq: string | number | boolean }
501
+ /** Match nodes whose `field` is one of the values in `in`. */
502
+ | { field: string; in: (string | number | boolean)[] };
503
+
504
+ // ---------------------------------------------------------------------------
505
+ // Find similar
506
+ // ---------------------------------------------------------------------------
507
+
508
+ /**
509
+ * Options for `POST /find_similar`.
510
+ *
511
+ * `field` and `vector` are required. All other fields are optional and apply
512
+ * server defaults when absent.
513
+ */
514
+ export interface FindSimilarOptions {
515
+ /** The vector property field to search. */
516
+ field: string;
517
+ /** Query vector — must be the same dimensionality as the stored vectors. */
518
+ vector: number[];
519
+ /** Maximum number of results (default: 10). */
520
+ k?: number;
521
+ /** Minimum cosine similarity threshold (default: 0.8). */
522
+ min?: number;
523
+ /** Restrict results to nodes of this label. */
524
+ label?: string;
525
+ /** Node-key allow-list — restrict to these nodes. */
526
+ mask?: string[];
527
+ /** Force exhaustive scan instead of ANN index. */
528
+ exact?: boolean;
529
+ /**
530
+ * Property predicate applied after the vector search: only nodes whose
531
+ * stored value for `field` matches are returned.
532
+ *
533
+ * The server accepts two forms — `eq` (single-value equality) and `in`
534
+ * (set membership). Plain JSON scalars are accepted (`"published"`, `3`,
535
+ * `true`) as well as the graph's own tagged form (`{"Str":"published"}`).
536
+ *
537
+ * @example `{ field: "status", eq: "published" }`
538
+ * @example `{ field: "status", in: ["draft", "review"] }`
539
+ */
540
+ where?: PropPredicate;
541
+ }
542
+
543
+ /**
544
+ * Response from `POST /find_similar`.
545
+ *
546
+ * `hits` is an array of `[node_key, similarity_score]` pairs sorted by score
547
+ * descending. Scores are cosine similarities in [min, 1.0].
548
+ */
549
+ export interface FindSimilarResult {
550
+ hits: [string, number][];
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // Node edges
555
+ // ---------------------------------------------------------------------------
556
+
557
+ /**
558
+ * A node-key endpoint in a masked edge.
559
+ *
560
+ * When `nodeEdges` is called with `mask` **and** `stub_hidden: true`, an
561
+ * endpoint that lies outside the mask is returned as a restricted stub
562
+ * object rather than a plain string. Without `stub_hidden`, out-of-mask edges
563
+ * are omitted entirely.
564
+ *
565
+ * ```ts
566
+ * if (typeof edge.dst_key === 'string') {
567
+ * // visible endpoint
568
+ * } else {
569
+ * // restricted stub — only the key is known
570
+ * console.log(edge.dst_key.key, edge.dst_key.restricted); // true
571
+ * }
572
+ * ```
573
+ */
574
+ export type NodeKeyOrStub = string | { key: string; restricted: true };
575
+
576
+ /**
577
+ * One edge entry from `GET /node/{key}/edges`.
578
+ *
579
+ * Mirrors `core_api::EdgeInfo` as serialised by
580
+ * `crates/server/src/json.rs::node_edges_json` (unmasked call) or
581
+ * `json.rs::masked_edges_json` (call with `mask`/`stub_hidden`).
582
+ *
583
+ * When the call is made **without** a mask, `src_key` and `dst_key` are
584
+ * always plain strings. When `stub_hidden: true` is in use, a masked-out
585
+ * endpoint is returned as `{ key: string; restricted: true }` — see
586
+ * {@link NodeKeyOrStub}.
587
+ */
588
+ export interface EdgeInfo {
589
+ edge_type: string;
590
+ src_key: NodeKeyOrStub;
591
+ dst_key: NodeKeyOrStub;
592
+ /** true when this edge was derived by a rule; false when it was asserted. */
593
+ derived: boolean;
594
+ }
595
+
596
+ /**
597
+ * Response from `GET /node/{key}/edges`.
598
+ */
599
+ export interface NodeEdgesResult {
600
+ edges: EdgeInfo[];
601
+ }
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // Node history
605
+ // ---------------------------------------------------------------------------
606
+
607
+ /**
608
+ * One change entry in a node's history.
609
+ *
610
+ * Mirrors `core_api::HistoryChange` via
611
+ * `crates/server/src/json.rs::history_change_json`.
612
+ * The `"type"` field is the discriminant.
613
+ */
614
+ export type HistoryChange =
615
+ | { type: "NodeInserted"; label: string }
616
+ | { type: "PropSet"; field: string; value: CellValue }
617
+ | { type: "PropRemoved"; field: string }
618
+ | { type: "EdgeAdded"; edge_type: string; other: string; outgoing: boolean }
619
+ | { type: "EdgeRemoved"; edge_type: string; other: string; outgoing: boolean }
620
+ | { type: "NodeDeleted" };
621
+
622
+ /**
623
+ * One commit entry in a node's history from `GET /node/{key}/history`.
624
+ *
625
+ * Mirrors `crates/server/src/json.rs::history_entry_json`.
626
+ */
627
+ export interface HistoryEntry {
628
+ commit: number;
629
+ change: HistoryChange;
630
+ }
631
+
632
+ /**
633
+ * Response from `GET /node/{key}/history`.
634
+ *
635
+ * `horizon` is the oldest commit history can reach; events before it were
636
+ * pruned and are not in `history`.
637
+ */
638
+ export interface NodeHistoryResult {
639
+ key: string;
640
+ history: HistoryEntry[];
641
+ /** Total WAL commits in the store. */
642
+ total_commits: number;
643
+ /** Oldest commit visible in history. Events before this were pruned. */
644
+ horizon: number;
645
+ }
646
+
647
+ // ---------------------------------------------------------------------------
648
+ // Edge history
649
+ // ---------------------------------------------------------------------------
650
+
651
+ /**
652
+ * One event in the lifecycle of an edge from `GET /history/edge?a=&b=`.
653
+ *
654
+ * Mirrors `core_api::EdgeHistoryEvent` as serialised by
655
+ * `crates/server/src/json.rs::edge_history_result_json`.
656
+ */
657
+ export interface EdgeHistoryEntry {
658
+ edge_type: string;
659
+ commit: number;
660
+ event: "Added" | "Retracted";
661
+ /** Rule that derived this edge, or null for an asserted edge. */
662
+ rule: string | null;
663
+ }
664
+
665
+ /**
666
+ * Response from `GET /history/edge?a=&b=`.
667
+ */
668
+ export interface EdgeHistoryResult {
669
+ a: string;
670
+ b: string;
671
+ events: EdgeHistoryEntry[];
672
+ /** Total WAL commits in the store. */
673
+ total_commits: number;
674
+ /** Oldest commit visible in history. Events before this were pruned. */
675
+ horizon: number;
676
+ }
677
+
678
+ // ---------------------------------------------------------------------------
679
+ // Was linked
680
+ // ---------------------------------------------------------------------------
681
+
682
+ /**
683
+ * Response from `GET /history/was_linked?a=&b=&edge_type=&at_commit=`.
684
+ *
685
+ * `linked` is true when any edge of `edge_type` existed between `a` and `b`
686
+ * (in either direction) at the given commit.
687
+ */
688
+ export interface WasLinkedResult {
689
+ a: string;
690
+ b: string;
691
+ edge_type: string;
692
+ at_commit: number;
693
+ linked: boolean;
694
+ }
695
+
696
+ // ---------------------------------------------------------------------------
697
+ // Create node result
698
+ // ---------------------------------------------------------------------------
699
+
700
+ /**
701
+ * Response from `POST /nodes`.
702
+ *
703
+ * `nodes` is 1 when the node was created. A duplicate key yields a 400 error
704
+ * instead of returning 0 — `POST /nodes` is an insert, not an upsert.
705
+ * `edges` is always 0 for this endpoint.
706
+ */
707
+ export interface CreateNodeResult {
708
+ ok: boolean;
709
+ nodes: number;
710
+ edges: number;
711
+ }
712
+
713
+ // ---------------------------------------------------------------------------
714
+ // Backup
715
+ // ---------------------------------------------------------------------------
716
+
717
+ /**
718
+ * Response from `POST /backup`.
719
+ *
720
+ * Mirrors `BackupReportJson` from `crates/server/src/http.rs`.
721
+ * When `verified` is false the server answers 500 — the backup was written
722
+ * but the post-copy verification failed; inspect `files` and `bytes`.
723
+ */
724
+ export interface BackupReport {
725
+ /** Names of the files copied into the backup directory. */
726
+ files: string[];
727
+ /** Total bytes written. */
728
+ bytes: number;
729
+ /** true when the copy was verified by re-reading the destination. */
730
+ verified: boolean;
731
+ }
732
+
733
+ // ---------------------------------------------------------------------------
734
+ // Mutation events (GET /watch WebSocket)
735
+ // ---------------------------------------------------------------------------
736
+
737
+ /**
738
+ * A mutation event delivered over the `/watch` WebSocket.
739
+ *
740
+ * Mirrors `core_api::MutationEvent` — serialised as externally-tagged
741
+ * snake_case JSON (`{"node_inserted":{"label":"A","key":"k"}}`).
742
+ *
743
+ * The `lagged` variant (`{"lagged": N}`) is not produced by serde but by the
744
+ * WebSocket handler; it means the broadcast channel overflowed by N events.
745
+ * Re-read any state your consumer depends on to recover consistency.
746
+ *
747
+ * **Note:** The `/watch` stream is the raw mutation log, distinct from the
748
+ * filtered post-commit subscription on `/subscribe`. Every write to the store
749
+ * appears here; `/subscribe` lets callers filter by rule or write type.
750
+ */
751
+ export type MutationEvent =
752
+ | { node_inserted: { label: string; key: string } }
753
+ | { prop_set: { key: string; field: string } }
754
+ | { prop_removed: { key: string; field: string } }
755
+ | { edge_inserted: { edge_type: string; src: string; dst: string } }
756
+ | { edge_deleted: { edge_type: string; src: string; dst: string } }
757
+ | { node_deleted: { key: string } }
758
+ | { rule_created: { name: string } }
759
+ | { rule_deleted: { name: string } }
760
+ | { rule_rebuilt: { name: string } }
761
+ | { batch_applied: { ops: number } }
762
+ | { ingested: { label: string; inserted: number } }
763
+ | { lagged: number };
764
+
431
765
  // ---------------------------------------------------------------------------
432
766
  // Subscribe message (sent by client → server)
433
767
  // ---------------------------------------------------------------------------
package/src/ws.ts CHANGED
@@ -33,9 +33,9 @@
33
33
  */
34
34
 
35
35
  import { MushroomError } from "./types.js";
36
- import type { DbEvent, SubscribeMessage } from "./types.js";
36
+ import type { DbEvent, MutationEvent, SubscribeMessage } from "./types.js";
37
37
 
38
- export type { DbEvent };
38
+ export type { DbEvent, MutationEvent };
39
39
 
40
40
  /**
41
41
  * Minimal WebSocket-like interface required by subscribe().
@@ -107,6 +107,169 @@ function resolveWsConstructor(opt?: WsConstructor): WsConstructor {
107
107
  );
108
108
  }
109
109
 
110
+ /** Options for {@link watch}. */
111
+ export interface WatchOptions {
112
+ /**
113
+ * Custom WebSocket constructor.
114
+ *
115
+ * **Node.js only** — required when `globalThis.WebSocket` is not available
116
+ * (Node < 21). Example:
117
+ *
118
+ * ```ts
119
+ * import WS from 'ws';
120
+ * { wsConstructor: WS as WsConstructor }
121
+ * ```
122
+ */
123
+ wsConstructor?: WsConstructor;
124
+ /**
125
+ * Called when the WebSocket connection closes after the watch has been
126
+ * acknowledged — whether cleanly (after the caller calls `handle.close()`)
127
+ * or because the server dropped it. Use this as a signal to reconnect.
128
+ */
129
+ onClose?: () => void;
130
+ }
131
+
132
+ /** Handle returned by {@link watch}. Identical interface to {@link SubscribeHandle}. */
133
+ export interface WatchHandle {
134
+ /**
135
+ * Close the WebSocket connection.
136
+ *
137
+ * Returns a promise that resolves when the connection is fully closed.
138
+ * Always await this in tests to avoid dangling handles.
139
+ */
140
+ close(): Promise<void>;
141
+ }
142
+
143
+ /**
144
+ * Open a `GET /watch` WebSocket and begin streaming {@link MutationEvent}s.
145
+ *
146
+ * The `/watch` stream is the raw mutation log. The server sends
147
+ * `{"subscribed":true}` immediately after the upgrade (no client message
148
+ * required), then streams one JSON frame per mutation event.
149
+ *
150
+ * A `{"lagged": N}` frame means the broadcast channel overflowed; N events
151
+ * were dropped. Re-read any state your consumer depends on to recover.
152
+ *
153
+ * **No auto-reconnect in v1.** When the connection drops, no further events
154
+ * are delivered.
155
+ *
156
+ * @param wsUrl Full WebSocket URL, e.g. `ws://127.0.0.1:8080/watch`.
157
+ * @param opts Optional `wsConstructor` for Node.js < 21.
158
+ * @param onEvent Callback invoked for each {@link MutationEvent}.
159
+ */
160
+ export async function watch(
161
+ wsUrl: string,
162
+ opts: WatchOptions,
163
+ onEvent: (event: MutationEvent) => void,
164
+ ): Promise<WatchHandle> {
165
+ const WS = resolveWsConstructor(opts.wsConstructor);
166
+ const ws = new WS(wsUrl);
167
+
168
+ return new Promise<WatchHandle>((resolve, reject) => {
169
+ let subscribed = false;
170
+ let closeResolve: (() => void) | null = null;
171
+
172
+ const closePromise = new Promise<void>((res) => {
173
+ closeResolve = res;
174
+ });
175
+
176
+ /** Resolve closePromise once, fire onClose once, drop all listeners. */
177
+ function doClose(): void {
178
+ const res = closeResolve;
179
+ if (!res) return; // already called
180
+ closeResolve = null;
181
+ // Drop all handlers so nothing keeps the event loop alive.
182
+ ws.onopen = null;
183
+ ws.onmessage = null;
184
+ ws.onerror = null;
185
+ ws.onclose = null;
186
+ opts.onClose?.();
187
+ res();
188
+ }
189
+
190
+ ws.onopen = () => {
191
+ // /watch sends the ack without a client subscribe message; nothing to send.
192
+ };
193
+
194
+ ws.onmessage = (ev: { data: unknown }) => {
195
+ let text: string;
196
+ try {
197
+ text = dataToString(ev.data);
198
+ } catch {
199
+ return;
200
+ }
201
+
202
+ let parsed: unknown;
203
+ try {
204
+ parsed = JSON.parse(text);
205
+ } catch {
206
+ return;
207
+ }
208
+
209
+ const frame = parsed as Record<string, unknown>;
210
+
211
+ if (!subscribed) {
212
+ if (frame["subscribed"] === true) {
213
+ subscribed = true;
214
+ resolve({
215
+ close(): Promise<void> {
216
+ ws.close();
217
+ // A server older than 0.8 never reads a /watch socket, so it
218
+ // does not answer the close and onclose does not fire until
219
+ // the ws package's own 30 s timeout. Resolve at most 3 s after
220
+ // asking, so a caller talking to one is not left hanging.
221
+ // terminate() (ws-package-only) force-drops the TCP connection;
222
+ // the standard WebSocket has no equivalent, and its socket can
223
+ // outlive this until the runtime's own close timeout.
224
+ // doClose() fires onClose once and drops all listeners.
225
+ const fallback = setTimeout(() => {
226
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
227
+ (ws as any).terminate?.();
228
+ doClose();
229
+ }, 3_000);
230
+ return closePromise.finally(() => clearTimeout(fallback));
231
+ },
232
+ });
233
+ } else if (typeof frame["error"] === "string") {
234
+ reject(new MushroomError(frame["error"] as string));
235
+ ws.close();
236
+ } else {
237
+ reject(new Error("Unexpected watch response: " + text));
238
+ ws.close();
239
+ }
240
+ } else {
241
+ onEvent(frame as unknown as MutationEvent);
242
+ }
243
+ };
244
+
245
+ ws.onerror = (err: unknown) => {
246
+ if (!subscribed) {
247
+ let detail: string;
248
+ if (err instanceof Error && err.message) {
249
+ detail = err.message;
250
+ } else {
251
+ // Global WebSocket error events carry no useful status; include the
252
+ // URL path so the caller can identify the endpoint.
253
+ try {
254
+ detail = `${new URL(wsUrl).pathname} failed before watch ack`;
255
+ } catch {
256
+ detail = "WebSocket error before watch ack";
257
+ }
258
+ }
259
+ reject(new MushroomError(detail));
260
+ }
261
+ };
262
+
263
+ ws.onclose = () => {
264
+ if (!subscribed) {
265
+ reject(new MushroomError("WebSocket closed before watch ack"));
266
+ return;
267
+ }
268
+ doClose();
269
+ };
270
+ });
271
+ }
272
+
110
273
  /**
111
274
  * Open a `GET /subscribe` WebSocket and begin streaming {@link DbEvent}s.
112
275
  *