@databricks/appkit 0.38.1 → 0.40.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.
Files changed (38) hide show
  1. package/dist/appkit/package.js +1 -1
  2. package/dist/cli/commands/generate-types.js +114 -5
  3. package/dist/cli/commands/generate-types.js.map +1 -1
  4. package/dist/cli/commands/spawn-lock.js +116 -0
  5. package/dist/cli/commands/spawn-lock.js.map +1 -0
  6. package/dist/cli/index.js +1 -1
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/connectors/index.js +1 -1
  9. package/dist/connectors/sql-warehouse/client.js +168 -1
  10. package/dist/connectors/sql-warehouse/client.js.map +1 -1
  11. package/dist/connectors/sql-warehouse/index.js +1 -1
  12. package/dist/connectors/sql-warehouse/warehouse-poll-backoff.js +24 -0
  13. package/dist/connectors/sql-warehouse/warehouse-poll-backoff.js.map +1 -0
  14. package/dist/connectors/sql-warehouse/warehouse-status-emitter.js +40 -0
  15. package/dist/connectors/sql-warehouse/warehouse-status-emitter.js.map +1 -0
  16. package/dist/plugins/analytics/analytics.d.ts.map +1 -1
  17. package/dist/plugins/analytics/analytics.js +73 -8
  18. package/dist/plugins/analytics/analytics.js.map +1 -1
  19. package/dist/plugins/analytics/manifest.js +17 -5
  20. package/dist/plugins/analytics/types.d.ts +13 -0
  21. package/dist/plugins/analytics/types.d.ts.map +1 -1
  22. package/dist/plugins/analytics/types.js.map +1 -1
  23. package/dist/type-generator/index.js +115 -12
  24. package/dist/type-generator/index.js.map +1 -1
  25. package/dist/type-generator/preflight.js +24 -0
  26. package/dist/type-generator/preflight.js.map +1 -0
  27. package/dist/type-generator/query-registry.js +322 -90
  28. package/dist/type-generator/query-registry.js.map +1 -1
  29. package/dist/type-generator/types.js.map +1 -1
  30. package/dist/type-generator/vite-plugin.d.ts.map +1 -1
  31. package/dist/type-generator/vite-plugin.js +141 -7
  32. package/dist/type-generator/vite-plugin.js.map +1 -1
  33. package/dist/type-generator/warehouse-status.js +116 -0
  34. package/dist/type-generator/warehouse-status.js.map +1 -0
  35. package/docs/development/type-generation.md +13 -0
  36. package/docs/plugins/analytics.md +156 -0
  37. package/package.json +1 -1
  38. package/sbom.cdx.json +1 -1
@@ -1,7 +1,9 @@
1
1
  import { createLogger } from "../logging/logger.js";
2
2
  import { CACHE_VERSION, hashSQL, loadCache, saveCache } from "./cache.js";
3
+ import { decidePreflight } from "./preflight.js";
3
4
  import { Spinner } from "./spinner.js";
4
5
  import { sqlTypeToHelper, sqlTypeToMarker } from "./types.js";
6
+ import { getWarehouseState, startWarehouse, waitUntilRunning } from "./warehouse-status.js";
5
7
  import { WorkspaceClient } from "@databricks/sdk-experimental";
6
8
  import pc from "picocolors";
7
9
  import fs from "node:fs/promises";
@@ -10,6 +12,12 @@ import path from "node:path";
10
12
  //#region src/type-generator/query-registry.ts
11
13
  const logger = createLogger("type-generator:query-registry");
