diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index 04b2091c6..bbce8d855 100644 --- a/docs/docs/development/type-generation.md +++ b/docs/docs/development/type-generation.md @@ -82,7 +82,22 @@ Pass `--wait` for CI and production builds, where accurate types must be present npx @databricks/appkit generate-types --wait ``` -In blocking mode the generator starts a stopped warehouse, waits (bounded) for it to reach `RUNNING`, and then describes your queries. It fails only when the configured warehouse no longer exists (deleted/deleting), so a transient outage or a cold warehouse degrades gracefully rather than breaking the build. The app template wires this up for you: `postinstall` and `predev` run the non-blocking default, while `prebuild` runs `--wait`. +#### CI resilience: committed types as fallback + +In blocking mode (`--wait`), the generator attempts to fetch real types from your warehouse, but delegates to **committed `.d.ts` files** (`shared/appkit-types/analytics.d.ts`, `metric-views.d.ts`) as the fallback when the warehouse is unreachable. These committed files should be part of your repository. On a fresh CI checkout, every build attempts to DESCRIBE against the warehouse; the committed types are used only when that cannot complete. + +The generator **never overwrites committed types with degraded (`result: unknown`) types** — it writes real types, or it does not write at all. + +A **two-bucket failure taxonomy** determines whether the build crashes or falls back to committed types: + +- **Deterministic failures (always crash):** SQL syntax errors in your queries (genuine DESCRIBE failure against a reachable warehouse), HTTP 404 (bad or unknown warehouse ID), HTTP 400 (malformed request). These are developer or configuration errors that committed types must not hide. +- **Environmental failures (gate on committed types):** Authentication failures (401/403), network unreachability, warehouse unavailability (cold, deleting, or deleted), wait timeout on `RUNNING`, or any unrecognized failure. If committed types exist, the build **keeps them, emits a loud warning to stderr, and succeeds (exit 0)**. If no committed types exist, the build **crashes** with a message instructing you to run `npx @databricks/appkit generate-types --wait` locally (against a reachable warehouse) and commit the `.d.ts` files. + +The loud warning is a single greppable stderr line naming the coarse cause (auth blocked / warehouse unreachable / warehouse unavailable) and the warehouse ID, so CI logs surface that the build fell back to committed types. + +**Note:** If your app declares only metric views and no `config/queries/`, the first build still writes an empty `analytics.d.ts`, which counts as "committed types present" for the gate. An environmental failure will then fall back and warn rather than crash, even on a first build — an accepted v1 simplification. + +The app template wires this up for you: `postinstall` and `predev` run the non-blocking default, while `prebuild` runs `--wait`. ## Metric-view types @@ -90,7 +105,7 @@ In blocking mode the generator starts a stopped warehouse, waits (bounded) for i - `metric-views.d.ts` — augments the `MetricRegistry` interface so `useMetricView('', …)` is autocompleted and type-checked. Each view's measures, dimensions, and their semantic metadata (SQL type, display name, format, time grains) are encoded at the type level. -If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` that same situation fails the build so CI never ships incomplete metric types. A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode. +If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` metric views obey the [two-bucket taxonomy](#ci-resilience-committed-types-as-fallback) (environmental failures gate to committed `metric-views.d.ts` + warn; deterministic failures like malformed definitions crash the build). A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode. `definitions.json` is keyed by metric key; each entry names the three-part UC FQN of the view and, optionally, the executor it runs as (`app_service_principal`, the default, or `user`): diff --git a/packages/appkit/src/type-generator/errors.ts b/packages/appkit/src/type-generator/errors.ts index 97c19d121..8a074795c 100644 --- a/packages/appkit/src/type-generator/errors.ts +++ b/packages/appkit/src/type-generator/errors.ts @@ -139,3 +139,91 @@ export function isConnectivityError(error: unknown): boolean { return false; } + +const AUTH_ERROR_STATUSES = new Set([401, 403]); + +/** + * Classifies a thrown failure into one of two buckets: deterministic failures + * that must be surfaced (bad warehouse id, malformed request) or environmental + * issues (connectivity, auth, deleted warehouse, timeouts) that the has-types + * gate will handle later. + * + * Returns: + * - "deterministic": HTTP 404 (bad warehouse id) or 400 (malformed request). + * The build must fail. + * - "environmental": Everything else — auth (401/403), connectivity errors, + * warehouse state changes (DELETED/DELETING), wait-for-RUNNING timeouts, + * unrecognized failures. Default = environmental. + * + * Walks `cause`/`AggregateError` chains when checking for deterministic status, + * so a wrapped 404 is still recognized as deterministic. + */ +export function classifyBlockingFailure( + error: unknown, +): "deterministic" | "environmental" { + // Deterministic: check first so they're never swallowed by environmental rules. + // Walk the error chain to find any deterministic status. + const seen = new Set(); + const stack = [error]; + + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined || seen.has(current)) continue; + seen.add(current); + + const status = getErrorStatus(current); + if (status === 404 || status === 400) { + return "deterministic"; + } + + stack.push(...getErrorChildren(current)); + } + + // Environmental: auth, connectivity, unrecognized, default. + const topLevelStatus = getErrorStatus(error); + if (topLevelStatus !== undefined && AUTH_ERROR_STATUSES.has(topLevelStatus)) { + return "environmental"; + } + + if (isConnectivityError(error)) { + return "environmental"; + } + + // Default: any unrecognized failure or no status (DELETED/DELETING messages, + // timeout messages, plain Error objects) → environmental. + return "environmental"; +} + +/** + * Coarse cause label for an environmental failure, used by the `--wait` + * committed-types warning so the log says *why* generation fell back. + * + * Returns: + * - "unreachable": transport/connectivity failure (see {@link isConnectivityError}). + * - "auth": HTTP 401/403, including a status carried on `response.status` or + * wrapped in a `cause`/`AggregateError` chain. + * - "unavailable": everything else (DELETED/DELETING, wait timeouts, degraded + * DESCRIBEs). + */ +export function classifyEnvironmentalCause( + error: unknown, +): "auth" | "unreachable" | "unavailable" { + if (isConnectivityError(error)) return "unreachable"; + + // Walk the error chain so a wrapped 401/403 is still labeled as auth. + const seen = new Set(); + const stack = [error]; + + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined || seen.has(current)) continue; + seen.add(current); + + const status = getErrorStatus(current); + if (status !== undefined && AUTH_ERROR_STATUSES.has(status)) return "auth"; + + stack.push(...getErrorChildren(current)); + } + + return "unavailable"; +} diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 4ded190c4..286874852 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import dotenv from "dotenv"; @@ -14,7 +15,12 @@ import { metricCacheHash, saveCache, } from "./cache"; -import { getErrorDiagnostic, isConnectivityError } from "./errors"; +import { + classifyBlockingFailure, + classifyEnvironmentalCause, + getErrorDiagnostic, + isConnectivityError, +} from "./errors"; import { migrateProjectConfig, removeOldGeneratedTypes, @@ -54,12 +60,53 @@ const logger = createLogger("type-generator"); */ const MV_PREFLIGHT_WAIT_MAX_MS = 300_000; +/** + * Generate a loud warning message for environmental failures with committed types present. + * @param cause - coarse cause label: "auth" (401/403), "unreachable" (connectivity), or "unavailable" (other) + */ +function determineWarningMessage( + cause: "auth" | "unreachable" | "unavailable", + warehouseId: string, +): string { + const causeLabel = + cause === "auth" + ? "auth blocked" + : cause === "unreachable" + ? "warehouse unreachable" + : "warehouse unavailable"; + // Use a stable prefix for greppability and CI log matching. + return `AppKit typegen: using committed types — warehouse ${warehouseId} ${causeLabel}; please check warehouse status and retry`; +} + type TypegenFailure = QuerySyntaxError | QueryFatalError; function plural(count: number, singular: string, pluralForm = `${singular}s`) { return count === 1 ? singular : pluralForm; } +/** + * Check if committed type artifacts exist (at least one of the requested surfaces). + * Serving types are excluded (gitignored, never part of the gate). + * Returns true if either the analytics or metric-views committed .d.ts file exists. + */ +function hasCommittedTypes( + analyticsOutFile: string, + metricViewsOutFile: string | undefined, +): boolean { + const hasAnalytics = existsSync(analyticsOutFile); + const hasMetrics = + metricViewsOutFile !== undefined && existsSync(metricViewsOutFile); + return hasAnalytics || hasMetrics; +} + +function isQueryDegraded(schema: QuerySchema): boolean { + return schema.degraded === true; +} + +function hasAnyDegradedMetrics(schemas: MetricSchema[]): boolean { + return schemas.some((s) => s.degraded === true); +} + function formatFailureRows( label: string, queries: TypegenFailure[], @@ -321,7 +368,13 @@ export async function generateFromEntryPoint(options: { let queryRegistry: QuerySchema[] = []; let syntaxErrors: QuerySyntaxError[] = []; + // Deterministic fatal errors only (404/400). let fatalErrors: QueryFatalError[] = []; + // Track whether an environmental failure occurred in blocking mode. + let hadEnvironmentalFailure = false; + // Track the coarse cause of the environmental failure for the warning message. + let environmentalCause: "auth" | "unreachable" | "unavailable" | undefined; + if (queryFolder) { const result = await generateQueriesFromDescribe(queryFolder, warehouseId, { noCache, @@ -330,12 +383,31 @@ export async function generateFromEntryPoint(options: { queryRegistry = result.schemas; syntaxErrors = result.syntaxErrors ?? []; fatalErrors = result.fatalErrors ?? []; + hadEnvironmentalFailure = + hadEnvironmentalFailure || (result.hadEnvironmentalFailure ?? false); + environmentalCause = + environmentalCause ?? result.environmentalCause ?? undefined; } const typeDeclarations = generateTypeDeclarations(queryRegistry); - await fs.mkdir(path.dirname(outFile), { recursive: true }); - await fs.writeFile(outFile, typeDeclarations, "utf-8"); + // In blocking mode, never overwrite committed types with a schema explicitly + // marked degraded. Leave the committed .d.ts as the fallback of record. + // Non-blocking mode always writes. + const hasAnyDegradedQuery = queryRegistry.some(isQueryDegraded); + if (mode === "blocking" && hasAnyDegradedQuery) { + // A degraded schema always participates in the committed-types gate. Keep + // this invariant next to write suppression so a new producer cannot update + // one decision without the other. + hadEnvironmentalFailure = true; + environmentalCause = environmentalCause ?? "unavailable"; + } + const shouldWriteQueries = mode !== "blocking" || !hasAnyDegradedQuery; + + if (shouldWriteQueries) { + await fs.mkdir(path.dirname(outFile), { recursive: true }); + await fs.writeFile(outFile, typeDeclarations, "utf-8"); + } // Metric-view types: emit whenever a metric-views folder is resolved (gated // on the metric config's own dir, NOT the queries folder — an app can declare @@ -354,6 +426,10 @@ export async function generateFromEntryPoint(options: { cache: !noCache, metricFetcher, mode, + // In blocking mode, never overwrite committed metric types with a + // degraded result — including on runs that go on to throw, so a failing + // build leaves the committed .d.ts intact. Non-blocking always writes. + suppressDegradedWrite: mode === "blocking", }); } catch (configError) { // syncMetricViewsTypes only throws for a malformed definitions.json — re-throw as a message-only TypegenFatalError. @@ -370,14 +446,24 @@ export async function generateFromEntryPoint(options: { // Deleted/deleting-warehouse fatal preflight (blocking mode only); // empty (no-op) when definitions.json is absent or in non-blocking mode. + // Only deterministic fatals are recorded in fatalErrors. for (const fe of mvResult.fatalErrors) { fatalErrors.push(fe); } - // Blocking (`--wait` / prod Vite) escalates per-key DESCRIBE failures — a bad or unreachable source, i.e. a config error - // to build failures so the end-of-run throw fails after the writes. + // Thread through the environmental failure flag and cause. + hadEnvironmentalFailure = + hadEnvironmentalFailure || (mvResult.hadEnvironmentalFailure ?? false); + environmentalCause = + environmentalCause ?? mvResult.environmentalCause ?? undefined; + + // Blocking (`--wait` / prod Vite) escalates only deterministic per-key + // DESCRIBE failures. Transient connectivity failures are already recorded + // as environmental by syncMetricViewsTypes and fall through to the + // committed-types gate below. if (mode === "blocking") { for (const failure of mvResult.failures) { + if (failure.transient) continue; fatalErrors.push({ name: failure.key, message: `metric view ${failure.key} (${failure.source}) could not be described: ${failure.reason}`, @@ -389,7 +475,7 @@ export async function generateFromEntryPoint(options: { await removeOldGeneratedTypes(projectRoot, "appKitTypes.d.ts"); await migrateProjectConfig(projectRoot); - // Types are always written above — including `result: unknown` for any Metric View that could not be described. + // Deterministic failures (SQL syntax errors or 404/400 HTTP) always crash regardless of mode. if (syntaxErrors.length > 0) { throw new TypegenSyntaxError(syntaxErrors, warehouseId, fatalErrors); } @@ -397,6 +483,35 @@ export async function generateFromEntryPoint(options: { throw new TypegenFatalError(fatalErrors, warehouseId); } + // Environmental failures (in blocking mode) trigger the has-types gate. + if (mode === "blocking" && hadEnvironmentalFailure) { + // Determine resolved metric-views file for the has-types check. + const resolvedMvFile = + options.mvOutFile ?? path.join(path.dirname(outFile), METRIC_TYPES_FILE); + + const hasTypes = hasCommittedTypes(outFile, resolvedMvFile); + + if (hasTypes) { + // Committed types present: emit loud warning and exit 0. + const warningMessage = determineWarningMessage( + environmentalCause ?? "unavailable", + warehouseId, + ); + logger.warn(warningMessage); + } else { + // No committed types: crash with a generic message. + throw new TypegenFatalError( + [ + { + name: "type-generator", + message: `Warehouse ${warehouseId} could not be reached and no committed types exist. Run 'npx @databricks/appkit generate-types --wait' locally and commit the generated .d.ts files.`, + }, + ], + warehouseId, + ); + } + } + logger.debug("Type generation complete!"); } @@ -416,12 +531,28 @@ export interface SyncMetricViewsTypesResult { noConfig: boolean; /** * Per-key fatal preflight errors (empty except in the `blocking`-mode - * deleted/deleting-warehouse and deterministic-preflight-failure cases). The - * artifacts are still written; {@link generateFromEntryPoint} surfaces these - * by throwing {@link TypegenFatalError} after the writes. A `"describe-now"` + * deleted/deleting-warehouse and deterministic-preflight-failure cases). + * {@link generateFromEntryPoint} surfaces these by throwing + * {@link TypegenFatalError}; when the run also degraded, `suppressDegradedWrite` + * means no artifact was written and the committed types stand. A `"describe-now"` * run sets no blocking preflight, so for that mode this is always empty. + * ONLY contains deterministic failures (404/400). */ fatalErrors: Array<{ name: string; message: string }>; + /** + * `true` when an environmental failure occurred in blocking mode (auth, connectivity, + * DELETED/DELETING, wait-timeout, or other unrecognized failures). Used by + * {@link generateFromEntryPoint} to decide whether to apply the has-types gate. + * Does not directly cause a throw — the gate decides that. Always false in + * non-blocking or describe-now mode. + */ + hadEnvironmentalFailure?: boolean; + /** + * Coarse cause label for the environmental failure, one of "auth" (401/403), + * "unreachable" (connectivity), or "unavailable" (other). Only set when + * hadEnvironmentalFailure is true; used by the warning message. + */ + environmentalCause?: "auth" | "unreachable" | "unavailable"; } /** @@ -436,7 +567,11 @@ export interface SyncMetricViewsTypesResult { * @param options.metricOutFile - output path for the MetricRegistry `.d.ts`. * @param options.cache - cache toggle, default ON. Only `cache === false` disables it (so `undefined`/`true` keep caching). * @param options.metricFetcher - optional injected {@link DescribeFetcher} - * @param options.mode - preflight/gate policy, default `"describe-now"`. + * @param options.mode - preflight/gate policy, default `"describe-now"`. When set to `"blocking"`, + * metric-view .d.ts writes are suppressed if any metric is degraded (to preserve committed files). + * @param options.suppressDegradedWrite - when true (only in `mode === "blocking"` context), skip + * the metricOutFile write if any metric schema has `degraded === true`. Used to prevent + * overwriting committed .d.ts files with degraded types in blocking mode. */ export async function syncMetricViewsTypes(options: { metricViewsFolder: string; @@ -445,6 +580,7 @@ export async function syncMetricViewsTypes(options: { cache?: boolean; metricFetcher?: DescribeFetcher; mode?: "describe-now" | "non-blocking" | "blocking"; + suppressDegradedWrite?: boolean; }): Promise { const { metricViewsFolder, @@ -453,6 +589,7 @@ export async function syncMetricViewsTypes(options: { cache: cacheEnabled, metricFetcher, mode = "describe-now", + suppressDegradedWrite, } = options; // Only `cache === false` disables caching; `undefined`/`true` keep it on. @@ -513,6 +650,8 @@ export async function syncMetricViewsTypes(options: { // Blocking-mode preflight: ensure the warehouse is running before the MV DESCRIBE // batch (probe → decide → wait / start+wait; only DELETED/DELETING is fatal). Two softenings vs the query preflight: a failed probe and a timed-out wait are NOT fatal here — we fall through to syncMetrics, which classifies a still-not-ready warehouse as degraded rather than failing the build. Skipped for `describe-now`/`non-blocking` (only `mode === "blocking"` enters here). let preflightFatalMessage: string | undefined; + let hadEnvironmentalFailure = false; + let environmentalCause: "auth" | "unreachable" | "unavailable" | undefined; if ( mode === "blocking" && metricFetcher === undefined && @@ -523,6 +662,9 @@ export async function syncMetricViewsTypes(options: { const decision = decidePreflight(state, mode); if (decision === "fatal") { preflightFatalMessage = `warehouse ${warehouseId} is ${state}`; + // State-based DELETED/DELETING is environmental, not deterministic. + hadEnvironmentalFailure = true; + environmentalCause = "unavailable"; } else if (decision === "startWaitProceed") { // treatStoppedAsTransient rides out the stale pre-start STOPPED/STOPPING // reading, same as the query preflight. @@ -535,21 +677,35 @@ export async function syncMetricViewsTypes(options: { // With treatStoppedAsTransient, a non-RUNNING resolve is exactly // DELETED/DELETING — the warehouse was deleted while we waited. preflightFatalMessage = `warehouse ${warehouseId} is ${settled}`; + hadEnvironmentalFailure = true; } } else if (decision === "waitThenProceed") { const settled = await waitUntilRunning(getMvClient(), warehouseId, { maxMs: MV_PREFLIGHT_WAIT_MAX_MS, }); if (settled === "DELETED" || settled === "DELETING") { - // Deleted mid-wait: fatal. + // Deleted mid-wait: fatal. Environmental (state-based). preflightFatalMessage = `warehouse ${warehouseId} is ${settled}`; + hadEnvironmentalFailure = true; } } } catch (err) { // Connectivity blip: fall through to syncMetrics, whose DESCRIBEs degrade // a not-ready / unreachable warehouse rather than throwing. if (!isConnectivityError(err)) { - preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + // Classify: deterministic (404/400) or environmental (auth, etc). + const classification = classifyBlockingFailure(err); + if (classification === "deterministic") { + // Keep as fatal preflight for deterministic errors (404/400). + preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + } else { + // Environmental: set preflightFatalMessage so DESCRIBE is skipped, but + // mark hadEnvironmentalFailure so the gate handles it later (not added + // to fatalErrors). + preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + hadEnvironmentalFailure = true; + environmentalCause = classifyEnvironmentalCause(err); + } } } } @@ -571,14 +727,19 @@ export async function syncMetricViewsTypes(options: { let described: MetricSchema[]; let failures: MetricSyncFailure[] = []; if (preflightFatalMessage !== undefined) { - // Fatal preflight (deleted/deleting warehouse): fail like the query path — + // Fatal preflight (deleted/deleting warehouse or deterministic error): // skip DESCRIBE, emit degraded schemas so both artifacts are still written, // and record one fatal error per describe-needed key (cache hits are - // unaffected). The caller surfaces them after the writes. The degraded - // schemas are not cached (see the write block), so a later pass re-probes. + // unaffected) ONLY if it's a deterministic error. Environmental failures + // degrade silently. The degraded schemas are not cached (see the write + // block), so a later pass re-probes. described = describeNeeded.map(emptyMetricSchema); - for (const entry of describeNeeded) { - fatalErrors.push({ name: entry.key, message: preflightFatalMessage }); + // Only deterministic fatals (404/400) record errors; environmental failures + // degrade silently for the has-types gate to handle. + if (!hadEnvironmentalFailure) { + for (const entry of describeNeeded) { + fatalErrors.push({ name: entry.key, message: preflightFatalMessage }); + } } } else if (describeNeeded.length === 0) { // Nothing left to describe — every configured key was a cache hit. @@ -609,6 +770,14 @@ export async function syncMetricViewsTypes(options: { } } + // A rejected DESCRIBE with a connectivity signal is expected to recover on + // a later pass. In blocking mode, route it through the same committed-types + // gate as preflight outages instead of treating it as a configuration error. + if (mode === "blocking" && failures.some((failure) => failure.transient)) { + hadEnvironmentalFailure = true; + environmentalCause = environmentalCause ?? "unreachable"; + } + // Degraded-but-not-failed keys: the warehouse answered with a non-terminal // state (stopped / cold-starting), so their schemas are unknown. const failedKeys = new Set(failures.map((f) => f.key)); @@ -622,11 +791,13 @@ export async function syncMetricViewsTypes(options: { degradedKeys.length, degradedKeys.join(", "), ); + hadEnvironmentalFailure = true; + environmentalCause = environmentalCause ?? "unavailable"; } } else { // Un-probed DESCRIBEs deliberately skipped, not failures: emit each // describe-needed key as a degraded schema so both artifacts exist; cache - // hits keep serving last-known-good. + // hits keep serving last-known-good. This is an environmental failure path. described = describeNeeded.map(emptyMetricSchema); logger.info( "Warehouse %s is not running — wrote degraded metric types (permissive) for %d metric view(s) (%s); they will refresh once the warehouse is available.", @@ -634,6 +805,8 @@ export async function syncMetricViewsTypes(options: { describeNeeded.length, describeNeeded.map((e) => e.key).join(", "), ); + hadEnvironmentalFailure = true; + environmentalCause = environmentalCause ?? "unavailable"; } // Cache only successful schema results for describe-needed keys; remove stale cache for degraded ones. @@ -686,12 +859,20 @@ export async function syncMetricViewsTypes(options: { return emptyMetricSchema(entry); }); - await fs.mkdir(path.dirname(metricOutFile), { recursive: true }); - await fs.writeFile( - metricOutFile, - generateMetricTypeDeclarations(schemas), - "utf-8", - ); + // Same anti-clobber rule as the query path: when suppressDegradedWrite is set + // (blocking mode), skip the write if any metric degraded, preserving the + // committed metric-views.d.ts. Non-blocking mode always writes. + const shouldWriteMetrics = + !suppressDegradedWrite || !hasAnyDegradedMetrics(schemas); + + if (shouldWriteMetrics) { + await fs.mkdir(path.dirname(metricOutFile), { recursive: true }); + await fs.writeFile( + metricOutFile, + generateMetricTypeDeclarations(schemas), + "utf-8", + ); + } logger.debug( "Wrote MetricRegistry augmentation for %d metric(s)%s", @@ -705,6 +886,12 @@ export async function syncMetricViewsTypes(options: { failures, fatalErrors, noConfig: false, + hadEnvironmentalFailure: + mode === "blocking" ? hadEnvironmentalFailure : undefined, + environmentalCause: + mode === "blocking" && hadEnvironmentalFailure + ? environmentalCause + : undefined, }; } diff --git a/packages/appkit/src/type-generator/query-registry.ts b/packages/appkit/src/type-generator/query-registry.ts index 0dd1f010b..720c61443 100644 --- a/packages/appkit/src/type-generator/query-registry.ts +++ b/packages/appkit/src/type-generator/query-registry.ts @@ -5,7 +5,12 @@ import pc from "picocolors"; import { createLogger } from "../logging/logger"; import { createWorkspaceClient } from "../workspace-client"; import { CACHE_VERSION, hashSQL, loadCache, saveCache } from "./cache"; -import { getErrorDiagnostic, isConnectivityError } from "./errors"; +import { + classifyBlockingFailure, + classifyEnvironmentalCause, + getErrorDiagnostic, + isConnectivityError, +} from "./errors"; import { decidePreflight, type PreflightMode } from "./preflight"; import { Spinner } from "./spinner"; import { type DescribeFormatMemo, describeAdaptive } from "./statement-result"; @@ -272,12 +277,15 @@ function degradedType( queryName: string, sql: string, sqlHash: string, -): string { +): Pick { const prior = cache.queries[queryName]; const canReusePrior = prior?.hash === sqlHash && !prior.retry; return canReusePrior - ? prior.type - : generateUnknownResultQuery(sql, queryName); + ? { type: prior.type } + : { + type: generateUnknownResultQuery(sql, queryName), + degraded: true, + }; } // Single source of truth for the `@param` type alternation, shared by @@ -675,7 +683,14 @@ export async function generateQueriesFromDescribe( // Genuine SQL errors (reachable warehouse). Connectivity failures are NOT // recorded here — they degrade silently so a transient outage isn't fatal. const syntaxErrors: QuerySyntaxError[] = []; + // Deterministic fatal errors only (404/400). Environmental failures are + // tracked separately below. const fatalErrors: QueryFatalError[] = []; + // Track whether an environmental failure occurred in blocking mode (for the + // has-types gate in generateFromEntryPoint), plus its coarse cause so the + // gate's warning can say why generation fell back to committed types. + let hadEnvironmentalFailure = false; + let environmentalCause: "auth" | "unreachable" | "unavailable" | undefined; if (uncachedQueries.length > 0) { // One-time warehouse preflight (before issuing any DESCRIBE). A single @@ -685,6 +700,9 @@ export async function generateQueriesFromDescribe( // not-ready warehouse degrades exactly like a per-query outage. let decision: ReturnType = "proceed"; let fatalMessage = ""; + // Track whether an environmental failure occurred so the caller's has-types + // gate can decide crash-vs-fall-back. + let isEnvironmental = false; if (mode === "non-blocking") { // `non-blocking` never describes and must make ZERO warehouse round-trips: // skip the probe entirely (no getWarehouseState) and go straight to @@ -697,7 +715,10 @@ export async function generateQueriesFromDescribe( const state = await getWarehouseState(client, warehouseId); decision = decidePreflight(state, mode); if (decision === "fatal") { + // DELETED/DELETING is state-based and environmental. fatalMessage = `warehouse ${warehouseId} is ${state}`; + isEnvironmental = true; + environmentalCause = "unavailable"; } if (decision === "startWaitProceed") { // Stopped/stopping warehouse: nudge it out of the stopped state, then @@ -714,6 +735,8 @@ export async function generateQueriesFromDescribe( } else { decision = "fatal"; fatalMessage = `warehouse ${warehouseId} did not reach RUNNING (now ${final})`; + isEnvironmental = true; // DELETED/DELETING or timeout is environmental + environmentalCause = "unavailable"; } } if (decision === "waitThenProceed") { @@ -725,35 +748,67 @@ export async function generateQueriesFromDescribe( } else { decision = "fatal"; fatalMessage = `warehouse ${warehouseId} did not reach RUNNING (now ${final})`; + isEnvironmental = true; // DELETED/DELETING or timeout is environmental + environmentalCause = "unavailable"; } } } catch (err) { if (isConnectivityError(err)) { - // Warehouse unreachable (transient outage): degrade silently like a - // per-query connectivity failure — never fail a build on a blip. + // Warehouse unreachable (transient outage): degrade rather than fail — + // never fail a build on a blip. Still environmental, so the caller's + // has-types gate decides warn-and-fall-back (committed types present) + // vs crash (fresh checkout with nothing to fall back to). decision = "degradeAll"; + isEnvironmental = true; + environmentalCause = "unreachable"; } else { - // Auth, bad warehouse id, malformed config, or a timed-out wait: fatal. - decision = "fatal"; - fatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + // Classify the exception: deterministic (404/400) or environmental (auth, etc). + const classification = classifyBlockingFailure(err); + if (classification === "deterministic") { + // Build-failing deterministic error (bad warehouse id, malformed request). + decision = "fatal"; + fatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + isEnvironmental = false; + } else { + // Environmental: auth, timeouts, unrecognized, etc. Degrade for the + // has-types gate to handle later. + decision = "degradeAll"; + isEnvironmental = true; + environmentalCause = classifyEnvironmentalCause(err); + fatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + } } } } + // Record blocking-mode environmental failures for the has-types gate. + if ( + mode === "blocking" && + ((decision === "degradeAll" && isEnvironmental) || + (decision === "fatal" && isEnvironmental)) + ) { + hadEnvironmentalFailure = true; + } + if (decision !== "proceed") { // degradeAll or fatal: skip DESCRIBE entirely. Every uncached query gets a // degraded schema (reused cache or `unknown`); fatal additionally records - // a fatalError per query so the caller fails the build after writing. - const kind = decision === "fatal" ? "fatal" : "connectivity"; + // a fatalError per query so the caller fails the build after writing. Only + // deterministic fatals are recorded; environmental degradations go silent + // so the has-types gate can decide. + const kind = + decision === "fatal" && !isEnvironmental ? "fatal" : "connectivity"; for (const { index, queryName, sql, sqlHash } of uncachedQueries) { freshResults.push({ index, schema: { name: queryName, - type: degradedType(cache, queryName, sql, sqlHash), + ...degradedType(cache, queryName, sql, sqlHash), }, }); - if (decision === "fatal") { + if (decision === "fatal" && !isEnvironmental) { + // Only deterministic fatals record an error; environmental failures + // degrade silently for the has-types gate. fatalErrors.push({ name: queryName, message: fatalMessage }); logEntries.push({ queryName, @@ -819,7 +874,7 @@ export async function generateQueriesFromDescribe( return { status: "syntax", index, - schema: { name: queryName, type }, + schema: { name: queryName, type, degraded: true }, error: withIdentifierHint(parseError(sqlError), sql), }; } @@ -836,7 +891,7 @@ export async function generateQueriesFromDescribe( index, schema: { name: queryName, - type: degradedType(cache, queryName, sql, sqlHash), + ...degradedType(cache, queryName, sql, sqlHash), }, }; } @@ -845,7 +900,11 @@ export async function generateQueriesFromDescribe( if (!hasResults) { // Described, but no result columns. Emit `unknown` and retry next run; // do not cache (we never persist `result: unknown`). - return { status: "empty", index, schema: { name: queryName, type } }; + return { + status: "empty", + index, + schema: { name: queryName, type, degraded: true }, + }; } return { status: "ok", @@ -892,6 +951,10 @@ export async function generateQueriesFromDescribe( // status === "unavailable": non-terminal DESCRIBE (warehouse // stopped/cold-starting/busy). Degrade like a transient outage: // tag OFFLINE, count as degraded, never cache. + if (mode === "blocking") { + hadEnvironmentalFailure = true; + environmentalCause = environmentalCause ?? "unavailable"; + } logEntries.push({ queryName, status: "MISS", @@ -914,8 +977,11 @@ export async function generateQueriesFromDescribe( const priorEntry = cache.queries[queryName]; const canReusePrior = priorEntry?.hash === sqlHash && !priorEntry.retry; - const type = degradedType(cache, queryName, sql, sqlHash); - freshResults.push({ index, schema: { name: queryName, type } }); + const degraded = degradedType(cache, queryName, sql, sqlHash); + freshResults.push({ + index, + schema: { name: queryName, ...degraded }, + }); if (!isConnectivityError(entry.reason)) { fatalErrors.push({ name: queryName, message: error.message }); @@ -928,6 +994,13 @@ export async function generateQueriesFromDescribe( continue; } + // Environmental for the same reason as the preflight connectivity + // branch above, so the has-types gate still sees it. + if (mode === "blocking") { + hadEnvironmentalFailure = true; + environmentalCause = environmentalCause ?? "unreachable"; + } + logger.warn( "DESCRIBE unreachable for %s: %s — %s", queryName, @@ -1039,7 +1112,15 @@ export async function generateQueriesFromDescribe( .sort((a, b) => a.index - b.index) .map((r) => r.schema); - return { schemas, syntaxErrors, fatalErrors }; + return { + schemas, + syntaxErrors, + fatalErrors, + hadEnvironmentalFailure, + environmentalCause: hadEnvironmentalFailure + ? environmentalCause + : undefined, + }; } /** diff --git a/packages/appkit/src/type-generator/tests/errors.test.ts b/packages/appkit/src/type-generator/tests/errors.test.ts new file mode 100644 index 000000000..a978cde0d --- /dev/null +++ b/packages/appkit/src/type-generator/tests/errors.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from "vitest"; +import { classifyBlockingFailure, classifyEnvironmentalCause } from "../errors"; + +describe("classifyBlockingFailure", () => { + describe("deterministic failures", () => { + it("classifies HTTP 400 as deterministic", () => { + const error = Object.assign(new Error("Bad request"), { status: 400 }); + expect(classifyBlockingFailure(error)).toBe("deterministic"); + }); + + it("classifies HTTP 404 as deterministic", () => { + const error = Object.assign(new Error("Not found"), { status: 404 }); + expect(classifyBlockingFailure(error)).toBe("deterministic"); + }); + + it("classifies HTTP 404 from response.status as deterministic", () => { + const error = Object.assign(new Error("Not found"), { + response: { status: 404 }, + }); + expect(classifyBlockingFailure(error)).toBe("deterministic"); + }); + + it("classifies HTTP 404 from statusCode as deterministic", () => { + const error = Object.assign(new Error("Not found"), { statusCode: 404 }); + expect(classifyBlockingFailure(error)).toBe("deterministic"); + }); + }); + + describe("environmental failures - auth", () => { + it("classifies HTTP 401 as environmental", () => { + const error = Object.assign(new Error("Unauthorized"), { status: 401 }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies HTTP 403 as environmental", () => { + const error = Object.assign(new Error("Forbidden"), { status: 403 }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - other HTTP statuses", () => { + it("classifies HTTP 500 as environmental (not in deterministic set)", () => { + const error = Object.assign(new Error("Internal server error"), { + status: 500, + }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies HTTP 502 as environmental (via connectivity)", () => { + const error = Object.assign(new Error("Bad gateway"), { status: 502 }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies HTTP 503 as environmental (via connectivity)", () => { + const error = Object.assign(new Error("Service unavailable"), { + status: 503, + }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies HTTP 504 as environmental (via connectivity)", () => { + const error = Object.assign(new Error("Gateway timeout"), { + status: 504, + }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - connectivity codes", () => { + it("classifies ECONNREFUSED as environmental", () => { + const error = Object.assign(new Error("Connection refused"), { + code: "ECONNREFUSED", + }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies ENOTFOUND as environmental", () => { + const error = Object.assign(new Error("ENOTFOUND"), { + code: "ENOTFOUND", + }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies ETIMEDOUT as environmental", () => { + const error = Object.assign(new Error("Timed out"), { + code: "ETIMEDOUT", + }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies ECONNRESET as environmental", () => { + const error = Object.assign(new Error("Connection reset"), { + code: "ECONNRESET", + }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies UND_ERR_* codes as environmental", () => { + const error = Object.assign(new Error("undici error"), { + code: "UND_ERR_ABORTED", + }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - TLS codes", () => { + it("classifies CERT_HAS_EXPIRED as environmental", () => { + const error = Object.assign(new Error("Certificate has expired"), { + code: "CERT_HAS_EXPIRED", + }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies DEPTH_ZERO_SELF_SIGNED_CERT as environmental", () => { + const error = Object.assign(new Error("Self signed cert"), { + code: "DEPTH_ZERO_SELF_SIGNED_CERT", + }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - connectivity messages", () => { + it("classifies connection refused message as environmental", () => { + const error = new Error("connection refused"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies socket hang up message as environmental", () => { + const error = new Error("socket hang up"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies network error message as environmental", () => { + const error = new Error("network error"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies certificate has expired message as environmental", () => { + const error = new Error("certificate has expired"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - warehouse state messages", () => { + it("classifies DELETED warehouse error as environmental", () => { + const error = new Error("warehouse has been DELETED"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies DELETING warehouse error as environmental", () => { + const error = new Error("warehouse is DELETING"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - timeout messages", () => { + it("classifies wait-for-RUNNING timeout as environmental", () => { + const error = new Error( + "warehouse did not reach RUNNING within 300000ms", + ); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - unrecognized errors", () => { + it("classifies plain Error with no status as environmental (default)", () => { + const error = new Error("boom"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies plain object error as environmental", () => { + const error = { message: "something went wrong" }; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies null as environmental", () => { + expect(classifyBlockingFailure(null)).toBe("environmental"); + }); + + it("classifies undefined as environmental", () => { + expect(classifyBlockingFailure(undefined)).toBe("environmental"); + }); + }); + + describe("wrapped errors", () => { + it("classifies deterministic status (404) nested under .cause as deterministic", () => { + const causedError = Object.assign(new Error("Not found"), { + status: 404, + }); + const error = Object.assign(new Error("Outer error"), { + cause: causedError, + }); + expect(classifyBlockingFailure(error)).toBe("deterministic"); + }); + + it("classifies connectivity code nested under .cause as environmental", () => { + const causedError = Object.assign(new Error("Connection refused"), { + code: "ECONNREFUSED", + }); + const error = Object.assign(new Error("Outer error"), { + cause: causedError, + }); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies AggregateError with 404 as deterministic", () => { + const statusError = Object.assign(new Error("Not found"), { + status: 404, + }); + const aggregateError = new AggregateError( + [statusError], + "Multiple errors", + ); + expect(classifyBlockingFailure(aggregateError)).toBe("deterministic"); + }); + }); + + describe("purity", () => { + it("returns the same classification when called twice with the same input", () => { + const error = Object.assign(new Error("Not found"), { status: 404 }); + + const result1 = classifyBlockingFailure(error); + const result2 = classifyBlockingFailure(error); + + expect(result1).toBe(result2); + expect(result1).toBe("deterministic"); + }); + + it("returns the same classification for equivalent errors", () => { + const error1 = Object.assign(new Error("Connection refused"), { + code: "ECONNREFUSED", + }); + const error2 = Object.assign(new Error("Connection refused"), { + code: "ECONNREFUSED", + }); + + expect(classifyBlockingFailure(error1)).toBe( + classifyBlockingFailure(error2), + ); + expect(classifyBlockingFailure(error1)).toBe("environmental"); + }); + }); +}); + +describe("classifyEnvironmentalCause", () => { + it("labels connectivity failures as unreachable", () => { + const error = Object.assign(new Error("connect ECONNREFUSED"), { + code: "ECONNREFUSED", + }); + expect(classifyEnvironmentalCause(error)).toBe("unreachable"); + }); + + it.each([401, 403])("labels HTTP %i as auth", (status) => { + const error = Object.assign(new Error("Denied"), { status }); + expect(classifyEnvironmentalCause(error)).toBe("auth"); + }); + + it("labels auth status carried on statusCode", () => { + const error = Object.assign(new Error("Denied"), { statusCode: 403 }); + expect(classifyEnvironmentalCause(error)).toBe("auth"); + }); + + it("labels auth status carried on response.status", () => { + const error = Object.assign(new Error("Denied"), { + response: { status: 401 }, + }); + expect(classifyEnvironmentalCause(error)).toBe("auth"); + }); + + it("labels an auth status wrapped in a cause chain", () => { + const error = new Error("Request failed", { + cause: Object.assign(new Error("Denied"), { status: 403 }), + }); + expect(classifyEnvironmentalCause(error)).toBe("auth"); + }); + + it("prefers unreachable when a failure is both connectivity and status-bearing", () => { + // 503 is connectivity; the label should describe the transport problem. + const error = Object.assign(new Error("Service unavailable"), { + status: 503, + }); + expect(classifyEnvironmentalCause(error)).toBe("unreachable"); + }); + + it.each([ + ["a warehouse state message", new Error("warehouse wh-1 is DELETED")], + ["a plain error", new Error("something went wrong")], + ["a non-auth status", Object.assign(new Error("teapot"), { status: 418 })], + ["a non-object", "just a string"], + ])("labels %s as unavailable", (_name, error) => { + expect(classifyEnvironmentalCause(error)).toBe("unavailable"); + }); +}); diff --git a/packages/appkit/src/type-generator/tests/generate-queries.test.ts b/packages/appkit/src/type-generator/tests/generate-queries.test.ts index 48ff4fb68..3a5d3e9a4 100644 --- a/packages/appkit/src/type-generator/tests/generate-queries.test.ts +++ b/packages/appkit/src/type-generator/tests/generate-queries.test.ts @@ -146,6 +146,7 @@ describe("generateQueriesFromDescribe", () => { expect(schemas[0].name).toBe("users"); expect(schemas[0].type).toContain("id: number"); expect(schemas[0].type).toContain("name: string"); + expect(schemas[0].degraded).toBeUndefined(); expect(mocks.spinnerStop).toHaveBeenCalledWith(""); expect(mocks.saveCache).toHaveBeenCalledTimes(1); // clean success: cached, and not flagged as a syntax error @@ -609,6 +610,7 @@ describe("generateQueriesFromDescribe", () => { ); expect(schemas[0].type).toContain("result: unknown"); + expect(schemas[0].degraded).toBe(true); expect(syntaxErrors).toEqual([]); expect(lastSavedQueries()).not.toHaveProperty("empty"); }); @@ -624,16 +626,21 @@ describe("generateQueriesFromDescribe", () => { status: { state: "PENDING" }, }); - const { schemas, syntaxErrors, fatalErrors } = await describeQueries( - "/queries", - "wh-123", - ); + const { + schemas, + syntaxErrors, + fatalErrors, + hadEnvironmentalFailure, + environmentalCause, + } = await describeQueries("/queries", "wh-123"); expect(schemas).toHaveLength(1); expect(schemas[0].name).toBe("users"); expect(schemas[0].type).toContain("result: unknown"); expect(syntaxErrors).toEqual([]); expect(fatalErrors).toEqual([]); + expect(hadEnvironmentalFailure).toBe(true); + expect(environmentalCause).toBe("unavailable"); // a non-ready warehouse must never persist `result: unknown` expect(lastSavedQueries()).not.toHaveProperty("users"); }); @@ -779,44 +786,47 @@ describe("generateQueriesFromDescribe", () => { }); test.each(["DELETED", "DELETING"] as const)( - "%s + blocking mode — fatal per query after schemas are written, never describes", + "%s + blocking mode — environmental, degrades silently, hadEnvironmentalFailure set for gate", async (state) => { + // DELETED/DELETING are environmental (state-based fatals). They degrade + // silently (fatalErrors empty) but set hadEnvironmentalFailure for the + // entry point's has-types gate to handle. mocks.readdir.mockResolvedValue(["a.sql", "b.sql"]); mocks.readFile .mockResolvedValueOnce("SELECT id FROM a") .mockResolvedValueOnce("SELECT id FROM b"); mocks.getWarehouse.mockReturnValue({ state }); - const { schemas, syntaxErrors, fatalErrors } = + const { schemas, syntaxErrors, fatalErrors, hadEnvironmentalFailure } = await generateQueriesFromDescribe("/queries", "wh-123", { mode: "blocking", }); - // A deleted/deleting warehouse is the only fatal case: never started, - // never described; one fatal entry per uncached query. + // Environmental failures degrade, not fatal at query level. expect(mocks.startWarehouse).not.toHaveBeenCalled(); expect(mocks.executeStatement).not.toHaveBeenCalled(); - expect(fatalErrors).toEqual([ - { name: "a", message: `warehouse wh-123 is ${state}` }, - { name: "b", message: `warehouse wh-123 is ${state}` }, - ]); + expect(fatalErrors).toEqual([]); // environmental, not fatal + expect(hadEnvironmentalFailure).toBe(true); // tracked for the gate expect(syntaxErrors).toEqual([]); // Schemas are still produced (degraded) so the .d.ts is written before - // generateFromEntryPoint throws on the recorded fatalErrors. + // generateFromEntryPoint uses the gate to decide throw/warn. expect(schemas).toHaveLength(2); expect(schemas[0].type).toContain("result: unknown"); expect(schemas[1].type).toContain("result: unknown"); }, ); - test("STOPPED + blocking — start succeeds but warehouse never reaches RUNNING is fatal", async () => { + test("STOPPED + blocking — start succeeds but warehouse never reaches RUNNING is environmental, degrades silently", async () => { + // A wait timeout (non-RUNNING resolve) is environmental (state-based + // fatal). It degrades silently (fatalErrors empty) but sets + // hadEnvironmentalFailure for the gate. vi.useFakeTimers(); try { mocks.readdir.mockResolvedValue(["a.sql"]); mocks.readFile.mockResolvedValue("SELECT id FROM a"); // Preflight sees STOPPED → start fires, but the warehouse then reports // DELETED (a genuinely terminal state even with treatStoppedAsTransient). - // The wait resolves non-RUNNING → fatal; schemas still written. + // The wait resolves non-RUNNING → environmental; schemas still written. mocks.getWarehouse .mockReturnValueOnce({ state: "STOPPED" }) .mockReturnValue({ state: "DELETED" }); @@ -825,17 +835,14 @@ describe("generateQueriesFromDescribe", () => { mode: "blocking", }); await vi.runAllTimersAsync(); - const { schemas, syntaxErrors, fatalErrors } = await promise; + const { schemas, syntaxErrors, fatalErrors, hadEnvironmentalFailure } = + await promise; expect(mocks.startWarehouse).toHaveBeenCalledTimes(1); expect(mocks.executeStatement).not.toHaveBeenCalled(); expect(syntaxErrors).toEqual([]); - expect(fatalErrors).toEqual([ - { - name: "a", - message: "warehouse wh-123 did not reach RUNNING (now DELETED)", - }, - ]); + expect(fatalErrors).toEqual([]); // environmental, not fatal + expect(hadEnvironmentalFailure).toBe(true); // tracked for the gate expect(schemas[0].type).toContain("result: unknown"); } finally { vi.useRealTimers(); @@ -896,7 +903,7 @@ describe("generateQueriesFromDescribe", () => { } }); - test("preflight connectivity error — degradeAll, never describes", async () => { + test("preflight connectivity error — degradeAll, never describes, flagged environmental for the gate", async () => { mocks.readdir.mockResolvedValue(["a.sql"]); mocks.readFile.mockResolvedValue("SELECT id FROM a"); mocks.getWarehouse.mockImplementation(() => { @@ -906,16 +913,88 @@ describe("generateQueriesFromDescribe", () => { ); }); - const { schemas, syntaxErrors, fatalErrors } = + const { + schemas, + syntaxErrors, + fatalErrors, + hadEnvironmentalFailure, + environmentalCause, + } = await generateQueriesFromDescribe("/queries", "wh-123", { + mode: "blocking", + }); + + // Without the environmental flag a fresh checkout would exit 0 having + // written no types at all: degraded queries suppress the write, and + // nothing else fails the run. + expect(mocks.executeStatement).not.toHaveBeenCalled(); + expect(fatalErrors).toEqual([]); + expect(syntaxErrors).toEqual([]); + expect(schemas[0].type).toContain("result: unknown"); + expect(hadEnvironmentalFailure).toBe(true); + expect(environmentalCause).toBe("unreachable"); + }); + + test("preflight auth error — environmentalCause is auth, including on response.status", async () => { + mocks.readdir.mockResolvedValue(["a.sql"]); + mocks.readFile.mockResolvedValue("SELECT id FROM a"); + // Status carried on `response.status` rather than `status` — some HTTP + // clients report it there, and it must still label as auth. + mocks.getWarehouse.mockImplementation(() => { + throw Object.assign(new Error("PERMISSION_DENIED"), { + response: { status: 403 }, + }); + }); + + const { fatalErrors, hadEnvironmentalFailure, environmentalCause } = await generateQueriesFromDescribe("/queries", "wh-123", { mode: "blocking", }); - // Unreachable warehouse degrades silently — even in blocking mode. expect(mocks.executeStatement).not.toHaveBeenCalled(); + expect(fatalErrors).toEqual([]); + expect(hadEnvironmentalFailure).toBe(true); + expect(environmentalCause).toBe("auth"); + }); + + test("per-query DESCRIBE connectivity failure is flagged environmental for the gate", async () => { + mocks.readdir.mockResolvedValue(["a.sql"]); + mocks.readFile.mockResolvedValue("SELECT id FROM a"); + mocks.getWarehouse.mockReturnValue({ state: "RUNNING" }); + mocks.executeStatement.mockRejectedValue( + Object.assign(new Error("connect ECONNREFUSED"), { + code: "ECONNREFUSED", + }), + ); + + const { + schemas, + syntaxErrors, + fatalErrors, + hadEnvironmentalFailure, + environmentalCause, + } = await generateQueriesFromDescribe("/queries", "wh-123", { + mode: "blocking", + }); + expect(fatalErrors).toEqual([]); expect(syntaxErrors).toEqual([]); expect(schemas[0].type).toContain("result: unknown"); + expect(hadEnvironmentalFailure).toBe(true); + expect(environmentalCause).toBe("unreachable"); + }); + + test("non-blocking mode never reports an environmental cause", async () => { + mocks.readdir.mockResolvedValue(["a.sql"]); + mocks.readFile.mockResolvedValue("SELECT id FROM a"); + + const { hadEnvironmentalFailure, environmentalCause } = + await generateQueriesFromDescribe("/queries", "wh-123", { + mode: "non-blocking", + }); + + // The gate is blocking-only; non-blocking degrades without signaling. + expect(hadEnvironmentalFailure).toBe(false); + expect(environmentalCause).toBeUndefined(); }); test("RUNNING preflight — describes normally", async () => { diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index bd9cb1074..b14bc85f6 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -107,6 +107,12 @@ const { hashSQL } = await import("../cache"); const outputDir = path.join(__dirname, "__output__"); +// Strip ANSI SGR escape sequences so warning/error messages assert as plain +// text (and match CI logs). The ESC byte is built via String.fromCharCode so +// no control character appears in a regex literal (Biome noControlCharactersInRegex). +const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); +const stripAnsi = (s: string): string => s.replace(ANSI_SGR, ""); + describe("generateFromEntryPoint", () => { beforeAll(() => { // Create output directory once before all tests @@ -162,6 +168,7 @@ describe("generateFromEntryPoint — query failure handling", () => { const unknownSchema = (name: string) => ({ name, type: `{ name: "${name}"; parameters: Record; result: unknown; }`, + degraded: true, }); beforeAll(() => { @@ -592,8 +599,41 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect((error as Error).message).toContain("revenue"); expect((error as Error).message).toContain("DESCRIBE exploded"); - // Write-first semantics: the degraded artifacts still ship before the throw. - expect(fs.existsSync(metricFile)).toBe(true); + // The degraded metric write is suppressed in blocking mode (committed types preserved). + expect(fs.existsSync(metricFile)).toBe(false); + }); + + test("blocking + transient metric DESCRIBE failure: warns and preserves committed metric types", async () => { + writeMetricConfig(); + fs.mkdirSync(path.dirname(metricFile), { recursive: true }); + const committed = "// committed metric types\n"; + fs.writeFileSync(metricFile, committed, "utf-8"); + + const unreachable = Object.assign( + new Error("connect ECONNREFUSED 10.0.0.1:443"), + { code: "ECONNREFUSED" }, + ); + mocks.getWarehouseState.mockRejectedValue(unreachable); + mocks.executeStatement.mockRejectedValue(unreachable); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }), + ).resolves.toBeUndefined(); + + const warnings = warnSpy.mock.calls.flat().map(String).join("\n"); + expect(warnings).toContain("AppKit typegen: using committed types"); + expect(warnings).toContain("warehouse unreachable"); + expect(fs.readFileSync(metricFile, "utf-8")).toBe(committed); + } finally { + warnSpy.mockRestore(); + } }); test("blocking + a non-terminal DESCRIBE (warehouse not ready): degrades, does NOT escalate", async () => { @@ -606,6 +646,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { // a per-key failure. Unlike a bad source (which `--wait` fails), a not-ready // warehouse stays a soft degrade even under `--wait`, so infra flakiness // can't break the build (mirrors the STOPPED-resolve preflight case). + // Degraded artifacts are NOT written in blocking mode when there are no failures + // (to preserve committed good types). await expect( generateFromEntryPoint({ outFile, @@ -621,10 +663,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { const warned = warnSpy.mock.calls.flat().map(String).join("\n"); expect(warned).not.toContain("metric sync failed"); - // Permissive artifacts still ship. - const declarations = fs.readFileSync(metricFile, "utf-8"); - expect(declarations).toContain('"revenue"'); - expect(declarations).toContain("measureKeys: string"); + // Degraded artifacts are suppressed, not written (to preserve committed types). + expect(fs.existsSync(metricFile)).toBe(false); } finally { warnSpy.mockRestore(); logSpy.mockRestore(); @@ -703,38 +743,34 @@ describe("generateFromEntryPoint — metric-view emission", () => { ); }); - test("blocking + DELETED: fails through the query path's fatal pathway (TypegenFatalError after artifacts are written)", async () => { + test("blocking + DELETED: environmental failure with committed types → no throw, warning emitted", async () => { + // DELETED is environmental. Since the query path writes analytics.d.ts + // (even with empty registry), committed types exist, so emit warning + return 0. writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue("DELETED"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const error = await generateFromEntryPoint({ - outFile, - queryFolder, - warehouseId: "wh-1", - mode: "blocking", - }).then( - () => { - throw new Error("expected generateFromEntryPoint to reject"); - }, - (err: unknown) => err, - ); + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }); - // Identical surfacing to a query-path fatal preflight: same error class, - // same per-name fatal entries, same message template. - expect(error).toBeInstanceOf(TypegenFatalError); - expect((error as InstanceType).queries).toEqual([ - { name: "revenue", message: "warehouse wh-1 is DELETED" }, - ]); + // Environmental failure with committed types → no throw. + // The generator returns normally (exit 0). + } finally { + warnSpy.mockRestore(); + } // A deleted warehouse is never started, waited on, or described. expect(mocks.startWarehouse).not.toHaveBeenCalled(); expect(mocks.waitUntilRunning).not.toHaveBeenCalled(); expect(mocks.executeStatement).not.toHaveBeenCalled(); - // Write-first semantics match query fatals: degraded artifacts exist. - const declarations = fs.readFileSync(metricFile, "utf-8"); - expect(declarations).toContain('"revenue"'); - expect(declarations).toContain("measureKeys: string"); + // Degraded metric artifacts are NOT written in blocking mode (committed types preserved). + expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is NEVER cached (mirrors the query path): the key is // left uncached so a later pass re-probes, and no stale/sticky entry can be @@ -743,10 +779,9 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(metrics.revenue).toBeUndefined(); }); - test("blocking + preflight wait rejects with a timeout: fatal after artifacts (no silent stall)", async () => { - // A timed-out wait is deterministic, not a connectivity blip: surface it as - // fatal rather than falling through to DESCRIBE a not-ready warehouse — the - // ~5-min stall that still "succeeds". (Hybrid: warehouse-level → fatal.) + test("blocking + preflight wait rejects with a timeout: environmental failure with committed types → no throw, warning emitted", async () => { + // Timeout is environmental. Since the query path writes analytics.d.ts, + // committed types exist, so emit warning + return 0. writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue("STARTING"); mocks.waitUntilRunning.mockRejectedValue( @@ -758,21 +793,14 @@ describe("generateFromEntryPoint — metric-view emission", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - const error = await generateFromEntryPoint({ + await generateFromEntryPoint({ outFile, queryFolder, warehouseId: "wh-1", mode: "blocking", - }).then( - () => { - throw new Error("expected generateFromEntryPoint to reject"); - }, - (err: unknown) => err, - ); - expect(error).toBeInstanceOf(TypegenFatalError); - expect((error as InstanceType).queries).toEqual( - [expect.objectContaining({ name: "revenue" })], - ); + }); + + // Environmental failure with committed types → no throw. } finally { warnSpy.mockRestore(); logSpy.mockRestore(); @@ -787,10 +815,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect.objectContaining({ maxMs: 300_000 }), ); expect(mocks.executeStatement).not.toHaveBeenCalled(); - // ... but degraded artifacts are still written before the throw. - expect(fs.readFileSync(metricFile, "utf-8")).toContain( - "measureKeys: string", - ); + // Degraded metric artifacts are NOT written in blocking mode (committed types preserved). + expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is not cached — the key stays uncached for the next // pass to re-probe. @@ -802,6 +828,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { // A non-RUNNING *resolve* (not a throw) for a startable state is soft: fall // through to DESCRIBE, which degrades on the still-cold warehouse. Only a // DELETED/DELETING resolve (or a thrown deterministic error) is fatal. + // Degraded artifacts are NOT written in blocking mode when there are no failures + // (to preserve committed good types). writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue("STARTING"); mocks.waitUntilRunning.mockResolvedValue("STOPPED"); @@ -842,11 +870,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { mocks.waitUntilRunning.mock.calls[0][2].treatStoppedAsTransient, ).toBeUndefined(); // The DESCRIBE batch still ran (fall-through), and its non-terminal answer - // degraded the key per Phase 1 semantics. + // degraded the key. expect(mocks.executeStatement).toHaveBeenCalledTimes(1); - expect(fs.readFileSync(metricFile, "utf-8")).toContain( - "measureKeys: string", - ); + // Degraded artifacts are suppressed, not written (to preserve committed types). + expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is not cached; the key stays uncached and the next // describe-capable pass re-probes it (convergence via re-describe, not via a @@ -862,8 +889,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { // STARTING probe → wait-only; a DELETED resolve is fatal there too. ["STARTING", false], ])( - "blocking + warehouse deleted mid-wait (probe read %s): fatal after artifacts, degraded outcome not cached", + "blocking + warehouse deleted mid-wait (probe read %s): environmental failure with committed types → no throw, warning emitted", async (probedState, startsWarehouse) => { + // DELETED mid-wait is environmental. Since the query path writes + // analytics.d.ts, committed types exist, so emit warning + return 0. writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue(probedState); mocks.startWarehouse.mockResolvedValue(undefined); @@ -871,24 +900,14 @@ describe("generateFromEntryPoint — metric-view emission", () => { // RESOLVES (does not throw) with the terminal state. mocks.waitUntilRunning.mockResolvedValue("DELETED"); - const error = await generateFromEntryPoint({ + await generateFromEntryPoint({ outFile, queryFolder, warehouseId: "wh-1", mode: "blocking", - }).then( - () => { - throw new Error("expected generateFromEntryPoint to reject"); - }, - (err: unknown) => err, - ); + }); - // Same fatal pathway as the decision-time DELETED: per-key entries - // with the query path's message template, thrown after the writes. - expect(error).toBeInstanceOf(TypegenFatalError); - expect((error as InstanceType).queries).toEqual( - [{ name: "revenue", message: "warehouse wh-1 is DELETED" }], - ); + // Environmental failure with committed types → no throw. expect(mocks.startWarehouse).toHaveBeenCalledTimes( startsWarehouse ? 1 : 0, @@ -896,10 +915,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { // The DESCRIBE batch is skipped — nothing can answer it. expect(mocks.executeStatement).not.toHaveBeenCalled(); - // Degraded artifacts are still written before the throw. - const declarations = fs.readFileSync(metricFile, "utf-8"); - expect(declarations).toContain('"revenue"'); - expect(declarations).toContain("measureKeys: string"); + // Degraded metric artifacts are NOT written in blocking mode (committed types preserved). + expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is not cached — no sticky entry to serve later. const metrics = @@ -1800,3 +1817,702 @@ describe("generateFromEntryPoint — metric cache section", () => { }, ); }); + +// ── Write suppression for blocking mode with degraded types ── +describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { + const antiClobberDir = path.join(__dirname, "__output_anti_clobber__"); + const queryFolder = path.join(antiClobberDir, "queries"); + const metricViewsFolder = path.join(antiClobberDir, "metric-views"); + const outFile = path.join(antiClobberDir, "generated", "analytics.d.ts"); + const metricFile = path.join( + antiClobberDir, + "generated", + "metric-views.d.ts", + ); + + const degradedQuerySchema = (name: string) => ({ + name, + type: `{ name: "${name}"; parameters: Record; result: unknown; }`, + degraded: true, + }); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.cacheFile.contents = undefined; + fs.rmSync(antiClobberDir, { recursive: true, force: true }); + fs.mkdirSync(queryFolder, { recursive: true }); + fs.mkdirSync(metricViewsFolder, { recursive: true }); + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [], + }); + }); + + afterAll(() => { + fs.rmSync(antiClobberDir, { recursive: true, force: true }); + }); + + test("blocking mode + degraded query (no errors): no write to outFile (queries .d.ts)", async () => { + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [degradedQuerySchema("offline_query")], + syntaxErrors: [], + fatalErrors: [], + }); + + // Pre-write a "good" committed file so we can verify it's NOT overwritten + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + const committedContent = + "// Committed good types\nexport const GOOD_VERSION = true;"; + fs.writeFileSync(outFile, committedContent, "utf-8"); + + // Run in blocking mode with degraded query and NO errors + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }), + ).resolves.toBeUndefined(); + + // The committed file must NOT be overwritten with degraded types + const finalContent = fs.readFileSync(outFile, "utf-8"); + expect(finalContent).toBe(committedContent); + expect(finalContent).not.toContain("offline_query"); + }); + + test("blocking mode + non-degraded query: writes to outFile normally", async () => { + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [ + { + name: "good_query", + type: `{ name: "good_query"; parameters: Record; result: Array<{ id: number; }> }`, + }, + ], + syntaxErrors: [], + fatalErrors: [], + }); + + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }), + ).resolves.toBeUndefined(); + + // File should be written with good types + const content = fs.readFileSync(outFile, "utf-8"); + expect(content).toContain("interface QueryRegistry"); + expect(content).toContain("good_query"); + }); + + test("non-blocking mode + degraded query: writes to outFile anyway", async () => { + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [degradedQuerySchema("offline_query")], + syntaxErrors: [], + fatalErrors: [], + }); + + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "non-blocking", + }), + ).resolves.toBeUndefined(); + + // In non-blocking mode, the file is written even with degraded types + const content = fs.readFileSync(outFile, "utf-8"); + expect(content).toContain("interface QueryRegistry"); + expect(content).toContain("offline_query"); + }); + + test("blocking mode + degraded metric (no failures): no write to metric-views.d.ts", async () => { + fs.writeFileSync( + path.join(metricViewsFolder, "definitions.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + + // Pre-write a "good" committed metric file + fs.mkdirSync(path.dirname(metricFile), { recursive: true }); + const committedMetricContent = + "// Committed good metric types\nexport const GOOD_METRIC = true;"; + fs.writeFileSync(metricFile, committedMetricContent, "utf-8"); + + // Inject a fetcher that returns PENDING (non-terminal, triggers degradation with NO failures) + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + metricFetcher: async () => ({ + statement_id: "stmt-mock", + status: { state: "PENDING" }, + }), + }), + ).resolves.toBeUndefined(); + + // The committed metric file must NOT be overwritten with degraded types + const finalMetricContent = fs.readFileSync(metricFile, "utf-8"); + expect(finalMetricContent).toBe(committedMetricContent); + expect(finalMetricContent).not.toContain("revenue"); + }); + + test("blocking mode + degraded query WITH syntax errors: no write to outFile, still throws", async () => { + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [degradedQuerySchema("bad_query")], + syntaxErrors: [{ name: "bad_query", message: "Table not found" }], + fatalErrors: [], + }); + + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + + const error = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }).then( + () => { + throw new Error("expected generateFromEntryPoint to reject"); + }, + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(TypegenSyntaxError); + // Degraded artifacts are NOT written in blocking mode (committed types preserved). + expect(fs.existsSync(outFile)).toBe(false); + }); + + test("blocking mode + non-degraded metric: writes to metric-views.d.ts normally", async () => { + fs.writeFileSync( + path.join(metricViewsFolder, "definitions.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + + const describeResponse: DatabricksStatementExecutionResponse = { + statement_id: "stmt-mock", + status: { state: "SUCCEEDED" }, + result: { + data_array: [ + [ + JSON.stringify({ + columns: [ + { + name: "total_revenue", + type: "DECIMAL(38,2)", + is_measure: true, + }, + { name: "region", type: "STRING", is_measure: false }, + ], + }), + ], + ], + }, + }; + + mocks.getWarehouseState.mockResolvedValue("RUNNING"); + mocks.executeStatement.mockResolvedValue(describeResponse); + + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }), + ).resolves.toBeUndefined(); + + // File should be written with good types + const content = fs.readFileSync(metricFile, "utf-8"); + expect(content).toContain("interface MetricRegistry"); + expect(content).toContain("revenue"); + expect(content).toContain('"total_revenue": number'); + }); + + test("non-blocking mode + degraded metric: writes to metric-views.d.ts anyway", async () => { + fs.writeFileSync( + path.join(metricViewsFolder, "definitions.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + + mocks.getWarehouseState.mockResolvedValue("STOPPED"); + + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "non-blocking", + }), + ).resolves.toBeUndefined(); + + // In non-blocking mode, the file is written even with degraded types + const content = fs.readFileSync(metricFile, "utf-8"); + expect(content).toContain("interface MetricRegistry"); + expect(content).toContain("revenue"); + expect(content).toContain("measureKeys: string"); // Permissive degraded type + }); + + test("blocking mode + degraded query (no syntax/fatal errors): crashes when no committed types exist", async () => { + // The explicit degraded marker must drive both write suppression and the + // committed-types gate, even if the producer omitted the legacy + // hadEnvironmentalFailure summary bit. + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [degradedQuerySchema("offline_query")], + syntaxErrors: [], + fatalErrors: [], + }); + + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }), + ).rejects.toThrow(TypegenFatalError); + + // The file was not written because the query was degraded in blocking mode + expect(fs.existsSync(outFile)).toBe(false); + }); + + test("blocking mode + degraded query WITH fatal errors (auth/bad-id): no write to outFile, still throws TypegenFatalError", async () => { + // This test covers the fresh-CI-checkout fatal-degrade clobber-prevention case: + // a degraded schema result with fatal errors (not syntax errors) should NOT write + // artifacts in blocking mode, yet should still throw TypegenFatalError. + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [ + { + name: "bad_query", + type: `{ name: "bad_query"; parameters: Record; result: unknown; }`, + degraded: true, + }, + ], + syntaxErrors: [], + fatalErrors: [ + { name: "bad_query", message: "warehouse wh-1: auth failed" }, + ], + }); + + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + + const error = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }).then( + () => { + throw new Error("expected generateFromEntryPoint to reject"); + }, + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(TypegenFatalError); + // Degraded artifacts are NOT written in blocking mode (committed types preserved). + expect(fs.existsSync(outFile)).toBe(false); + }); +}); + +describe("generateFromEntryPoint — warning message with cause labels", () => { + const warningTestDir = path.join(__dirname, "__output_warning__"); + const queryFolder = path.join(warningTestDir, "queries"); + const metricViewsFolder = path.join(warningTestDir, "metric-views"); + const outFile = path.join(warningTestDir, "generated", "analytics.d.ts"); + const metricFile = path.join( + warningTestDir, + "generated", + "metric-views.d.ts", + ); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.cacheFile.contents = undefined; + fs.rmSync(warningTestDir, { recursive: true, force: true }); + fs.mkdirSync(queryFolder, { recursive: true }); + fs.mkdirSync(metricViewsFolder, { recursive: true }); + // Pre-create committed types files so the gate triggers + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, "// committed types\n", "utf-8"); + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [], + }); + }); + + afterAll(() => { + fs.rmSync(warningTestDir, { recursive: true, force: true }); + }); + + test("warning: environmental failure with committed types → warning contains warehouse id and cause label (unavailable)", async () => { + // DELETED warehouse is classified as "unavailable" + mocks.getWarehouseState.mockResolvedValue("DELETED"); + // Mock the query path to return degraded queries so it triggers environmental failure + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [ + { + name: "q", + type: '{ name: "q"; parameters: Record; result: unknown; }', + }, + ], + syntaxErrors: [], + fatalErrors: [], + hadEnvironmentalFailure: true, + environmentalCause: "unavailable", + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-abc123", + mode: "blocking", + }); + + // Find the typegen warning call (skip other loggers) + const warnCalls = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + + expect(warnCalls.length).toBeGreaterThan(0); + const warnings = warnCalls.join("\n"); + // Strip ANSI codes for clean assertion + const cleanWarnings = stripAnsi(warnings); + + // Must contain stable prefix, warehouse ID, and the unavailable label + expect(cleanWarnings).toContain("AppKit typegen: using committed types"); + expect(cleanWarnings).toContain("wh-abc123"); + expect(cleanWarnings).toContain("warehouse unavailable"); + } finally { + warnSpy.mockRestore(); + } + }); + + test("warning: environmental failure (auth) with committed types → warning contains 'auth blocked' label", async () => { + // Query path returns auth failure + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [], + hadEnvironmentalFailure: true, + environmentalCause: "auth", + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-auth", + mode: "blocking", + }); + + const warnCalls = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + + expect(warnCalls.length).toBeGreaterThan(0); + const warnings = warnCalls.join("\n"); + const cleanWarnings = stripAnsi(warnings); + + expect(cleanWarnings).toContain("AppKit typegen: using committed types"); + expect(cleanWarnings).toContain("wh-auth"); + expect(cleanWarnings).toContain("auth blocked"); + } finally { + warnSpy.mockRestore(); + } + }); + + test("warning: environmental failure (connectivity) with committed types → warning contains 'warehouse unreachable' label", async () => { + // Query path returns connectivity failure + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [], + hadEnvironmentalFailure: true, + environmentalCause: "unreachable", + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-net", + mode: "blocking", + }); + + const warnCalls = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + + expect(warnCalls.length).toBeGreaterThan(0); + const warnings = warnCalls.join("\n"); + const cleanWarnings = stripAnsi(warnings); + + expect(cleanWarnings).toContain("AppKit typegen: using committed types"); + expect(cleanWarnings).toContain("wh-net"); + expect(cleanWarnings).toContain("warehouse unreachable"); + } finally { + warnSpy.mockRestore(); + } + }); + + test("crash: deterministic error (404 bad warehouse id) STILL crashes even with committed types present", async () => { + // A 404 is deterministic, not environmental — committed types cannot save a deterministic error + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [{ name: "test", message: "warehouse not found (404)" }], + hadEnvironmentalFailure: false, + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const error = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-missing", + mode: "blocking", + }).then( + () => { + throw new Error("expected generateFromEntryPoint to reject"); + }, + (err: unknown) => err, + ); + + // Deterministic errors throw TypegenFatalError even with committed types + expect(error).toBeInstanceOf(TypegenFatalError); + // No warning — this is a deterministic failure + const typegenWarns = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + expect(typegenWarns.length).toBe(0); + } finally { + warnSpy.mockRestore(); + } + }); + + test("partial presence: only analytics.d.ts exists (metric absent) + environmental → warning emitted (partial presence counts)", async () => { + // Keep analytics.d.ts but remove metric file + expect(fs.existsSync(outFile)).toBe(true); + fs.rmSync(metricFile, { force: true }); + + // Query path returns environmental failure + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [], + hadEnvironmentalFailure: true, + environmentalCause: "unavailable", + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-partial", + mode: "blocking", + }); + + // Warning emitted because at least one committed type exists (analytics.d.ts) + const warnCalls = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + + expect(warnCalls.length).toBeGreaterThan(0); + const warnings = warnCalls.join("\n"); + const cleanWarnings = stripAnsi(warnings); + expect(cleanWarnings).toContain("AppKit typegen: using committed types"); + } finally { + warnSpy.mockRestore(); + } + }); + + test("warning output is ANSI-free (plain text for CI log parsing)", async () => { + // Query path returns environmental failure + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [], + hadEnvironmentalFailure: true, + environmentalCause: "unavailable", + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-ci", + mode: "blocking", + }); + + const warnCalls = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + + expect(warnCalls.length).toBeGreaterThan(0); + const warnings = warnCalls.join("\n"); + // Verify no ANSI escape codes: stripping SGR sequences leaves it unchanged. + expect(stripAnsi(warnings)).toBe(warnings); + expect(warnings).toContain("AppKit typegen: using committed types"); + expect(warnings).toContain("wh-ci"); + expect(warnings).toContain("warehouse unavailable"); + } finally { + warnSpy.mockRestore(); + } + }); + + test("metric path: environmental failure + committed analytics exists → metric warning uses correct cause label", async () => { + fs.writeFileSync( + path.join(metricViewsFolder, "definitions.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + + // Pre-create metric committed types + fs.writeFileSync(metricFile, "// committed metric types\n", "utf-8"); + + // Metric preflight reports auth failure (environmental, not deterministic 404/400) + mocks.getWarehouseState.mockRejectedValue( + Object.assign( + new Error("PERMISSION_DENIED: cannot read warehouse wh-1"), + { status: 403 }, + ), + ); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-metric-auth", + mode: "blocking", + }); + + const warnCalls = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + + expect(warnCalls.length).toBeGreaterThan(0); + const warnings = warnCalls.join("\n"); + const cleanWarnings = stripAnsi(warnings); + + // The metric path's auth error should bubble up and generate the warning + expect(cleanWarnings).toContain("AppKit typegen: using committed types"); + expect(cleanWarnings).toContain("wh-metric-auth"); + expect(cleanWarnings).toContain("auth blocked"); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe("generateFromEntryPoint — has-types gate crash (no committed types)", () => { + const gateDir = path.join(__dirname, "__output_gate_crash__"); + const queryFolder = path.join(gateDir, "queries"); + const outFile = path.join(gateDir, "generated", "analytics.d.ts"); + + const degradedSchema = (name: string) => ({ + name, + type: `{ name: "${name}"; parameters: Record; result: unknown; }`, + degraded: true, + }); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.cacheFile.contents = undefined; + // Clean slate: no generated/ dir, so no committed analytics.d.ts / metric-views.d.ts. + fs.rmSync(gateDir, { recursive: true, force: true }); + fs.mkdirSync(queryFolder, { recursive: true }); + // A degraded query in blocking mode → write suppressed → nothing on disk. + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [degradedSchema("offline_query")], + syntaxErrors: [], + fatalErrors: [], + hadEnvironmentalFailure: true, + environmentalCause: "unavailable", + }); + }); + + afterAll(() => { + fs.rmSync(gateDir, { recursive: true, force: true }); + }); + + test("blocking + environmental failure + NO committed types → crash with run-locally remedy", async () => { + const err = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-nogate", + mode: "blocking", + }).then( + () => undefined, + (e: unknown) => e, + ); + + // Core safety path: no committed .d.ts to fall back on → build must fail. + expect(err).toBeInstanceOf(TypegenFatalError); + const message = stripAnsi((err as Error).message); + expect(message).toContain("generate-types --wait"); + expect(message).toContain("wh-nogate"); + // The degraded write was suppressed, so nothing was written this run either. + expect(fs.existsSync(outFile)).toBe(false); + }); + + test("blocking + environmental failure + only serving.d.ts present → still crashes (serving excluded from gate)", async () => { + // Pre-create ONLY a serving.d.ts sibling. analytics.d.ts / metric-views.d.ts stay absent. + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync( + path.join(path.dirname(outFile), "serving.d.ts"), + "// committed serving types\n", + "utf-8", + ); + + const err = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-serving", + mode: "blocking", + }).then( + () => undefined, + (e: unknown) => e, + ); + + // serving.d.ts presence must NOT satisfy the has-types gate. + expect(err).toBeInstanceOf(TypegenFatalError); + const message = stripAnsi((err as Error).message); + expect(message).toContain("generate-types --wait"); + expect(fs.existsSync(outFile)).toBe(false); + }); +}); diff --git a/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts b/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts new file mode 100644 index 000000000..0a4dde781 --- /dev/null +++ b/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts @@ -0,0 +1,213 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; + +/** + * End-to-end coverage for the `--wait` has-types gate when query DESCRIBE + * cannot produce a schema for environmental reasons. + * + * The sibling `index.test.ts` mocks `generateQueriesFromDescribe`, so its gate + * tests hand the entry point a `hadEnvironmentalFailure: true` they wrote + * themselves — they would still pass if the query path never set that flag. + * That is exactly how the original bug survived: the query path reported + * `false` for a connectivity failure and no test joined the two halves. + * + * Here only the client boundary is mocked. The real query path classifies the + * failure and the real gate decides, so a regression in either half fails a + * test. + */ + +const mocks = vi.hoisted(() => ({ + getWarehouse: vi.fn(), + executeStatement: vi.fn(), +})); + +// Stub the wrapper, not `@databricks/sdk-experimental` underneath it: the +// wrapper re-exports SDK values (`ConfigError`, `Context`, `Time`, `TimeUnits`) +// that a bare SDK factory mock would drop, breaking module init. Spreading +// `importOriginal` keeps those intact while swapping only the factory. +vi.mock("../../workspace-client", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createWorkspaceClient: () => ({ + statementExecution: { executeStatement: mocks.executeStatement }, + warehouses: { get: mocks.getWarehouse, start: vi.fn() }, + }), + }; +}); + +// Keep the on-disk typegen cache out of play: a reused cached type would mask +// the degrade this test depends on. +vi.mock("../cache", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadCache: vi.fn(async () => ({ + version: actual.CACHE_VERSION, + queries: {}, + })), + saveCache: vi.fn(), + }; +}); + +const { generateFromEntryPoint, TypegenFatalError } = await import("../index"); + +const testDir = path.join(__dirname, "__output_unreachable_gate__"); +const queryFolder = path.join(testDir, "queries"); +const outFile = path.join(testDir, "generated", "analytics.d.ts"); + +/** DNS-style transport failure: what a CI runner without warehouse egress sees. */ +function unreachableError() { + return Object.assign(new Error("getaddrinfo ENOTFOUND x.databricks.com"), { + code: "ENOTFOUND", + }); +} + +describe("--wait gate: environmental query failures (real query path)", () => { + beforeEach(() => { + vi.clearAllMocks(); + fs.rmSync(testDir, { recursive: true, force: true }); + fs.mkdirSync(queryFolder, { recursive: true }); + fs.writeFileSync( + path.join(queryFolder, "users.sql"), + "SELECT id FROM users", + "utf-8", + ); + mocks.getWarehouse.mockRejectedValue(unreachableError()); + }); + + afterAll(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + test("no committed types → crashes with the run-locally remedy instead of exiting 0", async () => { + const err = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-unreachable", + mode: "blocking", + }).then( + () => undefined, + (e: unknown) => e, + ); + + // The regression this guards: the run used to resolve, write nothing, and + // exit 0 — leaving the build to fail later with no usable diagnostic. + expect(err).toBeInstanceOf(TypegenFatalError); + expect((err as Error).message).toContain("generate-types --wait"); + expect(fs.existsSync(outFile)).toBe(false); + // Preflight failed, so no DESCRIBE was attempted. + expect(mocks.executeStatement).not.toHaveBeenCalled(); + }); + + test("committed types present → warns 'warehouse unreachable' and keeps them", async () => { + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + const committed = "// committed types\n"; + fs.writeFileSync(outFile, committed, "utf-8"); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-unreachable", + mode: "blocking", + }); + + const warnings = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")) + .join("\n"); + + expect(warnings).toContain("AppKit typegen: using committed types"); + expect(warnings).toContain("wh-unreachable"); + // The label the query path now supplies; it was unreachable in practice + // while connectivity failures reported no cause at all. + expect(warnings).toContain("warehouse unreachable"); + // Anti-clobber: the degraded result must not overwrite what was committed. + expect(fs.readFileSync(outFile, "utf-8")).toBe(committed); + } finally { + warnSpy.mockRestore(); + } + }); + + test("non-terminal DESCRIBE + no committed types → crashes instead of silently exiting 0", async () => { + mocks.getWarehouse.mockResolvedValue({ state: "RUNNING" }); + mocks.executeStatement.mockResolvedValue({ + statement_id: "stmt-pending", + status: { state: "PENDING" }, + }); + + const err = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-scaling", + mode: "blocking", + }).then( + () => undefined, + (e: unknown) => e, + ); + + expect(err).toBeInstanceOf(TypegenFatalError); + expect((err as Error).message).toContain("generate-types --wait"); + expect(fs.existsSync(outFile)).toBe(false); + }); + + test("non-terminal DESCRIBE + committed types → warns unavailable and keeps them", async () => { + mocks.getWarehouse.mockResolvedValue({ state: "RUNNING" }); + mocks.executeStatement.mockResolvedValue({ + statement_id: "stmt-pending", + status: { state: "RUNNING" }, + }); + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + const committed = "// committed types\n"; + fs.writeFileSync(outFile, committed, "utf-8"); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-scaling", + mode: "blocking", + }), + ).resolves.toBeUndefined(); + + const warnings = warnSpy.mock.calls.flat().map(String).join("\n"); + expect(warnings).toContain("AppKit typegen: using committed types"); + expect(warnings).toContain("warehouse unavailable"); + expect(fs.readFileSync(outFile, "utf-8")).toBe(committed); + } finally { + warnSpy.mockRestore(); + } + }); + + test("non-blocking mode stays silent and writes degraded types", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + // Must not throw: the non-blocking default never fails on warehouse state. + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-unreachable", + mode: "non-blocking", + }); + + const gateWarnings = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("using committed types")); + + expect(gateWarnings).toEqual([]); + // Degraded types are written here — the gate is blocking-only. + expect(fs.existsSync(outFile)).toBe(true); + expect(fs.readFileSync(outFile, "utf-8")).toContain("result: unknown"); + } finally { + warnSpy.mockRestore(); + } + }); +}); diff --git a/packages/appkit/src/type-generator/types.ts b/packages/appkit/src/type-generator/types.ts index e947bb530..954bde706 100644 --- a/packages/appkit/src/type-generator/types.ts +++ b/packages/appkit/src/type-generator/types.ts @@ -99,10 +99,14 @@ export const sqlTypeToHelper: Record = { * Query schema interface * @property name - the name of the query * @property type - the type of the query (string, number, boolean, object, array, etc.) + * @property degraded - true when the schema could not be resolved and `type` + * is an unknown fallback. Absent when `type` came from DESCRIBE or a matching + * last-known-good cache entry. */ export interface QuerySchema { name: string; type: string; + degraded?: boolean; } /** @@ -138,12 +142,21 @@ export interface QueryFatalError { * warehouse (genuine SQL errors). Connectivity failures are deliberately NOT * included: they degrade silently (reuse last-known-good type or emit * `unknown`) so a transient outage never fails a build. - * @property fatalErrors - non-SQL fatal describe request failures. These still - * produce `result: unknown` schemas so callers can write declarations before - * surfacing the error. + * @property fatalErrors - deterministic non-SQL fatal describe request failures + * (404/400). These still produce `result: unknown` schemas so callers can write + * declarations before surfacing the error. + * @property hadEnvironmentalFailure - `true` when an environmental failure occurred + * in blocking mode (auth, connectivity, timeouts, or other unrecognized failures). + * Used by {@link generateFromEntryPoint} to decide whether to apply the has-types + * gate. Always false in non-blocking mode. + * @property environmentalCause - coarse cause label for the environmental failure, + * one of "auth" (401/403), "unreachable" (connectivity), or "unavailable" (other). + * Only set when hadEnvironmentalFailure is true; used by the warning message. */ export interface QueryGenerationResult { schemas: QuerySchema[]; syntaxErrors: QuerySyntaxError[]; fatalErrors: QueryFatalError[]; + hadEnvironmentalFailure?: boolean; + environmentalCause?: "auth" | "unreachable" | "unavailable"; }