@ekodb/ekodb-client 0.23.1 → 0.25.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/dist/client.d.ts CHANGED
@@ -94,6 +94,55 @@ export interface UpsertOptions {
94
94
  transactionId?: string;
95
95
  bypassCache?: boolean;
96
96
  }
97
+ /**
98
+ * A health status value. Like the Go client's `HealthState` (a string type), an
99
+ * off-contract status reported by the server is preserved verbatim.
100
+ */
101
+ export type HealthState = string;
102
+ /** Canonical {@link HealthStatus.status} values. */
103
+ export declare const HealthOK = "ok";
104
+ export declare const HealthDegraded = "degraded";
105
+ export declare const HealthUnknown = "unknown";
106
+ /**
107
+ * A snapshot of an ekoDB /api/health probe.
108
+ *
109
+ * It is degraded-tolerant: a reachable server that reports `degraded` is a
110
+ * successful snapshot (`reachable: true`, `status: "degraded"`), NOT an error.
111
+ * An unreachable/unparseable probe yields `{ reachable: false, status: "unknown" }`.
112
+ *
113
+ * Consumers base liveness on `reachable` and treat `degraded` as a warning,
114
+ * never as a fatal. `detail` (the full admin body, which includes internal
115
+ * metrics and collection names) is excluded from JSON via `toJSON()` so
116
+ * surfacing the snapshot cannot leak internals; read it in-process when needed.
117
+ */
118
+ export declare class HealthStatus {
119
+ reachable: boolean;
120
+ status: HealthState;
121
+ integrityOk: boolean;
122
+ detail?: {
123
+ [key: string]: any;
124
+ };
125
+ constructor(init: {
126
+ reachable: boolean;
127
+ status: HealthState;
128
+ integrityOk: boolean;
129
+ detail?: {
130
+ [key: string]: any;
131
+ };
132
+ });
133
+ toJSON(): {
134
+ reachable: boolean;
135
+ status: HealthState;
136
+ integrity_ok: boolean;
137
+ };
138
+ }
139
+ /**
140
+ * Interprets a parsed /api/health body per the shared health contract. A
141
+ * missing/odd `status` on a reachable body fails safe to `degraded`; a
142
+ * non-object body yields an unreachable `unknown` snapshot. `integrity_ok` is
143
+ * read from the top-level field (public) or nested `integrity.healthy` (admin).
144
+ */
145
+ export declare function parseHealthStatus(body: any): HealthStatus;
97
146
  export interface FindOptions {
98
147
  filter?: any;
99
148
  sort?: any;
@@ -861,9 +910,21 @@ export declare class EkoDBClient {
861
910
  */
862
911
  distinctValues(collection: string, field: string, options?: DistinctValuesOptions): Promise<DistinctValuesResponse>;
863
912
  /**
864
- * Health check - verify the ekoDB server is responding
913
+ * Health check - reports whether the ekoDB server is reachable.
914
+ *
915
+ * Returns `true` whenever the server responds (INCLUDING when it reports
916
+ * `degraded`) and `false` only when it is unreachable. The ekoDB server
917
+ * returns HTTP 200 while degraded on purpose, so gating on `status === "ok"`
918
+ * would treat a degraded-but-serving server as down. Use {@link healthStatus}
919
+ * for the ok/degraded distinction.
865
920
  */
866
921
  health(): Promise<boolean>;
922
+ /**
923
+ * Structured, degraded-tolerant health. Returns a {@link HealthStatus}
924
+ * snapshot; an unreachable server yields `{ reachable: false, status:
925
+ * "unknown" }` rather than throwing.
926
+ */
927
+ healthStatus(): Promise<HealthStatus>;
867
928
  /**
868
929
  * Execute a tool via ekoDB's server-side tool pipeline.
869
930
  *
package/dist/client.js CHANGED
@@ -36,7 +36,8 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  };
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.WebSocketClient = exports.SchemaCache = exports.EventStream = exports.EkoDBClient = exports.MergeStrategy = exports.RateLimitError = exports.DEFAULT_REQUEST_TIMEOUT_MS = exports.SerializationFormat = void 0;
39
+ exports.WebSocketClient = exports.SchemaCache = exports.EventStream = exports.EkoDBClient = exports.MergeStrategy = exports.HealthStatus = exports.HealthUnknown = exports.HealthDegraded = exports.HealthOK = exports.RateLimitError = exports.DEFAULT_REQUEST_TIMEOUT_MS = exports.SerializationFormat = void 0;
40
+ exports.parseHealthStatus = parseHealthStatus;
40
41
  exports.extractRecordId = extractRecordId;
41
42
  const msgpack_1 = require("@msgpack/msgpack");
42
43
  const query_builder_1 = require("./query-builder");
@@ -71,6 +72,74 @@ class RateLimitError extends Error {
71
72
  }
72
73
  }
73
74
  exports.RateLimitError = RateLimitError;
75
+ /** Canonical {@link HealthStatus.status} values. */
76
+ exports.HealthOK = "ok";
77
+ exports.HealthDegraded = "degraded";
78
+ exports.HealthUnknown = "unknown";
79
+ /**
80
+ * A snapshot of an ekoDB /api/health probe.
81
+ *
82
+ * It is degraded-tolerant: a reachable server that reports `degraded` is a
83
+ * successful snapshot (`reachable: true`, `status: "degraded"`), NOT an error.
84
+ * An unreachable/unparseable probe yields `{ reachable: false, status: "unknown" }`.
85
+ *
86
+ * Consumers base liveness on `reachable` and treat `degraded` as a warning,
87
+ * never as a fatal. `detail` (the full admin body, which includes internal
88
+ * metrics and collection names) is excluded from JSON via `toJSON()` so
89
+ * surfacing the snapshot cannot leak internals; read it in-process when needed.
90
+ */
91
+ class HealthStatus {
92
+ constructor(init) {
93
+ this.reachable = init.reachable;
94
+ this.status = init.status;
95
+ this.integrityOk = init.integrityOk;
96
+ this.detail = init.detail;
97
+ }
98
+ toJSON() {
99
+ return {
100
+ reachable: this.reachable,
101
+ status: this.status,
102
+ integrity_ok: this.integrityOk,
103
+ };
104
+ }
105
+ }
106
+ exports.HealthStatus = HealthStatus;
107
+ /**
108
+ * Interprets a parsed /api/health body per the shared health contract. A
109
+ * missing/odd `status` on a reachable body fails safe to `degraded`; a
110
+ * non-object body yields an unreachable `unknown` snapshot. `integrity_ok` is
111
+ * read from the top-level field (public) or nested `integrity.healthy` (admin).
112
+ */
113
+ function parseHealthStatus(body) {
114
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
115
+ return new HealthStatus({
116
+ reachable: false,
117
+ status: exports.HealthUnknown,
118
+ integrityOk: false,
119
+ });
120
+ }
121
+ // Default to degraded; a non-empty string status is kept verbatim (matches
122
+ // the Go client). A missing/non-string status fails safe to degraded.
123
+ let status = exports.HealthDegraded;
124
+ if (typeof body.status === "string" && body.status !== "") {
125
+ status = body.status;
126
+ }
127
+ let integrityOk = false;
128
+ if (typeof body.integrity_ok === "boolean") {
129
+ integrityOk = body.integrity_ok;
130
+ }
131
+ else if (body.integrity &&
132
+ typeof body.integrity === "object" &&
133
+ typeof body.integrity.healthy === "boolean") {
134
+ integrityOk = body.integrity.healthy;
135
+ }
136
+ return new HealthStatus({
137
+ reachable: true,
138
+ status,
139
+ integrityOk,
140
+ detail: body,
141
+ });
142
+ }
74
143
  var MergeStrategy;
75
144
  (function (MergeStrategy) {
76
145
  MergeStrategy["Chronological"] = "Chronological";
@@ -1121,15 +1190,33 @@ class EkoDBClient {
1121
1190
  return this.makeRequest("POST", `/api/distinct/${encodeURIComponent(collection)}/${encodeURIComponent(field)}`, body, 0, true);
1122
1191
  }
1123
1192
  /**
1124
- * Health check - verify the ekoDB server is responding
1193
+ * Health check - reports whether the ekoDB server is reachable.
1194
+ *
1195
+ * Returns `true` whenever the server responds (INCLUDING when it reports
1196
+ * `degraded`) and `false` only when it is unreachable. The ekoDB server
1197
+ * returns HTTP 200 while degraded on purpose, so gating on `status === "ok"`
1198
+ * would treat a degraded-but-serving server as down. Use {@link healthStatus}
1199
+ * for the ok/degraded distinction.
1125
1200
  */
1126
1201
  async health() {
1202
+ return (await this.healthStatus()).reachable;
1203
+ }
1204
+ /**
1205
+ * Structured, degraded-tolerant health. Returns a {@link HealthStatus}
1206
+ * snapshot; an unreachable server yields `{ reachable: false, status:
1207
+ * "unknown" }` rather than throwing.
1208
+ */
1209
+ async healthStatus() {
1127
1210
  try {
1128
- const result = await this.makeRequest("GET", "/api/health", undefined, 0, true);
1129
- return result.status === "ok";
1211
+ const body = await this.makeRequest("GET", "/api/health", undefined, 0, true);
1212
+ return parseHealthStatus(body);
1130
1213
  }
1131
1214
  catch {
1132
- return false;
1215
+ return new HealthStatus({
1216
+ reachable: false,
1217
+ status: exports.HealthUnknown,
1218
+ integrityOk: false,
1219
+ });
1133
1220
  }
1134
1221
  }
1135
1222
  // ========== Chat Methods ==========
@@ -2671,3 +2671,90 @@ function mockErrorResponse(status, message) {
2671
2671
  await (0, vitest_1.expect)(client.find("users")).rejects.toThrow(/timed out after 20ms/);
2672
2672
  });
2673
2673
  });