12
14
  /**
15
+ * Upper bound on how long a `blocking`-mode preflight will wait for a starting
16
+ * warehouse to reach RUNNING before giving up (~5 min). Generous enough to ride
17
+ * out a cold start without hanging an interactive CLI invocation indefinitely.
18
+ */
19
+ const PREFLIGHT_WAIT_MAX_MS = 3e5;
20
+ /**
13
21
  * Regex breakdown:
14
22
  * '(?:[^']|'')*' — matches a SQL string literal, including escaped '' pairs
15
23
  * | — alternation: whichever branch matches first at a position wins
@@ -58,6 +66,83 @@ function parseError(raw) {
58
66
  } catch {}
59
67
  return { message: raw };
60
68
  }
69
+ function isObject(value) {
70
+ return typeof value === "object" && value !== null;
71
+ }
72
+ function getErrorMessage(error) {
73
+ if (error instanceof Error) return error.message;
74
+ if (isObject(error) && typeof error.message === "string") return error.message;
75
+ return String(error);
76
+ }
77
+ function getErrorDiagnostic(error) {
78
+ const seen = /* @__PURE__ */ new Set();
79
+ const messages = [];
80
+ const stack = [error];
81
+ while (stack.length > 0) {
82
+ const current = stack.pop();
83
+ if (current === void 0 || seen.has(current)) continue;
84
+ seen.add(current);
85
+ const message = getErrorMessage(current);
86
+ if (message && message !== "[object Object]" && !messages.includes(message)) messages.push(message);
87
+ const code = getErrorCode(current);
88
+ if (code && !messages.includes(code)) messages.push(code);
89
+ stack.push(...getErrorChildren(current));
90
+ }
91
+ return messages.length > 0 ? messages.join(": ") : getErrorMessage(error);
92
+ }
93
+ function getErrorCode(error) {
94
+ if (!isObject(error)) return void 0;
95
+ const code = error.code ?? error.errno;
96
+ return typeof code === "string" ? code : void 0;
97
+ }
98
+ function getErrorStatus(error) {
99
+ if (!isObject(error)) return void 0;
100
+ const direct = error.status ?? error.statusCode;
101
+ if (typeof direct === "number") return direct;
102
+ if (isObject(error.response) && typeof error.response.status === "number") return error.response.status;
103
+ }
104
+ function getErrorChildren(error) {
105
+ if (!isObject(error)) return [];
106
+ const children = [];
107
+ if ("cause" in error) children.push(error.cause);
108
+ if (error instanceof AggregateError) children.push(...error.errors);
109
+ return children;
110
+ }
111
+ const CONNECTIVITY_ERROR_CODES = new Set([
112
+ "ECONNREFUSED",
113
+ "ECONNRESET",
114
+ "ENOTFOUND",
115
+ "ETIMEDOUT",
116
+ "EAI_AGAIN",
117
+ "EAI_NODATA",
118
+ "EAI_NONAME",
119
+ "EHOSTUNREACH",
120
+ "ENETUNREACH",
121
+ "CERT_HAS_EXPIRED",
122
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
123
+ "ERR_TLS_CERT_ALTNAME_INVALID",
124
+ "SELF_SIGNED_CERT_IN_CHAIN",
125
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE"
126
+ ]);
127
+ function isConnectivityMessage(message) {
128
+ return /\bconnection (?:refused|reset|timed out)\b/i.test(message) || /\bsocket hang up\b/i.test(message) || /\bnetwork error\b/i.test(message) || /\bcan'?t connect to\b/i.test(message) || /\bcertificate has expired\b/i.test(message) || /\bunable to verify the first certificate\b/i.test(message) || /\bupstream connect error or disconnect\/reset before headers\b/i.test(message);
129
+ }
130
+ function isConnectivityError(error) {
131
+ const seen = /* @__PURE__ */ new Set();
132
+ const stack = [error];
133
+ while (stack.length > 0) {
134
+ const current = stack.pop();
135
+ if (current === void 0 || seen.has(current)) continue;
136
+ seen.add(current);
137
+ const code = getErrorCode(current);
138
+ if (code && (CONNECTIVITY_ERROR_CODES.has(code) || code.startsWith("UND_ERR_"))) return true;
139
+ const status = getErrorStatus(current);
140
+ if (status === 502 || status === 503 || status === 504) return true;
141
+ if (isConnectivityMessage(getErrorMessage(current))) return true;
142
+ stack.push(...getErrorChildren(current));
143
+ }
144
+ return false;
145
+ }
61
146
  /**
62
147
  * Extract parameters from a SQL query
63
148
  * @param sql - the SQL query to extract parameters from
@@ -120,6 +205,16 @@ function generateUnknownResultQuery(sql, queryName) {
120
205
  result: unknown;
121
206
  }`;
122
207
  }
208
+ /**
209
+ * Degrade gracefully when DESCRIBE can't produce a fresh schema (transient
210
+ * connectivity outage, or a warehouse that's reachable but not ready). Reuse
211
+ * the last-good cached type when the SQL hash is unchanged, otherwise emit
212
+ * `unknown` from SQL alone. Never persists `result: unknown`.
213
+ */
214
+ function degradedType(cache, queryName, sql, sqlHash) {
215
+ const prior = cache.queries[queryName];
216
+ return prior?.hash === sqlHash && !prior.retry ? prior.type : generateUnknownResultQuery(sql, queryName);
217
+ }
123
218
  function extractParameterTypes(sql) {
124
219
  const paramTypes = {};
125
220
  const matches = sql.matchAll(/--\s*@param\s+(\w+)\s+(STRING|NUMERIC|DECIMAL|BIGINT|TINYINT|SMALLINT|INT|FLOAT|DOUBLE|BOOLEAN|DATE|TIMESTAMP_NTZ|TIMESTAMP|BINARY)\b/gi);
@@ -167,10 +262,15 @@ function inferParameterTypes(sql, ranges) {
167
262
  * @param warehouseId - the warehouse id to use for schema analysis
168
263
  * @param options - options for the query generation
169
264
  * @param options.noCache - if true, skip the cache and regenerate all types
265
+ * @param options.mode - preflight policy: "non-blocking" never probes the
266
+ * warehouse and never describes (emits cached/`unknown` types and returns
267
+ * immediately), "blocking" waits for a starting warehouse and starts (then
268
+ * waits for) a stopped one, treating only a deleted/deleting warehouse as
269
+ * fatal. Defaults to "non-blocking".
170
270
  * @returns an array of query schemas
171
271
  */
172
272
  async function generateQueriesFromDescribe(queryFolder, warehouseId, options = {}) {
173
- const { noCache = false, concurrency: rawConcurrency = 10 } = options;
273
+ const { noCache = false, concurrency: rawConcurrency = 10, mode = "non-blocking" } = options;
174
274
  const concurrency = typeof rawConcurrency === "number" && Number.isFinite(rawConcurrency) ? Math.max(1, Math.floor(rawConcurrency)) : 10;
175
275
  const [allFiles, cache] = await Promise.all([fs.readdir(queryFolder), noCache ? {
176
276
  version: CACHE_VERSION,
@@ -231,105 +331,212 @@ async function generateQueriesFromDescribe(queryFolder, warehouseId, options = {
231
331
  }
232
332
  }
233
333
  const freshResults = [];
334
+ const syntaxErrors = [];
335
+ const fatalErrors = [];
234
336
  if (uncachedQueries.length > 0) {
235
- let completed = 0;
236
- const total = uncachedQueries.length;
237
- spinner.start(`Describing ${total} ${total === 1 ? "query" : "queries"} (0/${total})`);
238
- const describeOne = async ({ index, queryName, sql, sqlHash, cleanedSql }) => {
239
- const result = await client.statementExecution.executeStatement({
240
- statement: `DESCRIBE QUERY ${cleanedSql}`,
241
- warehouse_id: warehouseId
242
- });
243
- completed++;
244
- spinner.update(`Describing ${total} ${total === 1 ? "query" : "queries"} (${completed}/${total})`);
245
- logger.debug("DESCRIBE result for %s: state=%s, rows=%d", queryName, result.status.state, result.result?.data_array?.length ?? 0);
246
- if (result.status.state === "FAILED") {
247
- const sqlError = result.status.error?.message || "Query execution failed";
248
- logger.warn("DESCRIBE failed for %s: %s", queryName, sqlError);
249
- const type = generateUnknownResultQuery(sql, queryName);
250
- return {
251
- status: "fail",
337
+ let decision = "proceed";
338
+ let fatalMessage = "";
339
+ if (mode === "non-blocking") decision = "degradeAll";
340
+ else try {
341
+ const state = await getWarehouseState(client, warehouseId);
342
+ decision = decidePreflight(state, mode);
343
+ if (decision === "fatal") fatalMessage = `warehouse ${warehouseId} is ${state}`;
344
+ if (decision === "startWaitProceed") {
345
+ await startWarehouse(client, warehouseId);
346
+ const final = await waitUntilRunning(client, warehouseId, {
347
+ maxMs: PREFLIGHT_WAIT_MAX_MS,
348
+ treatStoppedAsTransient: true
349
+ });
350
+ if (final === "RUNNING") decision = "proceed";
351
+ else {
352
+ decision = "fatal";
353
+ fatalMessage = `warehouse ${warehouseId} did not reach RUNNING (now ${final})`;
354
+ }
355
+ }
356
+ if (decision === "waitThenProceed") {
357
+ const final = await waitUntilRunning(client, warehouseId, { maxMs: PREFLIGHT_WAIT_MAX_MS });
358
+ if (final === "RUNNING") decision = "proceed";
359
+ else {
360
+ decision = "fatal";
361
+ fatalMessage = `warehouse ${warehouseId} did not reach RUNNING (now ${final})`;
362
+ }
363
+ }
364
+ } catch (err) {
365
+ if (isConnectivityError(err)) decision = "degradeAll";
366
+ else {
367
+ decision = "fatal";
368
+ fatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`;
369
+ }
370
+ }
371
+ if (decision !== "proceed") {
372
+ const kind = decision === "fatal" ? "fatal" : "connectivity";
373
+ for (const { index, queryName, sql, sqlHash } of uncachedQueries) {
374
+ freshResults.push({
252
375
  index,
253
376
  schema: {
254
377
  name: queryName,
255
- type
256
- },
257
- cacheEntry: {
258
- hash: sqlHash,
259
- type,
260
- retry: true
261
- },
262
- error: parseError(sqlError)
263
- };
264
- }
265
- const { type, hasResults } = convertToQueryType(result, sql, queryName);
266
- return {
267
- status: "ok",
268
- index,
269
- schema: {
270
- name: queryName,
271
- type
272
- },
273
- cacheEntry: {
274
- hash: sqlHash,
275
- type,
276
- retry: !hasResults
277
- }
278
- };
279
- };
280
- const processBatchResults = (settled, batchOffset) => {
281
- for (let i = 0; i < settled.length; i++) {
282
- const entry = settled[i];
283
- const { queryName } = uncachedQueries[batchOffset + i];
284
- if (entry.status === "fulfilled") {
285
- const res = entry.value;
286
- freshResults.push({
287
- index: res.index,
288
- schema: res.schema
378
+ type: degradedType(cache, queryName, sql, sqlHash)
379
+ }
380
+ });
381
+ if (decision === "fatal") {
382
+ fatalErrors.push({
383
+ name: queryName,
384
+ message: fatalMessage
289
385
  });
290
- cache.queries[queryName] = res.cacheEntry;
291
386
  logEntries.push({
292
387
  queryName,
293
388
  status: "MISS",
294
- failed: res.status === "fail",
295
- error: res.status === "fail" ? res.error : void 0
389
+ kind,
390
+ error: { message: fatalMessage }
296
391
  });
297
- } else {
298
- const { sql, sqlHash, index } = uncachedQueries[batchOffset + i];
299
- const reason = entry.reason instanceof Error ? entry.reason.message : String(entry.reason);
300
- logger.warn("DESCRIBE rejected for %s: %s", queryName, reason);
301
- const type = generateUnknownResultQuery(sql, queryName);
302
- freshResults.push({
392
+ } else logEntries.push({
393
+ queryName,
394
+ status: "MISS",
395
+ kind
396
+ });
397
+ }
398
+ } else {
399
+ let completed = 0;
400
+ const total = uncachedQueries.length;
401
+ spinner.start(`Describing ${total} ${total === 1 ? "query" : "queries"} (0/${total})`);
402
+ const describeOne = async ({ index, queryName, sql, sqlHash, cleanedSql }) => {
403
+ const result = await client.statementExecution.executeStatement({
404
+ statement: `DESCRIBE QUERY ${cleanedSql}`,
405
+ warehouse_id: warehouseId
406
+ });
407
+ completed++;
408
+ spinner.update(`Describing ${total} ${total === 1 ? "query" : "queries"} (${completed}/${total})`);
409
+ logger.debug("DESCRIBE result for %s: state=%s, rows=%d", queryName, result.status.state, result.result?.data_array?.length ?? 0);
410
+ if (result.status.state === "FAILED") {
411
+ const sqlError = result.status.error?.message || "Query execution failed";
412
+ return {
413
+ status: "syntax",
303
414
  index,
304
415
  schema: {
305
416
  name: queryName,
306
- type
307
- }
308
- });
309
- cache.queries[queryName] = {
417
+ type: generateUnknownResultQuery(sql, queryName)
418
+ },
419
+ error: parseError(sqlError)
420
+ };
421
+ }
422
+ if (result.status.state !== "SUCCEEDED") return {
423
+ status: "unavailable",
424
+ index,
425
+ schema: {
426
+ name: queryName,
427
+ type: degradedType(cache, queryName, sql, sqlHash)
428
+ }
429
+ };
430
+ const { type, hasResults } = convertToQueryType(result, sql, queryName);
431
+ if (!hasResults) return {
432
+ status: "empty",
433
+ index,
434
+ schema: {
435
+ name: queryName,
436
+ type
437
+ }
438
+ };
439
+ return {
440
+ status: "ok",
441
+ index,
442
+ schema: {
443
+ name: queryName,
444
+ type
445
+ },
446
+ cacheEntry: {
310
447
  hash: sqlHash,
311
448
  type,
312
- retry: true
313
- };
314
- logEntries.push({
315
- queryName,
316
- status: "MISS",
317
- failed: true,
318
- error: parseError(reason)
319
- });
449
+ retry: false
450
+ }
451
+ };
452
+ };
453
+ const processBatchResults = (settled, batchOffset) => {
454
+ for (let i = 0; i < settled.length; i++) {
455
+ const entry = settled[i];
456
+ const { queryName } = uncachedQueries[batchOffset + i];
457
+ if (entry.status === "fulfilled") {
458
+ const res = entry.value;
459
+ freshResults.push({
460
+ index: res.index,
461
+ schema: res.schema
462
+ });
463
+ if (res.status === "ok") {
464
+ cache.queries[queryName] = res.cacheEntry;
465
+ logEntries.push({
466
+ queryName,
467
+ status: "MISS"
468
+ });
469
+ } else if (res.status === "syntax") {
470
+ syntaxErrors.push({
471
+ name: queryName,
472
+ message: res.error.message
473
+ });
474
+ logEntries.push({
475
+ queryName,
476
+ status: "MISS",
477
+ kind: "syntax",
478
+ error: res.error
479
+ });
480
+ } else if (res.status === "empty") logEntries.push({
481
+ queryName,
482
+ status: "MISS",
483
+ kind: "empty"
484
+ });
485
+ else logEntries.push({
486
+ queryName,
487
+ status: "MISS",
488
+ kind: "connectivity"
489
+ });
490
+ } else {
491
+ completed++;
492
+ spinner.update(`Describing ${total} ${total === 1 ? "query" : "queries"} (${completed}/${total})`);
493
+ const { sql, sqlHash, index } = uncachedQueries[batchOffset + i];
494
+ const reason = getErrorDiagnostic(entry.reason);
495
+ const error = parseError(reason);
496
+ const priorEntry = cache.queries[queryName];
497
+ const canReusePrior = priorEntry?.hash === sqlHash && !priorEntry.retry;
498
+ const type = degradedType(cache, queryName, sql, sqlHash);
499
+ freshResults.push({
500
+ index,
501
+ schema: {
502
+ name: queryName,
503
+ type
504
+ }
505
+ });
506
+ if (!isConnectivityError(entry.reason)) {
507
+ fatalErrors.push({
508
+ name: queryName,
509
+ message: error.message
510
+ });
511
+ logEntries.push({
512
+ queryName,
513
+ status: "MISS",
514
+ kind: "fatal",
515
+ error
516
+ });
517
+ continue;
518
+ }
519
+ logger.warn("DESCRIBE unreachable for %s: %s — %s", queryName, reason, canReusePrior ? "reusing last cached type" : "emitting unknown (no matching cache)");
520
+ logEntries.push({
521
+ queryName,
522
+ status: "MISS",
523
+ kind: "connectivity",
524
+ error
525
+ });
526
+ }
320
527
  }
528
+ };
529
+ if (uncachedQueries.length > concurrency) for (let b = 0; b < uncachedQueries.length; b += concurrency) {
530
+ const batch = uncachedQueries.slice(b, b + concurrency);
531
+ processBatchResults(await Promise.allSettled(batch.map(describeOne)), b);
532
+ await saveCache(cache);
321
533
  }
322
- };
323
- if (uncachedQueries.length > concurrency) for (let b = 0; b < uncachedQueries.length; b += concurrency) {
324
- const batch = uncachedQueries.slice(b, b + concurrency);
325
- processBatchResults(await Promise.allSettled(batch.map(describeOne)), b);
326
- await saveCache(cache);
327
- }
328
- else {
329
- processBatchResults(await Promise.allSettled(uncachedQueries.map(describeOne)), 0);
330
- await saveCache(cache);
534
+ else {
535
+ processBatchResults(await Promise.allSettled(uncachedQueries.map(describeOne)), 0);
536
+ await saveCache(cache);
537
+ }
538
+ spinner.stop("");
331
539
  }
332
- spinner.stop("");
333
540
  }
334
541
  const elapsed = ((performance.now() - startTime) / 1e3).toFixed(2);
335
542
  if (logEntries.length > 0) {
@@ -339,23 +546,48 @@ async function generateQueriesFromDescribe(queryFolder, warehouseId, options = {
339
546
  console.log(` ${pc.bold("Typegen Queries")} ${pc.dim(`(${logEntries.length})`)}`);
340
547
  console.log(` ${separator}`);
341
548
  for (const entry of logEntries) {
342
- const tag = entry.failed ? pc.bold(pc.red("ERROR")) : entry.status === "HIT" ? `cache ${pc.bold(pc.green("HIT "))}` : `cache ${pc.bold(pc.yellow("MISS "))}`;
549
+ let tag;
550
+ switch (entry.kind) {
551
+ case "syntax":
552
+ tag = pc.bold(pc.red("SQL ERR"));
553
+ break;
554
+ case "connectivity":
555
+ tag = pc.bold(pc.yellow("OFFLINE"));
556
+ break;
557
+ case "empty":
558
+ tag = pc.dim("EMPTY ");
559
+ break;
560
+ case "fatal":
561
+ tag = pc.bold(pc.red("FATAL "));
562
+ break;
563
+ default: tag = entry.status === "HIT" ? `cache ${pc.bold(pc.green("HIT "))}` : `cache ${pc.bold(pc.yellow("MISS "))}`;
564
+ }
343
565
  const rawName = entry.queryName.padEnd(maxNameLen);
344
- const name = entry.failed ? pc.dim(pc.strikethrough(rawName)) : rawName;
566
+ const name = entry.kind === "syntax" || entry.kind === "fatal" ? pc.dim(pc.strikethrough(rawName)) : rawName;
345
567
  const errorCode = entry.error?.message.match(/\[([^\]]+)\]/)?.[1];
346
568
  const reason = errorCode ? ` ${pc.dim(errorCode)}` : "";
347
569
  console.log(` ${tag} ${name}${reason}`);
348
570
  }
349
- const newCount = logEntries.filter((e) => e.status === "MISS" && !e.failed).length;
350
- const cacheCount = logEntries.filter((e) => e.status === "HIT" && !e.failed).length;
351
- const errorCount = logEntries.filter((e) => e.failed).length;
571
+ const newCount = logEntries.filter((e) => e.status === "MISS" && !e.kind).length;
572
+ const cacheCount = logEntries.filter((e) => e.status === "HIT").length;
573
+ const syntaxCount = logEntries.filter((e) => e.kind === "syntax").length;
574
+ const offlineCount = logEntries.filter((e) => e.kind === "connectivity").length;
575
+ const emptyCount = logEntries.filter((e) => e.kind === "empty").length;
576
+ const fatalCount = logEntries.filter((e) => e.kind === "fatal").length;
352
577
  console.log(` ${separator}`);
353
578
  const parts = [`${newCount} new`, `${cacheCount} from cache`];
354
- if (errorCount > 0) parts.push(`${errorCount} ${errorCount === 1 ? "error" : "errors"}`);
579
+ if (syntaxCount > 0) parts.push(`${syntaxCount} SQL ${syntaxCount === 1 ? "error" : "errors"}`);
580
+ if (offlineCount > 0) parts.push(`${offlineCount} degraded`);
581
+ if (emptyCount > 0) parts.push(`${emptyCount} empty`);
582
+ if (fatalCount > 0) parts.push(`${fatalCount} fatal ${fatalCount === 1 ? "error" : "errors"}`);
355
583
  console.log(` ${parts.join(", ")}. ${pc.dim(`${elapsed}s`)}`);
356
584
  console.log("");
357
585
  }
358
- return [...cachedResults, ...freshResults].sort((a, b) => a.index - b.index).map((r) => r.schema);
586
+ return {
587
+ schemas: [...cachedResults, ...freshResults].sort((a, b) => a.index - b.index).map((r) => r.schema),
588
+ syntaxErrors,
589
+ fatalErrors
590
+ };
359
591
  }
360
592
  /**
361
593
  * Normalize query name by removing the .obo extension