@havesomecode/kibana-mcp-server 1.1.2 → 1.2.1

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.
@@ -9,6 +9,24 @@ class KibanaHttpError extends Error {
9
9
  this.name = "KibanaHttpError";
10
10
  }
11
11
  }
12
+ export class KibanaRequestError extends Error {
13
+ code;
14
+ phase;
15
+ sourceId;
16
+ timeoutMs;
17
+ status;
18
+ causeCode;
19
+ constructor(code, phase, sourceId, timeoutMs, message, cause, status, causeCode) {
20
+ super(message, { cause });
21
+ this.code = code;
22
+ this.phase = phase;
23
+ this.sourceId = sourceId;
24
+ this.timeoutMs = timeoutMs;
25
+ this.status = status;
26
+ this.causeCode = causeCode;
27
+ this.name = "KibanaRequestError";
28
+ }
29
+ }
12
30
  function joinUrl(baseUrl, path) {
13
31
  return path.startsWith("http://") || path.startsWith("https://")
14
32
  ? path
@@ -17,6 +35,39 @@ function joinUrl(baseUrl, path) {
17
35
  function encodeBasicAuth(username, password) {
18
36
  return Buffer.from(`${username}:${password}`).toString("base64");
19
37
  }
38
+ function getNetworkCauseCode(error) {
39
+ if (!(error instanceof Error) || !error.cause || typeof error.cause !== "object") {
40
+ return undefined;
41
+ }
42
+ const code = error.cause.code;
43
+ return typeof code === "string" ? code : undefined;
44
+ }
45
+ function classifyNetworkError(causeCode) {
46
+ if (causeCode === "ENOTFOUND" || causeCode === "EAI_AGAIN") {
47
+ return "KIBANA_DNS";
48
+ }
49
+ if (causeCode?.startsWith("CERT_") ||
50
+ causeCode?.startsWith("ERR_TLS_") ||
51
+ [
52
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
53
+ "SELF_SIGNED_CERT_IN_CHAIN",
54
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
55
+ ].includes(causeCode ?? "")) {
56
+ return "KIBANA_TLS";
57
+ }
58
+ if ([
59
+ "ECONNREFUSED",
60
+ "ECONNRESET",
61
+ "EHOSTUNREACH",
62
+ "ENETUNREACH",
63
+ "ETIMEDOUT",
64
+ "UND_ERR_CONNECT_TIMEOUT",
65
+ "UND_ERR_SOCKET",
66
+ ].includes(causeCode ?? "")) {
67
+ return "KIBANA_CONNECTION";
68
+ }
69
+ return "KIBANA_NETWORK";
70
+ }
20
71
  function unwrapSearchResponse(raw) {
21
72
  if (!raw || typeof raw !== "object") {
22
73
  throw new Error("Unexpected Kibana search response shape");
@@ -46,7 +97,17 @@ function asArray(value) {
46
97
  return Array.isArray(value) ? value : [];
47
98
  }
48
99
  function normalizeFieldCapsResponse(raw) {
49
- const fields = asRecord(asRecord(raw).fields);
100
+ const record = asRecord(raw);
101
+ if (!raw ||
102
+ typeof raw !== "object" ||
103
+ Array.isArray(raw) ||
104
+ !("fields" in record) ||
105
+ !record.fields ||
106
+ typeof record.fields !== "object" ||
107
+ Array.isArray(record.fields)) {
108
+ throw new Error("Unexpected Elasticsearch field capabilities response shape");
109
+ }
110
+ const fields = asRecord(record.fields);
50
111
  return Object.entries(fields)
51
112
  .map(([name, typeEntries]) => {
52
113
  const typeRecord = asRecord(typeEntries);
@@ -63,13 +124,21 @@ function normalizeFieldCapsResponse(raw) {
63
124
  .sort((left, right) => left.name.localeCompare(right.name));
64
125
  }
65
126
  function normalizeKibanaFieldsResponse(raw) {
66
- const rawFields = Array.isArray(raw) ? raw : asArray(asRecord(raw).fields);
127
+ const record = asRecord(raw);
128
+ const rawFields = Array.isArray(raw)
129
+ ? raw
130
+ : raw && typeof raw === "object" && !Array.isArray(raw) && Array.isArray(record.fields)
131
+ ? record.fields
132
+ : undefined;
133
+ if (!rawFields) {
134
+ throw new Error("Unexpected Kibana fields response shape");
135
+ }
67
136
  return rawFields
68
137
  .map((field) => {
69
138
  const fieldRecord = asRecord(field);
70
139
  const name = fieldRecord.name;
71
140
  if (typeof name !== "string") {
72
- return null;
141
+ throw new Error("Kibana fields response contained an entry without a field name");
73
142
  }
74
143
  const subType = asRecord(fieldRecord.subType);
75
144
  const nested = asRecord(subType.nested);
@@ -84,7 +153,6 @@ function normalizeKibanaFieldsResponse(raw) {
84
153
  subfields: [],
85
154
  };
86
155
  })
87
- .filter((field) => field !== null)
88
156
  .sort((left, right) => left.name.localeCompare(right.name));
89
157
  }
90
158
  function resolveSchemaIndexPatterns(source, schemaBackend) {
@@ -136,6 +204,11 @@ function inferPrimitiveFieldType(value, fieldName) {
136
204
  if (fieldName?.endsWith(".keyword")) {
137
205
  return "keyword";
138
206
  }
207
+ if (fieldName &&
208
+ /(^@timestamp$)|((^|[._-])(timestamp|time|date|created|ingested|updated)([._-]|$))/i.test(fieldName) &&
209
+ !Number.isNaN(Date.parse(value))) {
210
+ return "date";
211
+ }
139
212
  return "text";
140
213
  }
141
214
  return undefined;
@@ -254,18 +327,99 @@ function normalizeSearchSampleFields(raw) {
254
327
  }
255
328
  return [...fieldMap.values()].sort((left, right) => left.name.localeCompare(right.name));
256
329
  }
330
+ class RequestGate {
331
+ maxConcurrency;
332
+ maxQueueDepth;
333
+ active = 0;
334
+ waiters = [];
335
+ constructor(maxConcurrency, maxQueueDepth) {
336
+ this.maxConcurrency = maxConcurrency;
337
+ this.maxQueueDepth = maxQueueDepth;
338
+ }
339
+ acquire(signal) {
340
+ if (signal.aborted) {
341
+ return Promise.reject(signal.reason);
342
+ }
343
+ if (this.active >= this.maxConcurrency && this.waiters.length >= this.maxQueueDepth) {
344
+ return Promise.reject(new RequestQueueFullError(this.maxQueueDepth));
345
+ }
346
+ return new Promise((resolve, reject) => {
347
+ const waiter = {
348
+ signal,
349
+ resolve,
350
+ reject,
351
+ onAbort: () => {
352
+ const index = this.waiters.indexOf(waiter);
353
+ if (index >= 0) {
354
+ this.waiters.splice(index, 1);
355
+ }
356
+ reject(signal.reason);
357
+ },
358
+ };
359
+ signal.addEventListener("abort", waiter.onAbort, { once: true });
360
+ this.waiters.push(waiter);
361
+ this.dispatch();
362
+ });
363
+ }
364
+ dispatch() {
365
+ while (this.active < this.maxConcurrency) {
366
+ const waiter = this.waiters.shift();
367
+ if (!waiter) {
368
+ return;
369
+ }
370
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
371
+ if (waiter.signal.aborted) {
372
+ waiter.reject(waiter.signal.reason);
373
+ continue;
374
+ }
375
+ this.active += 1;
376
+ let released = false;
377
+ waiter.resolve(() => {
378
+ if (released) {
379
+ return;
380
+ }
381
+ released = true;
382
+ this.active -= 1;
383
+ this.dispatch();
384
+ });
385
+ }
386
+ }
387
+ }
388
+ class RequestQueueFullError extends Error {
389
+ maxQueueDepth;
390
+ constructor(maxQueueDepth) {
391
+ super(`Kibana request queue is full (${maxQueueDepth} waiting requests)`);
392
+ this.maxQueueDepth = maxQueueDepth;
393
+ this.name = "RequestQueueFullError";
394
+ }
395
+ }
257
396
  export class KibanaClient {
258
397
  config;
259
- constructor(config) {
398
+ requestGate;
399
+ queueTimeoutMs;
400
+ constructor(config, options = {}) {
260
401
  this.config = config;
402
+ this.requestGate = new RequestGate(options.maxConcurrency ?? 8, options.maxQueueDepth ?? 32);
403
+ this.queueTimeoutMs = Math.min(options.queueTimeoutMs ?? config.timeoutMs, config.timeoutMs);
261
404
  }
262
405
  async requestJson(url, options = {}) {
263
- const controller = new AbortController();
264
- const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
406
+ const timeoutController = new AbortController();
407
+ const timeout = setTimeout(() => timeoutController.abort(new DOMException(`Timed out after ${this.config.timeoutMs}ms`, "TimeoutError")), this.config.timeoutMs);
408
+ const signal = options.callerSignal
409
+ ? AbortSignal.any([timeoutController.signal, options.callerSignal])
410
+ : timeoutController.signal;
411
+ const queueController = new AbortController();
412
+ const queueTimeout = setTimeout(() => queueController.abort(new DOMException(`Queue wait timed out after ${this.queueTimeoutMs}ms`, "TimeoutError")), this.queueTimeoutMs);
413
+ const queueSignal = AbortSignal.any([signal, queueController.signal]);
414
+ let phase = "queue";
415
+ let release;
265
416
  try {
417
+ release = await this.requestGate.acquire(queueSignal);
418
+ clearTimeout(queueTimeout);
419
+ phase = "request";
266
420
  const response = await fetch(url, {
267
421
  method: options.method ?? "GET",
268
- signal: controller.signal,
422
+ signal,
269
423
  headers: {
270
424
  Authorization: `Basic ${encodeBasicAuth(this.config.username, this.config.password)}`,
271
425
  "Content-Type": "application/json",
@@ -273,113 +427,134 @@ export class KibanaClient {
273
427
  },
274
428
  ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
275
429
  });
430
+ phase = "response";
276
431
  if (!response.ok) {
277
432
  const errorBody = await response.text();
278
433
  throw new KibanaHttpError(response.status, errorBody);
279
434
  }
280
- return response.json();
435
+ return await response.json();
436
+ }
437
+ catch (error) {
438
+ const sourceLabel = options.sourceId ? ` for source '${options.sourceId}'` : "";
439
+ if (error instanceof KibanaRequestError) {
440
+ throw error;
441
+ }
442
+ if (options.callerSignal?.aborted) {
443
+ throw new KibanaRequestError("KIBANA_CANCELLED", phase, options.sourceId, this.config.timeoutMs, `[KIBANA_CANCELLED] Kibana request${sourceLabel} was cancelled during ${phase}`, error);
444
+ }
445
+ if (error instanceof RequestQueueFullError) {
446
+ throw new KibanaRequestError("KIBANA_OVERLOADED", "queue", options.sourceId, this.queueTimeoutMs, `[KIBANA_OVERLOADED] Kibana request queue${sourceLabel} is full (${error.maxQueueDepth} waiting requests)`, error);
447
+ }
448
+ if (queueController.signal.aborted) {
449
+ throw new KibanaRequestError("KIBANA_TIMEOUT", "queue", options.sourceId, this.queueTimeoutMs, `[KIBANA_TIMEOUT] Kibana request${sourceLabel} exceeded the ${this.queueTimeoutMs}ms queue-wait timeout`, error);
450
+ }
451
+ if (timeoutController.signal.aborted) {
452
+ throw new KibanaRequestError("KIBANA_TIMEOUT", phase, options.sourceId, this.config.timeoutMs, `[KIBANA_TIMEOUT] Kibana request${sourceLabel} timed out after ${this.config.timeoutMs}ms during ${phase}`, error);
453
+ }
454
+ if (error instanceof KibanaHttpError) {
455
+ const code = error.status === 401 || error.status === 403 ? "KIBANA_AUTHENTICATION" : "KIBANA_HTTP";
456
+ throw new KibanaRequestError(code, "response", options.sourceId, this.config.timeoutMs, `[${code}] Kibana request${sourceLabel} returned HTTP ${error.status}`, error, error.status);
457
+ }
458
+ const causeCode = getNetworkCauseCode(error);
459
+ const code = phase === "response" && !causeCode ? "KIBANA_RESPONSE" : classifyNetworkError(causeCode);
460
+ throw new KibanaRequestError(code, phase, options.sourceId, this.config.timeoutMs, `[${code}] Kibana request${sourceLabel} failed during ${phase}${causeCode ? ` (${causeCode})` : ""}`, error, undefined, causeCode);
281
461
  }
282
462
  finally {
463
+ release?.();
464
+ clearTimeout(queueTimeout);
283
465
  clearTimeout(timeout);
284
466
  }
285
467
  }
286
- async execute(compiledQuery) {
468
+ async verifyConnection() {
469
+ const response = await this.requestJson(joinUrl(this.config.baseUrl, "/api/status"));
470
+ const overall = asRecord(asRecord(asRecord(response).status).overall);
471
+ const indicator = overall.level ?? overall.state;
472
+ if (typeof indicator !== "string" || !indicator.trim()) {
473
+ throw new Error("Kibana connection verification did not return a valid Kibana status response.");
474
+ }
475
+ }
476
+ async execute(compiledQuery, callerSignal) {
287
477
  const source = compiledQuery.source;
288
478
  const endpoint = joinUrl(this.config.baseUrl, source.backend.path);
289
- const controller = new AbortController();
290
- const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
291
- try {
292
- const body = source.backend.kind === "kibana_internal_search_es"
293
- ? {
294
- params: {
295
- ...(source.backend.index ? { index: source.backend.index } : {}),
296
- body: compiledQuery.request.body,
297
- },
298
- }
299
- : compiledQuery.request.body;
300
- const response = await fetch(endpoint, {
301
- method: "POST",
302
- signal: controller.signal,
303
- headers: {
304
- Authorization: `Basic ${encodeBasicAuth(this.config.username, this.config.password)}`,
305
- "Content-Type": "application/json",
306
- ...(source.backend.kind === "kibana_internal_search_es"
307
- ? { "kbn-xsrf": "kibana-mcp-server" }
308
- : {}),
479
+ const body = source.backend.kind === "kibana_internal_search_es"
480
+ ? {
481
+ params: {
482
+ ...(source.backend.index ? { index: source.backend.index } : {}),
483
+ body: compiledQuery.request.body,
309
484
  },
310
- body: JSON.stringify(body),
311
- });
312
- if (!response.ok) {
313
- const errorBody = await response.text();
314
- throw new Error(`Kibana request failed for source '${source.id}' with status ${response.status}: ${errorBody}`);
315
485
  }
316
- const responseJson = (await response.json());
486
+ : compiledQuery.request.body;
487
+ const responseJson = await this.requestJson(endpoint, {
488
+ method: "POST",
489
+ callerSignal,
490
+ sourceId: source.id,
491
+ headers: source.backend.kind === "kibana_internal_search_es"
492
+ ? { "kbn-xsrf": "kibana-mcp-server" }
493
+ : undefined,
494
+ body,
495
+ });
496
+ try {
317
497
  return {
318
498
  source,
319
499
  rawResponse: unwrapSearchResponse(responseJson),
320
500
  };
321
501
  }
322
- finally {
323
- clearTimeout(timeout);
502
+ catch (error) {
503
+ throw new KibanaRequestError("KIBANA_RESPONSE", "response", source.id, this.config.timeoutMs, `[KIBANA_RESPONSE] Kibana returned an unexpected search response for source '${source.id}'`, error);
324
504
  }
325
505
  }
326
- async executeMany(sourceQueries) {
327
- return Promise.all(sourceQueries.map((sourceQuery) => this.execute(sourceQuery)));
506
+ async executeMany(sourceQueries, callerSignal) {
507
+ const siblingController = new AbortController();
508
+ const signal = callerSignal
509
+ ? AbortSignal.any([callerSignal, siblingController.signal])
510
+ : siblingController.signal;
511
+ const executions = sourceQueries.map((sourceQuery) => this.execute(sourceQuery, signal));
512
+ try {
513
+ return await Promise.all(executions);
514
+ }
515
+ catch (error) {
516
+ siblingController.abort(error);
517
+ await Promise.allSettled(executions);
518
+ throw error;
519
+ }
328
520
  }
329
- async describeFieldsViaSearchBackend(source) {
521
+ async describeFieldsViaSearchBackend(source, callerSignal) {
330
522
  const endpoint = joinUrl(this.config.baseUrl, source.backend.path);
331
- const controller = new AbortController();
332
- const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
333
- try {
334
- const body = source.backend.kind === "kibana_internal_search_es"
335
- ? {
336
- params: {
337
- ...(source.backend.index ? { index: source.backend.index } : {}),
338
- body: {
339
- size: 20,
340
- sort: [{ [source.timeField]: { order: "desc" } }],
341
- fields: ["*"],
342
- _source: true,
343
- track_total_hits: false,
344
- },
523
+ const body = source.backend.kind === "kibana_internal_search_es"
524
+ ? {
525
+ params: {
526
+ ...(source.backend.index ? { index: source.backend.index } : {}),
527
+ body: {
528
+ size: 20,
529
+ fields: ["*"],
530
+ _source: true,
531
+ track_total_hits: false,
345
532
  },
346
- }
347
- : {
348
- size: 20,
349
- sort: [{ [source.timeField]: { order: "desc" } }],
350
- fields: ["*"],
351
- _source: true,
352
- track_total_hits: false,
353
- };
354
- const response = await fetch(endpoint, {
355
- method: "POST",
356
- signal: controller.signal,
357
- headers: {
358
- Authorization: `Basic ${encodeBasicAuth(this.config.username, this.config.password)}`,
359
- "Content-Type": "application/json",
360
- ...(source.backend.kind === "kibana_internal_search_es"
361
- ? { "kbn-xsrf": "kibana-mcp-server" }
362
- : {}),
363
533
  },
364
- body: JSON.stringify(body),
365
- });
366
- if (!response.ok) {
367
- const errorBody = await response.text();
368
- throw new Error(`Search transport fallback failed for source '${source.id}' with status ${response.status}: ${errorBody}`);
369
534
  }
370
- const responseJson = (await response.json());
371
- const rawResponse = unwrapSearchResponse(responseJson);
372
- const fields = normalizeSearchSampleFields(rawResponse);
373
- if (fields.length === 0) {
374
- throw new Error(`Search transport fallback returned no fields for source '${source.id}'. Sample hits may be empty for the requested index pattern.`);
375
- }
376
- return fields;
377
- }
378
- finally {
379
- clearTimeout(timeout);
535
+ : {
536
+ size: 20,
537
+ fields: ["*"],
538
+ _source: true,
539
+ track_total_hits: false,
540
+ };
541
+ const responseJson = await this.requestJson(endpoint, {
542
+ method: "POST",
543
+ callerSignal,
544
+ sourceId: source.id,
545
+ headers: source.backend.kind === "kibana_internal_search_es"
546
+ ? { "kbn-xsrf": "kibana-mcp-server" }
547
+ : undefined,
548
+ body,
549
+ });
550
+ const rawResponse = unwrapSearchResponse(responseJson);
551
+ const fields = normalizeSearchSampleFields(rawResponse);
552
+ if (fields.length === 0) {
553
+ throw new KibanaRequestError("KIBANA_RESPONSE", "response", source.id, this.config.timeoutMs, `[KIBANA_RESPONSE] Search transport fallback returned no fields for source '${source.id}'. Sample hits may be empty for the requested index pattern.`);
380
554
  }
555
+ return fields;
381
556
  }
382
- async describeFields(source) {
557
+ async describeFields(source, callerSignal) {
383
558
  const schemaBackend = source.schema;
384
559
  if (!schemaBackend) {
385
560
  throw new Error(`Source '${source.id}' does not configure a schema backend. Add source.schema before using describe_fields or schema-dependent query features.`);
@@ -397,16 +572,21 @@ export class KibanaClient {
397
572
  try {
398
573
  responseJson = await this.requestJson(url, {
399
574
  method: "GET",
575
+ callerSignal,
576
+ sourceId: source.id,
400
577
  });
401
578
  }
402
579
  catch (error) {
580
+ if (error instanceof KibanaRequestError) {
581
+ throw error;
582
+ }
403
583
  throw new Error(`Schema backend '${schemaBackend.kind}' request failed for source '${source.id}' at '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`);
404
584
  }
405
585
  try {
406
586
  return normalizeFieldCapsResponse(responseJson);
407
587
  }
408
588
  catch (error) {
409
- throw new Error(`Schema backend '${schemaBackend.kind}' returned an unexpected field capabilities payload for source '${source.id}': ${error instanceof Error ? error.message : String(error)}`);
589
+ throw new KibanaRequestError("KIBANA_RESPONSE", "response", source.id, this.config.timeoutMs, `[KIBANA_RESPONSE] Schema backend '${schemaBackend.kind}' returned an unexpected field capabilities payload for source '${source.id}': ${error instanceof Error ? error.message : String(error)}`, error);
410
590
  }
411
591
  }
412
592
  const attempts = [];
@@ -415,6 +595,8 @@ export class KibanaClient {
415
595
  try {
416
596
  const responseJson = await this.requestJson(url, {
417
597
  method: "GET",
598
+ callerSignal,
599
+ sourceId: source.id,
418
600
  headers: {
419
601
  "kbn-xsrf": "kibana-mcp-server",
420
602
  },
@@ -423,18 +605,24 @@ export class KibanaClient {
423
605
  return normalizeKibanaFieldsResponse(responseJson);
424
606
  }
425
607
  catch (error) {
426
- throw new Error(`Schema backend '${schemaBackend.kind}' returned an unexpected Kibana field payload for source '${source.id}' from '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`);
608
+ throw new KibanaRequestError("KIBANA_RESPONSE", "response", source.id, this.config.timeoutMs, `[KIBANA_RESPONSE] Schema backend '${schemaBackend.kind}' returned an unexpected Kibana field payload for source '${source.id}' from '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`, error);
427
609
  }
428
610
  }
429
611
  catch (error) {
430
- if (error instanceof KibanaHttpError && error.status === 404) {
612
+ if (error instanceof KibanaRequestError && error.status === 404) {
431
613
  attempts.push(`${schemaPath} -> 404`);
432
614
  continue;
433
615
  }
616
+ if (error instanceof KibanaRequestError) {
617
+ throw error;
618
+ }
434
619
  throw new Error(`Schema backend '${schemaBackend.kind}' request failed for source '${source.id}' at '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`);
435
620
  }
436
621
  }
437
- return this.describeFieldsViaSearchBackend(source).catch((fallbackError) => {
622
+ return this.describeFieldsViaSearchBackend(source, callerSignal).catch((fallbackError) => {
623
+ if (fallbackError instanceof KibanaRequestError) {
624
+ throw fallbackError;
625
+ }
438
626
  throw new Error(`Schema backend '${schemaBackend.kind}' returned 404 for source '${source.id}' on every known Kibana field-discovery path (${attempts.join(", ")}). Search transport fallback also failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
439
627
  });
440
628
  }
@@ -1,4 +1,5 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
3
  import { dirname } from "node:path";
3
4
  import { z } from "zod";
4
5
  import { resolveProfilePaths } from "./profile_paths.js";
@@ -105,11 +106,28 @@ export class ProfileStore {
105
106
  defaultProfileId: profileId,
106
107
  });
107
108
  }
109
+ async replaceState(state) {
110
+ await this.save(savedProfileStoreStateSchema.parse(state));
111
+ }
108
112
  async save(state) {
109
113
  const mkdirImpl = this.io.mkdir ?? mkdir;
110
114
  const writeFileImpl = this.io.writeFile ?? writeFile;
111
115
  await mkdirImpl(dirname(this.paths.profilesPath), { recursive: true });
112
- await writeFileImpl(this.paths.profilesPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
116
+ if (this.io.writeFile) {
117
+ await writeFileImpl(this.paths.profilesPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
118
+ return;
119
+ }
120
+ const temporaryPath = `${this.paths.profilesPath}.${process.pid}.${randomUUID()}.tmp`;
121
+ try {
122
+ await writeFileImpl(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, {
123
+ encoding: "utf8",
124
+ mode: 0o600,
125
+ });
126
+ await rename(temporaryPath, this.paths.profilesPath);
127
+ }
128
+ finally {
129
+ await rm(temporaryPath, { force: true }).catch(() => { });
130
+ }
113
131
  }
114
132
  }
115
133
  export function normalizeProfileId(name) {
@@ -54,19 +54,33 @@ function mergeFieldHints(source, fields) {
54
54
  export class SchemaCatalog {
55
55
  client;
56
56
  cache = new Map();
57
+ inFlight = new Map();
57
58
  constructor(client) {
58
59
  this.client = client;
59
60
  }
60
- async getFields(source) {
61
+ async getFields(source, callerSignal) {
61
62
  const existing = this.cache.get(source.id);
62
63
  if (existing) {
63
64
  return existing;
64
65
  }
65
- const pending = this.client
66
- .describeFields(source)
67
- .then((fields) => linkFieldRelationships(mergeFieldHints(source, fields)));
68
- this.cache.set(source.id, pending);
69
- return pending;
66
+ const pending = this.inFlight.get(source.id);
67
+ if (pending) {
68
+ return pending;
69
+ }
70
+ const discovery = this.client.describeFields(source, callerSignal).then((fields) => {
71
+ const resolved = linkFieldRelationships(mergeFieldHints(source, fields));
72
+ this.cache.set(source.id, resolved);
73
+ return resolved;
74
+ });
75
+ this.inFlight.set(source.id, discovery);
76
+ try {
77
+ return await discovery;
78
+ }
79
+ finally {
80
+ if (this.inFlight.get(source.id) === discovery) {
81
+ this.inFlight.delete(source.id);
82
+ }
83
+ }
70
84
  }
71
85
  filterFields(fields, query, limit = 100) {
72
86
  const normalizedQuery = query ? normalizeFieldName(query) : undefined;