2674
+ // ============================================================================
2675
+ // Health Contract Tests
2676
+ // ============================================================================
2677
+ (0, vitest_1.describe)("health / healthStatus", () => {
2678
+ (0, vitest_1.it)("health() returns true for a reachable degraded server (bug fix)", async () => {
2679
+ const client = createTestClient();
2680
+ mockTokenResponse();
2681
+ mockJsonResponse({ status: "degraded", integrity_ok: false });
2682
+ (0, vitest_1.expect)(await client.health()).toBe(true);
2683
+ });
2684
+ (0, vitest_1.it)("health() returns false when unreachable", async () => {
2685
+ const client = createTestClient();
2686
+ mockTokenResponse();
2687
+ mockErrorResponse(503, "unavailable");
2688
+ (0, vitest_1.expect)(await client.health()).toBe(false);
2689
+ });
2690
+ (0, vitest_1.it)("healthStatus() surfaces degraded without erroring", async () => {
2691
+ const client = createTestClient();
2692
+ mockTokenResponse();
2693
+ mockJsonResponse({ status: "degraded", integrity_ok: false });
2694
+ const hs = await client.healthStatus();
2695
+ (0, vitest_1.expect)(hs.reachable).toBe(true);
2696
+ (0, vitest_1.expect)(hs.status).toBe("degraded");
2697
+ (0, vitest_1.expect)(hs.integrityOk).toBe(false);
2698
+ });
2699
+ (0, vitest_1.it)("healthStatus() unreachable -> reachable=false, status=unknown", async () => {
2700
+ const client = createTestClient();
2701
+ mockTokenResponse();
2702
+ mockErrorResponse(503, "unavailable");
2703
+ const hs = await client.healthStatus();
2704
+ (0, vitest_1.expect)(hs.reachable).toBe(false);
2705
+ (0, vitest_1.expect)(hs.status).toBe("unknown");
2706
+ });
2707
+ (0, vitest_1.it)("healthStatus() reads integrity from the admin nested shape", async () => {
2708
+ const client = createTestClient();
2709
+ mockTokenResponse();
2710
+ mockJsonResponse({
2711
+ status: "ok",
2712
+ integrity: { healthy: true, manifest_load_failed: [] },
2713
+ });
2714
+ const hs = await client.healthStatus();
2715
+ (0, vitest_1.expect)(hs.status).toBe("ok");
2716
+ (0, vitest_1.expect)(hs.integrityOk).toBe(true);
2717
+ });
2718
+ (0, vitest_1.it)("parseHealthStatus fails safe to degraded on a missing status", () => {
2719
+ const hs = (0, client_1.parseHealthStatus)({ integrity_ok: true });
2720
+ (0, vitest_1.expect)(hs.reachable).toBe(true);
2721
+ (0, vitest_1.expect)(hs.status).toBe("degraded");
2722
+ });
2723
+ (0, vitest_1.it)("parseHealthStatus yields an unknown snapshot for a non-object body", () => {
2724
+ const hs = (0, client_1.parseHealthStatus)("not json");
2725
+ (0, vitest_1.expect)(hs.reachable).toBe(false);
2726
+ (0, vitest_1.expect)(hs.status).toBe("unknown");
2727
+ });
2728
+ (0, vitest_1.it)("JSON serialization is a safe summary that excludes detail", () => {
2729
+ const hs = (0, client_1.parseHealthStatus)({
2730
+ status: "degraded",
2731
+ integrity: {
2732
+ healthy: false,
2733
+ manifest_load_failed: ["secret_collection"],
2734
+ },
2735
+ });
2736
+ const json = JSON.stringify(hs);
2737
+ (0, vitest_1.expect)(json).not.toContain("detail");
2738
+ (0, vitest_1.expect)(json).not.toContain("secret_collection");
2739
+ const parsed = JSON.parse(json);
2740
+ (0, vitest_1.expect)(parsed).toEqual({
2741
+ reachable: true,
2742
+ status: "degraded",
2743
+ integrity_ok: false,
2744
+ });
2745
+ // detail is still readable in-process
2746
+ (0, vitest_1.expect)(hs.detail).toBeDefined();
2747
+ });
2748
+ (0, vitest_1.it)("parseHealthStatus preserves a raw off-contract status (matches Go)", () => {
2749
+ (0, vitest_1.expect)((0, client_1.parseHealthStatus)({ status: "healthy" }).status).toBe("healthy");
2750
+ (0, vitest_1.expect)((0, client_1.parseHealthStatus)({ status: "ok" }).status).toBe("ok");
2751
+ (0, vitest_1.expect)((0, client_1.parseHealthStatus)({ status: "degraded" }).status).toBe("degraded");
2752
+ });
2753
+ (0, vitest_1.it)("parseHealthStatus fails safe to degraded on a non-string status", () => {
2754
+ (0, vitest_1.expect)((0, client_1.parseHealthStatus)({ status: { nested: 1 } }).status).toBe("degraded");
2755
+ });
2756
+ (0, vitest_1.it)("parseHealthStatus treats a non-object (array) body as unknown", () => {
2757
+ (0, vitest_1.expect)((0, client_1.parseHealthStatus)([1, 2, 3]).reachable).toBe(false);
2758
+ (0, vitest_1.expect)((0, client_1.parseHealthStatus)([1, 2, 3]).status).toBe("unknown");
2759
+ });
2760
+ });
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { EkoDBClient, WebSocketClient, EventStream, SerializationFormat, MergeStrategy, RateLimitError, SchemaCache, extractRecordId, } from "./client";
1
+ export { EkoDBClient, WebSocketClient, EventStream, SerializationFormat, MergeStrategy, RateLimitError, SchemaCache, extractRecordId, HealthStatus, parseHealthStatus, HealthOK, HealthDegraded, HealthUnknown, } from "./client";
2
+ export type { HealthState } from "./client";
2
3
  export { QueryBuilder, SortOrder } from "./query-builder";
3
4
  export { SearchQueryBuilder } from "./search";
4
5
  export { SchemaBuilder, FieldTypeSchemaBuilder, VectorIndexAlgorithm, DistanceMetric, } from "./schema";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Field = exports.getObjectValue = exports.getVectorValue = exports.getSetValue = exports.getArrayValue = exports.getBinaryValue = exports.getBytesValue = exports.getDurationValue = exports.getDecimalValue = exports.getUUIDValue = exports.getDateTimeValue = exports.extractRecord = exports.getValues = exports.getValue = exports.parameterRef = exports.ChatMessage = exports.Stage = exports.JoinBuilder = exports.DistanceMetric = exports.VectorIndexAlgorithm = exports.FieldTypeSchemaBuilder = exports.SchemaBuilder = exports.SearchQueryBuilder = exports.SortOrder = exports.QueryBuilder = exports.extractRecordId = exports.SchemaCache = exports.RateLimitError = exports.MergeStrategy = exports.SerializationFormat = exports.EventStream = exports.WebSocketClient = exports.EkoDBClient = void 0;
3
+ exports.Field = exports.getObjectValue = exports.getVectorValue = exports.getSetValue = exports.getArrayValue = exports.getBinaryValue = exports.getBytesValue = exports.getDurationValue = exports.getDecimalValue = exports.getUUIDValue = exports.getDateTimeValue = exports.extractRecord = exports.getValues = exports.getValue = exports.parameterRef = exports.ChatMessage = exports.Stage = exports.JoinBuilder = exports.DistanceMetric = exports.VectorIndexAlgorithm = exports.FieldTypeSchemaBuilder = exports.SchemaBuilder = exports.SearchQueryBuilder = exports.SortOrder = exports.QueryBuilder = exports.HealthUnknown = exports.HealthDegraded = exports.HealthOK = exports.parseHealthStatus = exports.HealthStatus = exports.extractRecordId = exports.SchemaCache = exports.RateLimitError = exports.MergeStrategy = exports.SerializationFormat = exports.EventStream = exports.WebSocketClient = exports.EkoDBClient = void 0;
4
4
  var client_1 = require("./client");
5
5
  Object.defineProperty(exports, "EkoDBClient", { enumerable: true, get: function () { return client_1.EkoDBClient; } });
6
6
  Object.defineProperty(exports, "WebSocketClient", { enumerable: true, get: function () { return client_1.WebSocketClient; } });
@@ -10,6 +10,11 @@ Object.defineProperty(exports, "MergeStrategy", { enumerable: true, get: functio
10
10
  Object.defineProperty(exports, "RateLimitError", { enumerable: true, get: function () { return client_1.RateLimitError; } });
11
11
  Object.defineProperty(exports, "SchemaCache", { enumerable: true, get: function () { return client_1.SchemaCache; } });
12
12
  Object.defineProperty(exports, "extractRecordId", { enumerable: true, get: function () { return client_1.extractRecordId; } });
13
+ Object.defineProperty(exports, "HealthStatus", { enumerable: true, get: function () { return client_1.HealthStatus; } });
14
+ Object.defineProperty(exports, "parseHealthStatus", { enumerable: true, get: function () { return client_1.parseHealthStatus; } });
15
+ Object.defineProperty(exports, "HealthOK", { enumerable: true, get: function () { return client_1.HealthOK; } });
16
+ Object.defineProperty(exports, "HealthDegraded", { enumerable: true, get: function () { return client_1.HealthDegraded; } });
17
+ Object.defineProperty(exports, "HealthUnknown", { enumerable: true, get: function () { return client_1.HealthUnknown; } });
13
18
  var query_builder_1 = require("./query-builder");
14
19
  Object.defineProperty(exports, "QueryBuilder", { enumerable: true, get: function () { return query_builder_1.QueryBuilder; } });
15
20
  Object.defineProperty(exports, "SortOrder", { enumerable: true, get: function () { return query_builder_1.SortOrder; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ekodb/ekodb-client",
3
- "version": "0.23.1",
3
+ "version": "0.25.0",
4
4
  "description": "Official TypeScript/JavaScript client for ekoDB",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -20,7 +20,7 @@
20
20
  "author": "ekoDB",
21
21
  "license": "MIT",
22
22
  "devDependencies": {
23
- "@types/node": "^25.3.5",
23
+ "@types/node": "^24.13.2",
24
24
  "@types/ws": "^8.18.1",
25
25
  "typescript": "^5.9.3",
26
26
  "vitest": "^4.0.18"
@@ -11,6 +11,7 @@ import {
11
11
  SerializationFormat,
12
12
  extractRecordId,
13
13
  DEFAULT_REQUEST_TIMEOUT_MS,
14
+ parseHealthStatus,
14
15
  } from "./client";
15
16
  import { SearchQueryBuilder } from "./search";
16
17
 
@@ -3518,3 +3519,104 @@ describe("request timeout", () => {
3518
3519
  await expect(client.find("users")).rejects.toThrow(/timed out after 20ms/);
3519
3520
  });
3520
3521
  });
3522
+
3523
+ // ============================================================================
3524
+ // Health Contract Tests
3525
+ // ============================================================================
3526
+
3527
+ describe("health / healthStatus", () => {
3528
+ it("health() returns true for a reachable degraded server (bug fix)", async () => {
3529
+ const client = createTestClient();
3530
+ mockTokenResponse();
3531
+ mockJsonResponse({ status: "degraded", integrity_ok: false });
3532
+ expect(await client.health()).toBe(true);
3533
+ });
3534
+
3535
+ it("health() returns false when unreachable", async () => {
3536
+ const client = createTestClient();
3537
+ mockTokenResponse();
3538
+ mockErrorResponse(503, "unavailable");
3539
+ expect(await client.health()).toBe(false);
3540
+ });
3541
+
3542
+ it("healthStatus() surfaces degraded without erroring", async () => {
3543
+ const client = createTestClient();
3544
+ mockTokenResponse();
3545
+ mockJsonResponse({ status: "degraded", integrity_ok: false });
3546
+ const hs = await client.healthStatus();
3547
+ expect(hs.reachable).toBe(true);
3548
+ expect(hs.status).toBe("degraded");
3549
+ expect(hs.integrityOk).toBe(false);
3550
+ });
3551
+
3552
+ it("healthStatus() unreachable -> reachable=false, status=unknown", async () => {
3553
+ const client = createTestClient();
3554
+ mockTokenResponse();
3555
+ mockErrorResponse(503, "unavailable");
3556
+ const hs = await client.healthStatus();
3557
+ expect(hs.reachable).toBe(false);
3558
+ expect(hs.status).toBe("unknown");
3559
+ });
3560
+
3561
+ it("healthStatus() reads integrity from the admin nested shape", async () => {
3562
+ const client = createTestClient();
3563
+ mockTokenResponse();
3564
+ mockJsonResponse({
3565
+ status: "ok",
3566
+ integrity: { healthy: true, manifest_load_failed: [] },
3567
+ });
3568
+ const hs = await client.healthStatus();
3569
+ expect(hs.status).toBe("ok");
3570
+ expect(hs.integrityOk).toBe(true);
3571
+ });
3572
+
3573
+ it("parseHealthStatus fails safe to degraded on a missing status", () => {
3574
+ const hs = parseHealthStatus({ integrity_ok: true });
3575
+ expect(hs.reachable).toBe(true);
3576
+ expect(hs.status).toBe("degraded");
3577
+ });
3578
+
3579
+ it("parseHealthStatus yields an unknown snapshot for a non-object body", () => {
3580
+ const hs = parseHealthStatus("not json");
3581
+ expect(hs.reachable).toBe(false);
3582
+ expect(hs.status).toBe("unknown");
3583
+ });
3584
+
3585
+ it("JSON serialization is a safe summary that excludes detail", () => {
3586
+ const hs = parseHealthStatus({
3587
+ status: "degraded",
3588
+ integrity: {
3589
+ healthy: false,
3590
+ manifest_load_failed: ["secret_collection"],
3591
+ },
3592
+ });
3593
+ const json = JSON.stringify(hs);
3594
+ expect(json).not.toContain("detail");
3595
+ expect(json).not.toContain("secret_collection");
3596
+ const parsed = JSON.parse(json);
3597
+ expect(parsed).toEqual({
3598
+ reachable: true,
3599
+ status: "degraded",
3600
+ integrity_ok: false,
3601
+ });
3602
+ // detail is still readable in-process
3603
+ expect(hs.detail).toBeDefined();
3604
+ });
3605
+
3606
+ it("parseHealthStatus preserves a raw off-contract status (matches Go)", () => {
3607
+ expect(parseHealthStatus({ status: "healthy" }).status).toBe("healthy");
3608
+ expect(parseHealthStatus({ status: "ok" }).status).toBe("ok");
3609
+ expect(parseHealthStatus({ status: "degraded" }).status).toBe("degraded");
3610
+ });
3611
+
3612
+ it("parseHealthStatus fails safe to degraded on a non-string status", () => {
3613
+ expect(parseHealthStatus({ status: { nested: 1 } }).status).toBe(
3614
+ "degraded",
3615
+ );
3616
+ });
3617
+
3618
+ it("parseHealthStatus treats a non-object (array) body as unknown", () => {
3619
+ expect(parseHealthStatus([1, 2, 3]).reachable).toBe(false);
3620
+ expect(parseHealthStatus([1, 2, 3]).status).toBe("unknown");
3621
+ });
3622
+ });
package/src/client.ts CHANGED
@@ -119,6 +119,94 @@ export interface UpsertOptions {
119
119
  bypassCache?: boolean;
120
120
  }
121
121
 
122
+ /**
123
+ * A health status value. Like the Go client's `HealthState` (a string type), an
124
+ * off-contract status reported by the server is preserved verbatim.
125
+ */
126
+ export type HealthState = string;
127
+
128
+ /** Canonical {@link HealthStatus.status} values. */
129
+ export const HealthOK = "ok";
130
+ export const HealthDegraded = "degraded";
131
+ export const HealthUnknown = "unknown";
132
+
133
+ /**
134
+ * A snapshot of an ekoDB /api/health probe.
135
+ *
136
+ * It is degraded-tolerant: a reachable server that reports `degraded` is a
137
+ * successful snapshot (`reachable: true`, `status: "degraded"`), NOT an error.
138
+ * An unreachable/unparseable probe yields `{ reachable: false, status: "unknown" }`.
139
+ *
140
+ * Consumers base liveness on `reachable` and treat `degraded` as a warning,
141
+ * never as a fatal. `detail` (the full admin body, which includes internal
142
+ * metrics and collection names) is excluded from JSON via `toJSON()` so
143
+ * surfacing the snapshot cannot leak internals; read it in-process when needed.
144
+ */
145
+ export class HealthStatus {
146
+ reachable: boolean;
147
+ status: HealthState;
148
+ integrityOk: boolean;
149
+ detail?: { [key: string]: any };
150
+
151
+ constructor(init: {
152
+ reachable: boolean;
153
+ status: HealthState;
154
+ integrityOk: boolean;
155
+ detail?: { [key: string]: any };
156
+ }) {
157
+ this.reachable = init.reachable;
158
+ this.status = init.status;
159
+ this.integrityOk = init.integrityOk;
160
+ this.detail = init.detail;
161
+ }
162
+
163
+ toJSON(): { reachable: boolean; status: HealthState; integrity_ok: boolean } {
164
+ return {
165
+ reachable: this.reachable,
166
+ status: this.status,
167
+ integrity_ok: this.integrityOk,
168
+ };
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Interprets a parsed /api/health body per the shared health contract. A
174
+ * missing/odd `status` on a reachable body fails safe to `degraded`; a
175
+ * non-object body yields an unreachable `unknown` snapshot. `integrity_ok` is
176
+ * read from the top-level field (public) or nested `integrity.healthy` (admin).
177
+ */
178
+ export function parseHealthStatus(body: any): HealthStatus {
179
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
180
+ return new HealthStatus({
181
+ reachable: false,
182
+ status: HealthUnknown,
183
+ integrityOk: false,
184
+ });
185
+ }
186
+ // Default to degraded; a non-empty string status is kept verbatim (matches
187
+ // the Go client). A missing/non-string status fails safe to degraded.
188
+ let status: HealthState = HealthDegraded;
189
+ if (typeof body.status === "string" && body.status !== "") {
190
+ status = body.status;
191
+ }
192
+ let integrityOk = false;
193
+ if (typeof body.integrity_ok === "boolean") {
194
+ integrityOk = body.integrity_ok;
195
+ } else if (
196
+ body.integrity &&
197
+ typeof body.integrity === "object" &&
198
+ typeof body.integrity.healthy === "boolean"
199
+ ) {
200
+ integrityOk = body.integrity.healthy;
201
+ }
202
+ return new HealthStatus({
203
+ reachable: true,
204
+ status,
205
+ integrityOk,
206
+ detail: body,
207
+ });
208
+ }
209
+
122
210
  export interface FindOptions {
123
211
  filter?: any;
124
212
  sort?: any;
@@ -1827,20 +1915,39 @@ export class EkoDBClient {
1827
1915
  }
1828
1916
 
1829
1917
  /**
1830
- * Health check - verify the ekoDB server is responding
1918
+ * Health check - reports whether the ekoDB server is reachable.
1919
+ *
1920
+ * Returns `true` whenever the server responds (INCLUDING when it reports
1921
+ * `degraded`) and `false` only when it is unreachable. The ekoDB server
1922
+ * returns HTTP 200 while degraded on purpose, so gating on `status === "ok"`
1923
+ * would treat a degraded-but-serving server as down. Use {@link healthStatus}
1924
+ * for the ok/degraded distinction.
1831
1925
  */
1832
1926
  async health(): Promise<boolean> {
1927
+ return (await this.healthStatus()).reachable;
1928
+ }
1929
+
1930
+ /**
1931
+ * Structured, degraded-tolerant health. Returns a {@link HealthStatus}
1932
+ * snapshot; an unreachable server yields `{ reachable: false, status:
1933
+ * "unknown" }` rather than throwing.
1934
+ */
1935
+ async healthStatus(): Promise<HealthStatus> {
1833
1936
  try {
1834
- const result = await this.makeRequest<{ status: string }>(
1937
+ const body = await this.makeRequest<any>(
1835
1938
  "GET",
1836
1939
  "/api/health",
1837
1940
  undefined,
1838
1941
  0,
1839
1942
  true,
1840
1943
  );
1841
- return result.status === "ok";
1944
+ return parseHealthStatus(body);
1842
1945
  } catch {
1843
- return false;
1946
+ return new HealthStatus({
1947
+ reachable: false,
1948
+ status: HealthUnknown,
1949
+ integrityOk: false,
1950
+ });
1844
1951
  }
1845
1952
  }
1846
1953
 
package/src/index.ts CHANGED
@@ -7,7 +7,13 @@ export {
7
7
  RateLimitError,
8
8
  SchemaCache,
9
9
  extractRecordId,
10
+ HealthStatus,
11
+ parseHealthStatus,
12
+ HealthOK,
13
+ HealthDegraded,
14
+ HealthUnknown,
10
15
  } from "./client";
16
+ export type { HealthState } from "./client";
11
17
  export { QueryBuilder, SortOrder } from "./query-builder";
12
18
  export { SearchQueryBuilder } from "./search";
13
19
  export {